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 },
});
/**
* 文件用途(白话):应用根模块,注册全局的响应包装与数据库连接。
* 关联文件: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/.../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 {}
{
"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
......@@ -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 }
});
# 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 }
};
});
/**
* 文件用途(白话):拿 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');
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