259 lines
14 KiB
TypeScript
259 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, 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);
|
|
}
|
|
}
|