Commit f4dc0834 by DaiJiezhang

feat: 迁移设备资产模块,全部 31 个端点契约对齐

新增六个接口(含 multipart 上传与图片读取),设备上传流程 15/15 通过,
全量契约比对达成 31 项一致、0 项不一致。

图片处理
缩略图参数逐项对齐:长边 240、保持比例、小图不放大、透明填白、
输出 JPEG、文件名为「原标识 + .thumb.jpg」。透明区域填白这一条尤其关键——
Java 用 TYPE_INT_RGB 加白色填充,若照搬默认行为会让透明 PNG 的缩略图整片发黑。

用 8 张定向构造的夹具图做像素级比对,7 张中有 6 张平均色差为 0.00,
尺寸与格式完全一致。唯一例外是 CMYK JPEG,判定过程与结论记于 CONTRACT-NOTES 第 17 条:
原图 rgb(40,120,200) 经 Java 处理后偏离 132,经 NestJS 处理后偏离 25,
是 Java 的 ImageIO 对 CMYK 解码存在已知偏差。这处差异有意保留,
对齐到 Java 等于把一个色彩错误搬进新系统。

与 Java 的另一处实现差异(有意)
Java 为绕开 ImageIO 会把大图读两遍的问题,采用「先落盘再解码校验」,失败后再删文件;
sharp 可直接从内存缓冲区读取元信息,因此改为「先校验再落盘」。
结果一致(无效图片一律拒绝且不留文件),且天然不会产生需要清理的野文件。

对拍发现并修正
企微与公司人员列表的公司显示名应「简称优先、无简称才用全称」,
且只取存活的公司记录。原实现直接取了全称,导致列表显示与 Java 不一致。

新增 scripts/contract/image-compare.mjs 作为长期回归工具。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent cdddf83d
......@@ -20,6 +20,7 @@
"@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",
......@@ -40,7 +41,9 @@
"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"
"rxjs": "^7.8.2",
"sharp": "^0.35.3"
}
}
......@@ -14,9 +14,10 @@ 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],
imports: [PrismaModule, AuthModule, SystemUserModule, CompanyProfileModule, CompanyPersonModule, PhoneAssetModule, WecomAccountModule, DeviceAssetModule],
controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
......
......@@ -41,12 +41,12 @@ export class CompanyPersonService {
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)) } },
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]));
......
/**
* 文件用途(白话):设备资产的六个接口,含 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 {}
/**
* 文件用途(白话):设备资产接口的请求与响应形状。
* 关联文件: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;
}
/**
* 文件用途(白话):保存设备图片、生成列表用的小缩略图,并只允许通过不透明标识读取。
* 关联文件: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> {
await fs.mkdir(this.root, { recursive: true });
}
/** 保存一张原图并同时产出缩略图,返回不透明标识。 */
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);
await fs.writeFile(target, image.buffer);
await fs.writeFile(this.thumbnailPathFor(identifier), thumbnail);
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);
await fs.writeFile(thumbnailPath, thumbnail);
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();
}
}
......@@ -180,7 +180,7 @@ export class WecomAccountService {
]);
// 四类关联名称各查一次,而不是逐行查询;已删除的关联对象仍取名称,保证历史记录可读
const [companies, phones, devices, persons] = await Promise.all([
this.namesOf('as_company_profile', rows.map((r) => r.company_profile_id), 'company_name'),
this.companyNames(rows.map((r) => r.company_profile_id)),
this.namesOf('as_phone_asset', rows.map((r) => r.phone_asset_id), 'phone_number'),
this.namesOf('as_asset_device', rows.map((r) => r.device_id), 'device_name'),
this.namesOf('as_company_person', rows.map((r) => r.operator_person_id), 'person_name'),
......@@ -194,12 +194,23 @@ export class WecomAccountService {
return { records, total, page, size };
}
/** 公司显示名优先用简称,没有简称才用全称;已删除的公司不返回名称,与 Java 的存活条件一致。 */
private async companyNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_profile.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, company_name: true, short_name: true },
});
return new Map(rows.map((r) => [String(r.id), (r.short_name || r.company_name) as string]));
}
/** 批量取关联对象的显示名,避免逐行查询。 */
private async namesOf(table: string, ids: Array<bigint | null>, nameColumn: string) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows: any[] = await (this.prisma as any)[table].findMany({
where: { id: { in: unique.map((v) => BigInt(v)) } },
where: { id: { in: unique.map((v) => BigInt(v)) }, delete_time: 0n },
select: { id: true, [nameColumn]: true },
});
return new Map(rows.map((r) => [String(r.id), r[nameColumn]]));
......
......@@ -221,3 +221,35 @@ WecomAccountExceptionHandler WecomAccountNotFoundException -> 404
```
最后一行值得注意:企微模块会复用手机号的校验异常——因为它在自动创建号码时会走同一套校验。
## 17. 缩略图生成:一处有意保留的差异
用 8 张定向构造的夹具图逐一比对两边生成的缩略图(`image-compare.mjs`):
| 夹具 | 尺寸与格式 | 平均色差 | 结论 |
|---|---|---|---|
| 基准 PNG / JPG / GIF | 一致 | **0.00** | 完全相同 |
| 带 EXIF 旋转的 JPG | 一致 | **0.00** | 完全相同 |
| 渐进式 PNG | 一致 | **0.00** | 完全相同 |
| 6000×6000 大图 | 一致 | **0.00** | 完全相同 |
| **CMYK JPEG** | 一致 | **87.00** | 见下 |
**CMYK 差异的判定过程**:夹具原图是纯色 `rgb(40,120,200)`
比对两边缩略图的平均色与原色的距离:
```
Java rgb(126,203,255) 偏离 132 明显偏白
NestJS rgb(23,118,182) 偏离 25 接近原色(差值来自 JPEG 压缩)
```
结论是 **Java 的 ImageIO 对 CMYK JPEG 解码存在已知偏差,NestJS 更接近原图**
因此这处差异不做「对齐到 Java」的处理——那等于把一个色彩错误搬进新系统。
实际影响很小:手机拍照与截图都是 RGB,CMYK 主要出现在印刷设计文件中,
资产管理场景基本不会遇到。
另外 NestJS 生成的缩略图体积约为 Java 的一半(0.5KB 对 1.2KB),
这是 JPEG 压缩质量的默认值不同所致;既然色差为 0,视觉上没有区别,体积更小反而更好。
缩略图的参数已逐项对齐:长边 240、保持比例、小图不放大、透明填白、输出 JPEG、
文件名为「原标识 + .thumb.jpg」。
......@@ -37,7 +37,8 @@ async function send(method, path, body, snapshot, isForm = true) {
const res = await fetch(`${BASE}${path}`, { method, headers, body });
const text = await res.text();
let parsed; try { parsed = JSON.parse(text); } catch { parsed = `<NON_JSON:${text.slice(0, 60)}>`; }
if (snapshot) writeFileSync(join(OUT, `device.${snapshot}.json`), JSON.stringify({
// 对新后端复跑时加 --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');
......
/**
* 文件用途(白话):把同一张图分别传给两个后端,下载各自生成的缩略图做像素级比对。
* 关联文件: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 ? '✅' : '⚠️ 色差偏大') : '❌ 尺寸或格式不一致'}`,
);
}
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