/** * 卡密兑换渠道契约(三个购买弹窗 · 1:1 对齐参考主题)。 * * 背景:余额卡片顶部的独立「卡密兑换」通用弹窗已删除,兑换入口收敛到 * 三个购买弹窗的支付方式中(对齐参考主题 zibpay_get_payment_methods + * zibpay_is_allow_card_pass_pay 过滤器): * 1) 余额充值弹窗(credits-wallet.php,服务端渲染) * 2) 购买积分弹窗(credits-balance.php,服务端渲染,Zibll points-pay modal 结构) * 3) 会员购买/续费弹窗(VipServiceModal JSON 视图模型 + pay-vip.js 渲染) * * 后台语义:余额充值 → 卡密兑换 → 三个独立开关 * 启用卡密兑换余额功能 / 启用卡密兑换积分功能 / 启用卡密兑换会员功能 * 任一开关关闭后,对应前台弹窗不出现卡密兑换渠道收款接口。 * * REST 契约:POST /pay/card-pass/redeem,type 必填(balance/vip/credits), * 不再有「空类型按卡密自动识别」;核销订单金额恒为 0。 */ const test = require('node:test'); const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..', '..'); const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8'); const wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php'); const creditsRenderer = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php'); const creditsJs = read('assets', 'js', 'credits.js'); const payVipJs = read('assets', 'js', 'pay-vip.js'); const chargeCard = read('src', 'Payment', 'ChargeCard.php'); const userOrders = read('src', 'Payment', 'UserOrders.php'); const vipPackages = read('src', 'Content', 'VipServicePackages.php'); const aksrtPay = read('inc', 'helpers', 'aksrt-pay.php'); const mainCss = read('assets', 'css', 'main.css'); const vipPurchase = read('src', 'Content', 'VipServicePurchase.php'); const vipModal = read('src', 'Content', 'VipServiceModal.php'); const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php'); const schemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js'); const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js'); test('独立入口与通用弹窗已删除', () => { assert.equal( fs.existsSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'credits-vip.php')), false, 'credits-vip.php 死文件应已删除' ); // 通用卡密弹窗与 initCardPass 不回潮。 assert.doesNotMatch(wallet, /data-aksrt-card-pass-modal|data-aksrt-card-redeem/); assert.doesNotMatch(creditsJs, /function initCardPass\(|initCardPass\(\)/); assert.doesNotMatch(creditsRenderer, /data-aksrt-card-pass-modal|data-aksrt-card-redeem/); // 卡密 logo 就位(三个弹窗共用)。 assert.equal(fs.existsSync(path.join(root, 'assets', 'img', 'pay-card-pass-logo.svg')), true); }); test('余额充值弹窗:卡密渠道为支付方式之一,卡号/卡密输入按单密码模式渲染', () => { assert.match(wallet, /'card_pass'\s+=>\s+'pay-card-pass-logo\.svg'/); // 支付方式列表注入 card_pass(由 get_charge_view / get_public_options['balance'] 驱动)。 assert.match(wallet, /card_pass_redeem\['desc'\]/); assert.match(wallet, /card_pass_redeem\['only_password'\]/); // 卡密输入盒契约与参考主题一致。 assert.match(wallet, /data-controller="payment_method" data-condition="==" data-value="card_pass"/); assert.match(wallet, /name="card_pass\[card\]"/); assert.match(wallet, /name="card_pass\[password\]"/); // 仅卡密可用时隐藏金额选择(参考主题语义)。 assert.match(wallet, /\$charge_card_only/); assert.match(wallet, /\$charge_card_in_methods/); }); test('购买积分弹窗:Zibll points-pay modal 结构 + 卡密渠道', () => { // 商品/自定义选择 + 支付方式 + hidden + 立即支付。 assert.match(creditsRenderer, /data-aksrt-points-pay-modal/); assert.match(creditsRenderer, /data-aksrt-points-pay-form/); assert.match(creditsRenderer, /data-aksrt-points-pay-open/); assert.doesNotMatch(creditsRenderer, /data-aksrt-points-exchange-(open|modal|form|amount|preview|close)/); assert.match(creditsRenderer, /请选择需购买的积分|请输入需购买的积分数额/); assert.match(creditsRenderer, /name="points_product"/); assert.match(creditsRenderer, /name="points_custom"/); assert.match(creditsRenderer, /name="payment_method"/); assert.match(creditsRenderer, /立即支付/); // 卡密渠道受「启用卡密兑换积分功能」控制。 assert.match(creditsRenderer, /points_card_pass\['enabled'\]/); assert.match(creditsRenderer, /data-controller="payment_method" data-condition="==" data-value="card_pass"/); assert.match(creditsRenderer, /name="card_pass\[password\]"/); }); test('购买积分前端:商品/支付方式选中模型 + 余额/卡密提交分流', () => { assert.match(creditsJs, /function initPointsPay\(\)/); assert.match(creditsJs, /data-aksrt-points-pay-open/); assert.match(creditsJs, /\[data-for="points_product"\]/); // 卡密选中:隐藏 .charge-box、显示卡密输入盒(契约与参考主题一致)。 assert.match(creditsJs, /data-controller="payment_method"\]\[data-value="card_pass"/); assert.match(creditsJs, /value === 'card_pass' \? 'none' : ''/); // 余额支付 → /pay/balance/points-exchange(product + custom 契约)。 assert.match(creditsJs, /\/pay\/balance\/points-exchange/); assert.match(creditsJs, /JSON\.stringify\(\{ product: pkg, custom: validCustom \? custom : '0' \}\)/); // 卡密支付 → /pay/card-pass/redeem type=credits。 assert.match(creditsJs, /redeemCardPass\('credits'/); // bootAssets 挂载新函数,无死调用。 assert.match(creditsJs, /function bootAssets\(\)[\s\S]*?initPointsPay\(\);/); }); test('会员弹窗:卡密渠道注入与直兑分流(不创建支付订单)', () => { // 后端:available_payment_methods 追加 card_pass + 视图模型透传。 assert.match(vipPackages, /'id' => 'card_pass', 'name' => __\('卡密', 'aksrt'\)/); assert.match(vipPackages, /function vip_card_pass_context\(\)/); assert.match(vipPackages, /vip_card_pass_context\(\)/); assert.match(vipModal, /'card_pass' => \$card_pass/); assert.match(vipModal, /'card_pass' => \$card_pass,/); // 支付方式组件与其他两个弹窗同源:logo URL 服务端映射 + 余额/货币符号透传。 assert.match(vipModal, /function payment_method_logo_url\(string \$id\): string/); assert.match(vipModal, /'card_pass'\s+=>\s+'pay-card-pass-logo\.svg'/); assert.match(vipModal, /'wallet_balance' => \(string\) \(\$catalog\['wallet_balance'\]/); assert.match(vipModal, /'currency_mark' =>/); // 后端防御:purchase() 拒绝 card_pass(卡密直兑走 redeem)。 assert.match(vipPurchase, /aksrt_card_pass_redeem_required/); // 前端:Zibll initiate_pay_input 同构组件——payment-method-radio logo 行、 // 我的余额盒(data-controller == balance)、卡密盒(password_box 类结构)、 // hidden payment_method、initiate-pay 按钮。 assert.match(payVipJs, /payment-method-radio hollow-radio flex-auto pointer/); assert.match(payVipJs, /item\.setAttribute\("data-for", "payment_method"\)/); assert.match(payVipJs, /mb10 muted-box padding-h10/); assert.match(payVipJs, /"data-condition", "=="/); assert.match(payVipJs, /"data-value", "balance"/); assert.match(payVipJs, /mb10 padding-h10 padding-w6/); assert.match(payVipJs, /"data-value", "card_pass"/); assert.match(payVipJs, /hidden\.name = "payment_method"/); assert.match(payVipJs, /mt6 but jb-red initiate-pay btn-block radius/); assert.doesNotMatch(payVipJs, /uc-vip-shop__method|aksrt_vip_pay_method|aksrt-vip-submit/); // 按钮字样对齐参考主题:会员弹窗即 initiate_pay_input(4) 默认「立即支付」; // 无可用支付方式渲染 Zibll 同款 badg 提示(而非禁用按钮/自实现文案)。 assert.doesNotMatch(payVipJs, /cta_label/); assert.match(payVipJs, /canPurchase \? "立即支付"/); assert.match(payVipJs, /badg px12 c-yellow-2/, "暂时无法支付,请与客服联系"); // 渠道展示名对齐参考主题 zibpay_get_payment_method_args(余额/微信/支付宝)。 assert.match(aksrtPay, /'balance'\s+=> __\('余额', 'aksrt'\)/); assert.match(aksrtPay, /'wechatpay'\s+=> __\('微信', 'aksrt'\)/); // 显隐:通用 data-controller(==/!=)匹配 + 卡密选中隐藏套餐。 assert.match(payVipJs, /function applyVipMethodVisibility\(/); assert.match(payVipJs, /querySelectorAll\('\[data-controller="payment_method"\]'\)/); assert.match(payVipJs, /querySelectorAll\("\.charge-box"\)/); // 提交分流:卡密直兑 /pay/card-pass/redeem type=vip。 assert.match(payVipJs, /function redeemVipCardPass\(/); assert.match(payVipJs, /\/pay\/card-pass\/redeem/); assert.match(payVipJs, /type: "vip"/); assert.match(payVipJs, /view\.card_pass && view\.card_pass\.enabled/); }); test('REST 契约:type 必填(balance/vip/credits),无空类型自动识别', () => { assert.match(chargeCard, /'type' => \['type' => 'string', 'required' => true, 'enum' => \[self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS\]\]/); assert.match(chargeCard, /卡密兑换类型无效/); assert.match(chargeCard, /REDEEMABLE_TYPES = \[self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS\]/); // 兼容层:redeem_for_user 仍保留 expected_type 形参(前端恒传明确类型)。 assert.match(chargeCard, /function redeem_for_user\(int \$user_id, string \$password, string \$card = '', string \$expected_type = ''/); }); test('后端类型闸门:三个独立开关各约束自己的类型', () => { assert.match(chargeCard, /'balance' => \['enabled' => false, 'desc' => '', 'only_password' => false\]/); assert.match(chargeCard, /'credits' => \['enabled' => false, 'desc' => '', 'only_password' => false\]/); assert.match(chargeCard, /'vip'\s+=> \['enabled' => false, 'desc' => '', 'only_password' => false\]/); // 会员单密码默认关闭(对齐参考主题 default false):未显式开启时卡密盒显示卡号+密码。 assert.doesNotMatch(chargeCard, /'only_password' => true/); assert.match(read('src', 'Settings', 'PaymentRuntime.php'), /'only_password' => !empty\(\$settings\['pay_vip_pass_charge_only_password'\]\),/); assert.doesNotMatch(read('src', 'Settings', 'PaymentRuntime.php'), /!isset\(\$settings\['pay_vip_pass_charge_only_password'\]\)/); assert.match(chargeCard, /卡密兑换余额功能未开启/); assert.match(chargeCard, /卡密兑换积分功能未开启/); assert.match(chargeCard, /卡密兑换会员功能未开启/); assert.match(chargeCard, /积分系统未开启/); // 收款接口 card_pass 主开关:三开关任一开启即生效。 const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php'); assert.match(paymentRuntime, /\$card_pass_enabled = !empty\(\$settings\['pay_balance_pass_charge_s'\]\)[\s\S]*?\|\| !empty\(\$settings\['pay_vip_pass_charge_s'\]\);/); }); test('后台 schema:卡密兑换拆分为三个独立开关', () => { for (const [name, src] of [['SchemaPayment.php', schemaPhp], ['recharge.js', schemaJs]]) { assert.match(src, /启用卡密兑换余额功能/, name); assert.match(src, /启用卡密兑换积分功能/, name); assert.match(src, /启用卡密兑换会员功能/, name); assert.match(src, /points_pass_exchange_s/, name); assert.match(src, /points_pass_exchange_desc/, name); assert.match(src, /points_pass_exchange_only_password/, name); assert.match(src, /pay_vip_pass_charge_desc/, name); assert.match(src, /pay_vip_pass_charge_only_password/, name); // 旧总开关语义不回潮。 assert.doesNotMatch(src, /启用卡密兑换功能'|启用卡密兑换功能"/, name); } // recharge.js defaults 补齐三开关字段。 assert.match(schemaJs, /points_pass_exchange_s: false/); assert.match(schemaJs, /pay_vip_pass_charge_s: false/); // vip schema 的「会员卡密」组迁移至 recharge 卡密兑换组,原组删除。 assert.doesNotMatch(vipSchemaJs, /title: '会员卡密'/); assert.doesNotMatch(vipSchemaJs, /pay_vip_pass_charge_only_password/); const schemaPaymentVip = schemaPhp; assert.doesNotMatch(schemaPaymentVip, /title' => '会员卡密'/); }); test('核销订单契约:金额恒为 0,订单名称按 note 生成', () => { assert.match(chargeCard, /订单金额恒为 0/); assert.match(userOrders, /if \(\$payment_method === 'card_pass'\)\s*\{\s*\$amount = \$is_points \? '0' : '0\.00';/); assert.match(userOrders, /if \(!empty\(\$meta\['card_pass'\]\)\)\s*\{[\s\S]*?resolve_card_pass_title\(\$meta\)/); assert.match(userOrders, /卡密兑换-余额充值/); assert.match(userOrders, /卡密兑换-积分充值/); assert.match(userOrders, /购入\[%1\$s\]%2\$s/); assert.match(userOrders, /永久/); }); test('多渠道防挤压:图标 flex:none + 行不换行项目收缩成芯片(对齐 Zibll)', () => { // 图标禁用 flex 压缩并抵消 AKSRT bootstrap 全局 img{max-width:100%} // (flex 压缩时把图标钳制到被压窄的项宽,实测 5 渠道 400px 弹窗图标 // 被压成 16px 细条)。行保持 Zibll 原样不换行:空间不足时项目收缩为 // 「图标上/文字下」芯片(实测 5 渠道一行 5 个、6 渠道一行 6 个,图标 // 恒为固有比例),绝不允许整行提前断行。 const rule = mainCss.match(/\.payment-method-radio img \{[\s\S]*?\n\}/); assert.ok(rule, 'main.css 应存在 .payment-method-radio img 规则块'); assert.match(rule[0], /height:\s*35px;/); assert.match(rule[0], /width:\s*auto;/); assert.match(rule[0], /max-width:\s*none;/); assert.match(rule[0], /flex:\s*none;/); // 方法行标记与参考主题逐字一致(.flex.mb10,无 hh)。 assert.match(wallet, /class="flex mb10">/); assert.match(creditsRenderer, /class="flex mb10">/); assert.match(payVipJs, /node\("div", "flex mb10"\)/); assert.doesNotMatch(payVipJs, /"flex hh mb10"/); }); test('渠道名称禁折行(三字渠道名对芯片宽度零余量,站点字体差异即触发换行)', () => { assert.match(mainCss, /\.payment-method-radio > div \{[^}]*white-space:\s*nowrap;/s); });