import { z } from "zod"; const resourceTypes = [ "avatar_frame", "profile_card", "coin", "vehicle", "chat_bubble", "badge", "floating_screen", "gift", "mic_seat_icon", "mic_seat_animation", "emoji_pack", ]; const walletAssetTypes = ["COIN"]; const resourcePriceTypes = ["coin", "free"]; const badgeForms = ["strip", "tile"]; const badgeKinds = ["normal", "level"]; const badgeLevelTracks = ["wealth", "game", "charm"]; const resourceShopDurations = ["1", "3", "7"]; 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().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"), badgeForm: z.enum(badgeForms).optional(), badgeKind: z.enum(badgeKinds).optional(), enabled: z.boolean(), levelTrack: z.enum(badgeLevelTracks).optional().or(z.literal("")), 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().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"), priceType: z.enum(resourcePriceTypes, "请选择价格类型"), resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"), resourceType: z.enum(resourceTypes, "请选择资源类型"), walletAssetAmount: z.union([z.string(), z.number()]).optional(), }) .superRefine((value, context) => { 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"], }); } } 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"], }); } } }); 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); }); }); export const giftFormSchema = z .object({ chargeAssetType: z.enum(["COIN"]), coinPrice: z.union([z.string(), z.number()]), 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 个字符"), giftPointAmount: z.union([z.string(), z.number()]).optional(), giftTypeCode: z .string() .trim() .min(1, "请选择礼物类型") .max(32, "礼物类型不能超过 32 个字符") .regex(/^[a-z][a-z0-9_]*$/, "礼物类型只能使用小写字母、数字和下划线"), heatValue: z.union([z.string(), z.number()]).optional(), 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 giftPointAmount = Number(value.giftPointAmount || 0); const heatValue = Number(value.heatValue || 0); 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 (!Number.isInteger(giftPointAmount) || giftPointAmount < 0) { context.addIssue({ code: "custom", message: "请输入有效积分", path: ["giftPointAmount"], }); } if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) { context.addIssue({ code: "custom", message: "积分必须同步价格", path: ["giftPointAmount"], }); } if (!Number.isInteger(heatValue) || heatValue < 0) { context.addIssue({ code: "custom", message: "请输入有效热度", path: ["heatValue"], }); } 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({ durationDays: z.union([z.string(), z.number()]), effectiveFrom: z.string().optional(), effectiveTo: z.string().optional(), resourceId: z.union([z.string(), z.number()]), sortOrder: z.union([z.string(), z.number()]).optional(), }), ) .min(1, "请至少选择一个资源"), }) .superRefine((value, context) => { const resourceIds = new Set(); value.items.forEach((item, index) => { const resourceId = Number(item.resourceId); const durationDays = String(item.durationDays || "").trim(); const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom); const effectiveToMs = datetimeLocalToMs(item.effectiveTo); const sortOrder = Number(item.sortOrder || 0); if (!Number.isInteger(resourceId) || resourceId <= 0) { context.addIssue({ code: "custom", message: "请选择资源", path: ["items", index, "resourceId"], }); } if (resourceIds.has(resourceId)) { context.addIssue({ code: "custom", message: "同一个资源不能重复添加", path: ["items", index, "resourceId"], }); } resourceIds.add(resourceId); if (!resourceShopDurations.includes(durationDays)) { context.addIssue({ code: "custom", message: "售卖天数只能选择 1、3、7 天", path: ["items", index, "durationDays"], }); } if (effectiveFromMs < 0) { context.addIssue({ code: "custom", message: "请选择生效开始时间", path: ["items", index, "effectiveFrom"], }); } if (effectiveToMs < 0) { context.addIssue({ code: "custom", message: "请选择生效结束时间", path: ["items", index, "effectiveTo"], }); } if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { context.addIssue({ code: "custom", message: "生效结束时间必须晚于开始时间", path: ["items", index, "effectiveTo"], }); } if (!Number.isInteger(sortOrder) || sortOrder < 0) { context.addIssue({ code: "custom", message: "排序不能小于 0", path: ["items", index, "sortOrder"], }); } }); }); 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"]), targetUserId: z.union([z.string(), z.number()]), }) .superRefine((value, context) => { const targetUserId = Number(value.targetUserId); const resourceIds = grantResourceIds(value); const groupId = Number(value.groupId || 0); const quantity = Number(value.quantity || 1); const durationDays = Number(value.durationDays || 0); if (!Number.isInteger(targetUserId) || targetUserId <= 0) { 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 (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) { context.addIssue({ code: "custom", message: "请选择资源组", path: ["groupId"], }); } 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"], }); } }); function grantResourceIds(value) { if (Array.isArray(value.resourceIds) && value.resourceIds.length) { return value.resourceIds; } return value.resourceId ? [value.resourceId] : []; }