import cloud from '@lafjs/cloud' const db = cloud.database(); import Utils from "@/Utils"; import { PAYMENT_PROFILE_RULE } from "@/paymentProfile"; 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), ...(ctx.body.automatic === true || ctx.body.automatic === 'true' ? { starterPackAutoShownRound: starterPackRound(res.data) } : {}), }); } 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" || action === "save" ? { ...ctx.body, failureCount: Number(ctx.body.failureCount) } : null; const user = await refreshStarterPack(res.data, !!options, 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), starterPackOffer: user.starterPackOffer ?? null, serverTime: Date.now(), starterPackLastShownAt: Number(user.starterPackLastShownAt) || 0, starterPackAutoShownRound: Number(user.starterPackAutoShownRound) || 0, reactivated: !!options && originalExpiry <= now && Number(user.starter_pack) > now, }, msg: "成功" }; } return { code: 400, data: null, msg: "无效的活动请求" }; } const DAY = 24 * 60 * 60 * 1000; const BEIJING_OFFSET = 8 * 60 * 60 * 1000; const STARTER_REWARD_VERSION = PAYMENT_PROFILE_RULE.starterPackRewardVersion; const STARTER_TIERS = [ { productId: 'starter_pack', priceFen: 300, coin: 3000, props: 1, minutes: 30 }, { productId: 'starter_pack_2', priceFen: 600, coin: 3000, props: 1, minutes: 30 }, { productId: 'starter_pack_3', priceFen: 1200, coin: 4500, props: 3, minutes: 45 }, { productId: 'starter_pack_4', priceFen: 1800, coin: 5000, props: 5, minutes: 60 }, { productId: 'starter_pack_5', priceFen: 3000, coin: 8000, props: 8, minutes: 180 }, ]; // A live offer is immutable, including when payment identity or daily factors change. export function starterPackPatch(user: any, now: number, trigger = false, options: any = null, profile: any = null) { const expiry = Number(user.starter_pack) || 0; if (expiry > now) { if (user.starterPackOffer) return null; // Preserve an existing legacy offer's original deadline and promised rewards. return { starterPackVersion: 3, starterPackRound: starterPackRound(user), starterPackOffer: { version: 3, legacy: true, round: starterPackRound(user), productId: 'starter_pack', priceFen: 300, reward: { coin: 3000, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 1800 }, startsAt: expiry - ((Number(user.starterPackVersion) || 1) < 2 ? DAY : 2 * DAY), expiresAt: expiry, rewardVersion: 'starter_pack_legacy', selectionBasis: 'legacy', }, }; } if (!trigger || !options || !Number.isFinite(Number(user.levelAmount)) || Number(user.levelAmount) < 15) return null; if (expiry > 0 && now < Math.floor((expiry + BEIJING_OFFSET) / DAY) * DAY - BEIJING_OFFSET + 2 * DAY) return null; const finite = (value: any) => value != null && Number.isFinite(Number(value)); const eligible = ['shop', 'level_purchase', 'purchase'].includes(options.reason) || (options.reason === 'low_coin' && finite(user.coinAmount) && Number(user.coinAmount) < 500) || (options.reason === 'low_health' && finite(user.healthAmount) && Number(user.healthAmount) === 0 && finite(user.userPowerTime) && Number(user.userPowerTime) <= now / 1000 && !(Number(user.healthtimestamp) > 0 && now - Number(user.healthtimestamp) >= 30 * 60000)) || (options.reason === 'low_props' && [user.freezeAmount, user.hammerAmount, user.magicAmount] .every(value => finite(value) && Number(value) >= 0 && Number(value) <= 3)) || (options.reason === 'four_failures' && Number(options.level) === Number(user.levelAmount) && Number.isInteger(options.failureCount) && options.failureCount >= 4); if (!eligible || (user.pay_user !== false && user.pay_user !== true)) return null; let tier = STARTER_TIERS[user.pay_user ? 1 : 0]; let selectionBasis = user.pay_user ? 'fallback' : 'never_paid'; if (user.pay_user === true) { const midnight = Math.floor((now + BEIJING_OFFSET) / DAY) * DAY - BEIJING_OFFSET; const rawRegistration = user.register_time instanceof Date ? user.register_time.getTime() : user.register_time; const numericRegistration = typeof rawRegistration === 'number' || typeof rawRegistration === 'string' ? Number(rawRegistration) : NaN; const parsedRegistration = Number.isSafeInteger(numericRegistration) && numericRegistration > 0 ? numericRegistration : typeof rawRegistration === 'string' && /T.*(?:Z|[+-]\d{2}:?\d{2})$/i.test(rawRegistration) ? Date.parse(rawRegistration) : NaN; const registration = Number.isSafeInteger(parsedRegistration) && parsedRegistration > 0 ? parsedRegistration : null; const daily = profile?.spending?.daily_reference_fen; const anchor = profile?.ticket_factors?.lucky?.anchor_fen; if (profile?.data_valid === true && profile.amount_basis === 'confirmed_order_amount' && profile.profile_rule_version === PAYMENT_PROFILE_RULE.version && [midnight, midnight - DAY].includes(profile.profile_calculated_as_of) && profile.openid === user.openid && profile.maintenance_identity?.pay_user === true && profile.maintenance_identity?.register_time === registration && Number.isFinite(daily) && daily >= 0 && Number.isFinite(anchor) && anchor >= 0) { const reference = Math.max(daily, 0.7 * anchor); tier = [...STARTER_TIERS.slice(1)].reverse().find(item => item.priceFen <= reference) || STARTER_TIERS[1]; selectionBasis = 'profile'; } } const round = starterPackRound(user) + 1; const expiresAt = now + (user.pay_user === false ? 12 : 6) * 3600000; return { starter_pack: expiresAt, starter_packState: 0, starterPackVersion: 3, starterPackRound: round, starterPackOffer: { version: 3, round, productId: tier.productId, priceFen: tier.priceFen, reward: { coin: tier.coin, hammer: tier.props, freeze: tier.props, magic_wand: tier.props, infinite_health: tier.minutes * 60 }, startsAt: now, expiresAt, rewardVersion: STARTER_REWARD_VERSION, selectionBasis, }, }; } 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 now = Date.now(); let profile: any = null; if (trigger && user.pay_user === true && Number(user.starter_pack || 0) <= now) { try { profile = (await db.collection('userRechargeStats').where({ _id: user._id }).getOne()).data; } catch { /* Missing/unavailable daily factors use the agreed 6-yuan fallback. */ } } const patch = starterPackPatch(user, now, trigger, options, profile); 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, pay_user: user.pay_user ?? null, openid: user.openid ?? null, register_time: user.register_time ?? null, levelAmount: user.levelAmount ?? null, ...(options?.reason === "low_coin" ? { coinAmount: user.coinAmount ?? null } : {}), ...(options?.reason === 'low_health' ? { healthAmount: user.healthAmount ?? null, userPowerTime: user.userPowerTime ?? null, healthtimestamp: user.healthtimestamp ?? null } : {}), ...(options?.reason === 'low_props' ? { freezeAmount: user.freezeAmount ?? null, hammerAmount: user.hammerAmount ?? null, magicAmount: user.magicAmount ?? 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 || Number(user?.starter_packState) === 1 ? 1 : 0; } export function starterPackOrderSnapshot(user: any, 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, activityType: 'lucky', }; } // 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 (!/^starter_pack(?:_[2-5])?$/.test(order?.itemid || '') || !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); } }