1993 lines
63 KiB
Markdown
1993 lines
63 KiB
Markdown
# Reward Service 开发指导
|
||
|
||
## 项目概述
|
||
|
||
Reward Service 是 RWA 榴莲皇后平台的权益奖励微服务,负责管理奖励计算与分配、24小时倒计时管理、收益状态流转、结算处理等功能。
|
||
|
||
### 核心职责 ✅
|
||
- 权益规则定义(6种权益类型)
|
||
- 奖励计算与分配
|
||
- 24小时倒计时管理(待领取→可结算/过期)
|
||
- 收益状态流转(待领取→可结算→已结算/过期)
|
||
- 结算处理(选择币种,触发SWAP)
|
||
- 过期奖励转移至总部社区
|
||
|
||
### 不负责 ❌
|
||
- 推荐关系查询(Referral Context)
|
||
- 授权资格判定(Authorization Context)
|
||
- 钱包余额管理(Wallet Context)
|
||
- 区块链SWAP执行(Wallet Context)
|
||
|
||
---
|
||
|
||
## 技术栈
|
||
|
||
| 组件 | 技术选型 |
|
||
|------|----------|
|
||
| **框架** | NestJS 10.x |
|
||
| **数据库** | PostgreSQL + Prisma ORM |
|
||
| **架构** | DDD + Hexagonal Architecture (六边形架构) |
|
||
| **语言** | TypeScript 5.x |
|
||
| **消息队列** | Kafka (kafkajs) |
|
||
| **缓存** | Redis (ioredis) |
|
||
| **定时任务** | @nestjs/schedule |
|
||
| **API文档** | Swagger (@nestjs/swagger) |
|
||
|
||
---
|
||
|
||
## 核心业务规则
|
||
|
||
### 1. 奖励分配规则(每棵树 2199 USDT)
|
||
|
||
| 权益类型 | 金额 | 算力 | 分配目标 | 说明 |
|
||
|---------|------|------|---------|------|
|
||
| 成本账户 | 400 USDT | - | 系统指定账户 | 固定分配 |
|
||
| 运营账户 | 300 USDT | - | 系统指定账户 | 固定分配 |
|
||
| 总部社区 | 9 USDT | - | 总部社区账户 | 固定分配 |
|
||
| **分享权益** | 500 USDT | - | 推荐人账户 | 进入待结算或可结算 |
|
||
| 省区域权益 | 15 USDT | 1% | 系统省公司账户 | 固定分配 |
|
||
| **省团队权益** | 20 USDT | - | 最近授权省公司 | 无则进总部社区 |
|
||
| 市区域权益 | 35 USDT | 2% | 系统市公司账户 | 固定分配 |
|
||
| **市团队权益** | 40 USDT | - | 最近授权市公司 | 无则进总部社区 |
|
||
| **社区权益** | 80 USDT | - | 最近社区 | 无则进总部社区 |
|
||
| RWAD底池 | 800 USDT | - | 底池注入 | 5天批次注入 |
|
||
|
||
### 2. 24小时待领取规则
|
||
|
||
```
|
||
用户A推荐用户B → B认种 → A获得分享权益
|
||
|
||
情况1: A已认种
|
||
→ 奖励直接进入A的"可结算收益"
|
||
|
||
情况2: A未认种
|
||
→ 奖励进入A的"待领取收益"(24小时倒计时)
|
||
→ 24小时内A认种 → 待领取转为可结算
|
||
→ 24小时后A仍未认种 → 待领取过期,转入总部社区
|
||
```
|
||
|
||
### 3. 收益状态流转
|
||
|
||
```
|
||
[PENDING] 待领取
|
||
│
|
||
├── 用户认种 ──→ [SETTLEABLE] 可结算
|
||
│ │
|
||
│ └── 用户结算 ──→ [SETTLED] 已结算
|
||
│
|
||
└── 24小时后未认种 ──→ [EXPIRED] 已过期 ──→ 转入总部社区
|
||
```
|
||
|
||
### 4. 结算规则
|
||
|
||
- 用户点击结算按钮
|
||
- 可结算收益通过 DST 公链 SWAP 兑换为选定币种(BNB/OG/USDT/DST)
|
||
- DST 可在 APP 交易功能中卖出转换为 USDT
|
||
|
||
---
|
||
|
||
## 架构设计
|
||
|
||
```
|
||
reward-service/
|
||
├── prisma/
|
||
│ ├── schema.prisma # 数据库模型定义
|
||
│ └── migrations/ # 数据库迁移文件
|
||
│
|
||
├── src/
|
||
│ ├── api/ # 🔵 Presentation Layer (表现层)
|
||
│ │ ├── controllers/
|
||
│ │ │ ├── health.controller.ts
|
||
│ │ │ ├── reward.controller.ts
|
||
│ │ │ └── settlement.controller.ts
|
||
│ │ ├── dto/
|
||
│ │ │ ├── request/
|
||
│ │ │ │ └── settle-rewards.dto.ts
|
||
│ │ │ └── response/
|
||
│ │ │ ├── reward-summary.dto.ts
|
||
│ │ │ ├── reward-entry.dto.ts
|
||
│ │ │ └── settlement-result.dto.ts
|
||
│ │ └── api.module.ts
|
||
│ │
|
||
│ ├── application/ # 🟢 Application Layer (应用层)
|
||
│ │ ├── commands/
|
||
│ │ │ ├── distribute-rewards/
|
||
│ │ │ │ ├── distribute-rewards.command.ts
|
||
│ │ │ │ └── distribute-rewards.handler.ts
|
||
│ │ │ ├── claim-pending-rewards/
|
||
│ │ │ │ ├── claim-pending-rewards.command.ts
|
||
│ │ │ │ └── claim-pending-rewards.handler.ts
|
||
│ │ │ ├── settle-rewards/
|
||
│ │ │ │ ├── settle-rewards.command.ts
|
||
│ │ │ │ └── settle-rewards.handler.ts
|
||
│ │ │ ├── expire-pending-rewards/
|
||
│ │ │ │ ├── expire-pending-rewards.command.ts
|
||
│ │ │ │ └── expire-pending-rewards.handler.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── queries/
|
||
│ │ │ ├── get-reward-summary/
|
||
│ │ │ │ ├── get-reward-summary.query.ts
|
||
│ │ │ │ └── get-reward-summary.handler.ts
|
||
│ │ │ ├── get-reward-details/
|
||
│ │ │ │ ├── get-reward-details.query.ts
|
||
│ │ │ │ └── get-reward-details.handler.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── services/
|
||
│ │ │ └── reward-application.service.ts
|
||
│ │ ├── schedulers/
|
||
│ │ │ └── reward-expiration.scheduler.ts
|
||
│ │ └── application.module.ts
|
||
│ │
|
||
│ ├── domain/ # 🟡 Domain Layer (领域层)
|
||
│ │ ├── aggregates/
|
||
│ │ │ ├── reward-ledger-entry/
|
||
│ │ │ │ ├── reward-ledger-entry.aggregate.ts
|
||
│ │ │ │ ├── reward-ledger-entry.spec.ts
|
||
│ │ │ │ └── index.ts
|
||
│ │ │ └── reward-summary/
|
||
│ │ │ ├── reward-summary.aggregate.ts
|
||
│ │ │ ├── reward-summary.spec.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── value-objects/
|
||
│ │ │ ├── entry-id.vo.ts
|
||
│ │ │ ├── reward-source.vo.ts
|
||
│ │ │ ├── right-type.enum.ts
|
||
│ │ │ ├── reward-status.enum.ts
|
||
│ │ │ ├── money.vo.ts
|
||
│ │ │ ├── hashpower.vo.ts
|
||
│ │ │ ├── pending-rewards.vo.ts
|
||
│ │ │ ├── settleable-rewards.vo.ts
|
||
│ │ │ ├── settled-rewards.vo.ts
|
||
│ │ │ ├── expired-rewards.vo.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── entities/
|
||
│ │ │ └── right-definition.entity.ts
|
||
│ │ ├── events/
|
||
│ │ │ ├── domain-event.base.ts
|
||
│ │ │ ├── reward-created.event.ts
|
||
│ │ │ ├── reward-claimed.event.ts
|
||
│ │ │ ├── reward-settled.event.ts
|
||
│ │ │ ├── reward-expired.event.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── repositories/
|
||
│ │ │ ├── reward-ledger-entry.repository.interface.ts
|
||
│ │ │ ├── reward-summary.repository.interface.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── services/
|
||
│ │ │ ├── reward-calculation.service.ts
|
||
│ │ │ ├── reward-expiration.service.ts
|
||
│ │ │ └── index.ts
|
||
│ │ └── domain.module.ts
|
||
│ │
|
||
│ ├── infrastructure/ # 🔴 Infrastructure Layer (基础设施层)
|
||
│ │ ├── persistence/
|
||
│ │ │ ├── prisma/
|
||
│ │ │ │ └── prisma.service.ts
|
||
│ │ │ ├── mappers/
|
||
│ │ │ │ ├── reward-ledger-entry.mapper.ts
|
||
│ │ │ │ └── reward-summary.mapper.ts
|
||
│ │ │ └── repositories/
|
||
│ │ │ ├── reward-ledger-entry.repository.impl.ts
|
||
│ │ │ └── reward-summary.repository.impl.ts
|
||
│ │ ├── external/
|
||
│ │ │ ├── referral-service/
|
||
│ │ │ │ └── referral-service.client.ts
|
||
│ │ │ ├── authorization-service/
|
||
│ │ │ │ └── authorization-service.client.ts
|
||
│ │ │ └── wallet-service/
|
||
│ │ │ └── wallet-service.client.ts
|
||
│ │ ├── kafka/
|
||
│ │ │ ├── event-consumer.controller.ts
|
||
│ │ │ ├── event-publisher.service.ts
|
||
│ │ │ └── kafka.module.ts
|
||
│ │ ├── redis/
|
||
│ │ │ ├── redis.service.ts
|
||
│ │ │ └── redis.module.ts
|
||
│ │ └── infrastructure.module.ts
|
||
│ │
|
||
│ ├── shared/ # 共享模块
|
||
│ │ ├── decorators/
|
||
│ │ ├── exceptions/
|
||
│ │ ├── filters/
|
||
│ │ ├── guards/
|
||
│ │ ├── interceptors/
|
||
│ │ └── strategies/
|
||
│ │
|
||
│ ├── config/
|
||
│ │ ├── app.config.ts
|
||
│ │ ├── database.config.ts
|
||
│ │ ├── jwt.config.ts
|
||
│ │ ├── redis.config.ts
|
||
│ │ ├── kafka.config.ts
|
||
│ │ └── index.ts
|
||
│ │
|
||
│ ├── app.module.ts
|
||
│ └── main.ts
|
||
│
|
||
├── test/
|
||
├── .env.example
|
||
├── .env.development
|
||
├── package.json
|
||
├── tsconfig.json
|
||
└── Dockerfile
|
||
```
|
||
|
||
---
|
||
|
||
## 第一阶段:项目初始化
|
||
|
||
### 1.1 创建 NestJS 项目
|
||
|
||
```bash
|
||
cd backend/services/reward-service
|
||
npx @nestjs/cli new . --skip-git --package-manager npm
|
||
```
|
||
|
||
### 1.2 安装依赖
|
||
|
||
```bash
|
||
# 核心依赖
|
||
npm install @nestjs/config @nestjs/swagger @nestjs/jwt @nestjs/passport @nestjs/microservices @nestjs/schedule
|
||
npm install @prisma/client class-validator class-transformer uuid ioredis kafkajs
|
||
npm install passport passport-jwt
|
||
|
||
# 开发依赖
|
||
npm install -D prisma @types/uuid @types/passport-jwt
|
||
npm install -D @nestjs/testing jest ts-jest @types/jest supertest @types/supertest
|
||
```
|
||
|
||
### 1.3 环境变量配置
|
||
|
||
创建 `.env.development`:
|
||
```env
|
||
# 应用配置
|
||
NODE_ENV=development
|
||
PORT=3005
|
||
APP_NAME=reward-service
|
||
|
||
# 数据库
|
||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_reward?schema=public"
|
||
|
||
# JWT (与 identity-service 共享密钥)
|
||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||
JWT_ACCESS_EXPIRES_IN=2h
|
||
|
||
# Redis
|
||
REDIS_HOST=localhost
|
||
REDIS_PORT=6379
|
||
REDIS_PASSWORD=
|
||
|
||
# Kafka
|
||
KAFKA_BROKERS=localhost:9092
|
||
KAFKA_GROUP_ID=reward-service-group
|
||
KAFKA_CLIENT_ID=reward-service
|
||
|
||
# 外部服务
|
||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||
REFERRAL_SERVICE_URL=http://localhost:3004
|
||
AUTHORIZATION_SERVICE_URL=http://localhost:3006
|
||
WALLET_SERVICE_URL=http://localhost:3002
|
||
PLANTING_SERVICE_URL=http://localhost:3003
|
||
|
||
# 奖励过期检查间隔(毫秒)
|
||
REWARD_EXPIRATION_CHECK_INTERVAL=3600000
|
||
|
||
# 总部社区账户ID
|
||
HEADQUARTERS_COMMUNITY_USER_ID=1
|
||
```
|
||
|
||
---
|
||
|
||
## 第二阶段:数据库设计 (Prisma Schema)
|
||
|
||
### 2.1 创建 prisma/schema.prisma
|
||
|
||
```prisma
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
// ============================================
|
||
// 奖励流水表 (聚合根1 - 行为表, append-only)
|
||
// 记录每一笔奖励的创建、领取、结算、过期
|
||
// ============================================
|
||
model RewardLedgerEntry {
|
||
id BigInt @id @default(autoincrement()) @map("entry_id")
|
||
userId BigInt @map("user_id") // 接收奖励的用户ID
|
||
|
||
// === 奖励来源 ===
|
||
sourceOrderId BigInt @map("source_order_id") // 来源认种订单ID
|
||
sourceUserId BigInt @map("source_user_id") // 触发奖励的用户ID(认种者)
|
||
rightType String @map("right_type") @db.VarChar(50) // 权益类型
|
||
|
||
// === 奖励金额 ===
|
||
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
||
hashpowerAmount Decimal @default(0) @map("hashpower_amount") @db.Decimal(20, 8)
|
||
|
||
// === 奖励状态 ===
|
||
rewardStatus String @default("PENDING") @map("reward_status") @db.VarChar(20)
|
||
|
||
// === 时间戳 ===
|
||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6)
|
||
expireAt DateTime? @map("expire_at") // 过期时间(24h后)
|
||
claimedAt DateTime? @map("claimed_at") // 领取时间(用户认种)
|
||
settledAt DateTime? @map("settled_at") // 结算时间
|
||
expiredAt DateTime? @map("expired_at") // 实际过期时间
|
||
|
||
// === 备注 ===
|
||
memo String? @map("memo") @db.VarChar(500)
|
||
|
||
@@map("reward_ledger_entries")
|
||
@@index([userId, rewardStatus], name: "idx_user_status")
|
||
@@index([userId, createdAt(sort: Desc)], name: "idx_user_created")
|
||
@@index([sourceOrderId], name: "idx_source_order")
|
||
@@index([sourceUserId], name: "idx_source_user")
|
||
@@index([rightType], name: "idx_right_type")
|
||
@@index([rewardStatus], name: "idx_status")
|
||
@@index([expireAt], name: "idx_expire")
|
||
@@index([createdAt], name: "idx_created")
|
||
}
|
||
|
||
// ============================================
|
||
// 奖励汇总表 (聚合根2 - 状态表)
|
||
// 每个用户的收益汇总,从流水表聚合
|
||
// ============================================
|
||
model RewardSummary {
|
||
id BigInt @id @default(autoincrement()) @map("summary_id")
|
||
userId BigInt @unique @map("user_id")
|
||
|
||
// === 待领取收益 (24h倒计时) ===
|
||
pendingUsdt Decimal @default(0) @map("pending_usdt") @db.Decimal(20, 8)
|
||
pendingHashpower Decimal @default(0) @map("pending_hashpower") @db.Decimal(20, 8)
|
||
pendingExpireAt DateTime? @map("pending_expire_at") // 最早过期时间
|
||
|
||
// === 可结算收益 ===
|
||
settleableUsdt Decimal @default(0) @map("settleable_usdt") @db.Decimal(20, 8)
|
||
settleableHashpower Decimal @default(0) @map("settleable_hashpower") @db.Decimal(20, 8)
|
||
|
||
// === 已结算收益 (累计) ===
|
||
settledTotalUsdt Decimal @default(0) @map("settled_total_usdt") @db.Decimal(20, 8)
|
||
settledTotalHashpower Decimal @default(0) @map("settled_total_hashpower") @db.Decimal(20, 8)
|
||
|
||
// === 已过期收益 (累计) ===
|
||
expiredTotalUsdt Decimal @default(0) @map("expired_total_usdt") @db.Decimal(20, 8)
|
||
expiredTotalHashpower Decimal @default(0) @map("expired_total_hashpower") @db.Decimal(20, 8)
|
||
|
||
// === 时间戳 ===
|
||
lastUpdateAt DateTime @default(now()) @updatedAt @map("last_update_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@map("reward_summaries")
|
||
@@index([userId], name: "idx_summary_user")
|
||
@@index([settleableUsdt(sort: Desc)], name: "idx_settleable_desc")
|
||
@@index([pendingExpireAt], name: "idx_pending_expire")
|
||
}
|
||
|
||
// ============================================
|
||
// 权益定义表 (配置表)
|
||
// 定义每种权益的奖励规则
|
||
// ============================================
|
||
model RightDefinition {
|
||
id BigInt @id @default(autoincrement()) @map("definition_id")
|
||
rightType String @unique @map("right_type") @db.VarChar(50)
|
||
|
||
// === 奖励规则 ===
|
||
usdtPerTree Decimal @map("usdt_per_tree") @db.Decimal(20, 8)
|
||
hashpowerPercent Decimal @default(0) @map("hashpower_percent") @db.Decimal(5, 2)
|
||
|
||
// === 分配目标 ===
|
||
payableTo String @map("payable_to") @db.VarChar(50) // USER_ACCOUNT/SYSTEM_ACCOUNT/HEADQUARTERS
|
||
|
||
// === 规则描述 ===
|
||
ruleDescription String? @map("rule_description") @db.Text
|
||
|
||
// === 启用状态 ===
|
||
isEnabled Boolean @default(true) @map("is_enabled")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@map("right_definitions")
|
||
@@index([rightType], name: "idx_def_right_type")
|
||
@@index([isEnabled], name: "idx_def_enabled")
|
||
}
|
||
|
||
// ============================================
|
||
// 结算记录表 (行为表)
|
||
// 记录每次结算的详情
|
||
// ============================================
|
||
model SettlementRecord {
|
||
id BigInt @id @default(autoincrement()) @map("settlement_id")
|
||
userId BigInt @map("user_id")
|
||
|
||
// === 结算金额 ===
|
||
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
||
hashpowerAmount Decimal @map("hashpower_amount") @db.Decimal(20, 8)
|
||
|
||
// === 结算币种 ===
|
||
settleCurrency String @map("settle_currency") @db.VarChar(10) // BNB/OG/USDT/DST
|
||
receivedAmount Decimal @map("received_amount") @db.Decimal(20, 8) // 实际收到的币种数量
|
||
|
||
// === 交易信息 ===
|
||
swapTxHash String? @map("swap_tx_hash") @db.VarChar(100)
|
||
swapRate Decimal? @map("swap_rate") @db.Decimal(20, 8) // SWAP汇率
|
||
|
||
// === 状态 ===
|
||
status String @default("PENDING") @map("status") @db.VarChar(20) // PENDING/SUCCESS/FAILED
|
||
|
||
// === 时间戳 ===
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
completedAt DateTime? @map("completed_at")
|
||
|
||
// === 关联的奖励条目ID列表 ===
|
||
rewardEntryIds BigInt[] @map("reward_entry_ids")
|
||
|
||
@@map("settlement_records")
|
||
@@index([userId], name: "idx_settlement_user")
|
||
@@index([status], name: "idx_settlement_status")
|
||
@@index([createdAt], name: "idx_settlement_created")
|
||
}
|
||
|
||
// ============================================
|
||
// 奖励事件表 (行为表, append-only)
|
||
// 用于事件溯源和审计
|
||
// ============================================
|
||
model RewardEvent {
|
||
id BigInt @id @default(autoincrement()) @map("event_id")
|
||
eventType String @map("event_type") @db.VarChar(50)
|
||
|
||
// 聚合根信息
|
||
aggregateId String @map("aggregate_id") @db.VarChar(100)
|
||
aggregateType String @map("aggregate_type") @db.VarChar(50)
|
||
|
||
// 事件数据
|
||
eventData Json @map("event_data")
|
||
|
||
// 元数据
|
||
userId BigInt? @map("user_id")
|
||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6)
|
||
version Int @default(1) @map("version")
|
||
|
||
@@map("reward_events")
|
||
@@index([aggregateType, aggregateId], name: "idx_reward_event_aggregate")
|
||
@@index([eventType], name: "idx_reward_event_type")
|
||
@@index([userId], name: "idx_reward_event_user")
|
||
@@index([occurredAt], name: "idx_reward_event_occurred")
|
||
}
|
||
```
|
||
|
||
### 2.2 初始化数据库和种子数据
|
||
|
||
```bash
|
||
# 生成 Prisma Client
|
||
npx prisma generate
|
||
|
||
# 创建并运行迁移
|
||
npx prisma migrate dev --name init
|
||
```
|
||
|
||
创建 `prisma/seed.ts`:
|
||
```typescript
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
async function main() {
|
||
// 初始化权益定义
|
||
const rightDefinitions = [
|
||
{
|
||
rightType: 'SHARE_RIGHT',
|
||
usdtPerTree: 500,
|
||
hashpowerPercent: 0,
|
||
payableTo: 'USER_ACCOUNT',
|
||
ruleDescription: '分享权益:每棵树500 USDT,分配给推荐链',
|
||
},
|
||
{
|
||
rightType: 'PROVINCE_AREA_RIGHT',
|
||
usdtPerTree: 15,
|
||
hashpowerPercent: 1,
|
||
payableTo: 'SYSTEM_ACCOUNT',
|
||
ruleDescription: '省区域权益:每棵树15 USDT + 1%算力,进系统省公司账户',
|
||
},
|
||
{
|
||
rightType: 'PROVINCE_TEAM_RIGHT',
|
||
usdtPerTree: 20,
|
||
hashpowerPercent: 0,
|
||
payableTo: 'USER_ACCOUNT',
|
||
ruleDescription: '省团队权益:每棵树20 USDT,给最近的授权省公司',
|
||
},
|
||
{
|
||
rightType: 'CITY_AREA_RIGHT',
|
||
usdtPerTree: 35,
|
||
hashpowerPercent: 2,
|
||
payableTo: 'SYSTEM_ACCOUNT',
|
||
ruleDescription: '市区域权益:每棵树35 USDT + 2%算力,进系统市公司账户',
|
||
},
|
||
{
|
||
rightType: 'CITY_TEAM_RIGHT',
|
||
usdtPerTree: 40,
|
||
hashpowerPercent: 0,
|
||
payableTo: 'USER_ACCOUNT',
|
||
ruleDescription: '市团队权益:每棵树40 USDT,给最近的授权市公司',
|
||
},
|
||
{
|
||
rightType: 'COMMUNITY_RIGHT',
|
||
usdtPerTree: 80,
|
||
hashpowerPercent: 0,
|
||
payableTo: 'USER_ACCOUNT',
|
||
ruleDescription: '社区权益:每棵树80 USDT,给最近的社区',
|
||
},
|
||
];
|
||
|
||
for (const def of rightDefinitions) {
|
||
await prisma.rightDefinition.upsert({
|
||
where: { rightType: def.rightType },
|
||
update: def,
|
||
create: def,
|
||
});
|
||
}
|
||
|
||
console.log('Seed completed: Right definitions initialized');
|
||
}
|
||
|
||
main()
|
||
.catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 第三阶段:领域层实现 (Domain Layer)
|
||
|
||
### 3.1 值对象 (Value Objects)
|
||
|
||
#### 3.1.1 src/domain/value-objects/right-type.enum.ts
|
||
```typescript
|
||
export enum RightType {
|
||
SHARE_RIGHT = 'SHARE_RIGHT', // 分享权益 500U
|
||
PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT', // 省区域权益 15U + 1%算力
|
||
PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT', // 省团队权益 20U
|
||
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 35U + 2%算力
|
||
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 40U
|
||
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 80U
|
||
}
|
||
|
||
// 权益金额配置
|
||
export const RIGHT_AMOUNTS: Record<RightType, { usdt: number; hashpowerPercent: number }> = {
|
||
[RightType.SHARE_RIGHT]: { usdt: 500, hashpowerPercent: 0 },
|
||
[RightType.PROVINCE_AREA_RIGHT]: { usdt: 15, hashpowerPercent: 1 },
|
||
[RightType.PROVINCE_TEAM_RIGHT]: { usdt: 20, hashpowerPercent: 0 },
|
||
[RightType.CITY_AREA_RIGHT]: { usdt: 35, hashpowerPercent: 2 },
|
||
[RightType.CITY_TEAM_RIGHT]: { usdt: 40, hashpowerPercent: 0 },
|
||
[RightType.COMMUNITY_RIGHT]: { usdt: 80, hashpowerPercent: 0 },
|
||
};
|
||
```
|
||
|
||
#### 3.1.2 src/domain/value-objects/reward-status.enum.ts
|
||
```typescript
|
||
export enum RewardStatus {
|
||
PENDING = 'PENDING', // 待领取(24h倒计时)
|
||
SETTLEABLE = 'SETTLEABLE', // 可结算
|
||
SETTLED = 'SETTLED', // 已结算
|
||
EXPIRED = 'EXPIRED', // 已过期(进总部社区)
|
||
}
|
||
```
|
||
|
||
#### 3.1.3 src/domain/value-objects/money.vo.ts
|
||
```typescript
|
||
export class Money {
|
||
private constructor(
|
||
public readonly amount: number,
|
||
public readonly currency: string = 'USDT',
|
||
) {
|
||
if (amount < 0) {
|
||
throw new Error('金额不能为负数');
|
||
}
|
||
}
|
||
|
||
static USDT(amount: number): Money {
|
||
return new Money(amount, 'USDT');
|
||
}
|
||
|
||
static zero(): Money {
|
||
return new Money(0, 'USDT');
|
||
}
|
||
|
||
add(other: Money): Money {
|
||
if (this.currency !== other.currency) {
|
||
throw new Error('货币类型不匹配');
|
||
}
|
||
return new Money(this.amount + other.amount, this.currency);
|
||
}
|
||
|
||
subtract(other: Money): Money {
|
||
if (this.currency !== other.currency) {
|
||
throw new Error('货币类型不匹配');
|
||
}
|
||
return new Money(Math.max(0, this.amount - other.amount), this.currency);
|
||
}
|
||
|
||
multiply(factor: number): Money {
|
||
return new Money(this.amount * factor, this.currency);
|
||
}
|
||
|
||
equals(other: Money): boolean {
|
||
return this.amount === other.amount && this.currency === other.currency;
|
||
}
|
||
|
||
isZero(): boolean {
|
||
return this.amount === 0;
|
||
}
|
||
|
||
isGreaterThan(other: Money): boolean {
|
||
return this.amount > other.amount;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.4 src/domain/value-objects/hashpower.vo.ts
|
||
```typescript
|
||
export class Hashpower {
|
||
private constructor(public readonly value: number) {
|
||
if (value < 0) {
|
||
throw new Error('算力不能为负数');
|
||
}
|
||
}
|
||
|
||
static create(value: number): Hashpower {
|
||
return new Hashpower(value);
|
||
}
|
||
|
||
static zero(): Hashpower {
|
||
return new Hashpower(0);
|
||
}
|
||
|
||
/**
|
||
* 根据树数量和百分比计算算力
|
||
* @param treeCount 树数量
|
||
* @param percent 算力百分比 (1 = 1%)
|
||
*/
|
||
static fromTreeCount(treeCount: number, percent: number): Hashpower {
|
||
return new Hashpower(treeCount * percent);
|
||
}
|
||
|
||
add(other: Hashpower): Hashpower {
|
||
return new Hashpower(this.value + other.value);
|
||
}
|
||
|
||
subtract(other: Hashpower): Hashpower {
|
||
return new Hashpower(Math.max(0, this.value - other.value));
|
||
}
|
||
|
||
equals(other: Hashpower): boolean {
|
||
return this.value === other.value;
|
||
}
|
||
|
||
isZero(): boolean {
|
||
return this.value === 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.5 src/domain/value-objects/reward-source.vo.ts
|
||
```typescript
|
||
import { RightType } from './right-type.enum';
|
||
|
||
export class RewardSource {
|
||
private constructor(
|
||
public readonly rightType: RightType,
|
||
public readonly sourceOrderId: bigint,
|
||
public readonly sourceUserId: bigint,
|
||
) {}
|
||
|
||
static create(
|
||
rightType: RightType,
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
): RewardSource {
|
||
return new RewardSource(rightType, sourceOrderId, sourceUserId);
|
||
}
|
||
|
||
equals(other: RewardSource): boolean {
|
||
return (
|
||
this.rightType === other.rightType &&
|
||
this.sourceOrderId === other.sourceOrderId &&
|
||
this.sourceUserId === other.sourceUserId
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.6 src/domain/value-objects/index.ts
|
||
```typescript
|
||
export * from './right-type.enum';
|
||
export * from './reward-status.enum';
|
||
export * from './money.vo';
|
||
export * from './hashpower.vo';
|
||
export * from './reward-source.vo';
|
||
```
|
||
|
||
### 3.2 领域事件 (Domain Events)
|
||
|
||
#### 3.2.1 src/domain/events/domain-event.base.ts
|
||
```typescript
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
|
||
export abstract class DomainEvent {
|
||
public readonly eventId: string;
|
||
public readonly occurredAt: Date;
|
||
public readonly version: number;
|
||
|
||
protected constructor(version: number = 1) {
|
||
this.eventId = uuidv4();
|
||
this.occurredAt = new Date();
|
||
this.version = version;
|
||
}
|
||
|
||
abstract get eventType(): string;
|
||
abstract get aggregateId(): string;
|
||
abstract get aggregateType(): string;
|
||
abstract toPayload(): Record<string, any>;
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 src/domain/events/reward-created.event.ts
|
||
```typescript
|
||
import { DomainEvent } from './domain-event.base';
|
||
import { RightType } from '../value-objects/right-type.enum';
|
||
import { RewardStatus } from '../value-objects/reward-status.enum';
|
||
|
||
export interface RewardCreatedPayload {
|
||
entryId: string;
|
||
userId: string;
|
||
sourceOrderId: string;
|
||
sourceUserId: string;
|
||
rightType: RightType;
|
||
usdtAmount: number;
|
||
hashpowerAmount: number;
|
||
rewardStatus: RewardStatus;
|
||
expireAt: Date | null;
|
||
}
|
||
|
||
export class RewardCreatedEvent extends DomainEvent {
|
||
constructor(private readonly payload: RewardCreatedPayload) {
|
||
super();
|
||
}
|
||
|
||
get eventType(): string {
|
||
return 'RewardCreated';
|
||
}
|
||
|
||
get aggregateId(): string {
|
||
return this.payload.entryId;
|
||
}
|
||
|
||
get aggregateType(): string {
|
||
return 'RewardLedgerEntry';
|
||
}
|
||
|
||
toPayload(): RewardCreatedPayload {
|
||
return { ...this.payload };
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.3 src/domain/events/reward-claimed.event.ts
|
||
```typescript
|
||
import { DomainEvent } from './domain-event.base';
|
||
|
||
export interface RewardClaimedPayload {
|
||
entryId: string;
|
||
userId: string;
|
||
usdtAmount: number;
|
||
hashpowerAmount: number;
|
||
}
|
||
|
||
export class RewardClaimedEvent extends DomainEvent {
|
||
constructor(private readonly payload: RewardClaimedPayload) {
|
||
super();
|
||
}
|
||
|
||
get eventType(): string {
|
||
return 'RewardClaimed';
|
||
}
|
||
|
||
get aggregateId(): string {
|
||
return this.payload.entryId;
|
||
}
|
||
|
||
get aggregateType(): string {
|
||
return 'RewardLedgerEntry';
|
||
}
|
||
|
||
toPayload(): RewardClaimedPayload {
|
||
return { ...this.payload };
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.4 src/domain/events/reward-expired.event.ts
|
||
```typescript
|
||
import { DomainEvent } from './domain-event.base';
|
||
|
||
export interface RewardExpiredPayload {
|
||
entryId: string;
|
||
userId: string;
|
||
usdtAmount: number;
|
||
hashpowerAmount: number;
|
||
transferredTo: string; // 转移到的目标账户 (总部社区)
|
||
}
|
||
|
||
export class RewardExpiredEvent extends DomainEvent {
|
||
constructor(private readonly payload: RewardExpiredPayload) {
|
||
super();
|
||
}
|
||
|
||
get eventType(): string {
|
||
return 'RewardExpired';
|
||
}
|
||
|
||
get aggregateId(): string {
|
||
return this.payload.entryId;
|
||
}
|
||
|
||
get aggregateType(): string {
|
||
return 'RewardLedgerEntry';
|
||
}
|
||
|
||
toPayload(): RewardExpiredPayload {
|
||
return { ...this.payload };
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.5 src/domain/events/reward-settled.event.ts
|
||
```typescript
|
||
import { DomainEvent } from './domain-event.base';
|
||
|
||
export interface RewardSettledPayload {
|
||
entryId: string;
|
||
userId: string;
|
||
usdtAmount: number;
|
||
hashpowerAmount: number;
|
||
settleCurrency: string;
|
||
receivedAmount: number;
|
||
}
|
||
|
||
export class RewardSettledEvent extends DomainEvent {
|
||
constructor(private readonly payload: RewardSettledPayload) {
|
||
super();
|
||
}
|
||
|
||
get eventType(): string {
|
||
return 'RewardSettled';
|
||
}
|
||
|
||
get aggregateId(): string {
|
||
return this.payload.entryId;
|
||
}
|
||
|
||
get aggregateType(): string {
|
||
return 'RewardLedgerEntry';
|
||
}
|
||
|
||
toPayload(): RewardSettledPayload {
|
||
return { ...this.payload };
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 聚合根 (Aggregates)
|
||
|
||
#### 3.3.1 src/domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate.ts
|
||
```typescript
|
||
import { DomainEvent } from '../../events/domain-event.base';
|
||
import { RewardCreatedEvent } from '../../events/reward-created.event';
|
||
import { RewardClaimedEvent } from '../../events/reward-claimed.event';
|
||
import { RewardExpiredEvent } from '../../events/reward-expired.event';
|
||
import { RewardSettledEvent } from '../../events/reward-settled.event';
|
||
import { RewardSource } from '../../value-objects/reward-source.vo';
|
||
import { RewardStatus } from '../../value-objects/reward-status.enum';
|
||
import { Money } from '../../value-objects/money.vo';
|
||
import { Hashpower } from '../../value-objects/hashpower.vo';
|
||
|
||
/**
|
||
* 奖励流水聚合根
|
||
*
|
||
* 不变式:
|
||
* 1. 待领取奖励必须在24小时内认种,否则过期
|
||
* 2. 只有待领取状态才能领取(claim)
|
||
* 3. 只有可结算状态才能结算(settle)
|
||
* 4. 已结算/已过期的奖励状态不可变更
|
||
*/
|
||
export class RewardLedgerEntry {
|
||
private _id: bigint | null = null;
|
||
private readonly _userId: bigint;
|
||
private readonly _rewardSource: RewardSource;
|
||
private readonly _usdtAmount: Money;
|
||
private readonly _hashpowerAmount: Hashpower;
|
||
private _rewardStatus: RewardStatus;
|
||
private readonly _createdAt: Date;
|
||
private _expireAt: Date | null;
|
||
private _claimedAt: Date | null;
|
||
private _settledAt: Date | null;
|
||
private _expiredAt: Date | null;
|
||
private readonly _memo: string;
|
||
|
||
private _domainEvents: DomainEvent[] = [];
|
||
|
||
private constructor(
|
||
userId: bigint,
|
||
rewardSource: RewardSource,
|
||
usdtAmount: Money,
|
||
hashpowerAmount: Hashpower,
|
||
rewardStatus: RewardStatus,
|
||
createdAt: Date,
|
||
expireAt: Date | null,
|
||
memo: string,
|
||
) {
|
||
this._userId = userId;
|
||
this._rewardSource = rewardSource;
|
||
this._usdtAmount = usdtAmount;
|
||
this._hashpowerAmount = hashpowerAmount;
|
||
this._rewardStatus = rewardStatus;
|
||
this._createdAt = createdAt;
|
||
this._expireAt = expireAt;
|
||
this._claimedAt = null;
|
||
this._settledAt = null;
|
||
this._expiredAt = null;
|
||
this._memo = memo;
|
||
}
|
||
|
||
// ============ Getters ============
|
||
get id(): bigint | null { return this._id; }
|
||
get userId(): bigint { return this._userId; }
|
||
get rewardSource(): RewardSource { return this._rewardSource; }
|
||
get usdtAmount(): Money { return this._usdtAmount; }
|
||
get hashpowerAmount(): Hashpower { return this._hashpowerAmount; }
|
||
get rewardStatus(): RewardStatus { return this._rewardStatus; }
|
||
get createdAt(): Date { return this._createdAt; }
|
||
get expireAt(): Date | null { return this._expireAt; }
|
||
get claimedAt(): Date | null { return this._claimedAt; }
|
||
get settledAt(): Date | null { return this._settledAt; }
|
||
get expiredAt(): Date | null { return this._expiredAt; }
|
||
get memo(): string { return this._memo; }
|
||
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||
|
||
get isPending(): boolean { return this._rewardStatus === RewardStatus.PENDING; }
|
||
get isSettleable(): boolean { return this._rewardStatus === RewardStatus.SETTLEABLE; }
|
||
get isSettled(): boolean { return this._rewardStatus === RewardStatus.SETTLED; }
|
||
get isExpired(): boolean { return this._rewardStatus === RewardStatus.EXPIRED; }
|
||
|
||
// ============ 工厂方法 ============
|
||
|
||
/**
|
||
* 创建待领取奖励 (24小时倒计时)
|
||
* 用于推荐人未认种的情况
|
||
*/
|
||
static createPending(params: {
|
||
userId: bigint;
|
||
rewardSource: RewardSource;
|
||
usdtAmount: Money;
|
||
hashpowerAmount: Hashpower;
|
||
memo?: string;
|
||
}): RewardLedgerEntry {
|
||
const now = new Date();
|
||
const expireAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24小时后
|
||
|
||
const entry = new RewardLedgerEntry(
|
||
params.userId,
|
||
params.rewardSource,
|
||
params.usdtAmount,
|
||
params.hashpowerAmount,
|
||
RewardStatus.PENDING,
|
||
now,
|
||
expireAt,
|
||
params.memo || '',
|
||
);
|
||
|
||
entry._domainEvents.push(new RewardCreatedEvent({
|
||
entryId: entry._id?.toString() || 'temp',
|
||
userId: entry._userId.toString(),
|
||
sourceOrderId: entry._rewardSource.sourceOrderId.toString(),
|
||
sourceUserId: entry._rewardSource.sourceUserId.toString(),
|
||
rightType: entry._rewardSource.rightType,
|
||
usdtAmount: entry._usdtAmount.amount,
|
||
hashpowerAmount: entry._hashpowerAmount.value,
|
||
rewardStatus: entry._rewardStatus,
|
||
expireAt: entry._expireAt,
|
||
}));
|
||
|
||
return entry;
|
||
}
|
||
|
||
/**
|
||
* 创建直接可结算奖励 (无需24小时等待)
|
||
* 用于推荐人已认种的情况
|
||
*/
|
||
static createSettleable(params: {
|
||
userId: bigint;
|
||
rewardSource: RewardSource;
|
||
usdtAmount: Money;
|
||
hashpowerAmount: Hashpower;
|
||
memo?: string;
|
||
}): RewardLedgerEntry {
|
||
const entry = new RewardLedgerEntry(
|
||
params.userId,
|
||
params.rewardSource,
|
||
params.usdtAmount,
|
||
params.hashpowerAmount,
|
||
RewardStatus.SETTLEABLE,
|
||
new Date(),
|
||
null,
|
||
params.memo || '',
|
||
);
|
||
|
||
entry._domainEvents.push(new RewardCreatedEvent({
|
||
entryId: entry._id?.toString() || 'temp',
|
||
userId: entry._userId.toString(),
|
||
sourceOrderId: entry._rewardSource.sourceOrderId.toString(),
|
||
sourceUserId: entry._rewardSource.sourceUserId.toString(),
|
||
rightType: entry._rewardSource.rightType,
|
||
usdtAmount: entry._usdtAmount.amount,
|
||
hashpowerAmount: entry._hashpowerAmount.value,
|
||
rewardStatus: entry._rewardStatus,
|
||
expireAt: null,
|
||
}));
|
||
|
||
return entry;
|
||
}
|
||
|
||
// ============ 领域行为 ============
|
||
|
||
/**
|
||
* 领取奖励 (用户认种后,待领取 → 可结算)
|
||
*/
|
||
claim(): void {
|
||
if (this._rewardStatus !== RewardStatus.PENDING) {
|
||
throw new Error('只有待领取状态才能领取');
|
||
}
|
||
|
||
if (this.isExpiredNow()) {
|
||
throw new Error('奖励已过期,无法领取');
|
||
}
|
||
|
||
this._rewardStatus = RewardStatus.SETTLEABLE;
|
||
this._claimedAt = new Date();
|
||
this._expireAt = null;
|
||
|
||
this._domainEvents.push(new RewardClaimedEvent({
|
||
entryId: this._id?.toString() || '',
|
||
userId: this._userId.toString(),
|
||
usdtAmount: this._usdtAmount.amount,
|
||
hashpowerAmount: this._hashpowerAmount.value,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 过期 (24小时后未认种)
|
||
*/
|
||
expire(): void {
|
||
if (this._rewardStatus !== RewardStatus.PENDING) {
|
||
throw new Error('只有待领取状态才能过期');
|
||
}
|
||
|
||
this._rewardStatus = RewardStatus.EXPIRED;
|
||
this._expiredAt = new Date();
|
||
|
||
this._domainEvents.push(new RewardExpiredEvent({
|
||
entryId: this._id?.toString() || '',
|
||
userId: this._userId.toString(),
|
||
usdtAmount: this._usdtAmount.amount,
|
||
hashpowerAmount: this._hashpowerAmount.value,
|
||
transferredTo: 'HEADQUARTERS_COMMUNITY',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 结算 (可结算 → 已结算)
|
||
*/
|
||
settle(settleCurrency: string, receivedAmount: number): void {
|
||
if (this._rewardStatus !== RewardStatus.SETTLEABLE) {
|
||
throw new Error('只有可结算状态才能结算');
|
||
}
|
||
|
||
this._rewardStatus = RewardStatus.SETTLED;
|
||
this._settledAt = new Date();
|
||
|
||
this._domainEvents.push(new RewardSettledEvent({
|
||
entryId: this._id?.toString() || '',
|
||
userId: this._userId.toString(),
|
||
usdtAmount: this._usdtAmount.amount,
|
||
hashpowerAmount: this._hashpowerAmount.value,
|
||
settleCurrency,
|
||
receivedAmount,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 检查是否已过期
|
||
*/
|
||
isExpiredNow(): boolean {
|
||
if (!this._expireAt) return false;
|
||
return new Date() > this._expireAt;
|
||
}
|
||
|
||
/**
|
||
* 获取剩余过期时间 (毫秒)
|
||
*/
|
||
getRemainingTimeMs(): number {
|
||
if (!this._expireAt) return 0;
|
||
const remaining = this._expireAt.getTime() - Date.now();
|
||
return Math.max(0, remaining);
|
||
}
|
||
|
||
setId(id: bigint): void {
|
||
this._id = id;
|
||
}
|
||
|
||
clearDomainEvents(): void {
|
||
this._domainEvents = [];
|
||
}
|
||
|
||
// ============ 重建 ============
|
||
|
||
static reconstitute(data: {
|
||
id: bigint;
|
||
userId: bigint;
|
||
rewardSource: RewardSource;
|
||
usdtAmount: number;
|
||
hashpowerAmount: number;
|
||
rewardStatus: RewardStatus;
|
||
createdAt: Date;
|
||
expireAt: Date | null;
|
||
claimedAt: Date | null;
|
||
settledAt: Date | null;
|
||
expiredAt: Date | null;
|
||
memo: string;
|
||
}): RewardLedgerEntry {
|
||
const entry = new RewardLedgerEntry(
|
||
data.userId,
|
||
data.rewardSource,
|
||
Money.USDT(data.usdtAmount),
|
||
Hashpower.create(data.hashpowerAmount),
|
||
data.rewardStatus,
|
||
data.createdAt,
|
||
data.expireAt,
|
||
data.memo,
|
||
);
|
||
entry._id = data.id;
|
||
entry._claimedAt = data.claimedAt;
|
||
entry._settledAt = data.settledAt;
|
||
entry._expiredAt = data.expiredAt;
|
||
return entry;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.2 src/domain/aggregates/reward-summary/reward-summary.aggregate.ts
|
||
```typescript
|
||
import { Money } from '../../value-objects/money.vo';
|
||
import { Hashpower } from '../../value-objects/hashpower.vo';
|
||
|
||
/**
|
||
* 奖励汇总聚合根
|
||
* 维护用户的各类收益汇总数据
|
||
*/
|
||
export class RewardSummary {
|
||
private _id: bigint | null = null;
|
||
private readonly _userId: bigint;
|
||
|
||
// 待领取收益
|
||
private _pendingUsdt: Money;
|
||
private _pendingHashpower: Hashpower;
|
||
private _pendingExpireAt: Date | null;
|
||
|
||
// 可结算收益
|
||
private _settleableUsdt: Money;
|
||
private _settleableHashpower: Hashpower;
|
||
|
||
// 已结算收益 (累计)
|
||
private _settledTotalUsdt: Money;
|
||
private _settledTotalHashpower: Hashpower;
|
||
|
||
// 已过期收益 (累计)
|
||
private _expiredTotalUsdt: Money;
|
||
private _expiredTotalHashpower: Hashpower;
|
||
|
||
private _lastUpdateAt: Date;
|
||
private readonly _createdAt: Date;
|
||
|
||
private constructor(userId: bigint) {
|
||
this._userId = userId;
|
||
this._pendingUsdt = Money.zero();
|
||
this._pendingHashpower = Hashpower.zero();
|
||
this._pendingExpireAt = null;
|
||
this._settleableUsdt = Money.zero();
|
||
this._settleableHashpower = Hashpower.zero();
|
||
this._settledTotalUsdt = Money.zero();
|
||
this._settledTotalHashpower = Hashpower.zero();
|
||
this._expiredTotalUsdt = Money.zero();
|
||
this._expiredTotalHashpower = Hashpower.zero();
|
||
this._lastUpdateAt = new Date();
|
||
this._createdAt = new Date();
|
||
}
|
||
|
||
// ============ Getters ============
|
||
get id(): bigint | null { return this._id; }
|
||
get userId(): bigint { return this._userId; }
|
||
get pendingUsdt(): Money { return this._pendingUsdt; }
|
||
get pendingHashpower(): Hashpower { return this._pendingHashpower; }
|
||
get pendingExpireAt(): Date | null { return this._pendingExpireAt; }
|
||
get settleableUsdt(): Money { return this._settleableUsdt; }
|
||
get settleableHashpower(): Hashpower { return this._settleableHashpower; }
|
||
get settledTotalUsdt(): Money { return this._settledTotalUsdt; }
|
||
get settledTotalHashpower(): Hashpower { return this._settledTotalHashpower; }
|
||
get expiredTotalUsdt(): Money { return this._expiredTotalUsdt; }
|
||
get expiredTotalHashpower(): Hashpower { return this._expiredTotalHashpower; }
|
||
get lastUpdateAt(): Date { return this._lastUpdateAt; }
|
||
get createdAt(): Date { return this._createdAt; }
|
||
|
||
// ============ 工厂方法 ============
|
||
|
||
static create(userId: bigint): RewardSummary {
|
||
return new RewardSummary(userId);
|
||
}
|
||
|
||
// ============ 领域行为 ============
|
||
|
||
/**
|
||
* 增加待领取收益
|
||
*/
|
||
addPending(usdt: Money, hashpower: Hashpower, expireAt: Date): void {
|
||
this._pendingUsdt = this._pendingUsdt.add(usdt);
|
||
this._pendingHashpower = this._pendingHashpower.add(hashpower);
|
||
// 更新为最早的过期时间
|
||
if (!this._pendingExpireAt || expireAt < this._pendingExpireAt) {
|
||
this._pendingExpireAt = expireAt;
|
||
}
|
||
this._lastUpdateAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 待领取 → 可结算 (用户认种)
|
||
*/
|
||
movePendingToSettleable(usdt: Money, hashpower: Hashpower): void {
|
||
this._pendingUsdt = this._pendingUsdt.subtract(usdt);
|
||
this._pendingHashpower = this._pendingHashpower.subtract(hashpower);
|
||
this._settleableUsdt = this._settleableUsdt.add(usdt);
|
||
this._settleableHashpower = this._settleableHashpower.add(hashpower);
|
||
|
||
// 如果待领取清空了,清除过期时间
|
||
if (this._pendingUsdt.isZero()) {
|
||
this._pendingExpireAt = null;
|
||
}
|
||
this._lastUpdateAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 待领取 → 已过期
|
||
*/
|
||
movePendingToExpired(usdt: Money, hashpower: Hashpower): void {
|
||
this._pendingUsdt = this._pendingUsdt.subtract(usdt);
|
||
this._pendingHashpower = this._pendingHashpower.subtract(hashpower);
|
||
this._expiredTotalUsdt = this._expiredTotalUsdt.add(usdt);
|
||
this._expiredTotalHashpower = this._expiredTotalHashpower.add(hashpower);
|
||
|
||
if (this._pendingUsdt.isZero()) {
|
||
this._pendingExpireAt = null;
|
||
}
|
||
this._lastUpdateAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 增加可结算收益 (直接可结算的奖励)
|
||
*/
|
||
addSettleable(usdt: Money, hashpower: Hashpower): void {
|
||
this._settleableUsdt = this._settleableUsdt.add(usdt);
|
||
this._settleableHashpower = this._settleableHashpower.add(hashpower);
|
||
this._lastUpdateAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 可结算 → 已结算
|
||
*/
|
||
settle(usdt: Money, hashpower: Hashpower): void {
|
||
this._settleableUsdt = this._settleableUsdt.subtract(usdt);
|
||
this._settleableHashpower = this._settleableHashpower.subtract(hashpower);
|
||
this._settledTotalUsdt = this._settledTotalUsdt.add(usdt);
|
||
this._settledTotalHashpower = this._settledTotalHashpower.add(hashpower);
|
||
this._lastUpdateAt = new Date();
|
||
}
|
||
|
||
setId(id: bigint): void {
|
||
this._id = id;
|
||
}
|
||
|
||
// ============ 重建 ============
|
||
|
||
static reconstitute(data: {
|
||
id: bigint;
|
||
userId: bigint;
|
||
pendingUsdt: number;
|
||
pendingHashpower: number;
|
||
pendingExpireAt: Date | null;
|
||
settleableUsdt: number;
|
||
settleableHashpower: number;
|
||
settledTotalUsdt: number;
|
||
settledTotalHashpower: number;
|
||
expiredTotalUsdt: number;
|
||
expiredTotalHashpower: number;
|
||
lastUpdateAt: Date;
|
||
createdAt: Date;
|
||
}): RewardSummary {
|
||
const summary = new RewardSummary(data.userId);
|
||
summary._id = data.id;
|
||
summary._pendingUsdt = Money.USDT(data.pendingUsdt);
|
||
summary._pendingHashpower = Hashpower.create(data.pendingHashpower);
|
||
summary._pendingExpireAt = data.pendingExpireAt;
|
||
summary._settleableUsdt = Money.USDT(data.settleableUsdt);
|
||
summary._settleableHashpower = Hashpower.create(data.settleableHashpower);
|
||
summary._settledTotalUsdt = Money.USDT(data.settledTotalUsdt);
|
||
summary._settledTotalHashpower = Hashpower.create(data.settledTotalHashpower);
|
||
summary._expiredTotalUsdt = Money.USDT(data.expiredTotalUsdt);
|
||
summary._expiredTotalHashpower = Hashpower.create(data.expiredTotalHashpower);
|
||
return summary;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 仓储接口 (Repository Interfaces)
|
||
|
||
#### 3.4.1 src/domain/repositories/reward-ledger-entry.repository.interface.ts
|
||
```typescript
|
||
import { RewardLedgerEntry } from '../aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
|
||
import { RewardStatus } from '../value-objects/reward-status.enum';
|
||
import { RightType } from '../value-objects/right-type.enum';
|
||
|
||
export interface IRewardLedgerEntryRepository {
|
||
save(entry: RewardLedgerEntry): Promise<void>;
|
||
saveAll(entries: RewardLedgerEntry[]): Promise<void>;
|
||
findById(entryId: bigint): Promise<RewardLedgerEntry | null>;
|
||
findByUserId(
|
||
userId: bigint,
|
||
filters?: {
|
||
status?: RewardStatus;
|
||
rightType?: RightType;
|
||
startDate?: Date;
|
||
endDate?: Date;
|
||
},
|
||
pagination?: { page: number; pageSize: number },
|
||
): Promise<RewardLedgerEntry[]>;
|
||
findPendingByUserId(userId: bigint): Promise<RewardLedgerEntry[]>;
|
||
findSettleableByUserId(userId: bigint): Promise<RewardLedgerEntry[]>;
|
||
findExpiredPending(beforeDate: Date): Promise<RewardLedgerEntry[]>;
|
||
findBySourceOrderId(sourceOrderId: bigint): Promise<RewardLedgerEntry[]>;
|
||
countByUserId(userId: bigint, status?: RewardStatus): Promise<number>;
|
||
}
|
||
|
||
export const REWARD_LEDGER_ENTRY_REPOSITORY = Symbol('IRewardLedgerEntryRepository');
|
||
```
|
||
|
||
#### 3.4.2 src/domain/repositories/reward-summary.repository.interface.ts
|
||
```typescript
|
||
import { RewardSummary } from '../aggregates/reward-summary/reward-summary.aggregate';
|
||
|
||
export interface IRewardSummaryRepository {
|
||
save(summary: RewardSummary): Promise<void>;
|
||
findByUserId(userId: bigint): Promise<RewardSummary | null>;
|
||
getOrCreate(userId: bigint): Promise<RewardSummary>;
|
||
findByUserIds(userIds: bigint[]): Promise<Map<string, RewardSummary>>;
|
||
findTopSettleableUsers(limit: number): Promise<RewardSummary[]>;
|
||
}
|
||
|
||
export const REWARD_SUMMARY_REPOSITORY = Symbol('IRewardSummaryRepository');
|
||
```
|
||
|
||
### 3.5 领域服务 (Domain Services)
|
||
|
||
#### 3.5.1 src/domain/services/reward-calculation.service.ts
|
||
```typescript
|
||
import { Injectable, Inject } from '@nestjs/common';
|
||
import { RewardLedgerEntry } from '../aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
|
||
import { RewardSource } from '../value-objects/reward-source.vo';
|
||
import { RightType, RIGHT_AMOUNTS } from '../value-objects/right-type.enum';
|
||
import { Money } from '../value-objects/money.vo';
|
||
import { Hashpower } from '../value-objects/hashpower.vo';
|
||
|
||
// 外部服务接口 (防腐层)
|
||
export interface IReferralServiceClient {
|
||
getReferralChain(userId: bigint): Promise<{
|
||
ancestors: Array<{ userId: bigint; hasPlanted: boolean }>;
|
||
}>;
|
||
}
|
||
|
||
export interface IAuthorizationServiceClient {
|
||
findNearestAuthorizedProvince(userId: bigint, provinceCode: string): Promise<bigint | null>;
|
||
findNearestAuthorizedCity(userId: bigint, cityCode: string): Promise<bigint | null>;
|
||
findNearestCommunity(userId: bigint): Promise<bigint | null>;
|
||
}
|
||
|
||
export const REFERRAL_SERVICE_CLIENT = Symbol('IReferralServiceClient');
|
||
export const AUTHORIZATION_SERVICE_CLIENT = Symbol('IAuthorizationServiceClient');
|
||
|
||
// 总部社区账户ID
|
||
const HEADQUARTERS_COMMUNITY_USER_ID = BigInt(1);
|
||
|
||
@Injectable()
|
||
export class RewardCalculationService {
|
||
constructor(
|
||
@Inject(REFERRAL_SERVICE_CLIENT)
|
||
private readonly referralService: IReferralServiceClient,
|
||
@Inject(AUTHORIZATION_SERVICE_CLIENT)
|
||
private readonly authorizationService: IAuthorizationServiceClient,
|
||
) {}
|
||
|
||
/**
|
||
* 计算认种订单产生的所有奖励
|
||
*/
|
||
async calculateRewards(params: {
|
||
sourceOrderId: bigint;
|
||
sourceUserId: bigint;
|
||
treeCount: number;
|
||
provinceCode: string;
|
||
cityCode: string;
|
||
}): Promise<RewardLedgerEntry[]> {
|
||
const rewards: RewardLedgerEntry[] = [];
|
||
|
||
// 1. 分享权益 (500 USDT)
|
||
const shareRewards = await this.calculateShareRights(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(...shareRewards);
|
||
|
||
// 2. 省团队权益 (20 USDT)
|
||
const provinceTeamReward = await this.calculateProvinceTeamRight(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.provinceCode,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(provinceTeamReward);
|
||
|
||
// 3. 省区域权益 (15 USDT + 1%算力)
|
||
const provinceAreaReward = this.calculateProvinceAreaRight(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.provinceCode,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(provinceAreaReward);
|
||
|
||
// 4. 市团队权益 (40 USDT)
|
||
const cityTeamReward = await this.calculateCityTeamRight(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.cityCode,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(cityTeamReward);
|
||
|
||
// 5. 市区域权益 (35 USDT + 2%算力)
|
||
const cityAreaReward = this.calculateCityAreaRight(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.cityCode,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(cityAreaReward);
|
||
|
||
// 6. 社区权益 (80 USDT)
|
||
const communityReward = await this.calculateCommunityRight(
|
||
params.sourceOrderId,
|
||
params.sourceUserId,
|
||
params.treeCount,
|
||
);
|
||
rewards.push(communityReward);
|
||
|
||
return rewards;
|
||
}
|
||
|
||
/**
|
||
* 计算分享权益 (500 USDT)
|
||
*/
|
||
private async calculateShareRights(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
treeCount: number,
|
||
): Promise<RewardLedgerEntry[]> {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.SHARE_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.SHARE_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 获取推荐链
|
||
const referralChain = await this.referralService.getReferralChain(sourceUserId);
|
||
|
||
if (referralChain.ancestors.length > 0) {
|
||
const directReferrer = referralChain.ancestors[0];
|
||
|
||
if (directReferrer.hasPlanted) {
|
||
// 推荐人已认种,直接可结算
|
||
return [RewardLedgerEntry.createSettleable({
|
||
userId: directReferrer.userId,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `分享权益:来自用户${sourceUserId}的认种`,
|
||
})];
|
||
} else {
|
||
// 推荐人未认种,进入待领取(24h倒计时)
|
||
return [RewardLedgerEntry.createPending({
|
||
userId: directReferrer.userId,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `分享权益:来自用户${sourceUserId}的认种(24h内认种可领取)`,
|
||
})];
|
||
}
|
||
} else {
|
||
// 无推荐人,进总部社区
|
||
return [RewardLedgerEntry.createSettleable({
|
||
userId: HEADQUARTERS_COMMUNITY_USER_ID,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: '分享权益:无推荐人,进总部社区',
|
||
})];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算省团队权益 (20 USDT)
|
||
*/
|
||
private async calculateProvinceTeamRight(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
provinceCode: string,
|
||
treeCount: number,
|
||
): Promise<RewardLedgerEntry> {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_TEAM_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.PROVINCE_TEAM_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 查找最近的授权省公司
|
||
const nearestProvince = await this.authorizationService.findNearestAuthorizedProvince(
|
||
sourceUserId,
|
||
provinceCode,
|
||
);
|
||
|
||
if (nearestProvince) {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: nearestProvince,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `省团队权益:来自${provinceCode}省的认种`,
|
||
});
|
||
} else {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: HEADQUARTERS_COMMUNITY_USER_ID,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: '省团队权益:无达标授权省公司,进总部社区',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算省区域权益 (15 USDT + 1%算力)
|
||
*/
|
||
private calculateProvinceAreaRight(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
provinceCode: string,
|
||
treeCount: number,
|
||
): RewardLedgerEntry {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_AREA_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.PROVINCE_AREA_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 进系统省公司账户 (使用特殊账户ID格式)
|
||
const systemProvinceAccountId = BigInt(`9${provinceCode.padStart(6, '0')}`);
|
||
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: systemProvinceAccountId,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `省区域权益:${provinceCode}省,15U + 1%算力`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 计算市团队权益 (40 USDT)
|
||
*/
|
||
private async calculateCityTeamRight(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
cityCode: string,
|
||
treeCount: number,
|
||
): Promise<RewardLedgerEntry> {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_TEAM_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.CITY_TEAM_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 查找最近的授权市公司
|
||
const nearestCity = await this.authorizationService.findNearestAuthorizedCity(
|
||
sourceUserId,
|
||
cityCode,
|
||
);
|
||
|
||
if (nearestCity) {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: nearestCity,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `市团队权益:来自${cityCode}市的认种`,
|
||
});
|
||
} else {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: HEADQUARTERS_COMMUNITY_USER_ID,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: '市团队权益:无达标授权市公司,进总部社区',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算市区域权益 (35 USDT + 2%算力)
|
||
*/
|
||
private calculateCityAreaRight(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
cityCode: string,
|
||
treeCount: number,
|
||
): RewardLedgerEntry {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_AREA_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.CITY_AREA_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 进系统市公司账户
|
||
const systemCityAccountId = BigInt(`8${cityCode.padStart(6, '0')}`);
|
||
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: systemCityAccountId,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: `市区域权益:${cityCode}市,35U + 2%算力`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 计算社区权益 (80 USDT)
|
||
*/
|
||
private async calculateCommunityRight(
|
||
sourceOrderId: bigint,
|
||
sourceUserId: bigint,
|
||
treeCount: number,
|
||
): Promise<RewardLedgerEntry> {
|
||
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.COMMUNITY_RIGHT];
|
||
const usdtAmount = Money.USDT(usdt * treeCount);
|
||
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||
|
||
const rewardSource = RewardSource.create(
|
||
RightType.COMMUNITY_RIGHT,
|
||
sourceOrderId,
|
||
sourceUserId,
|
||
);
|
||
|
||
// 查找最近的社区
|
||
const nearestCommunity = await this.authorizationService.findNearestCommunity(sourceUserId);
|
||
|
||
if (nearestCommunity) {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: nearestCommunity,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: '社区权益:来自社区成员的认种',
|
||
});
|
||
} else {
|
||
return RewardLedgerEntry.createSettleable({
|
||
userId: HEADQUARTERS_COMMUNITY_USER_ID,
|
||
rewardSource,
|
||
usdtAmount,
|
||
hashpowerAmount: hashpower,
|
||
memo: '社区权益:无归属社区,进总部社区',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.2 src/domain/services/reward-expiration.service.ts
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { RewardLedgerEntry } from '../aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
|
||
|
||
@Injectable()
|
||
export class RewardExpirationService {
|
||
/**
|
||
* 检查并过期所有到期的待领取奖励
|
||
*/
|
||
expireOverdueRewards(pendingRewards: RewardLedgerEntry[]): RewardLedgerEntry[] {
|
||
const expiredRewards: RewardLedgerEntry[] = [];
|
||
|
||
for (const reward of pendingRewards) {
|
||
if (reward.isExpiredNow()) {
|
||
reward.expire();
|
||
expiredRewards.push(reward);
|
||
}
|
||
}
|
||
|
||
return expiredRewards;
|
||
}
|
||
|
||
/**
|
||
* 检查用户的待领取奖励状态
|
||
*/
|
||
checkUserPendingRewards(
|
||
pendingRewards: RewardLedgerEntry[],
|
||
): {
|
||
expired: RewardLedgerEntry[];
|
||
stillPending: RewardLedgerEntry[];
|
||
} {
|
||
const expired: RewardLedgerEntry[] = [];
|
||
const stillPending: RewardLedgerEntry[] = [];
|
||
|
||
for (const reward of pendingRewards) {
|
||
if (reward.isExpiredNow()) {
|
||
reward.expire();
|
||
expired.push(reward);
|
||
} else {
|
||
stillPending.push(reward);
|
||
}
|
||
}
|
||
|
||
return { expired, stillPending };
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 领域不变式 (Domain Invariants)
|
||
|
||
```typescript
|
||
// 文档参考
|
||
class RewardContextInvariants {
|
||
// 1. 待领取奖励必须在24小时内认种,否则过期
|
||
static PENDING_REWARD_EXPIRES_IN_24H =
|
||
"待领取奖励必须在24小时内用户认种,否则进入总部社区";
|
||
|
||
// 2. 只有可结算状态才能结算
|
||
static ONLY_SETTLEABLE_CAN_BE_SETTLED =
|
||
"只有可结算状态的收益才能结算";
|
||
|
||
// 3. 已结算/已过期的奖励不可变更
|
||
static SETTLED_EXPIRED_IMMUTABLE =
|
||
"已结算或已过期的奖励状态不可变更";
|
||
|
||
// 4. 奖励金额必须匹配权益定义
|
||
static REWARD_AMOUNT_MUST_MATCH_DEFINITION =
|
||
"奖励金额必须符合权益定义规则";
|
||
|
||
// 5. 分享权益500 USDT必须分配给推荐链
|
||
static REFERRAL_RIGHTS_DISTRIBUTION =
|
||
"分享权益500 USDT必须按推荐链分配";
|
||
|
||
// 6. 无上级授权时,权益进系统账户或总部社区
|
||
static FALLBACK_TO_SYSTEM_ACCOUNT =
|
||
"无达标上级时,权益进对应系统账户或总部社区";
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## API 端点设计
|
||
|
||
| 方法 | 路径 | 描述 | 认证 |
|
||
|------|------|------|------|
|
||
| GET | `/health` | 健康检查 | 否 |
|
||
| GET | `/rewards/summary` | 获取我的收益汇总 | JWT |
|
||
| GET | `/rewards/details` | 获取我的奖励明细 | JWT |
|
||
| GET | `/rewards/pending` | 获取待领取奖励(含倒计时) | JWT |
|
||
| POST | `/rewards/settle` | 结算可结算收益 | JWT |
|
||
| GET | `/rewards/settlement-history` | 获取结算历史 | JWT |
|
||
|
||
---
|
||
|
||
## 事件订阅 (Kafka Events)
|
||
|
||
### 订阅的事件
|
||
|
||
| Topic | 事件类型 | 触发条件 | 处理逻辑 |
|
||
|-------|---------|---------|---------|
|
||
| `planting.order.paid` | PlantingOrderPaidEvent | 认种订单支付成功 | 计算并分配所有奖励 |
|
||
| `planting.order.paid` | PlantingOrderPaidEvent | 认种订单支付成功 | 检查该用户是否有待领取奖励,若有则转为可结算 |
|
||
|
||
### 发布的事件
|
||
|
||
| Topic | 事件类型 | 触发条件 |
|
||
|-------|---------|---------|
|
||
| `reward.created` | RewardCreatedEvent | 奖励创建成功 |
|
||
| `reward.claimed` | RewardClaimedEvent | 待领取奖励被领取 |
|
||
| `reward.expired` | RewardExpiredEvent | 待领取奖励过期 |
|
||
| `reward.settled` | RewardSettledEvent | 奖励结算成功 |
|
||
|
||
---
|
||
|
||
## 定时任务
|
||
|
||
### 过期奖励检查
|
||
|
||
```typescript
|
||
// application/schedulers/reward-expiration.scheduler.ts
|
||
|
||
import { Injectable } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { RewardApplicationService } from '../services/reward-application.service';
|
||
|
||
@Injectable()
|
||
export class RewardExpirationScheduler {
|
||
constructor(
|
||
private readonly rewardService: RewardApplicationService,
|
||
) {}
|
||
|
||
/**
|
||
* 每小时检查过期的待领取奖励
|
||
*/
|
||
@Cron(CronExpression.EVERY_HOUR)
|
||
async handleExpiredRewards() {
|
||
console.log('开始检查过期奖励...');
|
||
|
||
const result = await this.rewardService.expireOverdueRewards();
|
||
|
||
console.log(`过期奖励处理完成:${result.expiredCount}笔,共${result.totalUsdtExpired} USDT`);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 开发顺序建议
|
||
|
||
1. **Phase 1: 项目初始化**
|
||
- 创建NestJS项目
|
||
- 安装依赖
|
||
- 配置环境变量
|
||
|
||
2. **Phase 2: 数据库层**
|
||
- 创建Prisma Schema
|
||
- 运行迁移和种子数据
|
||
- 创建PrismaService
|
||
|
||
3. **Phase 3: 领域层**
|
||
- 实现所有值对象
|
||
- 实现聚合根 (RewardLedgerEntry, RewardSummary)
|
||
- 实现领域事件
|
||
- 实现领域服务 (RewardCalculationService, RewardExpirationService)
|
||
- 编写单元测试
|
||
|
||
4. **Phase 4: 基础设施层**
|
||
- 实现仓储 (Repository Implementations)
|
||
- 实现外部服务客户端 (ReferralServiceClient, AuthorizationServiceClient, WalletServiceClient)
|
||
- 实现Kafka消费者和发布者
|
||
|
||
5. **Phase 5: 应用层**
|
||
- 实现应用服务 (RewardApplicationService)
|
||
- 实现定时任务 (RewardExpirationScheduler)
|
||
- 实现Command/Query handlers
|
||
|
||
6. **Phase 6: API层**
|
||
- 实现DTO
|
||
- 实现Controllers
|
||
- 配置Swagger文档
|
||
- 配置JWT认证
|
||
|
||
7. **Phase 7: 测试和部署**
|
||
- 集成测试
|
||
- E2E测试
|
||
- Docker配置
|
||
|
||
---
|
||
|
||
## 与前端页面对应关系
|
||
|
||
### 我的收益页面
|
||
- 显示待领取收益(含24小时倒计时)
|
||
- 显示可结算收益
|
||
- 显示已结算总额
|
||
- 显示已过期总额
|
||
- 调用: `GET /rewards/summary`
|
||
|
||
### 奖励明细页面
|
||
- 分页显示奖励流水
|
||
- 按状态/类型筛选
|
||
- 调用: `GET /rewards/details?status=PENDING&page=1&pageSize=20`
|
||
|
||
### 结算页面
|
||
- 选择结算币种(BNB/OG/USDT/DST)
|
||
- 显示可结算金额
|
||
- 点击结算按钮
|
||
- 调用: `POST /rewards/settle`
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
1. **24小时倒计时**: 前端需要实时显示倒计时,后端返回 `expireAt` 和 `remainingTimeMs`
|
||
2. **结算币种选择**: 结算时支持 BNB/OG/USDT/DST 四种币种
|
||
3. **过期奖励转移**: 过期奖励自动转入总部社区账户
|
||
4. **外部服务依赖**: 需要调用 referral-service 获取推荐链,调用 authorization-service 查找授权用户
|
||
5. **SWAP执行**: 结算时调用 wallet-service 执行 SWAP 操作
|
||
6. **事务一致性**: 奖励分配需要保证事务一致性,使用数据库事务
|
||
7. **幂等性**: Kafka消费者需要实现幂等性,避免重复处理
|