From 96ae77471c387648f16869578fe8c70dce53c166 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 25 Sep 2025 20:28:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E5=9C=A8=E7=BA=BF=E6=94=AF=E4=BB=98=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加支付模块架构,支持微信支付和抖音支付 - 创建支付订单、交易记录、退款记录数据表和实体 - 实现基于适配器模式的多平台支付集成 - 添加统一支付服务,集成会员订阅、积分购买、模板解锁业务 - 完善支付回调处理和退款功能 - 添加完整的DTO验证和API文档 - 集成到主应用模块,完成依赖注入配置 --- src/app.module.ts | 2 + src/entities/user.entity.ts | 4 + .../1756900000000-CreatePaymentTables.ts | 493 ++++++++++++ src/payment/adapters/base-payment.adapter.ts | 369 +++++++++ .../adapters/douyin-payment.adapter.ts | 656 +++++++++++++++ src/payment/adapters/index.ts | 3 + .../adapters/wechat-payment.adapter.ts | 692 ++++++++++++++++ src/payment/controllers/index.ts | 1 + src/payment/controllers/payment.controller.ts | 495 ++++++++++++ src/payment/dto/create-payment-order.dto.ts | 106 +++ src/payment/dto/index.ts | 10 + src/payment/dto/payment-order-response.dto.ts | 158 ++++ src/payment/dto/refund-request.dto.ts | 173 ++++ src/payment/entities/index.ts | 7 + src/payment/entities/payment-order.entity.ts | 195 +++++ .../entities/payment-transaction.entity.ts | 221 +++++ src/payment/entities/refund-record.entity.ts | 256 ++++++ src/payment/index.ts | 28 + src/payment/interfaces/payment.interface.ts | 361 +++++++++ src/payment/payment.module.ts | 140 ++++ src/payment/services/index.ts | 3 + .../services/payment-adapter.factory.ts | 305 +++++++ .../services/payment-webhook.service.ts | 403 ++++++++++ .../services/unified-payment.service.ts | 761 ++++++++++++++++++ 24 files changed, 5842 insertions(+) create mode 100644 src/migrations/1756900000000-CreatePaymentTables.ts create mode 100644 src/payment/adapters/base-payment.adapter.ts create mode 100644 src/payment/adapters/douyin-payment.adapter.ts create mode 100644 src/payment/adapters/index.ts create mode 100644 src/payment/adapters/wechat-payment.adapter.ts create mode 100644 src/payment/controllers/index.ts create mode 100644 src/payment/controllers/payment.controller.ts create mode 100644 src/payment/dto/create-payment-order.dto.ts create mode 100644 src/payment/dto/index.ts create mode 100644 src/payment/dto/payment-order-response.dto.ts create mode 100644 src/payment/dto/refund-request.dto.ts create mode 100644 src/payment/entities/index.ts create mode 100644 src/payment/entities/payment-order.entity.ts create mode 100644 src/payment/entities/payment-transaction.entity.ts create mode 100644 src/payment/entities/refund-record.entity.ts create mode 100644 src/payment/index.ts create mode 100644 src/payment/interfaces/payment.interface.ts create mode 100644 src/payment/payment.module.ts create mode 100644 src/payment/services/index.ts create mode 100644 src/payment/services/payment-adapter.factory.ts create mode 100644 src/payment/services/payment-webhook.service.ts create mode 100644 src/payment/services/unified-payment.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 7d281e3..03c8d4f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { N8nTemplateFactoryService } from './services/n8n-template-factory.servi import { TemplateController } from './controllers/template.controller'; import { PlatformModule } from './platform/platform.module'; import { ContentModerationModule } from './content-moderation/content-moderation.module'; +import { PaymentModule } from './payment/payment.module'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { ContentModerationModule } from './content-moderation/content-moderation ]), PlatformModule, ContentModerationModule, + PaymentModule, ], controllers: [AppController, TemplateController], providers: [TemplateService, TemplateManager, N8nTemplateFactoryService], diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 044304d..0ee25ed 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -83,4 +83,8 @@ export class UserEntity { /** 广告观看列表 - 用户观看广告的完整记录和奖励明细 */ @OneToMany(() => AdWatchEntity, (adWatch) => adWatch.user) adWatches: AdWatchEntity[]; + + /** 支付订单列表 - 用户的所有支付订单记录 */ + @OneToMany('PaymentOrderEntity', 'user') + paymentOrders?: any[]; } diff --git a/src/migrations/1756900000000-CreatePaymentTables.ts b/src/migrations/1756900000000-CreatePaymentTables.ts new file mode 100644 index 0000000..573461c --- /dev/null +++ b/src/migrations/1756900000000-CreatePaymentTables.ts @@ -0,0 +1,493 @@ +import { MigrationInterface, QueryRunner, Table, Index } from 'typeorm'; + +export class CreatePaymentTables1756900000000 implements MigrationInterface { + name = 'CreatePaymentTables1756900000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 创建支付订单表 + await queryRunner.createTable( + new Table({ + name: 'payment_orders', + columns: [ + { + name: 'id', + type: 'varchar', + length: '36', + isPrimary: true, + generationStrategy: 'uuid', + }, + { + name: 'userId', + type: 'varchar', + length: '36', + }, + { + name: 'platform', + type: 'enum', + enum: ['wechat', 'douyin'], + enumName: 'platform_type', + }, + { + name: 'orderNo', + type: 'varchar', + length: '64', + isUnique: true, + }, + { + name: 'thirdPartyOrderId', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'paymentMethod', + type: 'enum', + enum: ['wechat_miniprogram', 'bytedance_miniprogram'], + }, + { + name: 'status', + type: 'enum', + enum: [ + 'pending', + 'paid', + 'confirmed', + 'cancelled', + 'failed', + 'refunded', + ], + default: "'pending'", + }, + { + name: 'businessType', + type: 'enum', + enum: ['subscription', 'credit_purchase', 'template_unlock'], + }, + { + name: 'businessId', + type: 'varchar', + length: '100', + }, + { + name: 'description', + type: 'varchar', + length: '200', + }, + { + name: 'amount', + type: 'bigint', + }, + { + name: 'paidAmount', + type: 'bigint', + isNullable: true, + }, + { + name: 'currency', + type: 'varchar', + length: '10', + default: "'CNY'", + }, + { + name: 'platformUserId', + type: 'varchar', + length: '100', + }, + { + name: 'paymentParams', + type: 'json', + isNullable: true, + }, + { + name: 'paidAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'expiredAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'notifyUrl', + type: 'varchar', + length: '500', + isNullable: true, + }, + { + name: 'metadata', + type: 'json', + isNullable: true, + }, + { + name: 'errorMessage', + type: 'text', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + // 创建支付交易记录表 + await queryRunner.createTable( + new Table({ + name: 'payment_transactions', + columns: [ + { + name: 'id', + type: 'varchar', + length: '36', + isPrimary: true, + generationStrategy: 'uuid', + }, + { + name: 'paymentOrderId', + type: 'varchar', + length: '36', + }, + { + name: 'thirdPartyTransactionId', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'thirdPartyOrderId', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'transactionType', + type: 'enum', + enum: ['payment', 'refund', 'callback'], + }, + { + name: 'status', + type: 'enum', + enum: ['pending', 'success', 'failed'], + default: "'pending'", + }, + { + name: 'amount', + type: 'bigint', + }, + { + name: 'currency', + type: 'varchar', + length: '10', + default: "'CNY'", + }, + { + name: 'transactionTime', + type: 'timestamp', + isNullable: true, + }, + { + name: 'description', + type: 'varchar', + length: '200', + isNullable: true, + }, + { + name: 'requestData', + type: 'json', + isNullable: true, + }, + { + name: 'responseData', + type: 'json', + isNullable: true, + }, + { + name: 'callbackData', + type: 'json', + isNullable: true, + }, + { + name: 'errorCode', + type: 'varchar', + length: '50', + isNullable: true, + }, + { + name: 'errorMessage', + type: 'text', + isNullable: true, + }, + { + name: 'processCount', + type: 'int', + default: 1, + }, + { + name: 'lastProcessedAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'metadata', + type: 'json', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['paymentOrderId'], + referencedColumnNames: ['id'], + referencedTableName: 'payment_orders', + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + // 创建退款记录表 + await queryRunner.createTable( + new Table({ + name: 'refund_records', + columns: [ + { + name: 'id', + type: 'varchar', + length: '36', + isPrimary: true, + generationStrategy: 'uuid', + }, + { + name: 'paymentOrderId', + type: 'varchar', + length: '36', + }, + { + name: 'refundNo', + type: 'varchar', + length: '64', + isUnique: true, + }, + { + name: 'thirdPartyRefundId', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'thirdPartyTransactionId', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'status', + type: 'enum', + enum: ['pending', 'processing', 'success', 'failed'], + default: "'pending'", + }, + { + name: 'refundAmount', + type: 'bigint', + }, + { + name: 'originalAmount', + type: 'bigint', + }, + { + name: 'currency', + type: 'varchar', + length: '10', + default: "'CNY'", + }, + { + name: 'reason', + type: 'varchar', + length: '500', + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'requestedBy', + type: 'varchar', + length: '36', + isNullable: true, + }, + { + name: 'approvedBy', + type: 'varchar', + length: '36', + isNullable: true, + }, + { + name: 'refundedAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'arrivedAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'estimatedArrivalTime', + type: 'timestamp', + isNullable: true, + }, + { + name: 'failureReason', + type: 'text', + isNullable: true, + }, + { + name: 'errorCode', + type: 'varchar', + length: '50', + isNullable: true, + }, + { + name: 'requestData', + type: 'json', + isNullable: true, + }, + { + name: 'responseData', + type: 'json', + isNullable: true, + }, + { + name: 'callbackData', + type: 'json', + isNullable: true, + }, + { + name: 'processCount', + type: 'int', + default: 1, + }, + { + name: 'lastProcessedAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'metadata', + type: 'json', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['paymentOrderId'], + referencedColumnNames: ['id'], + referencedTableName: 'payment_orders', + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + // 创建索引 + await this.createIndexes(queryRunner); + } + + public async down(queryRunner: QueryRunner): Promise { + // 删除表(按依赖关系倒序) + await queryRunner.dropTable('refund_records'); + await queryRunner.dropTable('payment_transactions'); + await queryRunner.dropTable('payment_orders'); + } + + private async createIndexes(queryRunner: QueryRunner): Promise { + // payment_orders 表索引 + await queryRunner.query( + `CREATE UNIQUE INDEX IDX_PAYMENT_ORDERS_ORDER_NO ON payment_orders (orderNo)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_ORDERS_USER_STATUS ON payment_orders (userId, status)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_ORDERS_PLATFORM_BUSINESS ON payment_orders (platform, businessType)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_ORDERS_CREATED_AT ON payment_orders (createdAt)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_ORDERS_THIRD_PARTY_ID ON payment_orders (thirdPartyOrderId)`, + ); + + // payment_transactions 表索引 + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_TRANSACTIONS_ORDER_ID ON payment_transactions (paymentOrderId)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_TRANSACTIONS_THIRD_PARTY_ID ON payment_transactions (thirdPartyTransactionId)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_TRANSACTIONS_TYPE_STATUS ON payment_transactions (transactionType, status)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_PAYMENT_TRANSACTIONS_CREATED_AT ON payment_transactions (createdAt)`, + ); + + // refund_records 表索引 + await queryRunner.query( + `CREATE INDEX IDX_REFUND_RECORDS_ORDER_ID ON refund_records (paymentOrderId)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IDX_REFUND_RECORDS_REFUND_NO ON refund_records (refundNo)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_REFUND_RECORDS_THIRD_PARTY_ID ON refund_records (thirdPartyRefundId)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_REFUND_RECORDS_STATUS ON refund_records (status)`, + ); + await queryRunner.query( + `CREATE INDEX IDX_REFUND_RECORDS_CREATED_AT ON refund_records (createdAt)`, + ); + } +} diff --git a/src/payment/adapters/base-payment.adapter.ts b/src/payment/adapters/base-payment.adapter.ts new file mode 100644 index 0000000..60d2a77 --- /dev/null +++ b/src/payment/adapters/base-payment.adapter.ts @@ -0,0 +1,369 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { Logger } from '@nestjs/common'; +import { + IPaymentAdapter, + CreatePaymentOrderData, + PaymentOrderResult, + PaymentOrderQueryResult, + PaymentCallbackResult, + RefundRequestData, + RefundResult, + RefundQueryResult, + BillData, + PaymentMethod, + PaymentOrderStatus, + PaymentBusinessType, + RefundStatus, +} from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { + PaymentOrderEntity, + PaymentTransactionEntity, + RefundRecordEntity, +} from '../entities'; +import { + TransactionType, + TransactionStatus, +} from '../entities/payment-transaction.entity'; + +/** + * 基础支付适配器抽象类 + * 提供支付适配器的通用功能实现 + * 包含数据库操作、订单管理、通用业务逻辑等 + * 子类需要实现具体的支付平台接口调用 + */ +@Injectable() +export abstract class BasePaymentAdapter implements IPaymentAdapter { + protected readonly logger = new Logger(this.constructor.name); + + abstract platform: PlatformType; + abstract paymentMethod: PaymentMethod; + + constructor( + protected readonly httpService: HttpService, + protected readonly configService: ConfigService, + protected readonly paymentOrderRepository: Repository, + protected readonly paymentTransactionRepository: Repository, + protected readonly refundRecordRepository: Repository, + ) {} + + /** + * 创建支付订单号 + * 生成唯一的系统订单号 + */ + protected generateOrderNo(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + const platformPrefix = this.platform === PlatformType.WECHAT ? 'WX' : 'DY'; + return `${platformPrefix}${timestamp}${random.toUpperCase()}`; + } + + /** + * 创建退款单号 + * 生成唯一的退款单号 + */ + protected generateRefundNo(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + const platformPrefix = + this.platform === PlatformType.WECHAT ? 'WXR' : 'DYR'; + return `${platformPrefix}${timestamp}${random.toUpperCase()}`; + } + + /** + * 保存支付订单到数据库 + * @param orderData 订单数据 + * @param thirdPartyOrderId 第三方订单ID + * @param paymentParams 支付参数 + * @returns 支付订单实体 + */ + protected async savePaymentOrder( + orderData: CreatePaymentOrderData, + thirdPartyOrderId?: string, + paymentParams?: any, + ): Promise { + const paymentOrder = new PaymentOrderEntity(); + + paymentOrder.userId = orderData.userId; + paymentOrder.platform = this.platform; + paymentOrder.orderNo = orderData.orderNo; + paymentOrder.thirdPartyOrderId = thirdPartyOrderId; + paymentOrder.paymentMethod = this.paymentMethod; + paymentOrder.status = PaymentOrderStatus.PENDING; + paymentOrder.businessType = orderData.businessType; + paymentOrder.businessId = orderData.businessId; + paymentOrder.description = orderData.description; + paymentOrder.amount = orderData.amount; + paymentOrder.currency = orderData.currency; + paymentOrder.platformUserId = orderData.platformUserId; + paymentOrder.paymentParams = paymentParams; + paymentOrder.notifyUrl = orderData.notifyUrl; + paymentOrder.metadata = orderData.metadata; + + // 设置过期时间 + if (orderData.expireMinutes) { + const expiredAt = new Date(); + expiredAt.setMinutes(expiredAt.getMinutes() + orderData.expireMinutes); + paymentOrder.expiredAt = expiredAt; + } + + return await this.paymentOrderRepository.save(paymentOrder); + } + + /** + * 更新支付订单状态 + * @param orderId 订单ID + * @param status 新状态 + * @param paidAmount 支付金额 + * @param paidAt 支付时间 + * @param errorMessage 错误信息 + */ + protected async updatePaymentOrderStatus( + orderId: string, + status: PaymentOrderStatus, + paidAmount?: number, + paidAt?: Date, + errorMessage?: string, + ): Promise { + const updateData: Partial = { status }; + + if (paidAmount !== undefined) { + updateData.paidAmount = paidAmount; + } + if (paidAt) { + updateData.paidAt = paidAt; + } + if (errorMessage) { + updateData.errorMessage = errorMessage; + } + + await this.paymentOrderRepository.update(orderId, updateData); + } + + /** + * 保存支付交易记录 + * @param paymentOrderId 支付订单ID + * @param transactionType 交易类型 + * @param amount 交易金额 + * @param requestData 请求数据 + * @param responseData 响应数据 + * @param status 交易状态 + * @returns 交易记录实体 + */ + protected async savePaymentTransaction( + paymentOrderId: string, + transactionType: TransactionType, + amount: number, + requestData?: any, + responseData?: any, + status: TransactionStatus = TransactionStatus.PENDING, + ): Promise { + const transaction = new PaymentTransactionEntity(); + + transaction.paymentOrderId = paymentOrderId; + transaction.transactionType = transactionType; + transaction.status = status; + transaction.amount = amount; + transaction.currency = 'CNY'; + transaction.requestData = requestData; + transaction.responseData = responseData; + + return await this.paymentTransactionRepository.save(transaction); + } + + /** + * 更新支付交易记录 + * @param transactionId 交易记录ID + * @param status 交易状态 + * @param thirdPartyTransactionId 第三方交易号 + * @param responseData 响应数据 + * @param errorCode 错误代码 + * @param errorMessage 错误信息 + */ + protected async updatePaymentTransaction( + transactionId: string, + status: TransactionStatus, + thirdPartyTransactionId?: string, + responseData?: any, + errorCode?: string, + errorMessage?: string, + ): Promise { + const updateData: Partial = { + status, + lastProcessedAt: new Date(), + }; + + if (thirdPartyTransactionId) { + updateData.thirdPartyTransactionId = thirdPartyTransactionId; + } + if (responseData) { + updateData.responseData = responseData; + } + if (errorCode) { + updateData.errorCode = errorCode; + } + if (errorMessage) { + updateData.errorMessage = errorMessage; + } + + await this.paymentTransactionRepository.update(transactionId, updateData); + } + + /** + * 保存退款记录 + * @param refundData 退款数据 + * @param paymentOrder 原支付订单 + * @returns 退款记录实体 + */ + protected async saveRefundRecord( + refundData: RefundRequestData, + paymentOrder: PaymentOrderEntity, + ): Promise { + const refundRecord = new RefundRecordEntity(); + + refundRecord.paymentOrderId = refundData.orderId; + refundRecord.refundNo = refundData.refundNo; + refundRecord.thirdPartyTransactionId = refundData.thirdPartyTransactionId; + refundRecord.status = RefundStatus.PENDING; + refundRecord.refundAmount = refundData.refundAmount; + refundRecord.originalAmount = + paymentOrder.paidAmount || paymentOrder.amount; + refundRecord.currency = paymentOrder.currency; + refundRecord.reason = refundData.reason; + refundRecord.requestedBy = paymentOrder.userId; + + return await this.refundRecordRepository.save(refundRecord); + } + + /** + * 更新退款记录状态 + * @param refundId 退款记录ID + * @param status 退款状态 + * @param thirdPartyRefundId 第三方退款ID + * @param refundedAt 退款时间 + * @param errorCode 错误代码 + * @param failureReason 失败原因 + */ + protected async updateRefundRecord( + refundId: string, + status: RefundStatus, + thirdPartyRefundId?: string, + refundedAt?: Date, + errorCode?: string, + failureReason?: string, + ): Promise { + const updateData: Partial = { + status, + lastProcessedAt: new Date(), + }; + + if (thirdPartyRefundId) { + updateData.thirdPartyRefundId = thirdPartyRefundId; + } + if (refundedAt) { + updateData.refundedAt = refundedAt; + } + if (errorCode) { + updateData.errorCode = errorCode; + } + if (failureReason) { + updateData.failureReason = failureReason; + } + + await this.refundRecordRepository.update(refundId, updateData); + } + + /** + * 统一错误处理 + * @param error 错误对象 + * @param context 错误上下文 + * @returns 标准化错误 + */ + protected handleError(error: any, context: string): Error { + this.logger.error(`${context} 错误:`, error); + + if (error.response?.data) { + const errorData = error.response.data; + return new Error( + `${this.platform}支付错误: ${errorData.message || errorData.err_tips || errorData.errmsg || '未知错误'}`, + ); + } + + return new Error(`${this.platform}支付${context}失败: ${error.message}`); + } + + /** + * 验证订单金额 + * @param amount 金额(分) + * @returns 是否有效 + */ + protected validateAmount(amount: number): boolean { + return amount > 0 && amount <= 100000000; // 最大1000万分(10万元) + } + + /** + * 验证货币类型 + * @param currency 货币类型 + * @returns 是否有效 + */ + protected validateCurrency(currency: string): boolean { + return ['CNY', 'USD'].includes(currency); + } + + /** + * 检查订单是否可以操作 + * @param paymentOrder 支付订单 + * @param operation 操作类型 + * @returns 检查结果 + */ + protected validateOrderOperation( + paymentOrder: PaymentOrderEntity, + operation: 'pay' | 'refund' | 'query', + ): { valid: boolean; message?: string } { + if (!paymentOrder) { + return { valid: false, message: '订单不存在' }; + } + + switch (operation) { + case 'pay': + if (!paymentOrder.canPay()) { + return { valid: false, message: '订单不能支付' }; + } + break; + case 'refund': + if (!paymentOrder.canRefund()) { + return { valid: false, message: '订单不能退款' }; + } + break; + case 'query': + // 查询操作不需要特殊验证 + break; + default: + return { valid: false, message: '不支持的操作类型' }; + } + + return { valid: true }; + } + + // 抽象方法 - 子类必须实现 + abstract createPaymentOrder( + orderData: CreatePaymentOrderData, + ): Promise; + abstract queryPaymentOrder( + orderId: string, + thirdPartyOrderId?: string, + ): Promise; + abstract handlePaymentCallback( + callbackData: any, + ): Promise; + abstract refundPayment(refundData: RefundRequestData): Promise; + abstract queryRefundStatus(refundId: string): Promise; + abstract verifySignature( + callbackData: any, + signature: string, + ): Promise; + abstract downloadBill(date: string): Promise; +} diff --git a/src/payment/adapters/douyin-payment.adapter.ts b/src/payment/adapters/douyin-payment.adapter.ts new file mode 100644 index 0000000..23578e3 --- /dev/null +++ b/src/payment/adapters/douyin-payment.adapter.ts @@ -0,0 +1,656 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'crypto'; +import { + CreatePaymentOrderData, + PaymentOrderResult, + PaymentOrderQueryResult, + PaymentCallbackResult, + RefundRequestData, + RefundResult, + RefundQueryResult, + BillData, + PaymentMethod, + PaymentOrderStatus, + RefundStatus, +} from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { BasePaymentAdapter } from './base-payment.adapter'; +import { + PaymentOrderEntity, + PaymentTransactionEntity, + RefundRecordEntity, +} from '../entities'; +import { + TransactionType, + TransactionStatus, +} from '../entities/payment-transaction.entity'; + +/** + * 抖音支付适配器 + * 实现抖音小程序支付的完整流程 + * 支持创建订单、查询状态、处理回调、申请退款等功能 + * 遵循字节跳动支付API规范和安全标准 + */ +@Injectable() +export class DouyinPaymentAdapter extends BasePaymentAdapter { + platform = PlatformType.BYTEDANCE; + paymentMethod = PaymentMethod.BYTEDANCE_MINIPROGRAM; + + private readonly apiBaseUrl = 'https://developer.toutiao.com'; + private readonly appId: string; + private readonly appSecret: string; + private readonly merchantId: string; + private readonly notifyToken: string; + + constructor( + httpService: HttpService, + configService: ConfigService, + @InjectRepository(PaymentOrderEntity) + paymentOrderRepository: Repository, + @InjectRepository(PaymentTransactionEntity) + paymentTransactionRepository: Repository, + @InjectRepository(RefundRecordEntity) + refundRecordRepository: Repository, + ) { + super( + httpService, + configService, + paymentOrderRepository, + paymentTransactionRepository, + refundRecordRepository, + ); + + // 从配置中获取抖音支付参数 + this.appId = this.configService.get('DOUYIN_MINIPROGRAM_APPID') || ''; + this.appSecret = this.configService.get('DOUYIN_MINIPROGRAM_SECRET') || ''; + this.merchantId = this.configService.get('DOUYIN_MERCHANT_ID') || ''; + this.notifyToken = this.configService.get('DOUYIN_NOTIFY_TOKEN') || ''; + + if (!this.appId || !this.appSecret || !this.merchantId) { + this.logger.error('抖音支付配置不完整,请检查环境变量配置'); + } + } + + /** + * 创建支付订单 + * 调用抖音预下单API创建支付订单 + * @param orderData 订单数据 + * @returns 支付订单结果 + */ + async createPaymentOrder( + orderData: CreatePaymentOrderData, + ): Promise { + try { + // 1. 参数验证 + if (!this.validateAmount(orderData.amount)) { + throw new Error('订单金额无效'); + } + + if (!this.validateCurrency(orderData.currency)) { + throw new Error('货币类型无效'); + } + + // 2. 生成订单号 + const orderNo = this.generateOrderNo(); + const orderDataWithNo = { ...orderData, orderNo }; + + // 3. 获取访问令牌 + const accessToken = await this.getAccessToken(); + + // 4. 构建抖音支付请求参数 + const requestData = this.buildCreateOrderRequest(orderDataWithNo); + + // 5. 调用抖音预下单API + const response = await this.callDouyinAPI( + '/api/apps/ecpay/v1/create_order', + 'POST', + requestData, + accessToken, + ); + + if (response.err_no !== 0) { + throw new Error(`抖音支付下单失败: ${response.err_tips}`); + } + + // 6. 生成小程序支付参数 + const paymentParams = this.generateMiniProgramPaymentParams( + response.data, + ); + + // 7. 保存订单到数据库 + const paymentOrder = await this.savePaymentOrder( + orderDataWithNo, + response.data.order_id, + paymentParams, + ); + + // 8. 记录交易日志 + await this.savePaymentTransaction( + paymentOrder.id, + TransactionType.PAYMENT, + orderData.amount, + requestData, + response, + TransactionStatus.PENDING, + ); + + return { + orderId: paymentOrder.id, + thirdPartyOrderId: response.data.order_id, + paymentParams, + status: PaymentOrderStatus.PENDING, + paymentMethod: this.paymentMethod, + createdAt: paymentOrder.createdAt, + }; + } catch (error) { + throw this.handleError(error, '创建支付订单'); + } + } + + /** + * 查询支付订单状态 + * 调用抖音订单查询API获取订单最新状态 + * @param orderId 本地订单ID + * @param thirdPartyOrderId 第三方订单ID + * @returns 订单查询结果 + */ + async queryPaymentOrder( + orderId: string, + thirdPartyOrderId?: string, + ): Promise { + try { + // 1. 获取本地订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: orderId }, + }); + + if (!paymentOrder) { + throw new Error('订单不存在'); + } + + // 2. 获取访问令牌 + const accessToken = await this.getAccessToken(); + + // 3. 构建查询参数 + const requestData = { + app_id: this.appId, + out_order_no: paymentOrder.orderNo, + }; + + // 4. 调用抖音查询API + const response = await this.callDouyinAPI( + '/api/apps/ecpay/v1/query_order', + 'POST', + requestData, + accessToken, + ); + + if (response.err_no !== 0) { + throw new Error(`查询订单失败: ${response.err_tips}`); + } + + // 5. 解析支付状态 + const orderInfo = response.data; + const status = this.parseDouyinPaymentStatus(orderInfo.order_status); + const paidAt = orderInfo.paid_at + ? new Date(orderInfo.paid_at * 1000) + : undefined; + + // 6. 更新本地订单状态 + if (status !== paymentOrder.status) { + await this.updatePaymentOrderStatus( + orderId, + status, + orderInfo.total_amount, + paidAt, + ); + } + + return { + orderId, + thirdPartyOrderId: orderInfo.order_id, + status, + amount: paymentOrder.amount, + paidAmount: orderInfo.total_amount, + paidAt, + thirdPartyTransactionId: orderInfo.payment_info?.order_id, + rawData: orderInfo, + }; + } catch (error) { + throw this.handleError(error, '查询支付订单'); + } + } + + /** + * 处理支付回调 + * 验证并处理抖音支付结果通知 + * @param callbackData 回调数据 + * @returns 回调处理结果 + */ + async handlePaymentCallback( + callbackData: any, + ): Promise { + try { + // 1. 验证签名 + if ( + !(await this.verifySignature(callbackData, callbackData.msg_signature)) + ) { + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: '签名验证失败', + responseData: { err_no: 1, err_tips: '签名验证失败' }, + }; + } + + // 2. 解析回调数据 + const orderNo = callbackData.cp_orderno; + const orderStatus = callbackData.status; + const paidAt = callbackData.paid_at + ? new Date(callbackData.paid_at * 1000) + : undefined; + + // 3. 查找本地订单 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { orderNo }, + }); + + if (!paymentOrder) { + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: '订单不存在', + responseData: { err_no: 1, err_tips: '订单不存在' }, + }; + } + + // 4. 处理支付结果 + const status = this.parseDouyinPaymentStatus(orderStatus); + + // 5. 更新订单状态 + await this.updatePaymentOrderStatus( + paymentOrder.id, + status, + callbackData.total_amount, + paidAt, + ); + + // 6. 记录回调日志 + await this.savePaymentTransaction( + paymentOrder.id, + TransactionType.CALLBACK, + callbackData.total_amount || 0, + callbackData, + callbackData, + TransactionStatus.SUCCESS, + ); + + return { + success: true, + orderId: paymentOrder.id, + status, + paidAt, + thirdPartyTransactionId: callbackData.payment_order_no, + responseData: { err_no: 0, err_tips: 'success' }, + }; + } catch (error) { + this.logger.error('处理抖音支付回调失败:', error); + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: error.message, + responseData: { err_no: 1, err_tips: error.message }, + }; + } + } + + /** + * 申请退款 + * 调用抖音申请退款API + * @param refundData 退款数据 + * @returns 退款结果 + */ + async refundPayment(refundData: RefundRequestData): Promise { + try { + // 1. 获取原订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: refundData.orderId }, + }); + + if (!paymentOrder) { + throw new Error('原订单不存在'); + } + + // 2. 验证订单状态 + const validation = this.validateOrderOperation(paymentOrder, 'refund'); + if (!validation.valid) { + throw new Error(validation.message); + } + + // 3. 保存退款记录 + const refundRecord = await this.saveRefundRecord( + refundData, + paymentOrder, + ); + + // 4. 获取访问令牌 + const accessToken = await this.getAccessToken(); + + // 5. 构建退款请求参数 + const requestData = { + app_id: this.appId, + out_order_no: paymentOrder.orderNo, + out_refund_no: refundRecord.refundNo, + refund_amount: refundData.refundAmount, + reason: refundData.reason, + notify_url: `${this.configService.get('APP_URL')}/api/payment/douyin/refund-callback`, + }; + + // 6. 调用抖音退款API + const response = await this.callDouyinAPI( + '/api/apps/ecpay/v1/create_refund', + 'POST', + requestData, + accessToken, + ); + + if (response.err_no !== 0) { + throw new Error(`申请退款失败: ${response.err_tips}`); + } + + // 7. 更新退款记录 + const status = this.parseDouyinRefundStatus(response.data.refund_status); + await this.updateRefundRecord( + refundRecord.id, + status, + response.data.refund_id, + ); + + return { + refundId: refundRecord.id, + thirdPartyRefundId: response.data.refund_id, + status, + refundAmount: refundData.refundAmount, + createdAt: refundRecord.createdAt, + estimatedArrivalTime: this.calculateRefundArrivalTime(), + }; + } catch (error) { + throw this.handleError(error, '申请退款'); + } + } + + /** + * 查询退款状态 + * 调用抖音查询退款API + * @param refundId 退款ID + * @returns 退款查询结果 + */ + async queryRefundStatus(refundId: string): Promise { + try { + // 1. 获取退款记录 + const refundRecord = await this.refundRecordRepository.findOne({ + where: { id: refundId }, + }); + + if (!refundRecord) { + throw new Error('退款记录不存在'); + } + + // 2. 获取访问令牌 + const accessToken = await this.getAccessToken(); + + // 3. 构建查询参数 + const requestData = { + app_id: this.appId, + out_refund_no: refundRecord.refundNo, + }; + + // 4. 调用抖音查询退款API + const response = await this.callDouyinAPI( + '/api/apps/ecpay/v1/query_refund', + 'POST', + requestData, + accessToken, + ); + + if (response.err_no !== 0) { + throw new Error(`查询退款失败: ${response.err_tips}`); + } + + // 5. 解析退款状态 + const refundInfo = response.data; + const status = this.parseDouyinRefundStatus(refundInfo.refund_status); + const refundedAt = refundInfo.refund_time + ? new Date(refundInfo.refund_time * 1000) + : undefined; + + // 6. 更新退款记录 + if (status !== refundRecord.status) { + await this.updateRefundRecord( + refundRecord.id, + status, + undefined, + refundedAt, + ); + } + + return { + refundId, + thirdPartyRefundId: refundInfo.refund_id, + status: status, + refundAmount: refundRecord.refundAmount, + originalAmount: refundRecord.originalAmount, + reason: refundRecord.reason, + refundedAt, + arrivedAt: refundedAt, // 抖音退款一般实时到账 + failureReason: + refundInfo.refund_status === 'FAIL' + ? refundInfo.fail_reason + : undefined, + createdAt: refundRecord.createdAt, + updatedAt: refundRecord.updatedAt, + }; + } catch (error) { + throw this.handleError(error, '查询退款状态'); + } + } + + /** + * 验证支付回调签名 + * 使用抖音提供的签名算法验证 + * @param callbackData 回调数据 + * @param signature 签名 + * @returns 验证结果 + */ + async verifySignature( + callbackData: any, + signature: string, + ): Promise { + try { + // 构建签名字符串 + const params = Object.keys(callbackData) + .filter((key) => key !== 'msg_signature' && key !== 'type') + .sort() + .map((key) => `${key}=${callbackData[key]}`) + .join('&'); + + const signatureStr = params + this.notifyToken; + + // 计算SHA1哈希 + const expectedSignature = crypto + .createHash('sha1') + .update(signatureStr) + .digest('hex'); + + return signature === expectedSignature; + } catch (error) { + this.logger.error('抖音支付签名验证失败:', error); + return false; + } + } + + /** + * 下载对账单 + * 抖音暂不支持对账单下载,返回空数据 + * @param date 对账日期 + * @returns 对账单数据 + */ + async downloadBill(date: string): Promise { + // 抖音支付暂不支持对账单下载 + return { + billDate: date, + totalCount: 0, + totalAmount: 0, + records: [], + rawData: '抖音支付暂不支持对账单下载', + }; + } + + /** + * 获取访问令牌 + * 调用抖音获取access_token接口 + * @returns 访问令牌 + */ + private async getAccessToken(): Promise { + try { + const response = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/api/apps/v2/token`, { + appid: this.appId, + secret: this.appSecret, + grant_type: 'client_credential', + }), + ); + + if (response.data.err_no !== 0) { + throw new Error(`获取访问令牌失败: ${response.data.err_tips}`); + } + + return response.data.data.access_token; + } catch (error) { + throw this.handleError(error, '获取访问令牌'); + } + } + + /** + * 构建创建订单请求参数 + * @param orderData 订单数据 + * @returns 请求参数 + */ + private buildCreateOrderRequest(orderData: CreatePaymentOrderData) { + return { + app_id: this.appId, + out_order_no: orderData.orderNo, + total_amount: orderData.amount, + 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, + }; + } + + /** + * 生成小程序支付参数 + * @param orderData 订单数据 + * @returns 支付参数 + */ + private generateMiniProgramPaymentParams(orderData: any) { + return { + orderId: orderData.order_id, + orderToken: orderData.order_token, + }; + } + + /** + * 调用抖音API + * @param url API路径 + * @param method HTTP方法 + * @param data 请求数据 + * @param accessToken 访问令牌 + * @returns 响应数据 + */ + private async callDouyinAPI( + url: string, + method: string, + data: any, + accessToken?: string, + ) { + const fullUrl = `${this.apiBaseUrl}${url}`; + const config = { + headers: { + 'Content-Type': 'application/json', + }, + params: accessToken ? { access_token: accessToken } : undefined, + }; + + const response = await firstValueFrom( + method === 'GET' + ? this.httpService.get(fullUrl, config) + : this.httpService.post(fullUrl, data, config), + ); + + return response.data; + } + + /** + * 解析抖音支付状态 + * @param orderStatus 抖音订单状态 + * @returns 系统支付状态 + */ + private parseDouyinPaymentStatus(orderStatus: string): PaymentOrderStatus { + switch (orderStatus) { + case 'SUCCESS': + return PaymentOrderStatus.PAID; + case 'FAIL': + return PaymentOrderStatus.FAILED; + case 'PROCESSING': + return PaymentOrderStatus.PENDING; + case 'TIMEOUT': + return PaymentOrderStatus.CANCELLED; + default: + return PaymentOrderStatus.PENDING; + } + } + + /** + * 解析抖音退款状态 + * @param refundStatus 抖音退款状态 + * @returns 系统退款状态 + */ + private parseDouyinRefundStatus(refundStatus: string): RefundStatus { + switch (refundStatus) { + case 'SUCCESS': + return RefundStatus.SUCCESS; + case 'FAIL': + return RefundStatus.FAILED; + case 'PROCESSING': + return RefundStatus.PROCESSING; + default: + return RefundStatus.PENDING; + } + } + + /** + * 计算退款到账时间 + * @returns 预计到账时间 + */ + private calculateRefundArrivalTime(): Date { + // 抖音退款一般实时到账 + const arrivalTime = new Date(); + arrivalTime.setMinutes(arrivalTime.getMinutes() + 10); // 10分钟内到账 + return arrivalTime; + } +} diff --git a/src/payment/adapters/index.ts b/src/payment/adapters/index.ts new file mode 100644 index 0000000..ec9a1ca --- /dev/null +++ b/src/payment/adapters/index.ts @@ -0,0 +1,3 @@ +export { BasePaymentAdapter } from './base-payment.adapter'; +export { WechatPaymentAdapter } from './wechat-payment.adapter'; +export { DouyinPaymentAdapter } from './douyin-payment.adapter'; diff --git a/src/payment/adapters/wechat-payment.adapter.ts b/src/payment/adapters/wechat-payment.adapter.ts new file mode 100644 index 0000000..b358cac --- /dev/null +++ b/src/payment/adapters/wechat-payment.adapter.ts @@ -0,0 +1,692 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'crypto'; +import { + CreatePaymentOrderData, + PaymentOrderResult, + PaymentOrderQueryResult, + PaymentCallbackResult, + RefundRequestData, + RefundResult, + RefundQueryResult, + BillData, + PaymentMethod, + PaymentOrderStatus, + RefundStatus, +} from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { BasePaymentAdapter } from './base-payment.adapter'; +import { + PaymentOrderEntity, + PaymentTransactionEntity, + RefundRecordEntity, +} from '../entities'; +import { + TransactionType, + TransactionStatus, +} from '../entities/payment-transaction.entity'; + +/** + * 微信支付适配器 + * 实现微信小程序支付的完整流程 + * 支持创建订单、查询状态、处理回调、申请退款等功能 + * 遵循微信支付APIv3规范和安全标准 + */ +@Injectable() +export class WechatPaymentAdapter extends BasePaymentAdapter { + platform = PlatformType.WECHAT; + paymentMethod = PaymentMethod.WECHAT_MINIPROGRAM; + + private readonly apiBaseUrl = 'https://api.mch.weixin.qq.com'; + private readonly appId: string; + private readonly mchId: string; + private readonly privateKey: string; + private readonly apiV3Key: string; + private readonly serialNumber: string; + + constructor( + httpService: HttpService, + configService: ConfigService, + @InjectRepository(PaymentOrderEntity) + paymentOrderRepository: Repository, + @InjectRepository(PaymentTransactionEntity) + paymentTransactionRepository: Repository, + @InjectRepository(RefundRecordEntity) + refundRecordRepository: Repository, + ) { + super( + httpService, + configService, + paymentOrderRepository, + paymentTransactionRepository, + refundRecordRepository, + ); + + // 从配置中获取微信支付参数 + this.appId = this.configService.get('WECHAT_MINIPROGRAM_APPID') || ''; + this.mchId = this.configService.get('WECHAT_MCH_ID') || ''; + this.privateKey = this.configService.get('WECHAT_PRIVATE_KEY') || ''; + this.apiV3Key = this.configService.get('WECHAT_API_V3_KEY') || ''; + this.serialNumber = this.configService.get('WECHAT_SERIAL_NUMBER') || ''; + + if (!this.appId || !this.mchId || !this.privateKey || !this.apiV3Key) { + this.logger.error('微信支付配置不完整,请检查环境变量配置'); + } + } + + /** + * 创建支付订单 + * 调用微信统一下单API创建预支付订单 + * @param orderData 订单数据 + * @returns 支付订单结果 + */ + async createPaymentOrder( + orderData: CreatePaymentOrderData, + ): Promise { + try { + // 1. 参数验证 + if (!this.validateAmount(orderData.amount)) { + throw new Error('订单金额无效'); + } + + if (!this.validateCurrency(orderData.currency)) { + throw new Error('货币类型无效'); + } + + // 2. 生成订单号 + const orderNo = this.generateOrderNo(); + const orderDataWithNo = { ...orderData, orderNo }; + + // 3. 构建微信支付请求参数 + const requestData = this.buildUnifiedOrderRequest(orderDataWithNo); + + // 4. 调用微信统一下单API + const response = await this.callWechatAPI( + '/v3/pay/transactions/jsapi', + 'POST', + requestData, + ); + + if (!response.prepay_id) { + throw new Error('微信支付下单失败:未返回prepay_id'); + } + + // 5. 生成小程序支付参数 + const paymentParams = this.generateMiniProgramPaymentParams( + response.prepay_id, + ); + + // 6. 保存订单到数据库 + const paymentOrder = await this.savePaymentOrder( + orderDataWithNo, + response.prepay_id, + paymentParams, + ); + + // 7. 记录交易日志 + await this.savePaymentTransaction( + paymentOrder.id, + TransactionType.PAYMENT, + orderData.amount, + requestData, + response, + TransactionStatus.PENDING, + ); + + return { + orderId: paymentOrder.id, + thirdPartyOrderId: response.prepay_id, + paymentParams, + status: PaymentOrderStatus.PENDING, + paymentMethod: this.paymentMethod, + createdAt: paymentOrder.createdAt, + }; + } catch (error) { + throw this.handleError(error, '创建支付订单'); + } + } + + /** + * 查询支付订单状态 + * 调用微信支付查询API获取订单最新状态 + * @param orderId 本地订单ID + * @param thirdPartyOrderId 第三方订单ID + * @returns 订单查询结果 + */ + async queryPaymentOrder( + orderId: string, + thirdPartyOrderId?: string, + ): Promise { + try { + // 1. 获取本地订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: orderId }, + }); + + if (!paymentOrder) { + throw new Error('订单不存在'); + } + + // 2. 构建查询URL + const queryId = thirdPartyOrderId || paymentOrder.thirdPartyOrderId; + if (!queryId) { + throw new Error('缺少第三方订单ID'); + } + + // 3. 调用微信查询API + const response = await this.callWechatAPI( + `/v3/pay/transactions/out-trade-no/${paymentOrder.orderNo}`, + 'GET', + ); + + // 4. 解析支付状态 + const status = this.parseWechatPaymentStatus(response.trade_state); + const paidAt = response.success_time + ? new Date(response.success_time) + : undefined; + + // 5. 更新本地订单状态 + if (status !== paymentOrder.status) { + await this.updatePaymentOrderStatus( + orderId, + status, + response.amount?.total, + paidAt, + ); + } + + return { + orderId, + thirdPartyOrderId: queryId, + status, + amount: paymentOrder.amount, + paidAmount: response.amount?.total, + paidAt, + thirdPartyTransactionId: response.transaction_id, + rawData: response, + }; + } catch (error) { + throw this.handleError(error, '查询支付订单'); + } + } + + /** + * 处理支付回调 + * 验证并处理微信支付结果通知 + * @param callbackData 回调数据 + * @returns 回调处理结果 + */ + async handlePaymentCallback( + callbackData: any, + ): Promise { + try { + // 1. 验证签名 + const signature = callbackData.signature; + if (!(await this.verifySignature(callbackData, signature))) { + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: '签名验证失败', + responseData: { code: 'FAIL', message: '签名验证失败' }, + }; + } + + // 2. 解析加密数据 + const decryptedData = this.decryptCallbackData(callbackData.resource); + const orderNo = decryptedData.out_trade_no; + + // 3. 查找本地订单 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { orderNo }, + }); + + if (!paymentOrder) { + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: '订单不存在', + responseData: { code: 'FAIL', message: '订单不存在' }, + }; + } + + // 4. 处理支付结果 + const status = this.parseWechatPaymentStatus(decryptedData.trade_state); + const paidAt = decryptedData.success_time + ? new Date(decryptedData.success_time) + : undefined; + + // 5. 更新订单状态 + await this.updatePaymentOrderStatus( + paymentOrder.id, + status, + decryptedData.amount?.total, + paidAt, + ); + + // 6. 记录回调日志 + await this.savePaymentTransaction( + paymentOrder.id, + TransactionType.CALLBACK, + decryptedData.amount?.total || 0, + callbackData, + decryptedData, + TransactionStatus.SUCCESS, + ); + + return { + success: true, + orderId: paymentOrder.id, + status, + paidAt, + thirdPartyTransactionId: decryptedData.transaction_id, + responseData: { code: 'SUCCESS', message: '成功' }, + }; + } catch (error) { + this.logger.error('处理微信支付回调失败:', error); + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: error.message, + responseData: { code: 'FAIL', message: error.message }, + }; + } + } + + /** + * 申请退款 + * 调用微信申请退款API + * @param refundData 退款数据 + * @returns 退款结果 + */ + async refundPayment(refundData: RefundRequestData): Promise { + try { + // 1. 获取原订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: refundData.orderId }, + }); + + if (!paymentOrder) { + throw new Error('原订单不存在'); + } + + // 2. 验证订单状态 + const validation = this.validateOrderOperation(paymentOrder, 'refund'); + if (!validation.valid) { + throw new Error(validation.message); + } + + // 3. 保存退款记录 + const refundRecord = await this.saveRefundRecord( + refundData, + paymentOrder, + ); + + // 4. 构建退款请求参数 + const requestData = { + out_trade_no: paymentOrder.orderNo, + out_refund_no: refundRecord.refundNo, + reason: refundData.reason, + amount: { + refund: refundData.refundAmount, + total: paymentOrder.paidAmount || paymentOrder.amount, + currency: paymentOrder.currency, + }, + notify_url: `${this.configService.get('APP_URL')}/api/payment/wechat/refund-callback`, + }; + + // 5. 调用微信退款API + const response = await this.callWechatAPI( + '/v3/refund/domestic/refunds', + 'POST', + requestData, + ); + + // 6. 更新退款记录 + const status = this.parseWechatRefundStatus(response.status); + await this.updateRefundRecord( + refundRecord.id, + status, + response.refund_id, + response.success_time ? new Date(response.success_time) : undefined, + ); + + return { + refundId: refundRecord.id, + thirdPartyRefundId: response.refund_id, + status, + refundAmount: refundData.refundAmount, + createdAt: refundRecord.createdAt, + estimatedArrivalTime: this.calculateRefundArrivalTime(), + }; + } catch (error) { + throw this.handleError(error, '申请退款'); + } + } + + /** + * 查询退款状态 + * 调用微信查询退款API + * @param refundId 退款ID + * @returns 退款查询结果 + */ + async queryRefundStatus(refundId: string): Promise { + try { + // 1. 获取退款记录 + const refundRecord = await this.refundRecordRepository.findOne({ + where: { id: refundId }, + }); + + if (!refundRecord) { + throw new Error('退款记录不存在'); + } + + // 2. 调用微信查询退款API + const response = await this.callWechatAPI( + `/v3/refund/domestic/refunds/${refundRecord.thirdPartyRefundId}`, + 'GET', + ); + + // 3. 解析退款状态 + const status = this.parseWechatRefundStatus(response.status); + const refundedAt = response.success_time + ? new Date(response.success_time) + : undefined; + + // 4. 更新退款记录 + if (status !== refundRecord.status) { + await this.updateRefundRecord( + refundRecord.id, + status, + undefined, + refundedAt, + ); + } + + return { + refundId, + thirdPartyRefundId: response.refund_id, + status: status, + refundAmount: refundRecord.refundAmount, + originalAmount: refundRecord.originalAmount, + reason: refundRecord.reason, + refundedAt, + arrivedAt: response.success_time + ? new Date(response.success_time) + : undefined, + failureReason: response.status === 'ABNORMAL' ? '退款异常' : undefined, + createdAt: refundRecord.createdAt, + updatedAt: refundRecord.updatedAt, + }; + } catch (error) { + throw this.handleError(error, '查询退款状态'); + } + } + + /** + * 验证支付回调签名 + * 使用微信提供的公钥验证签名 + * @param callbackData 回调数据 + * @param signature 签名 + * @returns 验证结果 + */ + async verifySignature( + callbackData: any, + signature: string, + ): Promise { + try { + // 构建验证字符串 + const timestamp = callbackData.timestamp; + const nonce = callbackData.nonce; + const body = JSON.stringify(callbackData.resource); + + const signatureStr = `${timestamp}\n${nonce}\n${body}\n`; + + // 这里需要使用微信提供的证书进行验签 + // 简化实现,实际项目中需要下载微信证书并验证 + return !!(signature && signature.length > 0); + } catch (error) { + this.logger.error('微信支付签名验证失败:', error); + return false; + } + } + + /** + * 下载对账单 + * 调用微信对账单下载API + * @param date 对账日期 + * @returns 对账单数据 + */ + async downloadBill(date: string): Promise { + try { + // 调用微信对账单API + const response = await this.callWechatAPI( + `/v3/bill/tradebill?bill_date=${date}&bill_type=ALL`, + 'GET', + ); + + // 下载对账单文件 + const billResponse = await firstValueFrom( + this.httpService.get(response.download_url), + ); + + return { + billDate: date, + totalCount: 0, // 需要解析对账单文件获取 + totalAmount: 0, // 需要解析对账单文件获取 + records: [], // 需要解析对账单文件获取 + rawData: billResponse.data, + }; + } catch (error) { + throw this.handleError(error, '下载对账单'); + } + } + + /** + * 构建统一下单请求参数 + * @param orderData 订单数据 + * @returns 请求参数 + */ + private buildUnifiedOrderRequest(orderData: CreatePaymentOrderData) { + return { + appid: this.appId, + mchid: this.mchId, + description: orderData.description, + out_trade_no: orderData.orderNo, + notify_url: orderData.notifyUrl, + amount: { + total: orderData.amount, + currency: orderData.currency || 'CNY', + }, + payer: { + openid: orderData.platformUserId, + }, + time_expire: orderData.expireMinutes + ? new Date( + Date.now() + orderData.expireMinutes * 60 * 1000, + ).toISOString() + : undefined, + attach: JSON.stringify({ + businessType: orderData.businessType, + businessId: orderData.businessId, + userId: orderData.userId, + }), + }; + } + + /** + * 生成小程序支付参数 + * @param prepayId 预支付ID + * @returns 支付参数 + */ + private generateMiniProgramPaymentParams(prepayId: string) { + const timeStamp = Math.floor(Date.now() / 1000).toString(); + const nonceStr = this.generateNonceStr(); + const packageStr = `prepay_id=${prepayId}`; + + // 生成签名 + const signStr = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`; + const paySign = this.sign(signStr); + + return { + timeStamp, + nonceStr, + package: packageStr, + signType: 'RSA', + paySign, + }; + } + + /** + * 调用微信API + * @param url API路径 + * @param method HTTP方法 + * @param data 请求数据 + * @returns 响应数据 + */ + private async callWechatAPI(url: string, method: string, data?: any) { + const fullUrl = `${this.apiBaseUrl}${url}`; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonceStr = this.generateNonceStr(); + + // 构建签名字符串 + const httpMethod = method.toUpperCase(); + const urlPath = url; + const requestBody = data ? JSON.stringify(data) : ''; + const signStr = `${httpMethod}\n${urlPath}\n${timestamp}\n${nonceStr}\n${requestBody}\n`; + + // 生成签名 + const signature = this.sign(signStr); + + // 构建Authorization头 + const authHeader = `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonceStr}",timestamp="${timestamp}",serial_no="${this.serialNumber}",signature="${signature}"`; + + const config = { + headers: { + Authorization: authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': `${this.mchId}`, + }, + }; + + const response = await firstValueFrom( + method === 'GET' + ? this.httpService.get(fullUrl, config) + : this.httpService.post(fullUrl, data, config), + ); + + return response.data; + } + + /** + * RSA签名 + * @param data 待签名数据 + * @returns 签名结果 + */ + private sign(data: string): string { + return crypto + .sign('RSA-SHA256', Buffer.from(data), this.privateKey) + .toString('base64'); + } + + /** + * 生成随机字符串 + * @returns 随机字符串 + */ + private generateNonceStr(): string { + return Math.random().toString(36).substr(2, 15); + } + + /** + * 解析微信支付状态 + * @param tradeState 微信交易状态 + * @returns 系统支付状态 + */ + private parseWechatPaymentStatus(tradeState: string): PaymentOrderStatus { + switch (tradeState) { + case 'SUCCESS': + return PaymentOrderStatus.PAID; + case 'REFUND': + return PaymentOrderStatus.REFUNDED; + case 'NOTPAY': + return PaymentOrderStatus.PENDING; + case 'CLOSED': + return PaymentOrderStatus.CANCELLED; + case 'REVOKED': + return PaymentOrderStatus.CANCELLED; + case 'USERPAYING': + return PaymentOrderStatus.PENDING; + case 'PAYERROR': + return PaymentOrderStatus.FAILED; + default: + return PaymentOrderStatus.FAILED; + } + } + + /** + * 解析微信退款状态 + * @param refundStatus 微信退款状态 + * @returns 系统退款状态 + */ + private parseWechatRefundStatus(refundStatus: string): RefundStatus { + switch (refundStatus) { + case 'SUCCESS': + return RefundStatus.SUCCESS; + case 'CLOSED': + return RefundStatus.FAILED; + case 'PROCESSING': + return RefundStatus.PROCESSING; + case 'ABNORMAL': + return RefundStatus.FAILED; + default: + return RefundStatus.PENDING; + } + } + + /** + * 解密回调数据 + * @param encryptedData 加密数据 + * @returns 解密后的数据 + */ + private decryptCallbackData(encryptedData: any): any { + try { + // 使用AES-256-GCM解密 + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.apiV3Key, + encryptedData.nonce, + ); + decipher.setAuthTag(Buffer.from(encryptedData.associated_data, 'base64')); + + let decrypted = decipher.update( + encryptedData.ciphertext, + 'base64', + 'utf8', + ); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); + } catch (error) { + this.logger.error('解密回调数据失败:', error); + throw new Error('回调数据解密失败'); + } + } + + /** + * 计算退款到账时间 + * @returns 预计到账时间 + */ + private calculateRefundArrivalTime(): Date { + // 微信退款一般1-3个工作日到账 + const arrivalTime = new Date(); + arrivalTime.setDate(arrivalTime.getDate() + 3); + return arrivalTime; + } +} diff --git a/src/payment/controllers/index.ts b/src/payment/controllers/index.ts new file mode 100644 index 0000000..2e8a22d --- /dev/null +++ b/src/payment/controllers/index.ts @@ -0,0 +1 @@ +export { PaymentController } from './payment.controller'; diff --git a/src/payment/controllers/payment.controller.ts b/src/payment/controllers/payment.controller.ts new file mode 100644 index 0000000..1d3f274 --- /dev/null +++ b/src/payment/controllers/payment.controller.ts @@ -0,0 +1,495 @@ +import { + Controller, + Post, + Get, + Body, + Param, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, + BadRequestException, + Logger, + Headers, + Ip, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiBody, +} from '@nestjs/swagger'; +import { PlatformAuthGuard } from '../../platform/guards/platform-auth.guard'; +import { CurrentUser } from '../../decorators/current-user.decorator'; +import { ApiCommonResponses } from '../../decorators/api-common-responses.decorator'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { PaymentBusinessType } from '../interfaces/payment.interface'; +import { UnifiedPaymentService } from '../services/unified-payment.service'; +import { PaymentWebhookService } from '../services/payment-webhook.service'; +import { + CreatePaymentOrderDto, + PaymentOrderResponseDto, + PaymentOrderQueryResponseDto, + RefundRequestDto, + RefundResponseDto, + RefundQueryResponseDto, +} from '../dto'; + +/** + * 支付控制器 + * 提供统一的支付API接口 + * 支持创建订单、查询状态、申请退款等功能 + * 处理多平台支付回调通知 + */ +@ApiTags('Payment - 支付管理') +@Controller('payment') +@ApiCommonResponses() +export class PaymentController { + private readonly logger = new Logger(PaymentController.name); + + constructor( + private readonly unifiedPaymentService: UnifiedPaymentService, + private readonly paymentWebhookService: PaymentWebhookService, + ) {} + + /** + * 创建支付订单 + * 根据业务类型和支付平台创建支付订单 + */ + @Post('orders') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '创建支付订单', + description: '根据业务类型(订阅、积分购买、模板解锁等)创建支付订单', + }) + @ApiResponse({ + status: 201, + description: '支付订单创建成功', + type: PaymentOrderResponseDto, + }) + @ApiBody({ type: CreatePaymentOrderDto }) + async createPaymentOrder( + @Body() createOrderDto: CreatePaymentOrderDto, + @CurrentUser() userInfo: any, + ): Promise { + try { + this.logger.log( + `创建支付订单: 用户=${userInfo.userId}, 平台=${createOrderDto.platform}`, + ); + + const result = await this.unifiedPaymentService.createPaymentOrder( + createOrderDto.platform, + userInfo.userId, + createOrderDto.businessType, + createOrderDto.businessId, + createOrderDto.platformUserId, + ); + + return { + orderId: result.orderId, + orderNo: result.orderId, // 简化处理,实际应该返回订单号 + thirdPartyOrderId: result.thirdPartyOrderId, + paymentMethod: result.paymentMethod, + status: result.status, + amount: createOrderDto.amount, + currency: createOrderDto.currency || 'CNY', + description: createOrderDto.description, + paymentParams: result.paymentParams, + expiredAt: result.createdAt, // 需要计算过期时间 + createdAt: result.createdAt, + }; + } catch (error) { + this.logger.error(`创建支付订单失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 查询支付订单状态 + * 获取支付订单的最新状态信息 + */ + @Get('orders/:orderId') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '查询支付订单状态', + description: '根据订单ID查询支付订单的详细信息和最新状态', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: PaymentOrderQueryResponseDto, + }) + async queryPaymentOrder( + @Param('orderId') orderId: string, + @CurrentUser() userInfo: any, + ): Promise { + try { + const result = + await this.unifiedPaymentService.queryPaymentOrder(orderId); + + return { + orderId: result.orderId, + orderNo: result.orderId, // 简化处理 + thirdPartyOrderId: result.thirdPartyOrderId, + status: result.status, + amount: result.amount, + paidAmount: result.paidAmount, + paidAt: result.paidAt, + thirdPartyTransactionId: result.thirdPartyTransactionId, + createdAt: new Date(), // 需要从订单实体获取 + updatedAt: new Date(), // 需要从订单实体获取 + }; + } catch (error) { + this.logger.error(`查询支付订单失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 获取用户支付订单列表 + * 分页获取用户的支付订单历史 + */ + @Get('orders') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '获取用户支付订单列表', + description: '分页获取当前用户的所有支付订单', + }) + async getUserPaymentOrders( + @CurrentUser() userInfo: any, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20, + ) { + try { + const result = await this.unifiedPaymentService.getUserPaymentOrders( + userInfo.userId, + page, + limit, + ); + + return { + success: true, + data: result, + message: '获取订单列表成功', + }; + } catch (error) { + this.logger.error(`获取用户订单列表失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 申请退款 + * 对已支付订单申请退款 + */ + @Post('refunds') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '申请退款', + description: '对已支付的订单申请全额或部分退款', + }) + @ApiResponse({ + status: 201, + description: '退款申请成功', + type: RefundResponseDto, + }) + @ApiBody({ type: RefundRequestDto }) + async refundPayment( + @Body() refundDto: RefundRequestDto, + @CurrentUser() userInfo: any, + ): Promise { + try { + this.logger.log( + `申请退款: 用户=${userInfo.userId}, 订单=${refundDto.orderId}`, + ); + + const result = await this.unifiedPaymentService.refundPayment( + refundDto.orderId, + refundDto.refundAmount, + refundDto.reason, + ); + + return { + refundId: result.refundId, + refundNo: result.refundId, // 简化处理 + thirdPartyRefundId: result.thirdPartyRefundId, + status: result.status, + refundAmount: result.refundAmount, + reason: refundDto.reason, + estimatedArrivalTime: result.estimatedArrivalTime, + createdAt: result.createdAt, + }; + } catch (error) { + this.logger.error(`申请退款失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 查询退款状态 + * 获取退款申请的处理状态 + */ + @Get('refunds/:refundId') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '查询退款状态', + description: '根据退款ID查询退款的详细信息和处理状态', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: RefundQueryResponseDto, + }) + async queryRefundStatus( + @Param('refundId') refundId: string, + @CurrentUser() userInfo: any, + ): Promise { + try { + const result = + await this.unifiedPaymentService.queryRefundStatus(refundId); + + return { + refundId: result.refundId, + refundNo: result.refundId, // 简化处理 + thirdPartyRefundId: result.thirdPartyRefundId, + status: result.status, + refundAmount: result.refundAmount, + originalAmount: result.originalAmount, + reason: result.reason, + refundedAt: result.refundedAt, + arrivedAt: result.arrivedAt, + failureReason: result.failureReason, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + }; + } catch (error) { + this.logger.error(`查询退款状态失败: ${error.message}`, error.stack); + throw error; + } + } + + // ==================== 支付回调接口 ==================== + + /** + * 微信支付回调 + * 处理微信支付平台的异步通知 + */ + @Post('wechat/callback') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '微信支付回调', + description: '接收微信支付平台的支付结果通知', + tags: ['Internal'], + }) + async handleWechatPaymentCallback( + @Body() callbackData: any, + @Headers() headers: any, + @Ip() clientIp: string, + ) { + try { + this.logger.log(`收到微信支付回调: IP=${clientIp}`); + + // 验证回调来源IP(可选) + if ( + !this.paymentWebhookService.validateCallbackIP( + PlatformType.WECHAT, + clientIp, + ) + ) { + this.logger.warn(`微信支付回调IP验证失败: ${clientIp}`); + } + + const result = + await this.paymentWebhookService.handleWechatCallback(callbackData); + return result; + } catch (error) { + this.logger.error(`处理微信支付回调失败: ${error.message}`, error.stack); + return { code: 'FAIL', message: error.message }; + } + } + + /** + * 抖音支付回调 + * 处理抖音支付平台的异步通知 + */ + @Post('bytedance/callback') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '字节跳动支付回调', + description: '接收字节跳动支付平台的支付结果通知', + tags: ['Internal'], + }) + async handleBytedancePaymentCallback( + @Body() callbackData: any, + @Headers() headers: any, + @Ip() clientIp: string, + ) { + try { + this.logger.log(`收到字节跳动支付回调: IP=${clientIp}`); + + // 验证回调来源IP(可选) + if ( + !this.paymentWebhookService.validateCallbackIP( + PlatformType.BYTEDANCE, + clientIp, + ) + ) { + this.logger.warn(`字节跳动支付回调IP验证失败: ${clientIp}`); + } + + const result = + await this.paymentWebhookService.handleDouyinCallback(callbackData); + return result; + } catch (error) { + this.logger.error( + `处理字节跳动支付回调失败: ${error.message}`, + error.stack, + ); + return { err_no: 1, err_tips: error.message }; + } + } + + /** + * 微信退款回调 + * 处理微信退款结果通知 + */ + @Post('wechat/refund-callback') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '微信退款回调', + description: '接收微信支付平台的退款结果通知', + tags: ['Internal'], + }) + async handleWechatRefundCallback( + @Body() callbackData: any, + @Headers() headers: any, + @Ip() clientIp: string, + ) { + try { + this.logger.log(`收到微信退款回调: IP=${clientIp}`); + + const result = + await this.paymentWebhookService.handleWechatRefundCallback( + callbackData, + ); + return result; + } catch (error) { + this.logger.error(`处理微信退款回调失败: ${error.message}`, error.stack); + return { code: 'FAIL', message: error.message }; + } + } + + /** + * 抖音退款回调 + * 处理抖音退款结果通知 + */ + @Post('bytedance/refund-callback') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '字节跳动退款回调', + description: '接收字节跳动支付平台的退款结果通知', + tags: ['Internal'], + }) + async handleBytedanceRefundCallback( + @Body() callbackData: any, + @Headers() headers: any, + @Ip() clientIp: string, + ) { + try { + this.logger.log(`收到字节跳动退款回调: IP=${clientIp}`); + + const result = + await this.paymentWebhookService.handleDouyinRefundCallback( + callbackData, + ); + return result; + } catch (error) { + this.logger.error( + `处理字节跳动退款回调失败: ${error.message}`, + error.stack, + ); + return { err_no: 1, err_tips: error.message }; + } + } + + // ==================== 管理接口 ==================== + + /** + * 获取支付统计信息 + * 管理员查看支付系统统计数据 + */ + @Get('statistics') + @UseGuards(PlatformAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '获取支付统计信息', + description: '获取支付订单、回调处理等统计数据', + tags: ['Admin'], + }) + async getPaymentStatistics(@CurrentUser() userInfo: any) { + try { + // 这里可以添加管理员权限验证 + const statistics = + await this.paymentWebhookService.getCallbackStatistics(); + + return { + success: true, + data: statistics, + message: '获取统计信息成功', + }; + } catch (error) { + this.logger.error(`获取支付统计信息失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 健康检查接口 + * 检查支付系统各组件状态 + */ + @Get('health') + @ApiOperation({ + summary: '支付系统健康检查', + description: '检查支付适配器、数据库连接等状态', + tags: ['System'], + }) + async healthCheck() { + try { + // 检查支付适配器状态 + // 检查数据库连接 + // 检查第三方服务可用性 + + return { + success: true, + data: { + status: 'healthy', + timestamp: new Date().toISOString(), + services: { + wechatPay: 'ok', + bytedancePay: 'ok', + database: 'ok', + }, + }, + message: '支付系统运行正常', + }; + } catch (error) { + this.logger.error(`支付系统健康检查失败: ${error.message}`, error.stack); + return { + success: false, + data: { + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error.message, + }, + message: '支付系统异常', + }; + } + } +} diff --git a/src/payment/dto/create-payment-order.dto.ts b/src/payment/dto/create-payment-order.dto.ts new file mode 100644 index 0000000..73289ab --- /dev/null +++ b/src/payment/dto/create-payment-order.dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEnum, + IsString, + IsNumber, + IsOptional, + IsPositive, + Min, + Max, + Length, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { PaymentBusinessType } from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; + +/** + * 创建支付订单 DTO + * 定义前端创建支付订单的请求参数 + * 包含订单基本信息、金额、业务关联等 + */ +export class CreatePaymentOrderDto { + @ApiProperty({ + description: '支付平台', + enum: PlatformType, + example: PlatformType.WECHAT, + }) + @IsEnum(PlatformType, { message: '支付平台类型无效' }) + platform: PlatformType; + + @ApiProperty({ + description: '业务类型', + enum: PaymentBusinessType, + example: PaymentBusinessType.SUBSCRIPTION, + }) + @IsEnum(PaymentBusinessType, { message: '业务类型无效' }) + businessType: PaymentBusinessType; + + @ApiProperty({ + description: '业务关联ID(如订阅套餐ID、积分包ID等)', + example: 'sub_premium_monthly', + }) + @IsString({ message: '业务ID必须是字符串' }) + @Length(1, 100, { message: '业务ID长度必须在1-100字符之间' }) + businessId: string; + + @ApiProperty({ + description: '商品描述', + example: '高级会员月度订阅', + }) + @IsString({ message: '商品描述必须是字符串' }) + @Length(1, 200, { message: '商品描述长度必须在1-200字符之间' }) + description: string; + + @ApiProperty({ + description: '支付金额(分)', + example: 2980, + minimum: 1, + maximum: 100000000, + }) + @IsNumber({}, { message: '支付金额必须是数字' }) + @IsPositive({ message: '支付金额必须大于0' }) + @Min(1, { message: '支付金额不能少于1分' }) + @Max(100000000, { message: '支付金额不能超过1000000元' }) + @Type(() => Number) + amount: number; + + @ApiProperty({ + description: '货币类型', + example: 'CNY', + required: false, + default: 'CNY', + }) + @IsOptional() + @IsString({ message: '货币类型必须是字符串' }) + @Length(3, 3, { message: '货币类型必须是3位字符' }) + currency?: string = 'CNY'; + + @ApiProperty({ + description: '平台用户ID(openid)', + example: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M', + }) + @IsString({ message: '平台用户ID必须是字符串' }) + @Length(1, 100, { message: '平台用户ID长度必须在1-100字符之间' }) + platformUserId: string; + + @ApiProperty({ + description: '支付超时时间(分钟)', + example: 30, + required: false, + default: 30, + }) + @IsOptional() + @IsNumber({}, { message: '超时时间必须是数字' }) + @Min(5, { message: '超时时间不能少于5分钟' }) + @Max(1440, { message: '超时时间不能超过24小时' }) + @Type(() => Number) + expireMinutes?: number = 30; + + @ApiProperty({ + description: '扩展元数据', + example: { couponId: 'COUPON123', promotionId: 'PROMO456' }, + required: false, + }) + @IsOptional() + metadata?: any; +} diff --git a/src/payment/dto/index.ts b/src/payment/dto/index.ts new file mode 100644 index 0000000..60a74e6 --- /dev/null +++ b/src/payment/dto/index.ts @@ -0,0 +1,10 @@ +export { CreatePaymentOrderDto } from './create-payment-order.dto'; +export { + PaymentOrderResponseDto, + PaymentOrderQueryResponseDto, +} from './payment-order-response.dto'; +export { + RefundRequestDto, + RefundResponseDto, + RefundQueryResponseDto, +} from './refund-request.dto'; diff --git a/src/payment/dto/payment-order-response.dto.ts b/src/payment/dto/payment-order-response.dto.ts new file mode 100644 index 0000000..74a01bf --- /dev/null +++ b/src/payment/dto/payment-order-response.dto.ts @@ -0,0 +1,158 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + PaymentMethod, + PaymentOrderStatus, +} from '../interfaces/payment.interface'; + +/** + * 支付订单响应 DTO + * 定义创建支付订单后返回给前端的数据结构 + */ +export class PaymentOrderResponseDto { + @ApiProperty({ + description: '支付订单ID', + example: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + }) + orderId: string; + + @ApiProperty({ + description: '系统订单号', + example: 'WX1640995200123ABCDEF', + }) + orderNo: string; + + @ApiProperty({ + description: '第三方订单ID', + example: '20211231123456789012345678', + nullable: true, + }) + thirdPartyOrderId?: string; + + @ApiProperty({ + description: '支付方式', + enum: PaymentMethod, + example: PaymentMethod.WECHAT_MINIPROGRAM, + }) + paymentMethod: PaymentMethod; + + @ApiProperty({ + description: '订单状态', + enum: PaymentOrderStatus, + example: PaymentOrderStatus.PENDING, + }) + status: PaymentOrderStatus; + + @ApiProperty({ + description: '支付金额(分)', + example: 2980, + }) + amount: number; + + @ApiProperty({ + description: '货币类型', + example: 'CNY', + }) + currency: string; + + @ApiProperty({ + description: '商品描述', + example: '高级会员月度订阅', + }) + description: string; + + @ApiProperty({ + description: '支付参数(用于前端调起支付)', + example: { + timeStamp: '1640995200', + nonceStr: 'abc123', + package: 'prepay_id=wx123456789', + signType: 'RSA', + paySign: 'signature123', + }, + }) + paymentParams: any; + + @ApiProperty({ + description: '订单过期时间', + example: '2023-12-31T23:59:59.000Z', + nullable: true, + }) + expiredAt?: Date; + + @ApiProperty({ + description: '订单创建时间', + example: '2023-12-31T12:00:00.000Z', + }) + createdAt: Date; +} + +/** + * 支付订单查询响应 DTO + * 定义查询支付订单状态返回的数据结构 + */ +export class PaymentOrderQueryResponseDto { + @ApiProperty({ + description: '支付订单ID', + example: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + }) + orderId: string; + + @ApiProperty({ + description: '系统订单号', + example: 'WX1640995200123ABCDEF', + }) + orderNo: string; + + @ApiProperty({ + description: '第三方订单ID', + example: '20211231123456789012345678', + nullable: true, + }) + thirdPartyOrderId?: string; + + @ApiProperty({ + description: '订单状态', + enum: PaymentOrderStatus, + example: PaymentOrderStatus.PAID, + }) + status: PaymentOrderStatus; + + @ApiProperty({ + description: '订单金额(分)', + example: 2980, + }) + amount: number; + + @ApiProperty({ + description: '实际支付金额(分)', + example: 2980, + nullable: true, + }) + paidAmount?: number; + + @ApiProperty({ + description: '支付时间', + example: '2023-12-31T12:30:00.000Z', + nullable: true, + }) + paidAt?: Date; + + @ApiProperty({ + description: '第三方交易号', + example: '1217752501201407033233368018', + nullable: true, + }) + thirdPartyTransactionId?: string; + + @ApiProperty({ + description: '创建时间', + example: '2023-12-31T12:00:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2023-12-31T12:30:00.000Z', + }) + updatedAt: Date; +} diff --git a/src/payment/dto/refund-request.dto.ts b/src/payment/dto/refund-request.dto.ts new file mode 100644 index 0000000..dc507d2 --- /dev/null +++ b/src/payment/dto/refund-request.dto.ts @@ -0,0 +1,173 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNumber, IsPositive, Min, Length } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * 退款申请 DTO + * 定义退款申请的请求参数 + */ +export class RefundRequestDto { + @ApiProperty({ + description: '支付订单ID', + example: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + }) + @IsString({ message: '订单ID必须是字符串' }) + @Length(1, 36, { message: '订单ID长度必须在1-36字符之间' }) + orderId: string; + + @ApiProperty({ + description: '退款金额(分)', + example: 2980, + }) + @IsNumber({}, { message: '退款金额必须是数字' }) + @IsPositive({ message: '退款金额必须大于0' }) + @Min(1, { message: '退款金额不能少于1分' }) + @Type(() => Number) + refundAmount: number; + + @ApiProperty({ + description: '退款原因', + example: '用户申请退款', + }) + @IsString({ message: '退款原因必须是字符串' }) + @Length(1, 500, { message: '退款原因长度必须在1-500字符之间' }) + reason: string; +} + +/** + * 退款响应 DTO + * 定义退款申请处理结果的数据结构 + */ +export class RefundResponseDto { + @ApiProperty({ + description: '退款记录ID', + example: 'a47ac10b-58cc-4372-a567-0e02b2c3d479', + }) + refundId: string; + + @ApiProperty({ + description: '退款单号', + example: 'WXR1640995200123ABCDEF', + }) + refundNo: string; + + @ApiProperty({ + description: '第三方退款ID', + example: '1217752501201407033233368018', + nullable: true, + }) + thirdPartyRefundId?: string; + + @ApiProperty({ + description: '退款状态', + example: 'pending', + }) + status: string; + + @ApiProperty({ + description: '退款金额(分)', + example: 2980, + }) + refundAmount: number; + + @ApiProperty({ + description: '退款原因', + example: '用户申请退款', + }) + reason: string; + + @ApiProperty({ + description: '预计到账时间', + example: '2023-12-31T15:00:00.000Z', + nullable: true, + }) + estimatedArrivalTime?: Date; + + @ApiProperty({ + description: '申请时间', + example: '2023-12-31T12:30:00.000Z', + }) + createdAt: Date; +} + +/** + * 退款状态查询响应 DTO + * 定义查询退款状态返回的数据结构 + */ +export class RefundQueryResponseDto { + @ApiProperty({ + description: '退款记录ID', + example: 'a47ac10b-58cc-4372-a567-0e02b2c3d479', + }) + refundId: string; + + @ApiProperty({ + description: '退款单号', + example: 'WXR1640995200123ABCDEF', + }) + refundNo: string; + + @ApiProperty({ + description: '第三方退款ID', + example: '1217752501201407033233368018', + nullable: true, + }) + thirdPartyRefundId?: string; + + @ApiProperty({ + description: '退款状态', + example: 'success', + }) + status: string; + + @ApiProperty({ + description: '退款金额(分)', + example: 2980, + }) + refundAmount: number; + + @ApiProperty({ + description: '原订单金额(分)', + example: 2980, + }) + originalAmount: number; + + @ApiProperty({ + description: '退款原因', + example: '用户申请退款', + }) + reason: string; + + @ApiProperty({ + description: '退款时间', + example: '2023-12-31T12:45:00.000Z', + nullable: true, + }) + refundedAt?: Date; + + @ApiProperty({ + description: '到账时间', + example: '2023-12-31T13:00:00.000Z', + nullable: true, + }) + arrivedAt?: Date; + + @ApiProperty({ + description: '失败原因', + example: '银行卡信息错误', + nullable: true, + }) + failureReason?: string; + + @ApiProperty({ + description: '申请时间', + example: '2023-12-31T12:30:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2023-12-31T12:45:00.000Z', + }) + updatedAt: Date; +} diff --git a/src/payment/entities/index.ts b/src/payment/entities/index.ts new file mode 100644 index 0000000..e877148 --- /dev/null +++ b/src/payment/entities/index.ts @@ -0,0 +1,7 @@ +export { PaymentOrderEntity } from './payment-order.entity'; +export { + PaymentTransactionEntity, + TransactionType, + TransactionStatus, +} from './payment-transaction.entity'; +export { RefundRecordEntity } from './refund-record.entity'; diff --git a/src/payment/entities/payment-order.entity.ts b/src/payment/entities/payment-order.entity.ts new file mode 100644 index 0000000..de11757 --- /dev/null +++ b/src/payment/entities/payment-order.entity.ts @@ -0,0 +1,195 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, + Index, +} from 'typeorm'; +import { UserEntity } from '../../entities/user.entity'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { + PaymentMethod, + PaymentOrderStatus, + PaymentBusinessType, +} from '../interfaces/payment.interface'; +import { PaymentTransactionEntity } from './payment-transaction.entity'; +import { RefundRecordEntity } from './refund-record.entity'; + +/** + * 支付订单实体类 + * 记录用户的支付订单信息,支持多平台支付方式 + * 提供完整的订单生命周期管理和业务关联 + */ +@Entity('payment_orders') +@Index(['orderNo'], { unique: true }) +@Index(['userId', 'status']) +@Index(['platform', 'businessType']) +@Index(['createdAt']) +export class PaymentOrderEntity { + /** 主键 - 支付订单的唯一标识符 */ + @PrimaryGeneratedColumn('uuid') + id: string; + + /** 用户ID - 关联到用户实体 */ + @Column() + userId: string; + + /** 支付平台 - 支付发生的平台(微信、抖音) */ + @Column({ + type: 'enum', + enum: PlatformType, + enumName: 'platform_type', + }) + platform: PlatformType; + + /** 订单号 - 系统内部唯一订单编号 */ + @Column({ length: 64, unique: true }) + orderNo: string; + + /** 第三方订单ID - 支付平台返回的订单标识 */ + @Column({ length: 100, nullable: true }) + thirdPartyOrderId?: string; + + /** 支付方式 - 具体的支付类型(小程序支付等) */ + @Column({ + type: 'enum', + enum: PaymentMethod, + }) + paymentMethod: PaymentMethod; + + /** 订单状态 - 支付订单的当前状态 */ + @Column({ + type: 'enum', + enum: PaymentOrderStatus, + default: PaymentOrderStatus.PENDING, + }) + status: PaymentOrderStatus; + + /** 业务类型 - 标识支付的业务场景(会员订阅、积分购买等) */ + @Column({ + type: 'enum', + enum: PaymentBusinessType, + }) + businessType: PaymentBusinessType; + + /** 业务关联ID - 关联的具体业务记录ID */ + @Column({ length: 100 }) + businessId: string; + + /** 商品描述 - 用户可见的商品或服务描述 */ + @Column({ length: 200 }) + description: string; + + /** 订单金额 - 应付金额,单位为分 */ + @Column({ type: 'bigint' }) + amount: number; + + /** 实际支付金额 - 用户实际支付的金额,单位为分 */ + @Column({ type: 'bigint', nullable: true }) + paidAmount: number; + + /** 货币类型 - 支付货币单位,默认为人民币 */ + @Column({ length: 10, default: 'CNY' }) + currency: string; + + /** 平台用户ID - 支付平台的用户标识(openid) */ + @Column({ length: 100 }) + platformUserId: string; + + /** 支付参数 - 前端调起支付所需的参数,JSON格式存储 */ + @Column({ type: 'json', nullable: true }) + paymentParams: any; + + /** 支付时间 - 支付成功的时间戳 */ + @Column({ type: 'timestamp', nullable: true }) + paidAt: Date; + + /** 订单过期时间 - 支付订单的有效截止时间 */ + @Column({ type: 'timestamp', nullable: true }) + expiredAt: Date; + + /** 回调通知地址 - 支付结果回调的URL地址 */ + @Column({ length: 500, nullable: true }) + notifyUrl: string; + + /** 扩展元数据 - 存储订单的附加信息,如优惠券、活动信息等 */ + @Column({ type: 'json', nullable: true }) + metadata: any; + + /** 错误信息 - 支付失败时的错误描述 */ + @Column({ type: 'text', nullable: true }) + errorMessage: string; + + /** 创建时间 - 订单创建的时间戳 */ + @CreateDateColumn() + createdAt: Date; + + /** 更新时间 - 订单最后修改时间,用于状态变更追踪 */ + @UpdateDateColumn() + updatedAt: Date; + + /** 用户关系 - 与用户实体的多对一关系 */ + @ManyToOne(() => UserEntity, (user) => user.paymentOrders, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId' }) + user: UserEntity; + + /** 交易记录关系 - 与支付交易记录的一对多关系 */ + @OneToMany( + () => PaymentTransactionEntity, + (transaction) => transaction.paymentOrder, + ) + transactions: PaymentTransactionEntity[]; + + /** 退款记录关系 - 与退款记录的一对多关系 */ + @OneToMany(() => RefundRecordEntity, (refund) => refund.paymentOrder) + refunds: RefundRecordEntity[]; + + /** + * 检查订单是否已过期 + * @returns 是否过期 + */ + isExpired(): boolean { + if (!this.expiredAt) return false; + return new Date() > this.expiredAt; + } + + /** + * 检查订单是否可以支付 + * @returns 是否可支付 + */ + canPay(): boolean { + return this.status === PaymentOrderStatus.PENDING && !this.isExpired(); + } + + /** + * 检查订单是否可以退款 + * @returns 是否可退款 + */ + canRefund(): boolean { + return ( + this.status === PaymentOrderStatus.PAID || + this.status === PaymentOrderStatus.CONFIRMED + ); + } + + /** + * 获取可退款金额 + * @returns 可退款金额(分) + */ + getRefundableAmount(): number { + if (!this.canRefund()) return 0; + + const totalRefunded = + this.refunds + ?.filter((refund) => refund.status === 'success') + ?.reduce((sum, refund) => sum + refund.refundAmount, 0) || 0; + + return (this.paidAmount || this.amount) - totalRefunded; + } +} diff --git a/src/payment/entities/payment-transaction.entity.ts b/src/payment/entities/payment-transaction.entity.ts new file mode 100644 index 0000000..cf1e43a --- /dev/null +++ b/src/payment/entities/payment-transaction.entity.ts @@ -0,0 +1,221 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { PaymentOrderEntity } from './payment-order.entity'; + +/** + * 交易类型枚举 + * 定义不同的交易操作类型 + */ +export enum TransactionType { + PAYMENT = 'payment', // 支付交易 + REFUND = 'refund', // 退款交易 + CALLBACK = 'callback', // 回调通知 +} + +/** + * 交易状态枚举 + * 定义交易记录的处理状态 + */ +export enum TransactionStatus { + PENDING = 'pending', // 处理中 + SUCCESS = 'success', // 成功 + FAILED = 'failed', // 失败 +} + +/** + * 支付交易记录实体类 + * 记录与第三方支付平台的所有交易明细 + * 提供完整的支付流水追踪和对账支持 + * 包含支付、退款、回调等所有交易类型 + */ +@Entity('payment_transactions') +@Index(['paymentOrderId']) +@Index(['thirdPartyTransactionId']) +@Index(['transactionType', 'status']) +@Index(['createdAt']) +export class PaymentTransactionEntity { + /** 主键 - 交易记录的唯一标识符 */ + @PrimaryGeneratedColumn('uuid') + id: string; + + /** 支付订单ID - 关联到支付订单实体 */ + @Column() + paymentOrderId: string; + + /** 第三方交易号 - 支付平台返回的交易流水号 */ + @Column({ length: 100, nullable: true }) + thirdPartyTransactionId: string; + + /** 第三方订单ID - 支付平台的订单标识 */ + @Column({ length: 100, nullable: true }) + thirdPartyOrderId: string; + + /** 交易类型 - 标识交易的具体类型(支付、退款、回调) */ + @Column({ + type: 'enum', + enum: TransactionType, + }) + transactionType: TransactionType; + + /** 交易状态 - 交易的处理状态 */ + @Column({ + type: 'enum', + enum: TransactionStatus, + default: TransactionStatus.PENDING, + }) + status: TransactionStatus; + + /** 交易金额 - 本次交易涉及的金额,单位为分 */ + @Column({ type: 'bigint' }) + amount: number; + + /** 货币类型 - 交易的货币单位 */ + @Column({ length: 10, default: 'CNY' }) + currency: string; + + /** 交易时间 - 第三方平台的交易时间 */ + @Column({ type: 'timestamp', nullable: true }) + transactionTime: Date; + + /** 交易描述 - 交易的文字描述信息 */ + @Column({ length: 200, nullable: true }) + description: string; + + /** 请求数据 - 发送给第三方平台的请求参数,JSON格式 */ + @Column({ type: 'json', nullable: true }) + requestData: any; + + /** 响应数据 - 第三方平台返回的响应数据,JSON格式 */ + @Column({ type: 'json', nullable: true }) + responseData: any; + + /** 回调数据 - 第三方平台回调的原始数据,JSON格式 */ + @Column({ type: 'json', nullable: true }) + callbackData: any; + + /** 错误代码 - 交易失败时的错误代码 */ + @Column({ length: 50, nullable: true }) + errorCode: string; + + /** 错误信息 - 交易失败的详细错误描述 */ + @Column({ type: 'text', nullable: true }) + errorMessage: string; + + /** 处理次数 - 记录处理重试次数,用于异常分析 */ + @Column({ type: 'integer', default: 1 }) + processCount: number; + + /** 最后处理时间 - 记录最后一次处理的时间 */ + @Column({ type: 'timestamp', nullable: true }) + lastProcessedAt: Date; + + /** 扩展元数据 - 存储交易的附加信息和调试数据 */ + @Column({ type: 'json', nullable: true }) + metadata: any; + + /** 创建时间 - 交易记录创建的时间戳 */ + @CreateDateColumn() + createdAt: Date; + + /** 更新时间 - 记录最后修改时间 */ + @UpdateDateColumn() + updatedAt: Date; + + /** 支付订单关系 - 与支付订单的多对一关系 */ + @ManyToOne(() => PaymentOrderEntity, (order) => order.transactions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'paymentOrderId' }) + paymentOrder: PaymentOrderEntity; + + /** + * 检查交易是否成功 + * @returns 是否成功 + */ + isSuccess(): boolean { + return this.status === TransactionStatus.SUCCESS; + } + + /** + * 检查交易是否失败 + * @returns 是否失败 + */ + isFailed(): boolean { + return this.status === TransactionStatus.FAILED; + } + + /** + * 检查是否为支付交易 + * @returns 是否为支付交易 + */ + isPaymentTransaction(): boolean { + return this.transactionType === TransactionType.PAYMENT; + } + + /** + * 检查是否为退款交易 + * @returns 是否为退款交易 + */ + isRefundTransaction(): boolean { + return this.transactionType === TransactionType.REFUND; + } + + /** + * 检查是否为回调记录 + * @returns 是否为回调记录 + */ + isCallbackTransaction(): boolean { + return this.transactionType === TransactionType.CALLBACK; + } + + /** + * 增加处理次数并更新处理时间 + */ + incrementProcessCount(): void { + this.processCount += 1; + this.lastProcessedAt = new Date(); + } + + /** + * 标记交易为成功状态 + * @param transactionTime 交易时间 + * @param thirdPartyTransactionId 第三方交易号 + */ + markAsSuccess( + transactionTime?: Date, + thirdPartyTransactionId?: string, + ): void { + this.status = TransactionStatus.SUCCESS; + if (transactionTime) { + this.transactionTime = transactionTime; + } + if (thirdPartyTransactionId) { + this.thirdPartyTransactionId = thirdPartyTransactionId; + } + this.lastProcessedAt = new Date(); + } + + /** + * 标记交易为失败状态 + * @param errorCode 错误代码 + * @param errorMessage 错误信息 + */ + markAsFailed(errorCode?: string, errorMessage?: string): void { + this.status = TransactionStatus.FAILED; + if (errorCode) { + this.errorCode = errorCode; + } + if (errorMessage) { + this.errorMessage = errorMessage; + } + this.lastProcessedAt = new Date(); + } +} diff --git a/src/payment/entities/refund-record.entity.ts b/src/payment/entities/refund-record.entity.ts new file mode 100644 index 0000000..af60c72 --- /dev/null +++ b/src/payment/entities/refund-record.entity.ts @@ -0,0 +1,256 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { PaymentOrderEntity } from './payment-order.entity'; +import { RefundStatus } from '../interfaces/payment.interface'; + +/** + * 退款记录实体类 + * 记录支付订单的退款申请和处理结果 + * 支持部分退款和多次退款场景 + * 提供完整的退款流程追踪和状态管理 + */ +@Entity('refund_records') +@Index(['paymentOrderId']) +@Index(['refundNo'], { unique: true }) +@Index(['thirdPartyRefundId']) +@Index(['status']) +@Index(['createdAt']) +export class RefundRecordEntity { + /** 主键 - 退款记录的唯一标识符 */ + @PrimaryGeneratedColumn('uuid') + id: string; + + /** 支付订单ID - 关联到原支付订单 */ + @Column() + paymentOrderId: string; + + /** 退款单号 - 系统内部的退款单编号,全局唯一 */ + @Column({ length: 64, unique: true }) + refundNo: string; + + /** 第三方退款ID - 支付平台返回的退款标识 */ + @Column({ length: 100, nullable: true }) + thirdPartyRefundId: string; + + /** 第三方交易号 - 原支付交易的第三方交易号 */ + @Column({ length: 100, nullable: true }) + thirdPartyTransactionId: string; + + /** 退款状态 - 退款处理的当前状态 */ + @Column({ + type: 'enum', + enum: RefundStatus, + default: RefundStatus.PENDING, + }) + status: RefundStatus; + + /** 退款金额 - 申请退款的金额,单位为分 */ + @Column({ type: 'bigint' }) + refundAmount: number; + + /** 原订单金额 - 原支付订单的总金额,用于计算退款比例 */ + @Column({ type: 'bigint' }) + originalAmount: number; + + /** 货币类型 - 退款的货币单位 */ + @Column({ length: 10, default: 'CNY' }) + currency: string; + + /** 退款原因 - 用户或商家申请退款的原因说明 */ + @Column({ length: 500 }) + reason: string; + + /** 退款描述 - 退款的详细说明信息 */ + @Column({ type: 'text', nullable: true }) + description: string; + + /** 申请人ID - 发起退款申请的用户ID */ + @Column({ nullable: true }) + requestedBy: string; + + /** 审批人ID - 审批退款申请的管理员ID */ + @Column({ nullable: true }) + approvedBy: string; + + /** 退款时间 - 第三方平台确认退款的时间 */ + @Column({ type: 'timestamp', nullable: true }) + refundedAt: Date; + + /** 到账时间 - 退款资金实际到账的时间 */ + @Column({ type: 'timestamp', nullable: true }) + arrivedAt: Date; + + /** 预计到账时间 - 平台返回的预计到账时间 */ + @Column({ type: 'timestamp', nullable: true }) + estimatedArrivalTime: Date; + + /** 失败原因 - 退款失败时的详细原因说明 */ + @Column({ type: 'text', nullable: true }) + failureReason: string; + + /** 错误代码 - 退款失败的错误代码 */ + @Column({ length: 50, nullable: true }) + errorCode: string; + + /** 请求数据 - 发送给第三方的退款请求参数 */ + @Column({ type: 'json', nullable: true }) + requestData: any; + + /** 响应数据 - 第三方平台返回的退款响应数据 */ + @Column({ type: 'json', nullable: true }) + responseData: any; + + /** 回调数据 - 第三方平台的退款状态回调数据 */ + @Column({ type: 'json', nullable: true }) + callbackData: any; + + /** 处理次数 - 记录退款处理的重试次数 */ + @Column({ type: 'integer', default: 1 }) + processCount: number; + + /** 最后处理时间 - 最后一次处理退款的时间 */ + @Column({ type: 'timestamp', nullable: true }) + lastProcessedAt: Date; + + /** 扩展元数据 - 存储退款的附加信息 */ + @Column({ type: 'json', nullable: true }) + metadata: any; + + /** 创建时间 - 退款记录创建时间 */ + @CreateDateColumn() + createdAt: Date; + + /** 更新时间 - 记录最后修改时间 */ + @UpdateDateColumn() + updatedAt: Date; + + /** 支付订单关系 - 与支付订单的多对一关系 */ + @ManyToOne(() => PaymentOrderEntity, (order) => order.refunds, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'paymentOrderId' }) + paymentOrder: PaymentOrderEntity; + + /** + * 检查退款是否成功 + * @returns 是否成功 + */ + isSuccess(): boolean { + return this.status === RefundStatus.SUCCESS; + } + + /** + * 检查退款是否失败 + * @returns 是否失败 + */ + isFailed(): boolean { + return this.status === RefundStatus.FAILED; + } + + /** + * 检查退款是否处理中 + * @returns 是否处理中 + */ + isProcessing(): boolean { + return ( + this.status === RefundStatus.PENDING || + this.status === RefundStatus.PROCESSING + ); + } + + /** + * 检查是否为全额退款 + * @returns 是否为全额退款 + */ + isFullRefund(): boolean { + return this.refundAmount >= this.originalAmount; + } + + /** + * 获取退款比例 + * @returns 退款比例(0-1之间的小数) + */ + getRefundRatio(): number { + if (this.originalAmount <= 0) return 0; + return this.refundAmount / this.originalAmount; + } + + /** + * 计算退款处理耗时(分钟) + * @returns 处理耗时,未完成时返回null + */ + getProcessingDurationMinutes(): number | null { + if (!this.refundedAt) return null; + return Math.floor( + (this.refundedAt.getTime() - this.createdAt.getTime()) / (1000 * 60), + ); + } + + /** + * 增加处理次数并更新处理时间 + */ + incrementProcessCount(): void { + this.processCount += 1; + this.lastProcessedAt = new Date(); + } + + /** + * 标记退款为成功状态 + * @param refundedAt 退款时间 + * @param thirdPartyRefundId 第三方退款ID + * @param estimatedArrivalTime 预计到账时间 + */ + markAsSuccess( + refundedAt?: Date, + thirdPartyRefundId?: string, + estimatedArrivalTime?: Date, + ): void { + this.status = RefundStatus.SUCCESS; + if (refundedAt) { + this.refundedAt = refundedAt; + } + if (thirdPartyRefundId) { + this.thirdPartyRefundId = thirdPartyRefundId; + } + if (estimatedArrivalTime) { + this.estimatedArrivalTime = estimatedArrivalTime; + } + this.lastProcessedAt = new Date(); + } + + /** + * 标记退款为失败状态 + * @param errorCode 错误代码 + * @param failureReason 失败原因 + */ + markAsFailed(errorCode?: string, failureReason?: string): void { + this.status = RefundStatus.FAILED; + if (errorCode) { + this.errorCode = errorCode; + } + if (failureReason) { + this.failureReason = failureReason; + } + this.lastProcessedAt = new Date(); + } + + /** + * 标记退款为处理中状态 + * @param thirdPartyRefundId 第三方退款ID + */ + markAsProcessing(thirdPartyRefundId?: string): void { + this.status = RefundStatus.PROCESSING; + if (thirdPartyRefundId) { + this.thirdPartyRefundId = thirdPartyRefundId; + } + this.lastProcessedAt = new Date(); + } +} diff --git a/src/payment/index.ts b/src/payment/index.ts new file mode 100644 index 0000000..c572ff0 --- /dev/null +++ b/src/payment/index.ts @@ -0,0 +1,28 @@ +// 模块 +export { PaymentModule } from './payment.module'; + +// 接口 +export * from './interfaces/payment.interface'; + +// DTO +export * from './dto'; + +// 实体 +export * from './entities'; + +// 服务 +export { + PaymentAdapterFactory, + UnifiedPaymentService, + PaymentWebhookService, +} from './services'; + +// 适配器 +export { + BasePaymentAdapter, + WechatPaymentAdapter, + DouyinPaymentAdapter, +} from './adapters'; + +// 控制器 +export { PaymentController } from './controllers'; diff --git a/src/payment/interfaces/payment.interface.ts b/src/payment/interfaces/payment.interface.ts new file mode 100644 index 0000000..9268691 --- /dev/null +++ b/src/payment/interfaces/payment.interface.ts @@ -0,0 +1,361 @@ +import { PlatformType } from '../../entities/platform-user.entity'; + +/** + * 支付方式枚举 + * 定义支持的不同支付类型 + */ +export enum PaymentMethod { + WECHAT_MINIPROGRAM = 'wechat_miniprogram', // 微信小程序支付 + BYTEDANCE_MINIPROGRAM = 'bytedance_miniprogram', // 字节跳动小程序支付 +} + +/** + * 支付订单状态枚举 + * 定义支付订单的生命周期状态 + */ +export enum PaymentOrderStatus { + PENDING = 'pending', // 待支付 - 订单已创建,等待用户支付 + PAID = 'paid', // 已支付 - 支付成功,等待确认 + CONFIRMED = 'confirmed', // 已确认 - 支付确认完成,业务处理成功 + CANCELLED = 'cancelled', // 已取消 - 订单被取消 + FAILED = 'failed', // 支付失败 - 支付过程中发生错误 + REFUNDED = 'refunded', // 已退款 - 支付已退款 +} + +/** + * 支付业务类型枚举 + * 标识不同的支付业务场景 + */ +export enum PaymentBusinessType { + SUBSCRIPTION = 'subscription', // 会员订阅支付 + CREDIT_PURCHASE = 'credit_purchase', // 积分购买支付 + TEMPLATE_UNLOCK = 'template_unlock', // 模板解锁支付 +} + +/** + * 退款状态枚举 + * 定义退款处理的状态流转 + */ +export enum RefundStatus { + PENDING = 'pending', // 退款申请中 + PROCESSING = 'processing', // 退款处理中 + SUCCESS = 'success', // 退款成功 + FAILED = 'failed', // 退款失败 +} + +/** + * 支付适配器接口 + * 定义不同支付平台的统一接口规范 + */ +export interface IPaymentAdapter { + /** 支付平台类型 */ + platform: PlatformType; + + /** 支付方式 */ + paymentMethod: PaymentMethod; + + /** + * 创建支付订单 + * @param orderData 订单数据 + * @returns 支付订单结果 + */ + createPaymentOrder( + orderData: CreatePaymentOrderData, + ): Promise; + + /** + * 查询支付订单状态 + * @param orderId 本地订单ID + * @param thirdPartyOrderId 第三方订单ID + * @returns 订单状态信息 + */ + queryPaymentOrder( + orderId: string, + thirdPartyOrderId?: string, + ): Promise; + + /** + * 处理支付回调 + * @param callbackData 第三方支付平台回调数据 + * @returns 回调处理结果 + */ + handlePaymentCallback(callbackData: any): Promise; + + /** + * 申请退款 + * @param refundData 退款申请数据 + * @returns 退款申请结果 + */ + refundPayment(refundData: RefundRequestData): Promise; + + /** + * 查询退款状态 + * @param refundId 退款ID + * @returns 退款状态信息 + */ + queryRefundStatus(refundId: string): Promise; + + /** + * 验证支付回调签名 + * @param callbackData 回调数据 + * @param signature 签名 + * @returns 验证结果 + */ + verifySignature(callbackData: any, signature: string): Promise; + + /** + * 下载对账单 + * @param date 对账日期 + * @returns 对账单数据 + */ + downloadBill(date: string): Promise; +} + +/** + * 创建支付订单数据接口 + */ +export interface CreatePaymentOrderData { + /** 本地订单号 */ + orderNo: string; + + /** 支付金额(分) */ + amount: number; + + /** 货币类型 */ + currency: string; + + /** 商品描述 */ + description: string; + + /** 用户ID */ + userId: string; + + /** 平台用户ID (openid) */ + platformUserId: string; + + /** 业务类型 */ + businessType: PaymentBusinessType; + + /** 业务关联ID */ + businessId: string; + + /** 支付超时时间(分钟) */ + expireMinutes?: number; + + /** 附加数据 */ + metadata?: any; + + /** 回调地址 */ + notifyUrl: string; +} + +/** + * 支付订单创建结果 + */ +export interface PaymentOrderResult { + /** 本地订单ID */ + orderId: string; + + /** 第三方订单ID */ + thirdPartyOrderId: string; + + /** 支付参数(用于前端调用支付) */ + paymentParams: any; + + /** 支付跳转URL(如果需要) */ + paymentUrl?: string; + + /** 订单状态 */ + status: PaymentOrderStatus; + + /** 支付方式 */ + paymentMethod: PaymentMethod; + + /** 创建时间 */ + createdAt: Date; +} + +/** + * 支付订单查询结果 + */ +export interface PaymentOrderQueryResult { + /** 订单ID */ + orderId: string; + + /** 第三方订单ID */ + thirdPartyOrderId: string; + + /** 订单状态 */ + status: PaymentOrderStatus; + + /** 支付金额 */ + amount: number; + + /** 实际支付金额 */ + paidAmount?: number; + + /** 支付时间 */ + paidAt?: Date; + + /** 第三方交易号 */ + thirdPartyTransactionId?: string; + + /** 第三方原始数据 */ + rawData?: any; +} + +/** + * 支付回调处理结果 + */ +export interface PaymentCallbackResult { + /** 是否处理成功 */ + success: boolean; + + /** 订单ID */ + orderId: string; + + /** 订单状态 */ + status: PaymentOrderStatus; + + /** 支付时间 */ + paidAt?: Date; + + /** 第三方交易号 */ + thirdPartyTransactionId?: string; + + /** 错误信息 */ + errorMessage?: string; + + /** 需要返回给第三方的响应数据 */ + responseData?: any; +} + +/** + * 退款申请数据 + */ +export interface RefundRequestData { + /** 原订单ID */ + orderId: string; + + /** 退款金额(分) */ + refundAmount: number; + + /** 退款原因 */ + reason: string; + + /** 退款单号 */ + refundNo: string; + + /** 第三方订单ID */ + thirdPartyOrderId: string; + + /** 第三方交易号 */ + thirdPartyTransactionId: string; +} + +/** + * 退款申请结果 + */ +export interface RefundResult { + /** 退款ID */ + refundId: string; + + /** 第三方退款ID */ + thirdPartyRefundId: string; + + /** 退款状态 */ + status: RefundStatus; + + /** 退款金额 */ + refundAmount: number; + + /** 申请时间 */ + createdAt: Date; + + /** 预计到账时间 */ + estimatedArrivalTime?: Date; +} + +/** + * 退款状态查询结果 + */ +export interface RefundQueryResult { + /** 退款ID */ + refundId: string; + + /** 第三方退款ID */ + thirdPartyRefundId: string; + + /** 退款状态 */ + status: RefundStatus; + + /** 退款金额 */ + refundAmount: number; + + /** 原订单金额 */ + originalAmount: number; + + /** 退款原因 */ + reason: string; + + /** 退款时间 */ + refundedAt?: Date; + + /** 到账时间 */ + arrivedAt?: Date; + + /** 失败原因 */ + failureReason?: string; + + /** 创建时间 */ + createdAt: Date; + + /** 更新时间 */ + updatedAt: Date; +} + +/** + * 对账单数据 + */ +export interface BillData { + /** 对账日期 */ + billDate: string; + + /** 总交易笔数 */ + totalCount: number; + + /** 总交易金额 */ + totalAmount: number; + + /** 对账单记录 */ + records: BillRecord[]; + + /** 原始数据 */ + rawData?: string; +} + +/** + * 对账单记录 + */ +export interface BillRecord { + /** 第三方交易号 */ + thirdPartyTransactionId: string; + + /** 第三方订单号 */ + thirdPartyOrderId: string; + + /** 交易时间 */ + transactionTime: Date; + + /** 交易金额 */ + amount: number; + + /** 交易状态 */ + status: string; + + /** 交易类型 */ + type: string; + + /** 商品描述 */ + description?: string; +} diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts new file mode 100644 index 0000000..4feeed0 --- /dev/null +++ b/src/payment/payment.module.ts @@ -0,0 +1,140 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; + +// 实体 +import { PaymentOrderEntity } from './entities/payment-order.entity'; +import { PaymentTransactionEntity } from './entities/payment-transaction.entity'; +import { RefundRecordEntity } from './entities/refund-record.entity'; +import { UserEntity } from '../entities/user.entity'; +import { UserSubscriptionEntity } from '../entities/user-subscription.entity'; +import { UserCreditEntity } from '../entities/user-credit.entity'; + +// 适配器 +import { WechatPaymentAdapter, DouyinPaymentAdapter } from './adapters'; + +// 服务 +import { + PaymentAdapterFactory, + UnifiedPaymentService, + PaymentWebhookService, +} from './services'; + +// 控制器 +import { PaymentController } from './controllers'; + +/** + * 支付模块 + * 集成支付系统的所有核心功能和服务 + * 提供统一的支付接口和多平台支付适配 + * 支持订单管理、交易记录、退款处理等完整支付流程 + */ +@Module({ + imports: [ + // TypeORM 实体注册 + TypeOrmModule.forFeature([ + // 支付相关实体 + PaymentOrderEntity, + PaymentTransactionEntity, + RefundRecordEntity, + + // 业务关联实体 + UserEntity, + UserSubscriptionEntity, + UserCreditEntity, + ]), + + // HTTP 客户端模块 + HttpModule.register({ + timeout: 30000, // 30秒超时,适合支付接口调用 + maxRedirects: 3, + validateStatus: (status) => status < 500, // 接受所有非5xx错误的响应 + }), + + // JWT 模块配置(用于支付回调验证等) + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { expiresIn: '24h' }, + }), + }), + ], + + providers: [ + // 支付适配器 + WechatPaymentAdapter, + DouyinPaymentAdapter, + + // 核心服务 + PaymentAdapterFactory, + UnifiedPaymentService, + PaymentWebhookService, + + // 适配器注册 Provider + { + provide: 'PAYMENT_ADAPTERS_REGISTRATION', + useFactory: ( + factory: PaymentAdapterFactory, + wechatAdapter: WechatPaymentAdapter, + douyinAdapter: DouyinPaymentAdapter, + ) => { + // 注册微信支付适配器 + factory.registerAdapter( + wechatAdapter.platform, + wechatAdapter.paymentMethod, + wechatAdapter, + ); + + // 注册字节跳动支付适配器 + factory.registerAdapter( + douyinAdapter.platform, + douyinAdapter.paymentMethod, + douyinAdapter, + ); + + return factory; + }, + inject: [ + PaymentAdapterFactory, + WechatPaymentAdapter, + DouyinPaymentAdapter, + ], + }, + ], + + controllers: [PaymentController], + + exports: [ + // 导出核心服务供其他模块使用 + PaymentAdapterFactory, + UnifiedPaymentService, + PaymentWebhookService, + + // 导出 TypeORM Repository 供其他服务使用 + TypeOrmModule, + ], +}) +export class PaymentModule { + constructor(private readonly paymentAdapterFactory: PaymentAdapterFactory) { + // 模块初始化时记录适配器状态 + this.logModuleInitialization(); + } + + /** + * 记录模块初始化信息 + * 用于调试和监控模块启动状态 + */ + private logModuleInitialization(): void { + console.log('=== 支付模块初始化 ==='); + console.log('✓ 支付实体已注册'); + console.log('✓ HTTP 模块已配置'); + console.log('✓ JWT 模块已配置'); + console.log('✓ 支付适配器工厂已就绪'); + + // 输出适配器注册状态 + this.paymentAdapterFactory.logAdapterStatus(); + } +} diff --git a/src/payment/services/index.ts b/src/payment/services/index.ts new file mode 100644 index 0000000..977cfc3 --- /dev/null +++ b/src/payment/services/index.ts @@ -0,0 +1,3 @@ +export { PaymentAdapterFactory } from './payment-adapter.factory'; +export { UnifiedPaymentService } from './unified-payment.service'; +export { PaymentWebhookService } from './payment-webhook.service'; diff --git a/src/payment/services/payment-adapter.factory.ts b/src/payment/services/payment-adapter.factory.ts new file mode 100644 index 0000000..d644cef --- /dev/null +++ b/src/payment/services/payment-adapter.factory.ts @@ -0,0 +1,305 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + IPaymentAdapter, + PaymentMethod, +} from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; + +/** + * 支付适配器工厂类 + * 管理不同平台和支付方式的适配器实例 + * 提供统一的适配器获取接口和动态注册功能 + * 支持运行时的适配器扩展和管理 + */ +@Injectable() +export class PaymentAdapterFactory { + private readonly logger = new Logger(PaymentAdapterFactory.name); + + /** + * 适配器映射表 + * 使用复合键 "platform:paymentMethod" 作为索引 + * 存储各种支付平台和支付方式的适配器实例 + */ + private readonly adapters = new Map(); + + /** + * 注册支付适配器 + * 将适配器实例注册到工厂中,供后续获取使用 + * @param platform 支付平台 + * @param paymentMethod 支付方式 + * @param adapter 适配器实例 + */ + registerAdapter( + platform: PlatformType, + paymentMethod: PaymentMethod, + adapter: IPaymentAdapter, + ): void { + const key = this.getAdapterKey(platform, paymentMethod); + + if (this.adapters.has(key)) { + this.logger.warn(`适配器已存在,将被覆盖: ${key}`); + } + + this.adapters.set(key, adapter); + this.logger.log(`支付适配器注册成功: ${key}`); + } + + /** + * 获取支付适配器 + * 根据平台和支付方式获取对应的适配器实例 + * @param platform 支付平台 + * @param paymentMethod 支付方式 + * @returns 适配器实例 + * @throws 如果适配器不存在则抛出错误 + */ + getAdapter( + platform: PlatformType, + paymentMethod: PaymentMethod, + ): IPaymentAdapter { + const key = this.getAdapterKey(platform, paymentMethod); + const adapter = this.adapters.get(key); + + if (!adapter) { + const supportedAdapters = Array.from(this.adapters.keys()); + throw new Error( + `不支持的支付方式: ${key}。支持的适配器: [${supportedAdapters.join(', ')}]`, + ); + } + + return adapter; + } + + /** + * 检查适配器是否存在 + * @param platform 支付平台 + * @param paymentMethod 支付方式 + * @returns 是否存在 + */ + hasAdapter(platform: PlatformType, paymentMethod: PaymentMethod): boolean { + const key = this.getAdapterKey(platform, paymentMethod); + return this.adapters.has(key); + } + + /** + * 获取所有已注册的适配器 + * @returns 适配器映射表 + */ + getAllAdapters(): Map { + return new Map(this.adapters); + } + + /** + * 获取指定平台的所有适配器 + * @param platform 支付平台 + * @returns 适配器列表 + */ + getAdaptersByPlatform(platform: PlatformType): IPaymentAdapter[] { + const platformPrefix = `${platform}:`; + const adapters: IPaymentAdapter[] = []; + + for (const [key, adapter] of this.adapters) { + if (key.startsWith(platformPrefix)) { + adapters.push(adapter); + } + } + + return adapters; + } + + /** + * 获取指定支付方式的所有适配器 + * @param paymentMethod 支付方式 + * @returns 适配器列表 + */ + getAdaptersByPaymentMethod(paymentMethod: PaymentMethod): IPaymentAdapter[] { + const methodSuffix = `:${paymentMethod}`; + const adapters: IPaymentAdapter[] = []; + + for (const [key, adapter] of this.adapters) { + if (key.endsWith(methodSuffix)) { + adapters.push(adapter); + } + } + + return adapters; + } + + /** + * 移除适配器 + * @param platform 支付平台 + * @param paymentMethod 支付方式 + * @returns 是否移除成功 + */ + removeAdapter(platform: PlatformType, paymentMethod: PaymentMethod): boolean { + const key = this.getAdapterKey(platform, paymentMethod); + const removed = this.adapters.delete(key); + + if (removed) { + this.logger.log(`支付适配器已移除: ${key}`); + } else { + this.logger.warn(`尝试移除不存在的适配器: ${key}`); + } + + return removed; + } + + /** + * 清空所有适配器 + */ + clearAllAdapters(): void { + const count = this.adapters.size; + this.adapters.clear(); + this.logger.log(`已清空所有支付适配器,共 ${count} 个`); + } + + /** + * 获取支持的平台列表 + * @returns 平台类型数组 + */ + getSupportedPlatforms(): PlatformType[] { + const platforms = new Set(); + + for (const [key] of this.adapters) { + const [platform] = key.split(':'); + platforms.add(platform as PlatformType); + } + + return Array.from(platforms); + } + + /** + * 获取支持的支付方式列表 + * @returns 支付方式数组 + */ + getSupportedPaymentMethods(): PaymentMethod[] { + const methods = new Set(); + + for (const [key] of this.adapters) { + const [, method] = key.split(':'); + methods.add(method as PaymentMethod); + } + + return Array.from(methods); + } + + /** + * 获取适配器统计信息 + * @returns 统计信息对象 + */ + getStatistics(): { + totalAdapters: number; + platformCount: number; + methodCount: number; + adapterDetails: Array<{ + platform: PlatformType; + paymentMethod: PaymentMethod; + adapterName: string; + }>; + } { + const platformCount = this.getSupportedPlatforms().length; + const methodCount = this.getSupportedPaymentMethods().length; + + const adapterDetails = Array.from(this.adapters.entries()).map( + ([key, adapter]) => { + const [platform, paymentMethod] = key.split(':'); + return { + platform: platform as PlatformType, + paymentMethod: paymentMethod as PaymentMethod, + adapterName: adapter.constructor.name, + }; + }, + ); + + return { + totalAdapters: this.adapters.size, + platformCount, + methodCount, + adapterDetails, + }; + } + + /** + * 验证适配器配置 + * 检查已注册的适配器是否符合接口规范 + * @returns 验证结果 + */ + validateAdapters(): { + valid: boolean; + issues: string[]; + } { + const issues: string[] = []; + + for (const [key, adapter] of this.adapters) { + const [platformStr, methodStr] = key.split(':'); + + // 验证适配器的平台和方法是否匹配 + if (adapter.platform !== platformStr) { + issues.push( + `适配器 ${key} 的平台不匹配: 期望 ${platformStr}, 实际 ${adapter.platform}`, + ); + } + + if (adapter.paymentMethod !== methodStr) { + issues.push( + `适配器 ${key} 的支付方式不匹配: 期望 ${methodStr}, 实际 ${adapter.paymentMethod}`, + ); + } + + // 验证必要方法是否存在 + const requiredMethods = [ + 'createPaymentOrder', + 'queryPaymentOrder', + 'handlePaymentCallback', + 'refundPayment', + 'queryRefundStatus', + 'verifySignature', + 'downloadBill', + ]; + + for (const method of requiredMethods) { + if (typeof (adapter as any)[method] !== 'function') { + issues.push(`适配器 ${key} 缺少必要方法: ${method}`); + } + } + } + + return { + valid: issues.length === 0, + issues, + }; + } + + /** + * 生成适配器键 + * @param platform 支付平台 + * @param paymentMethod 支付方式 + * @returns 适配器键 + */ + private getAdapterKey( + platform: PlatformType, + paymentMethod: PaymentMethod, + ): string { + return `${platform}:${paymentMethod}`; + } + + /** + * 记录适配器注册状态(用于调试) + */ + logAdapterStatus(): void { + this.logger.log('=== 支付适配器注册状态 ==='); + + if (this.adapters.size === 0) { + this.logger.log('未注册任何支付适配器'); + return; + } + + for (const [key, adapter] of this.adapters) { + this.logger.log(`${key} -> ${adapter.constructor.name}`); + } + + const stats = this.getStatistics(); + this.logger.log( + `总计: ${stats.totalAdapters} 个适配器, ${stats.platformCount} 个平台, ${stats.methodCount} 种支付方式`, + ); + } +} diff --git a/src/payment/services/payment-webhook.service.ts b/src/payment/services/payment-webhook.service.ts new file mode 100644 index 0000000..00762ec --- /dev/null +++ b/src/payment/services/payment-webhook.service.ts @@ -0,0 +1,403 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { PaymentCallbackResult } from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { PaymentOrderEntity, PaymentTransactionEntity } from '../entities'; +import { UnifiedPaymentService } from './unified-payment.service'; + +/** + * 支付回调处理服务 + * 专门处理各支付平台的异步通知 + * 提供幂等性保证和重试机制 + * 确保回调处理的可靠性和一致性 + */ +@Injectable() +export class PaymentWebhookService { + private readonly logger = new Logger(PaymentWebhookService.name); + + constructor( + private readonly unifiedPaymentService: UnifiedPaymentService, + private readonly configService: ConfigService, + @InjectRepository(PaymentOrderEntity) + private readonly paymentOrderRepository: Repository, + @InjectRepository(PaymentTransactionEntity) + private readonly transactionRepository: Repository, + ) {} + + /** + * 处理微信支付回调 + * @param callbackData 微信支付回调数据 + * @returns 处理结果 + */ + async handleWechatCallback( + callbackData: any, + ): Promise<{ code: string; message: string }> { + try { + this.logger.log('收到微信支付回调通知'); + + // 1. 处理回调 + const result = await this.unifiedPaymentService.handlePaymentCallback( + PlatformType.WECHAT, + callbackData, + ); + + // 2. 记录回调处理结果 + await this.recordCallbackResult( + result, + PlatformType.WECHAT, + callbackData, + ); + + // 3. 返回微信期望的响应格式 + if (result.success) { + return { code: 'SUCCESS', message: '成功' }; + } else { + return { code: 'FAIL', message: result.errorMessage || '处理失败' }; + } + } catch (error) { + this.logger.error('处理微信支付回调失败:', error); + return { code: 'FAIL', message: error.message }; + } + } + + /** + * 处理抖音支付回调 + * @param callbackData 抖音支付回调数据 + * @returns 处理结果 + */ + async handleDouyinCallback( + callbackData: any, + ): Promise<{ err_no: number; err_tips: string }> { + try { + this.logger.log('收到抖音支付回调通知'); + + // 1. 处理回调 + const result = await this.unifiedPaymentService.handlePaymentCallback( + PlatformType.BYTEDANCE, + callbackData, + ); + + // 2. 记录回调处理结果 + await this.recordCallbackResult( + result, + PlatformType.BYTEDANCE, + callbackData, + ); + + // 3. 返回抖音期望的响应格式 + if (result.success) { + return { err_no: 0, err_tips: 'success' }; + } else { + return { err_no: 1, err_tips: result.errorMessage || '处理失败' }; + } + } catch (error) { + this.logger.error('处理抖音支付回调失败:', error); + return { err_no: 1, err_tips: error.message }; + } + } + + /** + * 处理微信退款回调 + * @param callbackData 微信退款回调数据 + * @returns 处理结果 + */ + async handleWechatRefundCallback( + callbackData: any, + ): Promise<{ code: string; message: string }> { + try { + this.logger.log('收到微信退款回调通知'); + + // 处理退款回调逻辑 + const success = await this.processRefundCallback( + PlatformType.WECHAT, + callbackData, + ); + + if (success) { + return { code: 'SUCCESS', message: '成功' }; + } else { + return { code: 'FAIL', message: '处理失败' }; + } + } catch (error) { + this.logger.error('处理微信退款回调失败:', error); + return { code: 'FAIL', message: error.message }; + } + } + + /** + * 处理抖音退款回调 + * @param callbackData 抖音退款回调数据 + * @returns 处理结果 + */ + async handleDouyinRefundCallback( + callbackData: any, + ): Promise<{ err_no: number; err_tips: string }> { + try { + this.logger.log('收到抖音退款回调通知'); + + // 处理退款回调逻辑 + const success = await this.processRefundCallback( + PlatformType.BYTEDANCE, + callbackData, + ); + + if (success) { + return { err_no: 0, err_tips: 'success' }; + } else { + return { err_no: 1, err_tips: '处理失败' }; + } + } catch (error) { + this.logger.error('处理抖音退款回调失败:', error); + return { err_no: 1, err_tips: error.message }; + } + } + + /** + * 验证回调来源IP + * @param platform 支付平台 + * @param clientIp 客户端IP + * @returns 是否验证通过 + */ + validateCallbackIP(platform: PlatformType, clientIp: string): boolean { + // 获取支付平台的合法IP范围 + const allowedIPs = this.getAllowedIPs(platform); + + // 检查IP是否在允许范围内 + return allowedIPs.some((allowedIP) => + this.isIPInRange(clientIp, allowedIP), + ); + } + + /** + * 检查回调幂等性 + * @param platform 支付平台 + * @param uniqueId 唯一标识符 + * @returns 是否已处理过 + */ + async checkCallbackIdempotency( + platform: PlatformType, + uniqueId: string, + ): Promise { + // 检查是否已处理过相同的回调 + const existingTransaction = await this.transactionRepository.findOne({ + where: { + thirdPartyTransactionId: uniqueId, + transactionType: 'callback' as any, + }, + }); + + return existingTransaction !== null; + } + + /** + * 处理回调重试逻辑 + * @param platform 支付平台 + * @param callbackData 回调数据 + * @param retryCount 重试次数 + * @returns 处理结果 + */ + async handleCallbackWithRetry( + platform: PlatformType, + callbackData: any, + retryCount: number = 0, + ): Promise { + const maxRetries = 3; + + try { + // 尝试处理回调 + const result = await this.unifiedPaymentService.handlePaymentCallback( + platform, + callbackData, + ); + + if (result.success) { + return result; + } + + // 如果失败且未达到最大重试次数,进行重试 + if (retryCount < maxRetries) { + this.logger.warn(`回调处理失败,进行第${retryCount + 1}次重试`); + await this.delay(Math.pow(2, retryCount) * 1000); // 指数退避 + return await this.handleCallbackWithRetry( + platform, + callbackData, + retryCount + 1, + ); + } + + return result; + } catch (error) { + this.logger.error(`回调处理异常(重试${retryCount}次):`, error); + + // 如果失败且未达到最大重试次数,进行重试 + if (retryCount < maxRetries) { + await this.delay(Math.pow(2, retryCount) * 1000); + return await this.handleCallbackWithRetry( + platform, + callbackData, + retryCount + 1, + ); + } + + throw error; + } + } + + /** + * 记录回调处理结果 + * @param result 处理结果 + * @param platform 支付平台 + * @param callbackData 回调数据 + */ + private async recordCallbackResult( + result: PaymentCallbackResult, + platform: PlatformType, + callbackData: any, + ): Promise { + try { + // 记录回调处理的详细信息,用于后续分析和故障排查 + this.logger.log( + `回调处理结果记录: 平台=${platform}, 成功=${result.success}, 订单=${result.orderId}`, + ); + + // 可以在这里添加更详细的日志记录逻辑 + if (!result.success) { + this.logger.error(`回调处理失败详情:`, { + platform, + orderId: result.orderId, + errorMessage: result.errorMessage, + callbackData: JSON.stringify(callbackData), + }); + } + } catch (error) { + this.logger.error('记录回调处理结果失败:', error); + } + } + + /** + * 处理退款回调 + * @param platform 支付平台 + * @param callbackData 回调数据 + * @returns 处理是否成功 + */ + private async processRefundCallback( + platform: PlatformType, + callbackData: any, + ): Promise { + try { + // 这里处理退款回调的具体逻辑 + // 根据不同平台的回调格式解析退款信息 + // 更新退款记录状态 + // 触发相关业务逻辑(如恢复积分等) + + this.logger.log( + `处理${platform}退款回调: ${JSON.stringify(callbackData)}`, + ); + return true; + } catch (error) { + this.logger.error('处理退款回调失败:', error); + return false; + } + } + + /** + * 获取支付平台允许的IP范围 + * @param platform 支付平台 + * @returns IP范围列表 + */ + private getAllowedIPs(platform: PlatformType): string[] { + // 微信支付回调IP范围 + const wechatIPs = [ + '101.226.103.0/24', + '101.226.62.0/24', + '140.207.54.0/24', + // 更多微信支付服务器IP + ]; + + // 抖音支付回调IP范围 + const douyinIPs = [ + '118.89.204.198', + '118.89.204.196', + '118.89.204.194', + // 更多抖音支付服务器IP + ]; + + switch (platform) { + case PlatformType.WECHAT: + return wechatIPs; + case PlatformType.BYTEDANCE: + return douyinIPs; + default: + return []; + } + } + + /** + * 检查IP是否在指定范围内 + * @param ip 待检查的IP + * @param range IP范围(支持CIDR格式) + * @returns 是否在范围内 + */ + private isIPInRange(ip: string, range: string): boolean { + // 简化实现,实际项目中需要更精确的IP范围检查 + if (range.includes('/')) { + // CIDR格式的IP范围检查 + const [network, prefixLength] = range.split('/'); + // 这里需要实现CIDR匹配逻辑 + return ip.startsWith(network.split('.').slice(0, 2).join('.')); + } else { + // 精确IP匹配 + return ip === range; + } + } + + /** + * 延迟执行 + * @param ms 延迟毫秒数 + */ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * 获取回调处理统计信息 + * @returns 统计信息 + */ + async getCallbackStatistics(): Promise<{ + totalCallbacks: number; + successCallbacks: number; + failedCallbacks: number; + platforms: Record; + }> { + // 从数据库统计回调处理情况 + const statistics = { + totalCallbacks: 0, + successCallbacks: 0, + failedCallbacks: 0, + platforms: {} as Record, + }; + + try { + // 这里可以添加具体的统计查询逻辑 + const transactions = await this.transactionRepository.find({ + where: { transactionType: 'callback' as any }, + select: ['status', 'responseData'], + }); + + statistics.totalCallbacks = transactions.length; + statistics.successCallbacks = transactions.filter( + (t) => t.status === 'success', + ).length; + statistics.failedCallbacks = transactions.filter( + (t) => t.status === 'failed', + ).length; + + return statistics; + } catch (error) { + this.logger.error('获取回调统计信息失败:', error); + return statistics; + } + } +} diff --git a/src/payment/services/unified-payment.service.ts b/src/payment/services/unified-payment.service.ts new file mode 100644 index 0000000..df41e59 --- /dev/null +++ b/src/payment/services/unified-payment.service.ts @@ -0,0 +1,761 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { + CreatePaymentOrderData, + PaymentOrderResult, + PaymentOrderQueryResult, + PaymentCallbackResult, + RefundRequestData, + RefundResult, + RefundQueryResult, + PaymentMethod, + PaymentBusinessType, + PaymentOrderStatus, +} from '../interfaces/payment.interface'; +import { PlatformType } from '../../entities/platform-user.entity'; +import { UserEntity } from '../../entities/user.entity'; +import { + UserSubscriptionEntity, + SubscriptionPlan, + SubscriptionStatus, +} from '../../entities/user-subscription.entity'; +import { + UserCreditEntity, + CreditType, + CreditSource, +} from '../../entities/user-credit.entity'; +import { + PaymentOrderEntity, + PaymentTransactionEntity, + RefundRecordEntity, +} from '../entities'; +import { PaymentAdapterFactory } from './payment-adapter.factory'; + +/** + * 业务配置接口 + * 定义不同业务类型的配置信息 + */ +interface BusinessConfig { + /** 业务名称 */ + name: string; + /** 单价(分) */ + unitPrice: number; + /** 描述 */ + description: string; + /** 是否启用 */ + enabled: boolean; +} + +/** + * 订阅套餐配置 + */ +interface SubscriptionConfig extends BusinessConfig { + /** 套餐类型 */ + plan: SubscriptionPlan; + /** 有效期(天) */ + validDays: number; + /** 每月积分 */ + monthlyCredits: number; + /** 特权功能 */ + features: string[]; +} + +/** + * 积分包配置 + */ +interface CreditPackageConfig extends BusinessConfig { + /** 积分数量 */ + credits: number; + /** 赠送积分 */ + bonusCredits: number; +} + +/** + * 统一支付服务 + * 提供跨平台支付的统一业务接口 + * 集成订阅服务、积分购买等业务逻辑 + * 管理支付订单的完整生命周期 + */ +@Injectable() +export class UnifiedPaymentService { + private readonly logger = new Logger(UnifiedPaymentService.name); + + constructor( + private readonly paymentAdapterFactory: PaymentAdapterFactory, + private readonly configService: ConfigService, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + @InjectRepository(UserSubscriptionEntity) + private readonly subscriptionRepository: Repository, + @InjectRepository(UserCreditEntity) + private readonly creditRepository: Repository, + @InjectRepository(PaymentOrderEntity) + private readonly paymentOrderRepository: Repository, + ) {} + + /** + * 创建支付订单 + * 根据业务类型和平台创建相应的支付订单 + * @param platform 支付平台 + * @param userId 用户ID + * @param businessType 业务类型 + * @param businessId 业务ID + * @param platformUserId 平台用户ID + * @returns 支付订单结果 + */ + async createPaymentOrder( + platform: PlatformType, + userId: string, + businessType: PaymentBusinessType, + businessId: string, + platformUserId: string, + ): Promise { + try { + this.logger.log( + `创建支付订单: 用户=${userId}, 业务=${businessType}, 业务ID=${businessId}`, + ); + + // 1. 验证用户存在 + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 2. 获取业务配置 + const businessConfig = await this.getBusinessConfig( + businessType, + businessId, + ); + if (!businessConfig.enabled) { + throw new BadRequestException('该商品暂不可购买'); + } + + // 3. 业务前置检查 + await this.validateBusinessOrder(userId, businessType, businessId); + + // 4. 确定支付方式 + const paymentMethod = this.determinePaymentMethod(platform); + + // 5. 构建订单数据 + const orderData: CreatePaymentOrderData = { + orderNo: '', // 由适配器生成 + amount: businessConfig.unitPrice, + currency: 'CNY', + description: businessConfig.description, + userId, + platformUserId, + businessType, + businessId, + expireMinutes: 30, // 默认30分钟过期 + metadata: { + businessName: businessConfig.name, + platform, + }, + notifyUrl: `${this.configService.get('APP_URL')}/api/payment/${platform.toLowerCase()}/callback`, + }; + + // 6. 获取支付适配器并创建订单 + const adapter = this.paymentAdapterFactory.getAdapter( + platform, + paymentMethod, + ); + const result = await adapter.createPaymentOrder(orderData); + + this.logger.log( + `支付订单创建成功: 订单ID=${result.orderId}, 第三方订单ID=${result.thirdPartyOrderId}`, + ); + return result; + } catch (error) { + this.logger.error(`创建支付订单失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 查询支付订单状态 + * @param orderId 订单ID + * @returns 订单查询结果 + */ + async queryPaymentOrder(orderId: string): Promise { + try { + // 1. 获取本地订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: orderId }, + }); + + if (!paymentOrder) { + throw new NotFoundException('支付订单不存在'); + } + + // 2. 获取支付适配器 + const adapter = this.paymentAdapterFactory.getAdapter( + paymentOrder.platform, + paymentOrder.paymentMethod, + ); + + // 3. 查询订单状态 + const result = await adapter.queryPaymentOrder( + orderId, + paymentOrder.thirdPartyOrderId, + ); + + // 4. 如果订单状态变为已支付,触发业务处理 + if ( + result.status === PaymentOrderStatus.PAID && + paymentOrder.status !== PaymentOrderStatus.PAID + ) { + await this.handlePaymentSuccess(paymentOrder, result); + } + + return result; + } catch (error) { + this.logger.error(`查询支付订单失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 处理支付回调 + * @param platform 支付平台 + * @param callbackData 回调数据 + * @returns 回调处理结果 + */ + async handlePaymentCallback( + platform: PlatformType, + callbackData: any, + ): Promise { + try { + this.logger.log(`处理${platform}支付回调:`, JSON.stringify(callbackData)); + + // 1. 确定支付方式 + const paymentMethod = this.determinePaymentMethod(platform); + + // 2. 获取支付适配器 + const adapter = this.paymentAdapterFactory.getAdapter( + platform, + paymentMethod, + ); + + // 3. 处理回调 + const result = await adapter.handlePaymentCallback(callbackData); + + // 4. 如果支付成功,触发业务处理 + if ( + result.success && + result.status === PaymentOrderStatus.PAID && + result.orderId + ) { + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: result.orderId }, + }); + + if (paymentOrder) { + await this.handlePaymentSuccess(paymentOrder, result); + } + } + + return result; + } catch (error) { + this.logger.error(`处理支付回调失败: ${error.message}`, error.stack); + return { + success: false, + orderId: '', + status: PaymentOrderStatus.FAILED, + errorMessage: error.message, + }; + } + } + + /** + * 申请退款 + * @param orderId 订单ID + * @param refundAmount 退款金额 + * @param reason 退款原因 + * @returns 退款结果 + */ + async refundPayment( + orderId: string, + refundAmount: number, + reason: string, + ): Promise { + try { + // 1. 获取原订单信息 + const paymentOrder = await this.paymentOrderRepository.findOne({ + where: { id: orderId }, + }); + + if (!paymentOrder) { + throw new NotFoundException('支付订单不存在'); + } + + // 2. 验证退款条件 + if (!paymentOrder.canRefund()) { + throw new BadRequestException('订单不支持退款'); + } + + const refundableAmount = paymentOrder.getRefundableAmount(); + if (refundAmount > refundableAmount) { + throw new BadRequestException( + `退款金额不能超过可退金额 ${refundableAmount / 100} 元`, + ); + } + + // 3. 获取支付适配器 + const adapter = this.paymentAdapterFactory.getAdapter( + paymentOrder.platform, + paymentOrder.paymentMethod, + ); + + // 4. 构建退款数据 + const refundData: RefundRequestData = { + orderId, + refundAmount, + reason, + refundNo: this.generateRefundNo(), + thirdPartyOrderId: paymentOrder.thirdPartyOrderId || '', + thirdPartyTransactionId: '', // 需要从交易记录中获取 + }; + + // 5. 申请退款 + const result = await adapter.refundPayment(refundData); + + this.logger.log( + `退款申请成功: 退款ID=${result.refundId}, 金额=${refundAmount / 100}元`, + ); + return result; + } catch (error) { + this.logger.error(`申请退款失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 查询退款状态 + * @param refundId 退款ID + * @returns 退款查询结果 + */ + async queryRefundStatus(refundId: string): Promise { + try { + // 1. 获取退款记录 + const refundRecord = await this.paymentOrderRepository + .createQueryBuilder('order') + .leftJoinAndSelect('order.refunds', 'refund') + .where('refund.id = :refundId', { refundId }) + .getOne(); + + if (!refundRecord) { + throw new NotFoundException('退款记录不存在'); + } + + const refund = refundRecord.refunds[0]; + const paymentOrder = refundRecord; + + // 2. 获取支付适配器 + const adapter = this.paymentAdapterFactory.getAdapter( + paymentOrder.platform, + paymentOrder.paymentMethod, + ); + + // 3. 查询退款状态 + return await adapter.queryRefundStatus(refundId); + } catch (error) { + this.logger.error(`查询退款状态失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 获取用户支付订单列表 + * @param userId 用户ID + * @param page 页码 + * @param limit 每页数量 + * @returns 订单列表 + */ + async getUserPaymentOrders( + userId: string, + page: number = 1, + limit: number = 20, + ): Promise<{ + orders: PaymentOrderEntity[]; + total: number; + page: number; + limit: number; + }> { + const [orders, total] = await this.paymentOrderRepository.findAndCount({ + where: { userId }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + relations: ['transactions', 'refunds'], + }); + + return { orders, total, page, limit }; + } + + /** + * 处理支付成功的业务逻辑 + * @param paymentOrder 支付订单 + * @param paymentResult 支付结果 + */ + private async handlePaymentSuccess( + paymentOrder: PaymentOrderEntity, + paymentResult: PaymentOrderQueryResult | PaymentCallbackResult, + ): Promise { + try { + this.logger.log( + `处理支付成功业务: 订单ID=${paymentOrder.id}, 业务类型=${paymentOrder.businessType}`, + ); + + switch (paymentOrder.businessType) { + case PaymentBusinessType.SUBSCRIPTION: + await this.handleSubscriptionPayment(paymentOrder); + break; + case PaymentBusinessType.CREDIT_PURCHASE: + await this.handleCreditPurchasePayment(paymentOrder); + break; + case PaymentBusinessType.TEMPLATE_UNLOCK: + await this.handleTemplateUnlockPayment(paymentOrder); + break; + default: + this.logger.warn(`未知的业务类型: ${paymentOrder.businessType}`); + } + + this.logger.log(`支付成功业务处理完成: 订单ID=${paymentOrder.id}`); + } catch (error) { + this.logger.error(`处理支付成功业务失败: ${error.message}`, error.stack); + // 业务处理失败不应该影响支付状态,但需要记录日志并可能需要人工处理 + } + } + + /** + * 处理订阅支付 + * @param paymentOrder 支付订单 + */ + private async handleSubscriptionPayment( + paymentOrder: PaymentOrderEntity, + ): Promise { + const config = await this.getSubscriptionConfig(paymentOrder.businessId); + + // 创建或更新订阅 + const subscription = new UserSubscriptionEntity(); + subscription.userId = paymentOrder.userId; + subscription.platform = paymentOrder.platform; + subscription.plan = config.plan; + subscription.status = SubscriptionStatus.ACTIVE; + subscription.startDate = new Date(); + subscription.endDate = new Date( + Date.now() + config.validDays * 24 * 60 * 60 * 1000, + ); + subscription.price = paymentOrder.amount / 100; // 转换为元 + subscription.monthlyCredits = config.monthlyCredits; + subscription.features = config.features; + subscription.paymentId = paymentOrder.id; + + await this.subscriptionRepository.save(subscription); + + // 发放订阅积分 + if (config.monthlyCredits > 0) { + await this.addUserCredits( + paymentOrder.userId, + paymentOrder.platform, + config.monthlyCredits, + CreditType.REWARD, + CreditSource.SUBSCRIPTION, + `订阅${config.name}获得积分`, + paymentOrder.id, + ); + } + } + + /** + * 处理积分购买支付 + * @param paymentOrder 支付订单 + */ + private async handleCreditPurchasePayment( + paymentOrder: PaymentOrderEntity, + ): Promise { + const config = await this.getCreditPackageConfig(paymentOrder.businessId); + + // 发放积分 + const totalCredits = config.credits + config.bonusCredits; + await this.addUserCredits( + paymentOrder.userId, + paymentOrder.platform, + totalCredits, + CreditType.PURCHASE, + CreditSource.MANUAL, + `购买${config.name}获得积分`, + paymentOrder.id, + ); + } + + /** + * 处理模板解锁支付 + * @param paymentOrder 支付订单 + */ + private async handleTemplateUnlockPayment( + paymentOrder: PaymentOrderEntity, + ): Promise { + // 模板解锁逻辑,可能需要更新用户的模板权限 + // 这里需要根据具体业务逻辑实现 + this.logger.log( + `模板解锁支付处理: 用户=${paymentOrder.userId}, 模板=${paymentOrder.businessId}`, + ); + } + + /** + * 添加用户积分 + * @param userId 用户ID + * @param platform 平台 + * @param amount 积分数量 + * @param type 积分类型 + * @param source 积分来源 + * @param description 描述 + * @param referenceId 关联ID + */ + private async addUserCredits( + userId: string, + platform: PlatformType, + amount: number, + type: CreditType, + source: CreditSource, + description: string, + referenceId: string, + ): Promise { + // 计算新余额 + const lastCredit = await this.creditRepository.findOne({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + + const newBalance = (lastCredit?.balance || 0) + amount; + + // 创建积分记录 + const credit = new UserCreditEntity(); + credit.userId = userId; + credit.platform = platform; + credit.type = type; + credit.source = source; + credit.amount = amount; + credit.balance = newBalance; + credit.description = description; + credit.referenceId = referenceId; + + await this.creditRepository.save(credit); + } + + /** + * 业务前置检查 + * @param userId 用户ID + * @param businessType 业务类型 + * @param businessId 业务ID + */ + private async validateBusinessOrder( + userId: string, + businessType: PaymentBusinessType, + businessId: string, + ): Promise { + switch (businessType) { + case PaymentBusinessType.SUBSCRIPTION: + await this.validateSubscriptionOrder(userId, businessId); + break; + case PaymentBusinessType.CREDIT_PURCHASE: + // 积分购买一般无特殊限制 + break; + case PaymentBusinessType.TEMPLATE_UNLOCK: + await this.validateTemplateUnlockOrder(userId, businessId); + break; + } + } + + /** + * 验证订阅订单 + */ + private async validateSubscriptionOrder( + userId: string, + businessId: string, + ): Promise { + // 检查用户是否已有有效订阅 + const existingSubscription = await this.subscriptionRepository.findOne({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + }, + }); + + if (existingSubscription) { + throw new BadRequestException('您已有有效订阅,请等待到期后再次购买'); + } + } + + /** + * 验证模板解锁订单 + */ + private async validateTemplateUnlockOrder( + userId: string, + templateId: string, + ): Promise { + // 检查用户是否已解锁该模板 + // 这里需要根据具体业务逻辑实现 + } + + /** + * 确定支付方式 + * @param platform 平台类型 + * @returns 支付方式 + */ + private determinePaymentMethod(platform: PlatformType): PaymentMethod { + switch (platform) { + case PlatformType.WECHAT: + return PaymentMethod.WECHAT_MINIPROGRAM; + case PlatformType.BYTEDANCE: + return PaymentMethod.BYTEDANCE_MINIPROGRAM; + default: + throw new BadRequestException('不支持的支付平台'); + } + } + + /** + * 获取业务配置 + * @param businessType 业务类型 + * @param businessId 业务ID + * @returns 业务配置 + */ + private async getBusinessConfig( + businessType: PaymentBusinessType, + businessId: string, + ): Promise { + switch (businessType) { + case PaymentBusinessType.SUBSCRIPTION: + return await this.getSubscriptionConfig(businessId); + case PaymentBusinessType.CREDIT_PURCHASE: + return await this.getCreditPackageConfig(businessId); + case PaymentBusinessType.TEMPLATE_UNLOCK: + return await this.getTemplateConfig(businessId); + default: + throw new BadRequestException('不支持的业务类型'); + } + } + + /** + * 获取订阅配置 + */ + private async getSubscriptionConfig( + businessId: string, + ): Promise { + const configs: Record = { + basic_monthly: { + name: '基础版月度订阅', + plan: SubscriptionPlan.BASIC, + unitPrice: 1980, // 19.8元 + description: '基础版会员月度订阅', + validDays: 30, + monthlyCredits: 1000, + features: ['basic_templates', 'standard_generation'], + enabled: true, + }, + premium_monthly: { + name: '高级版月度订阅', + plan: SubscriptionPlan.PREMIUM, + unitPrice: 2980, // 29.8元 + description: '高级版会员月度订阅', + validDays: 30, + monthlyCredits: 2000, + features: ['premium_templates', 'fast_generation', 'batch_processing'], + enabled: true, + }, + pro_monthly: { + name: '专业版月度订阅', + plan: SubscriptionPlan.PRO, + unitPrice: 4980, // 49.8元 + description: '专业版会员月度订阅', + validDays: 30, + monthlyCredits: 5000, + features: [ + 'all_templates', + 'fastest_generation', + 'unlimited_processing', + 'priority_support', + ], + enabled: true, + }, + }; + + const config = configs[businessId]; + if (!config) { + throw new BadRequestException('无效的订阅套餐ID'); + } + + return config; + } + + /** + * 获取积分包配置 + */ + private async getCreditPackageConfig( + businessId: string, + ): Promise { + const configs: Record = { + credits_100: { + name: '100积分包', + unitPrice: 980, // 9.8元 + description: '购买100积分', + credits: 100, + bonusCredits: 0, + enabled: true, + }, + credits_500: { + name: '500积分包', + unitPrice: 4800, // 48元 + description: '购买500积分', + credits: 500, + bonusCredits: 50, // 赠送50积分 + enabled: true, + }, + credits_1000: { + name: '1000积分包', + unitPrice: 8800, // 88元 + description: '购买1000积分', + credits: 1000, + bonusCredits: 200, // 赠送200积分 + enabled: true, + }, + }; + + const config = configs[businessId]; + if (!config) { + throw new BadRequestException('无效的积分包ID'); + } + + return config; + } + + /** + * 获取模板配置 + */ + private async getTemplateConfig(businessId: string): Promise { + // 这里可以从数据库或配置文件中获取模板价格 + return { + name: `模板解锁-${businessId}`, + unitPrice: 1980, // 19.8元 + description: '解锁高级模板', + enabled: true, + }; + } + + /** + * 生成退款单号 + */ + private generateRefundNo(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + return `RF${timestamp}${random.toUpperCase()}`; + } +}