Files
gongxue-base/docs/superpowers/plans/2026-07-03-betterauth-hono-drizzle-migration.md

1694 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 接管认证与 RBACDrizzle 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<string>();
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<string, typeof perms>();
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<LogParams, 'ipAddress' | 'userAgent'>
) {
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 | 部署+清理+验证 | 完成迁移 |