server/laf-cloud/functions/limitedTimeEvent.ts
guanchao e2a5c3d2f6 Squash local into main while preserving battle pass compatibility
Integrate starter pack timing, reactivation, purchase analytics, payment routing and atomic user IDs. Resolve iOS conflicts by preserving both battle pass delivery and starter pack snapshots. Add coexistence regression checks and record remaining compatibility risks. Validation: 118/125 tests passed; 7 existing failures remain, plus a separately reproduced pre-existing V2 task login failure.
2026-09-10 16:31:14 +08:00

181 lines
9.0 KiB
TypeScript

import cloud from '@lafjs/cloud'
const db = cloud.database();
import Utils from "@/Utils";
export default async function (ctx: FunctionContext) {
const action = ctx.body.action;
const uid = ctx.body.uid;
const event = ctx.body.event;
let dbname = "users";
// const gameName = ctx.body.gameName;
// if (gameName == "iaa") {
// dbname = "usersAd";
// }
if (!action) {
return { code: 0, data: null, msg: "未获取到action" };
}
if (!uid) {
return { code: 0, data: null, msg: "未获取到uid" };
}
let res = await db.collection(dbname).where({ _id: uid }).getOne();
if (!res.data) return { code: 0, data: null, msg: "未获取到玩家数据" };
const token1 = ctx.body.token;
if (res.data && res.data.token) {
let istoken = Utils.checkToken(token1, res.data.token);
if (!istoken) {
return { code: 0, data: null, msg: "token校验失败" };
}
}
if (event === "starter_pack" && ["save", "read", "reactivate", "shown"].includes(action)) {
if (action === "shown") {
// Only an actually displayed, current offer can advance this timestamp.
const now = Date.now();
if (Number(res.data.starter_packState) !== 1 && Number(res.data.starter_pack) > now
&& Number(ctx.body.expiry) === Number(res.data.starter_pack)) {
await db.collection(dbname).where({
_id: uid, starter_pack: res.data.starter_pack,
starter_packState: res.data.starter_packState ?? null,
starterPackLastShownAt: res.data.starterPackLastShownAt ?? null,
}).update({ starterPackLastShownAt: Math.max(now, Number(res.data.starterPackLastShownAt) || 0) });
}
res = await db.collection(dbname).where({ _id: uid }).getOne();
}
const originalExpiry = Number(res.data.starter_pack) || 0;
const now = Date.now();
const options = action === "reactivate" ? ctx.body : null;
const user = await refreshStarterPack(res.data, action === "save", dbname, options);
return {
code: 1, data: {
starter_pack: Number(user.starter_pack) || 0,
starter_packState: Number(user.starter_packState) || 0,
starterPackVersion: Number(user.starterPackVersion) || 1,
starterPackRound: starterPackRound(user),
serverTime: Date.now(),
starterPackLastShownAt: Number(user.starterPackLastShownAt) || 0,
reactivated: action === "reactivate" && originalExpiry > 0
&& originalExpiry <= now && Number(user.starter_pack) > now,
}, msg: "成功"
};
}
return { code: 400, data: null, msg: "无效的活动请求" };
}
const DAY = 24 * 60 * 60 * 1000;
// starterPackVersion on the user is only a deadline-migration marker:
// 1 = legacy 24h, 2 = already using 48h. Orders do not use this field.
// Upgrade only an unpurchased, still-live legacy offer, once. Its old deadline
// was trigger + 24h, so extending by 24h preserves the original trigger time.
export function starterPackPatch(user: any, now: number, trigger = false, options: any = null) {
const expiry = Number(user.starter_pack) || 0;
if (Number(user.starter_packState) === 1) return null;
if (expiry === 0) {
if (!trigger || Number(user.levelAmount) < 15 || !Number.isFinite(Number(user.levelAmount))) return null;
return { starter_pack: now + 2 * DAY, starter_packState: 0, starterPackVersion: 2, starterPackRound: 1 };
}
if (expiry > now && (Number(user.starterPackVersion) || 1) < 2) {
return { starter_pack: expiry + DAY, starterPackVersion: 2, starterPackRound: starterPackRound(user) };
}
if (expiry > 0 && expiry <= now && options
&& Number.isFinite(Number(user.levelAmount)) && Number(user.levelAmount) >= 15) {
// Legacy records have no exact exposure time. Their expiry is a conservative
// fallback. A locally remembered exposure can only postpone reactivation.
const lastShown = Math.max(Number(user.starterPackLastShownAt) || expiry,
Math.min(now, Math.max(0, Number(options.lastShownAt) || 0)));
if (now - lastShown <= 2 * DAY) return null;
const eligible = options.reason === "shop" || options.reason === "level_purchase"
|| (options.reason === "low_coin" && user.coinAmount != null
&& Number.isFinite(Number(user.coinAmount)) && Number(user.coinAmount) < 500)
|| (options.reason === "four_failures" && Number(options.level) === Number(user.levelAmount)
&& Number.isInteger(options.failureCount) && options.failureCount >= 4);
if (eligible) return { starter_pack: now + 2 * DAY, starterPackVersion: 2, starterPackRound: starterPackRound(user) + 1 };
}
return null;
}
export async function refreshStarterPack(user: any, trigger = false, collection = 'users', options: any = null) {
const db = cloud.database();
for (let attempt = 0; attempt < 3; attempt++) {
const patch = starterPackPatch(user, Date.now(), trigger, options);
if (!patch) return user;
// Compare the stored values so concurrent reads cannot extend twice, and a
// payment callback cannot have its purchased flag overwritten by activation.
await db.collection(collection).where({
_id: user._id,
starter_pack: user.starter_pack ?? null,
starter_packState: user.starter_packState ?? null,
starterPackVersion: user.starterPackVersion ?? null,
starterPackRound: user.starterPackRound ?? null,
starterPackLastShownAt: user.starterPackLastShownAt ?? null,
...(options?.reason === "low_coin" ? { coinAmount: user.coinAmount ?? null } : {}),
}).update(patch);
const fresh = await db.collection(collection).where({ _id: user._id }).getOne();
if (!fresh.data) throw new Error('未获取到玩家数据');
user = fresh.data;
}
return user;
}
// One round per activated offer, independent of daily popup impressions.
export function starterPackRound(user: any): number {
const round = Number(user?.starterPackRound);
return Number.isSafeInteger(round) && round > 0 ? round : Number(user?.starter_pack) > 0 ? 1 : 0;
}
export function starterPackOrderSnapshot(user: any, itemid: string) {
if (itemid !== 'starter_pack') return {};
return {
starterPackBuyRound: Math.max(1, starterPackRound(user)),
starterPackExpiresAt: Math.max(0, Number(user?.starter_pack) || 0),
};
}
// Called only after server-side payment confirmation, never from client reward claims.
// Keep a persisted snapshot so retries and later offer rounds cannot change the event.
export async function reportRookieGift(order: any, user: any, paidAt: number) {
if (order?.itemid !== 'starter_pack' || !order.outTradeNo || user?.openid !== order.openid) return;
try {
const db = cloud.database();
if (!order.rookieGiftPaidAt) {
const now = Date.now();
const validPaidAt = Number.isFinite(paidAt) && paidAt > 0 && paidAt <= now
&& (!Number(order.time) || paidAt >= Number(order.time));
const paymentTime = validPaidAt ? paidAt : now;
// Pre-deployment pending orders have no cycle snapshot. Only use the user's
// current deadline if creation belongs to that cycle; otherwise report 0s.
const currentExpiry = Number(user.starter_pack) || 0;
const sameCycle = Number(order.time) >= currentExpiry - 2 * DAY && Number(order.time) <= currentExpiry;
const expiry = Number(order.starterPackExpiresAt ?? (sameCycle ? currentExpiry : 0)) || 0;
const round = Number(order.starterPackBuyRound ?? (sameCycle ? starterPackRound(user) : 1));
await db.collection('order').where({ outTradeNo: order.outTradeNo, rookieGiftPaidAt: null }).update({
rookieGiftPaidAt: paymentTime,
rookieGiftBuyRound: Number.isSafeInteger(round) && round > 0 ? round : 1,
rookieGiftTimeLeft: Math.max(0, Math.floor((expiry - paymentTime) / 1000)),
});
}
const fresh = await db.collection('order').where({ outTradeNo: order.outTradeNo }).getOne();
const saved = fresh.data;
if (!saved?.rookieGiftPaidAt) throw new Error('rookie_gift snapshot not persisted');
const ThinkingData = require('thinkingdata-node');
const appId = user.isDebug === 'true' ? '95993f9ab6f1402a87abe5147827e5e0' : '40e3d5c5f2af49a4a074205564ce5dbb';
const sdk = ThinkingData.initWithBatchMode(appId, 'https://data.nika4fun.com/sync_data', { dryRun: false, deviceId: '123456789' });
try {
// ThinkingData deduplicates this event by order ID, including concurrent
// callbacks and retries after uncertain network delivery.
sdk.trackFirst({
accountId: order.openid,
...(user.distinctId ? { distinctId: user.distinctId } : {}),
event: 'rookie_gift',
firstCheckId: order.outTradeNo,
time: new Date(saved.rookieGiftPaidAt + 8 * 3600000),
properties: { order_id: order.outTradeNo, buy_round: saved.rookieGiftBuyRound, time_left: saved.rookieGiftTimeLeft },
callback(error: any) { if (error) console.error('rookie_gift 上报失败', order.outTradeNo, error); },
});
} finally {
sdk.close();
}
} catch (error) {
// Analytics failure must not block payment acknowledgement or client delivery.
console.error('rookie_gift 上报失败', order.outTradeNo, error);
}
}