feat(mining-ecosystem): 添加挖矿生态系统完整微服务与前端

## 概述
为榴莲生态2.0添加完整的挖矿系统,包含3个后端微服务、1个管理后台和1个用户端App。

---

## 后端微服务

### 1. mining-service (挖矿服务) - Port 3021
**核心功能:**
- 积分股每日分配(基于算力快照)
- 每分钟定时销毁(进入黑洞)
- 价格计算:价格 = 积分股池 ÷ (100.02亿 - 黑洞 - 流通池)
- 全局状态管理(黑洞量、流通池、价格)

**关键文件:**
- src/application/services/mining-distribution.service.ts - 挖矿分配核心逻辑
- src/application/schedulers/mining.scheduler.ts - 定时任务调度
- src/domain/services/mining-calculator.service.ts - 分配计算
- src/infrastructure/persistence/repositories/black-hole.repository.ts - 黑洞管理

### 2. trading-service (交易服务) - Port 3022
**核心功能:**
- 积分股买卖撮合
- K线数据生成
- 手续费处理(10%买入/卖出)
- 流通池管理
- 卖出倍数计算:倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)

**关键文件:**
- src/domain/services/matching-engine.service.ts - 撮合引擎
- src/application/services/order.service.ts - 订单处理
- src/application/services/transfer.service.ts - 划转服务
- src/domain/aggregates/order.aggregate.ts - 订单聚合根

### 3. mining-admin-service (挖矿管理服务) - Port 3023
**核心功能:**
- 系统配置管理(分配参数、手续费率等)
- 老用户数据初始化
- 系统监控仪表盘
- 审计日志

**关键文件:**
- src/application/services/config.service.ts - 配置管理
- src/application/services/initialization.service.ts - 数据初始化
- src/application/services/dashboard.service.ts - 仪表盘数据

---

## 前端应用

### 1. mining-admin-web (管理后台) - Next.js 14
**技术栈:**
- Next.js 14 + React 18
- TailwindCSS + Radix UI
- React Query + Zustand
- ECharts 图表

**功能模块:**
- 登录认证
- 仪表盘(实时数据、价格走势)
- 用户查询(算力详情、挖矿记录、交易订单)
- 系统配置管理
- 数据初始化任务
- 审计日志查看

### 2. mining-app (用户端App) - Flutter 3.x
**技术栈:**
- Flutter 3.x + Dart
- Riverpod 状态管理
- GoRouter 路由
- Clean Architecture (3层)

**功能模块:**
- 首页资产总览
- 实时收益显示(每秒更新)
- 贡献值展示(个人/团队)
- 积分股买卖交易
- K线图与价格显示
- 个人中心

---

## 架构文档
- docs/mining-ecosystem-architecture.md - 系统架构总览
  - 服务职责与端口分配
  - 数据流向图
  - Kafka Topics 定义
  - 跨服务关联(account_sequence)
  - 配置参数说明
  - 开发顺序建议

---

## .gitignore 更新
- 添加 Flutter/Dart 构建文件忽略
- 添加 iOS/Android 构建产物忽略
- 添加 Next.js 构建目录忽略
- 添加 TypeScript 缓存文件忽略

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-10 17:45:46 -08:00
parent eaead7d4f3
commit a17f408653
221 changed files with 52451 additions and 1 deletions

95
.gitignore vendored
View File

@ -5,34 +5,127 @@ nul
# Dependencies
node_modules/
.pnp/
.pnp.js
# Build outputs
dist/
build/
out/
.next/
.nuxt/
.output/
# Environment files
.env
.env.local
.env.*.local
*.env
# IDE
.idea/
.vscode/
*.swp
*.swo
*.sublime-*
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Test coverage
coverage/
.nyc_output/
# Package lock (optional - keep package-lock.json if needed)
# Cache
.cache/
*.cache
.eslintcache
.stylelintcache
.turbo/
# Prisma
prisma/migrations/**/migration_lock.toml
# TypeScript
*.tsbuildinfo
# Flutter/Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
*.iml
*.ipr
*.iws
.idea/
*.lock
pubspec.lock
# iOS
ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework
ios/Flutter/Flutter.podspec
ios/Flutter/App.framework
ios/Flutter/engine/
ios/Flutter/Generated.xcconfig
**/ios/Flutter/.last_build_id
**/ios/Podfile.lock
# Android
android/.gradle/
android/captures/
android/gradlew
android/gradlew.bat
android/local.properties
**/android/app/debug
**/android/app/profile
**/android/app/release
*.apk
*.aab
*.dex
*.class
*.jks
*.keystore
# macOS
macos/Flutter/GeneratedPluginRegistrant.swift
macos/Flutter/ephemeral/
# Windows
windows/flutter/generated_plugin_registrant.cc
windows/flutter/generated_plugin_registrant.h
windows/flutter/generated_plugins.cmake
# Linux
linux/flutter/generated_plugin_registrant.cc
linux/flutter/generated_plugin_registrant.h
linux/flutter/generated_plugins.cmake
# Web
web/favicon.png
web/icons/
# Temporary files
*.tmp
*.temp
*.swp
*~
# Package lock files (keep for reproducible builds)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml

View File

@ -0,0 +1,26 @@
# Application
NODE_ENV=development
PORT=3023
SERVICE_NAME=mining-admin-service
# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/mining_admin_db?schema=public
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=3
# JWT
JWT_SECRET=your-admin-jwt-secret-key
JWT_EXPIRES_IN=24h
# Services
CONTRIBUTION_SERVICE_URL=http://localhost:3020
MINING_SERVICE_URL=http://localhost:3021
TRADING_SERVICE_URL=http://localhost:3022
# Default Admin
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123

View File

@ -0,0 +1,894 @@
# Mining Admin Service (挖矿管理服务) 开发指导
## 1. 服务概述
### 1.1 核心职责
Mining Admin Service 是挖矿系统的管理中枢,负责配置管理、系统监控、数据报表等后台管理功能。
**主要功能:**
- 系统参数配置(贡献值递增比例、分配比例等)
- 系统开关控制(转账开关等)
- 系统账户管理(运营/省/市/总部账户)
- 全局状态监控与报表
- 用户算力/积分股查询
- 历史数据初始化(老用户算力计算)
- 操作日志与审计
### 1.2 技术栈
- **框架**: NestJS + TypeScript
- **数据库**: PostgreSQL (CRUD为主)
- **ORM**: Prisma
- **消息队列**: Kafka (接收其他服务事件)
- **缓存**: Redis
### 1.3 端口分配
- HTTP: 3023
- 数据库: rwa_mining_admin
---
## 2. 架构设计
### 2.1 目录结构
```
mining-admin-service/
├── src/
│ ├── api/
│ │ ├── controllers/
│ │ │ ├── config.controller.ts # 配置管理API
│ │ │ ├── system-account.controller.ts # 系统账户API
│ │ │ ├── user-query.controller.ts # 用户查询API
│ │ │ ├── dashboard.controller.ts # 仪表盘API
│ │ │ ├── report.controller.ts # 报表API
│ │ │ ├── initialization.controller.ts # 初始化API
│ │ │ ├── audit-log.controller.ts # 审计日志API
│ │ │ └── health.controller.ts
│ │ └── dto/
│ │ ├── request/
│ │ │ ├── update-config.request.ts
│ │ │ ├── query-user.request.ts
│ │ │ └── initialize-users.request.ts
│ │ └── response/
│ │ ├── config.response.ts
│ │ ├── dashboard.response.ts
│ │ ├── user-detail.response.ts
│ │ └── report.response.ts
│ │
│ ├── application/
│ │ ├── commands/
│ │ │ ├── update-contribution-config.command.ts
│ │ │ ├── update-distribution-config.command.ts
│ │ │ ├── toggle-transfer.command.ts
│ │ │ ├── initialize-legacy-users.command.ts
│ │ │ ├── create-system-account.command.ts
│ │ │ └── manual-adjustment.command.ts
│ │ ├── queries/
│ │ │ ├── get-dashboard-stats.query.ts
│ │ │ ├── get-user-contribution-detail.query.ts
│ │ │ ├── get-user-mining-detail.query.ts
│ │ │ ├── get-global-state.query.ts
│ │ │ ├── get-system-accounts.query.ts
│ │ │ ├── get-audit-logs.query.ts
│ │ │ └── generate-report.query.ts
│ │ ├── services/
│ │ │ ├── config-management.service.ts
│ │ │ ├── initialization.service.ts
│ │ │ ├── report-generator.service.ts
│ │ │ └── audit.service.ts
│ │ └── event-handlers/
│ │ ├── contribution-calculated.handler.ts
│ │ ├── shares-distributed.handler.ts
│ │ ├── trade-completed.handler.ts
│ │ └── price-updated.handler.ts
│ │
│ ├── domain/
│ │ ├── aggregates/
│ │ │ ├── system-config.aggregate.ts
│ │ │ ├── system-account.aggregate.ts
│ │ │ └── audit-log.aggregate.ts
│ │ ├── repositories/
│ │ │ ├── system-config.repository.interface.ts
│ │ │ ├── system-account.repository.interface.ts
│ │ │ ├── audit-log.repository.interface.ts
│ │ │ └── synced-data.repository.interface.ts
│ │ ├── value-objects/
│ │ │ ├── config-key.vo.ts
│ │ │ └── audit-action.vo.ts
│ │ └── events/
│ │ ├── config-updated.event.ts
│ │ └── initialization-completed.event.ts
│ │
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ ├── repositories/
│ │ │ │ ├── system-config.repository.impl.ts
│ │ │ │ ├── system-account.repository.impl.ts
│ │ │ │ ├── audit-log.repository.impl.ts
│ │ │ │ └── synced-data.repository.impl.ts
│ │ │ └── unit-of-work/
│ │ │ └── unit-of-work.service.ts
│ │ ├── kafka/
│ │ │ ├── contribution-event-consumer.service.ts
│ │ │ ├── mining-event-consumer.service.ts
│ │ │ ├── trading-event-consumer.service.ts
│ │ │ ├── event-publisher.service.ts
│ │ │ └── kafka.module.ts
│ │ ├── redis/
│ │ │ ├── config-cache.service.ts
│ │ │ └── stats-cache.service.ts
│ │ ├── external/
│ │ │ ├── contribution-client.service.ts
│ │ │ ├── mining-client.service.ts
│ │ │ └── trading-client.service.ts
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/
│ │ ├── guards/
│ │ │ └── admin-auth.guard.ts
│ │ └── decorators/
│ │ └── audit-log.decorator.ts
│ ├── config/
│ ├── app.module.ts
│ └── main.ts
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── package.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml
```
---
## 3. 数据库设计
### 3.1 数据库类型
Mining Admin Service 以**查询和配置管理**为主,使用 PostgreSQL 作为主数据库,大部分表为 CRUD 操作。
### 3.2 核心表结构
```sql
-- ============================================
-- 系统配置表
-- ============================================
-- 通用配置表Key-Value
CREATE TABLE system_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value TEXT NOT NULL,
config_type VARCHAR(20) NOT NULL, -- STRING / NUMBER / BOOLEAN / JSON
description VARCHAR(200),
is_active BOOLEAN DEFAULT TRUE,
version INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_by VARCHAR(50) -- 操作人
);
-- 初始化配置数据
INSERT INTO system_configs (config_key, config_value, config_type, description) VALUES
('contribution.base_value', '22617', 'NUMBER', '基础贡献值'),
('contribution.increment_percentage', '0.003', 'NUMBER', '递增百分比 0.3%'),
('contribution.unit_size', '100', 'NUMBER', '单位大小(100棵)'),
('contribution.start_tree_number', '1000', 'NUMBER', '起始树编号'),
('distribution.personal_rate', '0.70', 'NUMBER', '个人分配比例 70%'),
('distribution.operation_rate', '0.12', 'NUMBER', '运营账户比例 12%'),
('distribution.province_rate', '0.01', 'NUMBER', '省公司比例 1%'),
('distribution.city_rate', '0.02', 'NUMBER', '市公司比例 2%'),
('distribution.team_rate', '0.15', 'NUMBER', '团队比例 15%'),
('distribution.level_rate_per', '0.005', 'NUMBER', '每级比例 0.5%'),
('distribution.bonus_rate_per', '0.025', 'NUMBER', '每档奖励比例 2.5%'),
('mining.transfer_enabled', 'false', 'BOOLEAN', '转账功能开关'),
('trading.buy_fee_rate', '0.10', 'NUMBER', '买入手续费率 10%'),
('trading.sell_fee_rate', '0.10', 'NUMBER', '卖出手续费率 10%');
-- 贡献值递增配置表(历史记录)
CREATE TABLE contribution_increment_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
base_contribution DECIMAL(20,10) NOT NULL,
increment_percentage DECIMAL(10,6) NOT NULL,
unit_size INT NOT NULL,
start_tree_number INT NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(50)
);
-- 分配比例配置表(历史记录)
CREATE TABLE distribution_rate_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rate_type VARCHAR(30) NOT NULL, -- PERSONAL/OPERATION/PROVINCE/CITY/TEAM_LEVEL/TEAM_BONUS
rate_value DECIMAL(10,6) NOT NULL,
level_depth INT, -- TEAM_LEVEL时使用
bonus_tier INT, -- TEAM_BONUS时使用
effective_from DATE NOT NULL,
effective_to DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(50)
);
-- ============================================
-- 系统账户表
-- ============================================
CREATE TABLE system_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_type VARCHAR(20) NOT NULL, -- OPERATION / PROVINCE / CITY / HEADQUARTERS
account_name VARCHAR(100) NOT NULL,
account_code VARCHAR(50) UNIQUE, -- 账户编码
-- 关联信息(省/市公司时使用)
region_code VARCHAR(20),
region_name VARCHAR(100),
-- 余额(从其他服务同步)
contribution_balance DECIMAL(30,10) DEFAULT 0,
share_balance DECIMAL(30,10) DEFAULT 0,
green_points_balance DECIMAL(30,10) DEFAULT 0,
-- 特殊标记
contribution_never_expires BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 初始化数据
INSERT INTO system_accounts (account_type, account_name, account_code, contribution_never_expires) VALUES
('OPERATION', '运营账户', 'SYS_OPERATION', TRUE),
('HEADQUARTERS', '总部账户', 'SYS_HEADQUARTERS', TRUE),
('PROVINCE', '省公司账户', 'SYS_PROVINCE', FALSE),
('CITY', '市公司账户', 'SYS_CITY', FALSE);
-- ============================================
-- 仪表盘统计快照表
-- ============================================
CREATE TABLE dashboard_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_time TIMESTAMP WITH TIME ZONE NOT NULL,
-- 全局状态
total_users INT DEFAULT 0,
total_adopted_users INT DEFAULT 0,
total_trees INT DEFAULT 0,
-- 算力统计
total_contribution DECIMAL(30,10) DEFAULT 0,
effective_contribution DECIMAL(30,10) DEFAULT 0,
-- 积分股统计
total_supply DECIMAL(30,10),
black_hole_amount DECIMAL(30,10),
circulation_pool DECIMAL(30,10),
current_price DECIMAL(30,18),
-- 交易统计
total_trade_volume DECIMAL(30,10) DEFAULT 0,
today_trade_volume DECIMAL(30,10) DEFAULT 0,
total_trade_count INT DEFAULT 0,
today_trade_count INT DEFAULT 0,
-- 分配统计
total_distributed DECIMAL(30,10) DEFAULT 0,
today_distributed DECIMAL(30,10) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_dashboard_snapshots_time ON dashboard_snapshots(snapshot_time);
-- ============================================
-- 同步数据表(从其他服务汇总)
-- ============================================
-- 用户汇总视图表(定期同步)
CREATE TABLE synced_user_summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_sequence VARCHAR(20) NOT NULL UNIQUE,
-- 基本信息
phone VARCHAR(20),
status VARCHAR(20),
-- 认种信息
total_trees INT DEFAULT 0,
first_adoption_date DATE,
last_adoption_date DATE,
-- 推荐信息
referrer_account_sequence VARCHAR(20),
direct_referral_count INT DEFAULT 0,
direct_referral_adopted_count INT DEFAULT 0,
total_team_size INT DEFAULT 0,
-- 算力信息
personal_contribution DECIMAL(30,10) DEFAULT 0,
team_level_contribution DECIMAL(30,10) DEFAULT 0,
team_bonus_contribution DECIMAL(30,10) DEFAULT 0,
total_contribution DECIMAL(30,10) DEFAULT 0,
effective_contribution DECIMAL(30,10) DEFAULT 0,
-- 积分股信息
share_balance DECIMAL(30,10) DEFAULT 0,
total_mined DECIMAL(30,10) DEFAULT 0,
total_sold DECIMAL(30,10) DEFAULT 0,
total_bought DECIMAL(30,10) DEFAULT 0,
-- 同步状态
last_synced_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_synced_user_summaries_phone ON synced_user_summaries(phone);
-- ============================================
-- 审计日志表
-- ============================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 操作信息
action_type VARCHAR(50) NOT NULL, -- CONFIG_UPDATE / ACCOUNT_CREATE / MANUAL_ADJUST / INIT_USERS / TOGGLE_SWITCH
action_target VARCHAR(100), -- 操作目标如配置key、账户ID等
action_detail JSONB, -- 操作详情
-- 操作前后值
before_value JSONB,
after_value JSONB,
-- 操作人
operator_id VARCHAR(50) NOT NULL,
operator_name VARCHAR(100),
operator_ip VARCHAR(50),
-- 操作结果
result VARCHAR(20) NOT NULL, -- SUCCESS / FAILED
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_action ON audit_logs(action_type);
CREATE INDEX idx_audit_logs_time ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_operator ON audit_logs(operator_id);
-- ============================================
-- 初始化任务表
-- ============================================
CREATE TABLE initialization_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_type VARCHAR(50) NOT NULL, -- LEGACY_USER_CONTRIBUTION / GLOBAL_STATE / SYSTEM_ACCOUNTS
task_status VARCHAR(20) NOT NULL, -- PENDING / RUNNING / COMPLETED / FAILED
-- 进度
total_count INT DEFAULT 0,
processed_count INT DEFAULT 0,
failed_count INT DEFAULT 0,
-- 参数
task_params JSONB,
-- 结果
result_summary JSONB,
error_message TEXT,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(50)
);
-- ============================================
-- 已处理事件(幂等性)
-- ============================================
CREATE TABLE processed_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(100) NOT NULL UNIQUE,
event_type VARCHAR(50) NOT NULL,
source_service VARCHAR(50),
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
---
## 4. 核心业务逻辑
### 4.1 老用户算力初始化
```typescript
/**
* 初始化老用户算力
* 这是系统上线时的一次性任务
*/
async initializeLegacyUserContributions(): Promise<InitializationResult> {
// 创建初始化任务
const task = await this.initTaskRepo.create({
taskType: 'LEGACY_USER_CONTRIBUTION',
taskStatus: 'RUNNING',
startedAt: new Date(),
});
try {
// 1. 获取所有已认种用户
const adoptedUsers = await this.userRepo.findAllAdoptedUsers();
await this.initTaskRepo.updateProgress(task.id, {
totalCount: adoptedUsers.length,
});
let processedCount = 0;
let failedCount = 0;
// 2. 批量处理用户
for (const user of adoptedUsers) {
try {
// 调用 contribution-service 计算算力
await this.contributionClient.calculateUserContribution(user.accountSequence);
processedCount++;
} catch (error) {
this.logger.error(`Failed to initialize user ${user.accountSequence}`, error);
failedCount++;
}
// 更新进度
if ((processedCount + failedCount) % 100 === 0) {
await this.initTaskRepo.updateProgress(task.id, {
processedCount,
failedCount,
});
}
}
// 3. 完成任务
await this.initTaskRepo.complete(task.id, {
processedCount,
failedCount,
resultSummary: {
totalUsers: adoptedUsers.length,
successCount: processedCount,
failedCount,
},
});
// 4. 记录审计日志
await this.auditService.log({
actionType: 'INIT_USERS',
actionDetail: { taskId: task.id },
afterValue: { processedCount, failedCount },
result: 'SUCCESS',
});
return { taskId: task.id, processedCount, failedCount };
} catch (error) {
await this.initTaskRepo.fail(task.id, error.message);
throw error;
}
}
```
### 4.2 配置管理
```typescript
/**
* 更新系统配置
* 所有配置更新都需要记录审计日志
*/
@AuditLog('CONFIG_UPDATE')
async updateConfig(key: string, value: string, operatorId: string): Promise<void> {
const existing = await this.configRepo.findByKey(key);
if (!existing) {
throw new ConfigNotFoundException(key);
}
const beforeValue = existing.configValue;
// 验证值格式
this.validateConfigValue(key, value, existing.configType);
// 更新配置
await this.configRepo.update(key, {
configValue: value,
updatedBy: operatorId,
version: existing.version + 1,
});
// 清除缓存
await this.configCache.invalidate(key);
// 发布配置更新事件
await this.eventPublisher.publish('mining-admin.config-updated', {
eventId: uuid(),
configKey: key,
beforeValue,
afterValue: value,
updatedBy: operatorId,
updatedAt: new Date().toISOString(),
});
}
/**
* 切换转账开关
*/
@AuditLog('TOGGLE_SWITCH')
async toggleTransfer(enabled: boolean, operatorId: string): Promise<void> {
const key = 'mining.transfer_enabled';
await this.updateConfig(key, String(enabled), operatorId);
// 通知 mining-service
await this.miningClient.updateTransferSwitch(enabled);
}
```
### 4.3 仪表盘统计
```typescript
/**
* 获取仪表盘统计数据
*/
async getDashboardStats(): Promise<DashboardStatsDto> {
// 优先从缓存获取
const cached = await this.statsCache.get('dashboard');
if (cached) {
return cached;
}
// 聚合各服务数据
const [
globalState,
contributionStats,
tradingStats,
userStats,
] = await Promise.all([
this.miningClient.getGlobalState(),
this.contributionClient.getNetworkStats(),
this.tradingClient.getTodayStats(),
this.getUserStats(),
]);
const stats: DashboardStatsDto = {
// 全局状态
currentPrice: globalState.currentPrice,
blackHoleAmount: globalState.blackHoleAmount,
circulationPool: globalState.circulationPool,
minuteBurnRate: globalState.minuteBurnRate,
// 算力统计
networkTotalContribution: contributionStats.totalContribution,
networkEffectiveContribution: contributionStats.effectiveContribution,
// 用户统计
totalUsers: userStats.totalUsers,
adoptedUsers: userStats.adoptedUsers,
totalTrees: userStats.totalTrees,
// 交易统计
todayTradeVolume: tradingStats.volume,
todayTradeCount: tradingStats.count,
// 分配统计
totalDistributed: globalState.totalDistributed,
distributionPhase: globalState.distributionPhase,
dailyDistribution: globalState.dailyDistribution,
updatedAt: new Date().toISOString(),
};
// 缓存 30 秒
await this.statsCache.set('dashboard', stats, 30);
// 保存快照
await this.dashboardSnapshotRepo.create(stats);
return stats;
}
```
### 4.4 用户详情查询
```typescript
/**
* 查询用户算力和挖矿详情
*/
async getUserDetail(accountSequence: string): Promise<UserDetailDto> {
const [
contribution,
shareAccount,
miningRecords,
tradeOrders,
referralInfo,
] = await Promise.all([
this.contributionClient.getUserContribution(accountSequence),
this.miningClient.getUserShareAccount(accountSequence),
this.miningClient.getUserMiningRecords(accountSequence, { limit: 30 }),
this.tradingClient.getUserOrders(accountSequence, { limit: 30 }),
this.getUserReferralInfo(accountSequence),
]);
return {
accountSequence,
// 算力信息
contribution: {
personal: contribution.personalContribution,
teamLevel: contribution.teamLevelContribution,
teamBonus: contribution.teamBonusContribution,
total: contribution.totalContribution,
effective: contribution.effectiveContribution,
hasAdopted: contribution.hasAdopted,
directReferralAdoptedCount: contribution.directReferralAdoptedCount,
unlockedLevelDepth: contribution.unlockedLevelDepth,
unlockedBonusTiers: contribution.unlockedBonusTiers,
},
// 积分股信息
shares: {
available: shareAccount.availableBalance,
frozen: shareAccount.frozenBalance,
totalMined: shareAccount.totalMined,
totalSold: shareAccount.totalSold,
totalBought: shareAccount.totalBought,
perSecondEarning: shareAccount.perSecondEarning,
},
// 推荐信息
referral: referralInfo,
// 最近挖矿记录
recentMiningRecords: miningRecords,
// 最近交易记录
recentTradeOrders: tradeOrders,
};
}
```
---
## 5. 服务间通信
### 5.1 订阅的事件
| Topic | 来源服务 | 数据内容 | 处理方式 |
|-------|---------|---------|---------|
| `contribution.contribution-calculated` | contribution-service | 用户算力计算完成 | 更新本地用户汇总 |
| `mining.shares-distributed` | mining-service | 积分股分配完成 | 更新分配统计 |
| `mining.price-updated` | mining-service | 价格更新 | 更新仪表盘 |
| `trading.trade-completed` | trading-service | 交易完成 | 更新交易统计 |
### 5.2 发布的事件
| Topic | 事件类型 | 订阅者 |
|-------|---------|-------|
| `mining-admin.config-updated` | ConfigUpdated | contribution/mining/trading |
### 5.3 调用其他服务
```typescript
// 调用 contribution-service
this.contributionClient.getUserContribution(accountSequence);
this.contributionClient.calculateUserContribution(accountSequence);
this.contributionClient.getNetworkStats();
// 调用 mining-service
this.miningClient.getGlobalState();
this.miningClient.getUserShareAccount(accountSequence);
this.miningClient.getUserMiningRecords(accountSequence);
this.miningClient.updateTransferSwitch(enabled);
// 调用 trading-service
this.tradingClient.getUserOrders(accountSequence);
this.tradingClient.getTodayStats();
this.tradingClient.getKlineData(period);
```
---
## 6. API 设计
### 6.1 配置管理 API
```
GET /api/admin/configs # 获取所有配置
GET /api/admin/configs/:key # 获取单个配置
PUT /api/admin/configs/:key # 更新配置
POST /api/admin/configs/toggle-transfer # 切换转账开关
```
### 6.2 系统账户 API
```
GET /api/admin/system-accounts # 获取所有系统账户
GET /api/admin/system-accounts/:id # 获取单个账户详情
POST /api/admin/system-accounts # 创建系统账户
PUT /api/admin/system-accounts/:id # 更新系统账户
```
### 6.3 用户查询 API
```
GET /api/admin/users # 用户列表(分页)
GET /api/admin/users/:accountSequence # 用户详情
GET /api/admin/users/:accountSequence/contribution-records # 算力明细
GET /api/admin/users/:accountSequence/mining-records # 挖矿明细
GET /api/admin/users/:accountSequence/trade-orders # 交易记录
```
### 6.4 仪表盘 API
```
GET /api/admin/dashboard # 仪表盘统计
GET /api/admin/dashboard/realtime # 实时数据WebSocket推送
GET /api/admin/dashboard/charts # 图表数据
```
### 6.5 报表 API
```
GET /api/admin/reports/daily # 日报表
GET /api/admin/reports/contribution # 算力报表
GET /api/admin/reports/mining # 挖矿报表
GET /api/admin/reports/trading # 交易报表
POST /api/admin/reports/export # 导出报表
```
### 6.6 初始化 API
```
POST /api/admin/initialization/legacy-users # 初始化老用户算力
POST /api/admin/initialization/global-state # 初始化全局状态
GET /api/admin/initialization/tasks # 获取初始化任务列表
GET /api/admin/initialization/tasks/:id # 获取任务详情
```
### 6.7 审计日志 API
```
GET /api/admin/audit-logs # 审计日志列表
GET /api/admin/audit-logs/:id # 审计日志详情
```
---
## 7. 审计日志装饰器
```typescript
/**
* 审计日志装饰器
* 自动记录操作前后的值变化
*/
export function AuditLog(actionType: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const auditService = this.auditService;
const operatorId = this.getCurrentOperatorId();
// 记录操作前状态(如果需要)
let beforeValue = null;
if (actionType === 'CONFIG_UPDATE') {
beforeValue = await this.getBeforeValue(args);
}
try {
const result = await originalMethod.apply(this, args);
// 记录成功日志
await auditService.log({
actionType,
actionTarget: this.getActionTarget(args),
actionDetail: this.getActionDetail(args),
beforeValue,
afterValue: this.getAfterValue(args, result),
operatorId,
result: 'SUCCESS',
});
return result;
} catch (error) {
// 记录失败日志
await auditService.log({
actionType,
actionTarget: this.getActionTarget(args),
actionDetail: this.getActionDetail(args),
beforeValue,
operatorId,
result: 'FAILED',
errorMessage: error.message,
});
throw error;
}
};
return descriptor;
};
}
```
---
## 8. 关键注意事项
### 8.1 权限控制
- 所有 API 需要管理员认证
- 敏感操作需要二次确认
- 配置修改需要记录操作人
### 8.2 数据一致性
- 本地汇总数据定期与源服务同步
- 仪表盘数据缓存有 TTL
- 重要操作使用事务
### 8.3 审计要求
- 所有配置变更记录审计日志
- 记录操作前后值
- 保留操作人信息和 IP
### 8.4 性能优化
- 使用 Redis 缓存频繁查询的数据
- 仪表盘统计定期快照
- 用户列表查询分页
---
## 9. 开发检查清单
- [ ] 实现配置管理 API
- [ ] 实现系统账户管理
- [ ] 实现用户查询 API
- [ ] 实现仪表盘统计
- [ ] 实现老用户初始化功能
- [ ] 实现审计日志记录
- [ ] 配置与其他服务的 HTTP 客户端
- [ ] 配置 Kafka Consumer
- [ ] 实现管理员认证
- [ ] 编写测试
---
## 10. 启动命令
```bash
# 开发环境
npm run start:dev
# 生成 Prisma Client
npx prisma generate
# 运行迁移
npx prisma migrate dev
# 生产环境
npm run build && npm run start:prod
```

View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": { "deleteOutDir": true }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
{
"name": "mining-admin-service",
"version": "1.0.0",
"description": "Mining admin service for configuration and monitoring",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"decimal.js": "^10.4.3",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.5",
"eslint": "^8.56.0",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,134 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== 管理员 ====================
model AdminUser {
id String @id @default(uuid())
username String @unique
password String // bcrypt hashed
name String
role String // SUPER_ADMIN, ADMIN, OPERATOR
status String @default("ACTIVE") // ACTIVE, DISABLED
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
@@map("admin_users")
}
// ==================== 系统配置 ====================
model SystemConfig {
id String @id @default(uuid())
category String // MINING, TRADING, CONTRIBUTION, SYSTEM
key String
value String
description String?
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([category, key])
@@map("system_configs")
}
// ==================== 系统账户(运营/省/市)====================
model SystemAccount {
id String @id @default(uuid())
accountType String @unique // OPERATION, PROVINCE, CITY
name String
description String?
totalContribution Decimal @db.Decimal(30, 8) @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_accounts")
}
// ==================== 初始化记录 ====================
model InitializationRecord {
id String @id @default(uuid())
type String // MINING_CONFIG, BLACK_HOLE, SYSTEM_ACCOUNTS
status String // PENDING, COMPLETED, FAILED
config Json
executedBy String
executedAt DateTime?
errorMessage String?
createdAt DateTime @default(now())
@@map("initialization_records")
}
// ==================== 审计日志 ====================
model AuditLog {
id String @id @default(uuid())
adminId String
action String // CREATE, UPDATE, DELETE, LOGIN, LOGOUT, INIT
resource String // CONFIG, USER, SYSTEM_ACCOUNT, MINING
resourceId String?
oldValue Json?
newValue Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
admin AdminUser @relation(fields: [adminId], references: [id])
@@index([adminId])
@@index([action])
@@index([resource])
@@index([createdAt(sort: Desc)])
@@map("audit_logs")
}
// ==================== 报表快照 ====================
model DailyReport {
id String @id @default(uuid())
reportDate DateTime @unique @db.Date
// 用户统计
totalUsers Int @default(0)
newUsers Int @default(0)
activeUsers Int @default(0)
// 认种统计
totalAdoptions Int @default(0)
newAdoptions Int @default(0)
totalTrees Int @default(0)
// 算力统计
totalContribution Decimal @db.Decimal(30, 8) @default(0)
contributionGrowth Decimal @db.Decimal(30, 8) @default(0)
// 挖矿统计
totalDistributed Decimal @db.Decimal(30, 8) @default(0)
totalBurned Decimal @db.Decimal(30, 8) @default(0)
// 交易统计
tradingVolume Decimal @db.Decimal(30, 8) @default(0)
tradingAmount Decimal @db.Decimal(30, 8) @default(0)
tradeCount Int @default(0)
// 价格
openPrice Decimal @db.Decimal(30, 18) @default(1)
closePrice Decimal @db.Decimal(30, 18) @default(1)
highPrice Decimal @db.Decimal(30, 18) @default(1)
lowPrice Decimal @db.Decimal(30, 18) @default(1)
createdAt DateTime @default(now())
@@map("daily_reports")
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from '../application/application.module';
import { AuthController } from './controllers/auth.controller';
import { DashboardController } from './controllers/dashboard.controller';
import { ConfigController } from './controllers/config.controller';
import { InitializationController } from './controllers/initialization.controller';
import { AuditController } from './controllers/audit.controller';
import { HealthController } from './controllers/health.controller';
@Module({
imports: [ApplicationModule],
controllers: [AuthController, DashboardController, ConfigController, InitializationController, AuditController, HealthController],
})
export class ApiModule {}

View File

@ -0,0 +1,27 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { DashboardService } from '../../application/services/dashboard.service';
@ApiTags('Audit')
@ApiBearerAuth()
@Controller('audit-logs')
export class AuditController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
@ApiOperation({ summary: '获取审计日志' })
@ApiQuery({ name: 'adminId', required: false })
@ApiQuery({ name: 'action', required: false })
@ApiQuery({ name: 'resource', required: false })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getAuditLogs(
@Query('adminId') adminId?: string,
@Query('action') action?: string,
@Query('resource') resource?: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.dashboardService.getAuditLogs({ adminId, action, resource, page: page ?? 1, pageSize: pageSize ?? 50 });
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Post, Body, Req, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from '../../application/services/auth.service';
import { Public } from '../../shared/guards/admin-auth.guard';
class LoginDto { username: string; password: string; }
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '管理员登录' })
async login(@Body() dto: LoginDto, @Req() req: any) {
return this.authService.login(dto.username, dto.password, req.ip, req.headers['user-agent']);
}
@Post('logout')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出登录' })
async logout(@Req() req: any) {
await this.authService.logout(req.admin.id);
return { success: true };
}
}

View File

@ -0,0 +1,41 @@
import { Controller, Get, Post, Delete, Body, Param, Query, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
import { ConfigManagementService } from '../../application/services/config.service';
class SetConfigDto { category: string; key: string; value: string; description?: string; }
@ApiTags('Config')
@ApiBearerAuth()
@Controller('configs')
export class ConfigController {
constructor(private readonly configService: ConfigManagementService) {}
@Get()
@ApiOperation({ summary: '获取配置列表' })
@ApiQuery({ name: 'category', required: false })
async getConfigs(@Query('category') category?: string) {
return this.configService.getConfigs(category);
}
@Get(':category/:key')
@ApiOperation({ summary: '获取单个配置' })
@ApiParam({ name: 'category' })
@ApiParam({ name: 'key' })
async getConfig(@Param('category') category: string, @Param('key') key: string) {
return this.configService.getConfig(category, key);
}
@Post()
@ApiOperation({ summary: '设置配置' })
async setConfig(@Body() dto: SetConfigDto, @Req() req: any) {
await this.configService.setConfig(req.admin.id, dto.category, dto.key, dto.value, dto.description);
return { success: true };
}
@Delete(':category/:key')
@ApiOperation({ summary: '删除配置' })
async deleteConfig(@Param('category') category: string, @Param('key') key: string, @Req() req: any) {
await this.configService.deleteConfig(req.admin.id, category, key);
return { success: true };
}
}

View File

@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { DashboardService } from '../../application/services/dashboard.service';
@ApiTags('Dashboard')
@ApiBearerAuth()
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('stats')
@ApiOperation({ summary: '获取仪表盘统计数据' })
async getStats() {
return this.dashboardService.getDashboardStats();
}
@Get('reports')
@ApiOperation({ summary: '获取每日报表' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getReports(@Query('page') page?: number, @Query('pageSize') pageSize?: number) {
return this.dashboardService.getReports(page ?? 1, pageSize ?? 30);
}
}

View File

@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { Public } from '../../shared/guards/admin-auth.guard';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@Public()
@ApiOperation({ summary: '健康检查' })
async check() {
const status = { status: 'healthy' as 'healthy' | 'unhealthy', timestamp: new Date().toISOString(), services: { database: 'up' as 'up' | 'down' } };
try { await this.prisma.$queryRaw`SELECT 1`; } catch { status.services.database = 'down'; status.status = 'unhealthy'; }
return status;
}
@Get('ready')
@Public()
@ApiOperation({ summary: '就绪检查' })
async ready() { return { ready: true }; }
}

View File

@ -0,0 +1,35 @@
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);
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { ApiModule } from './api/api.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { ApplicationModule } from './application/application.module';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
import { AdminAuthGuard } from './shared/guards/admin-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
}),
InfrastructureModule,
ApplicationModule,
ApiModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_GUARD, useClass: AdminAuthGuard },
],
})
export class AppModule {}

View File

@ -0,0 +1,19 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { AuthService } from './services/auth.service';
import { ConfigManagementService } from './services/config.service';
import { InitializationService } from './services/initialization.service';
import { DashboardService } from './services/dashboard.service';
@Module({
imports: [InfrastructureModule],
providers: [AuthService, ConfigManagementService, InitializationService, DashboardService],
exports: [AuthService, ConfigManagementService, InitializationService, DashboardService],
})
export class ApplicationModule implements OnModuleInit {
constructor(private readonly authService: AuthService) {}
async onModuleInit() {
await this.authService.createDefaultAdmin();
}
}

View File

@ -0,0 +1,72 @@
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import * as bcrypt from 'bcrypt';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly jwtSecret: string;
private readonly jwtExpiresIn: string;
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly configService: ConfigService,
) {
this.jwtSecret = this.configService.get<string>('JWT_SECRET', 'admin-secret');
this.jwtExpiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '24h');
}
async login(username: string, password: string, ipAddress?: string, userAgent?: string): Promise<{ token: string; admin: any }> {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, admin.password);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
const token = jwt.sign(
{ sub: admin.id, username: admin.username, role: admin.role },
this.jwtSecret,
{ expiresIn: this.jwtExpiresIn as jwt.SignOptions['expiresIn'] },
);
await this.prisma.adminUser.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() } });
await this.prisma.auditLog.create({
data: { adminId: admin.id, action: 'LOGIN', resource: 'AUTH', ipAddress, userAgent },
});
return {
token,
admin: { id: admin.id, username: admin.username, name: admin.name, role: admin.role },
};
}
async logout(adminId: string): Promise<void> {
await this.prisma.auditLog.create({
data: { adminId, action: 'LOGOUT', resource: 'AUTH' },
});
}
async createDefaultAdmin(): Promise<void> {
const defaultUsername = this.configService.get<string>('DEFAULT_ADMIN_USERNAME', 'admin');
const defaultPassword = this.configService.get<string>('DEFAULT_ADMIN_PASSWORD', 'admin123');
const exists = await this.prisma.adminUser.findUnique({ where: { username: defaultUsername } });
if (exists) return;
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
await this.prisma.adminUser.create({
data: { username: defaultUsername, password: hashedPassword, name: '超级管理员', role: 'SUPER_ADMIN' },
});
this.logger.log(`Default admin created: ${defaultUsername}`);
}
}

View File

@ -0,0 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@Injectable()
export class ConfigManagementService {
private readonly logger = new Logger(ConfigManagementService.name);
constructor(private readonly prisma: PrismaService) {}
async getConfigs(category?: string): Promise<any[]> {
const where = category ? { category } : {};
return this.prisma.systemConfig.findMany({ where, orderBy: [{ category: 'asc' }, { key: 'asc' }] });
}
async getConfig(category: string, key: string): Promise<any> {
return this.prisma.systemConfig.findUnique({ where: { category_key: { category, key } } });
}
async setConfig(adminId: string, category: string, key: string, value: string, description?: string): Promise<void> {
const existing = await this.getConfig(category, key);
await this.prisma.systemConfig.upsert({
where: { category_key: { category, key } },
create: { category, key, value, description },
update: { value, description },
});
await this.prisma.auditLog.create({
data: {
adminId,
action: existing ? 'UPDATE' : 'CREATE',
resource: 'CONFIG',
resourceId: `${category}:${key}`,
oldValue: existing ? { value: existing.value } : undefined,
newValue: { value },
},
});
}
async deleteConfig(adminId: string, category: string, key: string): Promise<void> {
const existing = await this.getConfig(category, key);
if (!existing) return;
await this.prisma.systemConfig.delete({ where: { category_key: { category, key } } });
await this.prisma.auditLog.create({
data: { adminId, action: 'DELETE', resource: 'CONFIG', resourceId: `${category}:${key}`, oldValue: existing },
});
}
}

View File

@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
async getDashboardStats(): Promise<any> {
const [contributionStats, miningStats, tradingStats, latestReport] = await Promise.all([
this.fetchContributionStats(),
this.fetchMiningStats(),
this.fetchTradingStats(),
this.prisma.dailyReport.findFirst({ orderBy: { reportDate: 'desc' } }),
]);
return {
contribution: contributionStats,
mining: miningStats,
trading: tradingStats,
latestReport,
timestamp: new Date().toISOString(),
};
}
private async fetchContributionStats(): Promise<any> {
try {
const url = `${this.configService.get('CONTRIBUTION_SERVICE_URL')}/api/v1/contributions/stats`;
const response = await fetch(url);
if (response.ok) {
const result = await response.json();
return result.data;
}
} catch (error) {
this.logger.error('Failed to fetch contribution stats', error);
}
return null;
}
private async fetchMiningStats(): Promise<any> {
try {
const url = `${this.configService.get('MINING_SERVICE_URL')}/api/v1/mining/stats`;
const response = await fetch(url);
if (response.ok) {
const result = await response.json();
return result.data;
}
} catch (error) {
this.logger.error('Failed to fetch mining stats', error);
}
return null;
}
private async fetchTradingStats(): Promise<any> {
try {
const url = `${this.configService.get('TRADING_SERVICE_URL')}/api/v1/trading/stats`;
const response = await fetch(url);
if (response.ok) {
const result = await response.json();
return result.data;
}
} catch (error) {
this.logger.error('Failed to fetch trading stats', error);
}
return null;
}
async getReports(page: number = 1, pageSize: number = 30): Promise<{ data: any[]; total: number }> {
const [reports, total] = await Promise.all([
this.prisma.dailyReport.findMany({
orderBy: { reportDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.dailyReport.count(),
]);
return { data: reports, total };
}
async getAuditLogs(
options: { adminId?: string; action?: string; resource?: string; page?: number; pageSize?: number },
): Promise<{ data: any[]; total: number }> {
const where: any = {};
if (options.adminId) where.adminId = options.adminId;
if (options.action) where.action = options.action;
if (options.resource) where.resource = options.resource;
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 50;
const [logs, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
include: { admin: { select: { username: true, name: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.auditLog.count({ where }),
]);
return { data: logs, total };
}
}

View File

@ -0,0 +1,100 @@
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 {
// 调用 mining-service API 初始化配置
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 };
}
}
}

View File

@ -0,0 +1,24 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PrismaModule } from './persistence/prisma/prisma.module';
import { RedisService } from './redis/redis.service';
@Global()
@Module({
imports: [PrismaModule],
providers: [
{
provide: 'REDIS_OPTIONS',
useFactory: (configService: ConfigService) => ({
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD'),
db: configService.get<number>('REDIS_DB', 3),
}),
inject: [ConfigService],
},
RedisService,
],
exports: [RedisService],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}

View File

@ -0,0 +1,11 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({ log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'] });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}

View File

@ -0,0 +1,25 @@
import { Injectable, Inject, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client: Redis;
constructor(@Inject('REDIS_OPTIONS') private readonly options: any) {}
async onModuleInit() {
this.client = new Redis({ ...this.options, retryStrategy: (times: number) => Math.min(times * 50, 2000) });
this.client.on('error', (err) => this.logger.error('Redis error', err));
this.client.on('connect', () => this.logger.log('Connected to Redis'));
}
async onModuleDestroy() { await this.client.quit(); }
getClient(): Redis { return this.client; }
async get(key: string): Promise<string | null> { return this.client.get(key); }
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) await this.client.setex(key, ttlSeconds, value);
else await this.client.set(key, value);
}
async del(key: string): Promise<void> { await this.client.del(key); }
}

View File

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
app.enableCors({ origin: process.env.CORS_ORIGIN || '*', credentials: true });
app.setGlobalPrefix('api/v1');
const config = new DocumentBuilder()
.setTitle('Mining Admin Service API')
.setDescription('挖矿管理后台 API 文档')
.setVersion('1.0')
.addBearerAuth()
.addTag('Auth', '认证')
.addTag('Config', '配置管理')
.addTag('Dashboard', '仪表盘')
.addTag('Users', '用户管理')
.addTag('Reports', '报表')
.addTag('Audit', '审计日志')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 3023;
await app.listen(port);
console.log(`Mining Admin Service is running on port ${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,32 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger, HttpException } from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'object' ? (exceptionResponse as any).message || exception.message : exception.message;
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: { code: status, message: Array.isArray(message) ? message : [message] },
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(private reflector: Reflector, private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) throw new UnauthorizedException('No token provided');
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) throw new UnauthorizedException('Invalid token format');
try {
const secret = this.configService.get<string>('JWT_SECRET', 'admin-secret');
const payload = jwt.verify(token, secret) as any;
request.admin = { id: payload.sub, username: payload.username, role: payload.role };
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View File

@ -0,0 +1,10 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ success: true, data, timestamp: new Date().toISOString() })));
}
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
}
}

View File

@ -0,0 +1,31 @@
# Application
NODE_ENV=development
PORT=3021
SERVICE_NAME=mining-service
# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/mining_db?schema=public
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=1
# Kafka
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=mining-service
KAFKA_GROUP_ID=mining-service-group
# JWT
JWT_SECRET=your-jwt-secret-key
# Mining Configuration
TOTAL_SHARES=100020000000
DISTRIBUTION_POOL=200000000
INITIAL_PRICE=1
HALVING_PERIOD_YEARS=2
BURN_TARGET=10000000000
# Contribution Service
CONTRIBUTION_SERVICE_URL=http://localhost:3020

View File

@ -0,0 +1,728 @@
# Mining Service (挖矿服务) 开发指导
## 1. 服务概述
### 1.1 核心职责
Mining Service 负责积分股的挖矿分配和全局状态管理,是代币经济的核心引擎。
**主要功能:**
- 管理积分股全局状态(总量、黑洞、流通池、价格)
- 执行每分钟销毁(维护币价上涨)
- 计算并分配每日/每小时/每分钟/每秒的积分股
- 维护挖矿明细账(每个用户获得的积分股来源)
- 管理分配阶段2年减半规则
### 1.2 技术栈
- **框架**: NestJS + TypeScript
- **数据库**: PostgreSQL (事务型) + Redis (实时状态)
- **ORM**: Prisma
- **消息队列**: Kafka
### 1.3 端口分配
- HTTP: 3021
- 数据库: rwa_mining
---
## 2. 架构设计
### 2.1 目录结构
```
mining-service/
├── src/
│ ├── api/
│ │ ├── controllers/
│ │ │ ├── share-account.controller.ts # 用户积分股账户API
│ │ │ ├── global-state.controller.ts # 全局状态API
│ │ │ ├── mining-record.controller.ts # 挖矿记录API
│ │ │ └── health.controller.ts
│ │ └── dto/
│ │ ├── request/
│ │ └── response/
│ │ ├── share-account.response.ts
│ │ ├── global-state.response.ts
│ │ ├── mining-record.response.ts
│ │ └── realtime-earning.response.ts
│ │
│ ├── application/
│ │ ├── commands/
│ │ │ ├── execute-minute-burn.command.ts # 每分钟销毁
│ │ │ ├── distribute-daily-shares.command.ts # 每日分配
│ │ │ ├── calculate-user-earning.command.ts # 计算用户收益
│ │ │ ├── advance-distribution-phase.command.ts # 阶段推进
│ │ │ └── initialize-global-state.command.ts # 初始化
│ │ ├── queries/
│ │ │ ├── get-user-share-account.query.ts
│ │ │ ├── get-global-state.query.ts
│ │ │ ├── get-user-mining-records.query.ts
│ │ │ └── get-realtime-earning.query.ts
│ │ ├── services/
│ │ │ ├── mining-distribution.service.ts
│ │ │ ├── burn-executor.service.ts
│ │ │ └── price-calculator.service.ts
│ │ ├── event-handlers/
│ │ │ ├── contribution-snapshot-created.handler.ts
│ │ │ └── trade-completed.handler.ts
│ │ └── schedulers/
│ │ ├── minute-burn.scheduler.ts # 每分钟销毁定时器
│ │ ├── daily-distribution.scheduler.ts # 每日分配定时器
│ │ └── phase-check.scheduler.ts # 阶段检查
│ │
│ ├── domain/
│ │ ├── aggregates/
│ │ │ ├── share-global-state.aggregate.ts
│ │ │ ├── share-account.aggregate.ts
│ │ │ └── mining-record.aggregate.ts
│ │ ├── repositories/
│ │ │ ├── share-global-state.repository.interface.ts
│ │ │ ├── share-account.repository.interface.ts
│ │ │ ├── mining-record.repository.interface.ts
│ │ │ └── burn-record.repository.interface.ts
│ │ ├── value-objects/
│ │ │ ├── share-amount.vo.ts
│ │ │ ├── share-price.vo.ts
│ │ │ └── distribution-phase.vo.ts
│ │ ├── events/
│ │ │ ├── shares-distributed.event.ts
│ │ │ ├── shares-burned.event.ts
│ │ │ ├── price-updated.event.ts
│ │ │ └── phase-advanced.event.ts
│ │ └── services/
│ │ ├── burn-calculator.domain-service.ts
│ │ └── distribution-calculator.domain-service.ts
│ │
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ ├── repositories/
│ │ │ │ ├── share-global-state.repository.impl.ts
│ │ │ │ ├── share-account.repository.impl.ts
│ │ │ │ ├── mining-record.repository.impl.ts
│ │ │ │ └── burn-record.repository.impl.ts
│ │ │ └── unit-of-work/
│ │ │ └── unit-of-work.service.ts
│ │ ├── kafka/
│ │ │ ├── contribution-event-consumer.service.ts
│ │ │ ├── trade-event-consumer.service.ts
│ │ │ ├── event-publisher.service.ts
│ │ │ └── kafka.module.ts
│ │ ├── redis/
│ │ │ ├── global-state-cache.service.ts # 实时状态缓存
│ │ │ ├── price-cache.service.ts # 价格缓存
│ │ │ └── earning-cache.service.ts # 收益缓存
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/
│ ├── config/
│ ├── app.module.ts
│ └── main.ts
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── package.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml
```
---
## 3. 数据库设计
### 3.1 数据库类型选择
| 表类型 | 数据库类型 | 原因 |
|--------|-----------|------|
| 全局状态表 | 事务型 + Redis | 需要强一致性 + 实时读取 |
| 用户账户表 | 事务型 | 余额变更需要事务 |
| 挖矿明细表 | 事务型 | 明细账需要完整性 |
| 销毁记录表 | 事务型 | 审计追踪 |
### 3.2 核心表结构
```sql
-- ============================================
-- 全局状态表(单例)
-- ============================================
CREATE TABLE share_global_state (
id UUID PRIMARY KEY DEFAULT '00000000-0000-0000-0000-000000000001',
-- 积分股总量与池子
total_supply DECIMAL(30,10) DEFAULT 10002000000, -- 100.02亿
original_pool DECIMAL(30,10) DEFAULT 2000000, -- 200万用于分配
black_hole_amount DECIMAL(30,10) DEFAULT 0, -- 黑洞(已销毁)
circulation_pool DECIMAL(30,10) DEFAULT 0, -- 流通池(卖出的)
-- 积分股池(决定价格)
share_pool_green_points DECIMAL(30,10) DEFAULT 0, -- 池中绿积分
-- 价格
current_price DECIMAL(30,18) DEFAULT 0,
-- 销毁相关
minute_burn_rate DECIMAL(30,18), -- 每分钟销毁量
total_minutes_remaining BIGINT, -- 剩余分钟数
last_burn_at TIMESTAMP WITH TIME ZONE,
-- 分配相关
distribution_phase INT DEFAULT 1, -- 当前阶段 (1,2,3...)
daily_distribution DECIMAL(30,18), -- 每日分配量
total_distributed DECIMAL(30,10) DEFAULT 0, -- 累计已分配
-- 系统控制
transfer_enabled BOOLEAN DEFAULT FALSE,
system_start_at TIMESTAMP WITH TIME ZONE,
is_initialized BOOLEAN DEFAULT FALSE,
-- 乐观锁
version INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================
-- 用户积分股账户
-- ============================================
CREATE TABLE share_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_sequence VARCHAR(20) NOT NULL UNIQUE,
-- 余额
available_balance DECIMAL(30,10) DEFAULT 0, -- 可用余额
frozen_balance DECIMAL(30,10) DEFAULT 0, -- 冻结余额
-- 统计
total_mined DECIMAL(30,10) DEFAULT 0, -- 累计挖矿获得
total_sold DECIMAL(30,10) DEFAULT 0, -- 累计卖出
total_bought DECIMAL(30,10) DEFAULT 0, -- 累计买入
-- 实时收益计算参数(缓存)
last_contribution_ratio DECIMAL(30,18) DEFAULT 0, -- 最近算力占比
per_second_earning DECIMAL(30,18) DEFAULT 0, -- 每秒收益
-- 乐观锁
version INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================
-- 挖矿明细账
-- ============================================
-- 每日挖矿汇总记录
CREATE TABLE daily_mining_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_sequence VARCHAR(20) NOT NULL,
mining_date DATE NOT NULL,
-- 算力快照
effective_contribution DECIMAL(30,10) NOT NULL,
network_total_contribution DECIMAL(30,10) NOT NULL,
contribution_ratio DECIMAL(30,18) NOT NULL,
-- 分配结果
daily_network_distribution DECIMAL(30,18) NOT NULL, -- 当日全网分配量
mined_amount DECIMAL(30,18) NOT NULL, -- 用户获得量
-- 分配阶段
distribution_phase INT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(account_sequence, mining_date)
);
CREATE INDEX idx_daily_mining_account ON daily_mining_records(account_sequence);
CREATE INDEX idx_daily_mining_date ON daily_mining_records(mining_date);
-- 小时级挖矿记录(更细粒度追踪)
CREATE TABLE hourly_mining_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_sequence VARCHAR(20) NOT NULL,
mining_hour TIMESTAMP WITH TIME ZONE NOT NULL,
contribution_ratio DECIMAL(30,18) NOT NULL,
hourly_distribution DECIMAL(30,18) NOT NULL,
mined_amount DECIMAL(30,18) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(account_sequence, mining_hour)
);
-- ============================================
-- 销毁记录(明细账)
-- ============================================
CREATE TABLE burn_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
burn_type VARCHAR(20) NOT NULL, -- SCHEDULED / SELL_TRIGGERED
burn_amount DECIMAL(30,10) NOT NULL,
-- 触发信息
trigger_trade_id UUID, -- 卖出触发时的交易ID
trigger_account_sequence VARCHAR(20), -- 触发用户
-- 状态快照
before_black_hole DECIMAL(30,10),
after_black_hole DECIMAL(30,10),
before_price DECIMAL(30,18),
after_price DECIMAL(30,18),
before_minute_rate DECIMAL(30,18),
after_minute_rate DECIMAL(30,18),
-- 计算参数(审计用)
remaining_minutes_at_burn BIGINT,
total_to_burn_at_time DECIMAL(30,10),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_burn_records_type ON burn_records(burn_type);
CREATE INDEX idx_burn_records_time ON burn_records(created_at);
-- ============================================
-- 分配阶段配置
-- ============================================
CREATE TABLE distribution_phase_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phase_number INT NOT NULL UNIQUE,
duration_years INT DEFAULT 2,
total_shares DECIMAL(30,10) NOT NULL, -- 该阶段总分配量
daily_shares DECIMAL(30,18) NOT NULL, -- 每日分配量
start_date DATE,
end_date DATE,
is_active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 初始化数据
INSERT INTO distribution_phase_configs (phase_number, total_shares, daily_shares) VALUES
(1, 1000000, 1369.86301369863), -- 第一个两年100万
(2, 500000, 684.9315068493151), -- 第二个两年50万
(3, 250000, 342.46575342465753), -- 第三个两年25万
(4, 125000, 171.23287671232876); -- 第四个两年12.5万
-- ============================================
-- 价格历史(每分钟记录)
-- ============================================
CREATE TABLE price_ticks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tick_time TIMESTAMP WITH TIME ZONE NOT NULL,
price DECIMAL(30,18) NOT NULL,
-- 状态快照
share_pool_green_points DECIMAL(30,10),
black_hole_amount DECIMAL(30,10),
circulation_pool DECIMAL(30,10),
effective_supply DECIMAL(30,10), -- 有效供应量
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_price_ticks_time ON price_ticks(tick_time);
-- ============================================
-- 同步的算力快照(从 contribution-service
-- ============================================
CREATE TABLE synced_contribution_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_date DATE NOT NULL,
account_sequence VARCHAR(20) NOT NULL,
effective_contribution DECIMAL(30,10) NOT NULL,
network_total_contribution DECIMAL(30,10) NOT NULL,
contribution_ratio DECIMAL(30,18) NOT NULL,
source_sequence_num BIGINT NOT NULL,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 是否已用于分配
distribution_processed BOOLEAN DEFAULT FALSE,
distribution_processed_at TIMESTAMP WITH TIME ZONE,
UNIQUE(snapshot_date, account_sequence)
);
-- ============================================
-- 已处理事件(幂等性)
-- ============================================
CREATE TABLE processed_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(100) NOT NULL UNIQUE,
event_type VARCHAR(50) NOT NULL,
source_service VARCHAR(50),
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
---
## 4. 核心业务逻辑
### 4.1 价格计算公式
```typescript
/**
* 积分股价格 = 积分股池绿积分 ÷ (100.02亿 - 黑洞量 - 流通池量)
*/
function calculatePrice(state: GlobalState): Decimal {
const effectiveSupply = state.totalSupply
.minus(state.blackHoleAmount)
.minus(state.circulationPool);
if (effectiveSupply.isZero()) {
return new Decimal(0);
}
return state.sharePoolGreenPoints.dividedBy(effectiveSupply);
}
```
### 4.2 每分钟销毁逻辑
```typescript
/**
* 定时销毁任务 - 每分钟执行
* 目的:让代币价格持续上涨
*/
@Cron('* * * * *') // 每分钟
async executeMinuteBurn(): Promise<void> {
await this.unitOfWork.runInTransaction(async (tx) => {
const state = await this.globalStateRepo.getForUpdate(tx);
// 计算当前每分钟销毁量
// 每分钟销毁量 = (100亿 - 黑洞总量) ÷ 剩余分钟数
const remainingToBurn = new Decimal('10000000000').minus(state.blackHoleAmount);
const remainingMinutes = this.calculateRemainingMinutes(state);
const burnAmount = remainingToBurn.dividedBy(remainingMinutes);
// 执行销毁
const newBlackHole = state.blackHoleAmount.plus(burnAmount);
// 重新计算价格
const newPrice = this.calculatePrice({
...state,
blackHoleAmount: newBlackHole
});
// 更新全局状态
await this.globalStateRepo.update(tx, {
blackHoleAmount: newBlackHole,
currentPrice: newPrice,
minuteBurnRate: burnAmount,
lastBurnAt: new Date(),
version: state.version + 1
});
// 记录销毁明细
await this.burnRecordRepo.create(tx, {
burnType: 'SCHEDULED',
burnAmount,
beforeBlackHole: state.blackHoleAmount,
afterBlackHole: newBlackHole,
beforePrice: state.currentPrice,
afterPrice: newPrice,
remainingMinutesAtBurn: remainingMinutes,
});
// 记录价格
await this.priceTickRepo.create(tx, {
tickTime: new Date(),
price: newPrice,
sharePoolGreenPoints: state.sharePoolGreenPoints,
blackHoleAmount: newBlackHole,
circulationPool: state.circulationPool,
});
// 更新 Redis 缓存
await this.globalStateCache.update({
price: newPrice,
blackHoleAmount: newBlackHole,
minuteBurnRate: burnAmount,
});
});
}
/**
* 计算剩余分钟数
* 4年 = 365 × 4 × 24 × 60 = 2,102,400 分钟
*/
private calculateRemainingMinutes(state: GlobalState): number {
const totalMinutes = 365 * 4 * 24 * 60; // 2,102,400
const elapsedMinutes = this.getElapsedMinutes(state.systemStartAt);
return Math.max(totalMinutes - elapsedMinutes, 1); // 至少1分钟避免除零
}
```
### 4.3 每日积分股分配
```typescript
/**
* 每日分配任务 - 凌晨执行
* 根据用户算力占比分配当日积分股
*/
@Cron('0 0 * * *') // 每天0点
async distributeDailyShares(): Promise<void> {
const today = new Date();
const yesterday = subDays(today, 1);
// 获取昨日算力快照
const snapshots = await this.syncedSnapshotRepo.findByDate(yesterday);
if (snapshots.length === 0) {
this.logger.warn('No contribution snapshots for distribution');
return;
}
const state = await this.globalStateRepo.get();
const dailyDistribution = state.dailyDistribution;
// 批量处理用户分配
for (const snapshot of snapshots) {
await this.unitOfWork.runInTransaction(async (tx) => {
// 计算用户应得份额
const userShare = dailyDistribution.multipliedBy(snapshot.contributionRatio);
// 更新用户账户
const account = await this.shareAccountRepo.getByAccountSequence(
tx,
snapshot.accountSequence
);
await this.shareAccountRepo.update(tx, {
id: account.id,
availableBalance: account.availableBalance.plus(userShare),
totalMined: account.totalMined.plus(userShare),
lastContributionRatio: snapshot.contributionRatio,
perSecondEarning: this.calculatePerSecondEarning(snapshot.contributionRatio, state),
version: account.version + 1,
});
// 记录挖矿明细
await this.dailyMiningRecordRepo.create(tx, {
accountSequence: snapshot.accountSequence,
miningDate: yesterday,
effectiveContribution: snapshot.effectiveContribution,
networkTotalContribution: snapshot.networkTotalContribution,
contributionRatio: snapshot.contributionRatio,
dailyNetworkDistribution: dailyDistribution,
minedAmount: userShare,
distributionPhase: state.distributionPhase,
});
// 标记快照已处理
await this.syncedSnapshotRepo.markProcessed(tx, snapshot.id);
});
}
// 更新全局分配统计
await this.globalStateRepo.addDistributed(dailyDistribution);
}
/**
* 计算每秒收益(用于前端实时显示)
* 每秒收益 = 每日分配量 × 用户占比 ÷ 86400
*/
private calculatePerSecondEarning(
contributionRatio: Decimal,
state: GlobalState
): Decimal {
return state.dailyDistribution
.multipliedBy(contributionRatio)
.dividedBy(86400);
}
```
### 4.4 实时收益查询(前端显示)
```typescript
/**
* 获取用户实时收益(用于前端每秒更新显示)
*/
async getRealtimeEarning(accountSequence: string): Promise<RealtimeEarningDto> {
// 优先从 Redis 获取
const cached = await this.earningCache.get(accountSequence);
if (cached) {
return cached;
}
const account = await this.shareAccountRepo.getByAccountSequence(accountSequence);
const state = await this.globalStateCache.get();
// 计算显示资产
// 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
const multiplier = this.calculateMultiplier(state);
const effectiveShares = account.availableBalance
.multipliedBy(new Decimal(1).plus(multiplier));
const displayAssetValue = effectiveShares.multipliedBy(state.currentPrice);
const result = {
accountSequence,
availableBalance: account.availableBalance.toString(),
perSecondEarning: account.perSecondEarning.toString(),
displayAssetValue: displayAssetValue.toString(),
currentPrice: state.currentPrice.toString(),
multiplier: multiplier.toString(),
};
// 缓存 10 秒
await this.earningCache.set(accountSequence, result, 10);
return result;
}
/**
* 计算倍数
* 倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
*/
private calculateMultiplier(state: GlobalState): Decimal {
const numerator = new Decimal('10000000000').minus(state.blackHoleAmount);
const denominator = new Decimal('2000000').minus(state.circulationPool);
if (denominator.isZero() || denominator.isNegative()) {
return new Decimal(0);
}
return numerator.dividedBy(denominator);
}
```
---
## 5. 服务间通信
### 5.1 订阅的事件
| Topic | 来源服务 | 数据内容 | 处理方式 |
|-------|---------|---------|---------|
| `contribution.daily-snapshot-created` | contribution-service | 每日算力快照 | 用于挖矿分配 |
| `trading.trade-completed` | trading-service | 交易完成事件 | 更新流通池、触发卖出销毁 |
### 5.2 发布的事件
| Topic | 事件类型 | 订阅者 |
|-------|---------|-------|
| `mining.shares-distributed` | SharesDistributed | trading-service |
| `mining.price-updated` | PriceUpdated | trading-service, mining-admin |
| `mining.shares-burned` | SharesBurned | mining-admin |
### 5.3 Redis 缓存结构
```typescript
// 全局状态缓存 - 高频读取
interface GlobalStateCache {
key: 'mining:global-state';
ttl: 60; // 60秒
data: {
currentPrice: string;
blackHoleAmount: string;
circulationPool: string;
minuteBurnRate: string;
dailyDistribution: string;
};
}
// 用户收益缓存 - 实时查询
interface UserEarningCache {
key: `mining:earning:${accountSequence}`;
ttl: 10; // 10秒
data: RealtimeEarningDto;
}
```
---
## 6. 关键计算公式汇总
### 6.1 价格相关
```
积分股价格 = 积分股池绿积分 ÷ (100.02亿 - 黑洞量 - 流通池量)
每分钟销毁量 = (100亿 - 黑洞总量) ÷ 剩余分钟数
倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
```
### 6.2 分配相关
```
第一个两年:每日分配 = 1000000 ÷ 730 = 1369.86301369863
第二个两年:每日分配 = 500000 ÷ 730 = 684.9315068493151
第N个两年每日分配 = 前一阶段 ÷ 2
用户每日获得 = 每日全网分配 × 用户算力占比
用户每秒获得 = 用户每日获得 ÷ 86400
```
---
## 7. 关键注意事项
### 7.1 精度处理
- 价格使用 `DECIMAL(30,18)` - 18位小数
- 积分股数量使用 `DECIMAL(30,10)` - 10位小数
- 使用 `Decimal.js` 库处理所有数学运算,避免浮点数精度问题
### 7.2 并发控制
- 全局状态更新使用乐观锁 (`version` 字段)
- 定时任务使用分布式锁Redis避免多实例重复执行
### 7.3 性能优化
- 全局状态缓存到 Redis避免频繁查库
- 用户收益预计算 `per_second_earning`,前端直接使用
- 批量处理每日分配,避免单条事务
### 7.4 数据一致性
- 销毁操作必须在事务中同时更新:黑洞量、价格、销毁记录
- 分配操作必须在事务中同时更新:用户余额、挖矿记录
---
## 8. 开发检查清单
- [ ] 实现全局状态管理
- [ ] 实现每分钟销毁定时任务
- [ ] 实现每日积分股分配
- [ ] 实现价格计算服务
- [ ] 实现挖矿明细账记录
- [ ] 实现销毁明细账记录
- [ ] 实现实时收益查询 API
- [ ] 实现分配阶段管理
- [ ] 配置 Redis 缓存
- [ ] 编写单元测试
- [ ] 配置 Kafka Consumer
---
## 9. 启动命令
```bash
# 开发环境
npm run start:dev
# 初始化全局状态
npm run cli -- init-global-state
# 生产环境
npm run build && npm run start:prod
```

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
{
"name": "mining-service",
"version": "1.0.0",
"description": "Mining service for share token distribution",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"decimal.js": "^10.4.3",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.0",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,198 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== 挖矿配置 ====================
// 挖矿全局配置
model MiningConfig {
id String @id @default(uuid())
totalShares Decimal @db.Decimal(30, 8) // 总积分股数量 (100.02B)
distributionPool Decimal @db.Decimal(30, 8) // 分配池 (200M)
remainingDistribution Decimal @db.Decimal(30, 8) // 剩余可分配
halvingPeriodYears Int @default(2) // 减半周期(年)
currentEra Int @default(1) // 当前纪元
eraStartDate DateTime // 当前纪元开始日期
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(false) // 是否已激活挖矿
activatedAt DateTime? // 激活时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("mining_configs")
}
// 减半纪元记录
model MiningEra {
id String @id @default(uuid())
eraNumber Int @unique
startDate DateTime
endDate DateTime?
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
totalDistributed Decimal @db.Decimal(30, 8) @default(0) // 已分配量
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@map("mining_eras")
}
// ==================== 用户挖矿账户 ====================
// 用户挖矿账户
model MiningAccount {
id String @id @default(uuid())
accountSequence String @unique
totalMined Decimal @db.Decimal(30, 8) @default(0) // 总挖到的积分股
availableBalance Decimal @db.Decimal(30, 8) @default(0) // 可用余额
frozenBalance Decimal @db.Decimal(30, 8) @default(0) // 冻结余额
totalContribution Decimal @db.Decimal(30, 8) @default(0) // 当前算力(从 contribution-service 同步)
lastSyncedAt DateTime? // 最后同步算力时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records MiningRecord[]
transactions MiningTransaction[]
@@index([totalContribution(sort: Desc)])
@@map("mining_accounts")
}
// 挖矿记录(分钟级别)
model MiningRecord {
id String @id @default(uuid())
accountSequence String
miningMinute DateTime // 挖矿分钟(精确到分钟)
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
minuteDistribution Decimal @db.Decimal(30, 18) // 当分钟总分配量
minedAmount Decimal @db.Decimal(30, 18) // 挖到的数量
createdAt DateTime @default(now())
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
@@unique([accountSequence, miningMinute])
@@index([miningMinute])
@@map("mining_records")
}
// 挖矿交易流水
model MiningTransaction {
id String @id @default(uuid())
accountSequence String
type String // MINE, FREEZE, UNFREEZE, TRANSFER_OUT, TRANSFER_IN, BURN
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
referenceId String? // 关联ID如交易ID、划转ID
referenceType String? // 关联类型
description String?
createdAt DateTime @default(now())
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
@@index([accountSequence, createdAt(sort: Desc)])
@@index([type])
@@map("mining_transactions")
}
// ==================== 挖矿统计 ====================
// 每分钟挖矿统计
model MinuteMiningStat {
id String @id @default(uuid())
minute DateTime @unique
totalContribution Decimal @db.Decimal(30, 8) // 参与挖矿的总算力
totalDistributed Decimal @db.Decimal(30, 18) // 该分钟分配的总量
participantCount Int // 参与者数量
burnAmount Decimal @db.Decimal(30, 8) @default(0) // 该分钟销毁量
createdAt DateTime @default(now())
@@index([minute(sort: Desc)])
@@map("minute_mining_stats")
}
// 每日挖矿统计
model DailyMiningStat {
id String @id @default(uuid())
date DateTime @unique @db.Date
totalContribution Decimal @db.Decimal(30, 8)
totalDistributed Decimal @db.Decimal(30, 8)
totalBurned Decimal @db.Decimal(30, 8)
participantCount Int
avgContributionRate Decimal @db.Decimal(10, 8) // 平均算力利用率
createdAt DateTime @default(now())
@@map("daily_mining_stats")
}
// ==================== 销毁机制 ====================
// 黑洞账户
model BlackHole {
id String @id @default(uuid())
totalBurned Decimal @db.Decimal(30, 8) @default(0) // 已销毁总量
targetBurn Decimal @db.Decimal(30, 8) // 目标销毁量 (10B)
remainingBurn Decimal @db.Decimal(30, 8) // 剩余待销毁
lastBurnMinute DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records BurnRecord[]
@@map("black_holes")
}
// 销毁记录
model BurnRecord {
id String @id @default(uuid())
blackHoleId String
burnMinute DateTime
burnAmount Decimal @db.Decimal(30, 18)
remainingTarget Decimal @db.Decimal(30, 8) // 销毁后剩余目标
createdAt DateTime @default(now())
blackHole BlackHole @relation(fields: [blackHoleId], references: [id])
@@unique([blackHoleId, burnMinute])
@@index([burnMinute])
@@map("burn_records")
}
// ==================== 价格相关 ====================
// 价格快照(每分钟)
model PriceSnapshot {
id String @id @default(uuid())
snapshotTime DateTime @unique
price Decimal @db.Decimal(30, 18) // 当时价格
sharePool Decimal @db.Decimal(30, 8) // 股池
blackHoleAmount Decimal @db.Decimal(30, 8) // 黑洞数量
circulationPool Decimal @db.Decimal(30, 8) // 流通池
effectiveDenominator Decimal @db.Decimal(30, 8) // 有效分母
createdAt DateTime @default(now())
@@index([snapshotTime(sort: Desc)])
@@map("price_snapshots")
}
// ==================== Outbox ====================
model OutboxEvent {
id String @id @default(uuid())
aggregateType String
aggregateId String
eventType String
payload Json
createdAt DateTime @default(now())
processedAt DateTime?
@@index([processedAt])
@@index([createdAt])
@@map("outbox_events")
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { MiningController } from './controllers/mining.controller';
import { PriceController } from './controllers/price.controller';
import { HealthController } from './controllers/health.controller';
@Module({
imports: [ApplicationModule, InfrastructureModule],
controllers: [MiningController, PriceController, HealthController],
})
export class ApiModule {}

View File

@ -0,0 +1,55 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务健康' })
async check() {
const status = {
status: 'healthy' as 'healthy' | 'unhealthy',
timestamp: new Date().toISOString(),
services: {
database: 'up' as 'up' | 'down',
redis: 'up' as 'up' | 'down',
},
};
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
status.services.database = 'down';
status.status = 'unhealthy';
}
try {
await this.redis.getClient().ping();
} catch {
status.services.redis = 'down';
status.status = 'unhealthy';
}
return status;
}
@Get('ready')
@ApiOperation({ summary: '就绪检查' })
async ready() {
return { ready: true };
}
@Get('live')
@ApiOperation({ summary: '存活检查' })
async live() {
return { alive: true };
}
}

View File

@ -0,0 +1,78 @@
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { GetMiningAccountQuery } from '../../application/queries/get-mining-account.query';
import { GetMiningStatsQuery } from '../../application/queries/get-mining-stats.query';
@ApiTags('Mining')
@Controller('mining')
export class MiningController {
constructor(
private readonly getAccountQuery: GetMiningAccountQuery,
private readonly getStatsQuery: GetMiningStatsQuery,
) {}
@Get('stats')
@ApiOperation({ summary: '获取挖矿统计数据' })
@ApiResponse({ status: 200, description: '挖矿统计' })
async getStats() {
return this.getStatsQuery.execute();
}
@Get('ranking')
@ApiOperation({ summary: '获取挖矿排行榜' })
@ApiQuery({ name: 'limit', required: false, description: '返回数量限制', type: Number })
@ApiResponse({ status: 200, description: '挖矿排行榜' })
async getRanking(@Query('limit') limit?: number) {
const ranking = await this.getStatsQuery.getRanking(limit ?? 100);
return { data: ranking };
}
@Get('accounts/:accountSequence')
@ApiOperation({ summary: '获取挖矿账户信息' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiResponse({ status: 200, description: '挖矿账户信息' })
@ApiResponse({ status: 404, description: '账户不存在' })
async getAccount(@Param('accountSequence') accountSequence: string) {
const account = await this.getAccountQuery.execute(accountSequence);
if (!account) {
throw new NotFoundException(`Account ${accountSequence} not found`);
}
return account;
}
@Get('accounts/:accountSequence/records')
@ApiOperation({ summary: '获取挖矿记录' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiResponse({ status: 200, description: '挖矿记录列表' })
async getRecords(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.getAccountQuery.getMiningRecords(
accountSequence,
page ?? 1,
pageSize ?? 50,
);
}
@Get('accounts/:accountSequence/transactions')
@ApiOperation({ summary: '获取交易流水' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiResponse({ status: 200, description: '交易流水列表' })
async getTransactions(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.getAccountQuery.getTransactions(
accountSequence,
page ?? 1,
pageSize ?? 50,
);
}
}

View File

@ -0,0 +1,58 @@
import { Controller, Get, Query, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { GetPriceQuery } from '../../application/queries/get-price.query';
@ApiTags('Price')
@Controller('price')
export class PriceController {
constructor(private readonly getPriceQuery: GetPriceQuery) {}
@Get('current')
@ApiOperation({ summary: '获取当前价格' })
@ApiResponse({ status: 200, description: '当前价格' })
async getCurrentPrice() {
const price = await this.getPriceQuery.getCurrentPrice();
if (!price) {
throw new NotFoundException('Price not available');
}
return price;
}
@Get('history')
@ApiOperation({ summary: '获取价格历史' })
@ApiQuery({ name: 'startTime', required: true, description: '开始时间 (ISO 8601)' })
@ApiQuery({ name: 'endTime', required: true, description: '结束时间 (ISO 8601)' })
@ApiQuery({ name: 'interval', required: false, enum: ['minute', 'hour', 'day'], description: '时间间隔' })
@ApiResponse({ status: 200, description: '价格历史' })
async getPriceHistory(
@Query('startTime') startTime: string,
@Query('endTime') endTime: string,
@Query('interval') interval?: 'minute' | 'hour' | 'day',
) {
const history = await this.getPriceQuery.getPriceHistory(
new Date(startTime),
new Date(endTime),
interval ?? 'hour',
);
return { data: history };
}
@Get('kline')
@ApiOperation({ summary: '获取K线数据' })
@ApiQuery({ name: 'startTime', required: true, description: '开始时间 (ISO 8601)' })
@ApiQuery({ name: 'endTime', required: true, description: '结束时间 (ISO 8601)' })
@ApiQuery({ name: 'interval', required: false, enum: ['minute', 'hour', 'day'], description: '时间间隔' })
@ApiResponse({ status: 200, description: 'K线数据' })
async getKLineData(
@Query('startTime') startTime: string,
@Query('endTime') endTime: string,
@Query('interval') interval?: 'minute' | 'hour' | 'day',
) {
const kLine = await this.getPriceQuery.getKLineData(
new Date(startTime),
new Date(endTime),
interval ?? 'hour',
);
return { data: kLine };
}
}

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { ApiModule } from './api/api.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { ApplicationModule } from './application/application.module';
import { DomainExceptionFilter } from './shared/filters/domain-exception.filter';
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
`.env.${process.env.NODE_ENV || 'development'}`,
'.env',
],
ignoreEnvFile: false,
}),
InfrastructureModule,
ApplicationModule,
ApiModule,
],
providers: [
{
provide: APP_FILTER,
useClass: DomainExceptionFilter,
},
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
// Services
import { MiningDistributionService } from './services/mining-distribution.service';
import { ContributionSyncService } from './services/contribution-sync.service';
// Queries
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
import { GetMiningStatsQuery } from './queries/get-mining-stats.query';
import { GetPriceQuery } from './queries/get-price.query';
// Event Handlers
import { ContributionEventHandler } from './event-handlers/contribution-event.handler';
// Schedulers
import { MiningScheduler } from './schedulers/mining.scheduler';
@Module({
imports: [ScheduleModule.forRoot(), InfrastructureModule],
providers: [
// Services
MiningDistributionService,
ContributionSyncService,
// Queries
GetMiningAccountQuery,
GetMiningStatsQuery,
GetPriceQuery,
// Event Handlers
ContributionEventHandler,
// Schedulers
MiningScheduler,
],
exports: [
MiningDistributionService,
ContributionSyncService,
GetMiningAccountQuery,
GetMiningStatsQuery,
GetPriceQuery,
],
})
export class ApplicationModule {}

View File

@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { ContributionSyncService } from '../services/contribution-sync.service';
@Injectable()
export class ContributionEventHandler {
private readonly logger = new Logger(ContributionEventHandler.name);
constructor(private readonly syncService: ContributionSyncService) {}
@EventPattern('contribution.ContributionCalculated')
async handleContributionCalculated(@Payload() message: any): Promise<void> {
try {
const { payload } = message.value || message;
this.logger.debug(`Received ContributionCalculated event for ${payload.accountSequence}`);
await this.syncService.handleContributionCalculated({
accountSequence: payload.accountSequence,
personalContribution: payload.personalContribution,
calculatedAt: payload.calculatedAt,
});
} catch (error) {
this.logger.error('Failed to handle ContributionCalculated event', error);
}
}
@EventPattern('contribution.DailySnapshotCreated')
async handleDailySnapshotCreated(@Payload() message: any): Promise<void> {
try {
const { payload } = message.value || message;
this.logger.log(`Received DailySnapshotCreated event for ${payload.snapshotDate}`);
await this.syncService.handleDailySnapshotCreated({
snapshotId: payload.snapshotId,
snapshotDate: payload.snapshotDate,
totalContribution: payload.totalContribution,
activeAccounts: payload.activeAccounts,
});
} catch (error) {
this.logger.error('Failed to handle DailySnapshotCreated event', error);
}
}
}

View File

@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { MiningAccountRepository } from '../../infrastructure/persistence/repositories/mining-account.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
export interface MiningAccountDto {
accountSequence: string;
totalMined: string;
availableBalance: string;
frozenBalance: string;
totalBalance: string;
totalContribution: string;
lastSyncedAt: Date | null;
}
export interface MiningRecordDto {
id: string;
miningMinute: Date;
contributionRatio: string;
totalContribution: string;
minuteDistribution: string;
minedAmount: string;
createdAt: Date;
}
export interface MiningTransactionDto {
id: string;
type: string;
amount: string;
balanceBefore: string;
balanceAfter: string;
referenceId: string | null;
referenceType: string | null;
description: string | null;
createdAt: Date;
}
@Injectable()
export class GetMiningAccountQuery {
constructor(
private readonly accountRepository: MiningAccountRepository,
private readonly prisma: PrismaService,
) {}
async execute(accountSequence: string): Promise<MiningAccountDto | null> {
const account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
return null;
}
return {
accountSequence: account.accountSequence,
totalMined: account.totalMined.toString(),
availableBalance: account.availableBalance.toString(),
frozenBalance: account.frozenBalance.toString(),
totalBalance: account.totalBalance.toString(),
totalContribution: account.totalContribution.toString(),
lastSyncedAt: account.lastSyncedAt,
};
}
async getMiningRecords(
accountSequence: string,
page: number = 1,
pageSize: number = 50,
): Promise<{ data: MiningRecordDto[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.miningRecord.findMany({
where: { accountSequence },
orderBy: { miningMinute: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.miningRecord.count({ where: { accountSequence } }),
]);
return {
data: records.map((r) => ({
id: r.id,
miningMinute: r.miningMinute,
contributionRatio: r.contributionRatio.toString(),
totalContribution: r.totalContribution.toString(),
minuteDistribution: r.minuteDistribution.toString(),
minedAmount: r.minedAmount.toString(),
createdAt: r.createdAt,
})),
total,
};
}
async getTransactions(
accountSequence: string,
page: number = 1,
pageSize: number = 50,
): Promise<{ data: MiningTransactionDto[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.miningTransaction.findMany({
where: { accountSequence },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.miningTransaction.count({ where: { accountSequence } }),
]);
return {
data: records.map((r) => ({
id: r.id,
type: r.type,
amount: r.amount.toString(),
balanceBefore: r.balanceBefore.toString(),
balanceAfter: r.balanceAfter.toString(),
referenceId: r.referenceId,
referenceType: r.referenceType,
description: r.description,
createdAt: r.createdAt,
})),
total,
};
}
}

View File

@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import { MiningAccountRepository } from '../../infrastructure/persistence/repositories/mining-account.repository';
import { MiningConfigRepository } from '../../infrastructure/persistence/repositories/mining-config.repository';
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
import { PriceSnapshotRepository } from '../../infrastructure/persistence/repositories/price-snapshot.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
export interface MiningStatsDto {
// 配置信息
isActive: boolean;
currentEra: number;
eraStartDate: Date | null;
activatedAt: Date | null;
// 分配信息
totalShares: string;
distributionPool: string;
remainingDistribution: string;
minuteDistribution: string;
// 参与信息
totalContribution: string;
participantCount: number;
totalMined: string;
// 销毁信息
burnTarget: string;
totalBurned: string;
remainingBurn: string;
// 价格信息
currentPrice: string;
priceChangePercent24h: number | null;
}
export interface MiningRankingDto {
rank: number;
accountSequence: string;
totalMined: string;
totalContribution: string;
}
@Injectable()
export class GetMiningStatsQuery {
constructor(
private readonly accountRepository: MiningAccountRepository,
private readonly configRepository: MiningConfigRepository,
private readonly blackHoleRepository: BlackHoleRepository,
private readonly priceRepository: PriceSnapshotRepository,
private readonly prisma: PrismaService,
) {}
async execute(): Promise<MiningStatsDto> {
const [config, blackHole, latestPrice, price24hAgo, totalMined, participantCount, totalContribution] =
await Promise.all([
this.configRepository.getConfig(),
this.blackHoleRepository.getBlackHole(),
this.priceRepository.getLatestSnapshot(),
this.get24hAgoPrice(),
this.getTotalMined(),
this.accountRepository.countAccountsWithContribution(),
this.accountRepository.getTotalContribution(),
]);
let priceChangePercent24h: number | null = null;
if (latestPrice && price24hAgo) {
const currentPrice = latestPrice.price.value.toNumber();
const oldPrice = price24hAgo.price.value.toNumber();
if (oldPrice > 0) {
priceChangePercent24h = ((currentPrice - oldPrice) / oldPrice) * 100;
}
}
return {
isActive: config?.isActive || false,
currentEra: config?.currentEra || 1,
eraStartDate: config?.eraStartDate || null,
activatedAt: config?.activatedAt || null,
totalShares: config?.totalShares.toString() || '0',
distributionPool: config?.distributionPool.toString() || '0',
remainingDistribution: config?.remainingDistribution.toString() || '0',
minuteDistribution: config?.minuteDistribution.toString() || '0',
totalContribution: totalContribution.toString(),
participantCount,
totalMined: totalMined.toString(),
burnTarget: blackHole?.targetBurn.toString() || '0',
totalBurned: blackHole?.totalBurned.toString() || '0',
remainingBurn: blackHole?.remainingBurn.toString() || '0',
currentPrice: latestPrice?.price.toString() || '1',
priceChangePercent24h,
};
}
async getRanking(limit: number = 100): Promise<MiningRankingDto[]> {
const topMiners = await this.accountRepository.getTopMiners(limit);
return topMiners.map((account, index) => ({
rank: index + 1,
accountSequence: account.accountSequence,
totalMined: account.totalMined.toString(),
totalContribution: account.totalContribution.toString(),
}));
}
private async getTotalMined(): Promise<string> {
const result = await this.prisma.miningAccount.aggregate({
_sum: { totalMined: true },
});
return (result._sum.totalMined || 0).toString();
}
private async get24hAgoPrice() {
const time24hAgo = new Date();
time24hAgo.setHours(time24hAgo.getHours() - 24);
return this.priceRepository.getSnapshotAt(time24hAgo);
}
}

View File

@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { PriceSnapshotRepository, PriceSnapshotEntity } from '../../infrastructure/persistence/repositories/price-snapshot.repository';
export interface PriceDto {
snapshotTime: Date;
price: string;
sharePool: string;
blackHoleAmount: string;
circulationPool: string;
effectiveDenominator: string;
}
export interface KLineDataDto {
time: Date;
open: string;
high: string;
low: string;
close: string;
}
@Injectable()
export class GetPriceQuery {
constructor(private readonly priceRepository: PriceSnapshotRepository) {}
async getCurrentPrice(): Promise<PriceDto | null> {
const snapshot = await this.priceRepository.getLatestSnapshot();
if (!snapshot) {
return null;
}
return this.toDto(snapshot);
}
async getPriceAt(time: Date): Promise<PriceDto | null> {
const snapshot = await this.priceRepository.getSnapshotAt(time);
if (!snapshot) {
return null;
}
return this.toDto(snapshot);
}
async getPriceHistory(
startTime: Date,
endTime: Date,
interval: 'minute' | 'hour' | 'day',
): Promise<PriceDto[]> {
const snapshots = await this.priceRepository.getPriceHistory(startTime, endTime, interval);
return snapshots.map((s) => this.toDto(s));
}
async getKLineData(
startTime: Date,
endTime: Date,
interval: 'minute' | 'hour' | 'day',
): Promise<KLineDataDto[]> {
const snapshots = await this.priceRepository.getPriceHistory(startTime, endTime, 'minute');
// 按间隔分组
const grouped = new Map<string, PriceSnapshotEntity[]>();
for (const snapshot of snapshots) {
let key: string;
if (interval === 'minute') {
key = snapshot.snapshotTime.toISOString().substring(0, 16);
} else if (interval === 'hour') {
key = snapshot.snapshotTime.toISOString().substring(0, 13);
} else {
key = snapshot.snapshotTime.toISOString().substring(0, 10);
}
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)!.push(snapshot);
}
// 生成 K 线数据
const kLineData: KLineDataDto[] = [];
for (const [key, items] of grouped) {
if (items.length === 0) continue;
const prices = items.map((i) => i.price.value.toNumber());
const times = items.map((i) => i.snapshotTime);
kLineData.push({
time: times[0],
open: items[0].price.toString(),
high: Math.max(...prices).toString(),
low: Math.min(...prices).toString(),
close: items[items.length - 1].price.toString(),
});
}
return kLineData.sort((a, b) => a.time.getTime() - b.time.getTime());
}
private toDto(snapshot: PriceSnapshotEntity): PriceDto {
return {
snapshotTime: snapshot.snapshotTime,
price: snapshot.price.toString(),
sharePool: snapshot.sharePool.toString(),
blackHoleAmount: snapshot.blackHoleAmount.toString(),
circulationPool: snapshot.circulationPool.toString(),
effectiveDenominator: snapshot.effectiveDenominator.toString(),
};
}
}

View File

@ -0,0 +1,113 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MiningDistributionService } from '../services/mining-distribution.service';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
@Injectable()
export class MiningScheduler implements OnModuleInit {
private readonly logger = new Logger(MiningScheduler.name);
constructor(
private readonly distributionService: MiningDistributionService,
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
onModuleInit() {
this.logger.log('Mining scheduler initialized');
}
/**
*
*/
@Cron(CronExpression.EVERY_MINUTE)
async executeMinuteDistribution(): Promise<void> {
try {
await this.distributionService.executeMinuteDistribution();
} catch (error) {
this.logger.error('Failed to execute minute distribution', error);
}
}
/**
* 0
*/
@Cron('0 0 * * *')
async generateDailyStats(): Promise<void> {
const lockValue = await this.redis.acquireLock('mining:daily-stats:lock', 300);
if (!lockValue) {
return;
}
try {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const endOfYesterday = new Date(yesterday);
endOfYesterday.setHours(23, 59, 59, 999);
// 聚合昨天的分钟统计
const minuteStats = await this.prisma.minuteMiningStat.aggregate({
where: {
minute: {
gte: yesterday,
lte: endOfYesterday,
},
},
_sum: {
totalDistributed: true,
burnAmount: true,
},
_avg: {
totalContribution: true,
},
_max: {
participantCount: true,
},
});
// 创建每日统计
await this.prisma.dailyMiningStat.create({
data: {
date: yesterday,
totalContribution: minuteStats._avg.totalContribution || 0,
totalDistributed: minuteStats._sum.totalDistributed || 0,
totalBurned: minuteStats._sum.burnAmount || 0,
participantCount: minuteStats._max.participantCount || 0,
avgContributionRate: 0, // TODO: 计算
},
});
this.logger.log(`Daily stats generated for ${yesterday.toISOString().split('T')[0]}`);
} catch (error) {
this.logger.error('Failed to generate daily stats', error);
} finally {
await this.redis.releaseLock('mining:daily-stats:lock', lockValue);
}
}
/**
* 7
*/
@Cron('0 * * * *')
async cleanupOldMinuteStats(): Promise<void> {
try {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const result = await this.prisma.minuteMiningStat.deleteMany({
where: {
minute: { lt: sevenDaysAgo },
},
});
if (result.count > 0) {
this.logger.log(`Cleaned up ${result.count} old minute stats`);
}
} catch (error) {
this.logger.error('Failed to cleanup old minute stats', error);
}
}
}

View File

@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MiningAccountRepository } from '../../infrastructure/persistence/repositories/mining-account.repository';
import { MiningAccountAggregate } from '../../domain/aggregates/mining-account.aggregate';
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
import { RedisService } from '../../infrastructure/redis/redis.service';
interface ContributionData {
accountSequence: string;
totalContribution: string;
}
/**
*
* contribution-service
*/
@Injectable()
export class ContributionSyncService {
private readonly logger = new Logger(ContributionSyncService.name);
private readonly LOCK_KEY = 'mining:sync:lock';
constructor(
private readonly miningAccountRepository: MiningAccountRepository,
private readonly redis: RedisService,
private readonly configService: ConfigService,
) {}
/**
*
*/
async handleContributionCalculated(data: {
accountSequence: string;
personalContribution: string;
calculatedAt: string;
}): Promise<void> {
try {
// 获取或创建挖矿账户
let account = await this.miningAccountRepository.findByAccountSequence(data.accountSequence);
if (!account) {
account = MiningAccountAggregate.create(data.accountSequence);
}
// 更新算力
const contribution = new ShareAmount(data.personalContribution);
account.updateContribution(contribution);
await this.miningAccountRepository.save(account);
this.logger.debug(
`Updated contribution for ${data.accountSequence}: ${data.personalContribution}`,
);
} catch (error) {
this.logger.error(`Failed to handle contribution calculated event`, error);
throw error;
}
}
/**
*
*
*/
async handleDailySnapshotCreated(data: {
snapshotId: string;
snapshotDate: string;
totalContribution: string;
activeAccounts: number;
}): Promise<void> {
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 300);
if (!lockValue) {
this.logger.debug('Another instance is syncing contributions');
return;
}
try {
this.logger.log(`Starting contribution sync for snapshot ${data.snapshotId}`);
// 调用 contribution-service API 批量获取用户算力
const contributionServiceUrl = this.configService.get<string>(
'CONTRIBUTION_SERVICE_URL',
'http://localhost:3020',
);
let page = 1;
const pageSize = 1000;
let syncedCount = 0;
while (true) {
// 获取算力数据
const response = await this.fetchContributionRatios(
contributionServiceUrl,
data.snapshotDate,
page,
pageSize,
);
if (!response || response.data.length === 0) break;
// 批量更新
for (const item of response.data) {
await this.syncAccountContribution(item.accountSequence, item.totalContribution);
syncedCount++;
}
if (response.data.length < pageSize) break;
page++;
}
this.logger.log(`Contribution sync completed: synced ${syncedCount} accounts`);
} catch (error) {
this.logger.error('Failed to sync contributions from daily snapshot', error);
} finally {
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
}
}
/**
* contribution-service
*/
private async fetchContributionRatios(
baseUrl: string,
snapshotDate: string,
page: number,
pageSize: number,
): Promise<{ data: ContributionData[]; total: number } | null> {
try {
const url = `${baseUrl}/api/v1/snapshots/${snapshotDate}/ratios?page=${page}&pageSize=${pageSize}`;
const response = await fetch(url);
if (!response.ok) {
this.logger.error(`Failed to fetch contribution ratios: ${response.status}`);
return null;
}
const result = await response.json();
return result.data;
} catch (error) {
this.logger.error('Failed to fetch contribution ratios', error);
return null;
}
}
/**
*
*/
private async syncAccountContribution(accountSequence: string, contribution: string): Promise<void> {
let account = await this.miningAccountRepository.findByAccountSequence(accountSequence);
if (!account) {
account = MiningAccountAggregate.create(accountSequence);
}
account.updateContribution(new ShareAmount(contribution));
await this.miningAccountRepository.save(account);
}
}

View File

@ -0,0 +1,240 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MiningAccountRepository } from '../../infrastructure/persistence/repositories/mining-account.repository';
import { MiningConfigRepository } from '../../infrastructure/persistence/repositories/mining-config.repository';
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
import { PriceSnapshotRepository } from '../../infrastructure/persistence/repositories/price-snapshot.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.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 { Price } from '../../domain/value-objects/price.vo';
/**
*
*
*/
@Injectable()
export class MiningDistributionService {
private readonly logger = new Logger(MiningDistributionService.name);
private readonly calculator = new MiningCalculatorService();
private readonly LOCK_KEY = 'mining:distribution:lock';
constructor(
private readonly miningAccountRepository: MiningAccountRepository,
private readonly miningConfigRepository: MiningConfigRepository,
private readonly blackHoleRepository: BlackHoleRepository,
private readonly priceSnapshotRepository: PriceSnapshotRepository,
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly configService: ConfigService,
) {}
/**
*
*/
async executeMinuteDistribution(): Promise<void> {
// 获取分布式锁
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
if (!lockValue) {
this.logger.debug('Another instance is processing distribution');
return;
}
try {
const config = await this.miningConfigRepository.getConfig();
if (!config || !config.isActive) {
this.logger.debug('Mining is not active');
return;
}
const currentMinute = this.getCurrentMinute();
// 检查是否已处理过这一分钟
const processedKey = `mining:processed:${currentMinute.toISOString()}`;
if (await this.redis.get(processedKey)) {
return;
}
// 计算每分钟分配量
const remainingMinutes = this.calculator.calculateRemainingMinutes(
config.eraStartDate,
MiningCalculatorService.HALVING_PERIOD_MINUTES,
);
const minuteDistribution = this.calculator.calculateMinuteDistribution(
config.remainingDistribution,
config.currentEra,
remainingMinutes,
);
if (minuteDistribution.isZero()) {
this.logger.debug('No distribution available');
return;
}
// 获取有算力的账户
const totalContribution = await this.miningAccountRepository.getTotalContribution();
if (totalContribution.isZero()) {
this.logger.debug('No contribution available');
return;
}
// 分批处理账户
let page = 1;
const pageSize = 1000;
let totalDistributed = ShareAmount.zero();
let participantCount = 0;
while (true) {
const { data: accounts, total } = await this.miningAccountRepository.findAllWithContribution(page, pageSize);
if (accounts.length === 0) break;
for (const account of accounts) {
const reward = this.calculator.calculateUserMiningReward(
account.totalContribution,
totalContribution,
minuteDistribution,
);
if (!reward.isZero()) {
account.mine(reward, `分钟挖矿 ${currentMinute.toISOString()}`);
await this.miningAccountRepository.save(account);
// 保存挖矿记录
await this.prisma.miningRecord.create({
data: {
accountSequence: account.accountSequence,
miningMinute: currentMinute,
contributionRatio: account.totalContribution.value.dividedBy(totalContribution.value),
totalContribution: totalContribution.value,
minuteDistribution: minuteDistribution.value,
minedAmount: reward.value,
},
});
totalDistributed = totalDistributed.add(reward);
participantCount++;
}
}
if (page * pageSize >= total) break;
page++;
}
// 执行销毁
const burnAmount = await this.executeBurn(currentMinute);
// 更新配置
const newRemaining = config.remainingDistribution.subtract(totalDistributed);
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
// 保存分钟统计
await this.prisma.minuteMiningStat.create({
data: {
minute: currentMinute,
totalContribution: totalContribution.value,
totalDistributed: totalDistributed.value,
participantCount,
burnAmount: burnAmount.value,
},
});
// 保存价格快照
await this.savePriceSnapshot(currentMinute);
// 标记已处理
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) {
this.logger.error('Failed to execute minute distribution', error);
throw error;
} finally {
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
}
}
/**
*
*/
private async executeBurn(burnMinute: Date): Promise<ShareAmount> {
const blackHole = await this.blackHoleRepository.getBlackHole();
if (!blackHole) {
return ShareAmount.zero();
}
if (blackHole.remainingBurn.isZero()) {
return ShareAmount.zero();
}
const config = await this.miningConfigRepository.getConfig();
if (!config) {
return ShareAmount.zero();
}
// 计算剩余销毁分钟数(使用整个挖矿周期)
const totalBurnMinutes = 10 * 365 * 24 * 60; // 10年
const remainingMinutes = this.calculator.calculateRemainingBurnMinutes(
config.activatedAt || new Date(),
totalBurnMinutes,
);
const burnAmount = this.calculator.calculateMinuteBurn(
blackHole.targetBurn,
blackHole.totalBurned,
remainingMinutes,
);
if (!burnAmount.isZero()) {
await this.blackHoleRepository.recordBurn(burnMinute, burnAmount);
}
return burnAmount;
}
/**
*
*/
private async savePriceSnapshot(snapshotTime: Date): Promise<void> {
const blackHole = await this.blackHoleRepository.getBlackHole();
// 获取流通池数据(需要从 trading-service 获取,这里简化处理)
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 {
const now = new Date();
now.setSeconds(0);
now.setMilliseconds(0);
return now;
}
}

View File

@ -0,0 +1,271 @@
import { ShareAmount } from '../value-objects/share-amount.vo';
export enum MiningTransactionType {
MINE = 'MINE',
FREEZE = 'FREEZE',
UNFREEZE = 'UNFREEZE',
TRANSFER_OUT = 'TRANSFER_OUT',
TRANSFER_IN = 'TRANSFER_IN',
BURN = 'BURN',
}
export interface MiningTransaction {
type: MiningTransactionType;
amount: ShareAmount;
balanceBefore: ShareAmount;
balanceAfter: ShareAmount;
referenceId?: string;
referenceType?: string;
description?: string;
createdAt: Date;
}
/**
*
*/
export class MiningAccountAggregate {
private _id: string | null;
private _accountSequence: string;
private _totalMined: ShareAmount;
private _availableBalance: ShareAmount;
private _frozenBalance: ShareAmount;
private _totalContribution: ShareAmount;
private _lastSyncedAt: Date | null;
private _pendingTransactions: MiningTransaction[] = [];
private constructor(
accountSequence: string,
totalMined: ShareAmount,
availableBalance: ShareAmount,
frozenBalance: ShareAmount,
totalContribution: ShareAmount,
lastSyncedAt: Date | null,
id: string | null = null,
) {
this._id = id;
this._accountSequence = accountSequence;
this._totalMined = totalMined;
this._availableBalance = availableBalance;
this._frozenBalance = frozenBalance;
this._totalContribution = totalContribution;
this._lastSyncedAt = lastSyncedAt;
}
static create(accountSequence: string): MiningAccountAggregate {
return new MiningAccountAggregate(
accountSequence,
ShareAmount.zero(),
ShareAmount.zero(),
ShareAmount.zero(),
ShareAmount.zero(),
null,
);
}
static reconstitute(props: {
id: string;
accountSequence: string;
totalMined: ShareAmount;
availableBalance: ShareAmount;
frozenBalance: ShareAmount;
totalContribution: ShareAmount;
lastSyncedAt: Date | null;
}): MiningAccountAggregate {
return new MiningAccountAggregate(
props.accountSequence,
props.totalMined,
props.availableBalance,
props.frozenBalance,
props.totalContribution,
props.lastSyncedAt,
props.id,
);
}
// Getters
get id(): string | null {
return this._id;
}
get accountSequence(): string {
return this._accountSequence;
}
get totalMined(): ShareAmount {
return this._totalMined;
}
get availableBalance(): ShareAmount {
return this._availableBalance;
}
get frozenBalance(): ShareAmount {
return this._frozenBalance;
}
get totalBalance(): ShareAmount {
return this._availableBalance.add(this._frozenBalance);
}
get totalContribution(): ShareAmount {
return this._totalContribution;
}
get lastSyncedAt(): Date | null {
return this._lastSyncedAt;
}
get pendingTransactions(): MiningTransaction[] {
return [...this._pendingTransactions];
}
/**
*
*/
mine(amount: ShareAmount, description?: string): void {
if (amount.isZero()) return;
const balanceBefore = this._availableBalance;
this._totalMined = this._totalMined.add(amount);
this._availableBalance = this._availableBalance.add(amount);
this._pendingTransactions.push({
type: MiningTransactionType.MINE,
amount,
balanceBefore,
balanceAfter: this._availableBalance,
description: description || '挖矿收入',
createdAt: new Date(),
});
}
/**
*
*/
freeze(amount: ShareAmount, referenceId: string, referenceType: string): void {
if (this._availableBalance.isLessThan(amount)) {
throw new Error('Insufficient available balance to freeze');
}
const balanceBefore = this._availableBalance;
this._availableBalance = this._availableBalance.subtract(amount);
this._frozenBalance = this._frozenBalance.add(amount);
this._pendingTransactions.push({
type: MiningTransactionType.FREEZE,
amount,
balanceBefore,
balanceAfter: this._availableBalance,
referenceId,
referenceType,
description: '冻结余额',
createdAt: new Date(),
});
}
/**
*
*/
unfreeze(amount: ShareAmount, referenceId: string, referenceType: string): void {
if (this._frozenBalance.isLessThan(amount)) {
throw new Error('Insufficient frozen balance to unfreeze');
}
const balanceBefore = this._availableBalance;
this._frozenBalance = this._frozenBalance.subtract(amount);
this._availableBalance = this._availableBalance.add(amount);
this._pendingTransactions.push({
type: MiningTransactionType.UNFREEZE,
amount,
balanceBefore,
balanceAfter: this._availableBalance,
referenceId,
referenceType,
description: '解冻余额',
createdAt: new Date(),
});
}
/**
*
*/
transferOut(amount: ShareAmount, referenceId: string, referenceType: string, fromFrozen: boolean = true): void {
const balanceBefore = this._availableBalance;
if (fromFrozen) {
if (this._frozenBalance.isLessThan(amount)) {
throw new Error('Insufficient frozen balance to transfer');
}
this._frozenBalance = this._frozenBalance.subtract(amount);
} else {
if (this._availableBalance.isLessThan(amount)) {
throw new Error('Insufficient available balance to transfer');
}
this._availableBalance = this._availableBalance.subtract(amount);
}
this._pendingTransactions.push({
type: MiningTransactionType.TRANSFER_OUT,
amount,
balanceBefore,
balanceAfter: this._availableBalance,
referenceId,
referenceType,
description: '转出',
createdAt: new Date(),
});
}
/**
*
*/
transferIn(amount: ShareAmount, referenceId: string, referenceType: string): void {
const balanceBefore = this._availableBalance;
this._availableBalance = this._availableBalance.add(amount);
this._pendingTransactions.push({
type: MiningTransactionType.TRANSFER_IN,
amount,
balanceBefore,
balanceAfter: this._availableBalance,
referenceId,
referenceType,
description: '转入',
createdAt: new Date(),
});
}
/**
*
*/
updateContribution(contribution: ShareAmount): void {
this._totalContribution = contribution;
this._lastSyncedAt = new Date();
}
/**
*
*/
clearPendingTransactions(): void {
this._pendingTransactions = [];
}
toSnapshot(): {
accountSequence: string;
totalMined: ShareAmount;
availableBalance: ShareAmount;
frozenBalance: ShareAmount;
totalContribution: ShareAmount;
lastSyncedAt: Date | null;
} {
return {
accountSequence: this._accountSequence,
totalMined: this._totalMined,
availableBalance: this._availableBalance,
frozenBalance: this._frozenBalance,
totalContribution: this._totalContribution,
lastSyncedAt: this._lastSyncedAt,
};
}
}

View File

@ -0,0 +1,145 @@
import Decimal from 'decimal.js';
import { ShareAmount } from '../value-objects/share-amount.vo';
import { Price } from '../value-objects/price.vo';
/**
*
*/
export class MiningCalculatorService {
// 总积分股数量: 100.02B
static readonly TOTAL_SHARES = new ShareAmount('100020000000');
// 初始分配池: 200M
static readonly INITIAL_DISTRIBUTION_POOL = new ShareAmount('200000000');
// 目标销毁量: 10B
static readonly BURN_TARGET = new ShareAmount('10000000000');
// 减半周期: 2年 (分钟)
static readonly HALVING_PERIOD_MINUTES = 2 * 365 * 24 * 60;
/**
*
* @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);
}
/**
*
*
*/
calculateEraDistribution(eraNumber: number): ShareAmount {
// 第1纪元: 100M, 第2纪元: 50M, 第3纪元: 25M...
const divisor = new Decimal(2).pow(eraNumber - 1);
const initialEraAmount = MiningCalculatorService.INITIAL_DISTRIBUTION_POOL.divide(2);
return initialEraAmount.divide(divisor);
}
/**
*
* @param userContribution
* @param totalContribution
* @param minuteDistribution
*/
calculateUserMiningReward(
userContribution: ShareAmount,
totalContribution: ShareAmount,
minuteDistribution: ShareAmount,
): ShareAmount {
if (totalContribution.isZero() || userContribution.isZero()) {
return ShareAmount.zero();
}
// 用户收益 = 每分钟分配量 * (用户算力 / 总算力)
const ratio = userContribution.value.dividedBy(totalContribution.value);
return minuteDistribution.multiply(ratio);
}
/**
*
* 设计目标: 假设只有黑洞和股池,1
* minuteBurn = (burnTarget - currentBurned) / remainingMinutes
*/
calculateMinuteBurn(
burnTarget: ShareAmount,
currentBurned: ShareAmount,
remainingMinutes: number,
): ShareAmount {
if (remainingMinutes <= 0) {
return ShareAmount.zero();
}
const remaining = burnTarget.subtract(currentBurned);
if (remaining.isZero()) {
return ShareAmount.zero();
}
return remaining.divide(remainingMinutes);
}
/**
*
* price = sharePool / (totalShares - blackHole - circulationPool)
*/
calculatePrice(
sharePool: ShareAmount,
blackHole: ShareAmount,
circulationPool: ShareAmount,
): Price {
return Price.calculate(
sharePool,
MiningCalculatorService.TOTAL_SHARES,
blackHole,
circulationPool,
);
}
/**
* ,
*/
calculateTheoreticalPrice(
sharePool: ShareAmount,
blackHole: ShareAmount,
): Price {
return Price.calculate(
sharePool,
MiningCalculatorService.TOTAL_SHARES,
blackHole,
ShareAmount.zero(),
);
}
/**
*
*/
calculateRemainingMinutes(eraStartDate: Date, halvingPeriodMinutes: number): number {
const now = new Date();
const elapsedMs = now.getTime() - eraStartDate.getTime();
const elapsedMinutes = Math.floor(elapsedMs / 60000);
return Math.max(0, halvingPeriodMinutes - elapsedMinutes);
}
/**
*
*
*/
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);
}
}

View File

@ -0,0 +1,66 @@
import Decimal from 'decimal.js';
import { ShareAmount } from './share-amount.vo';
/**
*
*/
export class Price {
private readonly _value: Decimal;
constructor(value: Decimal | string | number) {
if (value instanceof Decimal) {
this._value = value;
} else {
this._value = new Decimal(value);
}
if (this._value.isNegative()) {
throw new Error('Price cannot be negative');
}
}
get value(): Decimal {
return this._value;
}
/**
*
* price = sharePool / (totalShares - blackHole - circulationPool)
*/
static calculate(
sharePool: ShareAmount,
totalShares: ShareAmount,
blackHole: ShareAmount,
circulationPool: ShareAmount,
): Price {
const denominator = totalShares.value
.minus(blackHole.value)
.minus(circulationPool.value);
if (denominator.isZero() || denominator.isNegative()) {
throw new Error('Invalid price calculation: denominator is zero or negative');
}
return new Price(sharePool.value.dividedBy(denominator));
}
multiply(amount: ShareAmount): ShareAmount {
return new ShareAmount(this._value.times(amount.value));
}
toFixed(decimals: number = 18): string {
return this._value.toFixed(decimals);
}
toString(): string {
return this._value.toString();
}
equals(other: Price): boolean {
return this._value.equals(other._value);
}
isGreaterThan(other: Price): boolean {
return this._value.greaterThan(other._value);
}
}

View File

@ -0,0 +1,81 @@
import Decimal from 'decimal.js';
/**
*
*
*/
export class ShareAmount {
private readonly _value: Decimal;
constructor(value: Decimal | string | number) {
if (value instanceof Decimal) {
this._value = value;
} else {
this._value = new Decimal(value);
}
if (this._value.isNegative()) {
throw new Error('Share amount cannot be negative');
}
}
get value(): Decimal {
return this._value;
}
add(other: ShareAmount): ShareAmount {
return new ShareAmount(this._value.plus(other._value));
}
subtract(other: ShareAmount): ShareAmount {
const result = this._value.minus(other._value);
if (result.isNegative()) {
throw new Error('Insufficient share amount');
}
return new ShareAmount(result);
}
multiply(factor: Decimal | string | number): ShareAmount {
return new ShareAmount(this._value.times(factor));
}
divide(divisor: Decimal | string | number): ShareAmount {
return new ShareAmount(this._value.dividedBy(divisor));
}
isZero(): boolean {
return this._value.isZero();
}
isGreaterThan(other: ShareAmount): boolean {
return this._value.greaterThan(other._value);
}
isLessThan(other: ShareAmount): boolean {
return this._value.lessThan(other._value);
}
isGreaterThanOrEqual(other: ShareAmount): boolean {
return this._value.greaterThanOrEqualTo(other._value);
}
equals(other: ShareAmount): boolean {
return this._value.equals(other._value);
}
toFixed(decimals: number = 8): string {
return this._value.toFixed(decimals);
}
toString(): string {
return this._value.toString();
}
static zero(): ShareAmount {
return new ShareAmount(0);
}
static fromString(value: string): ShareAmount {
return new ShareAmount(value);
}
}

View File

@ -0,0 +1,61 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { PrismaModule } from './persistence/prisma/prisma.module';
import { MiningAccountRepository } from './persistence/repositories/mining-account.repository';
import { MiningConfigRepository } from './persistence/repositories/mining-config.repository';
import { BlackHoleRepository } from './persistence/repositories/black-hole.repository';
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
import { RedisService } from './redis/redis.service';
@Global()
@Module({
imports: [
PrismaModule,
ClientsModule.registerAsync([
{
name: 'KAFKA_CLIENT',
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'mining-service',
brokers: configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
},
producer: {
allowAutoTopicCreation: true,
},
},
}),
inject: [ConfigService],
},
]),
],
providers: [
MiningAccountRepository,
MiningConfigRepository,
BlackHoleRepository,
PriceSnapshotRepository,
{
provide: 'REDIS_OPTIONS',
useFactory: (configService: ConfigService) => ({
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD'),
db: configService.get<number>('REDIS_DB', 1),
}),
inject: [ConfigService],
},
RedisService,
],
exports: [
MiningAccountRepository,
MiningConfigRepository,
BlackHoleRepository,
PriceSnapshotRepository,
RedisService,
ClientsModule,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,21 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ShareAmount } from '../../../domain/value-objects/share-amount.vo';
export interface BlackHoleEntity {
id: string;
totalBurned: ShareAmount;
targetBurn: ShareAmount;
remainingBurn: ShareAmount;
lastBurnMinute: Date | null;
}
export interface BurnRecordEntity {
id: string;
blackHoleId: string;
burnMinute: Date;
burnAmount: ShareAmount;
remainingTarget: ShareAmount;
}
@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: ShareAmount): Promise<void> {
const existing = await this.prisma.blackHole.findFirst();
if (existing) {
return;
}
await this.prisma.blackHole.create({
data: {
totalBurned: 0,
targetBurn: targetBurn.value,
remainingBurn: targetBurn.value,
},
});
}
async recordBurn(burnMinute: Date, burnAmount: ShareAmount): Promise<void> {
const blackHole = await this.prisma.blackHole.findFirst();
if (!blackHole) {
throw new Error('Black hole not initialized');
}
const newTotalBurned = new ShareAmount(blackHole.totalBurned).add(burnAmount);
const newRemainingBurn = new ShareAmount(blackHole.targetBurn).subtract(newTotalBurned);
await this.prisma.$transaction([
this.prisma.blackHole.update({
where: { id: blackHole.id },
data: {
totalBurned: newTotalBurned.value,
remainingBurn: newRemainingBurn.value,
lastBurnMinute: burnMinute,
},
}),
this.prisma.burnRecord.create({
data: {
blackHoleId: blackHole.id,
burnMinute,
burnAmount: burnAmount.value,
remainingTarget: newRemainingBurn.value,
},
}),
]);
}
async getBurnRecords(page: number, pageSize: number): Promise<{
data: BurnRecordEntity[];
total: number;
}> {
const [records, total] = await Promise.all([
this.prisma.burnRecord.findMany({
orderBy: { burnMinute: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.burnRecord.count(),
]);
return {
data: records.map((r) => ({
id: r.id,
blackHoleId: r.blackHoleId,
burnMinute: r.burnMinute,
burnAmount: new ShareAmount(r.burnAmount),
remainingTarget: new ShareAmount(r.remainingTarget),
})),
total,
};
}
private toDomain(record: any): BlackHoleEntity {
return {
id: record.id,
totalBurned: new ShareAmount(record.totalBurned),
targetBurn: new ShareAmount(record.targetBurn),
remainingBurn: new ShareAmount(record.remainingBurn),
lastBurnMinute: record.lastBurnMinute,
};
}
}

View File

@ -0,0 +1,137 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { MiningAccountAggregate, MiningTransactionType } from '../../../domain/aggregates/mining-account.aggregate';
import { ShareAmount } from '../../../domain/value-objects/share-amount.vo';
@Injectable()
export class MiningAccountRepository {
constructor(private readonly prisma: PrismaService) {}
async findByAccountSequence(accountSequence: string): Promise<MiningAccountAggregate | null> {
const record = await this.prisma.miningAccount.findUnique({
where: { accountSequence },
});
if (!record) {
return null;
}
return this.toDomain(record);
}
async findManyByAccountSequences(accountSequences: string[]): Promise<Map<string, MiningAccountAggregate>> {
const records = await this.prisma.miningAccount.findMany({
where: { accountSequence: { in: accountSequences } },
});
const result = new Map<string, MiningAccountAggregate>();
for (const record of records) {
result.set(record.accountSequence, this.toDomain(record));
}
return result;
}
async save(aggregate: MiningAccountAggregate): Promise<void> {
const snapshot = aggregate.toSnapshot();
const transactions = aggregate.pendingTransactions;
await this.prisma.$transaction(async (tx) => {
// 保存账户
await tx.miningAccount.upsert({
where: { accountSequence: snapshot.accountSequence },
create: {
accountSequence: snapshot.accountSequence,
totalMined: snapshot.totalMined.value,
availableBalance: snapshot.availableBalance.value,
frozenBalance: snapshot.frozenBalance.value,
totalContribution: snapshot.totalContribution.value,
lastSyncedAt: snapshot.lastSyncedAt,
},
update: {
totalMined: snapshot.totalMined.value,
availableBalance: snapshot.availableBalance.value,
frozenBalance: snapshot.frozenBalance.value,
totalContribution: snapshot.totalContribution.value,
lastSyncedAt: snapshot.lastSyncedAt,
},
});
// 保存交易流水
if (transactions.length > 0) {
await tx.miningTransaction.createMany({
data: transactions.map((t) => ({
accountSequence: snapshot.accountSequence,
type: t.type,
amount: t.amount.value,
balanceBefore: t.balanceBefore.value,
balanceAfter: t.balanceAfter.value,
referenceId: t.referenceId,
referenceType: t.referenceType,
description: t.description,
})),
});
}
});
aggregate.clearPendingTransactions();
}
async findAllWithContribution(page: number, pageSize: number): Promise<{
data: MiningAccountAggregate[];
total: number;
}> {
const [records, total] = await Promise.all([
this.prisma.miningAccount.findMany({
where: { totalContribution: { gt: 0 } },
orderBy: { totalContribution: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.miningAccount.count({
where: { totalContribution: { gt: 0 } },
}),
]);
return {
data: records.map((r) => this.toDomain(r)),
total,
};
}
async getTotalContribution(): Promise<ShareAmount> {
const result = await this.prisma.miningAccount.aggregate({
_sum: { totalContribution: true },
});
return new ShareAmount(result._sum.totalContribution || 0);
}
async countAccountsWithContribution(): Promise<number> {
return this.prisma.miningAccount.count({
where: { totalContribution: { gt: 0 } },
});
}
async getTopMiners(limit: number): Promise<MiningAccountAggregate[]> {
const records = await this.prisma.miningAccount.findMany({
where: { totalMined: { gt: 0 } },
orderBy: { totalMined: 'desc' },
take: limit,
});
return records.map((r) => this.toDomain(r));
}
private toDomain(record: any): MiningAccountAggregate {
return MiningAccountAggregate.reconstitute({
id: record.id,
accountSequence: record.accountSequence,
totalMined: new ShareAmount(record.totalMined),
availableBalance: new ShareAmount(record.availableBalance),
frozenBalance: new ShareAmount(record.frozenBalance),
totalContribution: new ShareAmount(record.totalContribution),
lastSyncedAt: record.lastSyncedAt,
});
}
}

View File

@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ShareAmount } from '../../../domain/value-objects/share-amount.vo';
export interface MiningConfigEntity {
id: string;
totalShares: ShareAmount;
distributionPool: ShareAmount;
remainingDistribution: ShareAmount;
halvingPeriodYears: number;
currentEra: number;
eraStartDate: Date;
minuteDistribution: ShareAmount;
isActive: boolean;
activatedAt: Date | null;
}
@Injectable()
export class MiningConfigRepository {
constructor(private readonly prisma: PrismaService) {}
async getConfig(): Promise<MiningConfigEntity | null> {
const record = await this.prisma.miningConfig.findFirst();
if (!record) {
return null;
}
return this.toDomain(record);
}
async saveConfig(config: Partial<MiningConfigEntity>): Promise<void> {
const existing = await this.prisma.miningConfig.findFirst();
if (existing) {
await this.prisma.miningConfig.update({
where: { id: existing.id },
data: {
totalShares: config.totalShares?.value,
distributionPool: config.distributionPool?.value,
remainingDistribution: config.remainingDistribution?.value,
halvingPeriodYears: config.halvingPeriodYears,
currentEra: config.currentEra,
eraStartDate: config.eraStartDate,
minuteDistribution: config.minuteDistribution?.value,
isActive: config.isActive,
activatedAt: config.activatedAt,
},
});
} else {
await this.prisma.miningConfig.create({
data: {
totalShares: config.totalShares?.value || 0,
distributionPool: config.distributionPool?.value || 0,
remainingDistribution: config.remainingDistribution?.value || 0,
halvingPeriodYears: config.halvingPeriodYears || 2,
currentEra: config.currentEra || 1,
eraStartDate: config.eraStartDate || new Date(),
minuteDistribution: config.minuteDistribution?.value || 0,
isActive: config.isActive || false,
activatedAt: config.activatedAt,
},
});
}
}
async updateRemainingDistribution(amount: ShareAmount): Promise<void> {
const existing = await this.prisma.miningConfig.findFirst();
if (!existing) {
throw new Error('Mining config not found');
}
await this.prisma.miningConfig.update({
where: { id: existing.id },
data: { remainingDistribution: amount.value },
});
}
async activate(): Promise<void> {
const existing = await this.prisma.miningConfig.findFirst();
if (!existing) {
throw new Error('Mining config not found');
}
await this.prisma.miningConfig.update({
where: { id: existing.id },
data: {
isActive: true,
activatedAt: new Date(),
eraStartDate: new Date(),
},
});
}
private toDomain(record: any): MiningConfigEntity {
return {
id: record.id,
totalShares: new ShareAmount(record.totalShares),
distributionPool: new ShareAmount(record.distributionPool),
remainingDistribution: new ShareAmount(record.remainingDistribution),
halvingPeriodYears: record.halvingPeriodYears,
currentEra: record.currentEra,
eraStartDate: record.eraStartDate,
minuteDistribution: new ShareAmount(record.minuteDistribution),
isActive: record.isActive,
activatedAt: record.activatedAt,
};
}
}

View File

@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ShareAmount } from '../../../domain/value-objects/share-amount.vo';
import { Price } from '../../../domain/value-objects/price.vo';
export interface PriceSnapshotEntity {
id: string;
snapshotTime: Date;
price: Price;
sharePool: ShareAmount;
blackHoleAmount: ShareAmount;
circulationPool: ShareAmount;
effectiveDenominator: ShareAmount;
}
@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 saveSnapshot(snapshot: Omit<PriceSnapshotEntity, 'id'>): Promise<void> {
await this.prisma.priceSnapshot.upsert({
where: { snapshotTime: snapshot.snapshotTime },
create: {
snapshotTime: snapshot.snapshotTime,
price: snapshot.price.value,
sharePool: snapshot.sharePool.value,
blackHoleAmount: snapshot.blackHoleAmount.value,
circulationPool: snapshot.circulationPool.value,
effectiveDenominator: snapshot.effectiveDenominator.value,
},
update: {
price: snapshot.price.value,
sharePool: snapshot.sharePool.value,
blackHoleAmount: snapshot.blackHoleAmount.value,
circulationPool: snapshot.circulationPool.value,
effectiveDenominator: snapshot.effectiveDenominator.value,
},
});
}
async getPriceHistory(
startTime: Date,
endTime: Date,
interval: 'minute' | 'hour' | 'day',
): Promise<PriceSnapshotEntity[]> {
const records = await this.prisma.priceSnapshot.findMany({
where: {
snapshotTime: {
gte: startTime,
lte: endTime,
},
},
orderBy: { snapshotTime: 'asc' },
});
// 根据间隔过滤数据
if (interval === 'minute') {
return records.map((r) => this.toDomain(r));
}
// 对于小时和天级别,需要聚合
const grouped = new Map<string, any>();
for (const record of records) {
let key: string;
if (interval === 'hour') {
key = record.snapshotTime.toISOString().substring(0, 13);
} else {
key = record.snapshotTime.toISOString().substring(0, 10);
}
if (!grouped.has(key) || record.snapshotTime > grouped.get(key).snapshotTime) {
grouped.set(key, record);
}
}
return Array.from(grouped.values()).map((r) => this.toDomain(r));
}
private toDomain(record: any): PriceSnapshotEntity {
return {
id: record.id,
snapshotTime: record.snapshotTime,
price: new Price(record.price),
sharePool: new ShareAmount(record.sharePool),
blackHoleAmount: new ShareAmount(record.blackHoleAmount),
circulationPool: new ShareAmount(record.circulationPool),
effectiveDenominator: new ShareAmount(record.effectiveDenominator),
};
}
}

View File

@ -0,0 +1,90 @@
import { Injectable, Inject, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import Redis from 'ioredis';
interface RedisOptions {
host: string;
port: number;
password?: string;
db?: number;
}
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client: Redis;
constructor(@Inject('REDIS_OPTIONS') private readonly options: RedisOptions) {}
async onModuleInit() {
this.client = new Redis({
host: this.options.host,
port: this.options.port,
password: this.options.password,
db: this.options.db ?? 1,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
this.client.on('error', (err) => this.logger.error('Redis error', err));
this.client.on('connect', () => this.logger.log('Connected to Redis'));
}
async onModuleDestroy() {
await this.client.quit();
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
async acquireLock(lockKey: string, ttlSeconds: number = 30): Promise<string | null> {
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const result = await this.client.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? lockValue : null;
}
async releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await this.client.eval(script, 1, lockKey, lockValue);
return result === 1;
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async incrByFloat(key: string, increment: number): Promise<string> {
return this.client.incrbyfloat(key, increment);
}
}

View File

@ -0,0 +1,66 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局验证管道
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
});
// 全局前缀
app.setGlobalPrefix('api/v1');
// Swagger 文档
const config = new DocumentBuilder()
.setTitle('Mining Service API')
.setDescription('挖矿服务 API 文档 - 积分股分配与销毁')
.setVersion('1.0')
.addBearerAuth()
.addTag('Mining', '挖矿相关')
.addTag('Price', '价格相关')
.addTag('Health', '健康检查')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// 连接 Kafka 微服务
const kafkaBrokers = process.env.KAFKA_BROKERS || 'localhost:9092';
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'mining-service',
brokers: kafkaBrokers.split(','),
},
consumer: {
groupId: 'mining-service-group',
},
},
});
await app.startAllMicroservices();
const port = process.env.PORT || 3021;
await app.listen(port);
console.log(`Mining Service is running on port ${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,64 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
export class DomainException extends Error {
constructor(
message: string,
public readonly code: string,
public readonly httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super(message);
this.name = 'DomainException';
}
}
@Catch()
export class DomainExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'INTERNAL_ERROR';
let message = 'Internal server error';
if (exception instanceof DomainException) {
status = exception.httpStatus;
code = exception.code;
message = exception.message;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || exception.message;
code = 'HTTP_ERROR';
} else {
message = exception.message;
code = 'HTTP_ERROR';
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: {
code,
message: Array.isArray(message) ? message : [message],
},
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@ -0,0 +1,54 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const secret = this.configService.get<string>('JWT_SECRET', 'default-secret');
const payload = jwt.verify(token, secret) as any;
request.user = {
userId: payload.sub,
accountSequence: payload.accountSequence,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : null;
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => {
const responseTime = Date.now() - startTime;
this.logger.log(`${method} ${url} ${responseTime}ms`);
},
error: (error) => {
const responseTime = Date.now() - startTime;
this.logger.error(`${method} ${url} ${responseTime}ms - Error: ${error.message}`);
},
}),
);
}
}

View File

@ -0,0 +1,16 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -0,0 +1,28 @@
# Application
NODE_ENV=development
PORT=3022
SERVICE_NAME=trading-service
# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/trading_db?schema=public
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=2
# Kafka
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=trading-service
KAFKA_GROUP_ID=trading-service-group
# JWT
JWT_SECRET=your-jwt-secret-key
# Mining Service
MINING_SERVICE_URL=http://localhost:3021
# Trading Configuration
MIN_TRANSFER_AMOUNT=5
TRADING_FEE_RATE=0.001

View File

@ -0,0 +1,833 @@
# Trading Service (交易服务) 开发指导
## 1. 服务概述
### 1.1 核心职责
Trading Service 负责积分股的买卖交易、K线数据生成、以及维护币价上涨机制。
**主要功能:**
- 处理积分股买卖订单
- 计算卖出销毁量(确保卖出不降价)
- 管理流通池
- 生成K线数据多周期
- 处理交易手续费
- 维护交易明细账
### 1.2 技术栈
- **框架**: NestJS + TypeScript
- **数据库**: PostgreSQL (事务型)
- **ORM**: Prisma
- **消息队列**: Kafka
- **缓存**: Redis (K线缓存、价格缓存)
### 1.3 端口分配
- HTTP: 3022
- 数据库: rwa_trading
---
## 2. 架构设计
### 2.1 目录结构
```
trading-service/
├── src/
│ ├── api/
│ │ ├── controllers/
│ │ │ ├── trade.controller.ts # 买卖API
│ │ │ ├── order.controller.ts # 订单查询API
│ │ │ ├── kline.controller.ts # K线数据API
│ │ │ ├── price.controller.ts # 价格查询API
│ │ │ └── health.controller.ts
│ │ └── dto/
│ │ ├── request/
│ │ │ ├── buy-shares.request.ts
│ │ │ ├── sell-shares.request.ts
│ │ │ └── get-kline.request.ts
│ │ └── response/
│ │ ├── trade-result.response.ts
│ │ ├── order.response.ts
│ │ ├── kline.response.ts
│ │ └── price.response.ts
│ │
│ ├── application/
│ │ ├── commands/
│ │ │ ├── buy-shares.command.ts
│ │ │ ├── sell-shares.command.ts
│ │ │ ├── cancel-order.command.ts
│ │ │ └── aggregate-kline.command.ts
│ │ ├── queries/
│ │ │ ├── get-user-orders.query.ts
│ │ │ ├── get-kline-data.query.ts
│ │ │ ├── get-current-price.query.ts
│ │ │ └── get-trade-history.query.ts
│ │ ├── services/
│ │ │ ├── trade-execution.service.ts
│ │ │ ├── sell-burn-calculator.service.ts
│ │ │ └── kline-aggregator.service.ts
│ │ ├── event-handlers/
│ │ │ ├── price-updated.handler.ts
│ │ │ └── shares-burned.handler.ts
│ │ └── schedulers/
│ │ ├── kline-aggregation.scheduler.ts # K线聚合定时器
│ │ └── price-tick.scheduler.ts # 价格记录
│ │
│ ├── domain/
│ │ ├── aggregates/
│ │ │ ├── trade-order.aggregate.ts
│ │ │ ├── kline-bar.aggregate.ts
│ │ │ └── trade-transaction.aggregate.ts
│ │ ├── repositories/
│ │ │ ├── trade-order.repository.interface.ts
│ │ │ ├── trade-transaction.repository.interface.ts
│ │ │ ├── kline.repository.interface.ts
│ │ │ └── price-tick.repository.interface.ts
│ │ ├── value-objects/
│ │ │ ├── order-type.vo.ts
│ │ │ ├── kline-period.vo.ts
│ │ │ └── trade-amount.vo.ts
│ │ ├── events/
│ │ │ ├── trade-completed.event.ts
│ │ │ ├── order-created.event.ts
│ │ │ └── kline-updated.event.ts
│ │ └── services/
│ │ ├── sell-multiplier-calculator.domain-service.ts
│ │ └── fee-calculator.domain-service.ts
│ │
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ ├── repositories/
│ │ │ │ ├── trade-order.repository.impl.ts
│ │ │ │ ├── trade-transaction.repository.impl.ts
│ │ │ │ ├── kline.repository.impl.ts
│ │ │ │ └── price-tick.repository.impl.ts
│ │ │ └── unit-of-work/
│ │ │ └── unit-of-work.service.ts
│ │ ├── kafka/
│ │ │ ├── mining-event-consumer.service.ts
│ │ │ ├── event-publisher.service.ts
│ │ │ └── kafka.module.ts
│ │ ├── redis/
│ │ │ ├── price-cache.service.ts
│ │ │ ├── kline-cache.service.ts
│ │ │ └── order-book-cache.service.ts
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/
│ ├── config/
│ ├── app.module.ts
│ └── main.ts
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── package.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml
```
---
## 3. 数据库设计
### 3.1 核心表结构
```sql
-- ============================================
-- 交易订单表
-- ============================================
CREATE TABLE trade_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_no VARCHAR(32) NOT NULL UNIQUE, -- 订单号
account_sequence VARCHAR(20) NOT NULL,
order_type VARCHAR(10) NOT NULL, -- BUY / SELL
order_status VARCHAR(20) NOT NULL, -- PENDING / COMPLETED / FAILED / CANCELLED
-- 数量
share_amount DECIMAL(30,10) NOT NULL, -- 积分股数量
burn_amount DECIMAL(30,10) DEFAULT 0, -- 卖出销毁量
effective_amount DECIMAL(30,10), -- 有效数量(含销毁)
-- 价格
price_at_order DECIMAL(30,18) NOT NULL, -- 下单时价格
execution_price DECIMAL(30,18), -- 成交价格
-- 金额
green_points_amount DECIMAL(30,10) NOT NULL, -- 绿积分金额
fee_amount DECIMAL(30,10) DEFAULT 0, -- 手续费
fee_rate DECIMAL(10,6) DEFAULT 0.10, -- 手续费率 10%
net_amount DECIMAL(30,10), -- 净额(扣除手续费后)
-- 卖出倍数(卖出时使用)
sell_multiplier DECIMAL(20,10),
-- 计算参数快照(审计用)
black_hole_at_order DECIMAL(30,10),
circulation_pool_at_order DECIMAL(30,10),
share_pool_at_order DECIMAL(30,10),
-- 版本号
version INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
executed_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_trade_orders_account ON trade_orders(account_sequence);
CREATE INDEX idx_trade_orders_status ON trade_orders(order_status);
CREATE INDEX idx_trade_orders_created ON trade_orders(created_at);
-- ============================================
-- 交易流水表(明细账)
-- ============================================
CREATE TABLE trade_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES trade_orders(id),
account_sequence VARCHAR(20) NOT NULL,
transaction_type VARCHAR(30) NOT NULL, -- SHARE_DEBIT / SHARE_CREDIT / GREEN_POINT_DEBIT / GREEN_POINT_CREDIT / FEE / BURN
asset_type VARCHAR(20) NOT NULL, -- SHARE / GREEN_POINT
amount DECIMAL(30,10) NOT NULL,
balance_before DECIMAL(30,10),
balance_after DECIMAL(30,10),
-- 关联的销毁记录如果是BURN类型
related_burn_id UUID,
memo VARCHAR(200),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_trade_transactions_order ON trade_transactions(order_id);
CREATE INDEX idx_trade_transactions_account ON trade_transactions(account_sequence);
-- ============================================
-- K线数据表
-- ============================================
CREATE TABLE kline_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
period_type VARCHAR(10) NOT NULL, -- 1m/5m/15m/30m/1h/4h/1d/1w/1M/1Q/1Y
period_start TIMESTAMP WITH TIME ZONE NOT NULL,
period_end TIMESTAMP WITH TIME ZONE NOT NULL,
-- OHLC
open_price DECIMAL(30,18) NOT NULL,
high_price DECIMAL(30,18) NOT NULL,
low_price DECIMAL(30,18) NOT NULL,
close_price DECIMAL(30,18) NOT NULL,
-- 成交量
volume DECIMAL(30,10) DEFAULT 0, -- 积分股成交量
green_points_volume DECIMAL(30,10) DEFAULT 0, -- 绿积分成交量
trade_count INT DEFAULT 0, -- 成交笔数
-- 买卖统计
buy_volume DECIMAL(30,10) DEFAULT 0,
sell_volume DECIMAL(30,10) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(period_type, period_start)
);
CREATE INDEX idx_kline_period ON kline_data(period_type, period_start);
-- ============================================
-- 价格快照表(每分钟)
-- ============================================
CREATE TABLE price_ticks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tick_time TIMESTAMP WITH TIME ZONE NOT NULL,
price DECIMAL(30,18) NOT NULL,
-- 状态快照
share_pool_green_points DECIMAL(30,10),
black_hole_amount DECIMAL(30,10),
circulation_pool DECIMAL(30,10),
effective_supply DECIMAL(30,10),
-- 该分钟内的交易统计
minute_volume DECIMAL(30,10) DEFAULT 0,
minute_trade_count INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_price_ticks_time ON price_ticks(tick_time);
-- ============================================
-- 手续费配置表
-- ============================================
CREATE TABLE fee_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fee_type VARCHAR(20) NOT NULL, -- BUY / SELL
fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0.10, -- 10%
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 初始化数据
INSERT INTO fee_configs (fee_type, fee_rate) VALUES
('BUY', 0.10),
('SELL', 0.10);
-- ============================================
-- 流通池状态表本地缓存与mining-service同步
-- ============================================
CREATE TABLE circulation_pool_state (
id UUID PRIMARY KEY DEFAULT '00000000-0000-0000-0000-000000000001',
circulation_pool DECIMAL(30,10) DEFAULT 0,
last_synced_from_mining TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================
-- 已处理事件(幂等性)
-- ============================================
CREATE TABLE processed_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(100) NOT NULL UNIQUE,
event_type VARCHAR(50) NOT NULL,
source_service VARCHAR(50),
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
---
## 4. 核心业务逻辑
### 4.1 卖出交易逻辑(核心)
```typescript
/**
* 卖出积分股
* 核心机制:通过提前销毁未来的积分股,确保卖出不会导致价格下跌
*/
async executeSellOrder(command: SellSharesCommand): Promise<TradeResult> {
const { accountSequence, shareAmount } = command;
return await this.unitOfWork.runInTransaction(async (tx) => {
// 1. 获取当前状态
const state = await this.getMiningState();
const currentPrice = new Decimal(state.currentPrice);
// 2. 计算卖出倍数
// 倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
const multiplier = this.calculateSellMultiplier(state);
// 3. 计算卖出销毁量
// 卖出销毁量 = 卖出积分股 × 倍数
const burnAmount = shareAmount.multipliedBy(multiplier);
// 4. 计算有效数量(卖出量 + 销毁量)
const effectiveAmount = shareAmount.plus(burnAmount);
// 5. 计算交易额
// 卖出交易额 = 有效数量 × 积分股价
const grossAmount = effectiveAmount.multipliedBy(currentPrice);
// 6. 计算手续费10%
const feeRate = await this.getFeeRate('SELL');
const feeAmount = grossAmount.multipliedBy(feeRate);
// 7. 计算净额
const netAmount = grossAmount.minus(feeAmount);
// 8. 验证用户余额
const shareAccount = await this.shareAccountRepo.getByAccountSequence(tx, accountSequence);
if (shareAccount.availableBalance.lessThan(shareAmount)) {
throw new InsufficientBalanceException('积分股余额不足');
}
// 9. 创建订单
const order = await this.orderRepo.create(tx, {
orderNo: generateOrderNo(),
accountSequence,
orderType: 'SELL',
orderStatus: 'COMPLETED',
shareAmount,
burnAmount,
effectiveAmount,
priceAtOrder: currentPrice,
executionPrice: currentPrice,
greenPointsAmount: grossAmount,
feeAmount,
feeRate,
netAmount,
sellMultiplier: multiplier,
blackHoleAtOrder: state.blackHoleAmount,
circulationPoolAtOrder: state.circulationPool,
sharePoolAtOrder: state.sharePoolGreenPoints,
executedAt: new Date(),
});
// 10. 扣减用户积分股
await this.shareAccountRepo.deductBalance(tx, accountSequence, shareAmount);
// 11. 记录交易流水 - 积分股扣减
await this.transactionRepo.create(tx, {
orderId: order.id,
accountSequence,
transactionType: 'SHARE_DEBIT',
assetType: 'SHARE',
amount: shareAmount,
balanceBefore: shareAccount.availableBalance,
balanceAfter: shareAccount.availableBalance.minus(shareAmount),
});
// 12. 积分股进入流通池
await this.updateCirculationPool(tx, shareAmount);
// 13. 记录交易流水 - 销毁
await this.transactionRepo.create(tx, {
orderId: order.id,
accountSequence,
transactionType: 'BURN',
assetType: 'SHARE',
amount: burnAmount,
memo: `卖出触发销毁,倍数: ${multiplier.toString()}`,
});
// 14. 从积分股池扣减绿积分给用户
await this.deductFromSharePool(tx, netAmount);
// 15. 增加用户绿积分(调用 wallet-service 或发送事件)
await this.creditGreenPoints(accountSequence, netAmount);
// 16. 记录交易流水 - 绿积分增加
await this.transactionRepo.create(tx, {
orderId: order.id,
accountSequence,
transactionType: 'GREEN_POINT_CREDIT',
assetType: 'GREEN_POINT',
amount: netAmount,
});
// 17. 手续费注入积分股池
await this.injectFeeToSharePool(tx, feeAmount);
// 18. 记录交易流水 - 手续费
await this.transactionRepo.create(tx, {
orderId: order.id,
accountSequence,
transactionType: 'FEE',
assetType: 'GREEN_POINT',
amount: feeAmount,
memo: '卖出手续费,注入积分股池',
});
// 19. 发布事件 - 触发 mining-service 执行销毁
await this.eventPublisher.publish('trading.trade-completed', {
eventId: uuid(),
orderNo: order.orderNo,
orderType: 'SELL',
accountSequence,
shareAmount: shareAmount.toString(),
burnAmount: burnAmount.toString(),
greenPointsAmount: grossAmount.toString(),
feeAmount: feeAmount.toString(),
executedAt: new Date().toISOString(),
});
// 20. 更新K线数据
await this.updateKlineData(tx, currentPrice, shareAmount, 'SELL');
return {
orderId: order.id,
orderNo: order.orderNo,
shareAmount: shareAmount.toString(),
burnAmount: burnAmount.toString(),
effectiveAmount: effectiveAmount.toString(),
grossAmount: grossAmount.toString(),
feeAmount: feeAmount.toString(),
netAmount: netAmount.toString(),
multiplier: multiplier.toString(),
price: currentPrice.toString(),
};
});
}
/**
* 计算卖出倍数
* 倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
*/
private calculateSellMultiplier(state: MiningState): Decimal {
const totalToBurn = new Decimal('10000000000'); // 100亿
const originalPool = new Decimal('2000000'); // 200万
const numerator = totalToBurn.minus(state.blackHoleAmount);
const denominator = originalPool.minus(state.circulationPool);
if (denominator.isZero() || denominator.isNegative()) {
throw new TradingException('流通池已满,无法卖出');
}
return numerator.dividedBy(denominator);
}
```
### 4.2 买入交易逻辑
```typescript
/**
* 买入积分股
*/
async executeBuyOrder(command: BuySharesCommand): Promise<TradeResult> {
const { accountSequence, greenPointsAmount } = command;
return await this.unitOfWork.runInTransaction(async (tx) => {
// 1. 获取当前状态
const state = await this.getMiningState();
const currentPrice = new Decimal(state.currentPrice);
// 2. 计算手续费10%
const feeRate = await this.getFeeRate('BUY');
const feeAmount = greenPointsAmount.multipliedBy(feeRate);
// 3. 计算净额(用于买入的金额)
const netAmount = greenPointsAmount.minus(feeAmount);
// 4. 计算可买入积分股数量
// 从流通池购买
const shareAmount = netAmount.dividedBy(currentPrice);
// 5. 验证流通池余额
const circulationPool = await this.getCirculationPool();
if (circulationPool.lessThan(shareAmount)) {
throw new InsufficientLiquidityException('流通池积分股不足');
}
// 6. 验证用户绿积分余额(调用 wallet-service
const hasBalance = await this.checkGreenPointsBalance(accountSequence, greenPointsAmount);
if (!hasBalance) {
throw new InsufficientBalanceException('绿积分余额不足');
}
// 7. 创建订单
const order = await this.orderRepo.create(tx, {
orderNo: generateOrderNo(),
accountSequence,
orderType: 'BUY',
orderStatus: 'COMPLETED',
shareAmount,
priceAtOrder: currentPrice,
executionPrice: currentPrice,
greenPointsAmount,
feeAmount,
feeRate,
netAmount,
executedAt: new Date(),
});
// 8. 扣减用户绿积分
await this.deductGreenPoints(accountSequence, greenPointsAmount);
// 9. 从流通池扣减积分股
await this.updateCirculationPool(tx, shareAmount.negated());
// 10. 增加用户积分股余额
await this.shareAccountRepo.addBalance(tx, accountSequence, shareAmount);
// 11. 绿积分进入积分股池
await this.injectToSharePool(tx, netAmount);
// 12. 手续费也进入积分股池
await this.injectFeeToSharePool(tx, feeAmount);
// 13. 记录交易流水
await this.createBuyTransactions(tx, order, shareAmount, greenPointsAmount, feeAmount, netAmount);
// 14. 发布事件
await this.eventPublisher.publish('trading.trade-completed', {
eventId: uuid(),
orderNo: order.orderNo,
orderType: 'BUY',
accountSequence,
shareAmount: shareAmount.toString(),
greenPointsAmount: greenPointsAmount.toString(),
feeAmount: feeAmount.toString(),
executedAt: new Date().toISOString(),
});
// 15. 更新K线数据
await this.updateKlineData(tx, currentPrice, shareAmount, 'BUY');
return {
orderId: order.id,
orderNo: order.orderNo,
shareAmount: shareAmount.toString(),
grossAmount: greenPointsAmount.toString(),
feeAmount: feeAmount.toString(),
netAmount: netAmount.toString(),
price: currentPrice.toString(),
};
});
}
```
### 4.3 K线数据聚合
```typescript
/**
* K线聚合定时器
* 支持周期1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1M, 1Q, 1Y
*/
@Cron('* * * * *') // 每分钟
async aggregateKlineData(): Promise<void> {
const now = new Date();
// 聚合各周期K线
await this.aggregate1MinuteKline(now);
if (now.getMinutes() % 5 === 0) {
await this.aggregate5MinuteKline(now);
}
if (now.getMinutes() % 15 === 0) {
await this.aggregate15MinuteKline(now);
}
if (now.getMinutes() % 30 === 0) {
await this.aggregate30MinuteKline(now);
}
if (now.getMinutes() === 0) {
await this.aggregate1HourKline(now);
if (now.getHours() % 4 === 0) {
await this.aggregate4HourKline(now);
}
if (now.getHours() === 0) {
await this.aggregate1DayKline(now);
// 周、月、季、年在日线基础上聚合
}
}
}
/**
* 聚合1分钟K线
*/
private async aggregate1MinuteKline(endTime: Date): Promise<void> {
const startTime = subMinutes(endTime, 1);
// 获取该分钟内的所有价格快照
const ticks = await this.priceTickRepo.findByTimeRange(startTime, endTime);
if (ticks.length === 0) {
// 无交易使用上一个K线的收盘价
const lastKline = await this.klineRepo.getLastKline('1m');
if (lastKline) {
await this.klineRepo.create({
periodType: '1m',
periodStart: startTime,
periodEnd: endTime,
openPrice: lastKline.closePrice,
highPrice: lastKline.closePrice,
lowPrice: lastKline.closePrice,
closePrice: lastKline.closePrice,
volume: new Decimal(0),
tradeCount: 0,
});
}
return;
}
// 计算OHLC
const openPrice = ticks[0].price;
const closePrice = ticks[ticks.length - 1].price;
const highPrice = Decimal.max(...ticks.map(t => t.price));
const lowPrice = Decimal.min(...ticks.map(t => t.price));
const volume = ticks.reduce((sum, t) => sum.plus(t.minuteVolume), new Decimal(0));
const tradeCount = ticks.reduce((sum, t) => sum + t.minuteTradeCount, 0);
await this.klineRepo.create({
periodType: '1m',
periodStart: startTime,
periodEnd: endTime,
openPrice,
highPrice,
lowPrice,
closePrice,
volume,
tradeCount,
});
// 更新缓存
await this.klineCache.updateLatest('1m', {
openPrice: openPrice.toString(),
highPrice: highPrice.toString(),
lowPrice: lowPrice.toString(),
closePrice: closePrice.toString(),
volume: volume.toString(),
});
}
```
---
## 5. 服务间通信
### 5.1 订阅的事件
| Topic | 来源服务 | 数据内容 | 处理方式 |
|-------|---------|---------|---------|
| `mining.price-updated` | mining-service | 价格更新 | 更新本地价格缓存 |
| `mining.shares-burned` | mining-service | 销毁事件 | 记录销毁对交易的影响 |
### 5.2 发布的事件
| Topic | 事件类型 | 订阅者 |
|-------|---------|-------|
| `trading.trade-completed` | TradeCompleted | mining-service更新流通池、触发销毁 |
| `trading.kline-updated` | KlineUpdated | mining-admin实时图表 |
### 5.3 与 wallet-service 交互
```typescript
// 买入时:扣减绿积分
await this.walletClient.deductGreenPoints(accountSequence, amount, orderId);
// 卖出时:增加绿积分
await this.walletClient.creditGreenPoints(accountSequence, amount, orderId);
```
---
## 6. Redis 缓存结构
```typescript
// 当前价格缓存
interface PriceCache {
key: 'trading:price:current';
ttl: 10; // 10秒
data: {
price: string;
updatedAt: string;
};
}
// K线缓存各周期最新一根
interface KlineCache {
key: `trading:kline:${periodType}:latest`;
ttl: 60; // 60秒
data: KlineBar;
}
// K线历史缓存按需加载
interface KlineHistoryCache {
key: `trading:kline:${periodType}:history`;
ttl: 300; // 5分钟
data: KlineBar[];
}
```
---
## 7. K线周期说明
| 周期代码 | 说明 | 聚合频率 |
|---------|------|---------|
| `1m` | 1分钟 | 每分钟 |
| `5m` | 5分钟 | 每5分钟 |
| `15m` | 15分钟 | 每15分钟 |
| `30m` | 30分钟 | 每30分钟 |
| `1h` | 1小时 | 每小时 |
| `4h` | 4小时 | 每4小时 |
| `1d` | 1天 | 每天0点 |
| `1w` | 1周 | 每周一0点 |
| `1M` | 1月 | 每月1号0点 |
| `1Q` | 1季度 | 每季度首日 |
| `1Y` | 1年 | 每年1月1日 |
---
## 8. 关键计算公式汇总
```
卖出倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
卖出销毁量 = 卖出积分股 × 倍数
卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
买入获得量 = (绿积分 - 手续费) ÷ 积分股价
手续费 = 交易额 × 10%
```
---
## 9. 关键注意事项
### 9.1 价格同步
- 从 mining-service 获取实时价格
- 本地缓存价格TTL 10秒
- 下单时锁定当前价格
### 9.2 流通池管理
- 卖出:积分股进入流通池
- 买入:从流通池购买
- 与 mining-service 保持同步
### 9.3 原子性
- 交易流水与余额变更在同一事务
- 发布事件使用 Outbox Pattern
### 9.4 K线精度
- 价格使用 `DECIMAL(30,18)`
- 成交量使用 `DECIMAL(30,10)`
---
## 10. 开发检查清单
- [ ] 实现卖出交易逻辑(含销毁计算)
- [ ] 实现买入交易逻辑
- [ ] 实现交易明细账记录
- [ ] 实现K线聚合所有周期
- [ ] 实现价格查询API
- [ ] 实现K线查询API
- [ ] 配置与 wallet-service 交互
- [ ] 配置与 mining-service 事件同步
- [ ] 编写单元测试
- [ ] 性能测试K线查询
---
## 11. 启动命令
```bash
# 开发环境
npm run start:dev
# 生成 Prisma Client
npx prisma generate
# 运行迁移
npx prisma migrate dev
# 生产环境
npm run build && npm run start:prod
```

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
{
"name": "trading-service",
"version": "1.0.0",
"description": "Trading service for share token exchange",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"decimal.js": "^10.4.3",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.0",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,214 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== 交易账户 ====================
// 用户交易账户
model TradingAccount {
id String @id @default(uuid())
accountSequence String @unique
shareBalance Decimal @db.Decimal(30, 8) @default(0) // 积分股余额
cashBalance Decimal @db.Decimal(30, 8) @default(0) // 现金余额
frozenShares Decimal @db.Decimal(30, 8) @default(0) // 冻结积分股
frozenCash Decimal @db.Decimal(30, 8) @default(0) // 冻结现金
totalBought Decimal @db.Decimal(30, 8) @default(0) // 累计买入量
totalSold Decimal @db.Decimal(30, 8) @default(0) // 累计卖出量
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
transactions TradingTransaction[]
@@map("trading_accounts")
}
// ==================== 订单 ====================
// 交易订单
model Order {
id String @id @default(uuid())
orderNo String @unique // 订单号
accountSequence String
type String // BUY, SELL
status String // PENDING, PARTIAL, FILLED, CANCELLED
price Decimal @db.Decimal(30, 18) // 挂单价格
quantity Decimal @db.Decimal(30, 8) // 订单数量
filledQuantity Decimal @db.Decimal(30, 8) @default(0) // 已成交数量
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
averagePrice Decimal @db.Decimal(30, 18) @default(0) // 平均成交价
totalAmount Decimal @db.Decimal(30, 8) @default(0) // 总成交金额
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cancelledAt DateTime?
completedAt DateTime?
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
trades Trade[]
@@index([accountSequence, status])
@@index([type, status, price])
@@index([createdAt(sort: Desc)])
@@map("orders")
}
// 成交记录
model Trade {
id String @id @default(uuid())
tradeNo String @unique
buyOrderId String
sellOrderId String
buyerSequence String
sellerSequence String
price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8) // price * quantity
createdAt DateTime @default(now())
buyOrder Order @relation(fields: [buyOrderId], references: [id])
@@index([buyerSequence])
@@index([sellerSequence])
@@index([createdAt(sort: Desc)])
@@map("trades")
}
// ==================== 交易流水 ====================
model TradingTransaction {
id String @id @default(uuid())
accountSequence String
type String // TRANSFER_IN, TRANSFER_OUT, BUY, SELL, FREEZE, UNFREEZE, DEPOSIT, WITHDRAW
assetType String // SHARE, CASH
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
referenceId String?
referenceType String?
description String?
createdAt DateTime @default(now())
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
@@index([accountSequence, createdAt(sort: Desc)])
@@map("trading_transactions")
}
// ==================== 流通池 ====================
// 流通池
model CirculationPool {
id String @id @default(uuid())
totalShares Decimal @db.Decimal(30, 8) @default(0) // 流通池中的积分股
totalCash Decimal @db.Decimal(30, 8) @default(0) // 流通池中的现金(股池)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("circulation_pools")
}
// 流通池变动记录
model PoolTransaction {
id String @id @default(uuid())
type String // SHARE_IN, SHARE_OUT, CASH_IN, CASH_OUT
amount Decimal @db.Decimal(30, 8)
referenceId String?
description String?
createdAt DateTime @default(now())
@@index([createdAt(sort: Desc)])
@@map("pool_transactions")
}
// ==================== K线数据 ====================
// 分钟K线
model MinuteKLine {
id String @id @default(uuid())
minute DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8) // 成交量
amount Decimal @db.Decimal(30, 8) // 成交额
tradeCount Int @default(0) // 成交笔数
createdAt DateTime @default(now())
@@index([minute(sort: Desc)])
@@map("minute_klines")
}
// 小时K线
model HourKLine {
id String @id @default(uuid())
hour DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
@@index([hour(sort: Desc)])
@@map("hour_klines")
}
// 日K线
model DayKLine {
id String @id @default(uuid())
date DateTime @unique @db.Date
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
@@index([date(sort: Desc)])
@@map("day_klines")
}
// ==================== 划转记录 ====================
// 从挖矿账户划转记录
model TransferRecord {
id String @id @default(uuid())
transferNo String @unique
accountSequence String
direction String // IN (从挖矿账户划入), OUT (划出到挖矿账户)
amount Decimal @db.Decimal(30, 8)
status String // PENDING, COMPLETED, FAILED
miningTxId String? // 挖矿服务的交易ID
errorMessage String?
createdAt DateTime @default(now())
completedAt DateTime?
@@index([accountSequence])
@@index([status])
@@map("transfer_records")
}
// ==================== Outbox ====================
model OutboxEvent {
id String @id @default(uuid())
aggregateType String
aggregateId String
eventType String
payload Json
createdAt DateTime @default(now())
processedAt DateTime?
@@index([processedAt])
@@map("outbox_events")
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { TradingController } from './controllers/trading.controller';
import { TransferController } from './controllers/transfer.controller';
import { HealthController } from './controllers/health.controller';
@Module({
imports: [ApplicationModule, InfrastructureModule],
controllers: [TradingController, TransferController, HealthController],
})
export class ApiModule {}

View File

@ -0,0 +1,36 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
@ApiOperation({ summary: '健康检查' })
async check() {
const status = {
status: 'healthy' as 'healthy' | 'unhealthy',
timestamp: new Date().toISOString(),
services: { database: 'up' as 'up' | 'down', redis: 'up' as 'up' | 'down' },
};
try { await this.prisma.$queryRaw`SELECT 1`; } catch { status.services.database = 'down'; status.status = 'unhealthy'; }
try { await this.redis.getClient().ping(); } catch { status.services.redis = 'down'; status.status = 'unhealthy'; }
return status;
}
@Get('ready')
@ApiOperation({ summary: '就绪检查' })
async ready() { return { ready: true }; }
@Get('live')
@ApiOperation({ summary: '存活检查' })
async live() { return { alive: true }; }
}

View File

@ -0,0 +1,119 @@
import { Controller, Get, Post, Param, Query, Body, NotFoundException, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
import { OrderService } from '../../application/services/order.service';
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { OrderType } from '../../domain/aggregates/order.aggregate';
class CreateOrderDto {
type: 'BUY' | 'SELL';
price: string;
quantity: string;
}
@ApiTags('Trading')
@ApiBearerAuth()
@Controller('trading')
export class TradingController {
constructor(
private readonly orderService: OrderService,
private readonly orderRepository: OrderRepository,
private readonly accountRepository: TradingAccountRepository,
) {}
@Get('accounts/:accountSequence')
@ApiOperation({ summary: '获取交易账户信息' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
async getAccount(@Param('accountSequence') accountSequence: string) {
const account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new NotFoundException('Account not found');
}
return {
accountSequence: account.accountSequence,
shareBalance: account.shareBalance.toString(),
cashBalance: account.cashBalance.toString(),
availableShares: account.availableShares.toString(),
availableCash: account.availableCash.toString(),
frozenShares: account.frozenShares.toString(),
frozenCash: account.frozenCash.toString(),
totalBought: account.totalBought.toString(),
totalSold: account.totalSold.toString(),
};
}
@Get('orderbook')
@ApiOperation({ summary: '获取订单簿' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getOrderBook(@Query('limit') limit?: number) {
return this.orderRepository.getOrderBook(limit ?? 20);
}
@Post('orders')
@ApiOperation({ summary: '创建订单' })
async createOrder(@Body() dto: CreateOrderDto, @Req() req: any) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
return this.orderService.createOrder(
accountSequence,
dto.type === 'BUY' ? OrderType.BUY : OrderType.SELL,
dto.price,
dto.quantity,
);
}
@Post('orders/:orderNo/cancel')
@ApiOperation({ summary: '取消订单' })
@ApiParam({ name: 'orderNo', description: '订单号' })
async cancelOrder(@Param('orderNo') orderNo: string, @Req() req: any) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
await this.orderService.cancelOrder(accountSequence, orderNo);
return { success: true };
}
@Get('orders')
@ApiOperation({ summary: '获取用户订单列表' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getOrders(
@Req() req: any,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const result = await this.orderRepository.findByAccountSequence(accountSequence, {
page: page ?? 1,
pageSize: pageSize ?? 50,
});
return {
data: result.data.map((o) => ({
id: o.id,
orderNo: o.orderNo,
type: o.type,
status: o.status,
price: o.price.toString(),
quantity: o.quantity.toString(),
filledQuantity: o.filledQuantity.toString(),
remainingQuantity: o.remainingQuantity.toString(),
averagePrice: o.averagePrice.toString(),
totalAmount: o.totalAmount.toString(),
createdAt: o.createdAt,
completedAt: o.completedAt,
cancelledAt: o.cancelledAt,
})),
total: result.total,
};
}
}

View File

@ -0,0 +1,53 @@
import { Controller, Get, Post, Param, Query, Body, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { TransferService } from '../../application/services/transfer.service';
class TransferDto {
amount: string;
}
@ApiTags('Transfer')
@ApiBearerAuth()
@Controller('transfers')
export class TransferController {
constructor(private readonly transferService: TransferService) {}
@Post('in')
@ApiOperation({ summary: '从挖矿账户划入积分股' })
async transferIn(@Body() dto: TransferDto, @Req() req: any) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
return this.transferService.transferIn(accountSequence, dto.amount);
}
@Post('out')
@ApiOperation({ summary: '划出积分股到挖矿账户' })
async transferOut(@Body() dto: TransferDto, @Req() req: any) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
return this.transferService.transferOut(accountSequence, dto.amount);
}
@Get('history')
@ApiOperation({ summary: '获取划转历史' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getHistory(
@Req() req: any,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
return this.transferService.getTransferHistory(accountSequence, page ?? 1, pageSize ?? 50);
}
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { ApiModule } from './api/api.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { ApplicationModule } from './application/application.module';
import { DomainExceptionFilter } from './shared/filters/domain-exception.filter';
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
ignoreEnvFile: false,
}),
InfrastructureModule,
ApplicationModule,
ApiModule,
],
providers: [
{ provide: APP_FILTER, useClass: DomainExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { OrderService } from './services/order.service';
import { TransferService } from './services/transfer.service';
@Module({
imports: [ScheduleModule.forRoot(), InfrastructureModule],
providers: [OrderService, TransferService],
exports: [OrderService, TransferService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,175 @@
import { Injectable, Logger } from '@nestjs/common';
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { OrderAggregate, OrderType, OrderStatus } from '../../domain/aggregates/order.aggregate';
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
import { MatchingEngineService } from '../../domain/services/matching-engine.service';
import { Money } from '../../domain/value-objects/money.vo';
@Injectable()
export class OrderService {
private readonly logger = new Logger(OrderService.name);
private readonly matchingEngine = new MatchingEngineService();
constructor(
private readonly orderRepository: OrderRepository,
private readonly accountRepository: TradingAccountRepository,
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async createOrder(
accountSequence: string,
type: OrderType,
price: string,
quantity: string,
): Promise<{ orderId: string; orderNo: string; status: OrderStatus; filledQuantity: string }> {
const lockValue = await this.redis.acquireLock(`order:create:${accountSequence}`, 10);
if (!lockValue) {
throw new Error('System busy, please try again');
}
try {
// 验证账户
let account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
account = TradingAccountAggregate.create(accountSequence);
}
const priceAmount = new Money(price);
const quantityAmount = new Money(quantity);
const totalCost = quantityAmount.multiply(priceAmount.value);
// 检查余额并冻结
if (type === OrderType.BUY) {
if (account.availableCash.isLessThan(totalCost)) {
throw new Error('Insufficient cash balance');
}
} else {
if (account.availableShares.isLessThan(quantityAmount)) {
throw new Error('Insufficient share balance');
}
}
// 生成订单号
const orderNo = this.generateOrderNo();
// 创建订单
const order = OrderAggregate.create(orderNo, accountSequence, type, priceAmount, quantityAmount);
// 冻结资产
if (type === OrderType.BUY) {
account.freezeCash(totalCost, orderNo);
} else {
account.freezeShares(quantityAmount, orderNo);
}
// 保存订单和账户
const orderId = await this.orderRepository.save(order);
await this.accountRepository.save(account);
// 尝试撮合
await this.tryMatch(order);
// 获取最新订单状态
const updatedOrder = await this.orderRepository.findByOrderNo(orderNo);
return {
orderId,
orderNo,
status: updatedOrder?.status || order.status,
filledQuantity: updatedOrder?.filledQuantity.toString() || '0',
};
} finally {
await this.redis.releaseLock(`order:create:${accountSequence}`, lockValue);
}
}
async cancelOrder(accountSequence: string, orderNo: string): Promise<void> {
const order = await this.orderRepository.findByOrderNo(orderNo);
if (!order) {
throw new Error('Order not found');
}
if (order.accountSequence !== accountSequence) {
throw new Error('Unauthorized');
}
const account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new Error('Account not found');
}
// 取消订单
order.cancel();
// 解冻资产
if (order.type === OrderType.BUY) {
account.unfreezeCash(order.lockedAmount, orderNo);
} else {
account.unfreezeShares(order.remainingQuantity, orderNo);
}
await this.orderRepository.save(order);
await this.accountRepository.save(account);
}
private async tryMatch(incomingOrder: OrderAggregate): Promise<void> {
const lockValue = await this.redis.acquireLock('order:matching', 30);
if (!lockValue) return;
try {
const oppositeType = incomingOrder.type === OrderType.BUY ? OrderType.SELL : OrderType.BUY;
const orderBook = await this.orderRepository.findActiveOrders(oppositeType);
const matches = this.matchingEngine.findMatchingOrders(incomingOrder, orderBook);
for (const match of matches) {
// 保存成交记录
await this.prisma.trade.create({
data: {
tradeNo: match.trade.tradeNo,
buyOrderId: match.buyOrder.id!,
sellOrderId: match.sellOrder.id!,
buyerSequence: match.buyOrder.accountSequence,
sellerSequence: match.sellOrder.accountSequence,
price: match.trade.price.value,
quantity: match.trade.quantity.value,
amount: match.trade.amount.value,
},
});
// 更新订单
await this.orderRepository.save(match.buyOrder);
await this.orderRepository.save(match.sellOrder);
// 更新买方账户
const buyerAccount = await this.accountRepository.findByAccountSequence(match.buyOrder.accountSequence);
if (buyerAccount) {
buyerAccount.executeBuy(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
await this.accountRepository.save(buyerAccount);
}
// 更新卖方账户
const sellerAccount = await this.accountRepository.findByAccountSequence(match.sellOrder.accountSequence);
if (sellerAccount) {
sellerAccount.executeSell(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
await this.accountRepository.save(sellerAccount);
}
this.logger.log(
`Trade executed: ${match.trade.tradeNo}, price=${match.trade.price}, qty=${match.trade.quantity}`,
);
}
} finally {
await this.redis.releaseLock('order:matching', lockValue);
}
}
private generateOrderNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `O${timestamp}${random}`.toUpperCase();
}
}

View File

@ -0,0 +1,224 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
import { Money } from '../../domain/value-objects/money.vo';
@Injectable()
export class TransferService {
private readonly logger = new Logger(TransferService.name);
private readonly minTransferAmount: number;
private readonly miningServiceUrl: string;
constructor(
private readonly accountRepository: TradingAccountRepository,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {
this.minTransferAmount = this.configService.get<number>('MIN_TRANSFER_AMOUNT', 5);
this.miningServiceUrl = this.configService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
}
/**
*
*/
async transferIn(accountSequence: string, amount: string): Promise<{ transferNo: string }> {
const transferAmount = new Money(amount);
if (transferAmount.value.lessThan(this.minTransferAmount)) {
throw new Error(`Minimum transfer amount is ${this.minTransferAmount}`);
}
const transferNo = this.generateTransferNo();
// 创建划转记录
await this.prisma.transferRecord.create({
data: {
transferNo,
accountSequence,
direction: 'IN',
amount: transferAmount.value,
status: 'PENDING',
},
});
try {
// 调用挖矿服务冻结/扣减用户余额
const response = await this.callMiningServiceTransferOut(accountSequence, amount, transferNo);
if (!response.success) {
throw new Error(response.message || 'Mining service transfer failed');
}
// 增加交易账户余额
let account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
account = TradingAccountAggregate.create(accountSequence);
}
account.transferSharesIn(transferAmount, transferNo);
await this.accountRepository.save(account);
// 更新划转记录
await this.prisma.transferRecord.update({
where: { transferNo },
data: {
status: 'COMPLETED',
miningTxId: response.txId,
completedAt: new Date(),
},
});
this.logger.log(`Transfer in completed: ${transferNo}, amount=${amount}`);
return { transferNo };
} catch (error: any) {
// 更新划转记录为失败
await this.prisma.transferRecord.update({
where: { transferNo },
data: {
status: 'FAILED',
errorMessage: error.message,
},
});
throw error;
}
}
/**
*
*/
async transferOut(accountSequence: string, amount: string): Promise<{ transferNo: string }> {
const transferAmount = new Money(amount);
if (transferAmount.value.lessThan(this.minTransferAmount)) {
throw new Error(`Minimum transfer amount is ${this.minTransferAmount}`);
}
const account = await this.accountRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new Error('Trading account not found');
}
if (account.availableShares.isLessThan(transferAmount)) {
throw new Error('Insufficient available shares');
}
const transferNo = this.generateTransferNo();
// 创建划转记录
await this.prisma.transferRecord.create({
data: {
transferNo,
accountSequence,
direction: 'OUT',
amount: transferAmount.value,
status: 'PENDING',
},
});
try {
// 先扣减交易账户余额
account.transferSharesOut(transferAmount, transferNo);
await this.accountRepository.save(account);
// 调用挖矿服务增加用户余额
const response = await this.callMiningServiceTransferIn(accountSequence, amount, transferNo);
if (!response.success) {
// 回滚交易账户余额
account.transferSharesIn(transferAmount, `${transferNo}_rollback`);
await this.accountRepository.save(account);
throw new Error(response.message || 'Mining service transfer failed');
}
// 更新划转记录
await this.prisma.transferRecord.update({
where: { transferNo },
data: {
status: 'COMPLETED',
miningTxId: response.txId,
completedAt: new Date(),
},
});
this.logger.log(`Transfer out completed: ${transferNo}, amount=${amount}`);
return { transferNo };
} catch (error: any) {
await this.prisma.transferRecord.update({
where: { transferNo },
data: {
status: 'FAILED',
errorMessage: error.message,
},
});
throw error;
}
}
async getTransferHistory(
accountSequence: string,
page: number = 1,
pageSize: number = 50,
): Promise<{ data: any[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.transferRecord.findMany({
where: { accountSequence },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.transferRecord.count({ where: { accountSequence } }),
]);
return { data: records, total };
}
private async callMiningServiceTransferOut(
accountSequence: string,
amount: string,
transferNo: string,
): Promise<{ success: boolean; txId?: string; message?: string }> {
try {
const response = await fetch(`${this.miningServiceUrl}/api/v1/mining/accounts/${accountSequence}/transfer-out`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, transferNo }),
});
const result = await response.json();
return { success: response.ok, txId: result.data?.txId, message: result.error?.message };
} catch (error: any) {
return { success: false, message: error.message };
}
}
private async callMiningServiceTransferIn(
accountSequence: string,
amount: string,
transferNo: string,
): Promise<{ success: boolean; txId?: string; message?: string }> {
try {
const response = await fetch(`${this.miningServiceUrl}/api/v1/mining/accounts/${accountSequence}/transfer-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, transferNo }),
});
const result = await response.json();
return { success: response.ok, txId: result.data?.txId, message: result.error?.message };
} catch (error: any) {
return { success: false, message: error.message };
}
}
private generateTransferNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `TR${timestamp}${random}`.toUpperCase();
}
}

View File

@ -0,0 +1,190 @@
import { Money } from '../value-objects/money.vo';
import Decimal from 'decimal.js';
export enum OrderType {
BUY = 'BUY',
SELL = 'SELL',
}
export enum OrderStatus {
PENDING = 'PENDING',
PARTIAL = 'PARTIAL',
FILLED = 'FILLED',
CANCELLED = 'CANCELLED',
}
export interface TradeInfo {
tradeNo: string;
counterpartyOrderId: string;
counterpartySequence: string;
price: Money;
quantity: Money;
amount: Money;
createdAt: Date;
}
export class OrderAggregate {
private _id: string | null;
private _orderNo: string;
private _accountSequence: string;
private _type: OrderType;
private _status: OrderStatus;
private _price: Money;
private _quantity: Money;
private _filledQuantity: Money;
private _remainingQuantity: Money;
private _averagePrice: Money;
private _totalAmount: Money;
private _trades: TradeInfo[] = [];
private _createdAt: Date;
private _cancelledAt: Date | null = null;
private _completedAt: Date | null = null;
private constructor(
orderNo: string,
accountSequence: string,
type: OrderType,
price: Money,
quantity: Money,
id: string | null = null,
) {
this._id = id;
this._orderNo = orderNo;
this._accountSequence = accountSequence;
this._type = type;
this._status = OrderStatus.PENDING;
this._price = price;
this._quantity = quantity;
this._filledQuantity = Money.zero();
this._remainingQuantity = quantity;
this._averagePrice = Money.zero();
this._totalAmount = Money.zero();
this._createdAt = new Date();
}
static create(
orderNo: string,
accountSequence: string,
type: OrderType,
price: Money,
quantity: Money,
): OrderAggregate {
if (price.isZero()) {
throw new Error('Price cannot be zero');
}
if (quantity.isZero()) {
throw new Error('Quantity cannot be zero');
}
return new OrderAggregate(orderNo, accountSequence, type, price, quantity);
}
static reconstitute(props: {
id: string;
orderNo: string;
accountSequence: string;
type: OrderType;
status: OrderStatus;
price: Money;
quantity: Money;
filledQuantity: Money;
remainingQuantity: Money;
averagePrice: Money;
totalAmount: Money;
createdAt: Date;
cancelledAt: Date | null;
completedAt: Date | null;
}): OrderAggregate {
const order = new OrderAggregate(
props.orderNo,
props.accountSequence,
props.type,
props.price,
props.quantity,
props.id,
);
order._status = props.status;
order._filledQuantity = props.filledQuantity;
order._remainingQuantity = props.remainingQuantity;
order._averagePrice = props.averagePrice;
order._totalAmount = props.totalAmount;
order._createdAt = props.createdAt;
order._cancelledAt = props.cancelledAt;
order._completedAt = props.completedAt;
return order;
}
// Getters
get id(): string | null { return this._id; }
get orderNo(): string { return this._orderNo; }
get accountSequence(): string { return this._accountSequence; }
get type(): OrderType { return this._type; }
get status(): OrderStatus { return this._status; }
get price(): Money { return this._price; }
get quantity(): Money { return this._quantity; }
get filledQuantity(): Money { return this._filledQuantity; }
get remainingQuantity(): Money { return this._remainingQuantity; }
get averagePrice(): Money { return this._averagePrice; }
get totalAmount(): Money { return this._totalAmount; }
get trades(): TradeInfo[] { return [...this._trades]; }
get createdAt(): Date { return this._createdAt; }
get cancelledAt(): Date | null { return this._cancelledAt; }
get completedAt(): Date | null { return this._completedAt; }
get lockedAmount(): Money {
if (this._type === OrderType.BUY) {
return this._remainingQuantity.multiply(this._price.value);
}
return this._remainingQuantity;
}
fill(trade: TradeInfo): void {
if (this._status === OrderStatus.CANCELLED || this._status === OrderStatus.FILLED) {
throw new Error('Cannot fill a cancelled or filled order');
}
if (trade.quantity.isGreaterThan(this._remainingQuantity)) {
throw new Error('Trade quantity exceeds remaining quantity');
}
this._trades.push(trade);
this._filledQuantity = this._filledQuantity.add(trade.quantity);
this._remainingQuantity = this._remainingQuantity.subtract(trade.quantity);
this._totalAmount = this._totalAmount.add(trade.amount);
// 重新计算平均价格
this._averagePrice = new Money(
this._totalAmount.value.dividedBy(this._filledQuantity.value),
);
if (this._remainingQuantity.isZero()) {
this._status = OrderStatus.FILLED;
this._completedAt = new Date();
} else {
this._status = OrderStatus.PARTIAL;
}
}
cancel(): void {
if (this._status === OrderStatus.FILLED) {
throw new Error('Cannot cancel a filled order');
}
if (this._status === OrderStatus.CANCELLED) {
throw new Error('Order is already cancelled');
}
this._status = OrderStatus.CANCELLED;
this._cancelledAt = new Date();
}
canMatch(other: OrderAggregate): boolean {
if (this._type === other._type) return false;
if (this._status !== OrderStatus.PENDING && this._status !== OrderStatus.PARTIAL) return false;
if (other._status !== OrderStatus.PENDING && other._status !== OrderStatus.PARTIAL) return false;
if (this._type === OrderType.BUY) {
return this._price.isGreaterThanOrEqual(other._price);
} else {
return this._price.value.lessThanOrEqualTo(other._price.value);
}
}
}

View File

@ -0,0 +1,294 @@
import { Money } from '../value-objects/money.vo';
export enum TradingTransactionType {
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
BUY = 'BUY',
SELL = 'SELL',
FREEZE = 'FREEZE',
UNFREEZE = 'UNFREEZE',
DEPOSIT = 'DEPOSIT',
WITHDRAW = 'WITHDRAW',
}
export enum AssetType {
SHARE = 'SHARE',
CASH = 'CASH',
}
export interface TradingTransaction {
type: TradingTransactionType;
assetType: AssetType;
amount: Money;
balanceBefore: Money;
balanceAfter: Money;
referenceId?: string;
referenceType?: string;
description?: string;
createdAt: Date;
}
export class TradingAccountAggregate {
private _id: string | null;
private _accountSequence: string;
private _shareBalance: Money;
private _cashBalance: Money;
private _frozenShares: Money;
private _frozenCash: Money;
private _totalBought: Money;
private _totalSold: Money;
private _pendingTransactions: TradingTransaction[] = [];
private constructor(
accountSequence: string,
shareBalance: Money,
cashBalance: Money,
frozenShares: Money,
frozenCash: Money,
totalBought: Money,
totalSold: Money,
id: string | null = null,
) {
this._id = id;
this._accountSequence = accountSequence;
this._shareBalance = shareBalance;
this._cashBalance = cashBalance;
this._frozenShares = frozenShares;
this._frozenCash = frozenCash;
this._totalBought = totalBought;
this._totalSold = totalSold;
}
static create(accountSequence: string): TradingAccountAggregate {
return new TradingAccountAggregate(
accountSequence,
Money.zero(),
Money.zero(),
Money.zero(),
Money.zero(),
Money.zero(),
Money.zero(),
);
}
static reconstitute(props: {
id: string;
accountSequence: string;
shareBalance: Money;
cashBalance: Money;
frozenShares: Money;
frozenCash: Money;
totalBought: Money;
totalSold: Money;
}): TradingAccountAggregate {
return new TradingAccountAggregate(
props.accountSequence,
props.shareBalance,
props.cashBalance,
props.frozenShares,
props.frozenCash,
props.totalBought,
props.totalSold,
props.id,
);
}
// Getters
get id(): string | null { return this._id; }
get accountSequence(): string { return this._accountSequence; }
get shareBalance(): Money { return this._shareBalance; }
get cashBalance(): Money { return this._cashBalance; }
get frozenShares(): Money { return this._frozenShares; }
get frozenCash(): Money { return this._frozenCash; }
get availableShares(): Money { return this._shareBalance.subtract(this._frozenShares); }
get availableCash(): Money { return this._cashBalance.subtract(this._frozenCash); }
get totalBought(): Money { return this._totalBought; }
get totalSold(): Money { return this._totalSold; }
get pendingTransactions(): TradingTransaction[] { return [...this._pendingTransactions]; }
// 从挖矿账户划入积分股
transferSharesIn(amount: Money, referenceId: string): void {
const balanceBefore = this._shareBalance;
this._shareBalance = this._shareBalance.add(amount);
this._pendingTransactions.push({
type: TradingTransactionType.TRANSFER_IN,
assetType: AssetType.SHARE,
amount,
balanceBefore,
balanceAfter: this._shareBalance,
referenceId,
referenceType: 'TRANSFER',
description: '从挖矿账户划入',
createdAt: new Date(),
});
}
// 划出积分股到挖矿账户
transferSharesOut(amount: Money, referenceId: string): void {
if (this.availableShares.isLessThan(amount)) {
throw new Error('Insufficient available shares');
}
const balanceBefore = this._shareBalance;
this._shareBalance = this._shareBalance.subtract(amount);
this._pendingTransactions.push({
type: TradingTransactionType.TRANSFER_OUT,
assetType: AssetType.SHARE,
amount,
balanceBefore,
balanceAfter: this._shareBalance,
referenceId,
referenceType: 'TRANSFER',
description: '划出到挖矿账户',
createdAt: new Date(),
});
}
// 冻结积分股(卖单)
freezeShares(amount: Money, orderId: string): void {
if (this.availableShares.isLessThan(amount)) {
throw new Error('Insufficient available shares to freeze');
}
const balanceBefore = this._shareBalance;
this._frozenShares = this._frozenShares.add(amount);
this._pendingTransactions.push({
type: TradingTransactionType.FREEZE,
assetType: AssetType.SHARE,
amount,
balanceBefore,
balanceAfter: this._shareBalance,
referenceId: orderId,
referenceType: 'ORDER',
description: '卖单冻结',
createdAt: new Date(),
});
}
// 解冻积分股(订单取消)
unfreezeShares(amount: Money, orderId: string): void {
if (this._frozenShares.isLessThan(amount)) {
throw new Error('Insufficient frozen shares');
}
const balanceBefore = this._shareBalance;
this._frozenShares = this._frozenShares.subtract(amount);
this._pendingTransactions.push({
type: TradingTransactionType.UNFREEZE,
assetType: AssetType.SHARE,
amount,
balanceBefore,
balanceAfter: this._shareBalance,
referenceId: orderId,
referenceType: 'ORDER',
description: '订单取消解冻',
createdAt: new Date(),
});
}
// 冻结现金(买单)
freezeCash(amount: Money, orderId: string): void {
if (this.availableCash.isLessThan(amount)) {
throw new Error('Insufficient available cash to freeze');
}
const balanceBefore = this._cashBalance;
this._frozenCash = this._frozenCash.add(amount);
this._pendingTransactions.push({
type: TradingTransactionType.FREEZE,
assetType: AssetType.CASH,
amount,
balanceBefore,
balanceAfter: this._cashBalance,
referenceId: orderId,
referenceType: 'ORDER',
description: '买单冻结',
createdAt: new Date(),
});
}
// 解冻现金(订单取消)
unfreezeCash(amount: Money, orderId: string): void {
if (this._frozenCash.isLessThan(amount)) {
throw new Error('Insufficient frozen cash');
}
const balanceBefore = this._cashBalance;
this._frozenCash = this._frozenCash.subtract(amount);
this._pendingTransactions.push({
type: TradingTransactionType.UNFREEZE,
assetType: AssetType.CASH,
amount,
balanceBefore,
balanceAfter: this._cashBalance,
referenceId: orderId,
referenceType: 'ORDER',
description: '订单取消解冻',
createdAt: new Date(),
});
}
// 卖出成交
executeSell(shareAmount: Money, cashAmount: Money, tradeId: string): void {
if (this._frozenShares.isLessThan(shareAmount)) {
throw new Error('Insufficient frozen shares for trade');
}
const shareBalanceBefore = this._shareBalance;
this._frozenShares = this._frozenShares.subtract(shareAmount);
this._shareBalance = this._shareBalance.subtract(shareAmount);
this._cashBalance = this._cashBalance.add(cashAmount);
this._totalSold = this._totalSold.add(shareAmount);
this._pendingTransactions.push({
type: TradingTransactionType.SELL,
assetType: AssetType.SHARE,
amount: shareAmount,
balanceBefore: shareBalanceBefore,
balanceAfter: this._shareBalance,
referenceId: tradeId,
referenceType: 'TRADE',
description: '卖出成交',
createdAt: new Date(),
});
}
// 买入成交
executeBuy(shareAmount: Money, cashAmount: Money, tradeId: string): void {
if (this._frozenCash.isLessThan(cashAmount)) {
throw new Error('Insufficient frozen cash for trade');
}
const cashBalanceBefore = this._cashBalance;
this._frozenCash = this._frozenCash.subtract(cashAmount);
this._cashBalance = this._cashBalance.subtract(cashAmount);
this._shareBalance = this._shareBalance.add(shareAmount);
this._totalBought = this._totalBought.add(shareAmount);
this._pendingTransactions.push({
type: TradingTransactionType.BUY,
assetType: AssetType.SHARE,
amount: shareAmount,
balanceBefore: new Money(cashBalanceBefore.value),
balanceAfter: this._cashBalance,
referenceId: tradeId,
referenceType: 'TRADE',
description: '买入成交',
createdAt: new Date(),
});
}
// 充值现金
deposit(amount: Money, referenceId: string): void {
const balanceBefore = this._cashBalance;
this._cashBalance = this._cashBalance.add(amount);
this._pendingTransactions.push({
type: TradingTransactionType.DEPOSIT,
assetType: AssetType.CASH,
amount,
balanceBefore,
balanceAfter: this._cashBalance,
referenceId,
referenceType: 'DEPOSIT',
description: '充值',
createdAt: new Date(),
});
}
clearPendingTransactions(): void {
this._pendingTransactions = [];
}
}

View File

@ -0,0 +1,118 @@
import { OrderAggregate, OrderType, OrderStatus, TradeInfo } from '../aggregates/order.aggregate';
import { Money } from '../value-objects/money.vo';
import { v4 as uuidv4 } from 'uuid';
export interface MatchResult {
buyOrder: OrderAggregate;
sellOrder: OrderAggregate;
trade: TradeInfo;
}
/**
*
*
*/
export class MatchingEngineService {
/**
*
*/
match(buyOrder: OrderAggregate, sellOrder: OrderAggregate): MatchResult | null {
if (!buyOrder.canMatch(sellOrder)) {
return null;
}
// 确定成交价格(以挂单在先的价格为准,这里简化为卖方价格)
const tradePrice = sellOrder.price;
// 确定成交数量
const tradeQuantity = buyOrder.remainingQuantity.isLessThan(sellOrder.remainingQuantity)
? buyOrder.remainingQuantity
: sellOrder.remainingQuantity;
// 计算成交金额
const tradeAmount = tradeQuantity.multiply(tradePrice.value);
const tradeNo = this.generateTradeNo();
const buyerTrade: TradeInfo = {
tradeNo,
counterpartyOrderId: sellOrder.id || sellOrder.orderNo,
counterpartySequence: sellOrder.accountSequence,
price: tradePrice,
quantity: tradeQuantity,
amount: tradeAmount,
createdAt: new Date(),
};
const sellerTrade: TradeInfo = {
tradeNo,
counterpartyOrderId: buyOrder.id || buyOrder.orderNo,
counterpartySequence: buyOrder.accountSequence,
price: tradePrice,
quantity: tradeQuantity,
amount: tradeAmount,
createdAt: new Date(),
};
// 更新订单
buyOrder.fill(buyerTrade);
sellOrder.fill(sellerTrade);
return {
buyOrder,
sellOrder,
trade: buyerTrade,
};
}
/**
* 簿
*/
findMatchingOrders(
incomingOrder: OrderAggregate,
orderBook: OrderAggregate[],
): MatchResult[] {
const results: MatchResult[] = [];
// 筛选可撮合的订单
const matchableOrders = orderBook.filter((order) => {
if (order.accountSequence === incomingOrder.accountSequence) return false;
if (order.type === incomingOrder.type) return false;
if (order.status !== OrderStatus.PENDING && order.status !== OrderStatus.PARTIAL) return false;
return incomingOrder.canMatch(order);
});
// 按价格排序(买单降序,卖单升序)
matchableOrders.sort((a, b) => {
if (incomingOrder.type === OrderType.BUY) {
// 买单进来,找卖单,价格低的优先
return a.price.value.minus(b.price.value).toNumber();
} else {
// 卖单进来,找买单,价格高的优先
return b.price.value.minus(a.price.value).toNumber();
}
});
// 逐个撮合
for (const order of matchableOrders) {
if (incomingOrder.status === OrderStatus.FILLED) break;
const result = this.match(
incomingOrder.type === OrderType.BUY ? incomingOrder : order,
incomingOrder.type === OrderType.SELL ? incomingOrder : order,
);
if (result) {
results.push(result);
}
}
return results;
}
private generateTradeNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `T${timestamp}${random}`.toUpperCase();
}
}

View File

@ -0,0 +1,68 @@
import Decimal from 'decimal.js';
export class Money {
private readonly _value: Decimal;
constructor(value: Decimal | string | number) {
this._value = value instanceof Decimal ? value : new Decimal(value);
if (this._value.isNegative()) {
throw new Error('Money cannot be negative');
}
}
get value(): Decimal {
return this._value;
}
add(other: Money): Money {
return new Money(this._value.plus(other._value));
}
subtract(other: Money): Money {
const result = this._value.minus(other._value);
if (result.isNegative()) {
throw new Error('Insufficient balance');
}
return new Money(result);
}
multiply(factor: Decimal | string | number): Money {
return new Money(this._value.times(factor));
}
divide(divisor: Decimal | string | number): Money {
return new Money(this._value.dividedBy(divisor));
}
isZero(): boolean {
return this._value.isZero();
}
isGreaterThan(other: Money): boolean {
return this._value.greaterThan(other._value);
}
isLessThan(other: Money): boolean {
return this._value.lessThan(other._value);
}
isGreaterThanOrEqual(other: Money): boolean {
return this._value.greaterThanOrEqualTo(other._value);
}
equals(other: Money): boolean {
return this._value.equals(other._value);
}
toFixed(decimals: number = 8): string {
return this._value.toFixed(decimals);
}
toString(): string {
return this._value.toString();
}
static zero(): Money {
return new Money(0);
}
}

View File

@ -0,0 +1,48 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { PrismaModule } from './persistence/prisma/prisma.module';
import { TradingAccountRepository } from './persistence/repositories/trading-account.repository';
import { OrderRepository } from './persistence/repositories/order.repository';
import { RedisService } from './redis/redis.service';
@Global()
@Module({
imports: [
PrismaModule,
ClientsModule.registerAsync([
{
name: 'KAFKA_CLIENT',
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'trading-service',
brokers: configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
},
producer: { allowAutoTopicCreation: true },
},
}),
inject: [ConfigService],
},
]),
],
providers: [
TradingAccountRepository,
OrderRepository,
{
provide: 'REDIS_OPTIONS',
useFactory: (configService: ConfigService) => ({
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD'),
db: configService.get<number>('REDIS_DB', 2),
}),
inject: [ConfigService],
},
RedisService,
],
exports: [TradingAccountRepository, OrderRepository, RedisService, ClientsModule],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,19 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,145 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OrderAggregate, OrderType, OrderStatus } from '../../../domain/aggregates/order.aggregate';
import { Money } from '../../../domain/value-objects/money.vo';
@Injectable()
export class OrderRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<OrderAggregate | null> {
const record = await this.prisma.order.findUnique({ where: { id } });
if (!record) return null;
return this.toDomain(record);
}
async findByOrderNo(orderNo: string): Promise<OrderAggregate | null> {
const record = await this.prisma.order.findUnique({ where: { orderNo } });
if (!record) return null;
return this.toDomain(record);
}
async save(aggregate: OrderAggregate): 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,
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[]> {
const where: any = {
status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] },
};
if (type) where.type = type;
const records = await this.prisma.order.findMany({
where,
orderBy: [{ price: type === OrderType.BUY ? 'desc' : 'asc' }, { createdAt: 'asc' }],
});
return records.map((r) => this.toDomain(r));
}
async findByAccountSequence(
accountSequence: string,
options?: { status?: OrderStatus; page?: number; pageSize?: number },
): Promise<{ data: OrderAggregate[]; total: number }> {
const where: any = { accountSequence };
if (options?.status) where.status = options.status;
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 50;
const [records, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
return {
data: records.map((r) => this.toDomain(r)),
total,
};
}
async getOrderBook(limit: number = 20): Promise<{
bids: { price: string; quantity: string; count: number }[];
asks: { price: string; quantity: string; count: number }[];
}> {
const [bids, asks] = await Promise.all([
this.prisma.order.groupBy({
by: ['price'],
where: { type: OrderType.BUY, status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] } },
_sum: { remainingQuantity: true },
_count: { id: true },
orderBy: { price: 'desc' },
take: limit,
}),
this.prisma.order.groupBy({
by: ['price'],
where: { type: OrderType.SELL, status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] } },
_sum: { remainingQuantity: true },
_count: { id: true },
orderBy: { price: 'asc' },
take: limit,
}),
]);
return {
bids: bids.map((b) => ({
price: b.price.toString(),
quantity: (b._sum.remainingQuantity || 0).toString(),
count: b._count.id,
})),
asks: asks.map((a) => ({
price: a.price.toString(),
quantity: (a._sum.remainingQuantity || 0).toString(),
count: a._count.id,
})),
};
}
private toDomain(record: any): OrderAggregate {
return OrderAggregate.reconstitute({
id: record.id,
orderNo: record.orderNo,
accountSequence: record.accountSequence,
type: record.type as OrderType,
status: record.status as OrderStatus,
price: new Money(record.price),
quantity: new Money(record.quantity),
filledQuantity: new Money(record.filledQuantity),
remainingQuantity: new Money(record.remainingQuantity),
averagePrice: new Money(record.averagePrice),
totalAmount: new Money(record.totalAmount),
createdAt: record.createdAt,
cancelledAt: record.cancelledAt,
completedAt: record.completedAt,
});
}
}

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TradingAccountAggregate, AssetType } from '../../../domain/aggregates/trading-account.aggregate';
import { Money } from '../../../domain/value-objects/money.vo';
@Injectable()
export class TradingAccountRepository {
constructor(private readonly prisma: PrismaService) {}
async findByAccountSequence(accountSequence: string): Promise<TradingAccountAggregate | null> {
const record = await this.prisma.tradingAccount.findUnique({
where: { accountSequence },
});
if (!record) return null;
return this.toDomain(record);
}
async save(aggregate: TradingAccountAggregate): Promise<void> {
const transactions = aggregate.pendingTransactions;
await this.prisma.$transaction(async (tx) => {
await tx.tradingAccount.upsert({
where: { accountSequence: aggregate.accountSequence },
create: {
accountSequence: aggregate.accountSequence,
shareBalance: aggregate.shareBalance.value,
cashBalance: aggregate.cashBalance.value,
frozenShares: aggregate.frozenShares.value,
frozenCash: aggregate.frozenCash.value,
totalBought: aggregate.totalBought.value,
totalSold: aggregate.totalSold.value,
},
update: {
shareBalance: aggregate.shareBalance.value,
cashBalance: aggregate.cashBalance.value,
frozenShares: aggregate.frozenShares.value,
frozenCash: aggregate.frozenCash.value,
totalBought: aggregate.totalBought.value,
totalSold: aggregate.totalSold.value,
},
});
if (transactions.length > 0) {
await tx.tradingTransaction.createMany({
data: transactions.map((t) => ({
accountSequence: aggregate.accountSequence,
type: t.type,
assetType: t.assetType,
amount: t.amount.value,
balanceBefore: t.balanceBefore.value,
balanceAfter: t.balanceAfter.value,
referenceId: t.referenceId,
referenceType: t.referenceType,
description: t.description,
})),
});
}
});
aggregate.clearPendingTransactions();
}
async getTransactions(
accountSequence: string,
page: number = 1,
pageSize: number = 50,
): Promise<{ data: any[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.tradingTransaction.findMany({
where: { accountSequence },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.tradingTransaction.count({ where: { accountSequence } }),
]);
return { data: records, total };
}
private toDomain(record: any): TradingAccountAggregate {
return TradingAccountAggregate.reconstitute({
id: record.id,
accountSequence: record.accountSequence,
shareBalance: new Money(record.shareBalance),
cashBalance: new Money(record.cashBalance),
frozenShares: new Money(record.frozenShares),
frozenCash: new Money(record.frozenCash),
totalBought: new Money(record.totalBought),
totalSold: new Money(record.totalSold),
});
}
}

View File

@ -0,0 +1,50 @@
import { Injectable, Inject, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client: Redis;
constructor(@Inject('REDIS_OPTIONS') private readonly options: any) {}
async onModuleInit() {
this.client = new Redis({
host: this.options.host,
port: this.options.port,
password: this.options.password,
db: this.options.db ?? 2,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
this.client.on('error', (err) => this.logger.error('Redis error', err));
this.client.on('connect', () => this.logger.log('Connected to Redis'));
}
async onModuleDestroy() {
await this.client.quit();
}
getClient(): Redis { return this.client; }
async get(key: string): Promise<string | null> { return this.client.get(key); }
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async acquireLock(lockKey: string, ttlSeconds: number = 30): Promise<string | null> {
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const result = await this.client.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? lockValue : null;
}
async releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const result = await this.client.eval(script, 1, lockKey, lockValue);
return result === 1;
}
}

View File

@ -0,0 +1,62 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
});
app.setGlobalPrefix('api/v1');
const config = new DocumentBuilder()
.setTitle('Trading Service API')
.setDescription('交易服务 API 文档 - 积分股买卖交易')
.setVersion('1.0')
.addBearerAuth()
.addTag('Trading', '交易相关')
.addTag('Order', '订单相关')
.addTag('Transfer', '划转相关')
.addTag('Health', '健康检查')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const kafkaBrokers = process.env.KAFKA_BROKERS || 'localhost:9092';
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'trading-service',
brokers: kafkaBrokers.split(','),
},
consumer: {
groupId: 'trading-service-group',
},
},
});
await app.startAllMicroservices();
const port = process.env.PORT || 3022;
await app.listen(port);
console.log(`Trading Service is running on port ${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,45 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
export class DomainException extends Error {
constructor(message: string, public readonly code: string, public readonly httpStatus: HttpStatus = HttpStatus.BAD_REQUEST) {
super(message);
this.name = 'DomainException';
}
}
@Catch()
export class DomainExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'INTERNAL_ERROR';
let message = 'Internal server error';
if (exception instanceof DomainException) {
status = exception.httpStatus;
code = exception.code;
message = exception.message;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'object' ? (exceptionResponse as any).message || exception.message : exception.message;
code = 'HTTP_ERROR';
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: { code, message: Array.isArray(message) ? message : [message] },
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private reflector: Reflector, private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) throw new UnauthorizedException('No token provided');
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) throw new UnauthorizedException('Invalid token format');
try {
const secret = this.configService.get<string>('JWT_SECRET', 'default-secret');
const payload = jwt.verify(token, secret) as any;
request.user = { userId: payload.sub, accountSequence: payload.accountSequence };
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View File

@ -0,0 +1,21 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => this.logger.log(`${method} ${url} ${Date.now() - startTime}ms`),
error: (error) => this.logger.error(`${method} ${url} ${Date.now() - startTime}ms - Error: ${error.message}`),
}),
);
}
}

View File

@ -0,0 +1,10 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ success: true, data, timestamp: new Date().toISOString() })));
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -0,0 +1,403 @@
# 榴莲生态2.0 挖矿系统架构总览
## 1. 系统架构图
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 用户端应用 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Mining App │ │ Mining Admin Web │ │
│ │ (Flutter 用户端) │ │ (Next.js 管理后台) │ │
│ │ │ │ │ │
│ │ - 贡献值展示 │ │ - 系统配置管理 │ │
│ │ - 实时收益显示 │ │ - 用户查询 │ │
│ │ - 买卖兑换 │ │ - 报表统计 │ │
│ │ - K线图 │ │ - 初始化任务 │ │
│ │ - 资产展示 │ │ - 审计日志 │ │
│ └───────────┬─────────────┘ └───────────┬─────────────┘ │
│ │ │ │
└──────────────┼────────────────────────────────────────┼──────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ API Gateway (Nginx) │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 微服务层 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Contribution │ │ Mining │ │ Trading │ │
│ │ Service │ │ Service │ │ Service │ │
│ │ (贡献值/算力) │ │ (挖矿分配) │ │ (交易) │ │
│ │ Port: 3020 │ │ Port: 3021 │ │ Port: 3022 │ │
│ ├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤ │
│ │ - CDC数据同步 │ │ - 积分股分配 │ │ - 买卖撮合 │ │
│ │ - 算力计算 │ │ - 每分钟销毁 │ │ - K线生成 │ │
│ │ - 明细账管理 │ │ - 价格计算 │ │ - 手续费处理 │ │
│ │ - 过期处理 │ │ - 全局状态 │ │ - 流通池管理 │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘ │
│ │ │ │ │
│ └────────────────────────┼────────────────────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Mining Admin │ │
│ │ Service │ │
│ │ (挖矿管理) │ │
│ │ Port: 3023 │ │
│ ├───────────────────────┤ │
│ │ - 配置管理 │ │
│ │ - 系统监控 │ │
│ │ - 数据初始化 │ │
│ │ - 审计日志 │ │
│ └───────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ 现有服务 (集成) │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Identity │ │ Wallet │ │ Planting │ │
│ │ Service │ │ Service │ │ Service │ │
│ │ (用户) │ │ (钱包) │ │ (认种) │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ Referral │ │
│ │ Service │ │
│ │ (推荐关系) │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 基础设施层 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ PostgreSQL │ │ Kafka │ │ Redis │ │ Debezium Connect │ │
│ │ (数据库) │ │ (消息队列) │ │ (缓存) │ │ (CDC) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. 服务职责与端口分配
| 服务名称 | 端口 | 数据库 | 核心职责 |
|---------|------|--------|---------|
| **contribution-service** | 3020 | rwa_contribution | 算力计算、CDC同步、明细账 |
| **mining-service** | 3021 | rwa_mining | 积分股分配、销毁、价格计算 |
| **trading-service** | 3022 | rwa_trading | 买卖交易、K线、流通池 |
| **mining-admin-service** | 3023 | rwa_mining_admin | 配置管理、监控、初始化 |
---
## 3. 数据流向
### 3.1 算力计算流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 算力计算数据流 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Identity Service Planting Service Referral Service │
│ │ │ │ │
│ │ CDC │ CDC │ CDC │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Debezium Kafka Connect │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Contribution Service │ │
│ │ │ │
│ │ 1. 同步用户数据 (synced_users) │ │
│ │ 2. 同步认种数据 (synced_adoptions) │ │
│ │ 3. 同步推荐关系 (synced_referrals) │ │
│ │ 4. 计算用户算力: │ │
│ │ - 个人算力 (70%) │ │
│ │ - 团队层级算力 (0.5% × N级) │ │
│ │ - 团队额外奖励 (2.5% × N档) │ │
│ │ 5. 记录算力明细账 │ │
│ │ 6. 生成每日算力快照 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ Event: daily-snapshot-created │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Mining Service │ │
│ │ │ │
│ │ 根据算力快照分配每日积分股 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 3.2 挖矿分配流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 挖矿分配数据流 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 每日凌晨定时任务: │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Mining Service │ │
│ │ │ │
│ │ 1. 获取昨日算力快照 │ │
│ │ 2. 计算全网总算力 │ │
│ │ 3. 计算每个用户算力占比 │ │
│ │ 4. 根据占比分配当日积分股: │ │
│ │ - 第1-2年: 每日 1369.86 股 │ │
│ │ - 第3-4年: 每日 684.93 股 │ │
│ │ - 依次减半... │ │
│ │ 5. 更新用户积分股账户 │ │
│ │ 6. 记录挖矿明细账 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 每分钟定时任务: │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Mining Service │ │
│ │ │ │
│ │ 1. 计算当前销毁量: │ │
│ │ 销毁量 = (100亿 - 黑洞量) ÷ 剩余分钟数 │ │
│ │ 2. 执行销毁(进入黑洞) │ │
│ │ 3. 重新计算价格: │ │
│ │ 价格 = 积分股池 ÷ (100.02亿 - 黑洞 - 流通池) │ │
│ │ 4. 记录销毁明细账 │ │
│ │ 5. 记录价格快照 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 3.3 交易流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 交易数据流 (卖出) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 用户发起卖出: │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Trading Service │ │
│ │ │ │
│ │ 1. 获取当前价格 │ │
│ │ 2. 计算卖出倍数: │ │
│ │ 倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量) │ │
│ │ 3. 计算卖出销毁量: │ │
│ │ 销毁量 = 卖出量 × 倍数 │ │
│ │ 4. 计算交易额: │ │
│ │ 交易额 = (卖出量 + 销毁量) × 价格 │ │
│ │ 5. 计算手续费 (10%) │ │
│ │ 6. 执行交易: │ │
│ │ - 扣减用户积分股 │ │
│ │ - 积分股进入流通池 │ │
│ │ - 从积分股池扣减绿积分给用户 │ │
│ │ - 手续费注入积分股池 │ │
│ │ 7. 记录交易明细账 │ │
│ │ 8. 更新K线数据 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ Event: trade-completed │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Mining Service │ │
│ │ │ │
│ │ 1. 更新流通池状态 │ │
│ │ 2. 触发卖出销毁(提前销毁未来的量) │ │
│ │ 3. 重新计算每分钟销毁量 │ │
│ │ 4. 重新计算价格 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 4. 服务间通信
### 4.1 事件驱动 (Kafka Topics)
| Topic | 发布者 | 订阅者 | 说明 |
|-------|--------|--------|------|
| `dbserver1.rwa_identity.users` | Debezium | contribution-service | 用户CDC |
| `dbserver1.rwa_planting.adoptions` | Debezium | contribution-service | 认种CDC |
| `dbserver1.rwa_referral.referral_relations` | Debezium | contribution-service | 推荐关系CDC |
| `contribution.daily-snapshot-created` | contribution-service | mining-service | 每日算力快照 |
| `mining.shares-distributed` | mining-service | trading-service, mining-admin | 积分股分配完成 |
| `mining.price-updated` | mining-service | trading-service, mining-admin | 价格更新 |
| `trading.trade-completed` | trading-service | mining-service | 交易完成 |
| `mining-admin.config-updated` | mining-admin-service | 所有服务 | 配置更新 |
### 4.2 同步调用 (HTTP)
```
mining-admin-service → contribution-service: 获取用户算力详情
mining-admin-service → mining-service: 获取全局状态、用户积分股
mining-admin-service → trading-service: 获取交易统计、K线数据
trading-service → mining-service: 获取当前价格、全局状态
trading-service → wallet-service: 扣减/增加绿积分
```
---
## 5. 关键数据表关系
### 5.1 跨服务数据关联
**所有服务统一使用 `account_sequence` 作为跨服务关联键,不使用 `userId`**
```
┌─────────────────────────────────────────────────────────────────────────┐
│ account_sequence 关联 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Identity Service │
│ └── users.account_sequence ────────────────────────────────┐ │
│ │ │
│ Contribution Service │ │
│ ├── synced_users.account_sequence ─────────────────────────┤ │
│ ├── contribution_accounts.account_sequence ────────────────┤ │
│ └── contribution_records.account_sequence ─────────────────┤ │
│ │ │
│ Mining Service │ │
│ ├── share_accounts.account_sequence ───────────────────────┤ │
│ └── daily_mining_records.account_sequence ─────────────────┤ │
│ │ │
│ Trading Service │ │
│ ├── trade_orders.account_sequence ─────────────────────────┤ │
│ └── trade_transactions.account_sequence ───────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 5.2 明细账设计原则
每个服务都维护自己的明细账,确保数据可追溯:
| 服务 | 明细账表 | 记录内容 |
|------|---------|---------|
| contribution-service | contribution_records | 每笔算力来源 |
| mining-service | daily_mining_records | 每日挖矿分配 |
| mining-service | burn_records | 每次销毁详情 |
| trading-service | trade_transactions | 每笔交易流水 |
---
## 6. 关键配置参数
### 6.1 贡献值配置
| 参数 | 默认值 | 说明 |
|------|--------|------|
| base_contribution | 22617 | 基础贡献值 |
| increment_percentage | 0.3% | 每单位递增比例 |
| unit_size | 100 | 单位大小(棵) |
| personal_rate | 70% | 个人分配比例 |
| level_rate_per | 0.5% | 每级分配比例 |
| bonus_rate_per | 2.5% | 每档奖励比例 |
### 6.2 分配配置
| 阶段 | 时长 | 总量 | 每日分配 |
|------|------|------|---------|
| 第1阶段 | 2年 | 100万 | 1369.86 |
| 第2阶段 | 2年 | 50万 | 684.93 |
| 第3阶段 | 2年 | 25万 | 342.47 |
| ... | ... | ... | ... |
### 6.3 交易配置
| 参数 | 默认值 | 说明 |
|------|--------|------|
| buy_fee_rate | 10% | 买入手续费 |
| sell_fee_rate | 10% | 卖出手续费 |
| transfer_enabled | false | 转账开关 |
---
## 7. 部署架构
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Docker Compose │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 服务容器: │
│ ├── contribution-service (3020) │
│ ├── mining-service (3021) │
│ ├── trading-service (3022) │
│ └── mining-admin-service (3023) │
│ │
│ 基础设施容器: │
│ ├── postgres (5432) - 已有,新增 schema │
│ ├── kafka (9092) - 已有 │
│ ├── redis (6379) - 已有 │
│ └── debezium-connect (8084) - 已有,新增 connector │
│ │
│ 前端容器: │
│ ├── mining-admin-web (3100) │
│ └── api-gateway (nginx) - 已有,新增路由 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 8. 开发顺序建议
1. **Phase 1: 数据同步**
- contribution-service: CDC 消费者实现
- 同步用户、认种、推荐关系数据
2. **Phase 2: 算力计算**
- contribution-service: 算力计算逻辑
- 老用户算力初始化
3. **Phase 3: 挖矿分配**
- mining-service: 全局状态管理
- mining-service: 每日分配、每分钟销毁
4. **Phase 4: 交易功能**
- trading-service: 买卖逻辑
- trading-service: K线生成
5. **Phase 5: 管理后台**
- mining-admin-service: 配置管理、监控
- mining-admin-web: 前端实现
6. **Phase 6: 用户端**
- mining-app: Flutter App 实现
---
## 9. 文档索引
| 服务/应用 | 开发指导文档路径 |
|----------|----------------|
| contribution-service | [backend/services/contribution-service/DEVELOPMENT_GUIDE.md](../backend/services/contribution-service/DEVELOPMENT_GUIDE.md) |
| mining-service | [backend/services/mining-service/DEVELOPMENT_GUIDE.md](../backend/services/mining-service/DEVELOPMENT_GUIDE.md) |
| trading-service | [backend/services/trading-service/DEVELOPMENT_GUIDE.md](../backend/services/trading-service/DEVELOPMENT_GUIDE.md) |
| mining-admin-service | [backend/services/mining-admin-service/DEVELOPMENT_GUIDE.md](../backend/services/mining-admin-service/DEVELOPMENT_GUIDE.md) |
| mining-admin-web | [frontend/mining-admin-web/DEVELOPMENT_GUIDE.md](../frontend/mining-admin-web/DEVELOPMENT_GUIDE.md) |
| mining-app | [frontend/mining-app/DEVELOPMENT_GUIDE.md](../frontend/mining-app/DEVELOPMENT_GUIDE.md) |

View File

@ -0,0 +1,809 @@
# Mining Admin Web (挖矿管理后台) 开发指导
## 1. 项目概述
### 1.1 核心职责
Mining Admin Web 是挖矿系统的管理后台,供运营人员使用,用于配置管理、监控、数据查询等功能。
**主要功能:**
- 仪表盘(实时数据监控)
- 用户管理(算力/积分股查询)
- 配置管理(系统参数配置)
- 系统账户管理
- 报表与统计
- 审计日志查看
- 初始化任务管理
### 1.2 技术栈
```
框架: Next.js 14 (App Router)
语言: TypeScript
状态管理: Zustand + Redux Toolkit (混合模式)
UI 组件: Shadcn/ui + Tailwind CSS
图表: ECharts / Recharts
表格: TanStack Table
表单: React Hook Form + Zod
请求: TanStack Query + Axios
```
### 1.3 架构模式
```
Clean Architecture + Feature-Sliced Design
```
---
## 2. 目录结构
```
mining-admin-web/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # 认证相关页面组
│ │ │ ├── login/
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── (dashboard)/ # 主应用页面组
│ │ │ ├── dashboard/
│ │ │ │ └── page.tsx # 仪表盘首页
│ │ │ ├── users/
│ │ │ │ ├── page.tsx # 用户列表
│ │ │ │ └── [accountSequence]/
│ │ │ │ └── page.tsx # 用户详情
│ │ │ ├── configs/
│ │ │ │ └── page.tsx # 配置管理
│ │ │ ├── system-accounts/
│ │ │ │ └── page.tsx # 系统账户
│ │ │ ├── reports/
│ │ │ │ └── page.tsx # 报表
│ │ │ ├── audit-logs/
│ │ │ │ └── page.tsx # 审计日志
│ │ │ ├── initialization/
│ │ │ │ └── page.tsx # 初始化任务
│ │ │ └── layout.tsx # 主布局(侧边栏+顶栏)
│ │ ├── api/ # API Routes (BFF)
│ │ │ └── [...path]/
│ │ │ └── route.ts # 代理到后端服务
│ │ ├── layout.tsx # 根布局
│ │ ├── page.tsx # 根页面(重定向)
│ │ └── globals.css
│ │
│ ├── components/ # 共享组件
│ │ ├── ui/ # 基础UI组件 (Shadcn)
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── input.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── toast.tsx
│ │ │ └── ...
│ │ ├── charts/ # 图表组件
│ │ │ ├── price-chart.tsx # 价格K线图
│ │ │ ├── contribution-chart.tsx # 算力趋势图
│ │ │ ├── distribution-chart.tsx # 分配统计图
│ │ │ └── realtime-gauge.tsx # 实时仪表盘
│ │ ├── tables/ # 表格组件
│ │ │ ├── data-table.tsx # 通用数据表格
│ │ │ ├── user-table.tsx
│ │ │ ├── config-table.tsx
│ │ │ └── audit-log-table.tsx
│ │ ├── forms/ # 表单组件
│ │ │ ├── config-form.tsx
│ │ │ └── system-account-form.tsx
│ │ └── layout/ # 布局组件
│ │ ├── sidebar.tsx
│ │ ├── header.tsx
│ │ ├── breadcrumb.tsx
│ │ └── page-header.tsx
│ │
│ ├── features/ # 功能模块 (Feature-Sliced)
│ │ ├── dashboard/
│ │ │ ├── components/
│ │ │ │ ├── stats-cards.tsx
│ │ │ │ ├── realtime-panel.tsx
│ │ │ │ ├── price-overview.tsx
│ │ │ │ └── activity-feed.tsx
│ │ │ ├── hooks/
│ │ │ │ ├── use-dashboard-stats.ts
│ │ │ │ └── use-realtime-data.ts
│ │ │ ├── api/
│ │ │ │ └── dashboard.api.ts
│ │ │ └── store/
│ │ │ └── dashboard.slice.ts # RTK Slice
│ │ │
│ │ ├── users/
│ │ │ ├── components/
│ │ │ │ ├── user-search.tsx
│ │ │ │ ├── user-detail-card.tsx
│ │ │ │ ├── contribution-breakdown.tsx
│ │ │ │ ├── mining-records-list.tsx
│ │ │ │ └── trade-orders-list.tsx
│ │ │ ├── hooks/
│ │ │ │ ├── use-users.ts
│ │ │ │ └── use-user-detail.ts
│ │ │ ├── api/
│ │ │ │ └── users.api.ts
│ │ │ └── store/
│ │ │ └── users.slice.ts
│ │ │
│ │ ├── configs/
│ │ │ ├── components/
│ │ │ │ ├── config-list.tsx
│ │ │ │ ├── config-edit-dialog.tsx
│ │ │ │ └── transfer-switch.tsx
│ │ │ ├── hooks/
│ │ │ │ └── use-configs.ts
│ │ │ ├── api/
│ │ │ │ └── configs.api.ts
│ │ │ └── store/
│ │ │ └── configs.slice.ts
│ │ │
│ │ ├── system-accounts/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── api/
│ │ │ └── store/
│ │ │
│ │ ├── reports/
│ │ │ ├── components/
│ │ │ │ ├── report-filters.tsx
│ │ │ │ ├── contribution-report.tsx
│ │ │ │ ├── mining-report.tsx
│ │ │ │ └── trading-report.tsx
│ │ │ ├── hooks/
│ │ │ ├── api/
│ │ │ └── store/
│ │ │
│ │ └── audit-logs/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── store/
│ │
│ ├── lib/ # 工具库
│ │ ├── api/
│ │ │ ├── client.ts # Axios 实例
│ │ │ ├── interceptors.ts # 请求拦截器
│ │ │ └── types.ts # API 类型
│ │ ├── utils/
│ │ │ ├── format.ts # 格式化工具
│ │ │ ├── decimal.ts # 高精度计算
│ │ │ └── date.ts # 日期处理
│ │ ├── hooks/
│ │ │ ├── use-auth.ts
│ │ │ ├── use-toast.ts
│ │ │ └── use-confirmation.ts
│ │ └── constants/
│ │ ├── routes.ts
│ │ └── config.ts
│ │
│ ├── store/ # 全局状态管理
│ │ ├── index.ts # Store 配置
│ │ ├── slices/
│ │ │ ├── auth.slice.ts # RTK: 认证状态
│ │ │ ├── ui.slice.ts # RTK: UI状态
│ │ │ └── realtime.slice.ts # RTK: 实时数据
│ │ ├── middleware/
│ │ │ └── logger.ts
│ │ └── zustand/
│ │ ├── use-sidebar.ts # Zustand: 侧边栏状态
│ │ └── use-theme.ts # Zustand: 主题状态
│ │
│ └── types/ # 全局类型定义
│ ├── api.ts
│ ├── user.ts
│ ├── config.ts
│ ├── dashboard.ts
│ └── common.ts
├── public/
│ └── images/
├── .env.local # 环境变量
├── .env.production
├── next.config.js
├── tailwind.config.js
├── tsconfig.json
├── package.json
└── README.md
```
---
## 3. 状态管理策略
### 3.1 混合模式Zustand + Redux Toolkit
```
┌─────────────────────────────────────────────────────────────┐
│ 状态管理分层策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Redux Toolkit (RTK) │
│ ├── 复杂业务状态(用户列表、配置、报表等) │
│ ├── 需要 DevTools 调试的状态 │
│ ├── 需要中间件处理的状态 │
│ └── 跨页面共享的业务数据 │
│ │
│ Zustand │
│ ├── 简单UI状态侧边栏、模态框、主题
│ ├── 临时状态(表单草稿、筛选条件) │
│ └── 组件级局部状态 │
│ │
│ TanStack Query │
│ ├── 服务端数据缓存 │
│ ├── 自动重新获取 │
│ └── 乐观更新 │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 RTK Slice 示例
```typescript
// store/slices/configs.slice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { configsApi } from '@/features/configs/api/configs.api';
import type { SystemConfig } from '@/types/config';
interface ConfigsState {
items: SystemConfig[];
loading: boolean;
error: string | null;
selectedKey: string | null;
}
const initialState: ConfigsState = {
items: [],
loading: false,
error: null,
selectedKey: null,
};
// Async Thunks
export const fetchConfigs = createAsyncThunk(
'configs/fetchConfigs',
async (_, { rejectWithValue }) => {
try {
const response = await configsApi.getAll();
return response.data;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const updateConfig = createAsyncThunk(
'configs/updateConfig',
async ({ key, value }: { key: string; value: string }, { rejectWithValue }) => {
try {
const response = await configsApi.update(key, value);
return response.data;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
// Slice
const configsSlice = createSlice({
name: 'configs',
initialState,
reducers: {
setSelectedKey: (state, action: PayloadAction<string | null>) => {
state.selectedKey = action.payload;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchConfigs.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchConfigs.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchConfigs.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
.addCase(updateConfig.fulfilled, (state, action) => {
const index = state.items.findIndex(
(item) => item.configKey === action.payload.configKey
);
if (index !== -1) {
state.items[index] = action.payload;
}
});
},
});
export const { setSelectedKey, clearError } = configsSlice.actions;
export default configsSlice.reducer;
```
### 3.3 Zustand Store 示例
```typescript
// store/zustand/use-sidebar.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SidebarState {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (collapsed: boolean) => void;
}
export const useSidebar = create<SidebarState>()(
persist(
(set) => ({
isCollapsed: false,
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
setCollapsed: (collapsed) => set({ isCollapsed: collapsed }),
}),
{
name: 'sidebar-state',
}
)
);
```
### 3.4 TanStack Query 示例
```typescript
// features/dashboard/hooks/use-dashboard-stats.ts
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '../api/dashboard.api';
export function useDashboardStats() {
return useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: () => dashboardApi.getStats(),
refetchInterval: 30000, // 30秒刷新
staleTime: 10000, // 10秒内不重新获取
});
}
export function useRealtimeData() {
return useQuery({
queryKey: ['dashboard', 'realtime'],
queryFn: () => dashboardApi.getRealtimeData(),
refetchInterval: 5000, // 5秒刷新
staleTime: 3000,
});
}
```
---
## 4. 页面开发示例
### 4.1 仪表盘页面
```typescript
// app/(dashboard)/dashboard/page.tsx
'use client';
import { StatsCards } from '@/features/dashboard/components/stats-cards';
import { RealtimePanel } from '@/features/dashboard/components/realtime-panel';
import { PriceOverview } from '@/features/dashboard/components/price-overview';
import { ActivityFeed } from '@/features/dashboard/components/activity-feed';
import { PageHeader } from '@/components/layout/page-header';
export default function DashboardPage() {
return (
<div className="space-y-6">
<PageHeader
title="仪表盘"
description="挖矿系统实时数据概览"
/>
{/* 统计卡片 */}
<StatsCards />
{/* 主要内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 价格概览 */}
<div className="lg:col-span-2">
<PriceOverview />
</div>
{/* 实时数据面板 */}
<div>
<RealtimePanel />
</div>
</div>
{/* 活动动态 */}
<ActivityFeed />
</div>
);
}
```
### 4.2 统计卡片组件
```typescript
// features/dashboard/components/stats-cards.tsx
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useDashboardStats } from '../hooks/use-dashboard-stats';
import { formatNumber, formatDecimal } from '@/lib/utils/format';
import { TrendingUp, Users, Coins, Activity } from 'lucide-react';
export function StatsCards() {
const { data: stats, isLoading } = useDashboardStats();
if (isLoading) {
return <StatsCardsSkeleton />;
}
const cards = [
{
title: '当前价格',
value: formatDecimal(stats?.currentPrice, 8),
unit: '绿积分/股',
icon: TrendingUp,
trend: '+12.5%',
trendUp: true,
},
{
title: '全网算力',
value: formatNumber(stats?.networkEffectiveContribution),
icon: Activity,
},
{
title: '已分配积分股',
value: formatNumber(stats?.totalDistributed),
icon: Coins,
},
{
title: '认种用户数',
value: formatNumber(stats?.adoptedUsers),
icon: Users,
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
{card.unit && (
<p className="text-xs text-muted-foreground">{card.unit}</p>
)}
{card.trend && (
<p className={`text-xs ${card.trendUp ? 'text-green-500' : 'text-red-500'}`}>
{card.trend} 较昨日
</p>
)}
</CardContent>
</Card>
))}
</div>
);
}
```
### 4.3 用户详情页面
```typescript
// app/(dashboard)/users/[accountSequence]/page.tsx
'use client';
import { useParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/layout/page-header';
import { UserDetailCard } from '@/features/users/components/user-detail-card';
import { ContributionBreakdown } from '@/features/users/components/contribution-breakdown';
import { MiningRecordsList } from '@/features/users/components/mining-records-list';
import { TradeOrdersList } from '@/features/users/components/trade-orders-list';
import { useUserDetail } from '@/features/users/hooks/use-user-detail';
export default function UserDetailPage() {
const params = useParams();
const accountSequence = params.accountSequence as string;
const { data: user, isLoading } = useUserDetail(accountSequence);
if (isLoading) {
return <UserDetailSkeleton />;
}
return (
<div className="space-y-6">
<PageHeader
title="用户详情"
description={`账户序列: ${accountSequence}`}
backLink="/users"
/>
{/* 用户基本信息卡片 */}
<UserDetailCard user={user} />
{/* 详细信息标签页 */}
<Tabs defaultValue="contribution">
<TabsList>
<TabsTrigger value="contribution">算力明细</TabsTrigger>
<TabsTrigger value="mining">挖矿记录</TabsTrigger>
<TabsTrigger value="trading">交易记录</TabsTrigger>
</TabsList>
<TabsContent value="contribution">
<ContributionBreakdown accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="mining">
<MiningRecordsList accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="trading">
<TradeOrdersList accountSequence={accountSequence} />
</TabsContent>
</Tabs>
</div>
);
}
```
---
## 5. API 客户端
### 5.1 Axios 实例配置
```typescript
// lib/api/client.ts
import axios from 'axios';
import { getSession } from 'next-auth/react';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
apiClient.interceptors.request.use(
async (config) => {
const session = await getSession();
if (session?.accessToken) {
config.headers.Authorization = `Bearer ${session.accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 处理未授权
window.location.href = '/login';
}
return Promise.reject(error);
}
);
```
### 5.2 功能模块 API
```typescript
// features/dashboard/api/dashboard.api.ts
import { apiClient } from '@/lib/api/client';
import type { DashboardStats, RealtimeData } from '@/types/dashboard';
export const dashboardApi = {
getStats: async (): Promise<DashboardStats> => {
const response = await apiClient.get('/admin/dashboard');
return response.data;
},
getRealtimeData: async (): Promise<RealtimeData> => {
const response = await apiClient.get('/admin/dashboard/realtime');
return response.data;
},
getChartData: async (type: string, period: string) => {
const response = await apiClient.get('/admin/dashboard/charts', {
params: { type, period },
});
return response.data;
},
};
```
---
## 6. 图表组件
### 6.1 价格K线图
```typescript
// components/charts/price-chart.tsx
'use client';
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts';
import type { KlineData } from '@/types/dashboard';
interface PriceChartProps {
data: KlineData[];
period: string;
}
export function PriceChart({ data, period }: PriceChartProps) {
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!chartRef.current || !data.length) return;
const chart = echarts.init(chartRef.current);
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
xAxis: {
type: 'category',
data: data.map((d) => d.time),
boundaryGap: false,
},
yAxis: {
type: 'value',
scale: true,
splitLine: { show: true },
},
series: [
{
name: '价格',
type: 'candlestick',
data: data.map((d) => [d.open, d.close, d.low, d.high]),
itemStyle: {
color: '#ef4444', // 上涨
color0: '#22c55e', // 下跌
borderColor: '#ef4444',
borderColor0: '#22c55e',
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 0,
yAxisIndex: 1,
data: data.map((d) => d.volume),
itemStyle: {
color: (params: any) => {
const item = data[params.dataIndex];
return item.close >= item.open ? '#ef4444' : '#22c55e';
},
},
},
],
grid: [
{ left: '10%', right: '10%', height: '50%' },
{ left: '10%', right: '10%', top: '65%', height: '20%' },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 50, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 10 },
],
};
chart.setOption(option);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.dispose();
};
}, [data, period]);
return <div ref={chartRef} className="w-full h-[400px]" />;
}
```
---
## 7. 环境变量
```bash
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:3023
NEXT_PUBLIC_WS_URL=ws://localhost:3023
# 认证
NEXTAUTH_URL=http://localhost:3100
NEXTAUTH_SECRET=your-secret-key
# 其他
NEXT_PUBLIC_APP_NAME="挖矿管理后台"
```
---
## 8. 关键注意事项
### 8.1 数据精度
- 使用 `Decimal.js` 或字符串处理大数值
- 显示时根据场景选择合适的小数位数
- 价格显示8位小数数量显示4位小数
### 8.2 实时数据
- 使用 TanStack Query 的 `refetchInterval` 实现轮询
- 考虑使用 WebSocket 获取实时推送
- 注意内存泄漏,组件卸载时清理订阅
### 8.3 性能优化
- 大表格使用虚拟滚动
- 图表数据分页加载
- 使用 `React.memo` 优化重渲染
### 8.4 错误处理
- 全局错误边界捕获异常
- API 错误统一 toast 提示
- 表单验证使用 Zod
---
## 9. 开发检查清单
- [ ] 配置 Next.js 项目
- [ ] 集成 Shadcn/ui 组件库
- [ ] 配置 Redux Toolkit Store
- [ ] 配置 Zustand Stores
- [ ] 配置 TanStack Query
- [ ] 实现登录认证
- [ ] 实现仪表盘页面
- [ ] 实现用户列表/详情页面
- [ ] 实现配置管理页面
- [ ] 实现系统账户页面
- [ ] 实现报表页面
- [ ] 实现审计日志页面
- [ ] 实现K线图表
- [ ] 响应式布局适配
- [ ] 编写测试
---
## 10. 启动命令
```bash
# 安装依赖
npm install
# 开发环境
npm run dev
# 构建生产版本
npm run build
# 启动生产版本
npm run start
# 代码检查
npm run lint
# 类型检查
npm run type-check
```

View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023'}/api/v1/:path*`,
},
];
},
};
module.exports = nextConfig;

8301
frontend/mining-admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More