Commit 5b0f2f4e by DaiJiezhang

feat: 迁移公司档案与公司人员模块

新增九个接口,只读端点契约比对 3/3 一致,写路径对拍 14/14 一致。

公司档案
- 关键词在公司名、简称、统一社会信用代码、地址、联系人、联系方式六个字段上模糊匹配
- 删除前检查五类资产的引用(公司人员、企微、抖音、域名、商户),
  并把具体引用方拼进提示语。抖音、域名、商户三张表当前无业务模块在写,
  检查仍须保留:这些模块启用后少查一张就会误删仍被引用的档案

公司人员
- 在职状态仅允许「在职」「离职」,不填默认在职
- 状态为离职时必须填离职时间;改回在职时离职时间会被清空,
  避免留下「在职却有离职时间」的自相矛盾记录
- 删除前检查设备、企微、微信、抖音四类资产是否仍把此人记为使用人或运营人
- 列表一次性批量查出所属公司名,不逐行查询;已删除的公司也取名称,保证历史数据可读

页面权限改为标注加守卫
Java 是在每个控制器方法体首行手写 permissions.require(...),改为 RequirePermission 标注
配合全局守卫执行。查询要求 READ,增删改要求 EDIT,与 Java 逐个方法对应。

对拍发现并对齐的一处差异
Java 的 DTO 上写有 @NotBlank(message = "公司名称不能为空"),但全局异常处理器
把所有校验失败统一成「提交内容不符合要求,请检查后重试」,DTO 里的文案实际是死代码。
NestJS 原本在 service 层校验,会返回更具体的文案,反而与线上行为不符。
已将必填校验移到管道层(先 trim 再判空),文案与 Java 保持一致。

整体契约比对:14 项一致、17 项待实现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a1702e1c
......@@ -10,9 +10,11 @@ import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
import { SystemUserModule } from './system-user/system-user.module';
import { CompanyProfileModule } from './company-profile/company-profile.module';
import { CompanyPersonModule } from './company-person/company-person.module';
@Module({
imports: [PrismaModule, AuthModule, SystemUserModule],
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule],
controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
......
......@@ -14,6 +14,7 @@ import { LoginAttemptService } from './login-attempt.service';
import { PagePermissionService } from './page-permission.service';
import { CsrfGuard } from './csrf.guard';
import { SessionGuard } from './session.guard';
import { PermissionGuard } from '../common/permission.guard';
@Module({
controllers: [AuthController],
......@@ -21,6 +22,8 @@ import { SessionGuard } from './session.guard';
AuthService, AuthTokenService, LoginAttemptService, PagePermissionService,
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: SessionGuard },
// 顺序在会话守卫之后:先确认身份,再检查该身份对目标页面的权限
{ provide: APP_GUARD, useClass: PermissionGuard },
],
exports: [PagePermissionService, LoginAttemptService, AuthTokenService],
})
......
/**
* 文件用途(白话):按接口上标注的页面权限要求,决定当前登录身份能否访问。
* 关联文件:require-permission.decorator.ts、auth/page-permission.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫解析出权限表 -> 本守卫比对标注要求 -> 放行或 403「没有页面权限」。
*
* 只作用于标注了 RequirePermission 的接口;未标注的接口交由各自的守卫或控制器自行判断
* (例如账号管理接口在 service 内部按角色判断)。
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PagePermissionService } from '../auth/page-permission.service';
import { AuthenticatedRequest } from '../auth/session.guard';
import { PERMISSION_KEY, RequiredPermission } from './require-permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector, private readonly permissions: PagePermissionService) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<RequiredPermission>(PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!required) return true;
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
this.permissions.require(req.permissions, required.page, required.minimum);
return true;
}
}
/**
* 文件用途(白话):给接口标注"需要哪个页面的什么权限",由守卫统一执行检查。
* 关联文件:permission.guard.ts、backend/.../PagePermissionService.java 的 require 方法。
* 关联逻辑(调用链):控制器方法上的标注 -> 守卫读取 -> 与会话权限比对 -> 放行或 403。
*
* Java 侧是在每个控制器方法体的第一行手写 permissions.require(...),
* 这里改用标注加守卫:漏写标注时接口会因为没有任何权限约束而暴露,
* 因此下面的 PermissionGuard 采用「未标注即拒绝」的默认策略,把漏写变成立刻可见的失败。
*/
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'requiredPermission';
export interface RequiredPermission { page: string; minimum: 'READ' | 'EDIT'; }
export const RequirePermission = (page: string, minimum: 'READ' | 'EDIT') =>
SetMetadata(PERMISSION_KEY, { page, minimum } as RequiredPermission);
/**
* 文件用途(白话):统一处理表单文本——去掉首尾空格,空字符串一律存成 null。
* 关联文件:各业务 service、backend/.../CompanyProfileService.java 的 normalizeOptionalText。
*
* 为什么空字符串要转 null:库里 null 与 '' 语义不同,前端也据此区分"没填"和"填了空"。
* Java 侧所有可选文本字段都做了这个转换,不照做会出现同一字段在两个后端存法不同。
*/
export function optionalText(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
/** 必填文本:规范化后仍为空则抛出指定文案。 */
export function requiredText(value: string | null | undefined, message: string): string {
const normalized = optionalText(value);
if (normalized === null) throw new Error(message);
return normalized;
}
export function hasText(value: string | null | undefined): boolean {
return value != null && value.trim() !== '';
}
/**
* 文件用途(白话):公司人员的五个接口。
* 关联文件:backend/.../asset/controller/CompanyPersonController.java、company-person.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*
* 注意路由顺序:lookups 这类固定路径必须声明在 :id 之前,
* 否则会被当成 id 匹配走进详情类接口。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { CompanyPersonService } from './company-person.service';
import { CompanyPersonPageQueryDto, CompanyPersonSaveDto } from './dto/company-person.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/company-persons')
export class CompanyPersonController {
constructor(private readonly service: CompanyPersonService) {}
@Get('lookups/company-profiles')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'READ')
companies(@Query('keyword') keyword?: string) {
return this.service.searchCompanies(keyword);
}
@Get()
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'READ')
page(@Query() query: CompanyPersonPageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async create(@Body() dto: CompanyPersonSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: CompanyPersonSaveDto) {
return success(await this.service.update(id, dto), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CompanyPersonController } from './company-person.controller';
import { CompanyPersonService } from './company-person.service';
@Module({ imports: [AuthModule], controllers: [CompanyPersonController], providers: [CompanyPersonService] })
export class CompanyPersonModule {}
/**
* 文件用途(白话):公司人员的查询、新增、修改、删除,以及所属公司的下拉搜索。
* 关联文件:backend/.../asset/service/CompanyPersonService.java、company-person.controller.ts。
* 关联逻辑(数据流):列表筛选 -> 分页查询并补上公司名 -> 表单提交 -> 状态校验 -> 写库。
*
* 三条业务规则:
* - 在职状态只能是「在职」或「离职」,不填默认「在职」
* - 状态为「离职」时必须填离职时间;状态为「在职」时离职时间会被清空,
* 避免出现「在职却有离职时间」这种自相矛盾的数据
* - 删除前检查四类资产是否仍把此人作为使用人或运营人
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException } from '../common/business.exception';
import { nowForDatabase, toDatabaseDate } from '../common/datetime';
import { hasText, optionalText } from '../common/text';
import { CompanyPersonPageQueryDto, CompanyPersonResponse, CompanyPersonSaveDto } from './dto/company-person.dto';
const VALID_STATUS = new Set(['在职', '离职']);
const DEFAULT_SIZE = 20;
/** 下拉搜索最多返回 20 条,与 Java 侧的 limit 一致。 */
const LOOKUP_LIMIT = 20;
@Injectable()
export class CompanyPersonService {
constructor(private readonly prisma: PrismaService) {}
async page(query: CompanyPersonPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where: any = { delete_time: 0n };
if (hasText(query.keyword)) where.person_name = { contains: query.keyword };
if (query.companyProfileId != null) where.company_profile_id = BigInt(query.companyProfileId);
if (hasText(query.employmentStatus)) where.employment_status = query.employmentStatus;
const [rows, total] = await Promise.all([
this.prisma.as_company_person.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_company_person.count({ where }),
]);
const names = await this.companyNames(rows.map((r) => r.company_profile_id));
return { records: rows.map((r) => this.toResponse(r, names.get(String(r.company_profile_id)) ?? null)), total, page, size };
}
/** 一次查出本页涉及的公司名,避免逐行查询。已删除的公司也取名称,保证历史数据仍可读。 */
private async companyNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_profile.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) } },
select: { id: true, company_name: true, short_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.short_name || r.company_name]));
}
async create(dto: CompanyPersonSaveDto): Promise<CompanyPersonResponse> {
const company = await this.validateCompany(dto.companyProfileId);
const now = nowForDatabase();
const created = await this.prisma.as_company_person.create({
data: { ...this.editableFields(dto), create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created, company ? (company.short_name || company.company_name) : null);
}
async update(id: number, dto: CompanyPersonSaveDto): Promise<CompanyPersonResponse> {
await this.requireActive(id);
const company = await this.validateCompany(dto.companyProfileId);
const updated = await this.prisma.as_company_person.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated, company ? (company.short_name || company.company_name) : null);
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_company_person.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
async searchCompanies(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.OR = [{ company_name: { contains: keyword } }, { short_name: { contains: keyword } }];
const rows = await this.prisma.as_company_profile.findMany({ where, orderBy: { id: 'desc' }, take: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), companyName: r.company_name, shortName: r.short_name }));
}
private editableFields(dto: CompanyPersonSaveDto) {
const personName = optionalText(dto.personName);
if (personName === null) throw new BusinessException('人员姓名不能为空');
const status = hasText(dto.employmentStatus) ? (dto.employmentStatus as string) : '在职';
if (!VALID_STATUS.has(status)) throw new BusinessException('在职状态取值无效');
if (status === '离职' && dto.resignedAt == null) throw new BusinessException('离职人员必须填写离职时间');
return {
company_profile_id: dto.companyProfileId == null ? null : BigInt(dto.companyProfileId),
person_name: personName,
employment_status: status,
resigned_at: status === '离职' ? toDatabaseDate(dto.resignedAt as string) : null,
};
}
private async validateCompany(id: number | null | undefined) {
if (id == null) return null;
const company = await this.prisma.as_company_profile.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!company) throw new BusinessException('所属公司不存在或已删除');
return company;
}
private async requireActive(id: number) {
const entity = await this.prisma.as_company_person.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new BusinessException('公司人员不存在或已删除');
return entity;
}
/** 设备把此人记为使用人,企微/微信/抖音把此人记为运营人时,都不允许删除。 */
private async checkActiveReferences(id: number): Promise<void> {
const personId = BigInt(id);
const [device, wecom, wechat, douyin] = await Promise.all([
this.prisma.as_asset_device.count({ where: { user_person_id: personId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
this.prisma.as_wechat_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
]);
const types: string[] = [];
if (device > 0) types.push('设备资产管理');
if (wecom > 0) types.push('企业微信资产');
if (wechat > 0) types.push('微信资产');
if (douyin > 0) types.push('抖音资产');
if (types.length > 0) throw new BusinessException(`公司人员仍被${types.join('、')}引用,不能删除`);
}
private toResponse(entity: any, companyName: string | null): CompanyPersonResponse {
return {
id: Number(entity.id),
companyProfileId: entity.company_profile_id == null ? null : Number(entity.company_profile_id),
companyProfileName: companyName,
personName: entity.person_name,
employmentStatus: entity.employment_status,
resignedAt: entity.resigned_at,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):公司人员接口的请求与响应形状。
* 关联文件:backend/.../dto/CompanyPerson*.java、company-person.service.ts。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class CompanyPersonPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
@IsOptional() @IsString() keyword?: string;
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number;
@IsOptional() @IsString() employmentStatus?: string;
}
export class CompanyPersonSaveDto {
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number | null;
/** 同 companyName:必填校验在管道层完成,返回统一文案,与 Java 的 @NotBlank 一致。 */
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
personName!: string;
@IsOptional() @IsString() employmentStatus?: string;
/** 离职时间:仅在状态为离职时有意义,在职时会被清空。 */
@IsOptional() resignedAt?: string | null;
}
export interface CompanyPersonResponse {
id: number;
companyProfileId: number | null;
companyProfileName: string | null;
personName: string;
employmentStatus: string;
resignedAt: Date | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):公司档案的四个接口。
* 关联文件:backend/.../asset/controller/CompanyProfileController.java、company-profile.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*
* 权限要求逐个对应 Java 侧方法体首行的 permissions.require 调用:
* 查询需要 READ,增删改需要 EDIT。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { CompanyProfileService } from './company-profile.service';
import { CompanyProfilePageQueryDto, CompanyProfileSaveDto } from './dto/company-profile.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/company-profiles')
export class CompanyProfileController {
constructor(private readonly service: CompanyProfileService) {}
@Get()
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'READ')
page(@Query() query: CompanyProfilePageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async create(@Body() dto: CompanyProfileSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: CompanyProfileSaveDto) {
return success(await this.service.update(id, dto), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CompanyProfileController } from './company-profile.controller';
import { CompanyProfileService } from './company-profile.service';
@Module({ imports: [AuthModule], controllers: [CompanyProfileController], providers: [CompanyProfileService] })
export class CompanyProfileModule {}
/**
* 文件用途(白话):公司档案的查询、新增、修改与删除。
* 关联文件:backend/.../asset/service/CompanyProfileService.java、company-profile.controller.ts。
* 关联逻辑(数据流):列表页筛选 -> 分页查询 -> 表单提交 -> 校验与规范化 -> 写库。
*
* 两处容易被简化掉、但简化了就会出事的规则:
*
* 一、删除前必须检查五张表的引用。
* 公司人员、企微、抖音、域名、商户任何一处还在用这家公司,就不允许删除,
* 并把具体的引用方拼进提示语(如「公司档案仍被公司人员、企业微信资产引用,不能删除」)。
* 少查一张表,就可能删掉一家仍被引用的公司,留下一批指向空档案的记录。
*
* 二、可选文本字段空值统一存 null,不存空字符串。
* 库里 null 与 '' 语义不同,前端也据此区分「没填」与「填了空」。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText, optionalText } from '../common/text';
import { CompanyProfilePageQueryDto, CompanyProfileResponse, CompanyProfileSaveDto } from './dto/company-profile.dto';
const DEFAULT_PAGE = 1;
const DEFAULT_SIZE = 20;
@Injectable()
export class CompanyProfileService {
constructor(private readonly prisma: PrismaService) {}
async page(query: CompanyProfilePageQueryDto) {
const page = query.page ?? DEFAULT_PAGE;
const size = query.size ?? DEFAULT_SIZE;
const where = this.activeWhere(query.keyword);
const [rows, total] = await Promise.all([
this.prisma.as_company_profile.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_company_profile.count({ where }),
]);
return { records: rows.map((r) => this.toResponse(r)), total, page, size };
}
/** 关键词在六个字段上做模糊匹配,与 Java 侧的 or 条件组一致。 */
private activeWhere(keyword?: string) {
const base: any = { delete_time: 0n };
if (!hasText(keyword)) return base;
const like = { contains: keyword as string };
return {
...base,
OR: [
{ company_name: like }, { short_name: like }, { unified_social_credit_code: like },
{ address: like }, { contact_name: like }, { contact_value: like },
],
};
}
async create(dto: CompanyProfileSaveDto): Promise<CompanyProfileResponse> {
const now = nowForDatabase();
const created = await this.prisma.as_company_profile.create({
data: { ...this.editableFields(dto), create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created);
}
async update(id: number, dto: CompanyProfileSaveDto): Promise<CompanyProfileResponse> {
await this.requireActive(id);
const updated = await this.prisma.as_company_profile.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated);
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_company_profile.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
private editableFields(dto: CompanyProfileSaveDto) {
const companyName = optionalText(dto.companyName);
if (companyName === null) throw new BusinessException('公司名称不能为空');
return {
company_name: companyName,
short_name: optionalText(dto.shortName),
unified_social_credit_code: optionalText(dto.unifiedSocialCreditCode),
address: optionalText(dto.address),
contact_name: optionalText(dto.contactName),
contact_value: optionalText(dto.contactValue),
};
}
private async requireActive(id: number) {
const entity = await this.prisma.as_company_profile.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new BusinessException('公司档案不存在或已删除');
return entity;
}
/**
* 逐一检查五类资产是否仍引用这家公司,并把引用方拼进提示语。
* 抖音、域名、商户三张表当前没有业务模块在写,但检查必须保留——
* 将来这些模块启用后,少查一张就会误删仍被引用的公司档案。
*/
private async checkActiveReferences(id: number): Promise<void> {
const companyId = BigInt(id);
const [person, wecom, douyin, domain, merchant] = await Promise.all([
this.prisma.as_company_person.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_domain_asset.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_merchant.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
]);
const types: string[] = [];
if (person > 0) types.push('公司人员');
if (wecom > 0) types.push('企业微信资产');
if (douyin > 0) types.push('抖音资产');
if (domain > 0) types.push('域名资产');
if (merchant > 0) types.push('商户');
if (types.length > 0) throw new BusinessException(`公司档案仍被${types.join('、')}引用,不能删除`);
}
private toResponse(entity: any): CompanyProfileResponse {
return {
id: Number(entity.id),
companyName: entity.company_name,
shortName: entity.short_name,
unifiedSocialCreditCode: entity.unified_social_credit_code,
address: entity.address,
contactName: entity.contact_name,
contactValue: entity.contact_value,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):公司档案接口的请求与响应形状。
* 关联文件:backend/.../dto/CompanyProfile*.java、company-profile.service.ts。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class CompanyProfilePageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
page?: number;
/** 上限 100:Java 侧同样限制,超出直接返回校验失败而不是静默截断。 */
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
size?: number;
@IsOptional() @IsString()
keyword?: string;
}
export class CompanyProfileSaveDto {
/**
* 必填校验放在管道层,与 Java 的 @NotBlank 位置一致——两边都在进入业务代码之前就被拦下,
* 返回统一文案「提交内容不符合要求,请检查后重试」。
* 先 trim 再判空,使纯空格也被视为未填。
* service 里同名的检查作为防御保留,正常情况下走不到。
*/
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
companyName!: string;
@IsOptional() @IsString() shortName?: string;
@IsOptional() @IsString() unifiedSocialCreditCode?: string;
@IsOptional() @IsString() address?: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsString() contactValue?: string;
}
export interface CompanyProfileResponse {
id: number;
companyName: string;
shortName: string | null;
unifiedSocialCreditCode: string | null;
address: string | null;
contactName: string | null;
contactValue: string | null;
createTime: Date | null;
updateTime: Date | null;
}
......@@ -17,7 +17,9 @@ export default defineConfig({
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:7690',
// 迁移期用环境变量切换后端:不设时连 Java(7690),设 VITE_API_TARGET 可指向 NestJS(7691)。
// 这样验证新后端不需要改任何代码,切换与回退都是一条命令的事。
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:7690',
changeOrigin: true
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment