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

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