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:
parent
85c820b5af
commit
9769012795
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
```
|
||||
|
|
@ -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消费者组实现负载均衡
|
||||
- 幂等性设计防止重复处理
|
||||
|
|
@ -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. **审计日志**: 记录所有敏感操作
|
||||
|
|
@ -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 */ });
|
||||
```
|
||||
|
|
@ -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 │
|
||||
└─────────────────┴──────────┴──────────┴──────────┴─────────┘
|
||||
```
|
||||
|
|
@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
},
|
||||
});
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
}));
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as appConfig } from './app.config';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './reward-ledger-entry.aggregate';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './reward-summary.aggregate';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './reward-ledger-entry.repository.interface';
|
||||
export * from './reward-summary.repository.interface';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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');
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './reward-calculation.service';
|
||||
export * from './reward-expiration.service';
|
||||
|
|
@ -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: '社区权益:无归属社区,进总部社区',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export enum RewardStatus {
|
||||
PENDING = 'PENDING', // 待领取(24h倒计时)
|
||||
SETTLEABLE = 'SETTLEABLE', // 可结算
|
||||
SETTLED = 'SETTLED', // 已结算
|
||||
EXPIRED = 'EXPIRED', // 已过期(进总部社区)
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue