Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 60 additions & 19 deletions src/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,32 @@ export async function addUser(data: {
limitConcurrentSessions?: number | null;
isEnabled?: boolean;
expiresAt?: Date | null;
}): Promise<ActionResult> {
}): 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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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;

Expand All @@ -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(仅针对减少场景)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 || "",
Expand Down
70 changes: 69 additions & 1 deletion src/app/api/actions/[...route]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/lib/api/action-adapter-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export interface ActionRouteOptions {
* 权限要求
*/
requiredRole?: "admin" | "user";

/**
* 请求示例(显示在 API 文档中)
*/
requestExamples?: Record<
string,
{
summary?: string;
description?: string;
value: unknown;
}
>;
}

/**
Expand Down Expand Up @@ -164,6 +176,7 @@ export function createActionRoute(
tags = [module],
requiresAuth = true,
requiredRole,
requestExamples,
} = options;

// 创建 OpenAPI 路由定义
Expand All @@ -178,6 +191,7 @@ export function createActionRoute(
content: {
"application/json": {
schema: requestSchema,
...(requestExamples && { examples: requestExamples }),
},
},
description: "请求参数",
Expand Down
97 changes: 88 additions & 9 deletions src/lib/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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年",
});
}
})
),
});

/**
Expand Down Expand Up @@ -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年",
});
}
})
),
});

Expand Down
Loading