232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
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<number>();
|
||
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<number>();
|
||
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;
|
||
}
|