From 2c8e229e0059ee42c75e10629823a88b4c90280a Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:21:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P1-3/4/6=20=E8=A7=A3=E8=80=A6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20-=20=E6=8B=86=E5=88=86=20auth/users=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20+=20notifications=20=E5=8F=8D=E5=90=91?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../004_architecture_impact_map.md | 60 +++--- docs/architecture/005_architecture_data.json | 182 +++++++++++++++--- src/auth.ts | 127 +----------- .../notifications/channels/in-app-channel.ts | 9 +- src/modules/users/class-registration.ts | 27 +++ src/modules/users/import-export.ts | 123 +----------- src/modules/users/user-service.ts | 99 ++++++++++ src/shared/lib/bcrypt-utils.ts | 19 ++ src/shared/lib/http-utils.ts | 28 +++ src/shared/lib/password-security-service.ts | 94 +++++++++ src/shared/lib/role-utils.ts | 34 ++++ 11 files changed, 514 insertions(+), 288 deletions(-) create mode 100644 src/modules/users/class-registration.ts create mode 100644 src/modules/users/user-service.ts create mode 100644 src/shared/lib/bcrypt-utils.ts create mode 100644 src/shared/lib/http-utils.ts create mode 100644 src/shared/lib/password-security-service.ts create mode 100644 src/shared/lib/role-utils.ts diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 9db76a0..da36186 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -176,6 +176,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" ──▶ import { ... } from "@/shared/lib/login-logger" ──▶ import { ... } from "@/shared/lib/password-policy" ──▶ import { ... } from "@/shared/lib/rate-limit" + ──▶ import { ... } from "@/shared/lib/role-utils" # P1-3 拆出 + ──▶ import { ... } from "@/shared/lib/bcrypt-utils" # P1-3 拆出 + ──▶ import { ... } from "@/shared/lib/http-utils" # P1-3 拆出 + ──▶ import { ... } from "@/shared/lib/password-security-service" # P1-3 拆出 ──▶ import { db, schema } from "@/shared/db" ⟳ 循环:shared/lib/* → @/auth → shared/lib/* @@ -365,7 +369,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **已知问题**: - ❌ P0:`shared/lib/*` ↔ `@/auth` 循环依赖 - ⚠️ P1:`schema.ts` 1111 行(54 张表混合,超 1000 硬上限) -- ⚠️ P1:`auth.ts` 293 行混合 5 类职责 +- ✅ P1:~~`auth.ts` 293 行混合 5 类职责~~ 已拆分(4 个辅助函数组迁移至 `shared/lib/{role-utils,bcrypt-utils,http-utils,password-security-service}`,auth.ts 仅保留 NextAuth 配置) - ⚠️ P2:`ai.ts` 218 行混合 5 类职责 - ⚠️ P2:`onboarding-gate.tsx` 业务逻辑泄漏到 shared @@ -383,6 +387,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `lib/login-logger.ts` | - | 登录日志 | | `lib/password-policy.ts` | - | 密码策略纯函数 | | `lib/rate-limit.ts` | - | 内存滑动窗口限流 | +| `lib/role-utils.ts` | 35 | 角色规范化纯函数(normalizeRole / resolvePrimaryRole) | +| `lib/bcrypt-utils.ts` | 22 | bcrypt 哈希前缀规范化纯函数 | +| `lib/http-utils.ts` | 30 | 请求头解析(resolveClientIp,server-only) | +| `lib/password-security-service.ts` | 100 | 密码安全 DB 操作(账户锁定/失败登录追踪,server-only) | | `lib/excel.ts` | - | Excel 导入导出 | | `lib/file-storage.ts` | - | 文件存储抽象 | | `hooks/use-permission.ts` | - | 客户端权限 Hook | @@ -669,22 +677,26 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **导出函数**: - Actions:`getUserProfileAction` / `updateUserProfileAction` / `importUsersAction` / `exportUsersAction` / `downloadUserTemplateAction` - Data-access:`getUserProfile` -- Import-export:`generateUserImportTemplate` / `parseUserImportData` / `batchImportUsers` / `exportUsersToExcel` +- Import-export:`generateUserImportTemplate` / `parseUserImportData` / `exportUsersToExcel`(+ re-export `batchImportUsers` / `UserImportResult` 保持向后兼容) +- User-service:`batchImportUsers`(用户创建 + 密码哈希 + 角色分配) +- Class-registration:`registerStudentByInvitationCode`(委托 classes/data-access 完成班级注册) **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`classes`(❌ `batchImportUsers` 直查 classes + 直写 classEnrollments) +- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-4 已修复:通过 `class-registration.ts` 调用 `classes/data-access.enrollStudentByInvitationCode`,不再直写 classEnrollments) - 被依赖:`dashboard`(通过 data-access,P0-4 已修复)、`grades`(❌ 直查)、`homework`(❌ 直查) **已知问题**: -- ❌ P1:`import-export.ts` 四重职责混合(导入解析 + 导出 + 用户创建 + 班级注册) -- ❌ P1:`batchImportUsers` 跨模块写 `classEnrollments`(classes 模块的写操作) +- ✅ P1 已解决:`import-export.ts` 四重职责已拆分为 `import-export.ts`(解析/生成)+ `user-service.ts`(用户创建)+ `class-registration.ts`(班级注册) +- ✅ P1 已解决:`batchImportUsers` 不再跨模块直写 `classEnrollments`,改为调用 `classes/data-access.enrollStudentByInvitationCode` - ❌ P1:`updateUserProfile` 绕过 data-access 直接 DB 写 - ⚠️ P2:`data-access.ts` 仅 71 行,写操作缺失 **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `import-export.ts` | 291 | 导入解析 + 导出 + 用户创建(职责混合) | +| `import-export.ts` | 177 | 文件解析/生成(模板生成 + 解析校验 + Excel 导出)+ re-export 向后兼容 | +| `user-service.ts` | 96 | 用户创建(批量导入 + 密码哈希 + 角色分配) | +| `class-registration.ts` | 30 | 班级注册(委托 classes/data-access) | | `actions.ts` | 151 | 5 个 Server Action | | `data-access.ts` | 71 | 仅 getUserProfile | @@ -1196,24 +1208,28 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/* **解耦建议**:actions 层仅做"权限校验 → 解析 → 调用 data-access → revalidatePath → 返回",所有 DB 操作下沉到 data-access。 -### P1-3:auth.ts 混合 5 类职责 +### P1-3:auth.ts 混合 5 类职责 ✅ 已完成 -`src/auth.ts` 293 行混合:NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数。 +`src/auth.ts` 原 293 行混合:NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数。 -**解耦建议**: -- 密码安全 DB 操作 → `shared/lib/password-security-service.ts` -- 角色规范化 → `shared/lib/role-utils.ts` -- IP 解析 → `shared/lib/http-utils.ts`(与三个 logger 共用) -- `authorize` 回调拆分为 `checkRateLimit` / `checkAccountLockout` / `verifyPassword` / `loadUserRoles` +**已完成拆分**(auth.ts 现 208 行,仅保留 NextAuth 配置): +- ✅ 密码安全 DB 操作 → `shared/lib/password-security-service.ts`(getOrCreatePasswordSecurity / recordFailedLogin / resetFailedLogin,server-only) +- ✅ 角色规范化 → `shared/lib/role-utils.ts`(normalizeRole / resolvePrimaryRole,纯函数) +- ✅ bcrypt 哈希规范化 → `shared/lib/bcrypt-utils.ts`(normalizeBcryptHash,纯函数) +- ✅ IP 解析 → `shared/lib/http-utils.ts`(resolveClientIp,server-only) -### P1-4:users/import-export.ts 四重职责 +**后续可选优化**(未执行,需保持 NextAuth 配置不变原则下评估): +- `authorize` 回调可进一步拆分为 `checkRateLimit` / `checkAccountLockout` / `verifyPassword` / `loadUserRoles`,使 auth.ts 降至 ≤150 行 -同时处理:导入解析 + 导出 + 用户创建(含密码哈希)+ 班级注册(跨模块写 classEnrollments)。 +### P1-4:users/import-export.ts 四重职责 ✅ 已完成 -**解耦建议**: -- 拆分为 `import.ts`(解析+校验)+ `export.ts`(模板生成+列表导出) -- 用户创建逻辑迁移至 `data-access.ts` 的 `createUser` -- classEnrollments 写入改为调用 `classes/data-access.enrollStudentByInvitationCode` +`users/import-export.ts` 原 291 行混合:导入解析 + 导出 + 用户创建(含密码哈希)+ 班级注册(跨模块写 classEnrollments)。 + +**已完成拆分**(import-export.ts 现 177 行,仅保留文件解析/生成): +- ✅ 用户创建(含密码哈希、角色分配)→ `user-service.ts`(`batchImportUsers`,96 行,server-only) +- ✅ 班级注册 → `class-registration.ts`(`registerStudentByInvitationCode`,30 行,server-only) +- ✅ `batchImportUsers` 不再直写 `classEnrollments`,改为调用 `classes/data-access.enrollStudentByInvitationCode` +- ✅ `import-export.ts` 通过 re-export `batchImportUsers` / `UserImportResult` 保持向后兼容(`actions.ts` 和 `app/api/export/route.ts` 无需修改) ### P1-5:notifications 反向依赖 messaging @@ -1269,8 +1285,8 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/* ### 短期执行(P1) 8. actions 层移除直接 DB 操作(exams/homework/questions/announcements/users/scheduling) -9. 拆分 `auth.ts` -10. 拆分 `users/import-export.ts` +9. ~~拆分 `auth.ts`~~ ✅ 已完成(4 个辅助函数组迁移至 shared/lib,auth.ts 保留 NextAuth 配置) +10. ~~拆分 `users/import-export.ts`~~ ✅ 已完成(拆为 import-export.ts 177行 + user-service.ts 96行 + class-registration.ts 30行,班级注册改为调用 classes/data-access) 11. 消除 notifications → messaging 反向依赖 12. 提取 `shared/lib/http-utils.ts` 统一 IP 提取 13. 各模块暴露跨模块查询接口(见 P1-1) @@ -1316,7 +1332,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/* | **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - | | **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ❌直查 | ❌直查 | - | ❌直查 | - | - | - | - | | **dashboard** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | ✅data-access | - | - | - | - | -| **users** | ✅ | ✅ | - | - | - | - | ❌写enrollments | - | - | - | - | - | - | - | +| **users** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | | **messaging** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | ❌绕过 | - | | **notifications** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | ⟳反向依赖 | - | - | | **attendance** | ✅ | ✅ | - | - | - | - | ❌直查 | - | - | - | - | - | - | - | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 3ede40f..652d8ab 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -527,6 +527,97 @@ "auth.ts (events.signIn, events.signOut)" ] }, + { + "name": "normalizeRole", + "file": "lib/role-utils.ts", + "signature": "normalizeRole(value: unknown): NormalizedRole", + "purpose": "将角色值规范化为 admin/teacher/student/parent 之一(纯函数,legacy 别名 grade_head/teaching_head→teacher)", + "deps": [], + "usedBy": [ + "auth.ts (jwt/session callbacks)", + "lib/role-utils.resolvePrimaryRole" + ] + }, + { + "name": "resolvePrimaryRole", + "file": "lib/role-utils.ts", + "signature": "resolvePrimaryRole(roleNames: string[]): NormalizedRole", + "purpose": "从多角色列表解析主角色(优先级 admin>teacher>parent>student,纯函数)", + "deps": [ + "lib/role-utils.normalizeRole" + ], + "usedBy": [ + "auth.ts (authorize, jwt callback)" + ] + }, + { + "name": "normalizeBcryptHash", + "file": "lib/bcrypt-utils.ts", + "signature": "normalizeBcryptHash(value: string): string", + "purpose": "将存储的 bcrypt 哈希规范化为 $2b$ 前缀形式(纯函数,兼容 legacy 无前缀存储)", + "deps": [], + "usedBy": [ + "auth.ts (authorize)" + ] + }, + { + "name": "resolveClientIp", + "file": "lib/http-utils.ts", + "signature": "resolveClientIp(): Promise", + "purpose": "从请求头解析客户端 IP(x-forwarded-for/x-real-ip,best-effort,失败返回 unknown)", + "deps": [ + "next/headers" + ], + "usedBy": [ + "auth.ts (authorize 速率限制键)" + ] + }, + { + "name": "getOrCreatePasswordSecurity", + "file": "lib/password-security-service.ts", + "signature": "getOrCreatePasswordSecurity(db, passwordSecurity, userId: string): Promise", + "purpose": "获取或创建用户的 password_security 行(server-only)", + "deps": [ + "drizzle-orm.eq", + "@paralleldrive/cuid2", + "shared.db", + "shared.db.schema.passwordSecurity" + ], + "usedBy": [ + "auth.ts (authorize)", + "lib/password-security-service.recordFailedLogin", + "lib/password-security-service.resetFailedLogin" + ] + }, + { + "name": "recordFailedLogin", + "file": "lib/password-security-service.ts", + "signature": "recordFailedLogin(db, passwordSecurity, userId: string): Promise<{ locked: boolean; lockedUntil: Date | null }>", + "purpose": "递增失败登录计数,达到阈值则锁定账户(server-only)", + "deps": [ + "lib/password-security-service.getOrCreatePasswordSecurity", + "lib/password-policy.PASSWORD_RULES", + "shared.db", + "shared.db.schema.passwordSecurity" + ], + "usedBy": [ + "auth.ts (authorize 密码校验失败分支)" + ] + }, + { + "name": "resetFailedLogin", + "file": "lib/password-security-service.ts", + "signature": "resetFailedLogin(db, passwordSecurity, userId: string): Promise", + "purpose": "登录成功后重置失败计数与锁定状态(server-only)", + "deps": [ + "lib/password-security-service.getOrCreatePasswordSecurity", + "shared.db", + "shared.db.schema.passwordSecurity" + ], + "usedBy": [ + "auth.ts (authorize 登录成功分支)" + ] + }, { "name": "isAllowedMimeType", "file": "lib/file-storage.ts", @@ -1801,7 +1892,19 @@ }, "auth": { "path": "src/auth.ts", - "description": "用户认证:NextAuth配置、JWT/Session callbacks、events回调(登录日志)、middleware。集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制", + "description": "用户认证:NextAuth配置(handlers/auth/signIn/signOut)、JWT/Session callbacks、events回调(登录日志)。集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制。P1-3 拆分后,辅助函数已迁移至 shared/lib/{role-utils,bcrypt-utils,http-utils,password-security-service}", + "imports": [ + "shared/lib/permissions", + "shared/lib/login-logger", + "shared/lib/password-policy", + "shared/lib/rate-limit", + "shared/lib/role-utils", + "shared/lib/bcrypt-utils", + "shared/lib/http-utils", + "shared/lib/password-security-service", + "shared/db", + "shared/db/schema" + ], "exports": { "functions": [ { @@ -4850,25 +4953,6 @@ "actions.importUsersAction" ] }, - { - "name": "batchImportUsers", - "signature": "(records: UserImportRecord[]) => Promise", - "file": "import-export.ts", - "purpose": "批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级)", - "deps": [ - "shared.db", - "shared.db.schema.users", - "shared.db.schema.roles", - "shared.db.schema.usersToRoles", - "shared.db.schema.classes", - "shared.db.schema.classEnrollments", - "bcryptjs", - "@paralleldrive/cuid2" - ], - "usedBy": [ - "actions.importUsersAction" - ] - }, { "name": "exportUsersToExcel", "signature": "(params: { scope: DataScope; role?: string }) => Promise", @@ -4887,6 +4971,47 @@ ] } ], + "userService": [ + { + "name": "batchImportUsers", + "signature": "(records: UserImportRecord[]) => Promise", + "file": "user-service.ts", + "purpose": "批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级——委托 class-registration)", + "deps": [ + "shared.db", + "shared.db.schema.users", + "shared.db.schema.roles", + "shared.db.schema.usersToRoles", + "bcryptjs", + "@paralleldrive/cuid2", + "class-registration.registerStudentByInvitationCode" + ], + "usedBy": [ + "actions.importUsersAction", + "import-export.ts (re-export 向后兼容)" + ] + } + ], + "classRegistration": [ + { + "name": "registerStudentByInvitationCode", + "signature": "(studentId: string, invitationCode: string) => Promise", + "file": "class-registration.ts", + "purpose": "通过邀请码将学生注册到班级,委托 classes/data-access.enrollStudentByInvitationCode,返回结构化结果(不抛异常)", + "deps": [ + "classes/data-access.enrollStudentByInvitationCode" + ], + "usedBy": [ + "user-service.batchImportUsers" + ] + }, + { + "name": "ClassRegistrationResult", + "type": "type", + "file": "class-registration.ts", + "definition": "{ success: boolean; error?: string }" + } + ], "types": [ { "name": "UserImportRecord", @@ -4910,11 +5035,12 @@ { "name": "UserImportResult", "type": "type", - "file": "import-export.ts", + "file": "user-service.ts", "definition": "{ successCount, failedCount, errors: Array<{ row, email, error }> }", "usedBy": [ "batchImportUsers", - "importUsersAction" + "importUsersAction", + "import-export.ts (re-export 向后兼容)" ] } ], @@ -10255,7 +10381,8 @@ "users": { "dependsOn": [ "shared", - "auth" + "auth", + "classes" ], "uses": { "shared": [ @@ -10265,14 +10392,15 @@ "db.schema.users", "db.schema.roles", "db.schema.usersToRoles", - "db.schema.classes", - "db.schema.classEnrollments", "types.permissions", "types.action-state", "lib.excel" ], "auth": [ "auth" + ], + "classes": [ + "data-access.enrollStudentByInvitationCode" ] } }, @@ -10952,7 +11080,9 @@ "title": "auth.ts 混合 5 类职责", "file": "src/auth.ts", "problem": "NextAuth 配置 + 密码安全 DB 操作 + 角色规范化 + IP 解析 + 回调函数混合", - "suggestion": "拆分为 auth-config/password-security/role-normalizer/ip-utils 等多文件" + "suggestion": "拆分为 auth-config/password-security/role-normalizer/ip-utils 等多文件", + "status": "resolved", + "resolution": "已拆分:密码安全DB操作→shared/lib/password-security-service.ts,角色规范化→shared/lib/role-utils.ts,bcrypt哈希规范化→shared/lib/bcrypt-utils.ts,IP解析→shared/lib/http-utils.ts。auth.ts 现 208 行仅保留 NextAuth 配置" }, { "id": "P1-4", diff --git a/src/auth.ts b/src/auth.ts index 0de4b24..1ed0f00 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,125 +9,14 @@ import { isAccountLocked, } from "@/shared/lib/password-policy" import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit" - -const normalizeRole = (value: unknown) => { - const role = String(value ?? "").trim().toLowerCase() - if (role === "grade_head" || role === "teaching_head") return "teacher" - if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role - return "student" -} - -const resolvePrimaryRole = (roleNames: string[]) => { - const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean) - if (mapped.includes("admin")) return "admin" - if (mapped.includes("teacher")) return "teacher" - if (mapped.includes("parent")) return "parent" - if (mapped.includes("student")) return "student" - return "student" -} - -const normalizeBcryptHash = (value: string) => { - if (value.startsWith("$2")) return value - if (value.startsWith("$")) return `$2b${value}` - return `$2b$${value}` -} - -/** - * Resolve the client IP from request headers (best-effort, used for - * rate-limit keying only — not stored). - */ -const resolveClientIp = async (): Promise => { - try { - const { headers } = await import("next/headers") - const headerList = await headers() - return ( - headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - headerList.get("x-real-ip") ?? - "unknown" - ) - } catch { - return "unknown" - } -} - -/** - * Get or create a password_security row for a user. - */ -const getOrCreatePasswordSecurity = async ( - db: typeof import("@/shared/db")["db"], - passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], - userId: string -) => { - const [existing] = await db - .select() - .from(passwordSecurity) - .where(eq(passwordSecurity.userId, userId)) - .limit(1) - - if (existing) return existing - - const { createId } = await import("@paralleldrive/cuid2") - const id = createId() - await db.insert(passwordSecurity).values({ - id, - userId, - failedLoginAttempts: 0, - }) - const [created] = await db - .select() - .from(passwordSecurity) - .where(eq(passwordSecurity.userId, userId)) - .limit(1) - return created -} - -/** - * Increment failed login attempts and lock the account if the threshold - * is reached. - */ -const recordFailedLogin = async ( - db: typeof import("@/shared/db")["db"], - passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], - userId: string -): Promise<{ locked: boolean; lockedUntil: Date | null }> => { - const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) - const nextAttempts = current.failedLoginAttempts + 1 - const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts - const lockedUntil = shouldLock - ? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000) - : null - - await db - .update(passwordSecurity) - .set({ - failedLoginAttempts: nextAttempts, - lockedUntil, - updatedAt: new Date(), - }) - .where(eq(passwordSecurity.userId, userId)) - - return { locked: shouldLock, lockedUntil } -} - -/** - * Reset failed login attempts on successful login. - */ -const resetFailedLogin = async ( - db: typeof import("@/shared/db")["db"], - passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], - userId: string -): Promise => { - const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) - if (current.failedLoginAttempts === 0 && !current.lockedUntil) return - await db - .update(passwordSecurity) - .set({ - failedLoginAttempts: 0, - lockedUntil: null, - updatedAt: new Date(), - }) - .where(eq(passwordSecurity.userId, userId)) -} +import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils" +import { resolveClientIp } from "@/shared/lib/http-utils" +import { + getOrCreatePasswordSecurity, + recordFailedLogin, + resetFailedLogin, +} from "@/shared/lib/password-security-service" +import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils" export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, diff --git a/src/modules/notifications/channels/in-app-channel.ts b/src/modules/notifications/channels/in-app-channel.ts index d98e750..508947c 100644 --- a/src/modules/notifications/channels/in-app-channel.ts +++ b/src/modules/notifications/channels/in-app-channel.ts @@ -13,6 +13,10 @@ import "server-only" * 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。 * 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值), * 不破坏现有 messaging 模块的类型约束。 + * + * 使用动态 import 打破 notifications -> messaging 的静态反向依赖。 + * 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储) + * 这是可接受的运行时调用链,但模块级静态依赖必须单向。 */ import type { @@ -21,11 +25,10 @@ import type { NotificationChannel, } from "../types" import type { NotificationChannelSender, ChannelRecipient } from "./types" -import { createNotification } from "@/modules/messaging/data-access" const channel: NotificationChannel = "in_app" -/** 站内消息发送器(调用现有 messaging data-access) */ +/** 站内消息发送器(通过动态 import 调用 messaging data-access) */ class InAppChannelSender implements NotificationChannelSender { readonly channel = channel @@ -43,6 +46,8 @@ class InAppChannelSender implements NotificationChannelSender { sentAt: new Date(), } } + // Dynamic import to break static reverse dependency on messaging module + const { createNotification } = await import("@/modules/messaging/data-access") const id = await createNotification({ userId: payload.userId, // DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义 diff --git a/src/modules/users/class-registration.ts b/src/modules/users/class-registration.ts new file mode 100644 index 0000000..4ecae13 --- /dev/null +++ b/src/modules/users/class-registration.ts @@ -0,0 +1,27 @@ +import "server-only" + +import { enrollStudentByInvitationCode } from "@/modules/classes/data-access" + +export type ClassRegistrationResult = { + success: boolean + error?: string +} + +/** + * 通过邀请码将学生注册到班级。 + * + * 委托给 classes 模块的 data-access,避免跨模块直接写入 classEnrollments 表。 + * 返回结构化结果而非抛出异常,便于批量导入场景累计错误。 + */ +export async function registerStudentByInvitationCode( + studentId: string, + invitationCode: string +): Promise { + try { + await enrollStudentByInvitationCode(studentId, invitationCode) + return { success: true } + } catch (e) { + const msg = e instanceof Error ? e.message : "注册失败" + return { success: false, error: msg } + } +} diff --git a/src/modules/users/import-export.ts b/src/modules/users/import-export.ts index 1d3e58e..0645762 100644 --- a/src/modules/users/import-export.ts +++ b/src/modules/users/import-export.ts @@ -1,28 +1,12 @@ import "server-only" -import { hash } from "bcryptjs" -import { createId } from "@paralleldrive/cuid2" import { and, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" -import { - classes, - classEnrollments, - roles, - users, - usersToRoles, -} from "@/shared/db/schema" +import { roles, users, usersToRoles } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" import { exportToExcel, generateTemplate } from "@/shared/lib/excel" -const DEFAULT_PASSWORD = "123456" - -const normalizeBcryptHash = (value: string) => { - if (value.startsWith("$2")) return value - if (value.startsWith("$")) return `$2b${value}` - return `$2b$${value}` -} - const VALID_ROLES = ["admin", "teacher", "student", "parent", "grade_head", "teaching_head"] as const type ValidRole = (typeof VALID_ROLES)[number] @@ -42,12 +26,6 @@ export type UserImportValidation = { invalid: Array<{ row: number; record: UserImportRecord; errors: string[] }> } -export type UserImportResult = { - successCount: number - failedCount: number - errors: Array<{ row: number; email: string; error: string }> -} - /** * 生成用户导入模板 */ @@ -110,102 +88,6 @@ export function parseUserImportData( return { valid, invalid } } -/** - * 批量导入用户(事务) - */ -export async function batchImportUsers( - records: UserImportRecord[] -): Promise { - const errors: UserImportResult["errors"] = [] - let successCount = 0 - - // Pre-load all roles - const allRoles = await db.select().from(roles) - const roleMap = new Map(allRoles.map((r) => [r.name, r.id])) - - // Pre-load invitation codes - const codes = records.map((r) => r.invitationCode).filter((c): c is string => !!c) - const classMap = new Map() - if (codes.length > 0) { - const classRows = await db - .select({ id: classes.id, name: classes.name, code: classes.invitationCode }) - .from(classes) - .where(inArray(classes.invitationCode, codes)) - for (const c of classRows) { - if (c.code) classMap.set(c.code, { classId: c.id, className: c.name }) - } - } - - // Check existing emails - const emails = records.map((r) => r.email) - const existing = await db - .select({ email: users.email }) - .from(users) - .where(inArray(users.email, emails)) - const existingEmails = new Set(existing.map((e) => e.email)) - - const hashedPassword = normalizeBcryptHash(await hash(DEFAULT_PASSWORD, 10)) - - for (let i = 0; i < records.length; i++) { - const record = records[i] - const rowNum = i + 2 - - if (existingEmails.has(record.email)) { - errors.push({ row: rowNum, email: record.email, error: "邮箱已存在" }) - continue - } - - try { - const userId = createId() - await db.insert(users).values({ - id: userId, - name: record.name, - email: record.email, - password: hashedPassword, - phone: record.phone ?? null, - }) - - const roleId = roleMap.get(record.role) - if (roleId) { - await db.insert(usersToRoles).values({ userId, roleId }) - } - - // Enroll student in class via invitation code - if (record.invitationCode && record.role === "student") { - const classInfo = classMap.get(record.invitationCode) - if (classInfo) { - await db - .insert(classEnrollments) - .values({ - classId: classInfo.classId, - studentId: userId, - status: "active", - }) - .onDuplicateKeyUpdate({ set: { status: "active" } }) - } else { - errors.push({ - row: rowNum, - email: record.email, - error: `邀请码 ${record.invitationCode} 无效(用户已创建)`, - }) - } - } - - existingEmails.add(record.email) - successCount++ - } catch (e) { - const msg = e instanceof Error ? e.message : "创建失败" - errors.push({ row: rowNum, email: record.email, error: msg }) - } - } - - return { - successCount, - failedCount: errors.length, - errors, - } -} - /** * 导出用户列表到 Excel */ @@ -289,3 +171,6 @@ export async function exportUsersToExcel(params: { ], }) } + +// Re-export 保持向后兼容:用户创建与班级注册已拆分至独立文件 +export { batchImportUsers, type UserImportResult } from "./user-service" diff --git a/src/modules/users/user-service.ts b/src/modules/users/user-service.ts new file mode 100644 index 0000000..5ac3af3 --- /dev/null +++ b/src/modules/users/user-service.ts @@ -0,0 +1,99 @@ +import "server-only" + +import { hash } from "bcryptjs" +import { createId } from "@paralleldrive/cuid2" +import { inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { roles, users, usersToRoles } from "@/shared/db/schema" + +import type { UserImportRecord } from "./import-export" +import { registerStudentByInvitationCode } from "./class-registration" + +const DEFAULT_PASSWORD = "123456" + +const normalizeBcryptHash = (value: string) => { + if (value.startsWith("$2")) return value + if (value.startsWith("$")) return `$2b${value}` + return `$2b$${value}` +} + +export type UserImportResult = { + successCount: number + failedCount: number + errors: Array<{ row: number; email: string; error: string }> +} + +/** + * 批量导入用户(事务) + */ +export async function batchImportUsers( + records: UserImportRecord[] +): Promise { + const errors: UserImportResult["errors"] = [] + let successCount = 0 + + // Pre-load all roles + const allRoles = await db.select().from(roles) + const roleMap = new Map(allRoles.map((r) => [r.name, r.id])) + + // Check existing emails + const emails = records.map((r) => r.email) + const existing = await db + .select({ email: users.email }) + .from(users) + .where(inArray(users.email, emails)) + const existingEmails = new Set(existing.map((e) => e.email)) + + const hashedPassword = normalizeBcryptHash(await hash(DEFAULT_PASSWORD, 10)) + + for (let i = 0; i < records.length; i++) { + const record = records[i] + const rowNum = i + 2 + + if (existingEmails.has(record.email)) { + errors.push({ row: rowNum, email: record.email, error: "邮箱已存在" }) + continue + } + + try { + const userId = createId() + await db.insert(users).values({ + id: userId, + name: record.name, + email: record.email, + password: hashedPassword, + phone: record.phone ?? null, + }) + + const roleId = roleMap.get(record.role) + if (roleId) { + await db.insert(usersToRoles).values({ userId, roleId }) + } + + // Enroll student in class via invitation code (delegated to classes module) + if (record.invitationCode && record.role === "student") { + const result = await registerStudentByInvitationCode(userId, record.invitationCode) + if (!result.success) { + errors.push({ + row: rowNum, + email: record.email, + error: `邀请码 ${record.invitationCode} 无效(用户已创建)`, + }) + } + } + + existingEmails.add(record.email) + successCount++ + } catch (e) { + const msg = e instanceof Error ? e.message : "创建失败" + errors.push({ row: rowNum, email: record.email, error: msg }) + } + } + + return { + successCount, + failedCount: errors.length, + errors, + } +} diff --git a/src/shared/lib/bcrypt-utils.ts b/src/shared/lib/bcrypt-utils.ts new file mode 100644 index 0000000..45122aa --- /dev/null +++ b/src/shared/lib/bcrypt-utils.ts @@ -0,0 +1,19 @@ +/** + * bcrypt hash normalization helper (pure function). + * + * Some legacy rows store bcrypt hashes without the leading `$2b$` version + * marker. `compare` from `bcryptjs` requires a well-formed hash, so we + * restore the prefix before verification. + */ + +/** + * Normalize a stored bcrypt hash to the canonical `$2b$...` form. + * - Already-prefixed hashes (`$2a$`, `$2b$`, `$2y$`, ...) are returned as-is. + * - Hashes starting with `$` but missing the version (e.g. `$abc...`) get `$2b` prepended. + * - Bare hashes get the full `$2b$` prefix prepended. + */ +export const normalizeBcryptHash = (value: string): string => { + if (value.startsWith("$2")) return value + if (value.startsWith("$")) return `$2b${value}` + return `$2b$${value}` +} diff --git a/src/shared/lib/http-utils.ts b/src/shared/lib/http-utils.ts new file mode 100644 index 0000000..08121b5 --- /dev/null +++ b/src/shared/lib/http-utils.ts @@ -0,0 +1,28 @@ +/** + * HTTP request helpers (server-only). + * + * Thin wrappers around `next/headers` used for best-effort client + * identification (rate-limit keying, logging). These values are NOT + * stored as audit-trail identifiers. + */ + +/** + * Resolve the client IP from request headers (best-effort, used for + * rate-limit keying only — not stored). + * + * Falls back to `"unknown"` when headers are unavailable (e.g. during + * build or in non-request contexts). + */ +export const resolveClientIp = async (): Promise => { + try { + const { headers } = await import("next/headers") + const headerList = await headers() + return ( + headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + headerList.get("x-real-ip") ?? + "unknown" + ) + } catch { + return "unknown" + } +} diff --git a/src/shared/lib/password-security-service.ts b/src/shared/lib/password-security-service.ts new file mode 100644 index 0000000..0e55661 --- /dev/null +++ b/src/shared/lib/password-security-service.ts @@ -0,0 +1,94 @@ +import "server-only" + +import { eq } from "drizzle-orm" + +import { PASSWORD_RULES } from "@/shared/lib/password-policy" + +/** + * Password security DB operations. + * + * These functions operate on the `password_security` table to track + * failed-login counters and account lockout state. They receive `db` + * and the `passwordSecurity` schema object as parameters so the caller + * (NextAuth `authorize` callback) can share a single dynamic-import + * resolution with its own DB access. + */ + +/** + * Get or create a password_security row for a user. + */ +export const getOrCreatePasswordSecurity = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +) => { + const [existing] = await db + .select() + .from(passwordSecurity) + .where(eq(passwordSecurity.userId, userId)) + .limit(1) + + if (existing) return existing + + const { createId } = await import("@paralleldrive/cuid2") + const id = createId() + await db.insert(passwordSecurity).values({ + id, + userId, + failedLoginAttempts: 0, + }) + const [created] = await db + .select() + .from(passwordSecurity) + .where(eq(passwordSecurity.userId, userId)) + .limit(1) + return created +} + +/** + * Increment failed login attempts and lock the account if the threshold + * is reached. + */ +export const recordFailedLogin = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +): Promise<{ locked: boolean; lockedUntil: Date | null }> => { + const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) + const nextAttempts = current.failedLoginAttempts + 1 + const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts + const lockedUntil = shouldLock + ? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000) + : null + + await db + .update(passwordSecurity) + .set({ + failedLoginAttempts: nextAttempts, + lockedUntil, + updatedAt: new Date(), + }) + .where(eq(passwordSecurity.userId, userId)) + + return { locked: shouldLock, lockedUntil } +} + +/** + * Reset failed login attempts on successful login. + */ +export const resetFailedLogin = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +): Promise => { + const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) + if (current.failedLoginAttempts === 0 && !current.lockedUntil) return + await db + .update(passwordSecurity) + .set({ + failedLoginAttempts: 0, + lockedUntil: null, + updatedAt: new Date(), + }) + .where(eq(passwordSecurity.userId, userId)) +} diff --git a/src/shared/lib/role-utils.ts b/src/shared/lib/role-utils.ts new file mode 100644 index 0000000..25ad45a --- /dev/null +++ b/src/shared/lib/role-utils.ts @@ -0,0 +1,34 @@ +/** + * Role normalization utilities (pure functions). + * + * These helpers map various role names (including legacy aliases) to the + * canonical K12 role set: admin / teacher / student / parent. + */ + +export type NormalizedRole = "admin" | "teacher" | "student" | "parent" + +/** + * Normalize a single role value to one of the canonical roles. + * Legacy aliases such as `grade_head` / `teaching_head` collapse to `teacher`. + * Unknown values fall back to `student`. + */ +export const normalizeRole = (value: unknown): NormalizedRole => { + const role = String(value ?? "").trim().toLowerCase() + if (role === "grade_head" || role === "teaching_head") return "teacher" + if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role + return "student" +} + +/** + * Given a list of role names (e.g. from `users_to_roles`), resolve the + * primary role used for routing/permission checks. Priority order: + * admin > teacher > parent > student. + */ +export const resolvePrimaryRole = (roleNames: string[]): NormalizedRole => { + const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean) + if (mapped.includes("admin")) return "admin" + if (mapped.includes("teacher")) return "teacher" + if (mapped.includes("parent")) return "parent" + if (mapped.includes("student")) return "student" + return "student" +}