184 lines
13 KiB
TypeScript
184 lines
13 KiB
TypeScript
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" }; }
|