feat(shop): support layer3 B shop product pricing
This commit is contained in:
parent
79cd1fa36d
commit
81dac3b154
18
server/laf-cloud/functions/shopConfig.ts
Normal file
18
server/laf-cloud/functions/shopConfig.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// layer_3 B 商城新商品;旧商品 ID 和订单继续沿用原流程。
|
||||
export const NEW_SHOP_PRODUCTS: Record<string, { price: number; name: string }> = {
|
||||
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;
|
||||
}
|
||||
5
server/laf-cloud/functions/shopConfig.yaml
Normal file
5
server/laf-cloud/functions/shopConfig.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name: shopConfig
|
||||
desc: "layer_3 B商城商品价格和客服名称"
|
||||
methods:
|
||||
- POST
|
||||
tags: []
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
50
server/laf-cloud/shop-ab.README.md
Normal file
50
server/laf-cloud/shop-ab.README.md
Normal file
|
|
@ -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 文件单独转译无语法诊断。
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
66
server/laf-cloud/tests/shop-ab.test.cjs
Normal file
66
server/laf-cloud/tests/shop-ab.test.cjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user