feat(contribution-service): 添加算力管理微服务
## 概述 为榴莲生态2.0添加 contribution-service 微服务,负责算力计算、分配和快照管理。 ## 架构设计 - 采用 DDD + Hexagonal Architecture (六边形架构) - 使用 NestJS 框架 + Prisma ORM - 通过 Kafka CDC (Debezium) 从 user-service 同步数据 - 使用 accountSequence (而非 userId) 进行跨服务关联 ## 核心功能模块 ### 1. Domain Layer (领域层) - ContributionAccountAggregate: 算力账户聚合根 - ContributionRecordAggregate: 算力记录聚合根 - ContributionAmount: 算力金额值对象 (基于 Decimal.js) - DistributionRate: 分配比例值对象 - ContributionSourceType: 算力来源类型枚举 (PERSONAL/TEAM_LEVEL/TEAM_BONUS) ### 2. Application Layer (应用层) - ContributionCalculationService: 算力计算核心服务 - 个人算力: 认种金额 × 10 - 团队等级奖励: 基于直推有效认种人数 - 团队极差奖励: 多级分销算法 - SnapshotService: 每日算力快照服务 - CDC Event Handlers: 处理用户、认种、引荐关系同步事件 ### 3. Infrastructure Layer (基础设施层) - Prisma Repositories: - ContributionAccountRepository - ContributionRecordRepository - SyncedDataRepository (同步数据) - OutboxRepository (发件箱模式) - SystemAccountRepository - UnallocatedContributionRepository - Kafka CDC Consumer: 消费 Debezium CDC 事件 - Redis: 缓存支持 - UnitOfWork: 事务管理 ### 4. API Layer (接口层) - ContributionController: 算力查询接口 - SnapshotController: 快照管理接口 - HealthController: 健康检查 ## 数据模型 (Prisma Schema) - ContributionAccount: 算力账户 - ContributionRecord: 算力记录 (支持过期) - DailyContributionSnapshot: 每日快照 - SyncedUser/SyncedAdoption/SyncedReferral: CDC 同步数据 - OutboxEvent: 发件箱事件 - SystemContributionAccount: 系统账户 - UnallocatedContribution: 未分配算力 ## TypeScript 类型修复 - 修复所有 Repository 接口与实现的类型不匹配 - 修复 ContributionAmount.multiply() 返回值类型 - 修复 isZero getter vs method 问题 - 修复 bigint vs string 类型转换 - 统一使用 items/total 返回格式 - 修复 Prisma schema 字段名映射 (unallocType, contributionBalance 等) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d9f9ae5122
commit
eaead7d4f3
|
|
@ -2,3 +2,37 @@ nul
|
||||||
|
|
||||||
# Claude Code settings
|
# Claude Code settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Package lock (optional - keep package-lock.json if needed)
|
||||||
|
# package-lock.json
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Application
|
||||||
|
APP_PORT=3020
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwa_contribution?schema=public"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Kafka
|
||||||
|
KAFKA_BROKERS=localhost:9092
|
||||||
|
KAFKA_GROUP_ID=contribution-service-group
|
||||||
|
|
||||||
|
# JWT (for auth validation)
|
||||||
|
JWT_SECRET=your-jwt-secret
|
||||||
|
|
||||||
|
# CDC Topics
|
||||||
|
CDC_TOPIC_USERS=dbserver1.rwa_identity.users
|
||||||
|
CDC_TOPIC_ADOPTIONS=dbserver1.rwa_planting.adoptions
|
||||||
|
CDC_TOPIC_REFERRALS=dbserver1.rwa_referral.referral_relations
|
||||||
|
|
@ -0,0 +1,720 @@
|
||||||
|
# Contribution Service (贡献值/算力服务) 开发指导
|
||||||
|
|
||||||
|
## 1. 服务概述
|
||||||
|
|
||||||
|
### 1.1 核心职责
|
||||||
|
Contribution Service 负责管理用户的贡献值(算力),这是挖矿系统的核心计算基础。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- 通过 Debezium CDC 同步用户、认种、推荐关系数据
|
||||||
|
- 计算用户算力(来自自己认种 + 团队贡献)
|
||||||
|
- 维护算力明细账(每笔算力的来源可追溯)
|
||||||
|
- 处理算力过期(2年有效期)
|
||||||
|
- 管理未分配算力归总部逻辑
|
||||||
|
|
||||||
|
### 1.2 技术栈
|
||||||
|
- **框架**: NestJS + TypeScript
|
||||||
|
- **数据库**: PostgreSQL (事务型)
|
||||||
|
- **ORM**: Prisma
|
||||||
|
- **消息队列**: Kafka (Debezium CDC + 事件发布)
|
||||||
|
- **缓存**: Redis
|
||||||
|
|
||||||
|
### 1.3 端口分配
|
||||||
|
- HTTP: 3020
|
||||||
|
- 数据库: rwa_contribution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 架构设计
|
||||||
|
|
||||||
|
### 2.1 六边形架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ API Layer (api/) │
|
||||||
|
│ Controllers, DTOs - 处理 HTTP 请求 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Application Layer (application/) │
|
||||||
|
│ Commands, Queries, Event Handlers - 业务流程编排 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Domain Layer (domain/) │
|
||||||
|
│ Aggregates, Value Objects, Domain Events - 核心业务规则 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Infrastructure Layer (infrastructure/) │
|
||||||
|
│ Prisma, Kafka, Redis - 技术实现细节 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
contribution-service/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API层
|
||||||
|
│ │ ├── controllers/
|
||||||
|
│ │ │ ├── contribution.controller.ts # 算力查询API
|
||||||
|
│ │ │ ├── sync-status.controller.ts # 同步状态API
|
||||||
|
│ │ │ └── health.controller.ts
|
||||||
|
│ │ └── dto/
|
||||||
|
│ │ ├── request/
|
||||||
|
│ │ └── response/
|
||||||
|
│ │ ├── contribution-account.response.ts
|
||||||
|
│ │ └── contribution-detail.response.ts
|
||||||
|
│ │
|
||||||
|
│ ├── application/ # 应用层
|
||||||
|
│ │ ├── commands/
|
||||||
|
│ │ │ ├── calculate-user-contribution.command.ts
|
||||||
|
│ │ │ ├── process-adoption-contribution.command.ts
|
||||||
|
│ │ │ ├── expire-contributions.command.ts
|
||||||
|
│ │ │ └── recalculate-all-contributions.command.ts
|
||||||
|
│ │ ├── queries/
|
||||||
|
│ │ │ ├── get-user-contribution.query.ts
|
||||||
|
│ │ │ ├── get-contribution-details.query.ts
|
||||||
|
│ │ │ └── get-network-total-contribution.query.ts
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── contribution-calculation.service.ts
|
||||||
|
│ │ ├── event-handlers/
|
||||||
|
│ │ │ ├── adoption-synced.handler.ts
|
||||||
|
│ │ │ ├── user-synced.handler.ts
|
||||||
|
│ │ │ └── referral-synced.handler.ts
|
||||||
|
│ │ └── schedulers/
|
||||||
|
│ │ ├── contribution-expiry.scheduler.ts
|
||||||
|
│ │ └── daily-snapshot.scheduler.ts
|
||||||
|
│ │
|
||||||
|
│ ├── domain/ # 领域层
|
||||||
|
│ │ ├── aggregates/
|
||||||
|
│ │ │ ├── contribution-account.aggregate.ts
|
||||||
|
│ │ │ └── contribution-record.aggregate.ts
|
||||||
|
│ │ ├── repositories/
|
||||||
|
│ │ │ ├── contribution-account.repository.interface.ts
|
||||||
|
│ │ │ ├── contribution-record.repository.interface.ts
|
||||||
|
│ │ │ ├── synced-user.repository.interface.ts
|
||||||
|
│ │ │ ├── synced-adoption.repository.interface.ts
|
||||||
|
│ │ │ └── synced-referral.repository.interface.ts
|
||||||
|
│ │ ├── value-objects/
|
||||||
|
│ │ │ ├── contribution-amount.vo.ts
|
||||||
|
│ │ │ ├── distribution-rate.vo.ts
|
||||||
|
│ │ │ └── account-sequence.vo.ts
|
||||||
|
│ │ ├── events/
|
||||||
|
│ │ │ ├── contribution-calculated.event.ts
|
||||||
|
│ │ │ ├── contribution-expired.event.ts
|
||||||
|
│ │ │ └── daily-snapshot-created.event.ts
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── contribution-calculator.service.ts
|
||||||
|
│ │ └── team-contribution-calculator.service.ts
|
||||||
|
│ │
|
||||||
|
│ ├── infrastructure/ # 基础设施层
|
||||||
|
│ │ ├── persistence/
|
||||||
|
│ │ │ ├── prisma/
|
||||||
|
│ │ │ │ └── prisma.service.ts
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ │ ├── contribution-account.repository.impl.ts
|
||||||
|
│ │ │ │ ├── contribution-record.repository.impl.ts
|
||||||
|
│ │ │ │ ├── synced-user.repository.impl.ts
|
||||||
|
│ │ │ │ ├── synced-adoption.repository.impl.ts
|
||||||
|
│ │ │ │ └── synced-referral.repository.impl.ts
|
||||||
|
│ │ │ └── unit-of-work/
|
||||||
|
│ │ │ └── unit-of-work.service.ts
|
||||||
|
│ │ ├── kafka/
|
||||||
|
│ │ │ ├── cdc-consumers/
|
||||||
|
│ │ │ │ ├── user-cdc.consumer.ts
|
||||||
|
│ │ │ │ ├── adoption-cdc.consumer.ts
|
||||||
|
│ │ │ │ └── referral-cdc.consumer.ts
|
||||||
|
│ │ │ ├── event-publisher.service.ts
|
||||||
|
│ │ │ └── kafka.module.ts
|
||||||
|
│ │ ├── redis/
|
||||||
|
│ │ │ └── contribution-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 数据库类型选择
|
||||||
|
|
||||||
|
| 表类型 | 数据库类型 | 原因 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| 同步数据表 | 事务型 (PostgreSQL) | CDC 数据需要精确同步,支持事务 |
|
||||||
|
| 算力账户表 | 事务型 (PostgreSQL) | 余额变更需要强一致性 |
|
||||||
|
| 算力明细表 | 事务型 (PostgreSQL) | 明细账需要完整性约束 |
|
||||||
|
| 快照表 | 事务型 (PostgreSQL) | 历史数据需要持久化 |
|
||||||
|
|
||||||
|
### 3.2 核心表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ============================================
|
||||||
|
-- CDC 同步数据表(从其他服务同步)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 同步的用户数据
|
||||||
|
CREATE TABLE synced_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_sequence VARCHAR(20) NOT NULL UNIQUE, -- 跨服务关联键
|
||||||
|
original_user_id UUID NOT NULL,
|
||||||
|
phone VARCHAR(20),
|
||||||
|
status VARCHAR(20),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- CDC 同步元数据
|
||||||
|
source_sequence_num BIGINT NOT NULL, -- 源数据的序列号
|
||||||
|
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- 算力计算状态
|
||||||
|
contribution_calculated BOOLEAN DEFAULT FALSE,
|
||||||
|
contribution_calculated_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_synced_users_sequence ON synced_users(account_sequence);
|
||||||
|
CREATE INDEX idx_synced_users_not_calculated ON synced_users(contribution_calculated) WHERE contribution_calculated = FALSE;
|
||||||
|
|
||||||
|
-- 同步的认种数据
|
||||||
|
CREATE TABLE synced_adoptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
original_adoption_id UUID NOT NULL UNIQUE,
|
||||||
|
account_sequence VARCHAR(20) NOT NULL,
|
||||||
|
tree_count INT NOT NULL,
|
||||||
|
adoption_date DATE NOT NULL,
|
||||||
|
status VARCHAR(20),
|
||||||
|
|
||||||
|
-- 贡献值计算参数(从认种时的配置)
|
||||||
|
contribution_per_tree DECIMAL(20,10) NOT NULL,
|
||||||
|
|
||||||
|
-- CDC 同步元数据
|
||||||
|
source_sequence_num BIGINT NOT NULL,
|
||||||
|
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- 算力分配状态
|
||||||
|
contribution_distributed BOOLEAN DEFAULT FALSE,
|
||||||
|
contribution_distributed_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_synced_adoptions_account ON synced_adoptions(account_sequence);
|
||||||
|
CREATE INDEX idx_synced_adoptions_not_distributed ON synced_adoptions(contribution_distributed) WHERE contribution_distributed = FALSE;
|
||||||
|
|
||||||
|
-- 同步的推荐关系数据
|
||||||
|
CREATE TABLE synced_referrals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_sequence VARCHAR(20) NOT NULL, -- 用户
|
||||||
|
referrer_account_sequence VARCHAR(20), -- 推荐人
|
||||||
|
|
||||||
|
-- 预计算的层级路径(便于快速查询上下级)
|
||||||
|
ancestor_path TEXT, -- 格式: /root/seq1/seq2/.../
|
||||||
|
depth INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- CDC 同步元数据
|
||||||
|
source_sequence_num BIGINT NOT NULL,
|
||||||
|
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(account_sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_synced_referrals_referrer ON synced_referrals(referrer_account_sequence);
|
||||||
|
CREATE INDEX idx_synced_referrals_path ON synced_referrals USING gin(ancestor_path gin_trgm_ops);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 算力账户与明细表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 算力账户表(汇总)
|
||||||
|
CREATE TABLE contribution_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_sequence VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- 算力汇总
|
||||||
|
personal_contribution DECIMAL(30,10) DEFAULT 0, -- 来自自己认种 (70%)
|
||||||
|
team_level_contribution DECIMAL(30,10) DEFAULT 0, -- 来自团队层级 (0.5%×N级)
|
||||||
|
team_bonus_contribution DECIMAL(30,10) DEFAULT 0, -- 来自团队额外奖励 (2.5%×N)
|
||||||
|
total_contribution DECIMAL(30,10) DEFAULT 0, -- 总算力
|
||||||
|
effective_contribution DECIMAL(30,10) DEFAULT 0, -- 有效算力(未过期)
|
||||||
|
|
||||||
|
-- 用户条件(决定能获得多少团队算力)
|
||||||
|
has_adopted BOOLEAN DEFAULT FALSE,
|
||||||
|
direct_referral_adopted_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- 解锁状态
|
||||||
|
unlocked_level_depth INT DEFAULT 0, -- 5/10/15
|
||||||
|
unlocked_bonus_tiers INT DEFAULT 0, -- 1/2/3
|
||||||
|
|
||||||
|
-- 版本号(乐观锁)
|
||||||
|
version INT DEFAULT 1,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 算力明细表(分类账)
|
||||||
|
CREATE TABLE contribution_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_sequence VARCHAR(20) NOT NULL, -- 算力归属用户
|
||||||
|
|
||||||
|
-- 来源信息(可追溯)
|
||||||
|
source_type VARCHAR(30) NOT NULL, -- PERSONAL / TEAM_LEVEL / TEAM_BONUS
|
||||||
|
source_adoption_id UUID NOT NULL, -- 来源认种记录
|
||||||
|
source_account_sequence VARCHAR(20) NOT NULL, -- 认种人
|
||||||
|
|
||||||
|
-- 计算参数(审计用)
|
||||||
|
tree_count INT NOT NULL,
|
||||||
|
base_contribution DECIMAL(20,10) NOT NULL,
|
||||||
|
distribution_rate DECIMAL(10,6) NOT NULL, -- 70% / 0.5% / 2.5%
|
||||||
|
level_depth INT, -- 层级(TEAM_LEVEL时)
|
||||||
|
bonus_tier INT, -- 档位(TEAM_BONUS时,1/2/3)
|
||||||
|
|
||||||
|
-- 结果
|
||||||
|
amount DECIMAL(30,10) NOT NULL,
|
||||||
|
|
||||||
|
-- 有效期
|
||||||
|
effective_date DATE NOT NULL, -- 次日生效
|
||||||
|
expire_date DATE NOT NULL, -- 2年后过期
|
||||||
|
is_expired BOOLEAN DEFAULT FALSE,
|
||||||
|
expired_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contribution_records_account ON contribution_records(account_sequence);
|
||||||
|
CREATE INDEX idx_contribution_records_source ON contribution_records(source_adoption_id);
|
||||||
|
CREATE INDEX idx_contribution_records_expire ON contribution_records(expire_date) WHERE is_expired = FALSE;
|
||||||
|
|
||||||
|
-- 未分配算力记录(归总部)
|
||||||
|
CREATE TABLE unallocated_contributions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
source_adoption_id UUID NOT NULL,
|
||||||
|
source_account_sequence VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
unalloc_type VARCHAR(30) NOT NULL, -- LEVEL_OVERFLOW / BONUS_TIER_1/2/3
|
||||||
|
would_be_account_sequence VARCHAR(20), -- 本应获得的上线
|
||||||
|
level_depth INT,
|
||||||
|
|
||||||
|
amount DECIMAL(30,10) NOT NULL,
|
||||||
|
reason VARCHAR(200),
|
||||||
|
|
||||||
|
-- 归总部后的处理
|
||||||
|
allocated_to_headquarters BOOLEAN DEFAULT FALSE,
|
||||||
|
allocated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
expire_date DATE NOT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 系统账户(运营/省/市/总部)
|
||||||
|
CREATE TABLE system_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_type VARCHAR(20) NOT NULL UNIQUE, -- OPERATION/PROVINCE/CITY/HEADQUARTERS
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
contribution_balance DECIMAL(30,10) DEFAULT 0,
|
||||||
|
contribution_never_expires 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 system_contribution_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
system_account_id UUID NOT NULL REFERENCES system_accounts(id),
|
||||||
|
source_adoption_id UUID NOT NULL,
|
||||||
|
source_account_sequence VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
distribution_rate DECIMAL(10,6) NOT NULL, -- 12% / 1% / 2%
|
||||||
|
amount DECIMAL(30,10) NOT NULL,
|
||||||
|
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
expire_date DATE, -- NULL = 永不过期
|
||||||
|
is_expired BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 快照与统计表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 每日算力快照(用于挖矿分配计算)
|
||||||
|
CREATE TABLE daily_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, -- 占比(高精度)
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(snapshot_date, account_sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_daily_snapshots_date ON daily_contribution_snapshots(snapshot_date);
|
||||||
|
|
||||||
|
-- 用户团队统计(缓存,定期更新)
|
||||||
|
CREATE TABLE user_team_stats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_sequence VARCHAR(20) NOT NULL,
|
||||||
|
stats_date DATE NOT NULL,
|
||||||
|
|
||||||
|
-- 各级认种统计
|
||||||
|
level_1_trees INT DEFAULT 0,
|
||||||
|
level_2_trees INT DEFAULT 0,
|
||||||
|
level_3_trees INT DEFAULT 0,
|
||||||
|
level_4_trees INT DEFAULT 0,
|
||||||
|
level_5_trees INT DEFAULT 0,
|
||||||
|
level_6_trees INT DEFAULT 0,
|
||||||
|
level_7_trees INT DEFAULT 0,
|
||||||
|
level_8_trees INT DEFAULT 0,
|
||||||
|
level_9_trees INT DEFAULT 0,
|
||||||
|
level_10_trees INT DEFAULT 0,
|
||||||
|
level_11_trees INT DEFAULT 0,
|
||||||
|
level_12_trees INT DEFAULT 0,
|
||||||
|
level_13_trees INT DEFAULT 0,
|
||||||
|
level_14_trees INT DEFAULT 0,
|
||||||
|
level_15_trees INT DEFAULT 0,
|
||||||
|
|
||||||
|
total_team_trees INT DEFAULT 0,
|
||||||
|
direct_adopted_referrals INT DEFAULT 0, -- 直推认种用户数
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(account_sequence, stats_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CDC 同步状态追踪
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- CDC 同步进度表
|
||||||
|
CREATE TABLE cdc_sync_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
source_topic VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
last_sequence_num BIGINT DEFAULT 0,
|
||||||
|
last_synced_at 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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 配置表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 贡献值递增配置
|
||||||
|
CREATE TABLE contribution_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
base_contribution DECIMAL(20,10) DEFAULT 22617,
|
||||||
|
increment_percentage DECIMAL(10,6) DEFAULT 0.003, -- 0.3%
|
||||||
|
unit_size INT DEFAULT 100,
|
||||||
|
start_tree_number INT DEFAULT 1000,
|
||||||
|
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 分配比例配置
|
||||||
|
CREATE TABLE distribution_rate_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
rate_type VARCHAR(30) NOT NULL UNIQUE, -- PERSONAL/OPERATION/PROVINCE/CITY/LEVEL_PER/BONUS_PER
|
||||||
|
rate_value DECIMAL(10,6) NOT NULL,
|
||||||
|
description VARCHAR(100),
|
||||||
|
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心业务逻辑
|
||||||
|
|
||||||
|
### 4.1 算力计算公式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 计算用户 X 的总算力
|
||||||
|
*/
|
||||||
|
function calculateUserContribution(accountSequence: string): ContributionResult {
|
||||||
|
const user = getUserWithTeamStats(accountSequence);
|
||||||
|
|
||||||
|
// 1. 来自自己认种 (70%)
|
||||||
|
const personalContribution = user.ownAdoptions.reduce((sum, adoption) => {
|
||||||
|
return sum + adoption.treeCount * adoption.contributionPerTree * 0.70;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// 2. 来自团队层级 (每级0.5%)
|
||||||
|
const unlockedLevels = getUnlockedLevelDepth(user.directReferralAdoptedCount);
|
||||||
|
let teamLevelContribution = 0;
|
||||||
|
for (let level = 1; level <= unlockedLevels; level++) {
|
||||||
|
const levelTrees = user.teamStats[`level_${level}_trees`];
|
||||||
|
const levelContribution = levelTrees * baseContribution * 0.005; // 0.5%
|
||||||
|
teamLevelContribution += levelContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 来自团队额外奖励 (只看第1级,最多3个2.5%)
|
||||||
|
const unlockedBonusTiers = getUnlockedBonusTiers(user);
|
||||||
|
const level1Trees = user.teamStats.level_1_trees;
|
||||||
|
const teamBonusContribution = level1Trees * baseContribution * 0.025 * unlockedBonusTiers;
|
||||||
|
|
||||||
|
return {
|
||||||
|
personalContribution,
|
||||||
|
teamLevelContribution,
|
||||||
|
teamBonusContribution,
|
||||||
|
totalContribution: personalContribution + teamLevelContribution + teamBonusContribution
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据直推认种用户数确定解锁层级
|
||||||
|
*/
|
||||||
|
function getUnlockedLevelDepth(directReferralAdoptedCount: number): number {
|
||||||
|
if (directReferralAdoptedCount >= 5) return 15;
|
||||||
|
if (directReferralAdoptedCount >= 3) return 10;
|
||||||
|
if (directReferralAdoptedCount >= 1) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户条件确定解锁的额外奖励档位数
|
||||||
|
*/
|
||||||
|
function getUnlockedBonusTiers(user: User): number {
|
||||||
|
let tiers = 0;
|
||||||
|
if (user.hasAdopted) tiers++; // 自己认种过 → +1档
|
||||||
|
if (user.directReferralAdoptedCount >= 2) tiers++; // 直推≥2 → +1档
|
||||||
|
if (user.directReferralAdoptedCount >= 4) tiers++; // 直推≥4 → +1档
|
||||||
|
return tiers; // 0/1/2/3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 认种事件处理流程
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 当新认种发生时的处理流程
|
||||||
|
*/
|
||||||
|
async function processAdoptionContribution(adoption: SyncedAdoption): Promise<void> {
|
||||||
|
const totalContribution = adoption.treeCount * adoption.contributionPerTree;
|
||||||
|
|
||||||
|
await unitOfWork.runInTransaction(async (tx) => {
|
||||||
|
// 1. 分配给认种人 (70%)
|
||||||
|
await createContributionRecord(tx, {
|
||||||
|
accountSequence: adoption.accountSequence,
|
||||||
|
sourceType: 'PERSONAL',
|
||||||
|
sourceAdoptionId: adoption.id,
|
||||||
|
amount: totalContribution * 0.70,
|
||||||
|
distributionRate: 0.70,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 分配给系统账户 (15%)
|
||||||
|
await createSystemContribution(tx, 'OPERATION', adoption, 0.12);
|
||||||
|
await createSystemContribution(tx, 'PROVINCE', adoption, 0.01);
|
||||||
|
await createSystemContribution(tx, 'CITY', adoption, 0.02);
|
||||||
|
|
||||||
|
// 3. 分配给上线团队 (15%)
|
||||||
|
await distributeTeamContribution(tx, adoption, totalContribution * 0.15);
|
||||||
|
|
||||||
|
// 4. 标记已分配
|
||||||
|
await markAdoptionDistributed(tx, adoption.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配团队贡献值给上线链条
|
||||||
|
*/
|
||||||
|
async function distributeTeamContribution(
|
||||||
|
tx: Transaction,
|
||||||
|
adoption: Adoption,
|
||||||
|
teamTotal: number
|
||||||
|
): Promise<void> {
|
||||||
|
const ancestors = await getAncestorChain(adoption.accountSequence, 15);
|
||||||
|
|
||||||
|
let distributedLevel = 0;
|
||||||
|
let distributedBonus = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < ancestors.length && i < 15; i++) {
|
||||||
|
const ancestor = ancestors[i];
|
||||||
|
const level = i + 1;
|
||||||
|
|
||||||
|
// 层级部分 (0.5% 每级)
|
||||||
|
const levelAmount = teamTotal * 0.5 / 15; // 7.5% / 15 = 0.5%
|
||||||
|
if (ancestor.unlockedLevelDepth >= level) {
|
||||||
|
await createContributionRecord(tx, {
|
||||||
|
accountSequence: ancestor.accountSequence,
|
||||||
|
sourceType: 'TEAM_LEVEL',
|
||||||
|
levelDepth: level,
|
||||||
|
amount: levelAmount,
|
||||||
|
});
|
||||||
|
distributedLevel += levelAmount;
|
||||||
|
} else {
|
||||||
|
// 未解锁,归总部
|
||||||
|
await createUnallocatedContribution(tx, {
|
||||||
|
type: 'LEVEL_OVERFLOW',
|
||||||
|
wouldBeAccount: ancestor.accountSequence,
|
||||||
|
levelDepth: level,
|
||||||
|
amount: levelAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外奖励部分 (只给直接上线)
|
||||||
|
if (ancestors.length > 0) {
|
||||||
|
const directReferrer = ancestors[0];
|
||||||
|
const bonusPerTier = teamTotal * 0.5 / 3; // 7.5% / 3 = 2.5%
|
||||||
|
|
||||||
|
for (let tier = 1; tier <= 3; tier++) {
|
||||||
|
if (directReferrer.unlockedBonusTiers >= tier) {
|
||||||
|
await createContributionRecord(tx, {
|
||||||
|
accountSequence: directReferrer.accountSequence,
|
||||||
|
sourceType: 'TEAM_BONUS',
|
||||||
|
bonusTier: tier,
|
||||||
|
amount: bonusPerTier,
|
||||||
|
});
|
||||||
|
distributedBonus += bonusPerTier;
|
||||||
|
} else {
|
||||||
|
await createUnallocatedContribution(tx, {
|
||||||
|
type: `BONUS_TIER_${tier}`,
|
||||||
|
wouldBeAccount: directReferrer.accountSequence,
|
||||||
|
amount: bonusPerTier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 CDC 数据同步
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Debezium CDC Consumer - 用户数据同步
|
||||||
|
*/
|
||||||
|
@Consumer({ topic: 'dbserver1.rwa_identity.users' })
|
||||||
|
async handleUserCdc(message: DebeziumMessage): Promise<void> {
|
||||||
|
const { op, after, source } = message;
|
||||||
|
const sequenceNum = source.sequence;
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (await isEventProcessed(`user-cdc-${sequenceNum}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'c': // CREATE
|
||||||
|
case 'u': // UPDATE
|
||||||
|
await syncedUserRepository.upsert({
|
||||||
|
accountSequence: after.account_sequence,
|
||||||
|
originalUserId: after.id,
|
||||||
|
phone: after.phone,
|
||||||
|
status: after.status,
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'd': // DELETE
|
||||||
|
// 通常不处理删除,或标记为 inactive
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await markEventProcessed(`user-cdc-${sequenceNum}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 服务间通信
|
||||||
|
|
||||||
|
### 5.1 事件发布(Outbox Pattern)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 发布算力计算完成事件
|
||||||
|
interface ContributionCalculatedEvent {
|
||||||
|
eventId: string;
|
||||||
|
eventType: 'ContributionCalculated';
|
||||||
|
accountSequence: string;
|
||||||
|
totalContribution: string;
|
||||||
|
effectiveContribution: string;
|
||||||
|
calculatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mining Service 订阅此事件用于挖矿分配
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 订阅的 CDC Topics
|
||||||
|
|
||||||
|
| Topic | 来源服务 | 数据内容 |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `dbserver1.rwa_identity.users` | identity-service | 用户基本信息 |
|
||||||
|
| `dbserver1.rwa_planting.adoptions` | planting-service | 认种记录 |
|
||||||
|
| `dbserver1.rwa_referral.referral_relations` | referral-service | 推荐关系 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键注意事项
|
||||||
|
|
||||||
|
### 6.1 数据一致性
|
||||||
|
- 所有算力变更必须在事务中完成
|
||||||
|
- 使用乐观锁 (version 字段) 处理并发更新
|
||||||
|
- 明细账与汇总账必须保持一致
|
||||||
|
|
||||||
|
### 6.2 幂等性
|
||||||
|
- CDC 消息可能重复,使用 sequence_num 去重
|
||||||
|
- 认种分配使用 contribution_distributed 标记防止重复
|
||||||
|
|
||||||
|
### 6.3 性能优化
|
||||||
|
- 团队统计表 (user_team_stats) 定期预计算
|
||||||
|
- 使用 Redis 缓存热点用户算力
|
||||||
|
- 批量处理历史数据计算
|
||||||
|
|
||||||
|
### 6.4 跨服务关联
|
||||||
|
- **始终使用 account_sequence,不使用 userId**
|
||||||
|
- account_sequence 是唯一的跨服务关联标识
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 开发检查清单
|
||||||
|
|
||||||
|
- [ ] 实现 CDC Consumer 同步用户/认种/推荐数据
|
||||||
|
- [ ] 实现算力计算核心逻辑
|
||||||
|
- [ ] 实现算力明细账记录
|
||||||
|
- [ ] 实现未分配算力归总部逻辑
|
||||||
|
- [ ] 实现算力过期处理定时任务
|
||||||
|
- [ ] 实现每日快照生成
|
||||||
|
- [ ] 实现查询 API
|
||||||
|
- [ ] 编写单元测试
|
||||||
|
- [ ] 编写集成测试
|
||||||
|
- [ ] 配置 Debezium Connector
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 启动命令
|
||||||
|
|
||||||
|
```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,95 @@
|
||||||
|
{
|
||||||
|
"name": "contribution-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "RWA Contribution/Mining Power Service - 贡献值算力计算服务",
|
||||||
|
"author": "RWA Team",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"prisma": {
|
||||||
|
"schema": "prisma/schema.prisma",
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.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",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:migrate:prod": "prisma migrate deploy",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/microservices": "^10.0.0",
|
||||||
|
"@nestjs/passport": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"decimal.js": "^10.4.3",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"prisma": "^5.7.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-loader": "^9.4.3",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CDC 同步数据表(从其他服务同步)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 同步的用户数据
|
||||||
|
model SyncedUser {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @unique @map("account_sequence") @db.VarChar(20)
|
||||||
|
originalUserId BigInt @map("original_user_id")
|
||||||
|
phone String? @db.VarChar(20)
|
||||||
|
status String? @db.VarChar(20)
|
||||||
|
|
||||||
|
// CDC 同步元数据
|
||||||
|
sourceSequenceNum BigInt @map("source_sequence_num")
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at")
|
||||||
|
|
||||||
|
// 算力计算状态
|
||||||
|
contributionCalculated Boolean @default(false) @map("contribution_calculated")
|
||||||
|
contributionCalculatedAt DateTime? @map("contribution_calculated_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("synced_users")
|
||||||
|
@@index([originalUserId])
|
||||||
|
@@index([contributionCalculated])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步的认种数据
|
||||||
|
model SyncedAdoption {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
originalAdoptionId BigInt @unique @map("original_adoption_id")
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
treeCount Int @map("tree_count")
|
||||||
|
adoptionDate DateTime @map("adoption_date") @db.Date
|
||||||
|
status String? @db.VarChar(20)
|
||||||
|
|
||||||
|
// 贡献值计算参数(从认种时的配置)
|
||||||
|
contributionPerTree Decimal @map("contribution_per_tree") @db.Decimal(20, 10)
|
||||||
|
|
||||||
|
// CDC 同步元数据
|
||||||
|
sourceSequenceNum BigInt @map("source_sequence_num")
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at")
|
||||||
|
|
||||||
|
// 算力分配状态
|
||||||
|
contributionDistributed Boolean @default(false) @map("contribution_distributed")
|
||||||
|
contributionDistributedAt DateTime? @map("contribution_distributed_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("synced_adoptions")
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([adoptionDate])
|
||||||
|
@@index([contributionDistributed])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步的推荐关系数据
|
||||||
|
model SyncedReferral {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @unique @map("account_sequence") @db.VarChar(20)
|
||||||
|
referrerAccountSequence String? @map("referrer_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 预计算的层级路径(便于快速查询上下级)
|
||||||
|
ancestorPath String? @map("ancestor_path") @db.Text
|
||||||
|
depth Int @default(0)
|
||||||
|
|
||||||
|
// CDC 同步元数据
|
||||||
|
sourceSequenceNum BigInt @map("source_sequence_num")
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("synced_referrals")
|
||||||
|
@@index([referrerAccountSequence])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 算力账户与明细表
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 算力账户表(汇总)
|
||||||
|
model ContributionAccount {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @unique @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 算力汇总
|
||||||
|
personalContribution Decimal @default(0) @map("personal_contribution") @db.Decimal(30, 10)
|
||||||
|
teamLevelContribution Decimal @default(0) @map("team_level_contribution") @db.Decimal(30, 10)
|
||||||
|
teamBonusContribution Decimal @default(0) @map("team_bonus_contribution") @db.Decimal(30, 10)
|
||||||
|
totalContribution Decimal @default(0) @map("total_contribution") @db.Decimal(30, 10)
|
||||||
|
effectiveContribution Decimal @default(0) @map("effective_contribution") @db.Decimal(30, 10)
|
||||||
|
|
||||||
|
// 用户条件(决定能获得多少团队算力)
|
||||||
|
hasAdopted Boolean @default(false) @map("has_adopted")
|
||||||
|
directReferralAdoptedCount Int @default(0) @map("direct_referral_adopted_count")
|
||||||
|
|
||||||
|
// 解锁状态
|
||||||
|
unlockedLevelDepth Int @default(0) @map("unlocked_level_depth")
|
||||||
|
unlockedBonusTiers Int @default(0) @map("unlocked_bonus_tiers")
|
||||||
|
|
||||||
|
// 乐观锁
|
||||||
|
version Int @default(1)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("contribution_accounts")
|
||||||
|
@@index([totalContribution(sort: Desc)])
|
||||||
|
@@index([effectiveContribution(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 算力明细表(分类账)
|
||||||
|
model ContributionRecord {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 来源信息(可追溯)
|
||||||
|
sourceType String @map("source_type") @db.VarChar(30) // PERSONAL / TEAM_LEVEL / TEAM_BONUS
|
||||||
|
sourceAdoptionId BigInt @map("source_adoption_id")
|
||||||
|
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 计算参数(审计用)
|
||||||
|
treeCount Int @map("tree_count")
|
||||||
|
baseContribution Decimal @map("base_contribution") @db.Decimal(20, 10)
|
||||||
|
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
|
||||||
|
levelDepth Int? @map("level_depth")
|
||||||
|
bonusTier Int? @map("bonus_tier")
|
||||||
|
|
||||||
|
// 结果
|
||||||
|
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||||
|
|
||||||
|
// 有效期
|
||||||
|
effectiveDate DateTime @map("effective_date") @db.Date
|
||||||
|
expireDate DateTime @map("expire_date") @db.Date
|
||||||
|
isExpired Boolean @default(false) @map("is_expired")
|
||||||
|
expiredAt DateTime? @map("expired_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("contribution_records")
|
||||||
|
@@index([accountSequence, createdAt(sort: Desc)])
|
||||||
|
@@index([sourceAdoptionId])
|
||||||
|
@@index([sourceAccountSequence])
|
||||||
|
@@index([sourceType])
|
||||||
|
@@index([expireDate])
|
||||||
|
@@index([isExpired])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未分配算力记录(归总部)
|
||||||
|
model UnallocatedContribution {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
sourceAdoptionId BigInt @map("source_adoption_id")
|
||||||
|
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
unallocType String @map("unalloc_type") @db.VarChar(30) // LEVEL_OVERFLOW / BONUS_TIER_1/2/3
|
||||||
|
wouldBeAccountSequence String? @map("would_be_account_sequence") @db.VarChar(20)
|
||||||
|
levelDepth Int? @map("level_depth")
|
||||||
|
|
||||||
|
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||||
|
reason String? @db.VarChar(200)
|
||||||
|
|
||||||
|
// 归总部后的处理
|
||||||
|
allocatedToHeadquarters Boolean @default(false) @map("allocated_to_headquarters")
|
||||||
|
allocatedAt DateTime? @map("allocated_at")
|
||||||
|
|
||||||
|
effectiveDate DateTime @map("effective_date") @db.Date
|
||||||
|
expireDate DateTime @map("expire_date") @db.Date
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("unallocated_contributions")
|
||||||
|
@@index([sourceAdoptionId])
|
||||||
|
@@index([unallocType])
|
||||||
|
@@index([allocatedToHeadquarters])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统账户(运营/省/市/总部)
|
||||||
|
model SystemAccount {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountType String @unique @map("account_type") @db.VarChar(20) // OPERATION / PROVINCE / CITY / HEADQUARTERS
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
|
||||||
|
contributionBalance Decimal @default(0) @map("contribution_balance") @db.Decimal(30, 10)
|
||||||
|
contributionNeverExpires Boolean @default(false) @map("contribution_never_expires")
|
||||||
|
|
||||||
|
version Int @default(1)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
records SystemContributionRecord[]
|
||||||
|
|
||||||
|
@@map("system_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统账户算力明细
|
||||||
|
model SystemContributionRecord {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
systemAccountId BigInt @map("system_account_id")
|
||||||
|
sourceAdoptionId BigInt @map("source_adoption_id")
|
||||||
|
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
|
||||||
|
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||||
|
|
||||||
|
effectiveDate DateTime @map("effective_date") @db.Date
|
||||||
|
expireDate DateTime? @map("expire_date") @db.Date
|
||||||
|
isExpired Boolean @default(false) @map("is_expired")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
systemAccount SystemAccount @relation(fields: [systemAccountId], references: [id])
|
||||||
|
|
||||||
|
@@map("system_contribution_records")
|
||||||
|
@@index([systemAccountId])
|
||||||
|
@@index([sourceAdoptionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 快照与统计表
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 每日算力快照(用于挖矿分配计算)
|
||||||
|
model DailyContributionSnapshot {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
snapshotDate DateTime @map("snapshot_date") @db.Date
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
effectiveContribution Decimal @map("effective_contribution") @db.Decimal(30, 10)
|
||||||
|
networkTotalContribution Decimal @map("network_total_contribution") @db.Decimal(30, 10)
|
||||||
|
contributionRatio Decimal @map("contribution_ratio") @db.Decimal(30, 18)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([snapshotDate, accountSequence])
|
||||||
|
@@map("daily_contribution_snapshots")
|
||||||
|
@@index([snapshotDate])
|
||||||
|
@@index([accountSequence])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户团队统计(缓存,定期更新)
|
||||||
|
model UserTeamStats {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
statsDate DateTime @map("stats_date") @db.Date
|
||||||
|
|
||||||
|
// 各级认种统计
|
||||||
|
level1Trees Int @default(0) @map("level_1_trees")
|
||||||
|
level2Trees Int @default(0) @map("level_2_trees")
|
||||||
|
level3Trees Int @default(0) @map("level_3_trees")
|
||||||
|
level4Trees Int @default(0) @map("level_4_trees")
|
||||||
|
level5Trees Int @default(0) @map("level_5_trees")
|
||||||
|
level6Trees Int @default(0) @map("level_6_trees")
|
||||||
|
level7Trees Int @default(0) @map("level_7_trees")
|
||||||
|
level8Trees Int @default(0) @map("level_8_trees")
|
||||||
|
level9Trees Int @default(0) @map("level_9_trees")
|
||||||
|
level10Trees Int @default(0) @map("level_10_trees")
|
||||||
|
level11Trees Int @default(0) @map("level_11_trees")
|
||||||
|
level12Trees Int @default(0) @map("level_12_trees")
|
||||||
|
level13Trees Int @default(0) @map("level_13_trees")
|
||||||
|
level14Trees Int @default(0) @map("level_14_trees")
|
||||||
|
level15Trees Int @default(0) @map("level_15_trees")
|
||||||
|
|
||||||
|
totalTeamTrees Int @default(0) @map("total_team_trees")
|
||||||
|
directAdoptedReferrals Int @default(0) @map("direct_adopted_referrals")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([accountSequence, statsDate])
|
||||||
|
@@map("user_team_stats")
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([statsDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CDC 同步状态追踪
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// CDC 同步进度表
|
||||||
|
model CdcSyncProgress {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
sourceTopic String @unique @map("source_topic") @db.VarChar(100)
|
||||||
|
lastSequenceNum BigInt @default(0) @map("last_sequence_num")
|
||||||
|
lastSyncedAt DateTime? @map("last_synced_at")
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("cdc_sync_progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已处理事件表(幂等性)
|
||||||
|
model ProcessedEvent {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
eventId String @unique @map("event_id") @db.VarChar(100)
|
||||||
|
eventType String @map("event_type") @db.VarChar(50)
|
||||||
|
sourceService String? @map("source_service") @db.VarChar(50)
|
||||||
|
|
||||||
|
processedAt DateTime @default(now()) @map("processed_at")
|
||||||
|
|
||||||
|
@@map("processed_events")
|
||||||
|
@@index([eventType])
|
||||||
|
@@index([processedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 配置表
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 贡献值递增配置
|
||||||
|
model ContributionConfig {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
|
||||||
|
baseContribution Decimal @default(22617) @map("base_contribution") @db.Decimal(20, 10)
|
||||||
|
incrementPercentage Decimal @default(0.003) @map("increment_percentage") @db.Decimal(10, 6)
|
||||||
|
unitSize Int @default(100) @map("unit_size")
|
||||||
|
startTreeNumber Int @default(1000) @map("start_tree_number")
|
||||||
|
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("contribution_configs")
|
||||||
|
@@index([isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分配比例配置
|
||||||
|
model DistributionRateConfig {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
|
||||||
|
rateType String @unique @map("rate_type") @db.VarChar(30)
|
||||||
|
rateValue Decimal @map("rate_value") @db.Decimal(10, 6)
|
||||||
|
description String? @db.VarChar(100)
|
||||||
|
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("distribution_rate_configs")
|
||||||
|
@@index([isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Outbox 事件表(可靠事件发布)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model OutboxEvent {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("outbox_id")
|
||||||
|
|
||||||
|
eventType String @map("event_type") @db.VarChar(100)
|
||||||
|
topic String @map("topic") @db.VarChar(100)
|
||||||
|
key String @map("key") @db.VarChar(200)
|
||||||
|
payload Json @map("payload")
|
||||||
|
|
||||||
|
aggregateId String @map("aggregate_id") @db.VarChar(100)
|
||||||
|
aggregateType String @map("aggregate_type") @db.VarChar(50)
|
||||||
|
|
||||||
|
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||||
|
retryCount Int @default(0) @map("retry_count")
|
||||||
|
maxRetries Int @default(5) @map("max_retries")
|
||||||
|
lastError String? @map("last_error") @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
publishedAt DateTime? @map("published_at")
|
||||||
|
nextRetryAt DateTime? @map("next_retry_at")
|
||||||
|
|
||||||
|
@@map("outbox_events")
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([status, nextRetryAt])
|
||||||
|
@@index([aggregateType, aggregateId])
|
||||||
|
@@index([topic])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApplicationModule } from '../application/application.module';
|
||||||
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
|
import { ContributionController } from './controllers/contribution.controller';
|
||||||
|
import { SnapshotController } from './controllers/snapshot.controller';
|
||||||
|
import { HealthController } from './controllers/health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ApplicationModule, InfrastructureModule],
|
||||||
|
controllers: [ContributionController, SnapshotController, HealthController],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { GetContributionAccountQuery } from '../../application/queries/get-contribution-account.query';
|
||||||
|
import { GetContributionStatsQuery } from '../../application/queries/get-contribution-stats.query';
|
||||||
|
import { GetContributionRankingQuery } from '../../application/queries/get-contribution-ranking.query';
|
||||||
|
import {
|
||||||
|
ContributionAccountResponse,
|
||||||
|
ContributionRecordsResponse,
|
||||||
|
ActiveContributionResponse,
|
||||||
|
} from '../dto/response/contribution-account.response';
|
||||||
|
import { ContributionStatsResponse } from '../dto/response/contribution-stats.response';
|
||||||
|
import { ContributionRankingResponse, UserRankResponse } from '../dto/response/contribution-ranking.response';
|
||||||
|
import { GetContributionRecordsRequest } from '../dto/request/get-records.request';
|
||||||
|
|
||||||
|
@ApiTags('Contribution')
|
||||||
|
@Controller('contributions')
|
||||||
|
export class ContributionController {
|
||||||
|
constructor(
|
||||||
|
private readonly getAccountQuery: GetContributionAccountQuery,
|
||||||
|
private readonly getStatsQuery: GetContributionStatsQuery,
|
||||||
|
private readonly getRankingQuery: GetContributionRankingQuery,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@ApiOperation({ summary: '获取算力统计数据' })
|
||||||
|
@ApiResponse({ status: 200, type: ContributionStatsResponse })
|
||||||
|
async getStats(): Promise<ContributionStatsResponse> {
|
||||||
|
return this.getStatsQuery.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ranking')
|
||||||
|
@ApiOperation({ summary: '获取算力排行榜' })
|
||||||
|
@ApiResponse({ status: 200, type: ContributionRankingResponse })
|
||||||
|
async getRanking(@Query('limit') limit?: number): Promise<ContributionRankingResponse> {
|
||||||
|
const data = await this.getRankingQuery.execute(limit ?? 100);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '获取账户算力信息' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiResponse({ status: 200, type: ContributionAccountResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '账户不存在' })
|
||||||
|
async getAccount(@Param('accountSequence') accountSequence: string): Promise<ContributionAccountResponse> {
|
||||||
|
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: '账户序号' })
|
||||||
|
@ApiResponse({ status: 200, type: ContributionRecordsResponse })
|
||||||
|
async getRecords(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query() query: GetContributionRecordsRequest,
|
||||||
|
): Promise<ContributionRecordsResponse> {
|
||||||
|
const result = await this.getAccountQuery.getRecords(accountSequence, {
|
||||||
|
sourceType: query.sourceType,
|
||||||
|
includeExpired: query.includeExpired,
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
page: query.page ?? 1,
|
||||||
|
pageSize: query.pageSize ?? 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:accountSequence/active')
|
||||||
|
@ApiOperation({ summary: '获取账户活跃算力统计' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiResponse({ status: 200, type: ActiveContributionResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '账户不存在' })
|
||||||
|
async getActiveContribution(@Param('accountSequence') accountSequence: string): Promise<ActiveContributionResponse> {
|
||||||
|
const result = await this.getAccountQuery.getActiveContribution(accountSequence);
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException(`Account ${accountSequence} not found`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:accountSequence/rank')
|
||||||
|
@ApiOperation({ summary: '获取账户排名' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiResponse({ status: 200, type: UserRankResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '账户不存在' })
|
||||||
|
async getUserRank(@Param('accountSequence') accountSequence: string): Promise<UserRankResponse> {
|
||||||
|
const result = await this.getRankingQuery.getUserRank(accountSequence);
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException(`Account ${accountSequence} not found`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
status: 'healthy' | 'unhealthy';
|
||||||
|
timestamp: string;
|
||||||
|
services: {
|
||||||
|
database: 'up' | 'down';
|
||||||
|
redis: 'up' | 'down';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '健康检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务健康' })
|
||||||
|
@ApiResponse({ status: 503, description: '服务不健康' })
|
||||||
|
async check(): Promise<HealthStatus> {
|
||||||
|
const status: HealthStatus = {
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
services: {
|
||||||
|
database: 'up',
|
||||||
|
redis: 'up',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查数据库连接
|
||||||
|
try {
|
||||||
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
} catch {
|
||||||
|
status.services.database = 'down';
|
||||||
|
status.status = 'unhealthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Redis 连接
|
||||||
|
try {
|
||||||
|
await this.redis.getClient().ping();
|
||||||
|
} catch {
|
||||||
|
status.services.redis = 'down';
|
||||||
|
status.status = 'unhealthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
@ApiOperation({ summary: '就绪检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务就绪' })
|
||||||
|
async ready(): Promise<{ ready: boolean }> {
|
||||||
|
return { ready: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('live')
|
||||||
|
@ApiOperation({ summary: '存活检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务存活' })
|
||||||
|
async live(): Promise<{ alive: boolean }> {
|
||||||
|
return { alive: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { Controller, Get, Post, Query, Body, Param, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { SnapshotService } from '../../application/services/snapshot.service';
|
||||||
|
import {
|
||||||
|
DailySnapshotResponse,
|
||||||
|
UserContributionRatioResponse,
|
||||||
|
BatchUserRatiosResponse,
|
||||||
|
} from '../dto/response/snapshot.response';
|
||||||
|
import { CreateSnapshotRequest, GetBatchRatiosRequest } from '../dto/request/snapshot.request';
|
||||||
|
|
||||||
|
@ApiTags('Snapshot')
|
||||||
|
@Controller('snapshots')
|
||||||
|
export class SnapshotController {
|
||||||
|
constructor(private readonly snapshotService: SnapshotService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建每日快照' })
|
||||||
|
@ApiResponse({ status: 201, type: DailySnapshotResponse })
|
||||||
|
async createSnapshot(@Body() request: CreateSnapshotRequest): Promise<DailySnapshotResponse> {
|
||||||
|
const snapshot = await this.snapshotService.createDailySnapshot(new Date(request.snapshotDate));
|
||||||
|
return this.toResponse(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@ApiOperation({ summary: '获取最新快照' })
|
||||||
|
@ApiResponse({ status: 200, type: DailySnapshotResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '快照不存在' })
|
||||||
|
async getLatestSnapshot(): Promise<DailySnapshotResponse> {
|
||||||
|
const latestDate = await this.snapshotService.getLatestSnapshotDate();
|
||||||
|
if (!latestDate) {
|
||||||
|
throw new NotFoundException('No snapshot found');
|
||||||
|
}
|
||||||
|
const snapshot = await this.snapshotService.getSnapshotSummary(latestDate);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new NotFoundException('No snapshot found');
|
||||||
|
}
|
||||||
|
return this.toResponse(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':date')
|
||||||
|
@ApiOperation({ summary: '获取指定日期的快照' })
|
||||||
|
@ApiParam({ name: 'date', description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@ApiResponse({ status: 200, type: DailySnapshotResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '快照不存在' })
|
||||||
|
async getSnapshot(@Param('date') date: string): Promise<DailySnapshotResponse> {
|
||||||
|
const snapshot = await this.snapshotService.getSnapshotSummary(new Date(date));
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new NotFoundException(`Snapshot for ${date} not found`);
|
||||||
|
}
|
||||||
|
return this.toResponse(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':date/ratios/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '获取用户在指定日期的算力占比' })
|
||||||
|
@ApiParam({ name: 'date', description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiResponse({ status: 200, type: UserContributionRatioResponse })
|
||||||
|
@ApiResponse({ status: 404, description: '快照或账户不存在' })
|
||||||
|
async getUserRatio(
|
||||||
|
@Param('date') date: string,
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
): Promise<UserContributionRatioResponse> {
|
||||||
|
const result = await this.snapshotService.getUserContributionRatio(accountSequence, new Date(date));
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException(`Snapshot or account not found`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contribution: result.contribution.value.toString(),
|
||||||
|
ratio: result.ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':date/ratios')
|
||||||
|
@ApiOperation({ summary: '批量获取用户算力占比' })
|
||||||
|
@ApiParam({ name: 'date', description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@ApiResponse({ status: 200, type: BatchUserRatiosResponse })
|
||||||
|
async getBatchRatios(
|
||||||
|
@Param('date') date: string,
|
||||||
|
@Query() query: GetBatchRatiosRequest,
|
||||||
|
): Promise<BatchUserRatiosResponse> {
|
||||||
|
return this.snapshotService.batchGetUserContributionRatios(new Date(date), query.page, query.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResponse(snapshot: any): DailySnapshotResponse {
|
||||||
|
return {
|
||||||
|
id: snapshot.id?.toString() ?? snapshot.snapshotDate.toISOString().split('T')[0],
|
||||||
|
snapshotDate: snapshot.snapshotDate,
|
||||||
|
totalPersonalContribution: '0', // Not available in summary
|
||||||
|
totalTeamLevelContribution: '0', // Not available in summary
|
||||||
|
totalTeamBonusContribution: '0', // Not available in summary
|
||||||
|
totalContribution: snapshot.networkTotalContribution?.value?.toString() ?? '0',
|
||||||
|
totalAccounts: snapshot.totalAccounts,
|
||||||
|
activeAccounts: snapshot.activeAccounts,
|
||||||
|
createdAt: snapshot.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsEnum, IsBoolean, IsInt, Min, Max } from 'class-validator';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export enum ContributionSourceTypeFilter {
|
||||||
|
PERSONAL = 'PERSONAL',
|
||||||
|
TEAM_LEVEL = 'TEAM_LEVEL',
|
||||||
|
TEAM_BONUS = 'TEAM_BONUS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetContributionRecordsRequest {
|
||||||
|
@ApiPropertyOptional({ enum: ContributionSourceTypeFilter, description: '来源类型筛选' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContributionSourceTypeFilter)
|
||||||
|
sourceType?: ContributionSourceTypeFilter;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否包含已过期记录', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
includeExpired?: boolean = false;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页大小', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 50;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreateSnapshotRequest {
|
||||||
|
@ApiProperty({ description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
snapshotDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSnapshotRequest {
|
||||||
|
@ApiProperty({ description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
snapshotDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetBatchRatiosRequest {
|
||||||
|
@ApiProperty({ description: '快照日期 (YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
snapshotDate: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页大小', default: 1000 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5000)
|
||||||
|
pageSize?: number = 1000;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ContributionAccountResponse {
|
||||||
|
@ApiProperty({ description: '账户序号' })
|
||||||
|
accountSequence: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '个人算力' })
|
||||||
|
personalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队层级算力' })
|
||||||
|
teamLevelContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队奖励算力' })
|
||||||
|
teamBonusContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否已认种' })
|
||||||
|
hasAdopted: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直推认种用户数' })
|
||||||
|
directReferralAdoptedCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已解锁层级深度' })
|
||||||
|
unlockedLevelDepth: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已解锁奖励档位数' })
|
||||||
|
unlockedBonusTiers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否已完成计算' })
|
||||||
|
isCalculated: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '最后计算时间', nullable: true })
|
||||||
|
lastCalculatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContributionRecordResponse {
|
||||||
|
@ApiProperty({ description: '记录ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '来源类型', enum: ['PERSONAL', 'TEAM_LEVEL', 'TEAM_BONUS'] })
|
||||||
|
sourceType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '来源认种ID' })
|
||||||
|
sourceAdoptionId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '来源账户序号', nullable: true })
|
||||||
|
sourceAccountSequence: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '树数量' })
|
||||||
|
treeCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '基础算力' })
|
||||||
|
baseContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分配比例' })
|
||||||
|
distributionRate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '层级深度', nullable: true })
|
||||||
|
levelDepth: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '奖励档位', nullable: true })
|
||||||
|
bonusTier: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '最终算力' })
|
||||||
|
finalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '生效日期' })
|
||||||
|
effectiveDate: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '过期日期' })
|
||||||
|
expireDate: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否已过期' })
|
||||||
|
isExpired: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContributionRecordsResponse {
|
||||||
|
@ApiProperty({ type: [ContributionRecordResponse] })
|
||||||
|
data: ContributionRecordResponse[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总记录数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前页码' })
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每页大小' })
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveContributionResponse {
|
||||||
|
@ApiProperty({ description: '个人算力' })
|
||||||
|
personal: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队层级算力' })
|
||||||
|
teamLevel: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队奖励算力' })
|
||||||
|
teamBonus: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ContributionRankingItemResponse {
|
||||||
|
@ApiProperty({ description: '排名' })
|
||||||
|
rank: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '账户序号' })
|
||||||
|
accountSequence: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '个人算力' })
|
||||||
|
personalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队算力' })
|
||||||
|
teamContribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContributionRankingResponse {
|
||||||
|
@ApiProperty({ type: [ContributionRankingItemResponse] })
|
||||||
|
data: ContributionRankingItemResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserRankResponse {
|
||||||
|
@ApiProperty({ description: '排名', nullable: true })
|
||||||
|
rank: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '百分位', nullable: true })
|
||||||
|
percentile: number | null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SystemAccountDto {
|
||||||
|
@ApiProperty({ description: '账户类型' })
|
||||||
|
accountType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '账户名称' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContributionByTypeDto {
|
||||||
|
@ApiProperty({ description: '个人算力' })
|
||||||
|
personal: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队层级算力' })
|
||||||
|
teamLevel: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队奖励算力' })
|
||||||
|
teamBonus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContributionStatsResponse {
|
||||||
|
@ApiProperty({ description: '总用户数' })
|
||||||
|
totalUsers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总账户数' })
|
||||||
|
totalAccounts: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '有算力的账户数' })
|
||||||
|
accountsWithContribution: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总认种数' })
|
||||||
|
totalAdoptions: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已处理认种数' })
|
||||||
|
processedAdoptions: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '未处理认种数' })
|
||||||
|
unprocessedAdoptions: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ContributionByTypeDto, description: '按类型分布的算力' })
|
||||||
|
contributionByType: ContributionByTypeDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [SystemAccountDto], description: '系统账户' })
|
||||||
|
systemAccounts: SystemAccountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '未分配算力总量' })
|
||||||
|
totalUnallocated: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '按类型分布的未分配算力' })
|
||||||
|
unallocatedByType: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class DailySnapshotResponse {
|
||||||
|
@ApiProperty({ description: '快照ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '快照日期' })
|
||||||
|
snapshotDate: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总个人算力' })
|
||||||
|
totalPersonalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总团队层级算力' })
|
||||||
|
totalTeamLevelContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总团队奖励算力' })
|
||||||
|
totalTeamBonusContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总账户数' })
|
||||||
|
totalAccounts: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '活跃账户数' })
|
||||||
|
activeAccounts: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserContributionRatioResponse {
|
||||||
|
@ApiProperty({ description: '用户算力' })
|
||||||
|
contribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '占比' })
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchUserRatioItem {
|
||||||
|
@ApiProperty({ description: '账户序号' })
|
||||||
|
accountSequence: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '算力' })
|
||||||
|
contribution: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '占比' })
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchUserRatiosResponse {
|
||||||
|
@ApiProperty({ type: [BatchUserRatioItem] })
|
||||||
|
data: BatchUserRatioItem[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总算力' })
|
||||||
|
totalContribution: string;
|
||||||
|
}
|
||||||
|
|
@ -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,55 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
|
|
||||||
|
// Event Handlers
|
||||||
|
import { UserSyncedHandler } from './event-handlers/user-synced.handler';
|
||||||
|
import { ReferralSyncedHandler } from './event-handlers/referral-synced.handler';
|
||||||
|
import { AdoptionSyncedHandler } from './event-handlers/adoption-synced.handler';
|
||||||
|
import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
||||||
|
import { SnapshotService } from './services/snapshot.service';
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
||||||
|
import { GetContributionStatsQuery } from './queries/get-contribution-stats.query';
|
||||||
|
import { GetContributionRankingQuery } from './queries/get-contribution-ranking.query';
|
||||||
|
|
||||||
|
// Schedulers
|
||||||
|
import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
InfrastructureModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Event Handlers
|
||||||
|
UserSyncedHandler,
|
||||||
|
ReferralSyncedHandler,
|
||||||
|
AdoptionSyncedHandler,
|
||||||
|
CDCEventDispatcher,
|
||||||
|
|
||||||
|
// Services
|
||||||
|
ContributionCalculationService,
|
||||||
|
SnapshotService,
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
GetContributionAccountQuery,
|
||||||
|
GetContributionStatsQuery,
|
||||||
|
GetContributionRankingQuery,
|
||||||
|
|
||||||
|
// Schedulers
|
||||||
|
ContributionScheduler,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ContributionCalculationService,
|
||||||
|
SnapshotService,
|
||||||
|
GetContributionAccountQuery,
|
||||||
|
GetContributionStatsQuery,
|
||||||
|
GetContributionRankingQuery,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApplicationModule {}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { CDCEvent } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种 CDC 事件处理器
|
||||||
|
* 处理从种植服务同步过来的认种数据
|
||||||
|
* 认种是触发算力计算的核心事件
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdoptionSyncedHandler {
|
||||||
|
private readonly logger = new Logger(AdoptionSyncedHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly syncedDataRepository: SyncedDataRepository,
|
||||||
|
private readonly contributionCalculationService: ContributionCalculationService,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: CDCEvent): Promise<void> {
|
||||||
|
const { op, before, after } = event.payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (op) {
|
||||||
|
case 'c': // create
|
||||||
|
case 'r': // read (snapshot)
|
||||||
|
await this.handleCreate(after, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'u': // update
|
||||||
|
await this.handleUpdate(after, before, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'd': // delete
|
||||||
|
await this.handleDelete(before);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown CDC operation: ${op}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to handle adoption CDC event`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreate(data: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
// 保存同步的认种数据
|
||||||
|
const adoption = await this.syncedDataRepository.upsertSyncedAdoption({
|
||||||
|
originalAdoptionId: BigInt(data.id),
|
||||||
|
accountSequence: data.account_sequence || data.accountSequence,
|
||||||
|
treeCount: data.tree_count || data.treeCount,
|
||||||
|
adoptionDate: new Date(data.adoption_date || data.adoptionDate || data.created_at || data.createdAt),
|
||||||
|
status: data.status ?? null,
|
||||||
|
contributionPerTree: new Decimal(data.contribution_per_tree || data.contributionPerTree || '1'),
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触发算力计算
|
||||||
|
await this.contributionCalculationService.calculateForAdoption(adoption.originalAdoptionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Adoption synced and contribution calculated: ${data.id}, account: ${data.account_sequence || data.accountSequence}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpdate(after: any, before: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!after) return;
|
||||||
|
|
||||||
|
const originalAdoptionId = BigInt(after.id);
|
||||||
|
|
||||||
|
// 检查是否已经处理过
|
||||||
|
const existingAdoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(originalAdoptionId);
|
||||||
|
|
||||||
|
if (existingAdoption?.contributionDistributed) {
|
||||||
|
// 如果树数量发生变化,需要重新计算(这种情况较少)
|
||||||
|
const newTreeCount = after.tree_count || after.treeCount;
|
||||||
|
if (existingAdoption.treeCount !== newTreeCount) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Adoption tree count changed after processing: ${originalAdoptionId}. This requires special handling.`,
|
||||||
|
);
|
||||||
|
// TODO: 实现树数量变化的处理逻辑
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
const adoption = await this.syncedDataRepository.upsertSyncedAdoption({
|
||||||
|
originalAdoptionId: originalAdoptionId,
|
||||||
|
accountSequence: after.account_sequence || after.accountSequence,
|
||||||
|
treeCount: after.tree_count || after.treeCount,
|
||||||
|
adoptionDate: new Date(after.adoption_date || after.adoptionDate || after.created_at || after.createdAt),
|
||||||
|
status: after.status ?? null,
|
||||||
|
contributionPerTree: new Decimal(after.contribution_per_tree || after.contributionPerTree || '1'),
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAdoption?.contributionDistributed) {
|
||||||
|
await this.contributionCalculationService.calculateForAdoption(adoption.originalAdoptionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Adoption updated: ${originalAdoptionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDelete(data: any): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
// 认种删除需要特殊处理(回滚算力)
|
||||||
|
// 但通常不会发生删除操作
|
||||||
|
this.logger.warn(`Adoption delete event received: ${data.id}. This may require contribution rollback.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
|
import { CDCConsumerService, CDCEvent } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
|
import { UserSyncedHandler } from './user-synced.handler';
|
||||||
|
import { ReferralSyncedHandler } from './referral-synced.handler';
|
||||||
|
import { AdoptionSyncedHandler } from './adoption-synced.handler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDC 事件分发器
|
||||||
|
* 负责将 Debezium CDC 事件路由到对应的处理器
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CDCEventDispatcher implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(CDCEventDispatcher.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cdcConsumer: CDCConsumerService,
|
||||||
|
private readonly userHandler: UserSyncedHandler,
|
||||||
|
private readonly referralHandler: ReferralSyncedHandler,
|
||||||
|
private readonly adoptionHandler: AdoptionSyncedHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 注册各表的事件处理器
|
||||||
|
this.cdcConsumer.registerHandler('users', this.handleUserEvent.bind(this));
|
||||||
|
this.cdcConsumer.registerHandler('referrals', this.handleReferralEvent.bind(this));
|
||||||
|
this.cdcConsumer.registerHandler('adoptions', this.handleAdoptionEvent.bind(this));
|
||||||
|
|
||||||
|
// 启动 CDC 消费者
|
||||||
|
try {
|
||||||
|
await this.cdcConsumer.start();
|
||||||
|
this.logger.log('CDC event dispatcher started');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to start CDC event dispatcher', error);
|
||||||
|
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserEvent(event: CDCEvent): Promise<void> {
|
||||||
|
await this.userHandler.handle(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleReferralEvent(event: CDCEvent): Promise<void> {
|
||||||
|
await this.referralHandler.handle(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAdoptionEvent(event: CDCEvent): Promise<void> {
|
||||||
|
await this.adoptionHandler.handle(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { CDCEvent } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引荐关系 CDC 事件处理器
|
||||||
|
* 处理从引荐服务同步过来的引荐关系数据
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReferralSyncedHandler {
|
||||||
|
private readonly logger = new Logger(ReferralSyncedHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly syncedDataRepository: SyncedDataRepository,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: CDCEvent): Promise<void> {
|
||||||
|
const { op, before, after } = event.payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (op) {
|
||||||
|
case 'c': // create
|
||||||
|
case 'r': // read (snapshot)
|
||||||
|
await this.handleCreate(after, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'u': // update
|
||||||
|
await this.handleUpdate(after, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'd': // delete
|
||||||
|
await this.handleDelete(before);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown CDC operation: ${op}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to handle referral CDC event`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreate(data: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
await this.syncedDataRepository.upsertSyncedReferral({
|
||||||
|
accountSequence: data.account_sequence || data.accountSequence,
|
||||||
|
referrerAccountSequence: data.referrer_account_sequence || data.referrerAccountSequence || null,
|
||||||
|
ancestorPath: data.ancestor_path || data.ancestorPath || null,
|
||||||
|
depth: data.depth || 0,
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Referral synced: ${data.account_sequence || data.accountSequence} -> ${data.referrer_account_sequence || data.referrerAccountSequence || 'none'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpdate(data: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await this.syncedDataRepository.upsertSyncedReferral({
|
||||||
|
accountSequence: data.account_sequence || data.accountSequence,
|
||||||
|
referrerAccountSequence: data.referrer_account_sequence || data.referrerAccountSequence || null,
|
||||||
|
ancestorPath: data.ancestor_path || data.ancestorPath || null,
|
||||||
|
depth: data.depth || 0,
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Referral updated: ${data.account_sequence || data.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDelete(data: any): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
// 引荐关系删除需要特殊处理
|
||||||
|
this.logger.warn(`Referral delete event received: ${data.account_sequence || data.accountSequence}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { CDCEvent } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { ContributionAccountAggregate } from '../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 CDC 事件处理器
|
||||||
|
* 处理从身份服务同步过来的用户数据
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UserSyncedHandler {
|
||||||
|
private readonly logger = new Logger(UserSyncedHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly syncedDataRepository: SyncedDataRepository,
|
||||||
|
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: CDCEvent): Promise<void> {
|
||||||
|
const { op, before, after } = event.payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (op) {
|
||||||
|
case 'c': // create
|
||||||
|
case 'r': // read (snapshot)
|
||||||
|
await this.handleCreate(after, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'u': // update
|
||||||
|
await this.handleUpdate(after, event.sequenceNum);
|
||||||
|
break;
|
||||||
|
case 'd': // delete
|
||||||
|
await this.handleDelete(before);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown CDC operation: ${op}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to handle user CDC event`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreate(data: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
// 保存同步的用户数据
|
||||||
|
await this.syncedDataRepository.upsertSyncedUser({
|
||||||
|
originalUserId: BigInt(data.id),
|
||||||
|
accountSequence: data.account_sequence || data.accountSequence,
|
||||||
|
phone: data.phone || null,
|
||||||
|
status: data.status || 'ACTIVE',
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为用户创建算力账户(如果不存在)
|
||||||
|
const accountSequence = data.account_sequence || data.accountSequence;
|
||||||
|
const existingAccount = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
|
||||||
|
if (!existingAccount) {
|
||||||
|
const newAccount = ContributionAccountAggregate.create(accountSequence);
|
||||||
|
await this.contributionAccountRepository.save(newAccount);
|
||||||
|
this.logger.log(`Created contribution account for user: ${accountSequence}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`User synced: ${data.account_sequence || data.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpdate(data: any, sequenceNum: bigint): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await this.syncedDataRepository.upsertSyncedUser({
|
||||||
|
originalUserId: BigInt(data.id),
|
||||||
|
accountSequence: data.account_sequence || data.accountSequence,
|
||||||
|
phone: data.phone || null,
|
||||||
|
status: data.status || 'ACTIVE',
|
||||||
|
sourceSequenceNum: sequenceNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`User updated: ${data.account_sequence || data.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDelete(data: any): Promise<void> {
|
||||||
|
if (!data) return;
|
||||||
|
// 用户删除一般不处理,保留历史数据
|
||||||
|
this.logger.debug(`User delete event received: ${data.account_sequence || data.accountSequence}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
|
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
|
||||||
|
|
||||||
|
export interface ContributionAccountDto {
|
||||||
|
accountSequence: string;
|
||||||
|
personalContribution: string;
|
||||||
|
teamLevelContribution: string;
|
||||||
|
teamBonusContribution: string;
|
||||||
|
totalContribution: string;
|
||||||
|
hasAdopted: boolean;
|
||||||
|
directReferralAdoptedCount: number;
|
||||||
|
unlockedLevelDepth: number;
|
||||||
|
unlockedBonusTiers: number;
|
||||||
|
isCalculated: boolean;
|
||||||
|
lastCalculatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContributionRecordDto {
|
||||||
|
id: string;
|
||||||
|
sourceType: string;
|
||||||
|
sourceAdoptionId: string;
|
||||||
|
sourceAccountSequence: string | null;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: string;
|
||||||
|
distributionRate: string;
|
||||||
|
levelDepth: number | null;
|
||||||
|
bonusTier: number | null;
|
||||||
|
finalContribution: string;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
isExpired: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetContributionAccountQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly accountRepository: ContributionAccountRepository,
|
||||||
|
private readonly recordRepository: ContributionRecordRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户算力信息
|
||||||
|
*/
|
||||||
|
async execute(accountSequence: string): Promise<ContributionAccountDto | null> {
|
||||||
|
const account = await this.accountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toDto(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户的算力明细记录
|
||||||
|
*/
|
||||||
|
async getRecords(
|
||||||
|
accountSequence: string,
|
||||||
|
options?: {
|
||||||
|
sourceType?: string;
|
||||||
|
includeExpired?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ data: ContributionRecordDto[]; total: number }> {
|
||||||
|
const result = await this.recordRepository.findByAccountSequence(accountSequence, {
|
||||||
|
sourceType: options?.sourceType as any,
|
||||||
|
includeExpired: options?.includeExpired ?? false,
|
||||||
|
page: options?.page ?? 1,
|
||||||
|
limit: options?.pageSize ?? 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.items.map((r: any) => this.toRecordDto(r)),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户的活跃算力统计
|
||||||
|
*/
|
||||||
|
async getActiveContribution(accountSequence: string): Promise<{
|
||||||
|
personal: string;
|
||||||
|
teamLevel: string;
|
||||||
|
teamBonus: string;
|
||||||
|
total: string;
|
||||||
|
} | null> {
|
||||||
|
const result = await this.recordRepository.getActiveContributionByAccount(accountSequence);
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
personal: result.personal.value.toString(),
|
||||||
|
teamLevel: result.teamLevel.value.toString(),
|
||||||
|
teamBonus: result.teamBonus.value.toString(),
|
||||||
|
total: result.total.value.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(account: any): ContributionAccountDto {
|
||||||
|
return {
|
||||||
|
accountSequence: account.accountSequence,
|
||||||
|
personalContribution: account.personalContribution.value.toString(),
|
||||||
|
teamLevelContribution: account.teamLevelContribution.value.toString(),
|
||||||
|
teamBonusContribution: account.teamBonusContribution.value.toString(),
|
||||||
|
totalContribution: account.totalContribution.value.toString(),
|
||||||
|
hasAdopted: account.hasAdopted,
|
||||||
|
directReferralAdoptedCount: account.directReferralAdoptedCount,
|
||||||
|
unlockedLevelDepth: account.unlockedLevelDepth,
|
||||||
|
unlockedBonusTiers: account.unlockedBonusTiers,
|
||||||
|
isCalculated: account.isCalculated,
|
||||||
|
lastCalculatedAt: account.lastCalculatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRecordDto(record: any): ContributionRecordDto {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
sourceType: record.sourceType,
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
treeCount: record.treeCount,
|
||||||
|
baseContribution: record.baseContribution.value.toString(),
|
||||||
|
distributionRate: record.distributionRate.value.toString(),
|
||||||
|
levelDepth: record.levelDepth,
|
||||||
|
bonusTier: record.bonusTier,
|
||||||
|
finalContribution: record.finalContribution.value.toString(),
|
||||||
|
effectiveDate: record.effectiveDate,
|
||||||
|
expireDate: record.expireDate,
|
||||||
|
isExpired: record.isExpired,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
|
||||||
|
export interface ContributionRankingDto {
|
||||||
|
rank: number;
|
||||||
|
accountSequence: string;
|
||||||
|
totalContribution: string;
|
||||||
|
personalContribution: string;
|
||||||
|
teamContribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetContributionRankingQuery {
|
||||||
|
private readonly RANKING_CACHE_KEY = 'contribution:ranking';
|
||||||
|
private readonly CACHE_TTL = 300; // 5分钟缓存
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly accountRepository: ContributionAccountRepository,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取算力排行榜
|
||||||
|
*/
|
||||||
|
async execute(limit: number = 100): Promise<ContributionRankingDto[]> {
|
||||||
|
// 尝试从缓存获取
|
||||||
|
const cached = await this.redis.getJson<ContributionRankingDto[]>(`${this.RANKING_CACHE_KEY}:${limit}`);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库获取
|
||||||
|
const topContributors = await this.accountRepository.findTopContributors(limit);
|
||||||
|
|
||||||
|
const ranking: ContributionRankingDto[] = topContributors.map((account, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
accountSequence: account.accountSequence,
|
||||||
|
totalContribution: account.totalContribution.value.toString(),
|
||||||
|
personalContribution: account.personalContribution.value.toString(),
|
||||||
|
teamContribution: account.teamLevelContribution.add(account.teamBonusContribution).value.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
await this.redis.setJson(`${this.RANKING_CACHE_KEY}:${limit}`, ranking, this.CACHE_TTL);
|
||||||
|
|
||||||
|
return ranking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定用户的排名
|
||||||
|
*/
|
||||||
|
async getUserRank(accountSequence: string): Promise<{
|
||||||
|
rank: number | null;
|
||||||
|
totalContribution: string;
|
||||||
|
percentile: number | null;
|
||||||
|
} | null> {
|
||||||
|
const account = await this.accountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Redis 有序集合来快速获取排名
|
||||||
|
// 这需要在算力变化时同步更新 Redis
|
||||||
|
const rank = await this.redis.zrevrank('contribution:leaderboard', accountSequence);
|
||||||
|
const totalAccounts = await this.accountRepository.countAccountsWithContribution();
|
||||||
|
|
||||||
|
return {
|
||||||
|
rank: rank !== null ? rank + 1 : null,
|
||||||
|
totalContribution: account.totalContribution.value.toString(),
|
||||||
|
percentile: rank !== null && totalAccounts > 0 ? ((totalAccounts - rank) / totalAccounts) * 100 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新排行榜缓存
|
||||||
|
*/
|
||||||
|
async refreshRankingCache(): Promise<void> {
|
||||||
|
// 清除旧缓存
|
||||||
|
await this.redis.del(`${this.RANKING_CACHE_KEY}:100`);
|
||||||
|
await this.redis.del(`${this.RANKING_CACHE_KEY}:50`);
|
||||||
|
await this.redis.del(`${this.RANKING_CACHE_KEY}:10`);
|
||||||
|
|
||||||
|
// 重新生成缓存
|
||||||
|
await this.execute(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
|
import { UnallocatedContributionRepository } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||||
|
import { SystemAccountRepository } from '../../infrastructure/persistence/repositories/system-account.repository';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
|
||||||
|
export interface ContributionStatsDto {
|
||||||
|
// 用户统计
|
||||||
|
totalUsers: number;
|
||||||
|
totalAccounts: number;
|
||||||
|
accountsWithContribution: number;
|
||||||
|
|
||||||
|
// 认种统计
|
||||||
|
totalAdoptions: number;
|
||||||
|
processedAdoptions: number;
|
||||||
|
unprocessedAdoptions: number;
|
||||||
|
|
||||||
|
// 算力统计
|
||||||
|
totalContribution: string;
|
||||||
|
|
||||||
|
// 算力分布
|
||||||
|
contributionByType: {
|
||||||
|
personal: string;
|
||||||
|
teamLevel: string;
|
||||||
|
teamBonus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 系统账户
|
||||||
|
systemAccounts: {
|
||||||
|
accountType: string;
|
||||||
|
name: string;
|
||||||
|
totalContribution: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// 未分配算力
|
||||||
|
totalUnallocated: string;
|
||||||
|
unallocatedByType: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetContributionStatsQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly accountRepository: ContributionAccountRepository,
|
||||||
|
private readonly recordRepository: ContributionRecordRepository,
|
||||||
|
private readonly unallocatedRepository: UnallocatedContributionRepository,
|
||||||
|
private readonly systemAccountRepository: SystemAccountRepository,
|
||||||
|
private readonly syncedDataRepository: SyncedDataRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(): Promise<ContributionStatsDto> {
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
totalAccounts,
|
||||||
|
accountsWithContribution,
|
||||||
|
totalAdoptions,
|
||||||
|
undistributedAdoptions,
|
||||||
|
totalContribution,
|
||||||
|
contributionByType,
|
||||||
|
systemAccounts,
|
||||||
|
totalUnallocated,
|
||||||
|
unallocatedByType,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.syncedDataRepository.countUsers(),
|
||||||
|
this.accountRepository.countAccounts(),
|
||||||
|
this.accountRepository.countAccountsWithContribution(),
|
||||||
|
this.syncedDataRepository.countAdoptions(),
|
||||||
|
this.syncedDataRepository.countUndistributedAdoptions(),
|
||||||
|
this.accountRepository.getTotalContribution(),
|
||||||
|
this.recordRepository.getContributionSummaryBySourceType(),
|
||||||
|
this.systemAccountRepository.findAll(),
|
||||||
|
this.unallocatedRepository.getTotalUnallocated(),
|
||||||
|
this.unallocatedRepository.getTotalUnallocatedByType(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
totalAccounts,
|
||||||
|
accountsWithContribution,
|
||||||
|
totalAdoptions,
|
||||||
|
processedAdoptions: totalAdoptions - undistributedAdoptions,
|
||||||
|
unprocessedAdoptions: undistributedAdoptions,
|
||||||
|
totalContribution: totalContribution.value.toString(),
|
||||||
|
contributionByType: {
|
||||||
|
personal: (contributionByType.get(ContributionSourceType.PERSONAL)?.value || 0).toString(),
|
||||||
|
teamLevel: (contributionByType.get(ContributionSourceType.TEAM_LEVEL)?.value || 0).toString(),
|
||||||
|
teamBonus: (contributionByType.get(ContributionSourceType.TEAM_BONUS)?.value || 0).toString(),
|
||||||
|
},
|
||||||
|
systemAccounts: systemAccounts.map((a) => ({
|
||||||
|
accountType: a.accountType,
|
||||||
|
name: a.name,
|
||||||
|
totalContribution: a.contributionBalance.value.toString(),
|
||||||
|
})),
|
||||||
|
totalUnallocated: totalUnallocated.value.toString(),
|
||||||
|
unallocatedByType: Object.fromEntries(
|
||||||
|
Array.from(unallocatedByType.entries()).map(([k, v]) => [k, v.value.toString()]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||||
|
import { SnapshotService } from '../services/snapshot.service';
|
||||||
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算力相关定时任务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContributionScheduler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ContributionScheduler.name);
|
||||||
|
private readonly LOCK_KEY = 'contribution:scheduler:lock';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly calculationService: ContributionCalculationService,
|
||||||
|
private readonly snapshotService: SnapshotService,
|
||||||
|
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly kafkaProducer: KafkaProducerService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Contribution scheduler initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟处理未处理的认种记录
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async processUnprocessedAdoptions(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:process`, 55);
|
||||||
|
if (!lockValue) {
|
||||||
|
return; // 其他实例正在处理
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processed = await this.calculationService.processUndistributedAdoptions(100);
|
||||||
|
if (processed > 0) {
|
||||||
|
this.logger.log(`Processed ${processed} unprocessed adoptions`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to process unprocessed adoptions', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:process`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天凌晨1点创建每日快照
|
||||||
|
*/
|
||||||
|
@Cron('0 1 * * *')
|
||||||
|
async createDailySnapshot(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:snapshot`, 300);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建前一天的快照
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
await this.snapshotService.createDailySnapshot(yesterday);
|
||||||
|
this.logger.log(`Daily snapshot created for ${yesterday.toISOString().split('T')[0]}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create daily snapshot', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:snapshot`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天凌晨2点检查过期的算力记录
|
||||||
|
*/
|
||||||
|
@Cron('0 2 * * *')
|
||||||
|
async processExpiredRecords(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:expire`, 300);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const expiredRecords = await this.contributionRecordRepository.findExpiredRecords(now, 1000);
|
||||||
|
|
||||||
|
if (expiredRecords.length > 0) {
|
||||||
|
const ids = expiredRecords.map((r) => r.id).filter((id): id is bigint => id !== null);
|
||||||
|
await this.contributionRecordRepository.markAsExpired(ids);
|
||||||
|
this.logger.log(`Marked ${ids.length} contribution records as expired`);
|
||||||
|
|
||||||
|
// TODO: 需要相应地减少账户的算力值
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to process expired records', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:expire`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每30秒发布 Outbox 中的事件
|
||||||
|
*/
|
||||||
|
@Cron('*/30 * * * * *')
|
||||||
|
async publishOutboxEvents(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:outbox`, 25);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.outboxRepository.findUnprocessed(100);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
try {
|
||||||
|
await this.kafkaProducer.emit(`contribution.${event.eventType}`, {
|
||||||
|
key: event.aggregateId,
|
||||||
|
value: {
|
||||||
|
eventId: event.id,
|
||||||
|
aggregateType: event.aggregateType,
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
eventType: event.eventType,
|
||||||
|
payload: event.payload,
|
||||||
|
createdAt: event.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish event ${event.id}`, error);
|
||||||
|
// 继续处理下一个事件
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已处理
|
||||||
|
const processedIds = events.map((e) => e.id);
|
||||||
|
await this.outboxRepository.markAsProcessed(processedIds);
|
||||||
|
|
||||||
|
this.logger.debug(`Published ${processedIds.length} outbox events`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to publish outbox events', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:outbox`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天凌晨3点清理已处理的 Outbox 事件(保留7天)
|
||||||
|
*/
|
||||||
|
@Cron('0 3 * * *')
|
||||||
|
async cleanupOutbox(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:cleanup`, 300);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const deleted = await this.outboxRepository.deleteProcessed(sevenDaysAgo);
|
||||||
|
if (deleted > 0) {
|
||||||
|
this.logger.log(`Cleaned up ${deleted} processed outbox events`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to cleanup outbox', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ContributionCalculatorService, ContributionDistributionResult } from '../../domain/services/contribution-calculator.service';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||||
|
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||||
|
import { UnallocatedContributionRepository } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||||
|
import { SystemAccountRepository } from '../../infrastructure/persistence/repositories/system-account.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
import { ContributionAccountAggregate, ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
import { SyncedReferral } from '../../domain/repositories/synced-data.repository.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算力计算应用服务
|
||||||
|
* 协调领域服务和仓库,完成算力计算的完整流程
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContributionCalculationService {
|
||||||
|
private readonly logger = new Logger(ContributionCalculationService.name);
|
||||||
|
private readonly domainCalculator = new ContributionCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||||
|
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||||
|
private readonly syncedDataRepository: SyncedDataRepository,
|
||||||
|
private readonly unallocatedContributionRepository: UnallocatedContributionRepository,
|
||||||
|
private readonly systemAccountRepository: SystemAccountRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为认种计算并分配算力
|
||||||
|
*/
|
||||||
|
async calculateForAdoption(originalAdoptionId: bigint): Promise<void> {
|
||||||
|
// 检查是否已经处理过
|
||||||
|
const exists = await this.contributionRecordRepository.existsBySourceAdoptionId(originalAdoptionId);
|
||||||
|
if (exists) {
|
||||||
|
this.logger.debug(`Adoption ${originalAdoptionId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取认种数据
|
||||||
|
const adoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(originalAdoptionId);
|
||||||
|
if (!adoption) {
|
||||||
|
throw new Error(`Adoption not found: ${originalAdoptionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取认种用户的引荐关系
|
||||||
|
const userReferral = await this.syncedDataRepository.findSyncedReferralByAccountSequence(adoption.accountSequence);
|
||||||
|
|
||||||
|
// 获取上线链条(最多15级)
|
||||||
|
let ancestorChain: SyncedReferral[] = [];
|
||||||
|
if (userReferral?.referrerAccountSequence) {
|
||||||
|
ancestorChain = await this.buildAncestorChain(userReferral.referrerAccountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取上线的算力账户(用于判断解锁状态)
|
||||||
|
const ancestorAccountSequences = ancestorChain.map((a) => a.accountSequence);
|
||||||
|
const ancestorAccounts = await this.contributionAccountRepository.findByAccountSequences(ancestorAccountSequences);
|
||||||
|
|
||||||
|
// 执行算力计算
|
||||||
|
const result = this.domainCalculator.calculateAdoptionContribution(adoption, ancestorChain, ancestorAccounts);
|
||||||
|
|
||||||
|
// 在事务中保存所有结果
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
await this.saveDistributionResult(result, adoption.originalAdoptionId, adoption.accountSequence);
|
||||||
|
|
||||||
|
// 标记认种已处理
|
||||||
|
await this.syncedDataRepository.markAdoptionContributionDistributed(adoption.originalAdoptionId);
|
||||||
|
|
||||||
|
// 更新认种人的解锁状态(如果是首次认种)
|
||||||
|
await this.updateAdopterUnlockStatus(adoption.accountSequence);
|
||||||
|
|
||||||
|
// 更新直接上线的解锁状态
|
||||||
|
if (userReferral?.referrerAccountSequence) {
|
||||||
|
await this.updateReferrerUnlockStatus(userReferral.referrerAccountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布事件到 Outbox
|
||||||
|
await this.outboxRepository.save({
|
||||||
|
aggregateType: 'ContributionAccount',
|
||||||
|
aggregateId: adoption.accountSequence,
|
||||||
|
eventType: 'ContributionCalculated',
|
||||||
|
payload: {
|
||||||
|
accountSequence: adoption.accountSequence,
|
||||||
|
sourceAdoptionId: originalAdoptionId.toString(),
|
||||||
|
personalContribution: result.personalRecord.amount.value.toString(),
|
||||||
|
teamLevelCount: result.teamLevelRecords.length,
|
||||||
|
teamBonusCount: result.teamBonusRecords.length,
|
||||||
|
unallocatedCount: result.unallocatedContributions.length,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Contribution calculated for adoption ${originalAdoptionId}: ` +
|
||||||
|
`personal=${result.personalRecord.amount.value}, ` +
|
||||||
|
`teamLevel=${result.teamLevelRecords.length}, ` +
|
||||||
|
`teamBonus=${result.teamBonusRecords.length}, ` +
|
||||||
|
`unallocated=${result.unallocatedContributions.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量计算未处理的认种
|
||||||
|
*/
|
||||||
|
async processUndistributedAdoptions(batchSize: number = 100): Promise<number> {
|
||||||
|
const undistributed = await this.syncedDataRepository.findUndistributedAdoptions(batchSize);
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
for (const adoption of undistributed) {
|
||||||
|
try {
|
||||||
|
await this.calculateForAdoption(adoption.originalAdoptionId);
|
||||||
|
processedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to process adoption ${adoption.originalAdoptionId}`, error);
|
||||||
|
// 继续处理下一个
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新计算指定账户的全部算力
|
||||||
|
* 用于修正或审计
|
||||||
|
*/
|
||||||
|
async recalculateForAccount(accountSequence: string): Promise<void> {
|
||||||
|
const adoptions = await this.syncedDataRepository.findAdoptionsByAccountSequence(accountSequence);
|
||||||
|
|
||||||
|
for (const adoption of adoptions) {
|
||||||
|
// 这里需要特殊处理:先清除旧记录,再重新计算
|
||||||
|
// TODO: 实现完整的重新计算逻辑
|
||||||
|
this.logger.warn(`Recalculation for ${accountSequence} not fully implemented yet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建上线链条
|
||||||
|
*/
|
||||||
|
private async buildAncestorChain(startAccountSequence: string): Promise<SyncedReferral[]> {
|
||||||
|
return await this.syncedDataRepository.findAncestorChain(startAccountSequence, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存分配结果
|
||||||
|
*/
|
||||||
|
private async saveDistributionResult(
|
||||||
|
result: ContributionDistributionResult,
|
||||||
|
sourceAdoptionId: bigint,
|
||||||
|
sourceAccountSequence: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 1. 保存个人算力记录
|
||||||
|
await this.contributionRecordRepository.save(result.personalRecord);
|
||||||
|
|
||||||
|
// 更新个人算力账户
|
||||||
|
let account = await this.contributionAccountRepository.findByAccountSequence(
|
||||||
|
result.personalRecord.accountSequence,
|
||||||
|
);
|
||||||
|
if (!account) {
|
||||||
|
account = ContributionAccountAggregate.create(result.personalRecord.accountSequence);
|
||||||
|
}
|
||||||
|
account.addPersonalContribution(result.personalRecord.amount);
|
||||||
|
await this.contributionAccountRepository.save(account);
|
||||||
|
|
||||||
|
// 2. 保存团队层级算力记录
|
||||||
|
if (result.teamLevelRecords.length > 0) {
|
||||||
|
await this.contributionRecordRepository.saveMany(result.teamLevelRecords);
|
||||||
|
|
||||||
|
// 更新各上线的算力账户
|
||||||
|
for (const record of result.teamLevelRecords) {
|
||||||
|
await this.contributionAccountRepository.updateContribution(
|
||||||
|
record.accountSequence,
|
||||||
|
ContributionSourceType.TEAM_LEVEL,
|
||||||
|
record.amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 保存团队奖励算力记录
|
||||||
|
if (result.teamBonusRecords.length > 0) {
|
||||||
|
await this.contributionRecordRepository.saveMany(result.teamBonusRecords);
|
||||||
|
|
||||||
|
// 更新直接上线的算力账户
|
||||||
|
for (const record of result.teamBonusRecords) {
|
||||||
|
await this.contributionAccountRepository.updateContribution(
|
||||||
|
record.accountSequence,
|
||||||
|
ContributionSourceType.TEAM_BONUS,
|
||||||
|
record.amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effectiveDate and expireDate from the personal record
|
||||||
|
const effectiveDate = result.personalRecord.effectiveDate;
|
||||||
|
const expireDate = result.personalRecord.expireDate;
|
||||||
|
|
||||||
|
// 4. 保存未分配算力
|
||||||
|
if (result.unallocatedContributions.length > 0) {
|
||||||
|
await this.unallocatedContributionRepository.saveMany(
|
||||||
|
result.unallocatedContributions.map((u) => ({
|
||||||
|
...u,
|
||||||
|
sourceAdoptionId,
|
||||||
|
sourceAccountSequence,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 保存系统账户算力
|
||||||
|
if (result.systemContributions.length > 0) {
|
||||||
|
await this.systemAccountRepository.ensureSystemAccountsExist();
|
||||||
|
|
||||||
|
for (const sys of result.systemContributions) {
|
||||||
|
await this.systemAccountRepository.addContribution(sys.accountType, sys.amount);
|
||||||
|
await this.systemAccountRepository.saveContributionRecord({
|
||||||
|
systemAccountType: sys.accountType,
|
||||||
|
sourceAdoptionId,
|
||||||
|
sourceAccountSequence,
|
||||||
|
distributionRate: sys.rate.value.toNumber(),
|
||||||
|
amount: sys.amount,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate: null, // System account contributions never expire based on the schema's contributionNeverExpires field
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新认种人的解锁状态
|
||||||
|
*/
|
||||||
|
private async updateAdopterUnlockStatus(accountSequence: string): Promise<void> {
|
||||||
|
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (!account.hasAdopted) {
|
||||||
|
account.markAsAdopted();
|
||||||
|
await this.contributionAccountRepository.save(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新上线的解锁状态(直推用户认种后)
|
||||||
|
*/
|
||||||
|
private async updateReferrerUnlockStatus(referrerAccountSequence: string): Promise<void> {
|
||||||
|
const account = await this.contributionAccountRepository.findByAccountSequence(referrerAccountSequence);
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
// 重新计算直推认种用户数
|
||||||
|
const directReferralAdoptedCount = await this.syncedDataRepository.getDirectReferralAdoptedCount(
|
||||||
|
referrerAccountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新解锁状态
|
||||||
|
const currentCount = account.directReferralAdoptedCount;
|
||||||
|
if (directReferralAdoptedCount > currentCount) {
|
||||||
|
// 需要增量更新
|
||||||
|
for (let i = currentCount; i < directReferralAdoptedCount; i++) {
|
||||||
|
account.incrementDirectReferralAdoptedCount();
|
||||||
|
}
|
||||||
|
await this.contributionAccountRepository.save(account);
|
||||||
|
this.logger.debug(
|
||||||
|
`Updated referrer ${referrerAccountSequence} unlock status: level=${account.unlockedLevelDepth}, bonus=${account.unlockedBonusTiers}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface UserContributionSnapshot {
|
||||||
|
id: bigint;
|
||||||
|
snapshotDate: Date;
|
||||||
|
accountSequence: string;
|
||||||
|
effectiveContribution: ContributionAmount;
|
||||||
|
networkTotalContribution: ContributionAmount;
|
||||||
|
contributionRatio: Decimal;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailySnapshotSummary {
|
||||||
|
snapshotDate: Date;
|
||||||
|
networkTotalContribution: ContributionAmount;
|
||||||
|
totalAccounts: number;
|
||||||
|
activeAccounts: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日快照服务
|
||||||
|
* 负责生成每日算力快照,供 mining-service 使用
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SnapshotService {
|
||||||
|
private readonly logger = new Logger(SnapshotService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly unitOfWork: UnitOfWork,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建每日算力快照
|
||||||
|
* 为所有有效算力大于0的账户创建快照记录
|
||||||
|
*/
|
||||||
|
async createDailySnapshot(snapshotDate: Date): Promise<DailySnapshotSummary> {
|
||||||
|
const dateStr = this.formatDate(snapshotDate);
|
||||||
|
const snapshotDateOnly = new Date(dateStr);
|
||||||
|
|
||||||
|
// 检查是否已存在快照
|
||||||
|
const existingCount = await this.prisma.dailyContributionSnapshot.count({
|
||||||
|
where: { snapshotDate: snapshotDateOnly },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCount > 0) {
|
||||||
|
this.logger.warn(`Snapshot for ${dateStr} already exists with ${existingCount} records`);
|
||||||
|
const existingSummary = await this.getSnapshotSummary(snapshotDate);
|
||||||
|
if (!existingSummary) {
|
||||||
|
throw new Error(`Snapshot exists but summary could not be retrieved for ${dateStr}`);
|
||||||
|
}
|
||||||
|
return existingSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全网有效算力总和
|
||||||
|
const aggregation = await this.prisma.contributionAccount.aggregate({
|
||||||
|
_sum: { effectiveContribution: true },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkTotalContribution = aggregation._sum.effectiveContribution || new Decimal(0);
|
||||||
|
const totalAccounts = aggregation._count.id;
|
||||||
|
|
||||||
|
// 获取有效算力大于0的账户
|
||||||
|
const activeAccounts = await this.prisma.contributionAccount.findMany({
|
||||||
|
where: { effectiveContribution: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
accountSequence: true,
|
||||||
|
effectiveContribution: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
|
// 批量创建每个账户的快照记录
|
||||||
|
if (activeAccounts.length > 0) {
|
||||||
|
await this.prisma.dailyContributionSnapshot.createMany({
|
||||||
|
data: activeAccounts.map((account) => ({
|
||||||
|
snapshotDate: snapshotDateOnly,
|
||||||
|
accountSequence: account.accountSequence,
|
||||||
|
effectiveContribution: account.effectiveContribution,
|
||||||
|
networkTotalContribution: networkTotalContribution,
|
||||||
|
contributionRatio: networkTotalContribution.isZero()
|
||||||
|
? new Decimal(0)
|
||||||
|
: new Decimal(account.effectiveContribution).dividedBy(networkTotalContribution),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布快照创建事件
|
||||||
|
await this.outboxRepository.save({
|
||||||
|
aggregateType: 'DailySnapshot',
|
||||||
|
aggregateId: dateStr,
|
||||||
|
eventType: 'DailySnapshotCreated',
|
||||||
|
payload: {
|
||||||
|
snapshotDate: dateStr,
|
||||||
|
networkTotalContribution: networkTotalContribution.toString(),
|
||||||
|
activeAccounts: activeAccounts.length,
|
||||||
|
totalAccounts,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Daily snapshot created for ${dateStr}: networkTotal=${networkTotalContribution}, activeAccounts=${activeAccounts.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotDate: snapshotDateOnly,
|
||||||
|
networkTotalContribution: new ContributionAmount(networkTotalContribution),
|
||||||
|
totalAccounts,
|
||||||
|
activeAccounts: activeAccounts.length,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期的快照汇总
|
||||||
|
*/
|
||||||
|
async getSnapshotSummary(snapshotDate: Date): Promise<DailySnapshotSummary | null> {
|
||||||
|
const dateStr = this.formatDate(snapshotDate);
|
||||||
|
const snapshotDateOnly = new Date(dateStr);
|
||||||
|
|
||||||
|
const firstRecord = await this.prisma.dailyContributionSnapshot.findFirst({
|
||||||
|
where: { snapshotDate: snapshotDateOnly },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!firstRecord) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = await this.prisma.dailyContributionSnapshot.count({
|
||||||
|
where: { snapshotDate: snapshotDateOnly },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAccounts = await this.prisma.contributionAccount.count();
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotDate: snapshotDateOnly,
|
||||||
|
networkTotalContribution: new ContributionAmount(firstRecord.networkTotalContribution),
|
||||||
|
totalAccounts,
|
||||||
|
activeAccounts: activeCount,
|
||||||
|
createdAt: firstRecord.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新的快照日期
|
||||||
|
*/
|
||||||
|
async getLatestSnapshotDate(): Promise<Date | null> {
|
||||||
|
const snapshot = await this.prisma.dailyContributionSnapshot.findFirst({
|
||||||
|
orderBy: { snapshotDate: 'desc' },
|
||||||
|
select: { snapshotDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return snapshot?.snapshotDate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户在指定日期的算力快照
|
||||||
|
*/
|
||||||
|
async getUserSnapshot(
|
||||||
|
accountSequence: string,
|
||||||
|
snapshotDate: Date,
|
||||||
|
): Promise<UserContributionSnapshot | null> {
|
||||||
|
const dateStr = this.formatDate(snapshotDate);
|
||||||
|
const snapshotDateOnly = new Date(dateStr);
|
||||||
|
|
||||||
|
const snapshot = await this.prisma.dailyContributionSnapshot.findUnique({
|
||||||
|
where: {
|
||||||
|
snapshotDate_accountSequence: {
|
||||||
|
snapshotDate: snapshotDateOnly,
|
||||||
|
accountSequence,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toUserSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户在指定日期的算力占比
|
||||||
|
*/
|
||||||
|
async getUserContributionRatio(
|
||||||
|
accountSequence: string,
|
||||||
|
snapshotDate: Date,
|
||||||
|
): Promise<{ contribution: ContributionAmount; ratio: number } | null> {
|
||||||
|
const snapshot = await this.getUserSnapshot(accountSequence, snapshotDate);
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contribution: snapshot.effectiveContribution,
|
||||||
|
ratio: snapshot.contributionRatio.toNumber(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取用户算力占比
|
||||||
|
*/
|
||||||
|
async batchGetUserContributionRatios(
|
||||||
|
snapshotDate: Date,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 1000,
|
||||||
|
): Promise<{
|
||||||
|
data: Array<{ accountSequence: string; contribution: string; ratio: number }>;
|
||||||
|
total: number;
|
||||||
|
totalContribution: string;
|
||||||
|
}> {
|
||||||
|
const dateStr = this.formatDate(snapshotDate);
|
||||||
|
const snapshotDateOnly = new Date(dateStr);
|
||||||
|
|
||||||
|
const [snapshots, total] = await Promise.all([
|
||||||
|
this.prisma.dailyContributionSnapshot.findMany({
|
||||||
|
where: { snapshotDate: snapshotDateOnly },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { effectiveContribution: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.dailyContributionSnapshot.count({
|
||||||
|
where: { snapshotDate: snapshotDateOnly },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
return { data: [], total: 0, totalContribution: '0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkTotal = snapshots[0].networkTotalContribution;
|
||||||
|
|
||||||
|
const ratios = snapshots.map((snapshot) => ({
|
||||||
|
accountSequence: snapshot.accountSequence,
|
||||||
|
contribution: snapshot.effectiveContribution.toString(),
|
||||||
|
ratio: new Decimal(snapshot.contributionRatio).toNumber(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: ratios,
|
||||||
|
total,
|
||||||
|
totalContribution: networkTotal.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUserSnapshot(record: any): UserContributionSnapshot {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
snapshotDate: record.snapshotDate,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
effectiveContribution: new ContributionAmount(record.effectiveContribution),
|
||||||
|
networkTotalContribution: new ContributionAmount(record.networkTotalContribution),
|
||||||
|
contributionRatio: new Decimal(record.contributionRatio),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { ContributionAmount } from '../value-objects/contribution-amount.vo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值来源类型
|
||||||
|
*/
|
||||||
|
export enum ContributionSourceType {
|
||||||
|
PERSONAL = 'PERSONAL', // 来自自己认种
|
||||||
|
TEAM_LEVEL = 'TEAM_LEVEL', // 来自团队层级
|
||||||
|
TEAM_BONUS = 'TEAM_BONUS', // 来自团队额外奖励
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算力账户聚合根
|
||||||
|
* 管理用户的贡献值/算力
|
||||||
|
*/
|
||||||
|
export class ContributionAccountAggregate {
|
||||||
|
private _id: bigint | null;
|
||||||
|
private _accountSequence: string;
|
||||||
|
private _personalContribution: ContributionAmount;
|
||||||
|
private _teamLevelContribution: ContributionAmount;
|
||||||
|
private _teamBonusContribution: ContributionAmount;
|
||||||
|
private _totalContribution: ContributionAmount;
|
||||||
|
private _effectiveContribution: ContributionAmount;
|
||||||
|
private _hasAdopted: boolean;
|
||||||
|
private _directReferralAdoptedCount: number;
|
||||||
|
private _unlockedLevelDepth: number;
|
||||||
|
private _unlockedBonusTiers: number;
|
||||||
|
private _version: number;
|
||||||
|
private _createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(props: {
|
||||||
|
id?: bigint | null;
|
||||||
|
accountSequence: string;
|
||||||
|
personalContribution?: ContributionAmount;
|
||||||
|
teamLevelContribution?: ContributionAmount;
|
||||||
|
teamBonusContribution?: ContributionAmount;
|
||||||
|
totalContribution?: ContributionAmount;
|
||||||
|
effectiveContribution?: ContributionAmount;
|
||||||
|
hasAdopted?: boolean;
|
||||||
|
directReferralAdoptedCount?: number;
|
||||||
|
unlockedLevelDepth?: number;
|
||||||
|
unlockedBonusTiers?: number;
|
||||||
|
version?: number;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}) {
|
||||||
|
this._id = props.id ?? null;
|
||||||
|
this._accountSequence = props.accountSequence;
|
||||||
|
this._personalContribution = props.personalContribution ?? ContributionAmount.zero();
|
||||||
|
this._teamLevelContribution = props.teamLevelContribution ?? ContributionAmount.zero();
|
||||||
|
this._teamBonusContribution = props.teamBonusContribution ?? ContributionAmount.zero();
|
||||||
|
this._totalContribution = props.totalContribution ?? ContributionAmount.zero();
|
||||||
|
this._effectiveContribution = props.effectiveContribution ?? ContributionAmount.zero();
|
||||||
|
this._hasAdopted = props.hasAdopted ?? false;
|
||||||
|
this._directReferralAdoptedCount = props.directReferralAdoptedCount ?? 0;
|
||||||
|
this._unlockedLevelDepth = props.unlockedLevelDepth ?? 0;
|
||||||
|
this._unlockedBonusTiers = props.unlockedBonusTiers ?? 0;
|
||||||
|
this._version = props.version ?? 1;
|
||||||
|
this._createdAt = props.createdAt ?? new Date();
|
||||||
|
this._updatedAt = props.updatedAt ?? new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint | null { return this._id; }
|
||||||
|
get accountSequence(): string { return this._accountSequence; }
|
||||||
|
get personalContribution(): ContributionAmount { return this._personalContribution; }
|
||||||
|
get teamLevelContribution(): ContributionAmount { return this._teamLevelContribution; }
|
||||||
|
get teamBonusContribution(): ContributionAmount { return this._teamBonusContribution; }
|
||||||
|
get totalContribution(): ContributionAmount { return this._totalContribution; }
|
||||||
|
get effectiveContribution(): ContributionAmount { return this._effectiveContribution; }
|
||||||
|
get hasAdopted(): boolean { return this._hasAdopted; }
|
||||||
|
get directReferralAdoptedCount(): number { return this._directReferralAdoptedCount; }
|
||||||
|
get unlockedLevelDepth(): number { return this._unlockedLevelDepth; }
|
||||||
|
get unlockedBonusTiers(): number { return this._unlockedBonusTiers; }
|
||||||
|
get version(): number { return this._version; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
get updatedAt(): Date { return this._updatedAt; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加个人贡献值
|
||||||
|
*/
|
||||||
|
addPersonalContribution(amount: ContributionAmount): void {
|
||||||
|
this._personalContribution = this._personalContribution.add(amount);
|
||||||
|
this.recalculateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加团队层级贡献值
|
||||||
|
*/
|
||||||
|
addTeamLevelContribution(amount: ContributionAmount): void {
|
||||||
|
this._teamLevelContribution = this._teamLevelContribution.add(amount);
|
||||||
|
this.recalculateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加团队额外奖励贡献值
|
||||||
|
*/
|
||||||
|
addTeamBonusContribution(amount: ContributionAmount): void {
|
||||||
|
this._teamBonusContribution = this._teamBonusContribution.add(amount);
|
||||||
|
this.recalculateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记用户已认种
|
||||||
|
*/
|
||||||
|
markAsAdopted(): void {
|
||||||
|
this._hasAdopted = true;
|
||||||
|
this.updateUnlockStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加直推认种用户数
|
||||||
|
*/
|
||||||
|
incrementDirectReferralAdoptedCount(): void {
|
||||||
|
this._directReferralAdoptedCount++;
|
||||||
|
this.updateUnlockStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置直推认种用户数
|
||||||
|
*/
|
||||||
|
setDirectReferralAdoptedCount(count: number): void {
|
||||||
|
this._directReferralAdoptedCount = count;
|
||||||
|
this.updateUnlockStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新有效贡献值(扣除过期的)
|
||||||
|
*/
|
||||||
|
updateEffectiveContribution(effective: ContributionAmount): void {
|
||||||
|
this._effectiveContribution = effective;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新计算总贡献值
|
||||||
|
*/
|
||||||
|
private recalculateTotal(): void {
|
||||||
|
this._totalContribution = this._personalContribution
|
||||||
|
.add(this._teamLevelContribution)
|
||||||
|
.add(this._teamBonusContribution);
|
||||||
|
this._effectiveContribution = this._totalContribution; // 初始时有效=总量
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新解锁状态
|
||||||
|
* 根据直推认种用户数和是否认种过
|
||||||
|
*/
|
||||||
|
private updateUnlockStatus(): void {
|
||||||
|
// 层级解锁规则
|
||||||
|
if (this._directReferralAdoptedCount >= 5) {
|
||||||
|
this._unlockedLevelDepth = 15;
|
||||||
|
} else if (this._directReferralAdoptedCount >= 3) {
|
||||||
|
this._unlockedLevelDepth = 10;
|
||||||
|
} else if (this._directReferralAdoptedCount >= 1) {
|
||||||
|
this._unlockedLevelDepth = 5;
|
||||||
|
} else {
|
||||||
|
this._unlockedLevelDepth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外奖励解锁规则
|
||||||
|
let bonusTiers = 0;
|
||||||
|
if (this._hasAdopted) bonusTiers++;
|
||||||
|
if (this._directReferralAdoptedCount >= 2) bonusTiers++;
|
||||||
|
if (this._directReferralAdoptedCount >= 4) bonusTiers++;
|
||||||
|
this._unlockedBonusTiers = bonusTiers;
|
||||||
|
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加版本号(乐观锁)
|
||||||
|
*/
|
||||||
|
incrementVersion(): void {
|
||||||
|
this._version++;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新账户
|
||||||
|
*/
|
||||||
|
static create(accountSequence: string): ContributionAccountAggregate {
|
||||||
|
return new ContributionAccountAggregate({ accountSequence });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据恢复
|
||||||
|
*/
|
||||||
|
static fromPersistence(data: {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
personalContribution: Decimal;
|
||||||
|
teamLevelContribution: Decimal;
|
||||||
|
teamBonusContribution: Decimal;
|
||||||
|
totalContribution: Decimal;
|
||||||
|
effectiveContribution: Decimal;
|
||||||
|
hasAdopted: boolean;
|
||||||
|
directReferralAdoptedCount: number;
|
||||||
|
unlockedLevelDepth: number;
|
||||||
|
unlockedBonusTiers: number;
|
||||||
|
version: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ContributionAccountAggregate {
|
||||||
|
return new ContributionAccountAggregate({
|
||||||
|
id: data.id,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
personalContribution: new ContributionAmount(data.personalContribution),
|
||||||
|
teamLevelContribution: new ContributionAmount(data.teamLevelContribution),
|
||||||
|
teamBonusContribution: new ContributionAmount(data.teamBonusContribution),
|
||||||
|
totalContribution: new ContributionAmount(data.totalContribution),
|
||||||
|
effectiveContribution: new ContributionAmount(data.effectiveContribution),
|
||||||
|
hasAdopted: data.hasAdopted,
|
||||||
|
directReferralAdoptedCount: data.directReferralAdoptedCount,
|
||||||
|
unlockedLevelDepth: data.unlockedLevelDepth,
|
||||||
|
unlockedBonusTiers: data.unlockedBonusTiers,
|
||||||
|
version: data.version,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为持久化数据
|
||||||
|
*/
|
||||||
|
toPersistence(): {
|
||||||
|
accountSequence: string;
|
||||||
|
personalContribution: Decimal;
|
||||||
|
teamLevelContribution: Decimal;
|
||||||
|
teamBonusContribution: Decimal;
|
||||||
|
totalContribution: Decimal;
|
||||||
|
effectiveContribution: Decimal;
|
||||||
|
hasAdopted: boolean;
|
||||||
|
directReferralAdoptedCount: number;
|
||||||
|
unlockedLevelDepth: number;
|
||||||
|
unlockedBonusTiers: number;
|
||||||
|
version: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
personalContribution: this._personalContribution.value,
|
||||||
|
teamLevelContribution: this._teamLevelContribution.value,
|
||||||
|
teamBonusContribution: this._teamBonusContribution.value,
|
||||||
|
totalContribution: this._totalContribution.value,
|
||||||
|
effectiveContribution: this._effectiveContribution.value,
|
||||||
|
hasAdopted: this._hasAdopted,
|
||||||
|
directReferralAdoptedCount: this._directReferralAdoptedCount,
|
||||||
|
unlockedLevelDepth: this._unlockedLevelDepth,
|
||||||
|
unlockedBonusTiers: this._unlockedBonusTiers,
|
||||||
|
version: this._version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { ContributionAmount } from '../value-objects/contribution-amount.vo';
|
||||||
|
import { DistributionRate } from '../value-objects/distribution-rate.vo';
|
||||||
|
import { ContributionSourceType } from './contribution-account.aggregate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值记录聚合根
|
||||||
|
* 记录每一笔贡献值的来源和详情
|
||||||
|
*/
|
||||||
|
export class ContributionRecordAggregate {
|
||||||
|
private _id: bigint | null;
|
||||||
|
private _accountSequence: string;
|
||||||
|
private _sourceType: ContributionSourceType;
|
||||||
|
private _sourceAdoptionId: bigint;
|
||||||
|
private _sourceAccountSequence: string;
|
||||||
|
private _treeCount: number;
|
||||||
|
private _baseContribution: ContributionAmount;
|
||||||
|
private _distributionRate: DistributionRate;
|
||||||
|
private _levelDepth: number | null;
|
||||||
|
private _bonusTier: number | null;
|
||||||
|
private _amount: ContributionAmount;
|
||||||
|
private _effectiveDate: Date;
|
||||||
|
private _expireDate: Date;
|
||||||
|
private _isExpired: boolean;
|
||||||
|
private _expiredAt: Date | null;
|
||||||
|
private _createdAt: Date;
|
||||||
|
|
||||||
|
constructor(props: {
|
||||||
|
id?: bigint | null;
|
||||||
|
accountSequence: string;
|
||||||
|
sourceType: ContributionSourceType;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: ContributionAmount;
|
||||||
|
distributionRate: DistributionRate;
|
||||||
|
levelDepth?: number | null;
|
||||||
|
bonusTier?: number | null;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
isExpired?: boolean;
|
||||||
|
expiredAt?: Date | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
}) {
|
||||||
|
this._id = props.id ?? null;
|
||||||
|
this._accountSequence = props.accountSequence;
|
||||||
|
this._sourceType = props.sourceType;
|
||||||
|
this._sourceAdoptionId = props.sourceAdoptionId;
|
||||||
|
this._sourceAccountSequence = props.sourceAccountSequence;
|
||||||
|
this._treeCount = props.treeCount;
|
||||||
|
this._baseContribution = props.baseContribution;
|
||||||
|
this._distributionRate = props.distributionRate;
|
||||||
|
this._levelDepth = props.levelDepth ?? null;
|
||||||
|
this._bonusTier = props.bonusTier ?? null;
|
||||||
|
this._amount = props.amount;
|
||||||
|
this._effectiveDate = props.effectiveDate;
|
||||||
|
this._expireDate = props.expireDate;
|
||||||
|
this._isExpired = props.isExpired ?? false;
|
||||||
|
this._expiredAt = props.expiredAt ?? null;
|
||||||
|
this._createdAt = props.createdAt ?? new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint | null { return this._id; }
|
||||||
|
get accountSequence(): string { return this._accountSequence; }
|
||||||
|
get sourceType(): ContributionSourceType { return this._sourceType; }
|
||||||
|
get sourceAdoptionId(): bigint { return this._sourceAdoptionId; }
|
||||||
|
get sourceAccountSequence(): string { return this._sourceAccountSequence; }
|
||||||
|
get treeCount(): number { return this._treeCount; }
|
||||||
|
get baseContribution(): ContributionAmount { return this._baseContribution; }
|
||||||
|
get distributionRate(): DistributionRate { return this._distributionRate; }
|
||||||
|
get levelDepth(): number | null { return this._levelDepth; }
|
||||||
|
get bonusTier(): number | null { return this._bonusTier; }
|
||||||
|
get amount(): ContributionAmount { return this._amount; }
|
||||||
|
get effectiveDate(): Date { return this._effectiveDate; }
|
||||||
|
get expireDate(): Date { return this._expireDate; }
|
||||||
|
get isExpired(): boolean { return this._isExpired; }
|
||||||
|
get expiredAt(): Date | null { return this._expiredAt; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为已过期
|
||||||
|
*/
|
||||||
|
markAsExpired(): void {
|
||||||
|
if (this._isExpired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._isExpired = true;
|
||||||
|
this._expiredAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否应该过期
|
||||||
|
*/
|
||||||
|
shouldExpire(currentDate: Date = new Date()): boolean {
|
||||||
|
return !this._isExpired && currentDate >= this._expireDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建个人贡献值记录
|
||||||
|
*/
|
||||||
|
static createPersonal(props: {
|
||||||
|
accountSequence: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: ContributionAmount;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
}): ContributionRecordAggregate {
|
||||||
|
const rate = DistributionRate.PERSONAL;
|
||||||
|
const amount = props.baseContribution.multiply(props.treeCount).multiply(rate.value);
|
||||||
|
|
||||||
|
return new ContributionRecordAggregate({
|
||||||
|
accountSequence: props.accountSequence,
|
||||||
|
sourceType: ContributionSourceType.PERSONAL,
|
||||||
|
sourceAdoptionId: props.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: props.accountSequence,
|
||||||
|
treeCount: props.treeCount,
|
||||||
|
baseContribution: props.baseContribution,
|
||||||
|
distributionRate: rate,
|
||||||
|
amount: amount,
|
||||||
|
effectiveDate: props.effectiveDate,
|
||||||
|
expireDate: props.expireDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建团队层级贡献值记录
|
||||||
|
*/
|
||||||
|
static createTeamLevel(props: {
|
||||||
|
accountSequence: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: ContributionAmount;
|
||||||
|
levelDepth: number;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
}): ContributionRecordAggregate {
|
||||||
|
const rate = DistributionRate.LEVEL_PER;
|
||||||
|
const amount = props.baseContribution.multiply(props.treeCount).multiply(rate.value);
|
||||||
|
|
||||||
|
return new ContributionRecordAggregate({
|
||||||
|
accountSequence: props.accountSequence,
|
||||||
|
sourceType: ContributionSourceType.TEAM_LEVEL,
|
||||||
|
sourceAdoptionId: props.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: props.sourceAccountSequence,
|
||||||
|
treeCount: props.treeCount,
|
||||||
|
baseContribution: props.baseContribution,
|
||||||
|
distributionRate: rate,
|
||||||
|
levelDepth: props.levelDepth,
|
||||||
|
amount: amount,
|
||||||
|
effectiveDate: props.effectiveDate,
|
||||||
|
expireDate: props.expireDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建团队额外奖励贡献值记录
|
||||||
|
*/
|
||||||
|
static createTeamBonus(props: {
|
||||||
|
accountSequence: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: ContributionAmount;
|
||||||
|
bonusTier: number;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
}): ContributionRecordAggregate {
|
||||||
|
const rate = DistributionRate.BONUS_PER;
|
||||||
|
const amount = props.baseContribution.multiply(props.treeCount).multiply(rate.value);
|
||||||
|
|
||||||
|
return new ContributionRecordAggregate({
|
||||||
|
accountSequence: props.accountSequence,
|
||||||
|
sourceType: ContributionSourceType.TEAM_BONUS,
|
||||||
|
sourceAdoptionId: props.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: props.sourceAccountSequence,
|
||||||
|
treeCount: props.treeCount,
|
||||||
|
baseContribution: props.baseContribution,
|
||||||
|
distributionRate: rate,
|
||||||
|
bonusTier: props.bonusTier,
|
||||||
|
amount: amount,
|
||||||
|
effectiveDate: props.effectiveDate,
|
||||||
|
expireDate: props.expireDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据恢复
|
||||||
|
*/
|
||||||
|
static fromPersistence(data: {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
sourceType: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: Decimal;
|
||||||
|
distributionRate: Decimal;
|
||||||
|
levelDepth: number | null;
|
||||||
|
bonusTier: number | null;
|
||||||
|
amount: Decimal;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
isExpired: boolean;
|
||||||
|
expiredAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): ContributionRecordAggregate {
|
||||||
|
return new ContributionRecordAggregate({
|
||||||
|
id: data.id,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
sourceType: data.sourceType as ContributionSourceType,
|
||||||
|
sourceAdoptionId: data.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: data.sourceAccountSequence,
|
||||||
|
treeCount: data.treeCount,
|
||||||
|
baseContribution: new ContributionAmount(data.baseContribution),
|
||||||
|
distributionRate: new DistributionRate(data.distributionRate),
|
||||||
|
levelDepth: data.levelDepth,
|
||||||
|
bonusTier: data.bonusTier,
|
||||||
|
amount: new ContributionAmount(data.amount),
|
||||||
|
effectiveDate: data.effectiveDate,
|
||||||
|
expireDate: data.expireDate,
|
||||||
|
isExpired: data.isExpired,
|
||||||
|
expiredAt: data.expiredAt,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为持久化数据
|
||||||
|
*/
|
||||||
|
toPersistence(): {
|
||||||
|
accountSequence: string;
|
||||||
|
sourceType: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
baseContribution: Decimal;
|
||||||
|
distributionRate: Decimal;
|
||||||
|
levelDepth: number | null;
|
||||||
|
bonusTier: number | null;
|
||||||
|
amount: Decimal;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
isExpired: boolean;
|
||||||
|
expiredAt: Date | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
sourceType: this._sourceType,
|
||||||
|
sourceAdoptionId: this._sourceAdoptionId,
|
||||||
|
sourceAccountSequence: this._sourceAccountSequence,
|
||||||
|
treeCount: this._treeCount,
|
||||||
|
baseContribution: this._baseContribution.value,
|
||||||
|
distributionRate: this._distributionRate.value,
|
||||||
|
levelDepth: this._levelDepth,
|
||||||
|
bonusTier: this._bonusTier,
|
||||||
|
amount: this._amount.value,
|
||||||
|
effectiveDate: this._effectiveDate,
|
||||||
|
expireDate: this._expireDate,
|
||||||
|
isExpired: this._isExpired,
|
||||||
|
expiredAt: this._expiredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './contribution-account.aggregate';
|
||||||
|
export * from './contribution-record.aggregate';
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* 贡献值计算完成事件
|
||||||
|
* 当用户的算力被计算或更新时发布
|
||||||
|
*/
|
||||||
|
export class ContributionCalculatedEvent {
|
||||||
|
static readonly EVENT_TYPE = 'ContributionCalculated';
|
||||||
|
static readonly TOPIC = 'contribution.contribution-calculated';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly eventId: string,
|
||||||
|
public readonly accountSequence: string,
|
||||||
|
public readonly personalContribution: string,
|
||||||
|
public readonly teamLevelContribution: string,
|
||||||
|
public readonly teamBonusContribution: string,
|
||||||
|
public readonly totalContribution: string,
|
||||||
|
public readonly effectiveContribution: string,
|
||||||
|
public readonly calculatedAt: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
toPayload(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
eventId: this.eventId,
|
||||||
|
eventType: ContributionCalculatedEvent.EVENT_TYPE,
|
||||||
|
accountSequence: this.accountSequence,
|
||||||
|
personalContribution: this.personalContribution,
|
||||||
|
teamLevelContribution: this.teamLevelContribution,
|
||||||
|
teamBonusContribution: this.teamBonusContribution,
|
||||||
|
totalContribution: this.totalContribution,
|
||||||
|
effectiveContribution: this.effectiveContribution,
|
||||||
|
calculatedAt: this.calculatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* 每日算力快照创建事件
|
||||||
|
* 用于通知 mining-service 进行积分股分配
|
||||||
|
*/
|
||||||
|
export class DailySnapshotCreatedEvent {
|
||||||
|
static readonly EVENT_TYPE = 'DailySnapshotCreated';
|
||||||
|
static readonly TOPIC = 'contribution.daily-snapshot-created';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly eventId: string,
|
||||||
|
public readonly snapshotDate: string,
|
||||||
|
public readonly networkTotalContribution: string,
|
||||||
|
public readonly accountCount: number,
|
||||||
|
public readonly createdAt: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
toPayload(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
eventId: this.eventId,
|
||||||
|
eventType: DailySnapshotCreatedEvent.EVENT_TYPE,
|
||||||
|
snapshotDate: this.snapshotDate,
|
||||||
|
networkTotalContribution: this.networkTotalContribution,
|
||||||
|
accountCount: this.accountCount,
|
||||||
|
createdAt: this.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './contribution-calculated.event';
|
||||||
|
export * from './daily-snapshot-created.event';
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ContributionAccountAggregate } from '../aggregates/contribution-account.aggregate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值账户仓库接口
|
||||||
|
*/
|
||||||
|
export interface IContributionAccountRepository {
|
||||||
|
/**
|
||||||
|
* 根据账户序列号查找
|
||||||
|
*/
|
||||||
|
findByAccountSequence(accountSequence: string): Promise<ContributionAccountAggregate | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账户序列号查找(带锁)
|
||||||
|
*/
|
||||||
|
findByAccountSequenceForUpdate(
|
||||||
|
accountSequence: string,
|
||||||
|
tx?: any,
|
||||||
|
): Promise<ContributionAccountAggregate | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存(创建或更新)
|
||||||
|
*/
|
||||||
|
save(account: ContributionAccountAggregate, tx?: any): Promise<ContributionAccountAggregate>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存
|
||||||
|
*/
|
||||||
|
saveMany(accounts: ContributionAccountAggregate[], tx?: any): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有有效贡献值大于0的账户
|
||||||
|
*/
|
||||||
|
findAllWithEffectiveContribution(): Promise<ContributionAccountAggregate[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全网有效贡献值总和
|
||||||
|
*/
|
||||||
|
getNetworkTotalEffectiveContribution(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询
|
||||||
|
*/
|
||||||
|
findMany(options: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: 'totalContribution' | 'effectiveContribution';
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}): Promise<{
|
||||||
|
items: ContributionAccountAggregate[];
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTRIBUTION_ACCOUNT_REPOSITORY = Symbol('IContributionAccountRepository');
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { ContributionRecordAggregate } from '../aggregates/contribution-record.aggregate';
|
||||||
|
import { ContributionSourceType } from '../aggregates/contribution-account.aggregate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值记录仓库接口
|
||||||
|
*/
|
||||||
|
export interface IContributionRecordRepository {
|
||||||
|
/**
|
||||||
|
* 保存记录
|
||||||
|
*/
|
||||||
|
save(record: ContributionRecordAggregate, tx?: any): Promise<ContributionRecordAggregate>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存
|
||||||
|
*/
|
||||||
|
saveMany(records: ContributionRecordAggregate[], tx?: any): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账户序列号查找
|
||||||
|
*/
|
||||||
|
findByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
options?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sourceType?: ContributionSourceType;
|
||||||
|
includeExpired?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
items: ContributionRecordAggregate[];
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据来源认种ID查找
|
||||||
|
*/
|
||||||
|
findBySourceAdoptionId(sourceAdoptionId: bigint): Promise<ContributionRecordAggregate[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找需要过期的记录
|
||||||
|
*/
|
||||||
|
findExpiring(beforeDate: Date, limit?: number): Promise<ContributionRecordAggregate[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量标记过期
|
||||||
|
*/
|
||||||
|
markExpiredBatch(ids: bigint[], tx?: any): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户的有效贡献值总和
|
||||||
|
*/
|
||||||
|
getEffectiveContributionSum(accountSequence: string): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已经为某个认种分配过贡献值
|
||||||
|
*/
|
||||||
|
existsBySourceAdoptionId(sourceAdoptionId: bigint): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTRIBUTION_RECORD_REPOSITORY = Symbol('IContributionRecordRepository');
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './contribution-account.repository.interface';
|
||||||
|
export * from './contribution-record.repository.interface';
|
||||||
|
export * from './synced-data.repository.interface';
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步的用户数据
|
||||||
|
*/
|
||||||
|
export interface SyncedUser {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
originalUserId: bigint;
|
||||||
|
phone: string | null;
|
||||||
|
status: string | null;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
syncedAt: Date;
|
||||||
|
contributionCalculated: boolean;
|
||||||
|
contributionCalculatedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步的认种数据
|
||||||
|
*/
|
||||||
|
export interface SyncedAdoption {
|
||||||
|
id: bigint;
|
||||||
|
originalAdoptionId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
adoptionDate: Date;
|
||||||
|
status: string | null;
|
||||||
|
contributionPerTree: Decimal;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
syncedAt: Date;
|
||||||
|
contributionDistributed: boolean;
|
||||||
|
contributionDistributedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步的推荐关系数据
|
||||||
|
*/
|
||||||
|
export interface SyncedReferral {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
referrerAccountSequence: string | null;
|
||||||
|
ancestorPath: string | null;
|
||||||
|
depth: number;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
syncedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步数据仓库接口
|
||||||
|
*/
|
||||||
|
export interface ISyncedDataRepository {
|
||||||
|
// ============ 用户相关 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存或更新同步的用户数据
|
||||||
|
*/
|
||||||
|
upsertSyncedUser(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
originalUserId: bigint;
|
||||||
|
phone?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账户序列号查找用户
|
||||||
|
*/
|
||||||
|
findSyncedUserByAccountSequence(accountSequence: string): Promise<SyncedUser | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未计算算力的用户
|
||||||
|
*/
|
||||||
|
findUncalculatedUsers(limit?: number): Promise<SyncedUser[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记用户算力已计算
|
||||||
|
*/
|
||||||
|
markUserContributionCalculated(accountSequence: string, tx?: any): Promise<void>;
|
||||||
|
|
||||||
|
// ============ 认种相关 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存或更新同步的认种数据
|
||||||
|
*/
|
||||||
|
upsertSyncedAdoption(data: {
|
||||||
|
originalAdoptionId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
adoptionDate: Date;
|
||||||
|
status?: string | null;
|
||||||
|
contributionPerTree: Decimal;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedAdoption>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据原始ID查找认种
|
||||||
|
*/
|
||||||
|
findSyncedAdoptionByOriginalId(originalAdoptionId: bigint): Promise<SyncedAdoption | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未分配贡献值的认种
|
||||||
|
*/
|
||||||
|
findUndistributedAdoptions(limit?: number): Promise<SyncedAdoption[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的所有认种
|
||||||
|
*/
|
||||||
|
findAdoptionsByAccountSequence(accountSequence: string): Promise<SyncedAdoption[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记认种贡献值已分配
|
||||||
|
*/
|
||||||
|
markAdoptionContributionDistributed(originalAdoptionId: bigint, tx?: any): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户的总认种棵数
|
||||||
|
*/
|
||||||
|
getTotalTreesByAccountSequence(accountSequence: string): Promise<number>;
|
||||||
|
|
||||||
|
// ============ 推荐关系相关 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存或更新同步的推荐关系
|
||||||
|
*/
|
||||||
|
upsertSyncedReferral(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
referrerAccountSequence?: string | null;
|
||||||
|
ancestorPath?: string | null;
|
||||||
|
depth?: number;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedReferral>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账户序列号查找推荐关系
|
||||||
|
*/
|
||||||
|
findSyncedReferralByAccountSequence(accountSequence: string): Promise<SyncedReferral | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直推列表(直接推荐的下级)
|
||||||
|
*/
|
||||||
|
findDirectReferrals(referrerAccountSequence: string): Promise<SyncedReferral[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上线链条(最多15级)
|
||||||
|
*/
|
||||||
|
findAncestorChain(accountSequence: string, maxLevel?: number): Promise<SyncedReferral[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直推中已认种的用户数
|
||||||
|
*/
|
||||||
|
getDirectReferralAdoptedCount(referrerAccountSequence: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取伞下各级的认种棵数
|
||||||
|
*/
|
||||||
|
getTeamTreesByLevel(
|
||||||
|
accountSequence: string,
|
||||||
|
maxLevel?: number,
|
||||||
|
): Promise<Map<number, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYNCED_DATA_REPOSITORY = Symbol('ISyncedDataRepository');
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { ContributionAmount } from '../value-objects/contribution-amount.vo';
|
||||||
|
import { DistributionRate } from '../value-objects/distribution-rate.vo';
|
||||||
|
import { ContributionAccountAggregate, ContributionSourceType } from '../aggregates/contribution-account.aggregate';
|
||||||
|
import { ContributionRecordAggregate } from '../aggregates/contribution-record.aggregate';
|
||||||
|
import { SyncedAdoption, SyncedReferral } from '../repositories/synced-data.repository.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算力分配结果
|
||||||
|
*/
|
||||||
|
export interface ContributionDistributionResult {
|
||||||
|
// 个人贡献值记录
|
||||||
|
personalRecord: ContributionRecordAggregate;
|
||||||
|
|
||||||
|
// 团队层级贡献值记录(给上线们的)
|
||||||
|
teamLevelRecords: ContributionRecordAggregate[];
|
||||||
|
|
||||||
|
// 团队额外奖励贡献值记录(给直接上线的)
|
||||||
|
teamBonusRecords: ContributionRecordAggregate[];
|
||||||
|
|
||||||
|
// 未分配的贡献值(归总部)
|
||||||
|
unallocatedContributions: {
|
||||||
|
type: string;
|
||||||
|
wouldBeAccountSequence: string | null;
|
||||||
|
levelDepth: number | null;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
reason: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// 系统账户贡献值
|
||||||
|
systemContributions: {
|
||||||
|
accountType: 'OPERATION' | 'PROVINCE' | 'CITY';
|
||||||
|
rate: DistributionRate;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值计算领域服务
|
||||||
|
* 负责核心的算力计算逻辑
|
||||||
|
*/
|
||||||
|
export class ContributionCalculatorService {
|
||||||
|
// 贡献值有效期:2年
|
||||||
|
private static readonly CONTRIBUTION_VALIDITY_YEARS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算认种产生的贡献值分配
|
||||||
|
*
|
||||||
|
* @param adoption 认种记录
|
||||||
|
* @param ancestorChain 上线链条(从直接上线开始,最多15级)
|
||||||
|
* @param ancestorAccounts 上线的算力账户(用于判断解锁状态)
|
||||||
|
* @returns 分配结果
|
||||||
|
*/
|
||||||
|
calculateAdoptionContribution(
|
||||||
|
adoption: SyncedAdoption,
|
||||||
|
ancestorChain: SyncedReferral[],
|
||||||
|
ancestorAccounts: Map<string, ContributionAccountAggregate>,
|
||||||
|
): ContributionDistributionResult {
|
||||||
|
const baseContribution = new ContributionAmount(adoption.contributionPerTree);
|
||||||
|
const treeCount = adoption.treeCount;
|
||||||
|
const totalContribution = baseContribution.multiply(treeCount);
|
||||||
|
|
||||||
|
// 计算生效日期(次日)和过期日期(2年后)
|
||||||
|
const effectiveDate = this.getNextDay(adoption.adoptionDate);
|
||||||
|
const expireDate = this.addYears(effectiveDate, ContributionCalculatorService.CONTRIBUTION_VALIDITY_YEARS);
|
||||||
|
|
||||||
|
const result: ContributionDistributionResult = {
|
||||||
|
personalRecord: null as any,
|
||||||
|
teamLevelRecords: [],
|
||||||
|
teamBonusRecords: [],
|
||||||
|
unallocatedContributions: [],
|
||||||
|
systemContributions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 个人贡献值 (70%)
|
||||||
|
result.personalRecord = ContributionRecordAggregate.createPersonal({
|
||||||
|
accountSequence: adoption.accountSequence,
|
||||||
|
sourceAdoptionId: adoption.originalAdoptionId,
|
||||||
|
treeCount,
|
||||||
|
baseContribution,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 系统账户贡献值 (15%)
|
||||||
|
result.systemContributions = [
|
||||||
|
{
|
||||||
|
accountType: 'OPERATION',
|
||||||
|
rate: DistributionRate.OPERATION,
|
||||||
|
amount: totalContribution.multiply(DistributionRate.OPERATION.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountType: 'PROVINCE',
|
||||||
|
rate: DistributionRate.PROVINCE,
|
||||||
|
amount: totalContribution.multiply(DistributionRate.PROVINCE.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountType: 'CITY',
|
||||||
|
rate: DistributionRate.CITY,
|
||||||
|
amount: totalContribution.multiply(DistributionRate.CITY.value),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. 团队贡献值 (15%)
|
||||||
|
this.distributeTeamContribution(
|
||||||
|
adoption,
|
||||||
|
baseContribution,
|
||||||
|
treeCount,
|
||||||
|
ancestorChain,
|
||||||
|
ancestorAccounts,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配团队贡献值
|
||||||
|
*/
|
||||||
|
private distributeTeamContribution(
|
||||||
|
adoption: SyncedAdoption,
|
||||||
|
baseContribution: ContributionAmount,
|
||||||
|
treeCount: number,
|
||||||
|
ancestorChain: SyncedReferral[],
|
||||||
|
ancestorAccounts: Map<string, ContributionAccountAggregate>,
|
||||||
|
effectiveDate: Date,
|
||||||
|
expireDate: Date,
|
||||||
|
result: ContributionDistributionResult,
|
||||||
|
): void {
|
||||||
|
// 3.1 层级部分 (7.5% = 0.5% × 15级)
|
||||||
|
for (let level = 1; level <= 15; level++) {
|
||||||
|
const ancestor = ancestorChain[level - 1];
|
||||||
|
|
||||||
|
if (!ancestor) {
|
||||||
|
// 没有这一级的上线,归总部
|
||||||
|
const levelAmount = baseContribution.multiply(treeCount).multiply(DistributionRate.LEVEL_PER.value);
|
||||||
|
result.unallocatedContributions.push({
|
||||||
|
type: 'LEVEL_NO_ANCESTOR',
|
||||||
|
wouldBeAccountSequence: null,
|
||||||
|
levelDepth: level,
|
||||||
|
amount: levelAmount,
|
||||||
|
reason: `第${level}级无上线`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ancestorAccount = ancestorAccounts.get(ancestor.accountSequence);
|
||||||
|
const unlockedLevelDepth = ancestorAccount?.unlockedLevelDepth ?? 0;
|
||||||
|
|
||||||
|
if (unlockedLevelDepth >= level) {
|
||||||
|
// 上线已解锁该级别
|
||||||
|
result.teamLevelRecords.push(
|
||||||
|
ContributionRecordAggregate.createTeamLevel({
|
||||||
|
accountSequence: ancestor.accountSequence,
|
||||||
|
sourceAdoptionId: adoption.originalAdoptionId,
|
||||||
|
sourceAccountSequence: adoption.accountSequence,
|
||||||
|
treeCount,
|
||||||
|
baseContribution,
|
||||||
|
levelDepth: level,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 上线未解锁该级别,归总部
|
||||||
|
const levelAmount = baseContribution.multiply(treeCount).multiply(DistributionRate.LEVEL_PER.value);
|
||||||
|
result.unallocatedContributions.push({
|
||||||
|
type: 'LEVEL_OVERFLOW',
|
||||||
|
wouldBeAccountSequence: ancestor.accountSequence,
|
||||||
|
levelDepth: level,
|
||||||
|
amount: levelAmount,
|
||||||
|
reason: `上线${ancestor.accountSequence}未解锁第${level}级(已解锁${unlockedLevelDepth}级)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.2 额外奖励部分 (7.5% = 2.5% × 3档) - 只给直接上线
|
||||||
|
if (ancestorChain.length > 0) {
|
||||||
|
const directReferrer = ancestorChain[0];
|
||||||
|
const directReferrerAccount = ancestorAccounts.get(directReferrer.accountSequence);
|
||||||
|
const unlockedBonusTiers = directReferrerAccount?.unlockedBonusTiers ?? 0;
|
||||||
|
|
||||||
|
for (let tier = 1; tier <= 3; tier++) {
|
||||||
|
if (unlockedBonusTiers >= tier) {
|
||||||
|
// 上线已解锁该档位
|
||||||
|
result.teamBonusRecords.push(
|
||||||
|
ContributionRecordAggregate.createTeamBonus({
|
||||||
|
accountSequence: directReferrer.accountSequence,
|
||||||
|
sourceAdoptionId: adoption.originalAdoptionId,
|
||||||
|
sourceAccountSequence: adoption.accountSequence,
|
||||||
|
treeCount,
|
||||||
|
baseContribution,
|
||||||
|
bonusTier: tier,
|
||||||
|
effectiveDate,
|
||||||
|
expireDate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 上线未解锁该档位,归总部
|
||||||
|
const bonusAmount = baseContribution.multiply(treeCount).multiply(DistributionRate.BONUS_PER.value);
|
||||||
|
result.unallocatedContributions.push({
|
||||||
|
type: `BONUS_TIER_${tier}`,
|
||||||
|
wouldBeAccountSequence: directReferrer.accountSequence,
|
||||||
|
levelDepth: null,
|
||||||
|
amount: bonusAmount,
|
||||||
|
reason: `上线${directReferrer.accountSequence}未解锁第${tier}档奖励(已解锁${unlockedBonusTiers}档)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有上线,三个2.5%全部归总部
|
||||||
|
for (let tier = 1; tier <= 3; tier++) {
|
||||||
|
const bonusAmount = baseContribution.multiply(treeCount).multiply(DistributionRate.BONUS_PER.value);
|
||||||
|
result.unallocatedContributions.push({
|
||||||
|
type: `BONUS_TIER_${tier}`,
|
||||||
|
wouldBeAccountSequence: null,
|
||||||
|
levelDepth: null,
|
||||||
|
amount: bonusAmount,
|
||||||
|
reason: `认种人无上线`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据直推认种用户数计算解锁层级
|
||||||
|
*/
|
||||||
|
calculateUnlockedLevelDepth(directReferralAdoptedCount: number): number {
|
||||||
|
if (directReferralAdoptedCount >= 5) return 15;
|
||||||
|
if (directReferralAdoptedCount >= 3) return 10;
|
||||||
|
if (directReferralAdoptedCount >= 1) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据条件计算解锁的额外奖励档位数
|
||||||
|
*/
|
||||||
|
calculateUnlockedBonusTiers(hasAdopted: boolean, directReferralAdoptedCount: number): number {
|
||||||
|
let tiers = 0;
|
||||||
|
if (hasAdopted) tiers++;
|
||||||
|
if (directReferralAdoptedCount >= 2) tiers++;
|
||||||
|
if (directReferralAdoptedCount >= 4) tiers++;
|
||||||
|
return tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取次日日期
|
||||||
|
*/
|
||||||
|
private getNextDay(date: Date): Date {
|
||||||
|
const nextDay = new Date(date);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
nextDay.setHours(0, 0, 0, 0);
|
||||||
|
return nextDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加年数
|
||||||
|
*/
|
||||||
|
private addYears(date: Date, years: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setFullYear(result.getFullYear() + years);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* 账户序列号值对象
|
||||||
|
* 跨服务关联的唯一标识
|
||||||
|
*/
|
||||||
|
export class AccountSequence {
|
||||||
|
private readonly _value: string;
|
||||||
|
|
||||||
|
constructor(value: string) {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new Error('AccountSequence cannot be empty');
|
||||||
|
}
|
||||||
|
if (value.length > 20) {
|
||||||
|
throw new Error('AccountSequence cannot exceed 20 characters');
|
||||||
|
}
|
||||||
|
this._value = value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): string {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: AccountSequence): boolean {
|
||||||
|
return this._value === other._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): AccountSequence {
|
||||||
|
return new AccountSequence(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贡献值/算力金额值对象
|
||||||
|
* 使用 Decimal.js 保证高精度计算
|
||||||
|
*/
|
||||||
|
export class ContributionAmount {
|
||||||
|
private readonly _value: Decimal;
|
||||||
|
|
||||||
|
constructor(value: Decimal | string | number) {
|
||||||
|
this._value = new Decimal(value);
|
||||||
|
if (this._value.isNaN()) {
|
||||||
|
throw new Error('ContributionAmount must be a valid number');
|
||||||
|
}
|
||||||
|
if (this._value.isNegative()) {
|
||||||
|
throw new Error('ContributionAmount cannot be negative');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): Decimal {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isZero(): boolean {
|
||||||
|
return this._value.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(other: ContributionAmount): ContributionAmount {
|
||||||
|
return new ContributionAmount(this._value.plus(other._value));
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(other: ContributionAmount): ContributionAmount {
|
||||||
|
const result = this._value.minus(other._value);
|
||||||
|
if (result.isNegative()) {
|
||||||
|
throw new Error('ContributionAmount cannot be negative after subtraction');
|
||||||
|
}
|
||||||
|
return new ContributionAmount(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
multiply(rate: Decimal | string | number): ContributionAmount {
|
||||||
|
return new ContributionAmount(this._value.times(new Decimal(rate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
divide(divisor: Decimal | string | number): ContributionAmount {
|
||||||
|
const d = new Decimal(divisor);
|
||||||
|
if (d.isZero()) {
|
||||||
|
throw new Error('Cannot divide by zero');
|
||||||
|
}
|
||||||
|
return new ContributionAmount(this._value.dividedBy(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: ContributionAmount): boolean {
|
||||||
|
return this._value.equals(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
greaterThan(other: ContributionAmount): boolean {
|
||||||
|
return this._value.greaterThan(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
lessThan(other: ContributionAmount): boolean {
|
||||||
|
return this._value.lessThan(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(decimalPlaces: number = 10): string {
|
||||||
|
return this._value.toFixed(decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
toNumber(): number {
|
||||||
|
return this._value.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
static zero(): ContributionAmount {
|
||||||
|
return new ContributionAmount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: Decimal | string | number): ContributionAmount {
|
||||||
|
return new ContributionAmount(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配比例值对象
|
||||||
|
* 表示贡献值分配的百分比
|
||||||
|
*/
|
||||||
|
export class DistributionRate {
|
||||||
|
private readonly _value: Decimal;
|
||||||
|
|
||||||
|
// 预定义的分配比例
|
||||||
|
static readonly PERSONAL = new DistributionRate(0.70); // 70% 个人
|
||||||
|
static readonly OPERATION = new DistributionRate(0.12); // 12% 运营
|
||||||
|
static readonly PROVINCE = new DistributionRate(0.01); // 1% 省公司
|
||||||
|
static readonly CITY = new DistributionRate(0.02); // 2% 市公司
|
||||||
|
static readonly TEAM_TOTAL = new DistributionRate(0.15); // 15% 团队
|
||||||
|
static readonly LEVEL_PER = new DistributionRate(0.005); // 0.5% 每级
|
||||||
|
static readonly BONUS_PER = new DistributionRate(0.025); // 2.5% 每档
|
||||||
|
|
||||||
|
constructor(value: Decimal | string | number) {
|
||||||
|
this._value = new Decimal(value);
|
||||||
|
if (this._value.isNaN()) {
|
||||||
|
throw new Error('DistributionRate must be a valid number');
|
||||||
|
}
|
||||||
|
if (this._value.isNegative()) {
|
||||||
|
throw new Error('DistributionRate cannot be negative');
|
||||||
|
}
|
||||||
|
if (this._value.greaterThan(1)) {
|
||||||
|
throw new Error('DistributionRate cannot exceed 1 (100%)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): Decimal {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get asPercentage(): number {
|
||||||
|
return this._value.times(100).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
multiply(amount: Decimal | string | number): Decimal {
|
||||||
|
return this._value.times(new Decimal(amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: DistributionRate): boolean {
|
||||||
|
return this._value.equals(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this.asPercentage}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: Decimal | string | number): DistributionRate {
|
||||||
|
return new DistributionRate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPercentage(percentage: number): DistributionRate {
|
||||||
|
return new DistributionRate(percentage / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './account-sequence.vo';
|
||||||
|
export * from './contribution-amount.vo';
|
||||||
|
export * from './distribution-rate.vo';
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from './persistence/prisma/prisma.module';
|
||||||
|
import { UnitOfWork } from './persistence/unit-of-work/unit-of-work';
|
||||||
|
import {
|
||||||
|
ContributionAccountRepository,
|
||||||
|
ContributionRecordRepository,
|
||||||
|
SyncedDataRepository,
|
||||||
|
UnallocatedContributionRepository,
|
||||||
|
SystemAccountRepository,
|
||||||
|
OutboxRepository,
|
||||||
|
} from './persistence/repositories';
|
||||||
|
import { KafkaModule } from './kafka/kafka.module';
|
||||||
|
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
|
import { CDCConsumerService } from './kafka/cdc-consumer.service';
|
||||||
|
import { RedisModule } from './redis/redis.module';
|
||||||
|
|
||||||
|
// Repository injection tokens
|
||||||
|
export const CONTRIBUTION_ACCOUNT_REPOSITORY = 'CONTRIBUTION_ACCOUNT_REPOSITORY';
|
||||||
|
export const CONTRIBUTION_RECORD_REPOSITORY = 'CONTRIBUTION_RECORD_REPOSITORY';
|
||||||
|
export const SYNCED_DATA_REPOSITORY = 'SYNCED_DATA_REPOSITORY';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, KafkaModule, RedisModule],
|
||||||
|
providers: [
|
||||||
|
UnitOfWork,
|
||||||
|
// Repositories
|
||||||
|
ContributionAccountRepository,
|
||||||
|
ContributionRecordRepository,
|
||||||
|
SyncedDataRepository,
|
||||||
|
UnallocatedContributionRepository,
|
||||||
|
SystemAccountRepository,
|
||||||
|
OutboxRepository,
|
||||||
|
// Repository interface bindings
|
||||||
|
{
|
||||||
|
provide: CONTRIBUTION_ACCOUNT_REPOSITORY,
|
||||||
|
useClass: ContributionAccountRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CONTRIBUTION_RECORD_REPOSITORY,
|
||||||
|
useClass: ContributionRecordRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SYNCED_DATA_REPOSITORY,
|
||||||
|
useClass: SyncedDataRepository,
|
||||||
|
},
|
||||||
|
// Kafka
|
||||||
|
KafkaProducerService,
|
||||||
|
CDCConsumerService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
UnitOfWork,
|
||||||
|
ContributionAccountRepository,
|
||||||
|
ContributionRecordRepository,
|
||||||
|
SyncedDataRepository,
|
||||||
|
UnallocatedContributionRepository,
|
||||||
|
SystemAccountRepository,
|
||||||
|
OutboxRepository,
|
||||||
|
CONTRIBUTION_ACCOUNT_REPOSITORY,
|
||||||
|
CONTRIBUTION_RECORD_REPOSITORY,
|
||||||
|
SYNCED_DATA_REPOSITORY,
|
||||||
|
KafkaProducerService,
|
||||||
|
CDCConsumerService,
|
||||||
|
RedisModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class InfrastructureModule {}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
|
export interface CDCEvent {
|
||||||
|
schema: any;
|
||||||
|
payload: {
|
||||||
|
before: any | null;
|
||||||
|
after: any | null;
|
||||||
|
source: {
|
||||||
|
version: string;
|
||||||
|
connector: string;
|
||||||
|
name: string;
|
||||||
|
ts_ms: number;
|
||||||
|
snapshot: string;
|
||||||
|
db: string;
|
||||||
|
sequence: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
txId: number;
|
||||||
|
lsn: number;
|
||||||
|
xmin: number | null;
|
||||||
|
};
|
||||||
|
op: 'c' | 'u' | 'd' | 'r'; // create, update, delete, read (snapshot)
|
||||||
|
ts_ms: number;
|
||||||
|
transaction: any;
|
||||||
|
};
|
||||||
|
// 内部使用:Kafka offset 作为序列号
|
||||||
|
sequenceNum: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CDCHandler = (event: CDCEvent) => Promise<void>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CDCConsumerService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(CDCConsumerService.name);
|
||||||
|
private kafka: Kafka;
|
||||||
|
private consumer: Consumer;
|
||||||
|
private handlers: Map<string, CDCHandler> = new Map();
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const brokers = this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(',');
|
||||||
|
|
||||||
|
this.kafka = new Kafka({
|
||||||
|
clientId: 'contribution-service-cdc',
|
||||||
|
brokers,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer = this.kafka.consumer({
|
||||||
|
groupId: 'contribution-service-cdc-group',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 不在这里启动,等待注册处理器后再启动
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 CDC 事件处理器
|
||||||
|
* @param tableName 表名(如 "users", "adoptions", "referrals")
|
||||||
|
* @param handler 处理函数
|
||||||
|
*/
|
||||||
|
registerHandler(tableName: string, handler: CDCHandler): void {
|
||||||
|
this.handlers.set(tableName, handler);
|
||||||
|
this.logger.log(`Registered CDC handler for table: ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动消费者
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.logger.warn('CDC consumer is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.consumer.connect();
|
||||||
|
this.logger.log('CDC consumer connected');
|
||||||
|
|
||||||
|
// 订阅 Debezium CDC topics
|
||||||
|
const topics = [
|
||||||
|
// 用户表
|
||||||
|
this.configService.get<string>('CDC_TOPIC_USERS', 'dbserver1.public.users'),
|
||||||
|
// 认种表
|
||||||
|
this.configService.get<string>('CDC_TOPIC_ADOPTIONS', 'dbserver1.public.adoptions'),
|
||||||
|
// 引荐表
|
||||||
|
this.configService.get<string>('CDC_TOPIC_REFERRALS', 'dbserver1.public.referrals'),
|
||||||
|
];
|
||||||
|
|
||||||
|
await this.consumer.subscribe({
|
||||||
|
topics,
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
this.logger.log(`Subscribed to topics: ${topics.join(', ')}`);
|
||||||
|
|
||||||
|
await this.consumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
await this.handleMessage(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.logger.log('CDC consumer started');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to start CDC consumer', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止消费者
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.consumer.disconnect();
|
||||||
|
this.isRunning = false;
|
||||||
|
this.logger.log('CDC consumer stopped');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to stop CDC consumer', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||||||
|
const { topic, partition, message } = payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!message.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = JSON.parse(message.value.toString());
|
||||||
|
const event: CDCEvent = {
|
||||||
|
...eventData,
|
||||||
|
sequenceNum: BigInt(message.offset),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 topic 名称提取表名
|
||||||
|
// 格式通常是: dbserver1.schema.tablename
|
||||||
|
const parts = topic.split('.');
|
||||||
|
const tableName = parts[parts.length - 1];
|
||||||
|
|
||||||
|
const handler = this.handlers.get(tableName);
|
||||||
|
if (handler) {
|
||||||
|
await handler(event);
|
||||||
|
this.logger.debug(`Processed CDC event for table ${tableName}, op: ${event.payload.op}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`No handler registered for table: ${tableName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error processing CDC message from topic ${topic}, partition ${partition}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// 根据业务需求决定是否重试或记录到死信队列
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Injectable, Inject, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
|
import { ClientKafka } from '@nestjs/microservices';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface KafkaMessage {
|
||||||
|
key?: string;
|
||||||
|
value: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KafkaProducerService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(KafkaProducerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('KAFKA_CLIENT') private readonly kafkaClient: ClientKafka,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.kafkaClient.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(topic: string, message: KafkaMessage): Promise<void> {
|
||||||
|
try {
|
||||||
|
await lastValueFrom(
|
||||||
|
this.kafkaClient.emit(topic, {
|
||||||
|
key: message.key,
|
||||||
|
value: JSON.stringify(message.value),
|
||||||
|
headers: message.headers,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.debug(`Message emitted to topic ${topic}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to emit message to topic ${topic}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async emitBatch(topic: string, messages: KafkaMessage[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
for (const message of messages) {
|
||||||
|
await this.emit(topic, message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to emit batch messages to topic ${topic}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ClientsModule.registerAsync([
|
||||||
|
{
|
||||||
|
name: 'KAFKA_CLIENT',
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
transport: Transport.KAFKA,
|
||||||
|
options: {
|
||||||
|
client: {
|
||||||
|
clientId: 'contribution-service',
|
||||||
|
brokers: configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
|
||||||
|
},
|
||||||
|
producer: {
|
||||||
|
allowAutoTopicCreation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
exports: [ClientsModule],
|
||||||
|
})
|
||||||
|
export class KafkaModule {}
|
||||||
|
|
@ -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,44 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理数据库连接(用于测试)
|
||||||
|
*/
|
||||||
|
async cleanDatabase() {
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
throw new Error('cleanDatabase is only available in test environment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tablenames = await this.$queryRaw<
|
||||||
|
Array<{ tablename: string }>
|
||||||
|
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
|
||||||
|
|
||||||
|
const tables = tablenames
|
||||||
|
.map(({ tablename }) => tablename)
|
||||||
|
.filter((name) => name !== '_prisma_migrations')
|
||||||
|
.map((name) => `"public"."${name}"`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
if (tables.length > 0) {
|
||||||
|
await this.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Decimal } from 'decimal.js';
|
||||||
|
import { IContributionAccountRepository } from '../../../domain/repositories/contribution-account.repository.interface';
|
||||||
|
import { ContributionAccountAggregate, ContributionSourceType } from '../../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
import { ContributionAmount } from '../../../domain/value-objects/contribution-amount.vo';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContributionAccountRepository implements IContributionAccountRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequence(accountSequence: string): Promise<ContributionAccountAggregate | null> {
|
||||||
|
const record = await this.client.contributionAccount.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequenceForUpdate(
|
||||||
|
accountSequence: string,
|
||||||
|
tx?: any,
|
||||||
|
): Promise<ContributionAccountAggregate | null> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
// In Prisma, we use a raw query with FOR UPDATE or rely on transaction isolation
|
||||||
|
// For now, we'll use a regular findUnique within the transaction context
|
||||||
|
const record = await client.contributionAccount.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequences(accountSequences: string[]): Promise<Map<string, ContributionAccountAggregate>> {
|
||||||
|
const records = await this.client.contributionAccount.findMany({
|
||||||
|
where: { accountSequence: { in: accountSequences } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new Map<string, ContributionAccountAggregate>();
|
||||||
|
for (const record of records) {
|
||||||
|
result.set(record.accountSequence, this.toDomain(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(aggregate: ContributionAccountAggregate, tx?: any): Promise<ContributionAccountAggregate> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
const data = this.toPersistence(aggregate);
|
||||||
|
|
||||||
|
const result = await client.contributionAccount.upsert({
|
||||||
|
where: { accountSequence: aggregate.accountSequence },
|
||||||
|
create: data,
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMany(aggregates: ContributionAccountAggregate[], tx?: any): Promise<void> {
|
||||||
|
for (const aggregate of aggregates) {
|
||||||
|
await this.save(aggregate, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContribution(
|
||||||
|
accountSequence: string,
|
||||||
|
sourceType: ContributionSourceType,
|
||||||
|
amount: ContributionAmount,
|
||||||
|
): Promise<void> {
|
||||||
|
const fieldMap: Record<ContributionSourceType, string> = {
|
||||||
|
[ContributionSourceType.PERSONAL]: 'personalContribution',
|
||||||
|
[ContributionSourceType.TEAM_LEVEL]: 'teamLevelContribution',
|
||||||
|
[ContributionSourceType.TEAM_BONUS]: 'teamBonusContribution',
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = fieldMap[sourceType];
|
||||||
|
|
||||||
|
await this.client.contributionAccount.update({
|
||||||
|
where: { accountSequence },
|
||||||
|
data: {
|
||||||
|
[field]: { increment: amount.value },
|
||||||
|
totalContribution: { increment: amount.value },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllWithPagination(page: number, pageSize: number): Promise<{
|
||||||
|
data: ContributionAccountAggregate[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.client.contributionAccount.findMany({
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { totalContribution: 'desc' },
|
||||||
|
}),
|
||||||
|
this.client.contributionAccount.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMany(options: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: 'totalContribution' | 'effectiveContribution';
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}): Promise<{
|
||||||
|
items: ContributionAccountAggregate[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const page = options.page ?? 1;
|
||||||
|
const limit = options.limit ?? 50;
|
||||||
|
const orderBy = options.orderBy ?? 'totalContribution';
|
||||||
|
const order = options.order ?? 'desc';
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.client.contributionAccount.findMany({
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { [orderBy]: order },
|
||||||
|
}),
|
||||||
|
this.client.contributionAccount.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllWithEffectiveContribution(): Promise<ContributionAccountAggregate[]> {
|
||||||
|
const records = await this.client.contributionAccount.findMany({
|
||||||
|
where: { effectiveContribution: { gt: 0 } },
|
||||||
|
orderBy: { effectiveContribution: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetworkTotalEffectiveContribution(): Promise<string> {
|
||||||
|
const result = await this.client.contributionAccount.aggregate({
|
||||||
|
_sum: { effectiveContribution: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (result._sum.effectiveContribution || new Decimal(0)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTopContributors(limit: number): Promise<ContributionAccountAggregate[]> {
|
||||||
|
const records = await this.client.contributionAccount.findMany({
|
||||||
|
where: { totalContribution: { gt: 0 } },
|
||||||
|
orderBy: { totalContribution: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalContribution(): Promise<ContributionAmount> {
|
||||||
|
const result = await this.client.contributionAccount.aggregate({
|
||||||
|
_sum: { totalContribution: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ContributionAmount(result._sum.totalContribution || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAccounts(): Promise<number> {
|
||||||
|
return this.client.contributionAccount.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAccountsWithContribution(): Promise<number> {
|
||||||
|
return this.client.contributionAccount.count({
|
||||||
|
where: { totalContribution: { gt: 0 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): ContributionAccountAggregate {
|
||||||
|
return ContributionAccountAggregate.fromPersistence({
|
||||||
|
id: record.id,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
personalContribution: record.personalContribution,
|
||||||
|
teamLevelContribution: record.teamLevelContribution,
|
||||||
|
teamBonusContribution: record.teamBonusContribution,
|
||||||
|
totalContribution: record.totalContribution,
|
||||||
|
effectiveContribution: record.effectiveContribution,
|
||||||
|
hasAdopted: record.hasAdopted,
|
||||||
|
directReferralAdoptedCount: record.directReferralAdoptedCount,
|
||||||
|
unlockedLevelDepth: record.unlockedLevelDepth,
|
||||||
|
unlockedBonusTiers: record.unlockedBonusTiers,
|
||||||
|
version: record.version,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPersistence(aggregate: ContributionAccountAggregate): any {
|
||||||
|
return aggregate.toPersistence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { IContributionRecordRepository } from '../../../domain/repositories/contribution-record.repository.interface';
|
||||||
|
import { ContributionRecordAggregate } from '../../../domain/aggregates/contribution-record.aggregate';
|
||||||
|
import { ContributionSourceType } from '../../../domain/aggregates/contribution-account.aggregate';
|
||||||
|
import { ContributionAmount } from '../../../domain/value-objects/contribution-amount.vo';
|
||||||
|
import { DistributionRate } from '../../../domain/value-objects/distribution-rate.vo';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContributionRecordRepository implements IContributionRecordRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: bigint): Promise<ContributionRecordAggregate | null> {
|
||||||
|
const record = await this.client.contributionRecord.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
options?: {
|
||||||
|
sourceType?: ContributionSourceType;
|
||||||
|
includeExpired?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ items: ContributionRecordAggregate[]; total: number }> {
|
||||||
|
const where: any = { accountSequence };
|
||||||
|
|
||||||
|
if (options?.sourceType) {
|
||||||
|
where.sourceType = options.sourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.includeExpired) {
|
||||||
|
where.isExpired = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = options?.page ?? 1;
|
||||||
|
const limit = options?.limit ?? 50;
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.client.contributionRecord.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
this.client.contributionRecord.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySourceAdoptionId(sourceAdoptionId: bigint): Promise<ContributionRecordAggregate[]> {
|
||||||
|
const records = await this.client.contributionRecord.findMany({
|
||||||
|
where: { sourceAdoptionId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsBySourceAdoptionId(sourceAdoptionId: bigint): Promise<boolean> {
|
||||||
|
const count = await this.client.contributionRecord.count({
|
||||||
|
where: { sourceAdoptionId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(aggregate: ContributionRecordAggregate, tx?: any): Promise<ContributionRecordAggregate> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
const data = aggregate.toPersistence();
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (aggregate.id) {
|
||||||
|
result = await client.contributionRecord.update({
|
||||||
|
where: { id: aggregate.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await client.contributionRecord.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toDomain(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMany(aggregates: ContributionRecordAggregate[], tx?: any): Promise<void> {
|
||||||
|
if (aggregates.length === 0) return;
|
||||||
|
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
// 使用事务批量插入
|
||||||
|
const createData = aggregates.map((a) => a.toPersistence());
|
||||||
|
|
||||||
|
await client.contributionRecord.createMany({
|
||||||
|
data: createData,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExpiring(beforeDate: Date, limit?: number): Promise<ContributionRecordAggregate[]> {
|
||||||
|
const records = await this.client.contributionRecord.findMany({
|
||||||
|
where: {
|
||||||
|
expireDate: { lt: beforeDate },
|
||||||
|
isExpired: false,
|
||||||
|
},
|
||||||
|
take: limit ?? 1000,
|
||||||
|
orderBy: { expireDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExpiredRecords(beforeDate: Date, limit: number): Promise<ContributionRecordAggregate[]> {
|
||||||
|
return this.findExpiring(beforeDate, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markExpiredBatch(ids: bigint[], tx?: any): Promise<number> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
const result = await client.contributionRecord.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { isExpired: true, expiredAt: new Date() },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsExpired(ids: bigint[]): Promise<void> {
|
||||||
|
await this.markExpiredBatch(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectiveContributionSum(accountSequence: string): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await this.client.contributionRecord.aggregate({
|
||||||
|
where: {
|
||||||
|
accountSequence,
|
||||||
|
isExpired: false,
|
||||||
|
effectiveDate: { lte: now },
|
||||||
|
expireDate: { gt: now },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (result._sum.amount || 0).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveContributionByAccount(accountSequence: string): Promise<{
|
||||||
|
personal: ContributionAmount;
|
||||||
|
teamLevel: ContributionAmount;
|
||||||
|
teamBonus: ContributionAmount;
|
||||||
|
total: ContributionAmount;
|
||||||
|
}> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await this.client.contributionRecord.groupBy({
|
||||||
|
by: ['sourceType'],
|
||||||
|
where: {
|
||||||
|
accountSequence,
|
||||||
|
isExpired: false,
|
||||||
|
effectiveDate: { lte: now },
|
||||||
|
expireDate: { gt: now },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let personal = ContributionAmount.zero();
|
||||||
|
let teamLevel = ContributionAmount.zero();
|
||||||
|
let teamBonus = ContributionAmount.zero();
|
||||||
|
|
||||||
|
for (const item of result) {
|
||||||
|
const amount = new ContributionAmount(item._sum.amount || 0);
|
||||||
|
switch (item.sourceType) {
|
||||||
|
case 'PERSONAL':
|
||||||
|
personal = amount;
|
||||||
|
break;
|
||||||
|
case 'TEAM_LEVEL':
|
||||||
|
teamLevel = teamLevel.add(amount);
|
||||||
|
break;
|
||||||
|
case 'TEAM_BONUS':
|
||||||
|
teamBonus = teamBonus.add(amount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
personal,
|
||||||
|
teamLevel,
|
||||||
|
teamBonus,
|
||||||
|
total: personal.add(teamLevel).add(teamBonus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContributionSummaryBySourceType(): Promise<Map<ContributionSourceType, ContributionAmount>> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await this.client.contributionRecord.groupBy({
|
||||||
|
by: ['sourceType'],
|
||||||
|
where: {
|
||||||
|
isExpired: false,
|
||||||
|
effectiveDate: { lte: now },
|
||||||
|
expireDate: { gt: now },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = new Map<ContributionSourceType, ContributionAmount>();
|
||||||
|
|
||||||
|
for (const item of result) {
|
||||||
|
summary.set(
|
||||||
|
item.sourceType as ContributionSourceType,
|
||||||
|
new ContributionAmount(item._sum.amount || 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): ContributionRecordAggregate {
|
||||||
|
return ContributionRecordAggregate.fromPersistence({
|
||||||
|
id: record.id,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
sourceType: record.sourceType,
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
treeCount: record.treeCount,
|
||||||
|
baseContribution: record.baseContribution,
|
||||||
|
distributionRate: record.distributionRate,
|
||||||
|
levelDepth: record.levelDepth,
|
||||||
|
bonusTier: record.bonusTier,
|
||||||
|
amount: record.amount,
|
||||||
|
effectiveDate: record.effectiveDate,
|
||||||
|
expireDate: record.expireDate,
|
||||||
|
isExpired: record.isExpired,
|
||||||
|
expiredAt: record.expiredAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './contribution-account.repository';
|
||||||
|
export * from './contribution-record.repository';
|
||||||
|
export * from './synced-data.repository';
|
||||||
|
export * from './unallocated-contribution.repository';
|
||||||
|
export * from './system-account.repository';
|
||||||
|
export * from './outbox.repository';
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
export interface OutboxEvent {
|
||||||
|
id: bigint;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
topic: string;
|
||||||
|
key: string;
|
||||||
|
payload: any;
|
||||||
|
status: string;
|
||||||
|
retryCount: number;
|
||||||
|
maxRetries: number;
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
publishedAt: Date | null;
|
||||||
|
nextRetryAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbox Pattern 仓库
|
||||||
|
* 用于保证事件发布的可靠性
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(event: {
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: any;
|
||||||
|
topic?: string;
|
||||||
|
key?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const topic = event.topic ?? `contribution.${event.eventType.toLowerCase()}`;
|
||||||
|
const key = event.key ?? event.aggregateId;
|
||||||
|
|
||||||
|
await this.client.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: event.aggregateType,
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
eventType: event.eventType,
|
||||||
|
topic,
|
||||||
|
key,
|
||||||
|
payload: event.payload,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMany(events: {
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: any;
|
||||||
|
topic?: string;
|
||||||
|
key?: string;
|
||||||
|
}[]): Promise<void> {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
|
||||||
|
await this.client.outboxEvent.createMany({
|
||||||
|
data: events.map((e) => ({
|
||||||
|
aggregateType: e.aggregateType,
|
||||||
|
aggregateId: e.aggregateId,
|
||||||
|
eventType: e.eventType,
|
||||||
|
topic: e.topic ?? `contribution.${e.eventType.toLowerCase()}`,
|
||||||
|
key: e.key ?? e.aggregateId,
|
||||||
|
payload: e.payload,
|
||||||
|
status: 'PENDING',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUnprocessed(limit: number): Promise<OutboxEvent[]> {
|
||||||
|
const records = await this.client.outboxEvent.findMany({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsProcessed(ids: bigint[]): Promise<void> {
|
||||||
|
await this.client.outboxEvent.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { status: 'PUBLISHED', publishedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsFailed(id: bigint, error: string): Promise<void> {
|
||||||
|
const event = await this.client.outboxEvent.findUnique({ where: { id } });
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const retryCount = event.retryCount + 1;
|
||||||
|
const shouldRetry = retryCount < event.maxRetries;
|
||||||
|
|
||||||
|
await this.client.outboxEvent.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: shouldRetry ? 'PENDING' : 'FAILED',
|
||||||
|
retryCount,
|
||||||
|
lastError: error,
|
||||||
|
nextRetryAt: shouldRetry
|
||||||
|
? new Date(Date.now() + Math.pow(2, retryCount) * 1000) // exponential backoff
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProcessed(beforeDate: Date): Promise<number> {
|
||||||
|
const result = await this.client.outboxEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lt: beforeDate },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): OutboxEvent {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
aggregateType: record.aggregateType,
|
||||||
|
aggregateId: record.aggregateId,
|
||||||
|
eventType: record.eventType,
|
||||||
|
topic: record.topic,
|
||||||
|
key: record.key,
|
||||||
|
payload: record.payload,
|
||||||
|
status: record.status,
|
||||||
|
retryCount: record.retryCount,
|
||||||
|
maxRetries: record.maxRetries,
|
||||||
|
lastError: record.lastError,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
publishedAt: record.publishedAt,
|
||||||
|
nextRetryAt: record.nextRetryAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import {
|
||||||
|
ISyncedDataRepository,
|
||||||
|
SyncedUser,
|
||||||
|
SyncedAdoption,
|
||||||
|
SyncedReferral,
|
||||||
|
} from '../../../domain/repositories/synced-data.repository.interface';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SyncedDataRepository implements ISyncedDataRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== User 操作 ==========
|
||||||
|
|
||||||
|
async upsertSyncedUser(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
originalUserId: bigint;
|
||||||
|
phone?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedUser> {
|
||||||
|
const record = await this.client.syncedUser.upsert({
|
||||||
|
where: { accountSequence: data.accountSequence },
|
||||||
|
create: {
|
||||||
|
originalUserId: data.originalUserId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
phone: data.phone ?? null,
|
||||||
|
status: data.status ?? null,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
phone: data.phone ?? undefined,
|
||||||
|
status: data.status ?? undefined,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toSyncedUser(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSyncedUserByAccountSequence(accountSequence: string): Promise<SyncedUser | null> {
|
||||||
|
const record = await this.client.syncedUser.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toSyncedUser(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUncalculatedUsers(limit: number = 100): Promise<SyncedUser[]> {
|
||||||
|
const records = await this.client.syncedUser.findMany({
|
||||||
|
where: { contributionCalculated: false },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toSyncedUser(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markUserContributionCalculated(accountSequence: string, tx?: any): Promise<void> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
await client.syncedUser.update({
|
||||||
|
where: { accountSequence },
|
||||||
|
data: {
|
||||||
|
contributionCalculated: true,
|
||||||
|
contributionCalculatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Adoption 操作 ==========
|
||||||
|
|
||||||
|
async upsertSyncedAdoption(data: {
|
||||||
|
originalAdoptionId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
adoptionDate: Date;
|
||||||
|
status?: string | null;
|
||||||
|
contributionPerTree: Decimal;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedAdoption> {
|
||||||
|
const record = await this.client.syncedAdoption.upsert({
|
||||||
|
where: { originalAdoptionId: data.originalAdoptionId },
|
||||||
|
create: {
|
||||||
|
originalAdoptionId: data.originalAdoptionId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
treeCount: data.treeCount,
|
||||||
|
adoptionDate: data.adoptionDate,
|
||||||
|
status: data.status ?? null,
|
||||||
|
contributionPerTree: data.contributionPerTree,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
treeCount: data.treeCount,
|
||||||
|
adoptionDate: data.adoptionDate,
|
||||||
|
status: data.status ?? undefined,
|
||||||
|
contributionPerTree: data.contributionPerTree,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toSyncedAdoption(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSyncedAdoptionByOriginalId(originalAdoptionId: bigint): Promise<SyncedAdoption | null> {
|
||||||
|
const record = await this.client.syncedAdoption.findUnique({
|
||||||
|
where: { originalAdoptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toSyncedAdoption(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUndistributedAdoptions(limit: number = 100): Promise<SyncedAdoption[]> {
|
||||||
|
const records = await this.client.syncedAdoption.findMany({
|
||||||
|
where: { contributionDistributed: false },
|
||||||
|
orderBy: { adoptionDate: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toSyncedAdoption(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAdoptionsByAccountSequence(accountSequence: string): Promise<SyncedAdoption[]> {
|
||||||
|
const records = await this.client.syncedAdoption.findMany({
|
||||||
|
where: { accountSequence },
|
||||||
|
orderBy: { adoptionDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toSyncedAdoption(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAdoptionContributionDistributed(originalAdoptionId: bigint, tx?: any): Promise<void> {
|
||||||
|
const client = tx ?? this.client;
|
||||||
|
await client.syncedAdoption.update({
|
||||||
|
where: { originalAdoptionId },
|
||||||
|
data: {
|
||||||
|
contributionDistributed: true,
|
||||||
|
contributionDistributedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalTreesByAccountSequence(accountSequence: string): Promise<number> {
|
||||||
|
const result = await this.client.syncedAdoption.aggregate({
|
||||||
|
where: { accountSequence },
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
});
|
||||||
|
return result._sum.treeCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Referral 操作 ==========
|
||||||
|
|
||||||
|
async upsertSyncedReferral(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
referrerAccountSequence?: string | null;
|
||||||
|
ancestorPath?: string | null;
|
||||||
|
depth?: number;
|
||||||
|
sourceSequenceNum: bigint;
|
||||||
|
}): Promise<SyncedReferral> {
|
||||||
|
const record = await this.client.syncedReferral.upsert({
|
||||||
|
where: { accountSequence: data.accountSequence },
|
||||||
|
create: {
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
referrerAccountSequence: data.referrerAccountSequence ?? null,
|
||||||
|
ancestorPath: data.ancestorPath ?? null,
|
||||||
|
depth: data.depth ?? 0,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
referrerAccountSequence: data.referrerAccountSequence ?? undefined,
|
||||||
|
ancestorPath: data.ancestorPath ?? undefined,
|
||||||
|
depth: data.depth ?? undefined,
|
||||||
|
sourceSequenceNum: data.sourceSequenceNum,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toSyncedReferral(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSyncedReferralByAccountSequence(accountSequence: string): Promise<SyncedReferral | null> {
|
||||||
|
const record = await this.client.syncedReferral.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toSyncedReferral(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDirectReferrals(referrerAccountSequence: string): Promise<SyncedReferral[]> {
|
||||||
|
const records = await this.client.syncedReferral.findMany({
|
||||||
|
where: { referrerAccountSequence },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toSyncedReferral(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAncestorChain(accountSequence: string, maxLevel: number = 15): Promise<SyncedReferral[]> {
|
||||||
|
const ancestors: SyncedReferral[] = [];
|
||||||
|
let currentSequence = accountSequence;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLevel; i++) {
|
||||||
|
const referral = await this.findSyncedReferralByAccountSequence(currentSequence);
|
||||||
|
if (!referral || !referral.referrerAccountSequence) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrer = await this.findSyncedReferralByAccountSequence(referral.referrerAccountSequence);
|
||||||
|
if (!referrer) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestors.push(referrer);
|
||||||
|
currentSequence = referrer.accountSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDirectReferralAdoptedCount(referrerAccountSequence: string): Promise<number> {
|
||||||
|
const directReferrals = await this.client.syncedReferral.findMany({
|
||||||
|
where: { referrerAccountSequence },
|
||||||
|
select: { accountSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directReferrals.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSequences = directReferrals.map((r) => r.accountSequence);
|
||||||
|
|
||||||
|
const adoptedCount = await this.client.syncedAdoption.findMany({
|
||||||
|
where: { accountSequence: { in: accountSequences } },
|
||||||
|
distinct: ['accountSequence'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return adoptedCount.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamTreesByLevel(
|
||||||
|
accountSequence: string,
|
||||||
|
maxLevel: number = 15,
|
||||||
|
): Promise<Map<number, number>> {
|
||||||
|
const result = new Map<number, number>();
|
||||||
|
|
||||||
|
for (let level = 1; level <= maxLevel; level++) {
|
||||||
|
result.set(level, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const processLevel = async (sequences: string[], currentLevel: number): Promise<void> => {
|
||||||
|
if (currentLevel > maxLevel || sequences.length === 0) return;
|
||||||
|
|
||||||
|
const adoptions = await this.client.syncedAdoption.groupBy({
|
||||||
|
by: ['accountSequence'],
|
||||||
|
where: { accountSequence: { in: sequences } },
|
||||||
|
_sum: { treeCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let levelTrees = 0;
|
||||||
|
for (const adoption of adoptions) {
|
||||||
|
levelTrees += adoption._sum.treeCount ?? 0;
|
||||||
|
}
|
||||||
|
result.set(currentLevel, levelTrees);
|
||||||
|
|
||||||
|
const nextLevelReferrals = await this.client.syncedReferral.findMany({
|
||||||
|
where: { referrerAccountSequence: { in: sequences } },
|
||||||
|
select: { accountSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextLevelReferrals.length > 0) {
|
||||||
|
await processLevel(
|
||||||
|
nextLevelReferrals.map((r) => r.accountSequence),
|
||||||
|
currentLevel + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const directReferrals = await this.client.syncedReferral.findMany({
|
||||||
|
where: { referrerAccountSequence: accountSequence },
|
||||||
|
select: { accountSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directReferrals.length > 0) {
|
||||||
|
await processLevel(
|
||||||
|
directReferrals.map((r) => r.accountSequence),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 统计方法(用于查询服务)==========
|
||||||
|
|
||||||
|
async countUsers(): Promise<number> {
|
||||||
|
return this.client.syncedUser.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAdoptions(): Promise<number> {
|
||||||
|
return this.client.syncedAdoption.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUndistributedAdoptions(): Promise<number> {
|
||||||
|
return this.client.syncedAdoption.count({
|
||||||
|
where: { contributionDistributed: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
private toSyncedUser(record: any): SyncedUser {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
originalUserId: record.originalUserId,
|
||||||
|
phone: record.phone,
|
||||||
|
status: record.status,
|
||||||
|
sourceSequenceNum: record.sourceSequenceNum,
|
||||||
|
syncedAt: record.syncedAt,
|
||||||
|
contributionCalculated: record.contributionCalculated,
|
||||||
|
contributionCalculatedAt: record.contributionCalculatedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSyncedAdoption(record: any): SyncedAdoption {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
originalAdoptionId: record.originalAdoptionId,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
treeCount: record.treeCount,
|
||||||
|
adoptionDate: record.adoptionDate,
|
||||||
|
status: record.status,
|
||||||
|
contributionPerTree: record.contributionPerTree,
|
||||||
|
sourceSequenceNum: record.sourceSequenceNum,
|
||||||
|
syncedAt: record.syncedAt,
|
||||||
|
contributionDistributed: record.contributionDistributed,
|
||||||
|
contributionDistributedAt: record.contributionDistributedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSyncedReferral(record: any): SyncedReferral {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
referrerAccountSequence: record.referrerAccountSequence,
|
||||||
|
ancestorPath: record.ancestorPath,
|
||||||
|
depth: record.depth,
|
||||||
|
sourceSequenceNum: record.sourceSequenceNum,
|
||||||
|
syncedAt: record.syncedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ContributionAmount } from '../../../domain/value-objects/contribution-amount.vo';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
export type SystemAccountType = 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS';
|
||||||
|
|
||||||
|
export interface SystemAccount {
|
||||||
|
id: bigint;
|
||||||
|
accountType: SystemAccountType;
|
||||||
|
name: string;
|
||||||
|
contributionBalance: ContributionAmount;
|
||||||
|
contributionNeverExpires: boolean;
|
||||||
|
version: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemContributionRecord {
|
||||||
|
id: bigint;
|
||||||
|
systemAccountId: bigint;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
distributionRate: number;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date | null;
|
||||||
|
isExpired: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SystemAccountRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByType(accountType: SystemAccountType): Promise<SystemAccount | null> {
|
||||||
|
const record = await this.client.systemAccount.findUnique({
|
||||||
|
where: { accountType },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toSystemAccount(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<SystemAccount[]> {
|
||||||
|
const records = await this.client.systemAccount.findMany({
|
||||||
|
orderBy: { accountType: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toSystemAccount(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureSystemAccountsExist(): Promise<void> {
|
||||||
|
const accounts: { accountType: SystemAccountType; name: string }[] = [
|
||||||
|
{ accountType: 'OPERATION', name: '运营账户' },
|
||||||
|
{ accountType: 'PROVINCE', name: '省公司账户' },
|
||||||
|
{ accountType: 'CITY', name: '市公司账户' },
|
||||||
|
{ accountType: 'HEADQUARTERS', name: '总部账户' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
await this.client.systemAccount.upsert({
|
||||||
|
where: { accountType: account.accountType },
|
||||||
|
create: {
|
||||||
|
accountType: account.accountType,
|
||||||
|
name: account.name,
|
||||||
|
contributionBalance: 0,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addContribution(
|
||||||
|
accountType: SystemAccountType,
|
||||||
|
amount: ContributionAmount,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.client.systemAccount.update({
|
||||||
|
where: { accountType },
|
||||||
|
data: {
|
||||||
|
contributionBalance: { increment: amount.value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveContributionRecord(record: {
|
||||||
|
systemAccountType: SystemAccountType;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
distributionRate: number;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate?: Date | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const systemAccount = await this.findByType(record.systemAccountType);
|
||||||
|
if (!systemAccount) {
|
||||||
|
throw new Error(`System account ${record.systemAccountType} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.systemContributionRecord.create({
|
||||||
|
data: {
|
||||||
|
systemAccountId: systemAccount.id,
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
distributionRate: record.distributionRate,
|
||||||
|
amount: record.amount.value,
|
||||||
|
effectiveDate: record.effectiveDate,
|
||||||
|
expireDate: record.expireDate ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveContributionRecords(records: {
|
||||||
|
systemAccountType: SystemAccountType;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
distributionRate: number;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate?: Date | null;
|
||||||
|
}[]): Promise<void> {
|
||||||
|
if (records.length === 0) return;
|
||||||
|
|
||||||
|
const systemAccounts = await this.findAll();
|
||||||
|
const accountMap = new Map<SystemAccountType, bigint>();
|
||||||
|
for (const account of systemAccounts) {
|
||||||
|
accountMap.set(account.accountType, account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.systemContributionRecord.createMany({
|
||||||
|
data: records.map((r) => ({
|
||||||
|
systemAccountId: accountMap.get(r.systemAccountType)!,
|
||||||
|
sourceAdoptionId: r.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: r.sourceAccountSequence,
|
||||||
|
distributionRate: r.distributionRate,
|
||||||
|
amount: r.amount.value,
|
||||||
|
effectiveDate: r.effectiveDate,
|
||||||
|
expireDate: r.expireDate ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findContributionRecords(
|
||||||
|
systemAccountType: SystemAccountType,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{ data: SystemContributionRecord[]; total: number }> {
|
||||||
|
const systemAccount = await this.findByType(systemAccountType);
|
||||||
|
if (!systemAccount) {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.client.systemContributionRecord.findMany({
|
||||||
|
where: { systemAccountId: systemAccount.id },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
this.client.systemContributionRecord.count({
|
||||||
|
where: { systemAccountId: systemAccount.id },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toContributionRecord(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSystemAccount(record: any): SystemAccount {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
accountType: record.accountType as SystemAccountType,
|
||||||
|
name: record.name,
|
||||||
|
contributionBalance: new ContributionAmount(record.contributionBalance),
|
||||||
|
contributionNeverExpires: record.contributionNeverExpires,
|
||||||
|
version: record.version,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toContributionRecord(record: any): SystemContributionRecord {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
systemAccountId: record.systemAccountId,
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
distributionRate: record.distributionRate,
|
||||||
|
amount: new ContributionAmount(record.amount),
|
||||||
|
effectiveDate: record.effectiveDate,
|
||||||
|
expireDate: record.expireDate,
|
||||||
|
isExpired: record.isExpired,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ContributionAmount } from '../../../domain/value-objects/contribution-amount.vo';
|
||||||
|
import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work';
|
||||||
|
|
||||||
|
export interface UnallocatedContribution {
|
||||||
|
id: bigint;
|
||||||
|
unallocType: string;
|
||||||
|
wouldBeAccountSequence: string | null;
|
||||||
|
levelDepth: number | null;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
reason: string | null;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
allocatedToHeadquarters: boolean;
|
||||||
|
allocatedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UnallocatedContributionRepository {
|
||||||
|
constructor(private readonly unitOfWork: UnitOfWork) {}
|
||||||
|
|
||||||
|
private get client(): TransactionClient {
|
||||||
|
return this.unitOfWork.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(contribution: {
|
||||||
|
type: string;
|
||||||
|
wouldBeAccountSequence: string | null;
|
||||||
|
levelDepth: number | null;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
reason: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.client.unallocatedContribution.create({
|
||||||
|
data: {
|
||||||
|
unallocType: contribution.type,
|
||||||
|
wouldBeAccountSequence: contribution.wouldBeAccountSequence,
|
||||||
|
levelDepth: contribution.levelDepth,
|
||||||
|
amount: contribution.amount.value,
|
||||||
|
reason: contribution.reason,
|
||||||
|
sourceAdoptionId: contribution.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: contribution.sourceAccountSequence,
|
||||||
|
effectiveDate: contribution.effectiveDate,
|
||||||
|
expireDate: contribution.expireDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMany(contributions: {
|
||||||
|
type: string;
|
||||||
|
wouldBeAccountSequence: string | null;
|
||||||
|
levelDepth: number | null;
|
||||||
|
amount: ContributionAmount;
|
||||||
|
reason: string;
|
||||||
|
sourceAdoptionId: bigint;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
effectiveDate: Date;
|
||||||
|
expireDate: Date;
|
||||||
|
}[]): Promise<void> {
|
||||||
|
if (contributions.length === 0) return;
|
||||||
|
|
||||||
|
await this.client.unallocatedContribution.createMany({
|
||||||
|
data: contributions.map((c) => ({
|
||||||
|
unallocType: c.type,
|
||||||
|
wouldBeAccountSequence: c.wouldBeAccountSequence,
|
||||||
|
levelDepth: c.levelDepth,
|
||||||
|
amount: c.amount.value,
|
||||||
|
reason: c.reason,
|
||||||
|
sourceAdoptionId: c.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: c.sourceAccountSequence,
|
||||||
|
effectiveDate: c.effectiveDate,
|
||||||
|
expireDate: c.expireDate,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySourceAdoptionId(sourceAdoptionId: bigint): Promise<UnallocatedContribution[]> {
|
||||||
|
const records = await this.client.unallocatedContribution.findMany({
|
||||||
|
where: { sourceAdoptionId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalUnallocated(): Promise<ContributionAmount> {
|
||||||
|
const result = await this.client.unallocatedContribution.aggregate({
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ContributionAmount(result._sum.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalUnallocatedByType(): Promise<Map<string, ContributionAmount>> {
|
||||||
|
const result = await this.client.unallocatedContribution.groupBy({
|
||||||
|
by: ['unallocType'],
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = new Map<string, ContributionAmount>();
|
||||||
|
for (const item of result) {
|
||||||
|
map.set(item.unallocType, new ContributionAmount(item._sum.amount || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithPagination(page: number, pageSize: number): Promise<{
|
||||||
|
data: UnallocatedContribution[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.client.unallocatedContribution.findMany({
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
this.client.unallocatedContribution.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): UnallocatedContribution {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
unallocType: record.unallocType,
|
||||||
|
wouldBeAccountSequence: record.wouldBeAccountSequence,
|
||||||
|
levelDepth: record.levelDepth,
|
||||||
|
amount: new ContributionAmount(record.amount),
|
||||||
|
reason: record.reason,
|
||||||
|
sourceAdoptionId: record.sourceAdoptionId,
|
||||||
|
sourceAccountSequence: record.sourceAccountSequence,
|
||||||
|
effectiveDate: record.effectiveDate,
|
||||||
|
expireDate: record.expireDate,
|
||||||
|
allocatedToHeadquarters: record.allocatedToHeadquarters,
|
||||||
|
allocatedAt: record.allocatedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export type TransactionClient = Omit<
|
||||||
|
PrismaService,
|
||||||
|
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作单元模式
|
||||||
|
* 用于管理事务边界,确保多个仓库操作在同一事务中执行
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UnitOfWork {
|
||||||
|
private transactionClient: TransactionClient | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前事务客户端,如果没有活跃事务则返回普通客户端
|
||||||
|
*/
|
||||||
|
getClient(): TransactionClient {
|
||||||
|
return this.transactionClient || this.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在事务中执行操作
|
||||||
|
*/
|
||||||
|
async executeInTransaction<T>(
|
||||||
|
operation: (client: TransactionClient) => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
maxWait?: number;
|
||||||
|
timeout?: number;
|
||||||
|
isolationLevel?: Prisma.TransactionIsolationLevel;
|
||||||
|
},
|
||||||
|
): Promise<T> {
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
this.transactionClient = tx as TransactionClient;
|
||||||
|
try {
|
||||||
|
return await operation(tx as TransactionClient);
|
||||||
|
} finally {
|
||||||
|
this.transactionClient = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxWait: options?.maxWait ?? 5000,
|
||||||
|
timeout: options?.timeout ?? 10000,
|
||||||
|
isolationLevel: options?.isolationLevel ?? Prisma.TransactionIsolationLevel.ReadCommitted,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否在事务中
|
||||||
|
*/
|
||||||
|
isInTransaction(): boolean {
|
||||||
|
return this.transactionClient !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
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', 0),
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
RedisService,
|
||||||
|
],
|
||||||
|
exports: [RedisService],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
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 ?? 0,
|
||||||
|
retryStrategy: (times) => {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
this.logger.error('Redis connection 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
const result = await this.client.exists(key);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JSON 操作 ==========
|
||||||
|
|
||||||
|
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,
|
||||||
|
retryCount: number = 3,
|
||||||
|
retryDelay: number = 100,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
for (let i = 0; i < retryCount; i++) {
|
||||||
|
const result = await this.client.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX');
|
||||||
|
if (result === 'OK') {
|
||||||
|
return lockValue;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 incrBy(key: string, increment: number): Promise<number> {
|
||||||
|
return this.client.incrby(key, increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrByFloat(key: string, increment: number): Promise<string> {
|
||||||
|
return this.client.incrbyfloat(key, increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 哈希操作 ==========
|
||||||
|
|
||||||
|
async hget(key: string, field: string): Promise<string | null> {
|
||||||
|
return this.client.hget(key, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hset(key: string, field: string, value: string): Promise<void> {
|
||||||
|
await this.client.hset(key, field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hgetall(key: string): Promise<Record<string, string>> {
|
||||||
|
return this.client.hgetall(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hdel(key: string, ...fields: string[]): Promise<void> {
|
||||||
|
await this.client.hdel(key, ...fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 有序集合 ==========
|
||||||
|
|
||||||
|
async zadd(key: string, score: number, member: string): Promise<void> {
|
||||||
|
await this.client.zadd(key, score, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||||
|
return this.client.zrange(key, start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrangeWithScores(
|
||||||
|
key: string,
|
||||||
|
start: number,
|
||||||
|
stop: number,
|
||||||
|
): Promise<{ member: string; score: number }[]> {
|
||||||
|
const result = await this.client.zrange(key, start, stop, 'WITHSCORES');
|
||||||
|
const items: { member: string; score: number }[] = [];
|
||||||
|
for (let i = 0; i < result.length; i += 2) {
|
||||||
|
items.push({
|
||||||
|
member: result[i],
|
||||||
|
score: parseFloat(result[i + 1]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||||
|
return this.client.zrevrange(key, start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zscore(key: string, member: string): Promise<number | null> {
|
||||||
|
const score = await this.client.zscore(key, member);
|
||||||
|
return score ? parseFloat(score) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrank(key: string, member: string): Promise<number | null> {
|
||||||
|
return this.client.zrank(key, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrevrank(key: string, member: string): Promise<number | null> {
|
||||||
|
return this.client.zrevrank(key, member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: '*',
|
||||||
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Contribution Service API')
|
||||||
|
.setDescription('RWA贡献值算力服务API - 管理用户算力计算、分配、明细账等功能')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
// Kafka 微服务 - 用于 CDC 消费和事件处理
|
||||||
|
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
|
||||||
|
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'contribution-service-group';
|
||||||
|
|
||||||
|
app.connectMicroservice<MicroserviceOptions>({
|
||||||
|
transport: Transport.KAFKA,
|
||||||
|
options: {
|
||||||
|
client: {
|
||||||
|
clientId: 'contribution-service',
|
||||||
|
brokers: kafkaBrokers,
|
||||||
|
},
|
||||||
|
consumer: {
|
||||||
|
groupId: kafkaGroupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.startAllMicroservices();
|
||||||
|
logger.log('Kafka microservice started');
|
||||||
|
|
||||||
|
const port = process.env.APP_PORT || 3020;
|
||||||
|
await app.listen(port);
|
||||||
|
logger.log(`Contribution Service is running on port ${port}`);
|
||||||
|
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体未找到异常
|
||||||
|
*/
|
||||||
|
export class EntityNotFoundException extends DomainException {
|
||||||
|
constructor(entityName: string, id: string) {
|
||||||
|
super(`${entityName} with id ${id} not found`, 'ENTITY_NOT_FOUND', HttpStatus.NOT_FOUND);
|
||||||
|
this.name = 'EntityNotFoundException';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务规则违反异常
|
||||||
|
*/
|
||||||
|
export class BusinessRuleViolationException extends DomainException {
|
||||||
|
constructor(message: string, code: string = 'BUSINESS_RULE_VIOLATION') {
|
||||||
|
super(message, code, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||||
|
this.name = 'BusinessRuleViolationException';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发冲突异常
|
||||||
|
*/
|
||||||
|
export class ConcurrencyException extends DomainException {
|
||||||
|
constructor(message: string = 'Concurrency conflict detected') {
|
||||||
|
super(message, 'CONCURRENCY_CONFLICT', HttpStatus.CONFLICT);
|
||||||
|
this.name = 'ConcurrencyException';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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';
|
||||||
|
let details: any = null;
|
||||||
|
|
||||||
|
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 = (exceptionResponse as any).error?.toUpperCase().replace(/ /g, '_') || 'HTTP_ERROR';
|
||||||
|
details = (exceptionResponse as any).details;
|
||||||
|
} else {
|
||||||
|
message = exception.message;
|
||||||
|
code = 'HTTP_ERROR';
|
||||||
|
}
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
message = exception.message;
|
||||||
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message: Array.isArray(message) ? message : [message],
|
||||||
|
details,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
};
|
||||||
|
|
||||||
|
response.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } 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';
|
||||||
|
let error = 'Internal Server Error';
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||||
|
message = (exceptionResponse as any).message || exception.message;
|
||||||
|
error = (exceptionResponse as any).error || 'Error';
|
||||||
|
} else {
|
||||||
|
message = exception.message;
|
||||||
|
}
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
message = exception.message;
|
||||||
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
error,
|
||||||
|
message: Array.isArray(message) ? message : [message],
|
||||||
|
};
|
||||||
|
|
||||||
|
response.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 JwtPayload;
|
||||||
|
|
||||||
|
if (payload.type !== 'access') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息附加到请求对象
|
||||||
|
request.user = {
|
||||||
|
userId: payload.sub,
|
||||||
|
accountSequence: payload.accountSequence,
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
throw new UnauthorizedException('Token expired');
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('Authentication failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,35 @@
|
||||||
|
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, body, query } = 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,27 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
||||||
|
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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue