366 lines
23 KiB
TypeScript
366 lines
23 KiB
TypeScript
import { randomUUID, createHmac, timingSafeEqual } from 'crypto';
|
|
import { BASE_LEVELS } from '@/passCheckConfig';
|
|
|
|
export const PERIOD = 30 * 86400000;
|
|
const FIELD = 'passCheckV2';
|
|
const RESOURCE_FIELDS = ['coinAmount', 'freezeAmount', 'hammerAmount', 'magicAmount', 'userPowerTime'];
|
|
export interface PassReward { field: string; amount: number; }
|
|
export interface PassSeason {
|
|
id: string; startsAt: number; endsAt: number; claimUntil: number;
|
|
tier: number; points: number; unlocked: number;
|
|
freeClaimed: number[]; paidClaimed: number[]; loopClaimed: number;
|
|
giftGranted: boolean; orders: string[]; config: any;
|
|
}
|
|
export class PassError extends Error { reason: string; constructor(reason: string) { super(reason); this.reason = reason; } }
|
|
function requirePass(condition: any, reason: string): asserts condition { if (!condition) throw new PassError(reason); }
|
|
export function passFailure(error: any) {
|
|
if (!(error instanceof PassError)) console.error('passCheckV2 failure', error);
|
|
return { code: 0, data: { reason: error instanceof PassError ? error.reason : 'INTERNAL_ERROR' }, msg: error instanceof PassError ? error.reason : '通票服务暂时不可用' };
|
|
}
|
|
const clone = (value: any) => JSON.parse(JSON.stringify(value));
|
|
const positive = (n: any) => Number.isSafeInteger(n) && n > 0;
|
|
const hasGift = (c: any) => Array.isArray(c.gift) && c.gift.length > 0;
|
|
export function config() {
|
|
let extra: any = {};
|
|
try { extra = JSON.parse(process.env.PASSCHECK_V2_CONFIG || '{}'); } catch { throw new PassError('CONFIG_INVALID'); }
|
|
const c = { version: '1', levels: BASE_LEVELS, premiumProduct: '', upgradeProduct: '', loopPoints: null,
|
|
loopCoins: null, loopPowerSeconds: null, gift: null, ...extra };
|
|
requirePass(typeof c.version === 'string' && Array.isArray(c.levels) && c.levels.length === 30, 'CONFIG_INVALID');
|
|
for (const level of c.levels) {
|
|
requirePass(positive(level.token), 'CONFIG_INVALID');
|
|
validateRewards(level.free); validateRewards(level.paid);
|
|
}
|
|
c.ready = typeof c.premiumProduct === 'string' && !!c.premiumProduct && typeof c.upgradeProduct === 'string'
|
|
&& !!c.upgradeProduct && new Set(['battlepass', c.premiumProduct, c.upgradeProduct]).size === 3
|
|
&& positive(c.loopPoints) && positive(c.loopCoins) && positive(c.loopPowerSeconds) && Array.isArray(c.gift) && c.gift.length > 0;
|
|
if (c.gift !== null) validateRewards(c.gift);
|
|
c.paymentReady = !!process.env.PASSCHECK_PAY_CALLBACK_KEY;
|
|
c.ready = c.ready && c.paymentReady;
|
|
return c;
|
|
}
|
|
function validateRewards(rewards: any) {
|
|
requirePass(Array.isArray(rewards) && rewards.length > 0, 'CONFIG_INVALID');
|
|
for (const r of rewards) requirePass(RESOURCE_FIELDS.includes(r.field) && positive(r.amount), 'CONFIG_INVALID');
|
|
}
|
|
function enabled(uid: string) {
|
|
return process.env.PASSCHECK_V2_ENABLED === 'true' && (!process.env.PASSCHECK_V2_USERS
|
|
|| process.env.PASSCHECK_V2_USERS.split(',').map(s => s.trim()).includes(uid));
|
|
}
|
|
function authenticate(user: any, body: any) {
|
|
requirePass(user, 'USER_NOT_FOUND');
|
|
requirePass(typeof body.token === 'string' && !!body.token && body.token === user.token, 'TOKEN_INVALID');
|
|
requirePass(body.gameName !== 'iaa', 'UNSUPPORTED_GAME');
|
|
}
|
|
function parseState(raw: any) {
|
|
try {
|
|
const s = JSON.parse(raw);
|
|
requirePass(s.version === 2 && Array.isArray(s.seasons) && Number.isSafeInteger(s.revision)
|
|
&& Number.isSafeInteger(s.anchor), 'STATE_INVALID');
|
|
s.appliedOrders ||= [];
|
|
requirePass(Array.isArray(s.appliedOrders) && s.appliedOrders.every((id: any) => typeof id === 'string'), 'STATE_INVALID');
|
|
for (const p of s.seasons) {
|
|
requirePass([1, 2, 3].includes(p.tier) && Number.isSafeInteger(p.points) && p.points >= 0
|
|
&& Number.isSafeInteger(p.loopClaimed) && p.loopClaimed >= 0 && Array.isArray(p.freeClaimed)
|
|
&& Array.isArray(p.paidClaimed) && Array.isArray(p.orders) && p.config?.levels?.length === 30, 'STATE_INVALID');
|
|
requirePass(Number.isSafeInteger(p.startsAt) && p.id === String(p.startsAt) && p.endsAt === p.startsAt + PERIOD
|
|
&& p.claimUntil === p.endsAt + PERIOD && Number.isInteger(p.unlocked) && p.unlocked >= 1 && p.unlocked <= 30, 'STATE_INVALID');
|
|
for (const claims of [p.freeClaimed, p.paidClaimed]) requirePass(new Set(claims).size === claims.length
|
|
&& claims.every((n: any) => positive(n) && n <= 30), 'STATE_INVALID');
|
|
for (const row of p.config.levels) { requirePass(positive(row.token), 'STATE_INVALID'); validateRewards(row.free); validateRewards(row.paid); }
|
|
if (p.config.ready) { requirePass(positive(p.config.loopPoints) && positive(p.config.loopCoins) && positive(p.config.loopPowerSeconds), 'STATE_INVALID'); validateRewards(p.config.gift); }
|
|
}
|
|
return s;
|
|
} catch { throw new PassError('STATE_INVALID'); }
|
|
}
|
|
async function anchor(db: any) {
|
|
const id = process.env.PASSCHECKTIME_ID;
|
|
requirePass(id, 'ANCHOR_MISSING');
|
|
for (let i = 0; i < 5; i++) {
|
|
const row = (await db.collection('idcount').where({ _id: id }).getOne()).data;
|
|
requirePass(row && Number.isSafeInteger(row.passcheckTime), 'ANCHOR_INVALID');
|
|
if (Number.isSafeInteger(row.passcheckV2Anchor)) return row.passcheckV2Anchor;
|
|
const r = await db.collection('idcount').where({ _id: id, passcheckV2Anchor: null, passcheckTime: row.passcheckTime })
|
|
.update({ passcheckV2Anchor: row.passcheckTime });
|
|
if (r.updated === 1) return row.passcheckTime;
|
|
}
|
|
throw new PassError('CONFLICT');
|
|
}
|
|
function newSeason(start: number, c: any): PassSeason {
|
|
return { id: String(start), startsAt: start, endsAt: start + PERIOD, claimUntil: start + PERIOD * 2,
|
|
tier: 1, points: 0, unlocked: 1, freeClaimed: [], paidClaimed: [], loopClaimed: 0,
|
|
giftGranted: false, orders: [], config: clone(c) };
|
|
}
|
|
function refresh(s: any, now: number, c: any) {
|
|
requirePass(now >= s.anchor, 'ACTIVITY_NOT_STARTED');
|
|
const start = s.anchor + Math.floor((now - s.anchor) / PERIOD) * PERIOD;
|
|
s.seasons = s.seasons.filter((p: any) => p.claimUntil > now);
|
|
if (!s.seasons.some((p: any) => p.startsAt === start)) s.seasons.push(newSeason(start, c));
|
|
// Allow filling missing launch configuration in the current period; never rewrite issued rewards.
|
|
for (const p of s.seasons) if (p.startsAt === start && !p.config.ready && c.ready) {
|
|
p.config = { ...clone(c), levels: p.config.levels, gift: hasGift(p.config) ? p.config.gift : clone(c.gift) };
|
|
}
|
|
for (const p of s.seasons) if (p.startsAt === start) {
|
|
p.config.paymentReady = c.paymentReady;
|
|
if (!hasGift(p.config) && hasGift(c)) p.config.gift = clone(c.gift);
|
|
}
|
|
s.current = String(start);
|
|
}
|
|
function metrics(p: any) {
|
|
let consumed = 0, unlocked = 0, level = 1, levelStart = 0;
|
|
for (const row of p.config.levels) {
|
|
consumed += row.token;
|
|
if (p.points >= consumed) unlocked++;
|
|
if (p.points > consumed && level < 30) { level++; levelStart = consumed; }
|
|
}
|
|
unlocked = Math.max(unlocked, p.unlocked);
|
|
const total = p.config.levels.reduce((n: number, r: any) => n + r.token, 0);
|
|
const levelTarget = p.config.levels[level - 1].token;
|
|
return { unlocked, total, overflow: Math.max(0, p.points - total), level,
|
|
levelProgress: Math.min(levelTarget, p.points - levelStart), levelTarget };
|
|
}
|
|
function loopAvailable(p: any) {
|
|
return p.tier === 3 && p.config.ready ? Math.max(0, Math.floor(metrics(p).overflow / p.config.loopPoints) - p.loopClaimed) : 0;
|
|
}
|
|
function hasRewards(p: any) {
|
|
const n = metrics(p).unlocked;
|
|
return p.freeClaimed.length < n || (p.tier >= 2 && p.paidClaimed.length < n) || loopAvailable(p) > 0;
|
|
}
|
|
function view(s: any, user: any, now: number) {
|
|
const current = s.seasons.find((p: any) => p.id === s.current);
|
|
const previous = s.seasons.find((p: any) => p.id !== s.current && hasRewards(p));
|
|
const describe = (p: any) => p && { ...p, ...metrics(p), multiplier: p.tier === 3 ? 2 : 1,
|
|
loopAvailable: loopAvailable(p), loopRemainder: p.config.ready ? metrics(p).overflow % p.config.loopPoints : metrics(p).overflow };
|
|
const options = current.tier === 1 ? [...(hasGift(current.config) ? [{ productId: 'battlepass', price: 1800 }] : []), ...(current.config.ready ? [{ productId: current.config.premiumProduct, price: 3000 }] : [])]
|
|
: current.tier === 2 && current.config.ready ? [{ productId: current.config.upgradeProduct, price: 1200 }] : [];
|
|
return { current: describe(current), previous: describe(previous) || null, displaySeasonId: previous?.id || current.id,
|
|
purchaseOptions: previous || !current.config.paymentReady ? [] : options, serverNow: now, revision: s.revision,
|
|
resources: Object.fromEntries(RESOURCE_FIELDS.map(f => [f, Number(user[f]) || 0])) };
|
|
}
|
|
function migrate(user: any, a: number, c: any, now: number) {
|
|
requirePass(user.wucai_type !== 1, 'MIGRATION_CHOICE_PENDING');
|
|
let old = user.passCheck;
|
|
if (typeof old === 'string' && old !== '' && old !== 'null') { try { old = JSON.parse(old); } catch { throw new PassError('LEGACY_STATE_INVALID'); } }
|
|
const s: any = { version: 2, revision: 0, anchor: a, seasons: [], legacySnapshot: user.passCheck ?? null, appliedOrders: [], round: null, roundSeq: 0, settledSeq: 0, pendingOrder: null };
|
|
if (old && old !== 'null') {
|
|
requirePass(typeof old === 'object', 'LEGACY_STATE_INVALID');
|
|
for (const key of ['1', '2']) {
|
|
const raw = old[key]; if (!raw || Number(raw.time) === 0) continue;
|
|
const end = Number(raw.time); requirePass(Number.isSafeInteger(end), 'LEGACY_STATE_INVALID');
|
|
if (end + PERIOD <= now) continue;
|
|
const start = a + Math.round((end - PERIOD - a) / PERIOD) * PERIOD;
|
|
requirePass(Math.abs(start + PERIOD - end) < 86400000 && start <= now, 'LEGACY_PERIOD_INVALID');
|
|
const p = newSeason(start, c);
|
|
const level = Number(raw.progressLevel), progress = Number(raw.progress);
|
|
requirePass(Number.isSafeInteger(level) && level >= 0 && Number.isSafeInteger(progress) && progress >= 0, 'LEGACY_STATE_INVALID');
|
|
p.tier = raw.activate === true || raw.activate === 1 ? 2 : 1;
|
|
p.giftGranted = p.tier >= 2 && raw.giftGranted === true;
|
|
p.points = level > 30 ? metrics(p).total : c.levels.slice(0, Math.max(0, level - 1)).reduce((n: number, r: any) => n + r.token, 0) + progress;
|
|
p.points = Math.min(p.points, metrics(p).total);
|
|
for (const [source, dest] of [['free', 'freeClaimed'], ['passCheck', 'paidClaimed']] as const) {
|
|
requirePass(Array.isArray(raw[source]) && raw[source].length <= 30, 'LEGACY_STATE_INVALID');
|
|
raw[source].forEach((v: any, index: number) => {
|
|
requirePass([-2, -1, 0, 1].includes(v), 'LEGACY_STATE_INVALID');
|
|
if (v === 0 || v === 1) p.unlocked = Math.max(p.unlocked, index + 1);
|
|
if (v === 0) p[dest].push(index + 1);
|
|
});
|
|
}
|
|
requirePass(!s.seasons.some((x: any) => x.id === p.id), 'LEGACY_PERIOD_INVALID'); s.seasons.push(p);
|
|
}
|
|
}
|
|
refresh(s, now, c); return s;
|
|
}
|
|
function award(user: any, rewards: any[], now: number) {
|
|
for (const r of rewards) {
|
|
const base = Number(user[r.field]) || 0;
|
|
user[r.field] = (r.field === 'userPowerTime' ? Math.max(base, Math.floor(now / 1000)) : base) + r.amount;
|
|
requirePass(Number.isSafeInteger(user[r.field]), 'RESOURCE_OVERFLOW');
|
|
}
|
|
}
|
|
// State and resources share one CAS. Snapshot resource predicates prevent overwriting concurrent grants.
|
|
async function mutate(db: any, uid: string, fn: any, allowMigration = false) {
|
|
const c = config();
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
const user = (await db.collection('users').where({ _id: uid }).getOne()).data;
|
|
requirePass(user, 'USER_NOT_FOUND');
|
|
const before = clone(user), now = Date.now();
|
|
requirePass(user[FIELD] || allowMigration, 'CLIENT_UPGRADE_REQUIRED');
|
|
if (!user[FIELD]) {
|
|
const orders = (await db.collection('order').where({ openid: user.openid, itemid: 'battlepass' }).get()).data;
|
|
requirePass(!orders.some((o: any) => (o.state === 0 || o.state === 1) && !o.passClosedVerified), 'LEGACY_ORDER_PENDING');
|
|
}
|
|
const s = user[FIELD] ? parseState(user[FIELD]) : migrate(user, await anchor(db), c, now);
|
|
refresh(s, now, c);
|
|
const result = await fn(s, user, now);
|
|
s.revision++;
|
|
const query: any = { _id: uid, [FIELD]: before[FIELD] ?? null, token: before.token ?? null };
|
|
if (!before[FIELD]) { query.passCheck = before.passCheck ?? null; query.wucai_type = before.wucai_type ?? null; }
|
|
const update: any = { [FIELD]: JSON.stringify(s) };
|
|
if (user.pay_user === true && before.pay_user !== true) update.pay_user = true;
|
|
for (const field of RESOURCE_FIELDS) if (user[field] !== before[field]) { query[field] = before[field] ?? null; update[field] = user[field]; }
|
|
const r = await db.collection('users').where(query).update(update);
|
|
if (r.updated === 1) return { code: 1, data: { ...view(s, user, now), ...result }, msg: '成功' };
|
|
}
|
|
throw new PassError('CONFLICT');
|
|
}
|
|
function season(s: any, id: any) { const p = s.seasons.find((x: any) => x.id === id); requirePass(p, 'SEASON_EXPIRED'); return p; }
|
|
export async function handlePassRequest(db: any, body: any) {
|
|
const user = (await db.collection('users').where({ _id: body.uid }).getOne()).data;
|
|
authenticate(user, body);
|
|
requirePass(user[FIELD] || enabled(body.uid), 'FEATURE_DISABLED');
|
|
if (body.action === 'reconcile_order') return reconcilePassOrder(db, body.outTradeNo, body.uid);
|
|
requirePass(['read', 'begin_round', 'complete_round', 'claim_level', 'claim_loop'].includes(body.action), 'INVALID_ACTION');
|
|
return mutate(db, body.uid, (s: any, u: any, now: number) => {
|
|
authenticate(u, body);
|
|
if (body.action === 'read') return {};
|
|
if (body.action === 'begin_round') {
|
|
if (!s.round || s.round.seasonId !== s.current) s.round = { id: randomUUID(), seq: ++s.roundSeq, seasonId: s.current };
|
|
return { round: s.round };
|
|
}
|
|
if (body.action === 'complete_round') {
|
|
requirePass(typeof body.roundId === 'string' && Number.isSafeInteger(body.roundSeq), 'ROUND_INVALID');
|
|
if (s.lastRound?.id === body.roundId && s.lastRound.seq === body.roundSeq) return { alreadyApplied: true };
|
|
requirePass(s.round?.id === body.roundId && s.round.seq === body.roundSeq, 'ROUND_INVALID');
|
|
requirePass(s.round.seasonId === s.current, 'ROUND_EXPIRED');
|
|
requirePass(typeof body.won === 'boolean', 'ROUND_RESULT_REQUIRED');
|
|
const p = season(s, s.current);
|
|
if (body.won) { p.points += p.tier === 3 ? 2 : 1; requirePass(Number.isSafeInteger(p.points), 'PROGRESS_OVERFLOW'); }
|
|
s.lastRound = s.round; s.round = null; s.settledSeq = body.roundSeq;
|
|
return { alreadyApplied: false };
|
|
}
|
|
const p = season(s, body.seasonId);
|
|
if (body.action === 'claim_level') {
|
|
requirePass(body.track === 'free' || body.track === 'paid', 'TRACK_INVALID');
|
|
requirePass(positive(body.level) && body.level <= metrics(p).unlocked, 'LEVEL_LOCKED');
|
|
requirePass(body.track === 'free' || p.tier >= 2, 'TIER_REQUIRED');
|
|
const claims = body.track === 'free' ? p.freeClaimed : p.paidClaimed;
|
|
if (claims.includes(body.level)) return { alreadyApplied: true, rewards: [] };
|
|
const rewards = p.config.levels[body.level - 1][body.track]; award(u, rewards, now); claims.push(body.level);
|
|
return { rewards, alreadyApplied: false };
|
|
}
|
|
requirePass(p.tier === 3, 'TIER_REQUIRED'); requirePass(p.config.ready, 'CONFIG_INCOMPLETE');
|
|
requirePass(Number.isSafeInteger(body.expectedLoopClaimed) && body.expectedLoopClaimed >= 0, 'LOOP_CURSOR_REQUIRED');
|
|
if (body.expectedLoopClaimed < p.loopClaimed) return { alreadyApplied: true, rewards: [] };
|
|
requirePass(body.expectedLoopClaimed === p.loopClaimed, 'LOOP_CURSOR_INVALID');
|
|
const count = loopAvailable(p); requirePass(count > 0, 'LOOP_LOCKED');
|
|
const coins = Math.floor(count / 2) + (count % 2 && p.loopClaimed % 2 === 0 ? 1 : 0);
|
|
const rewards = [{ field: 'coinAmount', amount: coins * p.config.loopCoins }, { field: 'userPowerTime', amount: (count - coins) * p.config.loopPowerSeconds }].filter(r => r.amount > 0);
|
|
award(u, rewards, now); p.loopClaimed += count; return { rewards, alreadyApplied: false };
|
|
}, body.action === 'read');
|
|
}
|
|
|
|
export function isPassProduct(id: any) {
|
|
const c = config(); return id === 'battlepass' || (!!c.premiumProduct && id === c.premiumProduct) || (!!c.upgradeProduct && id === c.upgradeProduct);
|
|
}
|
|
export async function preparePassOrder(db: any, body: any, outTradeNo: string) {
|
|
const u = (await db.collection('users').where({ _id: body.uid }).getOne()).data;
|
|
if (!isPassProduct(body.itemid)) { requirePass(body.passCheckVersion !== 2, 'PRODUCT_INVALID'); return null; }
|
|
if (!u?.[FIELD] && body.itemid === 'battlepass') return null;
|
|
authenticate(u, body); requirePass(u[FIELD], 'CLIENT_UPGRADE_REQUIRED');
|
|
requirePass(config().paymentReady, 'PAYMENT_VERIFIER_UNCONFIGURED');
|
|
const result = await mutate(db, body.uid, async (s: any, user: any) => {
|
|
authenticate(user, body);
|
|
const p = season(s, s.current);
|
|
requirePass(!s.seasons.some((x: any) => x.id !== s.current && hasRewards(x)), 'OLD_REWARDS_PENDING');
|
|
if (s.pendingOrder) {
|
|
const old = (await db.collection('order').where({ outTradeNo: s.pendingOrder.outTradeNo }).getOne()).data;
|
|
if (!old?.passClosedVerified && !old?.passGranted) {
|
|
requirePass(s.pendingOrder.seasonId === p.id && s.pendingOrder.productId === body.itemid
|
|
&& s.pendingOrder.channel === body.passChannel && (!old || old.state === 0), 'ORDER_PENDING');
|
|
return { passOrder: clone(s.pendingOrder) };
|
|
}
|
|
s.pendingOrder = null;
|
|
}
|
|
let targetTier: number, price: number;
|
|
if (body.itemid === 'battlepass') {
|
|
requirePass(p.tier === 1, 'TIER_CONFLICT'); requirePass(hasGift(p.config), 'CONFIG_INCOMPLETE');
|
|
targetTier = 2; price = 1800;
|
|
}
|
|
else {
|
|
requirePass(p.config.ready, 'CONFIG_INCOMPLETE'); targetTier = 3;
|
|
requirePass((p.tier === 1 && body.itemid === p.config.premiumProduct) || (p.tier === 2 && body.itemid === p.config.upgradeProduct), 'TIER_CONFLICT');
|
|
price = p.tier === 1 ? 3000 : 1200;
|
|
}
|
|
s.pendingOrder = { outTradeNo, seasonId: p.id, fromTier: p.tier, targetTier, price, productId: body.itemid, channel: body.passChannel };
|
|
return { passOrder: clone(s.pendingOrder) };
|
|
});
|
|
const passOrder = result.data.passOrder;
|
|
// A deterministic document id makes a retry after an ambiguous add safe.
|
|
const existing = (await db.collection('order').where({ outTradeNo: passOrder.outTradeNo }).getOne()).data;
|
|
if (!existing) {
|
|
try {
|
|
const added = await db.collection('order').add({ _id: passOrder.outTradeNo, outTradeNo: passOrder.outTradeNo,
|
|
passOrder, passUid: body.uid, openid: u.openid, itemid: passOrder.productId, goodsPrice: passOrder.price,
|
|
itemCount: 1, type: body.passChannel, time: Date.now(), orderTime: new Date(), state: 0, chargeTime: 0, getTime: 0 });
|
|
requirePass(added && added.ok !== false, 'ORDER_CREATE_FAILED');
|
|
} catch (e) {
|
|
const retry = (await db.collection('order').where({ outTradeNo: passOrder.outTradeNo }).getOne()).data;
|
|
if (!retry || retry.passUid !== body.uid) throw e;
|
|
}
|
|
}
|
|
return passOrder;
|
|
}
|
|
export async function reconcilePassOrder(db: any, outTradeNo: string, uid?: string) {
|
|
requirePass(typeof outTradeNo === 'string' && !!outTradeNo, 'ORDER_REQUIRED');
|
|
const order = (await db.collection('order').where({ outTradeNo }).getOne()).data;
|
|
requirePass(order?.passOrder, 'ORDER_NOT_FOUND');
|
|
if (uid) requirePass(order.passUid === uid, 'ORDER_OWNER_MISMATCH');
|
|
requirePass(order.passPaymentVerified === true, 'ORDER_UNPAID');
|
|
if (order.passGranted === true) return { code: 1, data: { alreadyApplied: true, outTradeNo }, msg: '成功' };
|
|
try {
|
|
const result = await mutate(db, order.passUid, (s: any, u: any, now: number) => {
|
|
requirePass(u.openid === order.openid, 'ORDER_OWNER_MISMATCH');
|
|
if (s.appliedOrders.includes(outTradeNo)) return { alreadyApplied: true };
|
|
const p = season(s, order.passOrder.seasonId);
|
|
if (p.orders.includes(outTradeNo)) return { alreadyApplied: true };
|
|
requirePass(p.tier === order.passOrder.fromTier && s.pendingOrder?.outTradeNo === outTradeNo, 'TIER_CONFLICT');
|
|
requirePass(order.goodsPrice === order.passOrder.price && order.itemid === order.passOrder.productId, 'ORDER_MISMATCH');
|
|
if (order.passOrder.targetTier >= 2 && !p.giftGranted) {
|
|
requirePass(hasGift(p.config), 'CONFIG_INCOMPLETE'); validateRewards(p.config.gift);
|
|
award(u, p.config.gift, now); p.giftGranted = true;
|
|
}
|
|
p.tier = order.passOrder.targetTier; p.orders.push(outTradeNo); s.appliedOrders.push(outTradeNo); s.pendingOrder = null; u.pay_user = true;
|
|
return { alreadyApplied: false };
|
|
});
|
|
const marked = await db.collection('order').where({ outTradeNo }).update({ state: 2, passGranted: true, passReviewReason: null, getTime: new Date() });
|
|
requirePass(marked.updated === 1, 'ORDER_FINALIZE_RETRY');
|
|
return result;
|
|
} catch (e) {
|
|
await db.collection('order').where({ outTradeNo }).update({ passReviewReason: e instanceof PassError ? e.reason : 'RETRY_REQUIRED' });
|
|
console.error('Pass order requires reconciliation', outTradeNo, e instanceof PassError ? e.reason : 'RETRY_REQUIRED'); throw e;
|
|
}
|
|
}
|
|
// Only call with provider-authenticated transaction data, never client payment claims.
|
|
export async function acceptPassPayment(db: any, order: any, payment: any) {
|
|
requirePass(payment.outTradeNo === order.outTradeNo && payment.openid === order.openid && payment.price === order.goodsPrice
|
|
&& payment.quantity === 1 && payment.productId === order.itemid, 'PAYMENT_MISMATCH');
|
|
const verified = await db.collection('order').where({ outTradeNo: order.outTradeNo }).update({ passPaymentVerified: true, chargeTime: new Date() });
|
|
requirePass(verified.updated === 1, 'ORDER_NOT_FOUND');
|
|
return reconcilePassOrder(db, order.outTradeNo);
|
|
}
|
|
// Provider query results only. A client cancellation or elapsed time is not proof of closure.
|
|
export async function recordPassOrderClosed(db: any, order: any, transaction: any) {
|
|
requirePass(order?.passOrder && transaction?.trade_state === 'CLOSED' && transaction.out_trade_no === order.outTradeNo, 'ORDER_CLOSE_UNVERIFIED');
|
|
await db.collection('order').where({ outTradeNo: order.outTradeNo, passPaymentVerified: null, passGranted: null })
|
|
.update({ passClosedVerified: true, passClosedAt: Date.now() });
|
|
}
|
|
export function verifyPassCallback(body: any) {
|
|
const key = process.env.PASSCHECK_PAY_CALLBACK_KEY;
|
|
requirePass(key, 'PAYMENT_VERIFIER_UNCONFIGURED');
|
|
const payload = body?.MiniGame?.Payload, sig = body?.MiniGame?.PayEventSig;
|
|
requirePass(typeof payload === 'string' && typeof sig === 'string' && /^[a-f0-9]{64}$/i.test(sig), 'PAYMENT_SIGNATURE_INVALID');
|
|
requirePass(body.Event === 'minigame_game_pay_goods_deliver_notify', 'PAYMENT_SIGNATURE_INVALID');
|
|
const expected = createHmac('sha256', key).update(body.Event + '&' + payload).digest();
|
|
requirePass(timingSafeEqual(expected, Buffer.from(sig, 'hex')) && body.MiniGame.IsMock !== true, 'PAYMENT_SIGNATURE_INVALID');
|
|
requirePass(JSON.parse(payload).Env === 0, 'PAYMENT_ENV_INVALID');
|
|
}
|
|
export async function reconcileUserPassOrders(db: any, user: any) {
|
|
if (!user?.[FIELD]) return;
|
|
const s = parseState(user[FIELD]);
|
|
const ids = new Set<string>([s.pendingOrder?.outTradeNo, ...s.appliedOrders].filter(Boolean));
|
|
for (const id of ids) {
|
|
const order = (await db.collection('order').where({ outTradeNo: id }).getOne()).data;
|
|
if (order?.passPaymentVerified && !order.passGranted) { try { await reconcilePassOrder(db, id, user._id); } catch { /* durable review reason retained */ } }
|
|
}
|
|
}
|
|
export default async function () { return { code: 0, msg: 'Internal module' }; }
|