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); } }