feat: 实现完整的在线支付模块
- 添加支付模块架构,支持微信支付和抖音支付 - 创建支付订单、交易记录、退款记录数据表和实体 - 实现基于适配器模式的多平台支付集成 - 添加统一支付服务,集成会员订阅、积分购买、模板解锁业务 - 完善支付回调处理和退款功能 - 添加完整的DTO验证和API文档 - 集成到主应用模块,完成依赖注入配置
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -83,4 +83,8 @@ export class UserEntity {
|
||||
/** 广告观看列表 - 用户观看广告的完整记录和奖励明细 */
|
||||
@OneToMany(() => AdWatchEntity, (adWatch) => adWatch.user)
|
||||
adWatches: AdWatchEntity[];
|
||||
|
||||
/** 支付订单列表 - 用户的所有支付订单记录 */
|
||||
@OneToMany('PaymentOrderEntity', 'user')
|
||||
paymentOrders?: any[];
|
||||
}
|
||||
|
||||
493
src/migrations/1756900000000-CreatePaymentTables.ts
Normal file
493
src/migrations/1756900000000-CreatePaymentTables.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import { MigrationInterface, QueryRunner, Table, Index } from 'typeorm';
|
||||
|
||||
export class CreatePaymentTables1756900000000 implements MigrationInterface {
|
||||
name = 'CreatePaymentTables1756900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 创建支付订单表
|
||||
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<void> {
|
||||
// 删除表(按依赖关系倒序)
|
||||
await queryRunner.dropTable('refund_records');
|
||||
await queryRunner.dropTable('payment_transactions');
|
||||
await queryRunner.dropTable('payment_orders');
|
||||
}
|
||||
|
||||
private async createIndexes(queryRunner: QueryRunner): Promise<void> {
|
||||
// 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
369
src/payment/adapters/base-payment.adapter.ts
Normal file
369
src/payment/adapters/base-payment.adapter.ts
Normal file
@@ -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<PaymentOrderEntity>,
|
||||
protected readonly paymentTransactionRepository: Repository<PaymentTransactionEntity>,
|
||||
protected readonly refundRecordRepository: Repository<RefundRecordEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建支付订单号
|
||||
* 生成唯一的系统订单号
|
||||
*/
|
||||
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<PaymentOrderEntity> {
|
||||
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<void> {
|
||||
const updateData: Partial<PaymentOrderEntity> = { 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<PaymentTransactionEntity> {
|
||||
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<void> {
|
||||
const updateData: Partial<PaymentTransactionEntity> = {
|
||||
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<RefundRecordEntity> {
|
||||
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<void> {
|
||||
const updateData: Partial<RefundRecordEntity> = {
|
||||
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<PaymentOrderResult>;
|
||||
abstract queryPaymentOrder(
|
||||
orderId: string,
|
||||
thirdPartyOrderId?: string,
|
||||
): Promise<PaymentOrderQueryResult>;
|
||||
abstract handlePaymentCallback(
|
||||
callbackData: any,
|
||||
): Promise<PaymentCallbackResult>;
|
||||
abstract refundPayment(refundData: RefundRequestData): Promise<RefundResult>;
|
||||
abstract queryRefundStatus(refundId: string): Promise<RefundQueryResult>;
|
||||
abstract verifySignature(
|
||||
callbackData: any,
|
||||
signature: string,
|
||||
): Promise<boolean>;
|
||||
abstract downloadBill(date: string): Promise<BillData>;
|
||||
}
|
||||
656
src/payment/adapters/douyin-payment.adapter.ts
Normal file
656
src/payment/adapters/douyin-payment.adapter.ts
Normal file
@@ -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<PaymentOrderEntity>,
|
||||
@InjectRepository(PaymentTransactionEntity)
|
||||
paymentTransactionRepository: Repository<PaymentTransactionEntity>,
|
||||
@InjectRepository(RefundRecordEntity)
|
||||
refundRecordRepository: Repository<RefundRecordEntity>,
|
||||
) {
|
||||
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<PaymentOrderResult> {
|
||||
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<PaymentOrderQueryResult> {
|
||||
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<PaymentCallbackResult> {
|
||||
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<RefundResult> {
|
||||
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<RefundQueryResult> {
|
||||
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<boolean> {
|
||||
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<BillData> {
|
||||
// 抖音支付暂不支持对账单下载
|
||||
return {
|
||||
billDate: date,
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
records: [],
|
||||
rawData: '抖音支付暂不支持对账单下载',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
* 调用抖音获取access_token接口
|
||||
* @returns 访问令牌
|
||||
*/
|
||||
private async getAccessToken(): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
3
src/payment/adapters/index.ts
Normal file
3
src/payment/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BasePaymentAdapter } from './base-payment.adapter';
|
||||
export { WechatPaymentAdapter } from './wechat-payment.adapter';
|
||||
export { DouyinPaymentAdapter } from './douyin-payment.adapter';
|
||||
692
src/payment/adapters/wechat-payment.adapter.ts
Normal file
692
src/payment/adapters/wechat-payment.adapter.ts
Normal file
@@ -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<PaymentOrderEntity>,
|
||||
@InjectRepository(PaymentTransactionEntity)
|
||||
paymentTransactionRepository: Repository<PaymentTransactionEntity>,
|
||||
@InjectRepository(RefundRecordEntity)
|
||||
refundRecordRepository: Repository<RefundRecordEntity>,
|
||||
) {
|
||||
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<PaymentOrderResult> {
|
||||
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<PaymentOrderQueryResult> {
|
||||
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<PaymentCallbackResult> {
|
||||
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<RefundResult> {
|
||||
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<RefundQueryResult> {
|
||||
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<boolean> {
|
||||
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<BillData> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
src/payment/controllers/index.ts
Normal file
1
src/payment/controllers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PaymentController } from './payment.controller';
|
||||
495
src/payment/controllers/payment.controller.ts
Normal file
495
src/payment/controllers/payment.controller.ts
Normal file
@@ -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<PaymentOrderResponseDto> {
|
||||
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<PaymentOrderQueryResponseDto> {
|
||||
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<RefundResponseDto> {
|
||||
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<RefundQueryResponseDto> {
|
||||
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: '支付系统异常',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/payment/dto/create-payment-order.dto.ts
Normal file
106
src/payment/dto/create-payment-order.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
10
src/payment/dto/index.ts
Normal file
10
src/payment/dto/index.ts
Normal file
@@ -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';
|
||||
158
src/payment/dto/payment-order-response.dto.ts
Normal file
158
src/payment/dto/payment-order-response.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
173
src/payment/dto/refund-request.dto.ts
Normal file
173
src/payment/dto/refund-request.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
7
src/payment/entities/index.ts
Normal file
7
src/payment/entities/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { PaymentOrderEntity } from './payment-order.entity';
|
||||
export {
|
||||
PaymentTransactionEntity,
|
||||
TransactionType,
|
||||
TransactionStatus,
|
||||
} from './payment-transaction.entity';
|
||||
export { RefundRecordEntity } from './refund-record.entity';
|
||||
195
src/payment/entities/payment-order.entity.ts
Normal file
195
src/payment/entities/payment-order.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
221
src/payment/entities/payment-transaction.entity.ts
Normal file
221
src/payment/entities/payment-transaction.entity.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
256
src/payment/entities/refund-record.entity.ts
Normal file
256
src/payment/entities/refund-record.entity.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
28
src/payment/index.ts
Normal file
28
src/payment/index.ts
Normal file
@@ -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';
|
||||
361
src/payment/interfaces/payment.interface.ts
Normal file
361
src/payment/interfaces/payment.interface.ts
Normal file
@@ -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<PaymentOrderResult>;
|
||||
|
||||
/**
|
||||
* 查询支付订单状态
|
||||
* @param orderId 本地订单ID
|
||||
* @param thirdPartyOrderId 第三方订单ID
|
||||
* @returns 订单状态信息
|
||||
*/
|
||||
queryPaymentOrder(
|
||||
orderId: string,
|
||||
thirdPartyOrderId?: string,
|
||||
): Promise<PaymentOrderQueryResult>;
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
* @param callbackData 第三方支付平台回调数据
|
||||
* @returns 回调处理结果
|
||||
*/
|
||||
handlePaymentCallback(callbackData: any): Promise<PaymentCallbackResult>;
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* @param refundData 退款申请数据
|
||||
* @returns 退款申请结果
|
||||
*/
|
||||
refundPayment(refundData: RefundRequestData): Promise<RefundResult>;
|
||||
|
||||
/**
|
||||
* 查询退款状态
|
||||
* @param refundId 退款ID
|
||||
* @returns 退款状态信息
|
||||
*/
|
||||
queryRefundStatus(refundId: string): Promise<RefundQueryResult>;
|
||||
|
||||
/**
|
||||
* 验证支付回调签名
|
||||
* @param callbackData 回调数据
|
||||
* @param signature 签名
|
||||
* @returns 验证结果
|
||||
*/
|
||||
verifySignature(callbackData: any, signature: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 下载对账单
|
||||
* @param date 对账日期
|
||||
* @returns 对账单数据
|
||||
*/
|
||||
downloadBill(date: string): Promise<BillData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单数据接口
|
||||
*/
|
||||
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;
|
||||
}
|
||||
140
src/payment/payment.module.ts
Normal file
140
src/payment/payment.module.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
3
src/payment/services/index.ts
Normal file
3
src/payment/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PaymentAdapterFactory } from './payment-adapter.factory';
|
||||
export { UnifiedPaymentService } from './unified-payment.service';
|
||||
export { PaymentWebhookService } from './payment-webhook.service';
|
||||
305
src/payment/services/payment-adapter.factory.ts
Normal file
305
src/payment/services/payment-adapter.factory.ts
Normal file
@@ -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<string, IPaymentAdapter>();
|
||||
|
||||
/**
|
||||
* 注册支付适配器
|
||||
* 将适配器实例注册到工厂中,供后续获取使用
|
||||
* @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<string, IPaymentAdapter> {
|
||||
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<PlatformType>();
|
||||
|
||||
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<PaymentMethod>();
|
||||
|
||||
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} 种支付方式`,
|
||||
);
|
||||
}
|
||||
}
|
||||
403
src/payment/services/payment-webhook.service.ts
Normal file
403
src/payment/services/payment-webhook.service.ts
Normal file
@@ -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<PaymentOrderEntity>,
|
||||
@InjectRepository(PaymentTransactionEntity)
|
||||
private readonly transactionRepository: Repository<PaymentTransactionEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 处理微信支付回调
|
||||
* @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<boolean> {
|
||||
// 检查是否已处理过相同的回调
|
||||
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<PaymentCallbackResult> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回调处理统计信息
|
||||
* @returns 统计信息
|
||||
*/
|
||||
async getCallbackStatistics(): Promise<{
|
||||
totalCallbacks: number;
|
||||
successCallbacks: number;
|
||||
failedCallbacks: number;
|
||||
platforms: Record<string, number>;
|
||||
}> {
|
||||
// 从数据库统计回调处理情况
|
||||
const statistics = {
|
||||
totalCallbacks: 0,
|
||||
successCallbacks: 0,
|
||||
failedCallbacks: 0,
|
||||
platforms: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
761
src/payment/services/unified-payment.service.ts
Normal file
761
src/payment/services/unified-payment.service.ts
Normal file
@@ -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<UserEntity>,
|
||||
@InjectRepository(UserSubscriptionEntity)
|
||||
private readonly subscriptionRepository: Repository<UserSubscriptionEntity>,
|
||||
@InjectRepository(UserCreditEntity)
|
||||
private readonly creditRepository: Repository<UserCreditEntity>,
|
||||
@InjectRepository(PaymentOrderEntity)
|
||||
private readonly paymentOrderRepository: Repository<PaymentOrderEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
* 根据业务类型和平台创建相应的支付订单
|
||||
* @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<PaymentOrderResult> {
|
||||
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<PaymentOrderQueryResult> {
|
||||
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<PaymentCallbackResult> {
|
||||
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<RefundResult> {
|
||||
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<RefundQueryResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 模板解锁逻辑,可能需要更新用户的模板权限
|
||||
// 这里需要根据具体业务逻辑实现
|
||||
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<void> {
|
||||
// 计算新余额
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 检查用户是否已有有效订阅
|
||||
const existingSubscription = await this.subscriptionRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
throw new BadRequestException('您已有有效订阅,请等待到期后再次购买');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模板解锁订单
|
||||
*/
|
||||
private async validateTemplateUnlockOrder(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
): Promise<void> {
|
||||
// 检查用户是否已解锁该模板
|
||||
// 这里需要根据具体业务逻辑实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定支付方式
|
||||
* @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<BusinessConfig> {
|
||||
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<SubscriptionConfig> {
|
||||
const configs: Record<string, SubscriptionConfig> = {
|
||||
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<CreditPackageConfig> {
|
||||
const configs: Record<string, CreditPackageConfig> = {
|
||||
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<BusinessConfig> {
|
||||
// 这里可以从数据库或配置文件中获取模板价格
|
||||
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()}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user