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:
hailin 2026-01-10 17:39:25 -08:00
parent d9f9ae5122
commit eaead7d4f3
67 changed files with 17069 additions and 0 deletions

34
.gitignore vendored
View File

@ -2,3 +2,37 @@ nul
# Claude Code settings
.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

View File

@ -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

View File

@ -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
```

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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"
}
}
}

View File

@ -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])
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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 };
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>;
}

View File

@ -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;
}

View File

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

View File

@ -0,0 +1,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 {}

View File

@ -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.`);
}
}

View File

@ -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);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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,
};
}
}

View File

@ -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);
}
}

View File

@ -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()]),
),
};
}
}

View File

@ -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);
}
}
}

View File

@ -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}`,
);
}
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -0,0 +1,2 @@
export * from './contribution-account.aggregate';
export * from './contribution-record.aggregate';

View File

@ -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(),
};
}
}

View File

@ -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(),
};
}
}

View File

@ -0,0 +1,2 @@
export * from './contribution-calculated.event';
export * from './daily-snapshot-created.event';

View File

@ -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');

View File

@ -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');

View File

@ -0,0 +1,3 @@
export * from './contribution-account.repository.interface';
export * from './contribution-record.repository.interface';
export * from './synced-data.repository.interface';

View File

@ -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');

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,3 @@
export * from './account-sequence.vo';
export * from './contribution-amount.vo';
export * from './distribution-rate.vo';

View File

@ -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 {}

View File

@ -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,
);
// 根据业务需求决定是否重试或记录到死信队列
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 {}

View File

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

View File

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

View File

@ -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();
}
}

View File

@ -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,
});
}
}

View File

@ -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';

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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}`,
);
},
}),
);
}
}

View File

@ -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(),
})),
);
}
}

View File

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