feat: 增强支付模块和图片拼接功能,完善异常处理

- 新增 Stripe 支付依赖和支付测试控制器
- 完善抖音支付适配器,支持SHA256-RSA2048签名
- 优化图片拼接API的参数验证和错误处理
- 添加全局异常过滤器提升系统稳定性
- 更新配置文件和API文档
- 完善N8N模板结构
This commit is contained in:
imeepos
2025-09-26 14:13:48 +08:00
parent faaaf00f7f
commit cec66085dc
32 changed files with 1151 additions and 89 deletions

View File

@@ -29,7 +29,7 @@ BYTEDANCE_GRANT_TYPE=client_credential
# 内容审核配置
# 图片审核回调地址(重要:抖音审核完成后会调用此接口)
AUDIT_CALLBACK_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com/api/v1/content-moderation/bytedance/callback
TEST_SERVER_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com
SERVER_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com
PROD_SERVER_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com
# 抖音内容审核API地址已更新为V3版本
# 旧版本API已弃用: https://developer.toutiao.com/api/apps/v2/content/audit/image

27
douyin_private_key.pem Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA95s8NMUAsQSxZnTOTbHZvLvJtKNKFhDfX5LZQe4zmvbBsMX9
sZjTivdF22EpRQJFfYcU85eIfZ3Yg/8JcwBSoyI21llQIYfxWMVhQ1uLE424zx6N
L75ko7CQQZK7kM9jjIhdJ8ISxR+aDcro1ugtXYYNpn0hI+waXJG37DsvTZAWmVxX
YsBc3GGv5U8PuxbLEOEd5qgV/g8hrYcXNkcVUH9ogTf1QCEysZC0FhxV7ciO0HcZ
hZ/rhkKSBz5WmGXmKc5jOjsGtp+Wa063IVx5jCA5lX/x2IWJv8lQrO29cwifC58/
5l75ar82OvmonOrDstDDG4eid5Qe7nE/Mrp4PwIDAQABAoIBAHm6JHgg3b0o/HnN
NNs3cz745ynaGQMOGnKnBA4Ip/AoHYLlY1pbMqyf8gTvGFXnzvfuX9t9hMmUvyvt
Rtz1rKqsmF76m41QgtNMfguaRp4/Tzrr3yc4p8tdcx35oCKKAnvFUl7IZ2fnqFnd
hmiYziyrSayu2YsKeM9HiC8nzZKIKzY6iZFwa1sSe4T4SHbalGV0JezOP2s2rSyu
ZuxGC3JRlQkxnmx1eMVUqHpqbmnNhazqG8uarHYH9wpO0pzEyYrlhJpNNvbuotKo
H3hUOu7+QhLCnPgWUdHWBgFlGw2uXwD4AOjRgmA2KMabT/ziZOHRVQf6PA0CZ0so
hy5hcfECgYEA/TCVUYuJknRysXsQbBpOe0MWJZ1eUbONN8vD9dm30Jot8R5eZu3z
oC10HTN60S1WF6q8TES9VndqQbAXL46YOR6v1QFDIyqSs/Unp8eCDKnojjZSXJa/
VvI4n/oSaBiEPgrmro2v6QVEWQVfP/1x8iu5ptm5AmQlrdqJdudTobkCgYEA+lrJ
hPR2Qwxksg/bWuytv147Y7QZiwHkRPWvbaZqGdvKIDFRztrPz+nYEV/YwgTBk4B4
bSU/Gb6JAgzQlegkgNQcgGobAaLPTM+1RxP3RhdjVolGxda+kD4FVU4GpDUnRk2F
9osUYIvOLI5lEKO2PW99dYogRjeIX7I7NW+zRbcCgYBsH1o0x6UJUQdFuasqrxVC
2ERV7d1AgK/W2wKlM8OmpqDl7jUcOZOnnKGQctKJD5ZLZxMgct50VWyf/qX4Hint
gqhOC0rzcpPRm+jCYpWr+MciLBqCBbJSXABIeQ9xOr8Gi7s1YbZ0iVZFueCSQBZv
97JBiYrTdZcNply71U/GqQKBgEvDV8LlnDh0MrD0PYTJpVcJdF2a7jP0EZmQ5FMB
jpBexE9zvXclHsij0uCHKfilKdOyiD83i14GHD5DlIgZJZrbFfkCo0Oeeq+bGBf3
gdhzUXuptYI/l5HdQw3qu/XLpCQRZ94Rfsws4ur2+LzNXmQ/Li946oV1Xs7fCC4G
rLEHAoGBAKhOOBHZQ+S7Fn1lV2uqip4Xxt3kxt7CTMxYDwvw+uFby8kjAXgMLTFS
EwPItPFV3suayx08O/UAkxcwD8QlRKvPGZacjml9a0fXGW7OvnI5YxdK6CF0hxHL
NDrQn26qFZ66cNHqI2+n+IAUSogsXtEsuTqm+9Rs1vgmwlAgUZvs
-----END RSA PRIVATE KEY-----

9
douyin_public_key.pem Normal file
View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA95s8NMUAsQSxZnTOTbHZ
vLvJtKNKFhDfX5LZQe4zmvbBsMX9sZjTivdF22EpRQJFfYcU85eIfZ3Yg/8JcwBS
oyI21llQIYfxWMVhQ1uLE424zx6NL75ko7CQQZK7kM9jjIhdJ8ISxR+aDcro1ugt
XYYNpn0hI+waXJG37DsvTZAWmVxXYsBc3GGv5U8PuxbLEOEd5qgV/g8hrYcXNkcV
UH9ogTf1QCEysZC0FhxV7ciO0HcZhZ/rhkKSBz5WmGXmKc5jOjsGtp+Wa063IVx5
jCA5lX/x2IWJv8lQrO29cwifC58/5l75ar82OvmonOrDstDDG4eid5Qe7nE/Mrp4
PwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -46,6 +46,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.4",
"stripe": "^18.5.0",
"typeorm": "^0.3.20"
},
"devDependencies": {

40
pnpm-lock.yaml generated
View File

@@ -56,6 +56,9 @@ importers:
sharp:
specifier: ^0.34.4
version: 0.34.4
stripe:
specifier: ^18.5.0
version: 18.5.0(@types/node@22.18.0)
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))
@@ -430,78 +433,92 @@ packages:
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
libc: [musl]
'@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]
libc: [glibc]
'@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]
libc: [glibc]
'@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]
libc: [glibc]
'@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]
libc: [glibc]
'@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]
libc: [glibc]
'@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]
libc: [musl]
'@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]
libc: [musl]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
@@ -1191,41 +1208,49 @@ 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==}
@@ -3103,6 +3128,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@18.5.0:
resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
engines: {node: '>=18'}
@@ -6845,6 +6879,12 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@18.5.0(@types/node@22.18.0):
dependencies:
qs: 6.14.0
optionalDependencies:
'@types/node': 22.18.0
strtok3@10.3.4:
dependencies:
'@tokenizer/token': 0.3.0

View File

@@ -22,6 +22,7 @@ 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';
import { PaymentTestController } from './controllers/payment-test.controller';
@Module({
imports: [
@@ -56,8 +57,13 @@ import { PaymentModule } from './payment/payment.module';
ContentModerationModule,
PaymentModule,
],
controllers: [AppController, TemplateController, ImageCompositionController],
providers: [TemplateService, TemplateManager, N8nTemplateFactoryService, ImageCompositionService],
controllers: [AppController, TemplateController, ImageCompositionController, PaymentTestController],
providers: [
TemplateService,
TemplateManager,
N8nTemplateFactoryService,
ImageCompositionService,
],
exports: [N8nTemplateFactoryService],
})
export class AppModule {}
export class AppModule { }

View File

@@ -0,0 +1,111 @@
import { HttpException, HttpStatus } from '@nestjs/common';
/**
* 业务异常基类
* 用于处理应用中的业务逻辑异常
*/
export class BusinessException extends HttpException {
constructor(
message: string,
statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
public readonly errorCode?: string,
public readonly details?: any,
) {
super(
{
message,
error: 'Business Error',
errorCode,
details,
},
statusCode,
);
}
}
/**
* 支付相关异常
*/
export class PaymentException extends BusinessException {
constructor(
message: string,
statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
errorCode?: string,
details?: any,
) {
super(message, statusCode, errorCode, details);
this.name = 'PaymentException';
}
}
/**
* 认证相关异常
*/
export class AuthenticationException extends BusinessException {
constructor(
message: string = '认证失败',
errorCode?: string,
details?: any,
) {
super(message, HttpStatus.UNAUTHORIZED, errorCode, details);
this.name = 'AuthenticationException';
}
}
/**
* 权限相关异常
*/
export class AuthorizationException extends BusinessException {
constructor(
message: string = '权限不足',
errorCode?: string,
details?: any,
) {
super(message, HttpStatus.FORBIDDEN, errorCode, details);
this.name = 'AuthorizationException';
}
}
/**
* 第三方API调用异常
*/
export class ThirdPartyApiException extends BusinessException {
constructor(
message: string,
public readonly apiName: string,
public readonly apiResponse?: any,
errorCode?: string,
) {
super(
message,
HttpStatus.BAD_GATEWAY,
errorCode,
{
apiName,
apiResponse,
},
);
this.name = 'ThirdPartyApiException';
}
}
/**
* 配置异常
*/
export class ConfigurationException extends BusinessException {
constructor(
message: string,
public readonly configKey?: string,
errorCode?: string,
) {
super(
message,
HttpStatus.INTERNAL_SERVER_ERROR,
errorCode,
{
configKey,
},
);
this.name = 'ConfigurationException';
}
}

View File

@@ -0,0 +1 @@
export * from './business.exception';

View File

@@ -0,0 +1,125 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* 全局异常过滤器
* 统一处理应用中的所有异常,提供一致的错误响应格式
*/
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | object = '服务器内部错误';
let error: string = 'Internal Server Error';
if (exception instanceof HttpException) {
// 处理HTTP异常
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exception.name;
} else if (typeof exceptionResponse === 'object') {
message = (exceptionResponse as any).message || exceptionResponse;
error = (exceptionResponse as any).error || exception.name;
}
} else if (exception instanceof Error) {
// 处理普通Error异常
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = exception.message || '服务器内部错误';
error = exception.name || 'Internal Server Error';
} else {
// 处理其他类型的异常
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '服务器内部错误';
error = 'Internal Server Error';
}
// 记录错误日志
const errorLog = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message,
error,
userAgent: request.get('User-Agent'),
ip: this.getClientIP(request),
};
if (status >= 500) {
this.logger.error('服务器错误:', errorLog, exception instanceof Error ? exception.stack : '');
} else {
this.logger.warn('客户端错误:', errorLog);
}
// 构建统一的错误响应
const errorResponse = {
success: false,
statusCode: status,
message: this.formatErrorMessage(message),
error,
timestamp: new Date().toISOString(),
path: request.url,
};
// 在开发环境下添加更多调试信息
if (process.env.NODE_ENV === 'development') {
(errorResponse as any).stack = exception instanceof Error ? exception.stack : undefined;
(errorResponse as any).details = exception;
}
response.status(status).json(errorResponse);
}
/**
* 格式化错误消息
*/
private formatErrorMessage(message: string | object): string {
if (typeof message === 'string') {
return message;
}
if (Array.isArray(message)) {
return message.join(', ');
}
if (typeof message === 'object' && message !== null) {
if ('message' in message) {
return (message as any).message;
}
return JSON.stringify(message);
}
return '未知错误';
}
/**
* 获取客户端真实IP
*/
private getClientIP(request: Request): string {
return (
(request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
(request.headers['x-real-ip'] as string) ||
request.connection?.remoteAddress ||
request.socket?.remoteAddress ||
(request.connection as any)?.socket?.remoteAddress ||
request.ip ||
'未知IP'
);
}
}

View File

@@ -0,0 +1 @@
export * from './global-exception.filter';

View File

@@ -50,7 +50,7 @@ export function setupSwagger(app: INestApplication): void {
.addTag('扩展服务', '预留的扩展功能接口')
.addServer(`http://localhost:${process.env.PORT || 3003}`, '开发环境')
.addServer(
process.env.TEST_SERVER_URL ||
process.env.SERVER_URL ||
'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com',
'测试环境',
)

View File

@@ -46,13 +46,13 @@ export class ImageCompositionController {
images: [
'https://picsum.photos/300/200',
'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
quality: 80,
},
},
vertical: {
@@ -61,13 +61,13 @@ export class ImageCompositionController {
images: [
'https://picsum.photos/200/300',
'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
quality: 85,
},
},
grid: {
@@ -78,13 +78,13 @@ export class ImageCompositionController {
'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
quality: 75,
},
},
},
@@ -112,15 +112,16 @@ export class ImageCompositionController {
try {
// 基础参数验证
this.validateCompositionRequest(compositionDto);
const result = await this.imageCompositionService.composeImages(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,
@@ -134,17 +135,11 @@ export class ImageCompositionController {
private validateCompositionRequest(dto: ImageCompositionDto): void {
// 验证图片数量
if (!dto.images || dto.images.length === 0) {
throw new HttpException(
'至少需要提供一张图片',
HttpStatus.BAD_REQUEST,
);
throw new HttpException('至少需要提供一张图片', HttpStatus.BAD_REQUEST);
}
if (dto.images.length > 20) {
throw new HttpException(
'图片数量不能超过20张',
HttpStatus.BAD_REQUEST,
);
throw new HttpException('图片数量不能超过20张', HttpStatus.BAD_REQUEST);
}
// 验证排列方向
@@ -159,16 +154,13 @@ export class ImageCompositionController {
// 验证图片URL格式
for (const imageUrl of dto.images) {
if (!imageUrl || typeof imageUrl !== 'string') {
throw new HttpException(
'图片URL不能为空',
HttpStatus.BAD_REQUEST,
);
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格式',
@@ -178,14 +170,20 @@ export class ImageCompositionController {
}
// 验证尺寸参数
if (dto.maxWidth !== undefined && (dto.maxWidth < 100 || dto.maxWidth > 4096)) {
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)) {
if (
dto.maxHeight !== undefined &&
(dto.maxHeight < 100 || dto.maxHeight > 4096)
) {
throw new HttpException(
'最大高度必须在100-4096像素之间',
HttpStatus.BAD_REQUEST,

View File

@@ -0,0 +1,55 @@
import {
Controller,
Post,
Body,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UnifiedPaymentService } from '../payment/services/unified-payment.service';
import { PlatformType } from '../entities/platform-user.entity';
import { PaymentBusinessType } from '../payment/interfaces/payment.interface';
/**
* 支付测试控制器
* 仅用于开发环境测试,生产环境应移除
*/
@ApiTags('Payment Test - 支付测试')
@Controller('payment-test')
export class PaymentTestController {
private readonly logger = new Logger(PaymentTestController.name);
constructor(
private readonly unifiedPaymentService: UnifiedPaymentService,
) {}
/**
* 测试抖音支付订单创建
*/
@Post('douyin/create-order')
@ApiOperation({
summary: '测试抖音支付订单创建',
description: '用于测试抖音支付功能,仅在开发环境使用',
})
async testDouyinCreateOrder(@Body() testData: any) {
try {
this.logger.log('开始测试抖音支付订单创建...');
const result = await this.unifiedPaymentService.createPaymentOrder(
PlatformType.BYTEDANCE,
'99d7dff9-6aa0-4f1a-807a-5948387a9c69', // 测试用户ID
PaymentBusinessType.TEMPLATE_UNLOCK,
'001',
'99d7dff9-6aa0-4f1a-807a-5948387a9c69', // 平台用户ID
);
return {
success: true,
data: result,
message: '测试成功',
};
} catch (error) {
this.logger.error('抖音支付测试失败:', error);
throw error; // 让全局异常过滤器处理
}
}
}

View File

@@ -1,13 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, IsEnum, IsOptional, IsNumber, Min, Max } from 'class-validator';
import {
IsString,
IsArray,
IsEnum,
IsOptional,
IsNumber,
Min,
Max,
} from 'class-validator';
/**
* 排列方向枚举
*/
export enum ArrangementDirection {
HORIZONTAL = 'horizontal', // 水平排列
VERTICAL = 'vertical', // 垂直排列
GRID = 'grid', // 网格排列(自动计算行列)
VERTICAL = 'vertical', // 垂直排列
GRID = 'grid', // 网格排列(自动计算行列)
}
/**
@@ -23,42 +31,42 @@ export enum OutputFormat {
* 图片拼接请求DTO - 简化版本
*/
export class ImageCompositionDto {
@ApiProperty({
description: '图片URL或base64数据列表',
@ApiProperty({
description: '图片URL或base64数据列表',
example: [
'https://picsum.photos/300/200',
'https://picsum.photos/300/200',
'https://picsum.photos/300/200'
]
'https://picsum.photos/300/200',
],
})
@IsArray()
@IsString({ each: true })
images: string[];
@ApiProperty({
description: '排列方向',
@ApiProperty({
description: '排列方向',
enum: ArrangementDirection,
example: ArrangementDirection.HORIZONTAL
example: ArrangementDirection.HORIZONTAL,
})
@IsEnum(ArrangementDirection)
direction: ArrangementDirection;
@ApiPropertyOptional({
description: '输出格式',
enum: OutputFormat,
@ApiPropertyOptional({
description: '输出格式',
enum: OutputFormat,
example: OutputFormat.PNG,
default: OutputFormat.PNG
default: OutputFormat.PNG,
})
@IsOptional()
@IsEnum(OutputFormat)
outputFormat?: OutputFormat = OutputFormat.PNG;
@ApiPropertyOptional({
description: '最大输出宽度(像素),超过会等比压缩',
@ApiPropertyOptional({
description: '最大输出宽度(像素),超过会等比压缩',
example: 1920,
default: 1920,
minimum: 100,
maximum: 4096
maximum: 4096,
})
@IsOptional()
@IsNumber()
@@ -66,12 +74,12 @@ export class ImageCompositionDto {
@Max(4096)
maxWidth?: number = 1920;
@ApiPropertyOptional({
description: '最大输出高度(像素),超过会等比压缩',
@ApiPropertyOptional({
description: '最大输出高度(像素),超过会等比压缩',
example: 1080,
default: 1080,
minimum: 100,
maximum: 4096
maximum: 4096,
})
@IsOptional()
@IsNumber()
@@ -79,12 +87,12 @@ export class ImageCompositionDto {
@Max(4096)
maxHeight?: number = 1080;
@ApiPropertyOptional({
description: '图片质量1-100仅对JPEG/WebP有效',
@ApiPropertyOptional({
description: '图片质量1-100仅对JPEG/WebP有效',
example: 80,
default: 80,
minimum: 1,
maximum: 100
maximum: 100,
})
@IsOptional()
@IsNumber()
@@ -97,7 +105,10 @@ export class ImageCompositionDto {
* 图片拼接响应DTO
*/
export class ImageCompositionResponseDto {
@ApiProperty({ description: '拼接后的图片URL', example: 'https://s3.amazonaws.com/bucket/composed-image-1234567890.png' })
@ApiProperty({
description: '拼接后的图片URL',
example: 'https://s3.amazonaws.com/bucket/composed-image-1234567890.png',
})
imageUrl: string;
@ApiProperty({ description: '图片格式', example: 'png' })

View File

@@ -59,7 +59,7 @@ async function generateSwaggerJson() {
.addTag('扩展服务', '预留的扩展功能接口')
.addServer(`http://localhost:${process.env.PORT || 3003}`, '开发环境')
.addServer(
process.env.TEST_SERVER_URL ||
process.env.SERVER_URL ||
'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com',
'测试环境',
)

View File

@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { setupSwagger } from './config/swagger.config';
import { GlobalExceptionFilter } from './common/filters';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -15,6 +16,9 @@ async function bootstrap() {
}),
);
// 启用全局异常过滤器
app.useGlobalFilters(new GlobalExceptionFilter());
// 设置Swagger文档在全局前缀之前
try {
setupSwagger(app);

View File

@@ -0,0 +1,157 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import { DouyinPaymentAdapter } from '../douyin-payment.adapter';
import { PaymentOrderEntity } from '../../entities/payment-order.entity';
import { PaymentTransactionEntity } from '../../entities/payment-transaction.entity';
import { RefundRecordEntity } from '../../entities/refund-record.entity';
describe('DouyinPaymentAdapter', () => {
let adapter: DouyinPaymentAdapter;
let configService: ConfigService;
const mockConfigService = {
get: jest.fn((key: string) => {
switch (key) {
case 'DOUYIN_APP_ID':
return 'test_app_id';
case 'DOUYIN_PRIVATE_KEY':
return 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC48vOObRIq2aJqysFr3/SIzLzWDaqLSg74IKAOL/W1Ubl18Ow3+nBiXGF6Gn0LoH7SP2cvECs9UhGVH6NOZcKvX6k6Bk+RSq/J4aH5OFlNwbbZSTQwREKmieLf8eJwxpG0uTbz0xrKxgI6sry99CN/x95qMAdx10essBD/0Zyy6i+O3omtc4FP+wRYcXbTOw2s1l8ilGD1bCeHhYEN8zxnLIOC6suimBRvxygPMqf+zZoR8peYLLlTwsUyEwKMVioY3TduaogvItMyPqjjhbq7a/slzFdGEvL3fCM3MCujTQ5aGx4A10wiDym5sph+7r18eZgJc4cEy6QpjyCy1y1jAgMBAAECggEAGPVvE9Xj9pS8e9/qfqXQSL55xK3pyXalktZISJHap+z5nmZ0YlDcO2c5yly/v5gZn+2UKFWXbY+L4DgkStn7DhKr8jDUKl3OCkGg9lJ55BSTxI/sneTgVRF/j0wuYZy9ILVBVWHe7ZAPe0oP+YHJtp6g2mtVMzI5Bx9oM+Be3q/2ar1d3jVN4Xub7ATcXqulKxSf9SBbCqn0lVQUaNF+ChqB/9xEpjkbI5SnZfIXJXA6E6GwK3LuFr6CJO/Loyl5oHAWgvqxc6ShHqTOHrb8NxtvZpsinNdmev7vaUiEMaPZAuoFcCMt+A0dt5shJ/g1zrYnXaJrPWwXnwaLh4AqpQKBgQDzhGf9BNKjaryjI1J99hYXKxnocg97bbrBRWst2WSUbZr015tfiAmZc8N8FwfejbQBY2aYRM8uPBPQSISBFFult2WzLTWrdsMemdAjBEuyyRzxrllr4ab0jfmSDLdASSaPWUGqHe/653jnmMWjBd2v5X1IoVwNqW9EkdXbQNc8BQKBgQDCbfmLa+jxkzUMKVnp2YzEQWViaSFQXikwSSMAd5fmFGyHLRNOhsC1u9zCgWnGVYmrLCsGjaKIvi0HHauR4JNAAkNmxsZnH6i/OZzi3m/xvhdelbmZMTKFx4cjaYcVhXsUnGNJUm/2M9+5ZcII2WHbQDKznOUPKPJ9MOT1Uo3oRwKBgEnWvDFcZQJoOwq8iybml4wu5Yw2CbvDaf1wry8X3AeltHbJabNt7Hs7PutBkIFLNmm1WkaRlsDONrPRSVxab++vzIaoDr4PJNsCiLoVc16i3MPrufGcWqOM6T//2xEDHU4aIm8S5NGPnnmbOVJVVQMg8vwy8VazKaoBDneNEbaBAoGBAKRIJjV+3VnZZ1Y6TmXAUye0lgS8DdEJbsYE1hVb97YJhdYF6S1NwOENti+HB6f0uJkwfb5QyIfAIO3FTSW6WtEX2Hmr9RJHdt70wDIVfwME+lwKKMNfBXByMiZi0SgHHANDTMU0abPfCudtKrEtdHi8WtrW89iGYbYULTF4Kr2bAoGAPesw2hWZ0A7Ngw8aeMPz+rj5GCkhHwPklLCPXe1pgckO4le4AtWjT+NSM8/ZCh9lVQexTG2apQM+6dddwWA4qyf1WwywGyVXogPysv213fdsburOpgaYUn0PD0MvkLbs3YmAeoaveFdR7pVSDjlr/kf/06/s4sxAoDdQtcMBtbM=';
case 'DOUYIN_PUBLIC_KEY':
return 'test_public_key';
default:
return null;
}
}),
};
const mockHttpService = {
axiosRef: {
post: jest.fn(),
},
};
const mockPaymentOrderRepository = {
save: jest.fn(),
findOne: jest.fn(),
};
const mockPaymentTransactionRepository = {
save: jest.fn(),
findOne: jest.fn(),
};
const mockRefundRecordRepository = {
save: jest.fn(),
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DouyinPaymentAdapter,
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: getRepositoryToken(PaymentOrderEntity),
useValue: mockPaymentOrderRepository,
},
{
provide: getRepositoryToken(PaymentTransactionEntity),
useValue: mockPaymentTransactionRepository,
},
{
provide: getRepositoryToken(RefundRecordEntity),
useValue: mockRefundRecordRepository,
},
],
}).compile();
adapter = module.get<DouyinPaymentAdapter>(DouyinPaymentAdapter);
configService = module.get<ConfigService>(ConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('string2PrivateKey', () => {
it('should parse a properly formatted PEM private key', () => {
const pemKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC48vOObRIq2aJq
ysFr3/SIzLzWDaqLSg74IKAOL/W1Ubl18Ow3+nBiXGF6Gn0LoH7SP2cvECs9UhGV
H6NOZcKvX6k6Bk+RSq/J4aH5OFlNwbbZSTQwREKmieLf8eJwxpG0uTbz0xrKxgI6
sry99CN/x95qMAdx10essBD/0Zyy6i+O3omtc4FP+wRYcXbTOw2s1l8ilGD1bCeH
hYEN8zxnLIOC6suimBRvxygPMqf+zZoR8peYLLlTwsUyEwKMVioY3TduaogvItMy
Pqjjhbq7a/slzFdGEvL3fCM3MCujTQ5aGx4A10wiDym5sph+7r18eZgJc4cEy6Qp
jyCy1y1jAgMBAAECggEAGPVvE9Xj9pS8e9/qfqXQSL55xK3pyXalktZISJHap+z5
nmZ0YlDcO2c5yly/v5gZn+2UKFWXbY+L4DgkStn7DhKr8jDUKl3OCkGg9lJ55BST
xI/sneTgVRF/j0wuYZy9ILVBVWHe7ZAPe0oP+YHJtp6g2mtVMzI5Bx9oM+Be3q/2
ar1d3jVN4Xub7ATcXqulKxSf9SBbCqn0lVQUaNF+ChqB/9xEpjkbI5SnZfIXJXA6
E6GwK3LuFr6CJO/Loyl5oHAWgvqxc6ShHqTOHrb8NxtvZpsinNdmev7vaUiEMaPZ
AuoFcCMt+A0dt5shJ/g1zrYnXaJrPWwXnwaLh4AqpQKBgQDzhGf9BNKjaryjI1J9
9hYXKxnocg97bbrBRWst2WSUbZr015tfiAmZc8N8FwfejbQBY2aYRM8uPBPQSISB
FFult2WzLTWrdsMemdAjBEuyyRzxrllr4ab0jfmSDLdASSaPWUGqHe/653jnmMWj
Bd2v5X1IoVwNqW9EkdXbQNc8BQKBgQDCbfmLa+jxkzUMKVnp2YzEQWViaSFQXikw
SSMAd5fmFGyHLRNOhsC1u9zCgWnGVYmrLCsGjaKIvi0HHauR4JNAAkNmxsZnH6i/
OZzi3m/xvhdelbmZMTKFx4cjaYcVhXsUnGNJUm/2M9+5ZcII2WHbQDKznOUPKPJ9
MOT1Uo3oRwKBgEnWvDFcZQJoOwq8iybml4wu5Yw2CbvDaf1wry8X3AeltHbJabNt
7Hs7PutBkIFLNmm1WkaRlsDONrPRSVxab++vzIaoDr4PJNsCiLoVc16i3MPrufGc
WqOM6T//2xEDHU4aIm8S5NGPnnmbOVJVVQMg8vwy8VazKaoBDneNEbaBAoGBAKRI
JjV+3VnZZ1Y6TmXAUye0lgS8DdEJbsYE1hVb97YJhdYF6S1NwOENti+HB6f0uJkw
fb5QyIfAIO3FTSW6WtEX2Hmr9RJHdt70wDIVfwME+lwKKMNfBXByMiZi0SgHHAND
TMU0abPfCudtKrEtdHi8WtrW89iGYbYULTF4Kr2bAoGAPesw2hWZ0A7Ngw8aeMPz
+rj5GCkhHwPklLCPXe1pgckO4le4AtWjT+NSM8/ZCh9lVQexTG2apQM+6dddwWA4
qyf1WwywGyVXogPysv213fdsburOpgaYUn0PD0MvkLbs3YmAeoaveFdR7pVSDjlr
/kf/06/s4sxAoDdQtcMBtbM=
-----END PRIVATE KEY-----`;
// Use reflection to access private method
const privateKey = (adapter as any).string2PrivateKey(pemKey);
expect(privateKey).toBeDefined();
expect(typeof privateKey).toBe('object');
});
it('should parse a raw base64 private key and format it correctly', () => {
const rawBase64Key = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC48vOObRIq2aJqysFr3/SIzLzWDaqLSg74IKAOL/W1Ubl18Ow3+nBiXGF6Gn0LoH7SP2cvECs9UhGVH6NOZcKvX6k6Bk+RSq/J4aH5OFlNwbbZSTQwREKmieLf8eJwxpG0uTbz0xrKxgI6sry99CN/x95qMAdx10essBD/0Zyy6i+O3omtc4FP+wRYcXbTOw2s1l8ilGD1bCeHhYEN8zxnLIOC6suimBRvxygPMqf+zZoR8peYLLlTwsUyEwKMVioY3TduaogvItMyPqjjhbq7a/slzFdGEvL3fCM3MCujTQ5aGx4A10wiDym5sph+7r18eZgJc4cEy6QpjyCy1y1jAgMBAAECggEAGPVvE9Xj9pS8e9/qfqXQSL55xK3pyXalktZISJHap+z5nmZ0YlDcO2c5yly/v5gZn+2UKFWXbY+L4DgkStn7DhKr8jDUKl3OCkGg9lJ55BSTxI/sneTgVRF/j0wuYZy9ILVBVWHe7ZAPe0oP+YHJtp6g2mtVMzI5Bx9oM+Be3q/2ar1d3jVN4Xub7ATcXqulKxSf9SBbCqn0lVQUaNF+ChqB/9xEpjkbI5SnZfIXJXA6E6GwK3LuFr6CJO/Loyl5oHAWgvqxc6ShHqTOHrb8NxtvZpsinNdmev7vaUiEMaPZAuoFcCMt+A0dt5shJ/g1zrYnXaJrPWwXnwaLh4AqpQKBgQDzhGf9BNKjaryjI1J99hYXKxnocg97bbrBRWst2WSUbZr015tfiAmZc8N8FwfejbQBY2aYRM8uPBPQSISBFFult2WzLTWrdsMemdAjBEuyyRzxrllr4ab0jfmSDLdASSaPWUGqHe/653jnmMWjBd2v5X1IoVwNqW9EkdXbQNc8BQKBgQDCbfmLa+jxkzUMKVnp2YzEQWViaSFQXikwSSMAd5fmFGyHLRNOhsC1u9zCgWnGVYmrLCsGjaKIvi0HHauR4JNAAkNmxsZnH6i/OZzi3m/xvhdelbmZMTKFx4cjaYcVhXsUnGNJUm/2M9+5ZcII2WHbQDKznOUPKPJ9MOT1Uo3oRwKBgEnWvDFcZQJoOwq8iybml4wu5Yw2CbvDaf1wry8X3AeltHbJabNt7Hs7PutBkIFLNmm1WkaRlsDONrPRSVxab++vzIaoDr4PJNsCiLoVc16i3MPrufGcWqOM6T//2xEDHU4aIm8S5NGPnnmbOVJVVQMg8vwy8VazKaoBDneNEbaBAoGBAKRIJjV+3VnZZ1Y6TmXAUye0lgS8DdEJbsYE1hVb97YJhdYF6S1NwOENti+HB6f0uJkwfb5QyIfAIO3FTSW6WtEX2Hmr9RJHdt70wDIVfwME+lwKKMNfBXByMiZi0SgHHANDTMU0abPfCudtKrEtdHi8WtrW89iGYbYULTF4Kr2bAoGAPesw2hWZ0A7Ngw8aeMPz+rj5GCkhHwPklLCPXe1pgckO4le4AtWjT+NSM8/ZCh9lVQexTG2apQM+6dddwWA4qyf1WwywGyVXogPysv213fdsburOpgaYUn0PD0MvkLbs3YmAeoaveFdR7pVSDjlr/kf/06/s4sxAoDdQtcMBtbM=';
// Use reflection to access private method
const privateKey = (adapter as any).string2PrivateKey(rawBase64Key);
expect(privateKey).toBeDefined();
expect(typeof privateKey).toBe('object');
});
it('should throw error for empty key', () => {
expect(() => {
(adapter as any).string2PrivateKey('');
}).toThrow('私钥格式无效');
});
it('should throw error for invalid key format', () => {
expect(() => {
(adapter as any).string2PrivateKey('invalid-key-format');
}).toThrow('私钥格式无效');
});
it('should handle keys with extra whitespace', () => {
const keyWithWhitespace = `
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC48vOObRIq2aJqysFr3/SIzLzWDaqLSg74IKAOL/W1Ubl18Ow3+nBiXGF6Gn0LoH7SP2cvECs9UhGVH6NOZcKvX6k6Bk+RSq/J4aH5OFlNwbbZSTQwREKmieLf8eJwxpG0uTbz0xrKxgI6sry99CN/x95qMAdx10essBD/0Zyy6i+O3omtc4FP+wRYcXbTOw2s1l8ilGD1bCeHhYEN8zxnLIOC6suimBRvxygPMqf+zZoR8peYLLlTwsUyEwKMVioY3TduaogvItMyPqjjhbq7a/slzFdGEvL3fCM3MCujTQ5aGx4A10wiDym5sph+7r18eZgJc4cEy6QpjyCy1y1jAgMBAAECggEAGPVvE9Xj9pS8e9/qfqXQSL55xK3pyXalktZISJHap+z5nmZ0YlDcO2c5yly/v5gZn+2UKFWXbY+L4DgkStn7DhKr8jDUKl3OCkGg9lJ55BSTxI/sneTgVRF/j0wuYZy9ILVBVWHe7ZAPe0oP+YHJtp6g2mtVMzI5Bx9oM+Be3q/2ar1d3jVN4Xub7ATcXqulKxSf9SBbCqn0lVQUaNF+ChqB/9xEpjkbI5SnZfIXJXA6E6GwK3LuFr6CJO/Loyl5oHAWgvqxc6ShHqTOHrb8NxtvZpsinNdmev7vaUiEMaPZAuoFcCMt+A0dt5shJ/g1zrYnXaJrPWwXnwaLh4AqpQKBgQDzhGf9BNKjaryjI1J99hYXKxnocg97bbrBRWst2WSUbZr015tfiAmZc8N8FwfejbQBY2aYRM8uPBPQSISBFFult2WzLTWrdsMemdAjBEuyyRzxrllr4ab0jfmSDLdASSaPWUGqHe/653jnmMWjBd2v5X1IoVwNqW9EkdXbQNc8BQKBgQDCbfmLa+jxkzUMKVnp2YzEQWViaSFQXikwSSMAd5fmFGyHLRNOhsC1u9zCgWnGVYmrLCsGjaKIvi0HHauR4JNAAkNmxsZnH6i/OZzi3m/xvhdelbmZMTKFx4cjaYcVhXsUnGNJUm/2M9+5ZcII2WHbQDKznOUPKPJ9MOT1Uo3oRwKBgEnWvDFcZQJoOwq8iybml4wu5Yw2CbvDaf1wry8X3AeltHbJabNt7Hs7PutBkIFLNmm1WkaRlsDONrPRSVxab++vzIaoDr4PJNsCiLoVc16i3MPrufGcWqOM6T//2xEDHU4aIm8S5NGPnnmbOVJVVQMg8vwy8VazKaoBDneNEbaBAoGBAKRIJjV+3VnZZ1Y6TmXAUye0lgS8DdEJbsYE1hVb97YJhdYF6S1NwOENti+HB6f0uJkwfb5QyIfAIO3FTSW6WtEX2Hmr9RJHdt70wDIVfwME+lwKKMNfBXByMiZi0SgHHANDTMU0abPfCudtKrEtdHi8WtrW89iGYbYULTF4Kr2bAoGAPesw2hWZ0A7Ngw8aeMPz+rj5GCkhHwPklLCPXe1pgckO4le4AtWjT+NSM8/ZCh9lVQexTG2apQM+6dddwWA4qyf1WwywGyVXogPysv213fdsburOpgaYUn0PD0MvkLbs3YmAeoaveFdR7pVSDjlr/kf/06/s4sxAoDdQtcMBtbM=
`;
// Use reflection to access private method
const privateKey = (adapter as any).string2PrivateKey(keyWithWhitespace);
expect(privateKey).toBeDefined();
expect(typeof privateKey).toBe('object');
});
});
});

View File

@@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'crypto';
import { PaymentException, ThirdPartyApiException, ConfigurationException } from '../../common/exceptions';
import {
CreatePaymentOrderData,
PaymentOrderResult,
@@ -42,10 +43,13 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
paymentMethod = PaymentMethod.BYTEDANCE_MINIPROGRAM;
private readonly apiBaseUrl = 'https://developer.toutiao.com';
private readonly paymentApiBaseUrl = 'https://open.bytedance.com';
private readonly appId: string;
private readonly appSecret: string;
private readonly merchantId: string;
private readonly notifyToken: string;
private readonly publicKey: string;
private readonly privateKey: string;
constructor(
httpService: HttpService,
@@ -70,9 +74,34 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
this.appSecret = this.configService.get('DOUYIN_MINIPROGRAM_SECRET') || '';
this.merchantId = this.configService.get('DOUYIN_MERCHANT_ID') || '';
this.notifyToken = this.configService.get('DOUYIN_NOTIFY_TOKEN') || '';
this.publicKey = this.configService.get('DOUYIN_PUBLIC_KEY') || '';
// 尝试从文件读取私钥,否则从环境变量获取
try {
const fs = require('fs');
const path = require('path');
const privateKeyPath = path.join(process.cwd(), 'douyin_private_key.pem');
if (fs.existsSync(privateKeyPath)) {
this.privateKey = fs.readFileSync(privateKeyPath, 'utf8');
this.logger.log('从文件加载抖音私钥成功');
} else {
this.privateKey = this.configService.get('DOUYIN_PRIVATE_KEY') || '';
this.logger.log('从环境变量加载抖音私钥');
}
} catch (error) {
this.logger.warn('读取私钥文件失败,使用环境变量:', error.message);
this.privateKey = this.configService.get('DOUYIN_PRIVATE_KEY') || '';
}
if (!this.appId || !this.appSecret || !this.merchantId) {
this.logger.warn('抖音支付配置不完整,相关功能将不可用。请配置环境变量: DOUYIN_MINIPROGRAM_APPID, DOUYIN_MINIPROGRAM_SECRET, DOUYIN_MERCHANT_ID');
if (
!this.appId ||
!this.appSecret ||
!this.merchantId ||
!this.privateKey
) {
const message = '抖音支付配置不完整,相关功能将不可用。请配置环境变量: DOUYIN_MINIPROGRAM_APPID, DOUYIN_MINIPROGRAM_SECRET, DOUYIN_MERCHANT_ID, DOUYIN_PRIVATE_KEY';
this.logger.error(message);
throw new ConfigurationException(message, 'DOUYIN_PAYMENT_CONFIG');
}
}
@@ -88,25 +117,25 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
try {
// 1. 参数验证
if (!this.validateAmount(orderData.amount)) {
throw new Error('订单金额无效');
throw new PaymentException('订单金额无效', undefined, 'INVALID_AMOUNT');
}
if (!this.validateCurrency(orderData.currency)) {
throw new Error('货币类型无效');
throw new PaymentException('货币类型无效', undefined, 'INVALID_CURRENCY');
}
// 2. 生成订单号
const orderNo = this.generateOrderNo();
const orderDataWithNo = { ...orderData, orderNo };
// 3. 获取访问令牌
// 3. 获取access_token
const accessToken = await this.getAccessToken();
// 4. 构建抖音支付请求参数
const requestData = this.buildCreateOrderRequest(orderDataWithNo);
// 5. 调用抖音预下单API
const response = await this.callDouyinAPI(
// 5. 调用抖音小程序支付API (使用新的签名方法)
const response = await this.callDouyinAPIWithSignature(
'/api/apps/ecpay/v1/create_order',
'POST',
requestData,
@@ -114,7 +143,12 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
);
if (response.err_no !== 0) {
throw new Error(`抖音支付下单失败: ${response.err_tips}`);
throw new ThirdPartyApiException(
`抖音支付下单失败: ${response.err_tips || response.message || '未知错误'}`,
'DouYin Payment API',
response,
'PAYMENT_ORDER_FAILED'
);
}
// 6. 生成小程序支付参数
@@ -148,7 +182,11 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
createdAt: paymentOrder.createdAt,
};
} catch (error) {
throw this.handleError(error, '创建支付订单');
if (error instanceof PaymentException || error instanceof ThirdPartyApiException || error instanceof ConfigurationException) {
throw error;
}
this.logger.error('创建支付订单 错误:', error);
throw new PaymentException(`创建支付订单失败: ${error.message}`, undefined, 'CREATE_ORDER_FAILED', error);
}
}
@@ -521,6 +559,8 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
*/
private async getAccessToken(): Promise<string> {
try {
this.logger.log('正在获取抖音小程序访问令牌...');
const response = await firstValueFrom(
this.httpService.post(`${this.apiBaseUrl}/api/apps/v2/token`, {
appid: this.appId,
@@ -529,13 +569,39 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
}),
);
this.logger.log('抖音API响应:', JSON.stringify(response.data));
if (response.data.err_no !== 0) {
throw new Error(`获取访问令牌失败: ${response.data.err_tips}`);
throw new ThirdPartyApiException(
`获取访问令牌失败: ${response.data.err_tips || response.data.message || '未知错误'}`,
'DouYin Token API',
response.data,
'GET_ACCESS_TOKEN_FAILED'
);
}
if (!response.data.data?.access_token) {
throw new ThirdPartyApiException(
'访问令牌响应格式异常',
'DouYin Token API',
response.data,
'INVALID_TOKEN_RESPONSE'
);
}
this.logger.log('成功获取抖音访问令牌');
return response.data.data.access_token;
} catch (error) {
throw this.handleError(error, '获取访问令牌');
if (error instanceof ThirdPartyApiException) {
throw error;
}
this.logger.error('获取访问令牌失败:', error);
throw new ThirdPartyApiException(
`获取访问令牌异常: ${error.message}`,
'DouYin Token API',
null,
'GET_ACCESS_TOKEN_ERROR'
);
}
}
@@ -545,6 +611,7 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
* @returns 请求参数
*/
private buildCreateOrderRequest(orderData: CreatePaymentOrderData) {
// 抖音小程序支付使用SHA256-RSA2048签名算法
return {
app_id: this.appId,
out_order_no: orderData.orderNo,
@@ -552,13 +619,10 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
subject: orderData.description,
body: orderData.description,
valid_time: orderData.expireMinutes ? orderData.expireMinutes * 60 : 1800, // 默认30分钟
cp_extra: JSON.stringify({
businessType: orderData.businessType,
businessId: orderData.businessId,
userId: orderData.userId,
}),
notify_url: orderData.notifyUrl,
thirdparty_id: orderData.platformUserId,
// 添加签名相关参数
timestamp: Math.floor(Date.now() / 1000),
nonce_str: this.generateUUID(),
};
}
@@ -568,12 +632,62 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
* @returns 支付参数
*/
private generateMiniProgramPaymentParams(orderData: any) {
const timeStamp = Math.floor(Date.now() / 1000).toString();
const nonceStr = this.generateUUID();
const packageStr = `prepay_id=${orderData.order_id}`;
const signType = 'SHA256-RSA2048';
// 生成支付签名
const paySign = this.generatePaySign({
timeStamp,
nonceStr,
package: packageStr,
signType,
});
return {
timeStamp,
nonceStr,
package: packageStr,
signType,
paySign,
// 保留原有的抖音特有参数
orderId: orderData.order_id,
orderToken: orderData.order_token,
};
}
/**
* 调用抖音API带授权头
* @param url API路径
* @param method HTTP方法
* @param data 请求数据
* @param authorization 授权头
* @returns 响应数据
*/
private async callDouyinAPIWithAuth(
url: string,
method: string,
data: any,
authorization: string,
) {
const fullUrl = `${this.apiBaseUrl}${url}`;
const config = {
headers: {
'Content-Type': 'application/json',
Authorization: authorization,
},
};
const response = await firstValueFrom(
method === 'GET'
? this.httpService.get(fullUrl, config)
: this.httpService.post(fullUrl, data, config),
);
return response.data;
}
/**
* 调用抖音API
* @param url API路径
@@ -605,6 +719,108 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
return response.data;
}
/**
* 调用抖音API (使用SHA256-RSA2048签名)
* @param url API路径
* @param method HTTP方法
* @param data 请求数据
* @param accessToken 访问令牌
* @returns 响应数据
*/
private async callDouyinAPIWithSignature(
url: string,
method: string,
data: any,
accessToken?: string,
) {
try {
this.logger.log(`调用抖音支付API: ${method} ${url}`);
this.logger.log('请求数据:', JSON.stringify(data));
const fullUrl = `${this.apiBaseUrl}${url}`;
const timestamp = data.timestamp;
const nonceStr = data.nonce_str;
const bodyStr = JSON.stringify(data);
// 生成SHA256-RSA2048签名
const signature = this.generateDouyinSignature(method, url, timestamp, nonceStr, bodyStr);
// 构建授权头 - 按照抖音规范格式
const authorization = this.buildDouyinAuthorization({
appId: this.appId,
timestamp,
nonceStr,
signature,
});
this.logger.log('生成的Authorization头:', authorization);
const config = {
headers: {
'Content-Type': 'application/json',
'Authorization': authorization,
},
params: accessToken ? { access_token: accessToken } : undefined,
};
const response = await firstValueFrom(
method === 'GET'
? this.httpService.get(fullUrl, config)
: this.httpService.post(fullUrl, data, config),
);
this.logger.log('抖音API响应:', JSON.stringify(response.data));
return response.data;
} catch (error) {
this.logger.error('调用抖音API失败:', error);
if (error.response?.data) {
this.logger.error('API错误响应:', JSON.stringify(error.response.data));
}
throw error;
}
}
/**
* 调用抖音支付API
* @param url API路径
* @param method HTTP方法
* @param data 请求数据
* @param accessToken 访问令牌
* @returns 响应数据
*/
private async callDouyinPaymentAPI(
url: string,
method: string,
data: any,
accessToken: string,
) {
const fullUrl = `${this.paymentApiBaseUrl}${url}`;
const config = {
headers: {
'Content-Type': 'application/json',
},
params: { access_token: accessToken },
};
try {
const response = await firstValueFrom(
method === 'GET'
? this.httpService.get(fullUrl, config)
: this.httpService.post(fullUrl, data, config),
);
return response.data;
} catch (error) {
this.logger.error(`调用抖音支付API失败: ${url}`, error);
throw new ThirdPartyApiException(
`抖音支付API调用失败: ${error.message}`,
'DouYin Payment API',
error.response?.data,
'API_CALL_FAILED'
);
}
}
/**
* 解析抖音支付状态
* @param orderStatus 抖音订单状态
@@ -653,4 +869,278 @@ export class DouyinPaymentAdapter extends BasePaymentAdapter {
arrivalTime.setMinutes(arrivalTime.getMinutes() + 10); // 10分钟内到账
return arrivalTime;
}
/**
* 生成抖音支付授权头
* @param params 授权参数
* @returns 授权头字符串
*/
private buildDouyinAuthorization(params: {
appId: string;
timestamp: number;
nonceStr: string;
signature: string;
}): string {
try {
const keyVersion = '1'; // 应用公钥版本
return [
'SHA256-RSA2048',
`appid=${params.appId}`,
`nonce_str=${params.nonceStr}`,
`timestamp=${params.timestamp}`,
`key_version=${keyVersion}`,
`signature=${params.signature}`,
]
.join(',')
.replace('SHA256-RSA2048,', 'SHA256-RSA2048 ');
} catch (error) {
this.logger.error('生成抖音授权头失败:', error);
throw new Error('生成授权头失败');
}
}
/**
* 生成抖音签名 (SHA256-RSA2048)
* 按照抖音官方规范:五行格式签名串
* @param method HTTP方法
* @param uri 请求URI
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param body 请求体
* @returns Base64编码的签名
*/
private generateDouyinSignature(
method: string,
uri: string,
timestamp: number,
nonce: string,
body: string,
): string {
try {
// 构建待签名字符串 - 严格按照抖音规范的五行格式
const signString = [
method.toUpperCase(), // HTTP请求方法
uri, // URI不含域名以/开头)
timestamp.toString(), // 请求时间戳
nonce, // 请求随机串
body, // 请求报文主体
].join('\n');
this.logger.log('抖音签名原始字符串:', signString);
// 使用RSA私钥进行SHA256签名
const privateKey = this.string2PrivateKey(this.privateKey);
const sign = crypto.createSign('SHA256');
sign.update(signString, 'utf8');
sign.end();
const signature = sign.sign(privateKey, 'base64');
this.logger.log('生成的抖音签名:', signature);
return signature;
} catch (error) {
this.logger.error('生成抖音签名失败:', error);
throw new Error('生成抖音签名失败');
}
}
/**
* 生成字节授权头(旧版本,保留兼容性)
* @param data 请求数据
* @returns 授权头字符串
*/
private generateByteAuthorization(data: string): string {
try {
const timestamp = Math.floor(Date.now() / 1000);
const nonceStr = this.generateUUID();
const keyVersion = '1'; // 应用公钥版本,需要根据实际情况调整
const signature = this.generateSignature(
'POST',
'/api/trade_auth/v1/developer/create_pay_order/',
timestamp,
nonceStr,
data,
);
return [
'SHA256-RSA2048',
`appid=${this.appId}`,
`nonce_str=${nonceStr}`,
`timestamp=${timestamp}`,
`key_version=${keyVersion}`,
`signature=${signature}`,
]
.join(',')
.replace('SHA256-RSA2048,', 'SHA256-RSA2048 ');
} catch (error) {
this.logger.error('生成字节授权头失败:', error);
throw new Error('生成授权头失败');
}
}
/**
* 生成RSA签名
* @param method HTTP方法
* @param uri 请求URI
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param data 请求数据
* @returns Base64编码的签名
*/
private generateSignature(
method: string,
uri: string,
timestamp: number,
nonce: string,
data: string,
): string {
try {
const rawStr = `${method}\n${uri}\n${timestamp}\n${nonce}\n${data}\n`;
const privateKey = this.string2PrivateKey(this.privateKey);
const sign = crypto.createSign('SHA256');
sign.update(rawStr, 'utf8');
sign.end();
return sign.sign(privateKey, 'base64');
} catch (error) {
this.logger.error('生成RSA签名失败:', error);
throw new Error('生成签名失败');
}
}
/**
* 将私钥字符串转换为私钥对象
* @param privateKeyStr 私钥字符串
* @returns 私钥对象
*/
private string2PrivateKey(privateKeyStr: string): crypto.KeyLike {
try {
// 去除多余的空白字符
let cleanKey = privateKeyStr.trim();
// 检查是否已经是完整的PEM格式
if (cleanKey.includes('-----BEGIN') && cleanKey.includes('-----END')) {
// 如果已经是PEM格式直接使用
return crypto.createPrivateKey({
key: cleanKey,
format: 'pem',
});
}
// 如果是纯base64字符串需要格式化为PEM格式
// 移除所有非base64字符保留+、/、=
const base64Content = cleanKey.replace(/[^A-Za-z0-9+/=]/g, '');
// 验证base64内容长度
if (base64Content.length === 0) {
throw new Error('私钥内容为空');
}
// 将base64内容按64字符一行进行分割
const base64Lines: string[] = [];
for (let i = 0; i < base64Content.length; i += 64) {
base64Lines.push(base64Content.slice(i, i + 64));
}
// 构建完整的PEM格式
const pemKey = [
'-----BEGIN PRIVATE KEY-----',
...base64Lines,
'-----END PRIVATE KEY-----'
].join('\n');
return crypto.createPrivateKey({
key: pemKey,
format: 'pem',
});
} catch (error) {
this.logger.error('转换私钥失败:', error);
throw new Error('私钥格式无效');
}
}
/**
* 生成参数签名用于API请求
* @param params 请求参数
* @returns 签名字符串
*/
private generateParameterSignature(params: Record<string, any>): string {
try {
this.logger.log('开始生成参数签名,参数:', params);
// 1. 过滤空值参数
const filteredParams = Object.keys(params)
.filter(key => params[key] !== null && params[key] !== undefined && params[key] !== '')
.reduce((obj, key) => {
obj[key] = params[key];
return obj;
}, {} as Record<string, any>);
// 2. 参数按key排序
const sortedKeys = Object.keys(filteredParams).sort();
// 3. 构建签名字符串 key1=value1&key2=value2
const signString = sortedKeys
.map(key => `${key}=${filteredParams[key]}`)
.join('&');
this.logger.log('签名原始字符串:', signString);
// 4. 使用MD5或SHA256计算签名根据抖音要求
const signature = crypto
.createHash('md5')
.update(signString + `&key=${this.notifyToken}`)
.digest('hex')
.toUpperCase();
this.logger.log('生成的签名:', signature);
return signature;
} catch (error) {
this.logger.error('生成参数签名失败:', error);
throw new Error('生成参数签名失败');
}
}
/**
* 生成支付签名
* @param paymentData 支付数据
* @returns 支付签名
*/
private generatePaySign(paymentData: {
timeStamp: string;
nonceStr: string;
package: string;
signType: string;
}): string {
try {
// 构建签名字符串
const signStr = `${this.appId}\n${paymentData.timeStamp}\n${paymentData.nonceStr}\n${paymentData.package}\n`;
const privateKey = this.string2PrivateKey(this.privateKey);
const sign = crypto.createSign('SHA256');
sign.update(signStr, 'utf8');
sign.end();
return sign.sign(privateKey, 'base64');
} catch (error) {
this.logger.error('生成支付签名失败:', error);
throw new Error('生成支付签名失败');
}
}
/**
* 生成UUID
* @returns UUID字符串
*/
private generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}

View File

@@ -74,7 +74,9 @@ export class WechatPaymentAdapter extends BasePaymentAdapter {
this.serialNumber = this.configService.get('WECHAT_SERIAL_NUMBER') || '';
if (!this.appId || !this.mchId || !this.privateKey || !this.apiV3Key) {
this.logger.warn('微信支付配置不完整,相关功能将不可用。请配置环境变量: WECHAT_MINIPROGRAM_APPID, WECHAT_MCH_ID, WECHAT_PRIVATE_KEY, WECHAT_API_V3_KEY');
this.logger.warn(
'微信支付配置不完整,相关功能将不可用。请配置环境变量: WECHAT_MINIPROGRAM_APPID, WECHAT_MCH_ID, WECHAT_PRIVATE_KEY, WECHAT_API_V3_KEY',
);
}
}

View File

@@ -158,7 +158,7 @@ export class UnifiedPaymentService {
businessName: businessConfig.name,
platform,
},
notifyUrl: `${this.configService.get('APP_URL')}/api/payment/${platform.toLowerCase()}/callback`,
notifyUrl: `${this.configService.get('SERVER_URL')}/api/v1/payment/${platform.toLowerCase()}/callback`,
};
// 6. 获取支付适配器并创建订单
@@ -166,6 +166,7 @@ export class UnifiedPaymentService {
platform,
paymentMethod,
);
console.log(orderData)
const result = await adapter.createPaymentOrder(orderData);
this.logger.log(

View File

@@ -32,29 +32,31 @@ export class ImageCompositionService {
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}`);
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,
@@ -108,12 +110,14 @@ export class ImageCompositionService {
// 检查是否需要压缩最终图片
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
this.logger.log(
`最终图片需要压缩: ${layout.canvasWidth}x${layout.canvasHeight} -> 最大${maxWidth}x${maxHeight}`,
);
finalImage = finalImage.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
@@ -129,7 +133,10 @@ export class ImageCompositionService {
case OutputFormat.PNG:
default:
// PNG格式使用适度压缩
finalImage = finalImage.png({ compressionLevel: 8, adaptiveFiltering: true });
finalImage = finalImage.png({
compressionLevel: 8,
adaptiveFiltering: true,
});
break;
}

View File

@@ -45,6 +45,10 @@ export class DynamicN8nImageTemplate extends N8nImageGenerateTemplate {
return this;
}
get workflow() {
return this.config?.workflow || ``
}
// 从数据库动态获取配置 - 实现Template抽象类的属性
get code(): string {
return this.config?.code || '';
@@ -125,6 +129,9 @@ export class DynamicN8nVideoTemplate extends N8nVideoGenerateTemplate {
return this;
}
get workflow() {
return this.config?.workflow || ``
}
// 从数据库动态获取配置 - 实现Template抽象类的属性
get code(): string {

View File

@@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
export abstract class N8nImageGenerateTemplate extends ImageGenerateTemplate {
abstract readonly imageModel: string;
abstract readonly imagePrompt: string;
abstract readonly workflow: string;
constructor(private configService: ConfigService) {
super();
@@ -21,7 +22,7 @@ export abstract class N8nImageGenerateTemplate extends ImageGenerateTemplate {
url: webhookUrl,
method: 'post',
data: {
workflow: '图生图',
workflow: this.workflow || '图生图',
environment:
'https://bowongai-test--text-video-agent-fastapi-app.modal.run',
image_generation: {
@@ -46,6 +47,7 @@ export abstract class N8nVideoGenerateTemplate extends VideoGenerateTemplate {
abstract readonly videoPrompt: string;
abstract readonly duration: number;
abstract readonly aspectRatio: string;
abstract readonly workflow: string;
constructor(private configService: ConfigService) {
super();
@@ -62,7 +64,7 @@ export abstract class N8nVideoGenerateTemplate extends VideoGenerateTemplate {
url: webhookUrl,
method: 'post',
data: {
workflow: '图生图+生视频',
workflow: this.workflow || '图生图+生视频',
environment:
'https://bowongai-test--text-video-agent-fastapi-app.modal.run',
image_generation: {

View File

@@ -6,6 +6,7 @@ export class CharacterFigurineTemplate extends N8nVideoGenerateTemplate {
constructor(configService: ConfigService) {
super(configService);
}
readonly workflow: string = ``
readonly code = 'character_figurine_v1';
readonly name = '人物手办';
readonly description =

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
// cos真人模板
export class CosplayRealPersonTemplate extends N8nVideoGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
// 车库开门模板
export class GarageOpeningTemplate extends N8nVideoGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
// 日本杂志模板
export class JapaneseMagazineTemplate extends N8nVideoGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
// 原子弹爆炸模板
export class NuclearExplosionTemplate extends N8nVideoGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
// 宠物手办模板
export class PetFigurineTemplate extends N8nVideoGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
// 老照片修复上色模板
export class PhotoRestoreTemplate extends N8nImageGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
// 闭眼照片修复
export class OpenEyesTemplate extends N8nImageGenerateTemplate {
workflow: string;
constructor(configService: ConfigService) {
super(configService);
}