import { z } from "zod"; const statusValues = ["active", "disabled"] as const; const settlementModes = ["daily", "half_month"] as const; const settlementTriggerModes = ["automatic", "manual"] as const; const teamPolicyTypes = ["bd", "admin"] as const; const levelSchema = z.object({ // 输入框值可能是 string,复制/恢复表单时也可能是 number;统一在 superRefine 中转数值校验。 agencySalaryUsd: z.union([z.string(), z.number()]), hostCoinReward: z.union([z.string(), z.number()]), hostSalaryUsd: z.union([z.string(), z.number()]), level: z.union([z.string(), z.number()]), requiredDiamonds: z.union([z.string(), z.number()]), sortOrder: z.union([z.string(), z.number()]).optional(), status: z.enum(statusValues), }); export const hostAgencyPolicyFormSchema = z .object({ effectiveFrom: z.union([z.string(), z.number()]).optional(), effectiveTo: z.union([z.string(), z.number()]).optional(), enabled: z.boolean(), levels: z.array(levelSchema).min(1, "请至少配置一个等级"), name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"), regionId: z.union([z.string(), z.number()]), residualDiamondsPerUsd: z.union([z.string(), z.number()]), settlementMode: z.enum(settlementModes), settlementTriggerMode: z.enum(settlementTriggerModes), }) .superRefine((value, context) => { // 前端先做完整业务校验,降低运营误填成本;后端仍是最终可信校验边界。 const regionId = Number(value.regionId); if (!Number.isInteger(regionId) || regionId <= 0) { context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] }); } if (!isNonNegativeInteger(value.residualDiamondsPerUsd)) { context.addIssue({ code: "custom", message: "剩余钻石兑美元配置不正确", path: ["residualDiamondsPerUsd"], }); } const effectiveFromMs = timeValueToMs(value.effectiveFrom); const effectiveToMs = timeValueToMs(value.effectiveTo); if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) { context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] }); } const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level)); const seenLevels = new Set(); let previousRequiredDiamonds = 0; let previousHostSalary = 0; let previousHostCoinReward = 0; let previousAgencySalary = 0; normalizedLevels.forEach((level, index) => { // 等级号、钻石门槛和三类累计权益必须单调,保证后续差额结算不会出现负值或多重命中。 const levelNo = Number(level.level); if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) { context.addIssue({ code: "custom", message: "等级必须为不重复的正整数", path: ["levels", index, "level"], }); } seenLevels.add(levelNo); const requiredDiamonds = Number(level.requiredDiamonds); if ( !Number.isInteger(requiredDiamonds) || requiredDiamonds <= 0 || requiredDiamonds <= previousRequiredDiamonds ) { context.addIssue({ code: "custom", message: "钻石门槛必须逐级递增", path: ["levels", index, "requiredDiamonds"], }); } const hostSalary = decimalToScaled(level.hostSalaryUsd, 2); if (hostSalary < 0 || hostSalary < previousHostSalary) { context.addIssue({ code: "custom", message: "主播工资不能小于上一等级", path: ["levels", index, "hostSalaryUsd"], }); } const hostCoinReward = Number(level.hostCoinReward); if (!Number.isInteger(hostCoinReward) || hostCoinReward < 0 || hostCoinReward < previousHostCoinReward) { context.addIssue({ code: "custom", message: "金币奖励不能小于上一等级", path: ["levels", index, "hostCoinReward"], }); } const agencySalary = decimalToScaled(level.agencySalaryUsd, 2); if (agencySalary < 0 || agencySalary < previousAgencySalary) { context.addIssue({ code: "custom", message: "代理工资不能小于上一等级", path: ["levels", index, "agencySalaryUsd"], }); } previousRequiredDiamonds = requiredDiamonds; previousHostSalary = hostSalary; previousHostCoinReward = hostCoinReward; previousAgencySalary = agencySalary; }); }); const teamLevelSchema = z.object({ level: z.union([z.string(), z.number()]), ratePercent: z.union([z.string(), z.number()]), salaryUsd: z.union([z.string(), z.number()]), sortOrder: z.union([z.string(), z.number()]).optional(), status: z.enum(statusValues), thresholdUsd: z.union([z.string(), z.number()]), }); export const teamSalaryPolicyFormSchema = z .object({ effectiveFrom: z.union([z.string(), z.number()]).optional(), effectiveTo: z.union([z.string(), z.number()]).optional(), enabled: z.boolean(), levels: z.array(teamLevelSchema).min(1, "请至少配置一个等级"), name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"), policyType: z.enum(teamPolicyTypes), regionId: z.union([z.string(), z.number()]), settlementTriggerMode: z.enum(settlementTriggerModes), }) .superRefine((value, context) => { const regionId = Number(value.regionId); if (!Number.isInteger(regionId) || regionId <= 0) { context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] }); } const effectiveFromMs = timeValueToMs(value.effectiveFrom); const effectiveToMs = timeValueToMs(value.effectiveTo); if (effectiveFromMs < 0 || effectiveToMs < 0 || (effectiveToMs > 0 && effectiveToMs <= effectiveFromMs)) { context.addIssue({ code: "custom", message: "生效时间范围不正确", path: ["effectiveTo"] }); } const normalizedLevels = [...value.levels].sort((left, right) => Number(left.level) - Number(right.level)); const seenLevels = new Set(); let previousThreshold = 0; let previousSalary = 0; normalizedLevels.forEach((level, index) => { // BD/Admin 等级表保存累计门槛和累计工资,递增校验保证后续差额结算不会倒扣。 const levelNo = Number(level.level); if (!Number.isInteger(levelNo) || levelNo <= 0 || seenLevels.has(levelNo)) { context.addIssue({ code: "custom", message: "等级必须为不重复的正整数", path: ["levels", index, "level"], }); } seenLevels.add(levelNo); const threshold = decimalToScaled(level.thresholdUsd, 2, false); if (threshold <= 0 || threshold <= previousThreshold) { context.addIssue({ code: "custom", message: "收入门槛必须逐级递增", path: ["levels", index, "thresholdUsd"], }); } const rate = decimalToScaled(level.ratePercent, 4, false); if (rate <= 0) { context.addIssue({ code: "custom", message: "提成比例必须大于 0", path: ["levels", index, "ratePercent"], }); } const salary = decimalToScaled(level.salaryUsd, 2); if (salary < 0 || salary < previousSalary) { context.addIssue({ code: "custom", message: "累计工资不能小于上一等级", path: ["levels", index, "salaryUsd"], }); } previousThreshold = threshold; previousSalary = salary; }); }); function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) { return decimalToScaled(value, scale, allowZero) >= 0; } function isNonNegativeInteger(value: unknown) { const raw = String(value ?? "").trim(); return /^\d+$/.test(raw); } function decimalToScaled(value: unknown, scale: number, allowZero = true) { // 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。 const raw = String(value ?? "").trim(); if (!raw || raw.startsWith("-")) { return -1; } const pattern = new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`); if (!pattern.test(raw)) { return -1; } const [whole, fraction = ""] = raw.split("."); const scaled = Number(whole) * 10 ** scale + Number(fraction.padEnd(scale, "0")); if (!Number.isSafeInteger(scaled) || (!allowZero && scaled <= 0)) { return -1; } return scaled; } function timeValueToMs(value: unknown) { // 空值表示长期/立即生效;非法日期返回 -1,让上层统一提示时间范围错误。 if (value === "" || value === null || value === undefined) { return 0; } if (typeof value === "number") { return Number.isFinite(value) && value >= 0 ? value : -1; } const text = String(value).trim(); if (!text) { return 0; } const number = Number(value); if (/^\d+$/.test(text)) { return Number.isFinite(number) ? number : -1; } const date = new Date(text); const ms = date.getTime(); return Number.isFinite(ms) ? ms : -1; }