MatchMaster/server/laf-cloud/functions/limitedTimeEvent.ts
2026-09-30 21:49:05 +08:00

257 lines
14 KiB
TypeScript

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 },
{ productId: 'starter_pack_2', priceFen: 600 },
{ productId: 'starter_pack_3', priceFen: 1200 },
{ productId: 'starter_pack_4', priceFen: 1800 },
{ productId: 'starter_pack_5', priceFen: 3000 },
];
// 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 tier.
return {
starterPackVersion: 3, starterPackRound: starterPackRound(user),
starterPackOffer: {
version: 3, legacy: true, round: starterPackRound(user), productId: 'starter_pack', priceFen: 300,
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,
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);
}
}