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