feat(reward-service): Implement complete reward service with DDD architecture

## Core Features
- 6 types of reward calculation (Share Right 500U, Province Team 20U,
  Province Area 15U+1% hashpower, City Team 40U, City Area 35U+2% hashpower,
  Community Right 80U)
- 24-hour countdown mechanism for pending rewards
- Reward settlement with multi-currency support (BNB/OG/USDT/DST)
- Automatic reward expiration with scheduled tasks

## Domain Layer
- RewardLedgerEntry aggregate root with state machine
  (PENDING -> SETTLEABLE -> SETTLED, PENDING -> EXPIRED)
- RewardSummary aggregate for user reward overview
- Value objects: Money, Hashpower, RewardSource, RewardStatus, RightType
- Domain events: RewardCreated, RewardClaimed, RewardSettled, RewardExpired
- Domain services: RewardCalculationService, RewardExpirationService

## Application Layer
- RewardApplicationService for orchestrating business workflows
- RewardExpirationScheduler for automatic expiration processing

## Infrastructure Layer
- PostgreSQL persistence with Prisma ORM
- Redis caching integration
- Kafka event publishing/consuming
- External service clients (Referral, Authorization, Wallet)

## API Layer
- REST endpoints: /health, /rewards/summary, /rewards/details,
  /rewards/pending, /rewards/settle
- JWT authentication with Passport.js
- Swagger/OpenAPI documentation

## Testing (77 tests, 100% pass rate)
- 43 unit tests for domain logic (Money, Hashpower, aggregates)
- 20 integration tests for application services
- 14 E2E tests for API endpoints
- Docker Compose test infrastructure (PostgreSQL, Redis, Kafka)

## Documentation
- docs/ARCHITECTURE.md - DDD architecture overview
- docs/API.md - REST API documentation
- docs/DEVELOPMENT.md - Developer guide
- docs/TESTING.md - Testing guide
- docs/DEPLOYMENT.md - Deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-12-01 01:57:40 -08:00
parent 85c820b5af
commit 9769012795
86 changed files with 19462 additions and 0 deletions

View File

@ -0,0 +1,34 @@
# 应用配置
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

View File

@ -0,0 +1,34 @@
# 应用配置
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

View File

@ -0,0 +1,25 @@
# Test Environment
NODE_ENV=test
PORT=3005
# Database (for integration tests with Docker)
DATABASE_URL=postgresql://test:test@localhost:5433/reward_test
# JWT
JWT_SECRET=test-secret-key-for-testing
JWT_ACCESS_EXPIRES_IN=1h
# Redis (for integration tests with Docker)
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_PASSWORD=
# Kafka (for integration tests with Docker)
KAFKA_BROKERS=localhost:9093
KAFKA_CLIENT_ID=reward-service-test
KAFKA_GROUP_ID=reward-service-test-group
# External Services (mocked in tests)
REFERRAL_SERVICE_URL=http://localhost:3001
AUTHORIZATION_SERVICE_URL=http://localhost:3002
WALLET_SERVICE_URL=http://localhost:3003

View File

@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment variables
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
# Testing
coverage/
# Prisma
/generated/prisma
# Temporary files
*.tmp
*.temp
.cache/
# Claude Code
.claude/

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -0,0 +1,47 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy package files and install production dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy Prisma files
COPY prisma ./prisma/
# Generate Prisma client for production
RUN npx prisma generate
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=3005
EXPOSE 3005
# Run the application
CMD ["node", "dist/main"]

View File

@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy Prisma files and generate client
COPY prisma ./prisma/
COPY prisma.config.ts ./
RUN npx prisma generate
# Copy source code and tests
COPY . .
# Run tests
CMD ["npm", "test"]

View File

@ -0,0 +1,93 @@
.PHONY: install build test test-unit test-integration test-e2e test-cov test-docker-all clean docker-up docker-down lint format
# ============================================
# 基础命令
# ============================================
install:
npm install
build:
npm run build
lint:
npm run lint
format:
npm run format
clean:
rm -rf dist coverage node_modules
# ============================================
# 测试命令
# ============================================
# 单元测试 - 测试领域逻辑和值对象
test-unit:
npm test -- --testPathPatterns='src/.*\.spec\.ts$$' --verbose
# 集成测试 - 测试服务层和仓储
test-integration:
npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$$' --verbose
# 端到端测试 - 测试完整API流程
test-e2e:
npm run test:e2e -- --verbose
# 覆盖率测试
test-cov:
npm run test:cov
# 运行所有测试
test-all: test-unit test-integration test-e2e
# ============================================
# Docker 测试命令
# ============================================
# 启动测试依赖服务
docker-up:
docker-compose -f docker-compose.test.yml up -d
# 关闭测试依赖服务
docker-down:
docker-compose -f docker-compose.test.yml down -v
# 在Docker中运行所有测试
test-docker-all:
docker-compose -f docker-compose.test.yml up -d
sleep 5
npm test -- --testPathPatterns='src/.*\.spec\.ts$$' --verbose || true
npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$$' --verbose || true
npm run test:e2e -- --verbose || true
docker-compose -f docker-compose.test.yml down -v
# 构建测试Docker镜像
docker-build-test:
docker build -t reward-service-test:latest -f Dockerfile.test .
# 在Docker容器中运行测试
docker-run-test:
docker run --rm \
--network reward-test-network \
-e DATABASE_URL="postgresql://test:test@postgres:5432/reward_test" \
-e REDIS_HOST=redis \
-e KAFKA_BROKERS=kafka:9092 \
reward-service-test:latest
# ============================================
# 数据库命令
# ============================================
db-migrate:
npx prisma migrate dev
db-reset:
npx prisma migrate reset --force
db-seed:
npx prisma db seed
db-studio:
npx prisma studio

View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -0,0 +1,74 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: reward-test-postgres
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: reward_test
ports:
- '5433:5432'
volumes:
- postgres-test-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U test -d reward_test']
interval: 5s
timeout: 5s
retries: 5
networks:
- reward-test-network
redis:
image: redis:7-alpine
container_name: reward-test-redis
ports:
- '6380:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
networks:
- reward-test-network
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: reward-test-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
networks:
- reward-test-network
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: reward-test-kafka
depends_on:
- zookeeper
ports:
- '9093:9092'
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:9093
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
healthcheck:
test: ['CMD-SHELL', 'kafka-broker-api-versions --bootstrap-server localhost:9092']
interval: 10s
timeout: 10s
retries: 5
networks:
- reward-test-network
volumes:
postgres-test-data:
networks:
reward-test-network:
name: reward-test-network
driver: bridge

View File

@ -0,0 +1,426 @@
# Reward Service API 接口文档
## 概述
本文档描述 Reward Service 的 RESTful API 接口。所有接口均需要 JWT 认证(除健康检查外)。
### Base URL
```
http://localhost:3000
```
### 认证方式
所有受保护的端点都需要在请求头中携带 JWT Token
```
Authorization: Bearer <token>
```
### 通用响应格式
**成功响应**:
```json
{
"data": { ... },
"pagination": {
"page": 1,
"pageSize": 20,
"total": 100
}
}
```
**错误响应**:
```json
{
"statusCode": 400,
"message": "错误描述",
"error": "Bad Request"
}
```
---
## API 端点
### 1. 健康检查
#### GET /health
检查服务健康状态,无需认证。
**请求**:
```http
GET /health HTTP/1.1
Host: localhost:3000
```
**响应** (200 OK):
```json
{
"status": "ok",
"service": "reward-service",
"timestamp": "2024-12-01T00:00:00.000Z"
}
```
---
### 2. 奖励查询
#### GET /rewards/summary
获取当前用户的奖励汇总信息。
**请求**:
```http
GET /rewards/summary HTTP/1.1
Host: localhost:3000
Authorization: Bearer <token>
```
**响应** (200 OK):
```json
{
"pendingUsdt": 1000,
"pendingHashpower": 0.5,
"pendingExpireAt": "2024-12-02T00:00:00.000Z",
"pendingRemainingTimeMs": 43200000,
"settleableUsdt": 500,
"settleableHashpower": 0.2,
"settledTotalUsdt": 2000,
"settledTotalHashpower": 1.0,
"expiredTotalUsdt": 100,
"expiredTotalHashpower": 0.1
}
```
**响应字段说明**:
| 字段 | 类型 | 描述 |
|------|------|------|
| `pendingUsdt` | number | 待领取 USDT 金额 |
| `pendingHashpower` | number | 待领取算力 |
| `pendingExpireAt` | string \| null | 最近过期时间 (ISO 8601) |
| `pendingRemainingTimeMs` | number | 剩余过期毫秒数 |
| `settleableUsdt` | number | 可结算 USDT 金额 |
| `settleableHashpower` | number | 可结算算力 |
| `settledTotalUsdt` | number | 累计已结算 USDT |
| `settledTotalHashpower` | number | 累计已结算算力 |
| `expiredTotalUsdt` | number | 累计已过期 USDT |
| `expiredTotalHashpower` | number | 累计已过期算力 |
---
#### GET /rewards/details
获取用户奖励明细,支持筛选和分页。
**请求**:
```http
GET /rewards/details?status=PENDING&page=1&pageSize=20 HTTP/1.1
Host: localhost:3000
Authorization: Bearer <token>
```
**查询参数**:
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `status` | string | 否 | 奖励状态筛选 |
| `rightType` | string | 否 | 权益类型筛选 |
| `page` | number | 否 | 页码,默认 1 |
| `pageSize` | number | 否 | 每页条数,默认 20 |
**status 枚举值**:
- `PENDING` - 待领取
- `SETTLEABLE` - 可结算
- `SETTLED` - 已结算
- `EXPIRED` - 已过期
**rightType 枚举值**:
- `SHARE_RIGHT` - 分享权益 (500 USDT)
- `PROVINCE_AREA_RIGHT` - 省区域权益 (15 USDT + 1% 算力)
- `PROVINCE_TEAM_RIGHT` - 省团队权益 (20 USDT)
- `CITY_AREA_RIGHT` - 市区域权益 (35 USDT + 2% 算力)
- `CITY_TEAM_RIGHT` - 市团队权益 (40 USDT)
- `COMMUNITY_RIGHT` - 社区权益 (80 USDT)
**响应** (200 OK):
```json
{
"data": [
{
"id": "1",
"rightType": "SHARE_RIGHT",
"usdtAmount": 500,
"hashpowerAmount": 0,
"rewardStatus": "PENDING",
"createdAt": "2024-12-01T00:00:00.000Z",
"expireAt": "2024-12-02T00:00:00.000Z",
"remainingTimeMs": 86400000,
"claimedAt": null,
"settledAt": null,
"expiredAt": null,
"memo": "分享权益来自用户100的认种"
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 1
}
}
```
**响应字段说明**:
| 字段 | 类型 | 描述 |
|------|------|------|
| `id` | string | 奖励流水 ID |
| `rightType` | string | 权益类型 |
| `usdtAmount` | number | USDT 金额 |
| `hashpowerAmount` | number | 算力金额 |
| `rewardStatus` | string | 奖励状态 |
| `createdAt` | string | 创建时间 |
| `expireAt` | string \| null | 过期时间 (仅待领取状态) |
| `remainingTimeMs` | number | 剩余过期毫秒数 |
| `claimedAt` | string \| null | 领取时间 |
| `settledAt` | string \| null | 结算时间 |
| `expiredAt` | string \| null | 过期时间 |
| `memo` | string | 备注信息 |
---
#### GET /rewards/pending
获取用户待领取奖励列表,包含倒计时信息。
**请求**:
```http
GET /rewards/pending HTTP/1.1
Host: localhost:3000
Authorization: Bearer <token>
```
**响应** (200 OK):
```json
[
{
"id": "1",
"rightType": "SHARE_RIGHT",
"usdtAmount": 500,
"hashpowerAmount": 0,
"createdAt": "2024-12-01T00:00:00.000Z",
"expireAt": "2024-12-02T00:00:00.000Z",
"remainingTimeMs": 43200000,
"memo": "分享权益来自用户100的认种24h内认种可领取"
}
]
```
---
### 3. 奖励结算
#### POST /rewards/settle
结算可结算的奖励,支持多种目标币种。
**请求**:
```http
POST /rewards/settle HTTP/1.1
Host: localhost:3000
Authorization: Bearer <token>
Content-Type: application/json
{
"settleCurrency": "BNB"
}
```
**请求体参数**:
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `settleCurrency` | string | 是 | 目标结算币种 |
**支持的结算币种**:
- `BNB` - 币安币
- `OG` - OG Token
- `USDT` - 泰达币
- `DST` - DST Token
**成功响应** (201 Created):
```json
{
"success": true,
"settledUsdtAmount": 500,
"receivedAmount": 0.25,
"settleCurrency": "BNB",
"txHash": "0x123abc..."
}
```
**失败响应 - 无可结算奖励** (201 Created):
```json
{
"success": false,
"settledUsdtAmount": 0,
"receivedAmount": 0,
"settleCurrency": "BNB",
"error": "没有可结算的收益"
}
```
**失败响应 - 钱包服务错误** (201 Created):
```json
{
"success": false,
"settledUsdtAmount": 500,
"receivedAmount": 0,
"settleCurrency": "BNB",
"error": "Insufficient liquidity"
}
```
**验证错误** (400 Bad Request):
```json
{
"statusCode": 400,
"message": ["settleCurrency should not be empty"],
"error": "Bad Request"
}
```
**响应字段说明**:
| 字段 | 类型 | 描述 |
|------|------|------|
| `success` | boolean | 结算是否成功 |
| `settledUsdtAmount` | number | 结算的 USDT 金额 |
| `receivedAmount` | number | 收到的目标币种金额 |
| `settleCurrency` | string | 目标结算币种 |
| `txHash` | string \| undefined | 交易哈希 (成功时返回) |
| `error` | string \| undefined | 错误信息 (失败时返回) |
---
## 错误码
| HTTP Status | 错误码 | 描述 |
|-------------|--------|------|
| 400 | Bad Request | 请求参数验证失败 |
| 401 | Unauthorized | 未认证或 Token 无效 |
| 403 | Forbidden | 无权访问 |
| 404 | Not Found | 资源不存在 |
| 500 | Internal Server Error | 服务器内部错误 |
---
## 数据类型定义
### RewardStatus
```typescript
enum RewardStatus {
PENDING = 'PENDING', // 待领取 (24h倒计时)
SETTLEABLE = 'SETTLEABLE', // 可结算
SETTLED = 'SETTLED', // 已结算
EXPIRED = 'EXPIRED', // 已过期
}
```
### RightType
```typescript
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
}
```
### 权益金额配置
```typescript
const RIGHT_AMOUNTS = {
SHARE_RIGHT: { usdt: 500, hashpowerPercent: 0 },
PROVINCE_AREA_RIGHT: { usdt: 15, hashpowerPercent: 1 },
PROVINCE_TEAM_RIGHT: { usdt: 20, hashpowerPercent: 0 },
CITY_AREA_RIGHT: { usdt: 35, hashpowerPercent: 2 },
CITY_TEAM_RIGHT: { usdt: 40, hashpowerPercent: 0 },
COMMUNITY_RIGHT: { usdt: 80, hashpowerPercent: 0 },
};
```
---
## Swagger 文档
服务启动后,可通过以下地址访问 Swagger UI
```
http://localhost:3000/api
```
---
## 使用示例
### cURL 示例
```bash
# 健康检查
curl http://localhost:3000/health
# 获取奖励汇总
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/rewards/summary
# 获取奖励明细 (筛选待领取)
curl -H "Authorization: Bearer <token>" \
"http://localhost:3000/rewards/details?status=PENDING&page=1&pageSize=10"
# 结算奖励
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"settleCurrency": "BNB"}' \
http://localhost:3000/rewards/settle
```
### JavaScript 示例
```javascript
const BASE_URL = 'http://localhost:3000';
const TOKEN = 'your-jwt-token';
// 获取奖励汇总
async function getRewardSummary() {
const response = await fetch(`${BASE_URL}/rewards/summary`, {
headers: {
'Authorization': `Bearer ${TOKEN}`,
},
});
return response.json();
}
// 结算奖励
async function settleRewards(currency) {
const response = await fetch(`${BASE_URL}/rewards/settle`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ settleCurrency: currency }),
});
return response.json();
}
```

View File

@ -0,0 +1,511 @@
# Reward Service 架构概览
## 服务概述
Reward Service 是 RWA Durian Queen 平台的核心收益分配服务,负责处理用户认种榴莲树后的收益计算、分配、领取和结算。
### 技术栈
- **框架**: NestJS 11.x
- **语言**: TypeScript 5.7
- **数据库**: PostgreSQL 15 (通过 Prisma 7.x ORM)
- **缓存**: Redis 7.x
- **消息队列**: Apache Kafka
- **测试**: Jest 30.x + Supertest 7.x
- **API文档**: Swagger/OpenAPI
---
## 领域驱动设计 (DDD) 架构
本服务采用领域驱动设计架构,分为以下四层:
```
┌─────────────────────────────────────────────────────────────┐
│ API Layer │
│ Controllers, DTOs, Guards │
├─────────────────────────────────────────────────────────────┤
│ Application Layer │
│ Application Services, Schedulers, Use Cases │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ Aggregates, Value Objects, Domain Events, Domain Services │
├─────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ Repositories, External Clients, Kafka, Redis │
└─────────────────────────────────────────────────────────────┘
```
### 目录结构
```
src/
├── api/ # API层
│ ├── controllers/ # 控制器
│ │ ├── health.controller.ts
│ │ ├── reward.controller.ts
│ │ └── settlement.controller.ts
│ └── dto/ # 数据传输对象
│ ├── request/
│ └── response/
├── application/ # 应用层
│ ├── services/
│ │ └── reward-application.service.ts
│ └── schedulers/
│ └── reward-expiration.scheduler.ts
├── domain/ # 领域层 (核心)
│ ├── aggregates/ # 聚合根
│ │ ├── reward-ledger-entry/
│ │ └── reward-summary/
│ ├── value-objects/ # 值对象
│ │ ├── money.vo.ts
│ │ ├── hashpower.vo.ts
│ │ ├── reward-source.vo.ts
│ │ ├── reward-status.enum.ts
│ │ └── right-type.enum.ts
│ ├── events/ # 领域事件
│ │ ├── reward-created.event.ts
│ │ ├── reward-claimed.event.ts
│ │ ├── reward-expired.event.ts
│ │ └── reward-settled.event.ts
│ ├── services/ # 领域服务
│ │ ├── reward-calculation.service.ts
│ │ └── reward-expiration.service.ts
│ └── repositories/ # 仓储接口
├── infrastructure/ # 基础设施层
│ ├── persistence/
│ │ ├── prisma/
│ │ ├── repositories/ # 仓储实现
│ │ └── mappers/ # 持久化映射
│ ├── external/ # 外部服务客户端
│ │ ├── referral-service/
│ │ ├── authorization-service/
│ │ └── wallet-service/
│ ├── kafka/ # Kafka消息
│ └── redis/ # Redis缓存
├── shared/ # 共享模块
│ ├── guards/
│ └── strategies/
└── config/ # 配置
```
---
## 核心领域模型
### 聚合根
#### 1. RewardLedgerEntry (奖励流水)
奖励流水是核心聚合根,记录每一笔奖励的详细信息和状态变化。
```typescript
class RewardLedgerEntry {
// 身份
id: bigint
userId: bigint
rewardSource: RewardSource
// 金额
usdtAmount: Money
hashpowerAmount: Hashpower
// 状态
rewardStatus: RewardStatus // PENDING | SETTLEABLE | SETTLED | EXPIRED
// 时间戳
createdAt: Date
expireAt: Date | null // 待领取奖励24h后过期
claimedAt: Date | null
settledAt: Date | null
expiredAt: Date | null
// 行为
claim(): void // 领取 (PENDING → SETTLEABLE)
expire(): void // 过期 (PENDING → EXPIRED)
settle(): void // 结算 (SETTLEABLE → SETTLED)
}
```
**状态机**:
```
┌──────────┐
│ PENDING │
└────┬─────┘
┌────────────┼────────────┐
│ 用户认种 │ │ 24h超时
▼ │ ▼
┌───────────┐ │ ┌─────────┐
│ SETTLEABLE│ │ │ EXPIRED │
└─────┬─────┘ │ └─────────┘
│ │
│ 用户结算 │
▼ │
┌──────────┐ │
│ SETTLED │ │
└──────────┘ │
```
#### 2. RewardSummary (奖励汇总)
用户维度的奖励汇总,提供快速查询能力。
```typescript
class RewardSummary {
userId: bigint
// 待领取 (24h倒计时)
pendingUsdt: Money
pendingHashpower: Hashpower
pendingExpireAt: Date | null
// 可结算
settleableUsdt: Money
settleableHashpower: Hashpower
// 累计已结算
settledTotalUsdt: Money
settledTotalHashpower: Hashpower
// 累计已过期
expiredTotalUsdt: Money
expiredTotalHashpower: Hashpower
}
```
### 值对象
| 值对象 | 描述 | 不变式 |
|--------|------|--------|
| `Money` | 货币金额 | 金额 >= 0, 货币类型不可变 |
| `Hashpower` | 算力 | 值 >= 0 |
| `RewardSource` | 奖励来源 | 关联订单和用户不可变 |
| `RewardStatus` | 奖励状态 | 枚举值 |
| `RightType` | 权益类型 | 枚举值 |
---
## 收益类型
系统支持6种收益类型每种有不同的计算规则
| 权益类型 | USDT金额 | 算力百分比 | 接收方 |
|---------|---------|-----------|--------|
| 分享权益 (SHARE_RIGHT) | 500 U/棵 | 0% | 直接推荐人 |
| 省区域权益 (PROVINCE_AREA_RIGHT) | 15 U/棵 | 1% | 系统省公司 |
| 省团队权益 (PROVINCE_TEAM_RIGHT) | 20 U/棵 | 0% | 授权省公司 |
| 市区域权益 (CITY_AREA_RIGHT) | 35 U/棵 | 2% | 系统市公司 |
| 市团队权益 (CITY_TEAM_RIGHT) | 40 U/棵 | 0% | 授权市公司 |
| 社区权益 (COMMUNITY_RIGHT) | 80 U/棵 | 0% | 所属社区 |
**总计**: 690 USDT + 3% 算力 / 棵
---
## 核心业务流程
### 1. 奖励分配流程
```
订单支付成功事件
┌─────────────────┐
│ 计算6种权益奖励 │
│ RewardCalculation│
└────────┬────────┘
┌─────────────────┐
│ 保存奖励流水 │
│ RewardLedgerEntry│
└────────┬────────┘
┌─────────────────┐
│ 更新用户汇总 │
│ RewardSummary │
└────────┬────────┘
┌─────────────────┐
│ 发布领域事件 │
│ Kafka │
└─────────────────┘
```
### 2. 分享权益24小时机制
```
推荐人已认种?
├── 是 → 直接可结算 (SETTLEABLE)
└── 否 → 待领取 (PENDING, 24h倒计时)
├── 24h内认种 → claim() → 可结算
└── 24h超时 → expire() → 进总部社区
```
### 3. 结算流程
```
用户发起结算请求 (选择BNB/OG/USDT/DST)
┌───────────────┐
│ 获取可结算奖励 │
└───────┬───────┘
┌───────────────┐
│ 调用钱包服务 │
│ 执行SWAP │
└───────┬───────┘
┌───────────────┐
│ 更新奖励状态 │
│ SETTLED │
└───────┬───────┘
┌───────────────┐
│ 发布结算事件 │
└───────────────┘
```
---
## 外部服务集成
### 防腐层设计
通过接口定义与外部服务解耦:
```typescript
// 推荐关系服务
interface IReferralServiceClient {
getReferralChain(userId: bigint): Promise<{
ancestors: Array<{ userId: bigint; hasPlanted: boolean }>;
}>;
}
// 授权体系服务
interface IAuthorizationServiceClient {
findNearestAuthorizedProvince(userId: bigint, provinceCode: string): Promise<bigint | null>;
findNearestAuthorizedCity(userId: bigint, cityCode: string): Promise<bigint | null>;
findNearestCommunity(userId: bigint): Promise<bigint | null>;
}
// 钱包服务
interface IWalletServiceClient {
executeSwap(params: {
userId: bigint;
usdtAmount: number;
targetCurrency: string;
}): Promise<SwapResult>;
}
```
### 服务依赖
```
┌──────────────────┐ ┌───────────────────┐
│ Planting Service │────▶│ │
│ (认种服务) │ │ │
└──────────────────┘ │ │
│ Reward Service │
┌──────────────────┐ │ │
│ Referral Service │────▶│ │
│ (推荐服务) │ │ │
└──────────────────┘ │ │
│ │
┌──────────────────┐ │ │
│Authorization Svc │────▶│ │
│ (授权服务) │ │ │
└──────────────────┘ └─────────┬─────────┘
┌───────────────────┐
│ Wallet Service │
│ (钱包服务) │
└───────────────────┘
```
---
## 消息与事件
### Kafka Topics
| Topic | 描述 | 生产者 | 消费者 |
|-------|------|--------|--------|
| `planting.order.paid` | 认种订单支付成功 | Planting Service | Reward Service |
| `planting.user.planted` | 用户完成认种 | Planting Service | Reward Service |
| `reward.created` | 奖励创建 | Reward Service | - |
| `reward.claimed` | 奖励领取 | Reward Service | - |
| `reward.settled` | 奖励结算 | Reward Service | Wallet Service |
| `reward.expired` | 奖励过期 | Reward Service | - |
### 领域事件
```typescript
// 奖励创建事件
interface RewardCreatedEvent {
entryId: string;
userId: string;
sourceOrderId: string;
rightType: RightType;
usdtAmount: number;
hashpowerAmount: number;
rewardStatus: RewardStatus;
expireAt: Date | null;
}
// 奖励领取事件
interface RewardClaimedEvent {
entryId: string;
userId: string;
usdtAmount: number;
hashpowerAmount: number;
}
// 奖励结算事件
interface RewardSettledEvent {
entryId: string;
userId: string;
usdtAmount: number;
settleCurrency: string;
receivedAmount: number;
}
// 奖励过期事件
interface RewardExpiredEvent {
entryId: string;
userId: string;
usdtAmount: number;
transferredTo: string; // 'HEADQUARTERS_COMMUNITY'
}
```
---
## 定时任务
### 奖励过期检查
```typescript
@Cron('0 * * * * *') // 每分钟执行
async handleExpiredRewards() {
await this.rewardApplicationService.expireOverdueRewards();
}
```
处理逻辑:
1. 查找所有过期的待领取奖励
2. 将奖励状态改为已过期
3. 更新原用户的汇总数据
4. 将金额转入总部社区账户
5. 发布过期事件
---
## 数据库设计
### 核心表结构
```sql
-- 奖励流水表
CREATE TABLE reward_ledger_entries (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
source_order_id BIGINT NOT NULL,
source_user_id BIGINT NOT NULL,
right_type VARCHAR(50) NOT NULL,
usdt_amount DECIMAL(18,2) NOT NULL,
hashpower_amount DECIMAL(18,8) NOT NULL,
reward_status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP,
claimed_at TIMESTAMP,
settled_at TIMESTAMP,
expired_at TIMESTAMP,
memo TEXT,
INDEX idx_user_status (user_id, reward_status),
INDEX idx_expire_at (expire_at) WHERE reward_status = 'PENDING'
);
-- 用户奖励汇总表
CREATE TABLE reward_summaries (
user_id BIGINT PRIMARY KEY,
pending_usdt DECIMAL(18,2) NOT NULL DEFAULT 0,
pending_hashpower DECIMAL(18,8) NOT NULL DEFAULT 0,
pending_expire_at TIMESTAMP,
settleable_usdt DECIMAL(18,2) NOT NULL DEFAULT 0,
settleable_hashpower DECIMAL(18,8) NOT NULL DEFAULT 0,
settled_total_usdt DECIMAL(18,2) NOT NULL DEFAULT 0,
settled_total_hashpower DECIMAL(18,8) NOT NULL DEFAULT 0,
expired_total_usdt DECIMAL(18,2) NOT NULL DEFAULT 0,
expired_total_hashpower DECIMAL(18,8) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
---
## 安全设计
### 认证与授权
- **JWT认证**: 所有API请求需要携带有效的JWT Token
- **用户隔离**: 用户只能访问自己的奖励数据
- **Passport策略**: 使用`passport-jwt`进行Token验证
```typescript
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
});
}
}
```
### 数据完整性
- 使用数据库事务保证数据一致性
- 领域模型不变式检查
- 状态机约束防止非法状态转换
---
## 性能优化
### 缓存策略
- Redis缓存用户奖励汇总
- 热点数据预加载
### 数据库优化
- 复合索引优化查询
- 分页查询避免全表扫描
- 批量写入减少IO
### 消息处理
- Kafka消费者组实现负载均衡
- 幂等性设计防止重复处理

View File

@ -0,0 +1,679 @@
# Reward Service 部署指南
## 部署概述
本文档描述 Reward Service 的部署架构和操作指南。
### 部署架构
```
┌─────────────────┐
│ Load Balancer │
│ (Nginx/ALB) │
└────────┬────────┘
┌────────────────┼────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Reward Svc │ │ Reward Svc │ │ Reward Svc │
│ Instance 1 │ │ Instance 2 │ │ Instance 3 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┼────────────────┘
┌───────────────────────┼───────────────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌──────▼──────┐
│PostgreSQL│ │ Redis │ │ Kafka │
│ Primary │ │ Cluster │ │ Cluster │
└────┬────┘ └─────────────┘ └────────────┘
┌────▼────┐
│PostgreSQL│
│ Replica │
└──────────┘
```
---
## 环境要求
### 生产环境配置
| 组件 | 最低配置 | 推荐配置 |
|------|---------|---------|
| CPU | 2 vCPU | 4 vCPU |
| 内存 | 4 GB | 8 GB |
| 存储 | 50 GB SSD | 100 GB SSD |
| Node.js | 20.x LTS | 20.x LTS |
### 基础设施要求
| 服务 | 版本 | 说明 |
|------|------|------|
| PostgreSQL | 15.x | 主数据库 |
| Redis | 7.x | 缓存和会话 |
| Apache Kafka | 3.x | 消息队列 |
---
## Docker 部署
### Dockerfile
```dockerfile
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
COPY prisma ./prisma/
# 安装依赖
RUN npm ci
# 生成 Prisma Client
RUN npx prisma generate
# 复制源代码
COPY . .
# 构建
RUN npm run build
# 生产阶段
FROM node:20-alpine AS production
WORKDIR /app
# 安装生产依赖
COPY package*.json ./
RUN npm ci --only=production
# 复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# 设置环境变量
ENV NODE_ENV=production
ENV PORT=3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# 暴露端口
EXPOSE 3000
# 启动命令
CMD ["node", "dist/main.js"]
```
### docker-compose.yml (生产)
```yaml
version: '3.8'
services:
reward-service:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
- KAFKA_BROKERS=${KAFKA_BROKERS}
- JWT_SECRET=${JWT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
kafka:
condition: service_healthy
deploy:
replicas: 3
resources:
limits:
cpus: '2'
memory: 4G
reservations:
cpus: '1'
memory: 2G
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
postgres:
image: postgres:15-alpine
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
healthcheck:
test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"]
interval: 10s
timeout: 10s
retries: 5
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
volumes:
postgres-data:
redis-data:
```
### 构建和推送镜像
```bash
# 构建镜像
docker build -t reward-service:latest .
# 标记镜像
docker tag reward-service:latest your-registry/reward-service:v1.0.0
# 推送到镜像仓库
docker push your-registry/reward-service:v1.0.0
```
---
## Kubernetes 部署
### Deployment
```yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: reward-service
namespace: rwadurian
labels:
app: reward-service
spec:
replicas: 3
selector:
matchLabels:
app: reward-service
template:
metadata:
labels:
app: reward-service
spec:
containers:
- name: reward-service
image: your-registry/reward-service:v1.0.0
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: reward-service-secrets
key: database-url
- name: REDIS_HOST
valueFrom:
configMapKeyRef:
name: reward-service-config
key: redis-host
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: reward-service-secrets
key: jwt-secret
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2000m"
memory: "4Gi"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
```
### Service
```yaml
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: reward-service
namespace: rwadurian
spec:
selector:
app: reward-service
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP
```
### Ingress
```yaml
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: reward-service-ingress
namespace: rwadurian
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: api.rwadurian.com
http:
paths:
- path: /rewards
pathType: Prefix
backend:
service:
name: reward-service
port:
number: 80
```
### ConfigMap
```yaml
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: reward-service-config
namespace: rwadurian
data:
redis-host: "redis-master.rwadurian.svc.cluster.local"
redis-port: "6379"
kafka-brokers: "kafka-0.kafka.rwadurian.svc.cluster.local:9092"
```
### Secret
```yaml
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: reward-service-secrets
namespace: rwadurian
type: Opaque
stringData:
database-url: "postgresql://user:password@postgres:5432/reward_db"
jwt-secret: "your-jwt-secret-key"
```
### HorizontalPodAutoscaler
```yaml
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: reward-service-hpa
namespace: rwadurian
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reward-service
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
```
### 部署命令
```bash
# 创建命名空间
kubectl create namespace rwadurian
# 应用配置
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
# 部署服务
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/hpa.yaml
# 查看部署状态
kubectl get pods -n rwadurian
kubectl get services -n rwadurian
# 查看日志
kubectl logs -f deployment/reward-service -n rwadurian
```
---
## 数据库迁移
### 生产环境迁移
```bash
# 1. 备份数据库
pg_dump -h $DB_HOST -U $DB_USER -d $DB_NAME > backup_$(date +%Y%m%d).sql
# 2. 运行迁移
DATABASE_URL=$PRODUCTION_DATABASE_URL npx prisma migrate deploy
# 3. 验证迁移
npx prisma db pull --print
```
### 回滚策略
```bash
# 查看迁移历史
npx prisma migrate status
# 回滚到指定版本 (手动)
psql -h $DB_HOST -U $DB_USER -d $DB_NAME < rollback_migration.sql
```
---
## 环境变量配置
### 生产环境变量
| 变量 | 描述 | 示例 |
|------|------|------|
| `NODE_ENV` | 运行环境 | `production` |
| `PORT` | 服务端口 | `3000` |
| `DATABASE_URL` | 数据库连接串 | `postgresql://user:pass@host:5432/db` |
| `REDIS_HOST` | Redis主机 | `redis-master` |
| `REDIS_PORT` | Redis端口 | `6379` |
| `KAFKA_BROKERS` | Kafka集群 | `kafka-0:9092,kafka-1:9092` |
| `KAFKA_CLIENT_ID` | Kafka客户端ID | `reward-service` |
| `KAFKA_GROUP_ID` | Kafka消费组ID | `reward-service-group` |
| `JWT_SECRET` | JWT密钥 | `<strong-secret>` |
| `LOG_LEVEL` | 日志级别 | `info` |
---
## 监控与告警
### 健康检查端点
```http
GET /health
```
响应:
```json
{
"status": "ok",
"service": "reward-service",
"timestamp": "2024-12-01T00:00:00.000Z"
}
```
### Prometheus 指标
添加 `@nestjs/terminus` 和 Prometheus 指标:
```typescript
// src/api/controllers/metrics.controller.ts
@Controller('metrics')
export class MetricsController {
@Get()
@Header('Content-Type', 'text/plain')
async getMetrics() {
return register.metrics();
}
}
```
### 关键指标
| 指标 | 描述 | 告警阈值 |
|------|------|---------|
| `http_request_duration_seconds` | 请求响应时间 | P99 > 2s |
| `http_requests_total` | 请求总数 | - |
| `http_request_errors_total` | 错误请求数 | 错误率 > 1% |
| `reward_distributed_total` | 分配的奖励数 | - |
| `reward_settled_total` | 结算的奖励数 | - |
| `reward_expired_total` | 过期的奖励数 | - |
### Grafana 仪表板
关键面板:
1. 请求吞吐量 (QPS)
2. 响应时间分布 (P50/P90/P99)
3. 错误率
4. 奖励分配/结算/过期趋势
5. 数据库连接池状态
6. Redis 缓存命中率
---
## 日志管理
### 日志格式
```typescript
// 结构化日志输出
{
"timestamp": "2024-12-01T00:00:00.000Z",
"level": "info",
"context": "RewardApplicationService",
"message": "Distributed 6 rewards for order 123",
"metadata": {
"orderId": "123",
"userId": "100",
"rewardCount": 6
}
}
```
### 日志级别
| 级别 | 用途 |
|------|------|
| `error` | 错误和异常 |
| `warn` | 警告信息 |
| `info` | 业务日志 |
| `debug` | 调试信息 (仅开发环境) |
### ELK 集成
```yaml
# filebeat.yml
filebeat.inputs:
- type: container
paths:
- /var/lib/docker/containers/*/*.log
processors:
- add_kubernetes_metadata:
output.elasticsearch:
hosts: ["elasticsearch:9200"]
indices:
- index: "reward-service-%{+yyyy.MM.dd}"
```
---
## CI/CD 流水线
### GitHub Actions
```yaml
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint
- run: npm test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t reward-service:${{ github.sha }} .
- name: Push to registry
run: |
docker tag reward-service:${{ github.sha }} ${{ secrets.REGISTRY }}/reward-service:${{ github.sha }}
docker push ${{ secrets.REGISTRY }}/reward-service:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to Kubernetes
run: |
kubectl set image deployment/reward-service \
reward-service=${{ secrets.REGISTRY }}/reward-service:${{ github.sha }} \
-n rwadurian
```
---
## 故障排除
### 常见问题
#### 1. 服务无法启动
```bash
# 检查日志
kubectl logs -f deployment/reward-service -n rwadurian
# 检查环境变量
kubectl exec -it deployment/reward-service -n rwadurian -- env
# 检查数据库连接
kubectl exec -it deployment/reward-service -n rwadurian -- \
npx prisma db pull
```
#### 2. 数据库连接问题
```bash
# 测试数据库连接
kubectl run -it --rm debug --image=postgres:15-alpine --restart=Never -- \
psql -h postgres -U user -d reward_db
# 检查网络策略
kubectl get networkpolicy -n rwadurian
```
#### 3. Kafka 连接问题
```bash
# 列出 Kafka topics
kubectl exec -it kafka-0 -- \
kafka-topics --list --bootstrap-server localhost:9092
# 检查消费者组
kubectl exec -it kafka-0 -- \
kafka-consumer-groups --bootstrap-server localhost:9092 --describe --group reward-service-group
```
### 回滚部署
```bash
# 查看历史版本
kubectl rollout history deployment/reward-service -n rwadurian
# 回滚到上一版本
kubectl rollout undo deployment/reward-service -n rwadurian
# 回滚到指定版本
kubectl rollout undo deployment/reward-service -n rwadurian --to-revision=2
```
---
## 安全最佳实践
1. **密钥管理**: 使用 Kubernetes Secrets 或外部密钥管理服务 (Vault)
2. **网络隔离**: 使用 NetworkPolicy 限制 Pod 间通信
3. **镜像安全**: 定期扫描镜像漏洞
4. **最小权限**: 使用非 root 用户运行容器
5. **TLS**: 启用服务间 mTLS
6. **审计日志**: 记录所有敏感操作

View File

@ -0,0 +1,544 @@
# Reward Service 开发指南
## 环境准备
### 系统要求
- **Node.js**: >= 20.x LTS
- **npm**: >= 10.x
- **Docker**: >= 24.x (用于本地开发环境)
- **Git**: >= 2.x
### 开发环境设置
#### 1. 克隆项目
```bash
git clone <repository-url>
cd backend/services/reward-service
```
#### 2. 安装依赖
```bash
npm install
```
#### 3. 配置环境变量
创建 `.env` 文件:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
# 应用配置
NODE_ENV=development
PORT=3000
# 数据库配置
DATABASE_URL="postgresql://postgres:password@localhost:5432/reward_db"
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
# Kafka配置
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=reward-service
KAFKA_GROUP_ID=reward-service-group
# JWT配置
JWT_SECRET=your-jwt-secret-key
# 外部服务配置
REFERRAL_SERVICE_URL=http://localhost:3001
AUTHORIZATION_SERVICE_URL=http://localhost:3002
WALLET_SERVICE_URL=http://localhost:3003
```
#### 4. 启动基础设施
使用 Docker Compose 启动 PostgreSQL、Redis 和 Kafka
```bash
docker compose -f docker-compose.test.yml up -d
```
#### 5. 数据库迁移
```bash
# 生成 Prisma Client
npx prisma generate
# 运行数据库迁移
npx prisma migrate dev
```
#### 6. 启动开发服务器
```bash
# 开发模式 (热重载)
npm run start:dev
# 调试模式
npm run start:debug
```
---
## 项目结构
```
reward-service/
├── src/
│ ├── api/ # API层
│ │ ├── controllers/ # 控制器
│ │ ├── dto/ # 数据传输对象
│ │ │ ├── request/ # 请求DTO
│ │ │ └── response/ # 响应DTO
│ │ └── api.module.ts
│ │
│ ├── application/ # 应用层
│ │ ├── services/ # 应用服务
│ │ ├── schedulers/ # 定时任务
│ │ └── application.module.ts
│ │
│ ├── domain/ # 领域层 (核心)
│ │ ├── aggregates/ # 聚合根
│ │ ├── value-objects/ # 值对象
│ │ ├── events/ # 领域事件
│ │ ├── services/ # 领域服务
│ │ ├── repositories/ # 仓储接口
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # 基础设施层
│ │ ├── persistence/ # 持久化
│ │ │ ├── prisma/ # Prisma配置
│ │ │ ├── repositories/ # 仓储实现
│ │ │ └── mappers/ # 对象映射
│ │ ├── external/ # 外部服务客户端
│ │ ├── kafka/ # Kafka集成
│ │ ├── redis/ # Redis集成
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/ # 共享模块
│ │ ├── guards/ # 守卫
│ │ └── strategies/ # 认证策略
│ │
│ ├── config/ # 配置
│ ├── app.module.ts # 根模块
│ └── main.ts # 入口文件
├── test/ # 测试
│ ├── integration/ # 集成测试
│ └── app.e2e-spec.ts # E2E测试
├── prisma/ # Prisma配置
│ └── schema.prisma
├── docs/ # 文档
├── Makefile # Make命令
└── docker-compose.test.yml # Docker配置
```
---
## 开发规范
### 代码风格
项目使用 ESLint 和 Prettier 进行代码规范检查:
```bash
# 运行 ESLint 检查并自动修复
npm run lint
# 运行 Prettier 格式化
npm run format
```
### 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 文件名 | kebab-case | `reward-ledger-entry.aggregate.ts` |
| 类名 | PascalCase | `RewardLedgerEntry` |
| 接口名 | I + PascalCase | `IRewardLedgerEntryRepository` |
| 方法名 | camelCase | `calculateRewards` |
| 常量 | UPPER_SNAKE_CASE | `HEADQUARTERS_COMMUNITY_USER_ID` |
| 枚举值 | UPPER_SNAKE_CASE | `SHARE_RIGHT` |
### DDD 分层规范
#### 领域层 (Domain)
领域层是系统核心,**不依赖任何其他层**。
```typescript
// ✅ 正确:领域层只使用领域概念
import { Money } from '../value-objects/money.vo';
import { RewardStatus } from '../value-objects/reward-status.enum';
// ❌ 错误:领域层不应依赖基础设施
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
```
#### 应用层 (Application)
应用层协调领域层和基础设施层,实现用例。
```typescript
@Injectable()
export class RewardApplicationService {
constructor(
// 注入领域服务
private readonly rewardCalculationService: RewardCalculationService,
// 通过接口注入仓储
@Inject(REWARD_LEDGER_ENTRY_REPOSITORY)
private readonly repository: IRewardLedgerEntryRepository,
) {}
}
```
#### 基础设施层 (Infrastructure)
基础设施层实现领域层定义的接口。
```typescript
// 仓储实现
@Injectable()
export class RewardLedgerEntryRepositoryImpl implements IRewardLedgerEntryRepository {
constructor(private readonly prisma: PrismaService) {}
async save(entry: RewardLedgerEntry): Promise<void> {
// 实现持久化逻辑
}
}
```
---
## 添加新功能
### 1. 添加新的值对象
```typescript
// src/domain/value-objects/new-value.vo.ts
export class NewValue {
private readonly _value: number;
private constructor(value: number) {
if (value < 0) {
throw new Error('Value must be non-negative');
}
this._value = value;
}
static create(value: number): NewValue {
return new NewValue(value);
}
get value(): number {
return this._value;
}
equals(other: NewValue): boolean {
return this._value === other._value;
}
}
```
### 2. 添加新的领域事件
```typescript
// src/domain/events/new.event.ts
import { DomainEvent } from './domain-event.base';
export class NewEvent extends DomainEvent {
constructor(
public readonly data: {
id: string;
userId: string;
// ... 其他字段
},
) {
super('NewEvent');
}
}
```
### 3. 添加新的聚合根方法
```typescript
// 在聚合根中添加新行为
export class RewardLedgerEntry {
// ... 现有代码
/**
* 新的领域行为
*/
newBehavior(): void {
// 1. 检查不变式
if (!this.canPerformNewBehavior()) {
throw new Error('Cannot perform behavior in current state');
}
// 2. 修改状态
this._someField = newValue;
// 3. 发布领域事件
this._domainEvents.push(new NewEvent({
id: this._id?.toString() || '',
userId: this._userId.toString(),
}));
}
private canPerformNewBehavior(): boolean {
// 业务规则检查
return true;
}
}
```
### 4. 添加新的 API 端点
```typescript
// src/api/controllers/new.controller.ts
@ApiTags('New')
@Controller('new')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class NewController {
constructor(private readonly service: RewardApplicationService) {}
@Get()
@ApiOperation({ summary: '新接口描述' })
@ApiResponse({ status: 200, description: '成功' })
async newEndpoint(@Request() req) {
const userId = BigInt(req.user.sub);
return this.service.newMethod(userId);
}
}
```
---
## 依赖注入
### 定义仓储接口
```typescript
// src/domain/repositories/new.repository.interface.ts
export interface INewRepository {
findById(id: bigint): Promise<Entity | null>;
save(entity: Entity): Promise<void>;
}
export const NEW_REPOSITORY = Symbol('INewRepository');
```
### 实现仓储
```typescript
// src/infrastructure/persistence/repositories/new.repository.impl.ts
@Injectable()
export class NewRepositoryImpl implements INewRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: bigint): Promise<Entity | null> {
const data = await this.prisma.entity.findUnique({ where: { id } });
return data ? EntityMapper.toDomain(data) : null;
}
async save(entity: Entity): Promise<void> {
const data = EntityMapper.toPersistence(entity);
await this.prisma.entity.upsert({
where: { id: data.id },
create: data,
update: data,
});
}
}
```
### 注册依赖
```typescript
// src/infrastructure/infrastructure.module.ts
@Module({
providers: [
{
provide: NEW_REPOSITORY,
useClass: NewRepositoryImpl,
},
],
exports: [NEW_REPOSITORY],
})
export class InfrastructureModule {}
```
---
## 常用命令
### 开发命令
```bash
# 启动开发服务器 (热重载)
npm run start:dev
# 启动调试模式
npm run start:debug
# 构建生产版本
npm run build
# 启动生产服务器
npm run start:prod
```
### 数据库命令
```bash
# 生成 Prisma Client
npx prisma generate
# 创建新迁移
npx prisma migrate dev --name <migration-name>
# 应用迁移
npx prisma migrate deploy
# 重置数据库
npx prisma migrate reset --force
# 打开 Prisma Studio
npx prisma studio
```
### 代码质量
```bash
# ESLint 检查
npm run lint
# 代码格式化
npm run format
```
### 测试命令
```bash
# 运行所有测试
npm test
# 运行单元测试
make test-unit
# 运行集成测试
make test-integration
# 运行 E2E 测试
make test-e2e
# 测试覆盖率
npm run test:cov
```
---
## 调试技巧
### VS Code 调试配置
创建 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach NestJS",
"port": 9229,
"restart": true,
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--watchAll=false"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
```
### 日志调试
```typescript
import { Logger } from '@nestjs/common';
@Injectable()
export class SomeService {
private readonly logger = new Logger(SomeService.name);
async someMethod() {
this.logger.log('Processing started');
this.logger.debug('Debug information', { data });
this.logger.warn('Warning message');
this.logger.error('Error occurred', error.stack);
}
}
```
---
## 常见问题
### Q: 如何处理 BigInt 序列化问题?
```typescript
// 在 JSON 序列化时转换为字符串
return {
id: entity.id?.toString(),
userId: entity.userId.toString(),
};
```
### Q: 如何添加新的外部服务依赖?
1. 在 `src/domain/services/` 中定义接口 (防腐层)
2. 在 `src/infrastructure/external/` 中实现客户端
3. 在模块中注册依赖注入
### Q: 如何处理数据库事务?
```typescript
await this.prisma.$transaction(async (tx) => {
await tx.rewardLedgerEntry.create({ data: entry1 });
await tx.rewardLedgerEntry.create({ data: entry2 });
await tx.rewardSummary.update({ where: { userId }, data: summary });
});
```
### Q: 如何测试私有方法?
不要直接测试私有方法。通过公共接口测试私有方法的行为:
```typescript
// ❌ 错误:直接测试私有方法
expect(service['privateMethod']()).toBe(expected);
// ✅ 正确:通过公共接口测试
const result = await service.publicMethod();
expect(result).toMatchObject({ /* expected behavior */ });
```

View File

@ -0,0 +1,808 @@
# Reward Service 测试指南
## 测试概述
本服务采用分层测试策略,确保代码质量和业务逻辑正确性:
| 测试类型 | 目的 | 测试范围 | 依赖 |
|---------|------|---------|------|
| 单元测试 | 测试领域逻辑 | 值对象、聚合根 | 无外部依赖 |
| 集成测试 | 测试服务层 | 应用服务、领域服务 | Mock依赖 |
| E2E测试 | 测试完整流程 | API端点 | Mock服务 |
### 技术栈
- **测试框架**: Jest 30.x
- **HTTP测试**: Supertest 7.x
- **Mock工具**: Jest内置Mock
- **容器化**: Docker Compose
---
## 测试架构
```
test/
├── integration/ # 集成测试
│ ├── reward-application.service.spec.ts
│ └── reward-calculation.service.spec.ts
├── app.e2e-spec.ts # E2E测试
├── jest-e2e.json # E2E测试配置
└── setup.ts # 测试环境设置
src/
├── domain/
│ ├── aggregates/
│ │ ├── reward-ledger-entry/
│ │ │ └── reward-ledger-entry.spec.ts # 单元测试
│ │ └── reward-summary/
│ │ └── reward-summary.spec.ts # 单元测试
│ └── value-objects/
│ ├── money.spec.ts # 单元测试
│ └── hashpower.spec.ts # 单元测试
```
---
## 运行测试
### 快速命令
```bash
# 运行所有测试
npm test
# 运行单元测试
make test-unit
# 运行集成测试
make test-integration
# 运行E2E测试
make test-e2e
# 运行所有测试 (Docker环境)
make test-docker-all
# 测试覆盖率
npm run test:cov
```
### Makefile 命令详解
```makefile
# 单元测试 - 测试领域逻辑和值对象
test-unit:
npm test -- --testPathPatterns='src/.*\.spec\.ts$' --verbose
# 集成测试 - 测试服务层和仓储
test-integration:
npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$' --verbose
# 端到端测试 - 测试完整API流程
test-e2e:
npm run test:e2e -- --verbose
# Docker环境中运行所有测试
test-docker-all:
docker-compose -f docker-compose.test.yml up -d
sleep 5
npm test -- --testPathPatterns='src/.*\.spec\.ts$' --verbose || true
npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$' --verbose || true
npm run test:e2e -- --verbose || true
docker-compose -f docker-compose.test.yml down -v
```
---
## 单元测试
单元测试针对领域层的纯业务逻辑,不依赖外部服务。
### 测试值对象
```typescript
// src/domain/value-objects/money.spec.ts
describe('Money', () => {
describe('USDT factory', () => {
it('should create Money with USDT currency', () => {
const money = Money.USDT(100);
expect(money.amount).toBe(100);
expect(money.currency).toBe('USDT');
});
});
describe('validation', () => {
it('should throw error for negative amount', () => {
expect(() => Money.USDT(-100)).toThrow('金额不能为负数');
});
});
describe('add', () => {
it('should add two Money values', () => {
const money1 = Money.USDT(100);
const money2 = Money.USDT(50);
const result = money1.add(money2);
expect(result.amount).toBe(150);
});
});
describe('subtract', () => {
it('should return zero when subtracting larger value', () => {
const money1 = Money.USDT(50);
const money2 = Money.USDT(100);
const result = money1.subtract(money2);
expect(result.amount).toBe(0);
});
});
});
```
### 测试聚合根
```typescript
// src/domain/aggregates/reward-ledger-entry/reward-ledger-entry.spec.ts
describe('RewardLedgerEntry', () => {
describe('createPending', () => {
it('should create a pending reward with 24h expiration', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createTestRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
memo: 'Test reward',
});
expect(entry.isPending).toBe(true);
expect(entry.expireAt).toBeDefined();
expect(entry.getRemainingTimeMs()).toBeGreaterThan(0);
});
});
describe('claim', () => {
it('should transition pending to settleable', () => {
const entry = createPendingEntry();
entry.claim();
expect(entry.isSettleable).toBe(true);
expect(entry.claimedAt).toBeDefined();
expect(entry.expireAt).toBeNull();
});
it('should throw error when not pending', () => {
const entry = createSettleableEntry();
expect(() => entry.claim()).toThrow('只有待领取状态才能领取');
});
});
describe('expire', () => {
it('should transition pending to expired', () => {
const entry = createPendingEntry();
entry.expire();
expect(entry.isExpired).toBe(true);
expect(entry.expiredAt).toBeDefined();
});
});
describe('settle', () => {
it('should transition settleable to settled', () => {
const entry = createSettleableEntry();
entry.settle('BNB', 0.25);
expect(entry.isSettled).toBe(true);
expect(entry.settledAt).toBeDefined();
});
it('should throw error when not settleable', () => {
const entry = createPendingEntry();
expect(() => entry.settle('BNB', 0.25)).toThrow('只有可结算状态才能结算');
});
});
});
```
---
## 集成测试
集成测试验证应用服务层与领域服务的协作使用Mock隔离外部依赖。
### 测试应用服务
```typescript
// test/integration/reward-application.service.spec.ts
describe('RewardApplicationService (Integration)', () => {
let service: RewardApplicationService;
let mockLedgerRepository: jest.Mocked<IRewardLedgerEntryRepository>;
let mockSummaryRepository: jest.Mocked<IRewardSummaryRepository>;
let mockEventPublisher: jest.Mocked<EventPublisherService>;
let mockWalletService: jest.Mocked<WalletServiceClient>;
beforeEach(async () => {
// 创建Mock对象
mockLedgerRepository = {
save: jest.fn(),
saveAll: jest.fn(),
findByUserId: jest.fn(),
findPendingByUserId: jest.fn(),
findSettleableByUserId: jest.fn(),
findExpiredPending: jest.fn(),
countByUserId: jest.fn(),
};
mockSummaryRepository = {
findByUserId: jest.fn(),
getOrCreate: jest.fn(),
save: jest.fn(),
};
mockEventPublisher = {
publish: jest.fn(),
publishAll: jest.fn(),
};
mockWalletService = {
executeSwap: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RewardApplicationService,
RewardCalculationService,
RewardExpirationService,
{
provide: REWARD_LEDGER_ENTRY_REPOSITORY,
useValue: mockLedgerRepository,
},
{
provide: REWARD_SUMMARY_REPOSITORY,
useValue: mockSummaryRepository,
},
{
provide: EventPublisherService,
useValue: mockEventPublisher,
},
{
provide: WalletServiceClient,
useValue: mockWalletService,
},
// ... 其他Mock
],
}).compile();
service = module.get<RewardApplicationService>(RewardApplicationService);
});
describe('distributeRewards', () => {
it('should distribute rewards and update summaries', async () => {
// Arrange
const params = {
sourceOrderId: BigInt(1),
sourceUserId: BigInt(100),
treeCount: 10,
provinceCode: '440000',
cityCode: '440100',
};
mockSummaryRepository.getOrCreate.mockResolvedValue(
RewardSummary.create(BigInt(100))
);
// Act
await service.distributeRewards(params);
// Assert
expect(mockLedgerRepository.saveAll).toHaveBeenCalled();
expect(mockSummaryRepository.save).toHaveBeenCalled();
expect(mockEventPublisher.publishAll).toHaveBeenCalled();
});
});
describe('settleRewards', () => {
it('should settle rewards and call wallet service', async () => {
// Arrange
const settleableRewards = [createSettleableEntry()];
mockLedgerRepository.findSettleableByUserId.mockResolvedValue(settleableRewards);
mockSummaryRepository.getOrCreate.mockResolvedValue(
RewardSummary.create(BigInt(100))
);
mockWalletService.executeSwap.mockResolvedValue({
success: true,
receivedAmount: 0.25,
txHash: '0x123',
});
// Act
const result = await service.settleRewards({
userId: BigInt(100),
settleCurrency: 'BNB',
});
// Assert
expect(result.success).toBe(true);
expect(result.receivedAmount).toBe(0.25);
expect(mockWalletService.executeSwap).toHaveBeenCalledWith({
userId: BigInt(100),
usdtAmount: expect.any(Number),
targetCurrency: 'BNB',
});
});
it('should return error when no settleable rewards', async () => {
// Arrange
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([]);
// Act
const result = await service.settleRewards({
userId: BigInt(100),
settleCurrency: 'BNB',
});
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('没有可结算的收益');
});
});
});
```
### 测试领域服务
```typescript
// test/integration/reward-calculation.service.spec.ts
describe('RewardCalculationService (Integration)', () => {
let service: RewardCalculationService;
let mockReferralService: jest.Mocked<IReferralServiceClient>;
let mockAuthorizationService: jest.Mocked<IAuthorizationServiceClient>;
beforeEach(async () => {
mockReferralService = {
getReferralChain: jest.fn(),
};
mockAuthorizationService = {
findNearestAuthorizedProvince: jest.fn(),
findNearestAuthorizedCity: jest.fn(),
findNearestCommunity: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RewardCalculationService,
{
provide: REFERRAL_SERVICE_CLIENT,
useValue: mockReferralService,
},
{
provide: AUTHORIZATION_SERVICE_CLIENT,
useValue: mockAuthorizationService,
},
],
}).compile();
service = module.get<RewardCalculationService>(RewardCalculationService);
});
describe('calculateRewards', () => {
const baseParams = {
sourceOrderId: BigInt(1),
sourceUserId: BigInt(100),
treeCount: 10,
provinceCode: '440000',
cityCode: '440100',
};
it('should calculate all 6 types of rewards', async () => {
// Arrange
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: true }],
});
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(BigInt(300));
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(BigInt(400));
mockAuthorizationService.findNearestCommunity.mockResolvedValue(BigInt(500));
// Act
const rewards = await service.calculateRewards(baseParams);
// Assert
expect(rewards).toHaveLength(6);
const rightTypes = rewards.map(r => r.rewardSource.rightType);
expect(rightTypes).toContain(RightType.SHARE_RIGHT);
expect(rightTypes).toContain(RightType.PROVINCE_TEAM_RIGHT);
expect(rightTypes).toContain(RightType.PROVINCE_AREA_RIGHT);
expect(rightTypes).toContain(RightType.CITY_TEAM_RIGHT);
expect(rightTypes).toContain(RightType.CITY_AREA_RIGHT);
expect(rightTypes).toContain(RightType.COMMUNITY_RIGHT);
});
it('should calculate share right reward (500 USDT) when referrer has planted', async () => {
// Arrange
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: true }],
});
// ... 其他Mock设置
// Act
const rewards = await service.calculateRewards(baseParams);
// Assert
const shareReward = rewards.find(
r => r.rewardSource.rightType === RightType.SHARE_RIGHT
);
expect(shareReward).toBeDefined();
expect(shareReward?.isSettleable).toBe(true);
expect(shareReward?.usdtAmount.amount).toBe(500 * 10);
});
it('should create pending share right reward when referrer has not planted', async () => {
// Arrange
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: false }],
});
// Act
const rewards = await service.calculateRewards(baseParams);
// Assert
const shareReward = rewards.find(
r => r.rewardSource.rightType === RightType.SHARE_RIGHT
);
expect(shareReward?.isPending).toBe(true);
});
});
});
```
---
## E2E测试
E2E测试验证完整的HTTP请求-响应流程。
### 测试配置
```json
// test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
}
}
```
### E2E测试示例
```typescript
// test/app.e2e-spec.ts
describe('Reward Service (e2e)', () => {
let app: INestApplication;
let jwtService: JwtService;
let mockRewardService: any;
const TEST_JWT_SECRET = 'test-secret-key-for-testing';
const createTestToken = (userId: string = '100') => {
return jwtService.sign({
sub: userId,
username: 'testuser',
roles: ['user'],
});
};
beforeEach(async () => {
mockRewardService = {
getRewardSummary: jest.fn().mockResolvedValue({
pendingUsdt: 1000,
pendingHashpower: 0.5,
pendingExpireAt: new Date(Date.now() + 12 * 60 * 60 * 1000),
settleableUsdt: 500,
settleableHashpower: 0.2,
settledTotalUsdt: 2000,
settledTotalHashpower: 1.0,
expiredTotalUsdt: 100,
expiredTotalHashpower: 0.1,
}),
// ... 其他Mock方法
};
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [() => ({ JWT_SECRET: TEST_JWT_SECRET })],
}),
PassportModule,
JwtModule.register({
secret: TEST_JWT_SECRET,
signOptions: { expiresIn: '1h' },
}),
],
controllers: [HealthController, RewardController, SettlementController],
providers: [
{
provide: RewardApplicationService,
useValue: mockRewardService,
},
// ... JwtStrategy配置
],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
jwtService = moduleFixture.get<JwtService>(JwtService);
});
afterEach(async () => {
await app.close();
});
describe('Health Check', () => {
it('/health (GET) should return healthy status', () => {
return request(app.getHttpServer())
.get('/health')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ok');
expect(res.body.service).toBe('reward-service');
});
});
});
describe('Rewards API', () => {
describe('GET /rewards/summary', () => {
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/rewards/summary')
.expect(401);
});
it('should return reward summary with valid token', () => {
const token = createTestToken();
return request(app.getHttpServer())
.get('/rewards/summary')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
expect(res.body.pendingUsdt).toBe(1000);
expect(res.body.settleableUsdt).toBe(500);
});
});
});
});
describe('Settlement API', () => {
describe('POST /rewards/settle', () => {
it('should settle rewards successfully with valid token', () => {
const token = createTestToken();
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: 'BNB' })
.expect(201)
.expect((res) => {
expect(res.body.success).toBe(true);
});
});
it('should validate settleCurrency parameter', () => {
const token = createTestToken();
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: '' })
.expect(400);
});
});
});
});
```
---
## Docker测试环境
### docker-compose.test.yml
```yaml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: reward-test-postgres
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: reward_test
ports:
- '5433:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U test -d reward_test']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: reward-test-redis
ports:
- '6380:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: reward-test-kafka
depends_on:
- zookeeper
ports:
- '9093:9092'
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
# ... 其他配置
```
### 运行Docker测试
```bash
# 启动测试依赖
make docker-up
# 运行测试
make test-docker-all
# 关闭测试依赖
make docker-down
```
---
## 测试覆盖率
### 生成覆盖率报告
```bash
npm run test:cov
```
### 覆盖率目标
| 指标 | 目标 |
|------|------|
| 语句覆盖率 (Statements) | >= 80% |
| 分支覆盖率 (Branches) | >= 75% |
| 函数覆盖率 (Functions) | >= 85% |
| 行覆盖率 (Lines) | >= 80% |
### 查看报告
覆盖率报告生成在 `coverage/` 目录:
```bash
# 在浏览器中打开HTML报告
open coverage/lcov-report/index.html
```
---
## 测试最佳实践
### 1. 测试命名规范
```typescript
// 使用 describe 组织测试
describe('RewardLedgerEntry', () => {
describe('claim', () => {
it('should transition pending to settleable', () => { ... });
it('should throw error when not pending', () => { ... });
});
});
```
### 2. AAA 模式
```typescript
it('should calculate total amount', () => {
// Arrange - 准备测试数据
const reward1 = createReward(100);
const reward2 = createReward(200);
// Act - 执行被测试的行为
const total = service.calculateTotal([reward1, reward2]);
// Assert - 验证结果
expect(total).toBe(300);
});
```
### 3. 使用工厂函数创建测试数据
```typescript
// test/helpers/factories.ts
export function createTestRewardSource(overrides = {}) {
return RewardSource.create(
RightType.SHARE_RIGHT,
BigInt(1),
BigInt(100),
...overrides,
);
}
export function createPendingEntry(overrides = {}) {
return RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createTestRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
...overrides,
});
}
```
### 4. 避免测试实现细节
```typescript
// ❌ 测试实现细节
expect(service['privateField']).toBe(expectedValue);
// ✅ 测试行为
const result = await service.publicMethod();
expect(result).toMatchObject({ status: 'success' });
```
### 5. 使用 Mock 隔离依赖
```typescript
// 创建类型安全的Mock
const mockRepository = {
save: jest.fn(),
findById: jest.fn(),
} as jest.Mocked<IRewardLedgerEntryRepository>;
// 设置Mock返回值
mockRepository.findById.mockResolvedValue(expectedEntry);
// 验证Mock被调用
expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining({
userId: BigInt(100),
}));
```
---
## 测试报告示例
```
Test Suites: 7 passed, 7 total
Tests: 77 passed, 77 total
Snapshots: 0 total
Time: 13.026 s
┌─────────────────────────────────────────────────────────────┐
│ 测试结果汇总 │
├─────────────────┬──────────┬──────────┬──────────┬─────────┤
│ 测试类型 │ 测试套件 │ 测试用例 │ 状态 │ 耗时 │
├─────────────────┼──────────┼──────────┼──────────┼─────────┤
│ 单元测试 │ 4 │ 43 │ ✅ 通过 │ 3.2s │
│ 集成测试 │ 2 │ 20 │ ✅ 通过 │ 4.8s │
│ E2E测试 │ 1 │ 14 │ ✅ 通过 │ 5.0s │
├─────────────────┼──────────┼──────────┼──────────┼─────────┤
│ 总计 │ 7 │ 77 │ ✅ 通过 │ ~13s │
└─────────────────┴──────────┴──────────┴──────────┴─────────┘
```

View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
{
"name": "reward-service",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/cqrs": "^11.0.3",
"@nestjs/jwt": "^11.0.1",
"@nestjs/microservices": "^11.1.9",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.3",
"@prisma/client": "^7.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"ioredis": "^5.8.2",
"kafkajs": "^2.2.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.0.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"roots": ["<rootDir>/src", "<rootDir>/test"],
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"src/**/*.(t|j)s"
],
"coverageDirectory": "./coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
}
}
}

View File

@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@ -0,0 +1,175 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================
// 奖励流水表 (聚合根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")
}

View File

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

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthController } from './controllers/health.controller';
import { RewardController } from './controllers/reward.controller';
import { SettlementController } from './controllers/settlement.controller';
import { JwtStrategy } from '../shared/strategies/jwt.strategy';
import { ApplicationModule } from '../application/application.module';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') as any,
},
}),
inject: [ConfigService],
}),
ApplicationModule,
],
controllers: [HealthController, RewardController, SettlementController],
providers: [JwtStrategy],
})
export class ApiModule {}

View File

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('Health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务正常' })
check() {
return {
status: 'ok',
service: 'reward-service',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,61 @@
import { Controller, Get, Query, UseGuards, Request, DefaultValuePipe, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
import { RewardApplicationService } from '../../application/services/reward-application.service';
import { RewardSummaryDto } from '../dto/response/reward-summary.dto';
import { RewardEntryListDto } from '../dto/response/reward-entry.dto';
import { RewardStatus } from '../../domain/value-objects/reward-status.enum';
import { RightType } from '../../domain/value-objects/right-type.enum';
@ApiTags('Rewards')
@Controller('rewards')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class RewardController {
constructor(private readonly rewardService: RewardApplicationService) {}
@Get('summary')
@ApiOperation({ summary: '获取我的收益汇总' })
@ApiResponse({ status: 200, description: '成功', type: RewardSummaryDto })
async getSummary(@Request() req): Promise<RewardSummaryDto> {
const userId = BigInt(req.user.sub);
const summary = await this.rewardService.getRewardSummary(userId);
return {
...summary,
pendingRemainingTimeMs: summary.pendingExpireAt
? Math.max(0, summary.pendingExpireAt.getTime() - Date.now())
: 0,
};
}
@Get('details')
@ApiOperation({ summary: '获取我的奖励明细' })
@ApiQuery({ name: 'status', required: false, enum: RewardStatus })
@ApiQuery({ name: 'rightType', required: false, enum: RightType })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiResponse({ status: 200, description: '成功', type: RewardEntryListDto })
async getDetails(
@Request() req,
@Query('status') status?: RewardStatus,
@Query('rightType') rightType?: RightType,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number = 20,
) {
const userId = BigInt(req.user.sub);
const filters: any = {};
if (status) filters.status = status;
if (rightType) filters.rightType = rightType;
return this.rewardService.getRewardDetails(userId, filters, { page, pageSize });
}
@Get('pending')
@ApiOperation({ summary: '获取待领取奖励(含倒计时)' })
@ApiResponse({ status: 200, description: '成功' })
async getPending(@Request() req) {
const userId = BigInt(req.user.sub);
return this.rewardService.getPendingRewards(userId);
}
}

View File

@ -0,0 +1,30 @@
import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
import { RewardApplicationService } from '../../application/services/reward-application.service';
import { SettleRewardsDto } from '../dto/request/settle-rewards.dto';
import { SettlementResultDto } from '../dto/response/settlement-result.dto';
@ApiTags('Settlement')
@Controller('rewards')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class SettlementController {
constructor(private readonly rewardService: RewardApplicationService) {}
@Post('settle')
@ApiOperation({ summary: '结算可结算收益' })
@ApiResponse({ status: 200, description: '成功', type: SettlementResultDto })
@ApiResponse({ status: 400, description: '结算失败' })
async settle(
@Request() req,
@Body() dto: SettleRewardsDto,
): Promise<SettlementResultDto> {
const userId = BigInt(req.user.sub);
return this.rewardService.settleRewards({
userId,
settleCurrency: dto.settleCurrency,
});
}
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
export enum SettleCurrency {
BNB = 'BNB',
OG = 'OG',
USDT = 'USDT',
DST = 'DST',
}
export class SettleRewardsDto {
@ApiProperty({
description: '结算币种',
enum: SettleCurrency,
example: SettleCurrency.USDT,
})
@IsNotEmpty({ message: '请选择结算币种' })
@IsEnum(SettleCurrency, { message: '无效的结算币种' })
settleCurrency: SettleCurrency;
}

View File

@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { RightType } from '../../../domain/value-objects/right-type.enum';
import { RewardStatus } from '../../../domain/value-objects/reward-status.enum';
export class RewardEntryDto {
@ApiProperty({ description: '奖励流水ID', example: '1' })
id: string;
@ApiProperty({ description: '权益类型', enum: RightType, example: RightType.SHARE_RIGHT })
rightType: RightType;
@ApiProperty({ description: 'USDT金额', example: 500 })
usdtAmount: number;
@ApiProperty({ description: '算力', example: 0 })
hashpowerAmount: number;
@ApiProperty({ description: '奖励状态', enum: RewardStatus, example: RewardStatus.SETTLEABLE })
rewardStatus: RewardStatus;
@ApiProperty({ description: '创建时间', example: '2024-01-01T00:00:00.000Z' })
createdAt: Date;
@ApiProperty({ description: '过期时间', example: '2024-01-02T00:00:00.000Z', nullable: true })
expireAt: Date | null;
@ApiProperty({ description: '剩余过期时间(毫秒)', example: 86400000 })
remainingTimeMs: number;
@ApiProperty({ description: '领取时间', example: '2024-01-01T12:00:00.000Z', nullable: true })
claimedAt: Date | null;
@ApiProperty({ description: '结算时间', example: '2024-01-01T18:00:00.000Z', nullable: true })
settledAt: Date | null;
@ApiProperty({ description: '过期时间', example: null, nullable: true })
expiredAt: Date | null;
@ApiProperty({ description: '备注', example: '分享权益来自用户123的认种' })
memo: string;
}
export class RewardEntryListDto {
@ApiProperty({ description: '奖励列表', type: [RewardEntryDto] })
data: RewardEntryDto[];
@ApiProperty({ description: '分页信息' })
pagination: {
page: number;
pageSize: number;
total: number;
};
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class RewardSummaryDto {
@ApiProperty({ description: '待领取收益(USDT)', example: 500 })
pendingUsdt: number;
@ApiProperty({ description: '待领取算力', example: 0 })
pendingHashpower: number;
@ApiProperty({ description: '最早过期时间', example: '2024-01-01T00:00:00.000Z', nullable: true })
pendingExpireAt: Date | null;
@ApiProperty({ description: '剩余过期时间(毫秒)', example: 86400000 })
pendingRemainingTimeMs: number;
@ApiProperty({ description: '可结算收益(USDT)', example: 1000 })
settleableUsdt: number;
@ApiProperty({ description: '可结算算力', example: 5 })
settleableHashpower: number;
@ApiProperty({ description: '已结算总收益(USDT)', example: 5000 })
settledTotalUsdt: number;
@ApiProperty({ description: '已结算总算力', example: 20 })
settledTotalHashpower: number;
@ApiProperty({ description: '已过期总收益(USDT)', example: 200 })
expiredTotalUsdt: number;
@ApiProperty({ description: '已过期总算力', example: 0 })
expiredTotalHashpower: number;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class SettlementResultDto {
@ApiProperty({ description: '是否成功', example: true })
success: boolean;
@ApiProperty({ description: '结算的USDT金额', example: 1000 })
settledUsdtAmount: number;
@ApiProperty({ description: '实际收到的币种数量', example: 0.5 })
receivedAmount: number;
@ApiProperty({ description: '结算币种', example: 'BNB' })
settleCurrency: string;
@ApiProperty({ description: '交易哈希', example: '0x...', nullable: true })
txHash?: string;
@ApiProperty({ description: '错误信息', example: null, nullable: true })
error?: string;
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ApiModule } from './api/api.module';
import { appConfig } from './config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.development', '.env'],
load: [appConfig],
}),
ApiModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { RewardApplicationService } from './services/reward-application.service';
import { RewardExpirationScheduler } from './schedulers/reward-expiration.scheduler';
import { DomainModule } from '../domain/domain.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
@Module({
imports: [
ScheduleModule.forRoot(),
DomainModule,
InfrastructureModule,
],
providers: [
RewardApplicationService,
RewardExpirationScheduler,
],
exports: [RewardApplicationService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,27 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { RewardApplicationService } from '../services/reward-application.service';
@Injectable()
export class RewardExpirationScheduler {
private readonly logger = new Logger(RewardExpirationScheduler.name);
constructor(
private readonly rewardService: RewardApplicationService,
) {}
/**
*
*/
@Cron(CronExpression.EVERY_HOUR)
async handleExpiredRewards() {
this.logger.log('开始检查过期奖励...');
try {
const result = await this.rewardService.expireOverdueRewards();
this.logger.log(`过期奖励处理完成:${result.expiredCount}笔,共${result.totalUsdtExpired} USDT`);
} catch (error) {
this.logger.error('处理过期奖励时发生错误:', error);
}
}
}

View File

@ -0,0 +1,340 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { RewardLedgerEntry } from '../../domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
import { RewardCalculationService } from '../../domain/services/reward-calculation.service';
import { RewardExpirationService } from '../../domain/services/reward-expiration.service';
import type { IRewardLedgerEntryRepository } from '../../domain/repositories/reward-ledger-entry.repository.interface';
import { REWARD_LEDGER_ENTRY_REPOSITORY } from '../../domain/repositories/reward-ledger-entry.repository.interface';
import type { IRewardSummaryRepository } from '../../domain/repositories/reward-summary.repository.interface';
import { REWARD_SUMMARY_REPOSITORY } from '../../domain/repositories/reward-summary.repository.interface';
import { RewardStatus } from '../../domain/value-objects/reward-status.enum';
import { RightType } from '../../domain/value-objects/right-type.enum';
import { Money } from '../../domain/value-objects/money.vo';
import { Hashpower } from '../../domain/value-objects/hashpower.vo';
import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service';
import { WalletServiceClient } from '../../infrastructure/external/wallet-service/wallet-service.client';
// 总部社区账户ID
const HEADQUARTERS_COMMUNITY_USER_ID = BigInt(1);
@Injectable()
export class RewardApplicationService {
private readonly logger = new Logger(RewardApplicationService.name);
constructor(
private readonly rewardCalculationService: RewardCalculationService,
private readonly rewardExpirationService: RewardExpirationService,
@Inject(REWARD_LEDGER_ENTRY_REPOSITORY)
private readonly rewardLedgerEntryRepository: IRewardLedgerEntryRepository,
@Inject(REWARD_SUMMARY_REPOSITORY)
private readonly rewardSummaryRepository: IRewardSummaryRepository,
private readonly eventPublisher: EventPublisherService,
private readonly walletService: WalletServiceClient,
) {}
/**
* ()
*/
async distributeRewards(params: {
sourceOrderId: bigint;
sourceUserId: bigint;
treeCount: number;
provinceCode: string;
cityCode: string;
}): Promise<void> {
this.logger.log(`Distributing rewards for order ${params.sourceOrderId}`);
// 1. 计算所有奖励
const rewards = await this.rewardCalculationService.calculateRewards(params);
// 2. 保存奖励流水
await this.rewardLedgerEntryRepository.saveAll(rewards);
// 3. 更新各用户的汇总数据
const userIds = [...new Set(rewards.map(r => r.userId))];
for (const userId of userIds) {
const userRewards = rewards.filter(r => r.userId === userId);
const summary = await this.rewardSummaryRepository.getOrCreate(userId);
for (const reward of userRewards) {
if (reward.isPending) {
summary.addPending(
reward.usdtAmount,
reward.hashpowerAmount,
reward.expireAt!,
);
} else if (reward.isSettleable) {
summary.addSettleable(reward.usdtAmount, reward.hashpowerAmount);
}
}
await this.rewardSummaryRepository.save(summary);
}
// 4. 发布领域事件
for (const reward of rewards) {
await this.eventPublisher.publishAll(reward.domainEvents);
reward.clearDomainEvents();
}
this.logger.log(`Distributed ${rewards.length} rewards for order ${params.sourceOrderId}`);
}
/**
*
*/
async claimPendingRewardsForUser(userId: bigint): Promise<{
claimedCount: number;
totalUsdtClaimed: number;
}> {
this.logger.log(`Claiming pending rewards for user ${userId}`);
const pendingRewards = await this.rewardLedgerEntryRepository.findPendingByUserId(userId);
const summary = await this.rewardSummaryRepository.getOrCreate(userId);
let claimedCount = 0;
let totalUsdtClaimed = 0;
for (const reward of pendingRewards) {
if (!reward.isExpiredNow()) {
reward.claim();
await this.rewardLedgerEntryRepository.save(reward);
summary.movePendingToSettleable(reward.usdtAmount, reward.hashpowerAmount);
claimedCount++;
totalUsdtClaimed += reward.usdtAmount.amount;
await this.eventPublisher.publishAll(reward.domainEvents);
reward.clearDomainEvents();
}
}
await this.rewardSummaryRepository.save(summary);
this.logger.log(`Claimed ${claimedCount} rewards for user ${userId}, total ${totalUsdtClaimed} USDT`);
return { claimedCount, totalUsdtClaimed };
}
/**
*
*/
async settleRewards(params: {
userId: bigint;
settleCurrency: string; // BNB/OG/USDT/DST
}): Promise<{
success: boolean;
settledUsdtAmount: number;
receivedAmount: number;
settleCurrency: string;
txHash?: string;
error?: string;
}> {
this.logger.log(`Settling rewards for user ${params.userId}`);
// 1. 获取可结算奖励
const settleableRewards = await this.rewardLedgerEntryRepository.findSettleableByUserId(params.userId);
if (settleableRewards.length === 0) {
return {
success: false,
settledUsdtAmount: 0,
receivedAmount: 0,
settleCurrency: params.settleCurrency,
error: '没有可结算的收益',
};
}
// 2. 计算总金额
const totalUsdt = settleableRewards.reduce((sum, r) => sum + r.usdtAmount.amount, 0);
const totalHashpower = settleableRewards.reduce((sum, r) => sum + r.hashpowerAmount.value, 0);
// 3. 调用钱包服务执行SWAP
const swapResult = await this.walletService.executeSwap({
userId: params.userId,
usdtAmount: totalUsdt,
targetCurrency: params.settleCurrency,
});
if (!swapResult.success) {
return {
success: false,
settledUsdtAmount: totalUsdt,
receivedAmount: 0,
settleCurrency: params.settleCurrency,
error: swapResult.error,
};
}
// 4. 更新奖励状态为已结算
for (const reward of settleableRewards) {
reward.settle(params.settleCurrency, swapResult.receivedAmount!);
await this.rewardLedgerEntryRepository.save(reward);
await this.eventPublisher.publishAll(reward.domainEvents);
reward.clearDomainEvents();
}
// 5. 更新汇总数据
const summary = await this.rewardSummaryRepository.getOrCreate(params.userId);
summary.settle(Money.USDT(totalUsdt), Hashpower.create(totalHashpower));
await this.rewardSummaryRepository.save(summary);
this.logger.log(`Settled ${totalUsdt} USDT for user ${params.userId}`);
return {
success: true,
settledUsdtAmount: totalUsdt,
receivedAmount: swapResult.receivedAmount!,
settleCurrency: params.settleCurrency,
txHash: swapResult.txHash,
};
}
/**
* ()
*/
async expireOverdueRewards(): Promise<{
expiredCount: number;
totalUsdtExpired: number;
}> {
this.logger.log('Processing expired rewards');
const now = new Date();
const expiredPendingRewards = await this.rewardLedgerEntryRepository.findExpiredPending(now);
const expiredRewards = this.rewardExpirationService.expireOverdueRewards(expiredPendingRewards);
let totalUsdtExpired = 0;
// 按用户分组处理
const userRewardsMap = new Map<string, RewardLedgerEntry[]>();
for (const reward of expiredRewards) {
const userId = reward.userId.toString();
if (!userRewardsMap.has(userId)) {
userRewardsMap.set(userId, []);
}
userRewardsMap.get(userId)!.push(reward);
totalUsdtExpired += reward.usdtAmount.amount;
}
// 更新每个用户的汇总数据
for (const [userId, rewards] of userRewardsMap) {
const summary = await this.rewardSummaryRepository.getOrCreate(BigInt(userId));
for (const reward of rewards) {
await this.rewardLedgerEntryRepository.save(reward);
summary.movePendingToExpired(reward.usdtAmount, reward.hashpowerAmount);
await this.eventPublisher.publishAll(reward.domainEvents);
reward.clearDomainEvents();
}
await this.rewardSummaryRepository.save(summary);
}
// 将过期奖励转入总部社区
if (expiredRewards.length > 0) {
const hqSummary = await this.rewardSummaryRepository.getOrCreate(HEADQUARTERS_COMMUNITY_USER_ID);
const totalHqUsdt = expiredRewards.reduce((sum, r) => sum + r.usdtAmount.amount, 0);
const totalHqHashpower = expiredRewards.reduce((sum, r) => sum + r.hashpowerAmount.value, 0);
hqSummary.addSettleable(Money.USDT(totalHqUsdt), Hashpower.create(totalHqHashpower));
await this.rewardSummaryRepository.save(hqSummary);
}
this.logger.log(`Expired ${expiredRewards.length} rewards, total ${totalUsdtExpired} USDT`);
return {
expiredCount: expiredRewards.length,
totalUsdtExpired,
};
}
/**
*
*/
async getRewardSummary(userId: bigint) {
const summary = await this.rewardSummaryRepository.findByUserId(userId);
if (!summary) {
return {
pendingUsdt: 0,
pendingHashpower: 0,
pendingExpireAt: null,
settleableUsdt: 0,
settleableHashpower: 0,
settledTotalUsdt: 0,
settledTotalHashpower: 0,
expiredTotalUsdt: 0,
expiredTotalHashpower: 0,
};
}
return {
pendingUsdt: summary.pendingUsdt.amount,
pendingHashpower: summary.pendingHashpower.value,
pendingExpireAt: summary.pendingExpireAt,
settleableUsdt: summary.settleableUsdt.amount,
settleableHashpower: summary.settleableHashpower.value,
settledTotalUsdt: summary.settledTotalUsdt.amount,
settledTotalHashpower: summary.settledTotalHashpower.value,
expiredTotalUsdt: summary.expiredTotalUsdt.amount,
expiredTotalHashpower: summary.expiredTotalHashpower.value,
};
}
/**
*
*/
async getRewardDetails(
userId: bigint,
filters?: {
status?: RewardStatus;
rightType?: RightType;
startDate?: Date;
endDate?: Date;
},
pagination?: { page: number; pageSize: number },
) {
const rewards = await this.rewardLedgerEntryRepository.findByUserId(userId, filters, pagination);
const total = await this.rewardLedgerEntryRepository.countByUserId(userId, filters?.status);
return {
data: rewards.map(r => ({
id: r.id?.toString(),
rightType: r.rewardSource.rightType,
usdtAmount: r.usdtAmount.amount,
hashpowerAmount: r.hashpowerAmount.value,
rewardStatus: r.rewardStatus,
createdAt: r.createdAt,
expireAt: r.expireAt,
remainingTimeMs: r.getRemainingTimeMs(),
claimedAt: r.claimedAt,
settledAt: r.settledAt,
expiredAt: r.expiredAt,
memo: r.memo,
})),
pagination: {
page: pagination?.page || 1,
pageSize: pagination?.pageSize || 20,
total,
},
};
}
/**
*
*/
async getPendingRewards(userId: bigint) {
const rewards = await this.rewardLedgerEntryRepository.findPendingByUserId(userId);
return rewards.map(r => ({
id: r.id?.toString(),
rightType: r.rewardSource.rightType,
usdtAmount: r.usdtAmount.amount,
hashpowerAmount: r.hashpowerAmount.value,
createdAt: r.createdAt,
expireAt: r.expireAt,
remainingTimeMs: r.getRemainingTimeMs(),
memo: r.memo,
}));
}
}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3005', 10),
appName: process.env.APP_NAME || 'reward-service',
}));

View File

@ -0,0 +1 @@
export { default as appConfig } from './app.config';

View File

@ -0,0 +1 @@
export * from './reward-ledger-entry.aggregate';

View File

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

View File

@ -0,0 +1,211 @@
import { RewardLedgerEntry } from './reward-ledger-entry.aggregate';
import { RewardSource } from '../../value-objects/reward-source.vo';
import { RightType } from '../../value-objects/right-type.enum';
import { RewardStatus } from '../../value-objects/reward-status.enum';
import { Money } from '../../value-objects/money.vo';
import { Hashpower } from '../../value-objects/hashpower.vo';
describe('RewardLedgerEntry', () => {
const createRewardSource = () =>
RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(2));
describe('createPending', () => {
it('should create a pending reward with 24h expiration', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
memo: 'Test reward',
});
expect(entry.isPending).toBe(true);
expect(entry.rewardStatus).toBe(RewardStatus.PENDING);
expect(entry.usdtAmount.amount).toBe(500);
expect(entry.expireAt).toBeDefined();
expect(entry.domainEvents.length).toBe(1);
expect(entry.domainEvents[0].eventType).toBe('RewardCreated');
});
it('should set expireAt to 24 hours from now', () => {
const before = Date.now();
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
const after = Date.now();
const expectedExpireMin = before + 24 * 60 * 60 * 1000;
const expectedExpireMax = after + 24 * 60 * 60 * 1000;
expect(entry.expireAt!.getTime()).toBeGreaterThanOrEqual(expectedExpireMin);
expect(entry.expireAt!.getTime()).toBeLessThanOrEqual(expectedExpireMax);
});
});
describe('createSettleable', () => {
it('should create a settleable reward without expiration', () => {
const entry = RewardLedgerEntry.createSettleable({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.create(5),
memo: 'Direct reward',
});
expect(entry.isSettleable).toBe(true);
expect(entry.rewardStatus).toBe(RewardStatus.SETTLEABLE);
expect(entry.expireAt).toBeNull();
expect(entry.hashpowerAmount.value).toBe(5);
});
});
describe('claim', () => {
it('should transition pending to settleable', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
entry.clearDomainEvents();
entry.claim();
expect(entry.isSettleable).toBe(true);
expect(entry.claimedAt).toBeDefined();
expect(entry.expireAt).toBeNull();
expect(entry.domainEvents.length).toBe(1);
expect(entry.domainEvents[0].eventType).toBe('RewardClaimed');
});
it('should throw error when not pending', () => {
const entry = RewardLedgerEntry.createSettleable({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
expect(() => entry.claim()).toThrow('只有待领取状态才能领取');
});
});
describe('expire', () => {
it('should transition pending to expired', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
entry.clearDomainEvents();
entry.expire();
expect(entry.isExpired).toBe(true);
expect(entry.expiredAt).toBeDefined();
expect(entry.domainEvents.length).toBe(1);
expect(entry.domainEvents[0].eventType).toBe('RewardExpired');
});
it('should throw error when not pending', () => {
const entry = RewardLedgerEntry.createSettleable({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
expect(() => entry.expire()).toThrow('只有待领取状态才能过期');
});
});
describe('settle', () => {
it('should transition settleable to settled', () => {
const entry = RewardLedgerEntry.createSettleable({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
entry.clearDomainEvents();
entry.settle('BNB', 0.25);
expect(entry.isSettled).toBe(true);
expect(entry.settledAt).toBeDefined();
expect(entry.domainEvents.length).toBe(1);
expect(entry.domainEvents[0].eventType).toBe('RewardSettled');
});
it('should throw error when not settleable', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
expect(() => entry.settle('BNB', 0.25)).toThrow('只有可结算状态才能结算');
});
});
describe('getRemainingTimeMs', () => {
it('should return remaining time for pending rewards', () => {
const entry = RewardLedgerEntry.createPending({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
const remaining = entry.getRemainingTimeMs();
const expected24h = 24 * 60 * 60 * 1000;
expect(remaining).toBeGreaterThan(expected24h - 1000); // Allow 1 second tolerance
expect(remaining).toBeLessThanOrEqual(expected24h);
});
it('should return 0 for settleable rewards', () => {
const entry = RewardLedgerEntry.createSettleable({
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
});
expect(entry.getRemainingTimeMs()).toBe(0);
});
});
describe('reconstitute', () => {
it('should rebuild aggregate from persistence data', () => {
const data = {
id: BigInt(1),
userId: BigInt(100),
rewardSource: createRewardSource(),
usdtAmount: 500,
hashpowerAmount: 5,
rewardStatus: RewardStatus.SETTLEABLE,
createdAt: new Date(),
expireAt: null,
claimedAt: new Date(),
settledAt: null,
expiredAt: null,
memo: 'Test',
};
const entry = RewardLedgerEntry.reconstitute(data);
expect(entry.id).toBe(BigInt(1));
expect(entry.userId).toBe(BigInt(100));
expect(entry.usdtAmount.amount).toBe(500);
expect(entry.hashpowerAmount.value).toBe(5);
expect(entry.isSettleable).toBe(true);
expect(entry.domainEvents.length).toBe(0); // No events on reconstitute
});
});
});

View File

@ -0,0 +1 @@
export * from './reward-summary.aggregate';

View File

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

View File

@ -0,0 +1,149 @@
import { RewardSummary } from './reward-summary.aggregate';
import { Money } from '../../value-objects/money.vo';
import { Hashpower } from '../../value-objects/hashpower.vo';
describe('RewardSummary', () => {
describe('create', () => {
it('should create a new summary with zero values', () => {
const summary = RewardSummary.create(BigInt(100));
expect(summary.userId).toBe(BigInt(100));
expect(summary.pendingUsdt.amount).toBe(0);
expect(summary.settleableUsdt.amount).toBe(0);
expect(summary.settledTotalUsdt.amount).toBe(0);
expect(summary.expiredTotalUsdt.amount).toBe(0);
});
});
describe('addPending', () => {
it('should add pending rewards and update expire time', () => {
const summary = RewardSummary.create(BigInt(100));
const expireAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
summary.addPending(Money.USDT(500), Hashpower.create(5), expireAt);
expect(summary.pendingUsdt.amount).toBe(500);
expect(summary.pendingHashpower.value).toBe(5);
expect(summary.pendingExpireAt).toEqual(expireAt);
});
it('should keep earliest expire time', () => {
const summary = RewardSummary.create(BigInt(100));
const earlyExpire = new Date(Date.now() + 12 * 60 * 60 * 1000);
const lateExpire = new Date(Date.now() + 24 * 60 * 60 * 1000);
summary.addPending(Money.USDT(500), Hashpower.zero(), earlyExpire);
summary.addPending(Money.USDT(300), Hashpower.zero(), lateExpire);
expect(summary.pendingExpireAt).toEqual(earlyExpire);
expect(summary.pendingUsdt.amount).toBe(800);
});
});
describe('movePendingToSettleable', () => {
it('should move amounts from pending to settleable', () => {
const summary = RewardSummary.create(BigInt(100));
const expireAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
summary.addPending(Money.USDT(500), Hashpower.create(5), expireAt);
summary.movePendingToSettleable(Money.USDT(500), Hashpower.create(5));
expect(summary.pendingUsdt.amount).toBe(0);
expect(summary.pendingHashpower.value).toBe(0);
expect(summary.settleableUsdt.amount).toBe(500);
expect(summary.settleableHashpower.value).toBe(5);
expect(summary.pendingExpireAt).toBeNull(); // Cleared when pending is zero
});
it('should partially move pending amounts', () => {
const summary = RewardSummary.create(BigInt(100));
const expireAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
summary.addPending(Money.USDT(500), Hashpower.create(5), expireAt);
summary.movePendingToSettleable(Money.USDT(300), Hashpower.create(3));
expect(summary.pendingUsdt.amount).toBe(200);
expect(summary.pendingHashpower.value).toBe(2);
expect(summary.settleableUsdt.amount).toBe(300);
expect(summary.settleableHashpower.value).toBe(3);
});
});
describe('movePendingToExpired', () => {
it('should move amounts from pending to expired', () => {
const summary = RewardSummary.create(BigInt(100));
const expireAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
summary.addPending(Money.USDT(500), Hashpower.create(5), expireAt);
summary.movePendingToExpired(Money.USDT(500), Hashpower.create(5));
expect(summary.pendingUsdt.amount).toBe(0);
expect(summary.expiredTotalUsdt.amount).toBe(500);
expect(summary.expiredTotalHashpower.value).toBe(5);
});
});
describe('addSettleable', () => {
it('should add directly to settleable', () => {
const summary = RewardSummary.create(BigInt(100));
summary.addSettleable(Money.USDT(1000), Hashpower.create(10));
expect(summary.settleableUsdt.amount).toBe(1000);
expect(summary.settleableHashpower.value).toBe(10);
});
});
describe('settle', () => {
it('should move settleable to settled total', () => {
const summary = RewardSummary.create(BigInt(100));
summary.addSettleable(Money.USDT(1000), Hashpower.create(10));
summary.settle(Money.USDT(1000), Hashpower.create(10));
expect(summary.settleableUsdt.amount).toBe(0);
expect(summary.settledTotalUsdt.amount).toBe(1000);
expect(summary.settledTotalHashpower.value).toBe(10);
});
it('should accumulate settled totals', () => {
const summary = RewardSummary.create(BigInt(100));
summary.addSettleable(Money.USDT(1000), Hashpower.create(10));
summary.settle(Money.USDT(500), Hashpower.create(5));
summary.settle(Money.USDT(300), Hashpower.create(3));
expect(summary.settleableUsdt.amount).toBe(200);
expect(summary.settledTotalUsdt.amount).toBe(800);
expect(summary.settledTotalHashpower.value).toBe(8);
});
});
describe('reconstitute', () => {
it('should rebuild aggregate from persistence data', () => {
const data = {
id: BigInt(1),
userId: BigInt(100),
pendingUsdt: 500,
pendingHashpower: 5,
pendingExpireAt: new Date(),
settleableUsdt: 1000,
settleableHashpower: 10,
settledTotalUsdt: 5000,
settledTotalHashpower: 50,
expiredTotalUsdt: 200,
expiredTotalHashpower: 2,
lastUpdateAt: new Date(),
createdAt: new Date(),
};
const summary = RewardSummary.reconstitute(data);
expect(summary.id).toBe(BigInt(1));
expect(summary.pendingUsdt.amount).toBe(500);
expect(summary.settleableUsdt.amount).toBe(1000);
expect(summary.settledTotalUsdt.amount).toBe(5000);
expect(summary.expiredTotalUsdt.amount).toBe(200);
});
});
});

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { RewardCalculationService } from './services/reward-calculation.service';
import { RewardExpirationService } from './services/reward-expiration.service';
@Module({
providers: [
RewardCalculationService,
RewardExpirationService,
],
exports: [
RewardCalculationService,
RewardExpirationService,
],
})
export class DomainModule {}

View File

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

View File

@ -0,0 +1,5 @@
export * from './domain-event.base';
export * from './reward-created.event';
export * from './reward-claimed.event';
export * from './reward-expired.event';
export * from './reward-settled.event';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from './reward-ledger-entry.repository.interface';
export * from './reward-summary.repository.interface';

View File

@ -0,0 +1,26 @@
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');

View File

@ -0,0 +1,11 @@
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');

View File

@ -0,0 +1,2 @@
export * from './reward-calculation.service';
export * from './reward-expiration.service';

View File

@ -0,0 +1,347 @@
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: '社区权益:无归属社区,进总部社区',
});
}
}
}

View File

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

View File

@ -0,0 +1,76 @@
import { Hashpower } from './hashpower.vo';
describe('Hashpower', () => {
describe('create', () => {
it('should create Hashpower with value', () => {
const hp = Hashpower.create(100);
expect(hp.value).toBe(100);
});
});
describe('zero', () => {
it('should create zero Hashpower', () => {
const hp = Hashpower.zero();
expect(hp.value).toBe(0);
expect(hp.isZero()).toBe(true);
});
});
describe('fromTreeCount', () => {
it('should calculate hashpower from tree count and percent', () => {
// 10 trees at 2% = 20 hashpower
const hp = Hashpower.fromTreeCount(10, 2);
expect(hp.value).toBe(20);
});
it('should return zero for zero percent', () => {
const hp = Hashpower.fromTreeCount(10, 0);
expect(hp.value).toBe(0);
});
});
describe('validation', () => {
it('should throw error for negative value', () => {
expect(() => Hashpower.create(-10)).toThrow('算力不能为负数');
});
});
describe('add', () => {
it('should add two Hashpower values', () => {
const a = Hashpower.create(100);
const b = Hashpower.create(50);
const result = a.add(b);
expect(result.value).toBe(150);
});
});
describe('subtract', () => {
it('should subtract Hashpower values', () => {
const a = Hashpower.create(100);
const b = Hashpower.create(30);
const result = a.subtract(b);
expect(result.value).toBe(70);
});
it('should return zero when subtracting larger value', () => {
const a = Hashpower.create(50);
const b = Hashpower.create(100);
const result = a.subtract(b);
expect(result.value).toBe(0);
});
});
describe('equals', () => {
it('should return true for equal values', () => {
const a = Hashpower.create(100);
const b = Hashpower.create(100);
expect(a.equals(b)).toBe(true);
});
it('should return false for different values', () => {
const a = Hashpower.create(100);
const b = Hashpower.create(50);
expect(a.equals(b)).toBe(false);
});
});
});

View File

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

View File

@ -0,0 +1,5 @@
export * from './right-type.enum';
export * from './reward-status.enum';
export * from './money.vo';
export * from './hashpower.vo';
export * from './reward-source.vo';

View File

@ -0,0 +1,86 @@
import { Money } from './money.vo';
describe('Money', () => {
describe('USDT factory', () => {
it('should create Money with USDT currency', () => {
const money = Money.USDT(100);
expect(money.amount).toBe(100);
expect(money.currency).toBe('USDT');
});
});
describe('zero factory', () => {
it('should create zero Money', () => {
const money = Money.zero();
expect(money.amount).toBe(0);
expect(money.isZero()).toBe(true);
});
});
describe('validation', () => {
it('should throw error for negative amount', () => {
expect(() => Money.USDT(-100)).toThrow('金额不能为负数');
});
});
describe('add', () => {
it('should add two Money values', () => {
const a = Money.USDT(100);
const b = Money.USDT(50);
const result = a.add(b);
expect(result.amount).toBe(150);
});
});
describe('subtract', () => {
it('should subtract Money values', () => {
const a = Money.USDT(100);
const b = Money.USDT(30);
const result = a.subtract(b);
expect(result.amount).toBe(70);
});
it('should return zero when subtracting larger value', () => {
const a = Money.USDT(50);
const b = Money.USDT(100);
const result = a.subtract(b);
expect(result.amount).toBe(0);
});
});
describe('multiply', () => {
it('should multiply Money by factor', () => {
const money = Money.USDT(100);
const result = money.multiply(3);
expect(result.amount).toBe(300);
});
});
describe('equals', () => {
it('should return true for equal values', () => {
const a = Money.USDT(100);
const b = Money.USDT(100);
expect(a.equals(b)).toBe(true);
});
it('should return false for different values', () => {
const a = Money.USDT(100);
const b = Money.USDT(50);
expect(a.equals(b)).toBe(false);
});
});
describe('isGreaterThan', () => {
it('should return true when greater', () => {
const a = Money.USDT(100);
const b = Money.USDT(50);
expect(a.isGreaterThan(b)).toBe(true);
});
it('should return false when less or equal', () => {
const a = Money.USDT(50);
const b = Money.USDT(100);
expect(a.isGreaterThan(b)).toBe(false);
});
});
});

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export enum RewardStatus {
PENDING = 'PENDING', // 待领取(24h倒计时)
SETTLEABLE = 'SETTLEABLE', // 可结算
SETTLED = 'SETTLED', // 已结算
EXPIRED = 'EXPIRED', // 已过期(进总部社区)
}

View File

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

View File

@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAuthorizationServiceClient } from '../../../domain/services/reward-calculation.service';
@Injectable()
export class AuthorizationServiceClient implements IAuthorizationServiceClient {
private readonly logger = new Logger(AuthorizationServiceClient.name);
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>('AUTHORIZATION_SERVICE_URL', 'http://localhost:3006');
}
async findNearestAuthorizedProvince(userId: bigint, provinceCode: string): Promise<bigint | null> {
try {
const response = await fetch(
`${this.baseUrl}/authorization/nearest-province?userId=${userId}&provinceCode=${provinceCode}`,
);
if (!response.ok) {
this.logger.warn(`No authorized province found for user ${userId}, province ${provinceCode}`);
return null;
}
const data = await response.json();
return data.userId ? BigInt(data.userId) : null;
} catch (error) {
this.logger.error(`Error finding nearest authorized province:`, error);
return null;
}
}
async findNearestAuthorizedCity(userId: bigint, cityCode: string): Promise<bigint | null> {
try {
const response = await fetch(
`${this.baseUrl}/authorization/nearest-city?userId=${userId}&cityCode=${cityCode}`,
);
if (!response.ok) {
this.logger.warn(`No authorized city found for user ${userId}, city ${cityCode}`);
return null;
}
const data = await response.json();
return data.userId ? BigInt(data.userId) : null;
} catch (error) {
this.logger.error(`Error finding nearest authorized city:`, error);
return null;
}
}
async findNearestCommunity(userId: bigint): Promise<bigint | null> {
try {
const response = await fetch(
`${this.baseUrl}/authorization/nearest-community?userId=${userId}`,
);
if (!response.ok) {
this.logger.warn(`No community found for user ${userId}`);
return null;
}
const data = await response.json();
return data.userId ? BigInt(data.userId) : null;
} catch (error) {
this.logger.error(`Error finding nearest community:`, error);
return null;
}
}
}

View File

@ -0,0 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IReferralServiceClient } from '../../../domain/services/reward-calculation.service';
@Injectable()
export class ReferralServiceClient implements IReferralServiceClient {
private readonly logger = new Logger(ReferralServiceClient.name);
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>('REFERRAL_SERVICE_URL', 'http://localhost:3004');
}
async getReferralChain(userId: bigint): Promise<{
ancestors: Array<{ userId: bigint; hasPlanted: boolean }>;
}> {
try {
const response = await fetch(`${this.baseUrl}/referral/chain/${userId}`);
if (!response.ok) {
this.logger.warn(`Failed to get referral chain for user ${userId}: ${response.status}`);
return { ancestors: [] };
}
const data = await response.json();
return {
ancestors: (data.ancestors || []).map((a: any) => ({
userId: BigInt(a.userId),
hasPlanted: a.hasPlanted ?? false,
})),
};
} catch (error) {
this.logger.error(`Error fetching referral chain for user ${userId}:`, error);
return { ancestors: [] };
}
}
}

View File

@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface SwapResult {
success: boolean;
txHash?: string;
receivedAmount?: number;
swapRate?: number;
error?: string;
}
@Injectable()
export class WalletServiceClient {
private readonly logger = new Logger(WalletServiceClient.name);
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>('WALLET_SERVICE_URL', 'http://localhost:3002');
}
async executeSwap(params: {
userId: bigint;
usdtAmount: number;
targetCurrency: string; // BNB/OG/USDT/DST
}): Promise<SwapResult> {
try {
const response = await fetch(`${this.baseUrl}/wallet/swap`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: params.userId.toString(),
usdtAmount: params.usdtAmount,
targetCurrency: params.targetCurrency,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || `Swap failed with status ${response.status}`,
};
}
const data = await response.json();
return {
success: true,
txHash: data.txHash,
receivedAmount: data.receivedAmount,
swapRate: data.swapRate,
};
} catch (error) {
this.logger.error(`Error executing swap:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async getSwapRate(fromCurrency: string, toCurrency: string): Promise<number | null> {
try {
const response = await fetch(
`${this.baseUrl}/wallet/swap-rate?from=${fromCurrency}&to=${toCurrency}`,
);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.rate;
} catch (error) {
this.logger.error(`Error getting swap rate:`, error);
return null;
}
}
}

View File

@ -0,0 +1,48 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './persistence/prisma/prisma.service';
import { RewardLedgerEntryRepositoryImpl } from './persistence/repositories/reward-ledger-entry.repository.impl';
import { RewardSummaryRepositoryImpl } from './persistence/repositories/reward-summary.repository.impl';
import { ReferralServiceClient } from './external/referral-service/referral-service.client';
import { AuthorizationServiceClient } from './external/authorization-service/authorization-service.client';
import { WalletServiceClient } from './external/wallet-service/wallet-service.client';
import { KafkaModule } from './kafka/kafka.module';
import { RedisModule } from './redis/redis.module';
import { REWARD_LEDGER_ENTRY_REPOSITORY } from '../domain/repositories/reward-ledger-entry.repository.interface';
import { REWARD_SUMMARY_REPOSITORY } from '../domain/repositories/reward-summary.repository.interface';
import { REFERRAL_SERVICE_CLIENT, AUTHORIZATION_SERVICE_CLIENT } from '../domain/services/reward-calculation.service';
@Module({
imports: [ConfigModule, KafkaModule, RedisModule],
providers: [
PrismaService,
{
provide: REWARD_LEDGER_ENTRY_REPOSITORY,
useClass: RewardLedgerEntryRepositoryImpl,
},
{
provide: REWARD_SUMMARY_REPOSITORY,
useClass: RewardSummaryRepositoryImpl,
},
{
provide: REFERRAL_SERVICE_CLIENT,
useClass: ReferralServiceClient,
},
{
provide: AUTHORIZATION_SERVICE_CLIENT,
useClass: AuthorizationServiceClient,
},
WalletServiceClient,
],
exports: [
PrismaService,
REWARD_LEDGER_ENTRY_REPOSITORY,
REWARD_SUMMARY_REPOSITORY,
REFERRAL_SERVICE_CLIENT,
AUTHORIZATION_SERVICE_CLIENT,
WalletServiceClient,
KafkaModule,
RedisModule,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,48 @@
import { Controller, Logger } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { RewardApplicationService } from '../../application/services/reward-application.service';
interface PlantingOrderPaidEvent {
orderId: string;
userId: string;
treeCount: number;
provinceCode: string;
cityCode: string;
paidAt: string;
}
@Controller()
export class EventConsumerController {
private readonly logger = new Logger(EventConsumerController.name);
constructor(
private readonly rewardService: RewardApplicationService,
) {}
/**
*
*/
@MessagePattern('planting.order.paid')
async handlePlantingOrderPaid(@Payload() message: PlantingOrderPaidEvent) {
this.logger.log(`Received planting.order.paid event: ${JSON.stringify(message)}`);
try {
// 1. 计算并分配奖励
await this.rewardService.distributeRewards({
sourceOrderId: BigInt(message.orderId),
sourceUserId: BigInt(message.userId),
treeCount: message.treeCount,
provinceCode: message.provinceCode,
cityCode: message.cityCode,
});
// 2. 检查该用户是否有待领取奖励需要转为可结算
await this.rewardService.claimPendingRewardsForUser(BigInt(message.userId));
this.logger.log(`Successfully processed planting.order.paid for order ${message.orderId}`);
} catch (error) {
this.logger.error(`Error processing planting.order.paid:`, error);
throw error;
}
}
}

View File

@ -0,0 +1,58 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { DomainEvent } from '../../domain/events/domain-event.base';
@Injectable()
export class EventPublisherService {
private readonly logger = new Logger(EventPublisherService.name);
constructor(
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
) {}
async publish(event: DomainEvent): Promise<void> {
const topic = this.getTopicForEvent(event);
const message = {
key: event.aggregateId,
value: JSON.stringify({
eventId: event.eventId,
eventType: event.eventType,
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
occurredAt: event.occurredAt.toISOString(),
version: event.version,
payload: event.toPayload(),
}),
};
try {
this.kafkaClient.emit(topic, message);
this.logger.log(`Published event ${event.eventType} to topic ${topic}`);
} catch (error) {
this.logger.error(`Failed to publish event ${event.eventType}:`, error);
throw error;
}
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
private getTopicForEvent(event: DomainEvent): string {
switch (event.eventType) {
case 'RewardCreated':
return 'reward.created';
case 'RewardClaimed':
return 'reward.claimed';
case 'RewardExpired':
return 'reward.expired';
case 'RewardSettled':
return 'reward.settled';
default:
return 'reward.events';
}
}
}

View File

@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { EventPublisherService } from './event-publisher.service';
@Module({
imports: [
ClientsModule.registerAsync([
{
name: 'KAFKA_SERVICE',
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: configService.get<string>('KAFKA_CLIENT_ID', 'reward-service'),
brokers: configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
},
consumer: {
groupId: configService.get<string>('KAFKA_GROUP_ID', 'reward-service-group'),
},
},
}),
inject: [ConfigService],
},
]),
],
providers: [EventPublisherService],
exports: [EventPublisherService, ClientsModule],
})
export class KafkaModule {}

View File

@ -0,0 +1,47 @@
import { RewardLedgerEntry as PrismaRewardLedgerEntry, Prisma } from '@prisma/client';
import { RewardLedgerEntry } from '../../../domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
import { RewardSource } from '../../../domain/value-objects/reward-source.vo';
import { RewardStatus } from '../../../domain/value-objects/reward-status.enum';
import { RightType } from '../../../domain/value-objects/right-type.enum';
export class RewardLedgerEntryMapper {
static toDomain(raw: PrismaRewardLedgerEntry): RewardLedgerEntry {
return RewardLedgerEntry.reconstitute({
id: raw.id,
userId: raw.userId,
rewardSource: RewardSource.create(
raw.rightType as RightType,
raw.sourceOrderId,
raw.sourceUserId,
),
usdtAmount: Number(raw.usdtAmount),
hashpowerAmount: Number(raw.hashpowerAmount),
rewardStatus: raw.rewardStatus as RewardStatus,
createdAt: raw.createdAt,
expireAt: raw.expireAt,
claimedAt: raw.claimedAt,
settledAt: raw.settledAt,
expiredAt: raw.expiredAt,
memo: raw.memo || '',
});
}
static toPersistence(entry: RewardLedgerEntry) {
return {
id: entry.id || undefined,
userId: entry.userId,
sourceOrderId: entry.rewardSource.sourceOrderId,
sourceUserId: entry.rewardSource.sourceUserId,
rightType: entry.rewardSource.rightType,
usdtAmount: new Prisma.Decimal(entry.usdtAmount.amount),
hashpowerAmount: new Prisma.Decimal(entry.hashpowerAmount.value),
rewardStatus: entry.rewardStatus,
createdAt: entry.createdAt,
expireAt: entry.expireAt,
claimedAt: entry.claimedAt,
settledAt: entry.settledAt,
expiredAt: entry.expiredAt,
memo: entry.memo,
};
}
}

View File

@ -0,0 +1,40 @@
import { RewardSummary as PrismaRewardSummary, Prisma } from '@prisma/client';
import { RewardSummary } from '../../../domain/aggregates/reward-summary/reward-summary.aggregate';
export class RewardSummaryMapper {
static toDomain(raw: PrismaRewardSummary): RewardSummary {
return RewardSummary.reconstitute({
id: raw.id,
userId: raw.userId,
pendingUsdt: Number(raw.pendingUsdt),
pendingHashpower: Number(raw.pendingHashpower),
pendingExpireAt: raw.pendingExpireAt,
settleableUsdt: Number(raw.settleableUsdt),
settleableHashpower: Number(raw.settleableHashpower),
settledTotalUsdt: Number(raw.settledTotalUsdt),
settledTotalHashpower: Number(raw.settledTotalHashpower),
expiredTotalUsdt: Number(raw.expiredTotalUsdt),
expiredTotalHashpower: Number(raw.expiredTotalHashpower),
lastUpdateAt: raw.lastUpdateAt,
createdAt: raw.createdAt,
});
}
static toPersistence(summary: RewardSummary) {
return {
id: summary.id || undefined,
userId: summary.userId,
pendingUsdt: new Prisma.Decimal(summary.pendingUsdt.amount),
pendingHashpower: new Prisma.Decimal(summary.pendingHashpower.value),
pendingExpireAt: summary.pendingExpireAt,
settleableUsdt: new Prisma.Decimal(summary.settleableUsdt.amount),
settleableHashpower: new Prisma.Decimal(summary.settleableHashpower.value),
settledTotalUsdt: new Prisma.Decimal(summary.settledTotalUsdt.amount),
settledTotalHashpower: new Prisma.Decimal(summary.settledTotalHashpower.value),
expiredTotalUsdt: new Prisma.Decimal(summary.expiredTotalUsdt.amount),
expiredTotalHashpower: new Prisma.Decimal(summary.expiredTotalHashpower.value),
lastUpdateAt: summary.lastUpdateAt,
createdAt: summary.createdAt,
};
}
}

View File

@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,155 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RewardLedgerEntryMapper } from '../mappers/reward-ledger-entry.mapper';
import { RewardLedgerEntry } from '../../../domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
import { IRewardLedgerEntryRepository } from '../../../domain/repositories/reward-ledger-entry.repository.interface';
import { RewardStatus } from '../../../domain/value-objects/reward-status.enum';
import { RightType } from '../../../domain/value-objects/right-type.enum';
@Injectable()
export class RewardLedgerEntryRepositoryImpl implements IRewardLedgerEntryRepository {
constructor(private readonly prisma: PrismaService) {}
async save(entry: RewardLedgerEntry): Promise<void> {
const data = RewardLedgerEntryMapper.toPersistence(entry);
if (entry.id) {
await this.prisma.rewardLedgerEntry.update({
where: { id: entry.id },
data: {
rewardStatus: data.rewardStatus,
expireAt: data.expireAt,
claimedAt: data.claimedAt,
settledAt: data.settledAt,
expiredAt: data.expiredAt,
memo: data.memo,
},
});
} else {
const created = await this.prisma.rewardLedgerEntry.create({
data: {
userId: data.userId,
sourceOrderId: data.sourceOrderId,
sourceUserId: data.sourceUserId,
rightType: data.rightType,
usdtAmount: data.usdtAmount,
hashpowerAmount: data.hashpowerAmount,
rewardStatus: data.rewardStatus,
expireAt: data.expireAt,
claimedAt: data.claimedAt,
settledAt: data.settledAt,
expiredAt: data.expiredAt,
memo: data.memo,
},
});
entry.setId(created.id);
}
}
async saveAll(entries: RewardLedgerEntry[]): Promise<void> {
for (const entry of entries) {
await this.save(entry);
}
}
async findById(entryId: bigint): Promise<RewardLedgerEntry | null> {
const raw = await this.prisma.rewardLedgerEntry.findUnique({
where: { id: entryId },
});
return raw ? RewardLedgerEntryMapper.toDomain(raw) : null;
}
async findByUserId(
userId: bigint,
filters?: {
status?: RewardStatus;
rightType?: RightType;
startDate?: Date;
endDate?: Date;
},
pagination?: { page: number; pageSize: number },
): Promise<RewardLedgerEntry[]> {
const where: any = { userId };
if (filters?.status) {
where.rewardStatus = filters.status;
}
if (filters?.rightType) {
where.rightType = filters.rightType;
}
if (filters?.startDate || filters?.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = filters.startDate;
}
if (filters.endDate) {
where.createdAt.lte = filters.endDate;
}
}
const skip = pagination ? (pagination.page - 1) * pagination.pageSize : undefined;
const take = pagination?.pageSize;
const rawList = await this.prisma.rewardLedgerEntry.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
});
return rawList.map(RewardLedgerEntryMapper.toDomain);
}
async findPendingByUserId(userId: bigint): Promise<RewardLedgerEntry[]> {
const rawList = await this.prisma.rewardLedgerEntry.findMany({
where: {
userId,
rewardStatus: RewardStatus.PENDING,
},
orderBy: { createdAt: 'desc' },
});
return rawList.map(RewardLedgerEntryMapper.toDomain);
}
async findSettleableByUserId(userId: bigint): Promise<RewardLedgerEntry[]> {
const rawList = await this.prisma.rewardLedgerEntry.findMany({
where: {
userId,
rewardStatus: RewardStatus.SETTLEABLE,
},
orderBy: { createdAt: 'desc' },
});
return rawList.map(RewardLedgerEntryMapper.toDomain);
}
async findExpiredPending(beforeDate: Date): Promise<RewardLedgerEntry[]> {
const rawList = await this.prisma.rewardLedgerEntry.findMany({
where: {
rewardStatus: RewardStatus.PENDING,
expireAt: {
lte: beforeDate,
},
},
});
return rawList.map(RewardLedgerEntryMapper.toDomain);
}
async findBySourceOrderId(sourceOrderId: bigint): Promise<RewardLedgerEntry[]> {
const rawList = await this.prisma.rewardLedgerEntry.findMany({
where: { sourceOrderId },
});
return rawList.map(RewardLedgerEntryMapper.toDomain);
}
async countByUserId(userId: bigint, status?: RewardStatus): Promise<number> {
const where: any = { userId };
if (status) {
where.rewardStatus = status;
}
return this.prisma.rewardLedgerEntry.count({ where });
}
}

View File

@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RewardSummaryMapper } from '../mappers/reward-summary.mapper';
import { RewardSummary } from '../../../domain/aggregates/reward-summary/reward-summary.aggregate';
import { IRewardSummaryRepository } from '../../../domain/repositories/reward-summary.repository.interface';
@Injectable()
export class RewardSummaryRepositoryImpl implements IRewardSummaryRepository {
constructor(private readonly prisma: PrismaService) {}
async save(summary: RewardSummary): Promise<void> {
const data = RewardSummaryMapper.toPersistence(summary);
if (summary.id) {
await this.prisma.rewardSummary.update({
where: { id: summary.id },
data: {
pendingUsdt: data.pendingUsdt,
pendingHashpower: data.pendingHashpower,
pendingExpireAt: data.pendingExpireAt,
settleableUsdt: data.settleableUsdt,
settleableHashpower: data.settleableHashpower,
settledTotalUsdt: data.settledTotalUsdt,
settledTotalHashpower: data.settledTotalHashpower,
expiredTotalUsdt: data.expiredTotalUsdt,
expiredTotalHashpower: data.expiredTotalHashpower,
},
});
} else {
const created = await this.prisma.rewardSummary.create({
data: {
userId: data.userId,
pendingUsdt: data.pendingUsdt,
pendingHashpower: data.pendingHashpower,
pendingExpireAt: data.pendingExpireAt,
settleableUsdt: data.settleableUsdt,
settleableHashpower: data.settleableHashpower,
settledTotalUsdt: data.settledTotalUsdt,
settledTotalHashpower: data.settledTotalHashpower,
expiredTotalUsdt: data.expiredTotalUsdt,
expiredTotalHashpower: data.expiredTotalHashpower,
},
});
summary.setId(created.id);
}
}
async findByUserId(userId: bigint): Promise<RewardSummary | null> {
const raw = await this.prisma.rewardSummary.findUnique({
where: { userId },
});
return raw ? RewardSummaryMapper.toDomain(raw) : null;
}
async getOrCreate(userId: bigint): Promise<RewardSummary> {
const existing = await this.findByUserId(userId);
if (existing) {
return existing;
}
const newSummary = RewardSummary.create(userId);
await this.save(newSummary);
return newSummary;
}
async findByUserIds(userIds: bigint[]): Promise<Map<string, RewardSummary>> {
const rawList = await this.prisma.rewardSummary.findMany({
where: {
userId: { in: userIds },
},
});
const result = new Map<string, RewardSummary>();
for (const raw of rawList) {
result.set(raw.userId.toString(), RewardSummaryMapper.toDomain(raw));
}
return result;
}
async findTopSettleableUsers(limit: number): Promise<RewardSummary[]> {
const rawList = await this.prisma.rewardSummary.findMany({
where: {
settleableUsdt: { gt: 0 },
},
orderBy: { settleableUsdt: 'desc' },
take: limit,
});
return rawList.map(RewardSummaryMapper.toDomain);
}
}

View File

@ -0,0 +1,19 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisService } from './redis.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: RedisService,
useFactory: (configService: ConfigService) => {
return new RedisService(configService);
},
inject: [ConfigService],
},
],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,64 @@
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private readonly client: Redis;
constructor(private readonly configService: ConfigService) {
const password = this.configService.get<string>('REDIS_PASSWORD');
this.client = new Redis({
host: this.configService.get<string>('REDIS_HOST') || 'localhost',
port: this.configService.get<number>('REDIS_PORT') || 6379,
password: password || undefined,
});
this.client.on('connect', () => {
this.logger.log('Connected to Redis');
});
this.client.on('error', (err) => {
this.logger.error('Redis error:', err);
});
}
async onModuleDestroy() {
await this.client.quit();
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(key);
return result === 1;
}
async setJson(key: string, value: any, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key);
return value ? JSON.parse(value) : null;
}
}

View File

@ -0,0 +1,43 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 3005);
const appName = configService.get<string>('APP_NAME', 'reward-service');
// 全局验证管道
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// 跨域配置
app.enableCors();
// Swagger 配置
const config = new DocumentBuilder()
.setTitle('Reward Service API')
.setDescription('RWA榴莲女皇平台 - 权益奖励微服务 API 文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(port);
logger.log(`${appName} is running on port ${port}`);
logger.log(`Swagger documentation available at http://localhost:${port}/api`);
}
bootstrap();

View File

@ -0,0 +1,16 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('请先登录');
}
return user;
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') || 'default-secret-key',
});
}
async validate(payload: any) {
return {
sub: payload.sub,
username: payload.username,
roles: payload.roles,
};
}
}

View File

@ -0,0 +1,318 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthController } from '../src/api/controllers/health.controller';
import { RewardController } from '../src/api/controllers/reward.controller';
import { SettlementController } from '../src/api/controllers/settlement.controller';
import { RewardApplicationService } from '../src/application/services/reward-application.service';
import { JwtService } from '@nestjs/jwt';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from '../src/shared/strategies/jwt.strategy';
import { RewardStatus } from '../src/domain/value-objects/reward-status.enum';
import { RightType } from '../src/domain/value-objects/right-type.enum';
describe('Reward Service (e2e)', () => {
let app: INestApplication<App>;
let jwtService: JwtService;
let mockRewardService: any;
const TEST_JWT_SECRET = 'test-secret-key-for-testing';
const createTestToken = (userId: string = '100') => {
return jwtService.sign({
sub: userId,
username: 'testuser',
roles: ['user'],
});
};
beforeEach(async () => {
mockRewardService = {
getRewardSummary: jest.fn().mockResolvedValue({
pendingUsdt: 1000,
pendingHashpower: 0.5,
pendingExpireAt: new Date(Date.now() + 12 * 60 * 60 * 1000),
settleableUsdt: 500,
settleableHashpower: 0.2,
settledTotalUsdt: 2000,
settledTotalHashpower: 1.0,
expiredTotalUsdt: 100,
expiredTotalHashpower: 0.1,
}),
getRewardDetails: jest.fn().mockResolvedValue({
data: [
{
id: '1',
rightType: RightType.SHARE_RIGHT,
usdtAmount: 500,
hashpowerAmount: 0,
rewardStatus: RewardStatus.PENDING,
createdAt: new Date(),
expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
remainingTimeMs: 86400000,
claimedAt: null,
settledAt: null,
expiredAt: null,
memo: 'Test reward',
},
],
pagination: {
page: 1,
pageSize: 20,
total: 1,
},
}),
getPendingRewards: jest.fn().mockResolvedValue([
{
id: '1',
rightType: RightType.SHARE_RIGHT,
usdtAmount: 500,
hashpowerAmount: 0,
createdAt: new Date(),
expireAt: new Date(Date.now() + 12 * 60 * 60 * 1000),
remainingTimeMs: 43200000,
memo: 'Pending reward',
},
]),
settleRewards: jest.fn().mockResolvedValue({
success: true,
settledUsdtAmount: 500,
receivedAmount: 0.25,
settleCurrency: 'BNB',
txHash: '0x123abc',
}),
};
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [() => ({ JWT_SECRET: TEST_JWT_SECRET })],
}),
PassportModule,
JwtModule.register({
secret: TEST_JWT_SECRET,
signOptions: { expiresIn: '1h' },
}),
],
controllers: [HealthController, RewardController, SettlementController],
providers: [
{
provide: RewardApplicationService,
useValue: mockRewardService,
},
{
provide: JwtStrategy,
useFactory: (configService: ConfigService) => {
return new JwtStrategy({
get: (key: string) => key === 'JWT_SECRET' ? TEST_JWT_SECRET : undefined,
} as ConfigService);
},
inject: [ConfigService],
},
],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
jwtService = moduleFixture.get<JwtService>(JwtService);
});
afterEach(async () => {
await app.close();
});
describe('Health Check', () => {
it('/health (GET) should return healthy status', () => {
return request(app.getHttpServer())
.get('/health')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ok');
expect(res.body.service).toBe('reward-service');
expect(res.body.timestamp).toBeDefined();
});
});
});
describe('Rewards API', () => {
describe('GET /rewards/summary', () => {
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/rewards/summary')
.expect(401);
});
it('should return reward summary with valid token', () => {
const token = createTestToken();
return request(app.getHttpServer())
.get('/rewards/summary')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
expect(res.body.pendingUsdt).toBe(1000);
expect(res.body.settleableUsdt).toBe(500);
expect(res.body.settledTotalUsdt).toBe(2000);
expect(res.body.expiredTotalUsdt).toBe(100);
});
});
});
describe('GET /rewards/details', () => {
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/rewards/details')
.expect(401);
});
it('should return paginated reward details with valid token', () => {
const token = createTestToken();
return request(app.getHttpServer())
.get('/rewards/details')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].usdtAmount).toBe(500);
expect(res.body.pagination.total).toBe(1);
});
});
it('should accept filter parameters', () => {
const token = createTestToken();
return request(app.getHttpServer())
.get('/rewards/details')
.query({ status: RewardStatus.PENDING, page: 1, pageSize: 10 })
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
});
describe('GET /rewards/pending', () => {
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/rewards/pending')
.expect(401);
});
it('should return pending rewards with countdown', () => {
const token = createTestToken();
return request(app.getHttpServer())
.get('/rewards/pending')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
expect(res.body).toHaveLength(1);
expect(res.body[0].usdtAmount).toBe(500);
expect(res.body[0].remainingTimeMs).toBeGreaterThan(0);
});
});
});
});
describe('Settlement API', () => {
describe('POST /rewards/settle', () => {
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.post('/rewards/settle')
.send({ settleCurrency: 'BNB' })
.expect(401);
});
it('should settle rewards successfully with valid token', () => {
const token = createTestToken();
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: 'BNB' })
.expect(201)
.expect((res) => {
expect(res.body.success).toBe(true);
expect(res.body.settledUsdtAmount).toBe(500);
expect(res.body.receivedAmount).toBe(0.25);
expect(res.body.settleCurrency).toBe('BNB');
expect(res.body.txHash).toBe('0x123abc');
});
});
it('should validate settleCurrency parameter', () => {
const token = createTestToken();
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: '' })
.expect(400);
});
it('should accept different settlement currencies', async () => {
const token = createTestToken();
const currencies = ['BNB', 'OG', 'USDT', 'DST'];
for (const currency of currencies) {
mockRewardService.settleRewards.mockResolvedValueOnce({
success: true,
settledUsdtAmount: 500,
receivedAmount: currency === 'USDT' ? 500 : 0.25,
settleCurrency: currency,
txHash: '0x123',
});
await request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: currency })
.expect(201);
}
});
});
describe('Settlement failure scenarios', () => {
it('should handle no settleable rewards', () => {
const token = createTestToken();
mockRewardService.settleRewards.mockResolvedValueOnce({
success: false,
settledUsdtAmount: 0,
receivedAmount: 0,
settleCurrency: 'BNB',
error: '没有可结算的收益',
});
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: 'BNB' })
.expect(201)
.expect((res) => {
expect(res.body.success).toBe(false);
expect(res.body.error).toBe('没有可结算的收益');
});
});
it('should handle wallet service failure', () => {
const token = createTestToken();
mockRewardService.settleRewards.mockResolvedValueOnce({
success: false,
settledUsdtAmount: 500,
receivedAmount: 0,
settleCurrency: 'BNB',
error: 'Insufficient liquidity',
});
return request(app.getHttpServer())
.post('/rewards/settle')
.set('Authorization', `Bearer ${token}`)
.send({ settleCurrency: 'BNB' })
.expect(201)
.expect((res) => {
expect(res.body.success).toBe(false);
expect(res.body.error).toBe('Insufficient liquidity');
});
});
});
});
});

View File

@ -0,0 +1,354 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RewardApplicationService } from '../../src/application/services/reward-application.service';
import { RewardCalculationService } from '../../src/domain/services/reward-calculation.service';
import { RewardExpirationService } from '../../src/domain/services/reward-expiration.service';
import { REWARD_LEDGER_ENTRY_REPOSITORY } from '../../src/domain/repositories/reward-ledger-entry.repository.interface';
import { REWARD_SUMMARY_REPOSITORY } from '../../src/domain/repositories/reward-summary.repository.interface';
import { EventPublisherService } from '../../src/infrastructure/kafka/event-publisher.service';
import { WalletServiceClient } from '../../src/infrastructure/external/wallet-service/wallet-service.client';
import { RewardLedgerEntry } from '../../src/domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
import { RewardSummary } from '../../src/domain/aggregates/reward-summary/reward-summary.aggregate';
import { RewardSource } from '../../src/domain/value-objects/reward-source.vo';
import { RightType } from '../../src/domain/value-objects/right-type.enum';
import { RewardStatus } from '../../src/domain/value-objects/reward-status.enum';
import { Money } from '../../src/domain/value-objects/money.vo';
import { Hashpower } from '../../src/domain/value-objects/hashpower.vo';
describe('RewardApplicationService (Integration)', () => {
let service: RewardApplicationService;
let mockLedgerRepository: any;
let mockSummaryRepository: any;
let mockEventPublisher: any;
let mockWalletService: any;
let mockCalculationService: any;
beforeEach(async () => {
mockLedgerRepository = {
save: jest.fn(),
saveAll: jest.fn(),
findPendingByUserId: jest.fn(),
findSettleableByUserId: jest.fn(),
findExpiredPending: jest.fn(),
findByUserId: jest.fn(),
countByUserId: jest.fn(),
};
mockSummaryRepository = {
getOrCreate: jest.fn(),
findByUserId: jest.fn(),
save: jest.fn(),
};
mockEventPublisher = {
publishAll: jest.fn(),
};
mockWalletService = {
executeSwap: jest.fn(),
};
mockCalculationService = {
calculateRewards: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RewardApplicationService,
{
provide: RewardCalculationService,
useValue: mockCalculationService,
},
{
provide: RewardExpirationService,
useValue: { expireOverdueRewards: jest.fn((r) => r) },
},
{
provide: REWARD_LEDGER_ENTRY_REPOSITORY,
useValue: mockLedgerRepository,
},
{
provide: REWARD_SUMMARY_REPOSITORY,
useValue: mockSummaryRepository,
},
{
provide: EventPublisherService,
useValue: mockEventPublisher,
},
{
provide: WalletServiceClient,
useValue: mockWalletService,
},
],
}).compile();
service = module.get<RewardApplicationService>(RewardApplicationService);
});
describe('distributeRewards', () => {
it('should distribute rewards and update summaries', async () => {
const params = {
sourceOrderId: BigInt(1),
sourceUserId: BigInt(100),
treeCount: 10,
provinceCode: '440000',
cityCode: '440100',
};
const mockReward = RewardLedgerEntry.createPending({
userId: BigInt(200),
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(100)),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
memo: 'Test',
});
const mockSummary = RewardSummary.create(BigInt(200));
mockCalculationService.calculateRewards.mockResolvedValue([mockReward]);
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
await service.distributeRewards(params);
expect(mockCalculationService.calculateRewards).toHaveBeenCalledWith(params);
expect(mockLedgerRepository.saveAll).toHaveBeenCalledWith([mockReward]);
expect(mockSummaryRepository.save).toHaveBeenCalled();
expect(mockEventPublisher.publishAll).toHaveBeenCalled();
});
});
describe('claimPendingRewardsForUser', () => {
it('should claim pending rewards and move to settleable', async () => {
const userId = BigInt(100);
const pendingReward = RewardLedgerEntry.createPending({
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
memo: 'Test reward',
});
const mockSummary = RewardSummary.create(userId);
mockSummary.addPending(
Money.USDT(500),
Hashpower.zero(),
new Date(Date.now() + 24 * 60 * 60 * 1000),
);
mockLedgerRepository.findPendingByUserId.mockResolvedValue([pendingReward]);
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
const result = await service.claimPendingRewardsForUser(userId);
expect(result.claimedCount).toBe(1);
expect(result.totalUsdtClaimed).toBe(500);
expect(mockLedgerRepository.save).toHaveBeenCalled();
expect(mockSummaryRepository.save).toHaveBeenCalled();
});
it('should skip expired rewards', async () => {
const userId = BigInt(100);
const expiredReward = RewardLedgerEntry.reconstitute({
id: BigInt(1),
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: 500,
hashpowerAmount: 0,
rewardStatus: RewardStatus.PENDING,
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
expireAt: new Date(Date.now() - 1000),
claimedAt: null,
settledAt: null,
expiredAt: null,
memo: 'Expired reward',
});
const mockSummary = RewardSummary.create(userId);
mockLedgerRepository.findPendingByUserId.mockResolvedValue([expiredReward]);
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
const result = await service.claimPendingRewardsForUser(userId);
expect(result.claimedCount).toBe(0);
expect(result.totalUsdtClaimed).toBe(0);
});
});
describe('settleRewards', () => {
it('should settle rewards and call wallet service', async () => {
const userId = BigInt(100);
const settleableReward = RewardLedgerEntry.reconstitute({
id: BigInt(1),
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: 500,
hashpowerAmount: 0,
rewardStatus: RewardStatus.SETTLEABLE,
createdAt: new Date(),
expireAt: null,
claimedAt: new Date(),
settledAt: null,
expiredAt: null,
memo: 'Test',
});
const mockSummary = RewardSummary.create(userId);
mockSummary.addSettleable(Money.USDT(500), Hashpower.zero());
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([settleableReward]);
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
mockWalletService.executeSwap.mockResolvedValue({
success: true,
receivedAmount: 0.25,
txHash: '0x123',
});
const result = await service.settleRewards({
userId,
settleCurrency: 'BNB',
});
expect(result.success).toBe(true);
expect(result.settledUsdtAmount).toBe(500);
expect(result.receivedAmount).toBe(0.25);
expect(result.settleCurrency).toBe('BNB');
expect(mockWalletService.executeSwap).toHaveBeenCalledWith({
userId,
usdtAmount: 500,
targetCurrency: 'BNB',
});
});
it('should return error when no settleable rewards', async () => {
const userId = BigInt(100);
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([]);
const result = await service.settleRewards({
userId,
settleCurrency: 'BNB',
});
expect(result.success).toBe(false);
expect(result.error).toBe('没有可结算的收益');
});
it('should handle wallet service failure', async () => {
const userId = BigInt(100);
const settleableReward = RewardLedgerEntry.reconstitute({
id: BigInt(1),
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: 500,
hashpowerAmount: 0,
rewardStatus: RewardStatus.SETTLEABLE,
createdAt: new Date(),
expireAt: null,
claimedAt: new Date(),
settledAt: null,
expiredAt: null,
memo: 'Test',
});
const mockSummary = RewardSummary.create(userId);
mockSummary.addSettleable(Money.USDT(500), Hashpower.zero());
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([settleableReward]);
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
mockWalletService.executeSwap.mockResolvedValue({
success: false,
error: 'Insufficient liquidity',
});
const result = await service.settleRewards({
userId,
settleCurrency: 'BNB',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Insufficient liquidity');
});
});
describe('getRewardSummary', () => {
it('should return reward summary for user', async () => {
const userId = BigInt(100);
const mockSummary = RewardSummary.create(userId);
mockSummaryRepository.findByUserId.mockResolvedValue(mockSummary);
const result = await service.getRewardSummary(userId);
expect(result.pendingUsdt).toBe(0);
expect(result.settleableUsdt).toBe(0);
expect(result.settledTotalUsdt).toBe(0);
expect(result.expiredTotalUsdt).toBe(0);
});
it('should return zero values when no summary exists', async () => {
const userId = BigInt(100);
mockSummaryRepository.findByUserId.mockResolvedValue(null);
const result = await service.getRewardSummary(userId);
expect(result.pendingUsdt).toBe(0);
expect(result.settleableUsdt).toBe(0);
});
});
describe('getRewardDetails', () => {
it('should return paginated reward details', async () => {
const userId = BigInt(100);
const mockReward = RewardLedgerEntry.reconstitute({
id: BigInt(1),
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: 500,
hashpowerAmount: 0,
rewardStatus: RewardStatus.PENDING,
createdAt: new Date(),
expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
claimedAt: null,
settledAt: null,
expiredAt: null,
memo: 'Test',
});
mockLedgerRepository.findByUserId.mockResolvedValue([mockReward]);
mockLedgerRepository.countByUserId.mockResolvedValue(1);
const result = await service.getRewardDetails(userId, {}, { page: 1, pageSize: 20 });
expect(result.data).toHaveLength(1);
expect(result.data[0].usdtAmount).toBe(500);
expect(result.pagination.total).toBe(1);
});
});
describe('getPendingRewards', () => {
it('should return pending rewards with countdown', async () => {
const userId = BigInt(100);
const pendingReward = RewardLedgerEntry.createPending({
userId,
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, BigInt(1), BigInt(50)),
usdtAmount: Money.USDT(500),
hashpowerAmount: Hashpower.zero(),
memo: 'Test',
});
mockLedgerRepository.findPendingByUserId.mockResolvedValue([pendingReward]);
const result = await service.getPendingRewards(userId);
expect(result).toHaveLength(1);
expect(result[0].usdtAmount).toBe(500);
expect(result[0].remainingTimeMs).toBeGreaterThan(0);
});
});
});

View File

@ -0,0 +1,233 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
RewardCalculationService,
REFERRAL_SERVICE_CLIENT,
AUTHORIZATION_SERVICE_CLIENT,
} from '../../src/domain/services/reward-calculation.service';
import { RightType } from '../../src/domain/value-objects/right-type.enum';
describe('RewardCalculationService (Integration)', () => {
let service: RewardCalculationService;
let mockReferralService: any;
let mockAuthorizationService: any;
beforeEach(async () => {
mockReferralService = {
getReferralChain: jest.fn(),
};
mockAuthorizationService = {
findNearestAuthorizedProvince: jest.fn(),
findNearestAuthorizedCity: jest.fn(),
findNearestCommunity: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RewardCalculationService,
{
provide: REFERRAL_SERVICE_CLIENT,
useValue: mockReferralService,
},
{
provide: AUTHORIZATION_SERVICE_CLIENT,
useValue: mockAuthorizationService,
},
],
}).compile();
service = module.get<RewardCalculationService>(RewardCalculationService);
});
describe('calculateRewards', () => {
const baseParams = {
sourceOrderId: BigInt(1),
sourceUserId: BigInt(100),
treeCount: 10,
provinceCode: '440000',
cityCode: '440100',
};
it('should calculate all 6 types of rewards', async () => {
// Setup: referrer with planting status
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: true }],
});
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(BigInt(300));
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(BigInt(400));
mockAuthorizationService.findNearestCommunity.mockResolvedValue(BigInt(500));
const rewards = await service.calculateRewards(baseParams);
// Should return 6 rewards (one for each type)
expect(rewards).toHaveLength(6);
// Verify each reward type exists
const rightTypes = rewards.map((r) => r.rewardSource.rightType);
expect(rightTypes).toContain(RightType.SHARE_RIGHT);
expect(rightTypes).toContain(RightType.PROVINCE_TEAM_RIGHT);
expect(rightTypes).toContain(RightType.PROVINCE_AREA_RIGHT);
expect(rightTypes).toContain(RightType.CITY_TEAM_RIGHT);
expect(rightTypes).toContain(RightType.CITY_AREA_RIGHT);
expect(rightTypes).toContain(RightType.COMMUNITY_RIGHT);
});
it('should calculate share right reward (500 USDT) when referrer has planted', async () => {
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: true }],
});
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const shareReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.SHARE_RIGHT,
);
expect(shareReward).toBeDefined();
expect(shareReward?.usdtAmount.amount).toBe(500 * 10); // 500 USDT per tree
expect(shareReward?.userId).toBe(BigInt(200));
expect(shareReward?.isSettleable).toBe(true); // Already planted, so settleable
});
it('should create pending share right reward when referrer has not planted', async () => {
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [{ userId: BigInt(200), hasPlanted: false }],
});
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const shareReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.SHARE_RIGHT,
);
expect(shareReward).toBeDefined();
expect(shareReward?.isPending).toBe(true); // Not planted, so pending with 24h countdown
expect(shareReward?.expireAt).toBeDefined();
});
it('should assign share right to headquarters when no referrer', async () => {
mockReferralService.getReferralChain.mockResolvedValue({
ancestors: [],
});
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const shareReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.SHARE_RIGHT,
);
expect(shareReward).toBeDefined();
expect(shareReward?.userId).toBe(BigInt(1)); // Headquarters community ID
});
it('should calculate province team right (20 USDT)', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(BigInt(300));
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const provinceTeamReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.PROVINCE_TEAM_RIGHT,
);
expect(provinceTeamReward).toBeDefined();
expect(provinceTeamReward?.usdtAmount.amount).toBe(20 * 10);
expect(provinceTeamReward?.userId).toBe(BigInt(300));
});
it('should calculate province area right (15 USDT + 1% hashpower)', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const provinceAreaReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.PROVINCE_AREA_RIGHT,
);
expect(provinceAreaReward).toBeDefined();
expect(provinceAreaReward?.usdtAmount.amount).toBe(15 * 10);
expect(provinceAreaReward?.hashpowerAmount.value).toBe(1 * 10); // 1% * 10 trees = 10
});
it('should calculate city team right (40 USDT)', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(BigInt(400));
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const cityTeamReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.CITY_TEAM_RIGHT,
);
expect(cityTeamReward).toBeDefined();
expect(cityTeamReward?.usdtAmount.amount).toBe(40 * 10);
expect(cityTeamReward?.userId).toBe(BigInt(400));
});
it('should calculate city area right (35 USDT + 2% hashpower)', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
const cityAreaReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.CITY_AREA_RIGHT,
);
expect(cityAreaReward).toBeDefined();
expect(cityAreaReward?.usdtAmount.amount).toBe(35 * 10);
expect(cityAreaReward?.hashpowerAmount.value).toBe(2 * 10); // 2% * 10 trees = 20
});
it('should calculate community right (80 USDT)', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(BigInt(500));
const rewards = await service.calculateRewards(baseParams);
const communityReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.COMMUNITY_RIGHT,
);
expect(communityReward).toBeDefined();
expect(communityReward?.usdtAmount.amount).toBe(80 * 10);
expect(communityReward?.userId).toBe(BigInt(500));
});
it('should assign to headquarters when no authorized holder found', async () => {
mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [] });
mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(null);
mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(null);
mockAuthorizationService.findNearestCommunity.mockResolvedValue(null);
const rewards = await service.calculateRewards(baseParams);
// Province team, city team, community should go to headquarters (ID=1)
const provinceTeamReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.PROVINCE_TEAM_RIGHT,
);
const cityTeamReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.CITY_TEAM_RIGHT,
);
const communityReward = rewards.find(
(r) => r.rewardSource.rightType === RightType.COMMUNITY_RIGHT,
);
expect(provinceTeamReward?.userId).toBe(BigInt(1));
expect(cityTeamReward?.userId).toBe(BigInt(1));
expect(communityReward?.userId).toBe(BigInt(1));
});
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}