Vendored
+2
-1
@@ -2,6 +2,7 @@
|
||||
"cSpell.words": [
|
||||
"heroicons",
|
||||
"lucide",
|
||||
"pinia"
|
||||
"pinia",
|
||||
"superadmin"
|
||||
]
|
||||
}
|
||||
@@ -1,131 +1,203 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## 目标
|
||||
## 项目定位
|
||||
|
||||
本文档用于约束 AI 在本项目内的实现方式,确保代码简洁、可维护、安全,并与当前 Nuxt、Nitro、Prisma 项目结构保持一致。
|
||||
本项目是基于 Nuxt 4 / Nitro 的 OCS AI 答题服务,兼容 OCS AnswererWrapper 的题库接口,同时提供网页登录、答题工作台、Dashboard、个人 API 配置和 superadmin 系统配置后台。
|
||||
|
||||
核心技术栈:
|
||||
|
||||
- Nuxt 4、Vue 3、Pinia、VueUse、Tailwind CSS v4、shadcn-vue / reka-ui、lucide-vue-next。
|
||||
- Better Auth 邮箱密码登录,Prisma 7 + `@prisma/adapter-mariadb` 连接 MySQL/MariaDB。
|
||||
- OpenAI 兼容 Chat Completions 流式调用;对 OCS 客户端仍返回普通 JSON。
|
||||
- pnpm 10.33.0,Node 22 Alpine 用于 Docker 镜像。
|
||||
|
||||
## 核心原则
|
||||
|
||||
- 默认最小改动,优先复用现有目录结构、工具函数、类型定义和项目约定。
|
||||
- 保持单一入口和单一职责,避免同一业务暴露多套路由或重复实现。
|
||||
- 前端不接触数据库连接串、session、完整 API Key、token、cookie 等敏感信息。
|
||||
- 所有返回给前端的 `msg` 必须使用本地业务表达,不暴露内部服务名、第三方来源、token、key、连接串或调试细节。
|
||||
- 内部异常统一返回“服务器内部错误”;具体异常写入服务端日志。
|
||||
- 注释和日志要解释意图、边界和排查信息,不输出密码、cookie、完整 key、token、图片二进制、base64 或完整敏感请求内容。
|
||||
- 已存在的说明性注释不要随意删除;确需重写文件时,保留或迁移原注释表达的信息。
|
||||
- 不要写任何可能阻塞整个进程的代码。
|
||||
- 不为未来不确定需求预建兼容路由、抽象层或配置项。
|
||||
- 默认最小改动,优先复用现有目录结构、工具函数、共享类型、store 和 service。
|
||||
- 前端不接触数据库连接串、session 细节、完整 API Key、token、cookie 或上游响应体。
|
||||
- API 返回给前端或 OCS 的 `msg` 必须是本地业务表达,不暴露 OpenAI、Better Auth、Prisma、token、key、连接串或调试细节。
|
||||
- 内部异常统一返回“服务器内部错误”;具体异常只能进入服务端安全日志。
|
||||
- 日志记录 requestId、阶段、用户/记录标识、分页、耗时、状态等排查信息;不得记录密码、cookie、完整 key、token、完整 prompt、图片二进制、base64、完整请求体或响应体。
|
||||
- 不写可能阻塞整个进程的代码;流、DB、外部请求都要有明确边界或错误出口。
|
||||
- 不为未来不确定需求预建兼容路由、抽象层、配置项或多套入口。
|
||||
|
||||
## 目录结构约定
|
||||
|
||||
- 前端应用代码放在 `app/`:
|
||||
- `app/assets/`:需要被构建工具处理的资源。
|
||||
- `app/components/`:Vue 组件。
|
||||
- `app/composables/`:Vue composables。
|
||||
- `app/layouts/`:页面布局组件。
|
||||
- `app/middleware/`:前端路由中间件。
|
||||
- `app/pages/`:文件路由页面。
|
||||
- `app/plugins/`:Nuxt 应用插件。
|
||||
- `app/services/`:前端服务封装,按业务拆分。
|
||||
- `app/stores/`:Pinia store,按业务拆分。
|
||||
- `app/utils/`:前端通用工具。
|
||||
- 服务端代码放在 `server/`:
|
||||
- `server/api/`:JSON API 路由。
|
||||
- `server/routes/`:服务端非 API 路由,例如动态 sitemap。
|
||||
- `server/middleware/`:服务端中间件。
|
||||
- `server/plugins/`:Nitro 服务端插件。
|
||||
- `server/utils/`:服务端通用工具和外部服务适配。
|
||||
- 前后端都需要复用的类型和纯函数放在 `shared/`。
|
||||
- 静态公开文件放在 `public/`,不经过构建处理。
|
||||
- 不再使用 `@prisma/nuxt`;Prisma 只允许在服务端工具和 API 路由中使用。
|
||||
- `app/`:前端应用代码。
|
||||
- `app/pages/`:页面路由,当前包括首页答题、Dashboard、登录、注册。
|
||||
- `app/components/`:业务组件;`app/components/ui/` 是 shadcn-vue 组件,尽量不要手改生成件。
|
||||
- `app/services/`:前端 API 封装,组件不要直接写 `$fetch` 调业务接口。
|
||||
- `app/stores/`:Pinia 状态与动作;组件通过 store 消费状态和发起业务操作。
|
||||
- `app/interfaces/`:前端类型出口,主要重导出 `shared/types`。
|
||||
- `app/utils/`:前端工具,例如 Better Auth client、错误文案、剪贴板等。
|
||||
- `server/`:Nitro 服务端代码。
|
||||
- `server/api/`:JSON API 与 Better Auth 路由。
|
||||
- `server/middleware/`:服务端中间件,当前包含 API 统一鉴权和注册开关守卫。
|
||||
- `server/plugins/`:Nitro 启动插件,当前用于补全默认系统配置。
|
||||
- `server/utils/`:服务端业务工具、DB、鉴权、OpenAI、日志、配置、答题逻辑。
|
||||
- `shared/`:前后端共享类型和纯函数。新增或修改接口契约时优先更新这里。
|
||||
- `prisma/schema.prisma`:数据库结构唯一来源;Prisma Client 输出到 `prisma/generated`,不要改回 `app/generated`。
|
||||
- `public/`:静态公开文件,不经过构建处理。
|
||||
|
||||
## API 路由规范
|
||||
## 认证与权限
|
||||
|
||||
- API 路由按业务模块放在 `server/api/<module>/` 或明确的 `server/api/*.ts` 文件中。
|
||||
- 不为同一能力创建多个本地别名路由。
|
||||
- 临时排查接口不要长期保留,例如数据库健康检查类接口排查结束后必须删除。
|
||||
- Handler 只负责入参校验、鉴权、调用业务函数和统一响应;可复用逻辑放入 `server/utils`。
|
||||
- 新增或修改接口时,同步维护共享类型、服务端实现和前端调用。
|
||||
- Better Auth 实例在 `server/utils/auth.ts`,使用 Prisma MySQL/MariaDB 适配器,只启用邮箱密码登录。
|
||||
- 注册用户时通过 Better Auth `databaseHooks` 自动生成 32 位 hex `apiToken`。
|
||||
- `apiToken` 供 OCS 油猴脚本跨域调用 `/api/search` 使用;客户端不能自行写入,刷新走 `POST /api/user/refresh-token`。
|
||||
- `server/middleware/api-auth.ts` 默认保护所有 `/api/*` 路由;只有 `publicApiRoutes` 中声明的公开路由放行。
|
||||
- 当前公开路由:
|
||||
- `/api/auth/**`:Better Auth 登录、注册、退出、get-session 等。
|
||||
- `GET /api/health`:健康检查,不含敏感信息。
|
||||
- `/api/search`:有自己的 session/apiToken 双通道鉴权。
|
||||
- `OPTIONS` 预检请求在中间件中直接返回 204。
|
||||
- superadmin 接口必须在 handler 内二次校验 `role === "superadmin"`,不能只依赖通用 middleware。
|
||||
- 注册开关由 `allow_registration` 系统配置控制,`server/middleware/registration-guard.ts` 拦截 `POST /api/auth/sign-up/email`。
|
||||
|
||||
## API 规范
|
||||
|
||||
- 新增接口默认需要登录;只有确实公开的接口才能加入 `server/utils/api-auth-rules.ts`。
|
||||
- Handler 只负责入参校验、鉴权/权限边界、调用业务函数和统一响应;复用逻辑放到 `server/utils`。
|
||||
- 非 OCS 接口使用 `server/utils/response.ts`:
|
||||
- 成功:`apiOk(data)`,结构为 `{ code: 0, data, msg: "请求成功" }`。
|
||||
- 失败:`apiErr(code, msg)`,`data` 固定为 `null`。
|
||||
- `/api/search` 是 OCS 兼容接口,不使用 `apiOk/apiErr`:
|
||||
- 成功固定 `{ code: 1, question, answer }`。
|
||||
- 失败固定 `{ code: 0, msg }`。
|
||||
- 所有 JSON body 先校验为普通对象或 `null`;数组、字符串等无效 body 返回 400。
|
||||
- 对客户端传入字段必须白名单过滤,只接受当前接口声明的字段和正确类型。
|
||||
- 新增或修改接口时,同步维护 `shared/types`、服务端实现、前端 service、Pinia store 和组件消费。
|
||||
- API 响应只返回前端需要的字段,不透出内部响应体、数据库异常、第三方错误详情或完整调试对象。
|
||||
|
||||
## 请求、响应与错误处理
|
||||
## 答题流程与缓存
|
||||
|
||||
- 所有 JSON 接口先校验请求体:只接受 JSON 对象或 `null`,数组、字符串等无效 body 直接返回 400。
|
||||
- 对客户端传入和第三方透传字段必须使用白名单过滤,只接受类型正确且当前接口声明的字段。
|
||||
- 成功响应的 `msg` 只写本地业务结果,例如“创建成功”“更新成功”“查询成功”。
|
||||
- 错误响应的 `msg` 只写本地业务错误,例如“未登录”“请求体必须是 JSON 对象”“记录不存在”。
|
||||
- 内部或第三方异常统一转换为“服务器内部错误”,具体异常必须在返回前写入服务端日志。
|
||||
- 401 或登录态失效时统一清理本地 auth cookie。
|
||||
- 不在 handler 内重复实现复杂错误解析,通用判断放到 `server/utils`。
|
||||
- `/api/search` 兼容 GET、JSON POST、urlencoded POST、multipart/form-data。
|
||||
- 鉴权顺序:优先读取网页登录 session;没有 session 时使用请求中的 `token` 查 `User.apiToken`。
|
||||
- `title/type/options` 会标准化为 `SearchParams`,只把题目长度、题型和是否有选项写日志。
|
||||
- 缓存不再是进程内内存缓存;答案记录写入 `qa_record`,按 `userId + md5(title|type|options)` 查询最近答案。
|
||||
- 清缓存不删除历史记录,而是更新 `User.cacheClearedAt`;缓存查询只命中该时间之后的记录。
|
||||
- 调 OpenAI 前必须通过 `getUserEffectiveApiConfig(userId)` 取得生效配置,保证系统 key 不会发往用户自定义 endpoint。
|
||||
- 多选题继续使用 `#` 分隔;`server/utils/answer.ts` 中的 OCS 响应格式不可随意改动。
|
||||
|
||||
## 配置模型
|
||||
|
||||
- OpenAI 运行配置以数据库 `system_config` 为准,不再依赖 `OPENAI_API_KEY` 等旧环境变量。
|
||||
- `server/plugins/initConfig.ts` 启动时补全缺失的默认系统配置;不覆盖已有值。
|
||||
- `server/utils/sysConfig.ts` 对系统配置做 60 秒 TTL 内存缓存;superadmin 更新配置后必须调用 `invalidateSysConfigCache()`。
|
||||
- 系统配置接口:
|
||||
- `GET /api/admin/system-config`:仅 superadmin,可读,API Key 只返回遮蔽预览。
|
||||
- `PUT /api/admin/system-config`:仅 superadmin,可写,`openAiApiKey` 为空字符串表示不修改已有 key。
|
||||
- 用户自定义配置保存在 `user_config.value` JSON 中:
|
||||
- `GET /api/user/settings`:只返回遮蔽后的 key 预览。
|
||||
- `POST|PUT /api/user/settings`:白名单字段,空字符串 key 表示不修改旧 key。
|
||||
- 用户启用自定义配置时,只有 `customApiBase` 和 `customApiKey` 同时非空,才使用用户 endpoint + key;否则 base 和 key 完全回退系统配置。
|
||||
- `DATABASE_URL`、`BETTER_AUTH_SECRET`、`BETTER_AUTH_URL` 仍来自环境变量;`.env.example` 只放这些运行必需项。
|
||||
|
||||
## 数据库与 Prisma
|
||||
|
||||
- Prisma schema 位于 `prisma/schema.prisma`,实际数据库结构以 Prisma 为准。
|
||||
- 当前使用 Prisma 7 + `@prisma/adapter-mariadb`。
|
||||
- Prisma Client 生成目录为 `prisma/generated`,不要改回 `app/generated`。
|
||||
- Prisma Client 统一从 `server/utils/db.ts` 创建和复用;业务代码不要直接 `new PrismaClient()`。
|
||||
- 数据库连接只在服务端读取 `DATABASE_URL`,前端不允许读取或间接获取连接信息。
|
||||
- 修改数据库结构时,优先使用 Prisma migration;不要绕过 Prisma 手动改表。
|
||||
- 数据库结构变更的标准流程:
|
||||
1. 修改 `prisma/schema.prisma`。
|
||||
2. 新增对应 migration,或在全新开发库且确认可接受时说明原因后使用 `prisma db push`。
|
||||
3. 运行 `.\node_modules\.bin\prisma.CMD generate` 重新生成客户端。
|
||||
4. 对实际数据库执行同步命令,并确认 schema 已落库。
|
||||
5. 运行类型检查;涉及服务启动或构建行为时再运行构建。
|
||||
- 当前使用 Prisma 7 + `@prisma/adapter-mariadb`,Prisma Client 从 `server/utils/db.ts` 创建和复用。
|
||||
- 业务代码不要直接 `new PrismaClient()`,不要在前端读取或间接获取数据库连接信息。
|
||||
- `DATABASE_URL` 只允许服务端读取;日志和响应都不能输出完整连接串。
|
||||
- `prisma.config.ts` 在 Prisma 命令加载时需要 `DATABASE_URL`;Docker build 使用占位值,运行时由容器环境变量覆盖。
|
||||
- 修改数据库结构的标准流程:
|
||||
1. 先阅读 `prisma/schema.prisma`、相关 handler、`server/utils` 和共享类型。
|
||||
2. 修改 schema。
|
||||
3. 优先新增 Prisma migration;若当前开发库确认可接受 `prisma db push`,必须说明原因。
|
||||
4. 运行 `.\node_modules\.bin\prisma.CMD generate` 重新生成客户端。
|
||||
5. 对实际数据库执行同步命令,并确认 schema 已落库。
|
||||
6. 运行类型检查;涉及服务启动或构建行为时再运行构建。
|
||||
- 如果 migration 状态和数据库实际结构不一致,先停下来检查原因,不要直接强行部署或 resolve。
|
||||
- 数据库不要保存大体积二进制、base64 或敏感明文;确需保存排查字段时必须确认不会返回给前端。
|
||||
- 数据库不要保存大体积二进制、base64 或敏感明文;API Key 如需保存,只能服务端读取,前端只看遮蔽预览。
|
||||
|
||||
## 日志规范
|
||||
## 前端约定
|
||||
|
||||
- 日志必须包含足够排查信息,例如 `requestId`、阶段、用户标识、记录 id、分页、耗时、状态。
|
||||
- 全局请求日志只记录 method、path、status、耗时,不记录请求体和响应体。
|
||||
- 内部响应、第三方响应、数据库异常等细节只允许写入服务端日志或服务端排查字段,不允许通过接口返回给前端。
|
||||
- 不记录密码、cookie、完整 API key、token、完整连接串、完整敏感 prompt、图片二进制或 base64。
|
||||
- 后台任务失败不能影响主请求成功结果时,应记录失败摘要,并在业务状态中使用本地描述。
|
||||
|
||||
## 类型规范
|
||||
|
||||
- 只有前后端共享的接口契约放入 `shared/types`。
|
||||
- 服务端内部适配类型留在 `server/utils` 或对应 handler 内。
|
||||
- 新增或修改接口时先更新共享类型,再更新服务端和前端调用。
|
||||
- 类型字段应有中文注释,说明含义、可选性和是否可能为空。
|
||||
- TypeScript 严格类型优先,避免 `any` 扩散;确需使用时必须收敛在边界处。
|
||||
|
||||
## 前端接入约定
|
||||
|
||||
- 前端服务封装放在 `app/services/`,按业务模块拆分。
|
||||
- 所有接口请求逻辑和相关状态统一放在对应 Pinia store 中,不在组件内直接调用 service。
|
||||
- Store 文件放在 `app/stores/` 下,按业务模块单独拆文件。
|
||||
- 组件只负责 UI 渲染和交互,通过 `useXxxStore()` 和 `storeToRefs()` 消费状态,通过 store action 发起操作。
|
||||
- Store 内部的通用常量作为具名导出,供组件引用,不在组件中重复定义。
|
||||
- 组件不得展示内部服务名、第三方错误、token、key、连接串或原始异常信息。
|
||||
- 接口调用放在 `app/services/`;业务状态、loading、错误文案和 toast 放在 Pinia store。
|
||||
- Store 内部通用常量作为具名导出,供组件引用,不在组件中重复定义。
|
||||
- 认证状态由 `app/plugins/auth.ts` 做 SSR/client 共用初始化,避免首屏水合不一致。
|
||||
- `/dashboard` 由 `app/middleware/auth.ts` 保护,未登录跳 `/login`。
|
||||
- 深色模式通过 `useCookie("color-mode")` 持久化,避免 localStorage 导致 SSR 水合不匹配。
|
||||
- 页面与组件不得展示内部服务名、第三方错误、token、key、连接串或原始异常信息。
|
||||
- shadcn-vue 配置见 `components.json`;新增 UI 优先使用现有 `app/components/ui`、lucide 图标和 Tailwind token。
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 修改优先小步快改,不重构无关代码。
|
||||
- 新增通用逻辑优先抽到 `server/utils`、`app/utils` 或 `shared` 中合适的位置。
|
||||
- `server/` 下新增或大改文件时,文件顶部写一行中文用途说明,格式建议为 `// server/... - 用途说明`。
|
||||
- 复杂 `defineEventHandler` 上方写清楚接口流程、鉴权边界、数据是否入库、哪些信息不会返回前端。
|
||||
- 注释使用中文,描述意图与边界,不写机械解释。
|
||||
- 不删除已有有价值注释。
|
||||
- TypeScript 严格类型优先,避免 `any` 扩散;确需使用时收敛在边界处。
|
||||
- 新增或大改 `server/` 文件时,文件顶部写一行中文用途说明,格式建议为 `// server/... - 用途说明`。
|
||||
- 复杂 `defineEventHandler` 上方写清楚流程、鉴权边界、数据是否入库、哪些信息不会返回前端。
|
||||
- 注释使用中文,描述意图、边界和排查信息,不写机械解释。
|
||||
- 不删除已有有价值注释;重写文件时保留或迁移原注释表达的信息。
|
||||
- import 排序受 `eslint-plugin-simple-import-sort` 约束。
|
||||
- `app/components/ui/**` 在 ESLint 中忽略,通常视为生成代码,不做无关手改。
|
||||
|
||||
## 常用命令
|
||||
|
||||
- 安装依赖:`pnpm install`
|
||||
- 开发启动:`pnpm dev`
|
||||
- 构建:`pnpm build`
|
||||
- 预览构建产物:`pnpm preview`
|
||||
- Nuxt 静态生成:`pnpm generate`
|
||||
- Prisma generate:`.\node_modules\.bin\prisma.CMD generate`
|
||||
- 类型检查:`.\node_modules\.bin\nuxi.CMD typecheck`
|
||||
- ESLint:`.\node_modules\.bin\eslint.CMD .`
|
||||
|
||||
执行说明:
|
||||
|
||||
- 涉及 Prisma schema 时必须运行 Prisma generate。
|
||||
- 涉及 API、共享类型、store、组件联动时至少运行类型检查。
|
||||
- 涉及构建、Nitro 插件、Nuxt 配置、Docker 行为时再运行构建。
|
||||
- 当前仓库未声明专门的 `test` 脚本,不要假设存在单元测试命令。
|
||||
|
||||
## Docker 与部署
|
||||
|
||||
- `Dockerfile.base` 基于 `node:22-alpine`,启用 corepack 并激活 pnpm 10.33.0。
|
||||
- `Dockerfile` 从 `ocs-base:latest` 构建应用,复制源码、安装依赖、执行 `pnpm build`,最终用 `node .output/server/index.mjs` 启动。
|
||||
- `.dockerignore` 排除 `node_modules/`、`.nuxt/`、`.output/`、`prisma/generated/`、本地 env 和日志。
|
||||
- 容器默认 `PORT=3000`,`TZ=Asia/Shanghai`。
|
||||
- 构建阶段的 `DATABASE_URL` 是占位值;运行时必须提供真实数据库连接和 Better Auth 环境变量。
|
||||
|
||||
## 可用 Skills
|
||||
|
||||
本项目本地 `.agents/skills` 包含以下 Better Auth 相关 skills,遇到对应任务时优先查阅:
|
||||
|
||||
- `better-auth-best-practices`:Better Auth 服务端/客户端、数据库适配、session、插件和环境变量。
|
||||
- `create-auth-skill`:为 TypeScript/JavaScript 项目脚手架或扩展认证流程。
|
||||
- `email-and-password-best-practices`:邮箱密码、邮箱验证、密码重置和密码策略。
|
||||
- `better-auth-security-best-practices`:速率限制、secret、CSRF、trusted origins、cookie/session 安全和审计。
|
||||
- `organization-best-practices`:组织、多租户、成员、邀请、团队和 RBAC。
|
||||
- `two-factor-authentication-best-practices`:TOTP、OTP、备份码、可信设备和 2FA 登录流。
|
||||
|
||||
当前会话还可用的通用 skills / 插件能力:
|
||||
|
||||
- `frontend-design`:构建或美化前端页面、组件、应用体验。
|
||||
- `openai-docs`:查询 OpenAI 官方文档和最新 API/模型建议。
|
||||
- `imagegen`、`hatch-pet`:生成位图视觉素材或 Codex pet。
|
||||
- `documents:documents`、`presentations:Presentations`、`spreadsheets:Spreadsheets`:处理 Word、PPT、表格文件。
|
||||
- GitHub 插件 skills:`github:github`、`github:gh-address-comments`、`github:gh-fix-ci`、`github:yeet`。
|
||||
- `skill-creator`、`skill-installer`、`plugin-creator`:创建/安装 Codex skills 或插件。
|
||||
|
||||
使用 skills 时先读对应 `SKILL.md`,只加载与当前任务直接相关的引用文件,不要为了普通代码改动批量读取全部资料。
|
||||
|
||||
## AI 执行清单
|
||||
|
||||
当 AI 新增或调整 API、数据结构或核心业务逻辑时,按以下顺序执行:
|
||||
当新增或调整 API、数据结构、认证、配置或核心业务逻辑时,按以下顺序执行:
|
||||
|
||||
1. 先阅读现有 handler、utils、shared types、Prisma schema,确认真实实现。
|
||||
2. 更新共享类型和服务端内部类型。
|
||||
3. 在 handler 中完成请求体校验、白名单过滤、鉴权和日志。
|
||||
4. 将可复用业务逻辑抽到合适的 `server/utils`、`app/utils` 或 `shared`。
|
||||
5. 涉及数据库时同步修改 Prisma schema、migration,并对实际数据库执行同步。
|
||||
6. 确认前端响应中的 `msg`、`data` 不包含内部或第三方细节。
|
||||
7. 确认敏感信息不进入前端响应、日志或不该保存的数据库字段。
|
||||
8. 运行类型检查;涉及 Prisma schema 时必须运行 `prisma generate`,涉及服务启动或构建行为时再运行构建。
|
||||
1. 阅读真实实现:handler、middleware、`server/utils`、`shared/types`、Pinia store、Prisma schema。
|
||||
2. 明确鉴权边界:公开路由、session、apiToken、superadmin 二次校验。
|
||||
3. 更新共享类型,再更新服务端、前端 service、store 和组件。
|
||||
4. 对请求体做普通对象校验和字段白名单过滤。
|
||||
5. 确认响应 `msg`、`data` 不包含内部或第三方细节。
|
||||
6. 确认敏感信息不进入前端响应、日志或不该保存的数据库字段。
|
||||
7. 涉及数据库时同步 schema、migration 或 db push、Prisma generate 和实际数据库。
|
||||
8. 运行类型检查;涉及 Prisma schema 时必须 Prisma generate;涉及服务启动或构建行为时再构建。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不为未来不确定需求预建兼容路由。
|
||||
- 不引入与当前需求无关的抽象层。
|
||||
- 不恢复旧的 `ACCESS_TOKEN` 单全局令牌模式。
|
||||
- 不恢复旧的 OpenAI 环境变量配置作为主配置来源。
|
||||
- 不为同一能力创建多个本地别名路由。
|
||||
- 不新增长期保留的临时排查接口,例如数据库健康检查类接口。
|
||||
- 不引入与当前需求无关的抽象层、兼容层或配置项。
|
||||
- 不在多个地方维护同一配置值。
|
||||
- 不保存大体积 base64 或敏感明文。
|
||||
- 不把服务端密钥、session、token、cookie 或数据库连接信息暴露给前端。
|
||||
- 不保存大体积 base64、敏感明文或上游完整响应体。
|
||||
- 不把服务端密钥、session、token、cookie、数据库连接信息或内部错误暴露给前端。
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Moon,
|
||||
Search,
|
||||
Shield,
|
||||
Sun
|
||||
} from "lucide-vue-next";
|
||||
|
||||
@@ -21,7 +22,7 @@ const themeStore = useGlobalStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
/** 顶部导航项,icon 使用 lucide,和项目里 shadcn 的图标体系保持一致 */
|
||||
const navItems = [
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
label: "答题",
|
||||
to: "/",
|
||||
@@ -31,8 +32,17 @@ const navItems = [
|
||||
label: "Dashboard",
|
||||
to: "/dashboard",
|
||||
icon: LayoutDashboard
|
||||
}
|
||||
];
|
||||
},
|
||||
...(authStore.user?.role === "superadmin"
|
||||
? [
|
||||
{
|
||||
label: "用户管理",
|
||||
to: "/admin/users",
|
||||
icon: Shield
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
// app/interfaces/index.ts - 前端共享类型统一出口
|
||||
export * from "./answer";
|
||||
export type {
|
||||
IAdminUser,
|
||||
IAdminUserCreateRequest,
|
||||
IAdminUserMutationResponse,
|
||||
IAdminUserResponse,
|
||||
IAdminUsersData,
|
||||
IAdminUsersListRequest,
|
||||
IAdminUsersResponse,
|
||||
IAdminUserUpdateRequest,
|
||||
UserRole
|
||||
} from "~~/shared/types/adminUsers";
|
||||
export { USER_ROLE_VALUES } from "~~/shared/types/adminUsers";
|
||||
export type {
|
||||
IRegisterStatusPublic,
|
||||
IRegisterStatusResponse,
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { AuthSessionPayload } from "@/stores/auth";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// 只保护 /dashboard 路径
|
||||
if (!to.path.startsWith("/dashboard")) return;
|
||||
// 保护 Dashboard 和后台管理页
|
||||
const requiresAuth =
|
||||
to.path.startsWith("/dashboard") || to.path.startsWith("/admin");
|
||||
if (!requiresAuth) return;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
@@ -27,4 +29,8 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (!authStore.isOnline) {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
|
||||
if (to.path.startsWith("/admin") && authStore.user?.role !== "superadmin") {
|
||||
return navigateTo("/");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
<!-- app/pages/admin/users.vue - superadmin 用户管理页面 -->
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Edit,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users
|
||||
} from "lucide-vue-next";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
import TableCom from "@/components/Table/TableCom.vue";
|
||||
import type { TableColumn } from "@/components/Table/type";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import type { IAdminUser, UserRole } from "@/interfaces";
|
||||
import {
|
||||
ADMIN_USER_PAGE_SIZE_OPTIONS,
|
||||
useAdminUsersStore,
|
||||
USER_ROLE_LABELS,
|
||||
USER_ROLE_OPTIONS
|
||||
} from "@/stores/adminUsers";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
pageOrder: 5,
|
||||
pageTransition: { name: "slide-left" }
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "用户管理 - OCS AI 答题服务"
|
||||
});
|
||||
|
||||
const adminUsersStore = useAdminUsersStore();
|
||||
const { users, total, page, pageSize, keyword, roleFilter, loading, mutating } =
|
||||
storeToRefs(adminUsersStore);
|
||||
|
||||
const columns = computed<TableColumn[]>(() => [
|
||||
{
|
||||
label: "创建时间",
|
||||
slot: "createdAt",
|
||||
width: 170
|
||||
},
|
||||
{
|
||||
label: "用户",
|
||||
slot: "user",
|
||||
minWidth: 180
|
||||
},
|
||||
{
|
||||
label: "邮箱",
|
||||
prop: "email",
|
||||
minWidth: 220
|
||||
},
|
||||
{
|
||||
label: "角色",
|
||||
slot: "role",
|
||||
width: 120,
|
||||
cellAlign: "center",
|
||||
headerAlign: "center"
|
||||
},
|
||||
{
|
||||
label: "问答数",
|
||||
prop: "qaRecordCount",
|
||||
width: 90,
|
||||
cellAlign: "center",
|
||||
headerAlign: "center",
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
label: "更新时间",
|
||||
slot: "updatedAt",
|
||||
width: 170
|
||||
},
|
||||
{
|
||||
label: "操作",
|
||||
slot: "action",
|
||||
width: 120,
|
||||
cellAlign: "center",
|
||||
headerAlign: "center"
|
||||
}
|
||||
]);
|
||||
|
||||
const formOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const editingUser = ref<IAdminUser | null>(null);
|
||||
const deletingUser = ref<IAdminUser | null>(null);
|
||||
const formError = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user" as UserRole
|
||||
});
|
||||
|
||||
const isEditing = computed(() => Boolean(editingUser.value));
|
||||
const formTitle = computed(() => (isEditing.value ? "编辑用户" : "创建用户"));
|
||||
const formDescription = computed(() =>
|
||||
isEditing.value
|
||||
? "修改用户资料、角色或重置密码。密码留空时不会变更。"
|
||||
: "创建的新用户会获得独立登录账号和 OCS API Token。"
|
||||
);
|
||||
|
||||
const roleBadgeVariant = (role: UserRole) => {
|
||||
if (role === "superadmin") return "default";
|
||||
if (role === "admin") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const resetForm = (user?: IAdminUser) => {
|
||||
editingUser.value = user ?? null;
|
||||
form.name = user?.name ?? "";
|
||||
form.email = user?.email ?? "";
|
||||
form.password = "";
|
||||
form.role = user?.role ?? "user";
|
||||
formError.value = "";
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetForm();
|
||||
formOpen.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (user: IAdminUser) => {
|
||||
resetForm(user);
|
||||
formOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (user: IAdminUser) => {
|
||||
deletingUser.value = user;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const email = form.email.trim();
|
||||
const password = form.password;
|
||||
|
||||
if (!form.name.trim()) return "昵称不能为空";
|
||||
if (form.name.trim().length > 20) return "昵称不能超过 20 个字符";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
|
||||
return "请输入有效的邮箱地址";
|
||||
}
|
||||
if (!isEditing.value && !password) return "密码不能为空";
|
||||
if (password) {
|
||||
if (password.length < 8) return "密码至少 8 位";
|
||||
if (password.length > 100) return "密码不能超过 100 位";
|
||||
if (!/[a-zA-Z]/u.test(password)) return "密码必须包含英文字母";
|
||||
if (!/[0-9]/u.test(password)) return "密码必须包含数字";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
const error = validateForm();
|
||||
if (error) {
|
||||
formError.value = error;
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
role: form.role
|
||||
};
|
||||
|
||||
const ok = isEditing.value
|
||||
? await adminUsersStore.updateUser(editingUser.value!.id, {
|
||||
...payload,
|
||||
password: form.password
|
||||
})
|
||||
: await adminUsersStore.createUser({
|
||||
...payload,
|
||||
password: form.password
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
formOpen.value = false;
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
if (!deletingUser.value) return;
|
||||
const ok = await adminUsersStore.deleteUser(deletingUser.value.id);
|
||||
if (ok) {
|
||||
deleteOpen.value = false;
|
||||
deletingUser.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.client) {
|
||||
onNuxtReady(() => {
|
||||
void adminUsersStore.fetchUsers(1);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="size-5 text-primary" />
|
||||
<h1 class="text-2xl font-semibold tracking-normal">用户管理</h1>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
查询、创建和维护站内用户,仅超级管理员可访问。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button class="w-fit gap-2" @click="openCreateDialog">
|
||||
<UserPlus class="size-4" />
|
||||
新建用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div
|
||||
class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"
|
||||
>
|
||||
<div>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>
|
||||
按创建时间倒序展示,支持邮箱和昵称搜索。
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_150px_120px_auto]"
|
||||
>
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="pl-9"
|
||||
placeholder="搜索昵称或邮箱"
|
||||
autocomplete="off"
|
||||
@keyup.enter="adminUsersStore.applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="roleFilter || 'all'"
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
(roleFilter = value === 'all' ? '' : (value as UserRole))
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem
|
||||
v-for="item in USER_ROLE_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="
|
||||
(value) => adminUsersStore.setPageSize(Number(value))
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="size in ADMIN_USER_PAGE_SIZE_OPTIONS"
|
||||
:key="size"
|
||||
:value="String(size)"
|
||||
>
|
||||
{{ size }} 条/页
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 gap-2"
|
||||
:disabled="loading"
|
||||
@click="adminUsersStore.applyFilters"
|
||||
>
|
||||
<Search class="size-4" />
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="重置筛选"
|
||||
:disabled="loading"
|
||||
@click="adminUsersStore.resetFilters"
|
||||
>
|
||||
<RotateCcw class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<TableCom
|
||||
:data="users"
|
||||
:columns="columns"
|
||||
:load="loading"
|
||||
:buffer="true"
|
||||
:pagination="{
|
||||
currentPage: page,
|
||||
total,
|
||||
pageSize,
|
||||
align: 'end'
|
||||
}"
|
||||
cell-align="left"
|
||||
header-align="start"
|
||||
cell-class-name="align-middle"
|
||||
@page:change="adminUsersStore.setPage"
|
||||
>
|
||||
<template #createdAt="{ value }">
|
||||
<span class="whitespace-nowrap text-foreground">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #updatedAt="{ value }">
|
||||
<span class="whitespace-nowrap">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #user="{ row }">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-foreground">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #role="{ value }">
|
||||
<Badge :variant="roleBadgeVariant(value)">
|
||||
{{ USER_ROLE_LABELS[value as UserRole] || value }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
title="编辑用户"
|
||||
@click.stop="openEditDialog(row)"
|
||||
>
|
||||
<Edit class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
title="删除用户"
|
||||
@click.stop="openDeleteDialog(row)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</TableCom>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog v-model:open="formOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ formTitle }}</DialogTitle>
|
||||
<DialogDescription>{{ formDescription }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="admin-user-name">昵称</Label>
|
||||
<Input
|
||||
id="admin-user-name"
|
||||
v-model="form.name"
|
||||
maxlength="20"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="admin-user-email">邮箱</Label>
|
||||
<Input
|
||||
id="admin-user-email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="admin-user-role">角色</Label>
|
||||
<Select v-model="form.role" :disabled="mutating">
|
||||
<SelectTrigger id="admin-user-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in USER_ROLE_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="admin-user-password">
|
||||
{{ isEditing ? "新密码" : "初始密码" }}
|
||||
<span v-if="isEditing" class="font-normal text-muted-foreground">
|
||||
(留空不修改)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少 8 位,包含字母和数字"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="text-sm text-destructive">
|
||||
{{ formError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="mutating"
|
||||
@click="formOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button :disabled="mutating" @click="saveUser">
|
||||
{{ mutating ? "保存中..." : "保存" }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="deleteOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除后会同步移除该用户的会话、账号、个人配置和问答记录。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="rounded-lg border bg-muted/60 p-3 text-sm">
|
||||
<div class="font-medium text-foreground">
|
||||
{{ deletingUser?.name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground">{{ deletingUser?.email }}</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="mutating"
|
||||
@click="deleteOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="mutating"
|
||||
@click="confirmDeleteUser"
|
||||
>
|
||||
{{ mutating ? "删除中..." : "确认删除" }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
// app/services/admin_user_service.ts - 超级管理员用户管理 API 封装
|
||||
import type {
|
||||
IAdminUserCreateRequest,
|
||||
IAdminUserMutationResponse,
|
||||
IAdminUserResponse,
|
||||
IAdminUsersListRequest,
|
||||
IAdminUsersResponse,
|
||||
IAdminUserUpdateRequest
|
||||
} from "@/interfaces";
|
||||
import BaseClientService from "@/services/base_service";
|
||||
|
||||
export class AdminUserService {
|
||||
private static readonly basePath = "/api/admin/users";
|
||||
|
||||
/** 分页查询用户列表 */
|
||||
public static getUsers(req: IAdminUsersListRequest) {
|
||||
return BaseClientService.get<IAdminUsersResponse>(
|
||||
AdminUserService.basePath,
|
||||
{
|
||||
page: req.page,
|
||||
size: req.size,
|
||||
keyword: req.keyword,
|
||||
role: req.role
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建用户 */
|
||||
public static createUser(req: IAdminUserCreateRequest) {
|
||||
return BaseClientService.post<IAdminUserResponse, IAdminUserCreateRequest>(
|
||||
AdminUserService.basePath,
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新用户资料、角色或密码 */
|
||||
public static updateUser(id: string, req: IAdminUserUpdateRequest) {
|
||||
return BaseClientService.put<IAdminUserResponse, IAdminUserUpdateRequest>(
|
||||
`${AdminUserService.basePath}/${encodeURIComponent(id)}`,
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除用户 */
|
||||
public static deleteUser(id: string) {
|
||||
return BaseClientService.delete<IAdminUserMutationResponse>(
|
||||
`${AdminUserService.basePath}/${encodeURIComponent(id)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,13 @@ export default class BaseClientService {
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
/** 发起 DELETE 请求 */
|
||||
public static async delete<T>(url: string) {
|
||||
const data = await $fetch<T>(url, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
return { data };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// app/services/index.ts - 前端 service 统一出口
|
||||
export * from "./admin_user_service";
|
||||
export * from "./answer_service";
|
||||
export * from "./settings_service";
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// app/stores/adminUsers.ts - 超级管理员用户管理状态
|
||||
import { defineStore } from "pinia";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
import type {
|
||||
IAdminUser,
|
||||
IAdminUserCreateRequest,
|
||||
IAdminUserUpdateRequest,
|
||||
UserRole
|
||||
} from "@/interfaces";
|
||||
import { AdminUserService } from "@/services";
|
||||
import { getClientErrorMessage } from "@/utils";
|
||||
|
||||
export const ADMIN_USER_PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
|
||||
|
||||
export const USER_ROLE_OPTIONS: Array<{ label: string; value: UserRole }> = [
|
||||
{ label: "普通用户", value: "user" },
|
||||
{ label: "管理员", value: "admin" },
|
||||
{ label: "超级管理员", value: "superadmin" }
|
||||
];
|
||||
|
||||
export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
user: "普通用户",
|
||||
admin: "管理员",
|
||||
superadmin: "超级管理员"
|
||||
};
|
||||
|
||||
export const useAdminUsersStore = defineStore("adminUsers", () => {
|
||||
const users = ref<IAdminUser[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref("");
|
||||
const roleFilter = ref<UserRole | "">("");
|
||||
const loading = ref(false);
|
||||
const mutating = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const fetchUsers = async (nextPage = page.value) => {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const res = await AdminUserService.getUsers({
|
||||
page: nextPage,
|
||||
size: pageSize.value,
|
||||
keyword: keyword.value.trim(),
|
||||
role: roleFilter.value
|
||||
});
|
||||
|
||||
if (res.data.code === 0) {
|
||||
users.value = res.data.data.users;
|
||||
page.value = res.data.data.page;
|
||||
pageSize.value = res.data.data.size;
|
||||
total.value = res.data.data.total;
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = res.data.msg || "用户列表读取失败";
|
||||
toast.error(errorMessage.value);
|
||||
} catch (error) {
|
||||
errorMessage.value = getClientErrorMessage(error, "用户列表读取失败");
|
||||
toast.error(errorMessage.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPage = async (nextPage: number) => {
|
||||
if (loading.value || nextPage === page.value) return;
|
||||
await fetchUsers(nextPage);
|
||||
};
|
||||
|
||||
const setPageSize = async (nextSize: number) => {
|
||||
if (loading.value || nextSize === pageSize.value) return;
|
||||
pageSize.value = nextSize;
|
||||
await fetchUsers(1);
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
await fetchUsers(1);
|
||||
};
|
||||
|
||||
const resetFilters = async () => {
|
||||
keyword.value = "";
|
||||
roleFilter.value = "";
|
||||
await fetchUsers(1);
|
||||
};
|
||||
|
||||
const createUser = async (req: IAdminUserCreateRequest) => {
|
||||
mutating.value = true;
|
||||
try {
|
||||
const res = await AdminUserService.createUser(req);
|
||||
if (res.data.code === 0) {
|
||||
toast.success("用户已创建");
|
||||
await fetchUsers(1);
|
||||
return true;
|
||||
}
|
||||
toast.error(res.data.msg || "创建失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
toast.error(getClientErrorMessage(error, "创建失败"));
|
||||
return false;
|
||||
} finally {
|
||||
mutating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (id: string, req: IAdminUserUpdateRequest) => {
|
||||
mutating.value = true;
|
||||
try {
|
||||
const res = await AdminUserService.updateUser(id, req);
|
||||
if (res.data.code === 0) {
|
||||
toast.success("用户已更新");
|
||||
await fetchUsers(page.value);
|
||||
return true;
|
||||
}
|
||||
toast.error(res.data.msg || "更新失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
toast.error(getClientErrorMessage(error, "更新失败"));
|
||||
return false;
|
||||
} finally {
|
||||
mutating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (id: string) => {
|
||||
mutating.value = true;
|
||||
try {
|
||||
const res = await AdminUserService.deleteUser(id);
|
||||
if (res.data.code === 0) {
|
||||
toast.success("用户已删除");
|
||||
const nextPage =
|
||||
users.value.length === 1 && page.value > 1
|
||||
? page.value - 1
|
||||
: page.value;
|
||||
await fetchUsers(nextPage);
|
||||
return true;
|
||||
}
|
||||
toast.error(res.data.msg || "删除失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
toast.error(getClientErrorMessage(error, "删除失败"));
|
||||
return false;
|
||||
} finally {
|
||||
mutating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
roleFilter,
|
||||
loading,
|
||||
mutating,
|
||||
errorMessage,
|
||||
fetchUsers,
|
||||
setPage,
|
||||
setPageSize,
|
||||
applyFilters,
|
||||
resetFilters,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
// app/stores/answer.ts - 答题页和 Dashboard 的统一状态管理
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
@@ -64,8 +65,12 @@ export const useAnswerStore = defineStore("answer", () => {
|
||||
const cacheClearing = ref(false);
|
||||
/** 最近问答记录当前页码 */
|
||||
const recordsPage = ref(1);
|
||||
/** 最近问答记录每页数量 */
|
||||
const recordsPageSize = ref(DASHBOARD_RECORD_PAGE_SIZE);
|
||||
/** 最近问答记录每页数量,持久化到 localStorage;initOnMounted 保证 SSR 时不访问 storage,避免水合不匹配 */
|
||||
const recordsPageSize = useLocalStorage(
|
||||
"dashboard:records:pageSize",
|
||||
DASHBOARD_RECORD_PAGE_SIZE,
|
||||
{ initOnMounted: true }
|
||||
);
|
||||
/** 最近问答记录总数 */
|
||||
const recordCount = ref(0);
|
||||
/** 当前选中的问答记录 */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `banned` BOOLEAN NULL DEFAULT false,
|
||||
ADD COLUMN `banReason` VARCHAR(191) NULL,
|
||||
ADD COLUMN `banExpires` DATETIME(3) NULL;
|
||||
|
||||
ALTER TABLE `session`
|
||||
ADD COLUMN `impersonatedBy` VARCHAR(191) NULL;
|
||||
@@ -25,6 +25,12 @@ model User {
|
||||
cacheClearedAt DateTime?
|
||||
/// 用户角色:user(普通用户)、admin(管理员)、superadmin(超级管理员)
|
||||
role String @default("user")
|
||||
/// Better Auth Admin 插件字段:是否禁用登录
|
||||
banned Boolean? @default(false)
|
||||
/// Better Auth Admin 插件字段:禁用原因
|
||||
banReason String?
|
||||
/// Better Auth Admin 插件字段:禁用到期时间
|
||||
banExpires DateTime?
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
qaRecords QaRecord[]
|
||||
@@ -42,6 +48,8 @@ model Session {
|
||||
updatedAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
/// Better Auth Admin 插件字段:模拟登录来源管理员 id
|
||||
impersonatedBy String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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
@@ -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) });
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// shared/types/adminUsers.ts - 超级管理员用户管理接口契约
|
||||
import type { IApiResponse } from "./answer";
|
||||
|
||||
export const USER_ROLE_VALUES = ["user", "admin", "superadmin"] as const;
|
||||
|
||||
/** 用户角色:user 普通用户,admin 业务管理员,superadmin 超级管理员 */
|
||||
export type UserRole = (typeof USER_ROLE_VALUES)[number];
|
||||
|
||||
/** 用户管理页展示的用户行,不包含 token、密码或内部认证细节 */
|
||||
export interface IAdminUser {
|
||||
/** 用户 id */
|
||||
id: string;
|
||||
/** 用户昵称 */
|
||||
name: string;
|
||||
/** 登录邮箱 */
|
||||
email: string;
|
||||
/** 用户角色 */
|
||||
role: UserRole;
|
||||
/** 邮箱是否已验证 */
|
||||
emailVerified: boolean;
|
||||
/** 本地格式创建时间 */
|
||||
createdAt: string;
|
||||
/** 本地格式更新时间 */
|
||||
updatedAt: string;
|
||||
/** 该用户问答记录数量 */
|
||||
qaRecordCount: number;
|
||||
}
|
||||
|
||||
/** 用户列表查询参数 */
|
||||
export interface IAdminUsersListRequest {
|
||||
/** 当前页码,从 1 开始 */
|
||||
page: number;
|
||||
/** 每页数量 */
|
||||
size: number;
|
||||
/** 按昵称或邮箱模糊搜索 */
|
||||
keyword?: string;
|
||||
/** 角色筛选;空字符串表示全部 */
|
||||
role?: UserRole | "";
|
||||
}
|
||||
|
||||
/** 用户列表响应数据 */
|
||||
export interface IAdminUsersData {
|
||||
/** 当前页用户列表 */
|
||||
users: IAdminUser[];
|
||||
/** 当前页码 */
|
||||
page: number;
|
||||
/** 每页数量 */
|
||||
size: number;
|
||||
/** 符合筛选条件的总数 */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 创建用户请求 */
|
||||
export interface IAdminUserCreateRequest {
|
||||
/** 用户昵称 */
|
||||
name: string;
|
||||
/** 登录邮箱 */
|
||||
email: string;
|
||||
/** 初始密码 */
|
||||
password: string;
|
||||
/** 用户角色 */
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/** 更新用户请求;password 为空或未传时不改密码 */
|
||||
export interface IAdminUserUpdateRequest {
|
||||
/** 用户昵称 */
|
||||
name?: string;
|
||||
/** 登录邮箱 */
|
||||
email?: string;
|
||||
/** 用户角色 */
|
||||
role?: UserRole;
|
||||
/** 新密码;空字符串表示不修改 */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type IAdminUsersResponse = IApiResponse<IAdminUsersData>;
|
||||
export type IAdminUserResponse = IApiResponse<IAdminUser>;
|
||||
export type IAdminUserMutationResponse = IApiResponse<null>;
|
||||
@@ -1,3 +1,4 @@
|
||||
// shared/types/index.ts - 前后端通用类型统一出口
|
||||
export * from "./adminUsers";
|
||||
export * from "./answer";
|
||||
export * from "./settings";
|
||||
|
||||
Reference in New Issue
Block a user