2026-06-04 23:05:10 +08:00

232 lines
10 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 { 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;
}