feat: replace tenants with organization management

This commit is contained in:
2026-07-10 21:27:26 +08:00
parent 8ed1682b90
commit 8f0991a51f
49 changed files with 1292 additions and 698 deletions

View File

@@ -0,0 +1,62 @@
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
export class CreateOrganizationDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@Matches(/^[A-Z0-9_-]+$/)
code: string;
@IsOptional()
@IsBoolean()
isHost?: boolean;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9_-]+$/)
code?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(['active', 'archived'])
status?: 'active' | 'archived';
}

View File

@@ -0,0 +1,98 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('organizations')
export class OrganizationsController {
constructor(
private service: OrganizationsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('organization:view')
findAll(
@Query('includeArchived') includeArchived?: string,
@Query('scope') scope?: 'all' | 'host' | 'external',
) {
return this.service.findAll({ includeArchived: includeArchived === 'true', scope });
}
@Get(':id')
@RequirePermission('organization:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post()
@RequirePermission('organization:create')
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '机构管理',
action: '新增机构',
targetId: result.id,
targetType: 'organization',
detail: dto.name,
ipAddress,
userAgent,
});
return result;
}
@Put(':id')
@RequirePermission('organization:edit')
async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '机构管理',
action: '编辑机构',
targetId: +id,
targetType: 'organization',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('organization:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '机构管理',
action: '归档机构',
targetId: +id,
targetType: 'organization',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -0,0 +1,30 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from '../entities/organization.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],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],
})
export class OrganizationsModule implements OnModuleInit {
constructor(private readonly service: OrganizationsService) {}
async onModuleInit() {
try {
await this.service.getHostOrganization();
} catch {
await this.service.create({
name: process.env.HOST_ORGANIZATION_NAME || '本机构',
code: process.env.HOST_ORGANIZATION_CODE || 'HOST',
isHost: true,
color: '#1677ff',
notes: '系统默认运营主体',
});
}
}
}

View File

@@ -0,0 +1,30 @@
import { BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { OrganizationsService } from './organizations.service';
import { Organization } from '../entities/organization.entity';
describe('OrganizationsService — host organization rules', () => {
let repo: jest.Mocked<
Pick<Repository<Organization>, 'findOne' | 'find' | 'count' | 'create' | 'save' | 'update'>
>;
let service: OrganizationsService;
beforeEach(() => {
repo = {
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
} as any;
service = new OrganizationsService(repo as Repository<Organization>);
});
it('does not allow the host organization to be archived', async () => {
repo.findOne.mockResolvedValue({ id: 1, name: '本机构', isHost: true } as Organization);
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { Organization } from '../entities/organization.entity';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class OrganizationsService {
constructor(@InjectRepository(Organization) private repo: Repository<Organization>) {}
async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) {
const where: Record<string, unknown> = {};
if (!query?.includeArchived) where.status = Not('archived');
if (query?.scope === 'host') where.isHost = true;
if (query?.scope === 'external') where.isHost = false;
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
}
async findOne(id: number) {
const organization = await this.repo.findOne({ where: { id } });
if (!organization) throw new NotFoundException('机构不存在');
return organization;
}
async getHostOrganization() {
const organization = await this.repo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization) throw new NotFoundException('尚未配置本机构');
return organization;
}
async create(dto: CreateOrganizationDto) {
if (dto.isHost) {
const existingHost = await this.repo.findOne({ where: { isHost: true } });
if (existingHost) throw new BadRequestException('本机构已存在,只能配置一个本机构');
}
const color = dto.color || COLOR_PALETTE[(await this.repo.count()) % COLOR_PALETTE.length];
return this.repo.save(
this.repo.create({
...dto,
code: dto.code.trim().toUpperCase(),
publicId: uuidV7(),
color,
isHost: dto.isHost ?? false,
status: 'active',
}),
);
}
async update(id: number, dto: UpdateOrganizationDto) {
const organization = await this.findOne(id);
if (organization.isHost && dto.status === 'archived') {
throw new BadRequestException('本机构不能归档');
}
await this.repo.update(id, {
...dto,
...(dto.code ? { code: dto.code.trim().toUpperCase() } : {}),
});
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const organization = await this.findOne(id);
if (organization.isHost) throw new BadRequestException('本机构不能归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
}