feat: 新增用户管理
ocs-nuxt / deploy (push) Successful in 1m46s

This commit is contained in:
2026-05-24 23:26:43 +08:00
parent 3c467df01d
commit 90412a3688
22 changed files with 1620 additions and 114 deletions
+81
View File
@@ -0,0 +1,81 @@
// server/api/admin/users.get.ts - 超级管理员分页查询用户列表
//
// 流程:鉴权 → 二次校验 superadmin → 白名单 query → 查询用户和问答数 → 返回安全字段
// 安全边界:不返回 apiToken、密码、session、账号凭据或内部异常。
import { getQuery, setResponseStatus } from "h3";
import type { Prisma } from "~~/prisma/generated/client";
import {
ADMIN_USER_SELECT,
isUserRole,
parseAdminPage,
parseAdminPageSize,
requireSuperAdmin,
toAdminUser
} from "~~/server/utils/adminUsers";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import type { IAdminUsersResponse } from "~~/shared/types/adminUsers";
const adminUsersErr = (code: number, msg: string) =>
apiErr(code, msg) as unknown as IAdminUsersResponse;
export default defineEventHandler(
async (event): Promise<IAdminUsersResponse> => {
const forbidden = requireSuperAdmin(event);
if (forbidden) return forbidden as unknown as IAdminUsersResponse;
const logger = createApiLogger(event, "admin.users.list");
const query = getQuery(event);
const page = parseAdminPage(query.page);
const size = parseAdminPageSize(query.size);
const keyword = query.keyword?.toString().trim().slice(0, 100) || "";
const role = isUserRole(query.role) ? query.role : "";
const where: Prisma.UserWhereInput = {
...(role ? { role } : {}),
...(keyword
? {
OR: [
{ name: { contains: keyword } },
{ email: { contains: keyword } }
]
}
: {})
};
try {
const [total, rows] = await prisma.$transaction([
prisma.user.count({ where }),
prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * size,
take: size,
select: ADMIN_USER_SELECT
})
]);
logger.info("finish_success", {
page,
size,
total,
count: rows.length,
hasKeyword: Boolean(keyword),
role: role || "all"
});
return apiOk({
users: rows.map(toAdminUser),
page,
size,
total
});
} catch (error) {
logger.error("query_failed", { error: toSafeLogError(error) });
setResponseStatus(event, 500);
return adminUsersErr(500, "服务器内部错误");
}
}
);
+126
View File
@@ -0,0 +1,126 @@
// server/api/admin/users.post.ts - 超级管理员创建用户
//
// 流程:鉴权 → 二次校验 superadmin → JSON 对象和字段白名单校验 → Better Auth Admin createUser
// 安全边界:不使用公开注册接口,不返回密码、apiToken 或 Better Auth 原始错误。
import { readBody, setResponseStatus } from "h3";
import {
ADMIN_USER_SELECT,
getUnknownKeys,
isUserRole,
readTrimmedString,
requireSuperAdmin,
toAdminUser,
validateEmail,
validatePassword
} from "~~/server/utils/adminUsers";
import { auth, getAuthHeaderSnapshot } from "~~/server/utils/auth";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import type {
IAdminUserCreateRequest,
IAdminUserResponse
} from "~~/shared/types/adminUsers";
const ALLOWED_KEYS = new Set(["name", "email", "password", "role"]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const adminUserErr = (code: number, msg: string) =>
apiErr(code, msg) as unknown as IAdminUserResponse;
export default defineEventHandler(
async (event): Promise<IAdminUserResponse> => {
const forbidden = requireSuperAdmin(event);
if (forbidden) return forbidden as unknown as IAdminUserResponse;
const logger = createApiLogger(event, "admin.users.create");
const body = await readBody<unknown>(event).catch(() => null);
if (!isRecord(body)) {
setResponseStatus(event, 400);
return adminUserErr(400, "请求体必须是 JSON 对象");
}
const unknownKeys = getUnknownKeys(body, ALLOWED_KEYS);
if (unknownKeys.length > 0) {
setResponseStatus(event, 400);
return adminUserErr(400, "请求体包含不支持的字段");
}
const req: Partial<IAdminUserCreateRequest> = {
name: readTrimmedString(body.name),
email: readTrimmedString(body.email)?.toLowerCase(),
password: typeof body.password === "string" ? body.password : undefined,
role: isUserRole(body.role) ? body.role : undefined
};
if (!req.name) {
setResponseStatus(event, 400);
return adminUserErr(400, "昵称不能为空");
}
if (req.name.length > 20) {
setResponseStatus(event, 400);
return adminUserErr(400, "昵称不能超过 20 个字符");
}
if (!req.email || !validateEmail(req.email)) {
setResponseStatus(event, 400);
return adminUserErr(400, "请输入有效的邮箱地址");
}
if (!req.password) {
setResponseStatus(event, 400);
return adminUserErr(400, "密码不能为空");
}
const passwordError = validatePassword(req.password);
if (passwordError) {
setResponseStatus(event, 400);
return adminUserErr(400, passwordError);
}
if (!req.role) {
setResponseStatus(event, 400);
return adminUserErr(400, "用户角色无效");
}
try {
const existing = await prisma.user.findUnique({
where: { email: req.email },
select: { id: true }
});
if (existing) {
setResponseStatus(event, 400);
return adminUserErr(400, "邮箱已被使用");
}
await auth.api.createUser({
body: {
name: req.name,
email: req.email,
password: req.password,
role: req.role
},
headers: getAuthHeaderSnapshot(event)
});
const created = await prisma.user.findUnique({
where: { email: req.email },
select: ADMIN_USER_SELECT
});
if (!created) {
setResponseStatus(event, 500);
return adminUserErr(500, "服务器内部错误");
}
logger.info("finish_success", {
targetUserId: created.id,
role: created.role
});
return apiOk(toAdminUser(created));
} catch (error) {
logger.error("create_failed", { error: toSafeLogError(error) });
setResponseStatus(event, 500);
return adminUserErr(500, "服务器内部错误");
}
}
);
+68
View File
@@ -0,0 +1,68 @@
// server/api/admin/users/[id].delete.ts - 超级管理员硬删除用户
//
// 流程:鉴权 → 二次校验 superadmin → 防止删除自己/最后一个 superadmin → Better Auth Admin removeUser
// 安全边界:删除依赖 Prisma cascade 清理关联记录,不返回被删用户敏感信息。
import { getRouterParam, setResponseStatus } from "h3";
import { requireSuperAdmin } from "~~/server/utils/adminUsers";
import { auth, getAuthHeaderSnapshot } from "~~/server/utils/auth";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import type { IAdminUserMutationResponse } from "~~/shared/types/adminUsers";
const adminUserMutationErr = (code: number, msg: string) =>
apiErr(code, msg) as unknown as IAdminUserMutationResponse;
export default defineEventHandler(
async (event): Promise<IAdminUserMutationResponse> => {
const forbidden = requireSuperAdmin(event);
if (forbidden) return forbidden as unknown as IAdminUserMutationResponse;
const logger = createApiLogger(event, "admin.users.delete");
const userId = getRouterParam(event, "id");
if (!userId) {
setResponseStatus(event, 400);
return adminUserMutationErr(400, "用户不存在");
}
const currentUserId = event.context.auth!.user.id;
if (userId === currentUserId) {
setResponseStatus(event, 400);
return adminUserMutationErr(400, "不能删除当前登录用户");
}
try {
const target = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true }
});
if (!target) {
setResponseStatus(event, 404);
return adminUserMutationErr(404, "用户不存在");
}
if (target.role === "superadmin") {
const superAdminCount = await prisma.user.count({
where: { role: "superadmin" }
});
if (superAdminCount <= 1) {
setResponseStatus(event, 400);
return adminUserMutationErr(400, "至少保留一个超级管理员");
}
}
await auth.api.removeUser({
body: { userId },
headers: getAuthHeaderSnapshot(event)
});
logger.info("finish_success", { targetUserId: userId });
return apiOk(null);
} catch (error) {
logger.error("delete_failed", { error: toSafeLogError(error) });
setResponseStatus(event, 500);
return adminUserMutationErr(500, "服务器内部错误");
}
}
);
+176
View File
@@ -0,0 +1,176 @@
// server/api/admin/users/[id].put.ts - 超级管理员更新用户资料和密码
//
// 流程:鉴权 → 二次校验 superadmin → 白名单校验 → 防止最后一个 superadmin 被降级 → Better Auth Admin 更新
// 安全边界:密码只写入 Better Auth 账号,不返回明文或内部异常。
import { getRouterParam, readBody, setResponseStatus } from "h3";
import {
ADMIN_USER_SELECT,
getUnknownKeys,
isUserRole,
readTrimmedString,
requireSuperAdmin,
toAdminUser,
validateEmail,
validatePassword
} from "~~/server/utils/adminUsers";
import { auth, getAuthHeaderSnapshot } from "~~/server/utils/auth";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import type {
IAdminUserResponse,
IAdminUserUpdateRequest
} from "~~/shared/types/adminUsers";
const ALLOWED_KEYS = new Set(["name", "email", "role", "password"]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const adminUserErr = (code: number, msg: string) =>
apiErr(code, msg) as unknown as IAdminUserResponse;
export default defineEventHandler(
async (event): Promise<IAdminUserResponse> => {
const forbidden = requireSuperAdmin(event);
if (forbidden) return forbidden as unknown as IAdminUserResponse;
const logger = createApiLogger(event, "admin.users.update");
const userId = getRouterParam(event, "id");
if (!userId) {
setResponseStatus(event, 400);
return adminUserErr(400, "用户不存在");
}
const body = await readBody<unknown>(event).catch(() => null);
if (!isRecord(body)) {
setResponseStatus(event, 400);
return adminUserErr(400, "请求体必须是 JSON 对象");
}
const unknownKeys = getUnknownKeys(body, ALLOWED_KEYS);
if (unknownKeys.length > 0) {
setResponseStatus(event, 400);
return adminUserErr(400, "请求体包含不支持的字段");
}
const req: IAdminUserUpdateRequest = {};
if (body.name !== undefined) req.name = readTrimmedString(body.name);
if (body.email !== undefined) {
req.email = readTrimmedString(body.email)?.toLowerCase();
}
if (body.role !== undefined && isUserRole(body.role)) {
req.role = body.role;
} else if (body.role !== undefined) {
setResponseStatus(event, 400);
return adminUserErr(400, "用户角色无效");
}
if (typeof body.password === "string") {
req.password = body.password;
}
if (req.name !== undefined && req.name.length === 0) {
setResponseStatus(event, 400);
return adminUserErr(400, "昵称不能为空");
}
if (req.name && req.name.length > 20) {
setResponseStatus(event, 400);
return adminUserErr(400, "昵称不能超过 20 个字符");
}
if (req.email !== undefined && !validateEmail(req.email)) {
setResponseStatus(event, 400);
return adminUserErr(400, "请输入有效的邮箱地址");
}
const shouldUpdatePassword = Boolean(req.password);
if (shouldUpdatePassword) {
const passwordError = validatePassword(req.password!);
if (passwordError) {
setResponseStatus(event, 400);
return adminUserErr(400, passwordError);
}
}
try {
const target = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, role: true }
});
if (!target) {
setResponseStatus(event, 404);
return adminUserErr(404, "用户不存在");
}
const currentUserId = event.context.auth!.user.id;
if (userId === currentUserId && req.role && req.role !== "superadmin") {
setResponseStatus(event, 400);
return adminUserErr(400, "不能移除自己的超级管理员角色");
}
if (
target.role === "superadmin" &&
req.role &&
req.role !== "superadmin"
) {
const superAdminCount = await prisma.user.count({
where: { role: "superadmin" }
});
if (superAdminCount <= 1) {
setResponseStatus(event, 400);
return adminUserErr(400, "至少保留一个超级管理员");
}
}
if (req.email && req.email !== target.email) {
const existing = await prisma.user.findUnique({
where: { email: req.email },
select: { id: true }
});
if (existing && existing.id !== userId) {
setResponseStatus(event, 400);
return adminUserErr(400, "邮箱已被使用");
}
}
const data: Record<string, string> = {};
if (req.name !== undefined) data.name = req.name;
if (req.email !== undefined) data.email = req.email;
if (req.role !== undefined) data.role = req.role;
if (Object.keys(data).length > 0) {
await auth.api.adminUpdateUser({
body: { userId, data },
headers: getAuthHeaderSnapshot(event)
});
}
if (shouldUpdatePassword) {
await auth.api.setUserPassword({
body: { userId, newPassword: req.password! },
headers: getAuthHeaderSnapshot(event)
});
}
const updated = await prisma.user.findUnique({
where: { id: userId },
select: ADMIN_USER_SELECT
});
if (!updated) {
setResponseStatus(event, 404);
return adminUserErr(404, "用户不存在");
}
logger.info("finish_success", {
targetUserId: userId,
changedFields: Object.keys(data),
passwordChanged: shouldUpdatePassword
});
return apiOk(toAdminUser(updated));
} catch (error) {
logger.error("update_failed", { error: toSafeLogError(error) });
setResponseStatus(event, 500);
return adminUserErr(500, "服务器内部错误");
}
}
);
+87
View File
@@ -0,0 +1,87 @@
// server/utils/adminUsers.ts - 超级管理员用户管理的校验、权限和响应映射工具
import { type H3Event, setResponseStatus } from "h3";
import type { Prisma } from "~~/prisma/generated/client";
import { apiErr } from "~~/server/utils/response";
import { formatLocalDateTime } from "~~/server/utils/runtimeState";
import {
type IAdminUser,
USER_ROLE_VALUES,
type UserRole
} from "~~/shared/types/adminUsers";
export const ADMIN_USER_PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
export const ADMIN_USER_SELECT = {
id: true,
name: true,
email: true,
emailVerified: true,
role: true,
createdAt: true,
updatedAt: true,
_count: { select: { qaRecords: true } }
} satisfies Prisma.UserSelect;
export type AdminUserRow = Prisma.UserGetPayload<{
select: typeof ADMIN_USER_SELECT;
}>;
export const isUserRole = (value: unknown): value is UserRole =>
typeof value === "string" && USER_ROLE_VALUES.includes(value as UserRole);
export const toAdminUser = (user: AdminUserRow): IAdminUser => ({
id: user.id,
name: user.name,
email: user.email,
role: isUserRole(user.role) ? user.role : "user",
emailVerified: user.emailVerified,
createdAt: formatLocalDateTime(user.createdAt),
updatedAt: formatLocalDateTime(user.updatedAt),
qaRecordCount: user._count.qaRecords
});
export const requireSuperAdmin = (event: H3Event) => {
const role = (event.context.auth?.user as { role?: string } | undefined)
?.role;
if (role !== "superadmin") {
setResponseStatus(event, 403);
return apiErr(403, "权限不足");
}
return null;
};
export const readTrimmedString = (value: unknown) =>
typeof value === "string" ? value.trim() : undefined;
export const validateEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value);
export const validatePassword = (value: string) => {
if (value.length < 8) return "密码至少 8 位";
if (value.length > 100) return "密码不能超过 100 位";
if (!/[a-zA-Z]/u.test(value)) return "";
if (!/[0-9]/u.test(value)) return "";
return null;
};
export const parseAdminPageSize = (value: unknown) => {
const parsed = Number.parseInt(value?.toString() || "10", 10);
return ADMIN_USER_PAGE_SIZE_OPTIONS.includes(
parsed as (typeof ADMIN_USER_PAGE_SIZE_OPTIONS)[number]
)
? parsed
: 10;
};
export const parseAdminPage = (value: unknown) => {
const parsed = Number.parseInt(value?.toString() || "1", 10);
return Number.isFinite(parsed) ? Math.max(parsed, 1) : 1;
};
export const getUnknownKeys = (
body: Record<string, unknown>,
allowedKeys: Set<string>
) => Object.keys(body).filter((key) => !allowedKeys.has(key));
+18 -8
View File
@@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { admin } from "better-auth/plugins/admin";
import { adminAc, userAc } from "better-auth/plugins/admin/access";
import type { H3Event } from "h3";
import { prisma } from "~~/server/utils/db";
@@ -12,6 +14,7 @@ import { prisma } from "~~/server/utils/db";
*
* - 使用 Prisma MySQL/MariaDB 适配器
* - 只启用邮箱/密码登录,无邮箱验证和密码重置(保持最简)
* - 启用 Better Auth Admin 插件,但只有 superadmin 角色拥有用户管理权限
* - 注册时通过 databaseHook 自动生成 32 位 hex apiToken
* apiToken 供 OCS 油猴脚本跨域调用 /api/search 时在 body 中携带
* - apiToken 不允许客户端直接设置(input: false),但随 session 返回给登录用户
@@ -35,16 +38,23 @@ export const auth = betterAuth({
required: false,
// 客户端(signup/updateUser)不能设置此字段,只由 databaseHooks 写入
input: false
},
role: {
type: "string",
required: false,
// 角色只由服务端管理员设置,不允许客户端修改
input: false
}
}
},
plugins: [
admin({
defaultRole: "user",
adminRoles: ["superadmin"],
roles: {
user: userAc,
// admin 是业务展示角色,不授予 Better Auth Admin 管理权限
admin: userAc,
superadmin: adminAc
}
})
],
databaseHooks: {
user: {
create: {
@@ -70,7 +80,7 @@ export const auth = betterAuth({
* 后续 handler 再 readBody(event) 可能长时间挂起。
* 未登录或 session 无效时返回 null
*/
const getHeaderSnapshot = (event: H3Event) => {
export const getAuthHeaderSnapshot = (event: H3Event) => {
const headers = new Headers();
for (const [key, value] of Object.entries(event.node.req.headers)) {
@@ -85,4 +95,4 @@ const getHeaderSnapshot = (event: H3Event) => {
};
export const getAuthSession = (event: H3Event) =>
auth.api.getSession({ headers: getHeaderSnapshot(event) });
auth.api.getSession({ headers: getAuthHeaderSnapshot(event) });