diff --git a/.env.example b/.env.example index 7117198..6df5f7d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/douyin_private_key.pem b/douyin_private_key.pem new file mode 100644 index 0000000..23cf40d --- /dev/null +++ b/douyin_private_key.pem @@ -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----- diff --git a/douyin_public_key.pem b/douyin_public_key.pem new file mode 100644 index 0000000..81bddf9 --- /dev/null +++ b/douyin_public_key.pem @@ -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----- diff --git a/package.json b/package.json index e8eb874..6b97541 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9546e6..bcbda19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app.module.ts b/src/app.module.ts index 122089f..a226814 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 { } diff --git a/src/common/exceptions/business.exception.ts b/src/common/exceptions/business.exception.ts new file mode 100644 index 0000000..731f87d --- /dev/null +++ b/src/common/exceptions/business.exception.ts @@ -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'; + } +} \ No newline at end of file diff --git a/src/common/exceptions/index.ts b/src/common/exceptions/index.ts new file mode 100644 index 0000000..f53e7df --- /dev/null +++ b/src/common/exceptions/index.ts @@ -0,0 +1 @@ +export * from './business.exception'; \ No newline at end of file diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..2339f87 --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -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(); + const request = ctx.getRequest(); + + 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' + ); + } +} \ No newline at end of file diff --git a/src/common/filters/index.ts b/src/common/filters/index.ts new file mode 100644 index 0000000..7f46161 --- /dev/null +++ b/src/common/filters/index.ts @@ -0,0 +1 @@ +export * from './global-exception.filter'; \ No newline at end of file diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index 12310a4..f482640 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -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', '测试环境', ) diff --git a/src/controllers/__tests__/image-composition.example.ts b/src/controllers/__tests__/image-composition.example.ts index 0519ecb..e69de29 100644 --- a/src/controllers/__tests__/image-composition.example.ts +++ b/src/controllers/__tests__/image-composition.example.ts @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/controllers/image-composition.controller.ts b/src/controllers/image-composition.controller.ts index a38e504..442e8b5 100644 --- a/src/controllers/image-composition.controller.ts +++ b/src/controllers/image-composition.controller.ts @@ -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, diff --git a/src/controllers/payment-test.controller.ts b/src/controllers/payment-test.controller.ts new file mode 100644 index 0000000..32eeaa4 --- /dev/null +++ b/src/controllers/payment-test.controller.ts @@ -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; // 让全局异常过滤器处理 + } + } +} \ No newline at end of file diff --git a/src/dto/image-composition.dto.ts b/src/dto/image-composition.dto.ts index d5c8807..6be50f1 100644 --- a/src/dto/image-composition.dto.ts +++ b/src/dto/image-composition.dto.ts @@ -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' }) diff --git a/src/generate-swagger-json.ts b/src/generate-swagger-json.ts index a8a6ef8..69c5367 100644 --- a/src/generate-swagger-json.ts +++ b/src/generate-swagger-json.ts @@ -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', '测试环境', ) diff --git a/src/main.ts b/src/main.ts index 433a6b6..3a9f537 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/payment/adapters/__tests__/douyin-payment.adapter.spec.ts b/src/payment/adapters/__tests__/douyin-payment.adapter.spec.ts new file mode 100644 index 0000000..b7d22d1 --- /dev/null +++ b/src/payment/adapters/__tests__/douyin-payment.adapter.spec.ts @@ -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); + configService = module.get(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'); + }); + }); +}); \ No newline at end of file diff --git a/src/payment/adapters/douyin-payment.adapter.ts b/src/payment/adapters/douyin-payment.adapter.ts index 26b57a3..7487400 100644 --- a/src/payment/adapters/douyin-payment.adapter.ts +++ b/src/payment/adapters/douyin-payment.adapter.ts @@ -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 { 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 { + 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); + + // 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); + }); + } } diff --git a/src/payment/adapters/wechat-payment.adapter.ts b/src/payment/adapters/wechat-payment.adapter.ts index b4510ae..6a473bf 100644 --- a/src/payment/adapters/wechat-payment.adapter.ts +++ b/src/payment/adapters/wechat-payment.adapter.ts @@ -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', + ); } } diff --git a/src/payment/services/unified-payment.service.ts b/src/payment/services/unified-payment.service.ts index df41e59..bdd27bc 100644 --- a/src/payment/services/unified-payment.service.ts +++ b/src/payment/services/unified-payment.service.ts @@ -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( diff --git a/src/services/image-composition.service.ts b/src/services/image-composition.service.ts index 8d0d42c..fe2e30b 100644 --- a/src/services/image-composition.service.ts +++ b/src/services/image-composition.service.ts @@ -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; } diff --git a/src/templates/n8n-dynamic-template.ts b/src/templates/n8n-dynamic-template.ts index 5e6a630..ea40eec 100644 --- a/src/templates/n8n-dynamic-template.ts +++ b/src/templates/n8n-dynamic-template.ts @@ -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 { diff --git a/src/templates/n8nTemplate.ts b/src/templates/n8nTemplate.ts index 6c0e11d..399d703 100644 --- a/src/templates/n8nTemplate.ts +++ b/src/templates/n8nTemplate.ts @@ -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: { diff --git a/src/templates/n8nTemplates/CharacterFigurineTemplate.ts b/src/templates/n8nTemplates/CharacterFigurineTemplate.ts index da06eea..ea0885c 100644 --- a/src/templates/n8nTemplates/CharacterFigurineTemplate.ts +++ b/src/templates/n8nTemplates/CharacterFigurineTemplate.ts @@ -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 = diff --git a/src/templates/n8nTemplates/CosplayRealPersonTemplate.ts b/src/templates/n8nTemplates/CosplayRealPersonTemplate.ts index 8c819e1..8d91ed3 100644 --- a/src/templates/n8nTemplates/CosplayRealPersonTemplate.ts +++ b/src/templates/n8nTemplates/CosplayRealPersonTemplate.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; // cos真人模板 export class CosplayRealPersonTemplate extends N8nVideoGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/GarageOpeningTemplate.ts b/src/templates/n8nTemplates/GarageOpeningTemplate.ts index 3e51c87..d0743a7 100644 --- a/src/templates/n8nTemplates/GarageOpeningTemplate.ts +++ b/src/templates/n8nTemplates/GarageOpeningTemplate.ts @@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate'; // 车库开门模板 export class GarageOpeningTemplate extends N8nVideoGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/JapaneseMagazineTemplate.ts b/src/templates/n8nTemplates/JapaneseMagazineTemplate.ts index 0f11cd3..eb9e3f7 100644 --- a/src/templates/n8nTemplates/JapaneseMagazineTemplate.ts +++ b/src/templates/n8nTemplates/JapaneseMagazineTemplate.ts @@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate'; // 日本杂志模板 export class JapaneseMagazineTemplate extends N8nVideoGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/NuclearExplosionTemplate.ts b/src/templates/n8nTemplates/NuclearExplosionTemplate.ts index 4947e61..9b29baa 100644 --- a/src/templates/n8nTemplates/NuclearExplosionTemplate.ts +++ b/src/templates/n8nTemplates/NuclearExplosionTemplate.ts @@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate'; // 原子弹爆炸模板 export class NuclearExplosionTemplate extends N8nVideoGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/PetFigurineTemplate.ts b/src/templates/n8nTemplates/PetFigurineTemplate.ts index c7ad3f9..8d0823d 100644 --- a/src/templates/n8nTemplates/PetFigurineTemplate.ts +++ b/src/templates/n8nTemplates/PetFigurineTemplate.ts @@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate'; // 宠物手办模板 export class PetFigurineTemplate extends N8nVideoGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/PhotoRestoreTemplate.ts b/src/templates/n8nTemplates/PhotoRestoreTemplate.ts index 710fc48..5ed9876 100644 --- a/src/templates/n8nTemplates/PhotoRestoreTemplate.ts +++ b/src/templates/n8nTemplates/PhotoRestoreTemplate.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; // 老照片修复上色模板 export class PhotoRestoreTemplate extends N8nImageGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); } diff --git a/src/templates/n8nTemplates/open-eyes.template.ts b/src/templates/n8nTemplates/open-eyes.template.ts index c1f46d6..d2ad99a 100644 --- a/src/templates/n8nTemplates/open-eyes.template.ts +++ b/src/templates/n8nTemplates/open-eyes.template.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; // 闭眼照片修复 export class OpenEyesTemplate extends N8nImageGenerateTemplate { + workflow: string; constructor(configService: ConfigService) { super(configService); }