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
+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) });