server/laf-cloud/functions/limitedTimeEvent.ts
2026-09-11 15:40:32 +08:00

182 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');
// e5e0为正式服
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);
}
}