fix: align permission-gated UI actions

This commit is contained in:
2026-07-23 11:32:13 +08:00
parent adf738288f
commit c98d37307e
28 changed files with 1340 additions and 718 deletions

View File

@@ -0,0 +1,26 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { OccupanciesController } from './occupancies.controller';
describe('OccupanciesController permissions', () => {
it('requires occupancy:delete for single and batch archive actions', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.remove)).toEqual([
'occupancy:delete',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchRemove),
).toEqual(['occupancy:delete']);
});
it('keeps read-only endpoints on occupancy:view', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.findAll)).toEqual([
'occupancy:view',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.exportExcel),
).toEqual(['occupancy:view']);
expect(
Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate),
).toEqual(['occupancy:view']);
});
});

View File

@@ -159,7 +159,7 @@ export class OccupanciesController {
}
@Delete(':id')
@RequirePermission('occupancy:view')
@RequirePermission('occupancy:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
@@ -177,7 +177,7 @@ export class OccupanciesController {
}
@Post('batch-delete')
@RequirePermission('occupancy:view')
@RequirePermission('occupancy:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);

View File

@@ -33,8 +33,9 @@ export class CreateStudentDto {
@IsString()
emergencyPhone?: string;
@IsOptional()
@IsInt()
organizationId: number;
organizationId?: number;
@IsOptional()
@IsString()

View File

@@ -43,6 +43,21 @@ describe('StudentsService — archive lifecycle boundaries', () => {
expect(repo.save).not.toHaveBeenCalled();
});
it('defaults a new student to the active host organization when none is supplied', async () => {
const repo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 1 })),
};
const organizationRepo = {
findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }),
};
await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual(
expect.objectContaining({ organizationId: 7 }),
);
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 }));
});
it('returns not found for a missing student', async () => {
const repo = { findOne: jest.fn().mockResolvedValue(null) };
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
@@ -50,11 +65,13 @@ describe('StudentsService — archive lifecycle boundaries', () => {
it('builds export archive maps from profile and result rows', async () => {
const profileRepo = {
find: jest.fn().mockResolvedValue([{
studentId: 1,
targetCollege: '北京大学',
collegeSchool: '北京职业技术学院',
}]),
find: jest.fn().mockResolvedValue([
{
studentId: 1,
targetCollege: '北京大学',
collegeSchool: '北京职业技术学院',
},
]),
};
const resultRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),

View File

@@ -164,8 +164,9 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
await this.assertActiveOrganization(dto.organizationId);
return this.repo.save(this.repo.create(dto));
const organizationId = dto.organizationId || (await this.getHostOrganizationId());
await this.assertActiveOrganization(organizationId);
return this.repo.save(this.repo.create({ ...dto, organizationId }));
}
async update(id: number, dto: UpdateStudentDto) {
@@ -314,7 +315,9 @@ export class StudentsService {
};
}
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
private normalizeImportData(
importData: StudentWorkbookImport | StudentImportRow[],
): StudentWorkbookImport {
if (Array.isArray(importData)) {
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
}
@@ -370,18 +373,24 @@ export class StudentsService {
if (!phone) return imported;
const enrollmentByClassName = new Map<string, StudentEnrollment>();
for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
for (const enrollmentRow of data.enrollments.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
if (!enrollment) continue;
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
imported++;
}
for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
for (const examRow of data.examScores.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
for (const learningRow of data.learningRecords.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
imported++;
}
@@ -390,7 +399,9 @@ export class StudentsService {
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
const entity =
(await this.profileRepo.findOne({ where: { studentId } })) ||
this.profileRepo.create({ studentId });
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
@@ -403,9 +414,12 @@ export class StudentsService {
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
const entity =
(await this.resultRepo.findOne({ where: { studentId } })) ||
this.resultRepo.create({ studentId });
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
if (row.professionalFinalScore !== undefined)
entity.professionalFinalScore = row.professionalFinalScore;
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
@@ -623,10 +637,9 @@ export class StudentsService {
// ---- Filters ----
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query?.organizationId) {
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });