feat: 增强支付模块和图片拼接功能,完善异常处理
- 新增 Stripe 支付依赖和支付测试控制器 - 完善抖音支付适配器,支持SHA256-RSA2048签名 - 优化图片拼接API的参数验证和错误处理 - 添加全局异常过滤器提升系统稳定性 - 更新配置文件和API文档 - 完善N8N模板结构
This commit is contained in:
@@ -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
27
douyin_private_key.pem
Normal 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
9
douyin_public_key.pem
Normal 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-----
|
||||
@@ -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
40
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
111
src/common/exceptions/business.exception.ts
Normal file
111
src/common/exceptions/business.exception.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
1
src/common/exceptions/index.ts
Normal file
1
src/common/exceptions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './business.exception';
|
||||
125
src/common/filters/global-exception.filter.ts
Normal file
125
src/common/filters/global-exception.filter.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
1
src/common/filters/index.ts
Normal file
1
src/common/filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './global-exception.filter';
|
||||
@@ -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',
|
||||
'测试环境',
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
src/controllers/payment-test.controller.ts
Normal file
55
src/controllers/payment-test.controller.ts
Normal 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; // 让全局异常过滤器处理
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
'测试环境',
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
157
src/payment/adapters/__tests__/douyin-payment.adapter.spec.ts
Normal file
157
src/payment/adapters/__tests__/douyin-payment.adapter.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// cos真人模板
|
||||
export class CosplayRealPersonTemplate extends N8nVideoGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
|
||||
|
||||
// 车库开门模板
|
||||
export class GarageOpeningTemplate extends N8nVideoGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
|
||||
|
||||
// 日本杂志模板
|
||||
export class JapaneseMagazineTemplate extends N8nVideoGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
|
||||
|
||||
// 原子弹爆炸模板
|
||||
export class NuclearExplosionTemplate extends N8nVideoGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { N8nVideoGenerateTemplate } from '../n8nTemplate';
|
||||
|
||||
// 宠物手办模板
|
||||
export class PetFigurineTemplate extends N8nVideoGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// 老照片修复上色模板
|
||||
export class PhotoRestoreTemplate extends N8nImageGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// 闭眼照片修复
|
||||
export class OpenEyesTemplate extends N8nImageGenerateTemplate {
|
||||
workflow: string;
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user