387 lines
24 KiB
TypeScript
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" }; }
|