Commit a33af22a by DaiJiezhang

feat: 迁移手机号码管理模块

新增五个接口,契约比对 5/5 一致,业务规则对拍 12/12 一致。

按输入位数变化的号码搜索
这条规则从接口签名完全看不出来,但直接决定搜索是否可用:
  3 位  按号段前缀匹配
  4 位  按尾号匹配
  7 位  前 3 位与后 4 位组合匹配
  其他  精确匹配
写成统一的模糊匹配看似等价,实际会让「输入 8888 找尾号」退化成乱匹配。
对拍用真实数据逐条验证了五种输入长度的结果集完全一致。

其余业务规则
- 卡类型为「虚拟号码」时豁免 ICCID 与实名人的必填校验——虚拟号本无实体卡与实名主体
- 号码规范化会剥掉 +86 前缀,最终必须是 11 位纯数字
- 处置状态不填时默认「正常使用」
- 手动建档固定 number_type = SELF(企微自动录入的号标为 EXTERNAL 以便追溯来源)
- 删除不做引用检查,与 Java 一致:台账记录可以先于业务资产撤下

对拍发现:「记录不存在」的状态码各模块并不统一
公司档案与公司人员返回 400,手机号、设备、企微返回 404——
后三者各自定义了 NotFoundException 与专属异常处理器。这是既有契约不是笔误,
照搬处理:NestJS 侧新增 NotFoundException(404)与 BusinessException(400)区分。
完整映射记于 CONTRACT-NOTES 第 16 条,供设备与企微模块实现时对照。

整体契约比对:19 项一致、12 项待实现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 5b0f2f4e
......@@ -12,9 +12,10 @@ 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';
import { PhoneAssetModule } from './phone-asset/phone-asset.module';
@Module({
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule],
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule],
controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
......
......@@ -10,6 +10,18 @@ export class BusinessException extends Error {
constructor(message: string, readonly status = 400) { super(message); this.name = 'BusinessException'; }
}
/**
* 记录不存在,返回 404。
*
* 注意各模块并不统一,这是既有契约不是笔误:
* 公司档案 / 公司人员 记录不存在返回 400(Java 侧抛 IllegalArgumentException)
* 手机号 / 设备 / 企微 记录不存在返回 404(各自有 *NotFoundException 与专属处理器)
* 迁移期照搬,不做统一。
*/
export class NotFoundException extends BusinessException {
constructor(message: string) { super(message, 404); this.name = 'NotFoundException'; }
}
/** 权限不足,对应 Java 的 AccessDeniedException,统一 403。 */
export class ForbiddenException extends BusinessException {
constructor(message = '没有权限') { super(message, 403); this.name = 'ForbiddenException'; }
......
/**
* 文件用途(白话):手机号码管理接口的请求与响应形状。
* 关联文件:backend/.../dto/PhoneAsset*.java、phone-asset.service.ts。
*/
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PhoneAssetPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
/** 按位数决定匹配方式,规则见 service 的 phoneNumberFilter。 */
@IsOptional() @IsString() phoneNumber?: string;
@IsOptional() @IsString() iccid?: string;
@IsOptional() @IsString() realNameOwner?: string;
@IsOptional() @IsString() disposalStatus?: string;
}
export class PhoneAssetSaveDto {
@IsOptional() @IsString() phoneNumber?: string;
@IsOptional() @IsString() cardType?: string;
@IsOptional() @IsString() iccid?: string;
@IsOptional() @IsString() realNameOwner?: string;
@IsOptional() @IsString() managementType?: string;
@IsOptional() @IsString() disposalStatus?: string;
@IsOptional() @Type(() => Number) @IsInt() deviceId?: number | null;
}
export interface PhoneAssetResponse {
id: number;
phoneNumber: string;
numberType: string | null;
sourceAssetType: string | null;
sourceAssetId: number | null;
cardType: string | null;
iccid: string | null;
realNameOwner: string | null;
managementType: string | null;
disposalStatus: string | null;
deviceId: number | null;
deviceName: string | null;
relationSyncedAt: Date | null;
}
/**
* 文件用途(白话):手机号码管理的五个接口。
* 关联文件:backend/.../asset/controller/PhoneAssetController.java、phone-asset.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { PhoneAssetService } from './phone-asset.service';
import { PhoneAssetPageQueryDto, PhoneAssetSaveDto } from './dto/phone-asset.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/phone-assets')
export class PhoneAssetController {
constructor(private readonly service: PhoneAssetService) {}
@Get('lookups/devices')
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
devices(@Query('keyword') keyword?: string) {
return this.service.searchDevices(keyword);
}
@Get()
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
page(@Query() query: PhoneAssetPageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async create(@Body() dto: PhoneAssetSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: PhoneAssetSaveDto) {
// 手机号模块的修改成功文案是「编辑成功」,与其他模块的「修改成功」不同,属既有契约,不做统一
return success(await this.service.update(id, dto), '编辑成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.PHONE, '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 { PhoneAssetController } from './phone-asset.controller';
import { PhoneAssetService } from './phone-asset.service';
@Module({ imports: [AuthModule], controllers: [PhoneAssetController], providers: [PhoneAssetService] })
export class PhoneAssetModule {}
/**
* 文件用途(白话):手机号码台账的查询、新增、修改、删除,以及关联设备的下拉搜索。
* 关联文件:backend/.../asset/service/PhoneAssetService.java、phone-asset.controller.ts。
* 关联逻辑(数据流):列表筛选 -> 分页查询并补上设备名 -> 表单提交 -> 号码规范化与必填校验 -> 写库。
*
* 三条从接口签名完全看不出来的规则:
*
* 一、号码搜索按输入位数决定匹配方式。
* 3 位按号段前缀、4 位按尾号、7 位按「前 3 位 + 后 4 位」组合、其余精确匹配。
* 写成统一的模糊匹配看似等价,实际会让「输入 8888 找尾号」变成全表扫描式的乱匹配。
*
* 二、虚拟号码豁免实名信息。
* 卡类型为「虚拟号码」时跳过 ICCID 与实名人的必填校验——虚拟号本就没有实体卡和实名主体。
*
* 三、号码规范化会剥掉 +86 前缀。
* 粘贴带国际区号的号码时自动处理,最终必须是 11 位纯数字。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException, NotFoundException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText } from '../common/text';
import { PhoneAssetPageQueryDto, PhoneAssetResponse, PhoneAssetSaveDto } from './dto/phone-asset.dto';
const DEFAULT_SIZE = 20;
const DEVICE_LOOKUP_LIMIT = 20;
/** 卡类型为此值时不要求 ICCID 与实名人。 */
const VIRTUAL_CARD_TYPE = '虚拟号码';
@Injectable()
export class PhoneAssetService {
constructor(private readonly prisma: PrismaService) {}
async page(query: PhoneAssetPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where = this.activeWhere(query);
const [rows, total] = await Promise.all([
this.prisma.as_phone_asset.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_phone_asset.count({ where }),
]);
const names = await this.deviceNames(rows.map((r) => r.device_id));
return { records: rows.map((r) => this.toResponse(r, names)), total, page, size };
}
private activeWhere(query: PhoneAssetPageQueryDto) {
const where: any = { delete_time: 0n, ...this.phoneNumberFilter(query.phoneNumber) };
if (hasText(query.iccid)) where.iccid = query.iccid;
if (hasText(query.realNameOwner)) where.real_name_owner = { contains: query.realNameOwner };
if (hasText(query.disposalStatus)) where.disposal_status = query.disposalStatus;
return where;
}
/** 按输入位数选择匹配方式,逐条对应 Java 的 applyPhoneNumberFilter。 */
private phoneNumberFilter(phoneNumber?: string) {
if (!hasText(phoneNumber)) return {};
const value = phoneNumber as string;
if (value.length === 3) return { phone_number: { startsWith: value } };
if (value.length === 4) return { phone_number: { endsWith: value } };
if (value.length === 7) return { phone_number: { startsWith: value.slice(0, 3), endsWith: value.slice(3) } };
return { phone_number: value };
}
async searchDevices(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.device_name = { contains: keyword };
const rows = await this.prisma.as_asset_device.findMany({ where, orderBy: { id: 'desc' }, take: DEVICE_LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), deviceName: r.device_name }));
}
async create(dto: PhoneAssetSaveDto): Promise<PhoneAssetResponse> {
const now = nowForDatabase();
const created = await this.prisma.as_phone_asset.create({
data: {
...this.editableFields(dto),
// 手动建档的号码固定为自有号;企微自动录入的号会被标成 EXTERNAL 以便追溯来源
number_type: 'SELF',
create_time: now, update_time: now, delete_time: 0n,
linked_wecom_accounts: [], linked_wechat_accounts: [], linked_douyin_accounts: [],
linked_domain_accounts: [], linked_merchants: [],
},
});
return this.toResponse(created, await this.deviceNames([created.device_id]));
}
async update(id: number, dto: PhoneAssetSaveDto): Promise<PhoneAssetResponse> {
await this.requireActive(id);
const updated = await this.prisma.as_phone_asset.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated, await this.deviceNames([updated.device_id]));
}
/** 号码删除不做引用检查,与 Java 一致:台账记录可以先于业务资产撤下。 */
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.prisma.as_phone_asset.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
private editableFields(dto: PhoneAssetSaveDto) {
this.validateRequiredIdentityFields(dto);
return {
phone_number: this.normalizePhoneNumber(dto.phoneNumber),
card_type: dto.cardType ?? null,
iccid: dto.iccid ?? null,
real_name_owner: dto.realNameOwner ?? null,
management_type: dto.managementType ?? null,
disposal_status: hasText(dto.disposalStatus) ? (dto.disposalStatus as string) : '正常使用',
device_id: dto.deviceId == null ? null : BigInt(dto.deviceId),
};
}
private validateRequiredIdentityFields(dto: PhoneAssetSaveDto): void {
if (dto.cardType === VIRTUAL_CARD_TYPE) return;
if (!hasText(dto.iccid)) throw new BusinessException('ICCID 不能为空');
if (!hasText(dto.realNameOwner)) throw new BusinessException('实名人不能为空');
}
private normalizePhoneNumber(value?: string): string {
let normalized = (value ?? '').trim();
if (normalized.startsWith('+86')) normalized = normalized.slice(3);
if (!/^\d{11}$/.test(normalized)) throw new BusinessException('手机号必须是 11 位数字');
return normalized;
}
private async requireActive(id: number) {
const entity = await this.prisma.as_phone_asset.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new NotFoundException('手机号码管理不存在或已删除');
return entity;
}
/** 批量取设备名;已删除的设备不返回名称,与 Java 的 delete_time = 0 条件一致。 */
private async deviceNames(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_asset_device.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, device_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.device_name]));
}
private toResponse(entity: any, deviceNames: Map<string, string>): PhoneAssetResponse {
return {
id: Number(entity.id),
phoneNumber: entity.phone_number,
numberType: entity.number_type,
sourceAssetType: entity.source_asset_type,
sourceAssetId: entity.source_asset_id == null ? null : Number(entity.source_asset_id),
cardType: entity.card_type,
iccid: entity.iccid,
realNameOwner: entity.real_name_owner,
managementType: entity.management_type,
disposalStatus: entity.disposal_status,
deviceId: entity.device_id == null ? null : Number(entity.device_id),
deviceName: entity.device_id == null ? null : (deviceNames.get(String(entity.device_id)) ?? null),
relationSyncedAt: entity.relation_synced_at,
};
}
}
......@@ -196,3 +196,28 @@ NestJS 侧必须用 `prisma.$transaction` 把这三个流程整体包住。
注意 Prisma 的事务写法:交互式事务需把回调内所有查询都改用事务客户端 `tx`
漏掉一处该操作就跑在事务之外,等于没包。
## 16. 「记录不存在」的状态码各模块不统一
| 模块 | 记录不存在时 |
|---|---|
| 公司档案 / 公司人员 | **400**(Java 抛 IllegalArgumentException,由 CompanyManagementExceptionHandler 统一转 400) |
| 手机号 / 设备 / 企微 | **404**(各自定义了 *NotFoundException 与专属异常处理器) |
这是各模块独立演进留下的差异,不是笔误。迁移期照搬,不做统一。
NestJS 侧用 `NotFoundException`(404)与 `BusinessException`(默认 400)区分。
各模块异常处理器的完整映射:
```
CompanyManagementExceptionHandler IllegalArgumentException -> 400
PhoneAssetExceptionHandler PhoneAssetValidationException -> 400
PhoneAssetNotFoundException -> 404
DeviceAssetExceptionHandler DeviceAssetValidationException -> 400
DeviceAssetNotFoundException -> 404
MaxUploadSizeExceededException -> 400
WecomAccountExceptionHandler WecomAccountNotFoundException -> 404
PhoneAssetValidationException -> 400
```
最后一行值得注意:企微模块会复用手机号的校验异常——因为它在自动创建号码时会走同一套校验。
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