390 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|