146 lines
7.8 KiB
TypeScript
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 };
|