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:
parent
eaead7d4f3
commit
a17f408653
|
|
@ -5,34 +5,127 @@ nul
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
out/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
*.env
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# Test coverage
|
# Test coverage
|
||||||
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
|
# package-lock.json
|
||||||
|
# yarn.lock
|
||||||
|
# pnpm-lock.yaml
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }; }
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||||
|
export class PrismaModule {}
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 }; }
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) |
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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;
|
||||||
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
Loading…
Reference in New Issue