From 1df2822f019bfde8b2740038c25b0bbe7e0f0529 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 29 Oct 2026 14:01:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(pay):=20=E7=BB=9F=E4=B8=80=E6=94=B6?= =?UTF-8?q?=E9=93=B6=E5=8F=B0=E5=9B=9B=E7=A7=8D=E6=A8=A1=E5=BC=8F=EF=BC=88?= =?UTF-8?q?=E7=AB=99=E5=86=85=E4=BA=8C=E7=BB=B4=E7=A0=81/=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E9=A1=B5=E8=B7=B3=E8=BD=AC/=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E6=8F=90=E4=BA=A4/JSAPI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentOrchestrator 统一返回 mode + code_url/payment_url/form_html/jsapi_params - 支付宝:当面付二维码 / 电脑网站支付 / H5;微信:Native / H5 / JSAPI(WechatOpenid 静默授权) - 易支付:PC mapi 二维码,移动端自动提交表单 - 删除旧键 alipay_payment_type / wechat_payment_type / epay_device 与 redirect/qr_content 字段 - 新增 pay-cashier.js,移除 window.open,订单状态接口改为 /pay/order/{n} - 拆分 credits.js(affiliate / withdraw-history)至 1000 行以内 Co-Authored-By: Claude Fable 5 --- .../src/schema/sections/payment/gateway.js | 48 +- assets/js/cms-download.js | 27 +- assets/js/credits-affiliate.js | 342 ++++++++++++ assets/js/credits-withdraw-history.js | 163 ++++++ assets/js/credits.js | 494 +----------------- assets/js/order-center.js | 28 +- assets/js/pay-cashier.js | 162 ++++++ assets/js/pay-vip.js | 18 +- assets/js/vip-purchase-coordinator.js | 4 +- src/Admin/Schema/SchemaPayment.php | 25 +- src/Content/PayOrders.php | 11 +- src/Content/PayRest.php | 4 +- src/Content/VipServicePurchase.php | 11 +- src/Core/ScriptsPart1b.php | 51 +- src/Credits/BalanceServiceCharge.php | 14 +- src/Payment/Gateway.php | 6 + src/Payment/GatewayInterface.php | 5 +- src/Payment/Gateways/AlipayGateway.php | 70 ++- src/Payment/Gateways/EpayGateway.php | 155 +++--- src/Payment/Gateways/PayPalGateway.php | 12 +- src/Payment/Gateways/StripeGateway.php | 12 +- src/Payment/Gateways/WechatOpenid.php | 143 +++++ src/Payment/Gateways/WechatPayGateway.php | 130 +++-- src/Payment/Gateways/YansongdaPayFactory.php | 15 - src/Payment/PaymentOrchestrator.php | 98 +++- src/Payment/UserOrders.php | 11 +- src/Settings/PaymentRuntime.php | 2 +- tools/admin-contract-report.json | 30 +- 28 files changed, 1255 insertions(+), 836 deletions(-) create mode 100644 assets/js/credits-affiliate.js create mode 100644 assets/js/credits-withdraw-history.js create mode 100644 assets/js/pay-cashier.js create mode 100644 src/Payment/Gateways/WechatOpenid.php diff --git a/assets/admin/src/schema/sections/payment/gateway.js b/assets/admin/src/schema/sections/payment/gateway.js index e33e4382..bce7ec4d 100644 --- a/assets/admin/src/schema/sections/payment/gateway.js +++ b/assets/admin/src/schema/sections/payment/gateway.js @@ -54,13 +54,15 @@ export const gatewaySchema = { pay_sdk_order: {}, alipay_appid: '', alipay_privatekey: '', - alipay_payment_type: 'page', + alipay_pc_mode: 'facepay', + alipay_h5: false, alipay_app_public_cert_path: '', alipay_public_cert_path: '', alipay_root_cert_path: '', wechat_merchantid: '', wechat_appid: '', - wechat_payment_type: 'native', + wechat_h5: false, + wechat_jsapi: false, wechat_api_v3_key: '', wechat_private_key_path: '', wechat_cert_path: '', @@ -78,7 +80,7 @@ export const gatewaySchema = { epay_apiurl: '', epay_pid: '', epay_key: '', - epay_device: 'auto', + epay_qrcode: true, }, groups: [ { @@ -206,20 +208,22 @@ export const gatewaySchema = { label: '', type: 'content', ui_only: true, - content: '', + content: '', }, { key: 'alipay_appid', label: '当面付:APPID', type: 'input', default: '' }, { key: 'alipay_privatekey', label: '当面付:应用私钥', type: 'textarea', sanitize_type: 'code', default: '', rows: 4, secret: true }, { - key: 'alipay_payment_type', - label: '支付方式', + key: 'alipay_pc_mode', + label: '电脑端收款', type: 'select', - default: 'page', + default: 'facepay', options: [ - { label: '电脑网站支付(web)', value: 'page' }, - { label: '手机网站支付(wap)', value: 'wap' }, + { label: '当面付(站内二维码)', value: 'facepay' }, + { label: '电脑网站支付(跳转支付宝)', value: 'web' }, ], + help: '当面付在站内弹出二维码;电脑网站支付在当前页跳转到支付宝收银台', }, + { key: 'alipay_h5', label: '移动端 H5 跳转', type: 'switch', default: false, help: '需签约手机网站支付。开启后手机浏览器跳转支付宝 App/收银台付款;关闭则手机端也显示二维码(附唤起链接)' }, { key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' }, { key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' }, { key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' }, @@ -239,20 +243,12 @@ export const gatewaySchema = { label: '', type: 'content', ui_only: true, - content: '', + content: '', }, { key: 'wechat_merchantid', label: '商户号 PartnerID', type: 'input', default: '' }, { key: 'wechat_appid', label: '授权绑定的AppID', type: 'input', default: '' }, - { - key: 'wechat_payment_type', - label: '支付方式', - type: 'select', - default: 'native', - options: [ - { label: '扫码支付(Native)', value: 'native' }, - { label: 'H5支付', value: 'h5' }, - ], - }, + { key: 'wechat_h5', label: '移动端 H5 跳转', type: 'switch', default: false, help: '需开通 H5 支付。开启后手机浏览器(非微信内)跳转微信付款;关闭则显示站内二维码' }, + { key: 'wechat_jsapi', label: '微信内 JSAPI 支付', type: 'switch', default: false, help: '需开通 JSAPI 支付并在商户平台配置支付授权目录。微信内打开时直接调起支付;用户 openid 通过「用户→微信公众号」的 AppID/AppSecret 静默授权获取,公众号 AppID 必须与上方 AppID 一致' }, { key: 'wechat_api_v3_key', label: '支付APIv3密钥', type: 'input', inputType: 'password', secret: true, default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' }, { key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' }, { key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' }, @@ -329,17 +325,7 @@ export const gatewaySchema = { { key: 'epay_apiurl', label: 'API接口网址', type: 'input', default: '', help: '请填写接口地址,例如:https://pay.xxxxxxx.cn/' }, { key: 'epay_pid', label: '商户号', type: 'input', default: '' }, { key: 'epay_key', label: '商户秘钥', type: 'input', inputType: 'password', secret: true, default: '', help: 'MD5 通信密钥。' }, - { - key: 'epay_device', - label: '下单终端', - type: 'select', - default: 'auto', - options: [ - { label: '自动(按 UA)', value: 'auto' }, - { label: '电脑端 submit', value: 'pc' }, - { label: '移动端 mapi', value: 'mobile' }, - ], - }, + { key: 'epay_qrcode', label: '电脑端站内二维码', type: 'switch', default: true, help: '开启后电脑端通过 mapi 接口获取二维码在站内展示(接口未返回二维码时跳转支付页);关闭则跳转易支付收银台。移动端始终跳转' }, ], }, ], diff --git a/assets/js/cms-download.js b/assets/js/cms-download.js index 9e50b037..518b0eaf 100644 --- a/assets/js/cms-download.js +++ b/assets/js/cms-download.js @@ -281,7 +281,7 @@ const maxTries = 40; const timer = setInterval(function () { tries += 1; - fetch(REST_BASE + '/pay/content/order/' + encodeURIComponent(orderNumber), { + fetch(REST_BASE + '/pay/order/' + encodeURIComponent(orderNumber), { method: 'GET', credentials: 'same-origin', headers: restHeaders(), @@ -673,13 +673,17 @@ } const data = (r.json && r.json.data) ? r.json.data : r.json; delete button.dataset.aksrtOperationKey; - if (data && data.redirect && (data.payment_url || data.qr_content)) { - if (data.payment_url) { - window.open(data.payment_url, '_blank'); - } - renderScanPay(data, returnFocusEl); + if (data && data.mode) { if (window.AksrtModalRuntime) window.AksrtModalRuntime.invalidate(['orders']); - if (data.order_number) pollOrderPaid(data.order_number, returnFocusEl); + if (data.mode === 'qrcode') { + renderScanPay(data, returnFocusEl); + } else if (window.AksrtPayCashier) { + close(); + window.AksrtPayCashier.handle(data, {}); + } + if ((data.mode === 'qrcode' || data.mode === 'jsapi') && data.order_number) { + pollOrderPaid(data.order_number, returnFocusEl); + } return; } window.location.reload(); @@ -693,12 +697,9 @@ function renderScanPay(data, returnFocusEl) { data = data || {}; - let body = ''; - if (data.qr_content) { - body += '
凭证内容
' + - '' + escapeHtml(String(data.qr_content)) + '
'; - } - body += '

' + escapeHtml(data.message || '请使用手机扫码完成支付,支付后刷新页面。') + '

'; + const body = window.AksrtPayCashier + ? window.AksrtPayCashier.qrHtml(data) + : '

' + escapeHtml('收银台组件未加载,请刷新页面重试。') + '

'; open( dlView('pay', { subtitle: '支付完成后页面会自动刷新', diff --git a/assets/js/credits-affiliate.js b/assets/js/credits-affiliate.js new file mode 100644 index 00000000..cbc86f2a --- /dev/null +++ b/assets/js/credits-affiliate.js @@ -0,0 +1,342 @@ +/** + * AKSRT affiliate revenue center + referral poster (split from credits.js). + * Exposes window.AKSRTAffiliateRevenueInit for the user-center router. + * + * @package AKSRT + */ +(function () { + "use strict"; + + function api(path, options) { + return window.AksrtRequest.request(path, options || {}).then(function (response) { + return { ok: response.ok, status: response.status, json: response.json || {}, data: response.data }; + }).catch(function (error) { + var response = error && error.response ? error.response : {}; + return { ok: false, status: (error && error.status) || response.status || 0, json: (error && error.json) || response.json || {} }; + }); + } + + // ===== Forward-only affiliate revenue center ===== + /* 推广链接由 PHP 直出,首屏即可见、可复制;这里只补异步才拿得到的 + * 海报配置与佣金比例,取失败也不影响复制。 */ + function initAffiliateRevenue() { + var root = document.querySelector('[data-aksrt-revenue-page]'); + if (!root || root.dataset.affiliateBound) return; + root.dataset.affiliateBound = '1'; + var input = root.querySelector('[data-affiliate-link]'); + var copy = root.querySelector('[data-affiliate-copy]'); + var url = input ? input.value : ''; + if (copy) copy.disabled = !url; + api('/revenue/me').then(function (r) { + if (!r.ok) return; + var j = r.json || {}; + var ratio = root.querySelector('[data-affiliate-ratio]'); if (ratio) ratio.textContent = String(j.current_ratio || 0) + '%'; + initAffiliatePoster(root, url || j.url || '', j.poster || {}, j.site_name || document.title, j.referrer_name || ''); + }).catch(function () { /* 海报与比例为增强项,静默失败即可。 */ }); + if (copy) copy.addEventListener('click', function () { + if (!input || !input.value) return; + var done = function () { notyf('推广链接已复制', 'success', 3, 'aksrt-affiliate-copy-toast'); }; + var fail = function () { input.focus(); input.select(); notyf('复制失败,请手动复制', 'danger', 3, 'aksrt-affiliate-copy-toast'); }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(input.value).then(done).catch(fail); + else { input.focus(); input.select(); try { document.execCommand('copy'); done(); } catch (e) { fail(); } } + }); + } + + // ===== Referral poster ===== + /* 布局 1:1 复刻参考主题 poster-share.js 的 rebate 分支(非 product): + * 768×1168,顶部主图占上半屏、左上角日期、标题竖线 + 标题、红色推荐人标签层、 + * 简介,底部带边框盒内左侧 contain 居中 logo 图 + 右下二维码,最底部居中文案。 + * 顶部主图按参考主题的三级回退加载(banner → banner_default → banner_spare)。 */ + function initAffiliatePoster(root, url, poster, siteName, referrerName) { + var button = root.querySelector('[data-affiliate-poster]'); + var modal = document.querySelector('[data-aksrt-affiliate-poster-modal]'); + var stage = modal ? modal.querySelector('[data-affiliate-poster-stage]') : null; + var loading = modal ? modal.querySelector('[data-affiliate-poster-loading]') : null; + var content = modal ? modal.querySelector('.modal-content') : null; + var download = modal ? modal.querySelector('[data-affiliate-poster-download]') : null; + if (!button || !modal) return; + if (!url || !poster || poster.enabled !== true) return; + button.hidden = false; + + var backdrop = null; + var canvas = null; + + function openPosterModal() { + if (modal.classList.contains('in')) return; + modal.style.display = 'flex'; + void modal.offsetWidth; // 强制 reflow,让 .fade 过渡从 scale(0.9) 起手 + modal.classList.add('in'); + modal.setAttribute('aria-hidden', 'false'); + if (!backdrop) { + backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop fade in'; + backdrop.setAttribute('data-affiliate-poster-backdrop', '1'); + document.body.appendChild(backdrop); + } + document.body.classList.add('modal-open'); + } + + function closePosterModal() { + if (!modal.classList.contains('in')) return; + modal.classList.remove('in'); + modal.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('modal-open'); + if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); + backdrop = null; + // 等过渡结束再撤 display,复刻 Bootstrap 的 fade-out 收尾 + window.setTimeout(function () { + if (!modal.classList.contains('in')) modal.style.display = ''; + }, 300); + } + + modal.addEventListener('click', function (event) { + // 点遮罩层(.modal 自身)或关闭按钮(data-dismiss="modal") + if (event.target === modal || (event.target.closest && event.target.closest('[data-dismiss="modal"]'))) { + closePosterModal(); + } + }); + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && modal.classList.contains('in')) closePosterModal(); + }); + + if (download) { + download.addEventListener('click', function () { + if (!canvas) return; + try { + var link = document.createElement('a'); + link.download = 'referral-poster.png'; + link.href = canvas.toDataURL('image/png'); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (err) { + /* Canvas is same-origin generated; download can only fail if the browser blocks it. */ + } + }); + } + + button.addEventListener('click', function () { + if (typeof window.qrcode !== 'function') { + if (stage) { stage.innerHTML = '

' + '二维码组件未加载。' + '

'; } + openPosterModal(); + return; + } + if (stage) { stage.innerHTML = ''; stage.classList.remove('loaded'); } + if (loading) loading.style.display = ''; + if (download) download.classList.add('hide'); + openPosterModal(); + button.disabled = true; + buildPosterCanvas(url, poster, siteName, referrerName).then(function (built) { + canvas = built; + if (!stage) return; + // 参考主题:img.loaded-img 直接进 .poster-imgbox,遮罩淡出, + // 内容块高度从 220px 过渡到海报实际高度后清掉内联样式。 + var img = document.createElement('img'); + img.src = canvas.toDataURL('image/png'); + img.alt = '推广海报'; + img.className = 'loaded-img'; + // 参考主题按 0.7 比例预设尺寸;.poster-imgbox > img 的 max-width:360px + // 会在窄屏把它压回弹窗宽度,height:100% 让 offsetHeight 反映真实渲染高度。 + img.width = 768 * 0.7; + img.height = 1168 * 0.7; + stage.appendChild(img); + stage.classList.add('loaded'); + if (download) { + download.href = img.src; + download.classList.remove('hide'); + } + if (loading) loading.style.display = 'none'; + if (content) { + content.style.height = stage.offsetHeight + 'px'; + content.style.transition = '.2s'; + window.setTimeout(function () { + content.style.height = ''; + content.style.overflow = ''; + content.style.transition = ''; + }, 200); + } + }).catch(function () { + if (stage) { stage.innerHTML = '

' + '海报生成失败,请重试。' + '

'; } + }).finally(function () { + button.disabled = false; + }); + }); + } + + // 三级回退加载图片:任一源成功即返回,全部失败才 reject(参考主题 loadCount 0→1→2)。 + function loadPosterImage(sources) { + var list = (sources || []).filter(function (src) { return !!src; }); + return new Promise(function (resolve, reject) { + var index = 0; + function next() { + if (index >= list.length) { reject(new Error('poster image unavailable')); return; } + var image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = function () { resolve(image); }; + image.onerror = function () { index += 1; next(); }; + image.src = list[index]; + } + next(); + }); + } + + // contain 裁切:参考主题 containImg(),等比缩放塞进盒子并居中,不裁切。 + function containRect(sx, sy, boxW, boxH, srcW, srcH) { + var dx = sx, dy = sy, dWidth = boxW, dHeight = boxH; + if (srcW > srcH || (srcW === srcH && boxW < boxH)) { + dHeight = (srcH * dWidth) / srcW; + dy = sy + (boxH - dHeight) / 2; + } else if (srcW < srcH || (srcW === srcH && boxW > boxH)) { + dWidth = (srcW * dHeight) / srcH; + dx = sx + (boxW - dWidth) / 2; + } + return { x: dx, y: dy, width: dWidth, height: dHeight }; + } + + // 参考主题 drawTextOverflow():按 maxWidth-120 换行、x 偏移 38,最多 maxRow 行,溢出补 ...。 + // 返回实际测量宽度,供价格/标签层做横向排版。 + function drawTextOverflow(ctx, text, maxWidth, maxRow, font, color, lineHeight, x, y) { + var rows = []; + var temp = ''; + var limit = maxWidth - 120; + x = x + 38; + text = String(text || '').replace(/[\r\n]/g, ''); + ctx.fillStyle = color; + ctx.font = font; + var chars = text.split(''); + var measured = 0; + if (ctx.measureText(text).width <= limit) { + rows.push(text); + } else { + for (var i = 0; i < chars.length; i++) { + if (rows.length === maxRow && i < chars.length - 1) { + rows[rows.length - 1] += '...'; + break; + } + if (ctx.measureText(temp).width < limit) { + temp += chars[i]; + if (i === chars.length - 1) rows.push(temp); + } else { + i--; // 参考主题:回退一格防止字符丢失 + rows.push(temp); + temp = ''; + } + } + } + for (var r = 0; r < rows.length; r++) { + measured = Math.max(measured, ctx.measureText(rows[r]).width); + ctx.fillText(rows[r], x, y + r * lineHeight, limit); + } + return { width: measured, height: rows.length * lineHeight }; + } + + // 参考主题 buildPosterCanvas rebate 分支:768×1168,顶部主图占上半屏,左上角日期, + // 标题竖蓝线 + 标题,推荐人标签(红),简介,底部盒(logo + 二维码 + 底部文案)。 + function buildPosterCanvas(url, poster, siteName, referrerName) { + var width = 768, height = 1168; + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + var ctx = canvas.getContext('2d'); + var imgHeight = height / 2; // rebate 分支:顶部主图占上半屏 + // 站点名走「周」前缀,与参考主题 zib__('week_prefix') 一致。 + var weekLabel = '周' + '日一二三四五六'.charAt(new Date().getDay()); + // 推荐人标签:参考主题 tags = `推荐人: {昵称}`,缺昵称时整层不画。 + var tags = referrerName ? '推荐人: ' + String(referrerName) : ''; + // 底部文案:优先推广海报配置,回退到分享设置 share_desc,再回退默认文案。 + var bottom = String(poster.bottom || '扫描二维码阅读全文'); + var title = String(poster.title || siteName || ''); + var description = String(poster.description || ''); + + return Promise.all([ + loadPosterImage([poster.image, poster.banner_default, poster.banner_spare]), + poster.logo ? loadPosterImage([poster.logo]).catch(function () { return null; }) : Promise.resolve(null) + ]).then(function (assets) { + var banner = assets[0], logo = assets[1]; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + // 顶部主图(cover 裁切)。 + var rect = coverRect(width, imgHeight, banner.width, banner.height); + ctx.drawImage(banner, rect.sx, rect.sy, rect.sWidth, rect.sHeight, 0, 0, width, imgHeight); + // 左上角日期(白字,覆盖在主图上)。 + ctx.textBaseline = 'top'; + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255,255,255,1)'; + ctx.font = '35px Arial'; + ctx.fillText(weekLabel, 30, 37); + ctx.font = '28px Arial'; + ctx.fillText((new Date().getMonth() + 1) + ' / ' + new Date().getDate(), 30, 87); + // 标题竖蓝线。 + ctx.strokeStyle = '#00a1ff'; + ctx.lineWidth = 7; + ctx.beginPath(); + ctx.lineCap = 'round'; + ctx.moveTo(40, imgHeight + 38); + ctx.lineTo(40, imgHeight + 74); + ctx.stroke(); + // 标题层(粗体)。参考 rebate 分支把 $title 子画布置于 (20, img_height+40), + // 子画布内左对齐 x=40、文字基线偏移 2,故绝对坐标 (40, img_height+42); + // drawTextOverflow 内部会 +38,所以这里传 2。 + drawTextOverflow(ctx, title, width, 2, 'bold 35px Arial', '#424242', 52, 2, imgHeight + 42); + // 推荐人标签层(红,最多 1 行):$tags 置于 (0, img_height+160)。 + if (tags) { + drawTextOverflow(ctx, tags, width, 1, '24px Arial', '#fc4e1f', 36, 2, imgHeight + 162); + } + // 简介层(灰):$content 置于 (0, img_height+220)。 + if (description) { + drawTextOverflow(ctx, description, width, 4, '25px Arial', 'rgba(88,88,88,1)', 39, 2, imgHeight + 222); + } + // 底部带边框盒。 + ctx.strokeStyle = 'rgba(122,122,122,0.2)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.rect(30, height - 230, width - 60, 200); + ctx.stroke(); + // logo 图:contain 居中,缺图时整块不画(参考主题 logo.naturalWidth 守卫)。 + if (logo && logo.width && logo.height) { + var logoRect = containRect(80, height - 190, 300, 100, logo.width, logo.height); + ctx.drawImage(logo, logoRect.x, logoRect.y, logoRect.width, logoRect.height); + } + // 二维码(右下角)。 + var qr = window.qrcode(0, 'M'); + qr.addData(url); + qr.make(); + var count = qr.getModuleCount(); + var qrBox = 120; + var cell = qrBox / count; + var qrX = width - 200, qrY = height - 200; + ctx.fillStyle = '#0f172a'; + for (var r = 0; r < count; r++) { + for (var c = 0; c < count; c++) { + if (qr.isDark(r, c)) { + ctx.fillRect(qrX + Math.floor(c * cell), qrY + Math.floor(r * cell), Math.ceil(cell), Math.ceil(cell)); + } + } + } + // 底部文案(居中,盒下方)。 + ctx.textAlign = 'center'; + ctx.fillStyle = '#b4b4b4'; + ctx.font = '22px Arial'; + ctx.fillText(String(bottom).slice(0, 46), width / 2, height - 58); + ctx.textAlign = 'left'; + return canvas; + }); + } + + // cover 裁切:把源图按短边铺满目标框并居中裁切。 + function coverRect(boxW, boxH, srcW, srcH) { + var sx = 0, sy = 0, sWidth = srcW, sHeight = srcH; + if (srcW / srcH > boxW / boxH) { + sWidth = (boxW * srcH) / boxH; + sx = (srcW - sWidth) / 2; + } else { + sHeight = (boxH * srcW) / boxW; + sy = (srcH - sHeight) / 2; + } + return { sx: sx, sy: sy, sWidth: sWidth, sHeight: sHeight }; + } + + window.AKSRTAffiliateRevenueInit = initAffiliateRevenue; + + jQuery(document).off("auto_fun.aksrt-credits-affiliate").on("auto_fun.aksrt-credits-affiliate", initAffiliateRevenue); +})(); diff --git a/assets/js/credits-withdraw-history.js b/assets/js/credits-withdraw-history.js new file mode 100644 index 00000000..68c0cac4 --- /dev/null +++ b/assets/js/credits-withdraw-history.js @@ -0,0 +1,163 @@ +/** + * AKSRT withdraw history modal (split from credits.js). + * + * @package AKSRT + */ +(function () { + "use strict"; + + function initWithdrawHistory() { + var modal = document.querySelector('[data-aksrt-withdraw-history-modal]'); + if (!modal || modal.dataset.aksrtWithdrawHistoryInitialized === '1') return; + modal.dataset.aksrtWithdrawHistoryInitialized = '1'; + var list = modal.querySelector('[data-aksrt-withdraw-history-list]'); + var pagination = modal.querySelector('[data-aksrt-withdraw-history-pagination]'); + var statusLabels = { + pending: '待处理', + processing: '处理中', + completed: '已完成', + rejected: '已拒绝' + }; + var currentPage = 1; + var loading = false; + + function text(value) { + return value === null || value === undefined ? '' : String(value); + } + + function appendText(parent, value, className) { + var node = document.createElement('span'); + if (className) node.className = className; + node.textContent = text(value); + parent.appendChild(node); + return node; + } + + function formatGrossAmount(value) { + return text(value).replace(/\.00$/, ''); + } + + function formatNetAmount(value) { + return text(value); + } + + function renderItems(items) { + if (!list) return; + list.textContent = ''; + if (!Array.isArray(items) || !items.length) { + var empty = document.createElement('div'); + empty.className = 'uc-transactions__empty'; + var emptyText = document.createElement('p'); + emptyText.textContent = '暂无提现记录'; + empty.appendChild(emptyText); + list.appendChild(empty); + return; + } + items.forEach(function (item) { + var status = text(item && item.status).toLowerCase(); + var row = document.createElement('div'); + row.className = 'uc-withdraw-history__item'; + var badge = appendText(row, statusLabels[status] || status, 'badg badg-sm uc-withdraw-history__status'); + badge.classList.add(status === 'rejected' ? 'c-red' : (status === 'completed' ? 'c-green' : 'c-blue')); + var gross = text(item && (item.gross_amount || item.amount)); + var net = text(item && item.amount); + var amount = document.createElement('span'); + amount.className = 'uc-withdraw-history__amount em12'; + amount.appendChild(document.createTextNode(formatGrossAmount(gross))); + if (net !== '') { + var netText = document.createElement('span'); + netText.className = 'uc-withdraw-history__net'; + netText.textContent = '(实际到账:' + formatNetAmount(net) + '元)'; + amount.appendChild(netText); + } + row.appendChild(amount); + var number = document.createElement('span'); + number.className = 'uc-withdraw-history__number muted-2-color em09'; + var numberLabel = document.createElement('span'); + numberLabel.textContent = '订单号:'; + number.appendChild(numberLabel); + var code = document.createElement('code'); + code.textContent = text(item && item.withdrawal_number); + number.appendChild(code); + row.appendChild(number); + appendText(row, text(item && item.created_at), 'uc-withdraw-history__meta muted-2-color em09'); + list.appendChild(row); + }); + } + + function renderPagination(page, maxPages) { + if (!pagination) return; + pagination.textContent = ''; + if (maxPages <= 1) return; + function addButton(label, target, disabled, current) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'uc-withdraw-history__page' + (current ? ' is-current' : ''); + button.textContent = label; + button.disabled = !!disabled; + if (current) button.setAttribute('aria-current', 'page'); + button.dataset.aksrtWithdrawHistoryPage = String(target); + pagination.appendChild(button); + } + addButton('上一页', page - 1, page <= 1, false); + for (var index = 1; index <= maxPages; index += 1) addButton(String(index), index, false, index === page); + addButton('下一页', page + 1, page >= maxPages, false); + } + + function loadPage(page) { + if (loading || !list) return; + loading = true; + list.textContent = '加载中…'; + var rest = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || ''; + if (!rest) { + list.textContent = '记录加载失败,请重试'; + loading = false; + return; + } + var url = rest.replace(/\/$/, '') + '/earnings/withdrawals?page=' + encodeURIComponent(Math.max(1, page)) + '&per_page=10'; + fetch(url, { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } + }).then(function (response) { + return response.json().then(function (payload) { + if (!response.ok || !payload || !Array.isArray(payload.items)) throw new Error('withdraw-history-load-failed'); + currentPage = Math.max(1, parseInt(payload.page, 10) || 1); + var maxPages = Math.max(1, parseInt(payload.max_pages, 10) || 1); + renderItems(payload.items); + renderPagination(currentPage, maxPages); + }); + }).catch(function () { + list.textContent = '记录加载失败,请重试'; + if (pagination) pagination.textContent = ''; + }).finally(function () { + loading = false; + }); + } + + function open() { + modal.hidden = false; + document.body.classList.add('uc-modal-open'); + loadPage(1); + } + function close() { + modal.hidden = true; + document.body.classList.remove('uc-modal-open'); + } + document.querySelectorAll('[data-aksrt-withdraw-history-open]').forEach(function (btn) { + btn.addEventListener('click', function (e) { e.preventDefault(); open(); }); + }); + modal.querySelectorAll('[data-aksrt-withdraw-history-close]').forEach(function (btn) { + btn.addEventListener('click', function (e) { e.preventDefault(); close(); }); + }); + if (pagination) { + pagination.addEventListener('click', function (e) { + var button = e.target.closest('[data-aksrt-withdraw-history-page]'); + if (!button || button.disabled) return; + e.preventDefault(); + loadPage(parseInt(button.dataset.aksrtWithdrawHistoryPage || '1', 10) || 1); + }); + } + } + + jQuery(document).off("auto_fun.aksrt-withdraw-history").on("auto_fun.aksrt-withdraw-history", initWithdrawHistory); +})(); diff --git a/assets/js/credits.js b/assets/js/credits.js index b662b661..b013a610 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -124,327 +124,6 @@ }); } - // ===== Forward-only affiliate revenue center ===== - /* 推广链接由 PHP 直出,首屏即可见、可复制;这里只补异步才拿得到的 - * 海报配置与佣金比例,取失败也不影响复制。 */ - function initAffiliateRevenue() { - var root = document.querySelector('[data-aksrt-revenue-page]'); - if (!root || root.dataset.affiliateBound) return; - root.dataset.affiliateBound = '1'; - var input = root.querySelector('[data-affiliate-link]'); - var copy = root.querySelector('[data-affiliate-copy]'); - var url = input ? input.value : ''; - if (copy) copy.disabled = !url; - api('/revenue/me').then(function (r) { - if (!r.ok) return; - var j = r.json || {}; - var ratio = root.querySelector('[data-affiliate-ratio]'); if (ratio) ratio.textContent = String(j.current_ratio || 0) + '%'; - initAffiliatePoster(root, url || j.url || '', j.poster || {}, j.site_name || document.title, j.referrer_name || ''); - }).catch(function () { /* 海报与比例为增强项,静默失败即可。 */ }); - if (copy) copy.addEventListener('click', function () { - if (!input || !input.value) return; - var done = function () { notyf('推广链接已复制', 'success', 3, 'aksrt-affiliate-copy-toast'); }; - var fail = function () { input.focus(); input.select(); notyf('复制失败,请手动复制', 'danger', 3, 'aksrt-affiliate-copy-toast'); }; - if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(input.value).then(done).catch(fail); - else { input.focus(); input.select(); try { document.execCommand('copy'); done(); } catch (e) { fail(); } } - }); - } - - // ===== Referral poster ===== - /* 布局 1:1 复刻参考主题 poster-share.js 的 rebate 分支(非 product): - * 768×1168,顶部主图占上半屏、左上角日期、标题竖线 + 标题、红色推荐人标签层、 - * 简介,底部带边框盒内左侧 contain 居中 logo 图 + 右下二维码,最底部居中文案。 - * 顶部主图按参考主题的三级回退加载(banner → banner_default → banner_spare)。 */ - function initAffiliatePoster(root, url, poster, siteName, referrerName) { - var button = root.querySelector('[data-affiliate-poster]'); - var modal = document.querySelector('[data-aksrt-affiliate-poster-modal]'); - var stage = modal ? modal.querySelector('[data-affiliate-poster-stage]') : null; - var loading = modal ? modal.querySelector('[data-affiliate-poster-loading]') : null; - var content = modal ? modal.querySelector('.modal-content') : null; - var download = modal ? modal.querySelector('[data-affiliate-poster-download]') : null; - if (!button || !modal) return; - if (!url || !poster || poster.enabled !== true) return; - button.hidden = false; - - var backdrop = null; - var canvas = null; - - function openPosterModal() { - if (modal.classList.contains('in')) return; - modal.style.display = 'flex'; - void modal.offsetWidth; // 强制 reflow,让 .fade 过渡从 scale(0.9) 起手 - modal.classList.add('in'); - modal.setAttribute('aria-hidden', 'false'); - if (!backdrop) { - backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop fade in'; - backdrop.setAttribute('data-affiliate-poster-backdrop', '1'); - document.body.appendChild(backdrop); - } - document.body.classList.add('modal-open'); - } - - function closePosterModal() { - if (!modal.classList.contains('in')) return; - modal.classList.remove('in'); - modal.setAttribute('aria-hidden', 'true'); - document.body.classList.remove('modal-open'); - if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); - backdrop = null; - // 等过渡结束再撤 display,复刻 Bootstrap 的 fade-out 收尾 - window.setTimeout(function () { - if (!modal.classList.contains('in')) modal.style.display = ''; - }, 300); - } - - modal.addEventListener('click', function (event) { - // 点遮罩层(.modal 自身)或关闭按钮(data-dismiss="modal") - if (event.target === modal || (event.target.closest && event.target.closest('[data-dismiss="modal"]'))) { - closePosterModal(); - } - }); - document.addEventListener('keydown', function (event) { - if (event.key === 'Escape' && modal.classList.contains('in')) closePosterModal(); - }); - - if (download) { - download.addEventListener('click', function () { - if (!canvas) return; - try { - var link = document.createElement('a'); - link.download = 'referral-poster.png'; - link.href = canvas.toDataURL('image/png'); - document.body.appendChild(link); - link.click(); - link.remove(); - } catch (err) { - /* Canvas is same-origin generated; download can only fail if the browser blocks it. */ - } - }); - } - - button.addEventListener('click', function () { - if (typeof window.qrcode !== 'function') { - if (stage) { stage.innerHTML = '

' + '二维码组件未加载。' + '

'; } - openPosterModal(); - return; - } - if (stage) { stage.innerHTML = ''; stage.classList.remove('loaded'); } - if (loading) loading.style.display = ''; - if (download) download.classList.add('hide'); - openPosterModal(); - button.disabled = true; - buildPosterCanvas(url, poster, siteName, referrerName).then(function (built) { - canvas = built; - if (!stage) return; - // 参考主题:img.loaded-img 直接进 .poster-imgbox,遮罩淡出, - // 内容块高度从 220px 过渡到海报实际高度后清掉内联样式。 - var img = document.createElement('img'); - img.src = canvas.toDataURL('image/png'); - img.alt = '推广海报'; - img.className = 'loaded-img'; - // 参考主题按 0.7 比例预设尺寸;.poster-imgbox > img 的 max-width:360px - // 会在窄屏把它压回弹窗宽度,height:100% 让 offsetHeight 反映真实渲染高度。 - img.width = 768 * 0.7; - img.height = 1168 * 0.7; - stage.appendChild(img); - stage.classList.add('loaded'); - if (download) { - download.href = img.src; - download.classList.remove('hide'); - } - if (loading) loading.style.display = 'none'; - if (content) { - content.style.height = stage.offsetHeight + 'px'; - content.style.transition = '.2s'; - window.setTimeout(function () { - content.style.height = ''; - content.style.overflow = ''; - content.style.transition = ''; - }, 200); - } - }).catch(function () { - if (stage) { stage.innerHTML = '

' + '海报生成失败,请重试。' + '

'; } - }).finally(function () { - button.disabled = false; - }); - }); - } - - // 三级回退加载图片:任一源成功即返回,全部失败才 reject(参考主题 loadCount 0→1→2)。 - function loadPosterImage(sources) { - var list = (sources || []).filter(function (src) { return !!src; }); - return new Promise(function (resolve, reject) { - var index = 0; - function next() { - if (index >= list.length) { reject(new Error('poster image unavailable')); return; } - var image = new Image(); - image.crossOrigin = 'anonymous'; - image.onload = function () { resolve(image); }; - image.onerror = function () { index += 1; next(); }; - image.src = list[index]; - } - next(); - }); - } - - // contain 裁切:参考主题 containImg(),等比缩放塞进盒子并居中,不裁切。 - function containRect(sx, sy, boxW, boxH, srcW, srcH) { - var dx = sx, dy = sy, dWidth = boxW, dHeight = boxH; - if (srcW > srcH || (srcW === srcH && boxW < boxH)) { - dHeight = (srcH * dWidth) / srcW; - dy = sy + (boxH - dHeight) / 2; - } else if (srcW < srcH || (srcW === srcH && boxW > boxH)) { - dWidth = (srcW * dHeight) / srcH; - dx = sx + (boxW - dWidth) / 2; - } - return { x: dx, y: dy, width: dWidth, height: dHeight }; - } - - // 参考主题 drawTextOverflow():按 maxWidth-120 换行、x 偏移 38,最多 maxRow 行,溢出补 ...。 - // 返回实际测量宽度,供价格/标签层做横向排版。 - function drawTextOverflow(ctx, text, maxWidth, maxRow, font, color, lineHeight, x, y) { - var rows = []; - var temp = ''; - var limit = maxWidth - 120; - x = x + 38; - text = String(text || '').replace(/[\r\n]/g, ''); - ctx.fillStyle = color; - ctx.font = font; - var chars = text.split(''); - var measured = 0; - if (ctx.measureText(text).width <= limit) { - rows.push(text); - } else { - for (var i = 0; i < chars.length; i++) { - if (rows.length === maxRow && i < chars.length - 1) { - rows[rows.length - 1] += '...'; - break; - } - if (ctx.measureText(temp).width < limit) { - temp += chars[i]; - if (i === chars.length - 1) rows.push(temp); - } else { - i--; // 参考主题:回退一格防止字符丢失 - rows.push(temp); - temp = ''; - } - } - } - for (var r = 0; r < rows.length; r++) { - measured = Math.max(measured, ctx.measureText(rows[r]).width); - ctx.fillText(rows[r], x, y + r * lineHeight, limit); - } - return { width: measured, height: rows.length * lineHeight }; - } - - // 参考主题 buildPosterCanvas rebate 分支:768×1168,顶部主图占上半屏,左上角日期, - // 标题竖蓝线 + 标题,推荐人标签(红),简介,底部盒(logo + 二维码 + 底部文案)。 - function buildPosterCanvas(url, poster, siteName, referrerName) { - var width = 768, height = 1168; - var canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - var ctx = canvas.getContext('2d'); - var imgHeight = height / 2; // rebate 分支:顶部主图占上半屏 - // 站点名走「周」前缀,与参考主题 zib__('week_prefix') 一致。 - var weekLabel = '周' + '日一二三四五六'.charAt(new Date().getDay()); - // 推荐人标签:参考主题 tags = `推荐人: {昵称}`,缺昵称时整层不画。 - var tags = referrerName ? '推荐人: ' + String(referrerName) : ''; - // 底部文案:优先推广海报配置,回退到分享设置 share_desc,再回退默认文案。 - var bottom = String(poster.bottom || '扫描二维码阅读全文'); - var title = String(poster.title || siteName || ''); - var description = String(poster.description || ''); - - return Promise.all([ - loadPosterImage([poster.image, poster.banner_default, poster.banner_spare]), - poster.logo ? loadPosterImage([poster.logo]).catch(function () { return null; }) : Promise.resolve(null) - ]).then(function (assets) { - var banner = assets[0], logo = assets[1]; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, width, height); - // 顶部主图(cover 裁切)。 - var rect = coverRect(width, imgHeight, banner.width, banner.height); - ctx.drawImage(banner, rect.sx, rect.sy, rect.sWidth, rect.sHeight, 0, 0, width, imgHeight); - // 左上角日期(白字,覆盖在主图上)。 - ctx.textBaseline = 'top'; - ctx.textAlign = 'left'; - ctx.fillStyle = 'rgba(255,255,255,1)'; - ctx.font = '35px Arial'; - ctx.fillText(weekLabel, 30, 37); - ctx.font = '28px Arial'; - ctx.fillText((new Date().getMonth() + 1) + ' / ' + new Date().getDate(), 30, 87); - // 标题竖蓝线。 - ctx.strokeStyle = '#00a1ff'; - ctx.lineWidth = 7; - ctx.beginPath(); - ctx.lineCap = 'round'; - ctx.moveTo(40, imgHeight + 38); - ctx.lineTo(40, imgHeight + 74); - ctx.stroke(); - // 标题层(粗体)。参考 rebate 分支把 $title 子画布置于 (20, img_height+40), - // 子画布内左对齐 x=40、文字基线偏移 2,故绝对坐标 (40, img_height+42); - // drawTextOverflow 内部会 +38,所以这里传 2。 - drawTextOverflow(ctx, title, width, 2, 'bold 35px Arial', '#424242', 52, 2, imgHeight + 42); - // 推荐人标签层(红,最多 1 行):$tags 置于 (0, img_height+160)。 - if (tags) { - drawTextOverflow(ctx, tags, width, 1, '24px Arial', '#fc4e1f', 36, 2, imgHeight + 162); - } - // 简介层(灰):$content 置于 (0, img_height+220)。 - if (description) { - drawTextOverflow(ctx, description, width, 4, '25px Arial', 'rgba(88,88,88,1)', 39, 2, imgHeight + 222); - } - // 底部带边框盒。 - ctx.strokeStyle = 'rgba(122,122,122,0.2)'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.rect(30, height - 230, width - 60, 200); - ctx.stroke(); - // logo 图:contain 居中,缺图时整块不画(参考主题 logo.naturalWidth 守卫)。 - if (logo && logo.width && logo.height) { - var logoRect = containRect(80, height - 190, 300, 100, logo.width, logo.height); - ctx.drawImage(logo, logoRect.x, logoRect.y, logoRect.width, logoRect.height); - } - // 二维码(右下角)。 - var qr = window.qrcode(0, 'M'); - qr.addData(url); - qr.make(); - var count = qr.getModuleCount(); - var qrBox = 120; - var cell = qrBox / count; - var qrX = width - 200, qrY = height - 200; - ctx.fillStyle = '#0f172a'; - for (var r = 0; r < count; r++) { - for (var c = 0; c < count; c++) { - if (qr.isDark(r, c)) { - ctx.fillRect(qrX + Math.floor(c * cell), qrY + Math.floor(r * cell), Math.ceil(cell), Math.ceil(cell)); - } - } - } - // 底部文案(居中,盒下方)。 - ctx.textAlign = 'center'; - ctx.fillStyle = '#b4b4b4'; - ctx.font = '22px Arial'; - ctx.fillText(String(bottom).slice(0, 46), width / 2, height - 58); - ctx.textAlign = 'left'; - return canvas; - }); - } - - // cover 裁切:把源图按短边铺满目标框并居中裁切。 - function coverRect(boxW, boxH, srcW, srcH) { - var sx = 0, sy = 0, sWidth = srcW, sHeight = srcH; - if (srcW / srcH > boxW / boxH) { - sWidth = (boxW * srcH) / boxH; - sx = (srcW - sWidth) / 2; - } else { - sHeight = (boxH * srcW) / boxW; - sy = (srcH - sHeight) / 2; - } - return { sx: sx, sy: sy, sWidth: sWidth, sHeight: sHeight }; - } - - window.AKSRTAffiliateRevenueInit = initAffiliateRevenue; // ===== VIP 开通 ===== function initVipShop() { @@ -488,7 +167,7 @@ busy = false; btn.disabled = false; btn.classList.remove('is-loading'); - if (data.payment_url) window.open(data.payment_url, '_blank'); + if (window.AksrtPayCashier) window.AksrtPayCashier.handle(data, {}); setTip(data.message || '请完成支付,支付成功后将自动刷新。'); }, onSuccess: function (result) { @@ -573,6 +252,7 @@ tags: tags || ['credits', 'orders'], onTerminal: function (result) { activePoll = null; + if (window.AksrtPayCashier) window.AksrtPayCashier.close(); if (result.success) { notyf('支付成功,正在刷新…', 'success', 5, 'aksrt-balance-charge-payment-toast'); refreshAssetBalances(); @@ -767,9 +447,17 @@ } var data = (r.json && r.json.data) ? r.json.data : r.json; resetOperationKey(form); - if (data && data.redirect && (data.payment_url || data.qr_content)) { - if (data.payment_url) window.open(data.payment_url, '_blank'); + if (data && data.mode) { if (window.AksrtModalRuntime) window.AksrtModalRuntime.invalidate(['orders']); + if (window.AksrtPayCashier) { + window.AksrtPayCashier.handle(data, { + onClose: function () { + if (activePoll && typeof activePoll.abort === 'function') activePoll.abort(); + activePoll = null; + notyf('已停止查询支付状态', 'warning', 3, 'aksrt-balance-charge-payment-toast'); + } + }); + } notyf(data.message || '请完成支付,支付成功后将自动刷新。', 'info', 5, 'aksrt-balance-charge-toast'); if (data.order_number) activePoll = pollPaidOrder(data.order_number, ['credits', 'orders']); return; @@ -1244,158 +932,6 @@ } - function initWithdrawHistory() { - var modal = document.querySelector('[data-aksrt-withdraw-history-modal]'); - if (!modal || modal.dataset.aksrtWithdrawHistoryInitialized === '1') return; - modal.dataset.aksrtWithdrawHistoryInitialized = '1'; - var list = modal.querySelector('[data-aksrt-withdraw-history-list]'); - var pagination = modal.querySelector('[data-aksrt-withdraw-history-pagination]'); - var statusLabels = { - pending: '待处理', - processing: '处理中', - completed: '已完成', - rejected: '已拒绝' - }; - var currentPage = 1; - var loading = false; - - function text(value) { - return value === null || value === undefined ? '' : String(value); - } - - function appendText(parent, value, className) { - var node = document.createElement('span'); - if (className) node.className = className; - node.textContent = text(value); - parent.appendChild(node); - return node; - } - - function formatGrossAmount(value) { - return text(value).replace(/\.00$/, ''); - } - - function formatNetAmount(value) { - return text(value); - } - - function renderItems(items) { - if (!list) return; - list.textContent = ''; - if (!Array.isArray(items) || !items.length) { - var empty = document.createElement('div'); - empty.className = 'uc-transactions__empty'; - var emptyText = document.createElement('p'); - emptyText.textContent = '暂无提现记录'; - empty.appendChild(emptyText); - list.appendChild(empty); - return; - } - items.forEach(function (item) { - var status = text(item && item.status).toLowerCase(); - var row = document.createElement('div'); - row.className = 'uc-withdraw-history__item'; - var badge = appendText(row, statusLabels[status] || status, 'badg badg-sm uc-withdraw-history__status'); - badge.classList.add(status === 'rejected' ? 'c-red' : (status === 'completed' ? 'c-green' : 'c-blue')); - var gross = text(item && (item.gross_amount || item.amount)); - var net = text(item && item.amount); - var amount = document.createElement('span'); - amount.className = 'uc-withdraw-history__amount em12'; - amount.appendChild(document.createTextNode(formatGrossAmount(gross))); - if (net !== '') { - var netText = document.createElement('span'); - netText.className = 'uc-withdraw-history__net'; - netText.textContent = '(实际到账:' + formatNetAmount(net) + '元)'; - amount.appendChild(netText); - } - row.appendChild(amount); - var number = document.createElement('span'); - number.className = 'uc-withdraw-history__number muted-2-color em09'; - var numberLabel = document.createElement('span'); - numberLabel.textContent = '订单号:'; - number.appendChild(numberLabel); - var code = document.createElement('code'); - code.textContent = text(item && item.withdrawal_number); - number.appendChild(code); - row.appendChild(number); - appendText(row, text(item && item.created_at), 'uc-withdraw-history__meta muted-2-color em09'); - list.appendChild(row); - }); - } - - function renderPagination(page, maxPages) { - if (!pagination) return; - pagination.textContent = ''; - if (maxPages <= 1) return; - function addButton(label, target, disabled, current) { - var button = document.createElement('button'); - button.type = 'button'; - button.className = 'uc-withdraw-history__page' + (current ? ' is-current' : ''); - button.textContent = label; - button.disabled = !!disabled; - if (current) button.setAttribute('aria-current', 'page'); - button.dataset.aksrtWithdrawHistoryPage = String(target); - pagination.appendChild(button); - } - addButton('上一页', page - 1, page <= 1, false); - for (var index = 1; index <= maxPages; index += 1) addButton(String(index), index, false, index === page); - addButton('下一页', page + 1, page >= maxPages, false); - } - - function loadPage(page) { - if (loading || !list) return; - loading = true; - list.textContent = '加载中…'; - var rest = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || ''; - if (!rest) { - list.textContent = '记录加载失败,请重试'; - loading = false; - return; - } - var url = rest.replace(/\/$/, '') + '/earnings/withdrawals?page=' + encodeURIComponent(Math.max(1, page)) + '&per_page=10'; - fetch(url, { - credentials: 'same-origin', - headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } - }).then(function (response) { - return response.json().then(function (payload) { - if (!response.ok || !payload || !Array.isArray(payload.items)) throw new Error('withdraw-history-load-failed'); - currentPage = Math.max(1, parseInt(payload.page, 10) || 1); - var maxPages = Math.max(1, parseInt(payload.max_pages, 10) || 1); - renderItems(payload.items); - renderPagination(currentPage, maxPages); - }); - }).catch(function () { - list.textContent = '记录加载失败,请重试'; - if (pagination) pagination.textContent = ''; - }).finally(function () { - loading = false; - }); - } - - function open() { - modal.hidden = false; - document.body.classList.add('uc-modal-open'); - loadPage(1); - } - function close() { - modal.hidden = true; - document.body.classList.remove('uc-modal-open'); - } - document.querySelectorAll('[data-aksrt-withdraw-history-open]').forEach(function (btn) { - btn.addEventListener('click', function (e) { e.preventDefault(); open(); }); - }); - modal.querySelectorAll('[data-aksrt-withdraw-history-close]').forEach(function (btn) { - btn.addEventListener('click', function (e) { e.preventDefault(); close(); }); - }); - if (pagination) { - pagination.addEventListener('click', function (e) { - var button = e.target.closest('[data-aksrt-withdraw-history-page]'); - if (!button || button.disabled) return; - e.preventDefault(); - loadPage(parseInt(button.dataset.aksrtWithdrawHistoryPage || '1', 10) || 1); - }); - } - } window.AKSRTWithdrawInit = initWithdraw; window.AKSRTAssetBalances = { @@ -1405,14 +941,12 @@ function bootAssets() { refreshAssetBalances(); - initSigninGoto(); - initAffiliateRevenue(); + initSigninGoto(); initVipShop(); initBalanceCharge(); initTransfer(); initPointsPay(); - initWithdraw(); - initWithdrawHistory(); + initWithdraw(); initAssetTabs(); } diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 70f1cd76..40ef2a45 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -386,6 +386,23 @@ return loadView(state); } + function pollOrder(state, number) { + var coordinator = window.AksrtVipPurchaseCoordinator; + if (!coordinator || typeof coordinator.poll !== "function") return null; + return coordinator.poll(number, { + tags: ["orders"], + onTerminal: function (result) { + if (window.AksrtPayCashier) window.AksrtPayCashier.close(); + notyf(result.success ? "支付成功" : "支付未完成,请重试", result.success ? "success" : "danger"); + invalidateOrder(number, null); + if (activeDetail(state, number)) openDetail(state, number); + }, + onTimeout: function () { + notyf("支付状态暂未确认,请稍后刷新订单", "danger"); + } + }); + } + function startPayment(state, button) { var number = button.getAttribute("data-aksrt-order-pay") || ""; var method = button.getAttribute("data-aksrt-order-method") || ""; @@ -397,8 +414,15 @@ delete state.operationKeys[actionKey]; var data = result.data || {}; invalidateOrder(number, data.order); - if (data.redirect && data.payment_url) { - window.location.assign(data.payment_url); + if (data.mode) { + button.disabled = false; + if (!window.AksrtPayCashier) return; + var handled = window.AksrtPayCashier.handle(data, { + onClose: function () { if (poll) poll.abort(); } + }); + var poll = handled === "qrcode" || handled === "jsapi" + ? pollOrder(state, number) + : null; return; } notyf(data.message || "支付成功", "success"); diff --git a/assets/js/pay-cashier.js b/assets/js/pay-cashier.js new file mode 100644 index 00000000..e75aaf94 --- /dev/null +++ b/assets/js/pay-cashier.js @@ -0,0 +1,162 @@ +/** + * Unified cashier: renders the gateway result returned by PaymentOrchestrator::pay(). + * + * mode=qrcode on-site QR modal (+ optional mobile deep link) + * mode=redirect same-tab navigation to the gateway page + * mode=form auto-submit the server-built POST form + * mode=jsapi WeChat in-app WeixinJSBridge payment + * + * Polling stays with the caller (AksrtVipPurchaseCoordinator.poll); this module + * only presents the payment step and reports when the QR modal is dismissed. + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var KIND = "pay-cashier"; + var sequence = 0; + var listeners = Object.create(null); + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; + }); + } + + function isMobile() { + return /Android|iPhone|iPad|iPod|Mobile/i.test(window.navigator.userAgent || ""); + } + + function qrDataUrl(text) { + if (typeof window.qrcode !== "function" || !text) return ""; + try { + var qr = window.qrcode(0, "M"); + qr.addData(String(text)); + qr.make(); + return qr.createDataURL(6, 12); + } catch (error) { + return ""; + } + } + + function amountText(data) { + if (data.amount === undefined || data.amount === null || data.amount === "") return ""; + var mark = (window._win && window._win.pay_mark) || "¥"; + return mark + String(data.amount); + } + + /** Inner QR markup; reusable by callers that own their own modal. */ + function qrHtml(data) { + var src = qrDataUrl(data.code_url); + var html = '
'; + var amount = amountText(data); + if (amount) html += '
' + escapeHtml(amount) + "
"; + html += src + ? '支付二维码' + : '

二维码组件未加载,请刷新页面重试。

'; + html += '

' + escapeHtml(data.message || "请使用手机扫码完成支付") + "

"; + if (data.payment_url && isMobile()) { + html += '打开支付应用'; + } + if (data.order_number) { + html += '

订单号:' + escapeHtml(data.order_number) + "

"; + } + return html + "
"; + } + + function openQr(data, options) { + var runtime = window.AksrtModalRuntime; + if (!runtime) return false; + var id = String(++sequence); + if (typeof options.onClose === "function") listeners[id] = options.onClose; + runtime.open({ + kind: KIND, + cashierId: id, + title: options.title || "扫码支付", + className: "aksrt-pay-cashier-modal", + telemetry: false, + html: qrHtml(data), + }); + return true; + } + + function submitForm(html) { + var host = document.createElement("div"); + host.hidden = true; + host.innerHTML = String(html || ""); + var form = host.querySelector("form"); + if (!form) return false; + document.body.appendChild(host); + form.submit(); + return true; + } + + function invokeJsapi(params, options) { + var run = function () { + window.WeixinJSBridge.invoke("getBrandWCPayRequest", params, function (res) { + var msg = res && res.err_msg ? String(res.err_msg) : ""; + if (msg === "get_brand_wcpay_request:ok") return; + if (typeof options.onClose === "function") { + options.onClose(msg === "get_brand_wcpay_request:cancel" ? "cancel" : "error"); + } + }); + }; + if (window.WeixinJSBridge && typeof window.WeixinJSBridge.invoke === "function") { + run(); + } else { + document.addEventListener("WeixinJSBridgeReady", run, { once: true }); + } + return true; + } + + /** + * Present a pending payment. Returns the handled mode, or "" when the + * payload carries no cashier step (synchronous gateways). + * + * options.onClose(reason) fires when the QR modal is dismissed or the + * JSAPI sheet is cancelled; callers stop polling there. + */ + function handle(data, options) { + data = data || {}; + options = options || {}; + switch (data.mode) { + case "qrcode": + return data.code_url && openQr(data, options) ? "qrcode" : ""; + case "redirect": + if (!data.payment_url) return ""; + window.location.assign(data.payment_url); + return "redirect"; + case "form": + return submitForm(data.form_html) ? "form" : ""; + case "jsapi": + return data.jsapi_params ? invokeJsapi(data.jsapi_params, options) && "jsapi" : ""; + default: + return ""; + } + } + + /** Close the cashier QR modal after a terminal poll result. */ + function close() { + var runtime = window.AksrtModalRuntime; + var shell = runtime ? runtime.getActive() : null; + if (shell && shell.classList.contains("aksrt-pay-cashier-modal")) runtime.close("paid"); + } + + document.addEventListener("aksrt:modal:close", function (event) { + var detail = event && event.detail ? event.detail : {}; + var opts = detail.session && detail.session.options ? detail.session.options : {}; + if (opts.kind !== KIND || !listeners[opts.cashierId]) return; + var fn = listeners[opts.cashierId]; + delete listeners[opts.cashierId]; + if (detail.reason !== "paid") fn(detail.reason || "close"); + }); + + window.AksrtPayCashier = { + KIND: KIND, + handle: handle, + close: close, + qrHtml: qrHtml, + isPending: function (data) { return !!(data && data.mode); }, + }; +})(window, document); diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index 78c29f11..7a73124e 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -577,7 +577,9 @@ tags: ["vip", "orders"], onPending: function (data) { waitingPayment = true; - if (data.payment_url) window.open(data.payment_url, "_blank"); + if (window.AksrtPayCashier) { + window.AksrtPayCashier.handle(data, { onClose: cancelActivePurchase }); + } notyf(data.message || "请完成支付,支付成功后将自动刷新。", "info", 5, activePurchaseToastId); activePurchaseToastId = "aksrt-vip-payment-toast"; notyf("等待支付结果…", "load", "", activePurchaseToastId); @@ -831,16 +833,22 @@ vipPay(trigger); } - document.addEventListener("aksrt:modal:close", function (event) { - var detail = event && event.detail ? event.detail : {}; - var options = detail.session && detail.session.options ? detail.session.options : {}; - if (options.kind !== "vip" || !activePurchase) return; + function cancelActivePurchase() { + if (!activePurchase) return; if (typeof activePurchase.abort === "function") activePurchase.abort(); activePurchase = null; if (activePurchaseToastId) { notyf("已取消会员购买", "warning", 3, activePurchaseToastId); activePurchaseToastId = ""; } + } + + // VIP 弹窗被收银二维码弹窗替换(reason=replace)时保持轮询;用户主动关闭才取消。 + document.addEventListener("aksrt:modal:close", function (event) { + var detail = event && event.detail ? event.detail : {}; + var options = detail.session && detail.session.options ? detail.session.options : {}; + if (options.kind !== "vip" || detail.reason === "replace") return; + cancelActivePurchase(); }); // 积分兑换入口(data-aksrt-vip-points-open)文档级委托:一处绑定同时覆盖 diff --git a/assets/js/vip-purchase-coordinator.js b/assets/js/vip-purchase-coordinator.js index 0a7c19e6..e91a3742 100644 --- a/assets/js/vip-purchase-coordinator.js +++ b/assets/js/vip-purchase-coordinator.js @@ -10,7 +10,7 @@ (function (window, document) { "use strict"; - var ORDER_PATH = "/pay/content/order/"; + var ORDER_PATH = "/pay/order/"; var DEFAULT_MAX_ATTEMPTS = 12; var DEFAULT_MAX_ELAPSED = 120000; var MAX_DELAY = 15000; @@ -374,7 +374,7 @@ callback(options, "onError", response); return response; } - if (data.redirect && (data.payment_url || data.qr_content)) { + if (data.mode) { invalidate(["orders"]); callback(options, "onPending", data); if (!data.order_number) { diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 6debb7b6..3b9ee44a 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -486,25 +486,24 @@ class SchemaPayment { ], 'help' => '收银台支付方式显示顺序;仅显示已开启的接口'], ]], ['key' => 'alipay', 'title' => '支付宝官方', 'collapsible' => true, 'defaultCollapsed' => true, 'dependsOn' => ['key' => 'pay_alipay_sdk_options', 'value' => 'official_alipay'], 'fields' => [ - ['key' => 'alipay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => ''], + ['key' => 'alipay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => ''], ['key' => 'alipay_appid', 'label' => '当面付:APPID', 'type' => 'input', 'default' => ''], ['key' => 'alipay_privatekey', 'label' => '当面付:应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => '', 'rows' => 4, 'secret' => true], - ['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [ - ['label' => '电脑网站支付(web)', 'value' => 'page'], - ['label' => '手机网站支付(wap)', 'value' => 'wap'], - ]], + ['key' => 'alipay_pc_mode', 'label' => '电脑端收款', 'type' => 'select', 'default' => 'facepay', 'options' => [ + ['label' => '当面付(站内二维码)', 'value' => 'facepay'], + ['label' => '电脑网站支付(跳转支付宝)', 'value' => 'web'], + ], 'help' => '当面付在站内弹出二维码;电脑网站支付在当前页跳转到支付宝收银台'], + ['key' => 'alipay_h5', 'label' => '移动端 H5 跳转', 'type' => 'switch', 'default' => false, 'help' => '需签约手机网站支付。开启后手机浏览器跳转支付宝 App/收银台付款;关闭则手机端也显示二维码(附唤起链接)'], ['key' => 'alipay_app_public_cert_path', 'label' => '应用公钥证书路径', 'type' => 'input', 'default' => '', 'help' => 'appCertPublicKey.crt 绝对路径。'], ['key' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'input', 'default' => '', 'help' => 'alipayCertPublicKey_RSA2.crt 绝对路径。'], ['key' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'input', 'default' => '', 'help' => 'alipayRootCert.crt 绝对路径。'], ]], ['key' => 'wechat', 'title' => '微信官方', 'collapsible' => true, 'defaultCollapsed' => true, 'dependsOn' => ['key' => 'pay_wechat_sdk_options', 'value' => 'official_wechat'], 'fields' => [ - ['key' => 'wechat_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => ''], + ['key' => 'wechat_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => ''], ['key' => 'wechat_merchantid', 'label' => '商户号 PartnerID', 'type' => 'input', 'default' => ''], ['key' => 'wechat_appid', 'label' => '授权绑定的AppID', 'type' => 'input', 'default' => ''], - ['key' => 'wechat_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'native', 'options' => [ - ['label' => '扫码支付(Native)', 'value' => 'native'], - ['label' => 'H5支付', 'value' => 'h5'], - ]], + ['key' => 'wechat_h5', 'label' => '移动端 H5 跳转', 'type' => 'switch', 'default' => false, 'help' => '需开通 H5 支付。开启后手机浏览器(非微信内)跳转微信付款;关闭则显示站内二维码'], + ['key' => 'wechat_jsapi', 'label' => '微信内 JSAPI 支付', 'type' => 'switch', 'default' => false, 'help' => '需开通 JSAPI 支付并在商户平台配置支付授权目录。微信内打开时直接调起支付;用户 openid 通过「用户→微信公众号」的 AppID/AppSecret 静默授权获取,公众号 AppID 必须与上方 AppID 一致'], ['key' => 'wechat_api_v3_key', 'label' => '支付APIv3密钥', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => '微信商户平台设置的 APIv3 密钥(32位)。'], ['key' => 'wechat_private_key_path', 'label' => '商户私钥路径', 'type' => 'input', 'default' => '', 'help' => 'apiclient_key.pem 绝对路径,用于签名。'], ['key' => 'wechat_cert_path', 'label' => '商户API证书路径', 'type' => 'input', 'default' => '', 'help' => 'apiclient_cert.pem 绝对路径。'], @@ -534,11 +533,7 @@ class SchemaPayment { ['key' => 'epay_apiurl', 'label' => 'API接口网址', 'type' => 'input', 'default' => '', 'help' => '请填写接口地址,例如:https://pay.xxxxxxx.cn/'], ['key' => 'epay_pid', 'label' => '商户号', 'type' => 'input', 'default' => ''], ['key' => 'epay_key', 'label' => '商户秘钥', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => 'MD5 通信密钥。'], - ['key' => 'epay_device', 'label' => '下单终端', 'type' => 'select', 'default' => 'auto', 'options' => [ - ['label' => '自动(按 UA)', 'value' => 'auto'], - ['label' => '电脑端 submit', 'value' => 'pc'], - ['label' => '移动端 mapi', 'value' => 'mobile'], - ]], + ['key' => 'epay_qrcode', 'label' => '电脑端站内二维码', 'type' => 'switch', 'default' => true, 'help' => '开启后电脑端通过 mapi 接口获取二维码在站内展示(接口未返回二维码时跳转支付页);关闭则跳转易支付收银台。移动端始终跳转'], ]], ], ], diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index 83d8789f..82cd8e35 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -544,21 +544,18 @@ trait PayOrders { ); } - if (!empty($payment_result['redirect'])) { + $cashier = \AKSRT\Payment\PaymentOrchestrator::cashier_payload($payment_result); + if ($cashier !== []) { return [ 'success' => true, - 'data' => [ - 'redirect' => true, - 'payment_url' => (string) ($payment_result['payment_url'] ?? ''), - 'qr_content' => (string) ($payment_result['qr_content'] ?? ''), + 'data' => array_merge($cashier, [ 'order_number' => $order_number, 'amount' => $amount_decimal, 'pay_modo' => '0', 'message' => $payment_result['message'], 'reload' => false, - 'poll' => true, 'replayed' => false, - ], + ]), ]; } diff --git a/src/Content/PayRest.php b/src/Content/PayRest.php index bbf8adc0..cdf1eb50 100644 --- a/src/Content/PayRest.php +++ b/src/Content/PayRest.php @@ -37,7 +37,7 @@ trait PayRest { ], ]); - register_rest_route('aksrt/v1', '/pay/content/order/(?P[A-Za-z0-9_-]+)', [ + register_rest_route('aksrt/v1', '/pay/order/(?P[A-Za-z0-9_-]+)', [ 'methods' => 'GET', 'callback' => [$this, 'rest_order_status'], 'permission_callback' => [$this, 'check_rest_nonce'], @@ -164,7 +164,7 @@ trait PayRest { /** - * GET /pay/content/order/{order_number} + * GET /pay/order/{order_number} (all order types; guests limited to content orders) */ public function rest_order_status(\WP_REST_Request $request) { $uid = get_current_user_id(); diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index 5a1bf017..d44ee8ad 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -488,20 +488,17 @@ trait VipServicePurchase { ); } - if (!empty($payment_result['redirect'])) { + $cashier = \AKSRT\Payment\PaymentOrchestrator::cashier_payload($payment_result); + if ($cashier !== []) { return [ 'success' => true, - 'data' => [ - 'redirect' => true, - 'payment_url' => (string) ($payment_result['payment_url'] ?? ''), - 'qr_content' => (string) ($payment_result['qr_content'] ?? ''), + 'data' => array_merge($cashier, [ 'order_number' => $order_number, 'amount' => $amount_decimal, 'message' => $payment_result['message'], 'reload' => false, - 'poll' => true, 'replayed' => false, - ], + ]), ]; } diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index c682ba1c..48e788ba 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -93,6 +93,24 @@ trait ScriptsPart1b { && class_exists('\AKSRT\Credits\Signin') && class_exists('\AKSRT\Settings\Settings') && !empty(\AKSRT\Settings\Settings::get_signin_reward_settings()['enabled']); + // 统一收银台(站内二维码 / 当前页跳转 / 表单提交 / JSAPI):按需作为依赖入队。 + $qrcode_js_path = get_template_directory() . '/assets/js/vendor/qrcode.min.js'; + wp_register_script( + 'aksrt-qrcode', + $base_uri . '/assets/js/vendor/qrcode.min.js', + [], + file_exists($qrcode_js_path) ? (string) filemtime($qrcode_js_path) : $version, + true + ); + $pay_cashier_js = get_template_directory() . '/assets/js/pay-cashier.js'; + wp_register_script( + 'aksrt-pay-cashier', + $base_uri . '/assets/js/pay-cashier.js', + ['loader_js', 'aksrt-qrcode'], + file_exists($pay_cashier_js) ? (string) filemtime($pay_cashier_js) : $version, + true + ); + $vip_coordinator = get_template_directory() . '/assets/js/vip-purchase-coordinator.js'; if ((($vip_on && is_user_logged_in()) || $credits_on || $affiliate_on) && file_exists($vip_coordinator)) { wp_enqueue_script( @@ -120,7 +138,7 @@ trait ScriptsPart1b { wp_enqueue_script( 'aksrt-pay-vip', $base_uri . '/assets/js/pay-vip.js', - ['loader_js', 'aksrt-vip-coordinator'], + ['loader_js', 'aksrt-vip-coordinator', 'aksrt-pay-cashier'], (string) filemtime($vip_js), true ); @@ -407,7 +425,7 @@ trait ScriptsPart1b { wp_enqueue_script( 'aksrt-order-center', $base_uri . '/assets/js/order-center.js', - ['loader_js'], + ['loader_js', 'aksrt-pay-cashier'], (string) filemtime($order_center_js), true ); @@ -667,7 +685,7 @@ trait ScriptsPart1b { wp_enqueue_script( 'aksrt-cms-download', $base_uri . '/assets/js/cms-download.js', - ['loader_js'], + ['loader_js', 'aksrt-pay-cashier'], file_exists($cms_download_js_path) ? (string) filemtime($cms_download_js_path) : $version, true ); @@ -692,28 +710,25 @@ trait ScriptsPart1b { // profile 页:加载 credits.js(签到 + 邀请 + 提现交互) if ($is_profile_page) { - $affiliate_deps = []; - if ($affiliate_on) { - // 推广二维码生成库(本地打包,无外部 CDN)。 - $qrcode_js_path = get_template_directory() . '/assets/js/vendor/qrcode.min.js'; - wp_enqueue_script( - 'aksrt-qrcode', - $base_uri . '/assets/js/vendor/qrcode.min.js', - [], - file_exists($qrcode_js_path) ? (string) filemtime($qrcode_js_path) : $version, - true - ); - $affiliate_deps[] = 'aksrt-qrcode'; - } + // aksrt-pay-cashier 依赖 aksrt-qrcode(推广海报二维码同用)。 $credits_js_path = get_template_directory() . '/assets/js/credits.js'; wp_enqueue_script( 'aksrt-credits', $base_uri . '/assets/js/credits.js', - array_merge(['loader_js', 'aksrt-vip-coordinator'], $affiliate_deps), + ['loader_js', 'aksrt-vip-coordinator', 'aksrt-pay-cashier'], file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version, true ); - + foreach (['credits-withdraw-history' => ['loader_js']] + ($affiliate_on ? ['credits-affiliate' => ['loader_js', 'aksrt-qrcode']] : []) as $credits_part => $credits_part_deps) { + $credits_part_path = get_template_directory() . '/assets/js/' . $credits_part . '.js'; + wp_enqueue_script( + 'aksrt-' . $credits_part, + $base_uri . '/assets/js/' . $credits_part . '.js', + $credits_part_deps, + file_exists($credits_part_path) ? (string) filemtime($credits_part_path) : $version, + true + ); + } } } diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index 75c0df1c..47df5b88 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -389,7 +389,6 @@ trait BalanceServiceCharge { } if (is_array($existing) && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) { return [ - 'redirect' => false, 'order_number' => $order_number, 'amount' => $pay_decimal, 'credit_amount' => $credit_decimal, @@ -425,26 +424,21 @@ trait BalanceServiceCharge { ); } - if (!empty($payment_result['redirect'])) { - return [ - 'redirect' => true, - 'payment_url' => (string) ($payment_result['payment_url'] ?? ''), - 'qr_content' => (string) ($payment_result['qr_content'] ?? ''), + $cashier = \AKSRT\Payment\PaymentOrchestrator::cashier_payload($payment_result); + if ($cashier !== []) { + return array_merge($cashier, [ 'order_number' => $order_number, 'amount' => $pay_decimal, 'credit_amount' => $credit_decimal, 'message' => $payment_result['message'], 'reload' => false, - 'poll' => true, - 'transaction_id' => 0, 'replayed' => false, 'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), - ]; + ]); } // The synchronous gateway has already committed the recharge and order state. return [ - 'redirect' => false, 'order_number' => $order_number, 'amount' => $pay_decimal, 'credit_amount' => $credit_decimal, diff --git a/src/Payment/Gateway.php b/src/Payment/Gateway.php index b88907c1..a105b5db 100644 --- a/src/Payment/Gateway.php +++ b/src/Payment/Gateway.php @@ -73,6 +73,12 @@ class Gateway { register_rest_route('aksrt/v1', '/payment/(?P[a-z0-9_]+)/cancel', array_merge($args, [ 'callback' => [$this, 'handle_cancel'], ])); + // WeChat JSAPI snsapi_base callback: one-time state transient guards it. + register_rest_route('aksrt/v1', '/payment/wechatpay/openid', [ + 'methods' => 'GET', + 'permission_callback' => '__return_true', + 'callback' => ['\AKSRT\Payment\Gateways\WechatOpenid', 'handle_callback'], + ]); } /** diff --git a/src/Payment/GatewayInterface.php b/src/Payment/GatewayInterface.php index 7746be76..e885aa6a 100644 --- a/src/Payment/GatewayInterface.php +++ b/src/Payment/GatewayInterface.php @@ -15,7 +15,10 @@ interface GatewayInterface { * 创建支付单 * * @param array $order 订单数据 - * @return array{success: bool, payment_url?: string, payment_data?: array, message?: string} + * 外部网关返回 mode(qrcode|redirect|form|jsapi)及对应的 + * code_url / payment_url / form_html / jsapi_params;同步网关(余额/积分)不返回 mode。 + * + * @return array{success: bool, mode?: string, code_url?: string, payment_url?: string, form_html?: string, jsapi_params?: array, message?: string} */ public function create_payment(array $order): array; diff --git a/src/Payment/Gateways/AlipayGateway.php b/src/Payment/Gateways/AlipayGateway.php index 801ac889..9216210b 100644 --- a/src/Payment/Gateways/AlipayGateway.php +++ b/src/Payment/Gateways/AlipayGateway.php @@ -46,41 +46,52 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { ? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order) : $order_number; } - $amount = $this->amount_string($order['amount'] ?? null); - $subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt')); - $payment_type = $this->get_config()['payment_type']; + $amount = $this->amount_string($order['amount'] ?? null); + $subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt')); + $config = $this->get_config(); if ($order_number === '' || $out_trade_no === '' || $amount === '') { return ['success' => false, 'message' => __('订单参数无效', 'aksrt')]; } + $payload = [ + 'out_trade_no' => $out_trade_no, + 'total_amount' => $amount, + 'subject' => mb_substr($subject, 0, 256), + ]; + $mobile = wp_is_mobile(); + try { YansongdaPayFactory::boot_alipay(); - $payload = [ - '_method' => 'GET', - 'out_trade_no' => $out_trade_no, - 'total_amount' => $amount, - 'subject' => mb_substr($subject, 0, 256), - ]; - - $result = $payment_type === 'wap' - ? Pay::alipay()->h5($payload) - : Pay::alipay()->web($payload); - - $payment_url = YansongdaPayFactory::extract_payment_url($result); - if ($payment_url === '') { - return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')]; + // Off-site cashier: mobile H5 when signed, desktop web when chosen. + if (($mobile && $config['h5']) || (!$mobile && $config['pc_mode'] === 'web')) { + $payload['_method'] = 'GET'; + $result = $mobile ? Pay::alipay()->h5($payload) : Pay::alipay()->web($payload); + $payment_url = YansongdaPayFactory::extract_payment_url($result); + if ($payment_url === '') { + return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')]; + } + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => $payment_url, + 'message' => __('正在跳转支付宝…', 'aksrt'), + ]; } + // Face-to-face precreate: on-site QR code (deep link on mobile). + $data = YansongdaPayFactory::to_array(Pay::alipay()->scan($payload)); + $qr_code = (string) ($data['qr_code'] ?? ''); + if ($qr_code === '') { + return ['success' => false, 'message' => __('支付宝二维码生成失败', 'aksrt')]; + } return [ - 'success' => true, - 'payment_url' => $payment_url, - 'payment_data' => [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no, - ], - 'message' => __('支付宝支付链接已生成', 'aksrt'), + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_QRCODE, + 'code_url' => $qr_code, + 'payment_url' => $mobile ? $qr_code : '', + 'message' => __('请使用支付宝扫码支付', 'aksrt'), ]; } catch (\Throwable $e) { return ['success' => false, 'message' => $e->getMessage()]; @@ -277,15 +288,15 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { } /** - * @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string} + * @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,pc_mode:string,h5:bool} */ private function get_config(): array { $all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $type = sanitize_key((string) ($all['alipay_payment_type'] ?? 'page')); - if (!in_array($type, ['page', 'wap'], true)) { - $type = 'page'; + $pc_mode = sanitize_key((string) ($all['alipay_pc_mode'] ?? 'facepay')); + if (!in_array($pc_mode, ['facepay', 'web'], true)) { + $pc_mode = 'facepay'; } return [ 'app_id' => (string) ($all['alipay_appid'] ?? ''), @@ -293,7 +304,8 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { 'app_public_cert' => (string) ($all['alipay_app_public_cert_path'] ?? ''), 'alipay_public_cert' => (string) ($all['alipay_public_cert_path'] ?? ''), 'alipay_root_cert' => (string) ($all['alipay_root_cert_path'] ?? ''), - 'payment_type' => $type, + 'pc_mode' => $pc_mode, + 'h5' => !empty($all['alipay_h5']), ]; } diff --git a/src/Payment/Gateways/EpayGateway.php b/src/Payment/Gateways/EpayGateway.php index 9dc1187c..eb76ac66 100644 --- a/src/Payment/Gateways/EpayGateway.php +++ b/src/Payment/Gateways/EpayGateway.php @@ -65,56 +65,74 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface { 'return_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/return'), 'name' => mb_substr(wp_strip_all_tags($subject), 0, 127), 'money' => $amount, + 'sitename' => mb_substr(wp_strip_all_tags((string) get_bloginfo('name')), 0, 64), ]; - $params['sign'] = $this->sign($params, $config['key']); - $params['sign_type'] = 'MD5'; - $use_mobile = $this->prefer_mobile($config['device']); - if ($use_mobile) { - // mapi.php returns JSON with payurl / qrcode / urlscheme. - $api = $this->endpoint($config['apiurl'], 'mapi.php'); - $response = wp_remote_post($api, [ - 'timeout' => 30, - 'body' => $params, - 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'], - ]); - if (is_wp_error($response)) { - return ['success' => false, 'message' => $response->get_error_message()]; - } - $body = json_decode((string) wp_remote_retrieve_body($response), true); - if (!is_array($body)) { - // Fall back to browser submit if mapi is unavailable. - return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no); - } - $code = (int) ($body['code'] ?? 0); - if ($code !== 1) { - $msg = (string) ($body['msg'] ?? $body['message'] ?? __('易支付下单失败', 'aksrt')); - return ['success' => false, 'message' => $msg]; - } - $pay_url = (string) ($body['payurl'] ?? $body['urlscheme'] ?? ''); - $qrcode = (string) ($body['qrcode'] ?? ''); - if ($pay_url === '' && $qrcode === '') { - return ['success' => false, 'message' => __('易支付未返回支付链接', 'aksrt')]; - } - $result = [ - 'success' => true, - 'payment_data' => [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no, - 'code_url' => $qrcode, - 'channel' => $this->channel, - ], - 'message' => __('易支付订单已创建', 'aksrt'), - ]; - if ($pay_url !== '') { - $result['payment_url'] = $pay_url; - } elseif ($qrcode !== '') { - $result['payment_url'] = $qrcode; - } - return $result; + // Mobile, or desktop with the on-site QR switched off: hand off to the + // Epay cashier via a self-submitting form (current tab). + if (wp_is_mobile() || !$config['qrcode']) { + return $this->submit_form_result($config, $params); } - return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no); + // Desktop on-site QR: mapi.php returns qrcode / payurl as JSON. + $params['clientip'] = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $params['device'] = 'pc'; + $params['sign'] = $this->sign($params, $config['key']); + $params['sign_type'] = 'MD5'; + $response = wp_remote_post($this->endpoint($config['apiurl'], 'mapi.php'), [ + 'timeout' => 30, + 'body' => $params, + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'], + ]); + if (is_wp_error($response)) { + return ['success' => false, 'message' => $response->get_error_message()]; + } + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body) || (int) ($body['code'] ?? 0) !== 1) { + $msg = is_array($body) ? (string) ($body['msg'] ?? $body['message'] ?? '') : ''; + return ['success' => false, 'message' => $msg !== '' ? $msg : __('易支付下单失败', 'aksrt')]; + } + $qrcode = (string) ($body['qrcode'] ?? $body['code_url'] ?? ''); + $pay_url = (string) ($body['payurl'] ?? ''); + if ($qrcode !== '') { + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_QRCODE, + 'code_url' => $qrcode, + 'message' => $this->channel === 'wxpay' ? __('请使用微信扫码支付', 'aksrt') : __('请使用支付宝扫码支付', 'aksrt'), + ]; + } + if ($pay_url !== '') { + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => $pay_url, + 'message' => __('正在跳转支付页面…', 'aksrt'), + ]; + } + return ['success' => false, 'message' => __('易支付未返回支付链接', 'aksrt')]; + } + + /** + * Self-submitting POST form to submit.php (signed params as hidden inputs). + * + * @param array{apiurl:string,key:string} $config + * @param array $params + * @return array + */ + private function submit_form_result(array $config, array $params): array { + $params['sign'] = $this->sign($params, $config['key']); + $params['sign_type'] = 'MD5'; + $inputs = ''; + foreach ($params as $name => $value) { + $inputs .= ''; + } + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_FORM, + 'form_html' => '
' . $inputs . '
', + 'message' => __('正在跳转支付页面…', 'aksrt'), + ]; } public function verify_callback(\WP_REST_Request $request): array { @@ -231,16 +249,12 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface { } /** - * @return array{apiurl:string,pid:string,key:string,device:string} + * @return array{apiurl:string,pid:string,key:string,qrcode:bool} */ private function get_config(): array { $all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $device = sanitize_key((string) ($all['epay_device'] ?? 'auto')); - if (!in_array($device, ['auto', 'pc', 'mobile'], true)) { - $device = 'auto'; - } $apiurl = (string) ($all['epay_apiurl'] ?? ''); $apiurl = rtrim(trim($apiurl), '/') . '/'; if ($apiurl === '/') { @@ -250,7 +264,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface { 'apiurl' => $apiurl, 'pid' => (string) ($all['epay_pid'] ?? ''), 'key' => (string) ($all['epay_key'] ?? ''), - 'device' => $device, + 'qrcode' => !array_key_exists('epay_qrcode', $all) || !empty($all['epay_qrcode']), ]; } @@ -300,43 +314,6 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface { } } - private function prefer_mobile(string $device): bool { - if ($device === 'mobile') { - return true; - } - if ($device === 'pc') { - return false; - } - // auto - if (!empty($_SERVER['HTTP_USER_AGENT'])) { - $ua = (string) $_SERVER['HTTP_USER_AGENT']; - return (bool) preg_match('/Mobile|Android|iPhone|iPad|iPod|MicroMessenger/i', $ua); - } - return false; - } - - /** - * @param array $params - * @return array{success:bool,payment_url?:string,payment_data?:array,message?:string} - */ - /** - * @param array $params - * @return array{success:bool,payment_url?:string,payment_data?:array,message?:string} - */ - private function browser_submit_result(string $apiurl, array $params, string $order_number, string $out_trade_no = ''): array { - $url = add_query_arg($params, $this->endpoint($apiurl, 'submit.php')); - return [ - 'success' => true, - 'payment_url' => $url, - 'payment_data' => [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no !== '' ? $out_trade_no : $order_number, - 'channel' => $this->channel, - ], - 'message' => __('易支付支付链接已生成', 'aksrt'), - ]; - } - /** * Map local order_number → provider out_trade_no (supports resume attempts). */ diff --git a/src/Payment/Gateways/PayPalGateway.php b/src/Payment/Gateways/PayPalGateway.php index 9cac2779..31685386 100644 --- a/src/Payment/Gateways/PayPalGateway.php +++ b/src/Payment/Gateways/PayPalGateway.php @@ -90,14 +90,10 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface { } return [ - 'success' => true, - 'payment_url' => $approveUrl, - 'payment_data' => [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no, - 'paypal_id' => $data['id'] ?? '', - ], - 'message' => __('PayPal 订单已创建', 'aksrt'), + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => $approveUrl, + 'message' => __('正在跳转 PayPal…', 'aksrt'), ]; } diff --git a/src/Payment/Gateways/StripeGateway.php b/src/Payment/Gateways/StripeGateway.php index 9a53282d..1bcb2000 100644 --- a/src/Payment/Gateways/StripeGateway.php +++ b/src/Payment/Gateways/StripeGateway.php @@ -110,14 +110,10 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface { } return [ - 'success' => true, - 'payment_url' => $session->url, - 'payment_data' => [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no, - 'session_id' => $session->id, - ], - 'message' => __('Stripe 支付页面已创建', 'aksrt'), + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => (string) $session->url, + 'message' => __('正在跳转 Stripe…', 'aksrt'), ]; } catch (\Stripe\Exception\ApiErrorException $e) { return ['success' => false, 'message' => $e->getMessage()]; diff --git a/src/Payment/Gateways/WechatOpenid.php b/src/Payment/Gateways/WechatOpenid.php new file mode 100644 index 00000000..e195f1d8 --- /dev/null +++ b/src/Payment/Gateways/WechatOpenid.php @@ -0,0 +1,143 @@ + 0) { + $bound = sanitize_text_field((string) get_user_meta($user_id, 'aksrt_weixingzh_id', true)); + if ($bound !== '') { + return $bound; + } + } + return self::read_cookie($pay_appid); + } + + /** + * snsapi_base authorize URL that comes back to the current page. Empty when + * the official account is not configured with the pay AppID. + */ + public static function authorize_url(string $pay_appid): string { + if ($pay_appid === '' || !self::gzh_matches($pay_appid) || self::gzh()['appsecret'] === '') { + return ''; + } + $back = wp_validate_redirect((string) wp_get_referer(), home_url('/')); + $state = wp_generate_password(24, false, false); + set_transient(self::STATE_KEY . $state, ['appid' => $pay_appid, 'back' => $back], self::STATE_TTL); + + return 'https://open.weixin.qq.com/connect/oauth2/authorize?' . http_build_query([ + 'appid' => $pay_appid, + 'redirect_uri' => rest_url('aksrt/v1/payment/wechatpay/openid'), + 'response_type' => 'code', + 'scope' => 'snsapi_base', + 'state' => $state, + ], '', '&', PHP_QUERY_RFC3986) . '#wechat_redirect'; + } + + /** + * GET /payment/wechatpay/openid?code&state — one-time state, then redirect back. + */ + public static function handle_callback(\WP_REST_Request $request) { + $state = preg_replace('/[^A-Za-z0-9]/', '', (string) $request->get_param('state')); + $code = sanitize_text_field((string) $request->get_param('code')); + $saved = $state !== '' ? get_transient(self::STATE_KEY . $state) : false; + if ($state !== '') { + delete_transient(self::STATE_KEY . $state); + } + if (!is_array($saved) || $code === '') { + return new \WP_REST_Response(['message' => __('微信授权已失效,请返回重新支付', 'aksrt')], 400); + } + + $appid = (string) ($saved['appid'] ?? ''); + $back = wp_validate_redirect((string) ($saved['back'] ?? ''), home_url('/')); + $gzh = self::gzh(); + if ($appid === '' || !self::gzh_matches($appid) || $gzh['appsecret'] === '') { + return new \WP_REST_Response(['message' => __('微信支付授权配置无效', 'aksrt')], 400); + } + + $response = wp_remote_get(add_query_arg([ + 'appid' => rawurlencode($appid), + 'secret' => rawurlencode($gzh['appsecret']), + 'code' => rawurlencode($code), + 'grant_type' => 'authorization_code', + ], 'https://api.weixin.qq.com/sns/oauth2/access_token'), ['timeout' => 10]); + $body = is_wp_error($response) ? null : json_decode((string) wp_remote_retrieve_body($response), true); + $openid = is_array($body) ? sanitize_text_field((string) ($body['openid'] ?? '')) : ''; + if ($openid !== '') { + self::write_cookie($appid, $openid); + } + + wp_safe_redirect($back); + exit; + } + + /** @return array{appid:string,appsecret:string} */ + private static function gzh(): array { + $gzh = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_weixingzh_settings() : []; + return [ + 'appid' => trim((string) ($gzh['appid'] ?? '')), + 'appsecret' => trim((string) ($gzh['appsecret'] ?? '')), + ]; + } + + private static function gzh_matches(string $pay_appid): bool { + $appid = self::gzh()['appid']; + return $appid !== '' && hash_equals($appid, $pay_appid); + } + + private static function signature(string $appid, string $openid): string { + return hash_hmac('sha256', $appid . '|' . $openid, wp_salt('auth')); + } + + private static function read_cookie(string $appid): string { + $raw = isset($_COOKIE[self::COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[self::COOKIE])) : ''; + $parts = explode('.', $raw, 2); + if (count($parts) !== 2 || $parts[0] === '') { + return ''; + } + return hash_equals(self::signature($appid, $parts[0]), $parts[1]) ? $parts[0] : ''; + } + + private static function write_cookie(string $appid, string $openid): void { + if (headers_sent()) { + return; + } + setcookie(self::COOKIE, $openid . '.' . self::signature($appid, $openid), [ + 'expires' => time() + self::COOKIE_TTL, + 'path' => COOKIEPATH ?: '/', + 'domain' => COOKIE_DOMAIN ?: '', + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } +} diff --git a/src/Payment/Gateways/WechatPayGateway.php b/src/Payment/Gateways/WechatPayGateway.php index 1cf66174..706801ad 100644 --- a/src/Payment/Gateways/WechatPayGateway.php +++ b/src/Payment/Gateways/WechatPayGateway.php @@ -48,7 +48,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { : $order_number; } $subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt')); - $payment_type = $this->get_config()['payment_type']; + $config = $this->get_config(); try { $total_fen = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? trim($order['amount']) : '')->minor(); } catch (\InvalidArgumentException $e) { @@ -71,39 +71,73 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { ], ]; - switch ($payment_type) { - case 'h5': - $payload['scene_info'] = [ - 'payer_client_ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'), - 'h5_info' => ['type' => 'Wap'], + $in_wechat = WechatOpenid::in_wechat(); + + // Inside WeChat: JSAPI with the payer's official-account openid. + if ($in_wechat && $config['jsapi']) { + $openid = WechatOpenid::resolve($config['appid']); + if ($openid !== '') { + $payload['payer'] = ['openid' => $openid]; + $invoke = YansongdaPayFactory::to_array(Pay::wechat()->mp($payload)); + $params = []; + foreach (['appId', 'timeStamp', 'nonceStr', 'package', 'signType', 'paySign'] as $key) { + $params[$key] = (string) ($invoke[$key] ?? ''); + } + if (in_array('', $params, true)) { + return ['success' => false, 'message' => __('微信支付调起参数生成失败', 'aksrt')]; + } + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_JSAPI, + 'jsapi_params' => $params, + 'message' => __('正在调起微信支付…', 'aksrt'), ]; - $result = Pay::wechat()->h5($payload); - break; - case 'native': - default: - $result = Pay::wechat()->scan($payload); - break; + } + $authorize = WechatOpenid::authorize_url($config['appid']); + if ($authorize !== '') { + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => $authorize, + 'message' => __('正在获取微信授权,返回后请重新支付', 'aksrt'), + ]; + } } - $data = YansongdaPayFactory::to_array($result); - $payment_data = array_merge($data, [ - 'order_number' => $order_number, - 'out_trade_no' => $out_trade_no, - ]); + // Mobile browser outside WeChat: H5 hand-off in the current tab. + if (!$in_wechat && $config['h5'] && wp_is_mobile()) { + $payload['scene_info'] = [ + 'payer_client_ip' => function_exists('aksrt_get_client_ip') + ? aksrt_get_client_ip() + : (string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'), + 'h5_info' => ['type' => 'Wap'], + ]; + $data = YansongdaPayFactory::to_array(Pay::wechat()->h5($payload)); + $h5_url = (string) ($data['h5_url'] ?? ''); + if ($h5_url === '') { + return ['success' => false, 'message' => __('微信 H5 支付链接生成失败', 'aksrt')]; + } + $back = add_query_arg('out_trade_no', $out_trade_no, rest_url('aksrt/v1/payment/wechatpay/return')); + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_REDIRECT, + 'payment_url' => $h5_url . (str_contains($h5_url, '?') ? '&' : '?') . 'redirect_url=' . rawurlencode($back), + 'message' => __('正在跳转微信支付…', 'aksrt'), + ]; + } - $result_data = [ - 'success' => true, - 'payment_data' => $payment_data, - 'message' => __('微信支付订单已创建', 'aksrt'), + // Default: Native on-site QR code. + $data = YansongdaPayFactory::to_array(Pay::wechat()->scan($payload)); + $code_url = (string) ($data['code_url'] ?? ''); + if ($code_url === '') { + return ['success' => false, 'message' => __('微信支付二维码生成失败', 'aksrt')]; + } + return [ + 'success' => true, + 'mode' => \AKSRT\Payment\PaymentOrchestrator::MODE_QRCODE, + 'code_url' => $code_url, + 'message' => __('请使用微信扫码支付', 'aksrt'), ]; - - if ($payment_type === 'native' && !empty($data['code_url'])) { - $result_data['payment_url'] = (string) $data['code_url']; - } elseif ($payment_type === 'h5' && !empty($data['h5_url'])) { - $result_data['payment_url'] = (string) $data['h5_url']; - } - - return $result_data; } catch (\Throwable $e) { return ['success' => false, 'message' => $e->getMessage()]; } @@ -224,10 +258,9 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { try { YansongdaPayFactory::boot_wechat(); - $action = $this->query_action(); + // The default (jsapi) query plugin hits the same out-trade-no endpoint for every pay type. $result = Pay::wechat()->query([ 'out_trade_no' => $out_trade_no, - '_action' => $action, ]); $data = YansongdaPayFactory::to_array($result); @@ -267,7 +300,6 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { YansongdaPayFactory::boot_wechat(); Pay::wechat()->close([ 'out_trade_no' => $out_trade_no, - '_action' => $this->query_action(), ]); } catch (\Throwable $e) { // ignore — new attempt still uses a fresh out_trade_no @@ -291,11 +323,8 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { try { YansongdaPayFactory::boot_wechat(); - $action = $this->query_action(); - $query = Pay::wechat()->query([ 'out_trade_no' => $out_trade_no, - '_action' => $action, ]); $q = YansongdaPayFactory::to_array($query); $total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0); @@ -313,7 +342,6 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { $request_id !== '' ? $request_id : 'REF_' . $order_number ); $result = Pay::wechat()->refund([ - '_action' => 'refund_' . $action, 'out_trade_no' => $out_trade_no, 'out_refund_no' => $refund_request_id, 'reason' => __('用户申请退款', 'aksrt'), @@ -351,7 +379,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { YansongdaPayFactory::boot_wechat(); $result = Pay::wechat()->query([ 'out_refund_no' => $request_id, - '_action' => 'refund_' . $this->query_action(), + '_action' => 'refund', ]); $data = YansongdaPayFactory::to_array($result); $status = strtoupper((string) ($data['status'] ?? '')); @@ -369,35 +397,23 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { } /** - * @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string} + * @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,h5:bool,jsapi:bool} */ private function get_config(): array { $all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $type = sanitize_key((string) ($all['wechat_payment_type'] ?? 'native')); - if (!in_array($type, ['native', 'h5'], true)) { - $type = 'native'; - } return [ - 'mch_id' => (string) ($all['wechat_merchantid'] ?? ''), - 'appid' => (string) ($all['wechat_appid'] ?? ''), - 'api_v3_key' => (string) ($all['wechat_api_v3_key'] ?? ''), - 'private_key' => (string) ($all['wechat_private_key_path'] ?? ''), - 'cert_path' => (string) ($all['wechat_cert_path'] ?? ''), - 'payment_type' => $type, + 'mch_id' => (string) ($all['wechat_merchantid'] ?? ''), + 'appid' => (string) ($all['wechat_appid'] ?? ''), + 'api_v3_key' => (string) ($all['wechat_api_v3_key'] ?? ''), + 'private_key' => (string) ($all['wechat_private_key_path'] ?? ''), + 'cert_path' => (string) ($all['wechat_cert_path'] ?? ''), + 'h5' => !empty($all['wechat_h5']), + 'jsapi' => !empty($all['wechat_jsapi']), ]; } - private function query_action(): string { - $map = [ - 'native' => 'native', - 'h5' => 'h5', - ]; - $type = $this->get_config()['payment_type']; - return $map[$type] ?? 'native'; - } - private function fen_decimal(int $amount): string { return \AKSRT\Ledger\AssetAmount::from_minor( \AKSRT\Ledger\AssetType::BALANCE, diff --git a/src/Payment/Gateways/YansongdaPayFactory.php b/src/Payment/Gateways/YansongdaPayFactory.php index e3be23ce..af318708 100644 --- a/src/Payment/Gateways/YansongdaPayFactory.php +++ b/src/Payment/Gateways/YansongdaPayFactory.php @@ -187,21 +187,6 @@ class YansongdaPayFactory { return ''; } - /** - * HTML body from alipay Response (form auto-submit) when no Location header. - * - * @param mixed $result - */ - public static function extract_html_body($result): string { - if ($result instanceof \Psr\Http\Message\ResponseInterface) { - if ($result->getHeaderLine('Location') !== '') { - return ''; - } - return (string) $result->getBody(); - } - return ''; - } - /** * Emit SDK success()/fail() ResponseInterface (or string) and exit. * diff --git a/src/Payment/PaymentOrchestrator.php b/src/Payment/PaymentOrchestrator.php index 8aa79047..0bbe82e6 100644 --- a/src/Payment/PaymentOrchestrator.php +++ b/src/Payment/PaymentOrchestrator.php @@ -21,17 +21,29 @@ class PaymentOrchestrator { /** Methods settled synchronously in-process (no provider round trip). */ private const SYNC_METHODS = ['balance', 'points']; + /** Cashier modes a provider gateway may return (see cashier_fields()). */ + public const MODE_QRCODE = 'qrcode'; + public const MODE_REDIRECT = 'redirect'; + public const MODE_FORM = 'form'; + public const MODE_JSAPI = 'jsapi'; + /** * @param array $order Order row plus subject/amount overrides. * @param array $opts paid_note: string, order_context: array * + * Provider gateways answer with `mode` (qrcode|redirect|form|jsapi) plus the + * matching code_url / payment_url / form_html / jsapi_params; synchronous + * gateways settle in-process and carry no `mode`. + * * @return array{ * success:bool, * code?:string, * message:string, - * redirect?:bool, + * mode?:string, + * code_url?:string, * payment_url?:string, - * qr_content?:string, + * form_html?:string, + * jsapi_params?:array, * transaction_id?:string, * out_trade_no?:string * } @@ -108,19 +120,18 @@ class PaymentOrchestrator { ); } - $payment_url = (string) ($result['payment_url'] ?? ''); - $qr_content = (string) ($result['payment_data']['code_url'] ?? ''); - if ($payment_url !== '' || $qr_content !== '') { - // Redirect/scan gateways: the order stays pending until the signed + $cashier = self::cashier_fields($result); + if ($cashier !== []) { + // Provider gateways: the order stays pending until the signed // notify/return callback confirms it. Never pre-mark as paid here. - return [ + return array_merge($cashier, [ 'success' => true, - 'redirect' => true, - 'payment_url' => $payment_url, - 'qr_content' => $qr_content, 'out_trade_no' => (string) ($order['out_trade_no'] ?? $order_number), - 'message' => (string) ($result['message'] ?? __('请在新页面完成支付。', 'aksrt')), - ]; + 'message' => (string) ($result['message'] ?? __('请完成支付。', 'aksrt')), + ]); + } + if (!in_array($method, self::SYNC_METHODS, true)) { + return self::fail('aksrt_pay_failed', __('支付网关未返回收银信息。', 'aksrt')); } // Synchronous asset gateways commit the debit and paid transition together. @@ -128,7 +139,6 @@ class PaymentOrchestrator { if (!empty($result['order_committed'])) { return [ 'success' => true, - 'redirect' => false, 'transaction_id' => $transaction_id, 'replayed' => !empty($result['replayed']), 'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')), @@ -149,7 +159,6 @@ class PaymentOrchestrator { return [ 'success' => true, - 'redirect' => false, 'transaction_id' => $transaction_id, 'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')), ]; @@ -177,6 +186,67 @@ class PaymentOrchestrator { return ['gateway' => $gateway]; } + /** + * Cashier keys of a pay() result, for REST builders to pass through verbatim. + * Empty for synchronous settlements, so `!empty(...)` doubles as "awaiting provider". + * + * @param array $pay_result + * @return array + */ + public static function cashier_payload(array $pay_result): array { + if (empty($pay_result['mode'])) { + return []; + } + return array_intersect_key($pay_result, array_flip(['mode', 'code_url', 'payment_url', 'form_html', 'jsapi_params'])); + } + + /** + * Normalize a provider create_payment() result into the cashier contract + * shared by every front-end cashier. Empty when the gateway is synchronous. + * + * @param array $result + * @return array + */ + public static function cashier_fields(array $result): array { + $mode = sanitize_key((string) ($result['mode'] ?? '')); + switch ($mode) { + case self::MODE_QRCODE: + $code_url = (string) ($result['code_url'] ?? ''); + return $code_url === '' ? [] : [ + 'mode' => $mode, + 'code_url' => $code_url, + // Optional mobile deep link shown beside the QR code. + 'payment_url' => (string) ($result['payment_url'] ?? ''), + ]; + case self::MODE_REDIRECT: + $url = (string) ($result['payment_url'] ?? ''); + return $url === '' ? [] : ['mode' => $mode, 'payment_url' => $url]; + case self::MODE_FORM: + $html = (string) ($result['form_html'] ?? ''); + return $html === '' ? [] : ['mode' => $mode, 'form_html' => $html]; + case self::MODE_JSAPI: + $params = $result['jsapi_params'] ?? null; + return is_array($params) && $params !== [] ? ['mode' => $mode, 'jsapi_params' => $params] : []; + } + return []; + } + + /** + * Fields every cashier builder forwards to the browser for a pending provider payment. + * + * @param array $payment_result PaymentOrchestrator::pay() success result. + * @return array + */ + public static function public_cashier(array $payment_result): array { + $out = ['mode' => (string) ($payment_result['mode'] ?? '')]; + foreach (['code_url', 'payment_url', 'form_html', 'jsapi_params'] as $key) { + if (isset($payment_result[$key]) && $payment_result[$key] !== '') { + $out[$key] = $payment_result[$key]; + } + } + return $out; + } + private static function failure_code(string $method, string $message): string { if ($method === 'balance' && (str_contains($message, '余额') || str_contains($message, '不足'))) { return 'aksrt_balance_insufficient'; diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 3f1c6fad..4a4208b2 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -422,8 +422,6 @@ class UserOrders { } if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) { return ['success' => true, 'data' => [ - 'redirect' => false, - 'payment_url' => '', 'order_number' => $order_number, 'order' => $this->get_order_detail_view($user_id, $order_number), 'replayed' => true, @@ -455,14 +453,13 @@ class UserOrders { 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')), ]; } - return ['success' => true, 'data' => [ - 'redirect' => !empty($result['redirect']), - 'payment_url' => (string) ($result['payment_url'] ?? ''), + $cashier = PaymentOrchestrator::cashier_payload($result); + return ['success' => true, 'data' => array_merge($cashier, [ 'order_number' => $order_number, - 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, + 'order' => $cashier === [] ? $this->get_order_detail_view($user_id, $order_number) : null, 'replayed' => !empty($result['replayed']), 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, 'message' => (string) ($result['message'] ?? ''), - ]]; + ])]; } } diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 39a0988b..b5e4a5d0 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -410,7 +410,7 @@ class PaymentRuntime { 'apiurl' => esc_url_raw((string) ($settings['epay_apiurl'] ?? '')), 'pid' => sanitize_text_field((string) ($settings['epay_pid'] ?? '')), 'key' => sanitize_text_field((string) ($settings['epay_key'] ?? '')), - 'device' => sanitize_key((string) ($settings['epay_device'] ?? 'auto')), + 'qrcode' => !array_key_exists('epay_qrcode', $settings) || !empty($settings['epay_qrcode']), ], ]; } diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index a4549e78..f3abde4a 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,10 +1,10 @@ { - "generated_at": "2026-10-26T13:32:51+00:00", + "generated_at": "2026-10-29T05:44:00+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)", "customPage users-wechat-template section 'users/wechat-template' not in PHP schema (ok if pure operational page)", - "enum incomplete parse for 'alipay_payment_type' (php subset of js): php=[page] js=[page,wap]", + "enum incomplete parse for 'alipay_pc_mode' (php subset of js): php=[facepay] js=[facepay,web]", "enum incomplete parse for 'avatar_external' (php subset of js): php=[] js=[,gravatar,weavatar,cravatar,geekzu,loli,sep,v2ex]", "enum incomplete parse for 'backup_frequency' (php subset of js): php=[daily] js=[daily,weekly,monthly]", "enum incomplete parse for 'bind_reminder' (php subset of js): php=[email] js=[email,phone,email_phone,close]", @@ -12,7 +12,6 @@ "enum incomplete parse for 'content_audit_provider' (php subset of js): php=[baidu] js=[baidu,null]", "enum incomplete parse for 'direction' (php subset of js): php=[horizontal] js=[horizontal,vertical]", "enum incomplete parse for 'effect' (php subset of js): php=[slide] js=[slide,fade,cube,coverflow,flip]", - "enum incomplete parse for 'epay_device' (php subset of js): php=[auto] js=[auto,pc,mobile]", "enum incomplete parse for 'font_family' (php subset of js): php=[system] js=[system,noto-sans-sc,noto-serif-sc,custom]", "enum incomplete parse for 'i18n_default_lang' (php subset of js): php=[zh-CN] js=[zh-CN,en,zh-TW,ja,ko,fr,de,es,ru]", "enum incomplete parse for 'i18n_switcher_position' (php subset of js): php=[header] js=[header,footer,both,none]", @@ -58,13 +57,12 @@ "enum incomplete parse for 'user_email_limit_type' (php subset of js): php=[not] js=[not,allow]", "enum incomplete parse for 'user_sign_type' (php subset of js): php=[modal] js=[modal,page]", "enum incomplete parse for 'user_signin_nopas_active' (php subset of js): php=[nopas] js=[nopas,pas,only_nopas]", - "enum incomplete parse for 'wechat_payment_type' (php subset of js): php=[native] js=[native,h5]", "enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]" ], "info": [ "PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport, PostListList", "Menu pages discovered: 61", - "admin build ok; chunks=31" + "admin build ok; chunks=33" ], "domains": { "global": { @@ -260,24 +258,26 @@ "entry": true, "missing_chunks": [], "chunks": [ - "SchemaPage.C79jkx8B.js", + "SchemaPage.BBTFBTmo.js", "vendor-vue.AXlcobq-.js", "FaIcon.Cta3HYOc.js", - "AksMediaField.CiF8KSwp.js", + "useCollapsibleItems.BqcC9pDK.js", + "vendor-draggable.CsbWt1CM.js", "FaIconPicker.DJI1XtMq.js", "shared-api.GLIRMK1p.js", "vendor-misc.DSSPvhm2.js", - "vendor-draggable.CsbWt1CM.js", + "AksColorPicker.JO2GmxwS.js", + "sortable-options.Do4PkQoR.js", "level.E3ANSDAX.js", - "badge.CKTsmH1z.js", + "badge.ClviT45t.js", "vendor-naive.DgdCsaiA.js", "DeferredPage.CDpa6_lq.js", "NativeOpsRedirect.CD8j4uaH.js", - "FloatingButtonsSettings.BB4Ds-Vh.js", + "FloatingButtonsSettings.BI3xnao1.js", "PageHeader.C1t645NL.js", - "FooterTabbarSettings.D7iCVwp2.js", - "PostPageSettings.Cnn7DRgi.js", - "CommentSettings.1g5G6LQL.js", + "FooterTabbarSettings.DcDH4CdV.js", + "PostPageSettings.CIkb2bLS.js", + "CommentSettings.d47RErzk.js", "PermissionsCapabilities.eEUosHCJ.js", "CmsCredits.DUise9Hl.js", "ThemeInfoPage.DuqoCync.js", @@ -285,9 +285,9 @@ "DocsPage.CBhfO0JX.js", "UsersVerify.CWq6m0ZY.js", "UsersLevel.BnvrTSj0.js", - "UsersBadge.CZSYaoC4.js", + "UsersBadge.Cv_yOzWA.js", "UsersWechatTemplate.BGKwGvbY.js", - "UsersTicket.CoFrcj_8.js", + "UsersTicket.B_nhzy9S.js", "ToolsImportExport.CuVI1mrN.js", "ToolsHealth.BT7gJFVm.js", "ToolsUpdate.59iH8s4m.js"