diff --git a/src/actions/users.ts b/src/actions/users.ts index 2d9c9656f..4283436cc 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -208,7 +208,32 @@ export async function addUser(data: { limitConcurrentSessions?: number | null; isEnabled?: boolean; expiresAt?: Date | null; -}): Promise { +}): Promise< + ActionResult<{ + user: { + id: number; + name: string; + note?: string; + role: string; + isEnabled: boolean; + expiresAt: Date | null; + rpm: number; + dailyQuota: number; + providerGroup?: string; + tags: string[]; + limit5hUsd: number | null; + limitWeeklyUsd: number | null; + limitMonthlyUsd: number | null; + limitTotalUsd: number | null; + limitConcurrentSessions: number | null; + }; + defaultKey: { + id: number; + name: string; + key: string; + }; + }> +> { try { // Get translations for error messages const tError = await getTranslations("errors"); @@ -236,6 +261,8 @@ export async function addUser(data: { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + isEnabled: data.isEnabled, + expiresAt: data.expiresAt, }); if (!validationResult.success) { @@ -288,13 +315,13 @@ export async function addUser(data: { limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, - isEnabled: data.isEnabled ?? true, - expiresAt: data.expiresAt ?? null, + isEnabled: validatedData.isEnabled, + expiresAt: validatedData.expiresAt ?? null, }); // 为新用户创建默认密钥 const generatedKey = `sk-${randomBytes(16).toString("hex")}`; - await createKey({ + const newKey = await createKey({ user_id: newUser.id, name: "default", key: generatedKey, @@ -303,7 +330,33 @@ export async function addUser(data: { }); revalidatePath("/dashboard"); - return { ok: true }; + return { + ok: true, + data: { + user: { + id: newUser.id, + name: newUser.name, + note: newUser.description || undefined, + role: newUser.role, + isEnabled: newUser.isEnabled, + expiresAt: newUser.expiresAt ?? null, + rpm: newUser.rpm, + dailyQuota: newUser.dailyQuota, + providerGroup: newUser.providerGroup || undefined, + tags: newUser.tags || [], + limit5hUsd: newUser.limit5hUsd ?? null, + limitWeeklyUsd: newUser.limitWeeklyUsd ?? null, + limitMonthlyUsd: newUser.limitMonthlyUsd ?? null, + limitTotalUsd: newUser.limitTotalUsd ?? null, + limitConcurrentSessions: newUser.limitConcurrentSessions ?? null, + }, + defaultKey: { + id: newKey.id, + name: newKey.name, + key: generatedKey, // 返回完整密钥(仅此一次) + }, + }, + }; } catch (error) { logger.error("Failed to create user:", error); const tError = await getTranslations("errors"); @@ -409,18 +462,6 @@ export async function editUser( }; } - // 如果设置了过期时间,进行验证 - if (data.expiresAt !== undefined && data.expiresAt !== null) { - const validationResult = await validateExpiresAt(data.expiresAt, tError, { allowPast: true }); - if (validationResult) { - return { - ok: false, - error: validationResult.error, - errorCode: validationResult.errorCode, - }; - } - } - // 在更新前获取旧用户数据(用于级联更新判断) const oldUserForCascade = data.providerGroup !== undefined ? await findUserById(userId) : null; @@ -437,8 +478,8 @@ export async function editUser( limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, - isEnabled: data.isEnabled, - expiresAt: data.expiresAt, + isEnabled: validatedData.isEnabled, + expiresAt: validatedData.expiresAt, }); // 级联更新 KEY 的 providerGroup(仅针对减少场景) diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index c54dcc3e1..47a3efa55 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; +import { z } from "zod"; import { getAvailableProviderGroups } from "@/actions/providers"; import { addUser, editUser } from "@/actions/users"; import { DatePickerField } from "@/components/form/date-picker-field"; @@ -15,6 +16,11 @@ import { getErrorMessage } from "@/lib/utils/error-messages"; import { setZodErrorMap } from "@/lib/utils/zod-i18n"; import { CreateUserSchema } from "@/lib/validation/schemas"; +// 前端表单使用的 schema(接受字符串日期) +const UserFormSchema = CreateUserSchema.extend({ + expiresAt: z.string().optional(), +}); + interface UserFormProps { user?: { id: number; @@ -61,7 +67,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { }, []); const form = useZodForm({ - schema: CreateUserSchema, // Use CreateUserSchema for both, it has all fields with defaults + schema: UserFormSchema, // 使用前端表单的 schema(接受字符串日期) defaultValues: { name: user?.name || "", note: user?.note || "", diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 651a06581..f0410f5a0 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -63,10 +63,78 @@ const { route: addUserRoute, handler: addUserHandler } = createActionRoute( userActions.addUser, { requestSchema: CreateUserSchema, + responseSchema: z.object({ + user: z.object({ + id: z.number().describe("用户ID"), + name: z.string().describe("用户名"), + note: z.string().optional().describe("备注"), + role: z.enum(["admin", "user"]).describe("用户角色"), + isEnabled: z.boolean().describe("是否启用"), + expiresAt: z.date().nullable().describe("过期时间"), + rpm: z.number().describe("每分钟请求数限制"), + dailyQuota: z.number().describe("每日消费额度(美元)"), + providerGroup: z.string().optional().describe("供应商分组"), + tags: z.array(z.string()).describe("用户标签"), + limit5hUsd: z.number().nullable().describe("5小时消费上限"), + limitWeeklyUsd: z.number().nullable().describe("周消费上限"), + limitMonthlyUsd: z.number().nullable().describe("月消费上限"), + limitTotalUsd: z.number().nullable().describe("总消费上限"), + limitConcurrentSessions: z.number().nullable().describe("并发Session上限"), + }), + defaultKey: z.object({ + id: z.number().describe("密钥ID"), + name: z.string().describe("密钥名称"), + key: z.string().describe("API密钥(完整密钥,仅在创建时返回一次)"), + }), + }), description: "创建新用户 (管理员)", - summary: "创建新用户并返回用户信息", + summary: "创建新用户并返回用户信息及默认密钥", tags: ["用户管理"], requiredRole: "admin", + requestExamples: { + basic: { + summary: "基础用户", + description: "创建一个具有默认配置的普通用户", + value: { + name: "测试用户", + note: "这是一个测试账号", + rpm: 100, + dailyQuota: 100, + isEnabled: true, + }, + }, + withExpiry: { + summary: "带过期时间的用户", + description: "创建一个指定过期时间的用户(ISO 8601 格式)", + value: { + name: "临时用户", + note: "30天试用账号", + rpm: 60, + dailyQuota: 50, + isEnabled: true, + expiresAt: "2026-01-01T23:59:59.999Z", + }, + }, + withLimits: { + summary: "完整限额配置", + description: "创建一个具有完整金额限制和并发控制的用户", + value: { + name: "企业用户", + note: "企业级账号", + providerGroup: "premium,backup", + tags: ["vip", "enterprise"], + rpm: 200, + dailyQuota: 500, + limit5hUsd: 100, + limitWeeklyUsd: 500, + limitMonthlyUsd: 2000, + limitTotalUsd: 10000, + limitConcurrentSessions: 10, + isEnabled: true, + expiresAt: "2026-12-31T23:59:59.999Z", + }, + }, + }, } ); app.openapi(addUserRoute, addUserHandler); diff --git a/src/lib/api/action-adapter-openapi.ts b/src/lib/api/action-adapter-openapi.ts index 49ddad859..d0d308bbf 100644 --- a/src/lib/api/action-adapter-openapi.ts +++ b/src/lib/api/action-adapter-openapi.ts @@ -61,6 +61,18 @@ export interface ActionRouteOptions { * 权限要求 */ requiredRole?: "admin" | "user"; + + /** + * 请求示例(显示在 API 文档中) + */ + requestExamples?: Record< + string, + { + summary?: string; + description?: string; + value: unknown; + } + >; } /** @@ -164,6 +176,7 @@ export function createActionRoute( tags = [module], requiresAuth = true, requiredRole, + requestExamples, } = options; // 创建 OpenAPI 路由定义 @@ -178,6 +191,7 @@ export function createActionRoute( content: { "application/json": { schema: requestSchema, + ...(requestExamples && { examples: requestExamples }), }, }, description: "请求参数", diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 4ee3e17bb..f0cfdf418 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -67,11 +67,58 @@ export const CreateUserSchema = z.object({ .optional(), // User status and expiry management isEnabled: z.boolean().optional().default(true), - expiresAt: z - .string() - .optional() - .default("") - .transform((val) => (val === "" ? undefined : val)), + expiresAt: z.preprocess( + (val) => { + // null/undefined/空字符串 -> 视为未设置 + if (val === null || val === undefined || val === "") return undefined; + + // 已经是 Date 对象 + if (val instanceof Date) { + // 验证是否为有效日期,无效则返回原值让后续报错 + if (Number.isNaN(val.getTime())) return val; + return val; + } + + // 字符串日期 -> 转换为 Date 对象 + if (typeof val === "string") { + const date = new Date(val); + // 验证是否为有效日期,无效则返回原值让后续报错 + if (Number.isNaN(date.getTime())) return val; + return date; + } + + // 其他类型返回原值,让 z.date() 报错 + return val; + }, + z + .date() + .optional() + .superRefine((date, ctx) => { + if (!date) { + return; // 允许空值 + } + + const now = new Date(); + + // 检查是否为将来时间 + if (date <= now) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "过期时间必须是将来时间", + }); + } + + // 限制最大续期时长(10年) + const maxExpiry = new Date(now.getTime()); + maxExpiry.setFullYear(maxExpiry.getFullYear() + 10); + if (date > maxExpiry) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "过期时间不能超过10年", + }); + } + }) + ), }); /** @@ -131,16 +178,48 @@ export const UpdateUserSchema = z.object({ isEnabled: z.boolean().optional(), expiresAt: z.preprocess( (val) => { - // 兼容服务端传入的 Date 对象,统一转为字符串再走后续校验 - if (val instanceof Date) return val.toISOString(); // null/undefined/空字符串 -> 视为未设置 if (val === null || val === undefined || val === "") return undefined; + + // 已经是 Date 对象 + if (val instanceof Date) { + // 验证是否为有效日期,无效则返回原值让后续报错 + if (Number.isNaN(val.getTime())) return val; + return val; + } + + // 字符串日期 -> 转换为 Date 对象 + if (typeof val === "string") { + const date = new Date(val); + // 验证是否为有效日期,无效则返回原值让后续报错 + if (Number.isNaN(date.getTime())) return val; + return date; + } + + // 其他类型返回原值,让 z.date() 报错 return val; }, z - .string() + .date() .optional() - .transform((val) => (!val || val === "" ? undefined : val)) + .superRefine((date, ctx) => { + if (!date) { + return; // 允许空值 + } + + // 更新时不限制过去时间(允许立即让用户过期) + + // 限制最大续期时长(10年) + const now = new Date(); + const maxExpiry = new Date(now.getTime()); + maxExpiry.setFullYear(maxExpiry.getFullYear() + 10); + if (date > maxExpiry) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "过期时间不能超过10年", + }); + } + }) ), });