diff --git a/backend/services/reward-service/DEVELOPMENT_GUIDE.md b/backend/services/reward-service/DEVELOPMENT_GUIDE.md new file mode 100644 index 00000000..f51ab927 --- /dev/null +++ b/backend/services/reward-service/DEVELOPMENT_GUIDE.md @@ -0,0 +1,1992 @@ +# 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.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; +} +``` + +#### 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; + saveAll(entries: RewardLedgerEntry[]): Promise; + findById(entryId: bigint): Promise; + findByUserId( + userId: bigint, + filters?: { + status?: RewardStatus; + rightType?: RightType; + startDate?: Date; + endDate?: Date; + }, + pagination?: { page: number; pageSize: number }, + ): Promise; + findPendingByUserId(userId: bigint): Promise; + findSettleableByUserId(userId: bigint): Promise; + findExpiredPending(beforeDate: Date): Promise; + findBySourceOrderId(sourceOrderId: bigint): Promise; + countByUserId(userId: bigint, status?: RewardStatus): Promise; +} + +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; + findByUserId(userId: bigint): Promise; + getOrCreate(userId: bigint): Promise; + findByUserIds(userIds: bigint[]): Promise>; + findTopSettleableUsers(limit: number): Promise; +} + +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; + findNearestAuthorizedCity(userId: bigint, cityCode: string): Promise; + findNearestCommunity(userId: bigint): Promise; +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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消费者需要实现幂等性,避免重复处理