server/laf-cloud/functions/goldMiner/service.ts
2026-09-24 17:59:52 +08:00

387 lines
24 KiB
TypeScript

import * as ConfigStore from "@/activityConfig/store";
import cloud from "@lafjs/cloud";
import Utils from "@/Utils";
import { TABLES as T, calendar, validatePeriodId, isTestPeriod, testPeriodsEnabled, validateConfig, selectVipConfig, key, reject, open, integer, identifier, parseObject } from "@/goldMiner/config";
import { resolvePeriodDates, testPeriods } from "@/goldMiner/testPeriods";
export const collection = (name: string): any => (cloud.mongo.db as any).collection(name);
export const playerId = (uid: string, periodId: string) => key("users", uid, periodId);
const clone = (value: any) => JSON.parse(JSON.stringify(value));
export async function getUser(uid: string) {
return (await cloud.database().collection("users").where({ _id: uid }).getOne()).data;
}
export function authorize(user: any, body: any) {
if (body.gameName === "iaa" || !user || !user.token || !body.token || !Utils.checkToken(body.token, user.token)) reject("UNAUTHORIZED");
}
export async function authenticate(body: any) {
if (typeof body.uid !== "string" || !body.uid) reject("UNAUTHORIZED");
const user = await getUser(body.uid); authorize(user, body); return user;
}
/** Deterministic _id makes retries safe even before secondary indexes are installed. */
export async function insertOnce(table: string, row: any) {
try { await collection(table).updateOne({ _id: row._id }, { $setOnInsert: row }, { upsert: true }); }
catch (error) { if (!(await collection(table).findOne({ _id: row._id }))) throw error; }
return collection(table).findOne({ _id: row._id });
}
export const controlId = (periodId: string) => key("goldMinerControl", periodId);
export async function periodById(periodId: string) {
const dates = await resolvePeriodDates(periodId);
const control = await collection(T.configs).findOne({ _id: controlId(periodId), activityId: "goldMiner", recordType: "control" });
if (isTestPeriod(periodId)) {
const period = (await testPeriods()).find((p: any) => p.periodId === periodId);
return { ...period, enabled: ConfigStore.isEnabled(period), purchaseEnabled: control?.purchaseEnabled !== false };
}
let config: any;
try { config = await ConfigStore.latest("goldMiner", dates.startsAt, dates.startsAt); }
catch (error: any) { reject("CONFIG_UNAVAILABLE", error.message); }
let snapshot = control?.frozenConfig; // Migration-only pin for already frozen legacy periods.
if (!snapshot) {
if (!config) return null;
snapshot = { ...config.config, configVersion: config.configVersion, publishedAt: config.publishedAt };
}
const configSnapshot = validateConfig(snapshot);
return { ...dates, enabled: config ? ConfigStore.isEnabled(config) : true, configVersion: configSnapshot.configVersion, configSnapshot,
configHash: key(configSnapshot), purchaseEnabled: control?.purchaseEnabled !== false };
}
/** Read-only admission check. Existing participants and assigned paid entitlements survive a closure. */
export async function periodForPlayer(period: any, uid?: string) {
if (!period || ConfigStore.isEnabled(period)) return period;
if (!uid) return null;
const stored = await collection(T.players).findOne({ _id: playerId(uid, period.periodId) });
if (stored?.tasks) return { ...period, configSnapshot: stored.configSnapshot,
configVersion: stored.configVersion, configHash: stored.configHash };
return stored?.entitlement && stored.entitlement.state !== "manual_review" ? period : null;
}
export async function currentPeriod(now = Date.now(), uid?: string) {
const temporary = (await testPeriods()).find((p: any) => open(p, now));
if (temporary) return periodForPlayer(await periodById(temporary.periodId), uid);
const dates = calendar(now);
return open(dates, now) ? periodForPlayer(await periodById(dates.periodId), uid) : null;
}
export async function ensurePlayer(uid: string, user: any, period: any) {
const id = playerId(uid, period.periodId);
let stored = await collection(T.players).findOne({ _id: id });
if (stored?.tasks) return refreshPlayerOffer(id, user);
if (!ConfigStore.isEnabled(period) && (!stored?.entitlement || stored.entitlement.state === "manual_review")) reject("PERIOD_DISABLED");
if (Number(user?.levelAmount) < period.configSnapshot.unlockPassedLevel || !Number.isFinite(Number(user?.levelAmount))) reject("NOT_QUALIFIED");
const now = Date.now();
stored = stored || await insertOnce(T.players, { _id: id, uid, accountScope: "users", periodId: period.periodId,
startsAt: period.startsAt, endsAt: period.endsAt, entitlementStatus: "none", revision: 0, createdAt: now });
return mutatePlayer(id, row => {
if (row.tasks) return null;
const config = row.entitlement?.configSnapshot || selectVipConfig(period.configSnapshot, user);
return { nextPeriodId: period.nextPeriodId, initialized: true,
configVersion: config.configVersion, configSnapshot: clone(config), configHash: key(config),
...(period.configSnapshot.vipTiers ? { vipConfigSnapshot: clone(period.configSnapshot) } : {}),
qualifiedAt: now, qualifiedPassedLevel: Number(user.levelAmount), joinedAt: now,
progressWins: 0, progressClosed: false, progressAtClose: null, progressDedup: { entries: [] },
claimedThrough: 0, tasks: config.tasks.map((t: any) => ({
taskId: t.taskId, sequence: t.sequence, targetWins: t.targetWins, itemsSnapshot: clone(t.items), claimStatus: "unclaimed" })),
settlementState: "open", settlementIds: [], settlementTaskIds: [], settlementPreparedAt: null,
settlementId: key("settlement", id), settlementAcknowledgedAt: null, settlementNextRetryAt: 0 };
});
}
/** Unordered rewards still share one offer per period. A created order pins its quote before payment. */
async function refreshPlayerOffer(id: string, user: any) {
return mutatePlayer(id, row => {
if (row.entitlementStatus === "unlocked") return null;
const carried = row.entitlement?.configSnapshot;
if (!carried && (row.offerLockedAt || !row.vipConfigSnapshot)) return null;
const config = carried || selectVipConfig(row.vipConfigSnapshot, user);
if (key(config) === row.configHash) return null;
if (row.tasks.some((t: any) => t.claimStatus !== "unclaimed")) reject("ENTITLEMENT_CONFLICT");
return { configSnapshot: clone(config), configVersion: config.configVersion, configHash: key(config),
tasks: config.tasks.map((t: any) => ({ taskId: t.taskId, sequence: t.sequence, targetWins: t.targetWins,
itemsSnapshot: clone(t.items), claimStatus: "unclaimed" })) };
});
}
/** A future entitlement reserves a player-period shell without freezing next period's rewards. */
export async function reserveEntitlement(entitlement: any) {
const dates = await resolvePeriodDates(entitlement.targetPeriodId), id = playerId(entitlement.uid, dates.periodId);
await insertOnce(T.players, { _id: id, uid: entitlement.uid, accountScope: "users", ...dates,
entitlementStatus: "none", revision: 0, createdAt: Date.now() });
const row = await mutatePlayer(id, state => {
if (state.entitlement) {
if (state.entitlement.sourceOrderNo !== entitlement.sourceOrderNo) reject("ENTITLEMENT_CONFLICT");
return null;
}
return { entitlement, entitlementNextRetryAt: 0 };
});
return row.entitlement;
}
/** Every mutation rechecks its preconditions on the latest document. */
export async function mutatePlayer(id: string, change: (row: any) => any, timeBound = false) {
for (let attempt = 0; attempt < 12; attempt++) {
const old = await collection(T.players).findOne({ _id: id });
if (!old) reject("NOT_FOUND");
const patch = change(clone(old));
if (!patch) return old;
const query: any = { _id: id, revision: old.revision };
// Enforce cutoff at database write time, including requests delayed in flight.
if (timeBound) query.$expr = { $lt: ["$$NOW", new Date(old.endsAt)] };
const result = await collection(T.players).updateOne(query,
{ $set: { ...patch, updatedAt: Date.now() }, $inc: { revision: 1 } });
if (result.modifiedCount === 1) return { ...old, ...patch, updatedAt: Date.now(), revision: old.revision + 1 };
}
reject("RETRYABLE");
}
export async function recordProgress(uid: string, user: any, raw: any) {
const input = parseObject(raw);
validatePeriodId(input.periodId);
if (!identifier(input.eventId) || input.outcome !== "win" || !["main", "endless"].includes(input.mode)) reject("INVALID_INPUT");
const sequence = integer(input.mode === "main" ? input.clearedMainLevel : input.endlessSequence);
if (!Number.isSafeInteger(sequence) || sequence < 1) reject("INVALID_INPUT");
const businessKey = input.mode + ":" + sequence;
const payloadHash = key(input.mode, sequence, input.outcome, input.periodId);
const id = playerId(uid, input.periodId);
let row = await collection(T.players).findOne({ _id: id });
const duplicate = (state: any) => {
const entry = state?.progressDedup?.entries.find((e: any) => e.eventId === input.eventId || e.businessKey === businessKey);
if (entry && entry.payloadHash !== payloadHash) reject("EVENT_PAYLOAD_CONFLICT");
return !!entry;
};
if (duplicate(row)) return { activityCounted: false, duplicate: true, progressWins: row.progressWins };
const period = await currentPeriod(Date.now(), uid);
if (!period || period.periodId !== input.periodId) reject("PERIOD_ENDED");
if (input.mode === "main") {
if (sequence !== Number(user.levelAmount)) reject("INVALID_INPUT", "通关号与本次保存的主线进度不一致");
if (sequence <= period.configSnapshot.unlockPassedLevel) return { activityCounted: false, reason: "qualification_level", progressWins: row?.progressWins || 0 };
}
row = await ensurePlayer(uid, user, period);
await ensureEntitlement(uid, period.periodId);
let counted = false;
row = await mutatePlayer(id, state => {
counted = false;
if (duplicate(state)) return null;
if (!open(state) || state.progressClosed) reject("PERIOD_ENDED");
const maxTarget = state.vipConfigSnapshot && !state.offerLockedAt && state.entitlementStatus !== "unlocked"
? Math.max(...state.vipConfigSnapshot.vipTiers.map((t: any) => t.tasks[t.tasks.length - 1].targetWins))
: state.tasks[state.tasks.length - 1].targetWins;
if (state.progressWins >= maxTarget) return null;
counted = true;
return { progressWins: state.progressWins + 1, progressDedup: { entries: [...state.progressDedup.entries,
{ eventId: input.eventId, businessKey, payloadHash, countedAt: Date.now() }] } };
}, true);
return { activityCounted: counted, duplicate: !counted && duplicate(row), progressWins: row.progressWins };
}
export async function closePlayer(id: string) {
const row = await mutatePlayer(id, state => {
validatePeriodId(state.periodId);
if (Date.now() < state.endsAt || !state.tasks) return null;
const patch: any = { settlementNextRetryAt: 0, settlementId: state.settlementId || key("settlement", id) };
if (!state.progressClosed) Object.assign(patch, { progressClosed: true, progressAtClose: state.progressWins, progressFrozenAt: Date.now() });
if (!state.settlementPreparedAt) patch.settlementPreparedAt = Date.now();
if (state.entitlementStatus === "unlocked" && !state.settlementTaskIds.length) {
const taskIds = state.tasks.filter((t: any) => t.targetWins <= state.progressWins && t.claimStatus !== "claimed").map((t: any) => t.taskId);
if (taskIds.length) Object.assign(patch, { settlementTaskIds: taskIds, settlementIds: [key("settlement", id)], settlementState: "pending_delivery" });
else if (state.settlementState !== "no_reward") patch.settlementState = "no_reward";
}
// Authorize the entire earned remainder on the server. Preserve any in-flight manual grant.
const taskIds = patch.settlementTaskIds || state.settlementTaskIds;
let changed = false;
for (const task of state.tasks) {
if (!taskIds.includes(task.taskId) || task.claimStatus !== "unclaimed") continue;
Object.assign(task, { claimStatus: "issuing", grantId: key("grant", id, task.taskId), authorizedAt: Date.now(),
claimChannel: state.entitlementSource === "delayed_payment_original" ? "delayed_payment" : "expiry_auto" });
changed = true;
}
if (changed) patch.tasks = state.tasks;
return Object.keys(patch).length ? patch : null;
});
return row;
}
export async function activateEntitlement(entitlement: any) {
const dates = await resolvePeriodDates(entitlement.targetPeriodId);
if (Date.now() < dates.startsAt) return null;
// A late recovery may still deliver an already-assigned entitlement and settle it.
const existing = await collection(T.players).findOne({ _id: playerId(entitlement.uid, dates.periodId) });
const period = existing?.configSnapshot ? existing : await periodById(dates.periodId);
if (!period) reject("CONFIG_UNAVAILABLE", `权益目标期 ${dates.periodId} 没有适用的活动配置,请核对发布期次及启用状态`);
const user = await getUser(entitlement.uid);
if (!user) reject("NOT_FOUND");
const player = await ensurePlayer(entitlement.uid, user, period);
const row = await mutatePlayer(player._id, state => {
if (state.entitlementId && state.entitlementId !== entitlement._id) reject("ENTITLEMENT_CONFLICT");
if (state.entitlementStatus === "unlocked" && state.entitlement?.state === "active") return null;
return { entitlementStatus: "unlocked", entitlementId: entitlement._id, entitlementSource: entitlement.source,
sourceOrderNo: entitlement.sourceOrderNo, sourcePeriodId: entitlement.sourcePeriodId,
unlockedAt: Math.max(entitlement.createdAt, dates.startsAt),
entitlement: { ...entitlement, state: "active", activatedAt: Math.max(entitlement.createdAt, dates.startsAt) },
entitlementNextRetryAt: 0, settlementPreparedAt: null };
});
if (Date.now() >= row.endsAt) return closePlayer(row._id);
return row;
}
export async function ensureEntitlement(uid: string, periodId: string) {
const entitlement = (await collection(T.players).findOne({ _id: playerId(uid, periodId) }))?.entitlement;
if (entitlement && entitlement.state !== "manual_review") return activateEntitlement(entitlement);
return null;
}
// Retain the legacy contiguous-prefix field for compatibility; it no longer gates delivery.
function claimedThrough(tasks: any[]) {
const firstPending = tasks.findIndex((t: any) => t.claimStatus !== "claimed");
return firstPending < 0 ? tasks.length : firstPending;
}
export async function claim(uid: string, input: any) {
validatePeriodId(input.periodId);
if (!identifier(input.taskId) || !identifier(input.requestId)) reject("INVALID_INPUT");
const id = playerId(uid, input.periodId);
let state = await collection(T.players).findOne({ _id: id });
if (!state) reject("NOT_FOUND");
state = await mutatePlayer(id, row => {
if (Date.now() >= row.endsAt) reject("PERIOD_SETTLEMENT_REQUIRED", "活动已结束,服务器正在结算奖励,请查询补发清单");
if (Date.now() < row.startsAt || !row.tasks) reject("PERIOD_ENDED");
const index = row.tasks.findIndex((t: any) => t.taskId === input.taskId);
if (index < 0) reject("INVALID_TASK");
const task = row.tasks[index];
if (task.claimStatus !== "unclaimed") return null;
if (row.entitlementStatus !== "unlocked") reject("NOT_PAID");
if (task.targetWins > row.progressWins) reject("TARGET_NOT_REACHED");
row.tasks[index] = { ...task, claimStatus: "issuing", grantId: key("grant", id, task.taskId),
authorizedAt: Date.now(), claimChannel: "manual" };
return { tasks: row.tasks };
}, true);
const task = state.tasks.find((t: any) => t.taskId === input.taskId);
return { grantId: task.grantId, taskId: task.taskId, items: task.itemsSnapshot,
claimStatus: task.claimStatus, claimedThrough: state.claimedThrough, requiresClientDelivery: task.claimStatus !== "claimed" };
}
export async function confirmDelivery(uid: string, input: any) {
validatePeriodId(input.periodId);
if (!identifier(input.taskId) || !identifier(input.grantId)) reject("INVALID_INPUT");
const row = await mutatePlayer(playerId(uid, input.periodId), state => {
if (!state.tasks) reject("INVALID_GRANT");
const index = state.tasks.findIndex((t: any) => t.taskId === input.taskId);
const task = state.tasks[index];
if (!task || !task.grantId || task.grantId !== input.grantId) reject("INVALID_GRANT");
if (task.claimStatus === "claimed") return null;
if (task.claimStatus !== "issuing") reject("INVALID_GRANT");
state.tasks[index] = { ...task, claimStatus: "claimed", clientSavedAt: Date.now(), grantedItems: task.itemsSnapshot };
const settled = state.settlementTaskIds.length && state.settlementTaskIds.every((id: string) => state.tasks.find((t: any) => t.taskId === id).claimStatus === "claimed");
return { tasks: state.tasks, claimedThrough: claimedThrough(state.tasks),
settlementState: state.settlementTaskIds.length ? settled ? "client_saved" : "partial" : state.settlementState };
});
return { grantId: input.grantId, claimStatus: "claimed", claimedThrough: row.claimedThrough };
}
export async function settlements(uid: string, input: any) {
const limit = 20; // Server-owned page size; legacy clients' limit is ignored.
if (input.afterId !== undefined && !identifier(input.afterId)) reject("INVALID_INPUT");
// Query only: the server job/payment recovery prepares expired rewards, never the client claim route.
const scope: any = { uid, endsAt: { $lte: Date.now() },
...(!testPeriodsEnabled() ? { periodId: { $not: /^goldMiner:test:/ } } : {}) };
const rows = await collection(T.players).find({ ...scope,
...(input.afterId ? { _id: { $gt: input.afterId } } : {}) }).sort({ _id: 1 }).limit(limit + 1).toArray();
const page = rows.slice(0, limit);
const pendingRewards = (row: any) => row.entitlementStatus === "unlocked"
? (row.tasks || []).filter((t: any) => t.targetWins <= row.progressWins && t.claimStatus !== "claimed") : [];
const needsSettlement = (row: any, pending = pendingRewards(row)) => !row.tasks
? row.entitlement?.state === "reserved"
: !row.settlementPreparedAt || !row.progressClosed || pending.some((t: any) =>
t.claimStatus !== "issuing" || !t.grantId || !row.settlementTaskIds?.includes(t.taskId));
const items = [];
let settling = false;
for (const row of page) {
const pending = pendingRewards(row);
if (needsSettlement(row, pending)) {
settling = true; continue;
}
if (!pending.length) continue;
const settlementId = key("settlement", row._id);
items.push({ settlementId, periodId: row.periodId,
rewards: pending.map((t: any) => ({ taskId: t.taskId, grantId: t.grantId, items: t.itemsSnapshot })) });
}
let status = items.length ? "pending_delivery" : settling ? "settling" : "no_pending_rewards";
if (status === "no_pending_rewards") {
// Probe the entire player's history, without afterId. An empty page is not an empty account.
const outstanding = await collection(T.players).findOne({ ...scope, $or: [
{ tasks: { $exists: false }, "entitlement.state": "reserved" },
{ tasks: { $exists: true }, $or: [
{ settlementPreparedAt: null }, { settlementPreparedAt: 0 }, { progressClosed: { $ne: true } },
{ entitlementStatus: "unlocked", $expr: { $gt: [{ $size: { $filter: {
input: { $ifNull: ["$tasks", []] }, as: "task", cond: { $and: [
{ $lte: ["$$task.targetWins", "$progressWins"] }, { $ne: ["$$task.claimStatus", "claimed"] }
] }
} } }, 0] } }
] }
] });
if (outstanding) status = needsSettlement(outstanding) ? "settling" : "pending_delivery";
}
// Continue past unfinished periods; after a sweep, restart at page one until no work remains.
return { status, items, nextCursor: status !== "no_pending_rewards" && rows.length > limit
? page[page.length - 1]._id : null };
}
export async function confirmSettlementDelivery(uid: string, input: any) {
if (!identifier(input.settlementId) || !Array.isArray(input.grantIds) || !input.grantIds.length
|| input.grantIds.length > 100 || input.grantIds.some((id: any) => !identifier(id))
|| new Set(input.grantIds).size !== input.grantIds.length) reject("INVALID_INPUT");
const receipt = await collection(T.players).findOne({ settlementId: input.settlementId, uid });
if (!receipt) reject("NOT_FOUND");
validatePeriodId(receipt.periodId);
const row = await mutatePlayer(playerId(uid, receipt.periodId), state => {
if (Date.now() < state.endsAt || !state.settlementPreparedAt) reject("SETTLEMENT_NOT_READY");
const selected = state.tasks.filter((t: any) => input.grantIds.includes(t.grantId)
&& state.settlementTaskIds.includes(t.taskId));
if (selected.length !== input.grantIds.length) reject("INVALID_GRANT");
let changed = false;
for (const task of selected) {
if (task.claimStatus === "claimed") continue;
if (task.claimStatus !== "issuing") reject("INVALID_GRANT");
Object.assign(task, { claimStatus: "claimed", clientSavedAt: Date.now(), grantedItems: task.itemsSnapshot });
changed = true;
}
if (!changed) return null;
return { tasks: state.tasks, claimedThrough: claimedThrough(state.tasks),
settlementState: state.settlementTaskIds.every((id: string) => state.tasks.find((t: any) => t.taskId === id).claimStatus === "claimed")
? "client_saved" : "partial" };
});
return { settlementId: input.settlementId, confirmedGrantIds: input.grantIds,
deliveryStatus: row.settlementState === "client_saved" ? "client_saved" : "partial" };
}
export async function acknowledge(uid: string, id: string) {
if (!identifier(id)) reject("INVALID_INPUT");
const receipt = await collection(T.players).findOne({ settlementId: id, uid });
if (!receipt) reject("NOT_FOUND");
validatePeriodId(receipt.periodId);
await mutatePlayer(receipt._id, row => {
if (row.settlementState !== "client_saved") reject("DELIVERY_PENDING");
return row.settlementAcknowledgedAt ? null : { settlementAcknowledgedAt: Date.now() };
});
return { settlementId: id, acknowledged: true };
}
export async function activityInfo(uid: string, user: any) {
const dates = calendar();
const period = await currentPeriod(Date.now(), uid);
if (!period) return { serverTime: Date.now(), status: "unavailable", periodId: dates.periodId,
startsAt: dates.startsAt, endsAt: dates.endsAt, tasks: [] };
if (Number(user.levelAmount || 0) < period.configSnapshot.unlockPassedLevel) return {
serverTime: Date.now(), status: "locked", periodId: period.periodId, tasks: [] };
await ensurePlayer(uid, user, period);
await ensureEntitlement(uid, period.periodId);
const row = await collection(T.players).findOne({ _id: playerId(uid, period.periodId) });
const paid = row.entitlementStatus === "unlocked";
const serverTime = Date.now();
if (!open(row, serverTime)) return { serverTime, status: "unavailable", periodId: row.periodId,
startsAt: row.startsAt, endsAt: row.endsAt, tasks: [] };
return { serverTime, status: paid ? "unlocked" : period.purchaseEnabled ? "purchasable" : "purchase_disabled",
periodId: row.periodId, startsAt: row.startsAt, endsAt: row.endsAt, configVersion: row.configVersion,
priceFen: row.configSnapshot.priceFen, productId: row.configSnapshot.productId, progressWins: row.progressWins,
...(row.configSnapshot.vipLevel !== undefined ? { vipLevel: row.configSnapshot.vipLevel } : {}),
maxTarget: row.tasks[row.tasks.length - 1].targetWins,
// Tasks are stored in validated sequence order. Expose only display fields, never the internal grant record.
tasks: row.tasks.map((t: any) => ({ taskId: t.taskId, targetWins: t.targetWins, itemsSnapshot: t.itemsSnapshot,
claimStatus: t.claimStatus === "claimed" ? "claimed" : t.claimStatus === "issuing" ? "issuing"
: row.progressWins >= t.targetWins ? paid ? "claimable" : "pending_unlock" : "locked",
completed: row.progressWins >= t.targetWins, progress: Math.min(row.progressWins, t.targetWins) })) };
}
export default async function () { return { code: 0, msg: "internal module" }; }