2730 lines
102 KiB
Markdown
2730 lines
102 KiB
Markdown
---
|
||
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 hook(hasPermission, 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 增加 RbacModule;providers 增加 PermissionGuard 为 APP_GUARD;entities 列表增加 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. 角色列从单一 Tag(admin/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 消费
|