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

184 lines
13 KiB
TypeScript
Raw 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 { createHash } from "crypto";
export const DAY = 86400000;
export const MAX_TARGET = 1000; // Bounds the embedded dedup document, not a live activity value.
export const TABLES = {
configs: "activityConfigs", players: "goldMinerPlayerPeriods",
};
const ERROR_MESSAGES: Record<string, string> = {
CONFIG_UNAVAILABLE: "活动配置不可用,请检查配置字段及适用期次",
INVALID_INPUT: "请求参数不合法,请检查必填字段、类型和取值",
INVALID_ACTION: "不支持的 action,请按接口文档指定操作",
UNAUTHORIZED: "身份验证失败,请检查对应账号的 token 或管理接口的 adminToken",
NOT_FOUND: "未找到对应的玩家、活动记录、订单或结算记录",
NOT_QUALIFIED: "尚未达到本期活动要求的主线通关门槛",
INVALID_PERIOD: "活动期 ID 或起止时间不合法",
PERIOD_ENDED: "活动尚未开始、已经结束或当前没有可参与的活动期",
PERIOD_DISABLED: "该活动期已停用",
TEST_PERIOD_DISABLED: "临时期仅限已开启临时期开关的测试环境",
PURCHASE_DISABLED: "本期已停止新购买,已有权益不受影响",
INVALID_PRODUCT: "itemid 与本期活动商品不匹配",
ALREADY_UNLOCKED: "本期已支付或已分配解锁权益,请查询活动状态,不要重复购买",
NOT_PAID: "本期尚未解锁,请先完成支付并确认活动权益",
INVALID_TASK: "taskId 不属于该玩家的本期任务列表",
TARGET_NOT_REACHED: "本期累计通关次数尚未达到该任务目标",
INVALID_GRANT: "grantId 不存在或与本次任务、结算奖励不匹配",
EVENT_PAYLOAD_CONFLICT: "同一 eventId 或通关序号已用于不同的上报内容,请用原参数重试",
PERIOD_SETTLEMENT_REQUIRED: "活动已结束,服务器正在结算奖励,请查询补发清单",
SETTLEMENT_NOT_READY: "活动尚未结束或服务器尚未完成结算,请稍后查询补发清单",
DELIVERY_PENDING: "该结算单仍有奖励未完成发货确认",
ENTITLEMENT_CONFLICT: "目标期已有其他订单或权益,无法重复分配,请核对订单",
PAYMENT_PENDING: "订单尚未确认支付,请完成支付后重试查询",
PAYMENT_CONFIG_UNAVAILABLE: "支付配置不完整或不合法,请检查该环境的支付配置",
PAYMENT_CHANNEL_DISABLED: "旧客服支付渠道尚未启用,需配置 GOLD_MINER_LEGACY_ENABLED=true",
PAYMENT_CHANNEL_CONFLICT: "本期已有其他渠道订单,请继续原渠道支付",
INVALID_PAYMENT: "支付通知或交易信息校验失败,请核对订单、商品、金额及签名",
INVALID_PAYMENT_SIGNATURE: "支付响应签名、时间戳或验签公钥标识校验失败",
PAYMENT_CONFLICT: "该订单已关联另一笔支付交易,需要人工核对",
ORDER_CLOSED: "该订单已关闭或所属活动已结束,不能继续发起支付",
ORDER_ENVIRONMENT_MISMATCH: "订单的测试/正式环境或商户信息与当前部署不一致",
ORDER_SNAPSHOT_CONFLICT: "转单记录与原订单的账号、商品、价格或活动快照不一致",
CUSTOMER_SERVICE_UNAVAILABLE: "客服消息发送失败或缺少有效微信 access_token,请稍后重试",
MANUAL_REVIEW: "该订单存在权益分配或支付冲突,需要人工核对",
RETRYABLE: "处理暂未完成,请稍后使用相同请求参数重试",
};
export class ActivityError extends Error {
errorCode: string;
constructor(code: string, message?: string) {
super(message || ERROR_MESSAGES[code] || (code.startsWith("MERCHANT_") ? `微信商户接口返回错误(${code}),请核对商户配置和订单状态` : `活动处理失败(${code})`));
this.errorCode = code;
}
}
export function reject(code: string, message?: string): never { throw new ActivityError(code, message); }
export function failure(error: any) {
return {
code: 0, data: null, errorCode: error instanceof ActivityError ? error.errorCode : "RETRYABLE",
msg: error instanceof ActivityError ? error.message : "黄金矿工处理失败,请使用原请求重试"
};
}
export function key(...parts: any[]) { return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); }
export function integer(value: any): number {
return typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
}
export function identifier(value: any): boolean { return typeof value === "string" && /^[A-Za-z0-9_:-]{1,100}$/.test(value); }
export function isProduct(value: any): boolean { return typeof value === "string" && /^gold_miner(?:_[A-Za-z0-9_-]+)?$/.test(value); }
export function parseObject(value: any): any {
if (typeof value === "string") { try { value = JSON.parse(value); } catch { reject("INVALID_INPUT", "请求参数不是合法的 JSON 字符串"); } }
if (!value || typeof value !== "object" || Array.isArray(value)) reject("INVALID_INPUT", "请求参数必须是 JSON 对象,不能是 null、数组或普通字符串");
return value;
}
export function calendar(now = Date.now()) {
const china = new Date(now + 8 * 3600000);
const midnight = Date.UTC(china.getUTCFullYear(), china.getUTCMonth(), china.getUTCDate()) - 8 * 3600000;
const startsAt = midnight - ((china.getUTCDay() - 4 + 7) % 7) * DAY;
const id = (time: number) => "goldMiner:" + new Date(time + 8 * 3600000).toISOString().slice(0, 10);
return {
periodId: id(startsAt), startsAt, endsAt: startsAt + 4 * DAY,
nextPeriodId: id(startsAt + 7 * DAY), nextStartsAt: startsAt + 7 * DAY
};
}
export function periodDates(periodId: any) {
if (typeof periodId !== "string" || !/^goldMiner:\d{4}-\d{2}-\d{2}$/.test(periodId)) reject("INVALID_PERIOD", "普通期 periodId 必须为 goldMiner:YYYY-MM-DD,日期须为北京时间周四");
const time = Date.parse(periodId.slice(10) + "T00:00:00+08:00");
if (!Number.isFinite(time)) reject("INVALID_PERIOD", "periodId 中的日期不是有效日期");
const value = calendar(time);
if (value.periodId !== periodId || value.startsAt !== time) reject("INVALID_PERIOD", "periodId 中的日期必须真实存在且为北京时间周四");
return value;
}
export function open(period: any, now = Date.now()) { return now >= period.startsAt && now < period.endsAt; }
export const testPeriodsEnabled = () => process.env.PAYMENT_APP_ENV === "test" && process.env.GOLD_MINER_TEST_PERIODS_ENABLED === "true";
export const isTestPeriod = (id: any) => typeof id === "string" && id.startsWith("goldMiner:test:");
export function validatePeriodId(id: any) {
if (!isTestPeriod(id)) { periodDates(id); return; }
if (!testPeriodsEnabled()) reject("TEST_PERIOD_DISABLED", "临时期仅允许在已开启临时期开关的测试环境使用");
if (!/^goldMiner:test:[A-Za-z0-9_-]{1,60}$/.test(id)) reject("INVALID_PERIOD", "临时期 periodId 必须为 goldMiner:test: 加 1~60 位英文字母、数字、下划线或连字符");
}
/** Return a canonical snapshot, rejecting placeholders and unsupported reward types. */
export function validateConfig(raw: any): any {
const invalid = (field: string, reason: string): never => reject("CONFIG_UNAVAILABLE", `${field} ${reason}`);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) invalid("config", "必须为 JSON 对象");
if (raw.vipTiers !== undefined) {
if (!Array.isArray(raw.vipTiers) || raw.vipTiers.length !== 6) invalid("config.vipTiers", "必须包含 VIP0~VIP5 六个档位");
const products = new Set();
const tiers = raw.vipTiers.map((tier: any, index: number) => {
const path = `config.vipTiers[${index}]`;
if (!tier || tier.vipLevel !== index) invalid(`${path}.vipLevel`, `必须为数字 ${index},按 VIP0~VIP5 排列`);
let snapshot: any;
try { snapshot = validateConfig({ ...raw, vipTiers: undefined,
productId: tier.productId, priceFen: tier.priceFen, tasks: tier.tasks }); }
catch (error) {
if (error instanceof ActivityError) reject(error.errorCode, error.message.replace(/^config\./, `${path}.`));
throw error;
}
if (snapshot.tasks.length !== 10) invalid(`${path}.tasks`, "每个 VIP 档位必须包含 10 个任务");
if (products.has(snapshot.productId)) invalid(`${path}.productId`, "各 VIP 档位必须使用不同商品 ID");
products.add(snapshot.productId);
return { vipLevel: index, productId: snapshot.productId, priceFen: snapshot.priceFen, tasks: snapshot.tasks };
});
for (let i = 1; i < tiers.length; i++) {
for (let j = 0; j < 10; j++) {
if (tiers[i].tasks[j].targetWins <= tiers[0].tasks[j].targetWins
|| tiers[i].tasks[j].targetWins < tiers[i - 1].tasks[j].targetWins)
invalid(`config.vipTiers[${i}].tasks[${j}].targetWins`, "必须严格高于 VIP0 对应任务,且不得低于上一 VIP 档位");
}
if (tiers[i].tasks[9].targetWins <= tiers[i - 1].tasks[9].targetWins)
invalid(`config.vipTiers[${i}].tasks[9].targetWins`, "最终通关目标必须逐 VIP 档位提高");
}
return { configVersion: raw.configVersion, effectiveFromPeriodId: raw.effectiveFromPeriodId,
publishedAt: raw.publishedAt, unlockPassedLevel: raw.unlockPassedLevel, timezone: "Asia/Shanghai", currency: "CNY", vipTiers: tiers };
}
if (!identifier(raw.configVersion)) invalid("config.configVersion", "必须为 1~100 位英文字母、数字、下划线、冒号或连字符");
if (!isProduct(raw.productId)) invalid("config.productId", "必须为 gold_miner 或 gold_miner_ 加英文字母、数字、下划线或连字符后缀");
if (raw.timezone !== "Asia/Shanghai") invalid("config.timezone", "必须为 Asia/Shanghai");
if (raw.currency !== "CNY") invalid("config.currency", "必须为 CNY");
if (!Number.isSafeInteger(raw.unlockPassedLevel) || raw.unlockPassedLevel < 1) invalid("config.unlockPassedLevel", "必须为大于 0 的安全整数(JSON 数字,不能用字符串)");
if (!Number.isSafeInteger(raw.priceFen) || raw.priceFen <= 0) invalid("config.priceFen", "必须为大于 0 的安全整数,单位为分(JSON 数字,不能用字符串)");
if (!Number.isSafeInteger(raw.publishedAt) || raw.publishedAt <= 0) invalid("config.publishedAt", "必须为大于 0 的安全整数毫秒时间戳");
if (!Array.isArray(raw.tasks) || !raw.tasks.length || raw.tasks.length > 100) invalid("config.tasks", "必须为包含 1~100 个任务的数组");
try { validatePeriodId(raw.effectiveFromPeriodId); }
catch (error) {
if (error instanceof ActivityError) reject(error.errorCode, `config.effectiveFromPeriodId: ${error.message}`);
throw error;
}
const ids = new Set(); let previous = 0;
const tasks = raw.tasks.map((task: any, index: number) => {
const path = `config.tasks[${index}]`;
if (!task || typeof task !== "object" || Array.isArray(task)) invalid(path, "必须为任务对象");
if (!identifier(task.taskId)) invalid(`${path}.taskId`, "必须为 1~100 位英文字母、数字、下划线、冒号或连字符");
if (ids.has(task.taskId)) invalid(`${path}.taskId`, "与前面的任务 ID 重复");
if (task.sequence !== index + 1) invalid(`${path}.sequence`, `必须为数字 ${index + 1},任务顺序从 1 连续递增`);
if (!Number.isSafeInteger(task.targetWins) || task.targetWins < 1 || task.targetWins > MAX_TARGET) invalid(`${path}.targetWins`, `必须为 1~${MAX_TARGET} 的整数`);
if (task.targetWins <= previous) invalid(`${path}.targetWins`, `必须大于前一档目标(${previous})`);
if (!Array.isArray(task.items) || task.items.length !== 1) invalid(`${path}.items`, "当前必须为仅包含 1 项金币奖励的数组");
const item = task.items[0];
if (!item || typeof item !== "object" || Array.isArray(item)) invalid(`${path}.items[0]`, "必须为奖励对象");
if (item.type !== "coin") invalid(`${path}.items[0].type`, "当前仅支持 coin(金币)");
if (!Number.isSafeInteger(item.count) || item.count <= 0) invalid(`${path}.items[0].count`, "必须为大于 0 的安全整数(JSON 数字,不能用字符串)");
previous = task.targetWins; ids.add(task.taskId);
return {
taskId: task.taskId, sequence: task.sequence, targetWins: task.targetWins,
items: [{ type: "coin", count: task.items[0].count }]
};
});
if (!Number.isSafeInteger(tasks.reduce((sum: number, t: any) => sum + t.items[0].count, 0))) invalid("config.tasks", "金币奖励总和超出安全整数范围");
return {
configVersion: raw.configVersion, effectiveFromPeriodId: raw.effectiveFromPeriodId,
publishedAt: raw.publishedAt, unlockPassedLevel: raw.unlockPassedLevel, timezone: "Asia/Shanghai",
productId: raw.productId, priceFen: raw.priceFen, currency: "CNY", tasks
};
}
/** VIP is server-owned. Missing or invalid values use VIP0, without first-payer admission checks. */
export function selectVipConfig(config: any, user: any) {
if (!config.vipTiers) return config;
const level = integer(user?.vip_level);
const vipLevel = Number.isSafeInteger(level) && level >= 0 && level <= 5 ? level : 0;
const { vipTiers, ...common } = config;
return { ...common, ...vipTiers[vipLevel] };
}
export default async function () { return { code: 0, msg: "internal module" }; }