rwadurian/backend/services/planting-service/src/infrastructure/external/wallet-service.client.ts

390 lines
11 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { FundAllocationDTO } from '../../domain/value-objects/fund-allocation.vo';
export interface DeductForPlantingRequest {
userId: string;
amount: number;
orderId: string;
}
export interface AllocateFundsRequest {
orderId: string;
allocations: FundAllocationDTO[];
}
export interface WalletBalance {
userId: string;
available: number;
locked: number;
currency: string;
}
export interface FreezeForPlantingRequest {
userId: string;
accountSequence?: string; // 跨服务关联标识(优先使用)
amount: number;
orderId: string;
}
export interface ConfirmPlantingDeductionRequest {
userId: string;
accountSequence?: string; // 跨服务关联标识(优先使用)
orderId: string;
}
export interface UnfreezeForPlantingRequest {
userId: string;
accountSequence?: string; // 跨服务关联标识(优先使用)
orderId: string;
}
export interface FreezeResult {
success: boolean;
frozenAmount: number;
}
/**
* HTTP 重试配置
*/
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 10000,
};
@Injectable()
export class WalletServiceClient {
private readonly logger = new Logger(WalletServiceClient.name);
private readonly baseUrl: string;
private readonly retryConfig: RetryConfig;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.baseUrl =
this.configService.get<string>('WALLET_SERVICE_URL') ||
'http://localhost:3002';
this.retryConfig = DEFAULT_RETRY_CONFIG;
}
/**
* 带重试的 HTTP 请求包装器
* - 使用指数退避策略
* - 只对网络错误和 5xx 错误进行重试
* - 4xx 错误(客户端错误)不重试
*/
private async withRetry<T>(
operation: string,
fn: () => Promise<T>,
config: RetryConfig = this.retryConfig,
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error as Error;
// 判断是否应该重试
const shouldRetry = this.shouldRetry(error, attempt, config.maxRetries);
if (!shouldRetry) {
throw error;
}
// 计算退避延迟(指数退避 + 随机抖动)
const delay = this.calculateBackoffDelay(attempt, config);
this.logger.warn(
`${operation} failed (attempt ${attempt + 1}/${config.maxRetries + 1}), ` +
`retrying in ${delay}ms: ${(error as Error).message}`,
);
await this.delay(delay);
}
}
throw lastError;
}
/**
* 判断是否应该重试
*/
private shouldRetry(
error: unknown,
attempt: number,
maxRetries: number,
): boolean {
// 已达到最大重试次数
if (attempt >= maxRetries) {
return false;
}
// 检查是否是 HTTP 响应错误
const axiosError = error as { response?: { status?: number }; code?: string };
// 网络错误(无响应)- 应该重试
if (!axiosError.response) {
// 常见的网络错误码
const retryableCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'];
if (axiosError.code && retryableCodes.includes(axiosError.code)) {
return true;
}
// 超时错误
if ((error as Error).message?.includes('timeout')) {
return true;
}
return true; // 网络问题默认重试
}
// 5xx 服务器错误 - 应该重试
if (axiosError.response.status && axiosError.response.status >= 500) {
return true;
}
// 4xx 客户端错误 - 不重试(业务错误)
// 429 Too Many Requests - 可以重试
if (axiosError.response.status === 429) {
return true;
}
return false;
}
/**
* 计算指数退避延迟
*/
private calculateBackoffDelay(attempt: number, config: RetryConfig): number {
// 基础延迟 * 2^attempt + 随机抖动
const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * config.baseDelayMs * 0.5;
return Math.min(exponentialDelay + jitter, config.maxDelayMs);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 获取用户钱包余额
*/
async getBalance(userId: string): Promise<WalletBalance> {
try {
return await this.withRetry(`getBalance(${userId})`, async () => {
const response = await firstValueFrom(
this.httpService.get<WalletBalance>(
`${this.baseUrl}/api/v1/wallets/${userId}/balance`,
),
);
return response.data;
});
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}`, error);
// 在开发环境返回模拟数据
if (this.configService.get('NODE_ENV') === 'development') {
return {
userId,
available: 100000,
locked: 0,
currency: 'USDT',
};
}
throw error;
}
}
/**
* 认种扣款(幂等,支持重试)
*/
async deductForPlanting(request: DeductForPlantingRequest): Promise<boolean> {
try {
return await this.withRetry(
`deductForPlanting(${request.orderId})`,
async () => {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/deduct-for-planting`,
request,
),
);
return response.data.success;
},
);
} catch (error) {
this.logger.error(
`Failed to deduct for planting: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful deduction');
return true;
}
throw error;
}
}
/**
* 冻结资金用于认种
*/
async freezeForPlanting(request: FreezeForPlantingRequest): Promise<FreezeResult> {
try {
return await this.withRetry(
`freezeForPlanting(${request.orderId})`,
async () => {
const response = await firstValueFrom(
this.httpService.post<FreezeResult>(
`${this.baseUrl}/api/v1/wallets/freeze-for-planting`,
request,
),
);
return response.data;
},
);
} catch (error) {
this.logger.error(
`Failed to freeze for planting: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful freeze');
return { success: true, frozenAmount: request.amount };
}
throw error;
}
}
/**
* 确认认种扣款(从冻结金额扣除)
*/
async confirmPlantingDeduction(request: ConfirmPlantingDeductionRequest): Promise<boolean> {
try {
return await this.withRetry(
`confirmPlantingDeduction(${request.orderId})`,
async () => {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/confirm-planting-deduction`,
request,
),
);
return response.data.success;
},
);
} catch (error) {
this.logger.error(
`Failed to confirm planting deduction: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful confirmation');
return true;
}
throw error;
}
}
/**
* 解冻资金(认种失败时回滚)
*/
async unfreezeForPlanting(request: UnfreezeForPlantingRequest): Promise<boolean> {
try {
return await this.withRetry(
`unfreezeForPlanting(${request.orderId})`,
async () => {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/unfreeze-for-planting`,
request,
),
);
return response.data.success;
},
);
} catch (error) {
this.logger.error(
`Failed to unfreeze for planting: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful unfreeze');
return true;
}
throw error;
}
}
/**
* 执行资金分配(幂等,支持重试)
*/
async allocateFunds(request: AllocateFundsRequest): Promise<boolean> {
try {
return await this.withRetry(
`allocateFunds(${request.orderId})`,
async () => {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/api/v1/wallets/allocate-funds`,
request,
),
);
return response.data.success;
},
);
} catch (error) {
this.logger.error(
`Failed to allocate funds for order: ${request.orderId}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating successful allocation');
return true;
}
throw error;
}
}
/**
* 注入底池
*/
async injectToPool(
batchId: string,
amount: number,
): Promise<{ txHash: string }> {
try {
return await this.withRetry(`injectToPool(${batchId})`, async () => {
const response = await firstValueFrom(
this.httpService.post<{ txHash: string }>(
`${this.baseUrl}/api/v1/pool/inject`,
{ batchId, amount },
),
);
return response.data;
});
} catch (error) {
this.logger.error(`Failed to inject to pool: batch ${batchId}`, error);
// 在开发环境返回模拟交易哈希
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating pool injection');
return {
txHash: `0x${Date.now().toString(16)}${Math.random().toString(16).substring(2)}`,
};
}
throw error;
}
}
}