Commit 7ec26d20 by DaiJiezhang

feat: 建立 NestJS 迁移地基与契约基准

将后端从 Java Spring Boot 迁移至 TypeScript + NestJS 的第一阶段成果。
本次只新增代码与工具,不改动任何 Java 业务逻辑(行为冻结,保证契约对拍有效)。

契约基准(scripts/contract/)
- 录制 Java 后端 65 个响应快照,覆盖只读、错误路径、写操作、企微联动规则、
  开发者专属端点、设备图片上传六组场景
- 归一化抹去自增 id、时间戳、令牌等易变值,保留结构、字段顺序、类型与文案
- CONTRACT-NOTES.md 汇总 13 条实现要点,作为 NestJS 实现的唯一依据
- fixtures/ 内含定向构造的疑难图片(CMYK、EXIF 旋转、渐进式、大尺寸),
  覆盖 sharp 与 Java ImageIO 的已知差异点

NestJS 地基(backend-nest/)
- Prisma 只作只读映射:schema 由 db pull 从现有库反向生成,禁用 migrate
- 统一响应外壳、全局异常映射,文案与 Java 逐字一致
- 解决三处 Java/Node 固有差异:BigInt 主键无法 JSON 序列化、
  时间格式带时区与零毫秒、写入时本地时间被当作 UTC 而偏移 8 小时

Java 侧的两处必要改动
- application.yml 同时尝试两个 .env 路径,兼容 IDEA(工作目录为项目根)
  与命令行(工作目录为 backend/)两种启动方式
- 端口 7689 改为 7690,vite 代理同步更新;NestJS 迁移期占用 7691

迁移期不做的行为变更记录在 docs/migration-backlog.md。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 90002afb
...@@ -43,3 +43,7 @@ backend/uploads/ ...@@ -43,3 +43,7 @@ backend/uploads/
# 本地造数用的测试数据脚本,各人环境不同,不进仓库。 # 本地造数用的测试数据脚本,各人环境不同,不进仓库。
db-seed/ db-seed/
*.seed.sql *.seed.sql
# 契约快照含测试库真实业务数据(姓名、手机号),默认不进仓库。
# 需要团队共享基准时,改为只提交 error.* 这类不含个人信息的快照。
scripts/contract/snapshots/
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": { "deleteOutDir": true }
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "xyw-console-backend-nest",
"version": "0.0.1",
"private": true,
"description": "学有为资产后台 NestJS 后端(迁移自 Java Spring Boot)",
"scripts": {
"db:pull": "prisma db pull",
"db:generate": "prisma generate"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@nestjs/cli": "^11.0.24",
"@types/express": "^5.0.6",
"@types/node": "^26.2.0",
"dotenv": "^17.4.2",
"prisma": "^7.9.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "~5.7"
},
"dependencies": {
"@nestjs/common": "^11.2.1",
"@nestjs/core": "^11.2.1",
"@nestjs/platform-express": "^11.2.1",
"@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
}
}
// Prisma 7 起,连接串不再写在 schema 里,统一由本文件提供。
// 本项目只用 db pull(反向映射现有库),不使用 prisma migrate。
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: { url: process.env.DATABASE_URL },
});
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_asset_device {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
device_name String @db.VarChar(128)
image_attachment_1 String? @db.LongText
image_attachment_2 String? @db.LongText
user_person_id BigInt? @db.UnsignedBigInt
user_usage_status String? @db.VarChar(32)
asset_relation_status String? @db.VarChar(32)
@@unique([device_name, delete_time], map: "uk_asset_device_name_delete_time")
@@index([asset_relation_status], map: "idx_asset_device_relation_status")
@@index([user_person_id], map: "idx_asset_device_user_person_id")
@@index([user_usage_status], map: "idx_asset_device_user_usage_status")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_company_person {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
company_profile_id BigInt? @db.UnsignedBigInt
person_name String @db.VarChar(64)
employment_status String? @db.VarChar(32)
resigned_at DateTime? @db.DateTime(0)
@@index([company_profile_id, id], map: "idx_company_person_company_id_id")
@@index([employment_status], map: "idx_company_person_employment_status")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_company_profile {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
company_name String @db.VarChar(128)
short_name String? @db.VarChar(128)
unified_social_credit_code String? @db.VarChar(64)
address String? @db.VarChar(255)
contact_name String? @db.VarChar(64)
contact_value String? @db.VarChar(128)
@@unique([company_name, delete_time], map: "uk_company_profile_name_delete_time")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_domain_account {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
account_identifier String @db.VarChar(128)
phone_asset_id BigInt? @db.UnsignedBigInt
@@unique([account_identifier, delete_time], map: "uk_domain_account_identifier_delete_time")
@@index([phone_asset_id, id], map: "idx_domain_account_phone_id_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_domain_asset {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
domain_name String @db.VarChar(255)
domain_account_id BigInt? @db.UnsignedBigInt
company_profile_id BigInt? @db.UnsignedBigInt
expires_at DateTime? @db.DateTime(0)
@@index([domain_account_id, id], map: "idx_domain_asset_account_id_id")
@@index([company_profile_id], map: "idx_domain_asset_company_id")
@@index([expires_at], map: "idx_domain_asset_expires_at")
@@index([domain_name], map: "idx_domain_asset_name")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_douyin_account {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
douyin_id String? @db.VarChar(128)
real_name_owner String? @db.VarChar(64)
company_profile_id BigInt? @db.UnsignedBigInt
phone_asset_id BigInt? @db.UnsignedBigInt
device_id BigInt? @db.UnsignedBigInt
operator_person_id BigInt? @db.UnsignedBigInt
@@index([company_profile_id], map: "idx_douyin_account_company_id")
@@index([device_id], map: "idx_douyin_account_device_id")
@@index([douyin_id], map: "idx_douyin_account_douyin_id")
@@index([operator_person_id], map: "idx_douyin_account_operator_person_id")
@@index([phone_asset_id, id], map: "idx_douyin_account_phone_id_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_merchant {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
merchant_number String @db.VarChar(128)
company_profile_id BigInt? @db.UnsignedBigInt
phone_asset_id BigInt? @db.UnsignedBigInt
@@unique([merchant_number, delete_time], map: "uk_merchant_number_delete_time")
@@index([company_profile_id], map: "idx_merchant_company_id")
@@index([phone_asset_id, id], map: "idx_merchant_phone_id_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_phone_asset {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
phone_number String @db.Char(11)
card_type String? @db.VarChar(32)
iccid String? @db.VarChar(32)
real_name_owner String? @db.VarChar(64)
management_type String? @db.VarChar(32)
disposal_status String? @db.VarChar(32)
device_id BigInt? @db.UnsignedBigInt
linked_wecom_accounts Json?
linked_wechat_accounts Json?
linked_douyin_accounts Json?
linked_domain_accounts Json?
linked_merchants Json?
relation_synced_at DateTime? @db.DateTime(0)
number_type String? @db.VarChar(16)
source_asset_type String? @db.VarChar(32)
source_asset_id BigInt?
@@unique([phone_number, delete_time], map: "uk_phone_asset_phone_number_delete_time")
@@index([card_type], map: "idx_phone_asset_card_type")
@@index([device_id], map: "idx_phone_asset_device_id")
@@index([iccid], map: "idx_phone_asset_iccid")
@@index([source_asset_type, source_asset_id, delete_time], map: "idx_phone_asset_source")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_system_user {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
username String @unique(map: "uk_as_system_user_username") @db.VarChar(64)
password_hash String @db.VarChar(255)
role_code String @db.VarChar(32)
status String @default("ACTIVE") @db.VarChar(32)
page_permissions Json?
password_updated_at DateTime? @db.DateTime(0)
auth_version Int @default(1)
@@unique([username, delete_time], map: "uk_system_user_username_delete_time")
@@index([role_code, status], map: "idx_system_user_role_code_status")
@@index([status], map: "idx_system_user_status")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_wechat_account {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
wechat_id String? @db.VarChar(128)
real_name_owner String? @db.VarChar(64)
phone_asset_id BigInt? @db.UnsignedBigInt
device_id BigInt? @db.UnsignedBigInt
operator_person_id BigInt? @db.UnsignedBigInt
@@index([device_id], map: "idx_wechat_account_device_id")
@@index([operator_person_id], map: "idx_wechat_account_operator_person_id")
@@index([phone_asset_id, id], map: "idx_wechat_account_phone_id_id")
@@index([wechat_id], map: "idx_wechat_account_wechat_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model as_wecom_account {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
create_time DateTime? @db.DateTime(0)
update_time DateTime? @db.DateTime(0)
delete_time BigInt @default(0)
wecom_name String? @db.VarChar(128)
wecom_alias String? @db.VarChar(128)
company_profile_id BigInt? @db.UnsignedBigInt
wecom_account String? @db.VarChar(128)
phone_asset_id BigInt? @db.UnsignedBigInt
phone_link_mode String? @db.VarChar(16)
real_name_owner String? @db.VarChar(64)
real_name_owner_status String? @db.VarChar(32)
gender String? @db.VarChar(16)
device_id BigInt? @db.UnsignedBigInt
operator_person_id BigInt? @db.UnsignedBigInt
@@index([company_profile_id], map: "idx_wecom_account_company_id")
@@index([device_id], map: "idx_wecom_account_device_id")
@@index([wecom_name], map: "idx_wecom_account_name")
@@index([operator_person_id], map: "idx_wecom_account_operator_person_id")
@@index([phone_asset_id, id], map: "idx_wecom_account_phone_id_id")
}
/**
* 文件用途(白话):应用根模块,注册全局的响应包装与数据库连接。
* 关联文件:main.ts、common/*、health/health.controller.ts。
*/
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaService } from './common/prisma.service';
import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { HealthController } from './health/health.controller';
@Module({
controllers: [HealthController],
providers: [PrismaService, { provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
export class AppModule {}
/**
* 文件用途(白话):把所有异常转成与 Java 后端逐字一致的 JSON 响应。
* 关联文件:business.exception.ts、api-response.ts、backend/.../GlobalExceptionHandler.java。
* 关联逻辑(调用链):任意异常 -> 本过滤器 -> {code,message,data:null} -> 前端按 code 与 message 判定。
*
* 文案不能改:前端用 message === '请先登录' 判定会话过期并跳登录页,
* 用 payload.code !== 200 判定失败。任何一处措辞变化都会导致前端行为异常。
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { BusinessException } from './business.exception';
import { failure } from './api-response';
/** Prisma 唯一约束冲突的错误码,对应 Java 的 DuplicateKeyException。 */
const PRISMA_UNIQUE_VIOLATION = 'P2002';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(error: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const { status, message } = this.translate(error);
res.status(status).json(failure(status, message));
}
private translate(error: any): { status: number; message: string } {
// 业务异常:文案由 service 决定,逐字透传
if (error instanceof BusinessException) return { status: error.status, message: error.message };
// 唯一约束冲突:Java 按索引名区分手机号与其他,此处保持相同判断
if (error?.code === PRISMA_UNIQUE_VIOLATION) {
const target = JSON.stringify(error?.meta?.target ?? '');
const message = target.includes('phone_number') ? '手机号已存在,请勿重复新增' : '数据已存在,请勿重复提交';
return { status: 409, message };
}
if (error instanceof HttpException) {
const status = error.getStatus();
// 校验失败由 ValidationPipe 抛出,Java 侧统一为这句,不暴露具体字段
if (status === HttpStatus.BAD_REQUEST) return { status, message: '提交内容不符合要求,请检查后重试' };
if (status === HttpStatus.NOT_FOUND) return { status, message: '接口不存在,请确认前后端版本一致' };
if (status === HttpStatus.METHOD_NOT_ALLOWED) return { status, message: '接口不支持该请求方式,请确认前后端版本一致' };
if (status === HttpStatus.UNAUTHORIZED) return { status, message: '请先登录' };
if (status === HttpStatus.FORBIDDEN) return { status, message: '没有权限' };
if (status === HttpStatus.PAYLOAD_TOO_LARGE) return { status, message: '每张图片不能超过 20MB' };
}
// JSON 解析失败:Express 的 body-parser 抛 SyntaxError,对应 Java 的 HttpMessageNotReadableException
if (error instanceof SyntaxError && 'body' in (error as any)) return { status: 400, message: '请求格式不正确,请刷新页面后重试' };
console.error('[UNEXPECTED]', error?.stack ?? error);
return { status: 500, message: '服务器处理失败,请稍后重试' };
}
}
/**
* 文件用途(白话):把控制器的返回值自动包成 {code,message,data},并统一做 BigInt/日期转换。
* 关联文件:api-response.ts、serialize.ts。
* 关联逻辑(调用链):Controller 返回原始数据 -> 本拦截器 -> 统一外壳 -> 前端。
*
* 控制器若已经返回带 code 字段的完整外壳(例如需要自定义 message 的新增/删除接口),则原样放行,
* 避免出现 data 里再套一层 code 的双重包装。
*/
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { normalizeOutbound } from './serialize';
import { success } from './api-response';
@Injectable()
export class ApiResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((value) => {
const normalized = normalizeOutbound(value);
const alreadyWrapped = normalized && typeof normalized === 'object' && !Array.isArray(normalized) && typeof (normalized as any).code === 'number';
return alreadyWrapped ? normalized : success(normalized);
}));
}
}
/**
* 文件用途(白话):统一响应外壳,对应 Java 侧的 ApiResponse。
* 关联文件:common/ApiResponse.java、api-response.interceptor.ts、frontend/src/modules/auth/auth-api-client.js。
* 关联逻辑(数据流):Service 返回值 -> 拦截器包装 -> {code,message,data} -> 前端按 code 判定成败。
*
* 注意:前端判定成功的条件是 payload.code === 200,而不是 HTTP 状态码,
* 因此 code 字段必须始终存在且为数字。成功时 message 固定为 "success"。
*/
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T | null;
}
export const success = <T>(data: T, message = 'success'): ApiResponse<T> => ({ code: 200, message, data: data ?? null });
export const failure = (code: number, message: string): ApiResponse<null> => ({ code, message, data: null });
/**
* 文件用途(白话):业务规则不满足时抛出的异常,对应 Java 侧各模块抛的 IllegalArgumentException / 自定义校验异常。
* 关联文件:all-exceptions.filter.ts、各模块 service。
* 关联逻辑(调用链):Service 校验失败 -> 抛出本异常 -> 全局过滤器 -> {code,message,data:null}。
*
* Java 侧这类异常由各模块的 *ExceptionHandler 统一转成 400 + 具体业务文案(如「ICCID 不能为空」),
* 这些文案前端会直接展示,属于契约的一部分,必须逐字保持。
*/
export class BusinessException extends Error {
constructor(message: string, readonly status = 400) { super(message); this.name = 'BusinessException'; }
}
/** 权限不足,对应 Java 的 AccessDeniedException,统一 403。 */
export class ForbiddenException extends BusinessException {
constructor(message = '没有权限') { super(message, 403); this.name = 'ForbiddenException'; }
}
/** 未登录或会话失效,对应 Java SecurityConfig 的 authenticationEntryPoint,文案前端精确匹配。 */
export class UnauthorizedException extends BusinessException {
constructor(message = '请先登录') { super(message, 401); this.name = 'UnauthorizedException'; }
}
/**
* 文件用途(白话):把数据库时间转成 Java 后端那种格式输出,以及把"现在"按 Java 的方式写回库。
* 关联文件:serialize.ts、prisma.service.ts、scripts/contract/CONTRACT-NOTES.md 第 13 条。
* 关联逻辑(数据流):Prisma Date -> formatJavaLocalDateTime() -> 响应 JSON;now() -> Prisma -> MySQL datetime。
*
* 背景(实测得出,不是推测):
* 数据库存的 2026-08-01 18:39:41 MySQL datetime,本身没有时区信息
* Java 读出来 2026-08-01T18:39:41 数值原样,不带时区、不带零毫秒
* Prisma 读出来 2026-08-01T18:39:41.000Z 数值一样,但被贴上了 UTC 标签
*
* 因此读取时必须用 getUTC* 系列取值——数值本来就是墙上时间,用本地方法会平移 8 小时。
*/
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
/**
* 代码作用(白话):把 Date 输出成 Java 那种无时区字符串,毫秒为 0 时省略。
* 为什么毫秒要区别对待:Jackson 序列化 LocalDateTime 时毫秒为 0 就不输出,
* 于是"新建返回值"带毫秒(内存里的 now())、"列表读取"不带(MySQL 只存到秒)。
* 统一带或统一不带,都会有一半场景对不上契约。
*/
export function formatJavaLocalDateTime(value: Date | null | undefined): string | null {
if (!value) return null;
const y = value.getUTCFullYear();
const base = `${y}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}T${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
const ms = value.getUTCMilliseconds();
return ms === 0 ? base : `${base}.${pad(ms, 3)}`;
}
/**
* 代码作用(白话):取当前"墙上时间",构造成能被 Prisma 原样写进 MySQL 的 Date。
* 关联逻辑:Java 用 LocalDateTime.now() 存本地时间;Node 的 new Date() 交给 Prisma 会按 UTC 存,
* 北京时间 20:00 会变成库里的 12:00。这里先把本地时间的各字段搬进 UTC 字段,抵消这 8 小时。
*/
export function nowForDatabase(): Date {
const n = new Date();
return new Date(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate(), n.getHours(), n.getMinutes(), n.getSeconds(), n.getMilliseconds()));
}
/** 代码作用(白话):把外部传入的日期字符串按同样规则转成可写库的 Date。 */
export function toDatabaseDate(value: string | Date | null | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return value;
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d+))?/.exec(value);
if (!m) return null;
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] ?? 0), +((m[7] ?? '0').padEnd(3, '0').slice(0, 3))));
}
/**
* 文件用途(白话):全应用共用一个数据库连接,并在应用关闭时干净地断开。
* 关联文件:prisma/schema.prisma、app.module.ts。
* 关联逻辑(数据流):环境变量 DATABASE_URL -> mariadb adapter -> PrismaClient -> 各模块 service。
*
* 本项目只把 Prisma 当作访问层:schema 由 `prisma db pull` 从现有库反向生成,
* 禁止执行 prisma migrate 改动表结构(Java 后端仍在使用同一套表)。
*/
import { INestApplication, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('缺少 DATABASE_URL,请检查 backend-nest/.env');
super({ adapter: new PrismaMariaDb(url) });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
/**
* 文件用途(白话):把 Prisma 查出来的对象转成能直接 JSON 输出、且格式与 Java 一致的普通对象。
* 关联文件:datetime.ts、api-response.interceptor.ts。
* 关联逻辑(数据流):Prisma 结果 -> normalizeOutbound() -> 响应拦截器 -> JSON。
*
* 处理两类值:
* 1. BigInt:主键与外键在 Prisma 里都是 bigint,JSON.stringify(1n) 会直接抛异常。
* Java 的 Long 序列化出来是普通数字,因此这里转成 number 保持一致。
* 2. Date:按 Java 的无时区格式输出,详见 datetime.ts 的说明。
*/
import { formatJavaLocalDateTime } from './datetime';
/** id 超过该值转 number 会丢精度;本系统自增主键远未达到,越界时抛错而不是静默出错。 */
const SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER);
export function normalizeOutbound(value: any): any {
if (value === null || value === undefined) return value ?? null;
if (typeof value === 'bigint') {
if (value > SAFE_MAX || value < -SAFE_MAX) throw new Error(`BigInt ${value} 超出安全整数范围,转 number 会丢精度`);
return Number(value);
}
if (value instanceof Date) return formatJavaLocalDateTime(value);
if (Array.isArray(value)) return value.map(normalizeOutbound);
if (typeof value === 'object') {
// Buffer/Decimal 等带自定义原型的对象原样返回,避免被拆成普通对象
if (value.constructor && value.constructor !== Object) return value;
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) out[k] = normalizeOutbound(v);
return out;
}
return value;
}
/**
* 文件用途(白话):地基自检接口,用来验证响应包装、类型转换、数据库连通是否都正常。
* 关联文件:prisma.service.ts、api-response.interceptor.ts、serialize.ts。
* 关联逻辑(调用链):GET /api/health -> 查一条真实记录 -> 经拦截器序列化 -> 与 Java 快照对照格式。
*
* 它刻意返回真实的 bigint 主键和 datetime 字段——这两类值正是最容易出错的地方,
* 只要这个接口的输出格式正确,就说明地基对了。
*/
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException } from '../common/business.exception';
@Controller('api/health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async check() {
const account = await this.prisma.as_wecom_account.findFirst({ where: { delete_time: 0n }, orderBy: { id: 'asc' } });
return {
ok: true,
sampleId: account?.id ?? null,
sampleCreateTime: account?.create_time ?? null,
sampleUpdateTime: account?.update_time ?? null,
};
}
/** 用来验证业务异常是否被转成与 Java 一致的响应。 */
@Get('boom')
boom() { throw new BusinessException('ICCID 不能为空'); }
/** 用来验证未预期异常是否被兜底成 500 与统一文案。 */
@Get('crash')
crash() { throw new Error('deliberate failure'); }
}
/**
* 文件用途(白话):应用启动入口,设定端口、跨域、请求体上限与全局异常处理。
* 关联文件:app.module.ts、common/all-exceptions.filter.ts、backend/.../WebConfig.java、DeviceAssetMultipartConfig.java。
* 关联逻辑(数据流):浏览器 -> CORS 校验 -> 全局管道/过滤器 -> 控制器。
*
* 端口用 7691:迁移期 Java 占 7690,两边并行以便逐接口对拍;最终切换时再接管 7690。
*/
import 'dotenv/config';
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/all-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: true });
// CORS 白名单与 Java 侧 WebConfig 保持一致,凭据必须放行否则会话 Cookie 不会带上
app.enableCors({
origin: ['http://localhost:8000', 'http://127.0.0.1:8000', 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:5175', 'http://127.0.0.1:5175'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new AllExceptionsFilter());
const port = Number(process.env.PORT ?? 7691);
await app.listen(port);
console.log(`NestJS 已启动: http://127.0.0.1:${port}`);
}
bootstrap();
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2023",
"lib": ["ES2023"],
"moduleResolution": "node",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
...@@ -2,7 +2,11 @@ spring: ...@@ -2,7 +2,11 @@ spring:
application: application:
name: xyw-console-backend name: xyw-console-backend
config: config:
import: optional:file:.env[.properties] # 两条都写:IDEA 默认工作目录是项目根,命令行/Maven 通常是 backend/。
# 只写一条时,另一种启动方式会读不到 .env,且因数据源懒加载而在首次查库时才暴露成 500。
import:
- optional:file:.env[.properties]
- optional:file:backend/.env[.properties]
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
url: ${XYW_DB_URL} url: ${XYW_DB_URL}
...@@ -10,7 +14,7 @@ spring: ...@@ -10,7 +14,7 @@ spring:
password: ${XYW_DB_PASSWORD} password: ${XYW_DB_PASSWORD}
server: server:
port: 7689 port: 7690
xyw: xyw:
auth: auth:
......
# 迁移待办
重构期间**不做**的变更,全部记在这里,等 NestJS 迁移完成、契约全绿之后再逐项处理。
这样做的原因:契约对拍要求 Java 与 NestJS 行为完全一致,任何行为变更都会让基准失效。
---
## 1. 企微自动创建的手机号不再连带删除
**现状**:企微账号绑定一个台账里没有的手机号时,系统自动在 `as_phone_asset` 建一条
`number_type = EXTERNAL` 的记录;之后改绑或删除该企微账号时,这条记录会被软删。
**目标**:自动创建的号视同手动新建,一律留在台账。
已确认的三条决策(2026-08-18):
| 项 | 决定 |
|---|---|
| 修改时机 | 迁移完成后,在 NestJS 上改(重构期行为冻结) |
| `number_type` 取值 | 直接存 `SELF`,与手动新建完全一致 |
| 删除企微账号时 | 号码同样保留,不连带删除 |
**受影响的代码位置**(当前 Java 侧):
- `WecomAccountService` 改绑分支的软删逻辑(约 184 行)
- `WecomAccountService` 删除账号时的连带清理
- 5 张表的引用检查(`hasOtherReferences`)在新规则下基本失去作用,需重新评估是否保留
**受影响的测试**(迁移为 Jest 后需同步修改):
- `updateSoftDeletesThePhoneItCreatedEarlier`
- `softDeleteAlsoRemovesTheAutoCreatedPhone`
- `updateKeepsThePhoneWhenAnotherAssetStillReferencesIt`
- `softDeleteKeepsAReusedPhoneAsset`(行为不变,但断言前提要复核)
---
## 2. SUPER_ADMIN 创建账号必然 500
**现象**:以 SUPER_ADMIN 身份调 `POST /api/system-users` 一定返回 500。
**根因**`SystemUserAdminService.createUser` 在操作者是 SUPER_ADMIN 时执行
`user.setPasswordHash(null)`,而 `as_system_user.password_hash``NOT NULL`,插入直接失败。
**设计意图**(从 `validateRequestedStatus` 的文案「请由开发者先设置密码后再启用账号」推断):
超管建号时留空密码并置为 `DISABLED`,等开发者再设密码。意图合理,但数据库列约束没有配合。
**决定**:迁移期原样保留(NestJS 同样返回 500),契约快照 `write.system-user.create.json` 记录现状。
迁移完成后单独修复,可选方案:列改可空 / 存空字符串 / 改为拒绝并返回明确提示。
---
## 3. 设备图片上传目录用相对路径
`xyw.device-assets.upload-dir` 默认 `./uploads/device-assets`,相对**工作目录**
IDEA(工作目录=项目根)和命令行(工作目录=backend/)会写到两个不同的地方,
这很可能就是"数据库有 30 条图片记录、磁盘上一个文件都没有"的原因。
**决定**:迁移到 NestJS 时改为绝对路径配置,从根上消除工作目录依赖。
---
## 4. 30 条设备记录的图片字段是死链
`as_asset_device` 里约 30 行的 `image_attachment_1/2` 存着文件名,但磁盘上的文件已在一次清盘中丢失。
**当前决定(2026-08-18):迁移期保持不动。**
理由:
- 清空不可逆,若日后从备份找回图片,字段还在才能恢复
- 这批"记录存在、文件缺失"的数据正好是天然测试样本,可验证 NestJS 遇到死链时
是否同样返回预期响应、前端是否正常回落到占位图
代价:列表页每次会发出几十个必然失败的图片请求(前端 `img.onerror` 兜底,用户无感)。
迁移完成并确认图片无恢复可能后,再执行清理(把字段置空即可,设备记录本身不动)。
...@@ -3,7 +3,7 @@ import { defineConfig } from 'vite'; ...@@ -3,7 +3,7 @@ import { defineConfig } from 'vite';
/** /**
* 代码作用(白话):把前端固定部署在 /asset/ 前缀下,并把浏览器的 /api 请求转发给本机后端,避免接口请求被 Vite 回退成 HTML 页面。 * 代码作用(白话):把前端固定部署在 /asset/ 前缀下,并把浏览器的 /api 请求转发给本机后端,避免接口请求被 Vite 回退成 HTML 页面。
* 关联文件:frontend/src/router/index.js、frontend/src/modules/phone/phone-api-client.js、backend/src/main/resources/application.yml。 * 关联文件:frontend/src/router/index.js、frontend/src/modules/phone/phone-api-client.js、backend/src/main/resources/application.yml。
* 关联逻辑(调用链/消息链/数据流):/asset/#/phone-assets -> Vue Hash 路由;/api/phone-assets -> Vite proxy -> 127.0.0.1:7689 后端接口。 * 关联逻辑(调用链/消息链/数据流):/asset/#/phone-assets -> Vue Hash 路由;/api/phone-assets -> Vite proxy -> 127.0.0.1:7690 后端接口。
*/ */
export default defineConfig({ export default defineConfig({
base: '/asset/', base: '/asset/',
...@@ -17,7 +17,7 @@ export default defineConfig({ ...@@ -17,7 +17,7 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:7689', target: 'http://127.0.0.1:7690',
changeOrigin: true changeOrigin: true
} }
} }
......
# 契约要点
从 Java 后端实录出来的行为规则,NestJS 实现时逐条对照。
每条都有对应快照可查,快照在 `snapshots/`(不进仓库,用 `node scripts/contract/record.mjs` 重新生成)。
## 1. 响应包装
```json
{ "code": 200, "message": "success", "data": ... }
```
- 成功时 `message` 固定是 `success`
- **前端判定成功的条件是 `payload.code === 200`,不是 HTTP 状态码**——HTTP 200 但 code 非 200 也算失败
- 前端只从 `payload.data` 取数据
## 2. 时间格式(最容易错)
统一形状 `YYYY-MM-DDTHH:mm:ss`**无时区、无 Z 后缀**。毫秒为 0 时省略:
| 场景 | 实际输出 |
|---|---|
| 新建/更新的返回值(内存里的 `now()`) | `2026-08-18T20:30:15.123` 带毫秒 |
| 列表/详情读取(MySQL datetime 精度到秒) | `2026-08-18T20:30:15` 不带毫秒 |
NestJS 若直接序列化 Prisma 的 Date,会输出 `2026-08-18T12:30:15.000Z`**三处都不对**(多了时区、多了零毫秒)。
需要自定义序列化:本地时间、无时区、毫秒为 0 则省略。
## 3. 两套 Cookie,属性完全不同
```
XYW_SESSION=<jwt>; Path=/; Max-Age=28800; Expires=...; HttpOnly; SameSite=Lax
XSRF-TOKEN=<uuid>; Path=/
```
- 会话 Cookie 有 HttpOnly + SameSite=Lax,`Max-Age=28800` 对应配置 `session-hours: 8`
- CSRF Cookie **不能**有 HttpOnly(前端要 `document.cookie` 读),也没有 SameSite
- 两者都没有 Secure(因为 `cookie-secure=false`;HTTPS 部署时才加)
- `/api/auth/csrf` 的关键作用是 Set-Cookie,前端不读返回体里的 token
## 4. 错误文案(前端精确匹配,一字不能改)
| 场景 | 状态 | message |
|---|---|---|
| 未登录 | 401 | `请先登录` ← 前端靠这个字符串判定会话过期并跳登录页 |
| 密码错误 / 账号不存在 | 401 | `账号或密码错误` |
| CSRF 失效 | 403 | `安全校验已失效,请刷新页面后重试` |
| 参数校验失败 | 400 | `提交内容不符合要求,请检查后重试` |
| 未捕获异常 | 500 | `服务器处理失败,请稍后重试` |
Service 层另有约 50 条业务文案(如 `ICCID 不能为空``设备名称已存在`),
**全部不在 DTO 注解里**,只看 DTO 会全部漏掉。
## 5. 成功文案不统一(原样保留,勿"顺手统一")
```
company-profile.update → 修改成功
company-person.update → 修改成功
phone-asset.update → 编辑成功 ← 只有它不一样
create → 新增成功 delete → 删除成功
```
## 6. 空值语义
`null``""` 在同一条记录里共存且含义不同,逐字段照抄,不要统一转换。
归一化快照里分别是 `<NULL>``<EMPTY_STRING>`
## 7. 分页
- 结构:`data.records[] / total / page / size`
- **`size=200` 返回 400**`提交内容不符合要求`),上限在 100 附近
- `page` 超出范围返回空 records,不报错
## 8. 枚举值
| 字段 | 合法值 |
|---|---|
| `roleCode` | `SUPER_ADMIN` `FINANCE` `HR` `OPERATIONS``DEVELOPER` 禁止创建,仅固定账号 Jeddy) |
| 页面权限值 | `EDIT` `READ` `NONE` |
| 账号状态 | `ACTIVE` `DISABLED` |
| `userUsageStatus` | `使用中` `闲置` `维修中` `停用` |
| `assetRelationStatus` | `已关联` `未关联` `待确认` |
| `phoneLinkMode`(企微表) | `CREATED` `EXISTING` |
| `numberType`(手机资产表) | `SELF` `EXTERNAL` |
注意最后两行是**两张表的两个字段**,别混用。
## 9. 图片
- 上传字段:`imageAttachment1` / `imageAttachment2`(multipart,**不设 Content-Type**,由 runtime 生成 boundary)
- 响应返回完整 URL 而非裸标识:
`imageAttachment1Url` = `/api/device-assets/files/<uuid>.png`
`imageAttachment1ThumbUrl` = 同一路径 + `?variant=thumb`
- 落盘:原图保留原扩展名,缩略图统一 `.thumb.jpg`,一一配对
- 上限:单文件 20MB、整请求 42MB
- 实测 Java 侧**接受**这些边缘格式:CMYK JPG、带 EXIF Orientation 的 JPG、渐进式 PNG、6000×6000 大图。
NestJS 必须同样接受(夹具在 `fixtures/`
- 文本改扩展名伪装成 PNG → 400
- 上传目录 `./uploads/device-assets` 相对**工作目录**,IDEA 启动时落在项目根
## 10. 允许存在差异的地方
契约不是所有项都必须一致。以下差异经确认可接受,原因记录在此:
| 项 | 差异 | 原因 |
|---|---|---|
| 图片路径穿越 / 文件不存在的响应 | 状态码和响应体可能不同 | 前端用 `img.onerror` 兜底,完全不读响应内容;只需保证两边都拒绝 |
## 11. 已知缺陷(迁移期原样保留)
- SUPER_ADMIN 调 `POST /api/system-users` 必然 500(`password_hash` NOT NULL 但代码写入 null),
只有 DEVELOPER 能成功。详见 `docs/migration-backlog.md`
## 12. Prisma 映射带来的两个必须处理项
**BigInt 序列化**
所有主键和外键在 Prisma 里是 `BigInt`(对应 MySQL `bigint unsigned`)。
JavaScript 的 `BigInt` 无法被 `JSON.stringify` 序列化,会直接抛异常。
Java 侧 `Long` 序列化出来是普通数字(`"id": 4`),必须在 NestJS 全局做同样的转换,
否则任何返回带 id 的接口都会 500。
**唯一约束是组合键,不是单列**
```prisma
@@unique([username, delete_time]) // as_system_user
@@unique([device_name, delete_time]) // as_asset_device
```
含义:同一个名字允许存在多条已删除记录 + 最多一条存活记录。
**软删之后,同名可以重新创建。** 若在 NestJS 里写成"名称单列唯一",
会出现"删掉了却仍提示已存在"的 bug。查重时必须带上 `delete_time = 0` 条件。
## 13. 时间的时区处理(实测结论)
三方对照(`as_system_user` id=1):
```
数据库存储 2026-08-01 18:39:41 MySQL datetime,无时区信息
Java 读取返回 2026-08-01T18:39:41 数值原样,无时区标记
Prisma 读取 2026-08-01T18:39:41.000Z 数值原样,但被标记为 UTC
```
**读取方向**:数值没有偏移,Prisma 只是给 naive datetime 贴了 UTC 标签。
因此序列化时必须用 `getUTCFullYear/getUTCHours/...` 取值再拼成无时区字符串。
若误用本地时间方法(`getHours``toLocaleString`),会平移 +8 小时。
**写入方向(风险更高)**
- Java `LocalDateTime.now()` 存的是本地时间(北京 20:00 → 库里 `20:00`
- Node `new Date()` 经 Prisma 会按 UTC 存(北京 20:00 → 库里 `12:00`
不处理则新建记录的时间全部偏移 8 小时,且**不会报任何错**
写入前必须显式构造"UTC 字段值等于本地墙上时间"的 Date。
统一在框架层做,不要交给各个 service 自行转换。
/**
* 文件用途(白话):用开发者账号录制只有 DEVELOPER 才能成功的两个端点(建账号、重置密码)。
* 关联文件:write-flows.mjs、SystemUserAdminService.java。
* 关联逻辑(数据流):Jeddy 登录 -> 建测试账号 -> 重置其密码 -> 查 auth_version 是否被触发器自增 -> 快照。
*
* 为什么单独一个脚本:SUPER_ADMIN 建号必然 500(password_hash NOT NULL 的已知缺陷),
* 这两条成功路径只有开发者身份能走通,凭据也单独存放,避免和常规录制混在一起。
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { MARK } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, 'snapshots');
const BASE = process.argv.includes('--base') ? process.argv[process.argv.indexOf('--base') + 1] : 'http://127.0.0.1:7690';
const RUN = String(Date.now()).slice(-6);
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
let xsrf, session;
async function api(method, path, body, snapshot) {
const headers = { Cookie: `${session}; XSRF-TOKEN=${xsrf}` };
if (method !== 'GET') { headers['X-XSRF-TOKEN'] = xsrf; headers['Content-Type'] = 'application/json'; }
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, `dev.${snapshot}.json`), JSON.stringify({
request: { method, path, body: body ? { ...body, password: body.password ? '<PASSWORD>' : undefined } : null },
status: res.status, headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed),
}, null, 2) + '\n', 'utf8');
return { status: res.status, body: parsed };
}
{
const c = await fetch(`${BASE}/api/auth/csrf`);
xsrf = c.headers.getSetCookie().join(';').match(/XSRF-TOKEN=([^;]+)/)[1];
const l = await fetch(`${BASE}/api/auth/login`, { method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-dev.username'], password: env['xyw.contract-dev.password'] }) });
if (l.status !== 200) throw new Error(`开发者登录失败 ${l.status}: ${await l.text()}`);
session = l.headers.getSetCookie().map(x => x.split(';')[0]).join('; ');
writeFileSync(join(OUT, 'dev.login.json'), JSON.stringify({
request: { method: 'POST', path: '/api/auth/login', body: { username: '<DEVELOPER>', password: '<PASSWORD>' } },
status: l.status, headers: normalizeHeaders(l.headers, l.headers.getSetCookie()), body: normalizeBody(await l.clone().json()),
}, null, 2) + '\n', 'utf8');
}
mkdirSync(OUT, { recursive: true });
const results = [];
const check = (n, a, e) => results.push([n, a, e, JSON.stringify(a) === JSON.stringify(e)]);
// ---- 开发者建账号:走 else 分支,会设置密码、状态 ACTIVE ----
const username = `${MARK}dev${RUN}`;
const created = await api('POST', '/api/system-users', {
username, roleCode: 'OPERATIONS', password: 'Contract#Test2026', pagePermissions: { overview: 'READ' },
}, 'system-user.create.byDeveloper');
check('开发者建账号成功', created.status, 200);
check('新账号状态', created.body?.data?.status, 'ACTIVE');
const newId = created.body?.data?.id;
if (newId) {
// ---- 重置密码:触发器应让 auth_version 自增,password_updated_at 被写入 ----
const before = (await api('GET', '/api/system-users')).body?.data?.find(u => u.id === newId);
const reset = await api('PUT', `/api/system-users/${newId}/password`, { password: 'Contract#Reset2026' }, 'system-user.resetPassword');
check('重置密码成功', reset.status, 200);
const after = (await api('GET', '/api/system-users')).body?.data?.find(u => u.id === newId);
check('触发器写入 password_updated_at', after?.passwordUpdatedAt != null && before?.passwordUpdatedAt == null, true);
// ---- 开发者角色不可创建,验证边界文案 ----
await api('POST', '/api/system-users', { username: `${MARK}d2${RUN}`, roleCode: 'DEVELOPER', password: 'Contract#Test2026', pagePermissions: {} }, 'system-user.create.developerRejected');
}
const bad = results.filter(r => !r[3]);
console.log(`开发者端点录制:${results.length - bad.length}/${results.length} 通过,运行序号 ${RUN}`);
for (const [n, a, e, ok] of results) console.log(` ${ok ? '✅' : '❌'} ${n.padEnd(28)} 实际=${JSON.stringify(a)} 期望=${JSON.stringify(e)}`);
/**
* 文件用途(白话):录制设备模块的图片上传契约,覆盖正常上传、各类畸形图片的拒绝、以及缩略图生成。
* 关联文件:fixtures/*、DeviceAssetController.java、DeviceAssetFileStorageService.java。
* 关联逻辑(数据流):夹具图片 -> multipart 上传 -> 落盘原图+缩略图 -> 回读文件接口 -> 快照。
*
* 夹具是定向构造的:CMYK、EXIF 旋转、渐进式、大尺寸这几类,正是 sharp 与 Java ImageIO
* 处理策略最容易分歧的地方。真实历史图片已在清盘中丢失,用构造样本做定向覆盖更彻底。
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { MARK } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, 'snapshots');
const FIX = join(here, 'fixtures');
const BASE = process.argv.includes('--base') ? process.argv[process.argv.indexOf('--base') + 1] : 'http://127.0.0.1:7690';
const RUN = String(Date.now()).slice(-6);
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
let xsrf, session;
function form(deviceName, file, fileName) {
const fd = new FormData();
fd.append('deviceName', deviceName);
fd.append('userUsageStatus', '使用中');
fd.append('assetRelationStatus', '已关联');
if (file) fd.append('imageAttachment1', new Blob([file]), fileName);
return fd;
}
async function send(method, path, body, snapshot, isForm = true) {
const headers = { Cookie: `${session}; XSRF-TOKEN=${xsrf}`, 'X-XSRF-TOKEN': xsrf };
// multipart 刻意不设 Content-Type:交给 runtime 生成 boundary,与前端行为一致
const res = await fetch(`${BASE}${path}`, { method, headers, body });
const text = await res.text();
let parsed; try { parsed = JSON.parse(text); } catch { parsed = `<NON_JSON:${text.slice(0, 60)}>`; }
if (snapshot) writeFileSync(join(OUT, `device.${snapshot}.json`), JSON.stringify({
request: { method, path, body: isForm ? '<MULTIPART>' : (body ?? null) },
status: res.status, headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed),
}, null, 2) + '\n', 'utf8');
return { status: res.status, body: parsed };
}
{
const c = await fetch(`${BASE}/api/auth/csrf`);
xsrf = c.headers.getSetCookie().join(';').match(/XSRF-TOKEN=([^;]+)/)[1];
const l = await fetch(`${BASE}/api/auth/login`, { method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-test.username'], password: env['xyw.contract-test.password'] }) });
if (l.status !== 200) throw new Error(`登录失败 ${l.status}`);
session = l.headers.getSetCookie().map(x => x.split(';')[0]).join('; ');
}
mkdirSync(OUT, { recursive: true });
const results = [];
const check = (n, a, e) => results.push([n, a, e, JSON.stringify(a) === JSON.stringify(e)]);
const created = [];
// ---- 逐个夹具上传,记录每种图片被接受还是拒绝 ----
const cases = [
['normal.png', '基准 PNG', 200],
['normal.jpg', '基准 JPG', 200],
['normal.gif', '基准 GIF', 200],
['cmyk.jpg', 'CMYK JPG', null], // null = 不预设期望,录现状
['exif-rotate.jpg', 'EXIF 旋转', null],
['progressive.png', '渐进式 PNG', null],
['large-6000.png', '6000×6000', null],
['fake.png', '文本伪装 PNG', 400],
];
for (const [file, desc, expect] of cases) {
if (!existsSync(join(FIX, file))) { results.push([`${desc} 夹具缺失`, 'MISSING', 'exists', false]); continue; }
const buf = readFileSync(join(FIX, file));
const r = await send('POST', '/api/device-assets', form(`${MARK}${file.replace(/\W/g, '')}${RUN}`, buf, file), `upload.${file.replace(/\./g, '_')}`);
if (expect !== null) check(`${desc} 上传`, r.status, expect);
else results.push([`${desc} 上传(记录现状)`, r.status, '—', true]);
if (r.status === 200 && r.body?.data?.id) created.push({ id: r.body.data.id, file, data: r.body.data });
}
// ---- 超过 20MB:运行时生成,不进仓库 ----
{
const big = Buffer.alloc(21 * 1024 * 1024, 0x41);
const r = await send('POST', '/api/device-assets', form(`${MARK}big${RUN}`, big, 'oversize.png'), 'upload.oversize');
check('21MB 超限被拒', r.status >= 400, true);
}
// ---- 路径穿越与不存在的文件标识 ----
for (const [name, id] of [['traversal', '..%2F..%2Fapplication.yml'], ['dotdot', '../application.yml'], ['missing', '__not_exists__.png']]) {
const r = await send('GET', `/api/device-assets/files/${id}`, undefined, `file.${name}`, false);
check(`文件接口拒绝 ${name}`, r.status >= 400, true);
}
// ---- 正常上传后:原图与缩略图都应可读 ----
const ok = created.find(c => c.file === 'normal.png');
if (ok) {
// 响应给的是完整 URL(/api/device-assets/files/<uuid>.png),不是裸标识
const idf = (ok.data.imageAttachment1Url ?? '').split('/').pop() || null;
results.push([`上传返回的图片标识`, typeof idf === 'string' ? idf.replace(/^[0-9a-f-]{36}/, '<UUID>') : String(idf), '—', true]);
if (typeof idf === 'string') {
const orig = await send('GET', `/api/device-assets/files/${idf}`, undefined, 'file.original', false);
const thumb = await send('GET', `/api/device-assets/files/${idf}?variant=thumb`, undefined, 'file.thumbnail', false);
check('原图可读', orig.status, 200);
check('缩略图可读', thumb.status, 200);
}
}
// ---- 清理:删掉本次创建的设备 ----
for (const c of created) await send('DELETE', `/api/device-assets/${c.id}`, undefined, undefined, false);
const bad = results.filter(r => !r[3]);
console.log(`设备契约录制:${results.length - bad.length}/${results.length} 通过,运行序号 ${RUN}`);
for (const [n, a, e, o] of results) console.log(` ${o ? '✅' : '❌'} ${String(n).padEnd(26)} 实际=${JSON.stringify(a)}${e === '—' ? '' : ' 期望=' + JSON.stringify(e)}`);
/**
* 文件用途(白话):列出所有需要录制契约的接口,一处定义、录制与比对共用。
* 关联文件:record.mjs、compare.mjs、backend/src/main/java/com/xyw/console/**Controller.java。
* 关联逻辑(数据流):本清单 -> record.mjs 逐个请求 Java 后端 -> snapshots/*.json -> NestJS 比对基准。
*
* 分组说明:read 组零副作用可反复跑;write 组会在测试库留下数据,统一用 MARK 前缀便于事后清理。
*/
export const MARK = '__contract__';
/** 只读接口:不改变任何数据,可随时重复录制。 */
export const readEndpoints = [
{ name: 'auth.csrf', method: 'GET', path: '/api/auth/csrf', anonymous: true },
{ name: 'auth.me.anonymous', method: 'GET', path: '/api/auth/me', anonymous: true, expect: 401 },
{ name: 'auth.me', method: 'GET', path: '/api/auth/me' },
{ name: 'company-profiles.page', method: 'GET', path: '/api/company-profiles?page=1&size=10' },
{ name: 'company-persons.page', method: 'GET', path: '/api/company-persons?page=1&size=10' },
{ name: 'company-persons.lookup.profiles', method: 'GET', path: '/api/company-persons/lookups/company-profiles?keyword=' },
{ name: 'phone-assets.page', method: 'GET', path: '/api/phone-assets?page=1&size=10' },
{ name: 'phone-assets.lookup.devices', method: 'GET', path: '/api/phone-assets/lookups/devices?keyword=' },
{ name: 'device-assets.page', method: 'GET', path: '/api/device-assets?page=1&size=10' },
{ name: 'device-assets.lookup.persons', method: 'GET', path: '/api/device-assets/lookups/company-persons?keyword=' },
{ name: 'device-assets.lookup.nextName',method: 'GET', path: '/api/device-assets/lookups/next-device-name?prefix=' },
{ name: 'device-assets.file.missing', method: 'GET', path: '/api/device-assets/files/__not_exists__.png', expectAny: true },
{ name: 'wecom-accounts.page', method: 'GET', path: '/api/wecom-accounts?page=1&size=10' },
{ name: 'wecom-accounts.lookup.profiles', method: 'GET', path: '/api/wecom-accounts/lookups/company-profiles?keyword=' },
{ name: 'wecom-accounts.lookup.phones', method: 'GET', path: '/api/wecom-accounts/lookups/phone-assets?keyword=' },
{ name: 'wecom-accounts.lookup.phoneExists', method: 'GET', path: '/api/wecom-accounts/lookups/phone-exists?phoneNumber=13800138023' },
{ name: 'wecom-accounts.lookup.devices',method: 'GET', path: '/api/wecom-accounts/lookups/devices?keyword=' },
{ name: 'wecom-accounts.lookup.persons',method: 'GET', path: '/api/wecom-accounts/lookups/company-persons?keyword=' },
// 分页边界:实测 size=200 会返回空 data,上限行为必须在 NestJS 侧复现
{ name: 'phone-assets.page.size100', method: 'GET', path: '/api/phone-assets?page=1&size=100' },
{ name: 'phone-assets.page.sizeOver', method: 'GET', path: '/api/phone-assets?page=1&size=200', expectAny: true },
{ name: 'phone-assets.page.pageOver', method: 'GET', path: '/api/phone-assets?page=9999&size=10', expectAny: true },
{ name: 'system-users.list', method: 'GET', path: '/api/system-users' },
{ name: 'system-users.lockedAccounts', method: 'GET', path: '/api/system-users/locked-accounts' },
];
/** 错误路径:契约里最容易被改坏、前端又精确依赖的部分。 */
export const errorEndpoints = [
{ name: 'error.401.noSession', method: 'GET', path: '/api/wecom-accounts?page=1&size=10', anonymous: true, expect: 401 },
{ name: 'error.400.emptyLoginBody', method: 'POST', path: '/api/auth/login', anonymous: true, body: {}, expect: 400 },
{ name: 'error.401.wrongPassword', method: 'POST', path: '/api/auth/login', anonymous: true, body: { username: 'User', password: '__wrong__' }, expect: 401 },
{ name: 'error.401.unknownUser', method: 'POST', path: '/api/auth/login', anonymous: true, body: { username: '__nobody__', password: 'x' }, expect: 401 },
{ name: 'error.403.csrfMissing', method: 'POST', path: '/api/company-profiles', skipCsrf: true, body: { companyName: 'x' }, expect: 403 },
{ name: 'error.404.unknownPath', method: 'GET', path: '/api/not-a-real-endpoint', expectAny: true },
{ name: 'error.400.badPageParam', method: 'GET', path: '/api/wecom-accounts?page=abc&size=10', expectAny: true },
{ name: 'error.400.updateMissing', method: 'PUT', path: '/api/company-profiles/999999999', body: { companyName: 'x' }, expectAny: true },
];
this is definitely not an image, just plain text.
/**
* 文件用途(白话):把接口响应里"每次都会变"的部分替换成占位符,只留下契约本身。
* 关联文件:record.mjs、compare.mjs。
* 关联逻辑(数据流):原始响应 -> normalize() -> 稳定快照 -> 与 NestJS 输出逐字段比对。
*
* 为什么要归一化:主键自增、时间戳、JWT、CSRF UUID 每次请求都不同,直接比对全是噪音。
* 但它们的"存在性、类型、格式"必须保留——例如时间必须仍是 2026-08-05T19:04:54 这种
* 无时区无毫秒的形状,Prisma 默认输出 .000Z 就会在这里暴露出来。
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const JWT_RE = /^[\w-]+\.[\w-]+\.[\w-]+$/;
// 刻意分成三种时间形状:Java 现在输出的是无毫秒无时区,任何偏移都要能看出来
const TIME_PLAIN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
const TIME_MILLIS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+$/;
const TIME_ZONED_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
const ID_KEY_RE = /(^id$|Id$|Ids$)/;
const FILE_ID_RE = /^[0-9a-f-]{36}\.(jpg|jpeg|png|gif)$/i;
function normalizeValue(key, value) {
if (value === null) return '<NULL>'; // null 与 "" 必须可区分,二者语义不同
if (typeof value === 'number') return ID_KEY_RE.test(key) ? '<ID>' : value;
if (typeof value !== 'string') return value;
if (value === '') return '<EMPTY_STRING>';
if (TIME_PLAIN_RE.test(value)) return '<TIME:plain>';
if (TIME_MILLIS_RE.test(value)) return '<TIME:millis>';
if (TIME_ZONED_RE.test(value)) return '<TIME:zoned>';
if (JWT_RE.test(value)) return '<JWT>';
if (UUID_RE.test(value)) return '<UUID>';
if (FILE_ID_RE.test(value)) return '<FILE_ID>';
// 写操作造的测试数据带运行序号(避免唯一约束冲突),必须抹平否则快照每次都变
if (value.includes('__contract__')) return '<CONTRACT_FIXTURE>';
if (/^199\d{8}$/.test(value)) return '<CONTRACT_PHONE>';
if (/^8986\d+$/.test(value)) return '<CONTRACT_ICCID>';
return value; // 业务文案原样保留,这正是要锁定的部分
}
export function normalizeBody(node, key = '') {
if (Array.isArray(node)) {
// 数组只留第一条做结构样本 + 长度语义,避免快照随测试数据量漂移
return node.length === 0 ? [] : [normalizeBody(node[0], key), `<ARRAY_LENGTH:${node.length > 1 ? 'many' : 1}>`];
}
if (node && typeof node === 'object') {
const out = {};
for (const [k, v] of Object.entries(node)) out[k] = normalizeBody(v, k);
return out;
}
return normalizeValue(key, node);
}
/** 只保留与契约相关的响应头;Date、Content-Length 这类每次都变的丢弃。 */
export function normalizeHeaders(headers, setCookie) {
const keep = ['content-type', 'x-content-type-options', 'x-frame-options', 'cache-control'];
const out = {};
for (const k of keep) if (headers.get(k)) out[k] = headers.get(k);
if (setCookie?.length) {
// Cookie 的属性集合是硬契约:HttpOnly/SameSite/Max-Age/Secure 少一个前端行为就变
out['set-cookie'] = setCookie.map(c => {
const [pair, ...attrs] = c.split(';').map(s => s.trim());
const name = pair.slice(0, pair.indexOf('='));
return [`${name}=<VALUE>`, ...attrs.map(a => a.replace(/^Expires=.*/i, 'Expires=<DATE>'))].join('; ');
});
}
return out;
}
/**
* 文件用途(白话):依次请求 Java 后端的每个接口,把归一化后的响应存成快照,作为 NestJS 的验收基准。
* 关联文件:endpoints.mjs、normalize.mjs、backend/.env。
* 关联逻辑(数据流):backend/.env 凭据 -> 登录取会话 -> 逐端点请求 -> normalize -> snapshots/<name>.json。
*
* 用法:node scripts/contract/record.mjs [--base http://127.0.0.1:7690] [--out snapshots]
* 输出刻意只打统计与失败项,完整响应只落文件,避免刷屏。
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { readEndpoints, errorEndpoints } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const argOf = (flag, fallback) => { const i = process.argv.indexOf(flag); return i > -1 ? process.argv[i + 1] : fallback; };
const BASE = argOf('--base', 'http://127.0.0.1:7690');
const OUT = join(here, argOf('--out', 'snapshots'));
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
/** 取一次 CSRF 值:写请求必须带,且前端就是从这个 Cookie 里读的。 */
async function fetchCsrf() {
const res = await fetch(`${BASE}/api/auth/csrf`);
return res.headers.getSetCookie().join(';').match(/XSRF-TOKEN=([^;]+)/)[1];
}
async function login(xsrf) {
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-test.username'], password: env['xyw.contract-test.password'] }),
});
if (res.status !== 200) throw new Error(`登录失败 HTTP ${res.status}:${await res.text()}`);
return res.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
}
async function record(ep, ctx) {
const headers = {};
if (!ep.anonymous) headers.Cookie = `${ctx.session}; XSRF-TOKEN=${ctx.xsrf}`;
else headers.Cookie = `XSRF-TOKEN=${ctx.xsrf}`;
if (!ep.skipCsrf && ep.method !== 'GET') headers['X-XSRF-TOKEN'] = ctx.xsrf;
if (ep.body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${ep.path}`, {
method: ep.method,
headers,
body: ep.body !== undefined ? JSON.stringify(ep.body) : undefined,
redirect: 'manual',
});
const raw = await res.text();
let body; try { body = JSON.parse(raw); } catch { body = `<NON_JSON:${raw.slice(0, 40)}>`; }
const snapshot = {
request: { method: ep.method, path: ep.path, anonymous: !!ep.anonymous, body: ep.body ?? null },
status: res.status,
headers: normalizeHeaders(res.headers, res.headers.getSetCookie()),
body: normalizeBody(body),
};
writeFileSync(join(OUT, `${ep.name}.json`), JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
const expected = ep.expect;
const ok = ep.expectAny ? true : (expected ? res.status === expected : res.status === 200);
return { name: ep.name, status: res.status, ok };
}
mkdirSync(OUT, { recursive: true });
const xsrf = await fetchCsrf();
const session = await login(xsrf);
const ctx = { xsrf, session };
const all = [...readEndpoints, ...errorEndpoints];
const results = [];
for (const ep of all) {
try { results.push(await record(ep, ctx)); }
catch (err) { results.push({ name: ep.name, status: 'ERR', ok: false, err: err.message }); }
}
const bad = results.filter(r => !r.ok);
console.log(`录制完成:${results.length - bad.length}/${results.length} 符合预期,快照写入 ${OUT}`);
if (bad.length) { console.log('\n需要确认的端点:'); for (const b of bad) console.log(` ${b.name.padEnd(38)} status=${b.status}${b.err ? ' ' + b.err : ''}`); }
/**
* 文件用途(白话):把企微账号绑定手机号的四条业务规则跑一遍,除了录接口响应,还回头查台账确认副作用。
* 关联文件:write-flows.mjs、normalize.mjs、backend/.../WecomAccountService.java。
* 关联逻辑(数据流):登录 -> 建/改企微账号 -> 查手机台账 -> 断言号码存活状态 -> snapshots/wecom.*.json。
*
* 为什么要查台账:这几条规则在接口响应里完全看不出来,只体现在"操作之后台账变成什么样"。
* 用列表接口查(而不是直连数据库)是刻意的——软删的号不会出现在列表里,
* 这正是使用者能观察到的效果,也是 NestJS 必须复现的效果。
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { MARK } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, 'snapshots');
const BASE = process.argv.includes('--base') ? process.argv[process.argv.indexOf('--base') + 1] : 'http://127.0.0.1:7690';
const RUN = String(Date.now()).slice(-6);
const tag = (s) => `${MARK}${s}${RUN}`;
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
let xsrf, session;
async function api(method, path, body, snapshot) {
const headers = { Cookie: `${session}; XSRF-TOKEN=${xsrf}` };
if (method !== 'GET') { headers['X-XSRF-TOKEN'] = xsrf; headers['Content-Type'] = 'application/json'; }
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({
request: { method, path, body: body ?? null }, status: res.status,
headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(parsed),
}, null, 2) + '\n', 'utf8');
return { status: res.status, body: parsed };
}
/**
* 号码是否还活着。用 phone-exists 而不是翻列表:该接口内部带 delete_time = 0 条件,
* 语义精确且不受分页上限影响(实测 size=200 会直接返回空,上限在 100 左右)。
*/
async function phoneAlive(number) {
const r = await api('GET', `/api/wecom-accounts/lookups/phone-exists?phoneNumber=${number}`);
return r.body?.data === true;
}
{
const c = await fetch(`${BASE}/api/auth/csrf`);
xsrf = c.headers.getSetCookie().join(';').match(/XSRF-TOKEN=([^;]+)/)[1];
const l = await fetch(`${BASE}/api/auth/login`, { method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-test.username'], password: env['xyw.contract-test.password'] }) });
if (l.status !== 200) throw new Error(`登录失败 ${l.status}`);
session = l.headers.getSetCookie().map(x => x.split(';')[0]).join('; ');
}
mkdirSync(OUT, { recursive: true });
// 取一个台账里已存在的号,用于"复用"场景
const existingPhone = (await api('GET', '/api/wecom-accounts/lookups/phone-assets?keyword=')).body?.data?.[0]?.phoneNumber;
if (!existingPhone) throw new Error('台账里没有可复用的手机号,无法录制复用场景');
const NEW_A = `199${RUN}11`; // 场景 B 自动创建的号
const NEW_B = `199${RUN}22`; // 场景 B 改绑后的新号
const NEW_C = `199${RUN}33`; // 场景 A 改绑后的新号
const results = [];
const check = (name, actual, expected) => { results.push([name, actual, expected, JSON.stringify(actual) === JSON.stringify(expected)]); };
const base = { wecomAlias: '', wecomAccount: '', companyProfileId: null, realNameOwner: tag('实'), realNameOwnerStatus: '在职',
gender: '男', deviceId: null, operatorPersonId: null, cardType: '实体卡', iccid: `8986${RUN}9`, phoneRealNameOwner: tag('实') };
// ---- 场景 A:绑已存在的号 → 应复用,phoneLinkMode=EXISTING ----
const a = await api('POST', '/api/wecom-accounts', { ...base, wecomName: tag('复用'), phoneNumber: existingPhone }, 'A.createWithExistingPhone');
check('A 创建成功', a.status, 200);
check('A phoneLinkMode', a.body?.data?.phoneLinkMode, 'EXISTING');
// ---- 场景 B:绑台账没有的号 → 应自动创建,phoneLinkMode=CREATED ----
const b = await api('POST', '/api/wecom-accounts', { ...base, wecomName: tag('新建'), phoneNumber: NEW_A }, 'B.createWithNewPhone');
check('B 创建成功', b.status, 200);
check('B phoneLinkMode', b.body?.data?.phoneLinkMode, 'CREATED');
check('B 自动创建的号已进台账', await phoneAlive(NEW_A), true);
// ---- 场景 C:把 B 改绑到另一个新号 → 原先自动创建的号应被软删 ----
const c2 = await api('PUT', `/api/wecom-accounts/${b.body?.data?.id}`, { ...base, wecomName: tag('新建改'), phoneNumber: NEW_B }, 'C.rebindAutoCreatedPhone');
check('C 改绑成功', c2.status, 200);
check('C 旧的自动创建号已消失', await phoneAlive(NEW_A), false);
check('C 新号已进台账', await phoneAlive(NEW_B), true);
// ---- 场景 D:把 A 改绑到新号 → 被复用的原号必须原样保留 ----
const d = await api('PUT', `/api/wecom-accounts/${a.body?.data?.id}`, { ...base, wecomName: tag('复用改'), phoneNumber: NEW_C }, 'D.rebindReusedPhone');
check('D 改绑成功', d.status, 200);
check('D 被复用的原号仍在台账', await phoneAlive(existingPhone), true);
// ---- 清理:删掉两个测试企微账号(顺带录删除契约)----
if (a.body?.data?.id) await api('DELETE', `/api/wecom-accounts/${a.body.data.id}`, undefined, 'E.delete');
if (b.body?.data?.id) await api('DELETE', `/api/wecom-accounts/${b.body.data.id}`, undefined);
const bad = results.filter(r => !r[3]);
console.log(`企微规则验证:${results.length - bad.length}/${results.length} 通过,运行序号 ${RUN}`);
for (const [name, actual, expected, ok] of results) console.log(` ${ok ? '✅' : '❌'} ${name.padEnd(24)} 实际=${JSON.stringify(actual)} 期望=${JSON.stringify(expected)}`);
writeFileSync(join(OUT, 'wecom.RULES.json'), JSON.stringify({ note: '企微手机号联动规则的行为基准,NestJS 必须逐条复现',
rules: results.map(([name, actual, expected, ok]) => ({ name, actual, expected, passed: ok })) }, null, 2) + '\n', 'utf8');
/**
* 文件用途(白话):按真实业务顺序调用写接口(建→改→删),把每一步的响应录成契约快照。
* 关联文件:normalize.mjs、endpoints.mjs、record.mjs。
* 关联逻辑(数据流):登录 -> 逐场景写测试库 -> normalize -> snapshots/write.<场景>.json。
*
* 数据可识别:所有造出来的记录都带 __contract__ 前缀、手机号用 199 段,便于事后一条 SQL 清掉。
* 每次运行带序号后缀避免唯一约束冲突,归一化时会抹平成 <CONTRACT_FIXTURE>,快照仍然稳定。
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { MARK } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, 'snapshots');
const BASE = process.argv.includes('--base') ? process.argv[process.argv.indexOf('--base') + 1] : 'http://127.0.0.1:7690';
const RUN = String(Date.now()).slice(-6); // 运行序号:躲开唯一约束,快照里会被归一化掉
const tag = (s) => `${MARK}${s}${RUN}`;
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
let xsrf, session;
async function api(method, path, body, { snapshot, raw } = {}) {
const headers = { Cookie: `${session}; XSRF-TOKEN=${xsrf}` };
if (method !== 'GET') headers['X-XSRF-TOKEN'] = xsrf;
if (body !== undefined && !raw) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, {
method, headers,
body: body === undefined ? undefined : (raw ? body : 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, `write.${snapshot}.json`), JSON.stringify({
request: { method, path, body: raw ? '<MULTIPART>' : (body ?? null) },
status: res.status,
headers: normalizeHeaders(res.headers, res.headers.getSetCookie()),
body: normalizeBody(parsed),
}, null, 2) + '\n', 'utf8');
}
return { status: res.status, body: parsed };
}
// ---- 登录 ----
{
const c = await fetch(`${BASE}/api/auth/csrf`);
xsrf = c.headers.getSetCookie().join(';').match(/XSRF-TOKEN=([^;]+)/)[1];
const l = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-test.username'], password: env['xyw.contract-test.password'] }),
});
if (l.status !== 200) throw new Error(`登录失败 ${l.status}`);
session = l.headers.getSetCookie().map(x => x.split(';')[0]).join('; ');
}
mkdirSync(OUT, { recursive: true });
const log = [];
const step = async (label, fn) => {
try { const r = await fn(); log.push([label, r?.status ?? 'ok', r?.status < 400 || r?.status === undefined]); return r; }
catch (e) { log.push([label, 'ERR', false, e.message]); return null; }
};
// ---- 场景 1:公司档案 建→改→删 ----
const profile = await step('company-profile.create', () => api('POST', '/api/company-profiles', {
companyName: tag('公司'), shortName: tag('简称'), unifiedSocialCreditCode: '', address: '', contactName: '', contactValue: '',
}, { snapshot: 'company-profile.create' }));
const profileId = profile?.body?.data?.id;
if (profileId) {
await step('company-profile.update', () => api('PUT', `/api/company-profiles/${profileId}`, {
companyName: tag('公司改'), shortName: '', unifiedSocialCreditCode: '', address: '', contactName: '', contactValue: '',
}, { snapshot: 'company-profile.update' }));
}
// ---- 场景 2:公司人员 建→改→删(挂在上面的公司下)----
const person = await step('company-person.create', () => api('POST', '/api/company-persons', {
companyProfileId: profileId, personName: tag('人员'), employmentStatus: '在职', resignedAt: null,
}, { snapshot: 'company-person.create' }));
const personId = person?.body?.data?.id;
if (personId) {
await step('company-person.update', () => api('PUT', `/api/company-persons/${personId}`, {
companyProfileId: profileId, personName: tag('人员改'), employmentStatus: '在职', resignedAt: null,
}, { snapshot: 'company-person.update' }));
}
// ---- 场景 3:手机号资产 建→改→删 ----
const phone = await step('phone-asset.create', () => api('POST', '/api/phone-assets', {
phoneNumber: `199${RUN}01`, cardType: '实体卡', iccid: `8986${RUN}0001`, realNameOwner: tag('实名'), managementType: '', disposalStatus: '', deviceId: null,
}, { snapshot: 'phone-asset.create' }));
const phoneId = phone?.body?.data?.id;
if (phoneId) {
await step('phone-asset.update', () => api('PUT', `/api/phone-assets/${phoneId}`, {
phoneNumber: `199${RUN}02`, cardType: '实体卡', iccid: `8986${RUN}0002`, realNameOwner: tag('改'), managementType: '', disposalStatus: '', deviceId: null,
}, { snapshot: 'phone-asset.update' }));
}
// ---- 场景 4:系统用户 建→改(刻意不删,避免误伤可登录账号)----
const user = await step('system-user.create', () => api('POST', '/api/system-users', {
username: `${MARK}u${RUN}`, roleCode: 'OPERATIONS', password: 'Contract#Test2026', pagePermissions: { overview: 'READ' },
}, { snapshot: 'system-user.create' })); // 已知缺陷:SUPER_ADMIN 建号必 500,快照记录现状
const userId = user?.body?.data?.id;
if (userId) {
await step('system-user.update', () => api('PUT', `/api/system-users/${userId}`, {
roleCode: 'OPERATIONS', pagePermissions: { overview: 'EDIT' },
}, { snapshot: 'system-user.update' }));
}
// ---- 删除(放最后,先录完所有更新态)----
if (personId) await step('company-person.delete', () => api('DELETE', `/api/company-persons/${personId}`, undefined, { snapshot: 'company-person.delete' }));
if (phoneId) await step('phone-asset.delete', () => api('DELETE', `/api/phone-assets/${phoneId}`, undefined, { snapshot: 'phone-asset.delete' }));
if (profileId) await step('company-profile.delete', () => api('DELETE', `/api/company-profiles/${profileId}`, undefined, { snapshot: 'company-profile.delete' }));
const failed = log.filter(l => !l[2]);
console.log(`写操作录制:${log.length - failed.length}/${log.length} 成功,运行序号 ${RUN}`);
for (const [name, status, ok, err] of log) if (!ok) console.log(` 失败 ${name} -> ${status} ${err || ''}`);
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