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';
import { CompanyProfileModule } from './company-profile/company-profile.module';
import { CompanyPersonModule } from './company-person/company-person.module';
import { PhoneAssetModule } from './phone-asset/phone-asset.module';
import { WecomAccountModule } from './wecom-account/wecom-account.module';
@Module({
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule],
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule, WecomAccountModule],
controllers: [HealthController],
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 {}
/**
* 文件用途(白话):企业微信资产的增删改查,以及它与手机号台账之间的联动。
* 关联文件:backend/.../asset/service/WecomAccountService.java、CONTRACT-NOTES.md 第 15 条。
* 关联逻辑(数据流):表单提交 -> 校验关联对象 -> 号码复用或自动建档 -> 写企微账号 -> 清理旧号。
*
* 这是整个系统最容易写错的一块。规则在界面上完全看不出来,
* 写错了不报错、不留异常日志,只会让手机号台账悄悄多出或少掉记录。
*
* 四条联动规则:
* 绑定时号码已在台账 复用它,标记 EXISTING
* 绑定时号码不在台账 自动建档(EXTERNAL / 来源 WECOM / 来源 id 指向本账号),标记 CREATED
* 改绑或删除账号时 只清理「本账号自己建的」那条号码,且清理前确认没有别人在用
* 号码没变 完全不动关联,避免把 EXISTING 误改成 CREATED、或误删仍在用的号
*
* 判断「这条号码是不是本账号建的」需三个条件同时成立:
* number_type = EXTERNAL、source_asset_type = WECOM、source_asset_id = 本账号 id
* 少判一个就会误删别人的号码。
*
* 三个写方法都必须整体成事:同时写企微账号与手机号台账两张表,
* 中途失败若不回滚,台账里会留下无人引用的孤儿号码,且不会有任何报错。
* 因此统一用 prisma.$transaction 包住,且事务内所有读写一律走事务客户端 tx——
* 漏用一处,那一处就跑在事务之外,等于没包住。
*/
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../common/prisma.service';
import { BusinessException, NotFoundException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText } from '../common/text';
import { WecomAccountPageQueryDto, WecomAccountResponse, WecomAccountSaveDto } from './dto/wecom-account.dto';
const DEFAULT_SIZE = 20;
const LOOKUP_LIMIT = 20;
const VIRTUAL_CARD_TYPE = '虚拟号码';
type Tx = Prisma.TransactionClient;
@Injectable()
export class WecomAccountService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: WecomAccountSaveDto): Promise<WecomAccountResponse> {
return this.prisma.$transaction(async (tx) => {
await this.requireActiveCompanyProfile(tx, dto.companyProfileId);
await this.requireActiveDevice(tx, dto.deviceId);
const phoneNumber = this.normalizePhoneNumber(dto.phoneNumber);
const now = nowForDatabase();
const existing = await tx.as_phone_asset.findFirst({ where: { delete_time: 0n, phone_number: phoneNumber } });
const created = existing == null;
const phone = existing ?? await tx.as_phone_asset.create({
data: {
phone_number: phoneNumber, number_type: 'EXTERNAL', source_asset_type: 'WECOM',
...this.phoneIdentity(dto), disposal_status: '正常使用',
create_time: now, update_time: now, delete_time: 0n,
linked_wecom_accounts: [], linked_wechat_accounts: [], linked_douyin_accounts: [],
linked_domain_accounts: [], linked_merchants: [],
},
});
const account = await tx.as_wecom_account.create({
data: {
...this.editableFields(dto), phone_asset_id: phone.id,
phone_link_mode: created ? 'CREATED' : 'EXISTING',
create_time: now, update_time: now, delete_time: 0n,
},
});
// 号码是为本账号新建的,回填来源 id,后续才认得出「这条是我建的」
if (created) await tx.as_phone_asset.update({ where: { id: phone.id }, data: { source_asset_id: account.id } });
return this.toResponse(account, { phoneNumber });
});
}
async update(id: number, dto: WecomAccountSaveDto): Promise<WecomAccountResponse> {
return this.prisma.$transaction(async (tx) => {
const entity = await this.requireActiveAccount(tx, id);
await this.requireActiveCompanyProfile(tx, dto.companyProfileId);
await this.requireActiveDevice(tx, dto.deviceId);
const phoneNumber = this.normalizePhoneNumber(dto.phoneNumber);
const now = nowForDatabase();
const previousPhoneAssetId = entity.phone_asset_id;
const previousLinkMode = entity.phone_link_mode;
let phoneAssetId = previousPhoneAssetId;
let linkMode = previousLinkMode;
// 号码没变就完全不动关联:既不会把 EXISTING 误改成 CREATED,也不会误删仍在用的号
const currentNumber = await this.currentPhoneNumber(tx, previousPhoneAssetId);
if (phoneNumber !== currentNumber) {
const phone = await this.attachPhone(tx, phoneNumber, entity.id, now, dto);
phoneAssetId = phone.id;
linkMode = this.isCreatedFor(phone, entity.id) ? 'CREATED' : 'EXISTING';
await this.releaseAutoCreatedPhone(tx, previousPhoneAssetId, previousLinkMode, entity.id);
}
const updated = await tx.as_wecom_account.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), phone_asset_id: phoneAssetId, phone_link_mode: linkMode, update_time: now },
});
return this.toResponse(updated, { phoneNumber: await this.currentPhoneNumber(tx, phoneAssetId) });
});
}
async softDelete(id: number): Promise<void> {
await this.prisma.$transaction(async (tx) => {
const entity = await this.requireActiveAccount(tx, id);
await tx.as_wecom_account.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
await this.releaseAutoCreatedPhone(tx, entity.phone_asset_id, entity.phone_link_mode, entity.id);
});
}
/** 号码已在台账则直接复用,否则为本账号新建一条并标明来源。 */
private async attachPhone(tx: Tx, phoneNumber: string, wecomAccountId: bigint, now: Date, dto: WecomAccountSaveDto) {
const existing = await tx.as_phone_asset.findFirst({ where: { delete_time: 0n, phone_number: phoneNumber } });
if (existing) return existing;
return tx.as_phone_asset.create({
data: {
phone_number: phoneNumber, number_type: 'EXTERNAL', source_asset_type: 'WECOM', source_asset_id: wecomAccountId,
...this.phoneIdentity(dto), disposal_status: '正常使用',
create_time: now, update_time: now, delete_time: 0n,
linked_wecom_accounts: [], linked_wechat_accounts: [], linked_douyin_accounts: [],
linked_domain_accounts: [], linked_merchants: [],
},
});
}
/** 三个条件同时成立才算「这条号码是本账号建的」,少判一个就会误删别人的号码。 */
private isCreatedFor(phone: { number_type: string | null; source_asset_type: string | null; source_asset_id: bigint | null } | null, wecomAccountId: bigint): boolean {
return phone != null && phone.number_type === 'EXTERNAL' && phone.source_asset_type === 'WECOM' && phone.source_asset_id === wecomAccountId;
}
/**
* 清理本账号当初自动建的号码。以下任一情况都不清理:
* - 原本就是复用别人的号(linkMode 不是 CREATED)
* - 号码已不存在或并非本账号所建
* - 仍有其他企微、微信或抖音账号在用它
*/
private async releaseAutoCreatedPhone(tx: Tx, phoneAssetId: bigint | null, linkMode: string | null, wecomAccountId: bigint): Promise<void> {
if (phoneAssetId == null || linkMode !== 'CREATED') return;
const phone = await tx.as_phone_asset.findFirst({ where: { id: phoneAssetId, delete_time: 0n } });
if (!this.isCreatedFor(phone, wecomAccountId)) return;
if (await this.isPhoneStillReferenced(tx, phoneAssetId, wecomAccountId)) return;
await tx.as_phone_asset.update({
where: { id: phoneAssetId },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
/** 查企微(排除本账号)、微信、抖音三类账号是否仍引用该号码。 */
private async isPhoneStillReferenced(tx: Tx, phoneAssetId: bigint, excludedWecomAccountId: bigint): Promise<boolean> {
const [wecom, wechat, douyin] = await Promise.all([
tx.as_wecom_account.count({ where: { phone_asset_id: phoneAssetId, delete_time: 0n, id: { not: excludedWecomAccountId } } }),
tx.as_wechat_account.count({ where: { phone_asset_id: phoneAssetId, delete_time: 0n } }),
tx.as_douyin_account.count({ where: { phone_asset_id: phoneAssetId, delete_time: 0n } }),
]);
return wecom > 0 || wechat > 0 || douyin > 0;
}
private async currentPhoneNumber(tx: Tx, phoneAssetId: bigint | null): Promise<string | null> {
if (phoneAssetId == null) return null;
const phone = await tx.as_phone_asset.findFirst({ where: { id: phoneAssetId, delete_time: 0n }, select: { phone_number: true } });
return phone?.phone_number ?? null;
}
async page(query: WecomAccountPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where: any = { delete_time: 0n };
if (hasText(query.wecomAccount)) where.wecom_account = query.wecomAccount;
if (query.phoneAssetId != null) where.phone_asset_id = BigInt(query.phoneAssetId);
if (query.companyProfileId != null) where.company_profile_id = BigInt(query.companyProfileId);
if (hasText(query.realNameOwnerStatus)) where.real_name_owner_status = query.realNameOwnerStatus;
const [rows, total] = await Promise.all([
this.prisma.as_wecom_account.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_wecom_account.count({ where }),
]);
// 四类关联名称各查一次,而不是逐行查询;已删除的关联对象仍取名称,保证历史记录可读
const [companies, phones, devices, persons] = await Promise.all([
this.namesOf('as_company_profile', rows.map((r) => r.company_profile_id), 'company_name'),
this.namesOf('as_phone_asset', rows.map((r) => r.phone_asset_id), 'phone_number'),
this.namesOf('as_asset_device', rows.map((r) => r.device_id), 'device_name'),
this.namesOf('as_company_person', rows.map((r) => r.operator_person_id), 'person_name'),
]);
const records = rows.map((r) => this.toResponse(r, {
companyProfileName: companies.get(String(r.company_profile_id)) ?? null,
phoneNumber: phones.get(String(r.phone_asset_id)) ?? null,
deviceName: devices.get(String(r.device_id)) ?? null,
operatorPersonName: persons.get(String(r.operator_person_id)) ?? null,
}));
return { records, total, page, size };
}
/** 批量取关联对象的显示名,避免逐行查询。 */
private async namesOf(table: string, ids: Array<bigint | null>, nameColumn: string) {
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: any[] = await (this.prisma as any)[table].findMany({
where: { id: { in: unique.map((v) => BigInt(v)) } },
select: { id: true, [nameColumn]: true },
});
return new Map(rows.map((r) => [String(r.id), r[nameColumn]]));
}
async searchCompanyProfiles(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 }));
}
async searchPhoneAssets(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.phone_number = { contains: keyword };
const rows = await this.prisma.as_phone_asset.findMany({ where, orderBy: { id: 'desc' }, take: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), phoneNumber: r.phone_number, numberType: r.number_type }));
}
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: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), deviceName: r.device_name }));
}
async searchCompanyPersons(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.person_name = { contains: keyword };
const rows = await this.prisma.as_company_person.findMany({ where, orderBy: { id: 'desc' }, take: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), personName: r.person_name }));
}
/** 号码是否已在台账中(仅看存活记录),用于表单即时提示。 */
async phoneExists(phoneNumber: string): Promise<boolean> {
const count = await this.prisma.as_phone_asset.count({ where: { delete_time: 0n, phone_number: phoneNumber } });
return count > 0;
}
private editableFields(dto: WecomAccountSaveDto) {
return {
wecom_name: dto.wecomName ?? null,
wecom_alias: dto.wecomAlias ?? null,
wecom_account: dto.wecomAccount ?? null,
company_profile_id: dto.companyProfileId == null ? null : BigInt(dto.companyProfileId),
real_name_owner: dto.realNameOwner ?? null,
real_name_owner_status: dto.realNameOwnerStatus ?? '在职',
gender: dto.gender ?? null,
device_id: dto.deviceId == null ? null : BigInt(dto.deviceId),
operator_person_id: dto.operatorPersonId == null ? null : BigInt(dto.operatorPersonId),
};
}
/** 自动建档时写入的手机号身份信息;虚拟号码豁免必填校验。 */
private phoneIdentity(dto: WecomAccountSaveDto) {
if (dto.cardType !== VIRTUAL_CARD_TYPE) {
if (!hasText(dto.iccid)) throw new BusinessException('ICCID 不能为空');
if (!hasText(dto.phoneRealNameOwner)) throw new BusinessException('手机号实名人不能为空');
}
return { card_type: dto.cardType ?? null, iccid: dto.iccid ?? null, real_name_owner: dto.phoneRealNameOwner ?? null };
}
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 requireActiveAccount(tx: Tx, id: number) {
const entity = await tx.as_wecom_account.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new NotFoundException('企业微信资产不存在或已删除');
return entity;
}
private async requireActiveCompanyProfile(tx: Tx, id?: number | null) {
if (id == null) return;
const company = await tx.as_company_profile.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!company) throw new BusinessException('注册主体不存在或已删除');
}
private async requireActiveDevice(tx: Tx, id?: number | null) {
if (id == null) return;
const device = await tx.as_asset_device.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!device) throw new BusinessException('关联设备不存在或已删除');
}
private toResponse(entity: any, extra: { phoneNumber?: string | null; companyProfileName?: string | null; deviceName?: string | null; operatorPersonName?: string | null } = {}): WecomAccountResponse {
return {
id: Number(entity.id),
wecomName: entity.wecom_name,
wecomAlias: entity.wecom_alias,
wecomAccount: entity.wecom_account,
companyProfileId: entity.company_profile_id == null ? null : Number(entity.company_profile_id),
companyProfileName: extra.companyProfileName ?? null,
phoneAssetId: entity.phone_asset_id == null ? null : Number(entity.phone_asset_id),
phoneNumber: extra.phoneNumber ?? null,
phoneLinkMode: entity.phone_link_mode,
realNameOwner: entity.real_name_owner,
realNameOwnerStatus: entity.real_name_owner_status,
gender: entity.gender,
deviceId: entity.device_id == null ? null : Number(entity.device_id),
deviceName: extra.deviceName ?? null,
operatorPersonId: entity.operator_person_id == null ? null : Number(entity.operator_person_id),
operatorPersonName: extra.operatorPersonName ?? null,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
......@@ -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 text = await res.text();
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,
headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed),
}, 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