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
......@@ -18,8 +18,10 @@
"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"
},
"devDependencies": {
"@nestjs/cli": "^11.0.24",
......@@ -28,6 +30,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",
......@@ -878,6 +881,564 @@
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
......@@ -3135,6 +3696,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
......@@ -5233,6 +5804,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
......@@ -9054,6 +9634,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
......
......@@ -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/.../asset/service/DeviceAssetService.java、file-storage.service.ts。
* 关联逻辑(数据流):表单与图片 -> 校验 -> 图片落盘 -> 写设备记录 -> 失败时清理已落盘的新图。
*
* 三处要点:
* - 设备名在存活记录中唯一(修改时排除自己)。数据库唯一索引是「名称 + 删除时间」的组合,
* 因此删除之后同名可以重建,查重时必须带上删除时间条件。
* - 写库失败要把本次新落盘的图片删掉,否则磁盘会攒下没有任何记录引用的野文件。
* - 删除前检查手机号、企微、微信、抖音四类资产是否仍关联此设备。
*/
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { BusinessException, NotFoundException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { hasText } from '../common/text';
import { DeviceAssetFileStorageService, UploadedImage } from './file-storage.service';
import { DeviceAssetPageQueryDto, DeviceAssetResponse, DeviceAssetSaveDto } from './dto/device-asset.dto';
const USAGE_STATUSES = new Set(['使用中', '闲置', '维修中', '停用']);
const RELATION_STATUSES = new Set(['已关联', '未关联', '待确认']);
const DEFAULT_DEVICE_NAME_PREFIX = '学管师';
/** 前缀会拼进模糊查询条件,必须限定字符集:百分号与下划线会被当通配符。 */
const SAFE_PREFIX = /^[一-龥A-Za-z0-9]{1,20}$/;
/** 编号位数上限交给正则,避免脏数据把数值解析撑爆。 */
const NUMBERED_SUFFIX = /^(\d{1,6})号机$/;
const DEFAULT_SIZE = 20;
const LOOKUP_LIMIT = 20;
@Injectable()
export class DeviceAssetService {
constructor(private readonly prisma: PrismaService, private readonly files: DeviceAssetFileStorageService) {}
async page(query: DeviceAssetPageQueryDto) {
const page = query.page ?? 1;
const size = query.size ?? DEFAULT_SIZE;
const where: any = { delete_time: 0n };
if (hasText(query.deviceName)) where.device_name = { contains: query.deviceName };
if (hasText(query.userUsageStatus)) where.user_usage_status = query.userUsageStatus;
if (hasText(query.assetRelationStatus)) where.asset_relation_status = query.assetRelationStatus;
if (query.userPersonId != null) where.user_person_id = BigInt(query.userPersonId);
const [rows, total] = await Promise.all([
this.prisma.as_asset_device.findMany({ where, orderBy: { id: 'desc' }, skip: (page - 1) * size, take: size }),
this.prisma.as_asset_device.count({ where }),
]);
const names = await this.personNames(rows.map((r) => r.user_person_id));
return { records: rows.map((r) => this.toResponse(r, names)), total, page, size };
}
async create(dto: DeviceAssetSaveDto, images: { image1?: UploadedImage; image2?: UploadedImage }): Promise<DeviceAssetResponse> {
await this.validateSaveRequest(dto, null);
let image1: string | null = null;
let image2: string | null = null;
try {
image1 = await this.files.store(images.image1);
image2 = await this.files.store(images.image2);
const now = nowForDatabase();
const created = await this.prisma.as_asset_device.create({
data: { ...this.editableFields(dto), image_attachment_1: image1, image_attachment_2: image2, create_time: now, update_time: now, delete_time: 0n },
});
return this.toResponse(created, await this.personNames([created.user_person_id]));
} catch (error) {
// 写库失败时清掉本次新落盘的图,避免磁盘留下无人引用的野文件
await this.files.cleanupNewFile(image1);
await this.files.cleanupNewFile(image2);
throw error;
}
}
async update(id: number, dto: DeviceAssetSaveDto, images: { image1?: UploadedImage; image2?: UploadedImage }): Promise<DeviceAssetResponse> {
const entity = await this.requireActive(id);
await this.validateSaveRequest(dto, id);
const originalImage1 = entity.image_attachment_1;
const originalImage2 = entity.image_attachment_2;
let image1: string | null = null;
let image2: string | null = null;
try {
image1 = await this.files.replace(originalImage1, images.image1, dto.removeImageAttachment1 === true);
image2 = await this.files.replace(originalImage2, images.image2, dto.removeImageAttachment2 === true);
const updated = await this.prisma.as_asset_device.update({
where: { id: BigInt(id) },
data: { ...this.editableFields(dto), image_attachment_1: image1, image_attachment_2: image2, update_time: nowForDatabase() },
});
return this.toResponse(updated, await this.personNames([updated.user_person_id]));
} catch (error) {
// 只清理本次新产生的图片,原图必须留着——它仍被这条记录引用
if (image1 && image1 !== originalImage1) await this.files.cleanupNewFile(image1);
if (image2 && image2 !== originalImage2) await this.files.cleanupNewFile(image2);
throw error;
}
}
async softDelete(id: number): Promise<void> {
await this.requireActive(id);
await this.checkActiveReferences(id);
await this.prisma.as_asset_device.update({
where: { id: BigInt(id) },
data: { delete_time: BigInt(Date.now()), update_time: nowForDatabase() },
});
}
async searchCompanyPersons(keyword?: string) {
const where: any = { delete_time: 0n };
if (hasText(keyword)) where.person_name = { contains: keyword };
const rows = await this.prisma.as_company_person.findMany({ where, orderBy: { id: 'desc' }, take: LOOKUP_LIMIT });
return rows.map((r) => ({ id: Number(r.id), personName: r.person_name }));
}
/** 给弹窗一键填入下一个可用的「前缀 + 序号 + 号机」名称。 */
async suggestNextDeviceName(prefix?: string) {
const base = hasText(prefix) ? (prefix as string).trim() : DEFAULT_DEVICE_NAME_PREFIX;
if (!SAFE_PREFIX.test(base)) throw new BusinessException('设备名称前缀只能是 1-20 位中文、字母或数字');
// 先用前缀把候选行捞出来;「后面必须是纯数字加号机」这类形状判断查询语言表达不了,放在代码里做
const rows = await this.prisma.as_asset_device.findMany({
where: { delete_time: 0n, device_name: { startsWith: base } },
select: { device_name: true },
});
let largest = 0;
for (const row of rows) {
const name = row.device_name;
if (!name?.startsWith(base)) continue;
const matched = NUMBERED_SUFFIX.exec(name.slice(base.length));
if (matched) largest = Math.max(largest, Number.parseInt(matched[1], 10));
}
return { deviceName: `${base}${largest + 1}号机` };
}
async readImage(identifier: string, variant?: string): Promise<Buffer> {
return variant === 'thumb' ? this.files.readThumbnail(identifier) : this.files.readOriginal(identifier);
}
private editableFields(dto: DeviceAssetSaveDto) {
return {
device_name: dto.deviceName.trim(),
user_person_id: dto.userPersonId == null ? null : BigInt(dto.userPersonId),
user_usage_status: dto.userUsageStatus ?? null,
asset_relation_status: dto.assetRelationStatus ?? null,
};
}
private async validateSaveRequest(dto: DeviceAssetSaveDto, currentId: number | null): Promise<void> {
if (!hasText(dto.deviceName)) throw new BusinessException('设备名称不能为空');
this.validateStatus(dto.userUsageStatus, USAGE_STATUSES, '使用状态');
this.validateStatus(dto.assetRelationStatus, RELATION_STATUSES, '资产关联状态');
await this.validateUserPerson(dto.userPersonId);
// 唯一索引是「名称 + 删除时间」的组合,查重必须带删除时间条件,否则已删的同名会挡住新建
const where: any = { delete_time: 0n, device_name: dto.deviceName.trim() };
if (currentId != null) where.id = { not: BigInt(currentId) };
if (await this.prisma.as_asset_device.count({ where }) > 0) throw new BusinessException('设备名称已存在');
}
private validateStatus(value: string | undefined, allowed: Set<string>, label: string): void {
if (!hasText(value) || !allowed.has(value as string)) throw new BusinessException(`${label}取值无效`);
}
private async validateUserPerson(userPersonId?: number | null): Promise<void> {
if (userPersonId == null) return;
const count = await this.prisma.as_company_person.count({ where: { id: BigInt(userPersonId), delete_time: 0n } });
if (count === 0) throw new BusinessException('使用人不存在或已删除');
}
private async requireActive(id: number) {
const entity = await this.prisma.as_asset_device.findFirst({ where: { id: BigInt(id), delete_time: 0n } });
if (!entity) throw new NotFoundException('设备资产不存在或已删除');
return entity;
}
private async checkActiveReferences(id: number): Promise<void> {
const deviceId = BigInt(id);
const [phone, wecom, wechat, douyin] = await Promise.all([
this.prisma.as_phone_asset.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_wecom_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_wechat_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
this.prisma.as_douyin_account.count({ where: { device_id: deviceId, delete_time: 0n } }),
]);
const types: string[] = [];
if (phone > 0) types.push('手机号资产');
if (wecom > 0) types.push('企业微信资产');
if (wechat > 0) types.push('微信资产');
if (douyin > 0) types.push('抖音资产');
if (types.length > 0) throw new BusinessException(`设备仍被${types.join('、')}引用,不能删除`);
}
private async personNames(ids: Array<bigint | null>) {
const unique = [...new Set(ids.filter((v): v is bigint => v != null).map(String))];
if (unique.length === 0) return new Map<string, string>();
const rows = await this.prisma.as_company_person.findMany({
where: { id: { in: unique.map((v) => BigInt(v)) } },
select: { id: true, person_name: true },
});
return new Map(rows.map((r) => [String(r.id), r.person_name]));
}
private toResponse(entity: any, names: Map<string, string>): DeviceAssetResponse {
const urlOf = (identifier: string | null) => (identifier ? `/api/device-assets/files/${identifier}` : null);
return {
id: Number(entity.id),
deviceName: entity.device_name,
imageAttachment1Url: urlOf(entity.image_attachment_1),
imageAttachment2Url: urlOf(entity.image_attachment_2),
imageAttachment1ThumbUrl: entity.image_attachment_1 ? `${urlOf(entity.image_attachment_1)}?variant=thumb` : null,
imageAttachment2ThumbUrl: entity.image_attachment_2 ? `${urlOf(entity.image_attachment_2)}?variant=thumb` : null,
userPersonId: entity.user_person_id == null ? null : Number(entity.user_person_id),
userPersonName: entity.user_person_id == null ? null : (names.get(String(entity.user_person_id)) ?? null),
userUsageStatus: entity.user_usage_status,
assetRelationStatus: entity.asset_relation_status,
createTime: entity.create_time,
updateTime: entity.update_time,
};
}
}
/**
* 文件用途(白话):设备资产接口的请求与响应形状。
* 关联文件:backend/.../dto/DeviceAsset*.java、device-asset.service.ts。
*
* 表单以 multipart 提交,除文本字段外还带两张图片,
* 因此数字与布尔值到达时都是字符串,需要显式转换。
*/
import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class DeviceAssetPageQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) size?: number;
@IsOptional() @IsString() deviceName?: string;
@IsOptional() @IsString() userUsageStatus?: string;
@IsOptional() @IsString() assetRelationStatus?: string;
@IsOptional() @Type(() => Number) @IsInt() userPersonId?: number;
}
export class DeviceAssetSaveDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsNotEmpty()
deviceName!: string;
@IsOptional() @Transform(({ value }) => (value === '' || value == null ? null : Number(value)))
userPersonId?: number | null;
@IsOptional() @IsString() userUsageStatus?: string;
@IsOptional() @IsString() assetRelationStatus?: string;
/** multipart 传来的是字符串,"true" 才算真。 */
@IsOptional() @Transform(({ value }) => value === true || value === 'true')
removeImageAttachment1?: boolean;
@IsOptional() @Transform(({ value }) => value === true || value === 'true')
removeImageAttachment2?: boolean;
}
export interface DeviceAssetResponse {
id: number;
deviceName: string;
imageAttachment1Url: string | null;
imageAttachment2Url: string | null;
imageAttachment1ThumbUrl: string | null;
imageAttachment2ThumbUrl: string | null;
userPersonId: number | null;
userPersonName: string | null;
userUsageStatus: string | null;
assetRelationStatus: string | null;
createTime: Date | null;
updateTime: Date | null;
}
/**
* 文件用途(白话):保存设备图片、生成列表用的小缩略图,并只允许通过不透明标识读取。
* 关联文件: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