refactor: P1-3/4/6 解耦修复 - 拆分 auth/users 文件 + notifications 反向依赖

This commit is contained in:
SpecialX
2026-06-18 02:21:44 +08:00
parent 62be0b9404
commit 2c8e229e00
11 changed files with 514 additions and 288 deletions

View File

@@ -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<string> => {
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<void> => {
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,

View File

@@ -13,6 +13,10 @@ import "server-only"
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
* 此处将 payload.type 作为字符串写入 DBDB 列为 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 语义

View File

@@ -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<ClassRegistrationResult> {
try {
await enrollStudentByInvitationCode(studentId, invitationCode)
return { success: true }
} catch (e) {
const msg = e instanceof Error ? e.message : "注册失败"
return { success: false, error: msg }
}
}

View File

@@ -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<UserImportResult> {
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<string, { classId: string; className: string }>()
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"

View File

@@ -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<UserImportResult> {
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,
}
}

View File

@@ -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}`
}

View File

@@ -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<string> => {
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"
}
}

View File

@@ -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<void> => {
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))
}

View File

@@ -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"
}