feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
78
apps/server/src/organizations/organizations.purge.spec.ts
Normal file
78
apps/server/src/organizations/organizations.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: '已永久删除机构(不可恢复)' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user