From 81dac3b1547e48d68bf5186a24f06338482710ab Mon Sep 17 00:00:00 2001 From: "COMPUTER\\EDY" <249929363@qq.com> Date: Tue, 29 Sep 2026 16:06:14 +0800 Subject: [PATCH] feat(shop): support layer3 B shop product pricing --- server/laf-cloud/functions/shopConfig.ts | 18 +++++ server/laf-cloud/functions/shopConfig.yaml | 5 ++ server/laf-cloud/functions/wx/KeFuInfo.ts | 5 +- .../laf-cloud/functions/wx/iosorderPaySig.ts | 3 + server/laf-cloud/functions/wx/orderPaySig.ts | 3 + server/laf-cloud/shop-ab.README.md | 50 ++++++++++++++ .../laf-cloud/tests/jungle-treasure.test.mjs | 4 ++ .../laf-cloud/tests/payment-routing.test.mjs | 4 ++ server/laf-cloud/tests/shop-ab.test.cjs | 66 +++++++++++++++++++ 9 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 server/laf-cloud/functions/shopConfig.ts create mode 100644 server/laf-cloud/functions/shopConfig.yaml create mode 100644 server/laf-cloud/shop-ab.README.md create mode 100644 server/laf-cloud/tests/shop-ab.test.cjs diff --git a/server/laf-cloud/functions/shopConfig.ts b/server/laf-cloud/functions/shopConfig.ts new file mode 100644 index 0000000..bcf39d0 --- /dev/null +++ b/server/laf-cloud/functions/shopConfig.ts @@ -0,0 +1,18 @@ +// layer_3 B 商城新商品;旧商品 ID 和订单继续沿用原流程。 +export const NEW_SHOP_PRODUCTS: Record = { + new_gold_1: { price: 600, name: "新版金币包1" }, + new_gold_2: { price: 3000, name: "新版金币包2" }, + new_gold_3: { price: 6800, name: "新版金币包3" }, + new_gold_4: { price: 12800, name: "新版金币包4" }, + new_gold_5: { price: 32800, name: "新版金币包5" }, + new_gold_6: { price: 64800, name: "新版金币包6" }, + new_unlimited_health_bundle_8: { price: 800, name: "新版组合礼包8元" }, + new_unlimited_health_bundle_18: { price: 1800, name: "新版组合礼包18元" }, + new_unlimited_health_bundle_36: { price: 3600, name: "新版组合礼包36元" }, + new_unlimited_health_bundle_68: { price: 6800, name: "新版组合礼包68元" }, + new_unlimited_health_bundle_98: { price: 9800, name: "新版组合礼包98元" }, +}; + +export function getNewShopProduct(id: string) { + return Object.prototype.hasOwnProperty.call(NEW_SHOP_PRODUCTS, id) ? NEW_SHOP_PRODUCTS[id] : null; +} diff --git a/server/laf-cloud/functions/shopConfig.yaml b/server/laf-cloud/functions/shopConfig.yaml new file mode 100644 index 0000000..fd8b63b --- /dev/null +++ b/server/laf-cloud/functions/shopConfig.yaml @@ -0,0 +1,5 @@ +name: shopConfig +desc: "layer_3 B商城商品价格和客服名称" +methods: + - POST +tags: [] diff --git a/server/laf-cloud/functions/wx/KeFuInfo.ts b/server/laf-cloud/functions/wx/KeFuInfo.ts index baa6d9c..323398e 100644 --- a/server/laf-cloud/functions/wx/KeFuInfo.ts +++ b/server/laf-cloud/functions/wx/KeFuInfo.ts @@ -1,6 +1,7 @@ import * as StarterPackModule from "@/limitedTimeEvent"; const StarterPack: any = StarterPackModule as any; import cloud from '@lafjs/cloud' +import { getNewShopProduct } from '@/shopConfig'; import { passOrderFields, PASS_PRICES } from '@/passCheckUpgrade'; // const token = "nika123"; // const EncodingAESKey = "XcdOFaZOQjmyBqYHxWAITtrmq11YaZQ63DeEZAAgYJt"; @@ -153,6 +154,8 @@ async function PostMsg(OpenId, msg) { return "ok" } async function PostImg(OpenId, outTradeNo, name, count, price, passInput: any = {}) { + const shopProduct = getNewShopProduct(name); + if (shopProduct) { count = 1; price = shopProduct.price; } const rebornPrice = { reborn_Gift: 600, reborn_Gift_2: 800, reborn_Gift_3: 1000, reborn_Gift_4: 1200, reborn_Gift_5: 1500 }; if (/^reborn_Gift(?:_[2-5])?$/.test(name)) { count = 1; @@ -187,7 +190,7 @@ async function PostImg(OpenId, outTradeNo, name, count, price, passInput: any = let user = await db.collection("users").where({ openid: OpenId }).update({ iosTime: frontBody.timeStamp }); let param = { - name: propName[name], + name: shopProduct ? shopProduct.name : propName[name], openid: OpenId, outTradeNo: outTradeNo, time: timestamp, diff --git a/server/laf-cloud/functions/wx/iosorderPaySig.ts b/server/laf-cloud/functions/wx/iosorderPaySig.ts index 989336d..d334fdd 100644 --- a/server/laf-cloud/functions/wx/iosorderPaySig.ts +++ b/server/laf-cloud/functions/wx/iosorderPaySig.ts @@ -1,6 +1,7 @@ import * as StarterPackModule from "@/limitedTimeEvent"; const StarterPack: any = StarterPackModule as any; import cloud from '@lafjs/cloud' +import { getNewShopProduct } from '@/shopConfig'; import { passOrderFields, PASS_PRICES } from '@/passCheckUpgrade'; import * as crypto from 'crypto'; import * as JungleConfigModule from "@/jungleConfig"; @@ -17,6 +18,8 @@ export default async function (ctx: FunctionContext) { let itemid = ctx.body.itemid; let itemCount = ctx.body.itemCount; let itemPrice = ctx.body.itemPrice; + const shopProduct = getNewShopProduct(itemid); + if (shopProduct) { itemCount = 1; itemPrice = shopProduct.price; } const rebornPrice = { reborn_Gift: 600, reborn_Gift_2: 800, reborn_Gift_3: 1000, reborn_Gift_4: 1200, reborn_Gift_5: 1500 }; if (/^reborn_Gift(?:_[2-5])?$/.test(itemid)) { itemCount = 1; diff --git a/server/laf-cloud/functions/wx/orderPaySig.ts b/server/laf-cloud/functions/wx/orderPaySig.ts index b056a6a..6d73841 100644 --- a/server/laf-cloud/functions/wx/orderPaySig.ts +++ b/server/laf-cloud/functions/wx/orderPaySig.ts @@ -1,6 +1,7 @@ import * as StarterPackModule from "@/limitedTimeEvent"; const StarterPack: any = StarterPackModule as any; import cloud from '@lafjs/cloud' +import { getNewShopProduct } from '@/shopConfig'; import { passOrderFields, PASS_PRICES } from '@/passCheckUpgrade'; import * as crypto from 'crypto'; import * as JungleConfigModule from "@/jungleConfig"; @@ -17,6 +18,8 @@ export default async function (ctx: FunctionContext) { let itemid = ctx.body.itemid; let itemCount = ctx.body.itemCount; let itemPrice = ctx.body.itemPrice; + const shopProduct = getNewShopProduct(itemid); + if (shopProduct) { itemCount = 1; itemPrice = shopProduct.price; } const rebornPrice = { reborn_Gift: 600, reborn_Gift_2: 800, reborn_Gift_3: 1000, reborn_Gift_4: 1200, reborn_Gift_5: 1500 }; if (/^reborn_Gift(?:_[2-5])?$/.test(itemid)) { itemCount = 1; diff --git a/server/laf-cloud/shop-ab.README.md b/server/laf-cloud/shop-ab.README.md new file mode 100644 index 0000000..42ea0a6 --- /dev/null +++ b/server/laf-cloud/shop-ab.README.md @@ -0,0 +1,50 @@ +# layer_3 商城 A/B 接入 + +客户端读取登录返回的 layer_3:A 使用 shop,B 使用 shop2;无有效 B 分组时使用 A。首页和关卡共用同一选择规则,临时全部首充双倍的预览开关已关闭。月卡、战令、新手礼包的商品与奖励不随商城分组改变。 + +## B 商品配置 + +金额单位为元;客户端、服务端下单金额使用分。 + +| 商品 ID | 价格 | 金币 | 锤子 / 冻结 / 魔法棒 | 无限体力 | +| --- | ---: | ---: | --- | --- | +| new_gold_1 | 6 | 1200 | — | — | +| new_gold_2 | 30 | 6800 | — | — | +| new_gold_3 | 68 | 16000 | — | — | +| new_gold_4 | 128 | 32000 | — | — | +| new_gold_5 | 328 | 84000 | — | — | +| new_gold_6 | 648 | 168000 | — | — | +| new_unlimited_health_bundle_8 | 8 | 1500 | 2 / 2 / 2 | 无 | +| new_unlimited_health_bundle_18 | 18 | 3000 | 5 / 5 / 5 | 30 分钟 | +| new_unlimited_health_bundle_36 | 36 | 6000 | 10 / 10 / 10 | 2 小时 | +| new_unlimited_health_bundle_68 | 68 | 10000 | 20 / 20 / 20 | 6 小时 | +| new_unlimited_health_bundle_98 | 98 | 20000 | 25 / 25 / 25 | 12 小时 | + +new_gold_1~6 共用原 gold_1~6 的首充记录,不重置资格。礼包没有金币首充翻倍。补单按原订单商品 ID 发奖,不按补单时的 A/B 分组切换奖励。 + +## 发布依赖 + +本次只修改本地文件,未部署、未修改微信商品后台。 + +1. 先发布公共函数模块 `shopConfig`(包含 YAML),再发布引用它的 `wx/orderPaySig`、`wx/iosorderPaySig`、`wx/KeFuInfo`。 +2. 核对微信商品后台是否已配置上述 11 个新 ID 及对应价格。旧商品保留以支持 A 组和旧订单。 +3. 发布包含 ShopCatalog、shop2 和 A/B 加载修改的客户端。不要先给仍不识别新商品的旧客户端开放新商城订单。 +4. 微信真机分别验证 A/B 首页与关卡入口、首充已用/未用、8 元无无限体力、98 元加 12 小时、支付返回与重登补单。真机支付本次未执行。 + +服务端固定新商品的价格和数量 1;旧商品保持原路径。客户端新商品使用订单号做本地去重,支付轮询固定对应订单号,不依赖其他窗口可能改写的全局订单号。此机制不等于跨设备的服务端奖励账本。 + +## 本地验证 + +在仓库根目录运行(Node 24,已安装项目 TypeScript): + +```text +node --test tools/test-shop-ab.cjs server/laf-cloud/tests/shop-ab.test.cjs tools/test-starter-pack.cjs tools/test-shop-activity-carousel.cjs tools/test-monthly-card-lifecycle.cjs +``` + +覆盖商城路由、缓存复用、节点切换、全部新商品数值、首充共用、补单去重和安卓/iOS/客服支付金额。支付接口测试均使用桩,不调用真实支付。 + +扩大检查时发现以下既有问题,未纳入本次商城修改: + +- `tools/test-reborn-gift.cjs` 的未支付订单断言失败:`wx/getOrderReward.ts` 在校验前更新订单状态。 +- `jungle-treasure.test.mjs` 有 6 项配置/活动期/订单状态相关失败;`payment-routing.test.mjs` 有 1 项失败,测试期望固定转发 URL,但当前实现读取 `TEST_WX_PAY_NOTIFY_URL` 环境变量。 +- 全项目 `tsc --noEmit` 被 `functions/drawHead.ts:35` 的 TS1128 语法错误阻断。本次修改的 TypeScript 文件单独转译无语法诊断。 diff --git a/server/laf-cloud/tests/jungle-treasure.test.mjs b/server/laf-cloud/tests/jungle-treasure.test.mjs index cfee225..ef124bf 100644 --- a/server/laf-cloud/tests/jungle-treasure.test.mjs +++ b/server/laf-cloud/tests/jungle-treasure.test.mjs @@ -75,10 +75,14 @@ globalThis.require = () => ({ }); registerHooks({ + load(url, context, next) { + return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); + }, resolve(specifier, context, nextResolve) { const aliases = { "@/Utils": "data:text/javascript,export default globalThis.__jungleUtilsMock", "@/jungleConfig": configUrl.href, + "@/shopConfig": new URL("../functions/shopConfig.ts", import.meta.url).href, "@/limitedTimeEvent": new URL("../functions/limitedTimeEvent.ts", import.meta.url).href, "@/passCheckUpgrade": new URL("../functions/passCheckUpgrade.ts", import.meta.url).href, }; diff --git a/server/laf-cloud/tests/payment-routing.test.mjs b/server/laf-cloud/tests/payment-routing.test.mjs index 31c16b8..59f38d2 100644 --- a/server/laf-cloud/tests/payment-routing.test.mjs +++ b/server/laf-cloud/tests/payment-routing.test.mjs @@ -28,11 +28,15 @@ globalThis.__routingCloud = { }; globalThis.require = () => ({ initWithBatchMode: () => ({ track() { }, trackFirst() { }, close() { } }) }); registerHooks({ + load(url, context, next) { + return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); + }, resolve(specifier, context, next) { if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__routingCloud', shortCircuit: true }; if (specifier === '@/limitedTimeEvent') return { url: new URL('../functions/limitedTimeEvent.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/passCheckUpgrade') return { url: new URL('../functions/passCheckUpgrade.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/Utils') return { url: new URL('../functions/Utils.ts', import.meta.url).href, shortCircuit: true }; + if (specifier === '@/shopConfig') return { url: new URL('../functions/shopConfig.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/jungleConfig') return { url: new URL('../functions/jungleConfig.ts', import.meta.url).href, shortCircuit: true }; return next(specifier, context); } diff --git a/server/laf-cloud/tests/shop-ab.test.cjs b/server/laf-cloud/tests/shop-ab.test.cjs new file mode 100644 index 0000000..24622ee --- /dev/null +++ b/server/laf-cloud/tests/shop-ab.test.cjs @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +function load(file, deps = {}, globals = {}) { + const exports = {}; + const source = fs.readFileSync(path.resolve(__dirname, '../functions', file), 'utf8'); + const code = ts.transpileModule(source, { compilerOptions: { target:ts.ScriptTarget.ES2020, module:ts.ModuleKind.CommonJS } }).outputText; + vm.runInNewContext(code, { exports, require:name=>{ + if (!(name in deps)) throw new Error('Missing test dependency: '+name); + return deps[name]; + }, Date, ...globals }); + return exports; +} +const catalog = load('shopConfig.ts'); +const ids = [1,2,3,4,5,6].map(i=>'new_gold_'+i).concat([8,18,36,68,98].map(i=>'new_unlimited_health_bundle_'+i)); +const prices = [600,3000,6800,12800,32800,64800,800,1800,3600,6800,9800]; +function fixture() { + const state={orders:[],requests:[],user:{_id:'u',openid:'o',session_key:'session'}}; + const db={collection:()=>({add:async row=>{state.orders.push(row);return {};},where:()=>({getOne:async()=>({data:state.user}),update:async()=>({})})})}; + const cloud={database:()=>db,shared:{get:()=> 'token'},storage:{bucket:()=>({readFile:async()=>({Body:{transformToString:async()=> 'synthetic-key'}})})}}; + const deps={ + '@lafjs/cloud':{default:cloud}, '@/shopConfig':catalog, + '@/limitedTimeEvent':{starterPackOrderSnapshot:()=>({})}, + '@/passCheckUpgrade':{passOrderFields:()=>({}),PASS_PRICES:{}}, + '@/jungleConfig':{getJunglePaidTierByProductId:()=>null}, + crypto:{...crypto,default:{...crypto,createSign:()=>({update(){},sign:()=> 'synthetic-signature'})}} + }; + const globals={console:{log(){},error(){}},process:{env:{PAYMENT_APP_ENV:'test',WX_MIDAS_PAY_SIGN_KEY:'synthetic-key'}}, + fetch:async(url,options)=>{state.requests.push({url,body:JSON.parse(options.body)});return {ok:true,json:async()=>({prepay_id:'synthetic-prepay',errcode:0})};}}; + return {state,load:file=>load(file,deps,globals).default}; +} + +test('both signing endpoints enforce all eleven B prices and quantity one', async()=>{ + for(const file of ['wx/orderPaySig.ts','wx/iosorderPaySig.ts']) { + const f=fixture(), create=f.load(file); + for(const [i,id] of ids.entries()) { + const result=await create({body:{uid:'u',itemid:id,itemCount:99,itemPrice:1}}); + assert.equal(result.code,1); + const signed=JSON.parse(result.data.signData), row=f.state.orders[i]; + assert.equal(row.itemid,id);assert.equal(row.itemCount,1);assert.equal(row.goodsPrice,prices[i]); + assert.equal(signed.productId,id);assert.equal(signed.buyQuantity,1);assert.equal(signed.goodsPrice,prices[i]); + assert.equal(result.data.paySig,crypto.createHmac('sha256','synthetic-key').update('requestMidasPaymentGameItem&'+result.data.signData).digest('hex')); + } + const legacy=await create({body:{uid:'u',itemid:'gold_2',itemCount:1,itemPrice:3600}}); + assert.equal(legacy.code,1);assert.equal(f.state.orders.at(-1).goodsPrice,3600); + } +}); + +test('legacy iOS cashier uses all B prices, display names and order IDs',async()=>{ + const f=fixture(), create=f.load('wx/KeFuInfo.ts'); + for(const [i,id] of ids.entries()) { + await create({body:{FromUserName:'o',SessionFrom:JSON.stringify({tpye:'ios',propName:id,price:1,count:99,outTradeNo:'ios-'+i})}}); + const row=f.state.orders[i];assert.ok(row,'customer-service request must create order'); + assert.equal(row.goodsPrice,prices[i]);assert.equal(row.itemCount,1);assert.equal(row.itemid,id); + const payments=f.state.requests.filter(r=>r.url.includes('/v3/pay/transactions/jsapi')); + assert.equal(payments[i].body.amount.total,prices[i]); + const link=f.state.requests.filter(r=>r.body.msgtype==='link')[i].body.link; + const data=JSON.parse(decodeURIComponent(link.url.split('#data=')[1])); + assert.equal(data.name,catalog.getNewShopProduct(id).name);assert.equal(data.price,prices[i]);assert.equal(data.outTradeNo,'ios-'+i); + } +});