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

57 KiB
Raw Blame History

change, design-doc, base-ref
change design-doc base-ref
betterauth-hono-drizzle-migration docs/superpowers/specs/2026-07-03-betterauth-hono-drizzle-migration-design.md 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

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

{
  "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
{
  "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 特有配置):

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}
  • Step 4: 创建最小 Hono 入口验证项目可启动

创建 apps/server/src/index.ts

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: 验证启动
cd apps/server && pnpm dev
# 预期Server running on http://localhost:3003
# curl http://localhost:3003/api/health → {"status":"ok","timestamp":"..."}
  • Step 6: Commit
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

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 表)
// 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 导出
// apps/server/src/db/schema/index.ts
export * from './auth';
  • Step 4: Commit
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 表

// 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 表
// 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 表
// 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
// 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
// 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
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 张业务表定义

// 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: 数据库连接工厂
// 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
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: 环境变量工具

// 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 实例配置
// 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 初始化无错误
cd apps/server && pnpm typecheck
  • Step 4: Commit
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(等效 JwtAuthGuardrequirePermission(code)(等效 @RequirePermission

  • Consumes: Task 5 (auth 实例)

  • Step 1: JWT 认证中间件

// 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: 权限检查中间件
// 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 挂载中间件
// apps/server/src/index.ts 增加:
import { authMiddleware } from './middleware/auth';
app.use('/api/*', authMiddleware);
  • Step 4: Commit
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/loginGET /api/auth/profile

  • Consumes: Tasks 5-6 (auth 实例 + 中间件)

  • Step 1: 实现认证路由

// 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
// 在 apps/server/src/index.ts 中添加:
import { authRoute } from './routes/auth';
app.route('/api/auth', authRoute);
  • Step 3: Commit
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/usersPUT /api/users/:id/reset-password

  • Consumes: Task 6 (中间件)、Task 4 (db)

  • Step 1: 实现用户管理路由

基于现有 rbac.service.tsfindAllUserscreateUserupdateUserresetPassworddeleteUser 方法,转换为 Drizzle 查询。

// 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
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/rolesGET /api/permissionsGET /api/permissions/tree

  • Consumes: Task 6 (中间件)

  • Step 1: 角色管理路由

基于 rbac.service.ts:193-238,转换为 Drizzle

// 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: 权限查询路由
// 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
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/studentsPUT /api/students/:id/restorePOST /api/students/batch-delete

  • Consumes: Task 6 (中间件)、Task 4 (db)

  • Step 1: 实现学生 CRUD

基于现有 students.controller.ts274 行),逐方法转换为 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
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() 方式:

// 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: 模板下载
// 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 导入
// 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
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
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
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
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
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
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
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
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: 实现日志记录工具函数

// 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
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 配置

// 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 配置
// 在 apps/server/src/index.ts 中:
import { cors } from 'hono/cors';
app.use('*', cors({ origin: '*', credentials: true }));
  • Step 3: 全局错误处理
// 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: 组装中间件栈
// 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
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: 创建通用校验工具

// 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 校验。示例:

// 在 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
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

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
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 残留文件

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 依赖
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: 构建验证
cd apps/server && pnpm typecheck && pnpm build
# 预期无类型错误dist/index.js 生成成功
  • Step 4: 启动验证
cd apps/server && pnpm dev
# curl http://localhost:3003/api/health → 200
  • Step 5: Commit
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 部署+清理+验证 完成迁移