Commit cdddf83d by DaiJiezhang

feat: 迁移企业微信资产模块,含手机号联动与事务保护

新增九个接口(含四个下拉搜索与号码查重),契约比对 6/6 一致,
联动规则验证 10/10 通过,事务回滚验证两边行为一致。

四条手机号联动规则
  绑定时号码已在台账   复用,标记 EXISTING
  绑定时号码不在台账   自动建档(EXTERNAL / 来源 WECOM / 来源 id 指向本账号),标记 CREATED
  改绑或删除账号时     只清理「本账号自己建的」号码,且清理前确认无人在用
  号码没变            完全不动关联,避免把 EXISTING 误改成 CREATED 或误删仍在用的号

判断「这条号码是不是本账号建的」需三个条件同时成立:
number_type = EXTERNAL、source_asset_type = WECOM、source_asset_id = 本账号 id。
少判一个就会误删他人的号码,而界面上完全看不出异常。

事务保护
create、update、softDelete 三个方法都同时写企微账号与手机号台账两张表,
统一用 prisma.$transaction 包住,事务内所有读写一律走事务客户端。
漏用一处该操作就跑在事务之外,等于没包住,且不会有任何征兆。

验证方式没有停留在读代码:构造一个名称超出字段长度的请求,
使号码建好之后企微账号插入失败,再回查号码是否仍在台账。
Java 与 NestJS 均正确回滚,台账没有留下无人引用的孤儿号码。

整体契约比对:26 项一致、5 项待实现(仅剩设备模块)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a33af22a
...@@ -13,9 +13,10 @@ import { SystemUserModule } from './system-user/system-user.module'; ...@@ -13,9 +13,10 @@ import { SystemUserModule } from './system-user/system-user.module';
import { CompanyProfileModule } from './company-profile/company-profile.module'; import { CompanyProfileModule } from './company-profile/company-profile.module';
import { CompanyPersonModule } from './company-person/company-person.module'; import { CompanyPersonModule } from './company-person/company-person.module';
import { PhoneAssetModule } from './phone-asset/phone-asset.module'; import { PhoneAssetModule } from './phone-asset/phone-asset.module';
import { WecomAccountModule } from './wecom-account/wecom-account.module';
@Module({ @Module({
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule], imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule, WecomAccountModule],
controllers: [HealthController], controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }], providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
}) })
......
/**
* 文件用途(白话):企业微信资产接口的请求与响应形状。
* 关联文件:backend/.../dto/WecomAccount*.java、wecom-account.service.ts。
*
* 注意请求里同时包含企微账号本身的字段与手机号的身份字段
* (cardType / iccid / phoneRealNameOwner)——后者用于在号码不存在时自动建档。
*/
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class WecomAccountPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
@IsOptional() @IsString() wecomAccount?: string;
@IsOptional() @Type(() => Number) @IsInt() phoneAssetId?: number;
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number;
@IsOptional() @IsString() realNameOwnerStatus?: string;
}
export class WecomAccountSaveDto {
@IsOptional() @IsString() wecomName?: string;
@IsOptional() @IsString() wecomAlias?: string;
@IsOptional() @IsString() wecomAccount?: string;
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number | null;
@IsOptional() @IsString() phoneNumber?: string;
@IsOptional() @IsString() realNameOwner?: string;
@IsOptional() @IsString() realNameOwnerStatus?: string;
@IsOptional() @IsString() gender?: string;
@IsOptional() @Type(() => Number) @IsInt() deviceId?: number | null;
@IsOptional() @Type(() => Number) @IsInt() operatorPersonId?: number | null;
/** 以下三项在号码需要自动建档时写入手机号台账。 */
@IsOptional() @IsString() cardType?: string;
@IsOptional() @IsString() iccid?: string;
@IsOptional() @IsString() phoneRealNameOwner?: string;
}
export interface WecomAccountResponse {
id: number;
wecomName: string | null;
wecomAlias: string | null;
wecomAccount: string | null;
companyProfileId: number | null;
companyProfileName: string | null;
phoneAssetId: number | null;
phoneNumber: string | null;
phoneLinkMode: string | null;
realNameOwner: string | null;
realNameOwnerStatus: string | null;
gender: string | null;
deviceId: number | null;
deviceName: string | null;
operatorPersonId: number | null;
operatorPersonName: string | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):企业微信资产的九个接口,含四个下拉搜索与一个号码查重。
* 关联文件:backend/.../asset/controller/WecomAccountController.java、wecom-account.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*
* lookups 与 phone-exists 都声明在 :id 之前,避免被当成 id 匹配。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { WecomAccountService } from './wecom-account.service';
import { WecomAccountPageQueryDto, WecomAccountSaveDto } from './dto/wecom-account.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/wecom-accounts')
export class WecomAccountController {
constructor(private readonly service: WecomAccountService) {}
@Get('lookups/company-profiles')
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
companyProfiles(@Query('keyword') keyword?: string) { return this.service.searchCompanyProfiles(keyword); }
@Get('lookups/phone-assets')
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
phoneAssets(@Query('keyword') keyword?: string) { return this.service.searchPhoneAssets(keyword); }
@Get('lookups/phone-exists')
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
phoneExists(@Query('phoneNumber') phoneNumber = '') { return this.service.phoneExists(phoneNumber); }
@Get('lookups/devices')
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
devices(@Query('keyword') keyword?: string) { return this.service.searchDevices(keyword); }
@Get('lookups/company-persons')
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
companyPersons(@Query('keyword') keyword?: string) { return this.service.searchCompanyPersons(keyword); }
@Get()
@RequirePermission(PAGE_KEYS.WECOM, 'READ')
page(@Query() query: WecomAccountPageQueryDto) { return this.service.page(query); }
@Post()
@RequirePermission(PAGE_KEYS.WECOM, 'EDIT')
async create(@Body() dto: WecomAccountSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.WECOM, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: WecomAccountSaveDto) {
return success(await this.service.update(id, dto), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.WECOM, '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 { WecomAccountController } from './wecom-account.controller';
import { WecomAccountService } from './wecom-account.service';
@Module({ imports: [AuthModule], controllers: [WecomAccountController], providers: [WecomAccountService] })
export class WecomAccountModule {}
...@@ -30,7 +30,8 @@ async function api(method, path, body, snapshot) { ...@@ -30,7 +30,8 @@ async function api(method, path, body, snapshot) {
const res = await fetch(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); const res = await fetch(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
const text = await res.text(); const text = await res.text();
let parsed; try { parsed = JSON.parse(text); } catch { parsed = `<NON_JSON:${text.slice(0, 40)}>`; } let parsed; try { parsed = JSON.parse(text); } catch { parsed = `<NON_JSON:${text.slice(0, 40)}>`; }
if (snapshot) writeFileSync(join(OUT, `wecom.${snapshot}.json`), JSON.stringify({ // 对新后端复跑时加 --no-snapshot,避免覆盖 Java 录下的基准快照
if (snapshot && !process.argv.includes('--no-snapshot')) writeFileSync(join(OUT, `wecom.${snapshot}.json`), JSON.stringify({
request: { method, path, body: body ?? null }, status: res.status, request: { method, path, body: body ?? null }, status: res.status,
headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed), headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed),
}, null, 2) + '\n', 'utf8'); }, null, 2) + '\n', 'utf8');
......
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