Commit 9da1baa5 by DaiJiezhang

Merge branch 'feat/migrate-to-nestjs' into 'master'

feat: migrate backend to NestJS

See merge request !15
parents 90002afb 6f06c08d
......@@ -29,6 +29,8 @@ backend/.mvn/jvm.config
# Local configuration, IDE state, and generated browser-test artifacts.
.env
# 前端本机后端地址覆盖:只属于个人环境。.env.java 是团队共享的对拍配置,仍进仓库。
.env.local
.idea/
.vite/
frontend/.playwright-cli/
......@@ -43,3 +45,15 @@ backend/uploads/
# 本地造数用的测试数据脚本,各人环境不同,不进仓库。
db-seed/
*.seed.sql
# 契约快照含测试库真实业务数据(姓名、手机号),默认不进仓库。
# 需要团队共享基准时,改为只提交 error.* 这类不含个人信息的快照。
scripts/contract/snapshots/
scripts/contract/snapshots-backup-*/
# Office 打开文档时生成的锁文件,不进仓库。
~$*
# 验收测试用例文档:随迁移临时产出,交给执行者后不需要进仓库。
docs/qa-tasks.md
docs/测试用例-*.xlsx
# 学有为资产后台
## 当前重构状态
内部资产管理系统。前端 Vue 3,后端正在从 Java Spring Boot 迁移至 TypeScript NestJS。
- 前端已迁入 `frontend/`,开发入口为 Vite。
- 旧 phone/wechat 后端接口与业务模块已移除。
- `#/reference/phone` 是不可操作的旧界面参考页,不请求旧 API;`#/reference/wecom` 展示企微账号资产真实列表。
- 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。
## 目录结构
## 前端开发
```
frontend/ Vue 3 + Vite 前端(不随本次迁移改动)
backend/ Java 后端(迁移完成后删除,当前作为对拍基准保留)
backend-nest/ NestJS 后端(迁移目标)
scripts/contract/ 契约录制与比对工具
docs/ 迁移待办与设计记录
```
```powershell
cd frontend
npm install
npm run dev
迁移期两个后端并行运行:Java 占 **7690**,NestJS 占 **7691**
前端默认连 Java,通过环境变量可切到 NestJS,切换与回退都不需要改代码。
## 环境要求
| 项 | 版本 | 说明 |
|---|---|---|
| Node.js | >= 20(实测 24) | 前端与 NestJS |
| MySQL | 8.x | 库名 `xyw_data_test`,表前缀 `as_` |
| JDK | 17 | 仅迁移期需要,用于跑 Java 后端与对拍 |
| Maven | 3.8+ | 仅迁移期需要,用于跑 Java 测试 |
## 首次配置
两个后端各需一份 `.env`,都已在 `.gitignore` 中,不会进仓库。
**backend/.env**
```properties
XYW_DB_URL=jdbc:mysql://127.0.0.1:3306/xyw_data_test?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8
XYW_DB_USERNAME=数据库账号
XYW_DB_PASSWORD=数据库密码
XYW_AUTH_JWT_SECRET=至少32位的随机串
XYW_AUTH_SESSION_HOURS=8
XYW_AUTH_COOKIE_SECURE=false
XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS=400
xyw.device-assets.upload-dir=./uploads/device-assets
```
**backend-nest/.env**
```properties
DATABASE_URL="mysql://账号:密码@127.0.0.1:3306/xyw_data_test"
XYW_AUTH_JWT_SECRET=与 backend/.env 完全相同的那一个
XYW_AUTH_SESSION_HOURS=8
XYW_AUTH_COOKIE_SECURE=false
XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS=400
XYW_DEVICE_ASSETS_UPLOAD_DIR=./uploads/device-assets
```
访问:`http://localhost:5173/asset/#/reference/wecom`
生产构建仍使用 `/assets/` 基础路径:
两处 `XYW_AUTH_JWT_SECRET` 必须一致。否则在一个后端登录后切到另一个会被判为未登录,
切换与回退时所有人都要重新登录。
```powershell
生成随机密钥:
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
## 启动
### 前端
前端包管理器固定为 pnpm,版本由 `frontend/package.json``packageManager``volta` 字段约束。请勿在 `frontend/` 混用 npm,否则会生成冲突的 `package-lock.json` 与不同结构的 `node_modules`
```bash
cd frontend
npm run build
npm run test:e2e
pnpm install
pnpm dev
```
访问 `http://localhost:5173/asset/`**默认连 NestJS 后端(7691)。**
回退到 Java 后端(7690)对拍:
```bash
pnpm dev:java
```
后端地址不写在命令里,由文件管理:
| 位置 | 作用 | 是否进仓库 |
|---|---|---|
| `vite.config.js` 默认值 | NestJS `7691``pnpm dev` 用它 | 是 |
| `frontend/.env.java` | Java `7690``pnpm dev:java` 用它 | 是 |
| `frontend/.env.local` | 本机临时覆盖,只对 `pnpm dev` 生效 | 否 |
优先级:命令行环境变量 > `.env.<mode>` > `.env.local` > `.env` > `vite.config.js` 默认值。
注意:`pnpm dev:java``java` 模式,`.env.java` 的优先级高于 `.env.local`,此时本机覆盖不生效。要临时改 Java 对拍地址,直接改 `.env.java`,或在命令行设置 `VITE_API_TARGET`
### NestJS 后端(7691)
```bash
cd backend-nest
npm install
npx prisma generate
npm start
```
## 后端编译
`prisma generate` 只在首次或 `prisma/schema.prisma` 变更后需要执行。
> Prisma 在本项目中**只作只读映射**:schema 由 `npx prisma db pull` 从现有库反向生成。
> 禁止执行 `prisma migrate` —— Java 后端仍在使用同一套表,改表结构会同时影响两边。
### Java 后端(7690,迁移期保留)
```powershell
IDE 直接运行 `com.xyw.console.XywConsoleBackendApplication` 即可。
命令行方式:
```bash
cd backend
mvn -q -DskipTests compile
mvn -DskipTests spring-boot:run
```
需要先把 `JAVA_HOME` 指向 JDK 17。
`application.yml` 会同时尝试 `.env``backend/.env` 两个路径,
因此从项目根或 `backend/` 启动都能读到配置。
## 测试
```bash
cd backend-nest && npm test # NestJS 单元测试
cd backend && mvn test # Java 单元测试(迁移期基准)
cd frontend && pnpm test:e2e # 端到端测试
```
## 契约比对
迁移期用来确认 NestJS 与 Java 的行为逐字一致。**两个后端需同时运行。**
```bash
# 全量比对:重放全部端点,差异精确到字段路径
node scripts/contract/compare.mjs --base http://127.0.0.1:7691
# 只比对某个模块
node scripts/contract/compare.mjs --base http://127.0.0.1:7691 --filter wecom
# 重新录制基准(改动 Java 后端后需要执行)
node scripts/contract/record.mjs
```
运行 Maven 前需将 `JAVA_HOME` 配置为可用的 JDK 17 路径。
\ No newline at end of file
业务规则验证(会在测试库写入带 `__contract__` 前缀的数据):
```bash
node scripts/contract/wecom-flows.mjs --base http://127.0.0.1:7691 --no-snapshot # 企微手机号联动
node scripts/contract/device-flows.mjs --base http://127.0.0.1:7691 --no-snapshot # 设备图片上传
cd backend-nest && node ../scripts/contract/image-compare.mjs # 两边缩略图像素比对
```
## 相关文档
| 文档 | 内容 |
|---|---|
| [scripts/contract/CONTRACT-NOTES.md](scripts/contract/CONTRACT-NOTES.md) | 17 条实现要点:时间格式、Cookie 属性、错误文案、事务范围等,是 NestJS 实现的依据 |
| [docs/migration-backlog.md](docs/migration-backlog.md) | 迁移期有意不做的行为变更,迁移完成后逐项处理 |
| [DESIGN.md](DESIGN.md) | 界面与交互设计 |
| [PRODUCT.md](PRODUCT.md) | 产品说明 |
## 迁移进度
```
契约比对 31/31 端点一致
单元测试 101 条通过(NestJS)/ 114 条通过(Java)
已迁移 认证、账号管理、公司档案、公司人员、手机号、企微、设备
待完成 生产部署配置、切换到 7690、Java 后端下线
```
/**
* 文件用途(白话):单元测试配置。
* 关联文件:各模块下的 spec 测试文件、tsconfig.json。
* 测试文件与被测源码同目录,以 .spec.ts 结尾,与 Java 侧 src/test 的用例一一对应。
*/
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\.spec\.ts$',
transform: { '^.+\.ts$': ['ts-jest', { tsconfig: { experimentalDecorators: true, emitDecoratorMetadata: true, esModuleInterop: true, target: 'ES2021' } }] },
testEnvironment: 'node',
};
{
"$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",
"test": "jest",
"test:watch": "jest --watch",
"start": "ts-node src/main.ts"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@nestjs/cli": "^11.0.24",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.2.0",
"dotenv": "^17.4.2",
"jest": "^30.4.2",
"prisma": "^7.9.1",
"ts-jest": "^29.4.12",
"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",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.3"
}
}
// 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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaModule } from './common/prisma.module';
import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
import { SystemUserModule } from './system-user/system-user.module';
import { CompanyProfileModule } from './company-profile/company-profile.module';
import { CompanyPersonModule } from './company-person/company-person.module';
import { PhoneAssetModule } from './phone-asset/phone-asset.module';
import { WecomAccountModule } from './wecom-account/wecom-account.module';
import { DeviceAssetModule } from './device-asset/device-asset.module';
@Module({
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule, WecomAccountModule, DeviceAssetModule],
controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// 安全响应头对所有路由生效,与 Spring Security 的默认行为一致
consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
}
}
/**
* 文件用途(白话):签发、解析和清除登录会话 Cookie。
* 关联文件:backend/.../auth/AuthTokenService.java、session.guard.ts、auth.service.ts。
* 关联逻辑(数据流):登录成功 -> issue() 写 Set-Cookie -> 浏览器自动携带 -> session.guard 解析出身份。
*
* Cookie 属性必须与 Java 逐字一致(实测的 Set-Cookie):
* XYW_SESSION=<jwt>; Path=/; Max-Age=28800; Expires=<GMT>; HttpOnly; SameSite=Lax
* 因此这里手工拼接响应头,而不用 Express 的 res.cookie()——后者生成的属性顺序不同,
* 虽然浏览器行为一致,但会让契约比对产生噪音。
*/
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import * as jwt from 'jsonwebtoken';
import { UnauthorizedException } from '../common/business.exception';
const COOKIE_NAME = 'XYW_SESSION';
export interface AuthPrincipal { userId: number; username: string; roleCode: string; authVersion: number; }
@Injectable()
export class AuthTokenService {
private readonly secret: string;
private readonly lifetimeSeconds: number;
private readonly cookieSecure: boolean;
constructor() {
this.secret = process.env.XYW_AUTH_JWT_SECRET ?? '';
this.lifetimeSeconds = Number(process.env.XYW_AUTH_SESSION_HOURS ?? 8) * 3600;
this.cookieSecure = String(process.env.XYW_AUTH_COOKIE_SECURE ?? 'false') === 'true';
}
/** 密钥不足 32 字节直接拒绝签发,对应 Java 的同名校验;宁可启动即失败,也不用弱密钥签会话。 */
private key(): string {
if (Buffer.byteLength(this.secret, 'utf8') < 32) throw new Error('认证签名密钥未配置或长度不足');
return this.secret;
}
/** 代码作用(白话):把身份签成 JWT 并写入会话 Cookie。 */
issue(principal: AuthPrincipal, res: Response): void {
const token = jwt.sign(
{ username: principal.username, role: principal.roleCode, version: principal.authVersion },
this.key(),
{ algorithm: 'HS256', subject: String(principal.userId), expiresIn: this.lifetimeSeconds },
);
res.append('Set-Cookie', this.cookieHeader(token, this.lifetimeSeconds));
}
/** 代码作用(白话):写一个立即过期的同名 Cookie,让浏览器丢弃会话。 */
clear(res: Response): void { res.append('Set-Cookie', this.cookieHeader('', 0)); }
/** 代码作用(白话):按 Java 的属性顺序拼接 Set-Cookie 头。 */
private cookieHeader(value: string, maxAgeSeconds: number): string {
const expires = new Date(Date.now() + maxAgeSeconds * 1000).toUTCString();
const parts = [`${COOKIE_NAME}=${value}`, 'Path=/', `Max-Age=${maxAgeSeconds}`, `Expires=${expires}`, 'HttpOnly'];
if (this.cookieSecure) parts.push('Secure');
parts.push('SameSite=Lax');
return parts.join('; ');
}
/** 代码作用(白话):校验并解出 JWT 里的身份;签名无效或过期一律视为未登录。 */
parse(token: string): AuthPrincipal {
try {
const claims = jwt.verify(token, this.key(), { algorithms: ['HS256'] }) as jwt.JwtPayload;
return { userId: Number(claims.sub), username: String(claims.username), roleCode: String(claims.role), authVersion: Number(claims.version) };
} catch { throw new UnauthorizedException('请先登录'); }
}
/** 代码作用(白话):从请求的 Cookie 里取出会话令牌。 */
extract(cookies: Record<string, string> | undefined): string | null { return cookies?.[COOKIE_NAME] ?? null; }
}
/**
* 文件用途(白话):登录、登出、取当前身份、取 CSRF 令牌四个接口。
* 关联文件:backend/.../auth/AuthController.java、auth.service.ts、frontend/src/modules/auth/auth-api-client.js。
* 关联逻辑(数据流):登录表单 -> login -> Set-Cookie -> 后续请求由 SessionGuard 还原身份。
*/
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { AuthService } from './auth.service';
import { LoginRequestDto } from './dto/auth.dto';
import { AuthenticatedRequest, Public } from './session.guard';
import { CSRF_COOKIE } from './csrf.guard';
import { UnauthorizedException } from '../common/business.exception';
@Controller('api/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('login')
login(@Body() body: LoginRequestDto, @Res({ passthrough: true }) res: Response) {
return this.auth.login(body, res);
}
@Get('me')
me(@Req() req: AuthenticatedRequest) {
if (!req.principal) throw new UnauthorizedException('请先登录');
return this.auth.me(req.principal.userId);
}
/**
* 代码作用(白话):给前端一个写操作要用的校验令牌,同时写进不带 HttpOnly 的 Cookie。
* 关联逻辑:前端并不读响应体,而是从 XSRF-TOKEN Cookie 里取值放进请求头,
* 因此这个接口的关键副作用是 Set-Cookie,返回体只是顺带给出。
* 已有令牌时沿用旧值不重发 Cookie,与 Java 行为一致。
*/
@Public()
@Get('csrf')
csrf(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const existing = (req as any).cookies?.[CSRF_COOKIE];
if (existing) return existing;
const token = randomUUID();
res.append('Set-Cookie', `${CSRF_COOKIE}=${token}; Path=/`);
return token;
}
@Post('logout')
logout(@Res({ passthrough: true }) res: Response) {
this.auth.logout(res);
return null;
}
}
/**
* 文件用途(白话):认证模块的装配,把守卫注册成全局生效。
* 关联文件:auth.controller.ts、session.guard.ts、csrf.guard.ts、app.module.ts。
*
* 守卫顺序有讲究:先 CSRF 后会话。Java 侧 CsrfFilter 也排在认证过滤器之前,
* 保证令牌缺失时返回 403 而不是 401——前端对这两个状态的处理完全不同。
*/
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthTokenService } from './auth-token.service';
import { LoginAttemptService } from './login-attempt.service';
import { PagePermissionService } from './page-permission.service';
import { CsrfGuard } from './csrf.guard';
import { SessionGuard } from './session.guard';
import { PermissionGuard } from '../common/permission.guard';
@Module({
controllers: [AuthController],
providers: [
AuthService, AuthTokenService, LoginAttemptService, PagePermissionService,
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: SessionGuard },
// 顺序在会话守卫之后:先确认身份,再检查该身份对目标页面的权限
{ provide: APP_GUARD, useClass: PermissionGuard },
],
exports: [PagePermissionService, LoginAttemptService, AuthTokenService],
})
export class AuthModule {}
/**
* 文件用途(白话):验证登录的安全判定,逐条对应 Java 侧 AuthServiceTest 的 9 个用例。
* 关联文件:auth.service.ts、backend/src/test/.../AuthServiceTest.java。
*
* 这些用例守的是两件接口对拍看不见的事:
* - 无论账号是否存在、是否停用、是否有密码,都必须真的跑一次密码比对(否则耗时差可用来枚举账号)
* - 被锁定的账号在查数据库之前就被拒绝
* 从响应上看这些情况都是同一句「账号或密码错误」,只有单元测试能验证内部确实这么做了。
*/
// bcrypt 是原生模块,其导出属性不可被 spyOn,只能整体替换
jest.mock('bcrypt', () => ({ compare: jest.fn(), hash: jest.fn() }));
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PagePermissionService } from './page-permission.service';
import { LoginThrottledException } from './login-attempt.service';
import { UnauthorizedException } from '../common/business.exception';
const STORED_HASH = '$2a$12$stored.hash.placeholder.value.for.unit.test.only';
function makeUser(status: string, passwordHash: string | null) {
return { id: 7n, username: 'real_user', role_code: 'OPERATIONS', status, password_hash: passwordHash, page_permissions: null, auth_version: 1 };
}
describe('AuthService', () => {
let prisma: any;
let tokens: any;
let loginAttempts: any;
let service: AuthService;
let response: any;
let compareSpy: jest.Mock;
/** 补时下限设为 0,多数用例只验证判定逻辑,不必真的等待。 */
const build = async (failureMinMillis = 0) => {
process.env.XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS = String(failureMinMillis);
const s = new AuthService(prisma, new PagePermissionService(), loginAttempts, tokens);
// 占位哈希的生成本身要跑一次 bcrypt(cost 12),测试里直接给定,避免每个用例多等 200ms
(s as any).timingEqualizerHash = STORED_HASH;
return s;
};
beforeEach(async () => {
prisma = { as_system_user: { findFirst: jest.fn(), findUnique: jest.fn() } };
tokens = { issue: jest.fn(), clear: jest.fn() };
loginAttempts = { assertAllowed: jest.fn(), recordFailure: jest.fn(), recordSuccess: jest.fn() };
response = {};
compareSpy = bcrypt.compare as unknown as jest.Mock;
compareSpy.mockReset();
compareSpy.mockResolvedValue(false);
service = await build();
});
afterEach(() => jest.clearAllMocks());
it('账号不存在时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(null);
await expect(service.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('账号已停用时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('DISABLED', STORED_HASH));
await expect(service.login({ username: 'disabled_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('账号尚未设置密码时仍然执行一次密码比对', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', null));
await expect(service.login({ username: 'pending_user', password: 'some-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(compareSpy).toHaveBeenCalledWith('some-password', expect.any(String));
});
it('密码错误时不签发任何会话', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(false);
await expect(service.login({ username: 'real_user', password: 'wrong-password' }, response)).rejects.toThrow(UnauthorizedException);
expect(tokens.issue).not.toHaveBeenCalled();
});
it('密码正确时签发会话并返回账号信息', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(true);
const result = await service.login({ username: 'real_user', password: 'right-password' }, response);
expect(result.username).toBe('real_user');
expect(tokens.issue).toHaveBeenCalledWith(expect.objectContaining({ userId: 7, username: 'real_user' }), response);
});
it('登录失败计入锁定统计', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(null);
await expect(service.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow();
expect(loginAttempts.recordFailure).toHaveBeenCalledWith('ghost_user');
});
it('登录成功清空锁定统计', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(true);
await service.login({ username: 'real_user', password: 'right-password' }, response);
expect(loginAttempts.recordSuccess).toHaveBeenCalledWith('real_user');
});
it('已锁定的账号在查询数据库之前就被拒绝', async () => {
loginAttempts.assertAllowed.mockImplementation(() => { throw new LoginThrottledException(Date.parse('2026-08-06T09:00:00Z')); });
await expect(service.login({ username: 'Jeddy', password: 'any-password' }, response)).rejects.toThrow(LoginThrottledException);
expect(prisma.as_system_user.findFirst).not.toHaveBeenCalled();
});
it('各条失败路径的耗时都补齐到同一下限', async () => {
const padded = await build(200);
(padded as any).timingEqualizerHash = STORED_HASH;
prisma.as_system_user.findFirst.mockResolvedValue(null);
const unknownStart = Date.now();
await expect(padded.login({ username: 'ghost_user', password: 'some-password' }, response)).rejects.toThrow();
const unknownElapsed = Date.now() - unknownStart;
prisma.as_system_user.findFirst.mockResolvedValue(makeUser('ACTIVE', STORED_HASH));
compareSpy.mockResolvedValue(false);
const wrongStart = Date.now();
await expect(padded.login({ username: 'real_user', password: 'some-password' }, response)).rejects.toThrow();
const wrongElapsed = Date.now() - wrongStart;
expect(unknownElapsed).toBeGreaterThanOrEqual(200);
expect(wrongElapsed).toBeGreaterThanOrEqual(200);
});
});
/**
* 文件用途(白话):校验账号密码、签发会话,并保证失败响应的耗时恒定。
* 关联文件:backend/.../auth/AuthService.java、login-attempt.service.ts、auth-token.service.ts。
* 关联逻辑(数据流):登录表单 -> 锁定检查 -> 查库 -> 密码比对 -> 失败补齐耗时 / 成功写 Cookie。
*
* 三处安全设计必须原样保留,改动任何一处都会重新打开被利用的缺口:
*
* 一、密码比对无条件执行。
* 即便账号不存在、已停用、或根本没设密码,也要拿占位哈希跑一次 bcrypt。
* 若写成短路判断,"账号不存在"会比"账号存在但密码错"快十几倍,据此可枚举出有效账号。
*
* 二、所有失败路径补齐到同一最小耗时。
* 库里存量哈希强度不一(实测同时存在 cost 10 与 cost 12,比对耗时相差约 168ms),
* 仅靠"都跑一次 bcrypt"仍有可观测的时间差,因此统一补齐到配置的下限。
*
* 三、锁定检查放在查库之前。
* 被锁账号不产生任何数据库查询,既省资源,也不因查库耗时泄露账号是否存在。
*
* 与 Java 的实现差异(有意为之):
* Java 是一请求一线程,用 Thread.sleep 补齐没有副作用;
* Node 是单线程事件循环,同步 sleep 会卡住整个进程,因此改用 await + setTimeout。
* bcrypt 也必须用异步版本,它会走 libuv 线程池,不阻塞主线程。
*/
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Response } from 'express';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../common/prisma.service';
import { PagePermissionService } from './page-permission.service';
import { LoginAttemptService } from './login-attempt.service';
import { AuthTokenService } from './auth-token.service';
import { BusinessException, UnauthorizedException } from '../common/business.exception';
import { CurrentUserResponse, LoginRequestDto } from './dto/auth.dto';
@Injectable()
export class AuthService implements OnModuleInit {
/** 账号不存在时用来占位比对的哈希,唯一用途是让失败路径与正常路径耗时一致。 */
private timingEqualizerHash = '';
private readonly failureMinMillis = Number(process.env.XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS ?? 400);
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PagePermissionService,
private readonly loginAttempts: LoginAttemptService,
private readonly tokens: AuthTokenService,
) {}
/** 启动时生成一次占位哈希,cost 与库中较强的存量哈希一致,确保占位比对不会更快。 */
async onModuleInit() {
this.timingEqualizerHash = await bcrypt.hash('xyw-login-timing-equalizer', 12);
}
async login(request: LoginRequestDto, res: Response): Promise<CurrentUserResponse> {
this.loginAttempts.assertAllowed(request.username);
const startedAt = Date.now();
const user = await this.prisma.as_system_user.findFirst({ where: { username: request.username, delete_time: 0n } });
const storedHash = user?.password_hash ?? this.timingEqualizerHash;
// 刻意不短路:任何分支都要付出一次 bcrypt 的代价
const passwordMatches = await bcrypt.compare(request.password, storedHash);
if (!user || user.status !== 'ACTIVE' || user.password_hash == null || !passwordMatches) {
this.loginAttempts.recordFailure(request.username);
await this.padFailureDuration(startedAt);
throw new UnauthorizedException('账号或密码错误');
}
this.loginAttempts.recordSuccess(request.username);
this.tokens.issue({ userId: Number(user.id), username: user.username, roleCode: user.role_code, authVersion: user.auth_version ?? 1 }, res);
return this.responseOf(user);
}
/** 代码作用(白话):把本次失败的总耗时补齐到下限,抹平各失败路径之间的时间差。 */
private async padFailureDuration(startedAt: number): Promise<void> {
const remaining = this.failureMinMillis - (Date.now() - startedAt);
if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));
}
/** 代码作用(白话):按当前会话身份重新读取账号,返回最新的角色与页面权限。 */
async me(userId: number): Promise<CurrentUserResponse> {
const user = await this.prisma.as_system_user.findUnique({ where: { id: BigInt(userId) } });
if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('登录已失效');
return this.responseOf(user);
}
logout(res: Response): void { this.tokens.clear(res); }
private responseOf(user: { id: bigint; username: string; role_code: string; page_permissions: unknown }): CurrentUserResponse {
return { id: Number(user.id), username: user.username, roleCode: user.role_code, pagePermissions: this.permissions.effectivePermissions(user) };
}
}
/**
* 文件用途(白话):用双提交方式防跨站请求伪造——写操作必须同时带上 Cookie 里的令牌和请求头里的同一个值。
* 关联文件:backend/.../config/SecurityConfig.java、auth.controller.ts、frontend/src/modules/auth/auth-api-client.js。
* 关联逻辑(调用链):写请求 -> 本守卫比对 header 与 cookie -> 放行或 403。
*
* 对应 Java 的 CookieCsrfTokenRepository.withHttpOnlyFalse():
* - Cookie 名 XSRF-TOKEN,不设 HttpOnly(前端要用 document.cookie 读出来放进请求头)
* - 请求头名 X-XSRF-TOKEN
* - 安全方法(GET/HEAD/OPTIONS)不校验
* - /api/auth/login 豁免,否则首次登录无从获取令牌
* - 校验失败返回 403,文案前端会直接展示
*
* 与 Java 的一处有意差异见 scripts/contract/CONTRACT-NOTES.md 第 14 条:
* Java 会在已认证的 GET 请求后删除该 cookie,此处不复现。
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { ForbiddenException } from '../common/business.exception';
export const CSRF_COOKIE = 'XSRF-TOKEN';
export const CSRF_HEADER = 'x-xsrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const EXEMPT_PATHS = new Set(['/api/auth/login']);
@Injectable()
export class CsrfGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (SAFE_METHODS.has(req.method)) return true;
if (EXEMPT_PATHS.has(req.path)) return true;
const fromCookie = (req as any).cookies?.[CSRF_COOKIE];
const fromHeader = req.headers[CSRF_HEADER];
const headerValue = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader;
if (!fromCookie || !headerValue || fromCookie !== headerValue) {
throw new ForbiddenException('安全校验已失效,请刷新页面后重试');
}
return true;
}
}
/**
* 文件用途(白话):登录相关的请求与响应数据形状。
* 关联文件:backend/.../auth/dto/*.java、auth.controller.ts、frontend/src/modules/auth/auth-api-client.js。
*/
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginRequestDto {
@IsString() @IsNotEmpty({ message: '账号名不能为空' })
username!: string;
@IsString() @IsNotEmpty()
password!: string;
}
/** 与 Java 的 CurrentUserResponse 字段一一对应,前端登录后直接用它渲染菜单。 */
export interface CurrentUserResponse {
id: number;
username: string;
roleCode: string;
pagePermissions: Record<string, string>;
}
/**
* 文件用途(白话):验证登录锁定的每一条规则,逐条对应 Java 侧 InMemoryLoginAttemptGuardTest 的 9 个用例。
* 关联文件:login-attempt.service.ts、backend/src/test/.../InMemoryLoginAttemptGuardTest.java。
*
* 这些规则只存在于代码和测试里,接口对拍完全测不到——
* 因为"锁没锁上、锁多久、继续试会不会延长"这些事,从响应上看都是同一句「账号或密码错误」。
*
* 时间用可控时钟推进,而不是真的等 24 小时。
*/
import { LoginAttemptService, LoginThrottledException } from './login-attempt.service';
const HOUR = 60 * 60 * 1000;
const MINUTE = 60 * 1000;
describe('LoginAttemptService', () => {
let guard: LoginAttemptService;
let now: number;
beforeEach(() => {
now = Date.parse('2026-08-05T09:00:00Z');
guard = new LoginAttemptService();
guard.setClock(() => now);
});
const advance = (ms: number) => { now += ms; };
const fail = (times: number, username = 'Jeddy') => { for (let i = 0; i < times; i++) guard.recordFailure(username); };
it('第 20 次失败才锁定,第 19 次仍放行', () => {
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
});
it('锁定满 24 小时后自动解除', () => {
fail(20);
advance(23 * HOUR + 59 * MINUTE);
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
advance(2 * MINUTE);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
it('锁定期间继续尝试不会延长锁定时间', () => {
fail(20);
const lockedUntil = guard.lockedAccounts()[0].lockedUntil;
advance(12 * HOUR);
fail(50);
expect(guard.lockedAccounts()[0].lockedUntil).toBe(lockedUntil);
});
it('登录成功清空失败计数', () => {
fail(19);
guard.recordSuccess('Jeddy');
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
it('账号名比对忽略大小写与首尾空格', () => {
fail(10, 'Jeddy');
fail(10, ' JEDDY ');
expect(() => guard.assertAllowed('jeddy')).toThrow(LoginThrottledException);
});
it('手动解锁立即生效', () => {
fail(20);
expect(guard.unlock('JEDDY')).toBe(true);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
expect(guard.lockedAccounts()).toHaveLength(0);
});
it('锁定过期后重新计数,而不是再次直接锁定', () => {
fail(20);
advance(24 * HOUR + MINUTE);
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
expect(guard.lockedAccounts()).toHaveLength(0);
});
it('距上次失败超出统计窗口后重新计数', () => {
fail(19);
advance(25 * HOUR);
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
});
/**
* Java 侧用 8 线程并发调用验证计数不丢不重。
* Node 单线程执行同步方法本就不存在竞态,此处验证的是同一件事的等价语义:
* 19 次调用之后必须仍未锁定,第 20 次必须锁定——计数既不能少算也不能多算。
*/
it('失败计数精确,19 次不锁第 20 次锁', () => {
fail(19);
expect(() => guard.assertAllowed('Jeddy')).not.toThrow();
guard.recordFailure('Jeddy');
expect(() => guard.assertAllowed('Jeddy')).toThrow(LoginThrottledException);
});
});
/**
* 文件用途(白话):记录登录失败次数,连续失败到阈值就把账号锁一段时间,防止有人穷举密码。
* 关联文件:backend/.../auth/InMemoryLoginAttemptGuard.java、auth.service.ts、system-user.controller.ts。
* 关联逻辑(数据流):登录失败 -> recordFailure() -> 达阈值写入锁定时间 -> 下次 assertAllowed() 直接拒绝。
*
* 规则逐条对应 Java 实现(有 9 条测试锁定这些行为):
* - 连续 20 次失败锁定 24 小时
* - 锁定期间继续尝试不会延长锁定时间
* - 锁定过期后重新从 1 开始计数,而不是接着累加
* - 距上次失败超过 24 小时,计数重新开始
* - 登录成功立即清空该账号的失败记录
* - 账号名比对忽略大小写与首尾空格
*
* 状态存在进程内存里,与 Java 一致:重启即清空,多实例部署时各自独立。
* 这是已知的取舍,不是遗漏——真正的防线是密码强度与恒定耗时,锁定只是加一道门槛。
*/
import { Injectable } from '@nestjs/common';
import { BusinessException } from '../common/business.exception';
const THRESHOLD = 20;
const LOCK_DURATION_MS = 24 * 60 * 60 * 1000;
const FAILURE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** 条目上限,超过时清理已过期的记录,避免被大量随机账号名撑爆内存。 */
const MAX_ENTRIES = 50_000;
interface Attempt { failures: number; lockedUntil: number | null; lastFailureAt: number; }
export interface LockedAccount { username: string; failures: number; lockedUntil: string; lastFailureAt: string; }
/**
* 账号被锁定时抛出,返回 429 并告知大约还要等多久。
*
* 注意这里不能沿用「账号或密码错误」:被锁定与密码错误是两回事,
* 使用者必须知道自己是被锁了、还要等多久、以及可以找管理员解锁,
* 否则会一直重试,反而让锁定窗口不断延后(实际不会延长,但使用者并不知情)。
*
* 剩余小时向上取整且至少为 1,避免出现「请约 0 小时后重试」。
*/
export class LoginThrottledException extends BusinessException {
constructor(readonly lockedUntil: number) {
const minutes = Math.max(0, Math.round((lockedUntil - Date.now()) / 60000));
const hours = Math.max(1, Math.ceil(minutes / 60));
super(`登录失败次数过多,账号已被临时锁定,请约 ${hours} 小时后重试或联系管理员解锁`, 429);
this.name = 'LoginThrottledException';
}
}
@Injectable()
export class LoginAttemptService {
private readonly attempts = new Map<string, Attempt>();
/**
* 时钟做成可替换的属性而非构造参数:写成构造参数会被 Nest 的依赖注入当成待注入的 Function。
* 测试里用 setClock 快进 24 小时,对应 Java 侧构造函数注入的 Clock。
*/
private now: () => number = () => Date.now();
setClock(now: () => number) { this.now = now; }
private key(username: string | null | undefined): string {
return (username ?? '').trim().toLowerCase();
}
/**
* 代码作用(白话):登录前先看这个账号是否还在锁定期内。
* 关联逻辑:刻意放在查数据库之前——被锁的账号连库都不查,既省资源也不给攻击者任何时间差信息。
*/
assertAllowed(username: string): void {
const attempt = this.attempts.get(this.key(username));
const now = this.now();
if (attempt?.lockedUntil != null && attempt.lockedUntil > now) throw new LoginThrottledException(attempt.lockedUntil);
}
/** 代码作用(白话):记一次失败,必要时锁定账号。 */
recordFailure(username: string): void {
if (this.attempts.size >= MAX_ENTRIES) this.evictStale();
const key = this.key(username);
const now = this.now();
const current = this.attempts.get(key);
// 已在锁定期内:原样保留,绝不因为继续尝试而延长锁定
if (current?.lockedUntil != null && current.lockedUntil > now) return;
const lockExpired = current?.lockedUntil != null;
const outOfWindow = current != null && current.lastFailureAt + FAILURE_WINDOW_MS < now;
const restart = current == null || lockExpired || outOfWindow;
const failures = restart ? 1 : current!.failures + 1;
const lockedUntil = failures >= THRESHOLD ? now + LOCK_DURATION_MS : null;
this.attempts.set(key, { failures, lockedUntil, lastFailureAt: now });
}
/** 代码作用(白话):登录成功后清空该账号的失败记录。 */
recordSuccess(username: string): void { this.attempts.delete(this.key(username)); }
/** 代码作用(白话):列出当前仍处于锁定状态的账号,按解锁时间倒序,供管理界面展示。 */
lockedAccounts(): LockedAccount[] {
const now = this.now();
const locked: Array<LockedAccount & { _until: number }> = [];
for (const [username, a] of this.attempts) {
if (a.lockedUntil != null && a.lockedUntil > now) {
locked.push({ username, failures: a.failures, lockedUntil: new Date(a.lockedUntil).toISOString(), lastFailureAt: new Date(a.lastFailureAt).toISOString(), _until: a.lockedUntil });
}
}
locked.sort((x, y) => y._until - x._until);
return locked.map(({ _until, ...rest }) => rest);
}
/** 代码作用(白话):管理员手动解锁,返回该账号此前是否确实有记录。 */
unlock(username: string): boolean { return this.attempts.delete(this.key(username)); }
/** 代码作用(白话):清掉既未锁定、失败时间也已超出统计窗口的陈旧条目。 */
private evictStale(): void {
const now = this.now();
for (const [key, a] of this.attempts) {
const stillLocked = a.lockedUntil != null && a.lockedUntil > now;
if (!stillLocked && a.lastFailureAt + FAILURE_WINDOW_MS < now) this.attempts.delete(key);
}
}
}
/**
* 文件用途(白话):计算某个账号实际能访问哪些页面、以及校验前端提交的权限配置是否合法。
* 关联文件:backend/.../auth/PagePermissionService.java、auth.service.ts、system-user.service.ts。
* 关联逻辑(数据流):账号角色 + 已保存的页面权限 -> effectivePermissions() -> 登录响应 -> 前端菜单与路由。
*
* 规则与 Java 侧一致:管理员(DEVELOPER / SUPER_ADMIN)对所有页面固定 EDIT,
* 其余角色默认 NONE,再用数据库里保存的配置逐项覆盖;无法识别的 key 或非法取值一律忽略。
*/
import { Injectable } from '@nestjs/common';
import { BusinessException, ForbiddenException } from '../common/business.exception';
export const PAGE_KEYS = {
OVERVIEW: 'overview',
DOMAIN: 'domain',
WECOM: 'reference-wecom',
PHONE: 'phone-assets',
COMPANY_PROFILE: 'company-profile',
COMPANY_PERSON: 'company-person',
ALERTS: 'alerts',
} as const;
/**
* 页面 key 的输出顺序。
* Java 侧用 Map.of 构建,其迭代顺序取决于 JVM 启动时的随机哈希种子——实测同一份代码
* 重启前后顺序会变,因此字段顺序并非稳定契约,无法也不必复现。
* 这里改用固定的业务顺序(总览在前、提醒在后),保证本服务自身的输出始终一致。
* 前端按 key 取值,与顺序无关;契约比对时对象键会先排序再比较。
*/
const PAGE_ORDER = ['overview', 'company-profile', 'company-person', 'phone-assets', 'reference-wecom', 'domain', 'alerts'];
const VALID_LEVELS = new Set(['NONE', 'READ', 'EDIT']);
export type PermissionMap = Record<string, string>;
@Injectable()
export class PagePermissionService {
/** 管理员身份判定,两个角色对所有页面拥有 EDIT。 */
isAdministrator(roleCode: string | null | undefined): boolean {
return roleCode === 'DEVELOPER' || roleCode === 'SUPER_ADMIN';
}
/**
* 代码作用(白话):算出账号最终的逐页权限。
* 关联逻辑:管理员直接全 EDIT;普通角色先全部置 NONE,再用库里保存的配置覆盖合法项。
* 解析失败时保持全 NONE,与 Java 的 catch 后忽略保持一致——宁可少给权限,不可多给。
*/
effectivePermissions(user: { role_code: string | null; page_permissions?: unknown }): PermissionMap {
const admin = this.isAdministrator(user.role_code);
const result: PermissionMap = {};
for (const key of PAGE_ORDER) result[key] = admin ? 'EDIT' : 'NONE';
if (admin || user.page_permissions == null) return result;
try {
const saved = typeof user.page_permissions === 'string' ? JSON.parse(user.page_permissions) : user.page_permissions;
if (saved && typeof saved === 'object') {
for (const [key, value] of Object.entries(saved as Record<string, unknown>)) {
if (PAGE_ORDER.includes(key) && typeof value === 'string' && VALID_LEVELS.has(value)) result[key] = value;
}
}
} catch { /* 与 Java 一致:解析失败即视为无额外权限 */ }
return result;
}
/** 代码作用(白话):校验前端提交的权限配置,任何非法 key 或取值都整体拒绝。 */
validatePermissions(input: PermissionMap | null | undefined): PermissionMap {
const normalized: PermissionMap = {};
if (input) {
for (const [key, value] of Object.entries(input)) {
if (!PAGE_ORDER.includes(key) || !VALID_LEVELS.has(value)) throw new BusinessException('页面权限配置无效');
normalized[key] = value;
}
}
return normalized;
}
/** 代码作用(白话):把库里存的权限规整成统一形状,用于判断编辑前后权限是否真的变了。 */
normalizeStoredPermissions(stored: unknown): PermissionMap {
if (stored == null || stored === '') return this.validatePermissions({});
try {
const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored;
return this.validatePermissions(parsed as PermissionMap);
} catch { return this.validatePermissions({}); }
}
/**
* 代码作用(白话):断言当前请求对某页面具备所需权限,不足则 403。
* 关联逻辑:EDIT 可通过任何要求;READ 仅在要求为 READ 时通过。
*/
require(permissions: PermissionMap | undefined, pageKey: string, minimum: 'READ' | 'EDIT'): void {
const level = permissions?.[pageKey] ?? 'NONE';
const allowed = level === 'EDIT' || (level === 'READ' && minimum === 'READ');
if (!allowed) throw new ForbiddenException('没有页面权限');
}
/** 代码作用(白话):断言当前身份是管理员,用于账号管理类接口。 */
requireAdministrator(roleCode: string | null | undefined): void {
if (!this.isAdministrator(roleCode)) throw new ForbiddenException('没有权限');
}
}
/**
* 文件用途(白话):从会话 Cookie 还原出当前登录身份,并确认这个身份此刻仍然有效。
* 关联文件:backend/.../auth/AuthTokenFilter.java、auth-token.service.ts、page-permission.service.ts。
* 关联逻辑(调用链):受保护请求 -> 读 Cookie -> 验签 JWT -> 查账号状态与凭证版本 -> 挂到 request 上供控制器使用。
*
* 光验签不够,还要回库核对两件事:
* - 账号仍是 ACTIVE:停用后现有会话必须立刻失效
* - auth_version 与令牌里的一致:改密码时数据库触发器会让该值自增,
* 从而使改密码前签发的所有令牌立即作废(详见 V1__system_user_auth_permissions.sql)
*
* 任何一项不满足都按未登录处理,文案必须是「请先登录」——
* 前端靠这个字符串判定会话过期并跳转登录页。
*/
import { CanActivate, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { AuthPrincipal, AuthTokenService } from './auth-token.service';
import { PagePermissionService, PermissionMap } from './page-permission.service';
import { PrismaService } from '../common/prisma.service';
import { UnauthorizedException } from '../common/business.exception';
export const IS_PUBLIC = 'isPublic';
/** 标记无需登录即可访问的接口,对应 Java 的 permitAll 列表。 */
export const Public = () => SetMetadata(IS_PUBLIC, true);
export interface AuthenticatedRequest extends Request {
principal?: AuthPrincipal;
permissions?: PermissionMap;
}
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly tokens: AuthTokenService,
private readonly permissions: PagePermissionService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [context.getHandler(), context.getClass()])) return true;
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.tokens.extract((req as any).cookies);
if (!token) throw new UnauthorizedException('请先登录');
const principal = this.tokens.parse(token);
const user = await this.prisma.as_system_user.findUnique({ where: { id: BigInt(principal.userId) } });
if (!user || user.status !== 'ACTIVE' || (user.auth_version ?? 1) !== principal.authVersion) {
throw new UnauthorizedException('请先登录');
}
req.principal = principal;
req.permissions = this.permissions.effectivePermissions(user);
return true;
}
}
/**
* 文件用途(白话):把所有异常转成与 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';
/** 表或列与代码对不上时 Prisma 报的错误码,对应 Java 的 BadSqlGrammarException。 */
const PRISMA_SCHEMA_MISMATCH = new Set(['P2021', 'P2022']);
@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 (PRISMA_SCHEMA_MISMATCH.has(error?.code)) {
return { status: 500, 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> {
// Nest 对 POST 默认返回 201,Spring 一律返回 200;契约以 Java 为准,在此统一。
const res = context.switchToHttp().getResponse();
if (res.statusCode === 201) res.statusCode = 200;
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'; }
}
/**
* 记录不存在,返回 404。
*
* 注意各模块并不统一,这是既有契约不是笔误:
* 公司档案 / 公司人员 记录不存在返回 400(Java 侧抛 IllegalArgumentException)
* 手机号 / 设备 / 企微 记录不存在返回 404(各自有 *NotFoundException 与专属处理器)
* 迁移期照搬,不做统一。
*/
export class NotFoundException extends BusinessException {
constructor(message: string) { super(message, 404); this.name = 'NotFoundException'; }
}
/** 权限不足,对应 Java 的 AccessDeniedException,统一 403。 */
export class ForbiddenException extends BusinessException {
constructor(message = '没有权限') { super(message, 403); this.name = 'ForbiddenException'; }
}
/** 未登录或会话失效,对应 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))));
}
/**
* 文件用途(白话):按接口上标注的页面权限要求,决定当前登录身份能否访问。
* 关联文件:require-permission.decorator.ts、auth/page-permission.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫解析出权限表 -> 本守卫比对标注要求 -> 放行或 403「没有页面权限」。
*
* 只作用于标注了 RequirePermission 的接口;未标注的接口交由各自的守卫或控制器自行判断
* (例如账号管理接口在 service 内部按角色判断)。
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PagePermissionService } from '../auth/page-permission.service';
import { AuthenticatedRequest } from '../auth/session.guard';
import { PERMISSION_KEY, RequiredPermission } from './require-permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector, private readonly permissions: PagePermissionService) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<RequiredPermission>(PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!required) return true;
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
this.permissions.require(req.permissions, required.page, required.minimum);
return true;
}
}
/**
* 文件用途(白话):把数据库连接做成全局可用,避免每个业务模块都重复声明一遍。
* 关联文件:prisma.service.ts、app.module.ts、各业务模块。
*
* 标记为 @Global 是有意的:整个应用共用一个连接池,
* 若每个模块各自 provide 一次 PrismaService,会创建出多个互不相干的连接池。
*/
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}
/**
* 文件用途(白话):全应用共用一个数据库连接,并在应用关闭时干净地断开。
* 关联文件: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(); }
}
/**
* 文件用途(白话):给接口标注"需要哪个页面的什么权限",由守卫统一执行检查。
* 关联文件:permission.guard.ts、backend/.../PagePermissionService.java 的 require 方法。
* 关联逻辑(调用链):控制器方法上的标注 -> 守卫读取 -> 与会话权限比对 -> 放行或 403。
*
* Java 侧是在每个控制器方法体的第一行手写 permissions.require(...),
* 这里改用标注加守卫:漏写标注时接口会因为没有任何权限约束而暴露,
* 因此下面的 PermissionGuard 采用「未标注即拒绝」的默认策略,把漏写变成立刻可见的失败。
*/
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'requiredPermission';
export interface RequiredPermission { page: string; minimum: 'READ' | 'EDIT'; }
export const RequirePermission = (page: string, minimum: 'READ' | 'EDIT') =>
SetMetadata(PERMISSION_KEY, { page, minimum } as RequiredPermission);
/**
* 文件用途(白话):补上 Spring Security 默认会加的那几个安全响应头。
* 关联文件:backend/.../config/SecurityConfig.java、main.ts。
* 关联逻辑(数据流):任意响应 -> 本中间件写入固定响应头 -> 浏览器据此收紧行为。
*
* 这几个头是 Spring Security 开箱带的,换到 NestJS 后不会自动出现,
* 漏掉就是实打实的安全退化,而且功能完全正常、任何人工测试都发现不了。
* 取值逐一对齐 Java 侧实测结果:
*
* X-Content-Type-Options: nosniff 禁止浏览器猜测响应类型,避免把 JSON 当脚本执行
* X-Frame-Options: DENY 禁止页面被嵌入 iframe,防点击劫持
* X-XSS-Protection: 0 显式关闭浏览器老式 XSS 过滤器(现代做法,该过滤器本身会引入漏洞)
* Cache-Control / Pragma / Expires 禁止缓存,避免带权限的数据留在中间层或磁盘上
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class SecurityHeadersMiddleware implements NestMiddleware {
use(_req: Request, res: Response, next: NextFunction) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '0');
res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('X-Frame-Options', 'DENY');
next();
}
}
/**
* 文件用途(白话):把 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;
}
/**
* 文件用途(白话):统一处理表单文本——去掉首尾空格,空字符串一律存成 null。
* 关联文件:各业务 service、backend/.../CompanyProfileService.java 的 normalizeOptionalText。
*
* 为什么空字符串要转 null:库里 null 与 '' 语义不同,前端也据此区分"没填"和"填了空"。
* Java 侧所有可选文本字段都做了这个转换,不照做会出现同一字段在两个后端存法不同。
*/
export function optionalText(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
/** 必填文本:规范化后仍为空则抛出指定文案。 */
export function requiredText(value: string | null | undefined, message: string): string {
const normalized = optionalText(value);
if (normalized === null) throw new Error(message);
return normalized;
}
export function hasText(value: string | null | undefined): boolean {
return value != null && value.trim() !== '';
}
/**
* 文件用途(白话):公司人员的五个接口。
* 关联文件:backend/.../asset/controller/CompanyPersonController.java、company-person.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*
* 注意路由顺序:lookups 这类固定路径必须声明在 :id 之前,
* 否则会被当成 id 匹配走进详情类接口。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { CompanyPersonService } from './company-person.service';
import { CompanyPersonPageQueryDto, CompanyPersonSaveDto } from './dto/company-person.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/company-persons')
export class CompanyPersonController {
constructor(private readonly service: CompanyPersonService) {}
@Get('lookups/company-profiles')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'READ')
companies(@Query('keyword') keyword?: string) {
return this.service.searchCompanies(keyword);
}
@Get()
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'READ')
page(@Query() query: CompanyPersonPageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async create(@Body() dto: CompanyPersonSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: CompanyPersonSaveDto) {
return success(await this.service.update(id, dto), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PERSON, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CompanyPersonController } from './company-person.controller';
import { CompanyPersonService } from './company-person.service';
@Module({ imports: [AuthModule], controllers: [CompanyPersonController], providers: [CompanyPersonService] })
export class CompanyPersonModule {}
/**
* 文件用途(白话):验证公司人员的在职状态规则与删除保护,对应 Java 侧 CompanyPersonServiceTest。
* 关联文件:company-person.service.ts。
*
* 在职与离职时间的联动最容易写漏:改回在职时若不清空离职时间,
* 库里就会留下「在职却有离职时间」的自相矛盾记录,界面上完全看不出来。
*/
import { CompanyPersonService } from './company-person.service';
function buildService() {
const prisma: any = {
as_company_person: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_profile: { findFirst: jest.fn().mockResolvedValue({ id: 4n, company_name: '广州学有成', short_name: '学有为' }), findMany: jest.fn().mockResolvedValue([]) },
as_asset_device: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_wechat_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
};
return { service: new CompanyPersonService(prisma), prisma };
}
const activePerson = { id: 7n, person_name: '张三', employment_status: '在职', company_profile_id: 4n, delete_time: 0n };
describe('CompanyPersonService', () => {
it('不填在职状态时默认在职', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.create.mockResolvedValue(activePerson);
await service.create({ personName: '张三' });
expect(prisma.as_company_person.create.mock.calls[0][0].data.employment_status).toBe('在职');
});
it('在职状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create({ personName: '张三', employmentStatus: '实习' })).rejects.toThrow('在职状态取值无效');
});
it('状态为离职却没填离职时间时拒绝', async () => {
const { service } = buildService();
await expect(service.create({ personName: '张三', employmentStatus: '离职' })).rejects.toThrow('离职人员必须填写离职时间');
});
it('改回在职时清空离职时间,不留下自相矛盾的记录', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue({ ...activePerson, employment_status: '离职' });
prisma.as_company_person.update.mockResolvedValue(activePerson);
await service.update(7, { personName: '张三', employmentStatus: '在职' });
expect(prisma.as_company_person.update.mock.calls[0][0].data.resigned_at).toBeNull();
});
it('所属公司不存在时拒绝保存', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(null);
await expect(service.create({ personName: '张三', companyProfileId: 999 })).rejects.toThrow('所属公司不存在或已删除');
});
it('不填所属公司时允许保存', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.create.mockResolvedValue({ ...activePerson, company_profile_id: null });
await expect(service.create({ personName: '张三' })).resolves.toBeDefined();
expect(prisma.as_company_person.create.mock.calls[0][0].data.company_profile_id).toBeNull();
});
it('仍被设备或账号引用时不允许删除,并说明引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue(activePerson);
prisma.as_asset_device.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
await expect(service.softDelete(7)).rejects.toThrow('公司人员仍被设备资产管理、企业微信资产引用,不能删除');
});
it('公司显示名优先用简称', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findMany.mockResolvedValue([activePerson]);
prisma.as_company_profile.findMany.mockResolvedValue([{ id: 4n, company_name: '广州学有成文化传媒有限公司', short_name: '学有为' }]);
const result = await service.page({});
expect(result.records[0].companyProfileName).toBe('学有为');
});
it('没有简称时回落到公司全称', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findMany.mockResolvedValue([activePerson]);
prisma.as_company_profile.findMany.mockResolvedValue([{ id: 4n, company_name: '广州学有成文化传媒有限公司', short_name: null }]);
const result = await service.page({});
expect(result.records[0].companyProfileName).toBe('广州学有成文化传媒有限公司');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('公司人员不存在或已删除');
});
});
/**
* 文件用途(白话):公司人员的查询、新增、修改、删除,以及所属公司的下拉搜索。
* 关联文件:backend/.../asset/service/CompanyPersonService.java、company-person.controller.ts。
* 关联逻辑(数据流):列表筛选 -> 分页查询并补上公司名 -> 表单提交 -> 状态校验 -> 写库。
*
* 三条业务规则:
* - 在职状态只能是「在职」或「离职」,不填默认「在职」
* - 状态为「离职」时必须填离职时间;状态为「在职」时离职时间会被清空,
* 避免出现「在职却有离职时间」这种自相矛盾的数据
* - 删除前检查四类资产是否仍把此人作为使用人或运营人
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException } from '../common/business.exception';
import { nowForDatabase, toDatabaseDate } from '../common/datetime';
import { hasText, optionalText } from '../common/text';
import { CompanyPersonPageQueryDto, CompanyPersonResponse, CompanyPersonSaveDto } from './dto/company-person.dto';
const VALID_STATUS = new Set(['在职', '离职']);
const DEFAULT_SIZE = 20;
/** 下拉搜索最多返回 20 条,与 Java 侧的 limit 一致。 */
const LOOKUP_LIMIT = 20;
@Injectable()
export class CompanyPersonService {
constructor(private readonly prisma: PrismaService) {}
async page(query: CompanyPersonPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where: any = { delete_time: 0n };
if (hasText(query.keyword)) where.person_name = { contains: query.keyword };
if (query.companyProfileId != null) where.company_profile_id = BigInt(query.companyProfileId);
if (hasText(query.employmentStatus)) where.employment_status = query.employmentStatus;
const [rows, total] = await Promise.all([
this.prisma.as_company_person.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_company_person.count({ where }),
]);
const names = await this.companyNames(rows.map((r) => r.company_profile_id));
return { records: rows.map((r) => this.toResponse(r, names.get(String(r.company_profile_id)) ?? null)), total, page, size };
}
/** 一次查出本页涉及的公司名,避免逐行查询。只取存活的公司,与 Java 的 delete_time 条件一致。 */
private async companyNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_profile.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, company_name: true, short_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.short_name || r.company_name]));
}
async create(dto: CompanyPersonSaveDto): Promise<CompanyPersonResponse> {
const company = await this.validateCompany(dto.companyProfileId);
const now = nowForDatabase();
const created = await this.prisma.as_company_person.create({
data: { ...this.editableFields(dto), create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created, company ? (company.short_name || company.company_name) : null);
}
async update(id: number, dto: CompanyPersonSaveDto): Promise<CompanyPersonResponse> {
await this.requireActive(id);
const company = await this.validateCompany(dto.companyProfileId);
const updated = await this.prisma.as_company_person.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated, company ? (company.short_name || company.company_name) : null);
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_company_person.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
async searchCompanies(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.OR = [{ company_name: { contains: keyword } }, { short_name: { contains: keyword } }];
const rows = await this.prisma.as_company_profile.findMany({ where, orderBy: { id: 'desc' }, take: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), companyName: r.company_name, shortName: r.short_name }));
}
private editableFields(dto: CompanyPersonSaveDto) {
const personName = optionalText(dto.personName);
if (personName === null) throw new BusinessException('人员姓名不能为空');
const status = hasText(dto.employmentStatus) ? (dto.employmentStatus as string) : '在职';
if (!VALID_STATUS.has(status)) throw new BusinessException('在职状态取值无效');
if (status === '离职' && dto.resignedAt == null) throw new BusinessException('离职人员必须填写离职时间');
return {
company_profile_id: dto.companyProfileId == null ? null : BigInt(dto.companyProfileId),
person_name: personName,
employment_status: status,
resigned_at: status === '离职' ? toDatabaseDate(dto.resignedAt as string) : null,
};
}
private async validateCompany(id: number | null | undefined) {
if (id == null) return null;
const company = await this.prisma.as_company_profile.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!company) throw new BusinessException('所属公司不存在或已删除');
return company;
}
private async requireActive(id: number) {
const entity = await this.prisma.as_company_person.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new BusinessException('公司人员不存在或已删除');
return entity;
}
/** 设备把此人记为使用人,企微/微信/抖音把此人记为运营人时,都不允许删除。 */
private async checkActiveReferences(id: number): Promise<void> {
const personId = BigInt(id);
const [device, wecom, wechat, douyin] = await Promise.all([
this.prisma.as_asset_device.count({ where: { user_person_id: personId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
this.prisma.as_wechat_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { operator_person_id: personId, delete_time: 0n } }),
]);
const types: string[] = [];
if (device > 0) types.push('设备资产管理');
if (wecom > 0) types.push('企业微信资产');
if (wechat > 0) types.push('微信资产');
if (douyin > 0) types.push('抖音资产');
if (types.length > 0) throw new BusinessException(`公司人员仍被${types.join('、')}引用,不能删除`);
}
private toResponse(entity: any, companyName: string | null): CompanyPersonResponse {
return {
id: Number(entity.id),
companyProfileId: entity.company_profile_id == null ? null : Number(entity.company_profile_id),
companyProfileName: companyName,
personName: entity.person_name,
employmentStatus: entity.employment_status,
resignedAt: entity.resigned_at,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):公司人员接口的请求与响应形状。
* 关联文件:backend/.../dto/CompanyPerson*.java、company-person.service.ts。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class CompanyPersonPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
@IsOptional() @IsString() keyword?: string;
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number;
@IsOptional() @IsString() employmentStatus?: string;
}
export class CompanyPersonSaveDto {
@IsOptional() @Type(() => Number) @IsInt() companyProfileId?: number | null;
/** 同 companyName:必填校验在管道层完成,返回统一文案,与 Java 的 @NotBlank 一致。 */
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
personName!: string;
@IsOptional() @IsString() employmentStatus?: string;
/** 离职时间:仅在状态为离职时有意义,在职时会被清空。 */
@IsOptional() resignedAt?: string | null;
}
export interface CompanyPersonResponse {
id: number;
companyProfileId: number | null;
companyProfileName: string | null;
personName: string;
employmentStatus: string;
resignedAt: Date | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):公司档案的四个接口。
* 关联文件:backend/.../asset/controller/CompanyProfileController.java、company-profile.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*
* 权限要求逐个对应 Java 侧方法体首行的 permissions.require 调用:
* 查询需要 READ,增删改需要 EDIT。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { CompanyProfileService } from './company-profile.service';
import { CompanyProfilePageQueryDto, CompanyProfileSaveDto } from './dto/company-profile.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/company-profiles')
export class CompanyProfileController {
constructor(private readonly service: CompanyProfileService) {}
@Get()
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'READ')
page(@Query() query: CompanyProfilePageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async create(@Body() dto: CompanyProfileSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: CompanyProfileSaveDto) {
return success(await this.service.update(id, dto), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.COMPANY_PROFILE, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CompanyProfileController } from './company-profile.controller';
import { CompanyProfileService } from './company-profile.service';
@Module({ imports: [AuthModule], controllers: [CompanyProfileController], providers: [CompanyProfileService] })
export class CompanyProfileModule {}
/**
* 文件用途(白话):验证公司档案的校验与删除保护,对应 Java 侧 CompanyProfileServiceTest。
* 关联文件:company-profile.service.ts。
*
* 删除保护那条最关键:五类资产里任何一处还在引用这家公司都不能删,
* 提示语要说清是被谁引用。少查一张表就会删掉仍被引用的档案,
* 留下一批指向空档案的记录,而且不会有任何报错。
*/
import { CompanyProfileService } from './company-profile.service';
function buildService() {
const prisma: any = {
as_company_profile: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_person: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
as_domain_asset: { count: jest.fn().mockResolvedValue(0) },
as_merchant: { count: jest.fn().mockResolvedValue(0) },
};
return { service: new CompanyProfileService(prisma), prisma };
}
const activeProfile = { id: 3n, company_name: '广州学有成', short_name: '学有为', delete_time: 0n };
describe('CompanyProfileService', () => {
it('公司名称为空白时拒绝保存', async () => {
const { service } = buildService();
await expect(service.create({ companyName: ' ' })).rejects.toThrow('公司名称不能为空');
});
it('可选字段的空白值存成 null 而不是空字符串', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.create.mockResolvedValue({ ...activeProfile });
await service.create({ companyName: '广州学有成', shortName: ' ', address: '' });
const data = prisma.as_company_profile.create.mock.calls[0][0].data;
expect(data.short_name).toBeNull();
expect(data.address).toBeNull();
});
it('公司名称前后空格会被去掉', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.create.mockResolvedValue({ ...activeProfile });
await service.create({ companyName: ' 广州学有成 ' });
expect(prisma.as_company_profile.create.mock.calls[0][0].data.company_name).toBe('广州学有成');
});
it('仍被公司人员引用时不允许删除,并说明引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
prisma.as_company_person.count.mockResolvedValue(2);
await expect(service.softDelete(3)).rejects.toThrow('公司档案仍被公司人员引用,不能删除');
expect(prisma.as_company_profile.update).not.toHaveBeenCalled();
});
it('被多类资产引用时提示语按顺序列出全部引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
prisma.as_company_person.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
prisma.as_merchant.count.mockResolvedValue(1);
await expect(service.softDelete(3)).rejects.toThrow('公司档案仍被公司人员、企业微信资产、商户引用,不能删除');
});
it('无人引用时执行软删除而非物理删除', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
await service.softDelete(3);
const data = prisma.as_company_profile.update.mock.calls[0][0].data;
expect(data.delete_time).not.toBe(0n);
expect(typeof data.delete_time).toBe('bigint');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(null);
await expect(service.update(999, { companyName: 'x' })).rejects.toThrow('公司档案不存在或已删除');
await expect(service.softDelete(999)).rejects.toThrow('公司档案不存在或已删除');
});
it('列表查询只取存活记录并按 id 倒序', async () => {
const { service, prisma } = buildService();
await service.page({});
const args = prisma.as_company_profile.findMany.mock.calls[0][0];
expect(args.where.delete_time).toBe(0n);
expect(args.orderBy).toEqual({ id: 'desc' });
});
it('关键词在六个字段上做模糊匹配', async () => {
const { service, prisma } = buildService();
await service.page({ keyword: '学有' });
const fields = prisma.as_company_profile.findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]);
expect(fields).toEqual(['company_name', 'short_name', 'unified_social_credit_code', 'address', 'contact_name', 'contact_value']);
});
});
/**
* 文件用途(白话):公司档案的查询、新增、修改与删除。
* 关联文件:backend/.../asset/service/CompanyProfileService.java、company-profile.controller.ts。
* 关联逻辑(数据流):列表页筛选 -> 分页查询 -> 表单提交 -> 校验与规范化 -> 写库。
*
* 两处容易被简化掉、但简化了就会出事的规则:
*
* 一、删除前必须检查五张表的引用。
* 公司人员、企微、抖音、域名、商户任何一处还在用这家公司,就不允许删除,
* 并把具体的引用方拼进提示语(如「公司档案仍被公司人员、企业微信资产引用,不能删除」)。
* 少查一张表,就可能删掉一家仍被引用的公司,留下一批指向空档案的记录。
*
* 二、可选文本字段空值统一存 null,不存空字符串。
* 库里 null 与 '' 语义不同,前端也据此区分「没填」与「填了空」。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText, optionalText } from '../common/text';
import { CompanyProfilePageQueryDto, CompanyProfileResponse, CompanyProfileSaveDto } from './dto/company-profile.dto';
const DEFAULT_PAGE = 1;
const DEFAULT_SIZE = 20;
@Injectable()
export class CompanyProfileService {
constructor(private readonly prisma: PrismaService) {}
async page(query: CompanyProfilePageQueryDto) {
const page = query.page ?? DEFAULT_PAGE;
const size = query.size ?? DEFAULT_SIZE;
const where = this.activeWhere(query.keyword);
const [rows, total] = await Promise.all([
this.prisma.as_company_profile.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_company_profile.count({ where }),
]);
return { records: rows.map((r) => this.toResponse(r)), total, page, size };
}
/** 关键词在六个字段上做模糊匹配,与 Java 侧的 or 条件组一致。 */
private activeWhere(keyword?: string) {
const base: any = { delete_time: 0n };
if (!hasText(keyword)) return base;
const like = { contains: keyword as string };
return {
...base,
OR: [
{ company_name: like }, { short_name: like }, { unified_social_credit_code: like },
{ address: like }, { contact_name: like }, { contact_value: like },
],
};
}
async create(dto: CompanyProfileSaveDto): Promise<CompanyProfileResponse> {
const now = nowForDatabase();
const created = await this.prisma.as_company_profile.create({
data: { ...this.editableFields(dto), create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created);
}
async update(id: number, dto: CompanyProfileSaveDto): Promise<CompanyProfileResponse> {
await this.requireActive(id);
const updated = await this.prisma.as_company_profile.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated);
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_company_profile.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
private editableFields(dto: CompanyProfileSaveDto) {
const companyName = optionalText(dto.companyName);
if (companyName === null) throw new BusinessException('公司名称不能为空');
return {
company_name: companyName,
short_name: optionalText(dto.shortName),
unified_social_credit_code: optionalText(dto.unifiedSocialCreditCode),
address: optionalText(dto.address),
contact_name: optionalText(dto.contactName),
contact_value: optionalText(dto.contactValue),
};
}
private async requireActive(id: number) {
const entity = await this.prisma.as_company_profile.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new BusinessException('公司档案不存在或已删除');
return entity;
}
/**
* 逐一检查五类资产是否仍引用这家公司,并把引用方拼进提示语。
* 抖音、域名、商户三张表当前没有业务模块在写,但检查必须保留——
* 将来这些模块启用后,少查一张就会误删仍被引用的公司档案。
*/
private async checkActiveReferences(id: number): Promise<void> {
const companyId = BigInt(id);
const [person, wecom, douyin, domain, merchant] = await Promise.all([
this.prisma.as_company_person.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_domain_asset.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
this.prisma.as_merchant.count({ where: { company_profile_id: companyId, delete_time: 0n } }),
]);
const types: string[] = [];
if (person > 0) types.push('公司人员');
if (wecom > 0) types.push('企业微信资产');
if (douyin > 0) types.push('抖音资产');
if (domain > 0) types.push('域名资产');
if (merchant > 0) types.push('商户');
if (types.length > 0) throw new BusinessException(`公司档案仍被${types.join('、')}引用,不能删除`);
}
private toResponse(entity: any): CompanyProfileResponse {
return {
id: Number(entity.id),
companyName: entity.company_name,
shortName: entity.short_name,
unifiedSocialCreditCode: entity.unified_social_credit_code,
address: entity.address,
contactName: entity.contact_name,
contactValue: entity.contact_value,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):公司档案接口的请求与响应形状。
* 关联文件:backend/.../dto/CompanyProfile*.java、company-profile.service.ts。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class CompanyProfilePageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
page?: number;
/** 上限 100:Java 侧同样限制,超出直接返回校验失败而不是静默截断。 */
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
size?: number;
@IsOptional() @IsString()
keyword?: string;
}
export class CompanyProfileSaveDto {
/**
* 必填校验放在管道层,与 Java 的 @NotBlank 位置一致——两边都在进入业务代码之前就被拦下,
* 返回统一文案「提交内容不符合要求,请检查后重试」。
* 先 trim 再判空,使纯空格也被视为未填。
* service 里同名的检查作为防御保留,正常情况下走不到。
*/
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
companyName!: string;
@IsOptional() @IsString() shortName?: string;
@IsOptional() @IsString() unifiedSocialCreditCode?: string;
@IsOptional() @IsString() address?: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsString() contactValue?: string;
}
export interface CompanyProfileResponse {
id: number;
companyName: string;
shortName: string | null;
unifiedSocialCreditCode: string | null;
address: string | null;
contactName: string | null;
contactValue: string | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):设备资产的六个接口,含 multipart 图片上传与图片读取。
* 关联文件:backend/.../asset/controller/DeviceAssetController.java、device-asset.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service -> 图片存储。
*
* 图片读取接口直接回二进制,不走统一的 JSON 包装——前端是用 img 标签加载它的。
* 上传上限单文件 20MB、整请求 42MB,与 Java 的 multipart 配置一致。
*/
import { Body, Controller, Delete, Get, Header, Param, ParseIntPipe, Post, Put, Query, Res, UploadedFiles, UseInterceptors } from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { DeviceAssetService } from './device-asset.service';
import { DeviceAssetPageQueryDto, DeviceAssetSaveDto } from './dto/device-asset.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
import { UploadedImage } from './file-storage.service';
const MAX_FILE_BYTES = 20 * 1024 * 1024;
const UPLOAD_FIELDS = [{ name: 'imageAttachment1', maxCount: 1 }, { name: 'imageAttachment2', maxCount: 1 }];
const UPLOAD_OPTIONS = { limits: { fileSize: MAX_FILE_BYTES, fieldSize: 42 * 1024 * 1024 } };
type UploadedFileMap = { imageAttachment1?: UploadedImage[]; imageAttachment2?: UploadedImage[] };
const pick = (files: UploadedFileMap = {}) => ({ image1: files.imageAttachment1?.[0], image2: files.imageAttachment2?.[0] });
@Controller('api/device-assets')
export class DeviceAssetController {
constructor(private readonly service: DeviceAssetService) {}
@Get('lookups/company-persons')
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
companyPersons(@Query('keyword') keyword?: string) { return this.service.searchCompanyPersons(keyword); }
@Get('lookups/next-device-name')
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
nextDeviceName(@Query('prefix') prefix?: string) { return this.service.suggestNextDeviceName(prefix); }
/** 图片直出二进制,不套 JSON 外壳——前端用 img 标签加载。 */
@Get('files/:identifier')
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
@Header('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')
async file(@Param('identifier') identifier: string, @Query('variant') variant: string | undefined, @Res() res: Response) {
const data = await this.service.readImage(identifier, variant);
res.type(variant === 'thumb' ? 'image/jpeg' : identifier.slice(identifier.lastIndexOf('.') + 1));
res.send(data);
}
@Get()
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
page(@Query() query: DeviceAssetPageQueryDto) { return this.service.page(query); }
@Post()
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
@UseInterceptors(FileFieldsInterceptor(UPLOAD_FIELDS, UPLOAD_OPTIONS))
async create(@Body() dto: DeviceAssetSaveDto, @UploadedFiles() files: UploadedFileMap) {
return success(await this.service.create(dto, pick(files)), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
@UseInterceptors(FileFieldsInterceptor(UPLOAD_FIELDS, UPLOAD_OPTIONS))
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: DeviceAssetSaveDto, @UploadedFiles() files: UploadedFileMap) {
return success(await this.service.update(id, dto, pick(files)), '修改成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DeviceAssetController } from './device-asset.controller';
import { DeviceAssetService } from './device-asset.service';
import { DeviceAssetFileStorageService } from './file-storage.service';
@Module({ imports: [AuthModule], controllers: [DeviceAssetController], providers: [DeviceAssetService, DeviceAssetFileStorageService] })
export class DeviceAssetModule {}
/**
* 文件用途(白话):验证设备资产的校验、自动编号与删除保护,对应 Java 侧 DeviceAssetServiceTest。
* 关联文件:device-asset.service.ts。
*
* 设备名查重必须带上删除时间条件:数据库唯一索引是「名称 + 删除时间」的组合,
* 漏掉这个条件会出现「删掉了却仍提示名称已存在」,使用者完全无从理解。
*/
import { DeviceAssetService } from './device-asset.service';
function buildService() {
const prisma: any = {
as_asset_device: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_person: { count: jest.fn().mockResolvedValue(1), findMany: jest.fn().mockResolvedValue([]) },
as_phone_asset: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_wechat_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
};
const files: any = { store: jest.fn().mockResolvedValue(null), replace: jest.fn().mockResolvedValue(null), cleanupNewFile: jest.fn(), readOriginal: jest.fn(), readThumbnail: jest.fn() };
return { service: new DeviceAssetService(prisma, files), prisma, files };
}
const validSave = (overrides: any = {}) => ({ deviceName: '学管师1号机', userUsageStatus: '使用中', assetRelationStatus: '已关联', ...overrides });
const activeDevice = { id: 5n, device_name: '学管师1号机', image_attachment_1: null, image_attachment_2: null, delete_time: 0n };
describe('DeviceAssetService 校验', () => {
it('使用状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ userUsageStatus: '在用' }), {})).rejects.toThrow('使用状态取值无效');
});
it('资产关联状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ assetRelationStatus: '关联中' }), {})).rejects.toThrow('资产关联状态取值无效');
});
it('使用人不存在时拒绝', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.count.mockResolvedValue(0);
await expect(service.create(validSave({ userPersonId: 999 }), {})).rejects.toThrow('使用人不存在或已删除');
});
it('设备名重复时拒绝', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.count.mockResolvedValue(1);
await expect(service.create(validSave(), {})).rejects.toThrow('设备名称已存在');
});
it('查重条件带删除时间,使同名可在删除后重建', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.create.mockResolvedValue(activeDevice);
await service.create(validSave(), {});
expect(prisma.as_asset_device.count.mock.calls[0][0].where.delete_time).toBe(0n);
});
it('修改时查重排除自己,避免改别的字段被自己挡住', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
prisma.as_asset_device.update.mockResolvedValue(activeDevice);
await service.update(5, validSave(), {});
expect(prisma.as_asset_device.count.mock.calls[0][0].where.id).toEqual({ not: 5n });
});
});
describe('DeviceAssetService 自动编号', () => {
it('没有同前缀设备时从 1 号机开始', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师1号机' });
});
it('取现有最大编号加一,而不是数量加一', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findMany.mockResolvedValue([
{ device_name: '学管师3号机' }, { device_name: '学管师31号机' }, { device_name: '学管师7号机' },
]);
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师32号机' });
});
it('忽略形状不符的名称', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findMany.mockResolvedValue([
{ device_name: '学管师备用机' }, { device_name: '学管师2号机' }, { device_name: '学管师A3号机' },
]);
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师3号机' });
});
it('不传前缀时用默认前缀', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName()).resolves.toEqual({ deviceName: '学管师1号机' });
});
it('前缀含特殊字符时拒绝,避免污染模糊查询', async () => {
const { service } = buildService();
// 百分号与下划线在模糊查询里是通配符,其余符号也没有作为设备前缀的意义
for (const bad of ['%', '_abc', 'a%b', '设备-1', 'x'.repeat(21)]) {
await expect(service.suggestNextDeviceName(bad)).rejects.toThrow('设备名称前缀只能是 1-20 位中文、字母或数字');
}
});
it('前缀是纯空格时视为未填,回落到默认前缀', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName(' ')).resolves.toEqual({ deviceName: '学管师1号机' });
});
});
describe('DeviceAssetService 删除保护与图片清理', () => {
it('仍被手机号或账号引用时不允许删除', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
prisma.as_phone_asset.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
await expect(service.softDelete(5)).rejects.toThrow('设备仍被手机号资产、企业微信资产引用,不能删除');
});
it('无引用时执行软删除', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
await service.softDelete(5);
expect(prisma.as_asset_device.update.mock.calls[0][0].data.delete_time).not.toBe(0n);
});
it('写库失败时清掉本次新落盘的图片,不留野文件', async () => {
const { service, prisma, files } = buildService();
files.store.mockResolvedValueOnce('new-image-1.png').mockResolvedValueOnce('new-image-2.png');
prisma.as_asset_device.create.mockRejectedValue(new Error('写库失败'));
await expect(service.create(validSave(), {})).rejects.toThrow('写库失败');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-1.png');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-2.png');
});
it('修改失败时只清理新图,保留仍被记录引用的原图', async () => {
const { service, prisma, files } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue({ ...activeDevice, image_attachment_1: 'old-image.png' });
files.replace.mockResolvedValueOnce('old-image.png').mockResolvedValueOnce('new-image-2.png');
prisma.as_asset_device.update.mockRejectedValue(new Error('写库失败'));
await expect(service.update(5, validSave(), {})).rejects.toThrow('写库失败');
expect(files.cleanupNewFile).not.toHaveBeenCalledWith('old-image.png');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-2.png');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('设备资产不存在或已删除');
});
});
/**
* 文件用途(白话):设备资产的查询、新增、修改、删除,含图片保存与设备名自动编号。
* 关联文件:backend/.../asset/service/DeviceAssetService.java、file-storage.service.ts。
* 关联逻辑(数据流):表单与图片 -> 校验 -> 图片落盘 -> 写设备记录 -> 失败时清理已落盘的新图。
*
* 三处要点:
* - 设备名在存活记录中唯一(修改时排除自己)。数据库唯一索引是「名称 + 删除时间」的组合,
* 因此删除之后同名可以重建,查重时必须带上删除时间条件。
* - 写库失败要把本次新落盘的图片删掉,否则磁盘会攒下没有任何记录引用的野文件。
* - 删除前检查手机号、企微、微信、抖音四类资产是否仍关联此设备。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException, NotFoundException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText } from '../common/text';
import { DeviceAssetFileStorageService, UploadedImage } from './file-storage.service';
import { DeviceAssetPageQueryDto, DeviceAssetResponse, DeviceAssetSaveDto } from './dto/device-asset.dto';
const USAGE_STATUSES = new Set(['使用中', '闲置', '维修中', '停用']);
const RELATION_STATUSES = new Set(['已关联', '未关联', '待确认']);
const DEFAULT_DEVICE_NAME_PREFIX = '学管师';
/** 前缀会拼进模糊查询条件,必须限定字符集:百分号与下划线会被当通配符。 */
const SAFE_PREFIX = /^[一-龥A-Za-z0-9]{1,20}$/;
/** 编号位数上限交给正则,避免脏数据把数值解析撑爆。 */
const NUMBERED_SUFFIX = /^(\d{1,6})号机$/;
const DEFAULT_SIZE = 20;
const LOOKUP_LIMIT = 20;
@Injectable()
export class DeviceAssetService {
constructor(private readonly prisma: PrismaService, private readonly files: DeviceAssetFileStorageService) {}
async page(query: DeviceAssetPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where: any = { delete_time: 0n };
if (hasText(query.deviceName)) where.device_name = { contains: query.deviceName };
if (hasText(query.userUsageStatus)) where.user_usage_status = query.userUsageStatus;
if (hasText(query.assetRelationStatus)) where.asset_relation_status = query.assetRelationStatus;
if (query.userPersonId != null) where.user_person_id = BigInt(query.userPersonId);
const [rows, total] = await Promise.all([
this.prisma.as_asset_device.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_asset_device.count({ where }),
]);
const names = await this.personNames(rows.map((r) => r.user_person_id));
return { records: rows.map((r) => this.toResponse(r, names)), total, page, size };
}
async create(dto: DeviceAssetSaveDto, images: { image1?: UploadedImage; image2?: UploadedImage }): Promise<DeviceAssetResponse> {
await this.validateSaveRequest(dto, null);
let image1: string | null = null;
let image2: string | null = null;
try {
image1 = await this.files.store(images.image1);
image2 = await this.files.store(images.image2);
const now = nowForDatabase();
const created = await this.prisma.as_asset_device.create({
data: { ...this.editableFields(dto), image_attachment_1: image1, image_attachment_2: image2, create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created, await this.personNames([created.user_person_id]));
} catch (error) {
// 写库失败时清掉本次新落盘的图,避免磁盘留下无人引用的野文件
await this.files.cleanupNewFile(image1);
await this.files.cleanupNewFile(image2);
throw error;
}
}
async update(id: number, dto: DeviceAssetSaveDto, images: { image1?: UploadedImage; image2?: UploadedImage }): Promise<DeviceAssetResponse> {
const entity = await this.requireActive(id);
await this.validateSaveRequest(dto, id);
const originalImage1 = entity.image_attachment_1;
const originalImage2 = entity.image_attachment_2;
let image1: string | null = null;
let image2: string | null = null;
try {
image1 = await this.files.replace(originalImage1, images.image1, dto.removeImageAttachment1 === true);
image2 = await this.files.replace(originalImage2, images.image2, dto.removeImageAttachment2 === true);
const updated = await this.prisma.as_asset_device.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), image_attachment_1: image1, image_attachment_2: image2, update_time: nowForDatabase() },
});
return this.toResponse(updated, await this.personNames([updated.user_person_id]));
} catch (error) {
// 只清理本次新产生的图片,原图必须留着——它仍被这条记录引用
if (image1 && image1 !== originalImage1) await this.files.cleanupNewFile(image1);
if (image2 && image2 !== originalImage2) await this.files.cleanupNewFile(image2);
throw error;
}
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_asset_device.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
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 suggestNextDeviceName(prefix?: string) {
const base = hasText(prefix) ? (prefix as string).trim() : DEFAULT_DEVICE_NAME_PREFIX;
if (!SAFE_PREFIX.test(base)) throw new BusinessException('设备名称前缀只能是 1-20 位中文、字母或数字');
// 先用前缀把候选行捞出来;「后面必须是纯数字加号机」这类形状判断查询语言表达不了,放在代码里做
const rows = await this.prisma.as_asset_device.findMany({
where: { delete_time: 0n, device_name: { startsWith: base } },
select: { device_name: true },
});
let largest = 0;
for (const row of rows) {
const name = row.device_name;
if (!name?.startsWith(base)) continue;
const matched = NUMBERED_SUFFIX.exec(name.slice(base.length));
if (matched) largest = Math.max(largest, Number.parseInt(matched[1], 10));
}
return { deviceName: `${base}${largest + 1}号机` };
}
async readImage(identifier: string, variant?: string): Promise<Buffer> {
return variant === 'thumb' ? this.files.readThumbnail(identifier) : this.files.readOriginal(identifier);
}
private editableFields(dto: DeviceAssetSaveDto) {
return {
device_name: dto.deviceName.trim(),
user_person_id: dto.userPersonId == null ? null : BigInt(dto.userPersonId),
user_usage_status: dto.userUsageStatus ?? null,
asset_relation_status: dto.assetRelationStatus ?? null,
};
}
private async validateSaveRequest(dto: DeviceAssetSaveDto, currentId: number | null): Promise<void> {
if (!hasText(dto.deviceName)) throw new BusinessException('设备名称不能为空');
this.validateStatus(dto.userUsageStatus, USAGE_STATUSES, '使用状态');
this.validateStatus(dto.assetRelationStatus, RELATION_STATUSES, '资产关联状态');
await this.validateUserPerson(dto.userPersonId);
// 唯一索引是「名称 + 删除时间」的组合,查重必须带删除时间条件,否则已删的同名会挡住新建
const where: any = { delete_time: 0n, device_name: dto.deviceName.trim() };
if (currentId != null) where.id = { not: BigInt(currentId) };
if (await this.prisma.as_asset_device.count({ where }) > 0) throw new BusinessException('设备名称已存在');
}
private validateStatus(value: string | undefined, allowed: Set<string>, label: string): void {
if (!hasText(value) || !allowed.has(value as string)) throw new BusinessException(`${label}取值无效`);
}
private async validateUserPerson(userPersonId?: number | null): Promise<void> {
if (userPersonId == null) return;
const count = await this.prisma.as_company_person.count({ where: { id: BigInt(userPersonId), delete_time: 0n } });
if (count === 0) throw new BusinessException('使用人不存在或已删除');
}
private async requireActive(id: number) {
const entity = await this.prisma.as_asset_device.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new NotFoundException('设备资产不存在或已删除');
return entity;
}
private async checkActiveReferences(id: number): Promise<void> {
const deviceId = BigInt(id);
const [phone, wecom, wechat, douyin] = await Promise.all([
this.prisma.as_phone_asset.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_wechat_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
]);
const types: string[] = [];
if (phone > 0) types.push('手机号资产');
if (wecom > 0) types.push('企业微信资产');
if (wechat > 0) types.push('微信资产');
if (douyin > 0) types.push('抖音资产');
if (types.length > 0) throw new BusinessException(`设备仍被${types.join('、')}引用,不能删除`);
}
private async personNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_person.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) } },
select: { id: true, person_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.person_name]));
}
private toResponse(entity: any, names: Map<string, string>): DeviceAssetResponse {
const urlOf = (identifier: string | null) => (identifier ? `/api/device-assets/files/${identifier}` : null);
return {
id: Number(entity.id),
deviceName: entity.device_name,
imageAttachment1Url: urlOf(entity.image_attachment_1),
imageAttachment2Url: urlOf(entity.image_attachment_2),
imageAttachment1ThumbUrl: entity.image_attachment_1 ? `${urlOf(entity.image_attachment_1)}?variant=thumb` : null,
imageAttachment2ThumbUrl: entity.image_attachment_2 ? `${urlOf(entity.image_attachment_2)}?variant=thumb` : null,
userPersonId: entity.user_person_id == null ? null : Number(entity.user_person_id),
userPersonName: entity.user_person_id == null ? null : (names.get(String(entity.user_person_id)) ?? null),
userUsageStatus: entity.user_usage_status,
assetRelationStatus: entity.asset_relation_status,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):设备资产接口的请求与响应形状。
* 关联文件:backend/.../dto/DeviceAsset*.java、device-asset.service.ts。
*
* 表单以 multipart 提交,除文本字段外还带两张图片,
* 因此数字与布尔值到达时都是字符串,需要显式转换。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class DeviceAssetPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
@IsOptional() @IsString() deviceName?: string;
@IsOptional() @IsString() userUsageStatus?: string;
@IsOptional() @IsString() assetRelationStatus?: string;
@IsOptional() @Type(() => Number) @IsInt() userPersonId?: number;
}
export class DeviceAssetSaveDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
deviceName!: string;
@IsOptional() @Transform(({ value }) => (value === '' || value == null ? null : Number(value)))
userPersonId?: number | null;
@IsOptional() @IsString() userUsageStatus?: string;
@IsOptional() @IsString() assetRelationStatus?: string;
/** multipart 传来的是字符串,"true" 才算真。 */
@IsOptional() @Transform(({ value }) => value === true || value === 'true')
removeImageAttachment1?: boolean;
@IsOptional() @Transform(({ value }) => value === true || value === 'true')
removeImageAttachment2?: boolean;
}
export interface DeviceAssetResponse {
id: number;
deviceName: string;
imageAttachment1Url: string | null;
imageAttachment2Url: string | null;
imageAttachment1ThumbUrl: string | null;
imageAttachment2ThumbUrl: string | null;
userPersonId: number | null;
userPersonName: string | null;
userUsageStatus: string | null;
assetRelationStatus: string | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):验证设备图片的存取与安全边界,对应 Java 侧 DeviceAssetFileStorageServiceTest 的 7 个用例。
* 关联文件:file-storage.service.ts、backend/src/test/.../DeviceAssetFileStorageServiceTest.java。
*
* 其中路径穿越那条最要紧:图片标识来自 URL,若不加限制,
* 构造一个带上级引用的标识就能读到目录之外的任意文件。
* 这类漏洞功能测试完全发现不了——正常使用永远不会出现那样的标识。
*
* 测试在临时目录里跑,不碰真实的上传目录。
*/
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import sharp from 'sharp';
import { DeviceAssetFileStorageService } from './file-storage.service';
describe('DeviceAssetFileStorageService', () => {
let root: string;
let storage: DeviceAssetFileStorageService;
const pngOf = (width: number, height: number) =>
sharp({ create: { width, height, channels: 3, background: { r: 40, g: 120, b: 200 } } }).png().toBuffer();
const upload = (buffer: Buffer, originalname = 'photo.png') => ({ originalname, buffer, size: buffer.length });
beforeEach(async () => {
root = join(tmpdir(), `xyw-image-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
process.env.XYW_DEVICE_ASSETS_UPLOAD_DIR = root;
storage = new DeviceAssetFileStorageService();
await storage.ensureRoot();
});
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
it('保存原图的同时产出缩略图', async () => {
const identifier = await storage.store(upload(await pngOf(800, 600)));
expect(identifier).toMatch(/^[0-9a-f-]{36}\.png$/);
const files = await fs.readdir(root);
expect(files).toContain(identifier);
expect(files).toContain(`${identifier}.thumb.jpg`);
});
it('缩略图长边压到 240 且保持比例', async () => {
const identifier = await storage.store(upload(await pngOf(800, 600)));
const meta = await sharp(await storage.readThumbnail(identifier as string)).metadata();
expect(meta.width).toBe(240);
expect(meta.height).toBe(180);
expect(meta.format).toBe('jpeg');
});
it('小于目标尺寸的图片不被放大', async () => {
const identifier = await storage.store(upload(await pngOf(100, 80)));
const meta = await sharp(await storage.readThumbnail(identifier as string)).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(80);
});
it('支持 JPG、PNG、GIF,拒绝其他扩展名', async () => {
const image = await pngOf(200, 200);
for (const name of ['a.jpg', 'b.jpeg', 'c.png', 'd.gif']) {
await expect(storage.store(upload(image, name))).resolves.toBeTruthy();
}
await expect(storage.store(upload(image, 'e.bmp'))).rejects.toThrow('仅支持 JPG、PNG、GIF 图片');
});
it('拒绝超过 20MB 的文件与非图片内容', async () => {
const oversize = { originalname: 'big.png', buffer: Buffer.alloc(21 * 1024 * 1024), size: 21 * 1024 * 1024 };
await expect(storage.store(oversize)).rejects.toThrow('每张图片不能超过 20MB');
await expect(storage.store(upload(Buffer.from('not an image at all'), 'fake.png'))).rejects.toThrow('图片内容无效');
});
it('拒绝越界的图片标识', async () => {
for (const evil of ['../application.yml', '..%2Fapplication.yml', 'a/b.png', 'not-a-uuid.png', '../../etc/passwd']) {
await expect(storage.readOriginal(evil)).rejects.toThrow('图片标识无效');
}
});
it('只有原图没有缩略图时按需补生成', async () => {
const identifier = await storage.store(upload(await pngOf(400, 300))) as string;
// 模拟历史数据:缩略图缺失,只剩原图
await fs.unlink(join(root, `${identifier}.thumb.jpg`));
const thumbnail = await storage.readThumbnail(identifier);
expect((await sharp(thumbnail).metadata()).width).toBe(240);
// 补生成后应落盘,下次不必再算
expect(await fs.readdir(root)).toContain(`${identifier}.thumb.jpg`);
});
it('原图丢失时读取缩略图报图片不存在', async () => {
const identifier = await storage.store(upload(await pngOf(400, 300))) as string;
await fs.unlink(join(root, `${identifier}.thumb.jpg`));
await fs.unlink(join(root, identifier));
await expect(storage.readThumbnail(identifier)).rejects.toThrow('图片不存在');
});
it('清理时原图与缩略图一并删除', async () => {
const identifier = await storage.store(upload(await pngOf(300, 300))) as string;
await storage.cleanupNewFile(identifier);
expect(await fs.readdir(root)).toHaveLength(0);
});
});
/**
* 文件用途(白话):保存设备图片、生成列表用的小缩略图,并只允许通过不透明标识读取。
* 关联文件:backend/.../asset/service/DeviceAssetFileStorageService.java、device-asset.service.ts。
* 关联逻辑(数据流):multipart 图片 -> 校验 -> 落盘原图 -> 生成缩略图 -> 返回标识 -> 写入设备记录。
*
* 缩略图参数逐项对齐 Java 实现,任何一项不同都会让两边出图肉眼可辨:
* 长边上限 240(列表显示 40px、弹窗 104px,240 已覆盖二倍屏)
* 保持比例,小图不放大
* 透明区域填白,而不是默认的黑——Java 用 TYPE_INT_RGB 加 Color.WHITE 填充
* 统一输出 JPEG,文件名为「原标识 + .thumb.jpg」
*
* 与 Java 的一处实现差异(有意):
* Java 为绕开 ImageIO 会把 20MB 图读两遍的问题,采用「先落盘再解码校验」,失败后再删文件。
* sharp 可以直接从内存缓冲区读取元信息,因此这里改为「先校验再落盘」——
* 结果完全一致(无效图片一律拒绝且不留文件),但天然不会产生需要清理的野文件。
*/
import { Injectable } from '@nestjs/common';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { isAbsolute, join, normalize, resolve } from 'node:path';
import sharp from 'sharp';
import { BusinessException } from '../common/business.exception';
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif']);
const THUMBNAIL_MAX_EDGE = 240;
const THUMBNAIL_SUFFIX = '.thumb.jpg';
export interface UploadedImage { originalname: string; buffer: Buffer; size: number; }
@Injectable()
export class DeviceAssetFileStorageService {
private readonly root: string;
constructor() {
// 相对路径会随启动方式变化(项目根 / backend 目录),因此在此解析成绝对路径后固定下来
const configured = process.env.XYW_DEVICE_ASSETS_UPLOAD_DIR ?? './uploads/device-assets';
this.root = isAbsolute(configured) ? normalize(configured) : resolve(process.cwd(), configured);
}
async ensureRoot(): Promise<void> {
try { await fs.mkdir(this.root, { recursive: true }); }
catch { throw new BusinessException('设备图片目录无法创建'); }
}
/** 保存一张原图并同时产出缩略图,返回不透明标识。 */
async store(image?: UploadedImage | null): Promise<string | null> {
if (!image || image.size === 0) return null;
if (image.size > MAX_IMAGE_BYTES) throw new BusinessException('每张图片不能超过 20MB');
const extension = this.extensionOf(image.originalname);
if (!EXTENSIONS.has(extension)) throw new BusinessException('仅支持 JPG、PNG、GIF 图片');
await this.ensureRoot();
const identifier = `${randomUUID()}.${extension}`;
const target = this.resolveInsideRoot(identifier);
// 先确认确实是可解码的图片,再落盘,避免留下无法读取的残file
const thumbnail = await this.renderThumbnail(image.buffer);
try {
await fs.writeFile(target, image.buffer);
await fs.writeFile(this.thumbnailPathFor(identifier), thumbnail);
} catch {
await this.cleanupNewFile(identifier);
throw new BusinessException('设备图片保存失败');
}
return identifier;
}
/** 有新图则替换,标记删除则清空引用,否则保持原样。 */
async replace(currentIdentifier: string | null, replacement?: UploadedImage | null, remove = false): Promise<string | null> {
if (replacement && replacement.size > 0) return this.store(replacement);
return remove ? null : currentIdentifier;
}
/** 读原图。标识非法或文件缺失都按「图片不存在」处理,不暴露路径细节。 */
async readOriginal(identifier: string): Promise<Buffer> {
const path = this.resolveInsideRoot(identifier);
try { return await fs.readFile(path); }
catch { throw new BusinessException('图片不存在'); }
}
/** 读缩略图;历史图片可能只有原图,此时按需补生成一次。 */
async readThumbnail(identifier: string): Promise<Buffer> {
const thumbnailPath = this.thumbnailPathFor(identifier);
try { return await fs.readFile(thumbnailPath); }
catch { /* 继续走补生成 */ }
const original = await this.readOriginal(identifier);
const thumbnail = await this.renderThumbnail(original);
try { await fs.writeFile(thumbnailPath, thumbnail); }
catch { throw new BusinessException('图片读取失败'); }
return thumbnail;
}
async cleanupNewFile(identifier: string | null): Promise<void> {
if (!identifier) return;
for (const path of [this.resolveInsideRoot(identifier), this.thumbnailPathFor(identifier)]) {
await fs.unlink(path).catch(() => undefined);
}
}
/** 生成缩略图:长边 240、保持比例、不放大、透明填白、输出 JPEG。 */
private async renderThumbnail(source: Buffer): Promise<Buffer> {
try {
return await sharp(source, { animated: false })
.resize(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
.flatten({ background: { r: 255, g: 255, b: 255 } })
.jpeg()
.toBuffer();
} catch { throw new BusinessException('图片内容无效'); }
}
/**
* 把标识解析成根目录内的绝对路径,并拒绝任何越界尝试。
* 标识必须是「UUID.扩展名」的形状,含路径分隔符或上级引用一律拒绝。
*/
private resolveInsideRoot(identifier: string): string {
if (!/^[0-9a-fA-F-]{36}\.(jpg|jpeg|png|gif)$/.test(identifier)) throw new BusinessException('图片标识无效');
const path = normalize(join(this.root, identifier));
if (!path.startsWith(this.root)) throw new BusinessException('图片标识无效');
return path;
}
private thumbnailPathFor(identifier: string): string {
this.resolveInsideRoot(identifier);
return join(this.root, `${identifier}${THUMBNAIL_SUFFIX}`);
}
private extensionOf(filename?: string): string {
const dot = (filename ?? '').lastIndexOf('.');
return dot < 0 ? '' : (filename as string).slice(dot + 1).toLowerCase();
}
}
/**
* 文件用途(白话):地基自检接口,用来验证响应包装、类型转换、数据库连通是否都正常。
* 关联文件: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';
import cookieParser from 'cookie-parser';
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,
});
// 守卫要从 req.cookies 读会话与 CSRF 令牌,解析中间件必须先于路由注册
app.use(cookieParser());
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();
/**
* 文件用途(白话):手机号码管理接口的请求与响应形状。
* 关联文件:backend/.../dto/PhoneAsset*.java、phone-asset.service.ts。
*/
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PhoneAssetPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
/** 按位数决定匹配方式,规则见 service 的 phoneNumberFilter。 */
@IsOptional() @IsString() phoneNumber?: string;
@IsOptional() @IsString() iccid?: string;
@IsOptional() @IsString() realNameOwner?: string;
@IsOptional() @IsString() disposalStatus?: string;
}
export class PhoneAssetSaveDto {
@IsOptional() @IsString() phoneNumber?: string;
@IsOptional() @IsString() cardType?: string;
@IsOptional() @IsString() iccid?: string;
@IsOptional() @IsString() realNameOwner?: string;
@IsOptional() @IsString() managementType?: string;
@IsOptional() @IsString() disposalStatus?: string;
@IsOptional() @Type(() => Number) @IsInt() deviceId?: number | null;
}
export interface PhoneAssetResponse {
id: number;
phoneNumber: string;
numberType: string | null;
sourceAssetType: string | null;
sourceAssetId: number | null;
cardType: string | null;
iccid: string | null;
realNameOwner: string | null;
managementType: string | null;
disposalStatus: string | null;
deviceId: number | null;
deviceName: string | null;
relationSyncedAt: Date | null;
}
/**
* 文件用途(白话):手机号码管理的五个接口。
* 关联文件:backend/.../asset/controller/PhoneAssetController.java、phone-asset.service.ts。
* 关联逻辑(调用链):请求 -> 会话守卫 -> 页面权限守卫 -> service。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { PhoneAssetService } from './phone-asset.service';
import { PhoneAssetPageQueryDto, PhoneAssetSaveDto } from './dto/phone-asset.dto';
import { RequirePermission } from '../common/require-permission.decorator';
import { PAGE_KEYS } from '../auth/page-permission.service';
import { success } from '../common/api-response';
@Controller('api/phone-assets')
export class PhoneAssetController {
constructor(private readonly service: PhoneAssetService) {}
@Get('lookups/devices')
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
devices(@Query('keyword') keyword?: string) {
return this.service.searchDevices(keyword);
}
@Get()
@RequirePermission(PAGE_KEYS.PHONE, 'READ')
page(@Query() query: PhoneAssetPageQueryDto) {
return this.service.page(query);
}
@Post()
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async create(@Body() dto: PhoneAssetSaveDto) {
return success(await this.service.create(dto), '新增成功');
}
@Put(':id')
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: PhoneAssetSaveDto) {
// 手机号模块的修改成功文案是「编辑成功」,与其他模块的「修改成功」不同,属既有契约,不做统一
return success(await this.service.update(id, dto), '编辑成功');
}
@Delete(':id')
@RequirePermission(PAGE_KEYS.PHONE, 'EDIT')
async remove(@Param('id', ParseIntPipe) id: number) {
await this.service.softDelete(id);
return success(null, '删除成功');
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PhoneAssetController } from './phone-asset.controller';
import { PhoneAssetService } from './phone-asset.service';
@Module({ imports: [AuthModule], controllers: [PhoneAssetController], providers: [PhoneAssetService] })
export class PhoneAssetModule {}
/**
* 文件用途(白话):验证手机号的搜索匹配规则与身份校验。
* 关联文件:phone-asset.service.ts。
*
* 按输入位数变化的搜索规则从接口签名完全看不出来,
* 写成统一模糊匹配看似等价,实际会让「输入尾号查找」退化成乱匹配。
*/
import { PhoneAssetService } from './phone-asset.service';
function buildService() {
const prisma: any = {
as_phone_asset: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_asset_device: { findMany: jest.fn().mockResolvedValue([]) },
};
return { service: new PhoneAssetService(prisma), prisma };
}
const whereOf = (prisma: any) => prisma.as_phone_asset.findMany.mock.calls[0][0].where;
const validSave = (overrides: any = {}) => ({ phoneNumber: '13800138000', iccid: '89860000000000000001', realNameOwner: '张三', ...overrides });
describe('PhoneAssetService 号码搜索', () => {
it('输入 3 位按号段前缀匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '138' });
expect(whereOf(prisma).phone_number).toEqual({ startsWith: '138' });
});
it('输入 4 位按尾号匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '8000' });
expect(whereOf(prisma).phone_number).toEqual({ endsWith: '8000' });
});
it('输入 7 位按前 3 位与后 4 位组合匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '1388000' });
expect(whereOf(prisma).phone_number).toEqual({ startsWith: '138', endsWith: '8000' });
});
it('输入完整号码时精确匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '13800138000' });
expect(whereOf(prisma).phone_number).toBe('13800138000');
});
it('其他长度按精确匹配处理', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '13800' });
expect(whereOf(prisma).phone_number).toBe('13800');
});
it('不填号码时不附加号码条件', async () => {
const { service, prisma } = buildService();
await service.page({});
expect(whereOf(prisma).phone_number).toBeUndefined();
});
});
describe('PhoneAssetService 身份校验', () => {
it('缺少 ICCID 时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ iccid: '' }))).rejects.toThrow('ICCID 不能为空');
});
it('缺少实名人时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ realNameOwner: '' }))).rejects.toThrow('实名人不能为空');
});
it('虚拟号码豁免 ICCID 与实名人', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await expect(service.create({ phoneNumber: '13800138000', cardType: '虚拟号码' })).resolves.toBeDefined();
});
it('号码不是 11 位数字时拒绝', async () => {
const { service } = buildService();
for (const bad of ['1380013', '138001380000', 'abcdefghijk', '']) {
await expect(service.create(validSave({ phoneNumber: bad }))).rejects.toThrow('手机号必须是 11 位数字');
}
});
it('剥掉 +86 前缀后再校验', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave({ phoneNumber: '+8613800138000' }));
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.phone_number).toBe('13800138000');
});
it('处置状态不填时默认正常使用', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave());
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.disposal_status).toBe('正常使用');
});
it('手动建档固定标记为自有号码', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave());
// 企微自动录入的号会标成 EXTERNAL 以便追溯,手动建的必须是 SELF
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.number_type).toBe('SELF');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('手机号码管理不存在或已删除');
});
});
/**
* 文件用途(白话):手机号码台账的查询、新增、修改、删除,以及关联设备的下拉搜索。
* 关联文件:backend/.../asset/service/PhoneAssetService.java、phone-asset.controller.ts。
* 关联逻辑(数据流):列表筛选 -> 分页查询并补上设备名 -> 表单提交 -> 号码规范化与必填校验 -> 写库。
*
* 三条从接口签名完全看不出来的规则:
*
* 一、号码搜索按输入位数决定匹配方式。
* 3 位按号段前缀、4 位按尾号、7 位按「前 3 位 + 后 4 位」组合、其余精确匹配。
* 写成统一的模糊匹配看似等价,实际会让「输入 8888 找尾号」变成全表扫描式的乱匹配。
*
* 二、虚拟号码豁免实名信息。
* 卡类型为「虚拟号码」时跳过 ICCID 与实名人的必填校验——虚拟号本就没有实体卡和实名主体。
*
* 三、号码规范化会剥掉 +86 前缀。
* 粘贴带国际区号的号码时自动处理,最终必须是 11 位纯数字。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException, NotFoundException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText } from '../common/text';
import { PhoneAssetPageQueryDto, PhoneAssetResponse, PhoneAssetSaveDto } from './dto/phone-asset.dto';
const DEFAULT_SIZE = 20;
const DEVICE_LOOKUP_LIMIT = 20;
/** 卡类型为此值时不要求 ICCID 与实名人。 */
const VIRTUAL_CARD_TYPE = '虚拟号码';
@Injectable()
export class PhoneAssetService {
constructor(private readonly prisma: PrismaService) {}
async page(query: PhoneAssetPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where = this.activeWhere(query);
const [rows, total] = await Promise.all([
this.prisma.as_phone_asset.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_phone_asset.count({ where }),
]);
const names = await this.deviceNames(rows.map((r) => r.device_id));
return { records: rows.map((r) => this.toResponse(r, names)), total, page, size };
}
private activeWhere(query: PhoneAssetPageQueryDto) {
const where: any = { delete_time: 0n, ...this.phoneNumberFilter(query.phoneNumber) };
if (hasText(query.iccid)) where.iccid = query.iccid;
if (hasText(query.realNameOwner)) where.real_name_owner = { contains: query.realNameOwner };
if (hasText(query.disposalStatus)) where.disposal_status = query.disposalStatus;
return where;
}
/** 按输入位数选择匹配方式,逐条对应 Java 的 applyPhoneNumberFilter。 */
private phoneNumberFilter(phoneNumber?: string) {
if (!hasText(phoneNumber)) return {};
const value = phoneNumber as string;
if (value.length === 3) return { phone_number: { startsWith: value } };
if (value.length === 4) return { phone_number: { endsWith: value } };
if (value.length === 7) return { phone_number: { startsWith: value.slice(0, 3), endsWith: value.slice(3) } };
return { phone_number: value };
}
async searchDevices(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.device_name = { contains: keyword };
const rows = await this.prisma.as_asset_device.findMany({ where, orderBy: { id: 'desc' }, take: DEVICE_LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), deviceName: r.device_name }));
}
async create(dto: PhoneAssetSaveDto): Promise<PhoneAssetResponse> {
const now = nowForDatabase();
const created = await this.prisma.as_phone_asset.create({
data: {
...this.editableFields(dto),
// 手动建档的号码固定为自有号;企微自动录入的号会被标成 EXTERNAL 以便追溯来源
number_type: 'SELF',
create_time: now, update_time: now, delete_time: 0n,
linked_wecom_accounts: [], linked_wechat_accounts: [], linked_douyin_accounts: [],
linked_domain_accounts: [], linked_merchants: [],
},
});
return this.toResponse(created, await this.deviceNames([created.device_id]));
}
async update(id: number, dto: PhoneAssetSaveDto): Promise<PhoneAssetResponse> {
await this.requireActive(id);
const updated = await this.prisma.as_phone_asset.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), update_time: nowForDatabase() },
});
return this.toResponse(updated, await this.deviceNames([updated.device_id]));
}
/** 号码删除不做引用检查,与 Java 一致:台账记录可以先于业务资产撤下。 */
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.prisma.as_phone_asset.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
private editableFields(dto: PhoneAssetSaveDto) {
this.validateRequiredIdentityFields(dto);
return {
phone_number: this.normalizePhoneNumber(dto.phoneNumber),
card_type: dto.cardType ?? null,
iccid: dto.iccid ?? null,
real_name_owner: dto.realNameOwner ?? null,
management_type: dto.managementType ?? null,
disposal_status: hasText(dto.disposalStatus) ? (dto.disposalStatus as string) : '正常使用',
device_id: dto.deviceId == null ? null : BigInt(dto.deviceId),
};
}
private validateRequiredIdentityFields(dto: PhoneAssetSaveDto): void {
if (dto.cardType === VIRTUAL_CARD_TYPE) return;
if (!hasText(dto.iccid)) throw new BusinessException('ICCID 不能为空');
if (!hasText(dto.realNameOwner)) throw new BusinessException('实名人不能为空');
}
private normalizePhoneNumber(value?: string): string {
let normalized = (value ?? '').trim();
if (normalized.startsWith('+86')) normalized = normalized.slice(3);
if (!/^\d{11}$/.test(normalized)) throw new BusinessException('手机号必须是 11 位数字');
return normalized;
}
private async requireActive(id: number) {
const entity = await this.prisma.as_phone_asset.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new NotFoundException('手机号码管理不存在或已删除');
return entity;
}
/** 批量取设备名;已删除的设备不返回名称,与 Java 的 delete_time = 0 条件一致。 */
private async deviceNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_asset_device.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, device_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.device_name]));
}
private toResponse(entity: any, deviceNames: Map<string, string>): PhoneAssetResponse {
return {
id: Number(entity.id),
phoneNumber: entity.phone_number,
numberType: entity.number_type,
sourceAssetType: entity.source_asset_type,
sourceAssetId: entity.source_asset_id == null ? null : Number(entity.source_asset_id),
cardType: entity.card_type,
iccid: entity.iccid,
realNameOwner: entity.real_name_owner,
managementType: entity.management_type,
disposalStatus: entity.disposal_status,
deviceId: entity.device_id == null ? null : Number(entity.device_id),
deviceName: entity.device_id == null ? null : (deviceNames.get(String(entity.device_id)) ?? null),
relationSyncedAt: entity.relation_synced_at,
};
}
}
/**
* 文件用途(白话):账号管理接口的请求与响应形状。
* 关联文件:backend/.../auth/dto/SystemUser*.java、system-user.service.ts。
*/
import { IsNotEmpty, IsObject, IsOptional, IsString, Matches } from 'class-validator';
export class SystemUserCreateDto {
@IsString() @IsNotEmpty()
@Matches(/^[A-Za-z0-9_]{3,64}$/)
username!: string;
@IsString() @IsNotEmpty()
roleCode!: string;
@IsOptional() @IsString()
password?: string;
@IsOptional() @IsObject()
pagePermissions?: Record<string, string>;
}
export class SystemUserUpdateDto {
@IsString() @IsNotEmpty()
roleCode!: string;
@IsString() @IsNotEmpty()
status!: string;
@IsOptional() @IsObject()
pagePermissions?: Record<string, string>;
}
export class PasswordResetDto {
@IsString() @IsNotEmpty()
password!: string;
}
/** 字段与 Java 的 SystemUserResponse 一一对应。 */
export interface SystemUserResponse {
id: number;
username: string;
roleCode: string;
status: string;
pagePermissions: Record<string, string>;
passwordUpdatedAt: Date | null;
}
/**
* 文件用途(白话):账号管理的六个接口。
* 关联文件:backend/.../auth/SystemUserAdminController.java、system-user.service.ts。
* 关联逻辑(调用链):管理页面 -> 本控制器(取出当前身份的角色)-> service 做权限判断与读写。
*
* 角色从会话守卫挂在请求上的身份里取,不信任任何来自请求体的角色声明。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Req } from '@nestjs/common';
import { SystemUserService } from './system-user.service';
import { PasswordResetDto, SystemUserCreateDto, SystemUserUpdateDto } from './dto/system-user.dto';
import { AuthenticatedRequest } from '../auth/session.guard';
import { UnauthorizedException } from '../common/business.exception';
@Controller('api/system-users')
export class SystemUserController {
constructor(private readonly service: SystemUserService) {}
private role(req: AuthenticatedRequest): string {
if (!req.principal) throw new UnauthorizedException('请先登录');
return req.principal.roleCode;
}
@Get()
list(@Req() req: AuthenticatedRequest) {
return this.service.listVisibleUsers(this.role(req));
}
@Post()
create(@Req() req: AuthenticatedRequest, @Body() dto: SystemUserCreateDto) {
return this.service.createUser(this.role(req), dto);
}
@Put(':id')
update(@Req() req: AuthenticatedRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: SystemUserUpdateDto) {
return this.service.updateUser(this.role(req), id, dto);
}
@Put(':id/password')
async resetPassword(@Req() req: AuthenticatedRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: PasswordResetDto) {
await this.service.resetPassword(this.role(req), id, dto);
return null;
}
@Get('locked-accounts')
lockedAccounts(@Req() req: AuthenticatedRequest) {
return this.service.listLockedAccounts(this.role(req));
}
@Delete('locked-accounts/:username')
unlock(@Req() req: AuthenticatedRequest, @Param('username') username: string) {
this.service.unlockAccount(this.role(req), username);
return null;
}
}
/**
* 文件用途(白话):账号管理模块的装配。
* 关联文件:system-user.controller.ts、system-user.service.ts、auth.module.ts。
*/
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SystemUserController } from './system-user.controller';
import { SystemUserService } from './system-user.service';
@Module({
imports: [AuthModule],
controllers: [SystemUserController],
providers: [SystemUserService],
})
export class SystemUserModule {}
/**
* 文件用途(白话):验证账号管理的权限边界与会话失效规则,逐条对应 Java 侧 SystemUserAdminServiceTest 的 7 个用例。
* 关联文件:system-user.service.ts、backend/src/test/.../SystemUserAdminServiceTest.java。
*
* 其中四条围绕同一件要害:改了角色、状态或页面权限之后,对方手里那张登录令牌必须立刻作废。
* 令牌里记着签发时的 auth_version,账号表里的值一变,旧令牌就对不上了。
* 漏掉自增这一步,权限修改要等对方令牌自然过期(8 小时)才生效——
* 期间他仍以旧权限操作,界面上完全看不出异常,接口对拍也发现不了。
*/
jest.mock('bcrypt', () => ({ hash: jest.fn().mockResolvedValue('$2a$12$hashed'), compare: jest.fn() }));
import { SystemUserService } from './system-user.service';
import { PagePermissionService } from '../auth/page-permission.service';
import { ForbiddenException } from '../common/business.exception';
describe('SystemUserService', () => {
let prisma: any;
let loginAttempts: any;
let service: SystemUserService;
const managedUser = (roleCode: string, authVersion: number, permissions: Record<string, string>) => ({
id: 9n, username: 'managed_user', role_code: roleCode, status: 'ACTIVE',
password_hash: '$2a$12$hashed', delete_time: 0n, auth_version: authVersion,
page_permissions: permissions, password_updated_at: null,
});
beforeEach(() => {
prisma = {
as_system_user: {
findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: 10n, password_updated_at: null })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...managedUser('FINANCE', 1, {}), ...data })),
},
};
loginAttempts = { lockedAccounts: jest.fn(), unlock: jest.fn() };
service = new SystemUserService(prisma, new PagePermissionService(), loginAttempts);
});
/** 取出实际写库时用的 auth_version,用来判断是否触发了自增。 */
const writtenAuthVersion = () => prisma.as_system_user.update.mock.calls[0][0].data.auth_version;
it('提升角色会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'SUPER_ADMIN', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'READ' } });
expect(writtenAuthVersion()).toBe(5);
});
it('改变账号状态会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, {}));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'DISABLED', pagePermissions: {} });
expect(writtenAuthVersion()).toBe(5);
});
it('改变页面权限会让对方的现有登录失效', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'EDIT' } });
expect(writtenAuthVersion()).toBe(5);
});
it('提交内容与原值一致时保留原登录,不无谓地踢人下线', async () => {
prisma.as_system_user.findFirst.mockResolvedValue(managedUser('FINANCE', 4, { 'phone-assets': 'READ' }));
await service.updateUser('DEVELOPER', 9, { roleCode: 'FINANCE', status: 'ACTIVE', pagePermissions: { 'phone-assets': 'READ' } });
expect(writtenAuthVersion()).toBe(4);
});
it('开发者可以创建超级管理员', async () => {
await service.createUser('DEVELOPER', { username: 'admin_two', roleCode: 'SUPER_ADMIN', password: 'strong-password-123', pagePermissions: {} });
expect(prisma.as_system_user.create).toHaveBeenCalled();
});
/** 密码规则只限制长度,刻意允许密码与用户名相同、或包含开发者账号名。 */
it('密码与用户名相同或含开发者账号名时仍可创建', async () => {
await service.createUser('DEVELOPER', { username: 'Jeddy2026User', roleCode: 'OPERATIONS', password: 'Jeddy2026User', pagePermissions: {} });
expect(prisma.as_system_user.create).toHaveBeenCalled();
});
it('非管理员不能创建账号,且不产生任何数据库操作', async () => {
await expect(service.createUser('FINANCE', { username: 'admin_two', roleCode: 'SUPER_ADMIN', password: 'strong-password-123', pagePermissions: {} }))
.rejects.toThrow(ForbiddenException);
expect(prisma.as_system_user.count).not.toHaveBeenCalled();
expect(prisma.as_system_user.create).not.toHaveBeenCalled();
});
});
/**
* 文件用途(白话):管理系统账号——列出、新建、改角色与权限、重置密码、解除登录锁定。
* 关联文件:backend/.../auth/SystemUserAdminService.java、page-permission.service.ts、login-attempt.service.ts。
* 关联逻辑(数据流):管理页面 -> 角色边界检查 -> 账号表读写 -> 必要时使对方的现有会话失效。
*
* 三条容易忽略、做错了界面却完全看不出来的规则:
*
* 一、开发者账号在这里彻底隐身。
* 列表查询与按 id 查询都硬性排除 DEVELOPER,而不是靠前端隐藏。
* 否则超管直接调接口就能停用或改写开发者账号,等于夺取最高权限。
*
* 二、改动角色、状态或页面权限后,必须让对方的现有登录立即失效。
* 登录令牌里带着签发时的 auth_version,与账号表中的值不一致即视为过期。
* 漏掉自增,权限修改要等对方令牌自然过期(8 小时)才生效,
* 期间他仍以旧权限操作,而界面上看不出任何异常。
*
* 三、不能启用一个没有密码的账号。
* 超管新建的账号是「禁用且无密码」,若允许直接启用,
* 就会出现启用状态却无密码可校验的账号。
*
* 已知缺陷(迁移期原样保留):超管创建账号必然失败,因为代码写入 password_hash = null
* 而该列为 NOT NULL。详见 docs/migration-backlog.md 第 2 条。
*/
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../common/prisma.service';
import { PagePermissionService } from '../auth/page-permission.service';
import { LoginAttemptService, LockedAccount } from '../auth/login-attempt.service';
import { BusinessException, ForbiddenException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { PasswordResetDto, SystemUserCreateDto, SystemUserResponse, SystemUserUpdateDto } from './dto/system-user.dto';
/** 可被创建或指派的角色。DEVELOPER 不在其中——它只能是那个固定账号。 */
const ASSIGNABLE_ROLES = new Set(['SUPER_ADMIN', 'FINANCE', 'HR', 'OPERATIONS']);
const BCRYPT_COST = 12;
@Injectable()
export class SystemUserService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PagePermissionService,
private readonly loginAttempts: LoginAttemptService,
) {}
private assertAdministrator(actorRole: string): void {
if (!this.permissions.isAdministrator(actorRole)) throw new ForbiddenException('没有账号管理权限');
}
private assertCreatableRole(actorRole: string, roleCode: string): void {
if (roleCode === 'DEVELOPER') throw new ForbiddenException('开发者账号仅允许固定账号 Jeddy');
if (!ASSIGNABLE_ROLES.has(roleCode)) throw new BusinessException('角色无效');
if (actorRole === 'DEVELOPER' || actorRole === 'SUPER_ADMIN') return;
throw new ForbiddenException('没有账号管理权限');
}
/** 按 id 取出可管理的账号;开发者账号一律视为不存在。 */
private async findManageable(id: number) {
const user = await this.prisma.as_system_user.findFirst({
where: { id: BigInt(id), delete_time: 0n, NOT: { role_code: 'DEVELOPER' } },
});
if (!user) throw new BusinessException('账号不存在');
return user;
}
private requireValidPassword(password: string | null | undefined): void {
if (!password || password.length < 12 || password.length > 72) throw new BusinessException('密码需为 12-72 位');
}
async listVisibleUsers(actorRole: string): Promise<SystemUserResponse[]> {
this.assertAdministrator(actorRole);
const rows = await this.prisma.as_system_user.findMany({
where: { delete_time: 0n, NOT: { role_code: 'DEVELOPER' } },
orderBy: { id: 'desc' },
});
return rows.map((u) => this.responseOf(u));
}
async createUser(actorRole: string, dto: SystemUserCreateDto): Promise<SystemUserResponse> {
this.assertAdministrator(actorRole);
this.assertCreatableRole(actorRole, dto.roleCode);
const existing = await this.prisma.as_system_user.count({ where: { username: dto.username, delete_time: 0n } });
if (existing > 0) throw new BusinessException('用户名已存在');
const now = nowForDatabase();
const permissions = this.permissions.validatePermissions(dto.pagePermissions);
const superAdminActor = actorRole === 'SUPER_ADMIN';
if (!superAdminActor) this.requireValidPassword(dto.password);
const created = await this.prisma.as_system_user.create({
data: {
username: dto.username,
role_code: dto.roleCode,
page_permissions: permissions,
status: superAdminActor ? 'DISABLED' : 'ACTIVE',
password_hash: superAdminActor ? (null as any) : await bcrypt.hash(dto.password as string, BCRYPT_COST),
delete_time: 0n,
create_time: now,
update_time: now,
auth_version: 1,
},
});
return this.responseOf(created);
}
async updateUser(actorRole: string, id: number, dto: SystemUserUpdateDto): Promise<SystemUserResponse> {
this.assertAdministrator(actorRole);
this.assertCreatableRole(actorRole, dto.roleCode);
const user = await this.findManageable(id);
if (dto.status !== 'ACTIVE' && dto.status !== 'DISABLED') throw new BusinessException('账号状态无效');
if (dto.status === 'ACTIVE' && user.password_hash == null) throw new BusinessException('请由开发者先设置密码后再启用账号');
const normalized = this.permissions.validatePermissions(dto.pagePermissions);
const authorizationChanged =
user.role_code !== dto.roleCode ||
user.status !== dto.status ||
JSON.stringify(this.permissions.normalizeStoredPermissions(user.page_permissions)) !== JSON.stringify(normalized);
const updated = await this.prisma.as_system_user.update({
where: { id: BigInt(id) },
data: {
role_code: dto.roleCode,
status: dto.status,
page_permissions: normalized,
auth_version: authorizationChanged ? (user.auth_version ?? 1) + 1 : user.auth_version,
update_time: nowForDatabase(),
},
});
return this.responseOf(updated);
}
/**
* 只写 password_hash,不碰 auth_version:数据库触发器
* trg_as_system_user_password_changed 会在密码变更时自动让它自增。
* 若这里也加一次会变成 +2,把改密码的人自己也踢下线。
*/
async resetPassword(actorRole: string, id: number, dto: PasswordResetDto): Promise<void> {
if (actorRole !== 'DEVELOPER') throw new ForbiddenException('只有开发者可以修改密码');
const user = await this.findManageable(id);
this.requireValidPassword(dto.password);
await this.prisma.as_system_user.update({
where: { id: user.id },
data: { password_hash: await bcrypt.hash(dto.password, BCRYPT_COST), update_time: nowForDatabase() },
});
}
listLockedAccounts(actorRole: string): LockedAccount[] {
this.assertAdministrator(actorRole);
return this.loginAttempts.lockedAccounts();
}
unlockAccount(actorRole: string, username: string): void {
this.assertAdministrator(actorRole);
this.loginAttempts.unlock(username);
}
private responseOf(user: { id: bigint; username: string; role_code: string; status: string; page_permissions: unknown; password_updated_at: Date | null }): SystemUserResponse {
return {
id: Number(user.id),
username: user.username,
roleCode: user.role_code,
status: user.status,
pagePermissions: this.permissions.effectivePermissions(user),
passwordUpdatedAt: user.password_updated_at,
};
}
}
/**
* 文件用途(白话):企业微信资产接口的请求与响应形状。
* 关联文件: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 {}
/**
* 文件用途(白话):验证企微账号与手机号台账的联动规则,对应 Java 侧 WecomAccountServiceTest 的 14 个用例。
* 关联文件:wecom-account.service.ts、backend/src/test/.../WecomAccountServiceTest.java。
*
* 这些规则接口对拍看不出来——响应里只有一句「新增成功」,
* 至于台账是多了一条、少了一条还是没动,只能从数据库操作本身来验证。
*
* 事务客户端用同一组 mock:$transaction 直接把回调跑一遍并把 tx 传进去,
* 这样既能验证业务逻辑,也能确认所有读写确实走的是事务客户端而非全局实例。
*/
import { WecomAccountService } from './wecom-account.service';
const EXISTING_PHONE = { id: 50n, phone_number: '13800138000', number_type: 'SELF', source_asset_type: null, source_asset_id: null };
const AUTO_CREATED_PHONE = { id: 60n, phone_number: '19900001111', number_type: 'EXTERNAL', source_asset_type: 'WECOM', source_asset_id: 9n };
function buildService() {
const tx: any = {
as_phone_asset: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_wecom_account: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), count: jest.fn().mockResolvedValue(0) },
as_wechat_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
as_company_profile: { findFirst: jest.fn() },
as_asset_device: { findFirst: jest.fn() },
};
const prisma: any = {
$transaction: jest.fn(async (cb: any) => cb(tx)),
as_wecom_account: { findMany: jest.fn(), count: jest.fn() },
as_company_profile: { findMany: jest.fn().mockResolvedValue([]) },
as_phone_asset: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn() },
as_asset_device: { findMany: jest.fn().mockResolvedValue([]) },
as_company_person: { findMany: jest.fn().mockResolvedValue([]) },
};
return { service: new WecomAccountService(prisma), prisma, tx };
}
const saveDto = (overrides: any = {}) => ({
wecomName: '测试账号', phoneNumber: '13800138000',
iccid: '89860000000000000001', phoneRealNameOwner: '张三',
...overrides,
});
describe('WecomAccountService 手机号联动', () => {
it('号码已在台账时直接复用,标记为 EXISTING', async () => {
const { service, tx } = buildService();
tx.as_phone_asset.findFirst.mockResolvedValue(EXISTING_PHONE);
tx.as_wecom_account.create.mockResolvedValue({ id: 1n, phone_asset_id: 50n, phone_link_mode: 'EXISTING' });
const result = await service.create(saveDto());
expect(tx.as_phone_asset.create).not.toHaveBeenCalled();
expect(tx.as_wecom_account.create.mock.calls[0][0].data.phone_link_mode).toBe('EXISTING');
expect(result.phoneLinkMode).toBe('EXISTING');
});
it('号码不在台账时自动建档,标记为 CREATED 并回填来源 id', async () => {
const { service, tx } = buildService();
tx.as_phone_asset.findFirst.mockResolvedValue(null);
tx.as_phone_asset.create.mockResolvedValue({ id: 60n, phone_number: '19900001111' });
tx.as_wecom_account.create.mockResolvedValue({ id: 9n, phone_asset_id: 60n, phone_link_mode: 'CREATED' });
await service.create(saveDto({ phoneNumber: '19900001111' }));
const created = tx.as_phone_asset.create.mock.calls[0][0].data;
expect(created.number_type).toBe('EXTERNAL');
expect(created.source_asset_type).toBe('WECOM');
expect(tx.as_wecom_account.create.mock.calls[0][0].data.phone_link_mode).toBe('CREATED');
// 号码先建、账号后建,因此来源 id 只能在账号建好后回填
expect(tx.as_phone_asset.update).toHaveBeenCalledWith({ where: { id: 60n }, data: { source_asset_id: 9n } });
});
it('所属公司不存在时,在创建任何记录之前就拒绝', async () => {
const { service, tx } = buildService();
tx.as_company_profile.findFirst.mockResolvedValue(null);
await expect(service.create(saveDto({ companyProfileId: 999 }))).rejects.toThrow('注册主体不存在或已删除');
expect(tx.as_phone_asset.create).not.toHaveBeenCalled();
expect(tx.as_wecom_account.create).not.toHaveBeenCalled();
});
it('关联设备不存在时同样在创建之前拒绝', async () => {
const { service, tx } = buildService();
tx.as_asset_device.findFirst.mockResolvedValue(null);
await expect(service.create(saveDto({ deviceId: 888 }))).rejects.toThrow('关联设备不存在或已删除');
expect(tx.as_wecom_account.create).not.toHaveBeenCalled();
});
it('自动建档时缺少 ICCID 会被拒绝', async () => {
const { service } = buildService();
await expect(service.create(saveDto({ iccid: '' }))).rejects.toThrow('ICCID 不能为空');
});
it('自动建档时缺少实名人会被拒绝', async () => {
const { service } = buildService();
await expect(service.create(saveDto({ phoneRealNameOwner: '' }))).rejects.toThrow('手机号实名人不能为空');
});
it('虚拟号码豁免 ICCID 与实名人', async () => {
const { service, tx } = buildService();
tx.as_phone_asset.findFirst.mockResolvedValue(null);
tx.as_phone_asset.create.mockResolvedValue({ id: 61n });
tx.as_wecom_account.create.mockResolvedValue({ id: 10n });
await expect(service.create(saveDto({ cardType: '虚拟号码', iccid: '', phoneRealNameOwner: '' }))).resolves.toBeDefined();
});
});
describe('WecomAccountService 改绑与删除', () => {
/** 构造一个已绑定号码的账号,供改绑类用例复用。 */
const boundAccount = (phoneAssetId: bigint, linkMode: string) => ({
id: 9n, phone_asset_id: phoneAssetId, phone_link_mode: linkMode,
wecom_name: '原账号', company_profile_id: null, device_id: null, operator_person_id: null,
});
it('改绑到台账已有的号码时标记为 EXISTING', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(60n, 'CREATED'));
// 当前绑定的号码与目标号码不同,触发改绑
tx.as_phone_asset.findFirst
.mockResolvedValueOnce({ phone_number: '19900001111' }) // 当前号码
.mockResolvedValueOnce(EXISTING_PHONE) // 目标号码已存在
.mockResolvedValueOnce(AUTO_CREATED_PHONE) // 待清理的旧号
.mockResolvedValue({ phone_number: '13800138000' });
tx.as_wecom_account.update.mockResolvedValue({ id: 9n, phone_link_mode: 'EXISTING' });
await service.update(9, saveDto({ phoneNumber: '13800138000' }));
expect(tx.as_phone_asset.create).not.toHaveBeenCalled();
expect(tx.as_wecom_account.update.mock.calls[0][0].data.phone_link_mode).toBe('EXISTING');
});
it('改绑到台账没有的号码时自动建档并标记为 CREATED', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(50n, 'EXISTING'));
tx.as_phone_asset.findFirst
.mockResolvedValueOnce({ phone_number: '13800138000' }) // 当前号码
.mockResolvedValueOnce(null) // 目标号码不存在
.mockResolvedValue({ phone_number: '19900002222' });
tx.as_phone_asset.create.mockResolvedValue({ id: 70n, number_type: 'EXTERNAL', source_asset_type: 'WECOM', source_asset_id: 9n });
tx.as_wecom_account.update.mockResolvedValue({ id: 9n, phone_link_mode: 'CREATED' });
await service.update(9, saveDto({ phoneNumber: '19900002222' }));
expect(tx.as_phone_asset.create).toHaveBeenCalled();
expect(tx.as_wecom_account.update.mock.calls[0][0].data.phone_link_mode).toBe('CREATED');
});
it('改绑后清理本账号当初自动建的旧号', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(60n, 'CREATED'));
tx.as_phone_asset.findFirst
.mockResolvedValueOnce({ phone_number: '19900001111' })
.mockResolvedValueOnce(EXISTING_PHONE)
.mockResolvedValueOnce(AUTO_CREATED_PHONE) // 旧号确实是本账号建的
.mockResolvedValue({ phone_number: '13800138000' });
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.update(9, saveDto({ phoneNumber: '13800138000' }));
const softDeleted = tx.as_phone_asset.update.mock.calls.find((c: any) => c[0].where.id === 60n);
expect(softDeleted).toBeDefined();
expect(softDeleted[0].data.delete_time).not.toBe(0n);
});
it('改绑后不动被复用的原号,一个字段都不改', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(50n, 'EXISTING'));
tx.as_phone_asset.findFirst
.mockResolvedValueOnce({ phone_number: '13800138000' })
.mockResolvedValueOnce(null)
.mockResolvedValue({ phone_number: '19900002222' });
tx.as_phone_asset.create.mockResolvedValue({ id: 70n });
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.update(9, saveDto({ phoneNumber: '19900002222' }));
// 原号 id 为 50,不应出现在任何更新操作里
expect(tx.as_phone_asset.update.mock.calls.some((c: any) => c[0].where.id === 50n)).toBe(false);
});
it('旧号仍被其他账号引用时不清理', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(60n, 'CREATED'));
tx.as_phone_asset.findFirst
.mockResolvedValueOnce({ phone_number: '19900001111' })
.mockResolvedValueOnce(EXISTING_PHONE)
.mockResolvedValueOnce(AUTO_CREATED_PHONE)
.mockResolvedValue({ phone_number: '13800138000' });
// 另有一个企微账号还在用这个号
tx.as_wecom_account.count.mockResolvedValue(1);
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.update(9, saveDto({ phoneNumber: '13800138000' }));
expect(tx.as_phone_asset.update.mock.calls.some((c: any) => c[0].where.id === 60n)).toBe(false);
});
it('号码没变时完全不动关联', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(50n, 'EXISTING'));
// 当前号码与提交号码相同
tx.as_phone_asset.findFirst.mockResolvedValue({ phone_number: '13800138000' });
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.update(9, saveDto({ phoneNumber: '13800138000' }));
expect(tx.as_phone_asset.create).not.toHaveBeenCalled();
expect(tx.as_phone_asset.update).not.toHaveBeenCalled();
// 关联字段保持原值,不会把 EXISTING 误改成 CREATED
expect(tx.as_wecom_account.update.mock.calls[0][0].data.phone_link_mode).toBe('EXISTING');
});
it('删除账号时一并清理它自动建的号', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(60n, 'CREATED'));
tx.as_phone_asset.findFirst.mockResolvedValue(AUTO_CREATED_PHONE);
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.softDelete(9);
expect(tx.as_phone_asset.update.mock.calls.some((c: any) => c[0].where.id === 60n)).toBe(true);
});
it('删除账号时保留被复用的号', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(50n, 'EXISTING'));
tx.as_phone_asset.findFirst.mockResolvedValue(EXISTING_PHONE);
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.softDelete(9);
expect(tx.as_phone_asset.update).not.toHaveBeenCalled();
});
it('编辑或删除不存在的账号返回明确错误', async () => {
const { service, tx } = buildService();
tx.as_wecom_account.findFirst.mockResolvedValue(null);
await expect(service.update(999, saveDto())).rejects.toThrow('企业微信资产不存在或已删除');
await expect(service.softDelete(999)).rejects.toThrow('企业微信资产不存在或已删除');
});
it('三个写方法都在事务内执行', async () => {
const { service, prisma, tx } = buildService();
tx.as_phone_asset.findFirst.mockResolvedValue(EXISTING_PHONE);
tx.as_wecom_account.create.mockResolvedValue({ id: 1n });
tx.as_wecom_account.findFirst.mockResolvedValue(boundAccount(50n, 'EXISTING'));
tx.as_wecom_account.update.mockResolvedValue({ id: 9n });
await service.create(saveDto());
await service.update(9, saveDto());
await service.softDelete(9);
// 同时写两张表的操作必须整体成事,否则失败会留下孤儿号码
expect(prisma.$transaction).toHaveBeenCalledTimes(3);
});
});
/**
* 文件用途(白话):企业微信资产的增删改查,以及它与手机号台账之间的联动。
* 关联文件: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.companyNames(rows.map((r) => r.company_profile_id)),
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 };
}
/** 公司显示名优先用简称,没有简称才用全称;已删除的公司不返回名称,与 Java 的存活条件一致。 */
private async companyNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_profile.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, company_name: true, short_name: true },
});
return new Map(rows.map((r) => [String(r.id), (r.short_name || r.company_name) as string]));
}
/** 批量取关联对象的显示名,避免逐行查询。 */
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)) }, delete_time: 0n },
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,
};
}
}
{
"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,6 +2,7 @@ package com.xyw.console.common;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
......@@ -76,6 +77,21 @@ public class GlobalExceptionHandler {
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
/**
* 代码作用(白话):把业务层抛出的权限不足异常返回成 403 与具体原因,而不是笼统的服务器错误。
* 关联文件:PagePermissionService.java、SystemUserAdminService.java、SecurityConfig.java。
* 关联逻辑(调用链/数据流):Service 权限判断失败 -> AccessDeniedException -> 本处理器 -> 403 + 原因文案 -> 前端提示。
* 修复背景:过滤器链里抛出的权限异常由 SecurityConfig 的 accessDeniedHandler 处理,
* 但 Service 层抛出的会落到下面的 Exception 兜底分支变成 500,
* 导致普通角色访问无权限页面时看到「服务器处理失败」而非真正原因。
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> accessDenied(AccessDeniedException error) {
String message = error.getMessage() == null || error.getMessage().isBlank() ? "没有权限" : error.getMessage();
return ApiResponse.error(403, message);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> unexpected(Exception error) {
......
......@@ -2,7 +2,11 @@ spring:
application:
name: xyw-console-backend
config:
import: optional:file:.env[.properties]
# 两条都写:IDEA 默认工作目录是项目根,命令行/Maven 通常是 backend/。
# 只写一条时,另一种启动方式会读不到 .env,且因数据源懒加载而在首次查库时才暴露成 500。
import:
- optional:file:.env[.properties]
- optional:file:backend/.env[.properties]
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${XYW_DB_URL}
......@@ -10,7 +14,7 @@ spring:
password: ${XYW_DB_PASSWORD}
server:
port: 7689
port: 7690
xyw:
auth:
......
# 迁移待办
重构期间**不做**的变更,全部记在这里,等 NestJS 迁移完成、契约全绿之后再逐项处理。
这样做的原因:契约对拍要求 Java 与 NestJS 行为完全一致,任何行为变更都会让基准失效。
---
## 1. 企微自动创建的手机号不再连带删除
**现状**:企微账号绑定一个台账里没有的手机号时,系统自动在 `as_phone_asset` 建一条
`number_type = EXTERNAL` 的记录;之后改绑或删除该企微账号时,这条记录会被软删。
**目标**:自动创建的号视同手动新建,一律留在台账。
已确认的三条决策(2026-08-18):
| 项 | 决定 |
|---|---|
| 修改时机 | 迁移完成后,在 NestJS 上改(重构期行为冻结) |
| `number_type` 取值 | **保持 `EXTERNAL` 不变**(2026-08-19 修正,原定改为 SELF) |
| 删除企微账号时 | 号码同样保留,不连带删除 |
**业务理由(2026-08-19 补充)**
台账里的号码都跑过实名注册与开卡流程,是有获取成本的真实资产,
不该因为解除某个业务绑定就从台账消失。
而且现状是**大多数号码只绑定了企微**,只有少部分同时绑定微信、抖音、域名或商户。
这意味着当前的软删逻辑影响面很大——每次改绑企微,被删掉的多半正是这类真实号码,
只有少数因为被其他资产引用才侥幸保留。
**迁移期间的注意事项**:规则尚未修改前,在企微模块做改绑或删除操作
仍会软删对应号码。若需要保留,可先记下号码,改完规则后再从台账恢复
(软删只是把 delete_time 置为时间戳,数据仍在,可改回 0 恢复)。
**为什么保留 EXTERNAL 标记(2026-08-19 修正)**
号码来源的追溯链已经存在,且前端已完整使用:
```java
phone.setNumberType("EXTERNAL") // 区别于手动建档的 SELF
phone.setSourceAssetType("WECOM") // 从哪类业务录入
phone.setSourceAssetId(企微账号ID) // 具体哪一条
```
前端手机号列表的「号码类型」列据此渲染:自有号码显示纯文本,
外部号码显示为可点击链接,点击跳转到企微页面并筛出关联账号
(见 `frontend/src/modules/phone/PhoneAssetView.js` 的号码类型列)。
若把 `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` 兜底,用户无感)。
迁移完成并确认图片无恢复可能后,再执行清理(把字段置空即可,设备记录本身不动)。
---
## 5. 超级管理员可重置密码(限低于自己的角色)
**现状**`SystemUserAdminService.resetPassword` 要求当前角色必须是 `DEVELOPER`
否则抛出「只有开发者可以修改密码」。超管无法为任何账号设置密码。
**目标**:超管也能重置密码,减少对开发者的依赖(内部系统,超管即最高管理者)。
已确认的边界(2026-08-19):
| 操作者 | 可重置密码的目标 |
|---|---|
| DEVELOPER | 所有账号 |
| SUPER_ADMIN | 仅 `FINANCE` / `HR` / `OPERATIONS`**不含其他 SUPER_ADMIN**,更不含 DEVELOPER |
| 其他角色 | 无权限 |
超管之间不可互改,避免两个超管互相改密码互相踢出。
开发者账号 `Jeddy` 的密码仍只能由开发者本人修改——否则超管改掉它的密码即可取得最高权限,
使「开发者账号在管理接口中隐身」的设计失效。
**安全后果(已知并接受)**:能改他人密码即意味着能以该账号身份登录。
内部系统可接受,但必须配合下面的操作日志,使该动作可追溯。
**修改时机**:迁移完成、契约全绿之后。现在改会使 Java 与 NestJS 的权限行为不一致,
而重置密码正是权限最敏感的接口,失去对拍保护得不偿失。系统尚未上线,无紧迫性。
---
## 6. 账号操作审计日志(新增功能)
**目标**:记录所有角色对账号的敏感操作,供开发者审计追溯。
需要记录的动作(建议):重置密码、创建账号、修改角色、修改状态、修改页面权限、解除登录锁定。
每条至少包含:操作人账号、操作人角色、目标账号、动作类型、发生时间、来源 IP。
**注意:这是新增功能,需要新建数据表**,与本次迁移「不改动表结构」的原则冲突,
因此必须放在迁移完成之后单独实施。
与第 5 条配套:开放超管重置密码权限的同时上线日志,否则「谁改了谁的密码」将无从追溯。
# 文件用途(白话):迁移期回退到 Java 后端对拍时用的后端地址,由 `pnpm dev:java` 通过 --mode java 加载。
# 默认(pnpm dev)连 NestJS 7691,见 vite.config.js
VITE_API_TARGET=http://127.0.0.1:7690
{
"name": "xyw-console-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xyw-console-frontend",
"dependencies": {
"element-plus": "^2.14.2",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"vite": "^7.3.6"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
"integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
"integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
"integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
"integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
"integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
"integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
"integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
"integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
"integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
"integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
"integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
"integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
"integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
"integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
"integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
"integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
"integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
"integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
"integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
"integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
"integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
"integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
"integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
"integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
"integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
"integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.40",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
"integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.40",
"@vue/shared": "3.5.40"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
"integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.40",
"@vue/compiler-dom": "3.5.40",
"@vue/compiler-ssr": "3.5.40",
"@vue/shared": "3.5.40",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.19",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
"integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.40",
"@vue/shared": "3.5.40"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
"integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.40"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
"integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.40",
"@vue/shared": "3.5.40"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
"integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.40",
"@vue/runtime-core": "3.5.40",
"@vue/shared": "3.5.40",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
"integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.40",
"@vue/runtime-dom": "3.5.40",
"@vue/shared": "3.5.40"
}
},
"node_modules/@vue/shared": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
"integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/element-plus": {
"version": "2.14.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
"integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.7.6",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.3.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.5"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
"integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.62.3",
"@rollup/rollup-android-arm64": "4.62.3",
"@rollup/rollup-darwin-arm64": "4.62.3",
"@rollup/rollup-darwin-x64": "4.62.3",
"@rollup/rollup-freebsd-arm64": "4.62.3",
"@rollup/rollup-freebsd-x64": "4.62.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
"@rollup/rollup-linux-arm-musleabihf": "4.62.3",
"@rollup/rollup-linux-arm64-gnu": "4.62.3",
"@rollup/rollup-linux-arm64-musl": "4.62.3",
"@rollup/rollup-linux-loong64-gnu": "4.62.3",
"@rollup/rollup-linux-loong64-musl": "4.62.3",
"@rollup/rollup-linux-ppc64-gnu": "4.62.3",
"@rollup/rollup-linux-ppc64-musl": "4.62.3",
"@rollup/rollup-linux-riscv64-gnu": "4.62.3",
"@rollup/rollup-linux-riscv64-musl": "4.62.3",
"@rollup/rollup-linux-s390x-gnu": "4.62.3",
"@rollup/rollup-linux-x64-gnu": "4.62.3",
"@rollup/rollup-linux-x64-musl": "4.62.3",
"@rollup/rollup-openbsd-x64": "4.62.3",
"@rollup/rollup-openharmony-arm64": "4.62.3",
"@rollup/rollup-win32-arm64-msvc": "4.62.3",
"@rollup/rollup-win32-ia32-msvc": "4.62.3",
"@rollup/rollup-win32-x64-gnu": "4.62.3",
"@rollup/rollup-win32-x64-msvc": "4.62.3",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vue": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
"integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.40",
"@vue/compiler-sfc": "3.5.40",
"@vue/runtime-dom": "3.5.40",
"@vue/server-renderer": "3.5.40",
"@vue/shared": "3.5.40"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz",
"integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
}
}
......@@ -3,12 +3,18 @@
"private": true,
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev:java": "vite --host 0.0.0.0 --mode java",
"build": "vite build",
"preview": "vite preview",
"test:e2e": "playwright test"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
"node": "^20.19.0 || >=22.12.0"
},
"packageManager": "pnpm@12.3.4",
"volta": {
"node": "22.12.0",
"pnpm": "12.3.4"
},
"dependencies": {
"element-plus": "^2.14.2",
......
......@@ -3,5 +3,5 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:5173', browserName: 'chromium' },
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173/', reuseExistingServer: true, timeout: 30_000 }
webServer: { command: 'pnpm dev --host 127.0.0.1', url: 'http://127.0.0.1:5173/', reuseExistingServer: true, timeout: 30_000 }
});
......@@ -4,5 +4,5 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:5174', browserName: 'chromium' },
webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 5174', url: 'http://127.0.0.1:5174/', reuseExistingServer: false, timeout: 30_000 }
webServer: { command: 'pnpm dev --host 127.0.0.1 --port 5174', url: 'http://127.0.0.1:5174/', reuseExistingServer: false, timeout: 30_000 }
});
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.3.4
version: 12.3.4
packages:
'@pnpm/exe.darwin-arm64@12.3.4':
resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.3.4':
resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.3.4':
resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.3.4':
resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.3.4':
resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.3.4':
resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.3.4':
resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.3.4':
resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
pnpm@12.3.4:
resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.3.4':
optional: true
'@pnpm/exe.darwin-x64@12.3.4':
optional: true
'@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
'@pnpm/exe.linux-arm64@12.3.4':
optional: true
'@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
'@pnpm/exe.linux-x64@12.3.4':
optional: true
'@pnpm/exe.win32-arm64@12.3.4':
optional: true
'@pnpm/exe.win32-x64@12.3.4':
optional: true
pnpm@12.3.4:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.3.4
'@pnpm/exe.darwin-x64': 12.3.4
'@pnpm/exe.linux-arm64': 12.3.4
'@pnpm/exe.linux-arm64-musl': 12.3.4
'@pnpm/exe.linux-x64': 12.3.4
'@pnpm/exe.linux-x64-musl': 12.3.4
'@pnpm/exe.win32-arm64': 12.3.4
'@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
element-plus:
specifier: ^2.14.2
version: 2.14.5(vue@3.5.42)
vue:
specifier: ^3.5.39
version: 3.5.42
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.42)
devDependencies:
'@playwright/test':
specifier: ^1.62.0
version: 1.63.0
vite:
specifier: ^7.3.6
version: 7.3.6
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@ctrl/tinycolor@4.2.1':
resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==}
engines: {node: '>=14'}
'@element-plus/icons-vue@2.3.2':
resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==}
peerDependencies:
vue: ^3.2.0
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@rollup/rollup-android-arm-eabi@4.63.2':
resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.63.2':
resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.63.2':
resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.63.2':
resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.63.2':
resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.63.2':
resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.63.2':
resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.63.2':
resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.63.2':
resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.63.2':
resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.63.2':
resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.63.2':
resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.63.2':
resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.63.2':
resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.63.2':
resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.63.2':
resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.63.2':
resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.63.2':
resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.63.2':
resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.63.2':
resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.63.2':
resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.63.2':
resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.63.2':
resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.63.2':
resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.63.2':
resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==}
cpu: [x64]
os: [win32]
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.25':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vue/compiler-core@3.5.42':
resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==}
'@vue/compiler-dom@3.5.42':
resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==}
'@vue/compiler-sfc@3.5.42':
resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==}
'@vue/compiler-ssr@3.5.42':
resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/reactivity@3.5.42':
resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==}
'@vue/runtime-core@3.5.42':
resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==}
'@vue/runtime-dom@3.5.42':
resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==}
'@vue/server-renderer@3.5.42':
resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==}
'@vue/shared@3.5.42':
resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==}
'@vueuse/core@14.4.0':
resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.4.0':
resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==}
'@vueuse/shared@14.4.0':
resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==}
peerDependencies:
vue: ^3.5.0
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.23:
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
element-plus@2.14.5:
resolution: {integrity: sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==}
peerDependencies:
vue: ^3.3.7
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '*'
lodash-es: '*'
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
playwright@1.63.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.63.2:
resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vue-component-type-helpers@3.3.11:
resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==}
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
vue@3.5.42:
resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@ctrl/tinycolor@4.2.1': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.42)':
dependencies:
vue: 3.5.42
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@playwright/test@1.63.0':
dependencies:
playwright: 1.63.0
'@rollup/rollup-android-arm-eabi@4.63.2':
optional: true
'@rollup/rollup-android-arm64@4.63.2':
optional: true
'@rollup/rollup-darwin-arm64@4.63.2':
optional: true
'@rollup/rollup-darwin-x64@4.63.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.63.2':
optional: true
'@rollup/rollup-freebsd-x64@4.63.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.63.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.63.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.63.2':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-loong64-musl@4.63.2':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.63.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.63.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.63.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.63.2':
optional: true
'@rollup/rollup-openbsd-x64@4.63.2':
optional: true
'@rollup/rollup-openharmony-arm64@4.63.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.63.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.63.2':
optional: true
'@rollup/rollup-win32-x64-gnu@4.63.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.63.2':
optional: true
'@sxzz/popperjs-es@2.11.8': {}
'@types/estree@1.0.9': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.25
'@types/lodash@4.17.25': {}
'@types/web-bluetooth@0.0.21': {}
'@vue/compiler-core@3.5.42':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.42
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.42':
dependencies:
'@vue/compiler-core': 3.5.42
'@vue/shared': 3.5.42
'@vue/compiler-sfc@3.5.42':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.42
'@vue/compiler-dom': 3.5.42
'@vue/compiler-ssr': 3.5.42
'@vue/shared': 3.5.42
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.28
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.42':
dependencies:
'@vue/compiler-dom': 3.5.42
'@vue/shared': 3.5.42
'@vue/devtools-api@6.6.4': {}
'@vue/reactivity@3.5.42':
dependencies:
'@vue/shared': 3.5.42
'@vue/runtime-core@3.5.42':
dependencies:
'@vue/reactivity': 3.5.42
'@vue/shared': 3.5.42
'@vue/runtime-dom@3.5.42':
dependencies:
'@vue/reactivity': 3.5.42
'@vue/runtime-core': 3.5.42
'@vue/shared': 3.5.42
csstype: 3.2.3
'@vue/server-renderer@3.5.42':
dependencies:
'@vue/compiler-ssr': 3.5.42
'@vue/runtime-dom': 3.5.42
'@vue/shared': 3.5.42
'@vue/shared@3.5.42': {}
'@vueuse/core@14.4.0(vue@3.5.42)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.4.0
'@vueuse/shared': 14.4.0(vue@3.5.42)
vue: 3.5.42
'@vueuse/metadata@14.4.0': {}
'@vueuse/shared@14.4.0(vue@3.5.42)':
dependencies:
vue: 3.5.42
async-validator@4.2.5: {}
csstype@3.2.3: {}
dayjs@1.11.23: {}
element-plus@2.14.5(vue@3.5.42):
dependencies:
'@ctrl/tinycolor': 4.2.1
'@element-plus/icons-vue': 2.3.2(vue@3.5.42)
'@floating-ui/dom': 1.8.0
'@popperjs/core': '@sxzz/popperjs-es@2.11.8'
'@types/lodash': 4.17.25
'@types/lodash-es': 4.17.12
'@vueuse/core': 14.4.0(vue@3.5.42)
async-validator: 4.2.5
dayjs: 1.11.23
lodash: 4.18.1
lodash-es: 4.18.1
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.42
vue-component-type-helpers: 3.3.11
entities@7.0.1: {}
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fsevents@2.3.3:
optional: true
lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.18.1
lodash-es: 4.18.1
lodash@4.18.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
memoize-one@6.0.0: {}
nanoid@3.3.19: {}
normalize-wheel-es@1.2.0: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
playwright-core@1.63.0: {}
playwright@1.63.0:
dependencies:
playwright-core: 1.63.0
postcss@8.5.28:
dependencies:
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.63.2:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.63.2
'@rollup/rollup-android-arm64': 4.63.2
'@rollup/rollup-darwin-arm64': 4.63.2
'@rollup/rollup-darwin-x64': 4.63.2
'@rollup/rollup-freebsd-arm64': 4.63.2
'@rollup/rollup-freebsd-x64': 4.63.2
'@rollup/rollup-linux-arm-gnueabihf': 4.63.2
'@rollup/rollup-linux-arm-musleabihf': 4.63.2
'@rollup/rollup-linux-arm64-gnu': 4.63.2
'@rollup/rollup-linux-arm64-musl': 4.63.2
'@rollup/rollup-linux-loong64-gnu': 4.63.2
'@rollup/rollup-linux-loong64-musl': 4.63.2
'@rollup/rollup-linux-ppc64-gnu': 4.63.2
'@rollup/rollup-linux-ppc64-musl': 4.63.2
'@rollup/rollup-linux-riscv64-gnu': 4.63.2
'@rollup/rollup-linux-riscv64-musl': 4.63.2
'@rollup/rollup-linux-s390x-gnu': 4.63.2
'@rollup/rollup-linux-x64-gnu': 4.63.2
'@rollup/rollup-linux-x64-musl': 4.63.2
'@rollup/rollup-openbsd-x64': 4.63.2
'@rollup/rollup-openharmony-arm64': 4.63.2
'@rollup/rollup-win32-arm64-msvc': 4.63.2
'@rollup/rollup-win32-ia32-msvc': 4.63.2
'@rollup/rollup-win32-x64-gnu': 4.63.2
'@rollup/rollup-win32-x64-msvc': 4.63.2
fsevents: 2.3.3
source-map-js@1.2.1: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
vite@7.3.6:
dependencies:
esbuild: 0.28.2
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
postcss: 8.5.28
rollup: 4.63.2
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
vue-component-type-helpers@3.3.11: {}
vue-router@4.6.4(vue@3.5.42):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.42
vue@3.5.42:
dependencies:
'@vue/compiler-dom': 3.5.42
'@vue/compiler-sfc': 3.5.42
'@vue/runtime-dom': 3.5.42
'@vue/server-renderer': 3.5.42
'@vue/shared': 3.5.42
# esbuild 是 Vite 的底层打包器,其安装脚本负责下载平台二进制文件。
# 不放行会导致 pnpm install 以 ERR_PNPM_IGNORED_BUILDS 非零退出。
allowBuilds:
esbuild: true
......@@ -24,4 +24,4 @@ export default { setup(){
/** 代码作用(白话):把 ISO 时间转换为本地可读时间。关联文件:CompanyPersonResponse.java。关联逻辑(调用链/数据流):时间字符串 -> 本地文本 -> 表格。 */ function formatDate(value){return value?new Date(value).toLocaleString('zh-CN'):'—';}
/** 代码作用(白话):在职状态恢复时立即清空离职时间,避免提交矛盾数据。关联文件:CompanyPersonSaveRequest.java。关联逻辑(调用链/数据流):状态变化 -> resignedAt 清空 -> 保存。 */ watch(()=>form.employmentStatus,status=>{if(status==='在职')form.resignedAt=null;});
onMounted(loadPage);return{canEdit,changePage,changePageSize,companyOptions,confirmDelete,dialogVisible,editingId,filters,form,formatDate,formatValue,loadCompanies,loading,openCreate,openEdit,records,resetSearch,saving,scheduleSearch,submitSave,submitSearch,total};
},template:`<section class="phone-asset-list-page company-person-page"><header class="phone-asset-list-page__header"><div><h2>公司人员</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增公司人员</el-button></header><section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="人员姓名" clearable @input="scheduleSearch"/><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="所属公司" @change="submitSearch"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName||item.companyName" :value="item.id"/></el-select><el-select v-model="filters.employmentStatus" clearable placeholder="在职状态" @change="submitSearch"><el-option label="在职" value="在职"/><el-option label="离职" value="离职"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section><section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="personName" label="人员姓名" min-width="140"/><el-table-column label="所属公司" min-width="180"><template #default="{row}">{{formatValue(row.companyProfileName)}}</template></el-table-column><el-table-column prop="employmentStatus" label="在职状态" min-width="110"/><el-table-column label="离职时间" min-width="180"><template #default="{row}">{{formatDate(row.resignedAt)}}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{row}">{{formatDate(row.createTime)}}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{row}">{{formatDate(row.updateTime)}}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120" fixed="right"><template #default="{row}"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize"/></section><el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId===null?'新增公司人员':'编辑公司人员'" width="560px" :close-on-click-modal="false"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitSave"><el-form-item class="phone-asset-modal__form-row" label="人员姓名" required><el-input v-model="form.personName" maxlength="64" placeholder="请输入人员姓名"/></el-form-item><el-form-item class="phone-asset-modal__form-row" label="所属公司"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称搜索" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName||item.companyName" :value="item.id"/></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="在职状态" required><el-radio-group v-model="form.employmentStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.employmentStatus==='离职'" class="phone-asset-modal__form-row" label="离职时间" required><el-date-picker v-model="form.resignedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择离职时间" style="width:100%"/></el-form-item></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submitSave">{{editingId===null?'确认保存':'保存修改'}}</el-button></template></el-dialog></section>`};
},template:`<section class="phone-asset-list-page company-person-page"><header class="phone-asset-list-page__header"><div><h2>公司人员</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增公司人员</el-button></header><section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="人员姓名" clearable @input="scheduleSearch"/><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="所属公司:" @change="submitSearch"><template #prefix>所属公司:</template><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName||item.companyName" :value="item.id"/></el-select><el-select v-model="filters.employmentStatus" clearable placeholder="在职状态:" @change="submitSearch"><template #prefix>在职状态:</template><el-option label="在职" value="在职"/><el-option label="离职" value="离职"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section><section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="personName" label="人员姓名" min-width="140"/><el-table-column label="所属公司" min-width="180"><template #default="{row}">{{formatValue(row.companyProfileName)}}</template></el-table-column><el-table-column prop="employmentStatus" label="在职状态" min-width="110"/><el-table-column label="离职时间" min-width="180"><template #default="{row}">{{formatDate(row.resignedAt)}}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{row}">{{formatDate(row.createTime)}}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{row}">{{formatDate(row.updateTime)}}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120" fixed="right"><template #default="{row}"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize"/></section><el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId===null?'新增公司人员':'编辑公司人员'" width="560px" :close-on-click-modal="false"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitSave"><el-form-item class="phone-asset-modal__form-row" label="人员姓名" required><el-input v-model="form.personName" maxlength="64" placeholder="请输入人员姓名"/></el-form-item><el-form-item class="phone-asset-modal__form-row" label="所属公司"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称搜索" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName||item.companyName" :value="item.id"/></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="在职状态" required><el-radio-group v-model="form.employmentStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.employmentStatus==='离职'" class="phone-asset-modal__form-row" label="离职时间" required><el-date-picker v-model="form.resignedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择离职时间" style="width:100%"/></el-form-item></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submitSave">{{editingId===null?'确认保存':'保存修改'}}</el-button></template></el-dialog></section>`};
......@@ -166,7 +166,7 @@ export default {
<header class="phone-asset-list-page__header"><div><h2>公司档案</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增公司档案</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="公司名称、简称、信用代码、地址、联系人或联系方式" clearable @input="scheduleSearch" @clear="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120" fixed="right"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize" /></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId===null?'新增公司档案':'编辑公司档案'" width="560px">
<el-dialog v-model="dialogVisible" class="phone-asset-modal company-profile-modal" modal-class="phone-asset-modal-mask" :title="editingId===null?'新增公司档案':'编辑公司档案'" width="560px">
<el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate">
<el-form-item class="phone-asset-modal__form-row" label="公司名称" required><el-input v-model="form.companyName" maxlength="100" autocomplete="off" placeholder="请输入公司名称" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="公司简称"><el-input v-model="form.shortName" maxlength="100" autocomplete="off" placeholder="请输入公司简称" /></el-form-item>
......
......@@ -132,7 +132,7 @@ export default {
},
template:`<section class="device-asset-page">
<header class="device-asset-page__header phone-asset-list-page__header"><h2>设备资产管理</h2><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增设备资产</el-button></header>
<section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="设备名称" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="使用人" :remote-method="fetchPersonSuggestions" @change="submitSearch"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="使用状态" @change="submitSearch"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="资产关联状态" @change="submitSearch"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="设备名称" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="使用人:" :remote-method="fetchPersonSuggestions" @change="submitSearch"><template #prefix>使用人:</template><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="使用状态:" @change="submitSearch"><template #prefix>使用状态:</template><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="关联状态:" @change="submitSearch"><template #prefix>关联状态:</template><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="device-asset-page__panel device-asset-page__table"><div class="device-asset-page__grid-wrap"><el-table class="device-asset-page__grid" :data="records" v-loading="loading" empty-text="暂无匹配数据">
<el-table-column label="图片" width="122"><template #default="{row}"><div class="device-asset-page__thumbs">
<button v-for="(item,index) in rowImages(row)" :key="item.full" type="button" class="device-asset-page__thumb" title="查看大图" aria-label="查看大图" @mouseenter="prefetchFullImage(item.full)" @focus="prefetchFullImage(item.full)" @click="previewImages(rowImages(row).map(entry=>entry.full),index)"><img :src="item.thumb" alt="设备图片" loading="lazy" decoding="async" @error="markImageBroken(item.thumb)"/><span class="device-asset-page__thumb-mask"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z"/><circle cx="12" cy="12" r="2.6"/></svg></span></button>
......
......@@ -61,8 +61,12 @@ body { margin: 0; }
background: #ffffff;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
/* 手机号码管理弹窗:高度贴合内容,内容多时才限高滚动(企微等其余弹窗保持固定高度)。 */
.phone-asset-modal--phone {
/* 手机号码管理与设备资产弹窗:高度贴合内容,内容多时才限高滚动。
基类用的是固定高度,表单项少时底部会空出一大片,这两个表单尤其明显。
企微等其余弹窗保持固定高度不变。 */
.phone-asset-modal--phone,
.device-asset-modal,
.company-profile-modal {
height: auto;
max-height: min(680px, calc(100vh - 48px));
}
......@@ -664,3 +668,11 @@ body { margin: 0; }
.user-permission-page__grid-wrap .el-table__body-wrapper { flex: 1 1 auto; min-height: 0; }
.user-permission-page__grid-wrap .el-table__body-wrapper > .el-scrollbar { height: 100%; }
}
/* 手机号、公司档案、公司人员、企微四个列表共用这张卡片,其 padding 为 0,
表格因此紧贴卡片边缘,首列文字距左边只有 1px,与设备资产页(21px)明显不齐。
不给卡片加 padding 是为了不改变表格可用宽度(避免触发横向滚动),
改为只给首列的表头与单元格补足内边距,视觉上与设备资产页一致。 */
.phone-asset-list-page__table .el-table__cell:first-child .cell {
padding-left: 32px;
}
import { expect, test as base } from '@playwright/test';
/** File purpose (plain language): supplies the same developer login fixture to every asset browser test without changing production authentication code. */
export const test = base;
/** Plain purpose: register deterministic authentication responses before a test opens a protected route. Related files: auth-store.js, router/index.js, and all files in frontend/tests. Data flow: test setup -> mocked current-user/CSRF requests -> route guard -> protected page. */
test.beforeEach(async ({ page }) => {
/** Plain purpose: add the browser-visible CSRF Cookie used by the shared request helper in either local Playwright port. Related files: auth-api-client.js, SecurityConfig.java. Flow: test context Cookie -> X-XSRF-TOKEN header -> protected request fixture. */
await page.context().addCookies([{ name: 'XSRF-TOKEN', value: 'playwright-csrf', domain: '127.0.0.1', path: '/' }]);
const user = {
/** 文件用途(白话):为每个资产页面测试提供独立、确定的开发者登录态,不改生产认证代码。 */
const developerUser = {
id: 1,
username: 'playwright-asset-admin',
roleCode: 'DEVELOPER',
......@@ -20,19 +14,25 @@ test.beforeEach(async ({ page }) => {
'company-person': 'EDIT',
alerts: 'EDIT'
}
};
};
/** Plain purpose: return an administrator identity to the route guard. Related files: auth-store.js and router/index.js. Data flow: GET /api/auth/me -> mocked envelope -> auth state -> allowed route. */
/**
* 每个测试都重新装入 Cookie 与认证路由。
* 不能在模块顶层调用 beforeEach:同一 worker 载入第二个测试文件时模块已缓存,钩子不会再次注册。
*/
export const test = base.extend({
page: async ({ page }, use) => {
await page.context().addCookies([{ name: 'XSRF-TOKEN', value: 'playwright-csrf', domain: '127.0.0.1', path: '/' }]);
await page.route('**/api/auth/me', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: user })
body: JSON.stringify({ code: 200, message: 'success', data: developerUser })
}));
/** Plain purpose: make protected test requests receive a successful CSRF preflight. Related files: api-client.js and all asset API clients. Data flow: API client -> CSRF request -> mocked success -> test-owned business request mock. */
await page.route('**/api/auth/csrf', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: null })
}));
await use(page);
}
});
export { expect };
import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
/**
* 代码作用(白话):把前端固定部署在 /asset/ 前缀下,并把浏览器的 /api 请求转发给本机后端,避免接口请求被 Vite 回退成 HTML 页面。
* 关联文件: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 后端接口。
* 关联文件:frontend/.env.java、frontend/src/router/index.js、frontend/src/modules/phone/phone-api-client.js、backend-nest/src/main.ts
* 关联逻辑(调用链/消息链/数据流):/asset/#/phone-assets -> Vue Hash 路由;/api/phone-assets -> Vite proxy -> 127.0.0.1:7691 NestJS 接口。
*/
export default defineConfig({
const configDir = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig(({ mode }) => {
// 后端地址优先级:命令行环境变量 > .env 文件 > 默认值。
// 默认连 NestJS(7691);`pnpm dev:java` 走 --mode java 读 .env.java,回退到 Java(7690) 对拍。
const fileEnv = loadEnv(mode, configDir, 'VITE_');
const apiTarget = fileEnv.VITE_API_TARGET || 'http://127.0.0.1:7691';
return {
base: '/asset/',
define: {
__VUE_OPTIONS_API__: true,
......@@ -17,10 +26,11 @@ export default defineConfig({
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:7689',
target: apiTarget,
changeOrigin: true
}
}
},
preview: { port: 4173, strictPort: 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 自行转换。
## 14. CSRF cookie 的行为差异(有意不复现)
实测 Java 侧的完整行为:
```
无 cookie 调 /api/auth/csrf -> Set-Cookie: XSRF-TOKEN=<uuid>; Path=/
带 cookie 发已认证 GET -> Set-Cookie: XSRF-TOKEN=; Max-Age=0 ← 删除自己的 token
不带 cookie 发 GET -> 无 Set-Cookie
已有 cookie 再调 /csrf -> 无 Set-Cookie,返回原有 token
```
第二条是 Spring Security 6 延迟加载 token 的副作用:GET 请求未真正使用 token,
框架便调用 `saveToken(null)` 写入删除指令。后果是前端每次写操作前都要额外请求一次
`/api/auth/csrf` 重新拿 token。
**决定:NestJS 不复现这一条。** 依据:
- 前端两种行为都兼容——`auth-api-client.js` 的逻辑是"cookie 里有就直接用,没有才去取"
- 这是框架实现细节的副作用,并非有意的安全设计;双提交模式的安全性来自
攻击者无法读取跨站 cookie,而不是 token 轮换
- 复现它需要写一段"故意删除自己刚下发的 cookie"的反直觉代码,还平白多一次网络往返
保持一致的部分:token 用随机 UUID 生成、cookie 属性为 `Path=/` 且不带 HttpOnly
(前端需要用 `document.cookie` 读取)、校验方式为请求头 `X-XSRF-TOKEN` 与 cookie 值比对、
`/api/auth/login` 豁免校验、校验失败返回 403 与文案「安全校验已失效,请刷新页面后重试」。
## 15. 事务:全项目只有企微模块需要
```
WecomAccountService:79 @Transactional create
WecomAccountService:96 @Transactional update
WecomAccountService:123 @Transactional softDelete
```
原因是这三个方法会同时写 `as_wecom_account``as_phone_asset` 两张表
(自动创建外部手机号、改绑时软删旧号)。其余模块均为单表操作,没有事务需求。
NestJS 侧必须用 `prisma.$transaction` 把这三个流程整体包住。
不包的后果:中途失败会留下已创建但无人引用的手机号记录,
而且不会有任何报错——数据默默变脏,只有对账时才发现。
注意 Prisma 的事务写法:交互式事务需把回调内所有查询都改用事务客户端 `tx`
漏掉一处该操作就跑在事务之外,等于没包。
## 16. 「记录不存在」的状态码各模块不统一
| 模块 | 记录不存在时 |
|---|---|
| 公司档案 / 公司人员 | **400**(Java 抛 IllegalArgumentException,由 CompanyManagementExceptionHandler 统一转 400) |
| 手机号 / 设备 / 企微 | **404**(各自定义了 *NotFoundException 与专属异常处理器) |
这是各模块独立演进留下的差异,不是笔误。迁移期照搬,不做统一。
NestJS 侧用 `NotFoundException`(404)与 `BusinessException`(默认 400)区分。
各模块异常处理器的完整映射:
```
CompanyManagementExceptionHandler IllegalArgumentException -> 400
PhoneAssetExceptionHandler PhoneAssetValidationException -> 400
PhoneAssetNotFoundException -> 404
DeviceAssetExceptionHandler DeviceAssetValidationException -> 400
DeviceAssetNotFoundException -> 404
MaxUploadSizeExceededException -> 400
WecomAccountExceptionHandler WecomAccountNotFoundException -> 404
PhoneAssetValidationException -> 400
```
最后一行值得注意:企微模块会复用手机号的校验异常——因为它在自动创建号码时会走同一套校验。
## 17. 缩略图生成:一处有意保留的差异
用 8 张定向构造的夹具图逐一比对两边生成的缩略图(`image-compare.mjs`):
| 夹具 | 尺寸与格式 | 平均色差 | 结论 |
|---|---|---|---|
| 基准 PNG / JPG / GIF | 一致 | **0.00** | 完全相同 |
| 带 EXIF 旋转的 JPG | 一致 | **0.00** | 完全相同 |
| 渐进式 PNG | 一致 | **0.00** | 完全相同 |
| 6000×6000 大图 | 一致 | **0.00** | 完全相同 |
| **CMYK JPEG** | 一致 | **87.00** | 见下 |
**CMYK 差异的判定过程**:夹具原图是纯色 `rgb(40,120,200)`
比对两边缩略图的平均色与原色的距离:
```
Java rgb(126,203,255) 偏离 132 明显偏白
NestJS rgb(23,118,182) 偏离 25 接近原色(差值来自 JPEG 压缩)
```
结论是 **Java 的 ImageIO 对 CMYK JPEG 解码存在已知偏差,NestJS 更接近原图**
因此这处差异不做「对齐到 Java」的处理——那等于把一个色彩错误搬进新系统。
实际影响很小:手机拍照与截图都是 RGB,CMYK 主要出现在印刷设计文件中,
资产管理场景基本不会遇到。
另外 NestJS 生成的缩略图体积约为 Java 的一半(0.5KB 对 1.2KB),
这是 JPEG 压缩质量的默认值不同所致;既然色差为 0,视觉上没有区别,体积更小反而更好。
缩略图的参数已逐项对齐:长边 240、保持比例、小图不放大、透明填白、输出 JPEG、
文件名为「原标识 + .thumb.jpg」。
/**
* 文件用途(白话):拿 NestJS 的响应和之前录下的 Java 快照逐字段比对,把不一致的地方列出来。
* 关联文件:record.mjs(录制)、normalize.mjs(归一化)、endpoints.mjs(端点清单)。
* 关联逻辑(数据流):snapshots/*.json 基准 -> 对目标服务重放同一请求 -> 归一化 -> 逐字段比对 -> 差异报告。
*
* 用法:
* node scripts/contract/compare.mjs --base http://127.0.0.1:7691
* node scripts/contract/compare.mjs --base http://127.0.0.1:7691 --filter auth
*
* 两条比对规则,区分对待(这是本工具能不能用的关键):
*
* 对象的字段顺序 —— 排序后比较。
* 消费方按名字取值,顺序没有语义。而且 Java 侧 Map.of 的迭代顺序取决于
* JVM 启动时的随机哈希种子,实测重启前后会变,本就不是稳定契约。
*
* 数组的元素顺序 —— 严格按原顺序比较。
* 顺序就是业务语义:列表排序、分页结果都靠它。若把数组也排序,
* "本该倒序却写成正序"这类真 bug 就会被掩盖,比对从保障变成掩护。
*/
import { readFileSync, existsSync, readdirSync } 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 SNAP = join(here, 'snapshots');
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:7691');
const FILTER = argOf('--filter', null);
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()]));
/** 对象键排序、数组顺序保留。差异定位靠它把两边整理成同一形状。 */
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
}
return value;
}
/** 深度比较,返回具体到字段路径的差异列表,便于直接定位改哪一行。 */
function diff(expected, actual, path = '') {
const out = [];
const bothObjects = expected && actual && typeof expected === 'object' && typeof actual === 'object'
&& Array.isArray(expected) === Array.isArray(actual);
if (!bothObjects) {
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
out.push({ path: path || '(根)', expected, actual });
}
return out;
}
if (Array.isArray(expected)) {
if (expected.length !== actual.length) out.push({ path: `${path}.length`, expected: expected.length, actual: actual.length });
for (let i = 0; i < Math.min(expected.length, actual.length); i++) out.push(...diff(expected[i], actual[i], `${path}[${i}]`));
return out;
}
for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
const p = path ? `${path}.${key}` : key;
if (!(key in actual)) { out.push({ path: p, expected: expected[key], actual: '(缺少该字段)' }); continue; }
if (!(key in expected)) { out.push({ path: p, expected: '(不应存在)', actual: actual[key] }); continue; }
out.push(...diff(expected[key], actual[key], p));
}
return out;
}
/**
* 响应头里两类差异经确认无害,在此显式放行——写成白名单而不是默默跳过,
* 是为了让"为什么允许"和"允许了什么"都留在代码里可查。
*/
const HEADER_DIFF_ALLOWED = {
'content-type': 'Java 自身也不统一(部分接口带 charset、部分不带),HTTP 头解析不区分大小写与空格,前端无感',
'set-cookie': 'Java 会在已认证 GET 后下发删除 CSRF cookie 的指令,属框架副作用,不予复现,详见 CONTRACT-NOTES 第 14 条',
};
/** 判断某处头部差异是否落在白名单内。 */
function headerDiffAllowed(path) {
const key = path.replace(/^headers\./, '').split(/[.\[]/)[0];
return HEADER_DIFF_ALLOWED[key];
}
async function csrfAndSession() {
const c = await fetch(`${BASE}/api/auth/csrf`);
const setCookie = c.headers.getSetCookie();
const xsrf = setCookie.join(';').match(/XSRF-TOKEN=([^;]+)/)?.[1];
if (!xsrf) throw new Error('未能取得 CSRF 令牌,目标服务可能未实现 /api/auth/csrf');
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(`登录失败 HTTP ${l.status}`);
return { xsrf, session: l.headers.getSetCookie().map((x) => x.split(';')[0]).join('; ') };
}
async function replay(ep, ctx) {
const headers = { Cookie: ep.anonymous ? `XSRF-TOKEN=${ctx.xsrf}` : `${ctx.session}; 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)}>`; }
return { status: res.status, headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(body) };
}
const all = [...readEndpoints, ...errorEndpoints].filter((ep) => !FILTER || ep.name.includes(FILTER));
const ctx = await csrfAndSession();
const results = [];
for (const ep of all) {
const file = join(SNAP, `${ep.name}.json`);
if (!existsSync(file)) { results.push({ name: ep.name, state: 'NO_SNAPSHOT' }); continue; }
const snapshot = JSON.parse(readFileSync(file, 'utf8'));
let actual;
try { actual = await replay(ep, ctx); }
catch (err) { results.push({ name: ep.name, state: 'ERROR', message: err.message }); continue; }
const differences = [
...diff(canonical(snapshot.status), canonical(actual.status), 'status'),
...diff(canonical(snapshot.body), canonical(actual.body), 'body'),
...diff(canonical(snapshot.headers), canonical(actual.headers), 'headers'),
];
const real = differences.filter((d) => !(d.path.startsWith('headers.') && headerDiffAllowed(d.path)));
const waived = differences.filter((d) => d.path.startsWith('headers.') && headerDiffAllowed(d.path));
results.push(real.length === 0
? { name: ep.name, state: 'MATCH', waived: waived.length }
: { name: ep.name, state: 'DIFF', differences: real, allowDiff: ep.allowDiff, waived: waived.length });
}
const match = results.filter((r) => r.state === 'MATCH');
const allowed = results.filter((r) => r.state === 'DIFF' && r.allowDiff);
const failed = results.filter((r) => r.state === 'DIFF' && !r.allowDiff);
const missing = results.filter((r) => r.state === 'NO_SNAPSHOT' || r.state === 'ERROR');
console.log(`比对目标 ${BASE}${FILTER ? `(筛选 ${FILTER})` : ''}`);
console.log(`一致 ${match.length} / 允许差异 ${allowed.length} / 不一致 ${failed.length} / 无法比对 ${missing.length}\n`);
for (const f of failed) {
console.log(`❌ ${f.name}`);
for (const d of f.differences.slice(0, 8)) {
console.log(` ${d.path}`);
console.log(` 期望 ${JSON.stringify(d.expected)?.slice(0, 100)}`);
console.log(` 实际 ${JSON.stringify(d.actual)?.slice(0, 100)}`);
}
if (f.differences.length > 8) console.log(` …… 另有 ${f.differences.length - 8} 处差异`);
}
for (const a of allowed) console.log(`➖ ${a.name}(已标注允许差异:${a.allowDiff})`);
for (const m of missing) console.log(`⚠️ ${m.name} ${m.state === 'ERROR' ? m.message : '缺少基准快照'}`);
const waivedTotal = results.reduce((n, r) => n + (r.waived ?? 0), 0);
if (waivedTotal) console.log(`
(另有 ${waivedTotal} 处响应头差异在白名单内,已放行)`);
process.exitCode = failed.length > 0 ? 1 : 0;
/**
* 文件用途(白话):用开发者账号录制只有 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)}>`; }
// 对新后端复跑时加 --no-snapshot,避免覆盖 Java 录下的基准快照
if (snapshot && !process.argv.includes('--no-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.
/**
* 文件用途(白话):把同一张图分别传给两个后端,下载各自生成的缩略图做像素级比对。
* 关联文件:fixtures 目录下的夹具图、backend-nest/src/device-asset/file-storage.service.ts。
* 关联逻辑(数据流):夹具图 -> 两边上传 -> 取回缩略图 -> 尺寸/格式/平均色差比对 -> 删除测试记录。
*
* 用法(需两个后端同时在跑):node scripts/contract/image-compare.mjs
* 依赖 backend-nest 的 sharp,故从该目录执行:
* cd backend-nest && node ../scripts/contract/image-compare.mjs
*
* 判定标准:
* 尺寸与格式 必须完全一致,差一个像素都算不通过
* 像素内容 允许压缩差异,平均色差低于 12 视为视觉等同
*
* 已知且接受的例外:CMYK 色彩空间的 JPEG。
* 实测原图 rgb(40,120,200) 经两边处理后,Java 得到 rgb(126,203,255)(偏离 132),
* NestJS 得到 rgb(23,118,182)(偏离 25)——Java 的 ImageIO 对 CMYK 解码存在已知偏差,
* NestJS 反而更接近原色。详见 CONTRACT-NOTES 第 17 条。
*/
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import sharp from 'sharp';
const FIX = 'E:/xyw_asset_console/scripts/contract/fixtures';
const env = Object.fromEntries(readFileSync('E:/xyw_asset_console/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()]));
async function session(base) {
const c = await fetch(`${base}/api/auth/csrf`);
const 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'] }) });
return { xsrf, cookie: l.headers.getSetCookie().map(x=>x.split(';')[0]).join('; ') };
}
/** 上传一张图并取回它的缩略图字节。 */
async function uploadAndFetchThumb(base, s, file, name) {
const fd = new FormData();
fd.append('deviceName', name);
fd.append('userUsageStatus', '使用中');
fd.append('assetRelationStatus', '已关联');
fd.append('imageAttachment1', new Blob([readFileSync(join(FIX, file))]), file);
const res = await fetch(`${base}/api/device-assets`, { method:'POST', headers:{ Cookie:`${s.cookie}; XSRF-TOKEN=${s.xsrf}`, 'X-XSRF-TOKEN': s.xsrf }, body: fd });
const body = await res.json();
if (res.status !== 200) return { error: `${res.status} ${body.message}` };
const id = body.data.id;
const thumbUrl = body.data.imageAttachment1ThumbUrl;
const img = await fetch(`${base}${thumbUrl}`, { headers:{ Cookie:`${s.cookie}; XSRF-TOKEN=${s.xsrf}` } });
const bytes = Buffer.from(await img.arrayBuffer());
await fetch(`${base}/api/device-assets/${id}`, { method:'DELETE', headers:{ Cookie:`${s.cookie}; XSRF-TOKEN=${s.xsrf}`, 'X-XSRF-TOKEN': s.xsrf } });
return { bytes };
}
/** 平均色差:把两图缩到同尺寸后逐像素比 RGB 差值取平均,0 表示完全相同。 */
async function averageColorDelta(a, b) {
const metaA = await sharp(a).metadata();
const [rawA, rawB] = await Promise.all([
sharp(a).resize(metaA.width, metaA.height, { fit: 'fill' }).removeAlpha().raw().toBuffer(),
sharp(b).resize(metaA.width, metaA.height, { fit: 'fill' }).removeAlpha().raw().toBuffer(),
]);
let sum = 0;
for (let i = 0; i < rawA.length; i++) sum += Math.abs(rawA[i] - rawB[i]);
return sum / rawA.length;
}
const run = String(Date.now()).slice(-5);
const javaS = await session('http://127.0.0.1:7690');
const nestS = await session('http://127.0.0.1:7691');
const files = readdirSync(FIX).filter(f => !f.startsWith('fake'));
console.log('夹具'.padEnd(20), 'Java 缩略图'.padEnd(22), 'NestJS 缩略图'.padEnd(22), '平均色差');
for (const [i, file] of files.entries()) {
const j = await uploadAndFetchThumb('http://127.0.0.1:7690', javaS, file, `__contract__img${run}j${i}`);
const n = await uploadAndFetchThumb('http://127.0.0.1:7691', nestS, file, `__contract__img${run}n${i}`);
if (j.error || n.error) { console.log(file.padEnd(20), (j.error ?? 'ok').padEnd(22), n.error ?? 'ok'); continue; }
const [mj, mn] = await Promise.all([sharp(j.bytes).metadata(), sharp(n.bytes).metadata()]);
const delta = await averageColorDelta(j.bytes, n.bytes);
const sizeMatch = mj.width === mn.width && mj.height === mn.height;
const fmtMatch = mj.format === mn.format;
console.log(
file.padEnd(20),
`${mj.format} ${mj.width}x${mj.height} ${(j.bytes.length/1024).toFixed(1)}KB`.padEnd(22),
`${mn.format} ${mn.width}x${mn.height} ${(n.bytes.length/1024).toFixed(1)}KB`.padEnd(22),
`${delta.toFixed(2)} ${sizeMatch && fmtMatch ? (delta < 12 ? '✅' : '⚠️ 色差偏大') : '❌ 尺寸或格式不一致'}`,
);
}
/**
* 文件用途(白话):把接口响应里"每次都会变"的部分替换成占位符,只留下契约本身。
* 关联文件: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)}>`; }
// 对新后端复跑时加 --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');
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