diff --git a/docs/superpowers/plans/2026-07-03-betterauth-hono-drizzle-migration.md b/docs/superpowers/plans/2026-07-03-betterauth-hono-drizzle-migration.md new file mode 100644 index 0000000..94972e7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-betterauth-hono-drizzle-migration.md @@ -0,0 +1,1693 @@ +--- +change: betterauth-hono-drizzle-migration +design-doc: docs/superpowers/specs/2026-07-03-betterauth-hono-drizzle-migration-design.md +base-ref: ca3511d57d9b9b5b52afe02ca4ffc7fa112bc404 +--- + +# NestJS → HonoJS + better-auth + Drizzle + PGlite/PostgreSQL 迁移实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 apps/server 从 NestJS + TypeORM + MySQL/better-sqlite3 全栈替换为 HonoJS + Drizzle ORM + better-auth + PGlite(dev)/PostgreSQL(prod),保持 60+ API 端点兼容、49 权限点不变、前端零改动。 + +**Architecture:** HonoJS 作为 HTTP 框架,better-auth 接管认证与 RBAC,Drizzle ORM 替代 TypeORM 管理 16 张表,PGlite 提供零 Docker 的本地开发体验,PostgreSQL 用于生产。中间件栈:CORS → RateLimit → better-auth JWT → PermissionCheck → ZodValidator → Route Handler。 + +**Tech Stack:** Hono 4.x, @hono/node-server, better-auth, drizzle-orm, @electric-sql/pglite, pg, zod, @hono/zod-validator, hono-rate-limiter, exceljs, pdfkit, bcryptjs + +## Global Constraints + +- Node.js ≥ 20, TypeScript 5.x, pnpm (monorepo) +- 所有 API 端点路径和响应格式必须与 NestJS 版本保持兼容 +- 49 个权限码(14 分组)完整保留,前端 `@RequirePermission` 等效逻辑零改动 +- 4 个预设角色(admin/supervisor/teacher/institution)权限分配不变 +- PGlite 数据文件路径:`apps/server/pglite-data/`,加入 .gitignore +- better-auth JWT payload 必须包含 `{ sub: id, username, permissions: string[] }` +- 开发命令:`tsx watch src/index.ts`(热重载),构建:`tsc -p tsconfig.build.json` + +--- + +### Task 1: 项目初始化与环境搭建 + +**Files:** +- Modify: `apps/server/package.json` +- Create: `apps/server/tsconfig.build.json` +- Modify: `apps/server/tsconfig.json` +- Modify: `turbo.json` +- Create: `apps/server/.gitignore`(追加 pglite-data/) + +**Interfaces:** +- Produces: `pnpm dev` 可启动空 Hono 服务器在 :3003 + +- [ ] **Step 1: 更新 package.json — 替换依赖和 scripts** + +```bash +cd apps/server +# 移除 NestJS 相关依赖 +pnpm remove @nestjs/common @nestjs/core @nestjs/config @nestjs/jwt @nestjs/passport \ + @nestjs/platform-express @nestjs/throttler @nestjs/typeorm @nestjs/cli \ + @nestjs/schematics @nestjs/testing typeorm mysql2 better-sqlite3 \ + passport passport-jwt passport-local class-transformer class-validator \ + reflect-metadata rxjs multer @types/multer @types/better-sqlite3 @types/express + +# 安装新依赖 +pnpm add hono @hono/node-server better-auth drizzle-orm @electric-sql/pglite pg \ + exceljs pdfkit bcryptjs zod @hono/zod-validator hono-rate-limiter dotenv + +# 安装 dev 依赖 +pnpm add -D drizzle-kit tsx @types/pg @types/bcryptjs @types/pdfkit +``` + +更新 `apps/server/package.json` scripts: +```json +{ + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + } +} +``` + +- [ ] **Step 2: 创建 tsconfig.build.json** + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} +``` + +- [ ] **Step 3: 更新 turbo.json** + +确保 `apps/server` 的 build 和 dev pipeline 配置正确(移除 NestJS 特有配置): + +```json +{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "typecheck": { + "dependsOn": ["^build"] + } + } +} +``` + +- [ ] **Step 4: 创建最小 Hono 入口验证项目可启动** + +创建 `apps/server/src/index.ts`: +```typescript +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; + +const app = new Hono(); + +app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + +const port = Number(process.env.PORT) || 3003; +console.log(`Server running on http://localhost:${port}`); +serve({ fetch: app.fetch, port }); +``` + +- [ ] **Step 5: 验证启动** + +```bash +cd apps/server && pnpm dev +# 预期:Server running on http://localhost:3003 +# curl http://localhost:3003/api/health → {"status":"ok","timestamp":"..."} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/package.json apps/server/pnpm-lock.yaml apps/server/tsconfig.build.json \ + apps/server/tsconfig.json turbo.json apps/server/.gitignore apps/server/src/index.ts +git commit -m "feat: initialize HonoJS project structure, replace NestJS dependencies" +``` + +--- + +### Task 2: 数据库 Schema 定义 — 认证相关表 + +**Files:** +- Create: `apps/server/src/db/schema/auth.ts` +- Create: `apps/server/src/db/schema/index.ts` +- Create: `apps/server/drizzle.config.ts` + +**Interfaces:** +- Produces: `users`, `roles`, `permissions`, `user_roles`, `role_permissions` 5 张 Drizzle 表定义 +- Consumes: Task 1 (项目结构) + +- [ ] **Step 1: 创建 drizzle.config.ts** + +```typescript +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 5432, + user: process.env.DB_USERNAME || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_DATABASE || 'gongxue', + ssl: process.env.DB_SSL === 'true', + }, +}); +``` + +- [ ] **Step 2: 定义 Drizzle schema — auth.ts(认证 5 表)** + +```typescript +// apps/server/src/db/schema/auth.ts +import { pgTable, serial, varchar, boolean, timestamp, integer, primaryKey } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + username: varchar('username', { length: 50 }).unique().notNull(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + name: varchar('name', { length: 50 }), + isActive: boolean('is_active').default(true).notNull(), + lastLoginAt: timestamp('last_login_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const roles = pgTable('roles', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 30 }).unique().notNull(), + description: varchar('description', { length: 200 }), + isSystem: boolean('is_system').default(false).notNull(), + status: integer('status').default(1).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const permissions = pgTable('permissions', { + id: serial('id').primaryKey(), + code: varchar('code', { length: 50 }).unique().notNull(), + name: varchar('name', { length: 50 }).notNull(), + group: varchar('group', { length: 30 }).notNull(), + description: varchar('description', { length: 200 }), +}); + +export const userRoles = pgTable('user_roles', { + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), +}, (t) => ({ pk: primaryKey({ columns: [t.userId, t.roleId] }) })); + +export const rolePermissions = pgTable('role_permissions', { + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), + permissionId: integer('permission_id').references(() => permissions.id, { onDelete: 'cascade' }).notNull(), +}, (t) => ({ pk: primaryKey({ columns: [t.roleId, t.permissionId] }) })); + +// Relations +export const usersRelations = relations(users, ({ many }) => ({ + userRoles: many(userRoles), +})); + +export const rolesRelations = relations(roles, ({ many }) => ({ + userRoles: many(userRoles), + rolePermissions: many(rolePermissions), +})); + +export const permissionsRelations = relations(permissions, ({ many }) => ({ + rolePermissions: many(rolePermissions), +})); + +export const userRolesRelations = relations(userRoles, ({ one }) => ({ + user: one(users, { fields: [userRoles.userId], references: [users.id] }), + role: one(roles, { fields: [userRoles.roleId], references: [roles.id] }), +})); + +export const rolePermissionsRelations = relations(rolePermissions, ({ one }) => ({ + role: one(roles, { fields: [rolePermissions.roleId], references: [roles.id] }), + permission: one(permissions, { fields: [rolePermissions.permissionId], references: [permissions.id] }), +})); +``` + +- [ ] **Step 3: 创建 schema barrel 导出** + +```typescript +// apps/server/src/db/schema/index.ts +export * from './auth'; +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/drizzle.config.ts apps/server/src/db/schema/auth.ts apps/server/src/db/schema/index.ts +git commit -m "feat: add Drizzle schema for auth tables (users, roles, permissions, user_roles, role_permissions)" +``` + +--- + +### Task 3: 数据库 Schema 定义 — 业务表 + +**Files:** +- Create: `apps/server/src/db/schema/student.ts` +- Create: `apps/server/src/db/schema/room.ts` +- Create: `apps/server/src/db/schema/occupancy.ts` +- Create: `apps/server/src/db/schema/expense.ts` +- Create: `apps/server/src/db/schema/bill.ts` +- Modify: `apps/server/src/db/schema/index.ts` + +**Interfaces:** +- Produces: `students`, `rooms`, `occupancies`, `room_expenses`, `personal_expenses`, `bills`, `bill_items` 7 张表 +- Consumes: Task 2 (schema barrel) + +- [ ] **Step 1: students 表** + +```typescript +// apps/server/src/db/schema/student.ts +import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core'; + +export const students = pgTable('students', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 50 }).notNull(), + phone: varchar('phone', { length: 20 }), + idNumber: varchar('id_number', { length: 30 }), + gender: varchar('gender', { length: 10 }), + ethnicity: varchar('ethnicity', { length: 20 }), + emergencyContact: varchar('emergency_contact', { length: 50 }), + emergencyPhone: varchar('emergency_phone', { length: 20 }), + status: varchar('status', { length: 20 }).default('active').notNull(), + organization: varchar('organization', { length: 100 }), + supervisor: varchar('supervisor', { length: 50 }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 2: rooms 表** + +```typescript +// apps/server/src/db/schema/room.ts +import { pgTable, serial, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; + +export const rooms = pgTable('rooms', { + id: serial('id').primaryKey(), + roomNumber: varchar('room_number', { length: 20 }).unique().notNull(), + building: varchar('building', { length: 50 }), + floor: integer('floor'), + capacity: integer('capacity').notNull(), + status: varchar('status', { length: 20 }).default('available').notNull(), + roomType: varchar('room_type', { length: 20 }), + gender: varchar('gender', { length: 10 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 3: occupancies 表** + +```typescript +// apps/server/src/db/schema/occupancy.ts +import { pgTable, serial, integer, varchar, date, timestamp } from 'drizzle-orm/pg-core'; +import { students } from './student'; +import { rooms } from './room'; + +export const occupancies = pgTable('occupancies', { + id: serial('id').primaryKey(), + studentId: integer('student_id').references(() => students.id, { onDelete: 'cascade' }).notNull(), + roomId: integer('room_id').references(() => rooms.id, { onDelete: 'cascade' }).notNull(), + checkInDate: date('check_in_date').notNull(), + checkOutDate: date('check_out_date'), + price: integer('price'), + status: varchar('status', { length: 20 }).default('active').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 4: expenses 表(room_expenses + personal_expenses)** + +```typescript +// apps/server/src/db/schema/expense.ts +import { pgTable, serial, integer, varchar, decimal, date, timestamp } from 'drizzle-orm/pg-core'; +import { rooms } from './room'; +import { students } from './student'; + +export const roomExpenses = pgTable('room_expenses', { + id: serial('id').primaryKey(), + roomId: integer('room_id').references(() => rooms.id, { onDelete: 'cascade' }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + amount: decimal('amount', { precision: 10, scale: 2 }).notNull(), + periodStart: date('period_start').notNull(), + periodEnd: date('period_end').notNull(), + status: varchar('status', { length: 20 }).default('active').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const personalExpenses = pgTable('personal_expenses', { + id: serial('id').primaryKey(), + studentId: integer('student_id').references(() => students.id, { onDelete: 'cascade' }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + amount: decimal('amount', { precision: 10, scale: 2 }).notNull(), + periodStart: date('period_start').notNull(), + periodEnd: date('period_end').notNull(), + status: varchar('status', { length: 20 }).default('active').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 5: bills 表(bills + bill_items)** + +```typescript +// apps/server/src/db/schema/bill.ts +import { pgTable, serial, integer, varchar, decimal, date, timestamp } from 'drizzle-orm/pg-core'; +import { students } from './student'; + +export const bills = pgTable('bills', { + id: serial('id').primaryKey(), + studentId: integer('student_id').references(() => students.id, { onDelete: 'cascade' }).notNull(), + billNumber: varchar('bill_number', { length: 50 }).notNull(), + totalAmount: decimal('total_amount', { precision: 10, scale: 2 }).notNull(), + status: varchar('status', { length: 20 }).default('pending').notNull(), + periodStart: date('period_start'), + periodEnd: date('period_end'), + issuedAt: timestamp('issued_at').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const billItems = pgTable('bill_items', { + id: serial('id').primaryKey(), + billId: integer('bill_id').references(() => bills.id, { onDelete: 'cascade' }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + amount: decimal('amount', { precision: 10, scale: 2 }).notNull(), + type: varchar('type', { length: 20 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/db/schema/ +git commit -m "feat: add Drizzle schema for business tables (students, rooms, occupancies, expenses, bills)" +``` + +--- + +### Task 4: 数据库 Schema 定义 — 其他表 + 连接工厂 + Seed + +**Files:** +- Create: `apps/server/src/db/schema/classroom.ts` +- Create: `apps/server/src/db/schema/deposit.ts` +- Create: `apps/server/src/db/schema/operation-log.ts` +- Modify: `apps/server/src/db/schema/index.ts`(导出所有 16 表) +- Create: `apps/server/src/db/index.ts`(连接工厂) +- Create: `apps/server/src/db/seed.ts`(幂等 seed) + +**Interfaces:** +- Produces: `classrooms`, `tenants`, `classroom_rentals`, `deposits`, `operation_logs` 5 张表 + DB 连接工厂 + seed 脚本 +- Consumes: Tasks 2-3 (schema 定义) + +- [ ] **Step 1: 剩余 5 张业务表定义** + +```typescript +// apps/server/src/db/schema/classroom.ts +import { pgTable, serial, varchar, integer, decimal, timestamp } from 'drizzle-orm/pg-core'; + +export const classrooms = pgTable('classrooms', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }).notNull(), + capacity: integer('capacity'), + location: varchar('location', { length: 200 }), + pricePerDay: decimal('price_per_day', { precision: 10, scale: 2 }), + status: varchar('status', { length: 20 }).default('available').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const tenants = pgTable('tenants', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }).notNull(), + contactPerson: varchar('contact_person', { length: 50 }), + phone: varchar('phone', { length: 20 }), + notes: varchar('notes', { length: 500 }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const classroomRentals = pgTable('classroom_rentals', { + id: serial('id').primaryKey(), + classroomId: integer('classroom_id').references(() => classrooms.id, { onDelete: 'cascade' }).notNull(), + tenantId: integer('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }).notNull(), + rentalDate: date('rental_date').notNull(), + startTime: varchar('start_time', { length: 10 }), + endTime: varchar('end_time', { length: 10 }), + price: decimal('price', { precision: 10, scale: 2 }), + status: varchar('status', { length: 20 }).default('active').notNull(), + notes: varchar('notes', { length: 500 }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// apps/server/src/db/schema/deposit.ts +export const deposits = pgTable('deposits', { + id: serial('id').primaryKey(), + studentId: integer('student_id').references(() => students.id, { onDelete: 'cascade' }).notNull(), + amount: decimal('amount', { precision: 10, scale: 2 }).notNull(), + notes: varchar('notes', { length: 500 }), + status: varchar('status', { length: 20 }).default('active').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// apps/server/src/db/schema/operation-log.ts +export const operationLogs = pgTable('operation_logs', { + id: serial('id').primaryKey(), + userId: integer('user_id'), + username: varchar('username', { length: 50 }), + module: varchar('module', { length: 50 }), + action: varchar('action', { length: 50 }), + targetId: integer('target_id'), + targetType: varchar('target_type', { length: 50 }), + detail: text('detail'), + ipAddress: varchar('ip_address', { length: 50 }), + userAgent: varchar('user_agent', { length: 500 }), + status: varchar('status', { length: 20 }).default('success'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 2: 数据库连接工厂** + +```typescript +// apps/server/src/db/index.ts +import { drizzle } from 'drizzle-orm/pglite'; +import { PGlite } from '@electric-sql/pglite'; +import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import * as schema from './schema'; + +const isProd = process.env.NODE_ENV === 'production'; + +function createDevDb() { + const client = new PGlite('pglite-data'); + return drizzle(client, { schema }); +} + +function createProdDb() { + const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 5432, + user: process.env.DB_USERNAME || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_DATABASE || 'gongxue', + ssl: process.env.DB_SSL === 'true', + }); + return drizzlePg(pool, { schema }); +} + +export const db = isProd ? createProdDb() : createDevDb(); +``` + +- [ ] **Step 3: 幂等 seed 脚本** + +基于现有 `rbac.service.ts` 中的 `seedData()` 逻辑,改写为 Drizzle 版本(49 权限码 + 4 预设角色 + admin 用户)。保持相同的 seed 数据内容,用 Drizzle `insert...onConflictDoNothing` 实现幂等。 + +核心流程: +1. 插入 49 权限码(`onConflictDoNothing`,按 code 唯一键) +2. 插入 4 预设角色(`onConflictDoNothing`,按 name 唯一键) +3. 查询所有权限和角色,构建角色-权限关联 +4. 按 group 匹配权限给各角色(超管=全部,宿管=8组,老师=student:view,机构=3组) +5. 初始化 admin 用户(若 users 表为空),密码 bcrypt hash,分配超管角色 + +完整代码参考现有 `rbac.service.ts:120-191` 的 seedData 逻辑。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/db/ +git commit -m "feat: add remaining Drizzle schemas, DB connection factory, and idempotent seed script" +``` + +--- + +### Task 5: better-auth 实例配置 + +**Files:** +- Create: `apps/server/src/auth/index.ts` +- Create: `apps/server/src/lib/env.ts`(环境变量加载) + +**Interfaces:** +- Produces: `auth` 实例(含 username + jwt + admin 插件)、`getAuth` / `getSession` 工具函数 +- Consumes: Tasks 2-4 (schema + db) + +- [ ] **Step 1: 环境变量工具** + +```typescript +// apps/server/src/lib/env.ts +import { config } from 'dotenv'; +config(); + +export const env = { + PORT: Number(process.env.PORT) || 3003, + NODE_ENV: process.env.NODE_ENV || 'development', + DB_HOST: process.env.DB_HOST || 'localhost', + DB_PORT: Number(process.env.DB_PORT) || 5432, + DB_USERNAME: process.env.DB_USERNAME || 'postgres', + DB_PASSWORD: process.env.DB_PASSWORD || 'postgres', + DB_DATABASE: process.env.DB_DATABASE || 'gongxue', + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET || 'dorm-billing-jwt-secret-key-2024', + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || 'http://localhost:3003', + ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', +}; +``` + +- [ ] **Step 2: better-auth 实例配置** + +```typescript +// apps/server/src/auth/index.ts +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { username, jwt, admin } from 'better-auth/plugins'; +import { db } from '../db'; +import * as schema from '../db/schema'; +import { env } from '../lib/env'; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + users: schema.users, + roles: schema.roles, + permissions: schema.permissions, + userRoles: schema.userRoles, + rolePermissions: schema.rolePermissions, + }, + }), + emailAndPassword: { + enabled: false, + }, + plugins: [ + username(), + jwt({ + jwt: { + secret: env.BETTER_AUTH_SECRET, + expiresIn: '7d', + }, + }), + admin(), + ], + hooks: { + after: { + // JWT 签发后注入 permissions 到 claims + createJwt: async (ctx) => { + // 查询用户角色和权限,注入 permissions 数组到 JWT payload + }, + }, + }, +}); + +// 便捷导出 +export const getSession = auth.api.getSession; +``` + +**注意**: better-auth JWT plugin + admin plugin 的 `createJwt` hook 需要在 build 阶段验证具体 API。根据 better-auth 文档,可能需要通过 hook 或 middleware 注入 `permissions` 到 JWT claims。如果 hook 不支持,备选方案是在登录接口中自行组装 JWT claims。 + +- [ ] **Step 3: 验证 better-auth 初始化无错误** + +```bash +cd apps/server && pnpm typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/auth/ apps/server/src/lib/ +git commit -m "feat: configure better-auth instance with username, jwt, and admin plugins" +``` + +--- + +### Task 6: JWT 认证中间件 + 权限检查中间件 + +**Files:** +- Create: `apps/server/src/middleware/auth.ts` +- Create: `apps/server/src/middleware/permission.ts` + +**Interfaces:** +- Produces: `authMiddleware`(等效 JwtAuthGuard)、`requirePermission(code)`(等效 @RequirePermission) +- Consumes: Task 5 (auth 实例) + +- [ ] **Step 1: JWT 认证中间件** + +```typescript +// apps/server/src/middleware/auth.ts +import { createMiddleware } from 'hono/factory'; +import { getSession } from '../auth'; + +// 全局认证中间件:从 Authorization header 提取 JWT,校验并注入 session 到 context +export const authMiddleware = createMiddleware(async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + // 不直接拒绝,留给下游 permission 中间件处理 + return next(); + } + const token = authHeader.slice(7); + try { + const session = await getSession(token); + if (session) { + c.set('session', session); + c.set('user', session.user); + } + } catch { + // token 无效,继续(权限中间件会拒绝) + } + await next(); +}); +``` + +- [ ] **Step 2: 权限检查中间件** + +```typescript +// apps/server/src/middleware/permission.ts +import { createMiddleware } from 'hono/factory'; + +// 路由级权限检查中间件(等效 @RequirePermission 装饰器) +export function requirePermission(code: string) { + return createMiddleware(async (c, next) => { + const session = c.get('session'); + if (!session) { + return c.json({ message: '未登录', statusCode: 401 }, 401); + } + const perms: string[] = session.user?.permissions || []; + // 拥有通配符 *(超管)或指定权限码即可通过 + if (perms.includes('*') || perms.includes(code)) { + return next(); + } + return c.json({ message: '权限不足', statusCode: 403 }, 403); + }); +} +``` + +- [ ] **Step 3: 更新 index.ts 挂载中间件** + +```typescript +// apps/server/src/index.ts 增加: +import { authMiddleware } from './middleware/auth'; +app.use('/api/*', authMiddleware); +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/middleware/ +git commit -m "feat: implement JWT auth middleware and permission check middleware" +``` + +--- + +### Task 7: 登录接口 + +**Files:** +- Create: `apps/server/src/routes/auth.ts` +- Modify: `apps/server/src/index.ts`(挂载路由) + +**Interfaces:** +- Produces: `POST /api/auth/login`、`GET /api/auth/profile` +- Consumes: Tasks 5-6 (auth 实例 + 中间件) + +- [ ] **Step 1: 实现认证路由** + +```typescript +// apps/server/src/routes/auth.ts +import { Hono } from 'hono'; +import { auth } from '../auth'; +import { db } from '../db'; +import * as schema from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { requirePermission } from '../middleware/permission'; +import { logOperation } from '../middleware/operation-log'; + +const authRoute = new Hono(); + +// POST /api/auth/login — 包装 better-auth signInUsername +authRoute.post('/login', async (c) => { + const { username, password } = await c.req.json(); + const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'; + const ua = (c.req.header('user-agent') || '').substring(0, 500); + + try { + const result = await auth.api.signInUsername({ + body: { username, password }, + headers: c.req.raw.headers, + }); + + // 查询完整用户信息(含角色、权限) + const user = await db.query.users.findFirst({ + where: eq(schema.users.username, username), + with: { userRoles: { with: { role: { with: { rolePermissions: { with: { permission: true } } } } } }, + }); + + // 收集权限码 + const permSet = new Set(); + const roleNames: string[] = []; + if (user?.userRoles) { + for (const ur of user.userRoles) { + if (ur.role?.status === 1) { + roleNames.push(ur.role.name); + for (const rp of ur.role.rolePermissions || []) { + if (rp.permission) permSet.add(rp.permission.code); + } + } + } + } + + // 更新登录时间 + await db.update(schema.users).set({ lastLoginAt: new Date() }).where(eq(schema.users.id, user!.id)); + + // 记录操作日志 + await db.insert(schema.operationLogs).values({ + userId: user!.id, username, module: '认证', action: '登录成功', + ipAddress: ip, userAgent: ua, status: 'success', + }); + + return c.json({ + access_token: result?.token, + user: { id: user!.id, username: user!.username, name: user!.name, roles: roleNames, permissions: [...permSet] }, + }); + } catch (e: any) { + await db.insert(schema.operationLogs).values({ + username, module: '认证', action: '登录失败', + detail: e.message || '密码错误', ipAddress: ip, userAgent: ua, status: 'fail', + }); + return c.json({ message: e.message || '用户名或密码错误', statusCode: 401 }, 401); + } +}); + +// GET /api/auth/profile — 获取当前用户信息 +authRoute.get('/profile', requirePermission('dashboard:view'), (c) => { + const user = c.get('user'); + return c.json(user); +}); + +export { authRoute }; +``` + +- [ ] **Step 2: 挂载路由到主 app** + +```typescript +// 在 apps/server/src/index.ts 中添加: +import { authRoute } from './routes/auth'; +app.route('/api/auth', authRoute); +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/routes/auth.ts apps/server/src/index.ts +git commit -m "feat: implement login endpoint (POST /api/auth/login) with failed attempt tracking" +``` + +--- + +### Task 8: 用户管理 CRUD 路由 + +**Files:** +- Create: `apps/server/src/routes/users.ts` +- Modify: `apps/server/src/index.ts` + +**Interfaces:** +- Produces: `GET/POST/PUT/DELETE /api/users`、`PUT /api/users/:id/reset-password` +- Consumes: Task 6 (中间件)、Task 4 (db) + +- [ ] **Step 1: 实现用户管理路由** + +基于现有 `rbac.service.ts` 的 `findAllUsers`、`createUser`、`updateUser`、`resetPassword`、`deleteUser` 方法,转换为 Drizzle 查询。 + +```typescript +// apps/server/src/routes/users.ts +import { Hono } from 'hono'; +import { db } from '../db'; +import * as schema from '../db/schema'; +import { eq } from 'drizzle-orm'; +import bcrypt from 'bcryptjs'; +import { requirePermission } from '../middleware/permission'; +import { logOperation } from '../middleware/operation-log'; + +const usersRoute = new Hono(); + +// GET /api/users +usersRoute.get('/', requirePermission('user:view'), async (c) => { + const users = await db.query.users.findMany({ + with: { userRoles: { with: { role: true } } }, + orderBy: (users, { desc }) => [desc(users.createdAt)], + }); + return c.json(users.map((u) => ({ + id: u.id, username: u.username, name: u.name, + isActive: u.isActive, lastLoginAt: u.lastLoginAt, + createdAt: u.createdAt, updatedAt: u.updatedAt, + roles: u.userRoles?.map((ur) => ({ id: ur.role.id, name: ur.role.name })) || [], + }))); +}); + +// POST /api/users +usersRoute.post('/', requirePermission('user:create'), async (c) => { + const { username, password, name, roleIds } = await c.req.json(); + const existing = await db.query.users.findFirst({ where: eq(schema.users.username, username) }); + if (existing) return c.json({ message: '用户名已存在' }, 400); + const hash = await bcrypt.hash(password, 10); + const [user] = await db.insert(schema.users).values({ username, passwordHash: hash, name }).returning(); + if (roleIds?.length) { + await db.insert(schema.userRoles).values(roleIds.map((rid: number) => ({ userId: user.id, roleId: rid }))); + } + return c.json({ message: '用户创建成功' }); +}); + +// PUT /api/users/:id +usersRoute.put('/:id', requirePermission('user:edit'), async (c) => { + const id = Number(c.req.param('id')); + const { username, name, isActive, roleIds } = await c.req.json(); + // ... 更新逻辑(参考 rbac.service.ts:305-322) + await db.update(schema.users).set({ name, isActive, username }).where(eq(schema.users.id, id)); + // 更新角色关联 + await db.delete(schema.userRoles).where(eq(schema.userRoles.userId, id)); + if (roleIds?.length) { + await db.insert(schema.userRoles).values(roleIds.map((rid: number) => ({ userId: id, roleId: rid }))); + } + return c.json({ message: '更新成功' }); +}); + +// PUT /api/users/:id/reset-password +usersRoute.put('/:id/reset-password', requirePermission('user:reset-password'), async (c) => { + const id = Number(c.req.param('id')); + const { password } = await c.req.json(); + await db.update(schema.users).set({ passwordHash: await bcrypt.hash(password, 10) }).where(eq(schema.users.id, id)); + return c.json({ message: '密码已重置' }); +}); + +// DELETE /api/users/:id +usersRoute.delete('/:id', requirePermission('user:delete'), async (c) => { + const id = Number(c.req.param('id')); + const user = await db.query.users.findFirst({ where: eq(schema.users.id, id) }); + if (!user) return c.json({ message: '用户不存在' }, 404); + if (user.username === 'admin') return c.json({ message: '不能删除默认管理员' }, 400); + await db.delete(schema.users).where(eq(schema.users.id, id)); + return c.json({ message: '用户已删除' }); +}); + +export { usersRoute }; +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/users.ts apps/server/src/index.ts +git commit -m "feat: implement user management CRUD routes (GET/POST/PUT/DELETE /api/users)" +``` + +--- + +### Task 9: 角色管理 CRUD 路由 + 权限查询 + +**Files:** +- Create: `apps/server/src/routes/roles.ts` +- Create: `apps/server/src/routes/permissions.ts` +- Modify: `apps/server/src/index.ts` + +**Interfaces:** +- Produces: `GET/POST/PUT/DELETE /api/roles`、`GET /api/permissions`、`GET /api/permissions/tree` +- Consumes: Task 6 (中间件) + +- [ ] **Step 1: 角色管理路由** + +基于 `rbac.service.ts:193-238`,转换为 Drizzle: + +```typescript +// apps/server/src/routes/roles.ts +import { Hono } from 'hono'; +import { db } from '../db'; +import * as schema from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { requirePermission } from '../middleware/permission'; + +const rolesRoute = new Hono(); + +rolesRoute.get('/', requirePermission('role:view'), async (c) => { + const roles = await db.query.roles.findMany({ + with: { rolePermissions: { with: { permission: true } } }, + orderBy: (roles, { asc }) => [asc(roles.id)], + }); + return c.json(roles.map((r) => ({ + ...r, + permissions: r.rolePermissions?.map((rp) => rp.permission) || [], + }))); +}); + +rolesRoute.post('/', requirePermission('role:create'), async (c) => { + const { name, description, permissionIds } = await c.req.json(); + const [role] = await db.insert(schema.roles).values({ name, description }).returning(); + if (permissionIds?.length) { + await db.insert(schema.rolePermissions).values( + permissionIds.map((pid: number) => ({ roleId: role.id, permissionId: pid })) + ); + } + return c.json(role); +}); + +rolesRoute.put('/:id', requirePermission('role:edit'), async (c) => { + const id = Number(c.req.param('id')); + const role = await db.query.roles.findFirst({ where: eq(schema.roles.id, id) }); + if (!role) return c.json({ message: '角色不存在' }, 404); + const { name, description, permissionIds } = await c.req.json(); + if (role.isSystem && name !== undefined) return c.json({ message: '系统角色不可改名' }, 400); + await db.update(schema.roles).set({ name, description }).where(eq(schema.roles.id, id)); + if (permissionIds !== undefined) { + await db.delete(schema.rolePermissions).where(eq(schema.rolePermissions.roleId, id)); + if (permissionIds.length > 0) { + await db.insert(schema.rolePermissions).values( + permissionIds.map((pid: number) => ({ roleId: id, permissionId: pid })) + ); + } + } + return c.json({ message: '更新成功' }); +}); + +rolesRoute.delete('/:id', requirePermission('role:delete'), async (c) => { + const id = Number(c.req.param('id')); + const role = await db.query.roles.findFirst({ where: eq(schema.roles.id, id) }); + if (!role) return c.json({ message: '角色不存在' }, 404); + if (role.isSystem) return c.json({ message: '系统角色不可删除' }, 400); + await db.delete(schema.roles).where(eq(schema.roles.id, id)); + return c.json({ message: '角色已删除' }); +}); + +export { rolesRoute }; +``` + +- [ ] **Step 2: 权限查询路由** + +```typescript +// apps/server/src/routes/permissions.ts +import { Hono } from 'hono'; +import { db } from '../db'; +import { requirePermission } from '../middleware/permission'; + +const permissionsRoute = new Hono(); + +permissionsRoute.get('/', requirePermission('dashboard:view'), async (c) => { + const perms = await db.query.permissions.findMany({ + orderBy: (permissions, { asc }) => [asc(permissions.group), asc(permissions.code)], + }); + return c.json(perms); +}); + +permissionsRoute.get('/tree', requirePermission('dashboard:view'), async (c) => { + const perms = await db.query.permissions.findMany({ + orderBy: (permissions, { asc }) => [asc(permissions.group), asc(permissions.code)], + }); + const map = new Map(); + for (const p of perms) { + if (!map.has(p.group)) map.set(p.group, []); + map.get(p.group)!.push(p); + } + return c.json(Array.from(map.entries()).map(([group, permissions]) => ({ group, permissions }))); +}); + +export { permissionsRoute }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/routes/roles.ts apps/server/src/routes/permissions.ts apps/server/src/index.ts +git commit -m "feat: implement role management CRUD and permission query routes" +``` + +--- + +### Task 10: 学生管理路由 + +**Files:** +- Create: `apps/server/src/routes/students.ts` +- Modify: `apps/server/src/index.ts` + +**Interfaces:** +- Produces: `GET/POST/PUT/DELETE /api/students`、`PUT /api/students/:id/restore`、`POST /api/students/batch-delete` +- Consumes: Task 6 (中间件)、Task 4 (db) + +- [ ] **Step 1: 实现学生 CRUD** + +基于现有 `students.controller.ts`(274 行),逐方法转换为 Hono + Drizzle 版本。关键转换: + +| NestJS | HonoJS | +|--------|--------| +| `@Query('name')` | `c.req.query('name')` | +| `@Param('id')` | `c.req.param('id')` | +| `@Body() dto` | `await c.req.json()` | +| `this.service.findAll({...})` | `db.query.students.findMany({...})` | +| `this.service.create(dto)` | `db.insert(schema.students).values({...})` | +| `this.service.update(+id, dto)` | `db.update(schema.students).set({...}).where(eq(...))` | +| `this.service.remove(+id)` | `db.update(...).set({ status: 'archived' })`(软删除) | + +需要保持的 API 行为: +- 列表支持 `?name=`, `?status=`, `?includeArchived=true` 查询参数 +- 删除 = 软删除(设置 status='archived'),非物理删除 +- 批量删除 = `POST /api/students/batch-delete` + `{ ids: number[] }` +- 恢复 = `PUT /api/students/:id/restore`(设置 status='active') + +完整代码约 200 行(参考 students.controller.ts)。 + +- [ ] **Step 2: 挂载路由、操作日志中间件集成** + +在路由 handler 中调用 `logOperation(c, { userId, username, module, action, ... })` 记录操作日志。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/routes/students.ts apps/server/src/index.ts +git commit -m "feat: implement student CRUD routes with soft-delete, batch ops, and restore" +``` + +--- + +### Task 11: 学生 Excel 导入导出 + +**Files:** +- Modify: `apps/server/src/routes/students.ts`(追加导出、导入、模板端点) +- Create: `apps/server/src/utils/excel.ts`(可选) + +**Interfaces:** +- Consumes: Task 10 (学生基础 CRUD) + +- [ ] **Step 1: Excel 导出** + +转换为 HonoJS 的 `c.body()` 方式: + +```typescript +// GET /api/students/export +studentsRoute.get('/export', requirePermission('student:export'), async (c) => { + const includeArchived = c.req.query('includeArchived') === 'true'; + const students = await db.query.students.findMany({ + where: includeArchived ? undefined : (students, { ne }) => [ne(students.status, 'archived')], + }); + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('学生名单'); + // ... 列定义(同 NestJS 版本 students.controller.ts:47-62) + for (const s of students) { ws.addRow({...}); } + const buffer = await workbook.xlsx.writeBuffer(); + return c.body(buffer as any, 200, { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': 'attachment; filename=students.xlsx', + }); +}); +``` + +- [ ] **Step 2: 模板下载** + +```typescript +// GET /api/students/template +studentsRoute.get('/template', requirePermission('student:view'), async (c) => { + // ... 同 NestJS students.controller.ts:93-129 + const buffer = await workbook.xlsx.writeBuffer(); + return c.body(buffer as any, 200, { 'Content-Type': '...', 'Content-Disposition': 'attachment; filename=student_template.xlsx' }); +}); +``` + +- [ ] **Step 3: Excel 导入** + +```typescript +// POST /api/students/import +studentsRoute.post('/import', requirePermission('student:import'), async (c) => { + const body = await c.req.parseBody(); + const file = body['file'] as File; + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + // ... 解析逻辑(同 NestJS students.controller.ts:228-270) + return c.json(result); +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/routes/students.ts +git commit -m "feat: implement student Excel import/export and template download" +``` + +--- + +### Task 12: 宿舍管理路由 + +**Files:** +- Create: `apps/server/src/routes/rooms.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 实现宿舍 CRUD** + +基于 `rooms.controller.ts` / `rooms.service.ts`,转换为 Hono + Drizzle。端点: +- `GET /api/rooms` — 列表(支持 ?building=, ?status=, ?roomType=) +- `GET /api/rooms/:id` — 详情 +- `POST /api/rooms` — 创建 +- `PUT /api/rooms/:id` — 更新 +- `DELETE /api/rooms/:id` — 删除(检查无活跃入住记录) + +约 150 行代码。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/rooms.ts apps/server/src/index.ts +git commit -m "feat: implement room CRUD routes" +``` + +--- + +### Task 13: 入住管理路由 + +**Files:** +- Create: `apps/server/src/routes/occupancies.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 实现入住管理 CRUD + Excel 导入** + +端点: +- `GET /api/occupancies` — 列表(含 student/room join 数据) +- `POST /api/occupancies` — 办理入住 +- `PUT /api/occupancies/:id` — 更新(调换宿舍) +- `DELETE /api/occupancies/:id` — 退宿(设置 checkOutDate + status='inactive') +- `POST /api/occupancies/import` — Excel 导入 + +约 180 行代码。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/occupancies.ts apps/server/src/index.ts +git commit -m "feat: implement occupancy management routes with Excel import" +``` + +--- + +### Task 14: 费用管理路由 + +**Files:** +- Create: `apps/server/src/routes/expenses.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 实现宿舍费用 + 个人费用** + +端点(统一在 `/api/expenses` 下): +- `GET /api/expenses/room` — 宿舍费用列表(含 room join) +- `POST /api/expenses/room` — 录入宿舍费用 +- `PUT /api/expenses/room/:id` — 编辑 +- `DELETE /api/expenses/room/:id` — 删除 +- `POST /api/expenses/room/import` — Excel 导入 +- `GET /api/expenses/personal` — 个人费用列表(含 student join) +- `POST /api/expenses/personal` — 录入 +- `PUT /api/expenses/personal/:id` — 编辑 +- `DELETE /api/expenses/personal/:id` — 删除 +- `POST /api/expenses/personal/import` — Excel 导入 + +约 250 行代码。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/expenses.ts apps/server/src/index.ts +git commit -m "feat: implement room and personal expense management routes" +``` + +--- + +### Task 15: 账单管理路由 + +**Files:** +- Create: `apps/server/src/routes/bills.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 实现账单 CRUD + 生成 + Excel/PDF 导出** + +端点: +- `GET /api/bills` — 列表(含 student join,?status=, ?studentId=) +- `POST /api/bills` — 生成账单(自动汇总 student 的活跃费用 → bill_items) +- `PUT /api/bills/:id` — 更新 +- `DELETE /api/bills/:id` — 删除 +- `PUT /api/bills/:id/confirm` — 确认账单 +- `GET /api/bills/export-excel` — Excel 导出 +- `GET /api/bills/:id/pdf` — PDF 导出(PDFKit) + +PDF 生成使用 `c.body(buffer, 200, { 'Content-Type': 'application/pdf' })`。 + +约 300 行代码。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/bills.ts apps/server/src/index.ts +git commit -m "feat: implement bill management routes with Excel/PDF export" +``` + +--- + +### Task 16: 押金管理路由 + +**Files:** +- Create: `apps/server/src/routes/deposits.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 实现押金 CRUD** + +端点:`GET/POST/PUT/DELETE /api/deposits`。约 100 行代码。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/server/src/routes/deposits.ts apps/server/src/index.ts +git commit -m "feat: implement deposit CRUD routes" +``` + +--- + +### Task 17: 教室与租赁方管理路由 + +**Files:** +- Create: `apps/server/src/routes/classrooms.ts` +- Create: `apps/server/src/routes/tenants.ts` +- Create: `apps/server/src/routes/classroom-rentals.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 教室 CRUD**(含 Excel 导入,约 150 行) +- [ ] **Step 2: 租赁方 CRUD**(约 80 行) +- [ ] **Step 3: 租赁订单 CRUD**(约 150 行) + +合并为一个 commit。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/routes/classrooms.ts apps/server/src/routes/tenants.ts \ + apps/server/src/routes/classroom-rentals.ts apps/server/src/index.ts +git commit -m "feat: implement classroom, tenant, and classroom rental management routes" +``` + +--- + +### Task 18: 操作日志与数据面板路由 + +**Files:** +- Create: `apps/server/src/routes/operation-logs.ts` +- Create: `apps/server/src/routes/dashboard.ts` +- Modify: `apps/server/src/index.ts` + +- [ ] **Step 1: 操作日志查询路由** + +端点: +- `GET /api/operation-logs` — 分页列表(?page=, ?limit=, ?module=, ?action=, ?username=) +- `GET /api/operation-logs/:id` — 详情 + +约 80 行代码。 + +- [ ] **Step 2: 数据面板路由** + +端点:`GET /api/dashboard` — 聚合统计(学生数、宿舍占用率、费用总额、账单状态分布)。按现有 `dashboard.service.ts` 逻辑转换为 Drizzle 聚合查询。 + +约 60 行代码。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/routes/operation-logs.ts apps/server/src/routes/dashboard.ts apps/server/src/index.ts +git commit -m "feat: implement operation log query and dashboard aggregation routes" +``` + +--- + +### Task 19: 操作日志记录中间件 + +**Files:** +- Create: `apps/server/src/middleware/operation-log.ts` +- Modify: 所有路由文件(集成日志记录调用) + +- [ ] **Step 1: 实现日志记录工具函数** + +```typescript +// apps/server/src/middleware/operation-log.ts +import { db } from '../db'; +import * as schema from '../db/schema'; + +interface LogParams { + userId?: number; username?: string; + module: string; action: string; + targetId?: number; targetType?: string; + detail?: string; ipAddress?: string; + userAgent?: string; status?: 'success' | 'fail'; +} + +export async function logOperation( + c: any, // Hono Context + params: Omit +) { + const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'; + const ua = (c.req.header('user-agent') || '').substring(0, 500); + await db.insert(schema.operationLogs).values({ + userId: params.userId, username: params.username, + module: params.module, action: params.action, + targetId: params.targetId, targetType: params.targetType, + detail: params.detail, ipAddress: ip, userAgent: ua, + status: params.status || 'success', + }); +} +``` + +- [ ] **Step 2: 在所有写操作路由中集成日志记录** + +登录、学生创建/编辑/删除、宿舍创建/编辑/删除等所有写操作调用 `logOperation()`。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/middleware/operation-log.ts +git commit -m "feat: implement operation log middleware with automatic IP/UA capture" +``` + +--- + +### Task 20: 限流 + CORS + 全局错误处理中间件 + +**Files:** +- Create: `apps/server/src/middleware/rate-limit.ts` +- Create: `apps/server/src/middleware/error-handler.ts` +- Modify: `apps/server/src/index.ts`(挂载中间件栈) + +- [ ] **Step 1: Rate Limiter 配置** + +```typescript +// apps/server/src/middleware/rate-limit.ts +import { rateLimiter } from 'hono-rate-limiter'; + +export const rateLimitMiddleware = rateLimiter({ + windowMs: 60 * 1000, // 1 分钟 + limit: 100, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (c) => c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown', +}); +``` + +- [ ] **Step 2: CORS 配置** + +```typescript +// 在 apps/server/src/index.ts 中: +import { cors } from 'hono/cors'; +app.use('*', cors({ origin: '*', credentials: true })); +``` + +- [ ] **Step 3: 全局错误处理** + +```typescript +// apps/server/src/middleware/error-handler.ts +import { createMiddleware } from 'hono/factory'; + +export const errorHandler = createMiddleware(async (c, next) => { + try { + await next(); + } catch (err: any) { + console.error('[Error]', err.message || err); + return c.json({ + message: err.message || '服务器内部错误', + statusCode: err.status || 500, + }, err.status || 500); + } +}); +``` + +- [ ] **Step 4: 组装中间件栈** + +```typescript +// apps/server/src/index.ts 最终中间件顺序: +app.use('*', errorHandler); +app.use('*', cors({ origin: '*', credentials: true })); +app.use('/api/*', rateLimitMiddleware); +app.use('/api/*', authMiddleware); +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/middleware/ src/index.ts +git commit -m "feat: add CORS, rate limiter, and global error handler middleware" +``` + +--- + +### Task 21: Zod 校验集成 + +**Files:** +- Modify: 所有路由文件(为 POST/PUT 端点添加 Zod schema 校验) +- Create: `apps/server/src/utils/validate.ts` + +- [ ] **Step 1: 创建通用校验工具** + +```typescript +// apps/server/src/utils/validate.ts +import { z } from 'zod'; +import { zValidator } from '@hono/zod-validator'; + +export { z, zValidator }; +``` + +- [ ] **Step 2: 为主要 DTO 定义 Zod schema** + +为 student、room、occupancy、expense、bill、user、role 的 POST/PUT body 添加 Zod 校验。示例: + +```typescript +// 在 students.ts 中: +import { zValidator, z } from '../utils/validate'; + +const createStudentSchema = z.object({ + name: z.string().min(1).max(50), + phone: z.string().max(20).optional(), + idNumber: z.string().max(30).optional(), + gender: z.string().max(10).optional(), + // ... +}); + +studentsRoute.post('/', requirePermission('student:create'), zValidator('json', createStudentSchema), async (c) => { + const dto = c.req.valid('json'); + // ... +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/utils/validate.ts apps/server/src/routes/ +git commit -m "feat: integrate Zod validation for all POST/PUT endpoints" +``` + +--- + +### Task 22: 部署配置更新 + +**Files:** +- Modify: `apps/server/Dockerfile` +- Modify: `docker-compose.yml` +- Modify: `apps/server/.env.example` +- Create: `apps/server/.env` + +- [ ] **Step 1: 更新 Dockerfile** + +移除 NestJS 构建依赖,适配 HonoJS: + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +EXPOSE 3003 +CMD ["node", "dist/index.js"] +``` + +- [ ] **Step 2: 更新 docker-compose.yml** + +- 移除 `mysql` 服务和 `mysql_data` volume +- 后端依赖改为 external PostgreSQL(通过环境变量连接) +- 更新 `backend` 服务的环境变量列表(移除 DB_TYPE,添加 DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD/DB_DATABASE/BETTER_AUTH_SECRET) + +- [ ] **Step 3: 更新 .env 示例** + +``` +PORT=3003 +NODE_ENV=development +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD=postgres +DB_DATABASE=gongxue +BETTER_AUTH_SECRET=your-secret-key-change-in-production +ADMIN_PASSWORD=admin123 +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/Dockerfile docker-compose.yml apps/server/.env.example apps/server/.env +git commit -m "chore: update Dockerfile, docker-compose, and env config for HonoJS + PostgreSQL" +``` + +--- + +### Task 23: 清理 NestJS 残留 + 最终验证 + +**Files:** +- Delete: 所有 NestJS 特有文件和目录(见下方列表) +- Modify: `apps/server/package.json`(清理残留 dep) + +- [ ] **Step 1: 删除 NestJS 残留文件** + +```bash +cd apps/server +rm -rf src/app.module.ts src/app.controller.ts src/app.service.ts src/app.controller.spec.ts +rm -rf src/auth/strategies/ src/auth/guards/ src/auth/decorators/ src/auth/auth.module.ts +rm -rf src/entities/ +rm -rf src/students/students.module.ts src/rooms/rooms.module.ts +rm -rf src/occupancies/occupancies.module.ts src/expenses/expenses.module.ts +rm -rf src/bills/bills.module.ts src/deposits/deposits.module.ts +rm -rf src/classrooms/classrooms.module.ts src/classroom-rentals/classroom-rentals.module.ts +rm -rf src/tenants/tenants.module.ts src/operation-logs/operation-logs.module.ts +rm -rf src/dashboard/dashboard.module.ts src/rbac/rbac.module.ts +rm -rf src/common/ src/global.d.ts +rm -rf nest-cli.json test/ +``` + +- [ ] **Step 2: 验证 pnpm remove 清理所有 NestJS 依赖** + +```bash +cd apps/server +pnpm remove @nestjs/common @nestjs/core @nestjs/config @nestjs/jwt @nestjs/passport \ + @nestjs/platform-express @nestjs/throttler @nestjs/typeorm @nestjs/cli \ + @nestjs/schematics @nestjs/testing typeorm mysql2 better-sqlite3 \ + passport passport-jwt passport-local class-transformer class-validator \ + reflect-metadata rxjs multer @types/multer @types/better-sqlite3 @types/express \ + @types/passport-jwt @types/passport-local 2>/dev/null || true +``` + +- [ ] **Step 3: 构建验证** + +```bash +cd apps/server && pnpm typecheck && pnpm build +# 预期:无类型错误,dist/index.js 生成成功 +``` + +- [ ] **Step 4: 启动验证** + +```bash +cd apps/server && pnpm dev +# curl http://localhost:3003/api/health → 200 +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A apps/server/ +git commit -m "chore: remove all NestJS residual files and dependencies, finalize HonoJS migration" +``` + +--- + +### Task 24: 端到端兼容性验证清单 + +**检查项(手动 + 自动化)**: +- [ ] 24.1 所有 60+ API 端点可正常访问(对照 NestJS 端点列表逐一验证) +- [ ] 24.2 登录 → JWT token → 受保护路由 全流程可用 +- [ ] 24.3 49 个权限点检查正常(admin/supervisor/teacher/institution 权限隔离) +- [ ] 24.4 Excel 导入导出 round-trip(导出 → 修改 → 导入 → 验证) +- [ ] 24.5 PDF 账单生成可用 +- [ ] 24.6 操作日志记录完整(登录、CRUD 操作均有日志) +- [ ] 24.7 Rate limiter 生效(100 req/min) +- [ ] 24.8 PGlite 开发环境零 Docker 启动 +- [ ] 24.9 前端 23 个页面功能回归正常(无 API 兼容性错误) + +此 task 检查项确认后勾选。 + +--- + +## 执行顺序与依赖图 + +``` +Task 1 ─────────────────────────────────────────────────────────────┐ + ↓ │ +Task 2 (auth schema) ──→ Task 3 (biz schema) ──→ Task 4 (other + db) │ + ↓ ↓ ↓ │ +Task 5 (better-auth) ←────┴───────────────────────┘ │ + ↓ │ +Task 6 (auth middleware) │ + ↓ │ +Task 7 (login) │ + ↓ │ +Task 8 (users CRUD) ──→ Task 9 (roles + permissions) │ + ↓ ↓ │ +Task 10 (students) ←──────┴─────────────────────────────────────┐ │ + ↓ │ │ +Task 11 (student Excel) │ │ + ↓ │ │ +Task 12 (rooms) ──→ Task 13 (occupancies) ──→ Task 14 (expenses) │ │ + ↓ ↓ │ │ + └──────────→ Task 15 (bills) │ │ + ↓ │ │ +Task 16 (deposits) ←─────────────────┘ │ │ + ↓ │ │ +Task 17 (classroom + tenant + rental) │ │ + ↓ │ │ +Task 18 (logs + dashboard) ←─────────────────────────────────────┘ │ + ↓ │ +Task 19 (log middleware) ←── 可与其他路由同时进行 │ + ↓ │ +Task 20 (rate-limit + CORS + error) │ + ↓ │ +Task 21 (Zod validation) │ + ↓ │ +Task 22 (deploy config) ──→ Task 23 (cleanup) ──→ Task 24 (verify) │ + ↑ │ + └── 可与 Task 10-21 并行 ←─────────────────────────────────────────┘ +``` + +## 分批实施建议 + +| 批次 | Tasks | 预计工作量 | 关键产出 | +|------|-------|-----------|---------| +| 批 1 (基础) | 1-6 | 全局基础 | Hono + Drizzle + better-auth 可启动 | +| 批 2 (认证) | 7-9 | 认证 + RBAC | 登录/用户/角色/权限全功能 | +| 批 3 (核心业务) | 10-15 | 学生/宿舍/费用/账单 | 核心模块可用 | +| 批 4 (其他业务) | 16-18 | 押金/教室/日志 | 全模块完成 | +| 批 5 (横切) | 19-21 | 中间件 | 日志/限流/校验 | +| 批 6 (收尾) | 22-24 | 部署+清理+验证 | 完成迁移 |