feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -21,3 +21,25 @@ describe('OrganizationsController permissions', () => {
]);
});
});
describe('OrganizationsController', () => {
it('requires organization:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.purge)).toEqual([
'organization:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除机构(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new OrganizationsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '机构管理', action: '永久删除机构', targetId: 1 }),
);
});
});

View File

@@ -101,4 +101,23 @@ export class OrganizationsController {
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('organization:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.purge(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '机构管理',
action: '永久删除机构',
targetId: +id,
targetType: 'organization',
detail: '物理删除,不可恢复',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -1,12 +1,18 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from '../entities/organization.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([Organization, Student, Occupancy, ClassroomRental]),
OperationLogsModule,
],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],

View File

@@ -0,0 +1,78 @@
import { BadRequestException } from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
describe('OrganizationsService.purge', () => {
const createService = (overrides?: {
organization?: Record<string, unknown>;
studentCount?: number;
occupancyCount?: number;
lessorCount?: number;
lesseeCount?: number;
}) => {
const organization = {
id: 1,
name: '合作机构',
status: 'archived',
isHost: false,
...overrides?.organization,
};
const repo = {
findOne: jest.fn().mockResolvedValue(organization),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const studentRepo = { count: jest.fn().mockResolvedValue(overrides?.studentCount ?? 0) };
const occupancyRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) };
const rentalRepo = {
count: jest.fn().mockResolvedValue(overrides?.lessorCount ?? 0),
};
rentalRepo.count.mockResolvedValueOnce(overrides?.lessorCount ?? 0);
rentalRepo.count.mockResolvedValueOnce(overrides?.lesseeCount ?? 0);
const service = new OrganizationsService(
repo as never,
studentRepo as never,
occupancyRepo as never,
rentalRepo as never,
);
return { service, repo, studentRepo, occupancyRepo, rentalRepo };
};
it('rejects organizations that are not archived or are the host', async () => {
const notArchived = createService({ organization: { status: 'active' } });
await expect(notArchived.service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档机构可以永久删除,请先归档'),
);
const host = createService({ organization: { isHost: true } });
await expect(host.service.purge(1)).rejects.toThrow(
new BadRequestException('本机构不能永久删除'),
);
expect(notArchived.repo.delete).not.toHaveBeenCalled();
expect(host.repo.delete).not.toHaveBeenCalled();
});
it('rejects organizations with student, occupancy, or rental references', async () => {
const withStudents = createService({ studentCount: 1 });
await expect(withStudents.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(学生归属),无法永久删除'),
);
const withOccupancy = createService({ occupancyCount: 1 });
await expect(withOccupancy.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(入住责任机构),无法永久删除'),
);
const withLessee = createService({ lesseeCount: 1 });
await expect(withLessee.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(承租租赁订单),无法永久删除'),
);
expect(withLessee.repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived organization with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除机构(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { Organization } from '../entities/organization.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
const COLOR_PALETTE = [
@@ -20,7 +23,12 @@ const COLOR_PALETTE = [
@Injectable()
export class OrganizationsService {
constructor(@InjectRepository(Organization) private repo: Repository<Organization>) {}
constructor(
@InjectRepository(Organization) private repo: Repository<Organization>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Occupancy) private occupancyRepo: Repository<Occupancy>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
) {}
async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) {
const where: Record<string, unknown> = {};
@@ -86,4 +94,28 @@ export class OrganizationsService {
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async purge(id: number) {
const organization = await this.findOne(id);
if (organization.status !== 'archived') {
throw new BadRequestException('仅已归档机构可以永久删除,请先归档');
}
if (organization.isHost) throw new BadRequestException('本机构不能永久删除');
const [studentCount, occupancyCount, lessorCount, lesseeCount] = await Promise.all([
this.studentRepo.count({ where: { organizationId: id } }),
this.occupancyRepo.count({ where: { responsibleOrganizationId: id } }),
this.rentalRepo.count({ where: { lessorOrganizationId: id } }),
this.rentalRepo.count({ where: { lesseeOrganizationId: id } }),
]);
const references: string[] = [];
if (studentCount > 0) references.push('学生归属');
if (occupancyCount > 0) references.push('入住责任机构');
if (lessorCount > 0) references.push('出租租赁订单');
if (lesseeCount > 0) references.push('承租租赁订单');
if (references.length > 0) {
throw new BadRequestException(`该机构存在关联数据(${references.join('、')}),无法永久删除`);
}
await this.repo.delete(id);
return { message: '已永久删除机构(不可恢复)' };
}
}