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" }; }