refactor: P1-3/4/6 解耦修复 - 拆分 auth/users 文件 + notifications 反向依赖
This commit is contained in:
@@ -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 语义
|
||||
|
||||
27
src/modules/users/class-registration.ts
Normal file
27
src/modules/users/class-registration.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
99
src/modules/users/user-service.ts
Normal file
99
src/modules/users/user-service.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user