server/laf-cloud/functions/jungleTreasure.ts
2026-09-01 16:04:15 +08:00

355 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import cloud from "@lafjs/cloud";
import Utils from "@/Utils";
import * as JungleConfigModule from "@/jungleConfig";
// Laf 会为共享云函数生成 index 类型声明,该声明可能滞后于实际命名导出。
const JungleConfig: any = JungleConfigModule as any;
const db = cloud.database();
const USER_STATE_FIELD = "jungleTreasureState";
const PENDING_SETTLEMENT_FIELD = "pendingJungleSettlement";
const VALID_ACTIONS = ["read", "claim", "settle_expired", "ack_settlement"];
/**
* read:读取活动;claim:单档领取;
* settle_expired:按客户端资源基数一次结算到期奖励;ack_settlement:确认结算弹窗。
*/
export default async function jungleTreasure(ctx: FunctionContext) {
const body = ctx.body || {};
const action = String(body.action || "").toLowerCase();
const uid = body.uid;
if (!VALID_ACTIONS.includes(action)) {
return fail("无效的 Jungle 操作类型");
}
if (!uid) {
return fail("未获取到uid");
}
if (body.gameName === "iaa") {
return noActivity("iaa_excluded");
}
const userResult = await db.collection("users").where({ _id: uid }).getOne();
const user = userResult.data;
if (!user) {
return fail("未获取到玩家信息");
}
if (user.token && !Utils.checkToken(body.token, user.token)) {
return fail("token校验失败");
}
const pendingSettlement = normalizePendingSettlement(user[PENDING_SETTLEMENT_FIELD]);
if (action === "ack_settlement") {
return acknowledgeSettlement(uid, body.settlementId, pendingSettlement);
}
if (pendingSettlement) {
if (action === "settle_expired" && hasStoredValue(user[USER_STATE_FIELD])) {
const pendingState = JungleConfig.normalizeStoredJungleState(user[USER_STATE_FIELD]);
await markSettlementOrdersDelivered(user, pendingState, pendingSettlement.claimedTierIds || []);
}
return action === "read" || action === "settle_expired"
? settlementSuccess(pendingSettlement, "待确认的 Jungle 结算")
: fail("请先确认已结算的 Jungle 奖励");
}
const now = Date.now();
let storedStateValue: unknown = user[USER_STATE_FIELD];
if (action !== "read" && !hasStoredValue(storedStateValue)) {
return fail("当前没有可处理的 Jungle 活动");
}
const resolution = JungleConfig.resolveJungleActivity(storedStateValue, now, {
// 只有 read 保持现有界面触发时机;领取与结算不负责开启新一期。
allowCalendarStart: action === "read",
calendarUserId: uid,
});
let serverState = resolution.state;
if (serverState && resolution.changed) {
storedStateValue = JungleConfig.serializeJungleState(serverState);
const initialized = await db.collection("users").where({ _id: uid }).update({
[USER_STATE_FIELD]: storedStateValue,
});
if (isUpdateFailed(initialized)) {
return fail("礼包状态初始化失败");
}
}
if (!resolution.available || !serverState) {
return action === "read"
? noActivity(resolution.reason)
: fail("当前没有可处理的 Jungle 活动");
}
if (action === "read") {
return readSuccess(serverState, resolution.reason);
}
if (action === "settle_expired") {
return settleExpiredRewards(uid, user, storedStateValue, serverState, body.resources, now);
}
const claimResult = JungleConfig.claimJungleTier(serverState, body.tierId, now);
if (!claimResult.ok || !claimResult.state || !claimResult.tierId) {
return fail(claimResult.message || "礼包领取失败");
}
// 字符串不能按数组下标查询;比较完整旧值,避免并发请求覆盖彼此或重复领取。
const updateResult = await db.collection("users").where({
_id: uid,
[USER_STATE_FIELD]: storedStateValue,
}).update({
[USER_STATE_FIELD]: JungleConfig.serializeJungleState(claimResult.state),
});
if (isUpdateFailed(updateResult)) {
return fail("礼包状态已变化,请重新读取后再领取");
}
if (claimResult.paidOrderNo) {
await markOrderDelivered(user.openid, claimResult.paidOrderNo);
}
const latestUserResult = await db.collection("users").where({ _id: uid }).getOne();
if (!latestUserResult.data) {
return fail("礼包领取后读取状态失败");
}
serverState = JungleConfig.normalizeStoredJungleState(latestUserResult.data[USER_STATE_FIELD]);
return claimSuccess(claimResult.tierId, serverState);
}
async function settleExpiredRewards(
uid: string,
user: any,
storedStateValue: unknown,
serverState: any,
rawResources: unknown,
now: number,
) {
const resources = normalizeResourceSnapshot(rawResources);
if (!resources) {
return fail("Jungle 结算资源数据不合法");
}
const settlement = JungleConfig.settleExpiredJungleTiers(serverState, now);
if (!settlement.ok || !settlement.state || settlement.claimedTierIds.length === 0) {
return fail(settlement.message || "当前没有可结算的 Jungle 奖励");
}
const rewards = settlement.rewards;
const serverNowSeconds = Math.floor(now / 1000);
const balances = {
coinAmount: resources.coinAmount + rewards.coin,
freezeAmount: resources.freezeAmount + rewards.freeze,
hammerAmount: resources.hammerAmount + rewards.hammer,
magicAmount: resources.magicAmount + rewards.magic_wand,
userPowerTime: rewards.infinite_health > 0
? Math.max(resources.userPowerTime, serverNowSeconds) + rewards.infinite_health
: resources.userPowerTime,
};
if (!Object.values(balances).every((value) => Number.isSafeInteger(value) && value >= 0)) {
return fail("Jungle 结算后的资源数据不合法");
}
const pendingSettlement = {
settlementId: createSettlementId(serverState),
activityId: serverState.activityId,
activityType: serverState.activityType,
periodKey: serverState.periodKey,
startsAt: serverState.startsAt,
expiresAt: serverState.expiresAt,
claimedTierIds: settlement.claimedTierIds,
rewards,
balances,
createdAt: now,
};
const updateResult = await db.collection("users").where({
_id: uid,
[USER_STATE_FIELD]: storedStateValue,
}).update({
[USER_STATE_FIELD]: JungleConfig.serializeJungleState(settlement.state),
[PENDING_SETTLEMENT_FIELD]: pendingSettlement,
...balances,
timestamp: now,
});
if (isUpdateFailed(updateResult)) {
const latest = await db.collection("users").where({ _id: uid }).getOne();
const existing = normalizePendingSettlement(latest.data?.[PENDING_SETTLEMENT_FIELD]);
return existing
? settlementSuccess(existing, "Jungle 奖励已经结算")
: fail("Jungle 状态已变化,请重新读取后再结算");
}
await markSettlementOrdersDelivered(user, settlement.state, settlement.claimedTierIds);
return settlementSuccess(pendingSettlement, "Jungle 奖励结算成功");
}
async function acknowledgeSettlement(uid: string, rawSettlementId: unknown, pendingSettlement: any) {
const settlementId = String(rawSettlementId || "").trim();
if (!settlementId) {
return fail("未获取到 Jungle 结算编号");
}
// 确认响应丢失后重试也返回成功;有其他待确认结算时则禁止误清理。
if (!pendingSettlement) {
return ackSuccess(settlementId);
}
if (pendingSettlement.settlementId !== settlementId) {
return fail("Jungle 结算编号不匹配");
}
const updateResult = await db.collection("users").where({
_id: uid,
[`${PENDING_SETTLEMENT_FIELD}.settlementId`]: settlementId,
}).update({
[PENDING_SETTLEMENT_FIELD]: null,
});
if (isUpdateFailed(updateResult)) {
const latest = await db.collection("users").where({ _id: uid }).getOne();
const latestPending = normalizePendingSettlement(latest.data?.[PENDING_SETTLEMENT_FIELD]);
if (latestPending && latestPending.settlementId !== settlementId) {
return fail("Jungle 待确认结算已经变化");
}
}
return ackSuccess(settlementId);
}
async function markSettlementOrdersDelivered(user: any, state: any, tierIds: number[]) {
for (const tierId of tierIds) {
const orderNo = String(state.paidOrderNos?.[String(tierId)] || "").trim();
if (orderNo) {
await markOrderDelivered(user.openid, orderNo);
}
}
}
async function markOrderDelivered(openid: unknown, orderNo: string) {
await db.collection("order").where({
outTradeNo: orderNo,
openid,
state: 1,
}).update({
state: 2,
getTime: new Date(Date.now() + 8 * 3600000),
});
}
function readSuccess(state: any, reason: string) {
const serverNow = Date.now();
const expiresAt = state.expiresAt;
return {
code: 1,
data: {
tierStates: state.tierStates,
available: true,
reason,
activityType: state.activityType,
periodKey: state.periodKey,
startsAt: state.startsAt,
revision: state.revision,
expiresAt,
serverNow,
updatedAt: state.updatedAt,
config: {
...JungleConfig.JUNGLE_TREASURE_CONFIG,
endAt: expiresAt,
},
},
msg: "读取成功",
};
}
function claimSuccess(claimedTierId: number, state: any) {
return {
code: 1,
data: {
claimedTierId,
tierStates: state.tierStates,
available: true,
activityType: state.activityType,
periodKey: state.periodKey,
startsAt: state.startsAt,
revision: state.revision,
expiresAt: state.expiresAt,
serverNow: Date.now(),
updatedAt: state.updatedAt,
},
msg: "领取成功",
};
}
function settlementSuccess(pendingSettlement: any, msg: string) {
return {
code: 1,
data: {
available: false,
reason: "settlement_pending",
pendingSettlement,
serverNow: Date.now(),
},
msg,
};
}
function ackSuccess(settlementId: string) {
return {
code: 1,
data: { settlementId, acknowledged: true, serverNow: Date.now() },
msg: "Jungle 结算确认成功",
};
}
function noActivity(reason: string) {
return {
code: 1,
data: { available: false, reason, serverNow: Date.now() },
msg: "当前没有可参加的 Jungle 活动",
};
}
function normalizeResourceSnapshot(raw: any) {
if (typeof raw === "string") {
try {
raw = JSON.parse(raw);
} catch {
return null;
}
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const keys = ["coinAmount", "freezeAmount", "hammerAmount", "magicAmount", "userPowerTime"];
const values: any = {};
for (const key of keys) {
const value = Number(raw[key]);
if (!Number.isSafeInteger(value) || value < 0) {
return null;
}
values[key] = value;
}
return values;
}
function normalizePendingSettlement(raw: any) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const settlementId = String(raw.settlementId || "").trim();
if (!settlementId || !raw.rewards || !normalizeResourceSnapshot(raw.balances)) {
return null;
}
return raw;
}
function createSettlementId(state: any): string {
return `${state.activityId}:${state.startsAt}:${state.expiresAt}`;
}
function hasStoredValue(value: unknown): boolean {
return value !== undefined && value !== null && !(typeof value === "string" && !value.trim());
}
function isUpdateFailed(result: any): boolean {
return Boolean(result && (result.ok === false || result.updated === 0));
}
function fail(msg: string) {
return { code: 0, data: null, msg };
}