fix: disable starter pack server purchase guard

This commit is contained in:
COMPUTER\EDY 2026-09-30 22:01:37 +08:00
parent b8e02e08d0
commit 038743f3f2
14 changed files with 88 additions and 144 deletions

View File

@ -185,21 +185,10 @@ export function starterPackRound(user: any): number {
: Number(user?.starter_pack) > 0 || Number(user?.starter_packState) === 1 ? 1 : 0;
}
export function starterPackOrderSnapshot(user: any, itemid: string) {
export function starterPackOrderMetadata(itemid: string) {
if (!/^starter_pack(?:_[2-5])?$/.test(itemid)) return {};
if (!user || !Number.isFinite(Number(user.levelAmount)) || Number(user.levelAmount) < 15
|| Number(user.starter_packState) === 1 || !(Number(user.starter_pack) > Date.now())) {
throw new Error('本轮礼包未开放或已购买');
}
const offer = user.starterPackOffer ?? starterPackPatch(user, Date.now())?.starterPackOffer;
if (!offer || offer.productId !== itemid || offer.expiresAt !== Number(user.starter_pack)) {
throw new Error('礼包档位已更新,请重新打开礼包');
}
return {
starterPackOffer: offer,
starterPackBuyRound: Math.max(1, starterPackRound(user)),
starterPackExpiresAt: Math.max(0, Number(user?.starter_pack) || 0),
rewardVersion: offer.rewardVersion,
rewardVersion: STARTER_REWARD_VERSION,
activityType: 'lucky',
};
}

View File

@ -484,16 +484,7 @@ async function getIosOrder(openid, ctx) {
case "starter_pack_3":
case "starter_pack_4":
case "starter_pack_5": {
const order = res.data[i], user = res1.data;
const legacyCycle = (!user.starterPackOffer || user.starterPackOffer.legacy === true) && Number(order.time) >= Number(user.starter_pack) - 2 * 86400000
&& Number(order.time) <= Number(user.starter_pack);
const expiry = order.starterPackExpiresAt ?? (legacyCycle ? user.starter_pack : null);
if (expiry != null && Number(expiry) === Number(user.starter_pack)
&& (order.starterPackBuyRound == null || Number(order.starterPackBuyRound) === (Number(user.starterPackRound) || 1))) {
await db.collection("users").where({ _id: user._id, starter_pack: user.starter_pack,
starterPackRound: user.starterPackRound ?? null }).update({ starter_packState: 1 });
}
await db.collection("users").where({ _id: user._id }).update({ pay_user: true });
await db.collection("users").where({ _id: res1.data._id }).update({ starter_packState: 1, pay_user: true });
break;
}
default:

View File

@ -186,13 +186,11 @@ async function PostImg(OpenId, outTradeNo, name, count, price, passInput: any =
if (!token) {
return { code: 0, data: null, msg: "token获取失败" };
}
const giftUser = /^starter_pack(?:_[2-5])?$/.test(name) ? (await db.collection("users").where({ openid: OpenId }).getOne()).data : null;
let giftSnapshot: any = {};
try {
if (String(name).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(name)) throw new Error("无效的礼包商品");
giftSnapshot = StarterPack.starterPackOrderSnapshot(giftUser, name);
if (giftSnapshot.starterPackOffer) { count = 1; price = giftSnapshot.starterPackOffer.priceFen; }
} catch (error) { await PostMsg(OpenId, error.message); return; }
if (String(name).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(name)) {
await PostMsg(OpenId, "无效的礼包商品");
return;
}
const giftMetadata = StarterPack.starterPackOrderMetadata(name);
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${token}`;
const nonceStr = crypto.randomBytes(16).toString('hex');
const timestamp = Math.floor(Date.now() / 1000).toString();
@ -226,7 +224,7 @@ async function PostImg(OpenId, outTradeNo, name, count, price, passInput: any =
}
await db.collection("iosOrder").add({
...passFields,
...giftSnapshot,
...giftMetadata,
openid: OpenId,
outTradeNo: outTradeNo,
itemid: name,

View File

@ -25,12 +25,8 @@ export default async function (ctx: FunctionContext) {
openid: res.data.openid,
outTradeNo: res.data.outTradeNo,
itemid: res.data.itemid,
...(res.data.starterPackBuyRound != null ? { starterPackBuyRound: res.data.starterPackBuyRound } : {}),
...(res.data.starterPackExpiresAt != null ? { starterPackExpiresAt: res.data.starterPackExpiresAt } : {}),
...(res.data.starterPackOffer ? {
starterPackOffer: res.data.starterPackOffer,
activityType: res.data.activityType, rewardVersion: res.data.rewardVersion,
} : {}),
...(res.data.activityType ? { activityType: res.data.activityType } : {}),
...(res.data.rewardVersion ? { rewardVersion: res.data.rewardVersion } : {}),
itemCount: res.data.itemCount,
goodsPrice: res.data.goodsPrice,
time: res.data.time,

View File

@ -31,7 +31,6 @@ export default async function (ctx: FunctionContext) {
}
}
return { code: 1, data: "ok", msg: "成功", ...(/^starter_pack(?:_[2-5])?$/.test(order.data.itemid) ? {
starterPackOffer: order.data.starterPackOffer, itemid: order.data.itemid,
starterPackBuyRound: order.data.starterPackBuyRound,
itemid: order.data.itemid,
} : {}) };
}

View File

@ -23,10 +23,7 @@ export default async function (ctx: FunctionContext) {
if (res.data.openid !== user.data.openid) {
return { code: 0, data: null, msg: "充值订单不属于当前玩家" };
}
const starterData = /^starter_pack(?:_[2-5])?$/.test(res.data.itemid) ? {
starterPackOffer: res.data.starterPackOffer, itemid: res.data.itemid,
starterPackBuyRound: res.data.starterPackBuyRound,
} : {};
const starterData = /^starter_pack(?:_[2-5])?$/.test(res.data.itemid) ? { itemid: res.data.itemid } : {};
if (res.data.state == 1 || res.data.state == 2) {
// Retry only events already captured by the payment callback; do not backfill
// historical completed orders using today's remaining time.

View File

@ -31,8 +31,7 @@ export default async function (ctx: FunctionContext) {
}
let payment_name = ret.data?.itemid;
const starterGift = /^starter_pack(?:_[2-5])?$/.test(payment_name);
const starterData = starterGift ? { starterPackOffer: ret.data.starterPackOffer,
itemid: payment_name, starterPackBuyRound: ret.data.starterPackBuyRound } : {};
const starterData = starterGift ? { itemid: payment_name } : {};
if (starterGift) {
const owner = uid ? (await db.collection("users").where({ _id: uid }).getOne()).data : null;
if (!owner || owner.openid !== ret.data.openid) return { code: 0, data: null, msg: "充值订单不属于当前玩家" };
@ -95,15 +94,7 @@ export default async function (ctx: FunctionContext) {
case "starter_pack_3":
case "starter_pack_4":
case "starter_pack_5": {
const legacyCycle = (!res1.data.starterPackOffer || res1.data.starterPackOffer.legacy === true) && Number(ret.data.time) >= Number(res1.data.starter_pack) - 2 * 86400000
&& Number(ret.data.time) <= Number(res1.data.starter_pack);
const expiry = ret.data.starterPackExpiresAt ?? (legacyCycle ? res1.data.starter_pack : null);
if (expiry != null && Number(expiry) === Number(res1.data.starter_pack)
&& (ret.data.starterPackBuyRound == null || Number(ret.data.starterPackBuyRound) === (Number(res1.data.starterPackRound) || 1))) {
await db.collection("users").where({ _id: uid, starter_pack: res1.data.starter_pack,
starterPackRound: res1.data.starterPackRound ?? null }).update({ starter_packState: 1 });
}
await db.collection("users").where({ _id: uid }).update({ pay_user: true });
await db.collection("users").where({ _id: uid }).update({ starter_packState: 1, pay_user: true });
break;
}
default:

View File

@ -42,12 +42,10 @@ export default async function (ctx: FunctionContext) {
if (user.data == null) {
return { code: 0, data: null, msg: "未获取到玩家数据" };
}
let starterFields: any = {};
try {
if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) throw new Error("无效的礼包商品");
starterFields = StarterPack.starterPackOrderSnapshot(user.data, itemid);
if (starterFields.starterPackOffer) { itemCount = 1; itemPrice = starterFields.starterPackOffer.priceFen; }
} catch (error) { return { code: 0, data: null, msg: error.message }; }
if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) {
return { code: 0, data: null, msg: "无效的礼包商品" };
}
const starterFields = StarterPack.starterPackOrderMetadata(itemid);
const jungleTier = JungleConfig.getJunglePaidTierByProductId(itemid);
let passFields: any = {};
try {

View File

@ -42,12 +42,10 @@ export default async function (ctx: FunctionContext) {
if (user.data == null) {
return { code: 0, data: null, msg: "未获取到玩家数据" };
}
let starterFields: any = {};
try {
if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) throw new Error("无效的礼包商品");
starterFields = StarterPack.starterPackOrderSnapshot(user.data, itemid);
if (starterFields.starterPackOffer) { itemCount = 1; itemPrice = starterFields.starterPackOffer.priceFen; }
} catch (error) { return { code: 0, data: null, msg: error.message }; }
if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) {
return { code: 0, data: null, msg: "无效的礼包商品" };
}
const starterFields = StarterPack.starterPackOrderMetadata(itemid);
const jungleTier = JungleConfig.getJunglePaidTierByProductId(itemid);
let passFields: any = {};
try {

View File

@ -112,15 +112,7 @@ export default async function (ctx: FunctionContext) {
}
if (/^starter_pack(?:_[2-5])?$/.test(order.itemid)) {
// An earlier round may finish paying after a newer offer has opened.
const legacyCycle = (!user.starterPackOffer || user.starterPackOffer.legacy === true) && Number(order.time) >= Number(user.starter_pack) - 2 * 86400000
&& Number(order.time) <= Number(user.starter_pack);
const expiry = order.starterPackExpiresAt ?? (legacyCycle ? user.starter_pack : null);
if (expiry != null && Number(expiry) === Number(user.starter_pack)
&& (order.starterPackBuyRound == null || Number(order.starterPackBuyRound) === (Number(user.starterPackRound) || 1))) {
await db.collection("users").where({ _id: user._id, starter_pack: user.starter_pack,
starterPackRound: user.starterPackRound ?? null }).update({ starter_packState: 1 });
}
userUpdate.starter_packState = 1;
}
if (!user.pay_user) {
userUpdate.pay_user = true;

View File

@ -30,13 +30,13 @@
## 支付及兼容
现有下单接口仅对上述五个 SKU 校验当前有效轮次、未购买状态与商品匹配,并固定服务端报价和数量 1。订单保存不含奖励内容的 `starterPackOffer`、`starterPackBuyRound`、`starterPackExpiresAt`、`activityType`、`rewardVersion`,旧 iOS 转单继续携带这些字段。
当前临时关闭新手礼包的服务端档位价格、轮次和购买资格校验。五个有效 SKU 使用客户端提交的价格与数量创建订单;`starter_pack_1` 等无效 SKU 仍拒绝。订单不保存 `starterPackOffer`、`starterPackBuyRound` 或 `starterPackExpiresAt`,只保留画像统计使用的 `activityType` 和 `rewardVersion`。
新订单到期后不可创建;到期前的有效订单可继续原支付流程。回调只关闭所属轮次,不关闭后开的礼包。查单携带商品和轮次快照;`getOrderReward` 仍返回 `data="ok"`,礼包快照位于响应顶层。前端只按订单商品 ID 读取本地固定奖励,不读取后端奖励字段。登录和账号迁移保留轮次及报价。
支付回调和登录补单确认任一新手礼包 SKU 后,直接把当前礼包标记为已购买,不匹配订单轮次。查单和 `getOrderReward` 只返回订单商品 ID,前端按商品 ID 读取本地固定奖励。活动展示本身仍保留当前轮次及报价,用于客户端展示和触发下一轮。
保留原跨设备并发下单、客户端发奖及旧 iOS 响应中断的边界,未新增订单占位锁或服务端发奖事务。礼包待支付订单不再按原通用 3 小时期限清理,以免删除仍可能支付的旧单;关闭但未成功的礼包订单也会保留,不因此发奖。退款不在本次范围内。
`rookie_gift` 支付成功埋点扩展至五个 SKU,沿用订单级去重;轮次和剩余秒数取原订单快照。
`rookie_gift` 支付成功埋点扩展至五个 SKU,沿用订单级去重;新订单不保存活动轮次时按现有保守兼容逻辑推算轮次和剩余秒数。
## 发布与验证

View File

@ -56,25 +56,21 @@ test('disabled gold miner orders never enter legacy iOS recovery', async () => {
});
for (const itemid of ['starter_pack', 'starter_pack_2', 'starter_pack_3', 'starter_pack_4', 'starter_pack_5']) {
test(`login preserves lucky offer metadata and recovers ${itemid} through original delivery`, async () => {
const offer = { version: 3, round: 2, productId: itemid, expiresAt: Date.now() + 10000,
priceFen: 1200, reward: { coin: 4500, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 2700 } };
const { user, order, recover } = fixture(itemid, {
starterPackOffer: offer, starterPackBuyRound: 2, starterPackExpiresAt: offer.expiresAt,
});
Object.assign(user, { starterPackOffer: offer, starterPackRound: 2, starter_pack: offer.expiresAt });
test(`login recovers ${itemid} without order offer or cycle metadata`, async () => {
const { user, order, recover } = fixture(itemid);
const pending = await recover(user.openid, {});
assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true); assert.equal(order.state, 1);
assert.deepEqual(pending[0].starterPackOffer, offer);
assert.equal(pending[0].itemid, itemid); assert.equal(pending[0].starterPackOffer, undefined);
assert.equal(pending[0].starterPackBuyRound, undefined); assert.equal(pending[0].starterPackExpiresAt, undefined);
assert.deepEqual(await recover(user.openid, {}), []);
});
}
test('login payment for an earlier lucky round never closes the current offer', async () => {
test('login payment closes the current offer without matching an order round', async () => {
const { user, order, recover } = fixture('starter_pack_3', { starterPackBuyRound: 2, starterPackExpiresAt: 1000 });
Object.assign(user, { starterPackRound: 3, starter_pack: 2000, starterPackOffer: { version: 3, round: 3 } });
assert.equal((await recover(user.openid, {})).length, 1);
assert.equal(user.starter_packState, undefined); assert.equal(user.pay_user, true); assert.equal(order.state, 1);
assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true); assert.equal(order.state, 1);
});
test('login closes a matching migrated legacy cycle for an older order without an offer snapshot', async () => {

View File

@ -53,7 +53,7 @@ registerHooks({
return next(specifier, context);
}
});
const { reportRookieGift, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts');
const { reportRookieGift, starterPackOrderMetadata } = await import('../functions/limitedTimeEvent.ts');
const { default: callback } = await import('../functions/wx/payCallBack.ts');
const { default: query } = await import('../functions/wx/getPayInfo.ts');
const { default: iosQuery } = await import('../functions/wx/iosgetPayInfo.ts');
@ -73,7 +73,8 @@ function reset() {
tables.users.push(user); return user;
}
function order(user, extra = {}) {
const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300, state: 0, time: NOW - 10000, ...starterPackOrderSnapshot(user, 'starter_pack'), ...extra };
const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300,
state: 0, time: NOW - 10000, ...starterPackOrderMetadata('starter_pack'), ...extra };
tables.order.push(row); return row;
}
const ctx = body => ({ body, headers: {}, socket: { remoteAddress: '127.0.0.1' } });
@ -87,83 +88,88 @@ function setOffer(user, productId, priceFen) {
return user.starterPackOffer;
}
test('each lucky tier uses the server offer price, quantity and tier snapshot on native payment', async () => {
test('each lucky tier keeps the submitted price and quantity without an offer or cycle snapshot', async () => {
for (const create of [androidOrder, nativeIosOrder]) {
for (const [productId, priceFen] of [['starter_pack', 300], ['starter_pack_2', 600], ['starter_pack_3', 1200], ['starter_pack_4', 1800], ['starter_pack_5', 3000]]) {
const user = reset(), offer = setOffer(user, productId, priceFen);
const result = await create(ctx({ uid: 'u', itemid: productId, itemPrice: 1, itemCount: 9 }));
assert.equal(result.code, 1);
const saved = tables.order[0];
assert.equal(saved.goodsPrice, priceFen); assert.equal(saved.itemCount, 1);
assert.deepEqual(saved.starterPackOffer, offer);
assert.equal(saved.starterPackOffer.reward, undefined);
assert.equal(saved.goodsPrice, 1); assert.equal(saved.itemCount, 9);
assert.equal(saved.starterPackOffer, undefined);
assert.equal(saved.starterPackBuyRound, undefined); assert.equal(saved.starterPackExpiresAt, undefined);
assert.equal(saved.activityType, 'lucky'); assert.equal(saved.rewardVersion, offer.rewardVersion);
const signed = JSON.parse(result.data.signData);
assert.equal(signed.productId, productId); assert.equal(signed.goodsPrice, priceFen); assert.equal(signed.buyQuantity, 1);
assert.equal(signed.productId, productId); assert.equal(signed.goodsPrice, 1); assert.equal(signed.buyQuantity, 9);
}
}
});
test('lucky native creation rejects a mismatched tier, invalid suffix, expired or purchased offer', async () => {
for (const create of [androidOrder, nativeIosOrder]) for (const scenario of ['mismatch', 'suffix', 'expired', 'purchased']) {
test('lucky native creation accepts any valid tier without offer eligibility checks', async () => {
for (const create of [androidOrder, nativeIosOrder]) for (const scenario of ['mismatch', 'expired', 'purchased']) {
const user = reset(); setOffer(user, 'starter_pack_3', 1200);
if (scenario === 'expired') { user.starter_pack = NOW; user.starterPackOffer.expiresAt = NOW; }
if (scenario === 'purchased') user.starter_packState = 1;
const itemid = scenario === 'mismatch' ? 'starter_pack_2' : scenario === 'suffix' ? 'starter_pack_1' : 'starter_pack_3';
assert.equal((await create(ctx({ uid: 'u', itemid, itemPrice: 1200, itemCount: 1 }))).code, 0);
const itemid = scenario === 'mismatch' ? 'starter_pack_2' : 'starter_pack_3';
assert.equal((await create(ctx({ uid: 'u', itemid, itemPrice: 1200, itemCount: 1 }))).code, 1);
assert.equal(tables.order.length, 1);
}
for (const create of [androidOrder, nativeIosOrder]) {
reset();
assert.equal((await create(ctx({ uid: 'u', itemid: 'starter_pack_1', itemPrice: 300, itemCount: 1 }))).code, 0);
assert.equal(tables.order.length, 0);
}
});
test('lucky query and original reward acknowledgement return the purchased snapshot after expiry', async () => {
test('lucky query and reward acknowledgement return only the purchased SKU', async () => {
const user = reset(), offer = setOffer(user, 'starter_pack_4', 1800);
const row = { outTradeNo: 'wcx_test', openid: user.openid, itemid: offer.productId, goodsPrice: offer.priceFen,
itemCount: 1, state: 1, ...starterPackOrderSnapshot(user, offer.productId) };
itemCount: 1, state: 1, ...starterPackOrderMetadata(offer.productId) };
tables.order.push(row); now += 2 * DAY;
const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
assert.equal(result.data.pay_state, 2); assert.equal(result.data.itemid, offer.productId);
assert.deepEqual(result.data.starterPackOffer, offer);
assert.equal(result.data.starterPackOffer, undefined);
const claimed = await claimOrder(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
assert.equal(claimed.data, 'ok'); assert.deepEqual(claimed.starterPackOffer, offer); assert.equal(row.state, 2);
assert.equal(claimed.data, 'ok'); assert.equal(claimed.itemid, offer.productId); assert.equal(row.state, 2);
const done = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
assert.equal(done.data.pay_state, 1); assert.deepEqual(done.data.starterPackOffer, offer);
assert.equal(done.data.pay_state, 1); assert.equal(done.data.itemid, offer.productId);
});
test('lucky callback marks only its own round, while historical orders still confirm payment', async () => {
test('lucky callback closes the current offer without matching an order round', async () => {
for (const older of [false, true]) {
const user = reset(), offer = setOffer(user, 'starter_pack_5', 3000);
const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: offer.productId, goodsPrice: 3000,
itemCount: 1, time: NOW, state: 0, ...starterPackOrderSnapshot(user, offer.productId) };
itemCount: 1, time: NOW, state: 0, ...starterPackOrderMetadata(offer.productId) };
tables.order.push(row);
if (older) { user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_2', 600); }
assert.equal((await callback(notification())).ErrCode, 0); assert.equal(row.state, 1);
assert.equal(user.starter_packState === 1, !older); assert.equal(user.pay_user, true);
assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true);
}
const user = reset(), row = order(user);
delete row.starterPackOffer; delete row.starterPackExpiresAt; delete row.starterPackBuyRound;
user.starterPackRound++; user.starter_pack += DAY; setOffer(user, 'starter_pack_2', 600);
assert.equal((await callback(notification())).ErrCode, 0);
assert.notEqual(user.starter_packState, 1); assert.equal(row.state, 1);
assert.equal(user.starter_packState, 1); assert.equal(row.state, 1);
const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
assert.equal(result.data.starterPackOffer, undefined);
});
test('legacy iOS lucky tiers preserve the locked offer through creation, transfer and paid query', async () => {
test('legacy iOS lucky tiers keep submitted price and SKU without an offer snapshot', async () => {
const user = reset(), offer = setOffer(user, 'starter_pack_3', 1200);
shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' };
await customerService(ctx({ FromUserName: 'o', SessionFrom: JSON.stringify({ tpye: 'ios',
outTradeNo: 'tier-ios', propName: offer.productId, count: 9, price: 1 }) }));
assert.equal(tables.iosOrder[0].goodsPrice, 1200); assert.equal(tables.iosOrder[0].itemCount, 1);
assert.deepEqual(tables.iosOrder[0].starterPackOffer, offer);
assert.equal(tables.iosOrder[0].goodsPrice, 1); assert.equal(tables.iosOrder[0].itemCount, 9);
assert.equal(tables.iosOrder[0].starterPackOffer, undefined);
assert.equal(tables.iosOrder[0].activityType, 'lucky');
assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'tier-ios' })), true);
assert.deepEqual(tables.order[0].starterPackOffer, offer);
assert.equal(tables.order[0].starterPackOffer, undefined); assert.equal(tables.order[0].activityType, 'lucky');
user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_5', 3000);
provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() };
const paid = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' }));
assert.equal(paid.code, 1); assert.deepEqual(paid.data.starterPackOffer, offer);
assert.notEqual(user.starter_packState, 1); assert.equal(tables.order[0].state, 2);
assert.equal(paid.code, 1); assert.equal(paid.data.itemid, offer.productId);
assert.equal(user.starter_packState, 1); assert.equal(tables.order[0].state, 2);
const done = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' }));
assert.equal(done.msg, '已经获取到奖励'); assert.deepEqual(done.data.starterPackOffer, offer);
assert.equal(done.msg, '已经获取到奖励'); assert.equal(done.data.itemid, offer.productId);
});
test('old orders without snapshots close only a matching migrated legacy offer', async () => {
@ -181,23 +187,23 @@ test('old orders without snapshots close only a matching migrated legacy offer',
}
});
test('native Android and iOS orders snapshot the server cycle, ignoring client analytics values', async () => {
test('native Android and iOS orders do not retain client or server cycle fields', async () => {
for (const create of [androidOrder, nativeIosOrder]) {
const user = reset();
const result = await create(ctx({ uid: 'u', itemid: 'starter_pack', itemCount: 1, itemPrice: 300, starterPackBuyRound: 99, starterPackExpiresAt: 1 }));
assert.equal(result.code, 1);
assert.equal(tables.order[0].starterPackBuyRound, 3);
assert.equal(tables.order[0].starterPackExpiresAt, user.starter_pack);
assert.equal(tables.order[0].starterPackBuyRound, undefined);
assert.equal(tables.order[0].starterPackExpiresAt, undefined);
assert.equal(events.length, 0);
}
});
test('native callback reports the paid order cycle and integer seconds, not a later reactivation', async () => {
test('native callback without a cycle snapshot uses the conservative analytics fallback', async () => {
const user = reset(), row = order(user);
user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4;
assert.equal((await callback(notification())).ErrCode, 0);
assert.equal(row.state, 1); assert.notEqual(user.starter_packState, 1); assert.equal(user.pay_user, true);
assert.deepEqual(events[0].properties, { order_id: 'wcx_test', buy_round: 3, time_left: 3599 });
assert.equal(row.state, 1); assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true);
assert.deepEqual(events[0].properties, { order_id: 'wcx_test', buy_round: 1, time_left: 0 });
assert.equal(events[0].event, 'rookie_gift'); assert.equal(events[0].accountId, 'o');
assert.equal(events[0].distinctId, 'd'); assert.equal(events[0].firstCheckId, 'wcx_test');
});
@ -216,7 +222,7 @@ test('concurrent and repeated callbacks and paid queries keep a single dedup key
test('payment at or beyond expiry still succeeds and reports zero remaining seconds', async () => {
for (const expiry of [NOW, NOW - 1000]) {
const user = reset(); order(user, { starterPackExpiresAt: expiry });
const user = reset(); user.starter_pack = expiry; order(user);
assert.equal((await callback(notification())).ErrCode, 0);
assert.equal(events[0].properties.time_left, 0);
}
@ -227,7 +233,7 @@ test('unpaid queries and other products never send rookie_gift', async () => {
assert.equal((await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 0);
assert.equal(events.length, 0);
row.itemid = 'gold_1'; await callback(notification()); assert.equal(events.length, 0);
assert.deepEqual(starterPackOrderSnapshot(user, 'gold_1'), {});
assert.deepEqual(starterPackOrderMetadata('gold_1'), {});
});
test('SDK failure cannot block payment; a later query retries the original snapshot', async () => {
@ -254,23 +260,22 @@ test('existing ThinkingData debug routing is retained and account ID works witho
test('legacy pending orders use cycle one; missing historical deadline falls back to zero', async () => {
const user = reset(); delete user.starterPackRound;
const row = order(user); delete row.starterPackBuyRound; delete row.starterPackExpiresAt;
const row = order(user);
await reportRookieGift(row, user, NOW);
assert.equal(events[0].properties.buy_round, 1); assert.equal(events[0].properties.time_left, 3599);
reset(); const older = order(user, { time: NOW - 5 * DAY });
delete older.starterPackBuyRound; delete older.starterPackExpiresAt;
await reportRookieGift(older, user, NOW);
assert.equal(events[0].properties.time_left, 0);
});
test('legacy iOS creation and transfer preserve cycle snapshot even if the user later changes', async () => {
test('legacy iOS creation and transfer omit cycle snapshots', async () => {
const user = reset(); shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' };
await customerService(ctx({ FromUserName: 'o', SessionFrom: JSON.stringify({ tpye: 'ios', outTradeNo: 'old-ios', propName: 'starter_pack', count: 1, price: 300 }) }));
assert.equal(tables.iosOrder.length, 1);
assert.equal(tables.iosOrder[0].starterPackBuyRound, 3);
const expiry = user.starter_pack; user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4;
assert.equal(tables.iosOrder[0].starterPackBuyRound, undefined);
user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4;
assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'old-ios' })), true);
assert.equal(tables.order[0].starterPackBuyRound, 3); assert.equal(tables.order[0].starterPackExpiresAt, expiry);
assert.equal(tables.order[0].starterPackBuyRound, undefined); assert.equal(tables.order[0].starterPackExpiresAt, undefined);
assert.equal(events.length, 0);
});

View File

@ -39,7 +39,7 @@ registerHooks({
return next(url, context);
},
});
const { default: activity, starterPackPatch, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts');
const { default: activity, starterPackPatch, starterPackOrderMetadata } = await import('../functions/limitedTimeEvent.ts');
const { PAYMENT_PROFILE_RULE } = await import('../functions/paymentProfile.ts');
const { starterPackReward } = await import('../../../assets/Script/module/Config/StarterPack.ts');
function reset(patch = {}) {
@ -134,7 +134,6 @@ test('a displayed offer stays fixed when payment identity and daily factors chan
user.pay_user = true; profile(user, 100000, 100000); now += 3600000;
await read(); await trigger();
assert.deepEqual(user.starterPackOffer, offer);
assert.equal(starterPackOrderSnapshot(user, 'starter_pack').starterPackOffer.priceFen, 300);
});
test('legacy active offers retain their original deadline and use the client 3 yuan reward', async () => {
@ -231,18 +230,13 @@ test('form-encoded failure counts and automatic flags match the client transport
}
});
test('order snapshots bind a valid current SKU, price and cycle without reward data', async () => {
const user = reset({ pay_user: true }); profile(user, 3000); await trigger();
const snapshot = starterPackOrderSnapshot(user, 'starter_pack_5');
assert.equal(snapshot.starterPackOffer.priceFen, 3000);
assert.equal(snapshot.starterPackOffer.reward, undefined);
assert.equal(snapshot.activityType, 'lucky');
assert.equal(snapshot.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion);
assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack'), /档位/);
user.starter_packState = 1; assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /已购买/);
user.starter_packState = 0; now = user.starter_pack;
assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /未开放/);
assert.deepEqual(starterPackOrderSnapshot(user, 'other_product'), {});
test('payment metadata keeps factor labels without price, round or eligibility checks', () => {
for (const itemid of ['starter_pack', 'starter_pack_2', 'starter_pack_3', 'starter_pack_4', 'starter_pack_5']) {
assert.deepEqual(starterPackOrderMetadata(itemid), {
activityType: 'lucky', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion,
});
}
assert.deepEqual(starterPackOrderMetadata('other_product'), {});
});
test('formula ignores rejection state and historical successful price', () => {