182 lines
9.0 KiB
TypeScript
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);
|
|
}
|
|
}
|