MatchMaster/server/laf-cloud/functions/coinMadness.ts

146 lines
7.8 KiB
TypeScript

import cloud from '@lafjs/cloud';
import Utils from '@/Utils';
import { PRODUCT, type CoinState, type CoinConfig, parse, validConfig, createState, active, win, reward } from '@/coinMadnessModel';
const db = cloud.database();
const FIELD = 'coinMadnessState';
const PENDING = 'coinMadnessPending';
const changed = (result: any) => result?.ok && result.updated === 1;
const failure = (msg: string) => ({ code: 0, data: null, msg });
function configuration(): CoinConfig | null {
const raw = parse<CoinConfig>(process.env.COIN_MADNESS_CONFIG);
return validConfig(raw) ? raw : null;
}
async function userById(uid: string) {
return (await db.collection('users').where({ _id: uid }).getOne()).data;
}
function authenticate(user: any, token: any) {
if (!user || !user.token || !Utils.checkToken(token, user.token)) throw new Error('token校验失败');
}
async function save(user: any, state: CoinState, extra: any = {}) {
// Missing fields must use exists(false), rather than undefined (which DB drivers may drop).
const old = user[FIELD] === undefined ? db.command.exists(false) : user[FIELD];
return changed(await db.collection('users').where({ _id: user._id, [FIELD]: old })
.update({ [FIELD]: JSON.stringify(state), ...extra }));
}
function view(user: any, state: CoinState | null, now: number) {
return { code: 1, data: { available: !!state, serverNow: now, state,
pending: parse(user[PENDING]), needsSettlement: !!state && now >= state.config.endsAt && !state.settled } };
}
export default async function coinMadness(ctx: FunctionContext) {
const body = ctx.body || {};
try {
const user = await userById(body.uid);
authenticate(user, body.token);
if (body.gameName === 'iaa') return view({}, null, Date.now());
const now = Date.now();
let state = parse<CoinState>(user[FIELD]);
const pending: any = parse(user[PENDING]);
if (body.action === 'ack') {
if (!pending) return view(user, state, now);
if (pending.id !== body.receiptId) throw new Error('奖励确认编号不匹配');
const coinAmount = Number(body.coinAmount);
if (!Number.isSafeInteger(coinAmount) || coinAmount < 0) throw new Error('金币数据无效');
const ret = await db.collection('users').where({ _id: user._id, [PENDING]: user[PENDING] })
.update({ [PENDING]: null, coinAmount, timestamp: now,
coinMadnessWalletRevision: Number(user.coinMadnessWalletRevision || 0) + 1 });
if (!changed(ret)) throw new Error('奖励状态已变化,请刷新');
return view({ ...user, [PENDING]: null }, state, now);
}
if (!['read', 'claim', 'settle'].includes(body.action)) throw new Error('无效的活动操作');
if (pending) return view(user, state, now); // Replay one durable receipt until acknowledged.
if (body.action === 'read') {
const config = configuration();
const eligible = config && now >= config.startsAt && now < config.endsAt
&& Number(user.levelAmount || 0) >= config.minLevel;
if (eligible && (!state || (state.settled && state.config.periodId !== config.periodId))) {
state = createState(config, Number(user.levelAmount) || 0);
if (!await save(user, state)) throw new Error('活动状态已变化,请刷新');
}
// Disabled configuration hides unpurchased offers; paid snapshots remain serviceable.
if (state && !state.paidAt && (!eligible || state.config.periodId !== config.periodId)) {
if (now < state.config.endsAt) return view(user, null, now);
}
return view(user, state, now);
}
if (!state || body.periodId !== state.config.periodId) throw new Error('活动期次已变化,请刷新');
if (body.action === 'settle' && state.settled) return view(user, state, now);
if (body.action === 'claim' && Number(body.stage) !== state.stage) throw new Error('阶段已变化,请刷新');
const result = reward(state, now, body.action === 'settle');
result.state.lastLevel = Math.max(state.lastLevel, Number(user.levelAmount) || 0);
const extra: any = {};
if (result.coins) {
const base = Number(body.coinAmount);
if (!Number.isSafeInteger(base) || base < 0 || !Number.isSafeInteger(base + result.coins)) throw new Error('金币数据无效');
extra[PENDING] = JSON.stringify({ id: state.config.periodId + ':' + state.stage,
coins: result.coins, balance: base + result.coins, expired: body.action === 'settle' });
extra.coinAmount = base + result.coins;
extra.coinMadnessWalletRevision = Number(user.coinMadnessWalletRevision || 0) + 1;
extra.timestamp = now;
}
if (!await save(user, result.state, extra)) throw new Error('活动状态已变化,请刷新');
return view({ ...user, ...extra }, result.state, now);
} catch (error) { return failure(error.message || '活动处理失败'); }
}
/** Called only by native order creation. One reusable order number per account/period. */
export async function prepareCoinMadnessOrder(user: any, body: any, candidate: string) {
authenticate(user, body.token);
const config = configuration();
const now = Date.now();
const state = parse<CoinState>(user[FIELD]);
if (!config || !state || config.periodId !== state.config.periodId || !active(state, now)
|| state.paidAt || user[PENDING] || Number(user.levelAmount || 0) < config.minLevel)
throw new Error('当前无法购买金币狂热');
const level = Number(body.coinMadnessLevel);
if (!Number.isSafeInteger(level) || level < Number(user.levelAmount || 0) || level > Number(user.levelAmount || 0) + 10)
throw new Error('请先同步主线关卡');
if (!state.reservedOrder || level > state.lastLevel) {
if (!state.reservedOrder) state.reservedOrder = candidate;
state.lastLevel = Math.max(state.lastLevel, level);
if (!await save(user, state)) throw new Error('订单创建中,请重试');
}
return { orderNo: state.reservedOrder, price: state.config.priceCents,
snapshot: { coinMadnessPeriod: state.config.periodId } };
}
export async function confirmCoinMadnessPayment(order: any, now = Date.now()) {
// Retry CAS because victory reporting and payment notification can arrive together.
for (let attempt = 0; attempt < 3; attempt++) {
const user = (await db.collection('users').where({ openid: order.openid }).getOne()).data;
if (!user) throw new Error('玩家不存在');
const state = parse<CoinState>(user[FIELD]);
if (!state || state.config.periodId !== order.coinMadnessPeriod || state.reservedOrder !== order.outTradeNo) {
await db.collection('order').where({ outTradeNo: order.outTradeNo })
.update({ coinMadnessReviewReason: 'period_mismatch', state: 1 });
throw new Error('Coin Madness 订单期次不匹配,需人工核对');
}
if (state.orderNo === order.outTradeNo && state.paidAt) return;
if (!active(state, now)) {
await db.collection('order').where({ outTradeNo: order.outTradeNo })
.update({ coinMadnessReviewReason: 'confirmed_after_deadline', state: 1 });
throw new Error('付款确认晚于活动截止,需人工核对退款');
}
state.paidAt = now;
state.orderNo = order.outTradeNo;
state.progress = 0;
state.lastLevel = Math.max(state.lastLevel, Number(user.levelAmount) || 0);
if (await save(user, state, { pay_user: true })) return;
}
throw new Error('付费解锁冲突,请重试通知');
}
export async function recordCoinMadnessWin(uid: string, level: number, isVictory: boolean, infinite: boolean, now: number) {
for (let attempt = 0; attempt < 3; attempt++) {
const user = await userById(uid);
const state = parse<CoinState>(user?.[FIELD]);
if (!state || !state.paidAt || !active(state, now)) return;
const next = win(state, level, now, isVictory, infinite);
if (JSON.stringify(next) === JSON.stringify(state) || await save(user, next)) return;
}
throw new Error('Coin Madness 通关保存冲突');
}
export { PRODUCT };