Compare commits
No commits in common. "cec98e9d3e5357fad0d0024ce8ee1d0ad09c82a1" and "06dbe133c2ad67c7988ec91eab0f6b816ff65688" have entirely different histories.
cec98e9d3e
...
06dbe133c2
|
|
@ -1,9 +1,7 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { UserService, UserProfileResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
|
@ -25,21 +23,4 @@ export class UserController {
|
|||
const result = await this.userService.getProfile(user.accountSequence);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
* GET /user/lookup?phone=13800138000
|
||||
*/
|
||||
@Get('lookup')
|
||||
async lookupByPhone(
|
||||
@Query('phone') phone: string,
|
||||
@CurrentUser() currentUser: { accountSequence: string },
|
||||
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
|
||||
if (!phone || phone.length !== 11) {
|
||||
throw new BadRequestException('请输入有效的11位手机号');
|
||||
}
|
||||
|
||||
const result = await this.userService.lookupByPhone(phone);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,24 +48,6 @@ export class UserService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
*/
|
||||
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
|
||||
const phoneVO = Phone.create(phone);
|
||||
const user = await this.userRepository.findByPhone(phoneVO);
|
||||
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
accountSequence: user.accountSequence.value,
|
||||
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换手机号
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||
import { SnapshotService } from '../services/snapshot.service';
|
||||
import { BonusClaimService } from '../services/bonus-claim.service';
|
||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||
|
|
@ -21,7 +20,6 @@ export class ContributionScheduler implements OnModuleInit {
|
|||
constructor(
|
||||
private readonly calculationService: ContributionCalculationService,
|
||||
private readonly snapshotService: SnapshotService,
|
||||
private readonly bonusClaimService: BonusClaimService,
|
||||
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||
private readonly outboxRepository: OutboxRepository,
|
||||
|
|
@ -234,59 +232,6 @@ export class ContributionScheduler implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每10分钟扫描并补发未完全解锁的贡献值
|
||||
* 处理因下级先于上级认种导致的层级/奖励档位未能及时分配的情况
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
async processContributionBackfill(): Promise<void> {
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:backfill`, 540); // 9分钟锁
|
||||
if (!lockValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log('Starting contribution backfill scan...');
|
||||
|
||||
// 查找解锁状态不完整的账户(已认种但层级<15或奖励档位<3)
|
||||
const accounts = await this.contributionAccountRepository.findAccountsWithIncompleteUnlock(100);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
this.logger.debug('No accounts with incomplete unlock status found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${accounts.length} accounts with incomplete unlock status`);
|
||||
|
||||
let backfilledCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const hasBackfill = await this.bonusClaimService.processBackfillForAccount(account.accountSequence);
|
||||
if (hasBackfill) {
|
||||
backfilledCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
this.logger.error(
|
||||
`Failed to process backfill for account ${account.accountSequence}`,
|
||||
error,
|
||||
);
|
||||
// 继续处理下一个账户
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Contribution backfill completed: ${backfilledCount} accounts backfilled, ${errorCount} errors`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process contribution backfill', error);
|
||||
} finally {
|
||||
await this.redis.releaseLock(`${this.LOCK_KEY}:backfill`, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每天凌晨4点全量发布所有贡献值账户更新事件
|
||||
* 作为数据一致性的最终兜底保障
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ import { OutboxRepository } from '../../infrastructure/persistence/repositories/
|
|||
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||
import { ContributionRecordAggregate } from '../../domain/aggregates/contribution-record.aggregate';
|
||||
import { ContributionAccountAggregate, ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
|
||||
import { DistributionRate } from '../../domain/value-objects/distribution-rate.vo';
|
||||
import { ContributionCalculatorService } from '../../domain/services/contribution-calculator.service';
|
||||
import { ContributionRecordSyncedEvent, SystemAccountSyncedEvent, ContributionAccountUpdatedEvent } from '../../domain/events';
|
||||
import { ContributionRecordSyncedEvent, SystemAccountSyncedEvent } from '../../domain/events';
|
||||
|
||||
/**
|
||||
* 奖励补发服务
|
||||
|
|
@ -272,352 +271,4 @@ export class BonusClaimService {
|
|||
aggregateType: 'ContributionAccount',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 定时任务补发逻辑 ==========
|
||||
|
||||
private readonly domainCalculator = new ContributionCalculatorService();
|
||||
|
||||
/**
|
||||
* 处理单个账户的补发逻辑
|
||||
* 检查是否有新解锁的层级或奖励档位,并进行补发
|
||||
* @returns 是否有补发
|
||||
*/
|
||||
async processBackfillForAccount(accountSequence: string): Promise<boolean> {
|
||||
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新计算直推认种用户数
|
||||
const currentDirectReferralAdoptedCount = await this.syncedDataRepository.getDirectReferralAdoptedCount(
|
||||
accountSequence,
|
||||
);
|
||||
|
||||
// 计算应该解锁的层级深度和奖励档位
|
||||
const expectedLevelDepth = this.domainCalculator.calculateUnlockedLevelDepth(currentDirectReferralAdoptedCount);
|
||||
const expectedBonusTiers = this.domainCalculator.calculateUnlockedBonusTiers(
|
||||
account.hasAdopted,
|
||||
currentDirectReferralAdoptedCount,
|
||||
);
|
||||
|
||||
let hasBackfill = false;
|
||||
|
||||
// 检查是否需要补发层级贡献值
|
||||
if (expectedLevelDepth > account.unlockedLevelDepth) {
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} level unlock: ${account.unlockedLevelDepth} -> ${expectedLevelDepth} ` +
|
||||
`(directReferralAdoptedCount: ${account.directReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
|
||||
);
|
||||
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
// 补发层级贡献值
|
||||
const levelClaimed = await this.claimLevelContributions(
|
||||
accountSequence,
|
||||
account.unlockedLevelDepth + 1,
|
||||
expectedLevelDepth,
|
||||
);
|
||||
|
||||
if (levelClaimed > 0) {
|
||||
hasBackfill = true;
|
||||
}
|
||||
|
||||
// 更新账户的直推认种数和解锁状态
|
||||
await this.updateAccountUnlockStatus(
|
||||
account,
|
||||
currentDirectReferralAdoptedCount,
|
||||
expectedLevelDepth,
|
||||
expectedBonusTiers,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否需要补发奖励档位
|
||||
if (expectedBonusTiers > account.unlockedBonusTiers) {
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} bonus unlock: ${account.unlockedBonusTiers} -> ${expectedBonusTiers} ` +
|
||||
`(directReferralAdoptedCount: ${account.directReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
|
||||
);
|
||||
|
||||
// 使用现有的 checkAndClaimBonus 方法补发奖励
|
||||
await this.checkAndClaimBonus(
|
||||
accountSequence,
|
||||
account.directReferralAdoptedCount,
|
||||
currentDirectReferralAdoptedCount,
|
||||
);
|
||||
hasBackfill = true;
|
||||
|
||||
// 如果只有奖励档位需要补发(层级已经是最新的),也需要更新账户状态
|
||||
if (expectedLevelDepth <= account.unlockedLevelDepth) {
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
await this.updateAccountUnlockStatus(
|
||||
account,
|
||||
currentDirectReferralAdoptedCount,
|
||||
expectedLevelDepth,
|
||||
expectedBonusTiers,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hasBackfill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发层级贡献值
|
||||
* @param accountSequence 用户账号
|
||||
* @param minLevel 最小层级(包含)
|
||||
* @param maxLevel 最大层级(包含)
|
||||
* @returns 补发的记录数
|
||||
*/
|
||||
private async claimLevelContributions(
|
||||
accountSequence: string,
|
||||
minLevel: number,
|
||||
maxLevel: number,
|
||||
): Promise<number> {
|
||||
// 1. 查询待领取的层级贡献值记录
|
||||
const pendingRecords = await this.unallocatedContributionRepository.findPendingLevelByAccountSequence(
|
||||
accountSequence,
|
||||
minLevel,
|
||||
maxLevel,
|
||||
);
|
||||
|
||||
if (pendingRecords.length === 0) {
|
||||
this.logger.debug(`[Backfill] No pending level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Claiming ${pendingRecords.length} level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`,
|
||||
);
|
||||
|
||||
// 2. 查询原始认种数据,获取 treeCount 和 baseContribution
|
||||
const adoptionDataMap = new Map<string, { treeCount: number; baseContribution: ContributionAmount }>();
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionIdStr = pending.sourceAdoptionId.toString();
|
||||
if (!adoptionDataMap.has(adoptionIdStr)) {
|
||||
const adoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(pending.sourceAdoptionId);
|
||||
if (adoption) {
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: adoption.treeCount,
|
||||
baseContribution: new ContributionAmount(adoption.contributionPerTree),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`[Backfill] Adoption not found for sourceAdoptionId: ${pending.sourceAdoptionId}`);
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: 0,
|
||||
baseContribution: new ContributionAmount(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建贡献值记录
|
||||
const contributionRecords: ContributionRecordAggregate[] = [];
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionData = adoptionDataMap.get(pending.sourceAdoptionId.toString())!;
|
||||
const record = new ContributionRecordAggregate({
|
||||
accountSequence: accountSequence,
|
||||
sourceType: ContributionSourceType.TEAM_LEVEL,
|
||||
sourceAdoptionId: pending.sourceAdoptionId,
|
||||
sourceAccountSequence: pending.sourceAccountSequence,
|
||||
treeCount: adoptionData.treeCount,
|
||||
baseContribution: adoptionData.baseContribution,
|
||||
distributionRate: DistributionRate.LEVEL_PER,
|
||||
levelDepth: pending.levelDepth!,
|
||||
amount: pending.amount,
|
||||
effectiveDate: pending.effectiveDate,
|
||||
expireDate: pending.expireDate,
|
||||
});
|
||||
contributionRecords.push(record);
|
||||
}
|
||||
|
||||
// 4. 保存贡献值记录
|
||||
const savedRecords = await this.contributionRecordRepository.saveMany(contributionRecords);
|
||||
|
||||
// 5. 更新用户的贡献值账户(按层级分别更新)
|
||||
for (const pending of pendingRecords) {
|
||||
await this.contributionAccountRepository.updateContribution(
|
||||
accountSequence,
|
||||
ContributionSourceType.TEAM_LEVEL,
|
||||
pending.amount,
|
||||
pending.levelDepth,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 标记待领取记录为已分配
|
||||
const pendingIds = pendingRecords.map((r) => r.id);
|
||||
await this.unallocatedContributionRepository.claimLevelRecords(pendingIds, accountSequence);
|
||||
|
||||
// 7. 计算总金额用于从 HEADQUARTERS 扣除
|
||||
let totalAmount = new ContributionAmount(0);
|
||||
for (const pending of pendingRecords) {
|
||||
totalAmount = new ContributionAmount(totalAmount.value.plus(pending.amount.value));
|
||||
}
|
||||
|
||||
// 8. 从 HEADQUARTERS 减少算力并删除明细记录
|
||||
await this.systemAccountRepository.subtractContribution('HEADQUARTERS', null, totalAmount);
|
||||
for (const pending of pendingRecords) {
|
||||
await this.systemAccountRepository.deleteContributionRecordsByAdoption(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
pending.sourceAdoptionId,
|
||||
pending.sourceAccountSequence,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 发布 HEADQUARTERS 账户更新事件
|
||||
const headquartersAccount = await this.systemAccountRepository.findByTypeAndRegion('HEADQUARTERS', null);
|
||||
if (headquartersAccount) {
|
||||
const hqEvent = new SystemAccountSyncedEvent(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
headquartersAccount.name,
|
||||
headquartersAccount.contributionBalance.value.toString(),
|
||||
headquartersAccount.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: 'HEADQUARTERS',
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: hqEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 10. 发布贡献值记录同步事件
|
||||
await this.publishLevelClaimEvents(accountSequence, savedRecords, pendingRecords);
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Claimed level contributions for ${accountSequence}: ` +
|
||||
`${pendingRecords.length} records, total amount: ${totalAmount.value.toString()}`,
|
||||
);
|
||||
|
||||
return pendingRecords.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户的解锁状态
|
||||
*/
|
||||
private async updateAccountUnlockStatus(
|
||||
account: ContributionAccountAggregate,
|
||||
newDirectReferralAdoptedCount: number,
|
||||
expectedLevelDepth: number,
|
||||
expectedBonusTiers: number,
|
||||
): Promise<void> {
|
||||
// 增量更新直推认种数
|
||||
const previousCount = account.directReferralAdoptedCount;
|
||||
if (newDirectReferralAdoptedCount > previousCount) {
|
||||
for (let i = previousCount; i < newDirectReferralAdoptedCount; i++) {
|
||||
account.incrementDirectReferralAdoptedCount();
|
||||
}
|
||||
}
|
||||
|
||||
await this.contributionAccountRepository.save(account);
|
||||
|
||||
// 发布账户更新事件
|
||||
await this.publishContributionAccountUpdatedEvent(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布层级补发事件
|
||||
*/
|
||||
private async publishLevelClaimEvents(
|
||||
accountSequence: string,
|
||||
savedRecords: ContributionRecordAggregate[],
|
||||
pendingRecords: UnallocatedContribution[],
|
||||
): Promise<void> {
|
||||
// 1. 发布贡献值记录同步事件(用于 mining-admin-service CDC)
|
||||
for (const record of savedRecords) {
|
||||
const event = new ContributionRecordSyncedEvent(
|
||||
record.id!,
|
||||
record.accountSequence,
|
||||
record.sourceType,
|
||||
record.sourceAdoptionId,
|
||||
record.sourceAccountSequence,
|
||||
record.treeCount,
|
||||
record.baseContribution.value.toString(),
|
||||
record.distributionRate.value.toString(),
|
||||
record.levelDepth,
|
||||
record.bonusTier,
|
||||
record.amount.value.toString(),
|
||||
record.effectiveDate,
|
||||
record.expireDate,
|
||||
record.isExpired,
|
||||
record.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionRecordSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: record.id!.toString(),
|
||||
eventType: ContributionRecordSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 发布补发事件到 mining-wallet-service
|
||||
const userContributions = savedRecords.map((record) => ({
|
||||
accountSequence: record.accountSequence,
|
||||
contributionType: 'TEAM_LEVEL',
|
||||
amount: record.amount.value.toString(),
|
||||
levelDepth: record.levelDepth,
|
||||
effectiveDate: record.effectiveDate.toISOString(),
|
||||
expireDate: record.expireDate.toISOString(),
|
||||
sourceAdoptionId: record.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: record.sourceAccountSequence,
|
||||
isBackfill: true, // 标记为补发
|
||||
}));
|
||||
|
||||
const eventId = `level-claim-${accountSequence}-${Date.now()}`;
|
||||
const payload = {
|
||||
eventType: 'LevelClaimed',
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
accountSequence,
|
||||
claimedCount: savedRecords.length,
|
||||
userContributions,
|
||||
},
|
||||
};
|
||||
|
||||
await this.outboxRepository.save({
|
||||
eventType: 'LevelClaimed',
|
||||
topic: 'contribution.level.claimed',
|
||||
key: accountSequence,
|
||||
payload,
|
||||
aggregateId: accountSequence,
|
||||
aggregateType: 'ContributionAccount',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布贡献值账户更新事件
|
||||
*/
|
||||
private async publishContributionAccountUpdatedEvent(
|
||||
account: ContributionAccountAggregate,
|
||||
): Promise<void> {
|
||||
const totalContribution = account.personalContribution.value
|
||||
.plus(account.totalLevelPending.value)
|
||||
.plus(account.totalBonusPending.value);
|
||||
|
||||
const event = new ContributionAccountUpdatedEvent(
|
||||
account.accountSequence,
|
||||
account.personalContribution.value.toString(),
|
||||
account.totalLevelPending.value.toString(),
|
||||
account.totalBonusPending.value.toString(),
|
||||
totalContribution.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.hasAdopted,
|
||||
account.directReferralAdoptedCount,
|
||||
account.unlockedLevelDepth,
|
||||
account.unlockedBonusTiers,
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: account.accountSequence,
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,31 +233,6 @@ export class ContributionAccountRepository implements IContributionAccountReposi
|
|||
return records.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找解锁状态不完整的账户
|
||||
* 用于定时任务补发奖励
|
||||
* @param limit 返回的最大数量
|
||||
* @returns 解锁状态不完整的账户列表
|
||||
*/
|
||||
async findAccountsWithIncompleteUnlock(limit: number = 100): Promise<ContributionAccountAggregate[]> {
|
||||
// 查找已认种但未达到满解锁状态的账户:
|
||||
// - unlockedLevelDepth < 15 或
|
||||
// - unlockedBonusTiers < 3
|
||||
const records = await this.client.contributionAccount.findMany({
|
||||
where: {
|
||||
hasAdopted: true,
|
||||
OR: [
|
||||
{ unlockedLevelDepth: { lt: 15 } },
|
||||
{ unlockedBonusTiers: { lt: 3 } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'asc' }, // 优先处理最久未更新的
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return records.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取详细算力汇总(按类型分解)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -192,54 +192,6 @@ export class UnallocatedContributionRepository {
|
|||
return records.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户待领取的层级贡献值(按层级范围)
|
||||
* @param accountSequence 用户账号
|
||||
* @param minLevel 最小层级(包含)
|
||||
* @param maxLevel 最大层级(包含)
|
||||
*/
|
||||
async findPendingLevelByAccountSequence(
|
||||
accountSequence: string,
|
||||
minLevel: number,
|
||||
maxLevel: number,
|
||||
): Promise<UnallocatedContribution[]> {
|
||||
const records = await this.client.unallocatedContribution.findMany({
|
||||
where: {
|
||||
wouldBeAccountSequence: accountSequence,
|
||||
unallocType: 'LEVEL_OVERFLOW',
|
||||
levelDepth: {
|
||||
gte: minLevel,
|
||||
lte: maxLevel,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
orderBy: { levelDepth: 'asc' },
|
||||
});
|
||||
|
||||
return records.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取层级贡献值 - 将待领取记录标记为已分配给用户
|
||||
* @param ids 记录ID列表
|
||||
* @param accountSequence 分配给的用户账号
|
||||
*/
|
||||
async claimLevelRecords(ids: bigint[], accountSequence: string): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
await this.client.unallocatedContribution.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
status: 'PENDING',
|
||||
},
|
||||
data: {
|
||||
status: 'ALLOCATED_TO_USER',
|
||||
allocatedAt: new Date(),
|
||||
allocatedToAccountSequence: accountSequence,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分层级的未分配算力统计
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "p2p_transfers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"transfer_no" TEXT NOT NULL,
|
||||
"from_account_sequence" TEXT NOT NULL,
|
||||
"to_account_sequence" TEXT NOT NULL,
|
||||
"to_phone" TEXT NOT NULL,
|
||||
"to_nickname" TEXT,
|
||||
"from_phone" TEXT,
|
||||
"from_nickname" TEXT,
|
||||
"amount" DECIMAL(30,8) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"error_message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "p2p_transfers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "p2p_transfers_transfer_no_key" ON "p2p_transfers"("transfer_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_from_account_sequence_idx" ON "p2p_transfers"("from_account_sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_to_account_sequence_idx" ON "p2p_transfers"("to_account_sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_status_idx" ON "p2p_transfers"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_created_at_idx" ON "p2p_transfers"("created_at" DESC);
|
||||
|
|
@ -392,30 +392,6 @@ model TransferRecord {
|
|||
@@map("transfer_records")
|
||||
}
|
||||
|
||||
// P2P用户间转账记录
|
||||
model P2pTransfer {
|
||||
id String @id @default(uuid())
|
||||
transferNo String @unique @map("transfer_no")
|
||||
fromAccountSequence String @map("from_account_sequence")
|
||||
toAccountSequence String @map("to_account_sequence")
|
||||
toPhone String @map("to_phone") // 收款方手机号(用于显示)
|
||||
toNickname String? @map("to_nickname") // 收款方昵称
|
||||
fromPhone String? @map("from_phone") // 发送方手机号
|
||||
fromNickname String? @map("from_nickname") // 发送方昵称
|
||||
amount Decimal @db.Decimal(30, 8)
|
||||
memo String? @db.Text // 备注
|
||||
status String @default("PENDING") // PENDING, COMPLETED, FAILED
|
||||
errorMessage String? @map("error_message")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@index([fromAccountSequence])
|
||||
@@index([toAccountSequence])
|
||||
@@index([status])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("p2p_transfers")
|
||||
}
|
||||
|
||||
// ==================== Outbox ====================
|
||||
|
||||
enum OutboxStatus {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ApplicationModule } from '../application/application.module';
|
|||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||
import { TradingController } from './controllers/trading.controller';
|
||||
import { TransferController } from './controllers/transfer.controller';
|
||||
import { P2pTransferController } from './controllers/p2p-transfer.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { AdminController } from './controllers/admin.controller';
|
||||
import { PriceController } from './controllers/price.controller';
|
||||
|
|
@ -18,7 +17,6 @@ import { PriceGateway } from './gateways/price.gateway';
|
|||
controllers: [
|
||||
TradingController,
|
||||
TransferController,
|
||||
P2pTransferController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
PriceController,
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||
|
||||
class P2pTransferDto {
|
||||
@IsString()
|
||||
@Length(11, 11)
|
||||
@Matches(/^\d{11}$/, { message: '请输入有效的11位手机号' })
|
||||
toPhone: string;
|
||||
|
||||
@IsString()
|
||||
amount: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
@ApiTags('P2P Transfer')
|
||||
@ApiBearerAuth()
|
||||
@Controller('p2p')
|
||||
export class P2pTransferController {
|
||||
constructor(private readonly p2pTransferService: P2pTransferService) {}
|
||||
|
||||
@Post('transfer')
|
||||
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
||||
async transfer(
|
||||
@Body() dto: P2pTransferDto,
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const accountSequence = req.user?.accountSequence;
|
||||
if (!accountSequence) {
|
||||
throw new BadRequestException('Unauthorized');
|
||||
}
|
||||
|
||||
const token = authHeader?.replace('Bearer ', '') || '';
|
||||
const result = await this.p2pTransferService.transfer(
|
||||
accountSequence,
|
||||
dto.toPhone,
|
||||
dto.amount,
|
||||
dto.memo,
|
||||
token,
|
||||
);
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get('transfers/:accountSequence')
|
||||
@ApiOperation({ summary: '获取P2P转账历史' })
|
||||
@ApiParam({ name: 'accountSequence', required: true, description: '账户序列号' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||
async getHistory(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Req() req: any,
|
||||
@Query('page') page?: number,
|
||||
@Query('pageSize') pageSize?: number,
|
||||
) {
|
||||
// 验证只能查询自己的转账历史
|
||||
const currentUser = req.user?.accountSequence;
|
||||
if (!currentUser || currentUser !== accountSequence) {
|
||||
throw new BadRequestException('Unauthorized');
|
||||
}
|
||||
|
||||
const result = await this.p2pTransferService.getTransferHistory(
|
||||
accountSequence,
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
);
|
||||
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
|||
import { ApiModule } from '../api/api.module';
|
||||
import { OrderService } from './services/order.service';
|
||||
import { TransferService } from './services/transfer.service';
|
||||
import { P2pTransferService } from './services/p2p-transfer.service';
|
||||
import { PriceService } from './services/price.service';
|
||||
import { BurnService } from './services/burn.service';
|
||||
import { AssetService } from './services/asset.service';
|
||||
|
|
@ -28,7 +27,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
|||
AssetService,
|
||||
OrderService,
|
||||
TransferService,
|
||||
P2pTransferService,
|
||||
MarketMakerService,
|
||||
C2cService,
|
||||
// Schedulers
|
||||
|
|
@ -37,6 +35,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
|||
PriceBroadcastScheduler,
|
||||
C2cExpiryScheduler,
|
||||
],
|
||||
exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
|
||||
interface RecipientInfo {
|
||||
exists: boolean;
|
||||
accountSequence?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface P2pTransferResult {
|
||||
transferNo: string;
|
||||
amount: string;
|
||||
toPhone: string;
|
||||
toNickname?: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface P2pTransferHistoryItem {
|
||||
transferNo: string;
|
||||
fromAccountSequence: string;
|
||||
toAccountSequence: string;
|
||||
toPhone: string;
|
||||
amount: string;
|
||||
memo?: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class P2pTransferService {
|
||||
private readonly logger = new Logger(P2pTransferService.name);
|
||||
private readonly authServiceUrl: string;
|
||||
private readonly minTransferAmount: number;
|
||||
|
||||
constructor(
|
||||
private readonly accountRepository: TradingAccountRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://localhost:3020');
|
||||
this.minTransferAmount = this.configService.get<number>('MIN_P2P_TRANSFER_AMOUNT', 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找收款方
|
||||
*/
|
||||
async lookupRecipient(phone: string, token: string): Promise<RecipientInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.authServiceUrl}/api/v2/auth/user/lookup?phone=${phone}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success || !result.data) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to lookup recipient: ${error.message}`);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P转账(积分值)
|
||||
*/
|
||||
async transfer(
|
||||
fromAccountSequence: string,
|
||||
toPhone: string,
|
||||
amount: string,
|
||||
memo?: string,
|
||||
token?: string,
|
||||
): Promise<P2pTransferResult> {
|
||||
const transferAmount = new Money(amount);
|
||||
|
||||
// 验证转账金额
|
||||
if (transferAmount.value.lessThan(this.minTransferAmount)) {
|
||||
throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`);
|
||||
}
|
||||
|
||||
// 查找收款方
|
||||
const recipient = await this.lookupRecipient(toPhone, token || '');
|
||||
if (!recipient.exists || !recipient.accountSequence) {
|
||||
throw new NotFoundException('收款方账户不存在');
|
||||
}
|
||||
|
||||
// 不能转给自己
|
||||
if (recipient.accountSequence === fromAccountSequence) {
|
||||
throw new BadRequestException('不能转账给自己');
|
||||
}
|
||||
|
||||
// 查找发送方账户
|
||||
const fromAccount = await this.accountRepository.findByAccountSequence(fromAccountSequence);
|
||||
if (!fromAccount) {
|
||||
throw new NotFoundException('发送方账户不存在');
|
||||
}
|
||||
|
||||
// 检查余额
|
||||
if (fromAccount.availableCash.isLessThan(transferAmount)) {
|
||||
throw new BadRequestException('可用积分值不足');
|
||||
}
|
||||
|
||||
const transferNo = this.generateTransferNo();
|
||||
|
||||
// 使用事务执行转账
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. 创建转账记录
|
||||
await tx.p2pTransfer.create({
|
||||
data: {
|
||||
transferNo,
|
||||
fromAccountSequence,
|
||||
toAccountSequence: recipient.accountSequence!,
|
||||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
amount: transferAmount.value,
|
||||
memo,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 扣减发送方余额
|
||||
fromAccount.withdraw(transferAmount, transferNo);
|
||||
|
||||
// 保存发送方账户变动
|
||||
await tx.tradingAccount.update({
|
||||
where: { accountSequence: fromAccountSequence },
|
||||
data: {
|
||||
cashBalance: fromAccount.cashBalance.value,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录发送方交易流水
|
||||
for (const txn of fromAccount.pendingTransactions) {
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: fromAccountSequence,
|
||||
type: txn.type,
|
||||
assetType: txn.assetType,
|
||||
amount: txn.amount.value,
|
||||
balanceBefore: txn.balanceBefore.value,
|
||||
balanceAfter: txn.balanceAfter.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: recipient.accountSequence,
|
||||
memo: `P2P转出给 ${toPhone}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
fromAccount.clearPendingTransactions();
|
||||
|
||||
// 3. 增加收款方余额
|
||||
let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!);
|
||||
if (!toAccount) {
|
||||
toAccount = TradingAccountAggregate.create(recipient.accountSequence!);
|
||||
// 创建新账户
|
||||
await tx.tradingAccount.create({
|
||||
data: {
|
||||
accountSequence: recipient.accountSequence!,
|
||||
shareBalance: 0,
|
||||
cashBalance: 0,
|
||||
frozenShares: 0,
|
||||
frozenCash: 0,
|
||||
totalBought: 0,
|
||||
totalSold: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAccount.deposit(transferAmount, transferNo);
|
||||
|
||||
// 保存收款方账户变动
|
||||
await tx.tradingAccount.update({
|
||||
where: { accountSequence: recipient.accountSequence! },
|
||||
data: {
|
||||
cashBalance: toAccount.cashBalance.value,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录收款方交易流水
|
||||
for (const txn of toAccount.pendingTransactions) {
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: recipient.accountSequence!,
|
||||
type: txn.type,
|
||||
assetType: txn.assetType,
|
||||
amount: txn.amount.value,
|
||||
balanceBefore: txn.balanceBefore.value,
|
||||
balanceAfter: txn.balanceAfter.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: fromAccountSequence,
|
||||
memo: `P2P转入来自 ${fromAccountSequence}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 更新转账记录为完成
|
||||
await tx.p2pTransfer.update({
|
||||
where: { transferNo },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`);
|
||||
|
||||
return {
|
||||
transferNo,
|
||||
amount,
|
||||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
status: 'COMPLETED',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 更新转账记录为失败
|
||||
await this.prisma.p2pTransfer.update({
|
||||
where: { transferNo },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
this.logger.error(`P2P transfer failed: ${transferNo}, ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取P2P转账历史
|
||||
*/
|
||||
async getTransferHistory(
|
||||
accountSequence: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
): Promise<{ data: P2pTransferHistoryItem[]; total: number }> {
|
||||
const [records, total] = await Promise.all([
|
||||
this.prisma.p2pTransfer.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromAccountSequence: accountSequence },
|
||||
{ toAccountSequence: accountSequence },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.p2pTransfer.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromAccountSequence: accountSequence },
|
||||
{ toAccountSequence: accountSequence },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = records.map((record) => ({
|
||||
transferNo: record.transferNo,
|
||||
fromAccountSequence: record.fromAccountSequence,
|
||||
toAccountSequence: record.toAccountSequence,
|
||||
toPhone: record.toPhone,
|
||||
amount: record.amount.toString(),
|
||||
memo: record.memo,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt,
|
||||
} as P2pTransferHistoryItem));
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
private generateTransferNo(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `P2P${timestamp}${random}`.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
|
|||
import '../../presentation/pages/profile/team_page.dart';
|
||||
import '../../presentation/pages/profile/trading_records_page.dart';
|
||||
import '../../presentation/pages/trading/transfer_records_page.dart';
|
||||
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
|
||||
import '../../presentation/pages/profile/help_center_page.dart';
|
||||
import '../../presentation/pages/profile/about_page.dart';
|
||||
import '../../presentation/widgets/main_shell.dart';
|
||||
|
|
@ -168,10 +167,6 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
path: Routes.transferRecords,
|
||||
builder: (context, state) => const TransferRecordsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.p2pTransferRecords,
|
||||
builder: (context, state) => const P2pTransferRecordsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.helpCenter,
|
||||
builder: (context, state) => const HelpCenterPage(),
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ class Routes {
|
|||
static const String tradingRecords = '/trading-records';
|
||||
// 划转记录
|
||||
static const String transferRecords = '/transfer-records';
|
||||
// P2P转账记录
|
||||
static const String p2pTransferRecords = '/p2p-transfer-records';
|
||||
// 其他设置
|
||||
static const String helpCenter = '/help-center';
|
||||
static const String about = '/about';
|
||||
|
|
|
|||
|
|
@ -8,12 +8,9 @@ import '../../../core/network/price_websocket_service.dart';
|
|||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../domain/entities/asset_display.dart';
|
||||
import '../../../domain/entities/trade_order.dart';
|
||||
import '../../../data/models/trade_order_model.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/asset_providers.dart';
|
||||
import '../../providers/mining_providers.dart';
|
||||
import '../../providers/trading_providers.dart';
|
||||
import '../../widgets/shimmer_loading.dart';
|
||||
|
||||
class AssetPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -163,14 +160,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||
// 从 mining-service 获取每秒收益
|
||||
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
|
||||
// 获取订单列表,用于显示冻结状态
|
||||
final ordersAsync = ref.watch(ordersProvider);
|
||||
|
||||
// 提取数据和加载状态
|
||||
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
|
||||
final asset = assetAsync.valueOrNull;
|
||||
final shareAccount = shareAccountAsync.valueOrNull;
|
||||
final orders = ordersAsync.valueOrNull?.data ?? [];
|
||||
|
||||
// 获取每秒收益(优先使用 mining-service 的数据)
|
||||
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
|
||||
|
|
@ -211,7 +205,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
_lastAsset = null;
|
||||
ref.invalidate(accountAssetProvider(accountSequence));
|
||||
ref.invalidate(shareAccountProvider(accountSequence));
|
||||
ref.invalidate(ordersProvider);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
|
|
@ -234,7 +227,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
_buildQuickActions(context),
|
||||
const SizedBox(height: 24),
|
||||
// 资产列表 - 始终显示,数字部分闪烁,实时刷新
|
||||
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders),
|
||||
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning),
|
||||
const SizedBox(height: 24),
|
||||
// 交易统计
|
||||
_buildEarningsCard(context, asset, isLoading),
|
||||
|
|
@ -487,7 +480,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning, List<TradeOrder> orders) {
|
||||
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning) {
|
||||
// 使用实时积分股余额
|
||||
final shareBalance = asset != null && currentShareBalance > 0
|
||||
? currentShareBalance
|
||||
|
|
@ -497,10 +490,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
|
||||
final isDark = AppColors.isDark(context);
|
||||
|
||||
// 根据订单状态动态计算冻结原因
|
||||
final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0;
|
||||
final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 积分股 - 实时刷新
|
||||
|
|
@ -539,32 +528,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
title: '冻结积分股',
|
||||
amount: asset?.frozenShares,
|
||||
isLoading: isLoading,
|
||||
subtitle: frozenSharesSubtitle,
|
||||
onTap: frozenShares > 0 ? () => context.push(Routes.tradingRecords) : null,
|
||||
subtitle: '交易挂单中',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据订单状态获取冻结积分股的显示文字
|
||||
String? _getFrozenSharesSubtitle(double frozenShares, List<TradeOrder> orders) {
|
||||
if (frozenShares <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否有进行中的卖单(pending 或 partial)
|
||||
final hasPendingSellOrder = orders.any(
|
||||
(order) => order.isSell && (order.isPending || order.isPartial),
|
||||
);
|
||||
|
||||
if (hasPendingSellOrder) {
|
||||
return '交易挂单中';
|
||||
}
|
||||
|
||||
// 有冻结但没有进行中的挂单,可能是订单已成交但资产还未释放
|
||||
return '处理中';
|
||||
}
|
||||
|
||||
Widget _buildAssetItem({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
|
|
@ -573,7 +542,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
required String title,
|
||||
String? amount,
|
||||
bool isLoading = false,
|
||||
VoidCallback? onTap,
|
||||
String? valueInCny,
|
||||
String? tag,
|
||||
String? growthText,
|
||||
|
|
@ -583,23 +551,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
String? subtitle,
|
||||
}) {
|
||||
final isDark = AppColors.isDark(context);
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardOf(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardOf(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
|
|
@ -733,7 +698,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../../data/models/p2p_transfer_model.dart';
|
||||
import '../../providers/transfer_providers.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
|
||||
/// P2P转账记录页面
|
||||
class P2pTransferRecordsPage extends ConsumerWidget {
|
||||
const P2pTransferRecordsPage({super.key});
|
||||
|
||||
static const Color _orange = Color(0xFFFF6B00);
|
||||
static const Color _green = Color(0xFF10B981);
|
||||
static const Color _red = Color(0xFFEF4444);
|
||||
static const Color _grayText = Color(0xFF6B7280);
|
||||
static const Color _darkText = Color(0xFF1F2937);
|
||||
static const Color _bgGray = Color(0xFFF3F4F6);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userNotifierProvider);
|
||||
final accountSequence = user.accountSequence ?? '';
|
||||
final recordsAsync = ref.watch(p2pTransferHistoryProvider(accountSequence));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: _darkText),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(p2pTransferHistoryProvider(accountSequence));
|
||||
},
|
||||
child: recordsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: _grayText),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'加载失败',
|
||||
style: TextStyle(color: _grayText),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(p2pTransferHistoryProvider(accountSequence)),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (records) {
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long,
|
||||
size: 64,
|
||||
color: _grayText.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'暂无转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: records.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildRecordCard(context, records[index], accountSequence);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(BuildContext context, P2pTransferModel record, String myAccountSequence) {
|
||||
// 判断是转出还是转入
|
||||
final isSend = record.fromAccountSequence == myAccountSequence;
|
||||
final statusColor = _getStatusColor(record.status);
|
||||
final statusText = _getStatusText(record.status);
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: (isSend ? _orange : _green).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
isSend ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 18,
|
||||
color: isSend ? _orange : _green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isSend ? '转出' : '转入',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
dateFormat.format(record.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// 金额
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'转账金额',
|
||||
style: TextStyle(fontSize: 13, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
'${isSend ? '-' : '+'}${formatAmount(record.amount)} 积分值',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSend ? _orange : _green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 对方账号
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
isSend ? '收款方' : '付款方',
|
||||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
_maskPhone(record.toPhone),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 备注
|
||||
if (record.memo != null && record.memo!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'备注',
|
||||
style: TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
record.memo!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _darkText,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// 单号
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'转账单号',
|
||||
style: TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
record.transferNo,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _maskPhone(String phone) {
|
||||
if (phone.length != 11) return phone;
|
||||
return '${phone.substring(0, 3)}****${phone.substring(7)}';
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED':
|
||||
case 'SUCCESS':
|
||||
return _green;
|
||||
case 'PENDING':
|
||||
return _orange;
|
||||
case 'FAILED':
|
||||
return _red;
|
||||
default:
|
||||
return _grayText;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusText(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED':
|
||||
case 'SUCCESS':
|
||||
return '已完成';
|
||||
case 'PENDING':
|
||||
return '处理中';
|
||||
case 'FAILED':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/router/routes.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/asset_providers.dart';
|
||||
|
|
@ -45,7 +44,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||
final transferState = ref.watch(transferNotifierProvider);
|
||||
|
||||
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
|
||||
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
|
|
@ -65,18 +64,6 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.push(Routes.p2pTransferRecords),
|
||||
child: const Text(
|
||||
'转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
|
@ -89,7 +76,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
// 转账金额
|
||||
_buildAmountSection(availableCash),
|
||||
_buildAmountSection(availableShares),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -99,7 +86,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 32),
|
||||
|
||||
// 发送按钮
|
||||
_buildSendButton(transferState, availableCash),
|
||||
_buildSendButton(transferState, availableShares),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -261,7 +248,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountSection(String availableCash) {
|
||||
Widget _buildAmountSection(String availableShares) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
|
@ -284,7 +271,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'可用: ${formatAmount(availableCash)}',
|
||||
'可用: ${formatAmount(availableShares)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
|
|
@ -314,7 +301,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
_amountController.text = availableCash;
|
||||
_amountController.text = availableShares;
|
||||
},
|
||||
child: const Text(
|
||||
'全部',
|
||||
|
|
@ -378,9 +365,9 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(TransferState transferState, String availableCash) {
|
||||
Widget _buildSendButton(TransferState transferState, String availableShares) {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
final available = double.tryParse(availableCash) ?? 0;
|
||||
final available = double.tryParse(availableShares) ?? 0;
|
||||
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
|
||||
|
||||
return Padding(
|
||||
|
|
|
|||
Loading…
Reference in New Issue