2026-07-17 16:55:15 +08:00

588 lines
24 KiB
JavaScript
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";
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
import {
resourceRequiresExplicitVisualFormat,
roomBorderFormatOptions,
roomNameStyleInputError,
} from "@/features/resources/resourceMetadata.js";
const resourceTypes = [
"avatar_frame",
"profile_card",
"coin",
"vehicle",
"chat_bubble",
"badge",
"floating_screen",
"gift",
"mic_seat_icon",
"mic_seat_animation",
"room_border",
"room_name_style",
"emoji_pack",
"vip_trial_card",
];
const walletAssetTypes = ["COIN"];
const resourcePriceTypes = ["coin", "free"];
const avatarFrameKinds = ["normal", "level"];
const badgeForms = ["strip", "tile"];
const badgeKinds = ["normal", "level"];
const badgeLevelTracks = ["wealth", "game", "charm"];
const resourceShopDurations = ["1", "3", "7"];
const cpRelationTypes = cpRelationTypeOptions.map(([value]) => value);
const optionalWalletAssetTypeSchema = z
.preprocess((value) => {
if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toUpperCase();
return normalized || undefined;
}, z.string().optional())
.refine((value) => !value || walletAssetTypes.includes(value), "请选择金币");
export const resourceCreateFormSchema = z
.object({
animationUrl: z.string().trim().max(512, "动效素材不能超过 512 个字符"),
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
badgeForm: z.enum(badgeForms).optional(),
badgeKind: z.enum(badgeKinds).optional(),
enabled: z.boolean(),
levelTrack: z.enum(badgeLevelTracks).optional().or(z.literal("")),
managerGrantEnabled: z.boolean(),
metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(),
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
coinPrice: z.union([z.string(), z.number()]).optional(),
previewUrl: z.string().trim().max(512, "资源封面不能超过 512 个字符"),
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
resourceType: z.enum(resourceTypes, "请选择资源类型"),
roomBorderFormat: z.string().optional(),
roomNameAngleDegrees: z.union([z.string(), z.number()]).optional(),
roomNameStops: z.string().optional(),
roomNameTextColors: z.string().optional(),
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
// 彩色房名是纯样式资源,不要求伪造图片 URL其他既有资源继续保持封面和素材的原校验边界。
if (value.resourceType !== "room_name_style") {
if (!value.previewUrl) {
context.addIssue({ code: "custom", message: "请上传资源封面", path: ["previewUrl"] });
}
if (!value.animationUrl) {
context.addIssue({ code: "custom", message: "请上传动效素材", path: ["animationUrl"] });
}
} else {
const styleError = roomNameStyleInputError(value);
if (styleError) {
context.addIssue({ code: "custom", message: styleError, path: ["roomNameTextColors"] });
}
}
if (
resourceRequiresExplicitVisualFormat(value.resourceType) &&
!roomBorderFormatOptions.includes(value.roomBorderFormat)
) {
context.addIssue({ code: "custom", message: "请选择视觉资源素材格式", path: ["roomBorderFormat"] });
}
if (value.resourceType !== "coin") {
if (value.resourceType === "badge" && !value.badgeForm) {
context.addIssue({
code: "custom",
message: "请选择徽章属性",
path: ["badgeForm"],
});
}
if (value.resourceType === "badge" && value.badgeKind === "level" && !value.levelTrack) {
context.addIssue({
code: "custom",
message: "请选择等级徽章所属等级",
path: ["levelTrack"],
});
}
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
context.addIssue({
code: "custom",
message: "请选择等级头像框所属等级",
path: ["levelTrack"],
});
}
} else {
const amount = Number(value.walletAssetAmount);
if (!Number.isInteger(amount) || amount <= 0) {
context.addIssue({
code: "custom",
message: "请输入金币数量",
path: ["walletAssetAmount"],
});
}
}
if (value.priceType === "coin") {
const coinPrice = Number(value.coinPrice);
if (!Number.isInteger(coinPrice) || coinPrice <= 0) {
context.addIssue({
code: "custom",
message: "请输入资源价格",
path: ["coinPrice"],
});
}
}
if (hasUnconfirmedMp4Layout(value.metadataJson)) {
context.addIssue({
code: "custom",
message: "请确认 MP4 透明布局",
path: ["metadataJson"],
});
}
});
export const resourceFormSchema = resourceCreateFormSchema;
export const emojiPackFormSchema = z.object({
animationUrl: z.string().trim().min(1, "请上传动效图").max(512, "动效图不能超过 512 个字符"),
category: z.string().trim().min(1, "请输入分类").max(64, "分类不能超过 64 个字符"),
coverUrl: z.string().trim().min(1, "请上传封面图").max(512, "封面图不能超过 512 个字符"),
name: z.string().trim().min(1, "请输入名称").max(128, "名称不能超过 128 个字符"),
pricingType: z.enum(["free", "paid"]),
regionIds: z.array(z.union([z.string(), z.number()])).optional(),
sortOrder: z.union([z.string(), z.number()]).optional(),
});
const groupItemSchema = z.object({
durationDays: z.union([z.string(), z.number()]).optional(),
itemType: z.enum(["resource", "wallet_asset"]),
resourceId: z.union([z.string(), z.number()]).optional(),
sortOrder: z.union([z.string(), z.number()]).optional(),
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
walletAssetType: optionalWalletAssetTypeSchema,
});
export const resourceGroupCreateFormSchema = z
.object({
description: z.string().trim().max(512, "资源组说明不能超过 512 个字符").optional(),
enabled: z.boolean(),
groupCode: z.string().trim().min(1, "请输入资源组编码").max(96, "资源组编码不能超过 96 个字符"),
items: z.array(groupItemSchema).min(1, "请至少配置一个资源组条目"),
name: z.string().trim().min(1, "请输入资源组名称").max(128, "资源组名称不能超过 128 个字符"),
sortOrder: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
const resourceIds = new Set();
const walletAssets = new Set();
value.items.forEach((item, index) => {
if (item.itemType === "wallet_asset") {
const amount = Number(item.walletAssetAmount);
if (!item.walletAssetType || !Number.isInteger(amount) || amount <= 0) {
context.addIssue({
code: "custom",
message: "请输入金币数量",
path: ["items", index, "walletAssetAmount"],
});
}
if (item.walletAssetType) {
if (walletAssets.has(item.walletAssetType)) {
context.addIssue({
code: "custom",
message: "同一种钱包资产不能重复配置",
path: ["items", index, "walletAssetType"],
});
}
walletAssets.add(item.walletAssetType);
}
return;
}
const resourceId = Number(item.resourceId);
const durationDays = Number(item.durationDays);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
code: "custom",
message: "请选择资源",
path: ["items", index, "resourceId"],
});
}
if (!Number.isInteger(durationDays) || durationDays <= 0) {
context.addIssue({
code: "custom",
message: "请输入有效天数",
path: ["items", index, "durationDays"],
});
}
if (resourceIds.has(resourceId)) {
context.addIssue({
code: "custom",
message: "同一个资源不能重复配置",
path: ["items", index, "resourceId"],
});
}
resourceIds.add(resourceId);
});
});
const identityAutoGrantItemSchema = z.object({
identityType: z.enum(resourceIdentityAutoGrantTypes, "请选择身份"),
resourceGroupId: z.union([z.string(), z.number()]).optional(),
});
export const resourceIdentityAutoGrantFormSchema = z
.object({
items: z.array(identityAutoGrantItemSchema),
})
.superRefine((value, context) => {
const requiredIdentityTypes = new Set(resourceIdentityAutoGrantTypes);
const seenIdentityTypes = new Set();
if (value.items.length !== resourceIdentityAutoGrantTypes.length) {
context.addIssue({
code: "custom",
message: "必须配置所有身份",
path: ["items"],
});
}
value.items.forEach((item, index) => {
if (seenIdentityTypes.has(item.identityType)) {
context.addIssue({
code: "custom",
message: "身份不能重复配置",
path: ["items", index, "identityType"],
});
}
seenIdentityTypes.add(item.identityType);
requiredIdentityTypes.delete(item.identityType);
const resourceGroupId =
item.resourceGroupId === undefined || item.resourceGroupId === "" ? 0 : Number(item.resourceGroupId);
if (!Number.isInteger(resourceGroupId) || resourceGroupId < 0) {
context.addIssue({
code: "custom",
message: "资源组必须是有效资源组",
path: ["items", index, "resourceGroupId"],
});
}
});
requiredIdentityTypes.forEach((identityType) => {
context.addIssue({
code: "custom",
message: "必须配置所有身份",
path: ["items", identityType],
});
});
});
export const giftFormSchema = z
.object({
chargeAssetType: z.enum(["COIN"]),
coinPrice: z.union([z.string(), z.number()]),
cpRelationType: z.string().trim().optional(),
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
enabled: z.boolean(),
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
giftTypeCode: z
.string()
.trim()
.min(1, "请选择礼物类型")
.max(32, "礼物类型不能超过 32 个字符")
.regex(/^[a-z][a-z0-9_]*$/, "礼物类型只能使用小写字母、数字和下划线"),
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
presentationJson: z.string().trim().optional(),
priceVersion: z.string().trim().min(1, "请输入价格版本").max(64, "价格版本不能超过 64 个字符"),
regionIds: z.array(z.union([z.string(), z.number()])).min(1, "请选择区域"),
resourceId: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
const resourceId = Number(value.resourceId);
const coinPrice = Number(value.coinPrice);
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
code: "custom",
message: "请选择礼物资源",
path: ["resourceId"],
});
}
if (!Number.isInteger(coinPrice) || coinPrice < 0) {
context.addIssue({
code: "custom",
message: "请输入价格",
path: ["coinPrice"],
});
}
if (value.giftTypeCode === "cp" && !cpRelationTypes.includes(value.cpRelationType)) {
context.addIssue({
code: "custom",
message: "请选择关系类型",
path: ["cpRelationType"],
});
}
if (effectiveFromMs < 0) {
context.addIssue({
code: "custom",
message: "请选择有效开始时间",
path: ["effectiveFrom"],
});
}
if (effectiveToMs < 0) {
context.addIssue({
code: "custom",
message: "请选择有效结束时间",
path: ["effectiveTo"],
});
}
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({
code: "custom",
message: "有效结束时间必须晚于开始时间",
path: ["effectiveTo"],
});
}
value.regionIds.forEach((regionId, index) => {
const normalizedRegionId = Number(regionId);
if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) {
context.addIssue({
code: "custom",
message: "请选择区域",
path: ["regionIds", index],
});
}
});
if (value.presentationJson) {
try {
JSON.parse(value.presentationJson);
} catch {
context.addIssue({
code: "custom",
message: "展示配置必须是合法 JSON",
path: ["presentationJson"],
});
}
}
});
const giftTypeTabKeySchema = z
.string()
.trim()
.min(1, "Tab Key 不能为空")
.max(32, "Tab Key 不能超过 32 个字符")
.regex(/^[a-z][a-z0-9_]*$/, "Tab Key 只能使用小写字母、数字和下划线");
export const giftTypeConfigFormSchema = z.object({
items: z
.array(
z.object({
enabled: z.boolean(),
displayName: z.string().trim().min(1, "请输入显示名称").max(64, "显示名称不能超过 64 个字符"),
sortOrder: z.union([z.string(), z.number()]).optional(),
tabKey: giftTypeTabKeySchema,
tabName: z.string().trim().min(1, "请输入 Tab Name").max(32, "Tab Name 不能超过 32 个字符"),
}),
)
.min(1, "请至少保留一个礼物类型"),
});
export const resourceShopItemsFormSchema = z
.object({
items: z
.array(
z.object({
coinPrice: z.union([z.string(), z.number()]),
durationDays: z.union([z.string(), z.number()]),
effectiveFromMs: z.union([z.string(), z.number()]).optional(),
effectiveToMs: z.union([z.string(), z.number()]).optional(),
resourceId: z.union([z.string(), z.number()]),
shopItemId: z.union([z.string(), z.number()]).optional(),
sortOrder: z.union([z.string(), z.number()]).optional(),
status: z.enum(["active", "disabled"]).optional(),
}),
)
.min(1, "请至少选择一个资源"),
})
.superRefine((value, context) => {
const specificationKeys = new Set();
value.items.forEach((item, index) => {
const resourceId = Number(item.resourceId);
const durationDays = String(item.durationDays || "").trim();
const coinPrice = Number(item.coinPrice);
const effectiveFromMs = optionalEpochMs(item.effectiveFromMs);
const effectiveToMs = optionalEpochMs(item.effectiveToMs);
const sortOrder = Number(item.sortOrder || 0);
const specificationKey = `${resourceId}:${durationDays}`;
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
code: "custom",
message: "请选择资源",
path: ["items", index, "resourceId"],
});
}
if (specificationKeys.has(specificationKey)) {
context.addIssue({
code: "custom",
message: "同一个资源的相同售卖天数不能重复添加",
path: ["items", index, "durationDays"],
});
}
specificationKeys.add(specificationKey);
if (!resourceShopDurations.includes(durationDays)) {
context.addIssue({
code: "custom",
message: "售卖天数只能选择 1、3、7 天",
path: ["items", index, "durationDays"],
});
}
if (!Number.isSafeInteger(coinPrice) || coinPrice <= 0) {
context.addIssue({
code: "custom",
message: "请输入大于 0 的售卖价格",
path: ["items", index, "coinPrice"],
});
}
if (effectiveFromMs < 0) {
context.addIssue({
code: "custom",
message: "请选择生效开始时间",
path: ["items", index, "effectiveFromMs"],
});
}
if (effectiveToMs < 0) {
context.addIssue({
code: "custom",
message: "请选择生效结束时间",
path: ["items", index, "effectiveToMs"],
});
}
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({
code: "custom",
message: "生效结束时间必须晚于开始时间",
path: ["items", index, "effectiveToMs"],
});
}
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
context.addIssue({
code: "custom",
message: "排序不能小于 0",
path: ["items", index, "sortOrder"],
});
}
});
});
function optionalEpochMs(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
return 0;
}
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : -1;
}
function datetimeLocalToMs(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return 0;
}
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : -1;
}
export const resourceGrantFormSchema = z
.object({
durationDays: z.union([z.string(), z.number()]).optional(),
groupId: z.union([z.string(), z.number()]).optional(),
quantity: z.union([z.string(), z.number()]).optional(),
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
resourceId: z.union([z.string(), z.number()]).optional(),
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
subjectType: z.enum(["resource", "resource_group", "vip", "vip_trial_card"]),
targetUserId: z.union([z.string(), z.number()]),
vipLevel: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
const targetUserId = String(value.targetUserId || "").trim();
const resourceIds = grantResourceIds(value);
const groupId = Number(value.groupId || 0);
const quantity = Number(value.quantity || 1);
const durationDays = Number(value.durationDays || 0);
const vipLevel = Number(value.vipLevel || 0);
if (!/^[1-9]\d*$/.test(targetUserId)) {
context.addIssue({
code: "custom",
message: "请输入用户 ID",
path: ["targetUserId"],
});
}
if (value.subjectType === "resource") {
if (!resourceIds.length) {
context.addIssue({
code: "custom",
message: "请选择资源",
path: ["resourceIds"],
});
}
resourceIds.forEach((resourceIdValue, index) => {
const resourceId = Number(resourceIdValue || 0);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
code: "custom",
message: "请选择资源",
path: ["resourceIds", index],
});
}
});
if (!Number.isInteger(quantity) || quantity <= 0) {
context.addIssue({
code: "custom",
message: "请输入数量",
path: ["quantity"],
});
}
if (!Number.isFinite(durationDays) || durationDays < 0) {
context.addIssue({
code: "custom",
message: "请输入有效天数",
path: ["durationDays"],
});
}
}
if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) {
context.addIssue({
code: "custom",
message: "请选择资源组",
path: ["groupId"],
});
}
if (
(value.subjectType === "vip" || value.subjectType === "vip_trial_card") &&
(!Number.isInteger(vipLevel) || vipLevel <= 0)
) {
context.addIssue({
code: "custom",
message: "请选择VIP等级",
path: ["vipLevel"],
});
}
if (value.subjectType === "vip_trial_card") {
if (!Number.isInteger(durationDays) || durationDays <= 0) {
context.addIssue({
code: "custom",
message: "VIP体验卡有效天数必须大于 0",
path: ["durationDays"],
});
}
}
});
function grantResourceIds(value) {
if (Array.isArray(value.resourceIds) && value.resourceIds.length) {
return value.resourceIds;
}
return value.resourceId ? [value.resourceId] : [];
}