feat: 添加图片拼接功能和相关API文档更新

- 在应用中引入ImageCompositionController和ImageCompositionService
- 实现图片拼接功能,支持多种排列方式和输出格式
- 更新Swagger文档,添加图片拼接API描述和示例
- 修改模板执行API描述,简化审核流程说明
This commit is contained in:
iHeyTang
2025-09-25 20:42:19 +08:00
parent 81d93f59ff
commit 73ca6723ad
10 changed files with 1472 additions and 186 deletions

View File

@@ -95,7 +95,7 @@
},
"/api/v1/templates/code/{code}/execute": {
"post": {
"description": "\n 根据模板代码执行AI生成任务支持同步和异步两种审核模式:\n - 同步模式:立即返回审核结果,若通过则开始执行,返回 executionId 和 taskId\n - 异步模式:返回 executionId 和 auditTaskId等待审核回调完成后开始执行\n \n ID说明\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID同步模式时返回\n - auditTaskId: 审核服务任务ID异步模式时返回\n ",
"description": "\n 根据模板代码执行AI生成任务直接执行模板无需后端审核。\n \n 参数说明:\n - imageUrl: 输入图片URL必需\n - auditTaskId: 前端审核任务ID可选用于追踪审核记录\n \n 返回ID说明\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID用于追踪模板执行状态\n - auditTaskId: 前端传入的审核任务ID\n ",
"operationId": "TemplateController_executeTemplateByCode",
"parameters": [
{
@@ -131,7 +131,9 @@
"data": {
"executionId": 123,
"taskId": "n8n_task_456",
"status": "processing"
"auditTaskId": "audit_task_789",
"status": "processing",
"message": "模板执行已启动"
}
}
}
@@ -158,20 +160,6 @@
}
}
},
"403": {
"description": "图片审核未通过",
"content": {
"application/json": {
"schema": {
"example": {
"code": 403,
"message": "图片审核未通过: 包含不当内容",
"data": null
}
}
}
}
},
"429": {
"description": "任务数量限制",
"content": {
@@ -1094,52 +1082,95 @@
]
}
},
"/api/v1/enhanced/templates/code/{code}/execute": {
"/api/v1/image-composition/compose": {
"post": {
"description": "统一异步模式:先提交审核,然后通过回调继续执行模板。支持实时状态查询。",
"operationId": "EnhancedTemplateController_executeTemplateByCode",
"parameters": [
{
"name": "code",
"required": true,
"in": "path",
"schema": {
"type": "string"
"description": "将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置",
"operationId": "ImageCompositionController_composeImages",
"parameters": [],
"requestBody": {
"required": true,
"description": "图片拼接配置 - 简化版本",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImageCompositionDto"
},
"examples": {
"horizontal": {
"summary": "水平排列示例",
"value": {
"images": [
"https://picsum.photos/300/200",
"https://picsum.photos/300/200",
"https://picsum.photos/300/200"
],
"direction": "horizontal",
"outputFormat": "jpeg",
"maxWidth": 1920,
"maxHeight": 1080,
"quality": 80
}
},
"vertical": {
"summary": "垂直排列示例",
"value": {
"images": [
"https://picsum.photos/200/300",
"https://picsum.photos/200/300",
"https://picsum.photos/200/300"
],
"direction": "vertical",
"outputFormat": "webp",
"maxWidth": 800,
"maxHeight": 1200,
"quality": 85
}
},
"grid": {
"summary": "网格排列示例",
"value": {
"images": [
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200"
],
"direction": "grid",
"outputFormat": "jpeg",
"maxWidth": 1024,
"maxHeight": 1024,
"quality": 75
}
}
}
}
}
],
"responses": {
"201": {
"description": ""
}
},
"summary": "异步执行模板 (增强版)",
"tags": [
"AI模板系统 (增强版)"
]
}
},
"/api/v1/enhanced/templates/{executionId}/status": {
"get": {
"operationId": "EnhancedTemplateController_getExecutionStatus",
"parameters": [
{
"name": "executionId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
"description": "图片拼接成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImageCompositionResponseDto"
}
}
}
},
"400": {
"description": "请求参数错误"
},
"401": {
"description": "未授权访问"
},
"500": {
"description": "服务器内部错误"
}
},
"summary": "查询执行状态 (增强版)",
"summary": "图片拼接",
"tags": [
"AI模板系统 (增强版)"
"图片处理"
]
}
},
@@ -1778,7 +1809,7 @@
],
"servers": [
{
"url": "http://localhost:3004",
"url": "http://localhost:3003",
"description": "开发环境"
},
{
@@ -1850,6 +1881,11 @@
"type": "string",
"description": "输入图片URL",
"example": "https://cdn.example.com/upload/image.jpg"
},
"auditTaskId": {
"type": "string",
"description": "审核任务ID可选",
"example": "audit_task_12345"
}
},
"required": [
@@ -2227,6 +2263,115 @@
}
}
},
"ImageCompositionDto": {
"type": "object",
"properties": {
"images": {
"description": "图片URL或base64数据列表",
"example": [
"https://picsum.photos/300/200",
"https://picsum.photos/300/200",
"https://picsum.photos/300/200"
],
"type": "array",
"items": {
"type": "string"
}
},
"direction": {
"type": "string",
"description": "排列方向",
"enum": [
"horizontal",
"vertical",
"grid"
],
"example": "horizontal"
},
"outputFormat": {
"type": "string",
"description": "输出格式",
"enum": [
"jpeg",
"png",
"webp"
],
"example": "png",
"default": "png"
},
"maxWidth": {
"type": "number",
"description": "最大输出宽度(像素),超过会等比压缩",
"example": 1920,
"default": 1920,
"minimum": 100,
"maximum": 4096
},
"maxHeight": {
"type": "number",
"description": "最大输出高度(像素),超过会等比压缩",
"example": 1080,
"default": 1080,
"minimum": 100,
"maximum": 4096
},
"quality": {
"type": "number",
"description": "图片质量1-100仅对JPEG/WebP有效",
"example": 80,
"default": 80,
"minimum": 1,
"maximum": 100
}
},
"required": [
"images",
"direction"
]
},
"ImageCompositionResponseDto": {
"type": "object",
"properties": {
"imageUrl": {
"type": "string",
"description": "拼接后的图片URL",
"example": "https://s3.amazonaws.com/bucket/composed-image-1234567890.png"
},
"format": {
"type": "string",
"description": "图片格式",
"example": "png"
},
"size": {
"type": "number",
"description": "图片大小(字节)",
"example": 12345
},
"width": {
"type": "number",
"description": "画布宽度",
"example": 800
},
"height": {
"type": "number",
"description": "画布高度",
"example": 600
},
"processingTime": {
"type": "number",
"description": "处理耗时(毫秒)",
"example": 150
}
},
"required": [
"imageUrl",
"format",
"size",
"width",
"height",
"processingTime"
]
},
"PlatformType": {
"type": "string",
"enum": [

View File

@@ -41,9 +41,11 @@
"axios": "^1.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"form-data": "^4.0.4",
"mysql2": "^3.9.7",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.4",
"typeorm": "^0.3.20"
},
"devDependencies": {
@@ -55,6 +57,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

346
pnpm-lock.yaml generated
View File

@@ -32,12 +32,6 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.0
version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2)))
'@open-dy/open_api_credential':
specifier: ^1.0.0
version: 1.0.0
'@open-dy/open_api_sdk':
specifier: 1.1.2
version: 1.1.2
axios:
specifier: ^1.11.0
version: 1.11.0
@@ -47,6 +41,9 @@ importers:
class-validator:
specifier: ^0.14.2
version: 0.14.2
form-data:
specifier: ^4.0.4
version: 4.0.4
mysql2:
specifier: ^3.9.7
version: 3.14.4
@@ -56,6 +53,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
sharp:
specifier: ^0.34.4
version: 0.34.4
typeorm:
specifier: ^0.3.20
version: 0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2))
@@ -84,6 +84,9 @@ importers:
'@types/node':
specifier: ^22.10.7
version: 22.18.0
'@types/sharp':
specifier: ^0.32.0
version: 0.32.0
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
@@ -132,9 +135,6 @@ importers:
packages:
'@alicloud/tea-typescript@1.8.0':
resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==}
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@@ -400,6 +400,132 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.4':
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.4':
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.3':
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.3':
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.3':
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.4':
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.4':
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.4':
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inquirer/checkbox@4.2.2':
resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==}
engines: {node: '>=18'}
@@ -816,15 +942,6 @@ packages:
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
hasBin: true
'@open-dy/open_api_credential@1.0.0':
resolution: {integrity: sha512-MmLrJL2QFYHV3RNJ2/hQEXcjzBCa6mtklW+AJXG+xS/x8cJ7/AgW3wQjvNwJ0oMRyxch1nQ10h9EnOGr1kew0g==}
'@open-dy/open_api_sdk@1.1.2':
resolution: {integrity: sha512-Z+TzDxzFf+25Hw6E3crKLNRrFrN45I+BrfmnxPHm0QpGHO284FmvbEaug1lXq480jD32kPZMjJQR+r5d8LYW9g==}
'@open-dy/open_api_util@1.0.1':
resolution: {integrity: sha512-vgUTsyNqB0mtn0LclEr7J2E0eoXTHym1Xh4hO5gXXGYS4MoZGpu9IbYlkR4LMVUSOmJs5ZhRxmNqvEt4D/5fLg==}
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
@@ -936,12 +1053,6 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@20.19.13':
resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==}
'@types/node@22.18.0':
resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==}
@@ -957,6 +1068,10 @@ packages:
'@types/serve-static@1.15.8':
resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
'@types/sharp@0.32.0':
resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==}
deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed.
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@@ -1076,49 +1191,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1620,6 +1727,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
detect-libc@2.1.1:
resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==}
engines: {node: '>=8'}
detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@@ -2037,9 +2148,6 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
httpx@2.3.3:
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -2872,6 +2980,10 @@ packages:
engines: {node: '>= 0.10'}
hasBin: true
sharp@0.34.4:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3397,13 +3509,6 @@ packages:
snapshots:
'@alicloud/tea-typescript@1.8.0':
dependencies:
'@types/node': 12.20.55
httpx: 2.3.3
transitivePeerDependencies:
- supports-color
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -3713,6 +3818,94 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.3
optional: true
'@img/sharp-darwin-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.3
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.3':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm@1.2.3':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.3':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.3':
optional: true
'@img/sharp-libvips-linux-x64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
optional: true
'@img/sharp-linux-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.3
optional: true
'@img/sharp-linux-arm@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.3
optional: true
'@img/sharp-linux-ppc64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.3
optional: true
'@img/sharp-linux-s390x@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.3
optional: true
'@img/sharp-linux-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.3
optional: true
'@img/sharp-linuxmusl-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
optional: true
'@img/sharp-linuxmusl-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
optional: true
'@img/sharp-wasm32@0.34.4':
dependencies:
'@emnapi/runtime': 1.5.0
optional: true
'@img/sharp-win32-arm64@0.34.4':
optional: true
'@img/sharp-win32-ia32@0.34.4':
optional: true
'@img/sharp-win32-x64@0.34.4':
optional: true
'@inquirer/checkbox@4.2.2(@types/node@22.18.0)':
dependencies:
'@inquirer/core': 10.2.0(@types/node@22.18.0)
@@ -4257,27 +4450,6 @@ snapshots:
dependencies:
consola: 3.4.2
'@open-dy/open_api_credential@1.0.0':
dependencies:
axios: 1.11.0
transitivePeerDependencies:
- debug
'@open-dy/open_api_sdk@1.1.2':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@open-dy/open_api_credential': 1.0.0
'@open-dy/open_api_util': 1.0.1
transitivePeerDependencies:
- debug
- supports-color
'@open-dy/open_api_util@1.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@paralleldrive/cuid2@2.2.2':
dependencies:
'@noble/hashes': 1.8.0
@@ -4408,12 +4580,6 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/node@12.20.55': {}
'@types/node@20.19.13':
dependencies:
undici-types: 6.21.0
'@types/node@22.18.0':
dependencies:
undici-types: 6.21.0
@@ -4433,6 +4599,10 @@ snapshots:
'@types/node': 22.18.0
'@types/send': 0.17.5
'@types/sharp@0.32.0':
dependencies:
sharp: 0.34.4
'@types/stack-utils@2.0.3': {}
'@types/superagent@8.1.9':
@@ -5103,6 +5273,8 @@ snapshots:
depd@2.0.0: {}
detect-libc@2.1.1: {}
detect-newline@3.1.0: {}
dezalgo@1.0.4:
@@ -5577,13 +5749,6 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
httpx@2.3.3:
dependencies:
'@types/node': 20.19.13
debug: 4.4.1
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {}
iconv-lite@0.6.3:
@@ -6542,6 +6707,35 @@ snapshots:
safe-buffer: 5.2.1
to-buffer: 1.2.1
sharp@0.34.4:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.1
semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.4
'@img/sharp-darwin-x64': 0.34.4
'@img/sharp-libvips-darwin-arm64': 1.2.3
'@img/sharp-libvips-darwin-x64': 1.2.3
'@img/sharp-libvips-linux-arm': 1.2.3
'@img/sharp-libvips-linux-arm64': 1.2.3
'@img/sharp-libvips-linux-ppc64': 1.2.3
'@img/sharp-libvips-linux-s390x': 1.2.3
'@img/sharp-libvips-linux-x64': 1.2.3
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
'@img/sharp-linux-arm': 0.34.4
'@img/sharp-linux-arm64': 0.34.4
'@img/sharp-linux-ppc64': 0.34.4
'@img/sharp-linux-s390x': 0.34.4
'@img/sharp-linux-x64': 0.34.4
'@img/sharp-linuxmusl-arm64': 0.34.4
'@img/sharp-linuxmusl-x64': 0.34.4
'@img/sharp-wasm32': 0.34.4
'@img/sharp-win32-arm64': 0.34.4
'@img/sharp-win32-ia32': 0.34.4
'@img/sharp-win32-x64': 0.34.4
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

View File

@@ -17,6 +17,8 @@ import { UserSubscriptionEntity } from './entities/user-subscription.entity';
import { AdWatchEntity } from './entities/ad-watch.entity';
import { N8nTemplateFactoryService } from './services/n8n-template-factory.service';
import { TemplateController } from './controllers/template.controller';
import { ImageCompositionController } from './controllers/image-composition.controller';
import { ImageCompositionService } from './services/image-composition.service';
import { PlatformModule } from './platform/platform.module';
import { ContentModerationModule } from './content-moderation/content-moderation.module';
import { PaymentModule } from './payment/payment.module';
@@ -54,8 +56,8 @@ import { PaymentModule } from './payment/payment.module';
ContentModerationModule,
PaymentModule,
],
controllers: [AppController, TemplateController],
providers: [TemplateService, TemplateManager, N8nTemplateFactoryService],
controllers: [AppController, TemplateController, ImageCompositionController],
providers: [TemplateService, TemplateManager, N8nTemplateFactoryService, ImageCompositionService],
exports: [N8nTemplateFactoryService],
})
export class AppModule {}

View File

@@ -0,0 +1,95 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { ImageCompositionController } from '../image-composition.controller';
import { ImageCompositionService } from '../../services/image-composition.service';
import {
ImageCompositionDto,
ArrangementDirection,
OutputFormat,
} from '../../dto/image-composition.dto';
describe('ImageCompositionController', () => {
let controller: ImageCompositionController;
let service: ImageCompositionService;
const mockHttpService = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ImageCompositionController],
providers: [
ImageCompositionService,
{
provide: HttpService,
useValue: mockHttpService,
},
],
}).compile();
controller = module.get<ImageCompositionController>(
ImageCompositionController,
);
service = module.get<ImageCompositionService>(ImageCompositionService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('composeImages', () => {
it('should validate minimum requirements', async () => {
const invalidDto: ImageCompositionDto = {
images: [], // 空数组应该抛出错误
direction: ArrangementDirection.HORIZONTAL,
outputFormat: OutputFormat.PNG,
};
await expect(controller.composeImages(invalidDto)).rejects.toThrow(
'至少需要提供一张图片',
);
});
it('should validate image count limit', async () => {
const images = Array.from(
{ length: 25 },
(_, i) => `https://example.com/test${i}.jpg`,
);
const invalidDto: ImageCompositionDto = {
images, // 超过20张图片限制
direction: ArrangementDirection.HORIZONTAL,
outputFormat: OutputFormat.PNG,
};
await expect(controller.composeImages(invalidDto)).rejects.toThrow(
'图片数量不能超过20张',
);
});
it('should validate arrangement direction', async () => {
const invalidDto: any = {
images: ['https://example.com/test.jpg'],
direction: 'invalid-direction', // 无效的排列方向
outputFormat: OutputFormat.PNG,
};
await expect(controller.composeImages(invalidDto)).rejects.toThrow(
'排列方向必须是以下值之一',
);
});
it('should validate image URL format', async () => {
const invalidDto: ImageCompositionDto = {
images: ['invalid-url'], // 无效的URL格式
direction: ArrangementDirection.HORIZONTAL,
outputFormat: OutputFormat.PNG,
};
await expect(controller.composeImages(invalidDto)).rejects.toThrow(
'图片必须是有效的HTTP(S) URL或base64格式',
);
});
});
});

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,215 @@
import {
Controller,
Post,
Body,
HttpException,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse as SwaggerApiResponse,
ApiBody,
} from '@nestjs/swagger';
import { ImageCompositionService } from '../services/image-composition.service';
import {
ImageCompositionDto,
ImageCompositionResponseDto,
ArrangementDirection,
} from '../dto/image-composition.dto';
import { ApiCommonResponses } from '../decorators/api-common-responses.decorator';
import { PlatformAuthGuard } from '../platform/guards/platform-auth.guard';
import { ResponseUtil, ApiResponse } from '../utils/response.util';
@ApiTags('图片处理')
@Controller('image-composition')
@ApiCommonResponses()
export class ImageCompositionController {
constructor(
private readonly imageCompositionService: ImageCompositionService,
) {}
@Post('compose')
@UseGuards(PlatformAuthGuard)
@ApiOperation({
summary: '图片拼接',
description: '将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置',
})
@ApiBody({
type: ImageCompositionDto,
description: '图片拼接配置 - 简化版本',
examples: {
horizontal: {
summary: '水平排列示例',
value: {
images: [
'https://picsum.photos/300/200',
'https://picsum.photos/300/200',
'https://picsum.photos/300/200'
],
direction: 'horizontal',
outputFormat: 'jpeg',
maxWidth: 1920,
maxHeight: 1080,
quality: 80
},
},
vertical: {
summary: '垂直排列示例',
value: {
images: [
'https://picsum.photos/200/300',
'https://picsum.photos/200/300',
'https://picsum.photos/200/300'
],
direction: 'vertical',
outputFormat: 'webp',
maxWidth: 800,
maxHeight: 1200,
quality: 85
},
},
grid: {
summary: '网格排列示例',
value: {
images: [
'https://picsum.photos/200/200',
'https://picsum.photos/200/200',
'https://picsum.photos/200/200',
'https://picsum.photos/200/200',
'https://picsum.photos/200/200'
],
direction: 'grid',
outputFormat: 'jpeg',
maxWidth: 1024,
maxHeight: 1024,
quality: 75
},
},
},
})
@SwaggerApiResponse({
status: 200,
description: '图片拼接成功',
type: ImageCompositionResponseDto,
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误',
})
@SwaggerApiResponse({
status: 401,
description: '未授权访问',
})
@SwaggerApiResponse({
status: 500,
description: '服务器内部错误',
})
async composeImages(
@Body() compositionDto: ImageCompositionDto,
): Promise<ApiResponse<ImageCompositionResponseDto>> {
try {
// 基础参数验证
this.validateCompositionRequest(compositionDto);
const result = await this.imageCompositionService.composeImages(compositionDto);
return ResponseUtil.success(result, '图片拼接成功');
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
`图片拼接失败: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* 验证拼接请求参数 - 简化版本
*/
private validateCompositionRequest(dto: ImageCompositionDto): void {
// 验证图片数量
if (!dto.images || dto.images.length === 0) {
throw new HttpException(
'至少需要提供一张图片',
HttpStatus.BAD_REQUEST,
);
}
if (dto.images.length > 20) {
throw new HttpException(
'图片数量不能超过20张',
HttpStatus.BAD_REQUEST,
);
}
// 验证排列方向
const validDirections = Object.values(ArrangementDirection);
if (!validDirections.includes(dto.direction)) {
throw new HttpException(
`排列方向必须是以下值之一: ${validDirections.join(', ')}`,
HttpStatus.BAD_REQUEST,
);
}
// 验证图片URL格式
for (const imageUrl of dto.images) {
if (!imageUrl || typeof imageUrl !== 'string') {
throw new HttpException(
'图片URL不能为空',
HttpStatus.BAD_REQUEST,
);
}
// 检查是否为有效的URL或base64格式
const isValidUrl = this.isValidImageUrl(imageUrl);
const isValidBase64 = imageUrl.startsWith('data:image/');
if (!isValidUrl && !isValidBase64) {
throw new HttpException(
'图片必须是有效的HTTP(S) URL或base64格式',
HttpStatus.BAD_REQUEST,
);
}
}
// 验证尺寸参数
if (dto.maxWidth !== undefined && (dto.maxWidth < 100 || dto.maxWidth > 4096)) {
throw new HttpException(
'最大宽度必须在100-4096像素之间',
HttpStatus.BAD_REQUEST,
);
}
if (dto.maxHeight !== undefined && (dto.maxHeight < 100 || dto.maxHeight > 4096)) {
throw new HttpException(
'最大高度必须在100-4096像素之间',
HttpStatus.BAD_REQUEST,
);
}
// 验证质量参数
if (dto.quality !== undefined && (dto.quality < 1 || dto.quality > 100)) {
throw new HttpException(
'图片质量必须在1-100之间',
HttpStatus.BAD_REQUEST,
);
}
}
/**
* 验证图片URL格式
*/
private isValidImageUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,117 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, IsEnum, IsOptional, IsNumber, Min, Max } from 'class-validator';
/**
* 排列方向枚举
*/
export enum ArrangementDirection {
HORIZONTAL = 'horizontal', // 水平排列
VERTICAL = 'vertical', // 垂直排列
GRID = 'grid', // 网格排列(自动计算行列)
}
/**
* 输出格式枚举
*/
export enum OutputFormat {
JPEG = 'jpeg',
PNG = 'png',
WEBP = 'webp',
}
/**
* 图片拼接请求DTO - 简化版本
*/
export class ImageCompositionDto {
@ApiProperty({
description: '图片URL或base64数据列表',
example: [
'https://picsum.photos/300/200',
'https://picsum.photos/300/200',
'https://picsum.photos/300/200'
]
})
@IsArray()
@IsString({ each: true })
images: string[];
@ApiProperty({
description: '排列方向',
enum: ArrangementDirection,
example: ArrangementDirection.HORIZONTAL
})
@IsEnum(ArrangementDirection)
direction: ArrangementDirection;
@ApiPropertyOptional({
description: '输出格式',
enum: OutputFormat,
example: OutputFormat.PNG,
default: OutputFormat.PNG
})
@IsOptional()
@IsEnum(OutputFormat)
outputFormat?: OutputFormat = OutputFormat.PNG;
@ApiPropertyOptional({
description: '最大输出宽度(像素),超过会等比压缩',
example: 1920,
default: 1920,
minimum: 100,
maximum: 4096
})
@IsOptional()
@IsNumber()
@Min(100)
@Max(4096)
maxWidth?: number = 1920;
@ApiPropertyOptional({
description: '最大输出高度(像素),超过会等比压缩',
example: 1080,
default: 1080,
minimum: 100,
maximum: 4096
})
@IsOptional()
@IsNumber()
@Min(100)
@Max(4096)
maxHeight?: number = 1080;
@ApiPropertyOptional({
description: '图片质量1-100仅对JPEG/WebP有效',
example: 80,
default: 80,
minimum: 1,
maximum: 100
})
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
quality?: number = 80;
}
/**
* 图片拼接响应DTO
*/
export class ImageCompositionResponseDto {
@ApiProperty({ description: '拼接后的图片URL', example: 'https://s3.amazonaws.com/bucket/composed-image-1234567890.png' })
imageUrl: string;
@ApiProperty({ description: '图片格式', example: 'png' })
format: string;
@ApiProperty({ description: '图片大小(字节)', example: 12345 })
size: number;
@ApiProperty({ description: '画布宽度', example: 800 })
width: number;
@ApiProperty({ description: '画布高度', example: 600 })
height: number;
@ApiProperty({ description: '处理耗时(毫秒)', example: 150 })
processingTime: number;
}

View File

@@ -0,0 +1,369 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import sharp from 'sharp';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import FormData from 'form-data';
import {
ImageCompositionDto,
ImageCompositionResponseDto,
ArrangementDirection,
OutputFormat,
} from '../dto/image-composition.dto';
@Injectable()
export class ImageCompositionService {
private readonly logger = new Logger(ImageCompositionService.name);
constructor(private readonly httpService: HttpService) {}
/**
* 拼接图片 - 简化版本
*/
async composeImages(
compositionDto: ImageCompositionDto,
): Promise<ImageCompositionResponseDto> {
const startTime = Date.now();
try {
this.logger.log('开始图片拼接处理');
// 加载所有图片并获取尺寸信息,同时进行预处理
const imageInfos = await Promise.all(
compositionDto.images.map(async (imageUrl) => {
const buffer = await this.loadImage(imageUrl);
let processedBuffer = buffer;
// 压缩单张图片如果太大
const metadata = await sharp(buffer).metadata();
const originalWidth = metadata.width || 0;
const originalHeight = metadata.height || 0;
// 如果单张图片超过800x800先压缩
if (originalWidth > 800 || originalHeight > 800) {
processedBuffer = await sharp(buffer)
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
const newMetadata = await sharp(processedBuffer).metadata();
this.logger.log(`压缩图片: ${originalWidth}x${originalHeight} -> ${newMetadata.width}x${newMetadata.height}`);
return {
buffer: processedBuffer,
width: newMetadata.width || 0,
height: newMetadata.height || 0,
};
}
return {
buffer: processedBuffer,
width: originalWidth,
height: originalHeight,
};
}),
);
// 根据排列方向计算画布尺寸和图片位置
const layout = this.calculateLayout(imageInfos, compositionDto.direction);
// 创建画布
const canvas = sharp({
create: {
width: layout.canvasWidth,
height: layout.canvasHeight,
channels: 4,
background: '#ffffff',
},
});
// 准备所有图片的composite参数
const compositeInputs = await Promise.all(
imageInfos.map(async (imageInfo, index) => {
const position = layout.positions[index];
let processedImage = sharp(imageInfo.buffer);
// 如果需要调整尺寸
if (
position.width !== imageInfo.width ||
position.height !== imageInfo.height
) {
processedImage = processedImage.resize(
position.width,
position.height,
{ fit: 'fill' },
);
}
return {
input: await processedImage.png().toBuffer(),
left: position.x,
top: position.y,
};
}),
);
// 执行图片合成
let finalImage = canvas.composite(compositeInputs);
// 检查是否需要压缩最终图片
const maxWidth = compositionDto.maxWidth || 1920;
const maxHeight = compositionDto.maxHeight || 1080;
if (layout.canvasWidth > maxWidth || layout.canvasHeight > maxHeight) {
this.logger.log(`最终图片需要压缩: ${layout.canvasWidth}x${layout.canvasHeight} -> 最大${maxWidth}x${maxHeight}`);
finalImage = finalImage.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
});
}
// 根据输出格式处理
const quality = compositionDto.quality || 80;
switch (compositionDto.outputFormat) {
case OutputFormat.JPEG:
finalImage = finalImage.jpeg({ quality, progressive: true });
break;
case OutputFormat.WEBP:
finalImage = finalImage.webp({ quality, effort: 6 });
break;
case OutputFormat.PNG:
default:
// PNG格式使用适度压缩
finalImage = finalImage.png({ compressionLevel: 8, adaptiveFiltering: true });
break;
}
const resultBuffer = await finalImage.toBuffer();
const metadata = await sharp(resultBuffer).metadata();
const processingTime = Date.now() - startTime;
this.logger.log(`图片拼接完成,耗时: ${processingTime}ms`);
// 上传图片到S3并获取URL
const imageUrl = await this.uploadToS3(
resultBuffer,
compositionDto.outputFormat || 'png',
);
return {
imageUrl,
format: compositionDto.outputFormat || 'png',
size: resultBuffer.length,
width: metadata.width || layout.canvasWidth,
height: metadata.height || layout.canvasHeight,
processingTime,
};
} catch (error) {
this.logger.error('图片拼接失败', error);
throw new BadRequestException(`图片拼接失败: ${error.message}`);
}
}
/**
* 根据排列方向计算布局
*/
private calculateLayout(
imageInfos: Array<{ width: number; height: number }>,
direction: ArrangementDirection,
) {
const positions: Array<{
x: number;
y: number;
width: number;
height: number;
}> = [];
let canvasWidth = 0;
let canvasHeight = 0;
switch (direction) {
case ArrangementDirection.HORIZONTAL:
// 水平排列:所有图片保持原始高度,宽度累加
const maxHeight = Math.max(...imageInfos.map((img) => img.height));
canvasHeight = maxHeight;
let currentX = 0;
imageInfos.forEach((imageInfo) => {
positions.push({
x: currentX,
y: Math.floor((maxHeight - imageInfo.height) / 2), // 垂直居中
width: imageInfo.width,
height: imageInfo.height,
});
currentX += imageInfo.width;
});
canvasWidth = currentX;
break;
case ArrangementDirection.VERTICAL:
// 垂直排列:所有图片保持原始宽度,高度累加
const maxWidth = Math.max(...imageInfos.map((img) => img.width));
canvasWidth = maxWidth;
let currentY = 0;
imageInfos.forEach((imageInfo) => {
positions.push({
x: Math.floor((maxWidth - imageInfo.width) / 2), // 水平居中
y: currentY,
width: imageInfo.width,
height: imageInfo.height,
});
currentY += imageInfo.height;
});
canvasHeight = currentY;
break;
case ArrangementDirection.GRID:
// 网格排列:自动计算行列数
const imageCount = imageInfos.length;
const cols = Math.ceil(Math.sqrt(imageCount));
const rows = Math.ceil(imageCount / cols);
// 计算每个格子的尺寸(使用最大图片尺寸)
const cellWidth = Math.max(...imageInfos.map((img) => img.width));
const cellHeight = Math.max(...imageInfos.map((img) => img.height));
canvasWidth = cols * cellWidth;
canvasHeight = rows * cellHeight;
imageInfos.forEach((imageInfo, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
positions.push({
x: col * cellWidth + Math.floor((cellWidth - imageInfo.width) / 2),
y:
row * cellHeight +
Math.floor((cellHeight - imageInfo.height) / 2),
width: imageInfo.width,
height: imageInfo.height,
});
});
break;
default:
throw new BadRequestException(`不支持的排列方向: ${direction}`);
}
return { canvasWidth, canvasHeight, positions };
}
/**
* 加载图片
* 支持URL和base64格式
*/
private async loadImage(imageUrl: string): Promise<Buffer> {
try {
// 检查是否为base64格式
if (imageUrl.startsWith('data:image/')) {
const base64Data = imageUrl.split(',')[1];
return Buffer.from(base64Data, 'base64');
}
// 检查是否为HTTP(S) URL
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
const response = await firstValueFrom(
this.httpService.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 10000, // 10秒超时
maxContentLength: 10 * 1024 * 1024, // 最大10MB
}),
);
return Buffer.from(response.data);
}
throw new Error('不支持的图片格式请提供HTTP(S) URL或base64数据');
} catch (error) {
this.logger.error(`加载图片失败: ${imageUrl}`, error);
throw new BadRequestException(`加载图片失败: ${error.message}`);
}
}
/**
* 上传图片到S3
*/
private async uploadToS3(
imageBuffer: Buffer,
format: string,
): Promise<string> {
try {
this.logger.log('开始上传图片到S3');
// 创建FormData
const formData = new FormData();
const fileName = `composed-image-${Date.now()}.${format}`;
formData.append('file', imageBuffer, {
filename: fileName,
contentType: `image/${format}`,
});
// 上传到S3
const uploadResponse = await firstValueFrom(
this.httpService.post(
'https://bowongai-prod--text-video-agent-fastapi-app.modal.run/api/file/upload/s3',
formData,
{
headers: {
...formData.getHeaders(),
},
timeout: 30000, // 30秒超时
},
),
);
// 记录S3上传状态
this.logger.log('S3上传完成状态:', uploadResponse.data.status);
// 检查响应并返回URL
if (
uploadResponse.data &&
uploadResponse.data.status &&
uploadResponse.data.data
) {
this.logger.log(`图片上传成功: ${uploadResponse.data.data}`);
return uploadResponse.data.data;
} else {
this.logger.error('S3上传响应格式异常响应数据:', uploadResponse.data);
throw new Error('S3上传响应格式异常');
}
} catch (error) {
this.logger.error('上传图片到S3失败详细错误:', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
},
});
// 如果S3上传失败返回base64格式作为备选方案
this.logger.warn('S3上传失败使用base64格式返回');
return `data:image/${format};base64,${imageBuffer.toString('base64')}`;
}
}
/**
* 验证图片格式
*/
private async validateImage(buffer: Buffer): Promise<void> {
try {
const metadata = await sharp(buffer).metadata();
if (!metadata.format) {
throw new Error('无法识别的图片格式');
}
// 检查图片尺寸限制
if (metadata.width && metadata.width > 4096) {
throw new Error('图片宽度超过限制最大4096px');
}
if (metadata.height && metadata.height > 4096) {
throw new Error('图片高度超过限制最大4096px');
}
} catch (error) {
throw new BadRequestException(`图片验证失败: ${error.message}`);
}
}
}

View File

@@ -95,7 +95,7 @@
},
"/api/v1/templates/code/{code}/execute": {
"post": {
"description": "\n 根据模板代码执行AI生成任务支持同步和异步两种审核模式:\n - 同步模式:立即返回审核结果,若通过则开始执行,返回 executionId 和 taskId\n - 异步模式:返回 executionId 和 auditTaskId等待审核回调完成后开始执行\n \n ID说明\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID同步模式时返回\n - auditTaskId: 审核服务任务ID异步模式时返回\n ",
"description": "\n 根据模板代码执行AI生成任务直接执行模板无需后端审核。\n \n 参数说明:\n - imageUrl: 输入图片URL必需\n - auditTaskId: 前端审核任务ID可选用于追踪审核记录\n \n 返回ID说明\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID用于追踪模板执行状态\n - auditTaskId: 前端传入的审核任务ID\n ",
"operationId": "TemplateController_executeTemplateByCode",
"parameters": [
{
@@ -131,7 +131,9 @@
"data": {
"executionId": 123,
"taskId": "n8n_task_456",
"status": "processing"
"auditTaskId": "audit_task_789",
"status": "processing",
"message": "模板执行已启动"
}
}
}
@@ -158,20 +160,6 @@
}
}
},
"403": {
"description": "图片审核未通过",
"content": {
"application/json": {
"schema": {
"example": {
"code": 403,
"message": "图片审核未通过: 包含不当内容",
"data": null
}
}
}
}
},
"429": {
"description": "任务数量限制",
"content": {
@@ -1094,52 +1082,95 @@
]
}
},
"/api/v1/enhanced/templates/code/{code}/execute": {
"/api/v1/image-composition/compose": {
"post": {
"description": "统一异步模式:先提交审核,然后通过回调继续执行模板。支持实时状态查询。",
"operationId": "EnhancedTemplateController_executeTemplateByCode",
"parameters": [
{
"name": "code",
"required": true,
"in": "path",
"schema": {
"type": "string"
"description": "将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置",
"operationId": "ImageCompositionController_composeImages",
"parameters": [],
"requestBody": {
"required": true,
"description": "图片拼接配置 - 简化版本",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImageCompositionDto"
},
"examples": {
"horizontal": {
"summary": "水平排列示例",
"value": {
"images": [
"https://picsum.photos/300/200",
"https://picsum.photos/300/200",
"https://picsum.photos/300/200"
],
"direction": "horizontal",
"outputFormat": "jpeg",
"maxWidth": 1920,
"maxHeight": 1080,
"quality": 80
}
},
"vertical": {
"summary": "垂直排列示例",
"value": {
"images": [
"https://picsum.photos/200/300",
"https://picsum.photos/200/300",
"https://picsum.photos/200/300"
],
"direction": "vertical",
"outputFormat": "webp",
"maxWidth": 800,
"maxHeight": 1200,
"quality": 85
}
},
"grid": {
"summary": "网格排列示例",
"value": {
"images": [
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200",
"https://picsum.photos/200/200"
],
"direction": "grid",
"outputFormat": "jpeg",
"maxWidth": 1024,
"maxHeight": 1024,
"quality": 75
}
}
}
}
}
],
"responses": {
"201": {
"description": ""
}
},
"summary": "异步执行模板 (增强版)",
"tags": [
"AI模板系统 (增强版)"
]
}
},
"/api/v1/enhanced/templates/{executionId}/status": {
"get": {
"operationId": "EnhancedTemplateController_getExecutionStatus",
"parameters": [
{
"name": "executionId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
"description": "图片拼接成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImageCompositionResponseDto"
}
}
}
},
"400": {
"description": "请求参数错误"
},
"401": {
"description": "未授权访问"
},
"500": {
"description": "服务器内部错误"
}
},
"summary": "查询执行状态 (增强版)",
"summary": "图片拼接",
"tags": [
"AI模板系统 (增强版)"
"图片处理"
]
}
},
@@ -1778,7 +1809,7 @@
],
"servers": [
{
"url": "http://localhost:3004",
"url": "http://localhost:3003",
"description": "开发环境"
},
{
@@ -1850,6 +1881,11 @@
"type": "string",
"description": "输入图片URL",
"example": "https://cdn.example.com/upload/image.jpg"
},
"auditTaskId": {
"type": "string",
"description": "审核任务ID可选",
"example": "audit_task_12345"
}
},
"required": [
@@ -2227,6 +2263,115 @@
}
}
},
"ImageCompositionDto": {
"type": "object",
"properties": {
"images": {
"description": "图片URL或base64数据列表",
"example": [
"https://picsum.photos/300/200",
"https://picsum.photos/300/200",
"https://picsum.photos/300/200"
],
"type": "array",
"items": {
"type": "string"
}
},
"direction": {
"type": "string",
"description": "排列方向",
"enum": [
"horizontal",
"vertical",
"grid"
],
"example": "horizontal"
},
"outputFormat": {
"type": "string",
"description": "输出格式",
"enum": [
"jpeg",
"png",
"webp"
],
"example": "png",
"default": "png"
},
"maxWidth": {
"type": "number",
"description": "最大输出宽度(像素),超过会等比压缩",
"example": 1920,
"default": 1920,
"minimum": 100,
"maximum": 4096
},
"maxHeight": {
"type": "number",
"description": "最大输出高度(像素),超过会等比压缩",
"example": 1080,
"default": 1080,
"minimum": 100,
"maximum": 4096
},
"quality": {
"type": "number",
"description": "图片质量1-100仅对JPEG/WebP有效",
"example": 80,
"default": 80,
"minimum": 1,
"maximum": 100
}
},
"required": [
"images",
"direction"
]
},
"ImageCompositionResponseDto": {
"type": "object",
"properties": {
"imageUrl": {
"type": "string",
"description": "拼接后的图片URL",
"example": "https://s3.amazonaws.com/bucket/composed-image-1234567890.png"
},
"format": {
"type": "string",
"description": "图片格式",
"example": "png"
},
"size": {
"type": "number",
"description": "图片大小(字节)",
"example": 12345
},
"width": {
"type": "number",
"description": "画布宽度",
"example": 800
},
"height": {
"type": "number",
"description": "画布高度",
"example": 600
},
"processingTime": {
"type": "number",
"description": "处理耗时(毫秒)",
"example": 150
}
},
"required": [
"imageUrl",
"format",
"size",
"width",
"height",
"processingTime"
]
},
"PlatformType": {
"type": "string",
"enum": [