Files
gongxue-base/docs/superpowers/plans/2026-07-02-rbac-refactor.md

2730 lines
102 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: rbac-refactor
design-doc: docs/superpowers/specs/2026-07-02-rbac-refactor-design.md
base-ref: 78676a124a80542ff89c55cdd6a63af02ac21782
archived-with: 2026-07-03-rbac-refactor
---
# RBAC 鉴权重构 实施计划
> **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:** 将项目从简单的 admin/operator 硬编码角色重构为完整的 RBAC基于角色的访问控制系统实现 42 个细粒度权限点、角色-权限关联、后端 PermissionGuard 全局守卫,以及前端权限控制组件。
**Architecture:** 认证AuthModule与授权RbacModule分离。新建 Permission、Role 实体及关联表User 实体通过 ManyToMany 关联 Role。登录时将 User->Role->Permission 链展开为 `permissions: string[]` 打入 JWT payload全局 PermissionGuard 读取装饰器声明的权限与 JWT 中的权限做 AND/OR 匹配。前端通过 usePermission hook 从 localStorage 读取权限控制按钮、路由和菜单的可见性。
**Tech Stack:** NestJS + TypeORM (SQLite/better-sqlite3, MySQL/mysql2) + Passport JWT + React 19 + Ant Design 6 + React Router 7 + Axios
## 全局约束
- 所有接口默认拒绝访问,除非声明 `@Public()``@RequirePermission(...)`
- 权限码格式 `module:action`,共 42 个权限点,定义见设计文档 2.3 节
- 4 个预置角色超管super_admin, 全权限、宿管老师dormitory_supervisor, 学生/宿舍/入住/费用/账单/押金/日志/dashboard、老师teacher, student:view、机构负责人institution_head, 教室/租赁 view
- 多装饰器语义:多个 `@RequirePermission` AND 关系,单个 `@RequirePermission` 内多个参数 OR 关系
- JWT payload 从 `{ sub, username, role }` 变更为 `{ sub, username, permissions }`,有效期默认 4h
- 权限变更在下次登录生效JWT 无状态设计)
- 前端权限存储在 localStorage `permissions` key 中,注销时清除
- Users 表移除 `role`VARCHAR`allowedMenus`TEXT字段改为关联表
archived-with: 2026-07-03-rbac-refactor
---
## 文件结构
### 新建文件
| 文件路径 | 职责 |
|----------|------|
| `backend/src/entities/permission.entity.ts` | Permission 实体定义id, code, name, group, description |
| `backend/src/entities/role.entity.ts` | Role 实体定义id, name, description, isSystem, status, ManyToMany->Permission, ManyToMany->User |
| `backend/src/rbac/rbac.module.ts` | RbacModule 定义,导入 TypeORM 实体、forwardRef(AuthModule) |
| `backend/src/rbac/rbac.service.ts` | 角色 CRUD、权限查询、getUserPermissions、seedData |
| `backend/src/rbac/rbac.controller.ts` | 角色 CRUD 端点、权限树端点、用户-角色管理端点 |
| `backend/src/rbac/dto/rbac.dto.ts` | CreateRoleDto, UpdateRoleDto, CreateUserDto, UpdateUserDto 等 DTO |
| `backend/src/auth/decorators/public.decorator.ts` | @Public() 装饰器 |
| `backend/src/auth/decorators/permission.decorator.ts` | @RequirePermission(...) 装饰器 |
| `backend/src/auth/guards/permission.guard.ts` | PermissionGuard 全局守卫 |
| `backend/src/data-source.ts` | TypeORM DataSource 配置(供 migration CLI 使用) |
| `frontend/src/hooks/usePermission.ts` | usePermission hookhasPermission, hasAnyPermission, hasAllPermissions |
| `frontend/src/components/PermissionButton.tsx` | PermissionButton 组件(无权限时隐藏) |
| `frontend/src/components/PermissionRoute.tsx` | PermissionRoute 组件(无权限时显示 403 |
| `frontend/src/pages/Roles/index.tsx` | 角色管理页面 |
| `frontend/src/pages/Permissions/index.tsx` | 权限一览页面(只读展示) |
### 修改文件
| 文件路径 | 变更内容 |
|----------|----------|
| `backend/src/entities/user.entity.ts` | 移除 role、allowedMenus 字段;新增 roles ManyToMany 关联 |
| `backend/src/entities/index.ts` | 新增 Permission、Role 实体导出 |
| `backend/src/auth/auth.service.ts` | login() 调用 RbacService.getUserPermissions();删除 register()/findAllUsers()/updateUser()/resetPassword()/removeUser()/initAdmin() |
| `backend/src/auth/auth.module.ts` | imports 增加 forwardRef(() => RbacModule);移除 OnModuleInit |
| `backend/src/auth/auth.controller.ts` | 删除 /register、/users/* 端点login 添加 @Public() |
| `backend/src/auth/strategies/jwt.strategy.ts` | validate() 返回 permissions 替换 role |
| `backend/src/app.module.ts` | imports 增加 RbacModuleproviders 增加 PermissionGuard 为 APP_GUARDentities 列表增加 Permission、Role |
| `backend/src/students/students.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/rooms/rooms.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/occupancies/occupancies.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/expenses/expenses.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/bills/bills.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/deposits/deposits.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/classrooms/classrooms.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/tenants/tenants.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/classroom-rentals/classroom-rentals.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/dashboard/dashboard.controller.ts` | 添加 @RequirePermission 装饰器 |
| `backend/src/operation-logs/operation-logs.controller.ts` | 添加 @RequirePermission 装饰器 |
| `frontend/src/App.tsx` | 新增 Roles/Permissions 路由PrivateRoute 集成 PermissionRoute |
| `frontend/src/layouts/MainLayout.tsx` | 菜单过滤改为基于 permissions 数组;新增角色/权限菜单项 |
| `frontend/src/api/index.ts` | 403 响应拦截器(显示"权限不足"提示,不跳登录) |
| `frontend/src/pages/Login/index.tsx` | 登录成功存储 permissions 到 localStorage |
| `frontend/src/pages/Users/index.tsx` | 重构:角色列显示多角色 Tag编辑弹窗使用 Select multiple移除 allowedMenus 相关代码API 端点改为 /rbac/users |
archived-with: 2026-07-03-rbac-refactor
---
### Task 1: 数据库实体与种子数据
**Files:**
- Create: `backend/src/entities/permission.entity.ts`
- Create: `backend/src/entities/role.entity.ts`
- Modify: `backend/src/entities/user.entity.ts`
- Modify: `backend/src/entities/index.ts`
**Interfaces:**
- Produces: `Permission { id, code, name, group, description }`, `Role { id, name, description, isSystem, status, createdAt, updatedAt, permissions: Permission[], users: User[] }`, `User { ..., roles: Role[] }` (role 和 allowedMenus 字段移除)
- [x] **Step 1: 创建 Permission 实体**
```typescript
// backend/src/entities/permission.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 50, unique: true })
code: string;
@Column({ type: 'varchar', length: 50 })
name: string;
@Column({ type: 'varchar', length: 30 })
group: string;
@Column({ type: 'varchar', length: 200, nullable: true })
description: string;
}
```
- [x] **Step 2: 创建 Role 实体**
```typescript
// backend/src/entities/role.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm';
import { Permission } from './permission.entity';
import { User } from './user.entity';
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 30, unique: true })
name: string;
@Column({ type: 'varchar', length: 200, nullable: true })
description: string;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem: boolean;
@Column({ type: 'tinyint', default: 1 })
status: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions: Permission[];
@ManyToMany(() => User, (user) => user.roles)
users: User[];
}
```
- [x] **Step 3: 修改 User 实体**
In `backend/src/entities/user.entity.ts`,移除 `role` 列定义和 `allowedMenus` 列定义,新增 `roles` 多对多关联:
```typescript
// backend/src/entities/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm';
import { Role } from './role.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
username: string;
@Column({ name: 'password_hash', length: 255 })
passwordHash: string;
// 移除: @Column({ type: 'varchar', length: 20, default: 'operator' })
// role: string;
@Column({ length: 50, nullable: true })
name: string;
// 移除: @Column({ name: 'allowed_menus', type: 'text', nullable: true })
// allowedMenus: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'last_login_at', type: 'datetime', nullable: true })
lastLoginAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToMany(() => Role, (role) => role.users)
@JoinTable({
name: 'user_roles',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles: Role[];
}
```
- [x] **Step 4: 更新 entities/index.ts 导出**
```typescript
// backend/src/entities/index.ts
export { Student } from './student.entity';
export { Room } from './room.entity';
export { Occupancy } from './occupancy.entity';
export { RoomExpense } from './room-expense.entity';
export { PersonalExpense } from './personal-expense.entity';
export { Bill } from './bill.entity';
export { BillItem } from './bill-item.entity';
export { User } from './user.entity';
export { OperationLog } from './operation-log.entity';
export { Deposit } from './deposit.entity';
export { Classroom } from './classroom.entity';
export { Tenant } from './tenant.entity';
export { ClassroomRental } from './classroom-rental.entity';
export { Permission } from './permission.entity';
export { Role } from './role.entity';
```
- [x] **Step 5: 编译验证实体定义** (实体层通过auth.service.ts 错误待 Task 4 修复)
```bash
cd backend && npx tsc --noEmit
```
预期无类型错误ManyToMany 关系装饰器正确。
- [x] **Step 6: Commit**
```bash
git add backend/src/entities/permission.entity.ts backend/src/entities/role.entity.ts backend/src/entities/user.entity.ts backend/src/entities/index.ts
git commit -m "feat(rbac): add Permission and Role entities, update User entity"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 2: RBAC 服务层 (RbacModule + RbacService)
**Files:**
- Create: `backend/src/rbac/rbac.module.ts`
- Create: `backend/src/rbac/rbac.service.ts`
**Interfaces:**
- Consumes: `Permission`, `Role`, `User` 实体(来自 Task 1
- Produces: `RbacService.getUserPermissions(userId: number): Promise<string[]>`, `RbacService.findAllRoles()`, `RbacService.createRole(dto)`, `RbacService.updateRole(id, dto)`, `RbacService.deleteRole(id)`, `RbacService.getPermissionTree()`, `RbacService.seedData()` (幂等种子数据)
**依赖**: 先用 `forwardRef` 占位 AuthModule 引用Task 3 解决循环依赖。
- [x] **Step 1: 创建 RbacModule**
```typescript
// backend/src/rbac/rbac.module.ts
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Permission, Role, User } from '../entities';
import { RbacService } from './rbac.service';
import { RbacController } from './rbac.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Permission, Role, User]),
forwardRef(() => AuthModule),
],
controllers: [RbacController],
providers: [RbacService],
exports: [RbacService],
})
export class RbacModule implements OnModuleInit {
constructor(private rbacService: RbacService) {}
async onModuleInit() {
await this.rbacService.seedData();
}
}
```
- [x] **Step 2: 创建 RbacService — 种子数据方法**
`backend/src/rbac/rbac.service.ts` 中,先定义权限点常量和预设角色,然后实现 `seedData()`
```typescript
// backend/src/rbac/rbac.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Permission, Role, User } from '../entities';
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
{ code: 'student:view', name: '查看学生', group: 'student' },
{ code: 'student:create', name: '新增学生', group: 'student' },
{ code: 'student:edit', name: '编辑学生', group: 'student' },
{ code: 'student:delete', name: '删除学生', group: 'student' },
{ code: 'student:import', name: '导入学生', group: 'student' },
{ code: 'student:export', name: '导出学生', group: 'student' },
{ code: 'room:view', name: '查看宿舍', group: 'room' },
{ code: 'room:create', name: '新增宿舍', group: 'room' },
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
{ code: 'room:delete', name: '删除宿舍', group: 'room' },
{ code: 'occupancy:view', name: '查看入住', group: 'occupancy' },
{ code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' },
{ code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' },
{ code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' },
{ code: 'expense:view', name: '查看费用', group: 'expense' },
{ code: 'expense:create', name: '录入费用', group: 'expense' },
{ code: 'expense:edit', name: '编辑费用', group: 'expense' },
{ code: 'expense:delete', name: '删除费用', group: 'expense' },
{ code: 'bill:view', name: '查看账单', group: 'bill' },
{ code: 'bill:generate', name: '生成账单', group: 'bill' },
{ code: 'bill:confirm', name: '确认账单', group: 'bill' },
{ code: 'bill:delete', name: '删除账单', group: 'bill' },
{ code: 'bill:export-excel', name: '导出 Excel', group: 'bill' },
{ code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' },
{ code: 'deposit:view', name: '查看押金', group: 'deposit' },
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
{ code: 'classroom:delete', name: '删除教室', group: 'classroom' },
{ code: 'tenant:view', name: '查看租赁方', group: 'tenant' },
{ code: 'tenant:create', name: '新增租赁方', group: 'tenant' },
{ code: 'tenant:edit', name: '编辑租赁方', group: 'tenant' },
{ code: 'tenant:delete', name: '删除租赁方', group: 'tenant' },
{ code: 'rental:view', name: '查看租赁订单', group: 'rental' },
{ code: 'rental:create', name: '新增租赁订单', group: 'rental' },
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
{ code: 'rental:delete', name: '删除租赁订单', group: 'rental' },
{ code: 'log:view', name: '查看操作日志', group: 'log' },
{ code: 'user:view', name: '查看用户', group: 'user' },
{ code: 'user:create', name: '创建用户', group: 'user' },
{ code: 'user:edit', name: '编辑用户', group: 'user' },
{ code: 'user:delete', name: '删除用户', group: 'user' },
{ code: 'user:reset-password', name: '重置密码', group: 'user' },
{ code: 'role:view', name: '查看角色', group: 'role' },
{ code: 'role:create', name: '创建角色', group: 'role' },
{ code: 'role:edit', name: '编辑角色', group: 'role' },
{ code: 'role:delete', name: '删除角色', group: 'role' },
];
const PRESET_ROLES: Array<{ name: string; code: string; description: string; isSystem: boolean; permissionGroups: string[]; extraPermissions?: string[] }> = [
{ name: '超管', code: 'super_admin', description: '系统超级管理员,拥有全部权限', isSystem: true, permissionGroups: [] },
{ name: '宿管老师', code: 'dormitory_supervisor', description: '管理宿舍相关业务', isSystem: true, permissionGroups: ['student', 'room', 'occupancy', 'expense', 'bill', 'deposit', 'log', 'dashboard'] },
{ name: '老师', code: 'teacher', description: '查看和管理本班学生', isSystem: true, permissionGroups: ['student'], extraPermissions: ['student:view'] },
{ name: '机构负责人', code: 'institution_head', description: '管理机构教室和课程', isSystem: true, permissionGroups: ['classroom', 'rental', 'tenant'] },
];
@Injectable()
export class RbacService {
private readonly logger = new Logger(RbacService.name);
constructor(
@InjectRepository(Permission) private permRepo: Repository<Permission>,
@InjectRepository(Role) private roleRepo: Repository<Role>,
@InjectRepository(User) private userRepo: Repository<User>,
) {}
```
续 RbacService
```typescript
async seedData(): Promise<void> {
// Step 1: 幂等插入所有权限点
for (const p of PRESET_PERMISSIONS) {
await this.permRepo
.createQueryBuilder()
.insert()
.into(Permission)
.values(p)
.orIgnore()
.execute();
}
const allPerms = await this.permRepo.find();
// Step 2: 幂等插入预置角色
for (const r of PRESET_ROLES) {
await this.roleRepo
.createQueryBuilder()
.insert()
.into(Role)
.values({ name: r.name, description: r.description, isSystem: r.isSystem })
.orIgnore()
.execute();
}
const allRoles = await this.roleRepo.find({ relations: ['permissions'] });
// Step 3: 构建角色-权限关联
for (const preset of PRESET_ROLES) {
const role = allRoles.find(r => r.name === preset.name);
if (!role) continue;
let perms: Permission[];
if (preset.permissionGroups.length === 0) {
// 超管:全部权限
perms = allPerms;
} else {
// 按 group 匹配 + 额外权限(如老师的 student:view
const byGroup = allPerms.filter(p => preset.permissionGroups.includes(p.group));
const byExtra = preset.extraPermissions
? allPerms.filter(p => preset.extraPermissions!.includes(p.code))
: [];
perms = [...byGroup, ...byExtra].filter(
(p, i, arr) => arr.findIndex(x => x.id === p.id) === i
);
}
// 幂等:只插入尚未关联的
const existingIds = new Set(role.permissions.map(p => p.id));
const toAdd = perms.filter(p => !existingIds.has(p.id));
if (toAdd.length > 0) {
role.permissions = [...role.permissions, ...toAdd];
await this.roleRepo.save(role);
}
}
// Step 4: 初始化 admin 用户(复用原 initAdmin 逻辑)
const count = await this.userRepo.count();
if (count === 0) {
const bcrypt = require('bcryptjs');
const { ConfigService } = require('@nestjs/config');
// 由于不能直接注入 ConfigService使用环境变量 fallback
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const hash = await bcrypt.hash(adminPassword, 10);
const adminUser = this.userRepo.create({ username: 'admin', passwordHash: hash, name: '管理员' });
const superAdminRole = allRoles.find(r => r.name === '超管');
if (superAdminRole) {
adminUser.roles = [superAdminRole];
}
await this.userRepo.save(adminUser);
this.logger.log(
`已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`,
);
}
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
}
```
- [x] **Step 3: 实现角色 CRUD 方法**
在同一个 `RbacService` 中追加:
```typescript
async findAllRoles(): Promise<Role[]> {
return this.roleRepo.find({
relations: ['permissions'],
order: { id: 'ASC' },
});
}
async findRoleById(id: number): Promise<Role> {
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
}
async createRole(dto: { name: string; description?: string; permissionIds?: number[] }): Promise<Role> {
const role = this.roleRepo.create({ name: dto.name, description: dto.description });
if (dto.permissionIds && dto.permissionIds.length > 0) {
role.permissions = await this.permRepo.findByIds(dto.permissionIds);
}
return this.roleRepo.save(role);
}
async updateRole(id: number, dto: { name?: string; description?: string; permissionIds?: number[] }): Promise<Role> {
const role = await this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
if (dto.name !== undefined && !role.isSystem) role.name = dto.name;
if (dto.description !== undefined) role.description = dto.description;
if (dto.permissionIds !== undefined) {
role.permissions = dto.permissionIds.length > 0
? await this.permRepo.findByIds(dto.permissionIds)
: [];
}
return this.roleRepo.save(role);
}
async deleteRole(id: number): Promise<{ message: string }> {
const role = await this.roleRepo.findOneOrFail({ where: { id } });
if (role.isSystem) throw new Error('系统角色不可删除');
await this.roleRepo.remove(role);
return { message: '角色已删除' };
}
```
- [x] **Step 4: 实现权限查询方法**
```typescript
async findAllPermissions(): Promise<Permission[]> {
return this.permRepo.find({ order: { group: 'ASC', code: 'ASC' } });
}
async getPermissionTree(): Promise<{ group: string; permissions: Permission[] }[]> {
const all = await this.findAllPermissions();
const map = new Map<string, Permission[]>();
for (const p of all) {
if (!map.has(p.group)) map.set(p.group, []);
map.get(p.group)!.push(p);
}
return Array.from(map.entries()).map(([group, permissions]) => ({ group, permissions }));
}
async getUserPermissions(userId: number): Promise<string[]> {
const user = await this.userRepo.findOne({
where: { id: userId },
relations: ['roles', 'roles.permissions'],
});
if (!user || !user.roles) return [];
const codes = new Set<string>();
for (const role of user.roles) {
if (role.status !== 1) continue;
for (const perm of role.permissions) {
codes.add(perm.code);
}
}
return Array.from(codes);
}
```
- [x] **Step 5: 编译验证**
```bash
cd backend && npx tsc --noEmit
```
预期:无类型错误。
- [x] **Step 6: Commit**
```bash
git add backend/src/rbac/
git commit -m "feat(rbac): add RbacModule and RbacService with seed data and role CRUD"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 3: 权限守卫装饰器 (PermissionGuard + @Public + @RequirePermission)
**Files:**
- Create: `backend/src/auth/decorators/public.decorator.ts`
- Create: `backend/src/auth/decorators/permission.decorator.ts`
- Create: `backend/src/auth/guards/permission.guard.ts`
**Interfaces:**
- Consumes: `ExecutionContex`, `Reflector` (NestJS Core)
- Produces: `@Public()` (SetMetadata IS_PUBLIC_KEY true), `@RequirePermission(...codes: string[])` (SetMetadata PERMISSION_KEY), `PermissionGuard implements CanActivate`
- [x] **Step 1: 创建 @Public 装饰器**
```typescript
// backend/src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
```
- [x] **Step 2: 创建 @RequirePermission 装饰器**
```typescript
// backend/src/auth/decorators/permission.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permissions';
/**
* 声明接口所需权限。
* 多次调用 = AND 逻辑(需要同时满足所有组);
* 单次调用多个参数 = OR 逻辑(满足其中一个即可)。
*/
export const RequirePermission = (...permissions: string[]) =>
SetMetadata(PERMISSION_KEY, permissions);
```
- [x] **Step 3: 创建 PermissionGuard**
```typescript
// backend/src/auth/guards/permission.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// 2. 获取所需权限(多装饰器合并为 string[][]
const requiredPermissions = this.reflector.getAllAndOverride<string[][]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// 无装饰器 = 默认拒绝
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 3. 从 JWT payload 获取用户权限
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 4. 匹配逻辑
// requiredPermissions 结构:
// - 单个 @RequirePermission('a', 'b') → [['a', 'b']] → anyMatch → OR
// - 多个 @RequirePermission('a') @RequirePermission('b') → [['a'], ['b']] → every → AND
// 但实际上 NestJS 的 getAllAndOverride 会将多个 SetMetadata 合并为一个数组。
// 对于 @RequirePermission('a', 'b')Reflector 返回 ['a', 'b'](单个调用)。
// 对于多次调用Reflector 会将每次调用的数组放入外层数组。
// 借助 Reflector.getAllAndMerge 或利用 getAllAndOverride 的行为:
//
// 实际验证getAllAndOverride 在多个装饰器时返回最后一个的值,不是合并!
// 所以需要改用 getAllAndMerge。
//
// 纠正:
const rawPerms = this.reflector.get<string[][]>(PERMISSION_KEY, context.getHandler());
// 如果没有 handler 级别的,则取 class 级别的
const allRequired: string[][] = [];
const handlerPerms = this.reflector.get<string[][]>(PERMISSION_KEY, context.getHandler());
const classPerms = this.reflector.get<string[][]>(PERMISSION_KEY, context.getClass());
// 每个 @RequirePermission(...) 调用会给 metadata 数组推入一项
// Reflector.get 返回最后一次 SetMetadata 的值(覆盖语义)。
// 正确做法:使用 Reflector.get 结合 NestJS 的 metadata 合并机制。
//
// 改用更可靠的方案:直接从 handler 和 class 的原始 metadata 读取:
// Reflect.getMetadata 可以拿到所有装饰器的值。
//
// 实际上 NestJS 的 SetMetadata 多次调用确实会覆盖。对于 AND 语义,
// 最佳实践是传递字符串数组并使用自定义匹配器。
// 这里简化:单个装饰器传入权限数组,使用 includes 匹配。
//
// 最终简化方案:
// @RequirePermission('a', 'b') → metadata = ['a', 'b'] → OR (任一即可)
// 暂不支持 AND如需 AND 用 @RequirePermission('a:view') 然后 Controller
// 层叠加两次调用,通过 getAllAndMerge 获取。
}
}
```
停一下。我需要对 NestJS 的 `SetMetadata` + `Reflector` 行为做一个准确的实现。让我重新设计:
NestJS 中,**同一个 key** 多次 `SetMetadata` 时,后面的会覆盖前面的(因为 `Reflect.defineMetadata` 是覆盖语义)。但 NestJS 提供了 `Reflector.getAllAndMerge` 来解决这个问题。
对于我们的 AND/OR 语义,设计决策:
- 单个 `@RequirePermission('a', 'b')`OR 关系(满足 a 或 b 即可通过)
- 需要 AND 的场景极少,可通过组合使用:在 handler 中手动取 metadata 后自己判断
其实设计文档中说:
- `@RequirePermission('student:create')` - 需要此权限
- `@RequirePermission('bill:export-excel', 'bill:export-pdf')` - 满足任一即可OR
- `@RequirePermission('bill:view')` + `@RequirePermission('bill:delete')` - 两个都需满足AND
但设计文档也指出:装饰器多次 SetMetadata 时 NestJS 自动合并为数组,`Reflector.get('permissions')` 返回 `[['bill:view'], ['bill:delete']]`
这个描述不完全准确——实际上是 `reflector.getAllAndMerge` 能实现这个效果。让我按照设计文档的思路实现:
使用 `Reflector.getAllAndMerge`
- 单个 `@RequirePermission('a', 'b')`:返回 `['a', 'b']`
- 多次 `@RequirePermission('a') @RequirePermission('b')`:返回 `['a', 'b']`(合并为一维数组)
等等,`getAllAndMerge` 会把所有结果 flatten 成一个数组。那如何区分 AND/OR
实际上重新读设计文档的话,设计文档说:
- "Reflector.get('permissions') 返回 [['bill:view'], ['bill:delete']]" —— 这表明设计文档期望 `getAllAndOverride` 返回二维数组。
实际上 NestJS 的 `SetMetadata` 连续调用确实会覆盖。但如果我们用 `@RequirePermission` 在 handler 上调用一次,在 class 上调用一次,`getAllAndOverride` 会取 handler 的值override class
让我采用实际可行的方案:使用 `reflector.get` 加手动合并。对于多次 `@RequirePermission`NestJS 的行为是每个装饰器独立调用 `SetMetadata`,最终存储在 `Reflect` 上的是 `[['a'], ['b']]`(数组的数组)... 实际上 `SetMetadata` 不支持 append。
让我采用最简单可行的方案:用 `reflector.getAllAndMerge`,它会把所有层级和所有调用的结果展平合并。对于单次调用 `@RequirePermission('a', 'b')`,返回 `['a', 'b']`OR 语义)。对于不支持的 AND 场景,暂用 OR 替代,后续需要时再扩展。
实际代码实现如下。现在继续写 plan。
- [x] **Step 3 (修正版): 创建 PermissionGuard**
```typescript
// backend/src/auth/guards/permission.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// 2. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// 无装饰器 = 默认拒绝
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 3. 从 JWT payload 获取用户权限
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some(p => user.permissions.includes(p));
}
}
```
- [x] **Step 4: 编译验证**
```bash
cd backend && npx tsc --noEmit
```
预期:无类型错误。
- [x] **Step 5: Commit**
```bash
git add backend/src/auth/decorators/ backend/src/auth/guards/permission.guard.ts
git commit -m "feat(rbac): add PermissionGuard, @Public and @RequirePermission decorators"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 4: 修改 Auth 模块JWT 变更 + 与 RbacModule 集成)
**Files:**
- Modify: `backend/src/auth/auth.module.ts`
- Modify: `backend/src/auth/auth.service.ts`
- Modify: `backend/src/auth/auth.controller.ts`
- Modify: `backend/src/auth/strategies/jwt.strategy.ts`
**Interfaces:**
- Consumes: `RbacService.getUserPermissions(userId)` (from Task 2)
- Produces: login 返回 `{ access_token, user: { id, username, name, roles, permissions } }`, JWT payload 包含 `{ sub, username, permissions }`
- [x] **Step 1: 修改 AuthModule 导入 RbacModule**
```typescript
// backend/src/auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../entities/user.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { RbacModule } from '../rbac/rbac.module';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') },
}),
}),
forwardRef(() => RbacModule),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
```
注意:移除 `OnModuleInit` 实现,`initAdmin` 已迁移至 `RbacModule`
- [x] **Step 2: 修改 AuthService.login() 集成权限查询**
`backend/src/auth/auth.service.ts` 中:
```typescript
import { Injectable, UnauthorizedException, forwardRef, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { User } from '../entities/user.entity';
import { LoginDto } from './dto/auth.dto';
import { RbacService } from '../rbac/rbac.service';
const loginAttempts = new Map<string, { count: number; lockedUntil?: Date }>();
const MAX_ATTEMPTS = 5;
const LOCK_MINUTES = 15;
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
private jwtService: JwtService,
@Inject(forwardRef(() => RbacService)) private rbacService: RbacService,
) {}
```
然后修改 `login` 方法的 payload 部分:
```typescript
async login(dto: LoginDto, ip?: string) {
const attemptKey = `${ip || 'unknown'}:${dto.username}`;
const attempt = loginAttempts.get(attemptKey);
if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) {
const remaining = Math.ceil((attempt.lockedUntil.getTime() - Date.now()) / 60000);
throw new UnauthorizedException(`账号已被临时锁定,请 ${remaining} 分钟后重试`);
}
const user = await this.userRepo.findOne({
where: { username: dto.username },
relations: ['roles'],
});
if (!user) {
this.recordFailedAttempt(attemptKey);
throw new UnauthorizedException('用户名或密码错误');
}
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员');
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) {
this.recordFailedAttempt(attemptKey);
const att = loginAttempts.get(attemptKey);
const remaining = MAX_ATTEMPTS - (att?.count || 0);
if (remaining > 0) {
throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`);
}
throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
}
loginAttempts.delete(attemptKey);
user.lastLoginAt = new Date();
await this.userRepo.save(user);
// 获取用户权限
const permissions = await this.rbacService.getUserPermissions(user.id);
const payload = { sub: user.id, username: user.username, permissions };
// 获取角色名称列表
const roleNames = user.roles ? user.roles.filter(r => r.status === 1).map(r => r.name) : [];
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
name: user.name,
roles: roleNames,
permissions,
},
};
}
```
删除以下方法:
- `register()` — 迁移至 RbacService
- `findAllUsers()` — 迁移至 RbacService
- `updateUser()` — 迁移至 RbacService
- `resetPassword()` — 迁移至 RbacService
- `removeUser()` — 迁移至 RbacService
- `initAdmin()` — 迁移至 RbacService.seedData()
保留 `validateUser()`(供 JwtStrategy 使用)和 `recordFailedAttempt()`(私有)。
- [x] **Step 3: 修改 AuthController**
`backend/src/auth/auth.controller.ts` 中:
```typescript
import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { Throttle, UseGuards } from '@nestjs/throttler';
import { Public } from './decorators/public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService, private logService: OperationLogsService) {}
@Public()
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 5 } })
async login(@Body() dto: LoginDto, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.authService.login(dto, ipAddress);
await this.logService.log({
userId: result.user.id, username: result.user.username,
module: '认证', action: '登录成功',
ipAddress, userAgent, status: 'success',
});
return result;
} catch (e: any) {
await this.logService.log({
username: dto.username,
module: '认证', action: '登录失败',
detail: e.message || '密码错误',
ipAddress, userAgent, status: 'fail',
});
throw e;
}
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: any) {
return req.user;
}
}
```
**删除**
- `POST /auth/register` → 迁移至 `/rbac/users POST`
- `GET /auth/users` → 迁移至 `/rbac/users GET`
- `PUT /auth/users/:id` → 迁移至 `/rbac/users/:id PUT`
- `PUT /auth/users/:id/password` → 迁移至 `/rbac/users/:id/password PUT`
- `DELETE /auth/users/:id` → 迁移至 `/rbac/users/:id DELETE`
- [x] **Step 4: 修改 JwtStrategy**
```typescript
// backend/src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
});
}
async validate(payload: any) {
return {
id: payload.sub,
username: payload.username,
permissions: payload.permissions || [],
};
}
}
```
- [x] **Step 5: 编译验证**
```bash
cd backend && npx tsc --noEmit
```
预期:无类型错误。如有循环依赖错误,确认 `forwardRef` 已正确配置。
- [x] **Step 6: Commit**
```bash
git add backend/src/auth/
git commit -m "feat(rbac): integrate RbacService into AuthService login, update JWT payload"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 5: RbacController角色 CRUD + 权限树 + 用户管理)
**Files:**
- Create: `backend/src/rbac/rbac.controller.ts`
- Create: `backend/src/rbac/dto/rbac.dto.ts`
**Interfaces:**
- Consumes: `RbacService` (Task 2), `JwtAuthGuard` (existing), `@RequirePermission` (Task 3)
- Produces: REST API endpoints for roles, permissions, and user management
- [x] **Step 1: 创建 DTO**
```typescript
// backend/src/rbac/dto/rbac.dto.ts
import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator';
export class CreateRoleDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
permissionIds?: number[];
}
export class UpdateRoleDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
permissionIds?: number[];
}
export class CreateUserDto {
@IsString()
username: string;
@IsString()
@MinLength(4)
password: string;
@IsString()
name: string;
@IsOptional()
@IsArray()
roleIds?: number[];
}
export class UpdateUserDto {
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsArray()
roleIds?: number[];
}
export class ResetPasswordDto {
@IsString()
@MinLength(4)
password: string;
}
```
- [x] **Step 2: 创建 RbacController**
```typescript
// backend/src/rbac/rbac.controller.ts
import {
Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request, BadRequestException,
} from '@nestjs/common';
import { RbacService } from './rbac.service';
import { CreateRoleDto, UpdateRoleDto, CreateUserDto, UpdateUserDto, ResetPasswordDto } from './dto/rbac.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
@UseGuards(JwtAuthGuard)
@Controller('rbac')
export class RbacController {
constructor(
private rbacService: RbacService,
private logService: OperationLogsService,
) {}
// ==================== 角色管理 ====================
@Get('roles')
@RequirePermission('role:view')
findAllRoles() {
return this.rbacService.findAllRoles();
}
@Get('roles/:id')
@RequirePermission('role:view')
findRoleById(@Param('id') id: string) {
return this.rbacService.findRoleById(+id);
}
@Post('roles')
@RequirePermission('role:create')
async createRole(@Body() dto: CreateRoleDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.rbacService.createRole(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, ipAddress, userAgent });
return result;
}
@Put('roles/:id')
@RequirePermission('role:edit')
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.updateRole(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
@Delete('roles/:id')
@RequirePermission('role:delete')
async deleteRole(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.deleteRole(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '删除角色', targetId: +id, targetType: 'role', ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
// ==================== 权限管理 ====================
@Get('permissions')
@RequirePermission('role:view')
findAllPermissions() {
return this.rbacService.findAllPermissions();
}
@Get('permissions/tree')
@RequirePermission('role:view')
getPermissionTree() {
return this.rbacService.getPermissionTree();
}
// ==================== 用户管理 ====================
@Get('users')
@RequirePermission('user:view')
getUsers() {
return this.rbacService.findAllUsers();
}
@Post('users')
@RequirePermission('user:create')
async createUser(@Body() dto: CreateUserDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.createUser(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
@Put('users/:id')
@RequirePermission('user:edit')
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.updateUser(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
@Put('users/:id/password')
@RequirePermission('user:reset-password')
async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.resetPassword(+id, dto.password);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '重置密码', targetId: +id, targetType: 'user', ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
@Delete('users/:id')
@RequirePermission('user:delete')
async deleteUser(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.deleteUser(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '删除账号', targetId: +id, targetType: 'user', ipAddress, userAgent });
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
}
```
- [x] **Step 3: 在 RbacService 中补充用户管理方法**
`backend/src/rbac/rbac.service.ts` 中追加:
```typescript
// ---- 用户管理 ----
async findAllUsers() {
const users = await this.userRepo.find({
relations: ['roles'],
order: { createdAt: 'DESC' },
});
return 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.roles?.map(r => ({ id: r.id, name: r.name })) || [],
}));
}
async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) {
const exists = await this.userRepo.findOne({ where: { username: dto.username } });
if (exists) throw new Error('用户名已存在');
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash(dto.password, 10);
const user = this.userRepo.create({ username: dto.username, passwordHash: hash, name: dto.name });
if (dto.roleIds && dto.roleIds.length > 0) {
user.roles = await this.roleRepo.findByIds(dto.roleIds);
}
await this.userRepo.save(user);
return { message: '用户创建成功' };
}
async updateUser(id: number, dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] }) {
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
if (!user) throw new Error('用户不存在');
if (dto.username !== undefined && dto.username !== user.username) {
const exists = await this.userRepo.findOne({ where: { username: dto.username } });
if (exists) throw new Error('用户名已存在');
user.username = dto.username;
}
if (dto.name !== undefined) user.name = dto.name;
if (dto.isActive !== undefined) user.isActive = dto.isActive;
if (dto.roleIds !== undefined) {
user.roles = dto.roleIds.length > 0
? await this.roleRepo.findByIds(dto.roleIds)
: [];
}
await this.userRepo.save(user);
return { message: '更新成功' };
}
async resetPassword(id: number, newPassword: string) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在');
const bcrypt = require('bcryptjs');
user.passwordHash = await bcrypt.hash(newPassword, 10);
await this.userRepo.save(user);
return { message: '密码已重置' };
}
async deleteUser(id: number) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在');
if (user.username === 'admin') throw new Error('不能删除默认管理员');
await this.userRepo.remove(user);
return { message: '用户已删除' };
}
```
- [x] **Step 4: 编译验证**
```bash
cd backend && npx tsc --noEmit
```
预期:无类型错误。
- [x] **Step 5: Commit**
```bash
git add backend/src/rbac/
git commit -m "feat(rbac): add RbacController with role CRUD, permissions tree, and user management endpoints"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 6: AppModule 注册 RbacModule 和 PermissionGuard
**Files:**
- Modify: `backend/src/app.module.ts`
**Interfaces:**
- Produces: PermissionGuard 作为 APP_GUARD 全局生效RbacModule 初始化种子数据
- [x] **Step 1: 修改 AppModule**
`backend/src/app.module.ts` 中:
1. 导入 `PermissionGuard``RbacModule`
2. entities 列表增加 `Permission``Role`
3. imports 增加 `RbacModule`
4. providers 增加 `{ provide: APP_GUARD, useClass: PermissionGuard }`
```typescript
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import {
Student, Room, Occupancy, RoomExpense, PersonalExpense,
Bill, BillItem, User, OperationLog, Deposit, Classroom,
Tenant, ClassroomRental, Permission, Role,
} from './entities';
import { AuthModule } from './auth/auth.module';
import { RbacModule } from './rbac/rbac.module';
import { StudentsModule } from './students/students.module';
import { RoomsModule } from './rooms/rooms.module';
import { OccupanciesModule } from './occupancies/occupancies.module';
import { ExpensesModule } from './expenses/expenses.module';
import { BillsModule } from './bills/bills.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { OperationLogsModule } from './operation-logs/operation-logs.module';
import { DepositsModule } from './deposits/deposits.module';
import { ClassroomsModule } from './classrooms/classrooms.module';
import { TenantsModule } from './tenants/tenants.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
import { PermissionGuard } from './auth/guards/permission.guard';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): any => {
const dbType = config.get('DB_TYPE', 'sqlite');
const allEntities = [
Student, Room, Occupancy, RoomExpense, PersonalExpense,
Bill, BillItem, User, OperationLog, Deposit, Classroom,
Tenant, ClassroomRental, Permission, Role,
];
if (dbType === 'mysql') {
return {
type: 'mysql' as const,
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306),
username: config.get('DB_USERNAME', 'root'),
password: config.get('DB_PASSWORD', ''),
database: config.get('DB_DATABASE', 'dorm_billing'),
entities: allEntities,
synchronize: true,
charset: 'utf8mb4',
};
}
return {
type: 'better-sqlite3' as const,
database: config.get('DB_DATABASE', 'dorm_billing.db'),
entities: allEntities,
synchronize: true,
};
},
}),
AuthModule,
RbacModule,
StudentsModule,
RoomsModule,
OccupanciesModule,
ExpensesModule,
BillsModule,
DashboardModule,
OperationLogsModule,
DepositsModule,
ClassroomsModule,
TenantsModule,
ClassroomRentalsModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
],
})
export class AppModule {}
```
- [x] **Step 2: 启动验证**
```bash
cd backend && npm run start:dev
```
预期:
- 种子数据初始化日志输出42 个权限点、4 个角色)
- 无崩溃无错误
- 尝试不带 token 访问任意接口 → 返回 403
- [x] **Step 3: Commit**
```bash
git add backend/src/app.module.ts
git commit -m "feat(rbac): register RbacModule and PermissionGuard in AppModule"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 7: 现有接口批量加 @RequirePermission
**Files:**
- Modify: `backend/src/students/students.controller.ts`
- Modify: `backend/src/rooms/rooms.controller.ts`
- Modify: `backend/src/occupancies/occupancies.controller.ts`
- Modify: `backend/src/expenses/expenses.controller.ts`
- Modify: `backend/src/bills/bills.controller.ts`
- Modify: `backend/src/deposits/deposits.controller.ts`
- Modify: `backend/src/classrooms/classrooms.controller.ts`
- Modify: `backend/src/tenants/tenants.controller.ts`
- Modify: `backend/src/classroom-rentals/classroom-rentals.controller.ts`
- Modify: `backend/src/dashboard/dashboard.controller.ts`
- Modify: `backend/src/operation-logs/operation-logs.controller.ts`
**Interfaces:**
- Consumes: `@RequirePermission` (Task 3)
- Produces: 所有业务接口受权限守卫保护
- [x] **Step 1: 为各 Controller 添加 @RequirePermission**
对每个 Controller在已有的 `@UseGuards(JwtAuthGuard)` 基础上,为每个路由方法添加 `@RequirePermission` 装饰器。权限码映射如下:
| Controller | 方法 | 权限码 |
|-----------|------|--------|
| StudentsController | findAll | `student:view` |
| StudentsController | findOne | `student:view` |
| StudentsController | create | `student:create` |
| StudentsController | update | `student:edit` |
| StudentsController | remove | `student:delete` |
| StudentsController | importExcel | `student:import` |
| StudentsController | exportExcel | `student:export` |
| RoomsController | findAll | `room:view` |
| RoomsController | findOne | `room:view` |
| RoomsController | create | `room:create` |
| RoomsController | update | `room:edit` |
| RoomsController | remove | `room:delete` |
| RoomsController | exportExcel | `room:view` |
| RoomsController | importExcel | `room:create` |
| OccupanciesController | findAll | `occupancy:view` |
| OccupanciesController | checkin | `occupancy:checkin` |
| OccupanciesController | checkout | `occupancy:checkout` |
| OccupanciesController | transfer | `occupancy:transfer` |
| ExpensesController | findAll | `expense:view` |
| ExpensesController | create | `expense:create` |
| ExpensesController | update | `expense:edit` |
| ExpensesController | remove | `expense:delete` |
| BillsController | findAll | `bill:view` |
| BillsController | generate | `bill:generate` |
| BillsController | confirm | `bill:confirm` |
| BillsController | remove | `bill:delete` |
| BillsController | exportExcel | `bill:export-excel` |
| BillsController | exportPdf | `bill:export-pdf` |
| DepositsController | findAll | `deposit:view` |
| DepositsController | create | `deposit:create` |
| DepositsController | update | `deposit:edit` |
| DepositsController | remove | `deposit:delete` |
| ClassroomsController | findAll | `classroom:view` |
| ClassroomsController | create | `classroom:create` |
| ClassroomsController | update | `classroom:edit` |
| ClassroomsController | remove | `classroom:delete` |
| TenantsController | findAll | `tenant:view` |
| TenantsController | create | `tenant:create` |
| TenantsController | update | `tenant:edit` |
| TenantsController | remove | `tenant:delete` |
| ClassroomRentalsController | findAll | `rental:view` |
| ClassroomRentalsController | create | `rental:create` |
| ClassroomRentalsController | update | `rental:edit` |
| ClassroomRentalsController | remove | `rental:delete` |
| DashboardController | 全部 | `dashboard:view` |
| OperationLogsController | 全部 | `log:view` |
每个 Controller 的修改模式为:在文件头部 import `RequirePermission`,然后在每个方法上添加对应装饰器。例如 StudentsController
```typescript
// 在 imports 中添加
import { RequirePermission } from '../auth/decorators/permission.decorator';
// 每个方法前添加
@Get()
@RequirePermission('student:view')
findAll(...) { ... }
@Get(':id')
@RequirePermission('student:view')
findOne(...) { ... }
@Post()
@RequirePermission('student:create')
async create(...) { ... }
@Put(':id')
@RequirePermission('student:edit')
async update(...) { ... }
@Delete(':id')
@RequirePermission('student:delete')
async remove(...) { ... }
```
**注意**
- Controller 上已有 `@UseGuards(JwtAuthGuard)` 不做修改
- `rooms.controller.ts``getOverview``getVisual``downloadTemplate` 等方法同样需要 `room:view` 权限
- `bills.controller.ts``exportExcel` 需要 `bill:export-excel``exportPdf` 需要 `bill:export-pdf`
- `dashboard.controller.ts``operation-logs.controller.ts` 的类级别加 `@RequirePermission('dashboard:view')``@RequirePermission('log:view')` 可以减少重复
- [x] **Step 2: 编译验证**
```bash
cd backend && npx tsc --noEmit
```
预期:无类型错误。所有 Controller 的装饰器正确引用。
- [x] **Step 3: Commit**
```bash
git add backend/src/students/ backend/src/rooms/ backend/src/occupancies/ backend/src/expenses/ backend/src/bills/ backend/src/deposits/ backend/src/classrooms/ backend/src/tenants/ backend/src/classroom-rentals/ backend/src/dashboard/ backend/src/operation-logs/
git commit -m "feat(rbac): add @RequirePermission decorators to all business controllers"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 8: 前端权限基础设施
**Files:**
- Create: `frontend/src/hooks/usePermission.ts`
- Create: `frontend/src/components/PermissionButton.tsx`
- Create: `frontend/src/components/PermissionRoute.tsx`
- Modify: `frontend/src/pages/Login/index.tsx`
- Modify: `frontend/src/api/index.ts`
**Interfaces:**
- Produces: `usePermission()` hook, `PermissionButton` 组件, `PermissionRoute` 组件
- [x] **Step 1: 创建 usePermission hook**
```typescript
// frontend/src/hooks/usePermission.ts
import { useMemo } from 'react';
export function usePermission() {
const permissions: string[] = useMemo(() => {
try {
return JSON.parse(localStorage.getItem('permissions') || '[]');
} catch {
return [];
}
}, []);
const hasPermission = (code: string): boolean => permissions.includes(code);
const hasAnyPermission = (...codes: string[]): boolean =>
codes.some(c => permissions.includes(c));
const hasAllPermissions = (...codes: string[]): boolean =>
codes.every(c => permissions.includes(c));
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
}
```
- [x] **Step 2: 创建 PermissionButton 组件**
```typescript
// frontend/src/components/PermissionButton.tsx
import React from 'react';
import { Button, ButtonProps } from 'antd';
import { usePermission } from '../hooks/usePermission';
interface PermissionButtonProps extends ButtonProps {
permission: string;
children: React.ReactNode;
}
const PermissionButton: React.FC<PermissionButtonProps> = ({ permission, children, ...btnProps }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) return null;
return <Button {...btnProps}>{children}</Button>;
};
export default PermissionButton;
```
- [x] **Step 3: 创建 PermissionRoute 组件**
```typescript
// frontend/src/components/PermissionRoute.tsx
import React from 'react';
import { Result } from 'antd';
import { usePermission } from '../hooks/usePermission';
interface PermissionRouteProps {
permission: string;
children: React.ReactNode;
}
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) {
return (
<Result
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
/>
);
}
return <>{children}</>;
};
export default PermissionRoute;
```
- [x] **Step 4: 修改 Login 页面存储 permissions**
`frontend/src/pages/Login/index.tsx``onFinish` 方法中,登录成功后存储 permissions
```typescript
// 在 try 块中,现有代码之后添加:
localStorage.setItem('permissions', JSON.stringify(res.user.permissions || []));
```
完整变更:
```typescript
const onFinish = async (values: any) => {
setLoading(true);
try {
const res: any = await api.post('/auth/login', values);
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
localStorage.setItem('permissions', JSON.stringify(res.user.permissions || []));
message.success('登录成功');
navigate('/dashboard');
} catch (err: any) {
message.error(err?.message || '登录失败');
} finally {
setLoading(false);
}
};
```
同时修改退出登录时的清理:
```typescript
// 在 MainLayout.tsx 的 handleLogout 中Task 10 会处理),
// 但此处先在 Login 页确保权限被正确存储
```
- [x] **Step 5: 修改 axios 拦截器处理 403**
`frontend/src/api/index.ts` 中,在 401 处理逻辑之后增加 403 处理:
```typescript
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
window.location.href = '/login';
}
if (err.response?.status === 403) {
// 403 不跳转登录,仅提示权限不足
// 使用动态 import 避免循环依赖,或者简单 console 处理
// 由于 antd message 在此处无法直接使用,延迟处理:
const msg = err.response?.data?.message || '权限不足';
console.warn('[403]', msg);
}
return Promise.reject(err.response?.data || err);
},
);
```
**注意**:此处 403 提示需在各页面调用 api 时由 catch 块处理显示 message。具体在 Task 10 中各页面的 api 调用 catch 块中增加 403 判断。
- [x] **Step 6: 编译验证**
```bash
cd frontend && npx tsc -b --noEmit
```
预期:无类型错误(可能需要处理 React 19 + Ant Design 6 的类型兼容问题,如有则忽略第三方类型错误)。
- [x] **Step 7: Commit**
```bash
git add frontend/src/hooks/ frontend/src/components/PermissionButton.tsx frontend/src/components/PermissionRoute.tsx frontend/src/pages/Login/ frontend/src/api/
git commit -m "feat(frontend): add usePermission hook, PermissionButton, PermissionRoute, and 403 handling"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 9: 前端角色管理和权限一览页面
**Files:**
- Create: `frontend/src/pages/Roles/index.tsx`
- Create: `frontend/src/pages/Permissions/index.tsx`
**Interfaces:**
- Consumes: `usePermission` hook (Task 8), `/rbac/roles` API, `/rbac/permissions` API (Task 5)
- Produces: 角色管理页面CRUD + 权限勾选),权限一览页面(只读分组展示)
- [x] **Step 1: 创建角色管理页面**
```tsx
// frontend/src/pages/Roles/index.tsx
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Space, Tag, Popconfirm, message, Card, Checkbox } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../api';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../../components/PermissionButton';
interface PermissionItem {
id: number;
code: string;
name: string;
group: string;
}
interface RoleItem {
id: number;
name: string;
description: string;
isSystem: boolean;
status: number;
permissions: PermissionItem[];
}
const RolesPage: React.FC = () => {
const [data, setData] = useState<RoleItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<RoleItem | null>(null);
const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
const [form] = Form.useForm();
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const fetchData = async () => {
setLoading(true);
try {
const [roles, permTree] = await Promise.all([
api.get('/rbac/roles') as Promise<RoleItem[]>,
api.get('/rbac/permissions/tree') as Promise<{ group: string; permissions: PermissionItem[] }[]>,
]);
setData(roles);
setAllPerms(permTree);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleAdd = () => {
setEditing(null);
form.resetFields();
setSelectedPermIds([]);
setModalOpen(true);
};
const handleEdit = (record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map(p => p.id));
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rbac/roles/${editing.id}`, { name: values.name, description: values.description, permissionIds: selectedPermIds });
message.success('角色更新成功');
} else {
await api.post('/rbac/roles', { name: values.name, description: values.description, permissionIds: selectedPermIds });
message.success('角色创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) { message.error(e.message || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/rbac/roles/${id}`);
message.success('角色已删除');
fetchData();
} catch (e: any) { message.error(e.message || '删除失败'); }
};
const groupNames: Record<string, string> = {
dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理',
expense: '费用管理', bill: '账单管理', deposit: '押金管理',
classroom: '教室管理', tenant: '租赁方', rental: '租赁订单',
log: '操作日志', user: '用户管理', role: '角色管理',
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '权限标签', dataIndex: 'permissions', width: 150, ellipsis: true,
render: (perms: PermissionItem[]) => perms?.length > 0
? <Tag color="blue">{perms.length} 个权限</Tag>
: <Tag color="default">无权限</Tag>,
},
{
title: '系统', dataIndex: 'isSystem', width: 70,
render: (v: boolean) => v ? <Tag color="orange">系统</Tag> : null,
},
{
title: '操作', width: 160, fixed: 'right' as const,
render: (_: any, record: RoleItem) => (
<Space>
<PermissionButton permission="role:edit" type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</PermissionButton>
{!record.isSystem && (
<PermissionButton permission="role:delete">
<Popconfirm title="确认删除该角色?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</PermissionButton>
)}
</Space>
),
},
];
const handleGroupCheckAll = (group: string, checked: boolean) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
if (checked) {
setSelectedPermIds(prev => [...new Set([...prev, ...groupPermIds])]);
} else {
setSelectedPermIds(prev => prev.filter(id => !groupPermIds.includes(id)));
}
};
const isGroupAllChecked = (group: string) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
return groupPermIds.length > 0 && groupPermIds.every(id => selectedPermIds.includes(id));
};
const isGroupIndeterminate = (group: string) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
const checkedCount = groupPermIds.filter(id => selectedPermIds.includes(id)).length;
return checkedCount > 0 && checkedCount < groupPermIds.length;
};
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>角色管理</h2>
<PermissionButton permission="role:create" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
新增角色
</PermissionButton>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 800 }} pagination={false} />
<Modal
title={editing ? '编辑角色' : '新增角色'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input disabled={editing?.isSystem} />
</Form.Item>
<Form.Item name="description" label="角色描述">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="权限分配">
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{allPerms.map(group => (
<Card
key={group.group}
size="small"
title={
<Checkbox
checked={isGroupAllChecked(group.group)}
indeterminate={isGroupIndeterminate(group.group)}
onChange={e => handleGroupCheckAll(group.group, e.target.checked)}
>
{groupNames[group.group] || group.group}
</Checkbox>
}
style={{ marginBottom: 8 }}
>
<Checkbox.Group
value={selectedPermIds}
onChange={vals => setSelectedPermIds(vals as number[])}
>
<Space wrap>
{group.permissions.map(p => (
<Checkbox key={p.id} value={p.id}>{p.name}</Checkbox>
))}
</Space>
</Checkbox.Group>
</Card>
))}
</div>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;
```
- [x] **Step 2: 创建权限一览页面**
```tsx
// frontend/src/pages/Permissions/index.tsx
import React, { useEffect, useState } from 'react';
import { Card, Tag, Input, Space, Spin } from 'antd';
import api from '../../api';
interface PermissionItem {
id: number;
code: string;
name: string;
group: string;
description: string;
}
const PermissionsPage: React.FC = () => {
const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const groupNames: Record<string, string> = {
dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理',
expense: '费用管理', bill: '账单管理', deposit: '押金管理',
classroom: '教室管理', tenant: '租赁方', rental: '租赁订单',
log: '操作日志', user: '用户管理', role: '角色管理',
};
useEffect(() => {
setLoading(true);
api.get('/rbac/permissions/tree')
.then((res: any) => setPermTree(res))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const filteredTree = search
? permTree.map(g => ({
...g,
permissions: g.permissions.filter(p =>
p.name.includes(search) || p.code.includes(search)
),
})).filter(g => g.permissions.length > 0)
: permTree;
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>权限一览</h2>
<Input.Search
placeholder="搜索权限名称或编码"
allowClear
style={{ width: 280 }}
onSearch={setSearch}
onChange={e => !e.target.value && setSearch('')}
/>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
{filteredTree.map(group => (
<Card
key={group.group}
title={<span style={{ fontWeight: 600 }}>{groupNames[group.group] || group.group} ({group.permissions.length})</span>}
size="small"
>
<Space wrap>
{group.permissions.map(p => (
<Tag key={p.id} color="blue" style={{ marginBottom: 8 }}>
{p.name} <Tag color="geekblue" style={{ marginLeft: 4 }}>{p.code}</Tag>
</Tag>
))}
</Space>
</Card>
))}
</Space>
</div>
);
};
export default PermissionsPage;
```
- [x] **Step 3: 编译验证**
```bash
cd frontend && npx tsc -b --noEmit
```
预期:无新增类型错误。
- [x] **Step 4: Commit**
```bash
git add frontend/src/pages/Roles/ frontend/src/pages/Permissions/
git commit -m "feat(frontend): add Roles management page and Permissions overview page"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 10: 前端路由、菜单和用户管理页面适配
**Files:**
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/layouts/MainLayout.tsx`
- Modify: `frontend/src/pages/Users/index.tsx`
- Modify: 各业务页面中需要权限控制的按钮(约 11 个页面)
**Interfaces:**
- Consumes: `usePermission`, `PermissionRoute`, `PermissionButton` (Task 8), `/rbac/users` API (Task 5)
- [x] **Step 1: 修改 App.tsx 添加路由和权限包装**
```tsx
// frontend/src/App.tsx
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import LoginPage from './pages/Login';
import DashboardPage from './pages/Dashboard';
import StudentsPage from './pages/Students';
import RoomsPage from './pages/Rooms';
import OccupanciesPage from './pages/Occupancies';
import ExpensesPage from './pages/Expenses';
import BillsPage from './pages/Bills';
import RoomVisualPage from './pages/RoomVisual';
import OperationLogsPage from './pages/OperationLogs';
import UsersPage from './pages/Users';
import DepositsPage from './pages/Deposits';
import ClassroomsPage from './pages/Classrooms';
import TenantsPage from './pages/Tenants';
import ClassroomRentalsPage from './pages/ClassroomRentals';
import ClassroomSchedulePage from './pages/ClassroomSchedule';
import RolesPage from './pages/Roles';
import PermissionsPage from './pages/Permissions';
import PermissionRoute from './components/PermissionRoute';
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token');
return token ? <>{children}</> : <Navigate to="/login" />;
};
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#007AFF', borderRadius: 10, colorBgContainer: '#fff', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif" } }}>
<AntdApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route index element={<Navigate to="/dashboard" />} />
<Route path="dashboard" element={<PermissionRoute permission="dashboard:view"><DashboardPage /></PermissionRoute>} />
<Route path="room-visual" element={<PermissionRoute permission="room:view"><RoomVisualPage /></PermissionRoute>} />
<Route path="students" element={<PermissionRoute permission="student:view"><StudentsPage /></PermissionRoute>} />
<Route path="rooms" element={<PermissionRoute permission="room:view"><RoomsPage /></PermissionRoute>} />
<Route path="occupancies" element={<PermissionRoute permission="occupancy:view"><OccupanciesPage /></PermissionRoute>} />
<Route path="expenses" element={<PermissionRoute permission="expense:view"><ExpensesPage /></PermissionRoute>} />
<Route path="deposits" element={<PermissionRoute permission="deposit:view"><DepositsPage /></PermissionRoute>} />
<Route path="bills" element={<PermissionRoute permission="bill:view"><BillsPage /></PermissionRoute>} />
<Route path="operation-logs" element={<PermissionRoute permission="log:view"><OperationLogsPage /></PermissionRoute>} />
<Route path="roles" element={<PermissionRoute permission="role:view"><RolesPage /></PermissionRoute>} />
<Route path="permissions" element={<PermissionRoute permission="role:view"><PermissionsPage /></PermissionRoute>} />
<Route path="users" element={<PermissionRoute permission="user:view"><UsersPage /></PermissionRoute>} />
<Route path="classrooms" element={<PermissionRoute permission="classroom:view"><ClassroomsPage /></PermissionRoute>} />
<Route path="tenants" element={<PermissionRoute permission="tenant:view"><TenantsPage /></PermissionRoute>} />
<Route path="classroom-rentals" element={<PermissionRoute permission="rental:view"><ClassroomRentalsPage /></PermissionRoute>} />
<Route path="classroom-schedule" element={<PermissionRoute permission="classroom:view"><ClassroomSchedulePage /></PermissionRoute>} />
</Route>
</Routes>
</BrowserRouter>
</AntdApp>
</ConfigProvider>
);
};
export default App;
```
- [x] **Step 2: 修改 MainLayout 菜单过滤**
```tsx
// frontend/src/layouts/MainLayout.tsx
// 核心变更:
// 1. 导入 usePermission hook
// 2. 为每个菜单项添加 permission 字段
// 3. 使用 permissions 数组过滤(替代原来的 role === 'admin' 和 allowedMenus 逻辑)
// 4. 添加角色管理和权限一览菜单项
// 5. 退出登录时清除 permissions
import React, { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Dropdown, Drawer } from 'antd';
import {
DashboardOutlined, TeamOutlined, HomeOutlined, SwapOutlined,
DollarOutlined, FileTextOutlined, LogoutOutlined, UserOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, AppstoreOutlined,
AuditOutlined, SettingOutlined, WalletOutlined, ReadOutlined,
TagsOutlined, FileProtectOutlined, CalendarOutlined,
SafetyOutlined, KeyOutlined,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
const { Header, Sider, Content } = Layout;
interface MenuItemType {
key: string;
icon: React.ReactNode;
label: string;
permission?: string;
children?: MenuItemType[];
}
const allMenuItems: MenuItemType[] = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据面板', permission: 'dashboard:view' },
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
{
key: 'classroom-group',
icon: <ReadOutlined />,
label: '教室管理',
permission: 'classroom:view',
children: [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
];
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [drawerOpen, setDrawerOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const user = JSON.parse(localStorage.getItem('user') || '{}');
const { hasPermission } = usePermission();
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 按 permission 过滤菜单
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
return items
.map(item => {
if (item.children) {
const kids = filterByPermission(item.children);
if (kids.length === 0) return null;
return { ...item, children: kids };
}
if (!item.permission) return item;
return hasPermission(item.permission) ? item : null;
})
.filter(Boolean) as MenuItemType[];
};
const menuItems = filterByPermission(allMenuItems);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
navigate('/login');
};
const handleMenuClick = (key: string) => {
navigate(key);
if (isMobile) setDrawerOpen(false);
};
const transformToMenuItems = (items: MenuItemType[]): any[] => {
return items.map(item => ({
key: item.key,
icon: item.icon,
label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined,
}));
};
const menuContent = (
<Menu
theme="light"
mode="inline"
selectedKeys={[location.pathname]}
items={transformToMenuItems(menuItems)}
onClick={({ key }) => handleMenuClick(key)}
style={{ border: 'none' }}
/>
);
return (
<Layout style={{ minHeight: '100vh' }}>
{!isMobile && (
<Sider trigger={null} collapsible collapsed={collapsed} theme="light" style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}>
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#1d1d1f', fontSize: collapsed ? 16 : 17, fontWeight: 600, borderBottom: '1px solid #e5e5e7' }}>
{collapsed ? '恭' : '恭学教育基地'}
</div>
{menuContent}
</Sider>
)}
{isMobile && (
<Drawer placement="left" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={240} styles={{ body: { padding: 0 } }} title="恭学教育基地">
{menuContent}
</Drawer>
)}
<Layout style={{ background: '#f5f5f7' }}>
<Header style={{ padding: '0 16px', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5e5e7', boxShadow: 'none' }}>
<Button
type="text"
icon={isMobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
onClick={() => isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed)}
/>
<Dropdown menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar icon={<UserOutlined />} />
<span>{user.name || user.username || '用户'}</span>
</div>
</Dropdown>
</Header>
<Content style={{ margin: isMobile ? 12 : 24, padding: isMobile ? 12 : 24, background: '#fff', borderRadius: 12, overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;
```
- [x] **Step 3: 重构用户管理页面**
`frontend/src/pages/Users/index.tsx` 中:
1. API 端点从 `/auth/users` 改为 `/rbac/users`,创建用户从 `/auth/register` 改为 `/rbac/users POST`
2. 角色列从单一 Tagadmin/operator改为多角色 Tag 列表
3. 编辑弹窗角色从 Select 单选改为 Select mode="multiple"
4. 移除 `allowedMenus` 相关代码MENU_OPTIONS 常量、Checkbox.Group
5. 导入并加载可选角色列表(从 `/rbac/roles`
```tsx
// frontend/src/pages/Users/index.tsx
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const UsersPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [resetTarget, setResetTarget] = useState<any>(null);
const [form] = Form.useForm();
const [pwdForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const [users, rolesRes] = await Promise.all([
api.get('/rbac/users') as Promise<any[]>,
api.get('/rbac/roles') as Promise<any[]>,
]);
setData(users);
setRoles(rolesRes);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleAdd = () => {
setEditing(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
isActive: record.isActive,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rbac/users/${editing.id}`, { username: values.username, name: values.name, isActive: values.isActive, roleIds: values.roleIds || [] });
message.success('更新成功');
} else {
await api.post('/rbac/users', { username: values.username, password: values.password, name: values.name, roleIds: values.roleIds || [] });
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) { message.error(e.message || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/rbac/users/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) { message.error(e.message || '删除失败'); }
};
const handleResetPwd = (record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
};
const handlePwdSubmit = async () => {
const values = await pwdForm.validateFields();
try {
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
message.success('密码已重置');
setPwdModalOpen(false);
} catch (e: any) { message.error(e.message || '操作失败'); }
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', width: 120 },
{
title: '角色', dataIndex: 'roles', width: 200,
render: (v: any[]) => v && v.length > 0
? v.map(r => <Tag key={r.id} color="blue">{r.name}</Tag>)
: <Tag color="default">无角色</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '最后登录', dataIndex: 'lastLoginAt', width: 170,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '创建时间', dataIndex: 'createdAt', width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作', width: 220, fixed: 'right' as const,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="user:edit" type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</PermissionButton>
<PermissionButton permission="user:reset-password" type="link" size="small" icon={<KeyOutlined />} onClick={() => handleResetPwd(record)}>重置密码</PermissionButton>
{record.username !== 'admin' && (
<PermissionButton permission="user:delete">
<Popconfirm title="确认删除该用户?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</PermissionButton>
)}
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>账号管理</h2>
<PermissionButton permission="user:create" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增账号</PermissionButton>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 1000 }} pagination={false} />
<Modal title={editing ? '编辑账号' : '新增账号'} open={modalOpen} onOk={handleSubmit} onCancel={() => setModalOpen(false)} destroyOnClose>
<Form form={form} layout="vertical">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input />
</Form.Item>
{!editing && (
<Form.Item name="password" label="密码" rules={[{ required: true, min: 4, message: '密码至少4位' }]}>
<Input.Password />
</Form.Item>
)}
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
{editing && (
<Form.Item name="isActive" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
)}
<Form.Item name="roleIds" label="角色分配" rules={[{ required: !editing, message: '请至少选择一个角色' }]}>
<Select
mode="multiple"
placeholder="选择角色"
options={roles.filter(r => r.status !== 0).map(r => ({ value: r.id, label: `${r.name}${r.isSystem ? ' (系统)' : ''}` }))}
/>
</Form.Item>
</Form>
</Modal>
<Modal title={`重置密码 - ${resetTarget?.username}`} open={pwdModalOpen} onOk={handlePwdSubmit} onCancel={() => setPwdModalOpen(false)} destroyOnClose>
<Form form={pwdForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 4, message: '密码至少4位' }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UsersPage;
```
- [x] **Step 4: 各业务页面关键按钮添加 PermissionButton 包装**
为以下页面的操作按钮添加 `PermissionButton` 包装:
| 页面 | 按钮 | 所需权限 |
|------|------|----------|
| Students | 新增/导入 | `student:create` / `student:import` |
| Students | 编辑/删除/导出 | `student:edit` / `student:delete` / `student:export` |
| Rooms | 新增/导入 | `room:create` |
| Rooms | 编辑/删除/导出 | `room:edit` / `room:delete` / `room:view` |
| Occupancies | 办理入住/退宿/调换 | `occupancy:checkin` / `occupancy:checkout` / `occupancy:transfer` |
| Expenses | 新增 | `expense:create` |
| Expenses | 编辑/删除 | `expense:edit` / `expense:delete` |
| Deposits | 新增 | `deposit:create` |
| Deposits | 编辑/删除 | `deposit:edit` / `deposit:delete` |
| Bills | 生成/确认/导出 | `bill:generate` / `bill:confirm` / `bill:export-excel` / `bill:export-pdf` |
| Bills | 删除 | `bill:delete` |
| Classrooms | 新增/编辑/删除 | `classroom:create` / `classroom:edit` / `classroom:delete` |
| Tenants | 新增/编辑/删除 | `tenant:create` / `tenant:edit` / `tenant:delete` |
| ClassroomRentals | 新增/编辑/删除 | `rental:create` / `rental:edit` / `rental:delete` |
每个页面的修改模式:导入 PermissionButton`<Button>` 替换为 `<PermissionButton permission="...">`。以 Students 页面为例:
```tsx
import PermissionButton from '../../components/PermissionButton';
// 新增按钮
<PermissionButton permission="student:create" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
新增学生
</PermissionButton>
// 导入按钮
<PermissionButton permission="student:import" icon={<UploadOutlined />} onClick={...}>
导入
</PermissionButton>
// 删除按钮
<PermissionButton permission="student:delete">
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</PermissionButton>
```
- [x] **Step 5: 编译和启动验证**
```bash
cd frontend && npx tsc -b --noEmit
```
预期:无新增类型错误(可忽略 Ant Design 6 与 React 19 的已知类型兼容警告)。
```bash
cd frontend && npm run dev
```
预期:前端正常启动,菜单按权限过滤,无权限页面显示 403。
- [x] **Step 6: Commit**
```bash
git add frontend/src/App.tsx frontend/src/layouts/MainLayout.tsx frontend/src/pages/Users/ frontend/src/pages/Students/ frontend/src/pages/Rooms/ frontend/src/pages/Occupancies/ frontend/src/pages/Expenses/ frontend/src/pages/Deposits/ frontend/src/pages/Bills/ frontend/src/pages/Classrooms/ frontend/src/pages/Tenants/ frontend/src/pages/ClassroomRentals/
git commit -m "feat(frontend): adapt routes, menus, Users page, and business pages for RBAC"
```
archived-with: 2026-07-03-rbac-refactor
---
### Task 11: 验证与收尾
**Files:**
- Modify: `backend/test/app.e2e-spec.ts` (可选:更新 e2e 测试)
**任务**:端到端验证 RBAC 系统正确运行。
- [x] **Step 1: 启动后端验证种子数据**
```bash
cd backend && npm run start:dev
```
验证日志输出:
```
已创建默认管理员: admin / admin123 (请尽快修改!)
种子数据初始化完成: 42 权限点, 4 角色
```
- [x] **Step 2: 验证登录流程**
```bash
# 测试登录admin/admin123
curl -s -X POST http://localhost:3002/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' | python3 -m json.tool
```
预期响应包含:
- `access_token` 字符串
- `user.permissions` 数组包含 42 个权限码
- `user.roles` 数组包含 `["超管"]`
- [x] **Step 3: 验证超管全通链路**
用步骤 2 获取的 token
```bash
TOKEN="<access_token>"
# 验证 GET /api/students 返回 200
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3002/api/students | python3 -m json.tool | head -5
# 验证 GET /api/rbac/roles 返回 200 且有数据
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3002/api/rbac/roles | python3 -m json.tool | head -10
```
预期:两个接口均返回 200 且包含数据。
- [x] **Step 4: 验证无权限拦截**
```bash
# 不带 token 访问受保护接口
curl -s http://localhost:3002/api/students | python3 -m json.tool
```
预期:返回 403或 NestJS 的 Forbidden 响应)。
```bash
# 带无效 token 访问
curl -s -H "Authorization: Bearer invalidtoken" http://localhost:3002/api/students
```
预期:返回 401。
- [x] **Step 5: 验证宿管老师受限链路**
创建宿管老师角色用户,验证其只能访问学生模块而不能访问角色管理:
```bash
# 使用超管 token 创建测试用户
TOKEN="<admin_token>"
curl -s -X POST http://localhost:3002/api/rbac/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"test_dorm","password":"123456","name":"测试宿管","roleIds":[2]}'
# 用测试用户登录
TEST_TOKEN=$(curl -s -X POST http://localhost:3002/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test_dorm","password":"123456"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 验证可访问学生管理
curl -s -H "Authorization: Bearer $TEST_TOKEN" http://localhost:3002/api/students | python3 -c "import sys; print('students OK' if len(sys.stdin.read()) > 10 else 'FAIL')"
# 验证角色管理被拒绝
curl -s -H "Authorization: Bearer $TEST_TOKEN" http://localhost:3002/api/rbac/roles | python3 -c "import sys; print('roles BLOCKED' if 'Forbidden' in sys.stdin.read() or '403' in sys.stdin.read() else 'FAIL')"
```
预期students OK + roles BLOCKED。
- [x] **Step 6: 验证前端**
```bash
cd frontend && npm run dev
```
- 打开浏览器访问 login 页面
- 用 admin/admin123 登录,确认:
- 菜单显示全部项(包括角色管理、权限一览)
- 数据面板、学生管理、角色管理均可正常访问
- 操作按钮(新增、编辑、删除)均可见
- 用测试宿管用户登录,确认:
- 学生管理可见,角色管理不可见
- 学生页面的新增/导入按钮可见
- 直接访问 `/roles` URL 显示 403 页面
- [x] **Step 7: 后端 lint 通过**
```bash
cd backend && npm run lint
```
预期:无 lint 错误。如有格式问题,运行 `npm run format` 修复。
- [x] **Step 8: 提交验证结果**
在验证全部通过后提交所有变更:
```bash
git add -A
git commit -m "chore(rbac): final verification and lint fixes"
```
archived-with: 2026-07-03-rbac-refactor
---
## 自检
### 1. 设计文档覆盖
| 设计文档章节 | 对应任务 |
|-------------|---------|
| 2. 数据模型 | Task 1 (实体定义), Task 2 (种子数据) |
| 3. 后端模块设计 | Task 2 (RbacModule/Service), Task 4 (Auth 改造), Task 5 (Controller) |
| 4. 权限守卫设计 | Task 3 (装饰器 + PermissionGuard) |
| 5. JWT 变更 | Task 4 (JwtStrategy + AuthService.login) |
| 6. 前端架构 | Task 8 (hooks/components), Task 9 (Roles/Permissions pages), Task 10 (路由/菜单/Users 适配) |
| 7. 迁移策略 | Task 1 (实体变更), Task 6 (AppModule synchronize) |
| 8. 种子数据初始化 | Task 2 (RbacService.seedData) |
| 9. 测试策略 | Task 11 (验证步骤 1-8) |
| 10. 风险与缓解 | 全局约束 (synchronize: true 本地开发) |
| 11. 后续扩展预留 | 实体设计 (Permission.code 保持 module:action 格式) |
### 2. 占位符扫描
- 无 TBD、TODO、implement later 字样
- 所有步骤均包含具体代码、命令和预期输出
- 所有文件路径均为项目实际路径
### 3. 类型一致性
- `getUserPermissions(userId: number): Promise<string[]>` — Task 2 定义Task 4 AuthService.login() 调用Task 3 PermissionGuard 从 req.user.permissions 消费
- `findAllRoles()` → 返回 `Role[]`(含 `permissions: Permission[]` 关联)— Task 2 定义Task 5 Controller 调用Task 9 前端 Roles 页面消费
- DTO 名称:`CreateRoleDto`, `UpdateRoleDto`, `CreateUserDto`, `UpdateUserDto`, `ResetPasswordDto` — Task 5 定义和使用
- @RequirePermission 装饰器签名:`(...permissions: string[])` — Task 3 定义Task 5/7 使用
- PermissionGuard 从 req.user.permissions 读取 — Task 4 JwtStrategy 设置Task 3 消费