Compare commits
98 Commits
v2.0.0-cdc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
1bdb9bb336 | |
|
|
d7bbb19571 | |
|
|
420dfbfd9f | |
|
|
cfbf1b21f3 | |
|
|
1f15daa6c5 | |
|
|
8ae9e217ff | |
|
|
12f8fa67fc | |
|
|
b310fde426 | |
|
|
81a58edaca | |
|
|
debc8605df | |
|
|
dee9c511e5 | |
|
|
546c0060da | |
|
|
b81ae634a6 | |
|
|
0cccc0e2cd | |
|
|
cd938f4a34 | |
|
|
84fa3e5e19 | |
|
|
adeeadb495 | |
|
|
42a28efe74 | |
|
|
91b8cca41c | |
|
|
02cc79d67a | |
|
|
7bc8547a96 | |
|
|
caffb124d2 | |
|
|
141db46356 | |
|
|
f57b0f9c26 | |
|
|
c852f24a72 | |
|
|
cb3c7623dc | |
|
|
f2692a50ed | |
|
|
ed9f817fae | |
|
|
6bcb4af028 | |
|
|
106a287260 | |
|
|
30dc2f6665 | |
|
|
e1fb70e2ee | |
|
|
f3d4799efc | |
|
|
839feab97d | |
|
|
465e398040 | |
|
|
c6c875849a | |
|
|
ce95c40c84 | |
|
|
e6d966e89f | |
|
|
270c17829e | |
|
|
289ac0190c | |
|
|
467d637ccc | |
|
|
c9690b0d36 | |
|
|
7a65ab3319 | |
|
|
e99b5347da | |
|
|
29dd1affe1 | |
|
|
a15dcafc03 | |
|
|
d404521841 | |
|
|
09b15da3cb | |
|
|
901247366d | |
|
|
0abc04b9cb | |
|
|
2b083991d0 | |
|
|
8f616dd45b | |
|
|
1008672af9 | |
|
|
f4380604d9 | |
|
|
3b61f2e095 | |
|
|
25608babd6 | |
|
|
bd0f98cfb3 | |
|
|
a2adddbf3d | |
|
|
d6064294d7 | |
|
|
36c3ada6a6 | |
|
|
13e94db450 | |
|
|
feb871bcf1 | |
|
|
4292d5da66 | |
|
|
a7a2282ba7 | |
|
|
fa6826dde3 | |
|
|
eff71a6b22 | |
|
|
0bbb52284c | |
|
|
7588d18fff | |
|
|
e6e44d9a43 | |
|
|
bf004bab52 | |
|
|
a03b883350 | |
|
|
2a79c83715 | |
|
|
ef330a2687 | |
|
|
6594845d4c | |
|
|
77b682c8a8 | |
|
|
6ec79a6672 | |
|
|
631fe2bf31 | |
|
|
d968efcad4 | |
|
|
5a4970d7d9 | |
|
|
703c12e9f6 | |
|
|
8199bc4d66 | |
|
|
aef6feb2cd | |
|
|
22523aba14 | |
|
|
a01fd3aa86 | |
|
|
d58e8b44ee | |
|
|
30949af577 | |
|
|
1fbb88f773 | |
|
|
5eae4464ef | |
|
|
d43a70de93 | |
|
|
471702d562 | |
|
|
dbf97ae487 | |
|
|
fdfc2d6700 | |
|
|
3999d7cc51 | |
|
|
20eabbb85f | |
|
|
65bd4f9b65 | |
|
|
2f3a0f3652 | |
|
|
56ff8290c1 | |
|
|
1d7d38a82c |
|
|
@ -767,7 +767,15 @@
|
||||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
|
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
|
||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")"
|
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")",
|
||||||
|
"Bash(ssh -o ConnectTimeout=10 ceshi@14.215.128.96 'find /home/ceshi/rwadurian/frontend/mining-admin-web -name \"\"*.tsx\"\" -o -name \"\"*.ts\"\" | xargs grep -l \"\"用户管理\\\\|users\"\" 2>/dev/null | head -10')",
|
||||||
|
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
|
||||||
|
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\")",
|
||||||
|
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3021/api/v2/admin/status\")",
|
||||||
|
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\buy_shares.dart\")",
|
||||||
|
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\sell_shares.dart\")",
|
||||||
|
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\presentation\\\\pages\"\" 2>/dev/null || dir /b \"c:UsersdongDesktoprwadurianfrontendmining-applibpresentationpages \")",
|
||||||
|
"Bash(cd:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class ChangePasswordDto {
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('password')
|
@Controller('auth/password')
|
||||||
@UseGuards(ThrottlerGuard)
|
@UseGuards(ThrottlerGuard)
|
||||||
export class PasswordController {
|
export class PasswordController {
|
||||||
constructor(private readonly passwordService: PasswordService) {}
|
constructor(private readonly passwordService: PasswordService) {}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class VerifySmsDto {
|
||||||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('sms')
|
@Controller('auth/sms')
|
||||||
@UseGuards(ThrottlerGuard)
|
@UseGuards(ThrottlerGuard)
|
||||||
export class SmsController {
|
export class SmsController {
|
||||||
constructor(private readonly smsService: SmsService) {}
|
constructor(private readonly smsService: SmsService) {}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { UserService, UserProfileResult } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('auth/user')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) {}
|
constructor(private readonly userService: UserService) {}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
|
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { GetContributionAccountQuery } from '../../application/queries/get-contribution-account.query';
|
import { GetContributionAccountQuery } from '../../application/queries/get-contribution-account.query';
|
||||||
import { GetContributionStatsQuery } from '../../application/queries/get-contribution-stats.query';
|
import { GetContributionStatsQuery } from '../../application/queries/get-contribution-stats.query';
|
||||||
import { GetContributionRankingQuery } from '../../application/queries/get-contribution-ranking.query';
|
import { GetContributionRankingQuery } from '../../application/queries/get-contribution-ranking.query';
|
||||||
|
import { GetPlantingLedgerQuery, PlantingLedgerDto } from '../../application/queries/get-planting-ledger.query';
|
||||||
import {
|
import {
|
||||||
ContributionAccountResponse,
|
ContributionAccountResponse,
|
||||||
ContributionRecordsResponse,
|
ContributionRecordsResponse,
|
||||||
|
|
@ -19,6 +20,7 @@ export class ContributionController {
|
||||||
private readonly getAccountQuery: GetContributionAccountQuery,
|
private readonly getAccountQuery: GetContributionAccountQuery,
|
||||||
private readonly getStatsQuery: GetContributionStatsQuery,
|
private readonly getStatsQuery: GetContributionStatsQuery,
|
||||||
private readonly getRankingQuery: GetContributionRankingQuery,
|
private readonly getRankingQuery: GetContributionRankingQuery,
|
||||||
|
private readonly getPlantingLedgerQuery: GetPlantingLedgerQuery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
|
|
@ -95,4 +97,22 @@ export class ContributionController {
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:accountSequence/planting-ledger')
|
||||||
|
@ApiOperation({ summary: '获取账户认种分类账' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, description: '页码' })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number, description: '每页数量' })
|
||||||
|
@ApiResponse({ status: 200, description: '认种分类账' })
|
||||||
|
async getPlantingLedger(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
): Promise<PlantingLedgerDto> {
|
||||||
|
return this.getPlantingLedgerQuery.execute(
|
||||||
|
accountSequence,
|
||||||
|
page ?? 1,
|
||||||
|
pageSize ?? 20,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { CDCConsumerService } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
|
|
@ -20,6 +21,7 @@ export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
private readonly cdcConsumer: CDCConsumerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
|
@ -68,4 +70,15 @@ export class HealthController {
|
||||||
async live(): Promise<{ alive: boolean }> {
|
async live(): Promise<{ alive: boolean }> {
|
||||||
return { alive: true };
|
return { alive: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('cdc-sync')
|
||||||
|
@ApiOperation({ summary: 'CDC 同步状态检查' })
|
||||||
|
@ApiResponse({ status: 200, description: 'CDC 同步状态' })
|
||||||
|
async cdcSyncStatus(): Promise<{
|
||||||
|
isRunning: boolean;
|
||||||
|
sequentialMode: boolean;
|
||||||
|
allPhasesCompleted: boolean;
|
||||||
|
}> {
|
||||||
|
return this.cdcConsumer.getSyncStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@ import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
|
||||||
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
||||||
import { ContributionDistributionPublisherService } from './services/contribution-distribution-publisher.service';
|
import { ContributionDistributionPublisherService } from './services/contribution-distribution-publisher.service';
|
||||||
import { ContributionRateService } from './services/contribution-rate.service';
|
import { ContributionRateService } from './services/contribution-rate.service';
|
||||||
|
import { BonusClaimService } from './services/bonus-claim.service';
|
||||||
import { SnapshotService } from './services/snapshot.service';
|
import { SnapshotService } from './services/snapshot.service';
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
||||||
import { GetContributionStatsQuery } from './queries/get-contribution-stats.query';
|
import { GetContributionStatsQuery } from './queries/get-contribution-stats.query';
|
||||||
import { GetContributionRankingQuery } from './queries/get-contribution-ranking.query';
|
import { GetContributionRankingQuery } from './queries/get-contribution-ranking.query';
|
||||||
|
import { GetPlantingLedgerQuery } from './queries/get-planting-ledger.query';
|
||||||
|
|
||||||
// Schedulers
|
// Schedulers
|
||||||
import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
|
|
@ -38,12 +40,14 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
ContributionCalculationService,
|
ContributionCalculationService,
|
||||||
ContributionDistributionPublisherService,
|
ContributionDistributionPublisherService,
|
||||||
ContributionRateService,
|
ContributionRateService,
|
||||||
|
BonusClaimService,
|
||||||
SnapshotService,
|
SnapshotService,
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
GetContributionAccountQuery,
|
GetContributionAccountQuery,
|
||||||
GetContributionStatsQuery,
|
GetContributionStatsQuery,
|
||||||
GetContributionRankingQuery,
|
GetContributionRankingQuery,
|
||||||
|
GetPlantingLedgerQuery,
|
||||||
|
|
||||||
// Schedulers
|
// Schedulers
|
||||||
ContributionScheduler,
|
ContributionScheduler,
|
||||||
|
|
@ -55,6 +59,7 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
GetContributionAccountQuery,
|
GetContributionAccountQuery,
|
||||||
GetContributionStatsQuery,
|
GetContributionStatsQuery,
|
||||||
GetContributionRankingQuery,
|
GetContributionRankingQuery,
|
||||||
|
GetPlantingLedgerQuery,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
import { CDCEvent, TransactionClient } from '../../infrastructure/kafka/cdc-consumer.service';
|
import { CDCEvent, TransactionClient } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||||
|
import { ContributionRateService } from '../services/contribution-rate.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认种同步结果,用于事务提交后的算力计算
|
* 认种同步结果,用于事务提交后的算力计算
|
||||||
|
|
@ -15,19 +16,11 @@ export interface AdoptionSyncResult {
|
||||||
* 认种订单 CDC 事件处理器
|
* 认种订单 CDC 事件处理器
|
||||||
* 处理从1.0 planting-service同步过来的planting_orders数据
|
* 处理从1.0 planting-service同步过来的planting_orders数据
|
||||||
*
|
*
|
||||||
* 重要设计说明(符合业界最佳实践):
|
* 设计说明:
|
||||||
* ===========================================
|
* ===========================================
|
||||||
* - handle() 方法在事务内执行,只负责数据同步(synced_adoptions 表)
|
* - handle() 方法100%同步数据,不跳过任何更新
|
||||||
* - 返回 AdoptionSyncResult,包含需要计算算力的认种ID
|
* - 算力计算只在 status 变为 MINING_ENABLED 时触发
|
||||||
* - 算力计算(calculateForAdoption)必须在事务提交后单独执行
|
* - 算力计算在事务提交后执行(避免 Serializable 隔离级别的可见性问题)
|
||||||
*
|
|
||||||
* 为什么不能在事务内调用 calculateForAdoption:
|
|
||||||
* 1. calculateForAdoption 内部使用独立的数据库连接查询数据
|
|
||||||
* 2. 在 Serializable 隔离级别下,内部查询无法看到外部事务未提交的数据
|
|
||||||
* 3. 这会导致 "Adoption not found" 错误,因为 synced_adoptions 还未提交
|
|
||||||
*
|
|
||||||
* 参考:Kafka Idempotent Consumer & Transactional Outbox Pattern
|
|
||||||
* https://www.lydtechconsulting.com/blog/kafka-idempotent-consumer-transactional-outbox
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdoptionSyncedHandler {
|
export class AdoptionSyncedHandler {
|
||||||
|
|
@ -35,6 +28,7 @@ export class AdoptionSyncedHandler {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly contributionCalculationService: ContributionCalculationService,
|
private readonly contributionCalculationService: ContributionCalculationService,
|
||||||
|
private readonly contributionRateService: ContributionRateService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,13 +42,28 @@ export class AdoptionSyncedHandler {
|
||||||
this.logger.log(`[CDC] Adoption event received: op=${op}, seq=${event.sequenceNum}`);
|
this.logger.log(`[CDC] Adoption event received: op=${op}, seq=${event.sequenceNum}`);
|
||||||
this.logger.debug(`[CDC] Adoption event payload: ${JSON.stringify(after || before)}`);
|
this.logger.debug(`[CDC] Adoption event payload: ${JSON.stringify(after || before)}`);
|
||||||
|
|
||||||
|
// 获取认种日期,用于查询当日贡献值
|
||||||
|
const data = after || before;
|
||||||
|
const adoptionDate = data?.created_at || data?.createdAt || data?.paid_at || data?.paidAt;
|
||||||
|
|
||||||
|
// 在事务外获取当日每棵树的贡献值
|
||||||
|
let contributionPerTree = new Decimal('22617'); // 默认值
|
||||||
|
if (adoptionDate) {
|
||||||
|
try {
|
||||||
|
contributionPerTree = await this.contributionRateService.getContributionPerTree(new Date(adoptionDate));
|
||||||
|
this.logger.log(`[CDC] Got contributionPerTree for ${adoptionDate}: ${contributionPerTree.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`[CDC] Failed to get contributionPerTree, using default 22617`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case 'c': // create
|
case 'c': // create
|
||||||
case 'r': // read (snapshot)
|
case 'r': // read (snapshot)
|
||||||
return await this.handleCreate(after, event.sequenceNum, tx);
|
return await this.handleCreate(after, event.sequenceNum, tx, contributionPerTree);
|
||||||
case 'u': // update
|
case 'u': // update
|
||||||
return await this.handleUpdate(after, before, event.sequenceNum, tx);
|
return await this.handleUpdate(after, before, event.sequenceNum, tx, contributionPerTree);
|
||||||
case 'd': // delete
|
case 'd': // delete
|
||||||
await this.handleDelete(before);
|
await this.handleDelete(before);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -86,21 +95,21 @@ export class AdoptionSyncedHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCreate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<AdoptionSyncResult | null> {
|
private async handleCreate(data: any, sequenceNum: bigint, tx: TransactionClient, contributionPerTree: Decimal): Promise<AdoptionSyncResult | null> {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
this.logger.warn(`[CDC] Adoption create: empty data received`);
|
this.logger.warn(`[CDC] Adoption create: empty data received`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// planting_orders表字段: order_id, account_sequence, tree_count, created_at, status, selected_province, selected_city
|
|
||||||
const orderId = data.order_id || data.id;
|
const orderId = data.order_id || data.id;
|
||||||
const accountSequence = data.account_sequence || data.accountSequence;
|
const accountSequence = data.account_sequence || data.accountSequence;
|
||||||
const treeCount = data.tree_count || data.treeCount;
|
const treeCount = data.tree_count || data.treeCount;
|
||||||
const createdAt = data.created_at || data.createdAt || data.paid_at || data.paidAt;
|
const createdAt = data.created_at || data.createdAt || data.paid_at || data.paidAt;
|
||||||
const selectedProvince = data.selected_province || data.selectedProvince || null;
|
const selectedProvince = data.selected_province || data.selectedProvince || null;
|
||||||
const selectedCity = data.selected_city || data.selectedCity || null;
|
const selectedCity = data.selected_city || data.selectedCity || null;
|
||||||
|
const status = data.status ?? null;
|
||||||
|
|
||||||
this.logger.log(`[CDC] Adoption create: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}, province=${selectedProvince}, city=${selectedCity}`);
|
this.logger.log(`[CDC] Adoption create: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}, status=${status}, contributionPerTree=${contributionPerTree.toString()}`);
|
||||||
|
|
||||||
if (!orderId || !accountSequence) {
|
if (!orderId || !accountSequence) {
|
||||||
this.logger.warn(`[CDC] Invalid adoption data: missing order_id or account_sequence`, { data });
|
this.logger.warn(`[CDC] Invalid adoption data: missing order_id or account_sequence`, { data });
|
||||||
|
|
@ -109,8 +118,7 @@ export class AdoptionSyncedHandler {
|
||||||
|
|
||||||
const originalAdoptionId = BigInt(orderId);
|
const originalAdoptionId = BigInt(orderId);
|
||||||
|
|
||||||
// 在事务中保存同步的认种订单数据
|
// 100%同步数据,使用真实的每棵树贡献值
|
||||||
this.logger.log(`[CDC] Upserting synced adoption: ${orderId}`);
|
|
||||||
await tx.syncedAdoption.upsert({
|
await tx.syncedAdoption.upsert({
|
||||||
where: { originalAdoptionId },
|
where: { originalAdoptionId },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -118,10 +126,10 @@ export class AdoptionSyncedHandler {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
treeCount,
|
treeCount,
|
||||||
adoptionDate: new Date(createdAt),
|
adoptionDate: new Date(createdAt),
|
||||||
status: data.status ?? null,
|
status,
|
||||||
selectedProvince,
|
selectedProvince,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
contributionPerTree: new Decimal('1'), // 每棵树1算力
|
contributionPerTree,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
@ -129,25 +137,26 @@ export class AdoptionSyncedHandler {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
treeCount,
|
treeCount,
|
||||||
adoptionDate: new Date(createdAt),
|
adoptionDate: new Date(createdAt),
|
||||||
status: data.status ?? undefined,
|
status,
|
||||||
selectedProvince: selectedProvince ?? undefined,
|
selectedProvince,
|
||||||
selectedCity: selectedCity ?? undefined,
|
selectedCity,
|
||||||
contributionPerTree: new Decimal('1'),
|
contributionPerTree,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[CDC] Adoption synced successfully: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}`);
|
this.logger.log(`[CDC] Adoption synced: orderId=${orderId}, status=${status}`);
|
||||||
|
|
||||||
// 返回结果,供事务提交后计算算力
|
// 只有 MINING_ENABLED 状态才触发算力计算
|
||||||
|
const needsCalculation = status === 'MINING_ENABLED';
|
||||||
return {
|
return {
|
||||||
originalAdoptionId,
|
originalAdoptionId,
|
||||||
needsCalculation: true,
|
needsCalculation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUpdate(after: any, before: any, sequenceNum: bigint, tx: TransactionClient): Promise<AdoptionSyncResult | null> {
|
private async handleUpdate(after: any, before: any, sequenceNum: bigint, tx: TransactionClient, contributionPerTree: Decimal): Promise<AdoptionSyncResult | null> {
|
||||||
if (!after) {
|
if (!after) {
|
||||||
this.logger.warn(`[CDC] Adoption update: empty after data received`);
|
this.logger.warn(`[CDC] Adoption update: empty after data received`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -155,37 +164,22 @@ export class AdoptionSyncedHandler {
|
||||||
|
|
||||||
const orderId = after.order_id || after.id;
|
const orderId = after.order_id || after.id;
|
||||||
const originalAdoptionId = BigInt(orderId);
|
const originalAdoptionId = BigInt(orderId);
|
||||||
|
|
||||||
this.logger.log(`[CDC] Adoption update: orderId=${orderId}`);
|
|
||||||
|
|
||||||
// 检查是否已经处理过(使用事务客户端)
|
|
||||||
const existingAdoption = await tx.syncedAdoption.findUnique({
|
|
||||||
where: { originalAdoptionId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingAdoption?.contributionDistributed) {
|
|
||||||
// 如果树数量发生变化,需要重新计算(这种情况较少)
|
|
||||||
const newTreeCount = after.tree_count || after.treeCount;
|
|
||||||
if (existingAdoption.treeCount !== newTreeCount) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[CDC] Adoption tree count changed after processing: ${originalAdoptionId}, old=${existingAdoption.treeCount}, new=${newTreeCount}. This requires special handling.`,
|
|
||||||
);
|
|
||||||
// TODO: 实现树数量变化的处理逻辑
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`[CDC] Adoption ${orderId} already distributed, skipping update`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountSequence = after.account_sequence || after.accountSequence;
|
const accountSequence = after.account_sequence || after.accountSequence;
|
||||||
const treeCount = after.tree_count || after.treeCount;
|
const treeCount = after.tree_count || after.treeCount;
|
||||||
const createdAt = after.created_at || after.createdAt || after.paid_at || after.paidAt;
|
const createdAt = after.created_at || after.createdAt || after.paid_at || after.paidAt;
|
||||||
const selectedProvince = after.selected_province || after.selectedProvince || null;
|
const selectedProvince = after.selected_province || after.selectedProvince || null;
|
||||||
const selectedCity = after.selected_city || after.selectedCity || null;
|
const selectedCity = after.selected_city || after.selectedCity || null;
|
||||||
|
const newStatus = after.status ?? null;
|
||||||
|
const oldStatus = before?.status ?? null;
|
||||||
|
|
||||||
this.logger.log(`[CDC] Adoption update data: account=${accountSequence}, trees=${treeCount}, province=${selectedProvince}, city=${selectedCity}`);
|
this.logger.log(`[CDC] Adoption update: orderId=${orderId}, status=${oldStatus} -> ${newStatus}, contributionPerTree=${contributionPerTree.toString()}`);
|
||||||
|
|
||||||
// 在事务中保存同步的认种订单数据
|
// 查询现有记录
|
||||||
|
const existingAdoption = await tx.syncedAdoption.findUnique({
|
||||||
|
where: { originalAdoptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 100%同步数据,使用真实的每棵树贡献值
|
||||||
await tx.syncedAdoption.upsert({
|
await tx.syncedAdoption.upsert({
|
||||||
where: { originalAdoptionId },
|
where: { originalAdoptionId },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -193,10 +187,10 @@ export class AdoptionSyncedHandler {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
treeCount,
|
treeCount,
|
||||||
adoptionDate: new Date(createdAt),
|
adoptionDate: new Date(createdAt),
|
||||||
status: after.status ?? null,
|
status: newStatus,
|
||||||
selectedProvince,
|
selectedProvince,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
contributionPerTree: new Decimal('1'),
|
contributionPerTree,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
@ -204,21 +198,24 @@ export class AdoptionSyncedHandler {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
treeCount,
|
treeCount,
|
||||||
adoptionDate: new Date(createdAt),
|
adoptionDate: new Date(createdAt),
|
||||||
status: after.status ?? undefined,
|
status: newStatus,
|
||||||
selectedProvince: selectedProvince ?? undefined,
|
selectedProvince,
|
||||||
selectedCity: selectedCity ?? undefined,
|
selectedCity,
|
||||||
contributionPerTree: new Decimal('1'),
|
contributionPerTree,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[CDC] Adoption updated successfully: ${originalAdoptionId}`);
|
this.logger.log(`[CDC] Adoption synced: orderId=${orderId}, status=${newStatus}`);
|
||||||
|
|
||||||
|
// 只有当 status 变为 MINING_ENABLED 且尚未计算过算力时,才触发算力计算
|
||||||
|
const statusChangedToMiningEnabled = newStatus === 'MINING_ENABLED' && oldStatus !== 'MINING_ENABLED';
|
||||||
|
const needsCalculation = statusChangedToMiningEnabled && !existingAdoption?.contributionDistributed;
|
||||||
|
|
||||||
// 只有尚未分配算力的认种才需要计算
|
|
||||||
return {
|
return {
|
||||||
originalAdoptionId,
|
originalAdoptionId,
|
||||||
needsCalculation: !existingAdoption?.contributionDistributed,
|
needsCalculation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,17 @@ export class CDCEventDispatcher implements OnModuleInit {
|
||||||
this.handleAdoptionPostCommit.bind(this),
|
this.handleAdoptionPostCommit.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 启动 CDC 消费者
|
// 非阻塞启动 CDC 消费者
|
||||||
try {
|
// 让 HTTP 服务器先启动,CDC 同步在后台进行
|
||||||
await this.cdcConsumer.start();
|
// 脚本通过 /health/cdc-sync API 轮询同步状态
|
||||||
this.logger.log('CDC event dispatcher started with transactional idempotency');
|
this.cdcConsumer.start()
|
||||||
} catch (error) {
|
.then(() => {
|
||||||
this.logger.error('Failed to start CDC event dispatcher', error);
|
this.logger.log('CDC event dispatcher started with transactional idempotency');
|
||||||
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
})
|
||||||
}
|
.catch((error) => {
|
||||||
|
this.logger.error('Failed to start CDC event dispatcher', error);
|
||||||
|
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUserEvent(event: CDCEvent, tx: TransactionClient): Promise<void> {
|
private async handleUserEvent(event: CDCEvent, tx: TransactionClient): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,7 @@ import { CDCEvent, TransactionClient } from '../../infrastructure/kafka/cdc-cons
|
||||||
* 引荐关系 CDC 事件处理器
|
* 引荐关系 CDC 事件处理器
|
||||||
* 处理从1.0 referral-service同步过来的referral_relationships数据
|
* 处理从1.0 referral-service同步过来的referral_relationships数据
|
||||||
*
|
*
|
||||||
* 1.0 表结构 (referral_relationships):
|
* 设计说明:100%同步数据,不跳过任何字段更新
|
||||||
* - user_id: BigInt (用户ID)
|
|
||||||
* - account_sequence: String (账户序列号)
|
|
||||||
* - referrer_id: BigInt (推荐人用户ID, 注意:不是 account_sequence)
|
|
||||||
* - ancestor_path: BigInt[] (祖先路径数组,存储 user_id)
|
|
||||||
* - depth: Int (层级深度)
|
|
||||||
*
|
|
||||||
* 2.0 存储策略:
|
|
||||||
* - 保存 original_user_id (1.0 的 user_id)
|
|
||||||
* - 保存 referrer_user_id (1.0 的 referrer_id)
|
|
||||||
* - 尝试查找 referrer 的 account_sequence 并保存
|
|
||||||
* - ancestor_path 转换为逗号分隔的字符串
|
|
||||||
*
|
|
||||||
* 注意:此 handler 现在接收外部传入的事务客户端(tx),
|
|
||||||
* 所有数据库操作都必须使用此事务客户端执行,
|
|
||||||
* 以确保幂等记录和业务数据在同一事务中处理。
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReferralSyncedHandler {
|
export class ReferralSyncedHandler {
|
||||||
|
|
@ -61,12 +46,11 @@ export class ReferralSyncedHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.0 字段映射
|
|
||||||
const accountSequence = data.account_sequence || data.accountSequence;
|
const accountSequence = data.account_sequence || data.accountSequence;
|
||||||
const originalUserId = data.user_id || data.userId;
|
const originalUserId = data.user_id || data.userId;
|
||||||
const referrerUserId = data.referrer_id || data.referrerId;
|
const referrerUserId = data.referrer_id || data.referrerId;
|
||||||
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
||||||
const depth = data.depth || 0;
|
const depth = data.depth ?? 0;
|
||||||
|
|
||||||
this.logger.log(`[CDC] Referral create: account=${accountSequence}, userId=${originalUserId}, referrerId=${referrerUserId}, depth=${depth}`);
|
this.logger.log(`[CDC] Referral create: account=${accountSequence}, userId=${originalUserId}, referrerId=${referrerUserId}, depth=${depth}`);
|
||||||
|
|
||||||
|
|
@ -75,11 +59,9 @@ export class ReferralSyncedHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 BigInt[] 转换为逗号分隔的字符串
|
|
||||||
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
||||||
this.logger.debug(`[CDC] Referral ancestorPath converted: ${ancestorPath}`);
|
|
||||||
|
|
||||||
// 尝试查找推荐人的 account_sequence(使用事务客户端)
|
// 尝试查找推荐人的 account_sequence
|
||||||
let referrerAccountSequence: string | null = null;
|
let referrerAccountSequence: string | null = null;
|
||||||
if (referrerUserId) {
|
if (referrerUserId) {
|
||||||
const referrer = await tx.syncedReferral.findFirst({
|
const referrer = await tx.syncedReferral.findFirst({
|
||||||
|
|
@ -87,14 +69,10 @@ export class ReferralSyncedHandler {
|
||||||
});
|
});
|
||||||
if (referrer) {
|
if (referrer) {
|
||||||
referrerAccountSequence = referrer.accountSequence;
|
referrerAccountSequence = referrer.accountSequence;
|
||||||
this.logger.debug(`[CDC] Found referrer account_sequence: ${referrerAccountSequence} for referrer_id: ${referrerUserId}`);
|
|
||||||
} else {
|
|
||||||
this.logger.log(`[CDC] Referrer user_id ${referrerUserId} not found yet for ${accountSequence}, will resolve later`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用外部事务客户端执行所有操作
|
// 100%同步数据
|
||||||
this.logger.log(`[CDC] Upserting synced referral: ${accountSequence}`);
|
|
||||||
await tx.syncedReferral.upsert({
|
await tx.syncedReferral.upsert({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -108,17 +86,17 @@ export class ReferralSyncedHandler {
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
referrerAccountSequence: referrerAccountSequence ?? undefined,
|
referrerAccountSequence,
|
||||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : undefined,
|
referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
|
||||||
originalUserId: originalUserId ? BigInt(originalUserId) : undefined,
|
originalUserId: originalUserId ? BigInt(originalUserId) : null,
|
||||||
ancestorPath: ancestorPath ?? undefined,
|
ancestorPath,
|
||||||
depth: depth ?? undefined,
|
depth,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[CDC] Referral synced successfully: ${accountSequence} (user_id: ${originalUserId}) -> referrer_id: ${referrerUserId || 'none'}, depth: ${depth}`);
|
this.logger.log(`[CDC] Referral synced: ${accountSequence}, referrerId=${referrerUserId || 'none'}, depth=${depth}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
||||||
|
|
@ -131,7 +109,7 @@ export class ReferralSyncedHandler {
|
||||||
const originalUserId = data.user_id || data.userId;
|
const originalUserId = data.user_id || data.userId;
|
||||||
const referrerUserId = data.referrer_id || data.referrerId;
|
const referrerUserId = data.referrer_id || data.referrerId;
|
||||||
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
||||||
const depth = data.depth || 0;
|
const depth = data.depth ?? 0;
|
||||||
|
|
||||||
this.logger.log(`[CDC] Referral update: account=${accountSequence}, referrerId=${referrerUserId}, depth=${depth}`);
|
this.logger.log(`[CDC] Referral update: account=${accountSequence}, referrerId=${referrerUserId}, depth=${depth}`);
|
||||||
|
|
||||||
|
|
@ -142,7 +120,7 @@ export class ReferralSyncedHandler {
|
||||||
|
|
||||||
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
||||||
|
|
||||||
// 尝试查找推荐人的 account_sequence(使用事务客户端)
|
// 尝试查找推荐人的 account_sequence
|
||||||
let referrerAccountSequence: string | null = null;
|
let referrerAccountSequence: string | null = null;
|
||||||
if (referrerUserId) {
|
if (referrerUserId) {
|
||||||
const referrer = await tx.syncedReferral.findFirst({
|
const referrer = await tx.syncedReferral.findFirst({
|
||||||
|
|
@ -150,10 +128,10 @@ export class ReferralSyncedHandler {
|
||||||
});
|
});
|
||||||
if (referrer) {
|
if (referrer) {
|
||||||
referrerAccountSequence = referrer.accountSequence;
|
referrerAccountSequence = referrer.accountSequence;
|
||||||
this.logger.debug(`[CDC] Found referrer account_sequence: ${referrerAccountSequence}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 100%同步数据
|
||||||
await tx.syncedReferral.upsert({
|
await tx.syncedReferral.upsert({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -167,17 +145,17 @@ export class ReferralSyncedHandler {
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
referrerAccountSequence: referrerAccountSequence ?? undefined,
|
referrerAccountSequence,
|
||||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : undefined,
|
referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
|
||||||
originalUserId: originalUserId ? BigInt(originalUserId) : undefined,
|
originalUserId: originalUserId ? BigInt(originalUserId) : null,
|
||||||
ancestorPath: ancestorPath ?? undefined,
|
ancestorPath,
|
||||||
depth: depth ?? undefined,
|
depth,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[CDC] Referral updated successfully: ${accountSequence}`);
|
this.logger.log(`[CDC] Referral synced: ${accountSequence}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDelete(data: any): Promise<void> {
|
private async handleDelete(data: any): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import { ContributionAccountAggregate } from '../../domain/aggregates/contributi
|
||||||
* 用户 CDC 事件处理器
|
* 用户 CDC 事件处理器
|
||||||
* 处理从身份服务同步过来的用户数据
|
* 处理从身份服务同步过来的用户数据
|
||||||
*
|
*
|
||||||
* 注意:此 handler 现在接收外部传入的事务客户端(tx),
|
* 设计说明:100%同步数据,不跳过任何字段更新
|
||||||
* 所有数据库操作都必须使用此事务客户端执行,
|
|
||||||
* 以确保幂等记录和业务数据在同一事务中处理。
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSyncedHandler {
|
export class UserSyncedHandler {
|
||||||
|
|
@ -49,22 +47,19 @@ export class UserSyncedHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容不同的字段命名(CDC 使用 snake_case)
|
|
||||||
const userId = data.user_id ?? data.id;
|
const userId = data.user_id ?? data.id;
|
||||||
const accountSequence = data.account_sequence ?? data.accountSequence;
|
const accountSequence = data.account_sequence ?? data.accountSequence;
|
||||||
const phone = data.phone_number ?? data.phone ?? null;
|
const phone = data.phone_number ?? data.phone ?? null;
|
||||||
const status = data.status ?? 'ACTIVE';
|
const status = data.status ?? null;
|
||||||
|
|
||||||
this.logger.log(`[CDC] User create: userId=${userId}, accountSequence=${accountSequence}, phone=${phone}, status=${status}`);
|
this.logger.log(`[CDC] User create: userId=${userId}, accountSequence=${accountSequence}, status=${status}`);
|
||||||
|
|
||||||
if (!userId || !accountSequence) {
|
if (!userId || !accountSequence) {
|
||||||
this.logger.warn(`[CDC] Invalid user data: missing user_id or account_sequence`, { data });
|
this.logger.warn(`[CDC] Invalid user data: missing user_id or account_sequence`, { data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用外部事务客户端执行所有操作
|
// 100%同步数据
|
||||||
// 保存同步的用户数据
|
|
||||||
this.logger.log(`[CDC] Upserting synced user: ${accountSequence}`);
|
|
||||||
await tx.syncedUser.upsert({
|
await tx.syncedUser.upsert({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -76,8 +71,9 @@ export class UserSyncedHandler {
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
phone: phone ?? undefined,
|
originalUserId: BigInt(userId),
|
||||||
status: status ?? undefined,
|
phone,
|
||||||
|
status,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
@ -95,11 +91,9 @@ export class UserSyncedHandler {
|
||||||
data: persistData,
|
data: persistData,
|
||||||
});
|
});
|
||||||
this.logger.log(`[CDC] Created contribution account for user: ${accountSequence}`);
|
this.logger.log(`[CDC] Created contribution account for user: ${accountSequence}`);
|
||||||
} else {
|
|
||||||
this.logger.debug(`[CDC] Contribution account already exists for user: ${accountSequence}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[CDC] User synced successfully: ${accountSequence}`);
|
this.logger.log(`[CDC] User synced: ${accountSequence}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
||||||
|
|
@ -108,11 +102,10 @@ export class UserSyncedHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容不同的字段命名(CDC 使用 snake_case)
|
|
||||||
const userId = data.user_id ?? data.id;
|
const userId = data.user_id ?? data.id;
|
||||||
const accountSequence = data.account_sequence ?? data.accountSequence;
|
const accountSequence = data.account_sequence ?? data.accountSequence;
|
||||||
const phone = data.phone_number ?? data.phone ?? null;
|
const phone = data.phone_number ?? data.phone ?? null;
|
||||||
const status = data.status ?? 'ACTIVE';
|
const status = data.status ?? null;
|
||||||
|
|
||||||
this.logger.log(`[CDC] User update: userId=${userId}, accountSequence=${accountSequence}, status=${status}`);
|
this.logger.log(`[CDC] User update: userId=${userId}, accountSequence=${accountSequence}, status=${status}`);
|
||||||
|
|
||||||
|
|
@ -121,6 +114,7 @@ export class UserSyncedHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 100%同步数据
|
||||||
await tx.syncedUser.upsert({
|
await tx.syncedUser.upsert({
|
||||||
where: { accountSequence },
|
where: { accountSequence },
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -132,14 +126,15 @@ export class UserSyncedHandler {
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
phone: phone ?? undefined,
|
originalUserId: BigInt(userId),
|
||||||
status: status ?? undefined,
|
phone,
|
||||||
|
status,
|
||||||
sourceSequenceNum: sequenceNum,
|
sourceSequenceNum: sequenceNum,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[CDC] User updated successfully: ${accountSequence}`);
|
this.logger.log(`[CDC] User synced: ${accountSequence}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDelete(data: any): Promise<void> {
|
private async handleDelete(data: any): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -183,16 +183,16 @@ export class GetContributionAccountQuery {
|
||||||
|
|
||||||
private toRecordDto(record: any): ContributionRecordDto {
|
private toRecordDto(record: any): ContributionRecordDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id?.toString() ?? '',
|
||||||
sourceType: record.sourceType,
|
sourceType: record.sourceType,
|
||||||
sourceAdoptionId: record.sourceAdoptionId,
|
sourceAdoptionId: record.sourceAdoptionId?.toString() ?? '',
|
||||||
sourceAccountSequence: record.sourceAccountSequence,
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
treeCount: record.treeCount,
|
treeCount: record.treeCount,
|
||||||
baseContribution: record.baseContribution.value.toString(),
|
baseContribution: record.baseContribution?.value?.toString() ?? '0',
|
||||||
distributionRate: record.distributionRate.value.toString(),
|
distributionRate: record.distributionRate?.value?.toString() ?? '0',
|
||||||
levelDepth: record.levelDepth,
|
levelDepth: record.levelDepth,
|
||||||
bonusTier: record.bonusTier,
|
bonusTier: record.bonusTier,
|
||||||
finalContribution: record.finalContribution.value.toString(),
|
finalContribution: record.amount?.value?.toString() ?? '0',
|
||||||
effectiveDate: record.effectiveDate,
|
effectiveDate: record.effectiveDate,
|
||||||
expireDate: record.expireDate,
|
expireDate: record.expireDate,
|
||||||
isExpired: record.isExpired,
|
isExpired: record.isExpired,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
import { UnallocatedContributionRepository } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
import { UnallocatedContributionRepository } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||||
|
|
@ -6,6 +7,15 @@ import { SystemAccountRepository } from '../../infrastructure/persistence/reposi
|
||||||
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
|
||||||
|
// 基准算力常量
|
||||||
|
const BASE_CONTRIBUTION_PER_TREE = new Decimal('22617');
|
||||||
|
const RATE_PERSONAL = new Decimal('0.70');
|
||||||
|
const RATE_OPERATION = new Decimal('0.12');
|
||||||
|
const RATE_PROVINCE = new Decimal('0.01');
|
||||||
|
const RATE_CITY = new Decimal('0.02');
|
||||||
|
const RATE_LEVEL_TOTAL = new Decimal('0.075');
|
||||||
|
const RATE_BONUS_TOTAL = new Decimal('0.075');
|
||||||
|
|
||||||
export interface ContributionStatsDto {
|
export interface ContributionStatsDto {
|
||||||
// 用户统计
|
// 用户统计
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
|
|
@ -16,17 +26,57 @@ export interface ContributionStatsDto {
|
||||||
totalAdoptions: number;
|
totalAdoptions: number;
|
||||||
processedAdoptions: number;
|
processedAdoptions: number;
|
||||||
unprocessedAdoptions: number;
|
unprocessedAdoptions: number;
|
||||||
|
totalTrees: number;
|
||||||
|
|
||||||
// 算力统计
|
// 算力统计
|
||||||
totalContribution: string;
|
totalContribution: string;
|
||||||
|
|
||||||
// 算力分布
|
// 算力分布(基础)
|
||||||
contributionByType: {
|
contributionByType: {
|
||||||
personal: string;
|
personal: string;
|
||||||
teamLevel: string;
|
teamLevel: string;
|
||||||
teamBonus: string;
|
teamBonus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== 详细算力分解(按用户需求) ==========
|
||||||
|
// 全网算力 = 总认种树 * 22617
|
||||||
|
networkTotalContribution: string;
|
||||||
|
// 个人用户总算力 = 总认种树 * (22617 * 70%)
|
||||||
|
personalTotalContribution: string;
|
||||||
|
// 运营账户总算力 = 总认种树 * (22617 * 12%)
|
||||||
|
operationTotalContribution: string;
|
||||||
|
// 省公司总算力 = 总认种树 * (22617 * 1%)
|
||||||
|
provinceTotalContribution: string;
|
||||||
|
// 市公司总算力 = 总认种树 * (22617 * 2%)
|
||||||
|
cityTotalContribution: string;
|
||||||
|
|
||||||
|
// 层级算力详情 (7.5%)
|
||||||
|
levelContribution: {
|
||||||
|
total: string;
|
||||||
|
unlocked: string;
|
||||||
|
pending: string;
|
||||||
|
byTier: {
|
||||||
|
// 1档: 1-5级
|
||||||
|
tier1: { unlocked: string; pending: string };
|
||||||
|
// 2档: 6-10级
|
||||||
|
tier2: { unlocked: string; pending: string };
|
||||||
|
// 3档: 11-15级
|
||||||
|
tier3: { unlocked: string; pending: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 团队奖励算力详情 (7.5%)
|
||||||
|
bonusContribution: {
|
||||||
|
total: string;
|
||||||
|
unlocked: string;
|
||||||
|
pending: string;
|
||||||
|
byTier: {
|
||||||
|
tier1: { unlocked: string; pending: string };
|
||||||
|
tier2: { unlocked: string; pending: string };
|
||||||
|
tier3: { unlocked: string; pending: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 系统账户
|
// 系统账户
|
||||||
systemAccounts: {
|
systemAccounts: {
|
||||||
accountType: string;
|
accountType: string;
|
||||||
|
|
@ -61,6 +111,10 @@ export class GetContributionStatsQuery {
|
||||||
systemAccounts,
|
systemAccounts,
|
||||||
totalUnallocated,
|
totalUnallocated,
|
||||||
unallocatedByType,
|
unallocatedByType,
|
||||||
|
detailedStats,
|
||||||
|
unallocatedByLevelTier,
|
||||||
|
unallocatedByBonusTier,
|
||||||
|
totalTrees,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.syncedDataRepository.countUsers(),
|
this.syncedDataRepository.countUsers(),
|
||||||
this.accountRepository.countAccounts(),
|
this.accountRepository.countAccounts(),
|
||||||
|
|
@ -72,8 +126,33 @@ export class GetContributionStatsQuery {
|
||||||
this.systemAccountRepository.findAll(),
|
this.systemAccountRepository.findAll(),
|
||||||
this.unallocatedRepository.getTotalUnallocated(),
|
this.unallocatedRepository.getTotalUnallocated(),
|
||||||
this.unallocatedRepository.getTotalUnallocatedByType(),
|
this.unallocatedRepository.getTotalUnallocatedByType(),
|
||||||
|
this.accountRepository.getDetailedContributionStats(),
|
||||||
|
this.unallocatedRepository.getUnallocatedByLevelTier(),
|
||||||
|
this.unallocatedRepository.getUnallocatedByBonusTier(),
|
||||||
|
this.syncedDataRepository.getTotalTrees(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 计算理论算力(基于总认种树 * 基准算力)
|
||||||
|
const networkTotal = BASE_CONTRIBUTION_PER_TREE.mul(totalTrees);
|
||||||
|
const personalTotal = networkTotal.mul(RATE_PERSONAL);
|
||||||
|
const operationTotal = networkTotal.mul(RATE_OPERATION);
|
||||||
|
const provinceTotal = networkTotal.mul(RATE_PROVINCE);
|
||||||
|
const cityTotal = networkTotal.mul(RATE_CITY);
|
||||||
|
const levelTotal = networkTotal.mul(RATE_LEVEL_TOTAL);
|
||||||
|
const bonusTotal = networkTotal.mul(RATE_BONUS_TOTAL);
|
||||||
|
|
||||||
|
// 层级算力: 已解锁 + 未解锁
|
||||||
|
const levelUnlocked = new Decimal(detailedStats.levelUnlocked);
|
||||||
|
const levelPending = new Decimal(unallocatedByLevelTier.tier1)
|
||||||
|
.plus(unallocatedByLevelTier.tier2)
|
||||||
|
.plus(unallocatedByLevelTier.tier3);
|
||||||
|
|
||||||
|
// 团队奖励算力: 已解锁 + 未解锁
|
||||||
|
const bonusUnlocked = new Decimal(detailedStats.bonusUnlocked);
|
||||||
|
const bonusPending = new Decimal(unallocatedByBonusTier.tier1)
|
||||||
|
.plus(unallocatedByBonusTier.tier2)
|
||||||
|
.plus(unallocatedByBonusTier.tier3);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalUsers,
|
totalUsers,
|
||||||
totalAccounts,
|
totalAccounts,
|
||||||
|
|
@ -81,12 +160,63 @@ export class GetContributionStatsQuery {
|
||||||
totalAdoptions,
|
totalAdoptions,
|
||||||
processedAdoptions: totalAdoptions - undistributedAdoptions,
|
processedAdoptions: totalAdoptions - undistributedAdoptions,
|
||||||
unprocessedAdoptions: undistributedAdoptions,
|
unprocessedAdoptions: undistributedAdoptions,
|
||||||
|
totalTrees,
|
||||||
totalContribution: totalContribution.value.toString(),
|
totalContribution: totalContribution.value.toString(),
|
||||||
contributionByType: {
|
contributionByType: {
|
||||||
personal: (contributionByType.get(ContributionSourceType.PERSONAL)?.value || 0).toString(),
|
personal: (contributionByType.get(ContributionSourceType.PERSONAL)?.value || 0).toString(),
|
||||||
teamLevel: (contributionByType.get(ContributionSourceType.TEAM_LEVEL)?.value || 0).toString(),
|
teamLevel: (contributionByType.get(ContributionSourceType.TEAM_LEVEL)?.value || 0).toString(),
|
||||||
teamBonus: (contributionByType.get(ContributionSourceType.TEAM_BONUS)?.value || 0).toString(),
|
teamBonus: (contributionByType.get(ContributionSourceType.TEAM_BONUS)?.value || 0).toString(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 详细算力分解
|
||||||
|
networkTotalContribution: networkTotal.toString(),
|
||||||
|
personalTotalContribution: personalTotal.toString(),
|
||||||
|
operationTotalContribution: operationTotal.toString(),
|
||||||
|
provinceTotalContribution: provinceTotal.toString(),
|
||||||
|
cityTotalContribution: cityTotal.toString(),
|
||||||
|
|
||||||
|
// 层级算力详情
|
||||||
|
levelContribution: {
|
||||||
|
total: levelTotal.toString(),
|
||||||
|
unlocked: levelUnlocked.toString(),
|
||||||
|
pending: levelPending.toString(),
|
||||||
|
byTier: {
|
||||||
|
tier1: {
|
||||||
|
unlocked: detailedStats.levelByTier.tier1.unlocked,
|
||||||
|
pending: unallocatedByLevelTier.tier1,
|
||||||
|
},
|
||||||
|
tier2: {
|
||||||
|
unlocked: detailedStats.levelByTier.tier2.unlocked,
|
||||||
|
pending: unallocatedByLevelTier.tier2,
|
||||||
|
},
|
||||||
|
tier3: {
|
||||||
|
unlocked: detailedStats.levelByTier.tier3.unlocked,
|
||||||
|
pending: unallocatedByLevelTier.tier3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 团队奖励算力详情
|
||||||
|
bonusContribution: {
|
||||||
|
total: bonusTotal.toString(),
|
||||||
|
unlocked: bonusUnlocked.toString(),
|
||||||
|
pending: bonusPending.toString(),
|
||||||
|
byTier: {
|
||||||
|
tier1: {
|
||||||
|
unlocked: detailedStats.bonusByTier.tier1.unlocked,
|
||||||
|
pending: unallocatedByBonusTier.tier1,
|
||||||
|
},
|
||||||
|
tier2: {
|
||||||
|
unlocked: detailedStats.bonusByTier.tier2.unlocked,
|
||||||
|
pending: unallocatedByBonusTier.tier2,
|
||||||
|
},
|
||||||
|
tier3: {
|
||||||
|
unlocked: detailedStats.bonusByTier.tier3.unlocked,
|
||||||
|
pending: unallocatedByBonusTier.tier3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
systemAccounts: systemAccounts.map((a) => ({
|
systemAccounts: systemAccounts.map((a) => ({
|
||||||
accountType: a.accountType,
|
accountType: a.accountType,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
|
@ -98,4 +228,5 @@ export class GetContributionStatsQuery {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
|
||||||
|
export interface PlantingRecordDto {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
originalAdoptionId: string;
|
||||||
|
treeCount: number;
|
||||||
|
contributionPerTree: string;
|
||||||
|
totalContribution: string;
|
||||||
|
status: string;
|
||||||
|
adoptionDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlantingSummaryDto {
|
||||||
|
totalOrders: number;
|
||||||
|
totalTreeCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
effectiveTreeCount: number;
|
||||||
|
firstPlantingAt: string | null;
|
||||||
|
lastPlantingAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlantingLedgerDto {
|
||||||
|
summary: PlantingSummaryDto;
|
||||||
|
items: PlantingRecordDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetPlantingLedgerQuery {
|
||||||
|
constructor(private readonly syncedDataRepository: SyncedDataRepository) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<PlantingLedgerDto> {
|
||||||
|
const [summary, ledger] = await Promise.all([
|
||||||
|
this.syncedDataRepository.getPlantingSummary(accountSequence),
|
||||||
|
this.syncedDataRepository.getPlantingLedger(accountSequence, page, pageSize),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalOrders: summary.totalOrders,
|
||||||
|
totalTreeCount: summary.totalTreeCount,
|
||||||
|
totalAmount: summary.totalAmount,
|
||||||
|
effectiveTreeCount: summary.effectiveTreeCount,
|
||||||
|
firstPlantingAt: summary.firstPlantingAt?.toISOString() || null,
|
||||||
|
lastPlantingAt: summary.lastPlantingAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
items: ledger.items.map((item) => ({
|
||||||
|
orderId: item.id.toString(),
|
||||||
|
orderNo: `ORD-${item.originalAdoptionId}`,
|
||||||
|
originalAdoptionId: item.originalAdoptionId.toString(),
|
||||||
|
treeCount: item.treeCount,
|
||||||
|
contributionPerTree: item.contributionPerTree.toString(),
|
||||||
|
totalContribution: item.contributionPerTree.mul(item.treeCount).toString(),
|
||||||
|
status: item.status || 'UNKNOWN',
|
||||||
|
adoptionDate: item.adoptionDate?.toISOString() || null,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total: ledger.total,
|
||||||
|
page: ledger.page,
|
||||||
|
pageSize: ledger.pageSize,
|
||||||
|
totalPages: ledger.totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||||
import { SnapshotService } from '../services/snapshot.service';
|
import { SnapshotService } from '../services/snapshot.service';
|
||||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
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';
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service';
|
import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { ContributionAccountUpdatedEvent } from '../../domain/events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 算力相关定时任务
|
* 算力相关定时任务
|
||||||
|
|
@ -19,6 +21,7 @@ export class ContributionScheduler implements OnModuleInit {
|
||||||
private readonly calculationService: ContributionCalculationService,
|
private readonly calculationService: ContributionCalculationService,
|
||||||
private readonly snapshotService: SnapshotService,
|
private readonly snapshotService: SnapshotService,
|
||||||
private readonly contributionRecordRepository: ContributionRecordRepository,
|
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||||
|
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||||
private readonly outboxRepository: OutboxRepository,
|
private readonly outboxRepository: OutboxRepository,
|
||||||
private readonly kafkaProducer: KafkaProducerService,
|
private readonly kafkaProducer: KafkaProducerService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
|
@ -174,4 +177,128 @@ export class ContributionScheduler implements OnModuleInit {
|
||||||
await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue);
|
await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每10分钟增量发布最近更新的贡献值账户事件
|
||||||
|
* 只同步过去15分钟内有变更的账户,作为实时同步的补充
|
||||||
|
*/
|
||||||
|
@Cron('*/10 * * * *')
|
||||||
|
async publishRecentlyUpdatedAccounts(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:incremental-sync`, 540); // 9分钟锁
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找过去15分钟内更新的账户(比10分钟多5分钟余量,避免遗漏边界情况)
|
||||||
|
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
|
||||||
|
|
||||||
|
const accounts = await this.contributionAccountRepository.findRecentlyUpdated(fifteenMinutesAgo, 500);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = accounts.map((account) => {
|
||||||
|
const event = new ContributionAccountUpdatedEvent(
|
||||||
|
account.accountSequence,
|
||||||
|
account.personalContribution.value.toString(),
|
||||||
|
account.totalLevelPending.value.toString(),
|
||||||
|
account.totalBonusPending.value.toString(),
|
||||||
|
account.effectiveContribution.value.toString(),
|
||||||
|
account.effectiveContribution.value.toString(),
|
||||||
|
account.hasAdopted,
|
||||||
|
account.directReferralAdoptedCount,
|
||||||
|
account.unlockedLevelDepth,
|
||||||
|
account.unlockedBonusTiers,
|
||||||
|
account.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||||
|
aggregateId: account.accountSequence,
|
||||||
|
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||||
|
payload: event.toPayload(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.outboxRepository.saveMany(events);
|
||||||
|
|
||||||
|
this.logger.log(`Incremental sync: published ${accounts.length} recently updated accounts`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to publish recently updated accounts', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:incremental-sync`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天凌晨4点全量发布所有贡献值账户更新事件
|
||||||
|
* 作为数据一致性的最终兜底保障
|
||||||
|
*/
|
||||||
|
@Cron('0 4 * * *')
|
||||||
|
async publishAllAccountUpdates(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:full-sync`, 3600); // 1小时锁
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log('Starting daily full sync of contribution accounts...');
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
const pageSize = 100;
|
||||||
|
let totalPublished = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { items: accounts, total } = await this.contributionAccountRepository.findMany({
|
||||||
|
page,
|
||||||
|
limit: pageSize,
|
||||||
|
orderBy: 'effectiveContribution',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = accounts.map((account) => {
|
||||||
|
const event = new ContributionAccountUpdatedEvent(
|
||||||
|
account.accountSequence,
|
||||||
|
account.personalContribution.value.toString(),
|
||||||
|
account.totalLevelPending.value.toString(),
|
||||||
|
account.totalBonusPending.value.toString(),
|
||||||
|
account.effectiveContribution.value.toString(),
|
||||||
|
account.effectiveContribution.value.toString(),
|
||||||
|
account.hasAdopted,
|
||||||
|
account.directReferralAdoptedCount,
|
||||||
|
account.unlockedLevelDepth,
|
||||||
|
account.unlockedBonusTiers,
|
||||||
|
account.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||||
|
aggregateId: account.accountSequence,
|
||||||
|
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||||
|
payload: event.toPayload(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.outboxRepository.saveMany(events);
|
||||||
|
totalPublished += accounts.length;
|
||||||
|
|
||||||
|
if (accounts.length < pageSize || page * pageSize >= total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Daily full sync completed: published ${totalPublished} contribution account events`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to publish all account updates', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:full-sync`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { UnallocatedContributionRepository, UnallocatedContribution } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
import { ContributionRecordAggregate } from '../../domain/aggregates/contribution-record.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 { ContributionRecordSyncedEvent } from '../../domain/events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 奖励补发服务
|
||||||
|
* 当用户解锁新的奖励档位时,补发之前所有认种对应的奖励
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BonusClaimService {
|
||||||
|
private readonly logger = new Logger(BonusClaimService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly unallocatedContributionRepository: UnallocatedContributionRepository,
|
||||||
|
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||||
|
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并处理奖励补发
|
||||||
|
* 当用户的直推认种人数变化时调用
|
||||||
|
* @param accountSequence 用户账号
|
||||||
|
* @param previousCount 之前的直推认种人数
|
||||||
|
* @param newCount 新的直推认种人数
|
||||||
|
*/
|
||||||
|
async checkAndClaimBonus(
|
||||||
|
accountSequence: string,
|
||||||
|
previousCount: number,
|
||||||
|
newCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
// 检查是否达到新的解锁条件
|
||||||
|
const tiersToClaimList: number[] = [];
|
||||||
|
|
||||||
|
// T2: 直推≥2人认种时解锁
|
||||||
|
if (previousCount < 2 && newCount >= 2) {
|
||||||
|
tiersToClaimList.push(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T3: 直推≥4人认种时解锁
|
||||||
|
if (previousCount < 4 && newCount >= 4) {
|
||||||
|
tiersToClaimList.push(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tiersToClaimList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User ${accountSequence} unlocked bonus tiers: ${tiersToClaimList.join(', ')} ` +
|
||||||
|
`(directReferralAdoptedCount: ${previousCount} -> ${newCount})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查是否已在事务中(被 ContributionCalculationService 调用时)
|
||||||
|
// 如果已在事务中,直接执行,避免嵌套事务导致超时
|
||||||
|
if (this.unitOfWork.isInTransaction()) {
|
||||||
|
for (const tier of tiersToClaimList) {
|
||||||
|
await this.claimBonusTier(accountSequence, tier);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 独立调用时,开启新事务
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
for (const tier of tiersToClaimList) {
|
||||||
|
await this.claimBonusTier(accountSequence, tier);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补发指定档位的奖励
|
||||||
|
*/
|
||||||
|
private async claimBonusTier(accountSequence: string, bonusTier: number): Promise<void> {
|
||||||
|
// 1. 查询待领取的记录
|
||||||
|
const pendingRecords = await this.unallocatedContributionRepository.findPendingBonusByAccountSequence(
|
||||||
|
accountSequence,
|
||||||
|
bonusTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingRecords.length === 0) {
|
||||||
|
this.logger.debug(`No pending T${bonusTier} bonus records for ${accountSequence}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Claiming ${pendingRecords.length} T${bonusTier} bonus records for ${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 创建贡献值记录
|
||||||
|
const contributionRecords: ContributionRecordAggregate[] = [];
|
||||||
|
for (const pending of pendingRecords) {
|
||||||
|
const record = new ContributionRecordAggregate({
|
||||||
|
accountSequence: accountSequence,
|
||||||
|
sourceType: ContributionSourceType.TEAM_BONUS,
|
||||||
|
sourceAdoptionId: pending.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: pending.sourceAccountSequence,
|
||||||
|
treeCount: 0, // 补发记录不记录树数
|
||||||
|
baseContribution: new ContributionAmount(0),
|
||||||
|
distributionRate: DistributionRate.BONUS_PER,
|
||||||
|
bonusTier: bonusTier,
|
||||||
|
amount: pending.amount,
|
||||||
|
effectiveDate: pending.effectiveDate,
|
||||||
|
expireDate: pending.expireDate,
|
||||||
|
});
|
||||||
|
contributionRecords.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 保存贡献值记录
|
||||||
|
const savedRecords = await this.contributionRecordRepository.saveMany(contributionRecords);
|
||||||
|
|
||||||
|
// 4. 更新用户的贡献值账户
|
||||||
|
let totalAmount = new ContributionAmount(0);
|
||||||
|
for (const pending of pendingRecords) {
|
||||||
|
totalAmount = new ContributionAmount(totalAmount.value.plus(pending.amount.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contributionAccountRepository.updateContribution(
|
||||||
|
accountSequence,
|
||||||
|
ContributionSourceType.TEAM_BONUS,
|
||||||
|
totalAmount,
|
||||||
|
null,
|
||||||
|
bonusTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 标记待领取记录为已分配
|
||||||
|
const pendingIds = pendingRecords.map((r) => r.id);
|
||||||
|
await this.unallocatedContributionRepository.claimBonusRecords(pendingIds, accountSequence);
|
||||||
|
|
||||||
|
// 6. 发布事件到 Kafka(通过 Outbox)
|
||||||
|
await this.publishBonusClaimEvents(accountSequence, savedRecords, pendingRecords);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Claimed T${bonusTier} bonus for ${accountSequence}: ` +
|
||||||
|
`${pendingRecords.length} records, total amount: ${totalAmount.value.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布补发事件
|
||||||
|
*/
|
||||||
|
private async publishBonusClaimEvents(
|
||||||
|
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, index) => ({
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
contributionType: 'TEAM_BONUS',
|
||||||
|
amount: record.amount.value.toString(),
|
||||||
|
bonusTier: record.bonusTier,
|
||||||
|
effectiveDate: record.effectiveDate.toISOString(),
|
||||||
|
expireDate: record.expireDate.toISOString(),
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId.toString(),
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
isBackfill: true, // 标记为补发
|
||||||
|
}));
|
||||||
|
|
||||||
|
const eventId = `bonus-claim-${accountSequence}-${Date.now()}`;
|
||||||
|
const payload = {
|
||||||
|
eventType: 'BonusClaimed',
|
||||||
|
eventId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
accountSequence,
|
||||||
|
bonusTier: savedRecords[0]?.bonusTier,
|
||||||
|
claimedCount: savedRecords.length,
|
||||||
|
userContributions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.save({
|
||||||
|
eventType: 'BonusClaimed',
|
||||||
|
topic: 'contribution.bonus.claimed',
|
||||||
|
key: accountSequence,
|
||||||
|
payload,
|
||||||
|
aggregateId: accountSequence,
|
||||||
|
aggregateType: 'ContributionAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,8 @@ import { ContributionRecordAggregate } from '../../domain/aggregates/contributio
|
||||||
import { SyncedReferral } from '../../domain/repositories/synced-data.repository.interface';
|
import { SyncedReferral } from '../../domain/repositories/synced-data.repository.interface';
|
||||||
import { ContributionDistributionPublisherService } from './contribution-distribution-publisher.service';
|
import { ContributionDistributionPublisherService } from './contribution-distribution-publisher.service';
|
||||||
import { ContributionRateService } from './contribution-rate.service';
|
import { ContributionRateService } from './contribution-rate.service';
|
||||||
import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent } from '../../domain/events';
|
import { BonusClaimService } from './bonus-claim.service';
|
||||||
|
import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent, ContributionAccountUpdatedEvent } from '../../domain/events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 算力计算应用服务
|
* 算力计算应用服务
|
||||||
|
|
@ -33,6 +34,7 @@ export class ContributionCalculationService {
|
||||||
private readonly unitOfWork: UnitOfWork,
|
private readonly unitOfWork: UnitOfWork,
|
||||||
private readonly distributionPublisher: ContributionDistributionPublisherService,
|
private readonly distributionPublisher: ContributionDistributionPublisherService,
|
||||||
private readonly contributionRateService: ContributionRateService,
|
private readonly contributionRateService: ContributionRateService,
|
||||||
|
private readonly bonusClaimService: BonusClaimService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -164,6 +166,8 @@ export class ContributionCalculationService {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 收集所有保存后的记录(带ID)用于发布事件
|
// 收集所有保存后的记录(带ID)用于发布事件
|
||||||
const savedRecords: ContributionRecordAggregate[] = [];
|
const savedRecords: ContributionRecordAggregate[] = [];
|
||||||
|
// 收集所有被更新的账户序列号(用于发布账户更新事件)
|
||||||
|
const updatedAccountSequences = new Set<string>();
|
||||||
|
|
||||||
// 1. 保存个人算力记录
|
// 1. 保存个人算力记录
|
||||||
const savedPersonalRecord = await this.contributionRecordRepository.save(result.personalRecord);
|
const savedPersonalRecord = await this.contributionRecordRepository.save(result.personalRecord);
|
||||||
|
|
@ -178,6 +182,7 @@ export class ContributionCalculationService {
|
||||||
}
|
}
|
||||||
account.addPersonalContribution(result.personalRecord.amount);
|
account.addPersonalContribution(result.personalRecord.amount);
|
||||||
await this.contributionAccountRepository.save(account);
|
await this.contributionAccountRepository.save(account);
|
||||||
|
updatedAccountSequences.add(result.personalRecord.accountSequence);
|
||||||
|
|
||||||
// 2. 保存团队层级算力记录
|
// 2. 保存团队层级算力记录
|
||||||
if (result.teamLevelRecords.length > 0) {
|
if (result.teamLevelRecords.length > 0) {
|
||||||
|
|
@ -193,6 +198,7 @@ export class ContributionCalculationService {
|
||||||
record.levelDepth, // 传递层级深度
|
record.levelDepth, // 传递层级深度
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
updatedAccountSequences.add(record.accountSequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,6 +216,7 @@ export class ContributionCalculationService {
|
||||||
null,
|
null,
|
||||||
record.bonusTier, // 传递加成档位
|
record.bonusTier, // 传递加成档位
|
||||||
);
|
);
|
||||||
|
updatedAccountSequences.add(record.accountSequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,6 +257,23 @@ export class ContributionCalculationService {
|
||||||
|
|
||||||
// 6. 发布算力记录同步事件(用于 mining-admin-service)- 使用保存后带 ID 的记录
|
// 6. 发布算力记录同步事件(用于 mining-admin-service)- 使用保存后带 ID 的记录
|
||||||
await this.publishContributionRecordEvents(savedRecords);
|
await this.publishContributionRecordEvents(savedRecords);
|
||||||
|
|
||||||
|
// 7. 发布所有被更新账户的事件(用于 CDC 同步到 mining-admin-service)
|
||||||
|
await this.publishUpdatedAccountEvents(updatedAccountSequences);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布被更新账户的事件
|
||||||
|
*/
|
||||||
|
private async publishUpdatedAccountEvents(accountSequences: Set<string>): Promise<void> {
|
||||||
|
if (accountSequences.size === 0) return;
|
||||||
|
|
||||||
|
for (const accountSequence of accountSequences) {
|
||||||
|
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (account) {
|
||||||
|
await this.publishContributionAccountUpdatedEvent(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -300,11 +324,15 @@ export class ContributionCalculationService {
|
||||||
if (!account.hasAdopted) {
|
if (!account.hasAdopted) {
|
||||||
account.markAsAdopted();
|
account.markAsAdopted();
|
||||||
await this.contributionAccountRepository.save(account);
|
await this.contributionAccountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布账户更新事件到 outbox(用于 CDC 同步到 mining-admin-service)
|
||||||
|
await this.publishContributionAccountUpdatedEvent(account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新上线的解锁状态(直推用户认种后)
|
* 更新上线的解锁状态(直推用户认种后)
|
||||||
|
* 如果解锁了新的奖励档位,会触发补发逻辑
|
||||||
*/
|
*/
|
||||||
private async updateReferrerUnlockStatus(referrerAccountSequence: string): Promise<void> {
|
private async updateReferrerUnlockStatus(referrerAccountSequence: string): Promise<void> {
|
||||||
const account = await this.contributionAccountRepository.findByAccountSequence(referrerAccountSequence);
|
const account = await this.contributionAccountRepository.findByAccountSequence(referrerAccountSequence);
|
||||||
|
|
@ -316,16 +344,27 @@ export class ContributionCalculationService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新解锁状态
|
// 更新解锁状态
|
||||||
const currentCount = account.directReferralAdoptedCount;
|
const previousCount = account.directReferralAdoptedCount;
|
||||||
if (directReferralAdoptedCount > currentCount) {
|
if (directReferralAdoptedCount > previousCount) {
|
||||||
// 需要增量更新
|
// 需要增量更新
|
||||||
for (let i = currentCount; i < directReferralAdoptedCount; i++) {
|
for (let i = previousCount; i < directReferralAdoptedCount; i++) {
|
||||||
account.incrementDirectReferralAdoptedCount();
|
account.incrementDirectReferralAdoptedCount();
|
||||||
}
|
}
|
||||||
await this.contributionAccountRepository.save(account);
|
await this.contributionAccountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布账户更新事件到 outbox(用于 CDC 同步到 mining-admin-service)
|
||||||
|
await this.publishContributionAccountUpdatedEvent(account);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated referrer ${referrerAccountSequence} unlock status: level=${account.unlockedLevelDepth}, bonus=${account.unlockedBonusTiers}`,
|
`Updated referrer ${referrerAccountSequence} unlock status: level=${account.unlockedLevelDepth}, bonus=${account.unlockedBonusTiers}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 检查并处理奖励补发(T2: 直推≥2人, T3: 直推≥4人)
|
||||||
|
await this.bonusClaimService.checkAndClaimBonus(
|
||||||
|
referrerAccountSequence,
|
||||||
|
previousCount,
|
||||||
|
directReferralAdoptedCount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,4 +432,43 @@ export class ContributionCalculationService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布贡献值账户更新事件(用于 CDC 同步到 mining-admin-service)
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Published ContributionAccountUpdatedEvent for ${account.accountSequence}: ` +
|
||||||
|
`directReferralAdoptedCount=${account.directReferralAdoptedCount}, ` +
|
||||||
|
`hasAdopted=${account.hasAdopted}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* 贡献值账户更新事件
|
||||||
|
* 当账户的 directReferralAdoptedCount, unlockedLevelDepth, unlockedBonusTiers 等字段更新时发布
|
||||||
|
* 用于实时同步到 mining-admin-service
|
||||||
|
*/
|
||||||
|
export class ContributionAccountUpdatedEvent {
|
||||||
|
static readonly EVENT_TYPE = 'ContributionAccountUpdated';
|
||||||
|
static readonly AGGREGATE_TYPE = 'ContributionAccount';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly accountSequence: string,
|
||||||
|
public readonly personalContribution: string,
|
||||||
|
public readonly teamLevelContribution: string,
|
||||||
|
public readonly teamBonusContribution: string,
|
||||||
|
public readonly totalContribution: string,
|
||||||
|
public readonly effectiveContribution: string,
|
||||||
|
public readonly hasAdopted: boolean,
|
||||||
|
public readonly directReferralAdoptedCount: number,
|
||||||
|
public readonly unlockedLevelDepth: number,
|
||||||
|
public readonly unlockedBonusTiers: number,
|
||||||
|
public readonly createdAt: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
toPayload(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||||
|
accountSequence: this.accountSequence,
|
||||||
|
personalContribution: this.personalContribution,
|
||||||
|
teamLevelContribution: this.teamLevelContribution,
|
||||||
|
teamBonusContribution: this.teamBonusContribution,
|
||||||
|
totalContribution: this.totalContribution,
|
||||||
|
effectiveContribution: this.effectiveContribution,
|
||||||
|
hasAdopted: this.hasAdopted,
|
||||||
|
directReferralAdoptedCount: this.directReferralAdoptedCount,
|
||||||
|
unlockedLevelDepth: this.unlockedLevelDepth,
|
||||||
|
unlockedBonusTiers: this.unlockedBonusTiers,
|
||||||
|
createdAt: this.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './contribution-calculated.event';
|
export * from './contribution-calculated.event';
|
||||||
export * from './daily-snapshot-created.event';
|
export * from './daily-snapshot-created.event';
|
||||||
export * from './contribution-account-synced.event';
|
export * from './contribution-account-synced.event';
|
||||||
|
export * from './contribution-account-updated.event';
|
||||||
export * from './referral-synced.event';
|
export * from './referral-synced.event';
|
||||||
export * from './adoption-synced.event';
|
export * from './adoption-synced.event';
|
||||||
export * from './contribution-record-synced.event';
|
export * from './contribution-record-synced.event';
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ export type TransactionalCDCHandlerWithResult<T> = (event: CDCEvent, tx: Transac
|
||||||
/** 事务提交后的回调函数 */
|
/** 事务提交后的回调函数 */
|
||||||
export type PostCommitCallback<T> = (result: T) => Promise<void>;
|
export type PostCommitCallback<T> = (result: T) => Promise<void>;
|
||||||
|
|
||||||
|
/** Topic 消费阶段配置 */
|
||||||
|
export interface TopicPhase {
|
||||||
|
topic: string;
|
||||||
|
tableName: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CDCConsumerService implements OnModuleInit, OnModuleDestroy {
|
export class CDCConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CDCConsumerService.name);
|
private readonly logger = new Logger(CDCConsumerService.name);
|
||||||
|
|
@ -61,6 +67,14 @@ export class CDCConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
private handlers: Map<string, CDCHandler> = new Map();
|
private handlers: Map<string, CDCHandler> = new Map();
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
|
// 分阶段消费配置
|
||||||
|
private topicPhases: TopicPhase[] = [];
|
||||||
|
private currentPhaseIndex = 0;
|
||||||
|
private sequentialMode = false;
|
||||||
|
|
||||||
|
// 初始同步完成标记(只有顺序同步全部完成后才为 true)
|
||||||
|
private initialSyncCompleted = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
|
@ -247,7 +261,14 @@ export class CDCConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动消费者
|
* 启动消费者(顺序模式)
|
||||||
|
*
|
||||||
|
* 按顺序消费三个 topic,确保数据依赖关系正确:
|
||||||
|
* 1. 用户数据 (user_accounts)
|
||||||
|
* 2. 推荐关系 (referral_relationships) - 依赖用户数据
|
||||||
|
* 3. 认种订单 (planting_orders) - 依赖用户和推荐关系
|
||||||
|
*
|
||||||
|
* 每个阶段必须完全消费完毕后才进入下一阶段
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
|
|
@ -259,36 +280,213 @@ export class CDCConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
await this.consumer.connect();
|
await this.consumer.connect();
|
||||||
this.logger.log('CDC consumer connected');
|
this.logger.log('CDC consumer connected');
|
||||||
|
|
||||||
// 订阅 Debezium CDC topics (从1.0服务全量同步)
|
// 配置顺序消费阶段(顺序很重要!)
|
||||||
const topics = [
|
this.topicPhases = [
|
||||||
// 用户账户表 (identity-service: user_accounts)
|
{
|
||||||
this.configService.get<string>('CDC_TOPIC_USERS', 'cdc.identity.public.user_accounts'),
|
topic: this.configService.get<string>('CDC_TOPIC_USERS', 'cdc.identity.public.user_accounts'),
|
||||||
// 认种订单表 (planting-service: planting_orders)
|
tableName: 'user_accounts',
|
||||||
this.configService.get<string>('CDC_TOPIC_ADOPTIONS', 'cdc.planting.public.planting_orders'),
|
},
|
||||||
// 推荐关系表 (referral-service: referral_relationships)
|
{
|
||||||
this.configService.get<string>('CDC_TOPIC_REFERRALS', 'cdc.referral.public.referral_relationships'),
|
topic: this.configService.get<string>('CDC_TOPIC_REFERRALS', 'cdc.referral.public.referral_relationships'),
|
||||||
|
tableName: 'referral_relationships',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: this.configService.get<string>('CDC_TOPIC_ADOPTIONS', 'cdc.planting.public.planting_orders'),
|
||||||
|
tableName: 'planting_orders',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
await this.consumer.subscribe({
|
this.currentPhaseIndex = 0;
|
||||||
topics,
|
this.sequentialMode = true;
|
||||||
fromBeginning: true, // 首次启动时全量同步历史数据
|
|
||||||
});
|
|
||||||
this.logger.log(`Subscribed to topics: ${topics.join(', ')}`);
|
|
||||||
|
|
||||||
await this.consumer.run({
|
|
||||||
eachMessage: async (payload: EachMessagePayload) => {
|
|
||||||
await this.handleMessage(payload);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.logger.log('CDC consumer started with transactional idempotency protection');
|
|
||||||
|
// 开始顺序消费(阻塞直到完成,确保数据依赖顺序正确)
|
||||||
|
await this.startSequentialConsumption();
|
||||||
|
|
||||||
|
this.logger.log('CDC consumer started with sequential phase consumption');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to start CDC consumer', error);
|
this.logger.error('Failed to start CDC consumer', error);
|
||||||
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顺序消费所有阶段
|
||||||
|
*/
|
||||||
|
private async startSequentialConsumption(): Promise<void> {
|
||||||
|
for (let i = 0; i < this.topicPhases.length; i++) {
|
||||||
|
this.currentPhaseIndex = i;
|
||||||
|
const phase = this.topicPhases[i];
|
||||||
|
|
||||||
|
this.logger.log(`[CDC] Starting phase ${i + 1}/${this.topicPhases.length}: ${phase.tableName} (${phase.topic})`);
|
||||||
|
|
||||||
|
// 消费当前阶段直到追上最新
|
||||||
|
await this.consumePhaseToEnd(phase);
|
||||||
|
|
||||||
|
this.logger.log(`[CDC] Completed phase ${i + 1}/${this.topicPhases.length}: ${phase.tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('[CDC] All phases completed. Switching to continuous mode...');
|
||||||
|
|
||||||
|
// 所有阶段完成后,切换到持续消费模式(同时监听所有 topic)
|
||||||
|
await this.startContinuousMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消费单个阶段直到追上最新消息
|
||||||
|
*/
|
||||||
|
private async consumePhaseToEnd(phase: TopicPhase): Promise<void> {
|
||||||
|
const admin = this.kafka.admin();
|
||||||
|
await admin.connect();
|
||||||
|
|
||||||
|
// 获取 topic 的高水位线和最早 offset
|
||||||
|
const topicOffsets = await admin.fetchTopicOffsets(phase.topic);
|
||||||
|
const highWatermarks: Map<number, string> = new Map();
|
||||||
|
const earliestOffsets: Map<number, string> = new Map();
|
||||||
|
|
||||||
|
for (const partitionOffset of topicOffsets) {
|
||||||
|
highWatermarks.set(partitionOffset.partition, partitionOffset.high);
|
||||||
|
earliestOffsets.set(partitionOffset.partition, partitionOffset.low);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: High watermarks = ${JSON.stringify(Object.fromEntries(highWatermarks))}`);
|
||||||
|
|
||||||
|
// 检查是否 topic 为空
|
||||||
|
const allEmpty = Array.from(highWatermarks.values()).every(hw => hw === '0');
|
||||||
|
if (allEmpty) {
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: Topic is empty, skipping`);
|
||||||
|
await admin.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用固定的 group id
|
||||||
|
const phaseGroupId = `contribution-service-cdc-phase-${phase.tableName}`;
|
||||||
|
|
||||||
|
// 重置 consumer group 的 offset 到最早位置
|
||||||
|
// 使用 admin.resetOffsets 而不是 setOffsets,更简洁且专门用于重置到 earliest/latest
|
||||||
|
// 这确保每次服务启动都会从头开始消费,不受之前 committed offset 影响
|
||||||
|
// 参考: https://kafka.js.org/docs/admin#a-name-reset-offsets-a-resetoffsets
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: Resetting consumer group ${phaseGroupId} offsets to earliest`);
|
||||||
|
try {
|
||||||
|
await admin.resetOffsets({
|
||||||
|
groupId: phaseGroupId,
|
||||||
|
topic: phase.topic,
|
||||||
|
earliest: true,
|
||||||
|
});
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: Consumer group offsets reset successfully`);
|
||||||
|
} catch (resetError: any) {
|
||||||
|
// 如果 consumer group 不存在,resetOffsets 会失败,这是正常的(首次运行)
|
||||||
|
// fromBeginning: true 会在这种情况下生效
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: Could not reset offsets (may be first run): ${resetError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseConsumer = this.kafka.consumer({
|
||||||
|
groupId: phaseGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await phaseConsumer.connect();
|
||||||
|
|
||||||
|
// 订阅单个 topic,fromBeginning 对新 group 有效
|
||||||
|
await phaseConsumer.subscribe({
|
||||||
|
topic: phase.topic,
|
||||||
|
fromBeginning: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let processedOffsets: Map<number, bigint> = new Map();
|
||||||
|
let isComplete = false;
|
||||||
|
|
||||||
|
for (const partition of highWatermarks.keys()) {
|
||||||
|
processedOffsets.set(partition, BigInt(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始消费
|
||||||
|
await phaseConsumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
await this.handleMessage(payload);
|
||||||
|
|
||||||
|
// 更新已处理的 offset
|
||||||
|
processedOffsets.set(payload.partition, BigInt(payload.message.offset));
|
||||||
|
|
||||||
|
// 检查是否所有 partition 都已追上高水位线
|
||||||
|
let allCaughtUp = true;
|
||||||
|
for (const [partition, highWatermark] of highWatermarks) {
|
||||||
|
const processed = processedOffsets.get(partition) ?? BigInt(-1);
|
||||||
|
// 高水位线是下一个将被写入的 offset,所以已处理的 offset 需要 >= highWatermark - 1
|
||||||
|
if (processed < BigInt(highWatermark) - BigInt(1)) {
|
||||||
|
allCaughtUp = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCaughtUp && !isComplete) {
|
||||||
|
isComplete = true;
|
||||||
|
this.logger.log(`[CDC] Phase ${phase.tableName}: Caught up with all partitions`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待追上高水位线
|
||||||
|
while (!isComplete) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 每秒检查一次进度
|
||||||
|
const currentProgress = Array.from(processedOffsets.entries())
|
||||||
|
.map(([p, o]) => `P${p}:${o}/${highWatermarks.get(p)}`)
|
||||||
|
.join(', ');
|
||||||
|
this.logger.debug(`[CDC] Phase ${phase.tableName} progress: ${currentProgress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止消费
|
||||||
|
await phaseConsumer.stop();
|
||||||
|
await phaseConsumer.disconnect();
|
||||||
|
await admin.disconnect();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[CDC] Error in phase ${phase.tableName}`, error);
|
||||||
|
await phaseConsumer.disconnect();
|
||||||
|
await admin.disconnect();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到持续消费模式(所有 topic 同时消费)
|
||||||
|
*/
|
||||||
|
private async startContinuousMode(): Promise<void> {
|
||||||
|
this.sequentialMode = false;
|
||||||
|
this.initialSyncCompleted = true; // 标记初始同步完成
|
||||||
|
|
||||||
|
const topics = this.topicPhases.map(p => p.topic);
|
||||||
|
|
||||||
|
await this.consumer.subscribe({
|
||||||
|
topics,
|
||||||
|
fromBeginning: false, // 从上次消费的位置继续(不是从头开始)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[CDC] Continuous mode: Subscribed to topics: ${topics.join(', ')}`);
|
||||||
|
|
||||||
|
await this.consumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
await this.handleMessage(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('[CDC] Continuous mode started - all topics being consumed in parallel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CDC 同步状态
|
||||||
|
* - initialSyncCompleted = true: 初始顺序同步已完成
|
||||||
|
*/
|
||||||
|
getSyncStatus(): { isRunning: boolean; sequentialMode: boolean; allPhasesCompleted: boolean } {
|
||||||
|
return {
|
||||||
|
isRunning: this.isRunning,
|
||||||
|
sequentialMode: this.sequentialMode,
|
||||||
|
allPhasesCompleted: this.initialSyncCompleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止消费者
|
* 停止消费者
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,117 @@ export class ContributionAccountRepository implements IContributionAccountReposi
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRecentlyUpdated(since: Date, limit: number = 500): Promise<ContributionAccountAggregate[]> {
|
||||||
|
const records = await this.client.contributionAccount.findMany({
|
||||||
|
where: { updatedAt: { gte: since } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取详细算力汇总(按类型分解)
|
||||||
|
*/
|
||||||
|
async getDetailedContributionStats(): Promise<{
|
||||||
|
// 个人算力总计
|
||||||
|
personalTotal: string;
|
||||||
|
// 层级算力 - 已解锁(已分配给上线)
|
||||||
|
levelUnlocked: string;
|
||||||
|
// 层级算力 - 未解锁(待解锁的pending)
|
||||||
|
levelPending: string;
|
||||||
|
// 层级按档位分解
|
||||||
|
levelByTier: {
|
||||||
|
tier1: { unlocked: string; pending: string }; // 1-5级
|
||||||
|
tier2: { unlocked: string; pending: string }; // 6-10级
|
||||||
|
tier3: { unlocked: string; pending: string }; // 11-15级
|
||||||
|
};
|
||||||
|
// 团队奖励算力 - 已解锁
|
||||||
|
bonusUnlocked: string;
|
||||||
|
// 团队奖励算力 - 未解锁
|
||||||
|
bonusPending: string;
|
||||||
|
// 团队奖励按档位分解
|
||||||
|
bonusByTier: {
|
||||||
|
tier1: { unlocked: string; pending: string };
|
||||||
|
tier2: { unlocked: string; pending: string };
|
||||||
|
tier3: { unlocked: string; pending: string };
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const result = await this.client.contributionAccount.aggregate({
|
||||||
|
_sum: {
|
||||||
|
personalContribution: true,
|
||||||
|
// 层级 1-5
|
||||||
|
level1Pending: true,
|
||||||
|
level2Pending: true,
|
||||||
|
level3Pending: true,
|
||||||
|
level4Pending: true,
|
||||||
|
level5Pending: true,
|
||||||
|
// 层级 6-10
|
||||||
|
level6Pending: true,
|
||||||
|
level7Pending: true,
|
||||||
|
level8Pending: true,
|
||||||
|
level9Pending: true,
|
||||||
|
level10Pending: true,
|
||||||
|
// 层级 11-15
|
||||||
|
level11Pending: true,
|
||||||
|
level12Pending: true,
|
||||||
|
level13Pending: true,
|
||||||
|
level14Pending: true,
|
||||||
|
level15Pending: true,
|
||||||
|
// 团队奖励
|
||||||
|
bonusTier1Pending: true,
|
||||||
|
bonusTier2Pending: true,
|
||||||
|
bonusTier3Pending: true,
|
||||||
|
// 汇总
|
||||||
|
totalLevelPending: true,
|
||||||
|
totalBonusPending: true,
|
||||||
|
totalUnlocked: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sum = result._sum;
|
||||||
|
|
||||||
|
// 层级 1-5 已解锁(在pending字段中存储的是已分配给该用户的层级算力)
|
||||||
|
const level1to5 = new Decimal(sum.level1Pending || 0)
|
||||||
|
.plus(sum.level2Pending || 0)
|
||||||
|
.plus(sum.level3Pending || 0)
|
||||||
|
.plus(sum.level4Pending || 0)
|
||||||
|
.plus(sum.level5Pending || 0);
|
||||||
|
|
||||||
|
// 层级 6-10
|
||||||
|
const level6to10 = new Decimal(sum.level6Pending || 0)
|
||||||
|
.plus(sum.level7Pending || 0)
|
||||||
|
.plus(sum.level8Pending || 0)
|
||||||
|
.plus(sum.level9Pending || 0)
|
||||||
|
.plus(sum.level10Pending || 0);
|
||||||
|
|
||||||
|
// 层级 11-15
|
||||||
|
const level11to15 = new Decimal(sum.level11Pending || 0)
|
||||||
|
.plus(sum.level12Pending || 0)
|
||||||
|
.plus(sum.level13Pending || 0)
|
||||||
|
.plus(sum.level14Pending || 0)
|
||||||
|
.plus(sum.level15Pending || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
personalTotal: (sum.personalContribution || new Decimal(0)).toString(),
|
||||||
|
levelUnlocked: (sum.totalLevelPending || new Decimal(0)).toString(),
|
||||||
|
levelPending: '0', // 未解锁的存储在 unallocated 表中
|
||||||
|
levelByTier: {
|
||||||
|
tier1: { unlocked: level1to5.toString(), pending: '0' },
|
||||||
|
tier2: { unlocked: level6to10.toString(), pending: '0' },
|
||||||
|
tier3: { unlocked: level11to15.toString(), pending: '0' },
|
||||||
|
},
|
||||||
|
bonusUnlocked: (sum.totalBonusPending || new Decimal(0)).toString(),
|
||||||
|
bonusPending: '0', // 未解锁的存储在 unallocated 表中
|
||||||
|
bonusByTier: {
|
||||||
|
tier1: { unlocked: (sum.bonusTier1Pending || new Decimal(0)).toString(), pending: '0' },
|
||||||
|
tier2: { unlocked: (sum.bonusTier2Pending || new Decimal(0)).toString(), pending: '0' },
|
||||||
|
tier3: { unlocked: (sum.bonusTier3Pending || new Decimal(0)).toString(), pending: '0' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private toDomain(record: any): ContributionAccountAggregate {
|
private toDomain(record: any): ContributionAccountAggregate {
|
||||||
return ContributionAccountAggregate.fromPersistence({
|
return ContributionAccountAggregate.fromPersistence({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,10 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
|
||||||
async findUndistributedAdoptions(limit: number = 100): Promise<SyncedAdoption[]> {
|
async findUndistributedAdoptions(limit: number = 100): Promise<SyncedAdoption[]> {
|
||||||
const records = await this.client.syncedAdoption.findMany({
|
const records = await this.client.syncedAdoption.findMany({
|
||||||
where: { contributionDistributed: false },
|
where: {
|
||||||
|
contributionDistributed: false,
|
||||||
|
status: 'MINING_ENABLED', // 只处理最终成功的认种订单
|
||||||
|
},
|
||||||
orderBy: { adoptionDate: 'asc' },
|
orderBy: { adoptionDate: 'asc' },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
@ -171,7 +174,10 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
|
||||||
async getTotalTreesByAccountSequence(accountSequence: string): Promise<number> {
|
async getTotalTreesByAccountSequence(accountSequence: string): Promise<number> {
|
||||||
const result = await this.client.syncedAdoption.aggregate({
|
const result = await this.client.syncedAdoption.aggregate({
|
||||||
where: { accountSequence },
|
where: {
|
||||||
|
accountSequence,
|
||||||
|
status: 'MINING_ENABLED', // 只统计最终成功的认种订单
|
||||||
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
return result._sum.treeCount ?? 0;
|
return result._sum.treeCount ?? 0;
|
||||||
|
|
@ -285,8 +291,12 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
|
||||||
const accountSequences = directReferrals.map((r) => r.accountSequence);
|
const accountSequences = directReferrals.map((r) => r.accountSequence);
|
||||||
|
|
||||||
|
// 只统计有 MINING_ENABLED 状态认种记录的直推用户数
|
||||||
const adoptedCount = await this.client.syncedAdoption.findMany({
|
const adoptedCount = await this.client.syncedAdoption.findMany({
|
||||||
where: { accountSequence: { in: accountSequences } },
|
where: {
|
||||||
|
accountSequence: { in: accountSequences },
|
||||||
|
status: 'MINING_ENABLED', // 只统计最终成功的认种订单
|
||||||
|
},
|
||||||
distinct: ['accountSequence'],
|
distinct: ['accountSequence'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -308,7 +318,10 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
|
||||||
const adoptions = await this.client.syncedAdoption.groupBy({
|
const adoptions = await this.client.syncedAdoption.groupBy({
|
||||||
by: ['accountSequence'],
|
by: ['accountSequence'],
|
||||||
where: { accountSequence: { in: sequences } },
|
where: {
|
||||||
|
accountSequence: { in: sequences },
|
||||||
|
status: 'MINING_ENABLED', // 只统计最终成功的认种订单
|
||||||
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -346,6 +359,89 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 认种分类账查询 ==========
|
||||||
|
|
||||||
|
async getPlantingLedger(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<{
|
||||||
|
items: SyncedAdoption[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
// 只返回 MINING_ENABLED 状态的认种记录
|
||||||
|
const whereClause = { accountSequence, status: 'MINING_ENABLED' };
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.client.syncedAdoption.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { adoptionDate: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.client.syncedAdoption.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((r) => this.toSyncedAdoption(r)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlantingSummary(accountSequence: string): Promise<{
|
||||||
|
totalOrders: number;
|
||||||
|
totalTreeCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
effectiveTreeCount: number;
|
||||||
|
firstPlantingAt: Date | null;
|
||||||
|
lastPlantingAt: Date | null;
|
||||||
|
}> {
|
||||||
|
// 只统计 MINING_ENABLED 状态的认种记录
|
||||||
|
const adoptions = await this.client.syncedAdoption.findMany({
|
||||||
|
where: { accountSequence, status: 'MINING_ENABLED' },
|
||||||
|
orderBy: { adoptionDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adoptions.length === 0) {
|
||||||
|
return {
|
||||||
|
totalOrders: 0,
|
||||||
|
totalTreeCount: 0,
|
||||||
|
totalAmount: '0',
|
||||||
|
effectiveTreeCount: 0,
|
||||||
|
firstPlantingAt: null,
|
||||||
|
lastPlantingAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOrders = adoptions.length;
|
||||||
|
const totalTreeCount = adoptions.reduce((sum, a) => sum + a.treeCount, 0);
|
||||||
|
|
||||||
|
// 计算总金额:treeCount * contributionPerTree
|
||||||
|
let totalAmount = new Decimal(0);
|
||||||
|
for (const adoption of adoptions) {
|
||||||
|
const amount = new Decimal(adoption.contributionPerTree).mul(adoption.treeCount);
|
||||||
|
totalAmount = totalAmount.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOrders,
|
||||||
|
totalTreeCount,
|
||||||
|
totalAmount: totalAmount.toString(),
|
||||||
|
effectiveTreeCount: totalTreeCount, // 全部都是有效的 MINING_ENABLED
|
||||||
|
firstPlantingAt: adoptions[0]?.adoptionDate || null,
|
||||||
|
lastPlantingAt: adoptions[adoptions.length - 1]?.adoptionDate || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 统计方法(用于查询服务)==========
|
// ========== 统计方法(用于查询服务)==========
|
||||||
|
|
||||||
async countUsers(): Promise<number> {
|
async countUsers(): Promise<number> {
|
||||||
|
|
@ -358,10 +454,23 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
|
||||||
async countUndistributedAdoptions(): Promise<number> {
|
async countUndistributedAdoptions(): Promise<number> {
|
||||||
return this.client.syncedAdoption.count({
|
return this.client.syncedAdoption.count({
|
||||||
where: { contributionDistributed: false },
|
where: {
|
||||||
|
contributionDistributed: false,
|
||||||
|
status: 'MINING_ENABLED', // 只统计最终成功的认种订单
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTotalTrees(): Promise<number> {
|
||||||
|
const result = await this.client.syncedAdoption.aggregate({
|
||||||
|
where: {
|
||||||
|
status: 'MINING_ENABLED', // 只统计最终成功的认种订单
|
||||||
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
});
|
||||||
|
return result._sum.treeCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 私有方法 ==========
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
private toSyncedUser(record: any): SyncedUser {
|
private toSyncedUser(record: any): SyncedUser {
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,16 @@ export interface UnallocatedContribution {
|
||||||
unallocType: string;
|
unallocType: string;
|
||||||
wouldBeAccountSequence: string | null;
|
wouldBeAccountSequence: string | null;
|
||||||
levelDepth: number | null;
|
levelDepth: number | null;
|
||||||
|
bonusTier: number | null;
|
||||||
amount: ContributionAmount;
|
amount: ContributionAmount;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
sourceAdoptionId: bigint;
|
sourceAdoptionId: bigint;
|
||||||
sourceAccountSequence: string;
|
sourceAccountSequence: string;
|
||||||
effectiveDate: Date;
|
effectiveDate: Date;
|
||||||
expireDate: Date;
|
expireDate: Date;
|
||||||
allocatedToHeadquarters: boolean;
|
status: string;
|
||||||
allocatedAt: Date | null;
|
allocatedAt: Date | null;
|
||||||
|
allocatedToAccountSequence: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,20 +132,157 @@ export class UnallocatedContributionRepository {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户待领取的奖励档位贡献值
|
||||||
|
* @param accountSequence 用户账号
|
||||||
|
* @param bonusTier 奖励档位 (2 或 3)
|
||||||
|
*/
|
||||||
|
async findPendingBonusByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
bonusTier: number,
|
||||||
|
): Promise<UnallocatedContribution[]> {
|
||||||
|
const records = await this.client.unallocatedContribution.findMany({
|
||||||
|
where: {
|
||||||
|
wouldBeAccountSequence: accountSequence,
|
||||||
|
unallocType: `BONUS_TIER_${bonusTier}`,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取奖励档位 - 将待领取记录标记为已分配给用户
|
||||||
|
* @param ids 记录ID列表
|
||||||
|
* @param accountSequence 分配给的用户账号
|
||||||
|
*/
|
||||||
|
async claimBonusRecords(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户所有待领取的奖励(所有档位)
|
||||||
|
*/
|
||||||
|
async findAllPendingBonusByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<UnallocatedContribution[]> {
|
||||||
|
const records = await this.client.unallocatedContribution.findMany({
|
||||||
|
where: {
|
||||||
|
wouldBeAccountSequence: accountSequence,
|
||||||
|
unallocType: { startsWith: 'BONUS_TIER_' },
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分层级的未分配算力统计
|
||||||
|
*/
|
||||||
|
async getUnallocatedByLevelTier(): Promise<{
|
||||||
|
tier1: string; // 1-5级未分配
|
||||||
|
tier2: string; // 6-10级未分配
|
||||||
|
tier3: string; // 11-15级未分配
|
||||||
|
}> {
|
||||||
|
const results = await this.client.unallocatedContribution.groupBy({
|
||||||
|
by: ['levelDepth'],
|
||||||
|
where: {
|
||||||
|
levelDepth: { not: null },
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let tier1 = new ContributionAmount(0);
|
||||||
|
let tier2 = new ContributionAmount(0);
|
||||||
|
let tier3 = new ContributionAmount(0);
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
const depth = item.levelDepth!;
|
||||||
|
const amount = new ContributionAmount(item._sum.amount || 0);
|
||||||
|
if (depth >= 1 && depth <= 5) {
|
||||||
|
tier1 = tier1.add(amount);
|
||||||
|
} else if (depth >= 6 && depth <= 10) {
|
||||||
|
tier2 = tier2.add(amount);
|
||||||
|
} else if (depth >= 11 && depth <= 15) {
|
||||||
|
tier3 = tier3.add(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier1: tier1.value.toString(),
|
||||||
|
tier2: tier2.value.toString(),
|
||||||
|
tier3: tier3.value.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分档位的未分配奖励统计
|
||||||
|
*/
|
||||||
|
async getUnallocatedByBonusTier(): Promise<{
|
||||||
|
tier1: string;
|
||||||
|
tier2: string;
|
||||||
|
tier3: string;
|
||||||
|
}> {
|
||||||
|
const results = await this.client.unallocatedContribution.groupBy({
|
||||||
|
by: ['unallocType'],
|
||||||
|
where: {
|
||||||
|
unallocType: { startsWith: 'BONUS_TIER_' },
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let tier1 = '0';
|
||||||
|
let tier2 = '0';
|
||||||
|
let tier3 = '0';
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
const amount = (item._sum.amount || 0).toString();
|
||||||
|
if (item.unallocType === 'BONUS_TIER_1') {
|
||||||
|
tier1 = amount;
|
||||||
|
} else if (item.unallocType === 'BONUS_TIER_2') {
|
||||||
|
tier2 = amount;
|
||||||
|
} else if (item.unallocType === 'BONUS_TIER_3') {
|
||||||
|
tier3 = amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tier1, tier2, tier3 };
|
||||||
|
}
|
||||||
|
|
||||||
private toDomain(record: any): UnallocatedContribution {
|
private toDomain(record: any): UnallocatedContribution {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
unallocType: record.unallocType,
|
unallocType: record.unallocType,
|
||||||
wouldBeAccountSequence: record.wouldBeAccountSequence,
|
wouldBeAccountSequence: record.wouldBeAccountSequence,
|
||||||
levelDepth: record.levelDepth,
|
levelDepth: record.levelDepth,
|
||||||
|
bonusTier: record.bonusTier,
|
||||||
amount: new ContributionAmount(record.amount),
|
amount: new ContributionAmount(record.amount),
|
||||||
reason: record.reason,
|
reason: record.reason,
|
||||||
sourceAdoptionId: record.sourceAdoptionId,
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
sourceAccountSequence: record.sourceAccountSequence,
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
effectiveDate: record.effectiveDate,
|
effectiveDate: record.effectiveDate,
|
||||||
expireDate: record.expireDate,
|
expireDate: record.expireDate,
|
||||||
allocatedToHeadquarters: record.allocatedToHeadquarters,
|
status: record.status,
|
||||||
allocatedAt: record.allocatedAt,
|
allocatedAt: record.allocatedAt,
|
||||||
|
allocatedToAccountSequence: record.allocatedToAccountSequence,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1102,9 +1102,47 @@ full_reset() {
|
||||||
service_start "$service"
|
service_start "$service"
|
||||||
done
|
done
|
||||||
|
|
||||||
log_step "Step 10/18: Waiting for services to be ready and sync from 1.0..."
|
log_step "Step 10/18: Waiting for contribution-service CDC sync to complete..."
|
||||||
log_info "Waiting 30 seconds for all services to start and sync data from 1.0 CDC..."
|
log_info "Waiting for contribution-service to complete CDC sync (user_accounts -> referral_relationships -> planting_orders)..."
|
||||||
sleep 30
|
|
||||||
|
# 等待 contribution-service 的 CDC 顺序同步完成
|
||||||
|
# 通过 /health/cdc-sync API 检查同步状态
|
||||||
|
local max_wait=600 # 最多等待 10 分钟
|
||||||
|
local wait_count=0
|
||||||
|
local sync_completed=false
|
||||||
|
local cdc_sync_url="http://localhost:3020/api/v2/health/cdc-sync"
|
||||||
|
|
||||||
|
while [ "$wait_count" -lt "$max_wait" ] && [ "$sync_completed" = false ]; do
|
||||||
|
# 调用 API 检查同步状态
|
||||||
|
local sync_status
|
||||||
|
sync_status=$(curl -s "$cdc_sync_url" 2>/dev/null || echo '{}')
|
||||||
|
|
||||||
|
if echo "$sync_status" | grep -q '"allPhasesCompleted":true'; then
|
||||||
|
sync_completed=true
|
||||||
|
log_success "CDC sync completed - all phases finished"
|
||||||
|
else
|
||||||
|
# 显示当前状态
|
||||||
|
local is_running
|
||||||
|
local sequential_mode
|
||||||
|
is_running=$(echo "$sync_status" | grep -o '"isRunning":[^,}]*' | cut -d':' -f2)
|
||||||
|
sequential_mode=$(echo "$sync_status" | grep -o '"sequentialMode":[^,}]*' | cut -d':' -f2)
|
||||||
|
|
||||||
|
if [ "$is_running" = "true" ] && [ "$sequential_mode" = "true" ]; then
|
||||||
|
log_info "CDC sync in progress (sequential mode)... (waited ${wait_count}s)"
|
||||||
|
elif [ "$is_running" = "true" ]; then
|
||||||
|
log_info "CDC consumer running... (waited ${wait_count}s)"
|
||||||
|
else
|
||||||
|
log_info "Waiting for CDC consumer to start... (waited ${wait_count}s)"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
wait_count=$((wait_count + 5))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$sync_completed" = false ]; then
|
||||||
|
log_warn "CDC sync did not complete within ${max_wait}s, proceeding anyway..."
|
||||||
|
log_info "You may need to wait longer or check: curl $cdc_sync_url"
|
||||||
|
fi
|
||||||
|
|
||||||
log_step "Step 11/18: Registering Debezium outbox connectors..."
|
log_step "Step 11/18: Registering Debezium outbox connectors..."
|
||||||
# Register outbox connectors AFTER services are running and have synced data
|
# Register outbox connectors AFTER services are running and have synced data
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { ApplicationModule } from '../application/application.module';
|
||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
import { DashboardController } from './controllers/dashboard.controller';
|
import { DashboardController } from './controllers/dashboard.controller';
|
||||||
import { ConfigController } from './controllers/config.controller';
|
import { ConfigController } from './controllers/config.controller';
|
||||||
import { InitializationController } from './controllers/initialization.controller';
|
|
||||||
import { AuditController } from './controllers/audit.controller';
|
import { AuditController } from './controllers/audit.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { UsersController } from './controllers/users.controller';
|
import { UsersController } from './controllers/users.controller';
|
||||||
import { SystemAccountsController } from './controllers/system-accounts.controller';
|
import { SystemAccountsController } from './controllers/system-accounts.controller';
|
||||||
|
import { ReportsController } from './controllers/reports.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
|
|
@ -15,11 +15,11 @@ import { SystemAccountsController } from './controllers/system-accounts.controll
|
||||||
AuthController,
|
AuthController,
|
||||||
DashboardController,
|
DashboardController,
|
||||||
ConfigController,
|
ConfigController,
|
||||||
InitializationController,
|
|
||||||
AuditController,
|
AuditController,
|
||||||
HealthController,
|
HealthController,
|
||||||
UsersController,
|
UsersController,
|
||||||
SystemAccountsController,
|
SystemAccountsController,
|
||||||
|
ReportsController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { DashboardService } from '../../application/services/dashboard.service';
|
||||||
|
|
||||||
@ApiTags('Audit')
|
@ApiTags('Audit')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('audit-logs')
|
@Controller('audit')
|
||||||
export class AuditController {
|
export class AuditController {
|
||||||
constructor(private readonly dashboardService: DashboardService) {}
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
|
@ -13,15 +13,42 @@ export class AuditController {
|
||||||
@ApiQuery({ name: 'adminId', required: false })
|
@ApiQuery({ name: 'adminId', required: false })
|
||||||
@ApiQuery({ name: 'action', required: false })
|
@ApiQuery({ name: 'action', required: false })
|
||||||
@ApiQuery({ name: 'resource', required: false })
|
@ApiQuery({ name: 'resource', required: false })
|
||||||
|
@ApiQuery({ name: 'keyword', required: false })
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
async getAuditLogs(
|
async getAuditLogs(
|
||||||
@Query('adminId') adminId?: string,
|
@Query('adminId') adminId?: string,
|
||||||
@Query('action') action?: string,
|
@Query('action') action?: string,
|
||||||
@Query('resource') resource?: string,
|
@Query('resource') resource?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
@Query('page') page?: number,
|
@Query('page') page?: number,
|
||||||
@Query('pageSize') pageSize?: number,
|
@Query('pageSize') pageSize?: number,
|
||||||
) {
|
) {
|
||||||
return this.dashboardService.getAuditLogs({ adminId, action, resource, page: page ?? 1, pageSize: pageSize ?? 50 });
|
const result = await this.dashboardService.getAuditLogs({
|
||||||
|
adminId,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: pageSize ?? 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为前端期望的格式
|
||||||
|
return {
|
||||||
|
items: result.data.map((log: any) => ({
|
||||||
|
id: log.id,
|
||||||
|
adminId: log.adminId,
|
||||||
|
adminUsername: log.admin?.username || 'unknown',
|
||||||
|
action: log.action,
|
||||||
|
resource: log.resource,
|
||||||
|
resourceId: log.resourceId,
|
||||||
|
details: log.newValue ? JSON.stringify(log.newValue) : null,
|
||||||
|
ipAddress: log.ipAddress || '-',
|
||||||
|
createdAt: log.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
page: result.pagination.page,
|
||||||
|
pageSize: result.pagination.pageSize,
|
||||||
|
totalPages: result.pagination.totalPages,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Controller, Get, Post, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Post, Delete, Body, Param, Query, Req, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ConfigManagementService } from '../../application/services/config.service';
|
import { ConfigManagementService } from '../../application/services/config.service';
|
||||||
|
|
||||||
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
||||||
|
|
@ -8,7 +9,12 @@ class SetConfigDto { category: string; key: string; value: string; description?:
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('configs')
|
@Controller('configs')
|
||||||
export class ConfigController {
|
export class ConfigController {
|
||||||
constructor(private readonly configService: ConfigManagementService) {}
|
private readonly logger = new Logger(ConfigController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigManagementService,
|
||||||
|
private readonly appConfigService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取配置列表' })
|
@ApiOperation({ summary: '获取配置列表' })
|
||||||
|
|
@ -17,6 +23,90 @@ export class ConfigController {
|
||||||
return this.configService.getConfigs(category);
|
return this.configService.getConfigs(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('transfer-enabled')
|
||||||
|
@ApiOperation({ summary: '获取划转开关状态' })
|
||||||
|
async getTransferEnabled() {
|
||||||
|
const config = await this.configService.getConfig('system', 'transfer_enabled');
|
||||||
|
return { enabled: config?.configValue === 'true' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('transfer-enabled')
|
||||||
|
@ApiOperation({ summary: '设置划转开关状态' })
|
||||||
|
async setTransferEnabled(@Body() body: { enabled: boolean }, @Req() req: any) {
|
||||||
|
await this.configService.setConfig(req.admin.id, 'system', 'transfer_enabled', String(body.enabled), '划转开关');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('mining/status')
|
||||||
|
@ApiOperation({ summary: '获取挖矿状态' })
|
||||||
|
async getMiningStatus() {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
this.logger.log(`Fetching mining status from ${miningServiceUrl}/api/v2/admin/status`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v2/admin/status`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch mining status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.logger.log(`Mining service response: ${JSON.stringify(result)}`);
|
||||||
|
if (result.data) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
initialized: false,
|
||||||
|
isActive: false,
|
||||||
|
error: 'Invalid response from mining service',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get mining status', error);
|
||||||
|
return {
|
||||||
|
initialized: false,
|
||||||
|
isActive: false,
|
||||||
|
error: `Unable to connect to mining service: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mining/activate')
|
||||||
|
@ApiOperation({ summary: '激活挖矿' })
|
||||||
|
async activateMining(@Req() req: any) {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v2/admin/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to activate mining');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.logger.log(`Mining activated by admin ${req.admin?.id}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to activate mining', error);
|
||||||
|
return { success: false, message: 'Failed to activate mining' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mining/deactivate')
|
||||||
|
@ApiOperation({ summary: '停用挖矿' })
|
||||||
|
async deactivateMining(@Req() req: any) {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v2/admin/deactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to deactivate mining');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.logger.log(`Mining deactivated by admin ${req.admin?.id}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to deactivate mining', error);
|
||||||
|
return { success: false, message: 'Failed to deactivate mining' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':category/:key')
|
@Get(':category/:key')
|
||||||
@ApiOperation({ summary: '获取单个配置' })
|
@ApiOperation({ summary: '获取单个配置' })
|
||||||
@ApiParam({ name: 'category' })
|
@ApiParam({ name: 'category' })
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,99 @@ export class DashboardController {
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取仪表盘统计数据' })
|
@ApiOperation({ summary: '获取仪表盘统计数据' })
|
||||||
async getStats() {
|
async getStats() {
|
||||||
return this.dashboardService.getDashboardStats();
|
const raw = await this.dashboardService.getDashboardStats();
|
||||||
|
|
||||||
|
// 计算24小时价格变化
|
||||||
|
let priceChange24h = 0;
|
||||||
|
if (raw.latestPrice) {
|
||||||
|
const open = parseFloat(raw.latestPrice.open) || 1;
|
||||||
|
const close = parseFloat(raw.latestPrice.close) || 1;
|
||||||
|
priceChange24h = (close - open) / open;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详细算力分解数据
|
||||||
|
const dc = raw.detailedContribution || {};
|
||||||
|
|
||||||
|
// 转换为前端期望的格式
|
||||||
|
return {
|
||||||
|
// 基础统计
|
||||||
|
totalUsers: raw.users?.total || 0,
|
||||||
|
adoptedUsers: raw.users?.adopted || 0,
|
||||||
|
totalTrees: raw.contribution?.totalTrees || 0,
|
||||||
|
networkEffectiveContribution: raw.contribution?.effectiveContribution || '0',
|
||||||
|
networkTotalContribution: raw.contribution?.totalContribution || '0',
|
||||||
|
networkLevelPending: dc.levelContribution?.pending || '0',
|
||||||
|
networkBonusPending: dc.bonusContribution?.pending || '0',
|
||||||
|
totalDistributed: raw.mining?.totalMined || '0',
|
||||||
|
totalBurned: raw.mining?.latestDailyStat?.totalBurned || '0',
|
||||||
|
circulationPool: raw.trading?.circulationPool?.totalShares || '0',
|
||||||
|
currentPrice: raw.latestPrice?.close || '1',
|
||||||
|
priceChange24h,
|
||||||
|
totalOrders: raw.trading?.totalAccounts || 0,
|
||||||
|
totalTrades: raw.trading?.totalAccounts || 0,
|
||||||
|
|
||||||
|
// ========== 详细算力分解 ==========
|
||||||
|
detailedContribution: {
|
||||||
|
totalTrees: dc.totalTrees || 0,
|
||||||
|
// 全网算力(理论值)= 总树数 * 22617
|
||||||
|
networkTotalTheory: dc.networkTotalTheory || '0',
|
||||||
|
// 个人算力(70%)
|
||||||
|
personalTheory: dc.personalTheory || '0',
|
||||||
|
personalActual: raw.contribution?.personalContribution || '0',
|
||||||
|
// 运营账户(12%)
|
||||||
|
operationTheory: dc.operationTheory || '0',
|
||||||
|
operationActual: dc.operationActual || '0',
|
||||||
|
// 省公司(1%)
|
||||||
|
provinceTheory: dc.provinceTheory || '0',
|
||||||
|
provinceActual: dc.provinceActual || '0',
|
||||||
|
// 市公司(2%)
|
||||||
|
cityTheory: dc.cityTheory || '0',
|
||||||
|
cityActual: dc.cityActual || '0',
|
||||||
|
|
||||||
|
// 层级算力(7.5%)
|
||||||
|
level: {
|
||||||
|
theory: dc.levelTheory || '0',
|
||||||
|
unlocked: dc.levelContribution?.unlocked || '0',
|
||||||
|
pending: dc.levelContribution?.pending || '0',
|
||||||
|
// 分档详情
|
||||||
|
tier1: dc.levelContribution?.byTier?.tier1 || { unlocked: '0', pending: '0' },
|
||||||
|
tier2: dc.levelContribution?.byTier?.tier2 || { unlocked: '0', pending: '0' },
|
||||||
|
tier3: dc.levelContribution?.byTier?.tier3 || { unlocked: '0', pending: '0' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 团队奖励算力(7.5%)
|
||||||
|
bonus: {
|
||||||
|
theory: dc.bonusTheory || '0',
|
||||||
|
unlocked: dc.bonusContribution?.unlocked || '0',
|
||||||
|
pending: dc.bonusContribution?.pending || '0',
|
||||||
|
// 分档详情
|
||||||
|
tier1: dc.bonusContribution?.byTier?.tier1 || { unlocked: '0', pending: '0' },
|
||||||
|
tier2: dc.bonusContribution?.byTier?.tier2 || { unlocked: '0', pending: '0' },
|
||||||
|
tier3: dc.bonusContribution?.byTier?.tier3 || { unlocked: '0', pending: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
@ApiOperation({ summary: '获取仪表盘统计数据(别名)' })
|
@ApiOperation({ summary: '获取仪表盘统计数据(别名)' })
|
||||||
async getStatsAlias() {
|
async getStatsAlias() {
|
||||||
return this.dashboardService.getDashboardStats();
|
return this.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('realtime')
|
@Get('realtime')
|
||||||
@ApiOperation({ summary: '获取实时数据' })
|
@ApiOperation({ summary: '获取实时数据' })
|
||||||
async getRealtimeStats() {
|
async getRealtimeStats() {
|
||||||
return this.dashboardService.getRealtimeStats();
|
const raw = await this.dashboardService.getRealtimeStats();
|
||||||
|
|
||||||
|
// 转换为前端期望的格式
|
||||||
|
return {
|
||||||
|
currentMinuteDistribution: raw.minuteDistribution || '0',
|
||||||
|
currentMinuteBurn: '0', // 暂无实时销毁数据
|
||||||
|
activeOrders: 0, // 暂无实时订单数据
|
||||||
|
pendingTrades: 0, // 暂无待处理交易数据
|
||||||
|
lastPriceUpdateAt: raw.timestamp,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('reports')
|
@Get('reports')
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { Controller, Post, Body, Req } from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { InitializationService } from '../../application/services/initialization.service';
|
|
||||||
|
|
||||||
class InitMiningConfigDto {
|
|
||||||
totalShares: string;
|
|
||||||
distributionPool: string;
|
|
||||||
halvingPeriodYears: number;
|
|
||||||
burnTarget: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiTags('Initialization')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Controller('initialization')
|
|
||||||
export class InitializationController {
|
|
||||||
constructor(private readonly initService: InitializationService) {}
|
|
||||||
|
|
||||||
@Post('mining-config')
|
|
||||||
@ApiOperation({ summary: '初始化挖矿配置' })
|
|
||||||
async initMiningConfig(@Body() dto: InitMiningConfigDto, @Req() req: any) {
|
|
||||||
return this.initService.initializeMiningConfig(req.admin.id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('system-accounts')
|
|
||||||
@ApiOperation({ summary: '初始化系统账户' })
|
|
||||||
async initSystemAccounts(@Req() req: any) {
|
|
||||||
return this.initService.initializeSystemAccounts(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('activate-mining')
|
|
||||||
@ApiOperation({ summary: '激活挖矿' })
|
|
||||||
async activateMining(@Req() req: any) {
|
|
||||||
return this.initService.activateMining(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sync-users')
|
|
||||||
@ApiOperation({ summary: '同步所有用户数据(从auth-service初始同步)' })
|
|
||||||
async syncUsers(@Req() req: any) {
|
|
||||||
return this.initService.syncAllUsers(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sync-contribution-accounts')
|
|
||||||
@ApiOperation({ summary: '同步所有算力账户(从contribution-service初始同步)' })
|
|
||||||
async syncContributionAccounts(@Req() req: any) {
|
|
||||||
return this.initService.syncAllContributionAccounts(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sync-mining-accounts')
|
|
||||||
@ApiOperation({ summary: '同步所有挖矿账户(从mining-service初始同步)' })
|
|
||||||
async syncMiningAccounts(@Req() req: any) {
|
|
||||||
return this.initService.syncAllMiningAccounts(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sync-trading-accounts')
|
|
||||||
@ApiOperation({ summary: '同步所有交易账户(从trading-service初始同步)' })
|
|
||||||
async syncTradingAccounts(@Req() req: any) {
|
|
||||||
return this.initService.syncAllTradingAccounts(req.admin.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sync-all')
|
|
||||||
@ApiOperation({ summary: '执行完整的数据同步(用户+算力+挖矿+交易)' })
|
|
||||||
async syncAll(@Req() req: any) {
|
|
||||||
const adminId = req.admin.id;
|
|
||||||
const results = {
|
|
||||||
users: await this.initService.syncAllUsers(adminId),
|
|
||||||
contribution: await this.initService.syncAllContributionAccounts(adminId),
|
|
||||||
mining: await this.initService.syncAllMiningAccounts(adminId),
|
|
||||||
trading: await this.initService.syncAllTradingAccounts(adminId),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '全部同步完成',
|
|
||||||
details: results,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { DashboardService } from '../../application/services/dashboard.service';
|
||||||
|
|
||||||
|
@ApiTags('Reports')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('reports')
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
@Get('daily')
|
||||||
|
@ApiOperation({ summary: '获取每日报表' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'days', required: false, type: Number })
|
||||||
|
async getDailyReports(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
@Query('days') days?: number,
|
||||||
|
) {
|
||||||
|
const result = await this.dashboardService.getReports(
|
||||||
|
page ?? 1,
|
||||||
|
pageSize ?? 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 转换为前端期望的格式
|
||||||
|
return {
|
||||||
|
items: result.data.map((report: any) => ({
|
||||||
|
id: report.id,
|
||||||
|
reportDate: report.reportDate,
|
||||||
|
totalUsers: report.users?.total || 0,
|
||||||
|
newUsers: report.users?.new || 0,
|
||||||
|
adoptedUsers: report.adoptions?.total || 0,
|
||||||
|
newAdoptedUsers: report.adoptions?.new || 0,
|
||||||
|
totalContribution: report.contribution?.total || '0',
|
||||||
|
newContribution: report.contribution?.growth || '0',
|
||||||
|
totalDistributed: report.mining?.distributed || '0',
|
||||||
|
dailyDistributed: report.mining?.distributed || '0',
|
||||||
|
totalBurned: report.mining?.burned || '0',
|
||||||
|
dailyBurned: report.mining?.burned || '0',
|
||||||
|
openPrice: report.price?.open || '1',
|
||||||
|
closePrice: report.price?.close || '1',
|
||||||
|
highPrice: report.price?.high || '1',
|
||||||
|
lowPrice: report.price?.low || '1',
|
||||||
|
totalVolume: report.trading?.volume || '0',
|
||||||
|
dailyVolume: report.trading?.volume || '0',
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
page: result.pagination.page,
|
||||||
|
pageSize: result.pagination.pageSize,
|
||||||
|
totalPages: result.pagination.totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,28 +2,28 @@ import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { ConfigManagementService } from './services/config.service';
|
import { ConfigManagementService } from './services/config.service';
|
||||||
import { InitializationService } from './services/initialization.service';
|
|
||||||
import { DashboardService } from './services/dashboard.service';
|
import { DashboardService } from './services/dashboard.service';
|
||||||
import { UsersService } from './services/users.service';
|
import { UsersService } from './services/users.service';
|
||||||
import { SystemAccountsService } from './services/system-accounts.service';
|
import { SystemAccountsService } from './services/system-accounts.service';
|
||||||
|
import { DailyReportService } from './services/daily-report.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
ConfigManagementService,
|
ConfigManagementService,
|
||||||
InitializationService,
|
|
||||||
DashboardService,
|
DashboardService,
|
||||||
UsersService,
|
UsersService,
|
||||||
SystemAccountsService,
|
SystemAccountsService,
|
||||||
|
DailyReportService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
ConfigManagementService,
|
ConfigManagementService,
|
||||||
InitializationService,
|
|
||||||
DashboardService,
|
DashboardService,
|
||||||
UsersService,
|
UsersService,
|
||||||
SystemAccountsService,
|
SystemAccountsService,
|
||||||
|
DailyReportService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
export class ApplicationModule implements OnModuleInit {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DailyReportService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DailyReportService.name);
|
||||||
|
private reportInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 启动时先生成一次报表
|
||||||
|
await this.generateTodayReport();
|
||||||
|
|
||||||
|
// 每小时检查并更新当日报表
|
||||||
|
this.reportInterval = setInterval(
|
||||||
|
() => this.generateTodayReport(),
|
||||||
|
60 * 60 * 1000, // 1 hour
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('Daily report service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或更新今日报表
|
||||||
|
*/
|
||||||
|
async generateTodayReport(): Promise<void> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Generating daily report for ${today.toISOString().split('T')[0]}`);
|
||||||
|
|
||||||
|
// 收集各项统计数据
|
||||||
|
const [
|
||||||
|
userStats,
|
||||||
|
adoptionStats,
|
||||||
|
contributionStats,
|
||||||
|
miningStats,
|
||||||
|
tradingStats,
|
||||||
|
priceStats,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getUserStats(today),
|
||||||
|
this.getAdoptionStats(today),
|
||||||
|
this.getContributionStats(today),
|
||||||
|
this.getMiningStats(),
|
||||||
|
this.getTradingStats(today),
|
||||||
|
this.getPriceStats(today),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 更新或创建今日报表
|
||||||
|
await this.prisma.dailyReport.upsert({
|
||||||
|
where: { reportDate: today },
|
||||||
|
create: {
|
||||||
|
reportDate: today,
|
||||||
|
...userStats,
|
||||||
|
...adoptionStats,
|
||||||
|
...contributionStats,
|
||||||
|
...miningStats,
|
||||||
|
...tradingStats,
|
||||||
|
...priceStats,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...userStats,
|
||||||
|
...adoptionStats,
|
||||||
|
...contributionStats,
|
||||||
|
...miningStats,
|
||||||
|
...tradingStats,
|
||||||
|
...priceStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Daily report generated successfully for ${today.toISOString().split('T')[0]}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to generate daily report', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成历史报表(用于补数据)
|
||||||
|
*/
|
||||||
|
async generateHistoricalReport(date: Date): Promise<void> {
|
||||||
|
const reportDate = new Date(date);
|
||||||
|
reportDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [
|
||||||
|
userStats,
|
||||||
|
adoptionStats,
|
||||||
|
contributionStats,
|
||||||
|
miningStats,
|
||||||
|
tradingStats,
|
||||||
|
priceStats,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getUserStats(reportDate),
|
||||||
|
this.getAdoptionStats(reportDate),
|
||||||
|
this.getContributionStats(reportDate),
|
||||||
|
this.getMiningStats(),
|
||||||
|
this.getTradingStats(reportDate),
|
||||||
|
this.getPriceStats(reportDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.prisma.dailyReport.upsert({
|
||||||
|
where: { reportDate },
|
||||||
|
create: {
|
||||||
|
reportDate,
|
||||||
|
...userStats,
|
||||||
|
...adoptionStats,
|
||||||
|
...contributionStats,
|
||||||
|
...miningStats,
|
||||||
|
...tradingStats,
|
||||||
|
...priceStats,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...userStats,
|
||||||
|
...adoptionStats,
|
||||||
|
...contributionStats,
|
||||||
|
...miningStats,
|
||||||
|
...tradingStats,
|
||||||
|
...priceStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户统计
|
||||||
|
*/
|
||||||
|
private async getUserStats(date: Date) {
|
||||||
|
const nextDay = new Date(date);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
|
const [totalUsers, newUsers] = await Promise.all([
|
||||||
|
this.prisma.syncedUser.count({
|
||||||
|
where: { createdAt: { lt: nextDay } },
|
||||||
|
}),
|
||||||
|
this.prisma.syncedUser.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: date, lt: nextDay },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 活跃用户暂时用总用户数(需要有活跃度跟踪才能准确计算)
|
||||||
|
const activeUsers = totalUsers;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
newUsers,
|
||||||
|
activeUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种统计
|
||||||
|
*/
|
||||||
|
private async getAdoptionStats(date: Date) {
|
||||||
|
const nextDay = new Date(date);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
|
const [totalAdoptions, newAdoptions, treesResult] = await Promise.all([
|
||||||
|
this.prisma.syncedAdoption.count({
|
||||||
|
where: { adoptionDate: { lt: nextDay } },
|
||||||
|
}),
|
||||||
|
this.prisma.syncedAdoption.count({
|
||||||
|
where: {
|
||||||
|
adoptionDate: { gte: date, lt: nextDay },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.syncedAdoption.aggregate({
|
||||||
|
where: { adoptionDate: { lt: nextDay } },
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAdoptions,
|
||||||
|
newAdoptions,
|
||||||
|
totalTrees: treesResult._sum.treeCount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算力统计
|
||||||
|
*/
|
||||||
|
private async getContributionStats(date: Date) {
|
||||||
|
// 获取全网算力进度
|
||||||
|
const networkProgress = await this.prisma.syncedNetworkProgress.findFirst();
|
||||||
|
|
||||||
|
// 获取用户算力汇总
|
||||||
|
const userContribution = await this.prisma.syncedContributionAccount.aggregate({
|
||||||
|
_sum: {
|
||||||
|
totalContribution: true,
|
||||||
|
effectiveContribution: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalContribution = new Decimal(
|
||||||
|
userContribution._sum.totalContribution?.toString() || '0',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取昨日报表计算增长
|
||||||
|
const yesterday = new Date(date);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayReport = await this.prisma.dailyReport.findUnique({
|
||||||
|
where: { reportDate: yesterday },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contributionGrowth = yesterdayReport
|
||||||
|
? totalContribution.minus(new Decimal(yesterdayReport.totalContribution.toString()))
|
||||||
|
: totalContribution;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalContribution,
|
||||||
|
contributionGrowth: contributionGrowth.gt(0) ? contributionGrowth : new Decimal(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挖矿统计
|
||||||
|
*/
|
||||||
|
private async getMiningStats() {
|
||||||
|
const dailyStat = await this.prisma.syncedDailyMiningStat.findFirst({
|
||||||
|
orderBy: { statDate: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDistributed: dailyStat?.totalDistributed || new Decimal(0),
|
||||||
|
totalBurned: dailyStat?.totalBurned || new Decimal(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易统计
|
||||||
|
*/
|
||||||
|
private async getTradingStats(date: Date) {
|
||||||
|
const kline = await this.prisma.syncedDayKLine.findUnique({
|
||||||
|
where: { klineDate: date },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tradingVolume: kline?.volume || new Decimal(0),
|
||||||
|
tradingAmount: kline?.amount || new Decimal(0),
|
||||||
|
tradeCount: kline?.tradeCount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价格统计
|
||||||
|
*/
|
||||||
|
private async getPriceStats(date: Date) {
|
||||||
|
const kline = await this.prisma.syncedDayKLine.findUnique({
|
||||||
|
where: { klineDate: date },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPrice = new Decimal(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openPrice: kline?.open || defaultPrice,
|
||||||
|
closePrice: kline?.close || defaultPrice,
|
||||||
|
highPrice: kline?.high || defaultPrice,
|
||||||
|
lowPrice: kline?.low || defaultPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Decimal } from 'decimal.js';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
// 基准算力常量
|
||||||
|
const BASE_CONTRIBUTION_PER_TREE = new Decimal('22617');
|
||||||
|
const RATE_PERSONAL = new Decimal('0.70');
|
||||||
|
const RATE_OPERATION = new Decimal('0.12');
|
||||||
|
const RATE_PROVINCE = new Decimal('0.01');
|
||||||
|
const RATE_CITY = new Decimal('0.02');
|
||||||
|
const RATE_LEVEL_TOTAL = new Decimal('0.075');
|
||||||
|
const RATE_BONUS_TOTAL = new Decimal('0.075');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
private readonly logger = new Logger(DashboardService.name);
|
private readonly logger = new Logger(DashboardService.name);
|
||||||
|
|
@ -23,6 +33,7 @@ export class DashboardService {
|
||||||
tradingStats,
|
tradingStats,
|
||||||
latestReport,
|
latestReport,
|
||||||
latestKLine,
|
latestKLine,
|
||||||
|
detailedContributionStats,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.getUserStats(),
|
this.getUserStats(),
|
||||||
this.getContributionStats(),
|
this.getContributionStats(),
|
||||||
|
|
@ -30,6 +41,7 @@ export class DashboardService {
|
||||||
this.getTradingStats(),
|
this.getTradingStats(),
|
||||||
this.prisma.dailyReport.findFirst({ orderBy: { reportDate: 'desc' } }),
|
this.prisma.dailyReport.findFirst({ orderBy: { reportDate: 'desc' } }),
|
||||||
this.prisma.syncedDayKLine.findFirst({ orderBy: { klineDate: 'desc' } }),
|
this.prisma.syncedDayKLine.findFirst({ orderBy: { klineDate: 'desc' } }),
|
||||||
|
this.getDetailedContributionStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -37,6 +49,7 @@ export class DashboardService {
|
||||||
contribution: contributionStats,
|
contribution: contributionStats,
|
||||||
mining: miningStats,
|
mining: miningStats,
|
||||||
trading: tradingStats,
|
trading: tradingStats,
|
||||||
|
detailedContribution: detailedContributionStats,
|
||||||
latestReport: latestReport
|
latestReport: latestReport
|
||||||
? this.formatDailyReport(latestReport)
|
? this.formatDailyReport(latestReport)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -110,39 +123,191 @@ export class DashboardService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取算力统计
|
* 获取算力统计
|
||||||
|
*
|
||||||
|
* 有效算力 = 个人算力(70%) + 运营账户(12%) + 省公司(1%) + 市公司(2%)
|
||||||
|
* + 已解锁层级 + 未解锁层级 + 已解锁团队 + 未解锁团队
|
||||||
|
* = 理论总算力(因为包含所有部分)
|
||||||
|
* = 总树数 * 22617
|
||||||
*/
|
*/
|
||||||
private async getContributionStats() {
|
private async getContributionStats() {
|
||||||
const accounts = await this.prisma.syncedContributionAccount.aggregate({
|
const [accounts, systemContributions, adoptionStats] = await Promise.all([
|
||||||
_sum: {
|
this.prisma.syncedContributionAccount.aggregate({
|
||||||
totalContribution: true,
|
_sum: {
|
||||||
effectiveContribution: true,
|
totalContribution: true,
|
||||||
personalContribution: true,
|
effectiveContribution: true,
|
||||||
teamLevelContribution: true,
|
personalContribution: true,
|
||||||
teamBonusContribution: true,
|
teamLevelContribution: true,
|
||||||
},
|
teamBonusContribution: true,
|
||||||
_count: true,
|
},
|
||||||
});
|
_count: true,
|
||||||
|
}),
|
||||||
const systemContributions =
|
this.prisma.syncedSystemContribution.aggregate({
|
||||||
await this.prisma.syncedSystemContribution.aggregate({
|
|
||||||
_sum: { contributionBalance: true },
|
_sum: { contributionBalance: true },
|
||||||
_count: true,
|
_count: true,
|
||||||
});
|
}),
|
||||||
|
this.prisma.syncedAdoption.aggregate({
|
||||||
|
where: { status: 'MINING_ENABLED' },
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalTrees = adoptionStats._sum.treeCount || 0;
|
||||||
|
|
||||||
|
// 有效算力 = 理论总算力 = 总树数 * 22617
|
||||||
|
// 因为按照公式,有效算力包含所有部分(个人70%+运营12%+省1%+市2%+层级7.5%+团队7.5%=100%)
|
||||||
|
const effectiveContribution = BASE_CONTRIBUTION_PER_TREE.mul(totalTrees);
|
||||||
|
|
||||||
|
// 个人算力(已分配到用户账户)
|
||||||
|
const personalContribution = new Decimal(accounts._sum.personalContribution || 0);
|
||||||
|
|
||||||
|
// 系统账户算力(运营+省+市)
|
||||||
|
const systemContribution = new Decimal(systemContributions._sum.contributionBalance || 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAccounts: accounts._count,
|
totalAccounts: accounts._count,
|
||||||
totalContribution: accounts._sum.totalContribution?.toString() || '0',
|
totalContribution: accounts._sum.totalContribution?.toString() || '0',
|
||||||
effectiveContribution:
|
effectiveContribution: effectiveContribution.toString(),
|
||||||
accounts._sum.effectiveContribution?.toString() || '0',
|
personalContribution: personalContribution.toString(),
|
||||||
personalContribution:
|
|
||||||
accounts._sum.personalContribution?.toString() || '0',
|
|
||||||
teamLevelContribution:
|
teamLevelContribution:
|
||||||
accounts._sum.teamLevelContribution?.toString() || '0',
|
accounts._sum.teamLevelContribution?.toString() || '0',
|
||||||
teamBonusContribution:
|
teamBonusContribution:
|
||||||
accounts._sum.teamBonusContribution?.toString() || '0',
|
accounts._sum.teamBonusContribution?.toString() || '0',
|
||||||
systemAccounts: systemContributions._count,
|
systemAccounts: systemContributions._count,
|
||||||
systemContribution:
|
systemContribution: systemContribution.toString(),
|
||||||
systemContributions._sum.contributionBalance?.toString() || '0',
|
totalAdoptions: adoptionStats._count,
|
||||||
|
totalTrees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取详细算力分解统计(按用户需求)
|
||||||
|
*/
|
||||||
|
private async getDetailedContributionStats() {
|
||||||
|
// 获取总树数
|
||||||
|
const adoptionStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
|
where: { status: 'MINING_ENABLED' },
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
});
|
||||||
|
const totalTrees = adoptionStats._sum.treeCount || 0;
|
||||||
|
|
||||||
|
// 按层级统计已分配的层级算力
|
||||||
|
const levelRecords = await this.prisma.syncedContributionRecord.groupBy({
|
||||||
|
by: ['levelDepth'],
|
||||||
|
where: {
|
||||||
|
sourceType: 'TEAM_LEVEL',
|
||||||
|
levelDepth: { not: null },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按档位统计已分配的团队奖励算力
|
||||||
|
const bonusRecords = await this.prisma.syncedContributionRecord.groupBy({
|
||||||
|
by: ['bonusTier'],
|
||||||
|
where: {
|
||||||
|
sourceType: 'TEAM_BONUS',
|
||||||
|
bonusTier: { not: null },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取系统账户按类型的算力
|
||||||
|
const systemAccounts = await this.prisma.syncedSystemContribution.findMany();
|
||||||
|
|
||||||
|
// 汇总层级1-5, 6-10, 11-15
|
||||||
|
let levelTier1 = new Decimal(0);
|
||||||
|
let levelTier2 = new Decimal(0);
|
||||||
|
let levelTier3 = new Decimal(0);
|
||||||
|
for (const record of levelRecords) {
|
||||||
|
const depth = record.levelDepth!;
|
||||||
|
const amount = new Decimal(record._sum.amount || 0);
|
||||||
|
if (depth >= 1 && depth <= 5) levelTier1 = levelTier1.plus(amount);
|
||||||
|
else if (depth >= 6 && depth <= 10) levelTier2 = levelTier2.plus(amount);
|
||||||
|
else if (depth >= 11 && depth <= 15) levelTier3 = levelTier3.plus(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总团队奖励档位
|
||||||
|
let bonusTier1 = new Decimal(0);
|
||||||
|
let bonusTier2 = new Decimal(0);
|
||||||
|
let bonusTier3 = new Decimal(0);
|
||||||
|
for (const record of bonusRecords) {
|
||||||
|
const tier = record.bonusTier!;
|
||||||
|
const amount = new Decimal(record._sum.amount || 0);
|
||||||
|
if (tier === 1) bonusTier1 = amount;
|
||||||
|
else if (tier === 2) bonusTier2 = amount;
|
||||||
|
else if (tier === 3) bonusTier3 = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelUnlocked = levelTier1.plus(levelTier2).plus(levelTier3);
|
||||||
|
const bonusUnlocked = bonusTier1.plus(bonusTier2).plus(bonusTier3);
|
||||||
|
|
||||||
|
// 计算理论值
|
||||||
|
const networkTotal = BASE_CONTRIBUTION_PER_TREE.mul(totalTrees);
|
||||||
|
const personalTheory = networkTotal.mul(RATE_PERSONAL);
|
||||||
|
const operationTheory = networkTotal.mul(RATE_OPERATION);
|
||||||
|
const provinceTheory = networkTotal.mul(RATE_PROVINCE);
|
||||||
|
const cityTheory = networkTotal.mul(RATE_CITY);
|
||||||
|
const levelTheory = networkTotal.mul(RATE_LEVEL_TOTAL);
|
||||||
|
const bonusTheory = networkTotal.mul(RATE_BONUS_TOTAL);
|
||||||
|
|
||||||
|
// 计算未解锁(理论 - 已解锁)
|
||||||
|
const levelPending = levelTheory.minus(levelUnlocked).greaterThan(0)
|
||||||
|
? levelTheory.minus(levelUnlocked)
|
||||||
|
: new Decimal(0);
|
||||||
|
const bonusPending = bonusTheory.minus(bonusUnlocked).greaterThan(0)
|
||||||
|
? bonusTheory.minus(bonusUnlocked)
|
||||||
|
: new Decimal(0);
|
||||||
|
|
||||||
|
// 系统账户按类型汇总
|
||||||
|
let operationActual = new Decimal(0);
|
||||||
|
let provinceActual = new Decimal(0);
|
||||||
|
let cityActual = new Decimal(0);
|
||||||
|
for (const account of systemAccounts) {
|
||||||
|
const balance = new Decimal(account.contributionBalance || 0);
|
||||||
|
if (account.accountType === 'OPERATION') operationActual = operationActual.plus(balance);
|
||||||
|
else if (account.accountType === 'PROVINCE') provinceActual = provinceActual.plus(balance);
|
||||||
|
else if (account.accountType === 'CITY') cityActual = cityActual.plus(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTrees,
|
||||||
|
// 理论值(基于总树数计算)
|
||||||
|
networkTotalTheory: networkTotal.toString(),
|
||||||
|
personalTheory: personalTheory.toString(),
|
||||||
|
operationTheory: operationTheory.toString(),
|
||||||
|
provinceTheory: provinceTheory.toString(),
|
||||||
|
cityTheory: cityTheory.toString(),
|
||||||
|
levelTheory: levelTheory.toString(),
|
||||||
|
bonusTheory: bonusTheory.toString(),
|
||||||
|
|
||||||
|
// 实际值(从数据库统计)
|
||||||
|
operationActual: operationActual.toString(),
|
||||||
|
provinceActual: provinceActual.toString(),
|
||||||
|
cityActual: cityActual.toString(),
|
||||||
|
|
||||||
|
// 层级算力详情
|
||||||
|
levelContribution: {
|
||||||
|
total: levelTheory.toString(),
|
||||||
|
unlocked: levelUnlocked.toString(),
|
||||||
|
pending: levelPending.toString(),
|
||||||
|
byTier: {
|
||||||
|
tier1: { unlocked: levelTier1.toString(), pending: '0' },
|
||||||
|
tier2: { unlocked: levelTier2.toString(), pending: '0' },
|
||||||
|
tier3: { unlocked: levelTier3.toString(), pending: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 团队奖励算力详情
|
||||||
|
bonusContribution: {
|
||||||
|
total: bonusTheory.toString(),
|
||||||
|
unlocked: bonusUnlocked.toString(),
|
||||||
|
pending: bonusPending.toString(),
|
||||||
|
byTier: {
|
||||||
|
tier1: { unlocked: bonusTier1.toString(), pending: '0' },
|
||||||
|
tier2: { unlocked: bonusTier2.toString(), pending: '0' },
|
||||||
|
tier3: { unlocked: bonusTier3.toString(), pending: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class InitializationService {
|
|
||||||
private readonly logger = new Logger(InitializationService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async initializeMiningConfig(
|
|
||||||
adminId: string,
|
|
||||||
config: {
|
|
||||||
totalShares: string;
|
|
||||||
distributionPool: string;
|
|
||||||
halvingPeriodYears: number;
|
|
||||||
burnTarget: string;
|
|
||||||
},
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
const record = await this.prisma.initializationRecord.create({
|
|
||||||
data: { type: 'MINING_CONFIG', status: 'PENDING', config, executedBy: adminId },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const miningServiceUrl = this.configService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
|
||||||
const response = await fetch(`${miningServiceUrl}/api/v1/admin/initialize`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(config),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to initialize mining config');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.initializationRecord.update({
|
|
||||||
where: { id: record.id },
|
|
||||||
data: { status: 'COMPLETED', executedAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'INIT', resource: 'MINING', resourceId: record.id, newValue: config },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: 'Mining config initialized successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
await this.prisma.initializationRecord.update({
|
|
||||||
where: { id: record.id },
|
|
||||||
data: { status: 'FAILED', errorMessage: error.message },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeSystemAccounts(adminId: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
const accounts = [
|
|
||||||
{ accountType: 'OPERATION', name: '运营账户', description: '12% 运营收入' },
|
|
||||||
{ accountType: 'PROVINCE', name: '省公司账户', description: '1% 省公司收入' },
|
|
||||||
{ accountType: 'CITY', name: '市公司账户', description: '2% 市公司收入' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
await this.prisma.systemAccount.upsert({
|
|
||||||
where: { accountType: account.accountType },
|
|
||||||
create: account,
|
|
||||||
update: { name: account.name, description: account.description },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'INIT', resource: 'SYSTEM_ACCOUNT', newValue: accounts },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: 'System accounts initialized successfully' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async activateMining(adminId: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
try {
|
|
||||||
const miningServiceUrl = this.configService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
|
||||||
const response = await fetch(`${miningServiceUrl}/api/v1/admin/activate`, { method: 'POST' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to activate mining');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'INIT', resource: 'MINING', newValue: { action: 'ACTIVATE' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: 'Mining activated successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncAllUsers(adminId: string): Promise<{ success: boolean; message: string; syncedCount?: number }> {
|
|
||||||
try {
|
|
||||||
const authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://localhost:3024');
|
|
||||||
const response = await fetch(`${authServiceUrl}/api/v2/admin/users/sync`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch users: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const users = responseData.data?.users || responseData.users || [];
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
try {
|
|
||||||
await this.prisma.syncedUser.upsert({
|
|
||||||
where: { accountSequence: user.accountSequence },
|
|
||||||
create: {
|
|
||||||
originalUserId: user.id || user.accountSequence,
|
|
||||||
accountSequence: user.accountSequence,
|
|
||||||
phone: user.phone,
|
|
||||||
status: user.status || 'ACTIVE',
|
|
||||||
kycStatus: user.kycStatus || 'PENDING',
|
|
||||||
realName: user.realName || null,
|
|
||||||
isLegacyUser: user.isLegacyUser || false,
|
|
||||||
createdAt: new Date(user.createdAt),
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
phone: user.phone,
|
|
||||||
status: user.status || 'ACTIVE',
|
|
||||||
kycStatus: user.kycStatus || 'PENDING',
|
|
||||||
realName: user.realName || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
syncedCount++;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to sync user ${user.accountSequence}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'SYNC', resource: 'USER', newValue: { syncedCount } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: `Synced ${syncedCount} users`, syncedCount };
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncAllContributionAccounts(adminId: string): Promise<{ success: boolean; message: string; syncedCount?: number }> {
|
|
||||||
try {
|
|
||||||
const contributionServiceUrl = this.configService.get<string>('CONTRIBUTION_SERVICE_URL', 'http://localhost:3020');
|
|
||||||
const response = await fetch(`${contributionServiceUrl}/api/v2/admin/accounts/sync`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch accounts: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const accounts = responseData.data?.accounts || responseData.accounts || [];
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
try {
|
|
||||||
await this.prisma.syncedContributionAccount.upsert({
|
|
||||||
where: { accountSequence: account.accountSequence },
|
|
||||||
create: {
|
|
||||||
accountSequence: account.accountSequence,
|
|
||||||
personalContribution: account.personalContribution || 0,
|
|
||||||
teamLevelContribution: account.teamLevelContribution || 0,
|
|
||||||
teamBonusContribution: account.teamBonusContribution || 0,
|
|
||||||
totalContribution: account.totalContribution || 0,
|
|
||||||
effectiveContribution: account.effectiveContribution || 0,
|
|
||||||
hasAdopted: account.hasAdopted || false,
|
|
||||||
directReferralCount: account.directReferralAdoptedCount || 0,
|
|
||||||
unlockedLevelDepth: account.unlockedLevelDepth || 0,
|
|
||||||
unlockedBonusTiers: account.unlockedBonusTiers || 0,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
personalContribution: account.personalContribution,
|
|
||||||
teamLevelContribution: account.teamLevelContribution,
|
|
||||||
teamBonusContribution: account.teamBonusContribution,
|
|
||||||
totalContribution: account.totalContribution,
|
|
||||||
effectiveContribution: account.effectiveContribution,
|
|
||||||
hasAdopted: account.hasAdopted,
|
|
||||||
directReferralCount: account.directReferralAdoptedCount,
|
|
||||||
unlockedLevelDepth: account.unlockedLevelDepth,
|
|
||||||
unlockedBonusTiers: account.unlockedBonusTiers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
syncedCount++;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to sync account ${account.accountSequence}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'SYNC', resource: 'CONTRIBUTION_ACCOUNT', newValue: { syncedCount } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: `Synced ${syncedCount} accounts`, syncedCount };
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncAllMiningAccounts(adminId: string): Promise<{ success: boolean; message: string; syncedCount?: number }> {
|
|
||||||
try {
|
|
||||||
const miningServiceUrl = this.configService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
|
||||||
const response = await fetch(`${miningServiceUrl}/api/v1/admin/accounts/sync`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch accounts: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const accounts = responseData.data?.accounts || responseData.accounts || [];
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
try {
|
|
||||||
await this.prisma.syncedMiningAccount.upsert({
|
|
||||||
where: { accountSequence: account.accountSequence },
|
|
||||||
create: {
|
|
||||||
accountSequence: account.accountSequence,
|
|
||||||
totalMined: account.totalMined || 0,
|
|
||||||
availableBalance: account.availableBalance || 0,
|
|
||||||
frozenBalance: account.frozenBalance || 0,
|
|
||||||
totalContribution: account.totalContribution || 0,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
totalMined: account.totalMined,
|
|
||||||
availableBalance: account.availableBalance,
|
|
||||||
frozenBalance: account.frozenBalance,
|
|
||||||
totalContribution: account.totalContribution,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
syncedCount++;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to sync mining account ${account.accountSequence}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'SYNC', resource: 'MINING_ACCOUNT', newValue: { syncedCount } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: `Synced ${syncedCount} mining accounts`, syncedCount };
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncAllTradingAccounts(adminId: string): Promise<{ success: boolean; message: string; syncedCount?: number }> {
|
|
||||||
try {
|
|
||||||
const tradingServiceUrl = this.configService.get<string>('TRADING_SERVICE_URL', 'http://localhost:3022');
|
|
||||||
const response = await fetch(`${tradingServiceUrl}/api/v1/admin/accounts/sync`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch accounts: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const accounts = responseData.data?.accounts || responseData.accounts || [];
|
|
||||||
let syncedCount = 0;
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
try {
|
|
||||||
await this.prisma.syncedTradingAccount.upsert({
|
|
||||||
where: { accountSequence: account.accountSequence },
|
|
||||||
create: {
|
|
||||||
accountSequence: account.accountSequence,
|
|
||||||
shareBalance: account.shareBalance || 0,
|
|
||||||
cashBalance: account.cashBalance || 0,
|
|
||||||
frozenShares: account.frozenShares || 0,
|
|
||||||
frozenCash: account.frozenCash || 0,
|
|
||||||
totalBought: account.totalBought || 0,
|
|
||||||
totalSold: account.totalSold || 0,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
shareBalance: account.shareBalance,
|
|
||||||
cashBalance: account.cashBalance,
|
|
||||||
frozenShares: account.frozenShares,
|
|
||||||
frozenCash: account.frozenCash,
|
|
||||||
totalBought: account.totalBought,
|
|
||||||
totalSold: account.totalSold,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
syncedCount++;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to sync trading account ${account.accountSequence}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { adminId, action: 'SYNC', resource: 'TRADING_ACCOUNT', newValue: { syncedCount } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: `Synced ${syncedCount} trading accounts`, syncedCount };
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,55 +7,53 @@ export class SystemAccountsService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统账户列表
|
* 获取系统账户列表
|
||||||
|
* 从 CDC 同步的钱包系统账户表读取数据
|
||||||
*/
|
*/
|
||||||
async getSystemAccounts() {
|
async getSystemAccounts() {
|
||||||
// 先从本地 SystemAccount 表获取
|
// 从 CDC 同步的 SyncedWalletSystemAccount 表获取数据
|
||||||
const localAccounts = await this.prisma.systemAccount.findMany({
|
const syncedAccounts = await this.prisma.syncedWalletSystemAccount.findMany({
|
||||||
orderBy: { accountType: 'asc' },
|
orderBy: { accountType: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 再从 CDC 同步的 SyncedSystemContribution 获取算力数据
|
// 从 CDC 同步的 SyncedSystemContribution 获取算力数据
|
||||||
const syncedContributions =
|
const syncedContributions =
|
||||||
await this.prisma.syncedSystemContribution.findMany();
|
await this.prisma.syncedSystemContribution.findMany();
|
||||||
|
|
||||||
// 合并数据
|
// 构建算力数据映射
|
||||||
const accountsMap = new Map<string, any>();
|
const contributionMap = new Map<string, any>();
|
||||||
|
for (const contrib of syncedContributions) {
|
||||||
|
contributionMap.set(contrib.accountType, contrib);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加本地账户
|
// 构建返回数据
|
||||||
for (const account of localAccounts) {
|
const accounts = syncedAccounts.map((account) => {
|
||||||
accountsMap.set(account.accountType, {
|
const contrib = contributionMap.get(account.accountType);
|
||||||
|
return {
|
||||||
|
id: account.originalId,
|
||||||
accountType: account.accountType,
|
accountType: account.accountType,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
description: account.description,
|
code: account.code,
|
||||||
totalContribution: account.totalContribution.toString(),
|
provinceId: account.provinceId,
|
||||||
createdAt: account.createdAt,
|
cityId: account.cityId,
|
||||||
source: 'local',
|
shareBalance: account.shareBalance.toString(),
|
||||||
});
|
usdtBalance: account.usdtBalance.toString(),
|
||||||
}
|
greenPointBalance: account.greenPointBalance.toString(),
|
||||||
|
frozenShare: account.frozenShare.toString(),
|
||||||
// 更新或添加同步的算力数据
|
frozenUsdt: account.frozenUsdt.toString(),
|
||||||
for (const contrib of syncedContributions) {
|
totalInflow: account.totalInflow.toString(),
|
||||||
const existing = accountsMap.get(contrib.accountType);
|
totalOutflow: account.totalOutflow.toString(),
|
||||||
if (existing) {
|
blockchainAddress: account.blockchainAddress,
|
||||||
existing.contributionBalance = contrib.contributionBalance.toString();
|
isActive: account.isActive,
|
||||||
existing.contributionNeverExpires = contrib.contributionNeverExpires;
|
contributionBalance: contrib?.contributionBalance?.toString() || '0',
|
||||||
existing.syncedAt = contrib.syncedAt;
|
contributionNeverExpires: contrib?.contributionNeverExpires || false,
|
||||||
existing.source = 'synced';
|
syncedAt: account.syncedAt,
|
||||||
} else {
|
source: 'cdc',
|
||||||
accountsMap.set(contrib.accountType, {
|
};
|
||||||
accountType: contrib.accountType,
|
});
|
||||||
name: contrib.name,
|
|
||||||
contributionBalance: contrib.contributionBalance.toString(),
|
|
||||||
contributionNeverExpires: contrib.contributionNeverExpires,
|
|
||||||
syncedAt: contrib.syncedAt,
|
|
||||||
source: 'synced',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: Array.from(accountsMap.values()),
|
accounts,
|
||||||
total: accountsMap.size,
|
total: accounts.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,22 +61,21 @@ export class SystemAccountsService {
|
||||||
* 获取系统账户汇总
|
* 获取系统账户汇总
|
||||||
*/
|
*/
|
||||||
async getSystemAccountsSummary() {
|
async getSystemAccountsSummary() {
|
||||||
const [localAccounts, syncedContributions, miningConfig, circulationPool] =
|
const [
|
||||||
await Promise.all([
|
syncedSystemAccounts,
|
||||||
this.prisma.systemAccount.findMany(),
|
syncedPoolAccounts,
|
||||||
this.prisma.syncedSystemContribution.findMany(),
|
syncedContributions,
|
||||||
this.prisma.syncedMiningConfig.findFirst(),
|
miningConfig,
|
||||||
this.prisma.syncedCirculationPool.findFirst(),
|
circulationPool,
|
||||||
]);
|
] = await Promise.all([
|
||||||
|
this.prisma.syncedWalletSystemAccount.findMany(),
|
||||||
|
this.prisma.syncedWalletPoolAccount.findMany(),
|
||||||
|
this.prisma.syncedSystemContribution.findMany(),
|
||||||
|
this.prisma.syncedMiningConfig.findFirst(),
|
||||||
|
this.prisma.syncedCirculationPool.findFirst(),
|
||||||
|
]);
|
||||||
|
|
||||||
// 计算总算力
|
// 计算总算力
|
||||||
let totalSystemContribution = 0n;
|
|
||||||
for (const account of localAccounts) {
|
|
||||||
totalSystemContribution += BigInt(
|
|
||||||
account.totalContribution.toString().replace('.', ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalSyncedContribution = 0n;
|
let totalSyncedContribution = 0n;
|
||||||
for (const contrib of syncedContributions) {
|
for (const contrib of syncedContributions) {
|
||||||
totalSyncedContribution += BigInt(
|
totalSyncedContribution += BigInt(
|
||||||
|
|
@ -88,11 +85,22 @@ export class SystemAccountsService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
systemAccounts: {
|
systemAccounts: {
|
||||||
count: localAccounts.length,
|
count: syncedSystemAccounts.length,
|
||||||
totalContribution: (
|
totalBalance: syncedSystemAccounts.reduce(
|
||||||
Number(totalSystemContribution) / 100000000
|
(sum, acc) => sum + Number(acc.shareBalance),
|
||||||
|
0,
|
||||||
).toFixed(8),
|
).toFixed(8),
|
||||||
},
|
},
|
||||||
|
poolAccounts: {
|
||||||
|
count: syncedPoolAccounts.length,
|
||||||
|
pools: syncedPoolAccounts.map((pool) => ({
|
||||||
|
poolType: pool.poolType,
|
||||||
|
name: pool.name,
|
||||||
|
balance: pool.balance.toString(),
|
||||||
|
targetBurn: pool.targetBurn?.toString(),
|
||||||
|
remainingBurn: pool.remainingBurn?.toString(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
syncedContributions: {
|
syncedContributions: {
|
||||||
count: syncedContributions.length,
|
count: syncedContributions.length,
|
||||||
totalBalance: (Number(totalSyncedContribution) / 100000000).toFixed(8),
|
totalBalance: (Number(totalSyncedContribution) / 100000000).toFixed(8),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
|
@ -20,7 +21,15 @@ export interface GetOrdersQuery {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
private readonly logger = new Logger(UsersService.name);
|
||||||
|
private readonly miningServiceUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.miningServiceUrl = this.configService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户列表
|
* 获取用户列表
|
||||||
|
|
@ -103,32 +112,38 @@ export class UsersService {
|
||||||
*/
|
*/
|
||||||
private async getAdoptionStatsForUsers(
|
private async getAdoptionStatsForUsers(
|
||||||
accountSequences: string[],
|
accountSequences: string[],
|
||||||
): Promise<Map<string, { personalCount: number; teamCount: number }>> {
|
): Promise<Map<string, { personalCount: number; personalOrders: number; teamCount: number; teamOrders: number }>> {
|
||||||
const result = new Map<
|
const result = new Map<
|
||||||
string,
|
string,
|
||||||
{ personalCount: number; teamCount: number }
|
{ personalCount: number; personalOrders: number; teamCount: number; teamOrders: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
if (accountSequences.length === 0) return result;
|
if (accountSequences.length === 0) return result;
|
||||||
|
|
||||||
// 获取每个用户的个人认种数量
|
// 获取每个用户的个人认种数量和订单数(只统计 MINING_ENABLED 状态)
|
||||||
const personalAdoptions = await this.prisma.syncedAdoption.groupBy({
|
const personalAdoptions = await this.prisma.syncedAdoption.groupBy({
|
||||||
by: ['accountSequence'],
|
by: ['accountSequence'],
|
||||||
where: { accountSequence: { in: accountSequences } },
|
where: {
|
||||||
|
accountSequence: { in: accountSequences },
|
||||||
|
status: 'MINING_ENABLED',
|
||||||
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
|
_count: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const stat of personalAdoptions) {
|
for (const stat of personalAdoptions) {
|
||||||
result.set(stat.accountSequence, {
|
result.set(stat.accountSequence, {
|
||||||
personalCount: stat._sum.treeCount || 0,
|
personalCount: stat._sum.treeCount || 0,
|
||||||
|
personalOrders: stat._count.id || 0,
|
||||||
teamCount: 0,
|
teamCount: 0,
|
||||||
|
teamOrders: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有用户都有记录
|
// 确保所有用户都有记录
|
||||||
for (const seq of accountSequences) {
|
for (const seq of accountSequences) {
|
||||||
if (!result.has(seq)) {
|
if (!result.has(seq)) {
|
||||||
result.set(seq, { personalCount: 0, teamCount: 0 });
|
result.set(seq, { personalCount: 0, personalOrders: 0, teamCount: 0, teamOrders: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,12 +168,15 @@ export class UsersService {
|
||||||
const teamAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
const teamAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
||||||
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
|
_count: { id: true },
|
||||||
});
|
});
|
||||||
const stats = result.get(ref.accountSequence);
|
const stats = result.get(ref.accountSequence);
|
||||||
if (stats) {
|
if (stats) {
|
||||||
stats.teamCount = teamAdoptionStats._sum.treeCount || 0;
|
stats.teamCount = teamAdoptionStats._sum.treeCount || 0;
|
||||||
|
stats.teamOrders = teamAdoptionStats._count.id || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,9 +230,9 @@ export class UsersService {
|
||||||
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
|
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取个人认种数量(从 synced_adoptions 统计)
|
// 获取个人认种数量(从 synced_adoptions 统计,只统计 MINING_ENABLED 状态)
|
||||||
const personalAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
const personalAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
where: { accountSequence },
|
where: { accountSequence, status: 'MINING_ENABLED' },
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
});
|
});
|
||||||
|
|
@ -226,7 +244,7 @@ export class UsersService {
|
||||||
});
|
});
|
||||||
const directReferralCount = directReferrals.length;
|
const directReferralCount = directReferrals.length;
|
||||||
|
|
||||||
// 获取直推认种数量
|
// 获取直推认种数量(只统计 MINING_ENABLED 状态)
|
||||||
let directReferralAdoptions = 0;
|
let directReferralAdoptions = 0;
|
||||||
if (directReferrals.length > 0) {
|
if (directReferrals.length > 0) {
|
||||||
const directAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
const directAdoptionStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
|
|
@ -234,6 +252,7 @@ export class UsersService {
|
||||||
accountSequence: {
|
accountSequence: {
|
||||||
in: directReferrals.map((r) => r.accountSequence),
|
in: directReferrals.map((r) => r.accountSequence),
|
||||||
},
|
},
|
||||||
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
@ -267,6 +286,7 @@ export class UsersService {
|
||||||
accountSequence: {
|
accountSequence: {
|
||||||
in: teamMembers.map((m) => m.accountSequence),
|
in: teamMembers.map((m) => m.accountSequence),
|
||||||
},
|
},
|
||||||
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
@ -412,8 +432,7 @@ export class UsersService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户挖矿记录(从同步表获取概要)
|
* 获取用户挖矿记录(从 mining-service 获取)
|
||||||
* 注:详细流水需要调用 mining-service
|
|
||||||
*/
|
*/
|
||||||
async getUserMiningRecords(
|
async getUserMiningRecords(
|
||||||
accountSequence: string,
|
accountSequence: string,
|
||||||
|
|
@ -430,33 +449,79 @@ export class UsersService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mining = user.miningAccount;
|
const mining = user.miningAccount;
|
||||||
if (!mining) {
|
const emptySummary = {
|
||||||
|
accountSequence,
|
||||||
|
totalMined: '0',
|
||||||
|
availableBalance: '0',
|
||||||
|
frozenBalance: '0',
|
||||||
|
totalContribution: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 mining-service 获取挖矿记录
|
||||||
|
try {
|
||||||
|
const url = `${this.miningServiceUrl}/api/v2/mining/accounts/${accountSequence}/records?page=${page}&pageSize=${pageSize}`;
|
||||||
|
this.logger.log(`Fetching mining records from ${url}`);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn(`Failed to fetch mining records: ${response.status}`);
|
||||||
|
return {
|
||||||
|
summary: mining ? {
|
||||||
|
accountSequence,
|
||||||
|
totalMined: mining.totalMined.toString(),
|
||||||
|
availableBalance: mining.availableBalance.toString(),
|
||||||
|
frozenBalance: mining.frozenBalance.toString(),
|
||||||
|
totalContribution: mining.totalContribution.toString(),
|
||||||
|
} : emptySummary,
|
||||||
|
records: [],
|
||||||
|
pagination: { page, pageSize, total: 0, totalPages: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const recordsData = result.data || result;
|
||||||
|
|
||||||
|
// 格式化记录以匹配前端期望的格式
|
||||||
|
const records = (recordsData.data || []).map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
accountSequence,
|
||||||
|
distributionMinute: r.miningMinute,
|
||||||
|
contributionRatio: r.contributionRatio,
|
||||||
|
shareAmount: r.minedAmount,
|
||||||
|
priceSnapshot: r.secondDistribution,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: {
|
summary: mining ? {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
totalMined: '0',
|
totalMined: mining.totalMined.toString(),
|
||||||
availableBalance: '0',
|
availableBalance: mining.availableBalance.toString(),
|
||||||
frozenBalance: '0',
|
frozenBalance: mining.frozenBalance.toString(),
|
||||||
totalContribution: '0',
|
totalContribution: mining.totalContribution.toString(),
|
||||||
|
} : emptySummary,
|
||||||
|
records,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: recordsData.total || 0,
|
||||||
|
totalPages: Math.ceil((recordsData.total || 0) / pageSize),
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to fetch mining records from mining-service', error);
|
||||||
|
return {
|
||||||
|
summary: mining ? {
|
||||||
|
accountSequence,
|
||||||
|
totalMined: mining.totalMined.toString(),
|
||||||
|
availableBalance: mining.availableBalance.toString(),
|
||||||
|
frozenBalance: mining.frozenBalance.toString(),
|
||||||
|
totalContribution: mining.totalContribution.toString(),
|
||||||
|
} : emptySummary,
|
||||||
records: [],
|
records: [],
|
||||||
pagination: { page, pageSize, total: 0, totalPages: 0 },
|
pagination: { page, pageSize, total: 0, totalPages: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
summary: {
|
|
||||||
accountSequence,
|
|
||||||
totalMined: mining.totalMined.toString(),
|
|
||||||
availableBalance: mining.availableBalance.toString(),
|
|
||||||
frozenBalance: mining.frozenBalance.toString(),
|
|
||||||
totalContribution: mining.totalContribution.toString(),
|
|
||||||
},
|
|
||||||
// 详细流水需要从 mining-service 获取
|
|
||||||
records: [],
|
|
||||||
pagination: { page, pageSize, total: 0, totalPages: 0 },
|
|
||||||
note: '详细挖矿记录请查看 mining-service',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -568,14 +633,14 @@ export class UsersService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户认种统计
|
* 获取用户认种统计(只统计 MINING_ENABLED 状态)
|
||||||
*/
|
*/
|
||||||
private async getUserAdoptionStats(
|
private async getUserAdoptionStats(
|
||||||
accountSequence: string,
|
accountSequence: string,
|
||||||
): Promise<{ personal: number; team: number }> {
|
): Promise<{ personal: number; team: number }> {
|
||||||
// 个人认种
|
// 个人认种(只统计 MINING_ENABLED 状态)
|
||||||
const personalStats = await this.prisma.syncedAdoption.aggregate({
|
const personalStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
where: { accountSequence },
|
where: { accountSequence, status: 'MINING_ENABLED' },
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -587,7 +652,7 @@ export class UsersService {
|
||||||
|
|
||||||
let teamCount = 0;
|
let teamCount = 0;
|
||||||
if (referral?.originalUserId) {
|
if (referral?.originalUserId) {
|
||||||
// 团队认种 = 所有下级的认种总和
|
// 团队认种 = 所有下级的认种总和(只统计 MINING_ENABLED 状态)
|
||||||
const teamMembers = await this.prisma.syncedReferral.findMany({
|
const teamMembers = await this.prisma.syncedReferral.findMany({
|
||||||
where: {
|
where: {
|
||||||
ancestorPath: { contains: referral.originalUserId.toString() },
|
ancestorPath: { contains: referral.originalUserId.toString() },
|
||||||
|
|
@ -599,6 +664,7 @@ export class UsersService {
|
||||||
const teamStats = await this.prisma.syncedAdoption.aggregate({
|
const teamStats = await this.prisma.syncedAdoption.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
||||||
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_sum: { treeCount: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
@ -840,7 +906,7 @@ export class UsersService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户钱包流水
|
* 获取用户钱包流水
|
||||||
* TODO: 从 mining-service 同步钱包流水数据
|
* 从 SyncedUserWallet 获取钱包汇总,从 SyncedMiningAccount 获取挖矿余额
|
||||||
*/
|
*/
|
||||||
async getWalletLedger(accountSequence: string, page: number, pageSize: number) {
|
async getWalletLedger(accountSequence: string, page: number, pageSize: number) {
|
||||||
const user = await this.prisma.syncedUser.findUnique({
|
const user = await this.prisma.syncedUser.findUnique({
|
||||||
|
|
@ -852,20 +918,44 @@ export class UsersService {
|
||||||
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
|
throw new NotFoundException(`用户 ${accountSequence} 不存在`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户的各类钱包数据
|
||||||
|
const wallets = await this.prisma.syncedUserWallet.findMany({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按钱包类型分类
|
||||||
|
const walletByType = new Map(wallets.map(w => [w.walletType, w]));
|
||||||
|
const greenPointsWallet = walletByType.get('GREEN_POINTS');
|
||||||
|
const contributionWallet = walletByType.get('CONTRIBUTION');
|
||||||
|
const tokenWallet = walletByType.get('TOKEN_STORAGE');
|
||||||
|
|
||||||
const mining = user.miningAccount;
|
const mining = user.miningAccount;
|
||||||
|
|
||||||
|
// 构建前端期望的钱包汇总格式
|
||||||
|
// usdtAvailable = GREEN_POINTS 钱包的可用余额 (绿积分)
|
||||||
|
// usdtFrozen = GREEN_POINTS 钱包的冻结余额
|
||||||
|
// pendingUsdt = 待领取收益(挖矿余额)
|
||||||
|
// settleableUsdt = 可结算收益
|
||||||
|
// settledTotalUsdt = 已结算收益
|
||||||
|
// expiredTotalUsdt = 过期收益
|
||||||
|
const summary = {
|
||||||
|
usdtAvailable: greenPointsWallet?.balance?.toString() || '0',
|
||||||
|
usdtFrozen: greenPointsWallet?.frozenBalance?.toString() || '0',
|
||||||
|
pendingUsdt: mining?.availableBalance?.toString() || '0', // 挖矿可用余额作为待领取
|
||||||
|
settleableUsdt: '0', // 暂无数据源
|
||||||
|
settledTotalUsdt: greenPointsWallet?.totalInflow?.toString() || '0', // 总流入作为已结算
|
||||||
|
expiredTotalUsdt: '0', // 暂无数据源
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: 实现钱包流水分页查询
|
||||||
|
// 目前从 SyncedUserWallet 只能获取汇总数据,流水明细需要额外的表
|
||||||
return {
|
return {
|
||||||
summary: {
|
summary,
|
||||||
availableBalance: mining?.availableBalance?.toString() || '0',
|
|
||||||
frozenBalance: mining?.frozenBalance?.toString() || '0',
|
|
||||||
totalMined: mining?.totalMined?.toString() || '0',
|
|
||||||
},
|
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
note: '钱包流水数据需要从 mining-service 同步',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -876,7 +966,7 @@ export class UsersService {
|
||||||
private formatUserListItem(
|
private formatUserListItem(
|
||||||
user: any,
|
user: any,
|
||||||
extra?: {
|
extra?: {
|
||||||
adoptionStats?: { personalCount: number; teamCount: number };
|
adoptionStats?: { personalCount: number; personalOrders: number; teamCount: number; teamOrders: number };
|
||||||
referrerInfo?: { nickname: string | null; phone: string } | null;
|
referrerInfo?: { nickname: string | null; phone: string } | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -892,7 +982,9 @@ export class UsersService {
|
||||||
// 认种统计
|
// 认种统计
|
||||||
adoption: {
|
adoption: {
|
||||||
personalAdoptionCount: extra?.adoptionStats?.personalCount || 0,
|
personalAdoptionCount: extra?.adoptionStats?.personalCount || 0,
|
||||||
|
personalAdoptionOrders: extra?.adoptionStats?.personalOrders || 0,
|
||||||
teamAdoptions: extra?.adoptionStats?.teamCount || 0,
|
teamAdoptions: extra?.adoptionStats?.teamCount || 0,
|
||||||
|
teamAdoptionOrders: extra?.adoptionStats?.teamOrders || 0,
|
||||||
},
|
},
|
||||||
// 推荐人信息
|
// 推荐人信息
|
||||||
referral: user.referral
|
referral: user.referral
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,12 @@ export class CdcSyncService implements OnModuleInit {
|
||||||
this.withIdempotency(this.walletHandlers.handleFeeConfigUpdated.bind(this.walletHandlers)),
|
this.withIdempotency(this.walletHandlers.handleFeeConfigUpdated.bind(this.walletHandlers)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CONTRIBUTION_CREDITED 事件 - 贡献值入账时更新用户钱包
|
||||||
|
this.cdcConsumer.registerServiceHandler(
|
||||||
|
'CONTRIBUTION_CREDITED',
|
||||||
|
this.withIdempotency(this.handleContributionCredited.bind(this)),
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log('CDC sync handlers registered with idempotency protection');
|
this.logger.log('CDC sync handlers registered with idempotency protection');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -813,4 +819,60 @@ export class CdcSyncService implements OnModuleInit {
|
||||||
this.logger.debug('Synced circulation pool');
|
this.logger.debug('Synced circulation pool');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 钱包事件处理 (mining-wallet-service)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 CONTRIBUTION_CREDITED 事件
|
||||||
|
* mining-wallet-service 在为用户入账贡献值时发布
|
||||||
|
* payload: { accountSequence, walletType, amount, balanceAfter, transactionId, ... }
|
||||||
|
*/
|
||||||
|
private async handleContributionCredited(event: ServiceEvent, tx: TransactionClient): Promise<void> {
|
||||||
|
const { payload } = event;
|
||||||
|
const walletType = payload.walletType || 'CONTRIBUTION';
|
||||||
|
|
||||||
|
// 先查找是否已存在
|
||||||
|
const existing = await tx.syncedUserWallet.findUnique({
|
||||||
|
where: {
|
||||||
|
accountSequence_walletType: {
|
||||||
|
accountSequence: payload.accountSequence,
|
||||||
|
walletType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 更新余额(使用最新的 balanceAfter)
|
||||||
|
await tx.syncedUserWallet.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
balance: payload.balanceAfter,
|
||||||
|
totalInflow: {
|
||||||
|
increment: parseFloat(payload.amount) || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 创建新钱包记录
|
||||||
|
// originalId 使用 accountSequence + walletType 的组合生成一个稳定的 ID
|
||||||
|
const originalId = `wallet-${payload.accountSequence}-${walletType}`;
|
||||||
|
|
||||||
|
await tx.syncedUserWallet.create({
|
||||||
|
data: {
|
||||||
|
originalId,
|
||||||
|
accountSequence: payload.accountSequence,
|
||||||
|
walletType,
|
||||||
|
balance: payload.balanceAfter || 0,
|
||||||
|
frozenBalance: 0,
|
||||||
|
totalInflow: parseFloat(payload.amount) || 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Synced user wallet from CONTRIBUTION_CREDITED: ${payload.accountSequence}, balance: ${payload.balanceAfter}`);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ RUN npm ci
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build && ls -la dist/ && test -f dist/main.js && echo "Build successful: dist/main.js exists"
|
||||||
|
|
||||||
# 阶段2: 生产运行
|
# 阶段2: 生产运行
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
@ -30,14 +30,16 @@ WORKDIR /app
|
||||||
USER nestjs
|
USER nestjs
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs package*.json ./
|
COPY --chown=nestjs:nodejs package*.json ./
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
COPY --chown=nestjs:nodejs tsconfig*.json ./
|
||||||
|
RUN npm ci --only=production && npm install ts-node typescript @types/node --save-dev && npm cache clean --force
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs prisma ./prisma/
|
COPY --chown=nestjs:nodejs prisma ./prisma/
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
|
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
|
||||||
|
RUN ls -la dist/ && test -f dist/main.js && echo "Copy successful: dist/main.js exists"
|
||||||
|
|
||||||
RUN printf '#!/bin/sh\nset -e\necho "Running database migrations..."\nnpx prisma migrate deploy\necho "Starting application..."\nexec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
|
RUN printf '#!/bin/sh\nset -e\necho "Running database migrations..."\nnpx prisma migrate deploy\necho "Running database seed..."\nnpx prisma db seed || echo "Seed skipped or already applied"\necho "Starting application..."\nexec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:migrate:prod": "prisma migrate deploy",
|
"prisma:migrate:prod": "prisma migrate deploy",
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
|
|
@ -37,6 +38,9 @@
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.2.1",
|
"@nestjs/cli": "^10.2.1",
|
||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- ============================================================================
|
||||||
|
-- 将 minuteDistribution 改为 secondDistribution
|
||||||
|
-- 支持每秒挖矿分配
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 重命名 mining_configs 表的列
|
||||||
|
ALTER TABLE "mining_configs" RENAME COLUMN "minuteDistribution" TO "secondDistribution";
|
||||||
|
|
||||||
|
-- 重命名 mining_eras 表的列
|
||||||
|
ALTER TABLE "mining_eras" RENAME COLUMN "minuteDistribution" TO "secondDistribution";
|
||||||
|
|
||||||
|
-- 重命名 mining_records 表的列
|
||||||
|
ALTER TABLE "mining_records" RENAME COLUMN "minuteDistribution" TO "secondDistribution";
|
||||||
|
|
@ -18,7 +18,7 @@ model MiningConfig {
|
||||||
halvingPeriodYears Int @default(2) // 减半周期(年)
|
halvingPeriodYears Int @default(2) // 减半周期(年)
|
||||||
currentEra Int @default(1) // 当前纪元
|
currentEra Int @default(1) // 当前纪元
|
||||||
eraStartDate DateTime // 当前纪元开始日期
|
eraStartDate DateTime // 当前纪元开始日期
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
isActive Boolean @default(false) // 是否已激活挖矿
|
isActive Boolean @default(false) // 是否已激活挖矿
|
||||||
activatedAt DateTime? // 激活时间
|
activatedAt DateTime? // 激活时间
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
@ -35,7 +35,7 @@ model MiningEra {
|
||||||
endDate DateTime?
|
endDate DateTime?
|
||||||
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
|
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
|
||||||
totalDistributed Decimal @default(0) @db.Decimal(30, 8) // 已分配量
|
totalDistributed Decimal @default(0) @db.Decimal(30, 8) // 已分配量
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
@ -63,15 +63,16 @@ model MiningAccount {
|
||||||
@@map("mining_accounts")
|
@@map("mining_accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挖矿记录(分钟级别)
|
// 挖矿记录(分钟级别汇总)
|
||||||
|
// 每秒更新余额,每分钟写入一条汇总记录
|
||||||
model MiningRecord {
|
model MiningRecord {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
accountSequence String
|
accountSequence String
|
||||||
miningMinute DateTime // 挖矿分钟(精确到分钟)
|
miningMinute DateTime // 挖矿时间(精确到分钟)
|
||||||
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
|
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
|
||||||
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
|
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 当分钟总分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
minedAmount Decimal @db.Decimal(30, 18) // 挖到的数量
|
minedAmount Decimal @db.Decimal(30, 18) // 该分钟挖到的总数量
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
|
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mining Service 数据库初始化
|
||||||
|
*
|
||||||
|
* 需求:
|
||||||
|
* - 积分股共 100.02 亿
|
||||||
|
* - 200 万原始积分股作为全网贡献值分配
|
||||||
|
* - 第一个两年分配 100 万,第二个两年分配 50 万(减半)
|
||||||
|
* - 100 亿通过销毁机制进入黑洞(10年完成)
|
||||||
|
* - 每秒分配一次,用户实时看到收益
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log('🚀 Mining-service seed starting...\n');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 常量
|
||||||
|
const TOTAL_SHARES = new Decimal('100020000000'); // 100.02B
|
||||||
|
const DISTRIBUTION_POOL = new Decimal('2000000'); // 200万
|
||||||
|
const ERA1_DISTRIBUTION = new Decimal('1000000'); // 100万(第一个两年)
|
||||||
|
const BURN_TARGET = new Decimal('10000000000'); // 100亿
|
||||||
|
|
||||||
|
// 每秒分配量计算: 100万 / (2年 * 365天 * 24小时 * 60分钟 * 60秒)
|
||||||
|
const SECONDS_IN_2_YEARS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||||
|
const SECOND_DISTRIBUTION = ERA1_DISTRIBUTION.dividedBy(SECONDS_IN_2_YEARS);
|
||||||
|
|
||||||
|
// 1. MiningConfig - 挖矿配置(不激活,等待管理员手动启动)
|
||||||
|
await prisma.miningConfig.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
create: {
|
||||||
|
id: 'default',
|
||||||
|
totalShares: TOTAL_SHARES,
|
||||||
|
distributionPool: DISTRIBUTION_POOL,
|
||||||
|
remainingDistribution: ERA1_DISTRIBUTION,
|
||||||
|
halvingPeriodYears: 2,
|
||||||
|
currentEra: 1,
|
||||||
|
eraStartDate: now,
|
||||||
|
secondDistribution: SECOND_DISTRIBUTION,
|
||||||
|
isActive: false, // 等待管理员在后台启动
|
||||||
|
activatedAt: null,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
console.log('✅ MiningConfig initialized (inactive, waiting for admin activation)');
|
||||||
|
|
||||||
|
// 2. BlackHole - 黑洞账户
|
||||||
|
await prisma.blackHole.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
create: {
|
||||||
|
id: 'default',
|
||||||
|
totalBurned: 0,
|
||||||
|
targetBurn: BURN_TARGET,
|
||||||
|
remainingBurn: BURN_TARGET,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
console.log('✅ BlackHole initialized');
|
||||||
|
|
||||||
|
// 3. MiningEra - 第一纪元
|
||||||
|
await prisma.miningEra.upsert({
|
||||||
|
where: { eraNumber: 1 },
|
||||||
|
create: {
|
||||||
|
eraNumber: 1,
|
||||||
|
startDate: now,
|
||||||
|
initialDistribution: ERA1_DISTRIBUTION,
|
||||||
|
totalDistributed: 0,
|
||||||
|
secondDistribution: SECOND_DISTRIBUTION,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
console.log('✅ MiningEra 1 initialized');
|
||||||
|
|
||||||
|
// 4. PoolAccounts - 池账户
|
||||||
|
const pools = [
|
||||||
|
{ poolType: 'SHARE_POOL', name: '积分股池', balance: TOTAL_SHARES },
|
||||||
|
{ poolType: 'BLACK_HOLE_POOL', name: '黑洞池', balance: new Decimal(0) },
|
||||||
|
{ poolType: 'CIRCULATION_POOL', name: '流通池', balance: new Decimal(0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pool of pools) {
|
||||||
|
await prisma.poolAccount.upsert({
|
||||||
|
where: { poolType: pool.poolType as any },
|
||||||
|
create: {
|
||||||
|
poolType: pool.poolType as any,
|
||||||
|
name: pool.name,
|
||||||
|
balance: pool.balance,
|
||||||
|
totalInflow: pool.balance,
|
||||||
|
totalOutflow: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ PoolAccounts initialized');
|
||||||
|
|
||||||
|
// 输出配置
|
||||||
|
console.log('\n📊 Configuration:');
|
||||||
|
console.log(` Total Shares: ${TOTAL_SHARES.toFixed(0)} (100.02B)`);
|
||||||
|
console.log(` Distribution Pool: ${DISTRIBUTION_POOL.toFixed(0)} (200万)`);
|
||||||
|
console.log(` Era 1 Distribution: ${ERA1_DISTRIBUTION.toFixed(0)} (100万)`);
|
||||||
|
console.log(` Seconds in 2 Years: ${SECONDS_IN_2_YEARS}`);
|
||||||
|
console.log(` Second Distribution: ${SECOND_DISTRIBUTION.toFixed(12)}`);
|
||||||
|
console.log(` Burn Target: ${BURN_TARGET.toFixed(0)} (100亿, 10年完成)`);
|
||||||
|
console.log(` Mining Active: false (需要在管理后台手动启动)`);
|
||||||
|
|
||||||
|
console.log('\n🎉 Mining-service seed completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Post, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -37,4 +37,83 @@ export class AdminController {
|
||||||
total: accounts.length,
|
total: accounts.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取挖矿系统状态' })
|
||||||
|
async getStatus() {
|
||||||
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
|
const blackHole = await this.prisma.blackHole.findFirst();
|
||||||
|
const accountCount = await this.prisma.miningAccount.count();
|
||||||
|
const totalContribution = await this.prisma.miningAccount.aggregate({
|
||||||
|
_sum: { totalContribution: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized: !!config,
|
||||||
|
isActive: config?.isActive || false,
|
||||||
|
activatedAt: config?.activatedAt,
|
||||||
|
currentEra: config?.currentEra || 0,
|
||||||
|
remainingDistribution: config?.remainingDistribution?.toString() || '0',
|
||||||
|
secondDistribution: config?.secondDistribution?.toString() || '0',
|
||||||
|
blackHole: blackHole
|
||||||
|
? {
|
||||||
|
totalBurned: blackHole.totalBurned.toString(),
|
||||||
|
targetBurn: blackHole.targetBurn.toString(),
|
||||||
|
remainingBurn: blackHole.remainingBurn.toString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
accountCount,
|
||||||
|
totalContribution: totalContribution._sum.totalContribution?.toString() || '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('activate')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '激活挖矿系统' })
|
||||||
|
async activate() {
|
||||||
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new HttpException('挖矿系统未初始化,请先运行 seed 脚本', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.isActive) {
|
||||||
|
return { success: true, message: '挖矿系统已经处于激活状态' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.miningConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: '挖矿系统已激活' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('deactivate')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '停用挖矿系统' })
|
||||||
|
async deactivate() {
|
||||||
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new HttpException('挖矿系统未初始化', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.isActive) {
|
||||||
|
return { success: true, message: '挖矿系统已经处于停用状态' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.miningConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: '挖矿系统已停用' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
|
@Public()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { GetMiningAccountQuery } from '../../application/queries/get-mining-account.query';
|
import { GetMiningAccountQuery } from '../../application/queries/get-mining-account.query';
|
||||||
import { GetMiningStatsQuery } from '../../application/queries/get-mining-stats.query';
|
import { GetMiningStatsQuery } from '../../application/queries/get-mining-stats.query';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Mining')
|
@ApiTags('Mining')
|
||||||
@Controller('mining')
|
@Controller('mining')
|
||||||
|
@Public() // 服务间调用,不需要认证
|
||||||
export class MiningController {
|
export class MiningController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getAccountQuery: GetMiningAccountQuery,
|
private readonly getAccountQuery: GetMiningAccountQuery,
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,80 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ContributionSyncService } from '../services/contribution-sync.service';
|
import { ContributionSyncService } from '../services/contribution-sync.service';
|
||||||
|
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContributionEventHandler {
|
export class ContributionEventHandler implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ContributionEventHandler.name);
|
private readonly logger = new Logger(ContributionEventHandler.name);
|
||||||
|
private consumer: Consumer;
|
||||||
|
|
||||||
constructor(private readonly syncService: ContributionSyncService) {}
|
constructor(
|
||||||
|
private readonly syncService: ContributionSyncService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const kafkaBrokers = this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092');
|
||||||
|
const topic = this.configService.get<string>('CDC_TOPIC_CONTRIBUTION_OUTBOX', 'cdc.contribution.outbox');
|
||||||
|
|
||||||
|
const kafka = new Kafka({
|
||||||
|
clientId: 'mining-service',
|
||||||
|
brokers: kafkaBrokers.split(','),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer = kafka.consumer({ groupId: 'mining-service-contribution-sync' });
|
||||||
|
|
||||||
@EventPattern('contribution.ContributionCalculated')
|
|
||||||
async handleContributionCalculated(@Payload() message: any): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const { payload } = message.value || message;
|
await this.consumer.connect();
|
||||||
this.logger.debug(`Received ContributionCalculated event for ${payload.accountSequence}`);
|
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
|
||||||
await this.syncService.handleContributionCalculated({
|
await this.consumer.run({
|
||||||
accountSequence: payload.accountSequence,
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
personalContribution: payload.personalContribution,
|
await this.handleMessage(payload);
|
||||||
calculatedAt: payload.calculatedAt,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Subscribed to ${topic} for contribution sync`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to handle ContributionCalculated event', error);
|
this.logger.error('Failed to connect to Kafka for contribution sync', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventPattern('contribution.DailySnapshotCreated')
|
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||||||
async handleDailySnapshotCreated(@Payload() message: any): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const { payload } = message.value || message;
|
const { message } = payload;
|
||||||
this.logger.log(`Received DailySnapshotCreated event for ${payload.snapshotDate}`);
|
if (!message.value) return;
|
||||||
|
|
||||||
await this.syncService.handleDailySnapshotCreated({
|
const event = JSON.parse(message.value.toString());
|
||||||
snapshotId: payload.snapshotId,
|
|
||||||
snapshotDate: payload.snapshotDate,
|
// CDC 消息格式:{ after: { event_type, payload, ... } }
|
||||||
totalContribution: payload.totalContribution,
|
const data = event.after || event;
|
||||||
activeAccounts: payload.activeAccounts,
|
const eventType = data.event_type || data.eventType;
|
||||||
});
|
const eventPayload = typeof data.payload === 'string' ? JSON.parse(data.payload) : data.payload;
|
||||||
|
|
||||||
|
if (!eventPayload) return;
|
||||||
|
|
||||||
|
if (eventType === 'ContributionAccountUpdated') {
|
||||||
|
this.logger.debug(`Received ContributionAccountUpdated for ${eventPayload.accountSequence}`);
|
||||||
|
|
||||||
|
// 使用 effectiveContribution 作为挖矿算力
|
||||||
|
await this.syncService.handleContributionCalculated({
|
||||||
|
accountSequence: eventPayload.accountSequence,
|
||||||
|
personalContribution: eventPayload.effectiveContribution || eventPayload.totalContribution || '0',
|
||||||
|
calculatedAt: eventPayload.createdAt || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} else if (eventType === 'DailySnapshotCreated') {
|
||||||
|
this.logger.log(`Received DailySnapshotCreated for ${eventPayload.snapshotDate}`);
|
||||||
|
|
||||||
|
await this.syncService.handleDailySnapshotCreated({
|
||||||
|
snapshotId: eventPayload.snapshotId,
|
||||||
|
snapshotDate: eventPayload.snapshotDate,
|
||||||
|
totalContribution: eventPayload.totalContribution,
|
||||||
|
activeAccounts: eventPayload.activeAccounts,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to handle DailySnapshotCreated event', error);
|
this.logger.error('Failed to handle contribution event', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface MiningRecordDto {
|
||||||
miningMinute: Date;
|
miningMinute: Date;
|
||||||
contributionRatio: string;
|
contributionRatio: string;
|
||||||
totalContribution: string;
|
totalContribution: string;
|
||||||
minuteDistribution: string;
|
secondDistribution: string;
|
||||||
minedAmount: string;
|
minedAmount: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ export class GetMiningAccountQuery {
|
||||||
miningMinute: r.miningMinute,
|
miningMinute: r.miningMinute,
|
||||||
contributionRatio: r.contributionRatio.toString(),
|
contributionRatio: r.contributionRatio.toString(),
|
||||||
totalContribution: r.totalContribution.toString(),
|
totalContribution: r.totalContribution.toString(),
|
||||||
minuteDistribution: r.minuteDistribution.toString(),
|
secondDistribution: r.secondDistribution.toString(),
|
||||||
minedAmount: r.minedAmount.toString(),
|
minedAmount: r.minedAmount.toString(),
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export interface MiningStatsDto {
|
||||||
totalShares: string;
|
totalShares: string;
|
||||||
distributionPool: string;
|
distributionPool: string;
|
||||||
remainingDistribution: string;
|
remainingDistribution: string;
|
||||||
minuteDistribution: string;
|
secondDistribution: string;
|
||||||
|
|
||||||
// 参与信息
|
// 参与信息
|
||||||
totalContribution: string;
|
totalContribution: string;
|
||||||
|
|
@ -79,7 +79,7 @@ export class GetMiningStatsQuery {
|
||||||
totalShares: config?.totalShares.toString() || '0',
|
totalShares: config?.totalShares.toString() || '0',
|
||||||
distributionPool: config?.distributionPool.toString() || '0',
|
distributionPool: config?.distributionPool.toString() || '0',
|
||||||
remainingDistribution: config?.remainingDistribution.toString() || '0',
|
remainingDistribution: config?.remainingDistribution.toString() || '0',
|
||||||
minuteDistribution: config?.minuteDistribution.toString() || '0',
|
secondDistribution: config?.secondDistribution.toString() || '0',
|
||||||
totalContribution: totalContribution.toString(),
|
totalContribution: totalContribution.toString(),
|
||||||
participantCount,
|
participantCount,
|
||||||
totalMined: totalMined.toString(),
|
totalMined: totalMined.toString(),
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ export class MiningScheduler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每分钟执行挖矿分配
|
* 每秒执行挖矿分配
|
||||||
*/
|
*/
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_SECOND)
|
||||||
async executeMinuteDistribution(): Promise<void> {
|
async executeSecondDistribution(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.distributionService.executeMinuteDistribution();
|
await this.distributionService.executeSecondDistribution();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to execute minute distribution', error);
|
this.logger.error('Failed to execute second distribution', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,23 @@ import { PriceSnapshotRepository } from '../../infrastructure/persistence/reposi
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
import { MiningCalculatorService } from '../../domain/services/mining-calculator.service';
|
import { MiningCalculatorService } from '../../domain/services/mining-calculator.service';
|
||||||
import { MiningAccountAggregate } from '../../domain/aggregates/mining-account.aggregate';
|
|
||||||
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
||||||
import { Price } from '../../domain/value-objects/price.vo';
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 挖矿分配服务
|
* 挖矿分配服务
|
||||||
* 负责每分钟执行挖矿分配和销毁
|
* 负责每秒执行挖矿分配和销毁
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 每秒:计算并更新账户余额
|
||||||
|
* - 每分钟:写入汇总的MiningRecord记录
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MiningDistributionService {
|
export class MiningDistributionService {
|
||||||
private readonly logger = new Logger(MiningDistributionService.name);
|
private readonly logger = new Logger(MiningDistributionService.name);
|
||||||
private readonly calculator = new MiningCalculatorService();
|
private readonly calculator = new MiningCalculatorService();
|
||||||
private readonly LOCK_KEY = 'mining:distribution:lock';
|
private readonly LOCK_KEY = 'mining:distribution:lock';
|
||||||
|
private readonly MINUTE_ACCUMULATOR_PREFIX = 'mining:minute:accumulator:';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly miningAccountRepository: MiningAccountRepository,
|
private readonly miningAccountRepository: MiningAccountRepository,
|
||||||
|
|
@ -32,52 +36,43 @@ export class MiningDistributionService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行每分钟挖矿分配
|
* 执行每秒挖矿分配
|
||||||
|
* - 每秒更新账户余额
|
||||||
|
* - 每分钟写入汇总MiningRecord
|
||||||
*/
|
*/
|
||||||
async executeMinuteDistribution(): Promise<void> {
|
async executeSecondDistribution(): Promise<void> {
|
||||||
// 获取分布式锁
|
// 获取分布式锁(锁定时间900ms)
|
||||||
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
|
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 0.9);
|
||||||
if (!lockValue) {
|
if (!lockValue) {
|
||||||
this.logger.debug('Another instance is processing distribution');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
const config = await this.miningConfigRepository.getConfig();
|
||||||
if (!config || !config.isActive) {
|
if (!config || !config.isActive) {
|
||||||
this.logger.debug('Mining is not active');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSecond = this.getCurrentSecond();
|
||||||
const currentMinute = this.getCurrentMinute();
|
const currentMinute = this.getCurrentMinute();
|
||||||
|
const isMinuteEnd = currentSecond.getSeconds() === 59;
|
||||||
|
|
||||||
// 检查是否已处理过这一分钟
|
// 检查是否已处理过这一秒
|
||||||
const processedKey = `mining:processed:${currentMinute.toISOString()}`;
|
const processedKey = `mining:processed:${currentSecond.getTime()}`;
|
||||||
if (await this.redis.get(processedKey)) {
|
if (await this.redis.get(processedKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每分钟分配量
|
// 使用预计算的每秒分配量
|
||||||
const remainingMinutes = this.calculator.calculateRemainingMinutes(
|
const secondDistribution = config.secondDistribution;
|
||||||
config.eraStartDate,
|
|
||||||
MiningCalculatorService.HALVING_PERIOD_MINUTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const minuteDistribution = this.calculator.calculateMinuteDistribution(
|
if (secondDistribution.isZero()) {
|
||||||
config.remainingDistribution,
|
|
||||||
config.currentEra,
|
|
||||||
remainingMinutes,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (minuteDistribution.isZero()) {
|
|
||||||
this.logger.debug('No distribution available');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取有算力的账户
|
// 获取有算力的账户
|
||||||
const totalContribution = await this.miningAccountRepository.getTotalContribution();
|
const totalContribution = await this.miningAccountRepository.getTotalContribution();
|
||||||
if (totalContribution.isZero()) {
|
if (totalContribution.isZero()) {
|
||||||
this.logger.debug('No contribution available');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,24 +90,23 @@ export class MiningDistributionService {
|
||||||
const reward = this.calculator.calculateUserMiningReward(
|
const reward = this.calculator.calculateUserMiningReward(
|
||||||
account.totalContribution,
|
account.totalContribution,
|
||||||
totalContribution,
|
totalContribution,
|
||||||
minuteDistribution,
|
secondDistribution,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!reward.isZero()) {
|
if (!reward.isZero()) {
|
||||||
account.mine(reward, `分钟挖矿 ${currentMinute.toISOString()}`);
|
// 每秒更新账户余额
|
||||||
|
account.mine(reward, `秒挖矿 ${currentSecond.getTime()}`);
|
||||||
await this.miningAccountRepository.save(account);
|
await this.miningAccountRepository.save(account);
|
||||||
|
|
||||||
// 保存挖矿记录
|
// 累积每分钟的挖矿数据到Redis
|
||||||
await this.prisma.miningRecord.create({
|
await this.accumulateMinuteData(
|
||||||
data: {
|
account.accountSequence,
|
||||||
accountSequence: account.accountSequence,
|
currentMinute,
|
||||||
miningMinute: currentMinute,
|
reward,
|
||||||
contributionRatio: account.totalContribution.value.dividedBy(totalContribution.value),
|
account.totalContribution,
|
||||||
totalContribution: totalContribution.value,
|
totalContribution,
|
||||||
minuteDistribution: minuteDistribution.value,
|
secondDistribution,
|
||||||
minedAmount: reward.value,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
totalDistributed = totalDistributed.add(reward);
|
totalDistributed = totalDistributed.add(reward);
|
||||||
participantCount++;
|
participantCount++;
|
||||||
|
|
@ -123,46 +117,120 @@ export class MiningDistributionService {
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每分钟结束时,写入汇总的MiningRecord
|
||||||
|
if (isMinuteEnd) {
|
||||||
|
await this.writeMinuteRecords(currentMinute);
|
||||||
|
}
|
||||||
|
|
||||||
// 执行销毁
|
// 执行销毁
|
||||||
const burnAmount = await this.executeBurn(currentMinute);
|
const burnAmount = await this.executeBurn(currentSecond);
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
const newRemaining = config.remainingDistribution.subtract(totalDistributed);
|
const newRemaining = config.remainingDistribution.subtract(totalDistributed);
|
||||||
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
|
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
|
||||||
|
|
||||||
// 保存分钟统计
|
// 标记已处理(过期时间2秒)
|
||||||
await this.prisma.minuteMiningStat.create({
|
await this.redis.set(processedKey, '1', 2);
|
||||||
data: {
|
|
||||||
minute: currentMinute,
|
|
||||||
totalContribution: totalContribution.value,
|
|
||||||
totalDistributed: totalDistributed.value,
|
|
||||||
participantCount,
|
|
||||||
burnAmount: burnAmount.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存价格快照
|
// 每分钟记录一次日志
|
||||||
await this.savePriceSnapshot(currentMinute);
|
if (isMinuteEnd) {
|
||||||
|
this.logger.log(
|
||||||
// 标记已处理
|
`Minute distribution: distributed=${totalDistributed.toFixed(8)}, participants=${participantCount}`,
|
||||||
await this.redis.set(processedKey, '1', 120);
|
);
|
||||||
|
}
|
||||||
this.logger.log(
|
|
||||||
`Minute distribution completed: distributed=${totalDistributed.toFixed(8)}, ` +
|
|
||||||
`participants=${participantCount}, burned=${burnAmount.toFixed(8)}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to execute minute distribution', error);
|
this.logger.error('Failed to execute second distribution', error);
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 累积每分钟的挖矿数据到Redis
|
||||||
|
*/
|
||||||
|
private async accumulateMinuteData(
|
||||||
|
accountSequence: string,
|
||||||
|
minuteTime: Date,
|
||||||
|
reward: ShareAmount,
|
||||||
|
accountContribution: ShareAmount,
|
||||||
|
totalContribution: ShareAmount,
|
||||||
|
secondDistribution: ShareAmount,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `${this.MINUTE_ACCUMULATOR_PREFIX}${minuteTime.getTime()}:${accountSequence}`;
|
||||||
|
const existing = await this.redis.get(key);
|
||||||
|
|
||||||
|
let accumulated: {
|
||||||
|
minedAmount: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
totalContribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
secondCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
accumulated = JSON.parse(existing);
|
||||||
|
accumulated.minedAmount = new Decimal(accumulated.minedAmount).plus(reward.value).toString();
|
||||||
|
accumulated.secondCount += 1;
|
||||||
|
// 更新为最新的贡献比例
|
||||||
|
accumulated.contributionRatio = accountContribution.value.dividedBy(totalContribution.value).toString();
|
||||||
|
accumulated.totalContribution = totalContribution.value.toString();
|
||||||
|
accumulated.secondDistribution = secondDistribution.value.toString();
|
||||||
|
} else {
|
||||||
|
accumulated = {
|
||||||
|
minedAmount: reward.value.toString(),
|
||||||
|
contributionRatio: accountContribution.value.dividedBy(totalContribution.value).toString(),
|
||||||
|
totalContribution: totalContribution.value.toString(),
|
||||||
|
secondDistribution: secondDistribution.value.toString(),
|
||||||
|
secondCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期时间为2分钟,确保即使处理失败也能清理
|
||||||
|
await this.redis.set(key, JSON.stringify(accumulated), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入每分钟汇总的MiningRecord
|
||||||
|
*/
|
||||||
|
private async writeMinuteRecords(minuteTime: Date): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 获取所有该分钟的累积数据
|
||||||
|
const pattern = `${this.MINUTE_ACCUMULATOR_PREFIX}${minuteTime.getTime()}:*`;
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.redis.get(key);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
const accumulated = JSON.parse(data);
|
||||||
|
const accountSequence = key.split(':').pop();
|
||||||
|
|
||||||
|
if (!accountSequence) continue;
|
||||||
|
|
||||||
|
// 写入汇总的MiningRecord
|
||||||
|
await this.prisma.miningRecord.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
miningMinute: minuteTime,
|
||||||
|
contributionRatio: new Decimal(accumulated.contributionRatio),
|
||||||
|
totalContribution: new Decimal(accumulated.totalContribution),
|
||||||
|
secondDistribution: new Decimal(accumulated.secondDistribution),
|
||||||
|
minedAmount: new Decimal(accumulated.minedAmount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除已处理的累积数据
|
||||||
|
await this.redis.del(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to write minute records', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行销毁
|
* 执行销毁
|
||||||
*/
|
*/
|
||||||
private async executeBurn(burnMinute: Date): Promise<ShareAmount> {
|
private async executeBurn(burnSecond: Date): Promise<ShareAmount> {
|
||||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||||
if (!blackHole) {
|
if (!blackHole) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
|
|
@ -177,59 +245,37 @@ export class MiningDistributionService {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算剩余销毁分钟数(使用整个挖矿周期)
|
// 计算剩余销毁秒数(10年)
|
||||||
const totalBurnMinutes = 10 * 365 * 24 * 60; // 10年
|
const totalBurnSeconds = 10 * 365 * 24 * 60 * 60;
|
||||||
const remainingMinutes = this.calculator.calculateRemainingBurnMinutes(
|
const remainingSeconds = this.calculator.calculateRemainingSeconds(
|
||||||
config.activatedAt || new Date(),
|
config.activatedAt || new Date(),
|
||||||
totalBurnMinutes,
|
totalBurnSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const burnAmount = this.calculator.calculateMinuteBurn(
|
const burnAmount = this.calculator.calculateSecondBurn(
|
||||||
blackHole.targetBurn,
|
blackHole.targetBurn,
|
||||||
blackHole.totalBurned,
|
blackHole.totalBurned,
|
||||||
remainingMinutes,
|
remainingSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!burnAmount.isZero()) {
|
if (!burnAmount.isZero()) {
|
||||||
await this.blackHoleRepository.recordBurn(burnMinute, burnAmount);
|
await this.blackHoleRepository.recordBurn(burnSecond, burnAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return burnAmount;
|
return burnAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存价格快照
|
* 获取当前秒(向下取整,去掉毫秒)
|
||||||
*/
|
*/
|
||||||
private async savePriceSnapshot(snapshotTime: Date): Promise<void> {
|
private getCurrentSecond(): Date {
|
||||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
const now = new Date();
|
||||||
|
now.setMilliseconds(0);
|
||||||
// 获取流通池数据(需要从 trading-service 获取,这里简化处理)
|
return now;
|
||||||
const circulationPool = ShareAmount.zero(); // TODO: 从 trading-service 获取
|
|
||||||
|
|
||||||
// 获取股池数据(初始为分配池,实际需要计算)
|
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
|
||||||
const sharePool = config?.distributionPool || ShareAmount.zero();
|
|
||||||
|
|
||||||
const burnedAmount = blackHole?.totalBurned || ShareAmount.zero();
|
|
||||||
|
|
||||||
const price = this.calculator.calculatePrice(sharePool, burnedAmount, circulationPool);
|
|
||||||
|
|
||||||
const effectiveDenominator = MiningCalculatorService.TOTAL_SHARES.value
|
|
||||||
.minus(burnedAmount.value)
|
|
||||||
.minus(circulationPool.value);
|
|
||||||
|
|
||||||
await this.priceSnapshotRepository.saveSnapshot({
|
|
||||||
snapshotTime,
|
|
||||||
price,
|
|
||||||
sharePool,
|
|
||||||
blackHoleAmount: burnedAmount,
|
|
||||||
circulationPool,
|
|
||||||
effectiveDenominator: new ShareAmount(effectiveDenominator),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前分钟(向下取整)
|
* 获取当前分钟(向下取整,去掉秒和毫秒)
|
||||||
*/
|
*/
|
||||||
private getCurrentMinute(): Date {
|
private getCurrentMinute(): Date {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -15,27 +15,8 @@ export class MiningCalculatorService {
|
||||||
// 目标销毁量: 10B
|
// 目标销毁量: 10B
|
||||||
static readonly BURN_TARGET = new ShareAmount('10000000000');
|
static readonly BURN_TARGET = new ShareAmount('10000000000');
|
||||||
|
|
||||||
// 减半周期: 2年 (分钟)
|
// 减半周期: 2年(秒)
|
||||||
static readonly HALVING_PERIOD_MINUTES = 2 * 365 * 24 * 60;
|
static readonly HALVING_PERIOD_SECONDS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算每分钟分配量
|
|
||||||
* @param remainingDistribution 剩余可分配量
|
|
||||||
* @param eraNumber 当前纪元编号
|
|
||||||
* @param remainingMinutesInEra 当前纪元剩余分钟数
|
|
||||||
*/
|
|
||||||
calculateMinuteDistribution(
|
|
||||||
remainingDistribution: ShareAmount,
|
|
||||||
eraNumber: number,
|
|
||||||
remainingMinutesInEra: number,
|
|
||||||
): ShareAmount {
|
|
||||||
if (remainingDistribution.isZero() || remainingMinutesInEra <= 0) {
|
|
||||||
return ShareAmount.zero();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每分钟分配 = 剩余量 / 剩余分钟数
|
|
||||||
return remainingDistribution.divide(remainingMinutesInEra);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算纪元初始分配量
|
* 计算纪元初始分配量
|
||||||
|
|
@ -52,33 +33,32 @@ export class MiningCalculatorService {
|
||||||
* 计算用户挖矿收益
|
* 计算用户挖矿收益
|
||||||
* @param userContribution 用户算力
|
* @param userContribution 用户算力
|
||||||
* @param totalContribution 总算力
|
* @param totalContribution 总算力
|
||||||
* @param minuteDistribution 每分钟分配量
|
* @param secondDistribution 每秒分配量
|
||||||
*/
|
*/
|
||||||
calculateUserMiningReward(
|
calculateUserMiningReward(
|
||||||
userContribution: ShareAmount,
|
userContribution: ShareAmount,
|
||||||
totalContribution: ShareAmount,
|
totalContribution: ShareAmount,
|
||||||
minuteDistribution: ShareAmount,
|
secondDistribution: ShareAmount,
|
||||||
): ShareAmount {
|
): ShareAmount {
|
||||||
if (totalContribution.isZero() || userContribution.isZero()) {
|
if (totalContribution.isZero() || userContribution.isZero()) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户收益 = 每分钟分配量 * (用户算力 / 总算力)
|
// 用户收益 = 每秒分配量 * (用户算力 / 总算力)
|
||||||
const ratio = userContribution.value.dividedBy(totalContribution.value);
|
const ratio = userContribution.value.dividedBy(totalContribution.value);
|
||||||
return minuteDistribution.multiply(ratio);
|
return secondDistribution.multiply(ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算每分钟销毁量
|
* 计算每秒销毁量
|
||||||
* 设计目标: 假设只有黑洞和股池,价格每秒增长1分钱
|
* secondBurn = (burnTarget - currentBurned) / remainingSeconds
|
||||||
* minuteBurn = (burnTarget - currentBurned) / remainingMinutes
|
|
||||||
*/
|
*/
|
||||||
calculateMinuteBurn(
|
calculateSecondBurn(
|
||||||
burnTarget: ShareAmount,
|
burnTarget: ShareAmount,
|
||||||
currentBurned: ShareAmount,
|
currentBurned: ShareAmount,
|
||||||
remainingMinutes: number,
|
remainingSeconds: number,
|
||||||
): ShareAmount {
|
): ShareAmount {
|
||||||
if (remainingMinutes <= 0) {
|
if (remainingSeconds <= 0) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +67,7 @@ export class MiningCalculatorService {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining.divide(remainingMinutes);
|
return remaining.divide(remainingSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,23 +103,12 @@ export class MiningCalculatorService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算剩余挖矿分钟数
|
* 计算剩余挖矿秒数
|
||||||
*/
|
*/
|
||||||
calculateRemainingMinutes(eraStartDate: Date, halvingPeriodMinutes: number): number {
|
calculateRemainingSeconds(eraStartDate: Date, halvingPeriodSeconds: number): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const elapsedMs = now.getTime() - eraStartDate.getTime();
|
const elapsedMs = now.getTime() - eraStartDate.getTime();
|
||||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
||||||
return Math.max(0, halvingPeriodMinutes - elapsedMinutes);
|
return Math.max(0, halvingPeriodSeconds - elapsedSeconds);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算剩余销毁分钟数
|
|
||||||
* 假设销毁周期为整个挖矿周期
|
|
||||||
*/
|
|
||||||
calculateRemainingBurnMinutes(startDate: Date, totalMinutes: number): number {
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedMs = now.getTime() - startDate.getTime();
|
|
||||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
|
||||||
return Math.max(0, totalMinutes - elapsedMinutes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export interface MiningConfigEntity {
|
||||||
halvingPeriodYears: number;
|
halvingPeriodYears: number;
|
||||||
currentEra: number;
|
currentEra: number;
|
||||||
eraStartDate: Date;
|
eraStartDate: Date;
|
||||||
minuteDistribution: ShareAmount;
|
secondDistribution: ShareAmount;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
activatedAt: Date | null;
|
activatedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: config.halvingPeriodYears,
|
halvingPeriodYears: config.halvingPeriodYears,
|
||||||
currentEra: config.currentEra,
|
currentEra: config.currentEra,
|
||||||
eraStartDate: config.eraStartDate,
|
eraStartDate: config.eraStartDate,
|
||||||
minuteDistribution: config.minuteDistribution?.value,
|
secondDistribution: config.secondDistribution?.value,
|
||||||
isActive: config.isActive,
|
isActive: config.isActive,
|
||||||
activatedAt: config.activatedAt,
|
activatedAt: config.activatedAt,
|
||||||
},
|
},
|
||||||
|
|
@ -54,7 +54,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: config.halvingPeriodYears || 2,
|
halvingPeriodYears: config.halvingPeriodYears || 2,
|
||||||
currentEra: config.currentEra || 1,
|
currentEra: config.currentEra || 1,
|
||||||
eraStartDate: config.eraStartDate || new Date(),
|
eraStartDate: config.eraStartDate || new Date(),
|
||||||
minuteDistribution: config.minuteDistribution?.value || 0,
|
secondDistribution: config.secondDistribution?.value || 0,
|
||||||
isActive: config.isActive || false,
|
isActive: config.isActive || false,
|
||||||
activatedAt: config.activatedAt,
|
activatedAt: config.activatedAt,
|
||||||
},
|
},
|
||||||
|
|
@ -99,7 +99,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: record.halvingPeriodYears,
|
halvingPeriodYears: record.halvingPeriodYears,
|
||||||
currentEra: record.currentEra,
|
currentEra: record.currentEra,
|
||||||
eraStartDate: record.eraStartDate,
|
eraStartDate: record.eraStartDate,
|
||||||
minuteDistribution: new ShareAmount(record.minuteDistribution),
|
secondDistribution: new ShareAmount(record.secondDistribution),
|
||||||
isActive: record.isActive,
|
isActive: record.isActive,
|
||||||
activatedAt: record.activatedAt,
|
activatedAt: record.activatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
async acquireLock(lockKey: string, ttlSeconds: number = 30): Promise<string | null> {
|
async acquireLock(lockKey: string, ttlSeconds: number = 30): Promise<string | null> {
|
||||||
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
const result = await this.client.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX');
|
const ttlMs = Math.round(ttlSeconds * 1000);
|
||||||
|
const result = await this.client.set(lockKey, lockValue, 'PX', ttlMs, 'NX');
|
||||||
return result === 'OK' ? lockValue : null;
|
return result === 'OK' ? lockValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,4 +88,12 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
async incrByFloat(key: string, increment: number): Promise<string> {
|
async incrByFloat(key: string, increment: number): Promise<string> {
|
||||||
return this.client.incrbyfloat(key, increment);
|
return this.client.incrbyfloat(key, increment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async keys(pattern: string): Promise<string[]> {
|
||||||
|
return this.client.keys(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<number> {
|
||||||
|
return this.client.del(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,7 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "prisma"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,15 @@ WORKDIR /app
|
||||||
USER nestjs
|
USER nestjs
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs package*.json ./
|
COPY --chown=nestjs:nodejs package*.json ./
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
COPY --chown=nestjs:nodejs tsconfig*.json ./
|
||||||
|
RUN npm ci --only=production && npm install ts-node typescript @types/node --save-dev && npm cache clean --force
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs prisma ./prisma/
|
COPY --chown=nestjs:nodejs prisma ./prisma/
|
||||||
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
||||||
|
|
||||||
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
|
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
|
||||||
|
|
||||||
RUN printf '#!/bin/sh\nset -e\necho "Running database migrations..."\nnpx prisma migrate deploy\necho "Starting application..."\nexec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
|
RUN printf '#!/bin/sh\nset -e\necho "Running database migrations..."\nnpx prisma migrate deploy\necho "Running database seed..."\nnpx prisma db seed || echo "Seed skipped or already applied"\necho "Starting application..."\nexec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:migrate:prod": "prisma migrate deploy",
|
"prisma:migrate:prod": "prisma migrate deploy",
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
|
|
@ -38,6 +39,9 @@
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.2.1",
|
"@nestjs/cli": "^10.2.1",
|
||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding mining-wallet-service database...');
|
||||||
|
|
||||||
|
// 1. 初始化核心系统账户(总部、运营、手续费)
|
||||||
|
const systemAccounts = [
|
||||||
|
{ accountType: 'HEADQUARTERS', name: '总部账户', code: 'HQ' },
|
||||||
|
{ accountType: 'OPERATION', name: '运营账户', code: 'OP' },
|
||||||
|
{ accountType: 'FEE', name: '手续费账户', code: 'FEE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const account of systemAccounts) {
|
||||||
|
const existing = await prisma.systemAccount.findFirst({
|
||||||
|
where: { code: account.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const created = await prisma.systemAccount.create({
|
||||||
|
data: {
|
||||||
|
accountType: account.accountType as any,
|
||||||
|
name: account.name,
|
||||||
|
code: account.code,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布系统账户创建事件到 Outbox
|
||||||
|
await prisma.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'SystemAccount',
|
||||||
|
aggregateId: created.id,
|
||||||
|
eventType: 'WalletSystemAccountCreated',
|
||||||
|
topic: 'mining-wallet.system-account.created',
|
||||||
|
key: created.code,
|
||||||
|
payload: {
|
||||||
|
id: created.id,
|
||||||
|
accountType: created.accountType,
|
||||||
|
name: created.name,
|
||||||
|
code: created.code,
|
||||||
|
provinceId: null,
|
||||||
|
cityId: null,
|
||||||
|
shareBalance: 0,
|
||||||
|
usdtBalance: 0,
|
||||||
|
greenPointBalance: 0,
|
||||||
|
frozenShare: 0,
|
||||||
|
frozenUsdt: 0,
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
blockchainAddress: null,
|
||||||
|
isActive: created.isActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created system account: ${account.code}`);
|
||||||
|
} else {
|
||||||
|
console.log(`System account already exists: ${account.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化池账户(积分股池、黑洞池、流通池)
|
||||||
|
const poolAccounts = [
|
||||||
|
{
|
||||||
|
poolType: 'SHARE_POOL',
|
||||||
|
name: '积分股池',
|
||||||
|
balance: new Decimal('100000000'), // 1亿初始发行量
|
||||||
|
description: '挖矿奖励的来源池,总发行量',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
poolType: 'BLACK_HOLE_POOL',
|
||||||
|
name: '黑洞池',
|
||||||
|
balance: new Decimal('0'),
|
||||||
|
targetBurn: new Decimal('50000000'), // 目标销毁5000万
|
||||||
|
description: '销毁的积分股,用于减少流通量',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
poolType: 'CIRCULATION_POOL',
|
||||||
|
name: '流通池',
|
||||||
|
balance: new Decimal('0'),
|
||||||
|
description: '市场流通的积分股',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pool of poolAccounts) {
|
||||||
|
const existing = await prisma.poolAccount.findFirst({
|
||||||
|
where: { poolType: pool.poolType as any },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const created = await prisma.poolAccount.create({
|
||||||
|
data: {
|
||||||
|
poolType: pool.poolType as any,
|
||||||
|
name: pool.name,
|
||||||
|
balance: pool.balance,
|
||||||
|
targetBurn: pool.targetBurn,
|
||||||
|
remainingBurn: pool.targetBurn,
|
||||||
|
description: pool.description,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布池账户创建事件到 Outbox
|
||||||
|
await prisma.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'PoolAccount',
|
||||||
|
aggregateId: created.id,
|
||||||
|
eventType: 'WalletPoolAccountCreated',
|
||||||
|
topic: 'mining-wallet.pool-account.created',
|
||||||
|
key: created.poolType,
|
||||||
|
payload: {
|
||||||
|
id: created.id,
|
||||||
|
poolType: created.poolType,
|
||||||
|
name: created.name,
|
||||||
|
balance: created.balance.toString(),
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
targetBurn: created.targetBurn?.toString() || null,
|
||||||
|
remainingBurn: created.remainingBurn?.toString() || null,
|
||||||
|
isActive: created.isActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created pool account: ${pool.poolType}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Pool account already exists: ${pool.poolType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seeding failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { PoolAccountService } from '../../application/services/pool-account.service';
|
import { PoolAccountService } from '../../application/services/pool-account.service';
|
||||||
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
|
import { AdminOnly, Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
import { PoolAccountType, TransactionType } from '@prisma/client';
|
import { PoolAccountType, TransactionType } from '@prisma/client';
|
||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
|
@ -73,8 +73,8 @@ export class PoolAccountController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('initialize')
|
@Post('initialize')
|
||||||
@AdminOnly()
|
@Public()
|
||||||
@ApiOperation({ summary: '初始化池账户' })
|
@ApiOperation({ summary: '初始化池账户(仅限内网调用)' })
|
||||||
@ApiResponse({ status: 201, description: '池账户初始化成功' })
|
@ApiResponse({ status: 201, description: '池账户初始化成功' })
|
||||||
async initialize(@Body() dto: InitializePoolsDto) {
|
async initialize(@Body() dto: InitializePoolsDto) {
|
||||||
return this.poolAccountService.initializePools({
|
return this.poolAccountService.initializePools({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { SystemAccountService } from '../../application/services/system-account.service';
|
import { SystemAccountService } from '../../application/services/system-account.service';
|
||||||
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
|
import { AdminOnly, Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
import { SystemAccountType } from '@prisma/client';
|
import { SystemAccountType } from '@prisma/client';
|
||||||
|
|
||||||
class InitializeSystemAccountsDto {
|
class InitializeSystemAccountsDto {
|
||||||
|
|
@ -47,8 +47,8 @@ export class SystemAccountController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('initialize')
|
@Post('initialize')
|
||||||
@AdminOnly()
|
@Public()
|
||||||
@ApiOperation({ summary: '初始化核心系统账户' })
|
@ApiOperation({ summary: '初始化核心系统账户(仅限内网调用)' })
|
||||||
@ApiResponse({ status: 201, description: '系统账户初始化成功' })
|
@ApiResponse({ status: 201, description: '系统账户初始化成功' })
|
||||||
async initialize(@Body() dto: InitializeSystemAccountsDto) {
|
async initialize(@Body() dto: InitializeSystemAccountsDto) {
|
||||||
return this.systemAccountService.initializeCoreAccounts(dto);
|
return this.systemAccountService.initializeCoreAccounts(dto);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-r
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot()],
|
imports: [ScheduleModule.forRoot()],
|
||||||
|
controllers: [
|
||||||
|
// Kafka Consumers (微服务消息处理器需要是 Controller)
|
||||||
|
ContributionDistributionConsumer,
|
||||||
|
UserRegisteredConsumer,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Services
|
// Services
|
||||||
SystemAccountService,
|
SystemAccountService,
|
||||||
|
|
@ -26,9 +31,6 @@ import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-r
|
||||||
// Schedulers
|
// Schedulers
|
||||||
OutboxScheduler,
|
OutboxScheduler,
|
||||||
ContributionExpiryScheduler,
|
ContributionExpiryScheduler,
|
||||||
// Consumers
|
|
||||||
ContributionDistributionConsumer,
|
|
||||||
UserRegisteredConsumer,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
SystemAccountService,
|
SystemAccountService,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export class ContributionWalletService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isNewWallet = !wallet;
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
wallet = await tx.userWallet.create({
|
wallet = await tx.userWallet.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -60,13 +61,34 @@ export class ContributionWalletService {
|
||||||
frozenBalance: new Decimal(0),
|
frozenBalance: new Decimal(0),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 发布 UserWalletCreated 事件
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'UserWallet',
|
||||||
|
aggregateId: wallet.id,
|
||||||
|
eventType: 'UserWalletCreated',
|
||||||
|
topic: 'cdc.mining-wallet.outbox',
|
||||||
|
key: input.accountSequence,
|
||||||
|
payload: {
|
||||||
|
id: wallet.id,
|
||||||
|
accountSequence: wallet.accountSequence,
|
||||||
|
walletType: wallet.walletType,
|
||||||
|
balance: '0',
|
||||||
|
frozenBalance: '0',
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceBefore = new Decimal(wallet.balance.toString());
|
const balanceBefore = new Decimal(wallet.balance.toString());
|
||||||
const balanceAfter = balanceBefore.plus(input.amount);
|
const balanceAfter = balanceBefore.plus(input.amount);
|
||||||
|
|
||||||
// 2. 更新钱包余额
|
// 2. 更新钱包余额
|
||||||
await tx.userWallet.update({
|
const updatedWallet = await tx.userWallet.update({
|
||||||
where: { id: wallet.id },
|
where: { id: wallet.id },
|
||||||
data: {
|
data: {
|
||||||
balance: balanceAfter,
|
balance: balanceAfter,
|
||||||
|
|
@ -74,6 +96,27 @@ export class ContributionWalletService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 发布 UserWalletUpdated 事件(用于 mining-admin-service 同步)
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'UserWallet',
|
||||||
|
aggregateId: wallet.id,
|
||||||
|
eventType: 'UserWalletUpdated',
|
||||||
|
topic: 'cdc.mining-wallet.outbox',
|
||||||
|
key: input.accountSequence,
|
||||||
|
payload: {
|
||||||
|
id: wallet.id,
|
||||||
|
accountSequence: wallet.accountSequence,
|
||||||
|
walletType: wallet.walletType,
|
||||||
|
balance: balanceAfter.toString(),
|
||||||
|
frozenBalance: updatedWallet.frozenBalance.toString(),
|
||||||
|
totalInflow: updatedWallet.totalInflow.toString(),
|
||||||
|
totalOutflow: updatedWallet.totalOutflow.toString(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 3. 创建交易记录(分类账)
|
// 3. 创建交易记录(分类账)
|
||||||
const transaction = await tx.userWalletTransaction.create({
|
const transaction = await tx.userWalletTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -151,15 +194,25 @@ export class ContributionWalletService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemAccount = await tx.systemAccount.findFirst({
|
let systemAccount = await tx.systemAccount.findFirst({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 如果找不到,尝试自动创建省/市级系统账户
|
||||||
if (!systemAccount) {
|
if (!systemAccount) {
|
||||||
this.logger.warn(
|
systemAccount = await this.createSystemAccountIfNeeded(
|
||||||
`System account not found: ${input.accountType}, province: ${input.provinceCode}, city: ${input.cityCode}`,
|
tx,
|
||||||
|
input.accountType,
|
||||||
|
input.provinceCode,
|
||||||
|
input.cityCode,
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
|
if (!systemAccount) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to create system account: ${input.accountType}, province: ${input.provinceCode}, city: ${input.cityCode}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceBefore = new Decimal(
|
const balanceBefore = new Decimal(
|
||||||
|
|
@ -237,7 +290,7 @@ export class ContributionWalletService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新钱包余额
|
// 更新钱包余额
|
||||||
await tx.userWallet.update({
|
const updatedWallet = await tx.userWallet.update({
|
||||||
where: { id: wallet.id },
|
where: { id: wallet.id },
|
||||||
data: {
|
data: {
|
||||||
balance: balanceAfter,
|
balance: balanceAfter,
|
||||||
|
|
@ -245,6 +298,27 @@ export class ContributionWalletService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 发布 UserWalletUpdated 事件(用于 mining-admin-service 同步)
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'UserWallet',
|
||||||
|
aggregateId: wallet.id,
|
||||||
|
eventType: 'UserWalletUpdated',
|
||||||
|
topic: 'cdc.mining-wallet.outbox',
|
||||||
|
key: accountSequence,
|
||||||
|
payload: {
|
||||||
|
id: wallet.id,
|
||||||
|
accountSequence: wallet.accountSequence,
|
||||||
|
walletType: wallet.walletType,
|
||||||
|
balance: balanceAfter.toString(),
|
||||||
|
frozenBalance: updatedWallet.frozenBalance.toString(),
|
||||||
|
totalInflow: updatedWallet.totalInflow.toString(),
|
||||||
|
totalOutflow: updatedWallet.totalOutflow.toString(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 创建过期交易记录
|
// 创建过期交易记录
|
||||||
await tx.userWalletTransaction.create({
|
await tx.userWalletTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -281,4 +355,164 @@ export class ContributionWalletService {
|
||||||
};
|
};
|
||||||
return `${typeMap[input.contributionType]}, 来源认种: ${input.sourceAdoptionId}, 认种人: ${input.sourceAccountSequence}`;
|
return `${typeMap[input.contributionType]}, 来源认种: ${input.sourceAdoptionId}, 认种人: ${input.sourceAccountSequence}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动创建省/市级系统账户(如果不存在)
|
||||||
|
* 同时会创建对应的省/市区域记录
|
||||||
|
*/
|
||||||
|
private async createSystemAccountIfNeeded(
|
||||||
|
tx: any,
|
||||||
|
accountType: string,
|
||||||
|
provinceCode?: string,
|
||||||
|
cityCode?: string,
|
||||||
|
): Promise<any | null> {
|
||||||
|
// 只处理省/市级账户的自动创建
|
||||||
|
if (accountType === 'PROVINCE' && provinceCode) {
|
||||||
|
// 先找或创建省份
|
||||||
|
let province = await tx.province.findUnique({
|
||||||
|
where: { code: provinceCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!province) {
|
||||||
|
province = await tx.province.create({
|
||||||
|
data: {
|
||||||
|
code: provinceCode,
|
||||||
|
name: provinceCode,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Auto-created province: ${provinceCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建省级系统账户
|
||||||
|
const account = await tx.systemAccount.create({
|
||||||
|
data: {
|
||||||
|
accountType: 'PROVINCE',
|
||||||
|
name: `${province.name}账户`,
|
||||||
|
code: `PROV-${provinceCode}`,
|
||||||
|
provinceId: province.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Auto-created province system account: ${account.code}`);
|
||||||
|
|
||||||
|
// 发布系统账户创建事件到 Outbox
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'SystemAccount',
|
||||||
|
aggregateId: account.id,
|
||||||
|
eventType: 'WalletSystemAccountCreated',
|
||||||
|
topic: 'mining-wallet.system-account.created',
|
||||||
|
key: account.code,
|
||||||
|
payload: {
|
||||||
|
id: account.id,
|
||||||
|
accountType: account.accountType,
|
||||||
|
name: account.name,
|
||||||
|
code: account.code,
|
||||||
|
provinceId: account.provinceId,
|
||||||
|
cityId: null,
|
||||||
|
shareBalance: 0,
|
||||||
|
usdtBalance: 0,
|
||||||
|
greenPointBalance: 0,
|
||||||
|
frozenShare: 0,
|
||||||
|
frozenUsdt: 0,
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
blockchainAddress: null,
|
||||||
|
isActive: account.isActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountType === 'CITY' && cityCode) {
|
||||||
|
// 先找城市
|
||||||
|
let city = await tx.city.findUnique({
|
||||||
|
where: { code: cityCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!city) {
|
||||||
|
// 城市不存在,需要先有省份
|
||||||
|
if (!provinceCode) {
|
||||||
|
this.logger.warn(`Cannot create city without provinceCode: ${cityCode}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找或创建省份
|
||||||
|
let province = await tx.province.findUnique({
|
||||||
|
where: { code: provinceCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!province) {
|
||||||
|
province = await tx.province.create({
|
||||||
|
data: {
|
||||||
|
code: provinceCode,
|
||||||
|
name: provinceCode,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Auto-created province: ${provinceCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建城市
|
||||||
|
city = await tx.city.create({
|
||||||
|
data: {
|
||||||
|
code: cityCode,
|
||||||
|
name: cityCode,
|
||||||
|
provinceId: province.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Auto-created city: ${cityCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建市级系统账户
|
||||||
|
const account = await tx.systemAccount.create({
|
||||||
|
data: {
|
||||||
|
accountType: 'CITY',
|
||||||
|
name: `${city.name}账户`,
|
||||||
|
code: `CITY-${cityCode}`,
|
||||||
|
provinceId: city.provinceId,
|
||||||
|
cityId: city.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Auto-created city system account: ${account.code}`);
|
||||||
|
|
||||||
|
// 发布系统账户创建事件到 Outbox
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'SystemAccount',
|
||||||
|
aggregateId: account.id,
|
||||||
|
eventType: 'WalletSystemAccountCreated',
|
||||||
|
topic: 'mining-wallet.system-account.created',
|
||||||
|
key: account.code,
|
||||||
|
payload: {
|
||||||
|
id: account.id,
|
||||||
|
accountType: account.accountType,
|
||||||
|
name: account.name,
|
||||||
|
code: account.code,
|
||||||
|
provinceId: account.provinceId,
|
||||||
|
cityId: account.cityId,
|
||||||
|
shareBalance: 0,
|
||||||
|
usdtBalance: 0,
|
||||||
|
greenPointBalance: 0,
|
||||||
|
frozenShare: 0,
|
||||||
|
frozenUsdt: 0,
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
blockchainAddress: null,
|
||||||
|
isActive: account.isActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他类型(HEADQUARTERS, OPERATION, FEE)不自动创建,需要在 seed 中初始化
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
import { PrismaService } from '../../persistence/prisma/prisma.service';
|
import { PrismaService } from '../../persistence/prisma/prisma.service';
|
||||||
|
|
@ -9,12 +9,14 @@ import { SystemAccountService } from '../../../application/services/system-accou
|
||||||
import {
|
import {
|
||||||
ContributionDistributionCompletedEvent,
|
ContributionDistributionCompletedEvent,
|
||||||
ContributionDistributionPayload,
|
ContributionDistributionPayload,
|
||||||
|
BonusClaimedEvent,
|
||||||
|
BonusClaimedPayload,
|
||||||
} from '../events/contribution-distribution.event';
|
} from '../events/contribution-distribution.event';
|
||||||
|
|
||||||
// 4小时 TTL(秒)
|
// 4小时 TTL(秒)
|
||||||
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||||||
|
|
||||||
@Injectable()
|
@Controller()
|
||||||
export class ContributionDistributionConsumer implements OnModuleInit {
|
export class ContributionDistributionConsumer implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ContributionDistributionConsumer.name);
|
private readonly logger = new Logger(ContributionDistributionConsumer.name);
|
||||||
|
|
||||||
|
|
@ -114,6 +116,65 @@ export class ContributionDistributionConsumer implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理奖励补发事件
|
||||||
|
* 当用户解锁新的奖励档位时,补发之前所有认种对应的奖励
|
||||||
|
*/
|
||||||
|
@EventPattern('contribution.bonus.claimed')
|
||||||
|
async handleBonusClaimed(@Payload() message: any): Promise<void> {
|
||||||
|
const event: BonusClaimedEvent = message.value || message;
|
||||||
|
const eventId = event.eventId || message.eventId;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
this.logger.warn('Received BonusClaimed event without eventId, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Processing bonus claim event: ${eventId}`);
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (await this.isEventProcessed(eventId)) {
|
||||||
|
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processBonusClaim(event.payload);
|
||||||
|
|
||||||
|
// 标记为已处理
|
||||||
|
await this.markEventProcessed(eventId, event.eventType);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Bonus claim for ${event.payload.accountSequence} T${event.payload.bonusTier} processed: ` +
|
||||||
|
`${event.payload.claimedCount} records`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to process bonus claim for ${event.payload.accountSequence}`,
|
||||||
|
error instanceof Error ? error.stack : error,
|
||||||
|
);
|
||||||
|
throw error; // 让 Kafka 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理奖励补发
|
||||||
|
*/
|
||||||
|
private async processBonusClaim(payload: BonusClaimedPayload): Promise<void> {
|
||||||
|
for (const contrib of payload.userContributions) {
|
||||||
|
await this.contributionWalletService.creditContribution({
|
||||||
|
accountSequence: contrib.accountSequence,
|
||||||
|
amount: new Decimal(contrib.amount),
|
||||||
|
contributionType: contrib.contributionType,
|
||||||
|
bonusTier: contrib.bonusTier,
|
||||||
|
effectiveDate: new Date(contrib.effectiveDate),
|
||||||
|
expireDate: new Date(contrib.expireDate),
|
||||||
|
sourceAdoptionId: contrib.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: contrib.sourceAccountSequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 幂等性检查 - Redis + DB 双重检查,4小时去重窗口
|
* 幂等性检查 - Redis + DB 双重检查,4小时去重窗口
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||||
import { RedisService } from '../../redis/redis.service';
|
import { RedisService } from '../../redis/redis.service';
|
||||||
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||||||
|
|
@ -8,7 +8,7 @@ import { UserRegisteredEvent } from '../events/contribution-distribution.event';
|
||||||
// 4小时 TTL(秒)
|
// 4小时 TTL(秒)
|
||||||
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||||||
|
|
||||||
@Injectable()
|
@Controller()
|
||||||
export class UserRegisteredConsumer implements OnModuleInit {
|
export class UserRegisteredConsumer implements OnModuleInit {
|
||||||
private readonly logger = new Logger(UserRegisteredConsumer.name);
|
private readonly logger = new Logger(UserRegisteredConsumer.name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,36 @@ export interface UnallocatedContributionItem {
|
||||||
bonusTier?: number;
|
bonusTier?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 奖励补发事件
|
||||||
|
* 来自 contribution-service,当用户解锁新的奖励档位时触发
|
||||||
|
*/
|
||||||
|
export interface BonusClaimedEvent {
|
||||||
|
eventType: 'BonusClaimed';
|
||||||
|
eventId: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: BonusClaimedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusClaimedPayload {
|
||||||
|
accountSequence: string;
|
||||||
|
bonusTier: number;
|
||||||
|
claimedCount: number;
|
||||||
|
userContributions: BonusClaimedContributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusClaimedContributionItem {
|
||||||
|
accountSequence: string;
|
||||||
|
contributionType: 'TEAM_BONUS';
|
||||||
|
amount: string;
|
||||||
|
bonusTier: number;
|
||||||
|
effectiveDate: string;
|
||||||
|
expireDate: string;
|
||||||
|
sourceAdoptionId: string;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
isBackfill: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户注册事件
|
* 用户注册事件
|
||||||
* 来自 auth-service
|
* 来自 auth-service
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ async function bootstrap() {
|
||||||
consumer: {
|
consumer: {
|
||||||
groupId: 'mining-wallet-service-group',
|
groupId: 'mining-wallet-service-group',
|
||||||
},
|
},
|
||||||
|
subscribe: {
|
||||||
|
// 显式订阅需要消费的 topics
|
||||||
|
fromBeginning: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,7 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "prisma"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,14 @@ CREATE TABLE "orders" (
|
||||||
CREATE TABLE "trades" (
|
CREATE TABLE "trades" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"tradeNo" TEXT NOT NULL,
|
"tradeNo" TEXT NOT NULL,
|
||||||
"buyOrderId" TEXT NOT NULL,
|
"buy_order_id" TEXT NOT NULL,
|
||||||
"sellOrderId" TEXT NOT NULL,
|
"sell_order_id" TEXT NOT NULL,
|
||||||
"buyerSequence" TEXT NOT NULL,
|
"buyer_sequence" TEXT NOT NULL,
|
||||||
"sellerSequence" TEXT NOT NULL,
|
"seller_sequence" TEXT NOT NULL,
|
||||||
"price" DECIMAL(30,18) NOT NULL,
|
"price" DECIMAL(30,18) NOT NULL,
|
||||||
"quantity" DECIMAL(30,8) NOT NULL,
|
"quantity" DECIMAL(30,8) NOT NULL,
|
||||||
"amount" DECIMAL(30,8) NOT NULL,
|
"amount" DECIMAL(30,8) NOT NULL,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "trades_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "trades_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
@ -229,13 +229,13 @@ CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt" DESC);
|
||||||
CREATE UNIQUE INDEX "trades_tradeNo_key" ON "trades"("tradeNo");
|
CREATE UNIQUE INDEX "trades_tradeNo_key" ON "trades"("tradeNo");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_buyerSequence_idx" ON "trades"("buyerSequence");
|
CREATE INDEX "trades_buyer_sequence_idx" ON "trades"("buyer_sequence");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_sellerSequence_idx" ON "trades"("sellerSequence");
|
CREATE INDEX "trades_seller_sequence_idx" ON "trades"("seller_sequence");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_createdAt_idx" ON "trades"("createdAt" DESC);
|
CREATE INDEX "trades_created_at_idx" ON "trades"("created_at" DESC);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trading_transactions_accountSequence_createdAt_idx" ON "trading_transactions"("accountSequence", "createdAt" DESC);
|
CREATE INDEX "trading_transactions_accountSequence_createdAt_idx" ON "trading_transactions"("accountSequence", "createdAt" DESC);
|
||||||
|
|
@ -307,7 +307,7 @@ CREATE INDEX "outbox_events_created_at_idx" ON "outbox_events"("created_at");
|
||||||
ALTER TABLE "orders" ADD CONSTRAINT "orders_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "trades" ADD CONSTRAINT "trades_buyOrderId_fkey" FOREIGN KEY ("buyOrderId") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "trades" ADD CONSTRAINT "trades_buy_order_id_fkey" FOREIGN KEY ("buy_order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "trading_transactions" ADD CONSTRAINT "trading_transactions_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "trading_transactions" ADD CONSTRAINT "trading_transactions_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
-- ============================================================================
|
||||||
|
-- trading-service 添加交易销毁系统
|
||||||
|
-- 包含:交易配置、黑洞账户、积分股池、价格快照、订单销毁字段
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ==================== 交易配置表 ====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "trading_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"total_shares" DECIMAL(30,8) NOT NULL DEFAULT 100020000000,
|
||||||
|
"burn_target" DECIMAL(30,8) NOT NULL DEFAULT 10000000000,
|
||||||
|
"burn_period_minutes" INTEGER NOT NULL DEFAULT 2102400,
|
||||||
|
"minute_burn_rate" DECIMAL(30,18) NOT NULL DEFAULT 4756.468797564687,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"activated_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "trading_configs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 黑洞账户(销毁池)====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "black_holes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"total_burned" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"target_burn" DECIMAL(30,8) NOT NULL,
|
||||||
|
"remaining_burn" DECIMAL(30,8) NOT NULL,
|
||||||
|
"last_burn_minute" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "black_holes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "burn_records" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"black_hole_id" TEXT NOT NULL,
|
||||||
|
"burn_minute" TIMESTAMP(3) NOT NULL,
|
||||||
|
"burn_amount" DECIMAL(30,18) NOT NULL,
|
||||||
|
"remaining_target" DECIMAL(30,8) NOT NULL,
|
||||||
|
"source_type" TEXT,
|
||||||
|
"source_account_seq" TEXT,
|
||||||
|
"source_order_no" TEXT,
|
||||||
|
"memo" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "burn_records_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_burn_minute_idx" ON "burn_records"("burn_minute");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_account_seq_idx" ON "burn_records"("source_account_seq");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_order_no_idx" ON "burn_records"("source_order_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_type_idx" ON "burn_records"("source_type");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "burn_records" ADD CONSTRAINT "burn_records_black_hole_id_fkey" FOREIGN KEY ("black_hole_id") REFERENCES "black_holes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ==================== 积分股池(绿积分池)====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "share_pools" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"green_points" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"total_inflow" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"total_outflow" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "share_pools_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "share_pool_transactions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"pool_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(30,8) NOT NULL,
|
||||||
|
"balance_before" DECIMAL(30,8) NOT NULL,
|
||||||
|
"balance_after" DECIMAL(30,8) NOT NULL,
|
||||||
|
"reference_id" TEXT,
|
||||||
|
"reference_type" TEXT,
|
||||||
|
"memo" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "share_pool_transactions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "share_pool_transactions_pool_id_created_at_idx" ON "share_pool_transactions"("pool_id", "created_at" DESC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "share_pool_transactions" ADD CONSTRAINT "share_pool_transactions_pool_id_fkey" FOREIGN KEY ("pool_id") REFERENCES "share_pools"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ==================== 价格快照 ====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "price_snapshots" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"snapshot_time" TIMESTAMP(3) NOT NULL,
|
||||||
|
"price" DECIMAL(30,18) NOT NULL,
|
||||||
|
"green_points" DECIMAL(30,8) NOT NULL,
|
||||||
|
"black_hole_amount" DECIMAL(30,8) NOT NULL,
|
||||||
|
"circulation_pool" DECIMAL(30,8) NOT NULL,
|
||||||
|
"effective_denominator" DECIMAL(30,8) NOT NULL,
|
||||||
|
"minute_burn_rate" DECIMAL(30,18) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "price_snapshots_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "price_snapshots_snapshot_time_key" ON "price_snapshots"("snapshot_time");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "price_snapshots_snapshot_time_idx" ON "price_snapshots"("snapshot_time" DESC);
|
||||||
|
|
||||||
|
-- ==================== 订单表添加销毁相关字段 ====================
|
||||||
|
|
||||||
|
-- AlterTable: 添加销毁相关字段到 orders 表
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "burn_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "burn_multiplier" DECIMAL(30,18) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "effective_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- ==================== 成交记录表添加销毁相关字段 ====================
|
||||||
|
|
||||||
|
-- 添加销毁相关字段到 trades 表
|
||||||
|
ALTER TABLE "trades" ADD COLUMN "burn_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "trades" ADD COLUMN "effective_qty" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- ============================================================================
|
||||||
|
-- trading-service 添加已处理事件表(幂等性支持)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "processed_events" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"event_id" TEXT NOT NULL,
|
||||||
|
"event_type" TEXT NOT NULL,
|
||||||
|
"source_service" TEXT NOT NULL,
|
||||||
|
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "processed_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "processed_events_event_id_key" ON "processed_events"("event_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "processed_events_event_id_idx" ON "processed_events"("event_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "processed_events_processed_at_idx" ON "processed_events"("processed_at");
|
||||||
|
|
@ -7,6 +7,125 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 交易配置 ====================
|
||||||
|
|
||||||
|
// 交易全局配置
|
||||||
|
model TradingConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
// 总积分股数量: 100.02B
|
||||||
|
totalShares Decimal @default(100020000000) @map("total_shares") @db.Decimal(30, 8)
|
||||||
|
// 目标销毁量: 100亿 (4年销毁完)
|
||||||
|
burnTarget Decimal @default(10000000000) @map("burn_target") @db.Decimal(30, 8)
|
||||||
|
// 销毁周期: 4年 (分钟数) 365*4*1440 = 2102400
|
||||||
|
burnPeriodMinutes Int @default(2102400) @map("burn_period_minutes")
|
||||||
|
// 每分钟基础销毁量: 100亿÷(365*4*1440) = 4756.468797564687
|
||||||
|
minuteBurnRate Decimal @default(4756.468797564687) @map("minute_burn_rate") @db.Decimal(30, 18)
|
||||||
|
// 是否启用交易
|
||||||
|
isActive Boolean @default(false) @map("is_active")
|
||||||
|
// 启动时间
|
||||||
|
activatedAt DateTime? @map("activated_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("trading_configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 黑洞账户(销毁池)====================
|
||||||
|
|
||||||
|
// 黑洞账户
|
||||||
|
model BlackHole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
totalBurned Decimal @default(0) @map("total_burned") @db.Decimal(30, 8) // 已销毁总量
|
||||||
|
targetBurn Decimal @map("target_burn") @db.Decimal(30, 8) // 目标销毁量 (10B)
|
||||||
|
remainingBurn Decimal @map("remaining_burn") @db.Decimal(30, 8) // 剩余待销毁
|
||||||
|
lastBurnMinute DateTime? @map("last_burn_minute")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
records BurnRecord[]
|
||||||
|
|
||||||
|
@@map("black_holes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁记录
|
||||||
|
model BurnRecord {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
blackHoleId String @map("black_hole_id")
|
||||||
|
burnMinute DateTime @map("burn_minute")
|
||||||
|
burnAmount Decimal @map("burn_amount") @db.Decimal(30, 18)
|
||||||
|
remainingTarget Decimal @map("remaining_target") @db.Decimal(30, 8) // 销毁后剩余目标
|
||||||
|
|
||||||
|
// 来源信息
|
||||||
|
sourceType String? @map("source_type") // MINUTE_BURN (每分钟销毁), SELL_BURN (卖出销毁)
|
||||||
|
sourceAccountSeq String? @map("source_account_seq") // 来源账户序列号(卖出时)
|
||||||
|
sourceOrderNo String? @map("source_order_no") // 来源订单号(卖出时)
|
||||||
|
|
||||||
|
memo String? @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
blackHole BlackHole @relation(fields: [blackHoleId], references: [id])
|
||||||
|
|
||||||
|
@@index([burnMinute])
|
||||||
|
@@index([sourceAccountSeq])
|
||||||
|
@@index([sourceOrderNo])
|
||||||
|
@@index([sourceType])
|
||||||
|
@@map("burn_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 积分股池(绿积分池)====================
|
||||||
|
|
||||||
|
// 积分股池(存储绿积分用于计算价格)
|
||||||
|
model SharePool {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
// 绿积分总量(用于价格计算的分子)
|
||||||
|
greenPoints Decimal @default(0) @map("green_points") @db.Decimal(30, 8)
|
||||||
|
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
|
||||||
|
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
transactions SharePoolTransaction[]
|
||||||
|
|
||||||
|
@@map("share_pools")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分股池交易记录
|
||||||
|
model SharePoolTransaction {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
poolId String @map("pool_id")
|
||||||
|
type String // INJECT (注入), TRADE_IN (交易流入), TRADE_OUT (交易流出)
|
||||||
|
amount Decimal @db.Decimal(30, 8)
|
||||||
|
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
|
||||||
|
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
|
||||||
|
referenceId String? @map("reference_id")
|
||||||
|
referenceType String? @map("reference_type")
|
||||||
|
memo String? @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
pool SharePool @relation(fields: [poolId], references: [id])
|
||||||
|
|
||||||
|
@@index([poolId, createdAt(sort: Desc)])
|
||||||
|
@@map("share_pool_transactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 价格快照 ====================
|
||||||
|
|
||||||
|
// 价格快照(每分钟)
|
||||||
|
model PriceSnapshot {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
snapshotTime DateTime @unique @map("snapshot_time")
|
||||||
|
price Decimal @db.Decimal(30, 18) // 当时价格
|
||||||
|
greenPoints Decimal @map("green_points") @db.Decimal(30, 8) // 绿积分(股池)
|
||||||
|
blackHoleAmount Decimal @map("black_hole_amount") @db.Decimal(30, 8) // 黑洞数量
|
||||||
|
circulationPool Decimal @map("circulation_pool") @db.Decimal(30, 8) // 流通池
|
||||||
|
effectiveDenominator Decimal @map("effective_denominator") @db.Decimal(30, 8) // 有效分母
|
||||||
|
minuteBurnRate Decimal @map("minute_burn_rate") @db.Decimal(30, 18) // 当时的每分钟销毁率
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([snapshotTime(sort: Desc)])
|
||||||
|
@@map("price_snapshots")
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 交易账户 ====================
|
// ==================== 交易账户 ====================
|
||||||
|
|
||||||
// 用户交易账户
|
// 用户交易账户
|
||||||
|
|
@ -43,6 +162,10 @@ model Order {
|
||||||
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
|
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
|
||||||
averagePrice Decimal @default(0) @db.Decimal(30, 18) // 平均成交价
|
averagePrice Decimal @default(0) @db.Decimal(30, 18) // 平均成交价
|
||||||
totalAmount Decimal @default(0) @db.Decimal(30, 8) // 总成交金额
|
totalAmount Decimal @default(0) @db.Decimal(30, 8) // 总成交金额
|
||||||
|
// 卖出销毁相关字段
|
||||||
|
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
|
||||||
|
burnMultiplier Decimal @default(0) @map("burn_multiplier") @db.Decimal(30, 18) // 销毁倍数
|
||||||
|
effectiveQuantity Decimal @default(0) @map("effective_quantity") @db.Decimal(30, 8) // 有效卖出量(含销毁)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
|
|
@ -61,14 +184,16 @@ model Order {
|
||||||
model Trade {
|
model Trade {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tradeNo String @unique
|
tradeNo String @unique
|
||||||
buyOrderId String
|
buyOrderId String @map("buy_order_id")
|
||||||
sellOrderId String
|
sellOrderId String @map("sell_order_id")
|
||||||
buyerSequence String
|
buyerSequence String @map("buyer_sequence")
|
||||||
sellerSequence String
|
sellerSequence String @map("seller_sequence")
|
||||||
price Decimal @db.Decimal(30, 18)
|
price Decimal @db.Decimal(30, 18)
|
||||||
quantity Decimal @db.Decimal(30, 8)
|
quantity Decimal @db.Decimal(30, 8) // 实际成交量
|
||||||
amount Decimal @db.Decimal(30, 8) // price * quantity
|
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
|
||||||
createdAt DateTime @default(now())
|
effectiveQty Decimal @default(0) @map("effective_qty") @db.Decimal(30, 8) // 有效量(quantity + burnQuantity)
|
||||||
|
amount Decimal @db.Decimal(30, 8) // effectiveQty * price(卖出交易额)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
buyOrder Order @relation(fields: [buyOrderId], references: [id])
|
buyOrder Order @relation(fields: [buyOrderId], references: [id])
|
||||||
|
|
||||||
|
|
@ -281,3 +406,18 @@ model OutboxEvent {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("outbox_events")
|
@@map("outbox_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 已处理事件(幂等性)====================
|
||||||
|
|
||||||
|
// 已处理事件记录(用于消费者幂等性检查)
|
||||||
|
model ProcessedEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
eventId String @unique @map("event_id") // 事件唯一ID
|
||||||
|
eventType String @map("event_type") // 事件类型
|
||||||
|
sourceService String @map("source_service") // 来源服务
|
||||||
|
processedAt DateTime @default(now()) @map("processed_at")
|
||||||
|
|
||||||
|
@@index([eventId])
|
||||||
|
@@index([processedAt])
|
||||||
|
@@map("processed_events")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,20 @@ import { TradingController } from './controllers/trading.controller';
|
||||||
import { TransferController } from './controllers/transfer.controller';
|
import { TransferController } from './controllers/transfer.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { AdminController } from './controllers/admin.controller';
|
import { AdminController } from './controllers/admin.controller';
|
||||||
|
import { PriceController } from './controllers/price.controller';
|
||||||
|
import { BurnController } from './controllers/burn.controller';
|
||||||
|
import { AssetController } from './controllers/asset.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule, InfrastructureModule],
|
imports: [ApplicationModule, InfrastructureModule],
|
||||||
controllers: [TradingController, TransferController, HealthController, AdminController],
|
controllers: [
|
||||||
|
TradingController,
|
||||||
|
TransferController,
|
||||||
|
HealthController,
|
||||||
|
AdminController,
|
||||||
|
PriceController,
|
||||||
|
BurnController,
|
||||||
|
AssetController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AssetService } from '../../application/services/asset.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Asset')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('asset')
|
||||||
|
export class AssetController {
|
||||||
|
constructor(private readonly assetService: AssetService) {}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
@ApiOperation({ summary: '获取我的资产显示' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||||
|
async getMyAsset(@Req() req: any, @Query('dailyAllocation') dailyAllocation?: string) {
|
||||||
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await this.assetService.getAssetDisplay(accountSequence, dailyAllocation);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('account/:accountSequence')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取指定账户资产显示' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||||
|
async getAccountAsset(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('dailyAllocation') dailyAllocation?: string,
|
||||||
|
) {
|
||||||
|
const asset = await this.assetService.getAssetDisplay(accountSequence, dailyAllocation);
|
||||||
|
if (!asset) {
|
||||||
|
return { message: 'Account not found' };
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('estimate-sell')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '预估卖出收益' })
|
||||||
|
@ApiQuery({ name: 'quantity', required: true, type: String, description: '卖出数量' })
|
||||||
|
async estimateSellProceeds(@Query('quantity') quantity: string) {
|
||||||
|
return this.assetService.estimateSellProceeds(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取市场概览' })
|
||||||
|
async getMarketOverview() {
|
||||||
|
return this.assetService.getMarketOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('growth-per-second')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '计算资产每秒增长量' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: true, type: String, description: '每日分配量' })
|
||||||
|
async calculateGrowthPerSecond(@Query('dailyAllocation') dailyAllocation: string) {
|
||||||
|
const perSecond = this.assetService.calculateAssetGrowthPerSecond(dailyAllocation);
|
||||||
|
return { dailyAllocation, assetGrowthPerSecond: perSecond };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { BurnService } from '../../application/services/burn.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Burn')
|
||||||
|
@Controller('burn')
|
||||||
|
export class BurnController {
|
||||||
|
constructor(private readonly burnService: BurnService) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取销毁状态' })
|
||||||
|
async getBurnStatus() {
|
||||||
|
return this.burnService.getBurnStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('records')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取销毁记录' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'sourceType', required: false, enum: ['MINUTE_BURN', 'SELL_BURN'] })
|
||||||
|
async getBurnRecords(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
@Query('sourceType') sourceType?: 'MINUTE_BURN' | 'SELL_BURN',
|
||||||
|
) {
|
||||||
|
return this.burnService.getBurnRecords(page ?? 1, pageSize ?? 50, sourceType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@ import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
|
@Public()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { PriceService } from '../../application/services/price.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Price')
|
||||||
|
@Controller('price')
|
||||||
|
export class PriceController {
|
||||||
|
constructor(private readonly priceService: PriceService) {}
|
||||||
|
|
||||||
|
@Get('current')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取当前价格信息' })
|
||||||
|
async getCurrentPrice() {
|
||||||
|
return this.priceService.getCurrentPrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取最新价格快照' })
|
||||||
|
async getLatestSnapshot() {
|
||||||
|
const snapshot = await this.priceService.getLatestSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return { message: 'No price snapshot available' };
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取价格历史' })
|
||||||
|
@ApiQuery({ name: 'startTime', required: true, type: String, description: 'ISO datetime' })
|
||||||
|
@ApiQuery({ name: 'endTime', required: true, type: String, description: 'ISO datetime' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
async getPriceHistory(
|
||||||
|
@Query('startTime') startTime: string,
|
||||||
|
@Query('endTime') endTime: string,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
return this.priceService.getPriceHistory(
|
||||||
|
new Date(startTime),
|
||||||
|
new Date(endTime),
|
||||||
|
limit ?? 1440,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,25 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { OrderService } from './services/order.service';
|
import { OrderService } from './services/order.service';
|
||||||
import { TransferService } from './services/transfer.service';
|
import { TransferService } from './services/transfer.service';
|
||||||
|
import { PriceService } from './services/price.service';
|
||||||
|
import { BurnService } from './services/burn.service';
|
||||||
|
import { AssetService } from './services/asset.service';
|
||||||
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
|
import { BurnScheduler } from './schedulers/burn.scheduler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), InfrastructureModule],
|
imports: [ScheduleModule.forRoot(), InfrastructureModule],
|
||||||
providers: [OrderService, TransferService, OutboxScheduler],
|
providers: [
|
||||||
exports: [OrderService, TransferService],
|
// Services
|
||||||
|
PriceService,
|
||||||
|
BurnService,
|
||||||
|
AssetService,
|
||||||
|
OrderService,
|
||||||
|
TransferService,
|
||||||
|
// Schedulers
|
||||||
|
OutboxScheduler,
|
||||||
|
BurnScheduler,
|
||||||
|
],
|
||||||
|
exports: [OrderService, TransferService, PriceService, BurnService, AssetService],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { BurnService } from '../services/burn.service';
|
||||||
|
import { PriceService } from '../services/price.service';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BurnScheduler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(BurnScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly burnService: BurnService,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Burn scheduler initialized');
|
||||||
|
|
||||||
|
// 初始化销毁系统
|
||||||
|
try {
|
||||||
|
await this.burnService.initialize();
|
||||||
|
this.logger.log('Burn system initialized');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to initialize burn system', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟执行销毁
|
||||||
|
* 每分钟销毁量 = 100亿 ÷ (365×4×1440) = 4756.468797564687 进黑洞
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async executeMinuteBurn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const burnAmount = await this.burnService.executeMinuteBurn();
|
||||||
|
if (!burnAmount.isZero()) {
|
||||||
|
this.logger.debug(`Minute burn completed: ${burnAmount.toFixed(8)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to execute minute burn', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟创建价格快照
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async createPriceSnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.priceService.createSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create price snapshot', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天清理旧的价格快照(保留30天)
|
||||||
|
*/
|
||||||
|
@Cron('0 3 * * *') // 每天凌晨3点
|
||||||
|
async cleanupOldSnapshots(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock('snapshot:cleanup:lock', 300);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 通过 PriceService 调用 repository 清理
|
||||||
|
this.logger.log('Starting cleanup of old price snapshots');
|
||||||
|
// 这里可以添加清理逻辑
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to cleanup old snapshots', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock('snapshot:cleanup:lock', lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每小时记录销毁状态日志
|
||||||
|
*/
|
||||||
|
@Cron('0 * * * *') // 每小时整点
|
||||||
|
async logBurnStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await this.burnService.getBurnStatus();
|
||||||
|
this.logger.log(
|
||||||
|
`Burn status: burned=${status.totalBurned}, ` +
|
||||||
|
`remaining=${status.remainingBurn}, ` +
|
||||||
|
`progress=${status.burnProgress}%, ` +
|
||||||
|
`minuteRate=${status.minuteBurnRate}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to log burn status', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './outbox.scheduler';
|
export * from './outbox.scheduler';
|
||||||
|
export * from './burn.scheduler';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { SharePoolRepository } from '../../infrastructure/persistence/repositories/share-pool.repository';
|
||||||
|
import { PriceService } from './price.service';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface AssetDisplay {
|
||||||
|
// 账户积分股余额
|
||||||
|
shareBalance: string;
|
||||||
|
// 账户现金余额
|
||||||
|
cashBalance: string;
|
||||||
|
// 冻结积分股
|
||||||
|
frozenShares: string;
|
||||||
|
// 冻结现金
|
||||||
|
frozenCash: string;
|
||||||
|
// 可用积分股
|
||||||
|
availableShares: string;
|
||||||
|
// 可用现金
|
||||||
|
availableCash: string;
|
||||||
|
// 当前价格
|
||||||
|
currentPrice: string;
|
||||||
|
// 销毁倍数
|
||||||
|
burnMultiplier: string;
|
||||||
|
// 有效积分股(含销毁加成)
|
||||||
|
effectiveShares: string;
|
||||||
|
// 资产显示值 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
displayAssetValue: string;
|
||||||
|
// 每秒增长量(需要外部传入每日分配量)
|
||||||
|
assetGrowthPerSecond: string;
|
||||||
|
// 累计买入
|
||||||
|
totalBought: string;
|
||||||
|
// 累计卖出
|
||||||
|
totalSold: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetService {
|
||||||
|
private readonly logger = new Logger(AssetService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tradingAccountRepository: TradingAccountRepository,
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly sharePoolRepository: SharePoolRepository,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户资产显示
|
||||||
|
* 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
*
|
||||||
|
* @param accountSequence 账户序号
|
||||||
|
* @param dailyAllocation 用户每天分配的积分股(可选,用于计算每秒增长)
|
||||||
|
*/
|
||||||
|
async getAssetDisplay(
|
||||||
|
accountSequence: string,
|
||||||
|
dailyAllocation?: string,
|
||||||
|
): Promise<AssetDisplay | null> {
|
||||||
|
const account = await this.tradingAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前价格信息
|
||||||
|
const priceInfo = await this.priceService.getCurrentPrice();
|
||||||
|
const price = new Money(priceInfo.price);
|
||||||
|
const burnMultiplier = new Decimal(priceInfo.burnMultiplier);
|
||||||
|
|
||||||
|
// 计算有效积分股 = 余额 × (1 + 倍数)
|
||||||
|
const multiplierFactor = new Decimal(1).plus(burnMultiplier);
|
||||||
|
const effectiveShares = account.shareBalance.value.times(multiplierFactor);
|
||||||
|
|
||||||
|
// 计算资产显示值
|
||||||
|
const displayAssetValue = this.calculator.calculateDisplayAssetValue(
|
||||||
|
account.shareBalance,
|
||||||
|
burnMultiplier,
|
||||||
|
price,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算每秒增长量
|
||||||
|
let assetGrowthPerSecond = Money.zero();
|
||||||
|
if (dailyAllocation) {
|
||||||
|
const dailyAmount = new Money(dailyAllocation);
|
||||||
|
assetGrowthPerSecond = this.calculator.calculateAssetGrowthPerSecond(dailyAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareBalance: account.shareBalance.toFixed(8),
|
||||||
|
cashBalance: account.cashBalance.toFixed(8),
|
||||||
|
frozenShares: account.frozenShares.toFixed(8),
|
||||||
|
frozenCash: account.frozenCash.toFixed(8),
|
||||||
|
availableShares: account.availableShares.toFixed(8),
|
||||||
|
availableCash: account.availableCash.toFixed(8),
|
||||||
|
currentPrice: price.toFixed(18),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
effectiveShares: new Money(effectiveShares).toFixed(8),
|
||||||
|
displayAssetValue: displayAssetValue.toFixed(8),
|
||||||
|
assetGrowthPerSecond: assetGrowthPerSecond.toFixed(18),
|
||||||
|
totalBought: account.totalBought.toFixed(8),
|
||||||
|
totalSold: account.totalSold.toFixed(8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产每秒增长量
|
||||||
|
* 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*/
|
||||||
|
calculateAssetGrowthPerSecond(dailyAllocation: string): string {
|
||||||
|
const dailyAmount = new Money(dailyAllocation);
|
||||||
|
const perSecond = this.calculator.calculateAssetGrowthPerSecond(dailyAmount);
|
||||||
|
return perSecond.toFixed(18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预估卖出收益
|
||||||
|
* 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
*/
|
||||||
|
async estimateSellProceeds(sellQuantity: string): Promise<{
|
||||||
|
sellQuantity: string;
|
||||||
|
burnQuantity: string;
|
||||||
|
effectiveQuantity: string;
|
||||||
|
price: string;
|
||||||
|
proceeds: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
}> {
|
||||||
|
const quantity = new Money(sellQuantity);
|
||||||
|
const result = await this.priceService.calculateSellAmount(quantity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sellQuantity: quantity.toFixed(8),
|
||||||
|
burnQuantity: result.burnQuantity.toFixed(8),
|
||||||
|
effectiveQuantity: result.effectiveQuantity.toFixed(8),
|
||||||
|
price: result.price.toFixed(18),
|
||||||
|
proceeds: result.amount.toFixed(8),
|
||||||
|
burnMultiplier: (await this.priceService.getCurrentBurnMultiplier()).toFixed(18),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取市场概览
|
||||||
|
*/
|
||||||
|
async getMarketOverview(): Promise<{
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
totalShares: string;
|
||||||
|
burnTarget: string;
|
||||||
|
burnProgress: string;
|
||||||
|
}> {
|
||||||
|
const [sharePool, blackHole, circulationPool] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算价格
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
|
||||||
|
// 计算有效分母
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 销毁进度
|
||||||
|
const targetBurn = blackHole?.targetBurn || new Money(TradingCalculatorService.BURN_TARGET);
|
||||||
|
const burnProgress = blackHoleAmount.value.dividedBy(targetBurn.value).times(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: price.toFixed(18),
|
||||||
|
greenPoints: greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: circulationPoolAmount.toFixed(8),
|
||||||
|
effectiveDenominator: effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
totalShares: TradingCalculatorService.TOTAL_SHARES.toFixed(8),
|
||||||
|
burnTarget: targetBurn.toFixed(8),
|
||||||
|
burnProgress: burnProgress.toFixed(4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
BurnExecutedPayload,
|
||||||
|
MinuteBurnExecutedPayload,
|
||||||
|
} from '../../domain/events/trading.events';
|
||||||
|
|
||||||
|
export interface BurnStatus {
|
||||||
|
totalBurned: string;
|
||||||
|
targetBurn: string;
|
||||||
|
remainingBurn: string;
|
||||||
|
burnProgress: string; // 百分比
|
||||||
|
minuteBurnRate: string;
|
||||||
|
remainingMinutes: number;
|
||||||
|
lastBurnMinute: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SellBurnResult {
|
||||||
|
burnQuantity: Money;
|
||||||
|
burnMultiplier: Decimal;
|
||||||
|
newMinuteBurnRate: Money;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BurnService {
|
||||||
|
private readonly logger = new Logger(BurnService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly tradingConfigRepository: TradingConfigRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取销毁状态
|
||||||
|
*/
|
||||||
|
async getBurnStatus(): Promise<BurnStatus> {
|
||||||
|
const [blackHole, config] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalBurned = blackHole?.totalBurned || Money.zero();
|
||||||
|
const targetBurn = blackHole?.targetBurn || new Money(TradingCalculatorService.BURN_TARGET);
|
||||||
|
const remainingBurn = blackHole?.remainingBurn || targetBurn;
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const progress = totalBurned.value.dividedBy(targetBurn.value).times(100);
|
||||||
|
|
||||||
|
// 计算剩余分钟数
|
||||||
|
const activatedAt = config?.activatedAt || new Date();
|
||||||
|
const remainingMinutes = this.calculator.calculateRemainingMinutes(activatedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBurned: totalBurned.toFixed(8),
|
||||||
|
targetBurn: targetBurn.toFixed(8),
|
||||||
|
remainingBurn: remainingBurn.toFixed(8),
|
||||||
|
burnProgress: progress.toFixed(4),
|
||||||
|
minuteBurnRate: (config?.minuteBurnRate || Money.zero()).toFixed(18),
|
||||||
|
remainingMinutes,
|
||||||
|
lastBurnMinute: blackHole?.lastBurnMinute || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行每分钟自动销毁
|
||||||
|
*/
|
||||||
|
async executeMinuteBurn(): Promise<Money> {
|
||||||
|
const lockValue = await this.redis.acquireLock('burn:minute:lock', 55);
|
||||||
|
if (!lockValue) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.tradingConfigRepository.getConfig();
|
||||||
|
if (!config || !config.isActive) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||||
|
if (!blackHole) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已完成销毁目标
|
||||||
|
if (blackHole.remainingBurn.isZero()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMinute = new Date();
|
||||||
|
currentMinute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
// 检查是否已处理过这一分钟
|
||||||
|
const processedKey = `burn:processed:${currentMinute.getTime()}`;
|
||||||
|
if (await this.redis.get(processedKey)) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用当前配置的每分钟销毁率
|
||||||
|
let burnAmount = config.minuteBurnRate;
|
||||||
|
|
||||||
|
// 确保不超过剩余待销毁量
|
||||||
|
if (burnAmount.isGreaterThan(blackHole.remainingBurn)) {
|
||||||
|
burnAmount = blackHole.remainingBurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (burnAmount.isZero()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录销毁
|
||||||
|
const burnRecord = await this.blackHoleRepository.recordMinuteBurn(currentMinute, burnAmount);
|
||||||
|
|
||||||
|
// 标记已处理
|
||||||
|
await this.redis.set(processedKey, '1', 120);
|
||||||
|
|
||||||
|
this.logger.log(`Minute burn executed: ${burnAmount.toFixed(8)}`);
|
||||||
|
|
||||||
|
// 发布每分钟销毁事件
|
||||||
|
await this.publishMinuteBurnEvent(
|
||||||
|
burnRecord.id,
|
||||||
|
currentMinute,
|
||||||
|
burnAmount,
|
||||||
|
blackHole.totalBurned.add(burnAmount),
|
||||||
|
blackHole.remainingBurn.subtract(burnAmount),
|
||||||
|
);
|
||||||
|
|
||||||
|
return burnAmount;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to execute minute burn', error);
|
||||||
|
return Money.zero();
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock('burn:minute:lock', lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行卖出销毁
|
||||||
|
* 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
* 卖出后需要重新计算每分钟销毁量
|
||||||
|
*/
|
||||||
|
async executeSellBurn(
|
||||||
|
sellQuantity: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<SellBurnResult> {
|
||||||
|
const [blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!blackHole || !config) {
|
||||||
|
throw new Error('Trading system not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackHoleAmount = blackHole.totalBurned;
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁量
|
||||||
|
const burnQuantity = this.calculator.calculateSellBurnAmount(sellQuantity, burnMultiplier);
|
||||||
|
|
||||||
|
// 确保销毁量不超过剩余待销毁量
|
||||||
|
const actualBurnQuantity = burnQuantity.isGreaterThan(blackHole.remainingBurn)
|
||||||
|
? blackHole.remainingBurn
|
||||||
|
: burnQuantity;
|
||||||
|
|
||||||
|
if (!actualBurnQuantity.isZero()) {
|
||||||
|
// 记录卖出销毁
|
||||||
|
const burnMinute = new Date();
|
||||||
|
burnMinute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const burnRecord = await this.blackHoleRepository.recordSellBurn(
|
||||||
|
burnMinute,
|
||||||
|
actualBurnQuantity,
|
||||||
|
accountSeq,
|
||||||
|
orderNo,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重新计算每分钟销毁量
|
||||||
|
const newBlackHoleAmount = blackHoleAmount.add(actualBurnQuantity);
|
||||||
|
const remainingMinutes = this.calculator.calculateRemainingMinutes(
|
||||||
|
config.activatedAt || new Date(),
|
||||||
|
);
|
||||||
|
const newMinuteBurnRate = this.calculator.calculateMinuteBurnRate(
|
||||||
|
newBlackHoleAmount,
|
||||||
|
remainingMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新配置中的每分钟销毁率
|
||||||
|
await this.tradingConfigRepository.updateMinuteBurnRate(newMinuteBurnRate);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Sell burn executed: quantity=${actualBurnQuantity.toFixed(8)}, ` +
|
||||||
|
`multiplier=${burnMultiplier.toFixed(8)}, newRate=${newMinuteBurnRate.toFixed(18)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发布卖出销毁事件
|
||||||
|
await this.publishSellBurnEvent(
|
||||||
|
burnRecord.id,
|
||||||
|
accountSeq,
|
||||||
|
orderNo,
|
||||||
|
actualBurnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
blackHole.remainingBurn.subtract(actualBurnQuantity),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity: actualBurnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
newMinuteBurnRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity: Money.zero(),
|
||||||
|
burnMultiplier,
|
||||||
|
newMinuteBurnRate: config.minuteBurnRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化黑洞和配置
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
const [existingConfig, existingBlackHole] = await Promise.all([
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!existingConfig) {
|
||||||
|
await this.tradingConfigRepository.initializeConfig();
|
||||||
|
this.logger.log('Trading config initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingBlackHole) {
|
||||||
|
await this.blackHoleRepository.initializeBlackHole(
|
||||||
|
new Money(TradingCalculatorService.BURN_TARGET),
|
||||||
|
);
|
||||||
|
this.logger.log('Black hole initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取销毁记录
|
||||||
|
*/
|
||||||
|
async getBurnRecords(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
sourceType?: 'MINUTE_BURN' | 'SELL_BURN',
|
||||||
|
): Promise<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const result = await this.blackHoleRepository.getBurnRecords(page, pageSize, sourceType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
burnMinute: r.burnMinute,
|
||||||
|
burnAmount: r.burnAmount.toFixed(8),
|
||||||
|
remainingTarget: r.remainingTarget.toFixed(8),
|
||||||
|
sourceType: r.sourceType,
|
||||||
|
sourceAccountSeq: r.sourceAccountSeq,
|
||||||
|
sourceOrderNo: r.sourceOrderNo,
|
||||||
|
memo: r.memo,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件发布方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布每分钟销毁事件
|
||||||
|
*/
|
||||||
|
private async publishMinuteBurnEvent(
|
||||||
|
burnRecordId: string,
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
totalBurned: Money,
|
||||||
|
remainingTarget: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: MinuteBurnExecutedPayload = {
|
||||||
|
burnRecordId,
|
||||||
|
burnMinute: burnMinute.toISOString(),
|
||||||
|
burnAmount: burnAmount.toString(),
|
||||||
|
totalBurned: totalBurned.toString(),
|
||||||
|
remainingTarget: remainingTarget.toString(),
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'BurnRecord',
|
||||||
|
aggregateId: burnRecordId,
|
||||||
|
eventType: TradingEventTypes.MINUTE_BURN_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.BURNS,
|
||||||
|
key: 'minute-burn',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published MinuteBurnExecuted event: ${burnAmount.toFixed(8)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish MinuteBurnExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布卖出销毁事件
|
||||||
|
*/
|
||||||
|
private async publishSellBurnEvent(
|
||||||
|
burnRecordId: string,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
burnAmount: Money,
|
||||||
|
burnMultiplier: Decimal,
|
||||||
|
remainingTarget: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: BurnExecutedPayload = {
|
||||||
|
burnRecordId,
|
||||||
|
sourceType: 'SELL',
|
||||||
|
sourceAccountSeq: accountSeq,
|
||||||
|
sourceOrderNo: orderNo,
|
||||||
|
burnAmount: burnAmount.toString(),
|
||||||
|
burnMultiplier: burnMultiplier.toString(),
|
||||||
|
remainingTarget: remainingTarget.toString(),
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'BurnRecord',
|
||||||
|
aggregateId: burnRecordId,
|
||||||
|
eventType: TradingEventTypes.BURN_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.BURNS,
|
||||||
|
key: accountSeq,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published BurnExecuted event for account ${accountSeq}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish BurnExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
||||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
import { OrderAggregate, OrderType, OrderStatus } from '../../domain/aggregates/order.aggregate';
|
import { OrderAggregate, OrderType, OrderStatus } from '../../domain/aggregates/order.aggregate';
|
||||||
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
||||||
import { MatchingEngineService } from '../../domain/services/matching-engine.service';
|
import { MatchingEngineService } from '../../domain/services/matching-engine.service';
|
||||||
import { Money } from '../../domain/value-objects/money.vo';
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import { BurnService } from './burn.service';
|
||||||
|
import { PriceService } from './price.service';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
OrderCreatedPayload,
|
||||||
|
OrderCancelledPayload,
|
||||||
|
TradeExecutedPayload,
|
||||||
|
} from '../../domain/events/trading.events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
|
|
@ -16,8 +27,12 @@ export class OrderService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly accountRepository: TradingAccountRepository,
|
private readonly accountRepository: TradingAccountRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
private readonly burnService: BurnService,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
|
|
@ -70,6 +85,9 @@ export class OrderService {
|
||||||
const orderId = await this.orderRepository.save(order);
|
const orderId = await this.orderRepository.save(order);
|
||||||
await this.accountRepository.save(account);
|
await this.accountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布订单创建事件
|
||||||
|
await this.publishOrderCreatedEvent(orderId, order);
|
||||||
|
|
||||||
// 尝试撮合
|
// 尝试撮合
|
||||||
await this.tryMatch(order);
|
await this.tryMatch(order);
|
||||||
|
|
||||||
|
|
@ -113,6 +131,9 @@ export class OrderService {
|
||||||
|
|
||||||
await this.orderRepository.save(order);
|
await this.orderRepository.save(order);
|
||||||
await this.accountRepository.save(account);
|
await this.accountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布订单取消事件
|
||||||
|
await this.publishOrderCancelledEvent(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryMatch(incomingOrder: OrderAggregate): Promise<void> {
|
private async tryMatch(incomingOrder: OrderAggregate): Promise<void> {
|
||||||
|
|
@ -126,7 +147,36 @@ export class OrderService {
|
||||||
const matches = this.matchingEngine.findMatchingOrders(incomingOrder, orderBook);
|
const matches = this.matchingEngine.findMatchingOrders(incomingOrder, orderBook);
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
// 保存成交记录
|
const tradeQuantity = match.trade.quantity;
|
||||||
|
let burnQuantity = Money.zero();
|
||||||
|
let effectiveQuantity = tradeQuantity;
|
||||||
|
|
||||||
|
// 如果是卖出成交,执行销毁逻辑
|
||||||
|
// 卖出的销毁量 = 卖出积分股 × 倍数
|
||||||
|
// 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
if (match.sellOrder) {
|
||||||
|
try {
|
||||||
|
const burnResult = await this.burnService.executeSellBurn(
|
||||||
|
tradeQuantity,
|
||||||
|
match.sellOrder.accountSequence,
|
||||||
|
match.sellOrder.orderNo,
|
||||||
|
);
|
||||||
|
burnQuantity = burnResult.burnQuantity;
|
||||||
|
effectiveQuantity = new Money(tradeQuantity.value.plus(burnQuantity.value));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Sell burn executed: sellQty=${tradeQuantity.toFixed(8)}, ` +
|
||||||
|
`burnQty=${burnQuantity.toFixed(8)}, effectiveQty=${effectiveQuantity.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Sell burn failed, continuing without burn: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算交易额 = 有效数量 × 价格
|
||||||
|
const tradeAmount = new Money(effectiveQuantity.value.times(match.trade.price.value));
|
||||||
|
|
||||||
|
// 保存成交记录(包含销毁信息)
|
||||||
await this.prisma.trade.create({
|
await this.prisma.trade.create({
|
||||||
data: {
|
data: {
|
||||||
tradeNo: match.trade.tradeNo,
|
tradeNo: match.trade.tradeNo,
|
||||||
|
|
@ -135,31 +185,58 @@ export class OrderService {
|
||||||
buyerSequence: match.buyOrder.accountSequence,
|
buyerSequence: match.buyOrder.accountSequence,
|
||||||
sellerSequence: match.sellOrder.accountSequence,
|
sellerSequence: match.sellOrder.accountSequence,
|
||||||
price: match.trade.price.value,
|
price: match.trade.price.value,
|
||||||
quantity: match.trade.quantity.value,
|
quantity: tradeQuantity.value,
|
||||||
amount: match.trade.amount.value,
|
burnQuantity: burnQuantity.value,
|
||||||
|
effectiveQty: effectiveQuantity.value,
|
||||||
|
amount: tradeAmount.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新订单
|
// 卖出的积分股进入流通池
|
||||||
|
try {
|
||||||
|
await this.circulationPoolRepository.addSharesFromSell(
|
||||||
|
tradeQuantity,
|
||||||
|
match.sellOrder.accountSequence,
|
||||||
|
match.sellOrder.id!,
|
||||||
|
`卖出成交, 交易号${match.trade.tradeNo}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to add shares to circulation pool: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新订单(包含销毁信息)
|
||||||
await this.orderRepository.save(match.buyOrder);
|
await this.orderRepository.save(match.buyOrder);
|
||||||
await this.orderRepository.save(match.sellOrder);
|
await this.orderRepository.saveWithBurnInfo(match.sellOrder, burnQuantity, effectiveQuantity);
|
||||||
|
|
||||||
// 更新买方账户
|
// 更新买方账户
|
||||||
const buyerAccount = await this.accountRepository.findByAccountSequence(match.buyOrder.accountSequence);
|
const buyerAccount = await this.accountRepository.findByAccountSequence(match.buyOrder.accountSequence);
|
||||||
if (buyerAccount) {
|
if (buyerAccount) {
|
||||||
buyerAccount.executeBuy(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
|
buyerAccount.executeBuy(tradeQuantity, tradeAmount, match.trade.tradeNo);
|
||||||
await this.accountRepository.save(buyerAccount);
|
await this.accountRepository.save(buyerAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新卖方账户
|
// 更新卖方账户(获得的是有效交易额)
|
||||||
const sellerAccount = await this.accountRepository.findByAccountSequence(match.sellOrder.accountSequence);
|
const sellerAccount = await this.accountRepository.findByAccountSequence(match.sellOrder.accountSequence);
|
||||||
if (sellerAccount) {
|
if (sellerAccount) {
|
||||||
sellerAccount.executeSell(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
|
sellerAccount.executeSell(tradeQuantity, tradeAmount, match.trade.tradeNo);
|
||||||
await this.accountRepository.save(sellerAccount);
|
await this.accountRepository.save(sellerAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Trade executed: ${match.trade.tradeNo}, price=${match.trade.price}, qty=${match.trade.quantity}`,
|
`Trade executed: ${match.trade.tradeNo}, price=${match.trade.price.toFixed(8)}, ` +
|
||||||
|
`qty=${tradeQuantity.toFixed(8)}, burn=${burnQuantity.toFixed(8)}, amount=${tradeAmount.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发布成交事件
|
||||||
|
await this.publishTradeExecutedEvent(
|
||||||
|
match.trade.tradeNo,
|
||||||
|
match.buyOrder,
|
||||||
|
match.sellOrder,
|
||||||
|
match.trade.price,
|
||||||
|
tradeQuantity,
|
||||||
|
tradeAmount,
|
||||||
|
burnQuantity,
|
||||||
|
effectiveQuantity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -172,4 +249,116 @@ export class OrderService {
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
return `O${timestamp}${random}`.toUpperCase();
|
return `O${timestamp}${random}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 事件发布方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布订单创建事件
|
||||||
|
*/
|
||||||
|
private async publishOrderCreatedEvent(orderId: string, order: OrderAggregate): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: OrderCreatedPayload = {
|
||||||
|
orderId,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
accountSequence: order.accountSequence,
|
||||||
|
type: order.type,
|
||||||
|
price: order.price.toString(),
|
||||||
|
quantity: order.quantity.toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Order',
|
||||||
|
aggregateId: orderId,
|
||||||
|
eventType: TradingEventTypes.ORDER_CREATED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ORDERS,
|
||||||
|
key: order.accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published OrderCreated event for order ${order.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish OrderCreated event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布订单取消事件
|
||||||
|
*/
|
||||||
|
private async publishOrderCancelledEvent(order: OrderAggregate): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: OrderCancelledPayload = {
|
||||||
|
orderId: order.id!,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
accountSequence: order.accountSequence,
|
||||||
|
type: order.type,
|
||||||
|
cancelledQuantity: order.remainingQuantity.toString(),
|
||||||
|
cancelledAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Order',
|
||||||
|
aggregateId: order.id!,
|
||||||
|
eventType: TradingEventTypes.ORDER_CANCELLED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ORDERS,
|
||||||
|
key: order.accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published OrderCancelled event for order ${order.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish OrderCancelled event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布成交事件
|
||||||
|
*/
|
||||||
|
private async publishTradeExecutedEvent(
|
||||||
|
tradeNo: string,
|
||||||
|
buyOrder: OrderAggregate,
|
||||||
|
sellOrder: OrderAggregate,
|
||||||
|
price: Money,
|
||||||
|
quantity: Money,
|
||||||
|
amount: Money,
|
||||||
|
burnQuantity: Money,
|
||||||
|
effectiveQuantity: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 使用 tradeNo 查找刚创建的 trade 获取 id
|
||||||
|
const trade = await this.prisma.trade.findUnique({ where: { tradeNo } });
|
||||||
|
if (!trade) {
|
||||||
|
this.logger.warn(`Trade not found for event publishing: ${tradeNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TradeExecutedPayload = {
|
||||||
|
tradeId: trade.id,
|
||||||
|
tradeNo,
|
||||||
|
buyOrderId: buyOrder.id!,
|
||||||
|
sellOrderId: sellOrder.id!,
|
||||||
|
buyerSequence: buyOrder.accountSequence,
|
||||||
|
sellerSequence: sellOrder.accountSequence,
|
||||||
|
price: price.toString(),
|
||||||
|
quantity: quantity.toString(),
|
||||||
|
amount: amount.toString(),
|
||||||
|
burnQuantity: burnQuantity.toString(),
|
||||||
|
effectiveQuantity: effectiveQuantity.toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Trade',
|
||||||
|
aggregateId: trade.id,
|
||||||
|
eventType: TradingEventTypes.TRADE_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.TRADES,
|
||||||
|
key: tradeNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published TradeExecuted event for trade ${tradeNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish TradeExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { SharePoolRepository } from '../../infrastructure/persistence/repositories/share-pool.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { PriceSnapshotRepository } from '../../infrastructure/persistence/repositories/price-snapshot.repository';
|
||||||
|
import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface PriceInfo {
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
minuteBurnRate: string;
|
||||||
|
snapshotTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PriceService {
|
||||||
|
private readonly logger = new Logger(PriceService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly sharePoolRepository: SharePoolRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly priceSnapshotRepository: PriceSnapshotRepository,
|
||||||
|
private readonly tradingConfigRepository: TradingConfigRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前价格信息
|
||||||
|
*/
|
||||||
|
async getCurrentPrice(): Promise<PriceInfo> {
|
||||||
|
const [sharePool, blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算价格
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
|
||||||
|
// 计算有效分母
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取当前每分钟销毁率
|
||||||
|
const minuteBurnRate = config?.minuteBurnRate || Money.zero();
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: price.toFixed(18),
|
||||||
|
greenPoints: greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: circulationPoolAmount.toFixed(8),
|
||||||
|
effectiveDenominator: effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
minuteBurnRate: minuteBurnRate.toFixed(18),
|
||||||
|
snapshotTime: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前销毁倍数
|
||||||
|
*/
|
||||||
|
async getCurrentBurnMultiplier(): Promise<Decimal> {
|
||||||
|
const [blackHole, circulationPool] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
return this.calculator.calculateSellBurnMultiplier(blackHoleAmount, circulationPoolAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁量
|
||||||
|
*/
|
||||||
|
async calculateSellBurn(sellQuantity: Money): Promise<{
|
||||||
|
burnQuantity: Money;
|
||||||
|
burnMultiplier: Decimal;
|
||||||
|
effectiveQuantity: Money;
|
||||||
|
}> {
|
||||||
|
const burnMultiplier = await this.getCurrentBurnMultiplier();
|
||||||
|
const burnQuantity = this.calculator.calculateSellBurnAmount(sellQuantity, burnMultiplier);
|
||||||
|
const effectiveQuantity = new Money(sellQuantity.value.plus(burnQuantity.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
effectiveQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出交易额
|
||||||
|
*/
|
||||||
|
async calculateSellAmount(sellQuantity: Money): Promise<{
|
||||||
|
amount: Money;
|
||||||
|
burnQuantity: Money;
|
||||||
|
effectiveQuantity: Money;
|
||||||
|
price: Money;
|
||||||
|
}> {
|
||||||
|
const priceInfo = await this.getCurrentPrice();
|
||||||
|
const price = new Money(priceInfo.price);
|
||||||
|
|
||||||
|
const { burnQuantity, effectiveQuantity } = await this.calculateSellBurn(sellQuantity);
|
||||||
|
|
||||||
|
const amount = this.calculator.calculateSellAmount(sellQuantity, burnQuantity, price);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount,
|
||||||
|
burnQuantity,
|
||||||
|
effectiveQuantity,
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建价格快照
|
||||||
|
*/
|
||||||
|
async createSnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [sharePool, blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
const minuteBurnRate = config?.minuteBurnRate || Money.zero();
|
||||||
|
|
||||||
|
const snapshotTime = new Date();
|
||||||
|
snapshotTime.setSeconds(0, 0);
|
||||||
|
|
||||||
|
await this.priceSnapshotRepository.createSnapshot({
|
||||||
|
snapshotTime,
|
||||||
|
price,
|
||||||
|
greenPoints,
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPool: circulationPoolAmount,
|
||||||
|
effectiveDenominator,
|
||||||
|
minuteBurnRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Price snapshot created: ${price.toFixed(18)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create price snapshot', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取价格历史
|
||||||
|
*/
|
||||||
|
async getPriceHistory(
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
limit: number = 1440,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
time: Date;
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const snapshots = await this.priceSnapshotRepository.getPriceHistory(startTime, endTime, limit);
|
||||||
|
|
||||||
|
return snapshots.map((s) => ({
|
||||||
|
time: s.snapshotTime,
|
||||||
|
price: s.price.toFixed(18),
|
||||||
|
greenPoints: s.greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: s.blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: s.circulationPool.toFixed(8),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新价格快照
|
||||||
|
*/
|
||||||
|
async getLatestSnapshot(): Promise<PriceInfo | null> {
|
||||||
|
const snapshot = await this.priceSnapshotRepository.getLatestSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const burnMultiplier = await this.getCurrentBurnMultiplier();
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: snapshot.price.toFixed(18),
|
||||||
|
greenPoints: snapshot.greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: snapshot.blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: snapshot.circulationPool.toFixed(8),
|
||||||
|
effectiveDenominator: snapshot.effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
minuteBurnRate: snapshot.minuteBurnRate.toFixed(18),
|
||||||
|
snapshotTime: snapshot.snapshotTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Trading Service Event Types
|
||||||
|
export * from './trading.events';
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/**
|
||||||
|
* Trading Service 事件定义
|
||||||
|
* 这些事件通过 Outbox 模式发布到 Kafka
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 事件类型常量 ====================
|
||||||
|
|
||||||
|
export const TradingEventTypes = {
|
||||||
|
// 订单事件
|
||||||
|
ORDER_CREATED: 'order.created',
|
||||||
|
ORDER_CANCELLED: 'order.cancelled',
|
||||||
|
ORDER_COMPLETED: 'order.completed',
|
||||||
|
|
||||||
|
// 成交事件
|
||||||
|
TRADE_EXECUTED: 'trade.executed',
|
||||||
|
|
||||||
|
// 转账事件
|
||||||
|
TRANSFER_INITIATED: 'transfer.initiated',
|
||||||
|
TRANSFER_COMPLETED: 'transfer.completed',
|
||||||
|
TRANSFER_FAILED: 'transfer.failed',
|
||||||
|
|
||||||
|
// 销毁事件
|
||||||
|
BURN_EXECUTED: 'burn.executed',
|
||||||
|
MINUTE_BURN_EXECUTED: 'burn.minute-executed',
|
||||||
|
|
||||||
|
// 价格事件
|
||||||
|
PRICE_UPDATED: 'price.updated',
|
||||||
|
|
||||||
|
// 账户事件
|
||||||
|
TRADING_ACCOUNT_CREATED: 'trading-account.created',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TradingEventType =
|
||||||
|
(typeof TradingEventTypes)[keyof typeof TradingEventTypes];
|
||||||
|
|
||||||
|
// ==================== Kafka Topic 常量 ====================
|
||||||
|
|
||||||
|
export const TradingTopics = {
|
||||||
|
ORDERS: 'trading.orders',
|
||||||
|
TRADES: 'trading.trades',
|
||||||
|
TRANSFERS: 'trading.transfers',
|
||||||
|
BURNS: 'trading.burns',
|
||||||
|
PRICES: 'trading.prices',
|
||||||
|
ACCOUNTS: 'trading.accounts',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ==================== 事件 Payload 类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单创建事件
|
||||||
|
*/
|
||||||
|
export interface OrderCreatedPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
price: string;
|
||||||
|
quantity: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单取消事件
|
||||||
|
*/
|
||||||
|
export interface OrderCancelledPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
cancelledQuantity: string;
|
||||||
|
cancelledAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单完成事件
|
||||||
|
*/
|
||||||
|
export interface OrderCompletedPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
filledQuantity: string;
|
||||||
|
averagePrice: string;
|
||||||
|
totalAmount: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成交事件
|
||||||
|
*/
|
||||||
|
export interface TradeExecutedPayload {
|
||||||
|
tradeId: string;
|
||||||
|
tradeNo: string;
|
||||||
|
buyOrderId: string;
|
||||||
|
sellOrderId: string;
|
||||||
|
buyerSequence: string;
|
||||||
|
sellerSequence: string;
|
||||||
|
price: string;
|
||||||
|
quantity: string;
|
||||||
|
amount: string;
|
||||||
|
burnQuantity: string;
|
||||||
|
effectiveQuantity: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账发起事件
|
||||||
|
*/
|
||||||
|
export interface TransferInitiatedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
initiatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账完成事件
|
||||||
|
*/
|
||||||
|
export interface TransferCompletedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
miningTxId?: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账失败事件
|
||||||
|
*/
|
||||||
|
export interface TransferFailedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
errorMessage: string;
|
||||||
|
failedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁执行事件(卖出触发)
|
||||||
|
*/
|
||||||
|
export interface BurnExecutedPayload {
|
||||||
|
burnRecordId: string;
|
||||||
|
sourceType: 'SELL' | 'SCHEDULED';
|
||||||
|
sourceAccountSeq?: string;
|
||||||
|
sourceOrderNo?: string;
|
||||||
|
burnAmount: string;
|
||||||
|
burnMultiplier?: string;
|
||||||
|
remainingTarget: string;
|
||||||
|
executedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟定时销毁事件
|
||||||
|
*/
|
||||||
|
export interface MinuteBurnExecutedPayload {
|
||||||
|
burnRecordId: string;
|
||||||
|
burnMinute: string;
|
||||||
|
burnAmount: string;
|
||||||
|
totalBurned: string;
|
||||||
|
remainingTarget: string;
|
||||||
|
executedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价格更新事件
|
||||||
|
*/
|
||||||
|
export interface PriceUpdatedPayload {
|
||||||
|
snapshotId: string;
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
minuteBurnRate: string;
|
||||||
|
snapshotTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易账户创建事件
|
||||||
|
*/
|
||||||
|
export interface TradingAccountCreatedPayload {
|
||||||
|
accountId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件基类 ====================
|
||||||
|
|
||||||
|
export interface TradingEvent<T = unknown> {
|
||||||
|
eventId: string;
|
||||||
|
eventType: TradingEventType;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
payload: T;
|
||||||
|
timestamp: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标准事件结构
|
||||||
|
*/
|
||||||
|
export function createTradingEvent<T>(
|
||||||
|
eventType: TradingEventType,
|
||||||
|
aggregateType: string,
|
||||||
|
aggregateId: string,
|
||||||
|
payload: T,
|
||||||
|
): Omit<TradingEvent<T>, 'eventId'> {
|
||||||
|
return {
|
||||||
|
eventType,
|
||||||
|
aggregateType,
|
||||||
|
aggregateId,
|
||||||
|
payload,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { Money } from '../value-objects/money.vo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易计算领域服务
|
||||||
|
*
|
||||||
|
* 核心公式:
|
||||||
|
* 1. 每分钟销毁量 = 100亿 ÷ (365×4×1440) = 4756.468797564687 进黑洞
|
||||||
|
* 2. 积分股价格 = 积分股池的绿积分 ÷ (100.02亿积分股 - 黑洞积分股 - 流通池积分股)
|
||||||
|
* 3. 卖出销毁倍数 = (100亿积分股 - 黑洞销毁量) ÷ (200万 - 流通池量)
|
||||||
|
* 4. 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
* 5. 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
* 6. 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
* 7. 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*/
|
||||||
|
export class TradingCalculatorService {
|
||||||
|
// 总积分股数量: 100.02B
|
||||||
|
static readonly TOTAL_SHARES = new Decimal('100020000000');
|
||||||
|
|
||||||
|
// 目标销毁量: 100亿 (4年销毁完)
|
||||||
|
static readonly BURN_TARGET = new Decimal('10000000000');
|
||||||
|
|
||||||
|
// 销毁周期: 4年的分钟数
|
||||||
|
static readonly BURN_PERIOD_MINUTES = 365 * 4 * 1440; // 2102400
|
||||||
|
|
||||||
|
// 流通池目标量: 200万
|
||||||
|
static readonly CIRCULATION_POOL_TARGET = new Decimal('2000000');
|
||||||
|
|
||||||
|
// 基础每分钟销毁量: 100亿 ÷ (365×4×1440)
|
||||||
|
static readonly BASE_MINUTE_BURN_RATE = TradingCalculatorService.BURN_TARGET.dividedBy(
|
||||||
|
TradingCalculatorService.BURN_PERIOD_MINUTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算积分股价格
|
||||||
|
* 价格 = 绿积分(股池) ÷ (总积分股 - 黑洞积分股 - 流通池积分股)
|
||||||
|
*
|
||||||
|
* @param greenPoints 积分股池的绿积分(分子)
|
||||||
|
* @param blackHoleAmount 黑洞积分股数量
|
||||||
|
* @param circulationPoolAmount 流通池积分股数量
|
||||||
|
* @returns 价格
|
||||||
|
*/
|
||||||
|
calculatePrice(
|
||||||
|
greenPoints: Money,
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Money {
|
||||||
|
// 有效分母 = 100.02B - 黑洞 - 流通池
|
||||||
|
const effectiveDenominator = TradingCalculatorService.TOTAL_SHARES
|
||||||
|
.minus(blackHoleAmount.value)
|
||||||
|
.minus(circulationPoolAmount.value);
|
||||||
|
|
||||||
|
if (effectiveDenominator.isZero() || effectiveDenominator.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 价格 = 绿积分 / 有效分母
|
||||||
|
const price = greenPoints.value.dividedBy(effectiveDenominator);
|
||||||
|
return new Money(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算有效分母
|
||||||
|
* 有效分母 = 总积分股 - 黑洞积分股 - 流通池积分股
|
||||||
|
*/
|
||||||
|
calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Money {
|
||||||
|
const denominator = TradingCalculatorService.TOTAL_SHARES
|
||||||
|
.minus(blackHoleAmount.value)
|
||||||
|
.minus(circulationPoolAmount.value);
|
||||||
|
|
||||||
|
if (denominator.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Money(denominator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁倍数
|
||||||
|
* 倍数 = (100亿积分股 - 黑洞销毁量) ÷ (200万 - 流通池量)
|
||||||
|
*
|
||||||
|
* 目的:确保价格不会因为卖出而下跌
|
||||||
|
*
|
||||||
|
* @param blackHoleAmount 当前黑洞销毁总量
|
||||||
|
* @param circulationPoolAmount 当前流通池量
|
||||||
|
* @returns 销毁倍数
|
||||||
|
*/
|
||||||
|
calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Decimal {
|
||||||
|
// 分子 = 100亿 - 黑洞销毁量
|
||||||
|
const numerator = TradingCalculatorService.BURN_TARGET.minus(blackHoleAmount.value);
|
||||||
|
|
||||||
|
// 分母 = 200万 - 流通池量
|
||||||
|
const denominator = TradingCalculatorService.CIRCULATION_POOL_TARGET.minus(
|
||||||
|
circulationPoolAmount.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 防止除以零或负数
|
||||||
|
if (denominator.isZero() || denominator.isNegative()) {
|
||||||
|
// 当流通池已满时,销毁倍数设为最大合理值
|
||||||
|
return new Decimal('5'); // 或其他业务定义的最大倍数
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numerator.isNegative()) {
|
||||||
|
// 当黑洞已满时,不再销毁
|
||||||
|
return new Decimal('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return numerator.dividedBy(denominator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁量
|
||||||
|
* 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
*
|
||||||
|
* @param sellQuantity 卖出的积分股数量
|
||||||
|
* @param burnMultiplier 销毁倍数
|
||||||
|
* @returns 需要销毁的数量
|
||||||
|
*/
|
||||||
|
calculateSellBurnAmount(sellQuantity: Money, burnMultiplier: Decimal): Money {
|
||||||
|
const burnAmount = sellQuantity.value.times(burnMultiplier);
|
||||||
|
return new Money(burnAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出交易额
|
||||||
|
* 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
*
|
||||||
|
* @param sellQuantity 卖出的积分股数量
|
||||||
|
* @param burnQuantity 销毁的数量
|
||||||
|
* @param price 当前价格
|
||||||
|
* @returns 交易额
|
||||||
|
*/
|
||||||
|
calculateSellAmount(sellQuantity: Money, burnQuantity: Money, price: Money): Money {
|
||||||
|
const effectiveQuantity = sellQuantity.value.plus(burnQuantity.value);
|
||||||
|
const amount = effectiveQuantity.times(price.value);
|
||||||
|
return new Money(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产显示值
|
||||||
|
* 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
*
|
||||||
|
* @param shareBalance 账户积分股余额
|
||||||
|
* @param burnMultiplier 当前销毁倍数
|
||||||
|
* @param price 当前价格
|
||||||
|
* @returns 显示的资产价值
|
||||||
|
*/
|
||||||
|
calculateDisplayAssetValue(
|
||||||
|
shareBalance: Money,
|
||||||
|
burnMultiplier: Decimal,
|
||||||
|
price: Money,
|
||||||
|
): Money {
|
||||||
|
// 有效积分股 = 余额 + 余额 × 倍数 = 余额 × (1 + 倍数)
|
||||||
|
const multiplierFactor = new Decimal(1).plus(burnMultiplier);
|
||||||
|
const effectiveShares = shareBalance.value.times(multiplierFactor);
|
||||||
|
const assetValue = effectiveShares.times(price.value);
|
||||||
|
return new Money(assetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产每秒增长量
|
||||||
|
* 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*
|
||||||
|
* @param dailyAllocation 用户每天分配的积分股
|
||||||
|
* @returns 每秒增长量
|
||||||
|
*/
|
||||||
|
calculateAssetGrowthPerSecond(dailyAllocation: Money): Money {
|
||||||
|
const secondsPerDay = 24 * 60 * 60; // 86400
|
||||||
|
const perSecond = dailyAllocation.value.dividedBy(secondsPerDay);
|
||||||
|
return new Money(perSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算每分钟销毁量
|
||||||
|
* 每次卖出后需要重新计算:(100亿 - 黑洞总量) ÷ 剩余分钟
|
||||||
|
*
|
||||||
|
* @param blackHoleAmount 当前黑洞销毁总量
|
||||||
|
* @param remainingMinutes 剩余分钟数
|
||||||
|
* @returns 每分钟销毁量
|
||||||
|
*/
|
||||||
|
calculateMinuteBurnRate(blackHoleAmount: Money, remainingMinutes: number): Money {
|
||||||
|
if (remainingMinutes <= 0) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余需要销毁的量 = 100亿 - 已销毁量
|
||||||
|
const remainingBurn = TradingCalculatorService.BURN_TARGET.minus(blackHoleAmount.value);
|
||||||
|
|
||||||
|
if (remainingBurn.isZero() || remainingBurn.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const minuteRate = remainingBurn.dividedBy(remainingMinutes);
|
||||||
|
return new Money(minuteRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算剩余销毁分钟数
|
||||||
|
*
|
||||||
|
* @param activatedAt 激活时间
|
||||||
|
* @returns 剩余分钟数
|
||||||
|
*/
|
||||||
|
calculateRemainingMinutes(activatedAt: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const elapsedMs = now.getTime() - activatedAt.getTime();
|
||||||
|
const elapsedMinutes = Math.floor(elapsedMs / (60 * 1000));
|
||||||
|
return Math.max(0, TradingCalculatorService.BURN_PERIOD_MINUTES - elapsedMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出后的新价格(验证用)
|
||||||
|
* 确保卖出后价格不下跌
|
||||||
|
*
|
||||||
|
* @param currentPrice 当前价格
|
||||||
|
* @param sellQuantity 卖出数量
|
||||||
|
* @param burnQuantity 销毁数量
|
||||||
|
* @param greenPoints 绿积分
|
||||||
|
* @param blackHoleAmount 黑洞数量
|
||||||
|
* @param circulationPoolAmount 流通池数量
|
||||||
|
* @returns 卖出后的新价格
|
||||||
|
*/
|
||||||
|
calculatePriceAfterSell(
|
||||||
|
greenPoints: Money,
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
sellQuantity: Money,
|
||||||
|
burnQuantity: Money,
|
||||||
|
): Money {
|
||||||
|
// 卖出后:流通池增加sellQuantity,黑洞增加burnQuantity
|
||||||
|
const newBlackHole = blackHoleAmount.add(burnQuantity);
|
||||||
|
const newCirculation = circulationPoolAmount.add(sellQuantity);
|
||||||
|
|
||||||
|
return this.calculatePrice(greenPoints, newBlackHole, newCirculation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,15 @@ import { PrismaModule } from './persistence/prisma/prisma.module';
|
||||||
import { TradingAccountRepository } from './persistence/repositories/trading-account.repository';
|
import { TradingAccountRepository } from './persistence/repositories/trading-account.repository';
|
||||||
import { OrderRepository } from './persistence/repositories/order.repository';
|
import { OrderRepository } from './persistence/repositories/order.repository';
|
||||||
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
||||||
|
import { TradingConfigRepository } from './persistence/repositories/trading-config.repository';
|
||||||
|
import { BlackHoleRepository } from './persistence/repositories/black-hole.repository';
|
||||||
|
import { SharePoolRepository } from './persistence/repositories/share-pool.repository';
|
||||||
|
import { CirculationPoolRepository } from './persistence/repositories/circulation-pool.repository';
|
||||||
|
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
|
||||||
|
import { ProcessedEventRepository } from './persistence/repositories/processed-event.repository';
|
||||||
import { RedisService } from './redis/redis.service';
|
import { RedisService } from './redis/redis.service';
|
||||||
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
|
import { UserRegisteredConsumer } from './kafka/consumers/user-registered.consumer';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -32,10 +39,17 @@ import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
controllers: [UserRegisteredConsumer],
|
||||||
providers: [
|
providers: [
|
||||||
TradingAccountRepository,
|
TradingAccountRepository,
|
||||||
OrderRepository,
|
OrderRepository,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
|
TradingConfigRepository,
|
||||||
|
BlackHoleRepository,
|
||||||
|
SharePoolRepository,
|
||||||
|
CirculationPoolRepository,
|
||||||
|
PriceSnapshotRepository,
|
||||||
|
ProcessedEventRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
{
|
{
|
||||||
provide: 'REDIS_OPTIONS',
|
provide: 'REDIS_OPTIONS',
|
||||||
|
|
@ -53,6 +67,12 @@ import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
TradingAccountRepository,
|
TradingAccountRepository,
|
||||||
OrderRepository,
|
OrderRepository,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
|
TradingConfigRepository,
|
||||||
|
BlackHoleRepository,
|
||||||
|
SharePoolRepository,
|
||||||
|
CirculationPoolRepository,
|
||||||
|
PriceSnapshotRepository,
|
||||||
|
ProcessedEventRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
RedisService,
|
RedisService,
|
||||||
ClientsModule,
|
ClientsModule,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './user-registered.consumer';
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||||
|
import { RedisService } from '../../redis/redis.service';
|
||||||
|
import { TradingAccountRepository } from '../../persistence/repositories/trading-account.repository';
|
||||||
|
import { OutboxRepository } from '../../persistence/repositories/outbox.repository';
|
||||||
|
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||||||
|
import { TradingAccountAggregate } from '../../../domain/aggregates/trading-account.aggregate';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
TradingAccountCreatedPayload,
|
||||||
|
} from '../../../domain/events/trading.events';
|
||||||
|
|
||||||
|
// 用户注册事件结构(来自 auth-service)
|
||||||
|
interface UserRegisteredEvent {
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: {
|
||||||
|
accountSequence: string;
|
||||||
|
phone: string;
|
||||||
|
source: 'V1' | 'V2';
|
||||||
|
registeredAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4小时 TTL(秒)
|
||||||
|
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class UserRegisteredConsumer implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(UserRegisteredConsumer.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly tradingAccountRepository: TradingAccountRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly processedEventRepository: ProcessedEventRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('UserRegisteredConsumer initialized - listening for user.registered events');
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventPattern('auth.user.registered')
|
||||||
|
async handleUserRegistered(@Payload() message: any): Promise<void> {
|
||||||
|
// 解析消息格式
|
||||||
|
const event: UserRegisteredEvent = message.value || message;
|
||||||
|
const eventId = event.eventId || message.eventId;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
this.logger.warn('Received event without eventId, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSequence = event.payload?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
this.logger.warn(`Event ${eventId} missing accountSequence, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Processing user registered event: ${eventId}, accountSequence: ${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (await this.isEventProcessed(eventId)) {
|
||||||
|
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查账户是否已存在
|
||||||
|
const existingAccount = await this.tradingAccountRepository.findByAccountSequence(
|
||||||
|
accountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAccount) {
|
||||||
|
this.logger.debug(`Trading account ${accountSequence} already exists`);
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易账户
|
||||||
|
const account = TradingAccountAggregate.create(accountSequence);
|
||||||
|
const accountId = await this.tradingAccountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布交易账户创建事件
|
||||||
|
await this.publishAccountCreatedEvent(accountId, accountSequence);
|
||||||
|
|
||||||
|
// 标记为已处理
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Trading account created for user ${accountSequence}, source: ${event.payload.source}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是重复创建的唯一约束错误,忽略
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Trading account already exists for ${accountSequence}, marking as processed`,
|
||||||
|
);
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create trading account for ${accountSequence}`,
|
||||||
|
error instanceof Error ? error.stack : error,
|
||||||
|
);
|
||||||
|
throw error; // 让 Kafka 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等性检查 - Redis + DB 双重检查
|
||||||
|
* 1. 先检查 Redis 缓存(快速路径)
|
||||||
|
* 2. Redis 未命中则检查数据库(持久化保障)
|
||||||
|
*/
|
||||||
|
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const redisKey = `trading:processed-event:${eventId}`;
|
||||||
|
|
||||||
|
// 1. 先检查 Redis 缓存(快速路径)
|
||||||
|
const cached = await this.redis.get(redisKey);
|
||||||
|
if (cached) return true;
|
||||||
|
|
||||||
|
// 2. 检查数据库(Redis 可能过期或重启后丢失)
|
||||||
|
const dbRecord = await this.processedEventRepository.findByEventId(eventId);
|
||||||
|
if (dbRecord) {
|
||||||
|
// 回填 Redis 缓存
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记事件为已处理 - Redis + DB 双写
|
||||||
|
*/
|
||||||
|
private async markEventProcessed(eventId: string, eventType: string = 'user.registered'): Promise<void> {
|
||||||
|
const redisKey = `trading:processed-event:${eventId}`;
|
||||||
|
|
||||||
|
// 1. 写入数据库(持久化)
|
||||||
|
try {
|
||||||
|
await this.processedEventRepository.create({
|
||||||
|
eventId,
|
||||||
|
eventType,
|
||||||
|
sourceService: 'auth-service',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 可能已存在(并发情况),忽略唯一约束错误
|
||||||
|
if (!(error instanceof Error && error.message.includes('Unique constraint'))) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 写入 Redis 缓存(4小时 TTL)
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布交易账户创建事件
|
||||||
|
*/
|
||||||
|
private async publishAccountCreatedEvent(
|
||||||
|
accountId: string,
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: TradingAccountCreatedPayload = {
|
||||||
|
accountId,
|
||||||
|
accountSequence,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'TradingAccount',
|
||||||
|
aggregateId: accountId,
|
||||||
|
eventType: TradingEventTypes.TRADING_ACCOUNT_CREATED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ACCOUNTS,
|
||||||
|
key: accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published TradingAccountCreated event for ${accountSequence}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish TradingAccountCreated event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface BlackHoleEntity {
|
||||||
|
id: string;
|
||||||
|
totalBurned: Money;
|
||||||
|
targetBurn: Money;
|
||||||
|
remainingBurn: Money;
|
||||||
|
lastBurnMinute: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurnRecordEntity {
|
||||||
|
id: string;
|
||||||
|
blackHoleId: string;
|
||||||
|
burnMinute: Date;
|
||||||
|
burnAmount: Money;
|
||||||
|
remainingTarget: Money;
|
||||||
|
sourceType: string | null;
|
||||||
|
sourceAccountSeq: string | null;
|
||||||
|
sourceOrderNo: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BurnSourceType = 'MINUTE_BURN' | 'SELL_BURN';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BlackHoleRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getBlackHole(): Promise<BlackHoleEntity | null> {
|
||||||
|
const record = await this.prisma.blackHole.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeBlackHole(targetBurn: Money): Promise<BlackHoleEntity> {
|
||||||
|
const existing = await this.prisma.blackHole.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.blackHole.create({
|
||||||
|
data: {
|
||||||
|
totalBurned: 0,
|
||||||
|
targetBurn: targetBurn.value,
|
||||||
|
remainingBurn: targetBurn.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁(每分钟自动销毁)
|
||||||
|
*/
|
||||||
|
async recordMinuteBurn(burnMinute: Date, burnAmount: Money): Promise<BurnRecordEntity> {
|
||||||
|
return this.recordBurn(burnMinute, burnAmount, 'MINUTE_BURN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁(卖出销毁)
|
||||||
|
*/
|
||||||
|
async recordSellBurn(
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<BurnRecordEntity> {
|
||||||
|
return this.recordBurn(burnMinute, burnAmount, 'SELL_BURN', accountSeq, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁通用方法
|
||||||
|
*/
|
||||||
|
private async recordBurn(
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
sourceType: BurnSourceType,
|
||||||
|
sourceAccountSeq?: string,
|
||||||
|
sourceOrderNo?: string,
|
||||||
|
): Promise<BurnRecordEntity> {
|
||||||
|
const blackHole = await this.prisma.blackHole.findFirst();
|
||||||
|
if (!blackHole) {
|
||||||
|
throw new Error('Black hole not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotalBurned = new Decimal(blackHole.totalBurned.toString()).plus(burnAmount.value);
|
||||||
|
const newRemainingBurn = new Decimal(blackHole.targetBurn.toString()).minus(newTotalBurned);
|
||||||
|
|
||||||
|
const memo =
|
||||||
|
sourceType === 'MINUTE_BURN'
|
||||||
|
? `每分钟自动销毁 ${burnAmount.toFixed(8)}`
|
||||||
|
: `卖出销毁, 账户[${sourceAccountSeq}], 订单[${sourceOrderNo}], 数量${burnAmount.toFixed(8)}`;
|
||||||
|
|
||||||
|
const [, burnRecord] = await this.prisma.$transaction([
|
||||||
|
this.prisma.blackHole.update({
|
||||||
|
where: { id: blackHole.id },
|
||||||
|
data: {
|
||||||
|
totalBurned: newTotalBurned,
|
||||||
|
remainingBurn: newRemainingBurn.isNegative() ? 0 : newRemainingBurn,
|
||||||
|
lastBurnMinute: burnMinute,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.burnRecord.create({
|
||||||
|
data: {
|
||||||
|
blackHoleId: blackHole.id,
|
||||||
|
burnMinute,
|
||||||
|
burnAmount: burnAmount.value,
|
||||||
|
remainingTarget: newRemainingBurn.isNegative() ? 0 : newRemainingBurn,
|
||||||
|
sourceType,
|
||||||
|
sourceAccountSeq,
|
||||||
|
sourceOrderNo,
|
||||||
|
memo,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.toBurnRecordDomain(burnRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBurnRecords(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
sourceType?: BurnSourceType,
|
||||||
|
): Promise<{
|
||||||
|
data: BurnRecordEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const where = sourceType ? { sourceType } : {};
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.burnRecord.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { burnMinute: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.burnRecord.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toBurnRecordDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodayBurnAmount(): Promise<Money> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const result = await this.prisma.burnRecord.aggregate({
|
||||||
|
where: {
|
||||||
|
burnMinute: { gte: today },
|
||||||
|
},
|
||||||
|
_sum: { burnAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Money(result._sum.burnAmount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): BlackHoleEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalBurned: new Money(record.totalBurned),
|
||||||
|
targetBurn: new Money(record.targetBurn),
|
||||||
|
remainingBurn: new Money(record.remainingBurn),
|
||||||
|
lastBurnMinute: record.lastBurnMinute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBurnRecordDomain(record: any): BurnRecordEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
blackHoleId: record.blackHoleId,
|
||||||
|
burnMinute: record.burnMinute,
|
||||||
|
burnAmount: new Money(record.burnAmount),
|
||||||
|
remainingTarget: new Money(record.remainingTarget),
|
||||||
|
sourceType: record.sourceType,
|
||||||
|
sourceAccountSeq: record.sourceAccountSeq,
|
||||||
|
sourceOrderNo: record.sourceOrderNo,
|
||||||
|
memo: record.memo,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface CirculationPoolEntity {
|
||||||
|
id: string;
|
||||||
|
totalShares: Money;
|
||||||
|
totalCash: Money;
|
||||||
|
totalInflow: Money;
|
||||||
|
totalOutflow: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CirculationPoolTransactionType =
|
||||||
|
| 'SHARE_IN'
|
||||||
|
| 'SHARE_OUT'
|
||||||
|
| 'CASH_IN'
|
||||||
|
| 'CASH_OUT'
|
||||||
|
| 'TRADE_BUY'
|
||||||
|
| 'TRADE_SELL';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CirculationPoolRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPool(): Promise<CirculationPoolEntity | null> {
|
||||||
|
const record = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePool(): Promise<CirculationPoolEntity> {
|
||||||
|
const existing = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.circulationPool.create({
|
||||||
|
data: {
|
||||||
|
totalShares: 0,
|
||||||
|
totalCash: 0,
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卖出时积分股进入流通池
|
||||||
|
*/
|
||||||
|
async addSharesFromSell(
|
||||||
|
amount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderId: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Circulation pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.totalShares.toString());
|
||||||
|
const balanceAfter = balanceBefore.plus(amount.value);
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.circulationPool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
totalShares: balanceAfter,
|
||||||
|
totalInflow: new Decimal(pool.totalInflow.toString()).plus(amount.value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type: 'TRADE_SELL',
|
||||||
|
assetType: 'SHARE',
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
counterpartyType: 'USER',
|
||||||
|
counterpartyAccountSeq: accountSeq,
|
||||||
|
referenceId: orderId,
|
||||||
|
referenceType: 'ORDER',
|
||||||
|
memo: memo || `卖出积分股进入流通池 ${amount.toFixed(8)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 买入时积分股从流通池流出
|
||||||
|
*/
|
||||||
|
async removeSharesForBuy(
|
||||||
|
amount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderId: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Circulation pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.totalShares.toString());
|
||||||
|
const balanceAfter = balanceBefore.minus(amount.value);
|
||||||
|
|
||||||
|
if (balanceAfter.isNegative()) {
|
||||||
|
throw new Error('Insufficient shares in circulation pool');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.circulationPool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
totalShares: balanceAfter,
|
||||||
|
totalOutflow: new Decimal(pool.totalOutflow.toString()).plus(amount.value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type: 'TRADE_BUY',
|
||||||
|
assetType: 'SHARE',
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
counterpartyType: 'USER',
|
||||||
|
counterpartyAccountSeq: accountSeq,
|
||||||
|
referenceId: orderId,
|
||||||
|
referenceType: 'ORDER',
|
||||||
|
memo: memo || `买入积分股从流通池流出 ${amount.toFixed(8)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流通池积分股数量
|
||||||
|
*/
|
||||||
|
async getSharesAmount(): Promise<Money> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
if (!pool) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
return pool.totalShares;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.circulationPoolTransaction.findMany({
|
||||||
|
where: { poolId: pool.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.count({ where: { poolId: pool.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => ({
|
||||||
|
...r,
|
||||||
|
amount: r.amount.toString(),
|
||||||
|
balanceBefore: r.balanceBefore.toString(),
|
||||||
|
balanceAfter: r.balanceAfter.toString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): CirculationPoolEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalShares: new Money(record.totalShares),
|
||||||
|
totalCash: new Money(record.totalCash),
|
||||||
|
totalInflow: new Money(record.totalInflow),
|
||||||
|
totalOutflow: new Money(record.totalOutflow),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,46 @@ export class OrderRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存订单并更新销毁信息(用于卖出订单)
|
||||||
|
*/
|
||||||
|
async saveWithBurnInfo(
|
||||||
|
aggregate: OrderAggregate,
|
||||||
|
burnQuantity: Money,
|
||||||
|
effectiveQuantity: Money,
|
||||||
|
): Promise<string> {
|
||||||
|
const data = {
|
||||||
|
orderNo: aggregate.orderNo,
|
||||||
|
accountSequence: aggregate.accountSequence,
|
||||||
|
type: aggregate.type,
|
||||||
|
status: aggregate.status,
|
||||||
|
price: aggregate.price.value,
|
||||||
|
quantity: aggregate.quantity.value,
|
||||||
|
filledQuantity: aggregate.filledQuantity.value,
|
||||||
|
remainingQuantity: aggregate.remainingQuantity.value,
|
||||||
|
averagePrice: aggregate.averagePrice.value,
|
||||||
|
totalAmount: aggregate.totalAmount.value,
|
||||||
|
burnQuantity: burnQuantity.value,
|
||||||
|
burnMultiplier: burnQuantity.isZero()
|
||||||
|
? 0
|
||||||
|
: burnQuantity.value.dividedBy(aggregate.filledQuantity.value),
|
||||||
|
effectiveQuantity: effectiveQuantity.value,
|
||||||
|
cancelledAt: aggregate.cancelledAt,
|
||||||
|
completedAt: aggregate.completedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aggregate.id) {
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: aggregate.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return aggregate.id;
|
||||||
|
} else {
|
||||||
|
const created = await this.prisma.order.create({ data });
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findActiveOrders(type?: OrderType): Promise<OrderAggregate[]> {
|
async findActiveOrders(type?: OrderType): Promise<OrderAggregate[]> {
|
||||||
const where: any = {
|
const where: any = {
|
||||||
status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] },
|
status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
|
||||||
|
export interface PriceSnapshotEntity {
|
||||||
|
id: string;
|
||||||
|
snapshotTime: Date;
|
||||||
|
price: Money;
|
||||||
|
greenPoints: Money;
|
||||||
|
blackHoleAmount: Money;
|
||||||
|
circulationPool: Money;
|
||||||
|
effectiveDenominator: Money;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PriceSnapshotRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getLatestSnapshot(): Promise<PriceSnapshotEntity | null> {
|
||||||
|
const record = await this.prisma.priceSnapshot.findFirst({
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshotAt(time: Date): Promise<PriceSnapshotEntity | null> {
|
||||||
|
// 获取指定时间之前最近的快照
|
||||||
|
const record = await this.prisma.priceSnapshot.findFirst({
|
||||||
|
where: { snapshotTime: { lte: time } },
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSnapshot(data: {
|
||||||
|
snapshotTime: Date;
|
||||||
|
price: Money;
|
||||||
|
greenPoints: Money;
|
||||||
|
blackHoleAmount: Money;
|
||||||
|
circulationPool: Money;
|
||||||
|
effectiveDenominator: Money;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
}): Promise<PriceSnapshotEntity> {
|
||||||
|
const record = await this.prisma.priceSnapshot.create({
|
||||||
|
data: {
|
||||||
|
snapshotTime: data.snapshotTime,
|
||||||
|
price: data.price.value,
|
||||||
|
greenPoints: data.greenPoints.value,
|
||||||
|
blackHoleAmount: data.blackHoleAmount.value,
|
||||||
|
circulationPool: data.circulationPool.value,
|
||||||
|
effectiveDenominator: data.effectiveDenominator.value,
|
||||||
|
minuteBurnRate: data.minuteBurnRate.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPriceHistory(
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
limit: number = 1440,
|
||||||
|
): Promise<PriceSnapshotEntity[]> {
|
||||||
|
const records = await this.prisma.priceSnapshot.findMany({
|
||||||
|
where: {
|
||||||
|
snapshotTime: {
|
||||||
|
gte: startTime,
|
||||||
|
lte: endTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { snapshotTime: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshots(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: PriceSnapshotEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.priceSnapshot.findMany({
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.priceSnapshot.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理旧的价格快照(保留指定天数)
|
||||||
|
*/
|
||||||
|
async cleanupOldSnapshots(retentionDays: number): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||||
|
|
||||||
|
const result = await this.prisma.priceSnapshot.deleteMany({
|
||||||
|
where: { snapshotTime: { lt: cutoffDate } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): PriceSnapshotEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
snapshotTime: record.snapshotTime,
|
||||||
|
price: new Money(record.price),
|
||||||
|
greenPoints: new Money(record.greenPoints),
|
||||||
|
blackHoleAmount: new Money(record.blackHoleAmount),
|
||||||
|
circulationPool: new Money(record.circulationPool),
|
||||||
|
effectiveDenominator: new Money(record.effectiveDenominator),
|
||||||
|
minuteBurnRate: new Money(record.minuteBurnRate),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface ProcessedEventEntity {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
sourceService: string;
|
||||||
|
processedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProcessedEventRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找已处理事件
|
||||||
|
*/
|
||||||
|
async findByEventId(eventId: string): Promise<ProcessedEventEntity | null> {
|
||||||
|
const record = await this.prisma.processedEvent.findUnique({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建已处理事件记录
|
||||||
|
*/
|
||||||
|
async create(data: {
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
sourceService: string;
|
||||||
|
}): Promise<ProcessedEventEntity> {
|
||||||
|
return this.prisma.processedEvent.create({
|
||||||
|
data: {
|
||||||
|
eventId: data.eventId,
|
||||||
|
eventType: data.eventType,
|
||||||
|
sourceService: data.sourceService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查事件是否已处理
|
||||||
|
*/
|
||||||
|
async isProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.processedEvent.count({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除旧的已处理记录(清理)
|
||||||
|
* @param before 删除此时间之前的记录
|
||||||
|
*/
|
||||||
|
async deleteOldRecords(before: Date): Promise<number> {
|
||||||
|
const result = await this.prisma.processedEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
processedAt: { lt: before },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface SharePoolEntity {
|
||||||
|
id: string;
|
||||||
|
greenPoints: Money;
|
||||||
|
totalInflow: Money;
|
||||||
|
totalOutflow: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SharePoolTransactionType = 'INJECT' | 'TRADE_IN' | 'TRADE_OUT';
|
||||||
|
|
||||||
|
export interface SharePoolTransactionEntity {
|
||||||
|
id: string;
|
||||||
|
poolId: string;
|
||||||
|
type: SharePoolTransactionType;
|
||||||
|
amount: Money;
|
||||||
|
balanceBefore: Money;
|
||||||
|
balanceAfter: Money;
|
||||||
|
referenceId: string | null;
|
||||||
|
referenceType: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SharePoolRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPool(): Promise<SharePoolEntity | null> {
|
||||||
|
const record = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePool(initialGreenPoints: Money = Money.zero()): Promise<SharePoolEntity> {
|
||||||
|
const existing = await this.prisma.sharePool.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.sharePool.create({
|
||||||
|
data: {
|
||||||
|
greenPoints: initialGreenPoints.value,
|
||||||
|
totalInflow: initialGreenPoints.value,
|
||||||
|
totalOutflow: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入绿积分
|
||||||
|
*/
|
||||||
|
async inject(amount: Money, referenceId?: string, memo?: string): Promise<void> {
|
||||||
|
await this.updateBalance('INJECT', amount, true, referenceId, memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易流入(买入时绿积分进入股池)
|
||||||
|
*/
|
||||||
|
async tradeIn(amount: Money, tradeId: string): Promise<void> {
|
||||||
|
await this.updateBalance('TRADE_IN', amount, true, tradeId, `交易买入流入 ${amount.toFixed(8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易流出(卖出时绿积分从股池流出)
|
||||||
|
*/
|
||||||
|
async tradeOut(amount: Money, tradeId: string): Promise<void> {
|
||||||
|
await this.updateBalance(
|
||||||
|
'TRADE_OUT',
|
||||||
|
amount,
|
||||||
|
false,
|
||||||
|
tradeId,
|
||||||
|
`交易卖出流出 ${amount.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateBalance(
|
||||||
|
type: SharePoolTransactionType,
|
||||||
|
amount: Money,
|
||||||
|
isInflow: boolean,
|
||||||
|
referenceId?: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Share pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.greenPoints.toString());
|
||||||
|
const balanceAfter = isInflow
|
||||||
|
? balanceBefore.plus(amount.value)
|
||||||
|
: balanceBefore.minus(amount.value);
|
||||||
|
|
||||||
|
if (balanceAfter.isNegative()) {
|
||||||
|
throw new Error('Insufficient green points in share pool');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotalInflow = isInflow
|
||||||
|
? new Decimal(pool.totalInflow.toString()).plus(amount.value)
|
||||||
|
: pool.totalInflow;
|
||||||
|
const newTotalOutflow = !isInflow
|
||||||
|
? new Decimal(pool.totalOutflow.toString()).plus(amount.value)
|
||||||
|
: pool.totalOutflow;
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.sharePool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
greenPoints: balanceAfter,
|
||||||
|
totalInflow: newTotalInflow,
|
||||||
|
totalOutflow: newTotalOutflow,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.sharePoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type,
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
referenceId,
|
||||||
|
referenceType: type === 'INJECT' ? 'INJECT' : 'TRADE',
|
||||||
|
memo,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: SharePoolTransactionEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const pool = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.sharePoolTransaction.findMany({
|
||||||
|
where: { poolId: pool.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.sharePoolTransaction.count({ where: { poolId: pool.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toTransactionDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): SharePoolEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
greenPoints: new Money(record.greenPoints),
|
||||||
|
totalInflow: new Money(record.totalInflow),
|
||||||
|
totalOutflow: new Money(record.totalOutflow),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTransactionDomain(record: any): SharePoolTransactionEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
poolId: record.poolId,
|
||||||
|
type: record.type as SharePoolTransactionType,
|
||||||
|
amount: new Money(record.amount),
|
||||||
|
balanceBefore: new Money(record.balanceBefore),
|
||||||
|
balanceAfter: new Money(record.balanceAfter),
|
||||||
|
referenceId: record.referenceId,
|
||||||
|
referenceType: record.referenceType,
|
||||||
|
memo: record.memo,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,11 +15,11 @@ export class TradingAccountRepository {
|
||||||
return this.toDomain(record);
|
return this.toDomain(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(aggregate: TradingAccountAggregate): Promise<void> {
|
async save(aggregate: TradingAccountAggregate): Promise<string> {
|
||||||
const transactions = aggregate.pendingTransactions;
|
const transactions = aggregate.pendingTransactions;
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.tradingAccount.upsert({
|
const account = await tx.tradingAccount.upsert({
|
||||||
where: { accountSequence: aggregate.accountSequence },
|
where: { accountSequence: aggregate.accountSequence },
|
||||||
create: {
|
create: {
|
||||||
accountSequence: aggregate.accountSequence,
|
accountSequence: aggregate.accountSequence,
|
||||||
|
|
@ -55,9 +55,12 @@ export class TradingAccountRepository {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
aggregate.clearPendingTransactions();
|
aggregate.clearPendingTransactions();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransactions(
|
async getTransactions(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface TradingConfigEntity {
|
||||||
|
id: string;
|
||||||
|
totalShares: Money;
|
||||||
|
burnTarget: Money;
|
||||||
|
burnPeriodMinutes: number;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TradingConfigRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<TradingConfigEntity | null> {
|
||||||
|
const record = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeConfig(): Promise<TradingConfigEntity> {
|
||||||
|
const existing = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.tradingConfig.create({
|
||||||
|
data: {
|
||||||
|
totalShares: new Decimal('100020000000'),
|
||||||
|
burnTarget: new Decimal('10000000000'),
|
||||||
|
burnPeriodMinutes: 2102400, // 365 * 4 * 1440
|
||||||
|
minuteBurnRate: new Decimal('4756.468797564687'),
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Trading config not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMinuteBurnRate(newRate: Money): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Trading config not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: { minuteBurnRate: newRate.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): TradingConfigEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalShares: new Money(record.totalShares),
|
||||||
|
burnTarget: new Money(record.burnTarget),
|
||||||
|
burnPeriodMinutes: record.burnPeriodMinutes,
|
||||||
|
minuteBurnRate: new Money(record.minuteBurnRate),
|
||||||
|
isActive: record.isActive,
|
||||||
|
activatedAt: record.activatedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,15 @@ const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
// NEXT_PUBLIC_API_URL 应该是后端服务的基础 URL,如 http://mining-admin-service:3023
|
||||||
|
// 前端请求 /api/xxx 会被转发到 {API_URL}/api/v2/xxx
|
||||||
|
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023';
|
||||||
|
// 移除末尾可能存在的 /api/v2 避免重复
|
||||||
|
const cleanUrl = apiBaseUrl.replace(/\/api\/v2\/?$/, '');
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023'}/api/v2/:path*`,
|
destination: `${cleanUrl}/api/v2/:path*`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,20 +36,22 @@ const actionLabels: Record<string, { label: string; className: string }> = {
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [action, setAction] = useState<string>('');
|
const [action, setAction] = useState<string>('all');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['audit-logs', page, action, keyword],
|
queryKey: ['audit-logs', page, action, keyword],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get('/audit', {
|
const response = await apiClient.get('/audit', {
|
||||||
params: { page, pageSize, action: action || undefined, keyword: keyword || undefined },
|
params: { page, pageSize, action: action === 'all' ? undefined : action, keyword: keyword || undefined },
|
||||||
});
|
});
|
||||||
return response.data.data as PaginatedResponse<AuditLog>;
|
return response.data.data as PaginatedResponse<AuditLog>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="审计日志" description="查看系统操作日志" />
|
<PageHeader title="审计日志" description="查看系统操作日志" />
|
||||||
|
|
@ -71,7 +73,7 @@ export default function AuditLogsPage() {
|
||||||
<SelectValue placeholder="操作类型" />
|
<SelectValue placeholder="操作类型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">全部</SelectItem>
|
<SelectItem value="all">全部</SelectItem>
|
||||||
<SelectItem value="CREATE">创建</SelectItem>
|
<SelectItem value="CREATE">创建</SelectItem>
|
||||||
<SelectItem value="UPDATE">更新</SelectItem>
|
<SelectItem value="UPDATE">更新</SelectItem>
|
||||||
<SelectItem value="DELETE">删除</SelectItem>
|
<SelectItem value="DELETE">删除</SelectItem>
|
||||||
|
|
@ -108,14 +110,14 @@ export default function AuditLogsPage() {
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : data?.items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
暂无日志
|
暂无日志
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
data?.items.map((log) => {
|
items.map((log) => {
|
||||||
const actionInfo = actionLabels[log.action] || { label: log.action, className: '' };
|
const actionInfo = actionLabels[log.action] || { label: log.action, className: '' };
|
||||||
return (
|
return (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled } from '@/features/configs/hooks/use-configs';
|
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining } from '@/features/configs/hooks/use-configs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -11,7 +11,8 @@ import { Switch } from '@/components/ui/switch';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Pencil, Save, X } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Pencil, Save, X, Play, Pause, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
import type { SystemConfig } from '@/types/config';
|
import type { SystemConfig } from '@/types/config';
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
|
|
@ -24,8 +25,11 @@ const categoryLabels: Record<string, string> = {
|
||||||
export default function ConfigsPage() {
|
export default function ConfigsPage() {
|
||||||
const { data: configs, isLoading } = useConfigs();
|
const { data: configs, isLoading } = useConfigs();
|
||||||
const { data: transferEnabled, isLoading: transferLoading } = useTransferEnabled();
|
const { data: transferEnabled, isLoading: transferLoading } = useTransferEnabled();
|
||||||
|
const { data: miningStatus, isLoading: miningLoading } = useMiningStatus();
|
||||||
const updateConfig = useUpdateConfig();
|
const updateConfig = useUpdateConfig();
|
||||||
const setTransferEnabled = useSetTransferEnabled();
|
const setTransferEnabled = useSetTransferEnabled();
|
||||||
|
const activateMining = useActivateMining();
|
||||||
|
const deactivateMining = useDeactivateMining();
|
||||||
|
|
||||||
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
|
|
@ -58,10 +62,123 @@ export default function ConfigsPage() {
|
||||||
{} as Record<string, SystemConfig[]>
|
{} as Record<string, SystemConfig[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatNumber = (value: string) => {
|
||||||
|
return parseFloat(value).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="配置管理" description="管理系统配置参数" />
|
<PageHeader title="配置管理" description="管理系统配置参数" />
|
||||||
|
|
||||||
|
{/* 挖矿状态卡片 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">挖矿系统状态</CardTitle>
|
||||||
|
<CardDescription>控制挖矿分配系统的运行状态</CardDescription>
|
||||||
|
</div>
|
||||||
|
{miningLoading ? (
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
) : miningStatus?.error ? (
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
连接失败
|
||||||
|
</Badge>
|
||||||
|
) : miningStatus?.isActive ? (
|
||||||
|
<Badge variant="default" className="flex items-center gap-1 bg-green-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
运行中
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
|
<Pause className="h-3 w-3" />
|
||||||
|
已停用
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{miningLoading ? (
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
) : miningStatus?.error ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
|
||||||
|
<p>无法连接到挖矿服务</p>
|
||||||
|
<p className="text-sm">{miningStatus.error}</p>
|
||||||
|
</div>
|
||||||
|
) : !miningStatus?.initialized ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||||
|
<p>挖矿系统未初始化</p>
|
||||||
|
<p className="text-sm">请运行 seed 脚本初始化挖矿配置</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">当前时代</p>
|
||||||
|
<p className="text-lg font-semibold">第 {miningStatus.currentEra} 时代</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">剩余分配量</p>
|
||||||
|
<p className="text-lg font-semibold">{formatNumber(miningStatus.remainingDistribution)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">每秒分配</p>
|
||||||
|
<p className="text-lg font-semibold">{formatNumber(miningStatus.secondDistribution)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">挖矿账户数</p>
|
||||||
|
<p className="text-lg font-semibold">{miningStatus.accountCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{miningStatus.blackHole && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-sm font-medium mb-2">黑洞燃烧进度</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">已燃烧</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.totalBurned)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">目标</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.targetBurn)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">剩余</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.remainingBurn)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
{miningStatus.isActive ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deactivateMining.mutate()}
|
||||||
|
disabled={deactivateMining.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4 mr-2" />
|
||||||
|
{deactivateMining.isPending ? '停用中...' : '停用挖矿'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => activateMining.mutate()}
|
||||||
|
disabled={activateMining.isPending}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
{activateMining.isPending ? '激活中...' : '激活挖矿'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">划转开关</CardTitle>
|
<CardTitle className="text-lg">划转开关</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { StatsCards } from '@/features/dashboard/components/stats-cards';
|
import { StatsCards } from '@/features/dashboard/components/stats-cards';
|
||||||
import { RealtimePanel } from '@/features/dashboard/components/realtime-panel';
|
import { RealtimePanel } from '@/features/dashboard/components/realtime-panel';
|
||||||
import { PriceOverview } from '@/features/dashboard/components/price-overview';
|
import { PriceOverview } from '@/features/dashboard/components/price-overview';
|
||||||
|
import { ContributionBreakdown } from '@/features/dashboard/components/contribution-breakdown';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -12,6 +13,9 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
<StatsCards />
|
<StatsCards />
|
||||||
|
|
||||||
|
{/* 详细算力分解 */}
|
||||||
|
<ContributionBreakdown />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<PriceOverview />
|
<PriceOverview />
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue