Merge pull request '修复管理员班级可见范围、 修复班级归档查询参数转换、 移除账号启用状态统一使用归档' (#48) from xiongyuxing/gongxue-base:main into main

Reviewed-on: wangziqi/gongxue-base#48
This commit is contained in:
2026-07-24 06:32:55 +00:00
16 changed files with 221 additions and 74 deletions

View File

@@ -10,7 +10,6 @@ const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandida
id: 1,
username: 'teacher',
name: '测试老师',
isActive: true,
isArchived: false,
studentStatus: null,
roles: [{ code: 'teacher', name: '任课老师' }],
@@ -29,13 +28,12 @@ describe('class teacher candidates', () => {
expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true);
});
it('excludes active students, disabled, archived, and super-admin accounts', () => {
it('excludes active students, archived, and super-admin accounts', () => {
const users = [
baseUser({ id: 1, studentStatus: 'active' }),
baseUser({ id: 2, isActive: false }),
baseUser({ id: 3, isArchived: true }),
baseUser({ id: 2, isArchived: true }),
baseUser({
id: 4,
id: 3,
roles: [{ code: 'super_admin', name: '超级管理员' }],
}),
];

View File

@@ -7,7 +7,6 @@ export interface TeacherCandidateUser {
id: number;
username: string;
name?: string | null;
isActive: boolean;
isArchived: boolean;
studentStatus?: string | null;
roles?: TeacherCandidateRole[];
@@ -18,7 +17,7 @@ const isSuperAdminRole = (role: TeacherCandidateRole) =>
role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';
export const isTeacherCandidate = (user: TeacherCandidateUser) => {
if (!user.isActive || user.isArchived) return false;
if (user.isArchived) return false;
if (user.studentStatus && user.studentStatus !== 'staff') return false;
return !(user.roles || []).some(isSuperAdminRole);
};

View File

@@ -12,7 +12,6 @@ interface TeacherRow {
id: number;
username: string;
name: string;
isActive: boolean;
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
lastLoginAt: string;
roles: { code: string; name: string }[];
@@ -175,13 +174,6 @@ const TeachersPage: React.FC = () => {
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'isActive',
key: 'status',
width: 90,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '在职' : '停用'}</Tag>,
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',

View File

@@ -87,7 +87,6 @@ const UsersPage: React.FC = () => {
form.setFieldsValue({
username: record.username,
name: record.name,
isActive: record.isActive,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
@@ -101,7 +100,6 @@ const UsersPage: React.FC = () => {
await api.put(`/rbac/users/${editing.id}`, {
username: values.username,
name: values.name,
isActive: values.isActive,
roleIds: values.roleIds || [],
});
message.success('更新成功');
@@ -223,26 +221,6 @@ const UsersPage: React.FC = () => {
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean, r: any) => (
<EditableCell
value={String(v)}
editor="select"
options={[
{ value: 'true', label: '启用' },
{ value: 'false', label: '禁用' },
]}
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
>
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
</EditableCell>
),
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
@@ -350,7 +328,7 @@ const UsersPage: React.FC = () => {
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1250 }}
scroll={{ x: 1150 }}
pagination={false}
/>
@@ -382,11 +360,6 @@ const UsersPage: React.FC = () => {
<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="角色分配"

View File

@@ -47,4 +47,28 @@ describe('AuthService — authentication boundaries', () => {
).rejects.toThrow('账号已失效');
expect(userRepo.save).not.toHaveBeenCalled();
});
it('allows a legacy disabled user because archive is the only account status', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 3,
username: 'legacy-disabled',
name: '旧账号',
passwordHash: await bcrypt.hash('secret', 4),
isActive: false,
isArchived: false,
roles: [],
}),
save: jest.fn(),
};
const service = new AuthService(
userRepo as never,
{ sign: jest.fn().mockReturnValue('token') } as never,
{ getUserPermissions: jest.fn().mockResolvedValue([]) } as never,
);
await expect(
service.login({ username: 'legacy-disabled', password: 'secret' }, '192.0.2.11'),
).resolves.toEqual(expect.objectContaining({ access_token: 'token' }));
});
});

View File

@@ -38,7 +38,7 @@ export class AuthService {
this.recordFailedAttempt(attemptKey);
throw new UnauthorizedException('用户名或密码错误');
}
if (!user.isActive || user.isArchived) {
if (user.isArchived) {
throw new UnauthorizedException('账号已失效,请联系管理员');
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);

View File

@@ -50,16 +50,32 @@ describe('JwtStrategy', () => {
);
});
it.each([
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
[null],
])('rejects disabled, archived, or deleted users', async (user) => {
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
it.each([[{ id: 7, isArchived: true, roles: [] }], [null]])(
'rejects archived or deleted users',
async (user) => {
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
},
);
it('accepts a legacy disabled user when the account is not archived', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 7,
username: 'teacher',
isActive: false,
isArchived: false,
roles: [],
}),
};
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
UnauthorizedException,
await expect(strategy.validate({ sub: 7, username: 'teacher' })).resolves.toEqual(
expect.objectContaining({ id: 7, username: 'teacher' }),
);
});
});

View File

@@ -37,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
where: { id: payload.sub },
relations: ['roles', 'roles.permissions'],
});
if (!user || !user.isActive || user.isArchived) {
if (!user || user.isArchived) {
throw new UnauthorizedException('账号已失效,请重新登录');
}

View File

@@ -0,0 +1,116 @@
import { ValidationPipe } from '@nestjs/common';
import { ClassesController } from './classes.controller';
import { ClassesService } from './classes.service';
import { QueryClassDto } from './dto/class.dto';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CaslAction, SubjectName } from '../authorization';
describe('ClassesController - class data scope', () => {
const service = {
getAccessibleClassIds: jest.fn(),
assertClassAccess: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
getSchedule: jest.fn(),
getAttendanceSummary: jest.fn(),
getStudents: jest.fn(),
getTeachers: jest.fn(),
};
const authzService = { can: jest.fn() };
const logService = {};
const notificationsService = {};
let controller: ClassesController;
const request = (permissions: string[] = [], isSuperAdmin = false) => ({
user: {
id: 21,
username: 'user',
permissions,
isSuperAdmin,
roles: [],
},
});
beforeEach(() => {
jest.clearAllMocks();
authzService.can.mockImplementation((req, action, subject) => {
if (subject !== SubjectName.Class) return false;
if (req.user.isSuperAdmin && action === CaslAction.Manage) return true;
if (action === CaslAction.Create) return req.user.permissions.includes('class:create');
if (action === CaslAction.Update) return req.user.permissions.includes('class:edit');
return false;
});
service.getAccessibleClassIds.mockResolvedValue(undefined);
service.assertClassAccess.mockResolvedValue(undefined);
service.findAll.mockResolvedValue([]);
service.findOne.mockResolvedValue({ id: 8 });
service.getSchedule.mockResolvedValue([]);
service.getAttendanceSummary.mockResolvedValue({});
service.getStudents.mockResolvedValue([]);
service.getTeachers.mockResolvedValue([]);
controller = new ClassesController(
service as unknown as ClassesService,
logService as unknown as OperationLogsService,
notificationsService as unknown as NotificationsService,
authzService as never,
);
});
it.each([
['class creator', request(['class:view', 'class:create'])],
['class editor', request(['class:view', 'class:edit'])],
['super admin', request([], true)],
])('allows %s to list all classes', async (_label, req) => {
await controller.findAll({}, req);
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, true);
expect(service.findAll).toHaveBeenCalledWith({}, undefined);
});
it('keeps view-only users scoped to their assigned classes', async () => {
service.getAccessibleClassIds.mockResolvedValue([8]);
await controller.findAll({}, request(['class:view']));
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, false);
expect(service.findAll).toHaveBeenCalledWith({}, [8]);
});
it('allows a class creator to read an unassigned class through every detail endpoint', async () => {
const req = request(['class:view', 'class:create']);
await controller.findOne('8', req);
await controller.getSchedule('8', {}, req);
await controller.getAttendanceSummary('8', {}, req);
await controller.getStudents('8', req);
await controller.getTeachers('8', req);
expect(service.assertClassAccess).toHaveBeenCalledTimes(5);
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, true);
});
it('requires a view-only user to be assigned before reading class details', async () => {
await controller.findOne('8', request(['class:view']));
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
});
describe('QueryClassDto - query transformation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });
it.each([
['false', false],
['0', false],
['true', true],
['1', true],
])('transforms isArchived=%s to %s', async (input, expected) => {
await expect(
pipe.transform(
{ isArchived: input },
{ type: 'query', metatype: QueryClassDto, data: undefined },
),
).resolves.toEqual({ isArchived: expected });
});
});

View File

@@ -8,6 +8,8 @@ import {
Param,
Query,
UseGuards,
UsePipes,
ValidationPipe,
Request,
Res,
} from '@nestjs/common';
@@ -45,6 +47,7 @@ const teacherRoleLabels: Record<string, string> = {
};
@UseGuards(JwtAuthGuard)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
@Controller('classes')
export class ClassesController {
constructor(
@@ -54,12 +57,20 @@ export class ClassesController {
private readonly authz: AuthorizationService,
) {}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
const canManageAll =
private canManageAllClasses(req: AuthenticatedRequest): boolean {
return (
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
this.authz.can(req, CaslAction.Create, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
);
}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
return this.service.assertClassAccess(
req.user.id,
classId,
this.canManageAllClasses(req),
);
}
@Get()
@@ -67,8 +78,7 @@ export class ClassesController {
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
this.canManageAllClasses(req),
);
return this.service.findAll(query, classIds);
}

View File

@@ -9,6 +9,7 @@ import {
ArrayNotEmpty,
ValidateNested,
Min,
IsBoolean,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
@@ -160,6 +161,7 @@ export class QueryClassDto {
if (value === 'false' || value === '0') return false;
return value;
})
@IsBoolean()
isArchived?: boolean;
}

View File

@@ -1,7 +1,6 @@
import {
ArrayUnique,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
@@ -70,10 +69,6 @@ export class UpdateUserDto {
@IsString()
name?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsArray()
@ArrayUnique()

View File

@@ -92,4 +92,10 @@ describe('RBAC DTO id arrays', () => {
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
it('strips the retired isActive field from account updates', async () => {
await expect(
pipe.transform({ name: 'Alice', isActive: false }, { type: 'body', metatype: UpdateUserDto }),
).resolves.toEqual({ name: 'Alice' });
});
});

View File

@@ -35,7 +35,12 @@ describe('RbacService seedData', () => {
save: jest.fn(async (value: any) => value),
find: jest.fn(async () => [systemAdminRole]),
};
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
const userRepo = {
update: jest.fn(async () => ({ affected: 0 })),
count: jest.fn(async () => 1),
create: jest.fn(),
save: jest.fn(),
};
const service = new RbacService(
permRepo as never,
@@ -101,7 +106,12 @@ describe('RbacService seedData', () => {
save: jest.fn(async (value) => value),
find: jest.fn(async () => [teacherRole]),
};
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
const userRepo = {
update: jest.fn(async () => ({ affected: 0 })),
count: jest.fn(async () => 1),
create: jest.fn(),
save: jest.fn(),
};
const service = new RbacService(
permRepo as never,
@@ -184,6 +194,7 @@ describe('RbacService legacy role consolidation', () => {
remove: jest.fn(async (value) => value),
};
const userRepo = {
update: jest.fn(async () => ({ affected: 0 })),
count: jest.fn(async () => 1),
findOne: jest.fn(async () => user),
save: jest.fn(async (value) => value),

View File

@@ -296,6 +296,13 @@ export class RbacService {
}
async seedData(): Promise<void> {
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
if (restoredLegacyUsers.affected) {
this.logger.log(
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
);
}
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL
for (const p of PRESET_PERMISSIONS) {
const exists = await this.permRepo.findOne({ where: { code: p.code } });
@@ -568,7 +575,6 @@ export class RbacService {
id: u.id,
username: u.username,
name: u.name,
isActive: u.isActive,
isArchived: u.isArchived,
studentStatus: statusMap.get(u.id) || null,
lastLoginAt: u.lastLoginAt,
@@ -597,7 +603,7 @@ export class RbacService {
async updateUser(
id: number,
dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] },
dto: { username?: string; name?: string; roleIds?: number[] },
) {
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
if (!user) throw new Error('用户不存在');
@@ -607,7 +613,6 @@ export class RbacService {
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.resolveRoles(dto.roleIds) : [];
}
@@ -634,7 +639,7 @@ export class RbacService {
async restoreUser(id: number) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在');
await this.userRepo.update(id, { isArchived: false });
await this.userRepo.update(id, { isArchived: false, isActive: true });
return { message: '用户已恢复' };
}
@@ -766,7 +771,8 @@ export class RbacService {
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
roleCodes: teacherRoleCodes,
roleNames: teacherRoleNames,
});
})
.andWhere('u.isArchived = :isArchived', { isArchived: false });
if (query?.search) {
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
@@ -795,7 +801,6 @@ export class RbacService {
id: u.id,
username: u.username,
name: u.name,
isActive: u.isActive,
profile: u.profile,
lastLoginAt: u.lastLoginAt,
roles: u.roles || [],

View File

@@ -140,7 +140,7 @@ export class StudentsService {
: [];
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
for (const assignment of assignments) {
if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
if (!assignment.user || assignment.user.isArchived) continue;
teacherMap.set(assignment.userId, {
id: assignment.userId,
name: assignment.user.name || assignment.user.username,