feat(planting-service): Implement complete planting service with DDD architecture
- Domain Layer: - PlantingOrder aggregate with full lifecycle management - PlantingPosition aggregate for user holdings - PoolInjectionBatch aggregate for 5-day batch processing - Value objects: TreeCount, ProvinceCitySelection, FundAllocation, Money - Domain events for state transitions - FundAllocationDomainService for 10-target fund distribution (2199 USDT/tree) - Application Layer: - PlantingApplicationService for order management - PoolInjectionService for batch processing - 5-second province/city confirmation mechanism - Infrastructure Layer: - Prisma ORM with PostgreSQL - Repository implementations - External service clients (Wallet, Referral) - Object mappers - API Layer: - REST controllers with Swagger documentation - JWT authentication guard - Request/Response DTOs with validation - Testing: - 45+ unit tests - 12+ integration tests - 17+ E2E tests - Docker test environment - Documentation: - Architecture design (DDD + Hexagonal) - API documentation - Development guide - Testing guide - Deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2d18155ac2
commit
98f5d948dd
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dir:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx prisma generate:*)",
|
||||
"Bash(npm run test:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run test:e2e:*)",
|
||||
"Bash(npm run test:cov:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git add:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Database
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
|
||||
|
||||
# App
|
||||
NODE_ENV=development
|
||||
APP_PORT=3003
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="planting-service-dev-jwt-secret"
|
||||
|
||||
# External Services
|
||||
WALLET_SERVICE_URL=http://localhost:3002
|
||||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||||
REFERRAL_SERVICE_URL=http://localhost:3004
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Database
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
|
||||
|
||||
# App
|
||||
NODE_ENV=development
|
||||
APP_PORT=3003
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
|
||||
# External Services
|
||||
WALLET_SERVICE_URL=http://localhost:3002
|
||||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||||
REFERRAL_SERVICE_URL=http://localhost:3004
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Prisma
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# Testing
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy prisma schema and generate client
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy prisma schema and generate client
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/api/v1/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main"]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy prisma schema
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Default command for tests
|
||||
CMD ["npm", "run", "test"]
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
.PHONY: install build start dev test test-unit test-integration test-e2e test-cov test-watch test-docker-all clean prisma-generate prisma-migrate prisma-studio lint format docker-build docker-up docker-down
|
||||
|
||||
# =============================================================================
|
||||
# 环境变量
|
||||
# =============================================================================
|
||||
NODE_ENV ?= development
|
||||
DATABASE_URL ?= postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test?schema=public
|
||||
|
||||
# =============================================================================
|
||||
# 安装和构建
|
||||
# =============================================================================
|
||||
install:
|
||||
npm install
|
||||
|
||||
build:
|
||||
npm run build
|
||||
|
||||
clean:
|
||||
rm -rf dist node_modules coverage .nyc_output
|
||||
|
||||
# =============================================================================
|
||||
# 开发
|
||||
# =============================================================================
|
||||
start:
|
||||
npm run start
|
||||
|
||||
dev:
|
||||
npm run start:dev
|
||||
|
||||
# =============================================================================
|
||||
# 数据库
|
||||
# =============================================================================
|
||||
prisma-generate:
|
||||
npx prisma generate
|
||||
|
||||
prisma-migrate:
|
||||
npx prisma migrate dev
|
||||
|
||||
prisma-migrate-prod:
|
||||
npx prisma migrate deploy
|
||||
|
||||
prisma-studio:
|
||||
npx prisma studio
|
||||
|
||||
prisma-reset:
|
||||
npx prisma migrate reset --force
|
||||
|
||||
# =============================================================================
|
||||
# 测试
|
||||
# =============================================================================
|
||||
test: test-unit
|
||||
|
||||
test-unit:
|
||||
@echo "=========================================="
|
||||
@echo "Running Unit Tests..."
|
||||
@echo "=========================================="
|
||||
npm run test
|
||||
|
||||
test-unit-verbose:
|
||||
npm run test -- --verbose
|
||||
|
||||
test-watch:
|
||||
npm run test:watch
|
||||
|
||||
test-cov:
|
||||
@echo "=========================================="
|
||||
@echo "Running Unit Tests with Coverage..."
|
||||
@echo "=========================================="
|
||||
npm run test:cov
|
||||
|
||||
test-integration:
|
||||
@echo "=========================================="
|
||||
@echo "Running Integration Tests..."
|
||||
@echo "=========================================="
|
||||
npm run test -- --testPathPattern=integration --runInBand
|
||||
|
||||
test-e2e:
|
||||
@echo "=========================================="
|
||||
@echo "Running E2E Tests..."
|
||||
@echo "=========================================="
|
||||
npm run test:e2e
|
||||
|
||||
test-all:
|
||||
@echo "=========================================="
|
||||
@echo "Running All Tests..."
|
||||
@echo "=========================================="
|
||||
$(MAKE) test-unit
|
||||
$(MAKE) test-integration
|
||||
$(MAKE) test-e2e
|
||||
@echo "=========================================="
|
||||
@echo "All Tests Completed!"
|
||||
@echo "=========================================="
|
||||
|
||||
# =============================================================================
|
||||
# Docker 测试
|
||||
# =============================================================================
|
||||
docker-build:
|
||||
docker build -t planting-service:test .
|
||||
|
||||
docker-up:
|
||||
docker-compose up -d
|
||||
|
||||
docker-down:
|
||||
docker-compose down -v
|
||||
|
||||
docker-test-unit:
|
||||
@echo "=========================================="
|
||||
@echo "Running Unit Tests in Docker..."
|
||||
@echo "=========================================="
|
||||
docker-compose -f docker-compose.test.yml run --rm test npm run test
|
||||
|
||||
docker-test-integration:
|
||||
@echo "=========================================="
|
||||
@echo "Running Integration Tests in Docker..."
|
||||
@echo "=========================================="
|
||||
docker-compose -f docker-compose.test.yml run --rm test npm run test -- --testPathPattern=integration --runInBand
|
||||
|
||||
docker-test-e2e:
|
||||
@echo "=========================================="
|
||||
@echo "Running E2E Tests in Docker..."
|
||||
@echo "=========================================="
|
||||
docker-compose -f docker-compose.test.yml run --rm test npm run test:e2e
|
||||
|
||||
test-docker-all:
|
||||
@echo "=========================================="
|
||||
@echo "Running All Tests in Docker..."
|
||||
@echo "=========================================="
|
||||
docker-compose -f docker-compose.test.yml up -d db
|
||||
@sleep 5
|
||||
docker-compose -f docker-compose.test.yml run --rm test sh -c "npx prisma migrate deploy && npm run test && npm run test:e2e"
|
||||
docker-compose -f docker-compose.test.yml down -v
|
||||
@echo "=========================================="
|
||||
@echo "All Docker Tests Completed!"
|
||||
@echo "=========================================="
|
||||
|
||||
# =============================================================================
|
||||
# 代码质量
|
||||
# =============================================================================
|
||||
lint:
|
||||
npm run lint
|
||||
|
||||
format:
|
||||
npm run format
|
||||
|
||||
# =============================================================================
|
||||
# 帮助
|
||||
# =============================================================================
|
||||
help:
|
||||
@echo "Planting Service - Available Commands:"
|
||||
@echo ""
|
||||
@echo " Development:"
|
||||
@echo " make install - Install dependencies"
|
||||
@echo " make build - Build the project"
|
||||
@echo " make dev - Start in development mode"
|
||||
@echo " make start - Start in production mode"
|
||||
@echo ""
|
||||
@echo " Database:"
|
||||
@echo " make prisma-generate - Generate Prisma client"
|
||||
@echo " make prisma-migrate - Run database migrations"
|
||||
@echo " make prisma-studio - Open Prisma Studio"
|
||||
@echo " make prisma-reset - Reset database"
|
||||
@echo ""
|
||||
@echo " Testing:"
|
||||
@echo " make test-unit - Run unit tests"
|
||||
@echo " make test-integration - Run integration tests"
|
||||
@echo " make test-e2e - Run end-to-end tests"
|
||||
@echo " make test-cov - Run tests with coverage"
|
||||
@echo " make test-all - Run all tests"
|
||||
@echo " make test-docker-all - Run all tests in Docker"
|
||||
@echo ""
|
||||
@echo " Code Quality:"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make format - Format code"
|
||||
@echo ""
|
||||
@echo " Docker:"
|
||||
@echo " make docker-build - Build Docker image"
|
||||
@echo " make docker-up - Start Docker containers"
|
||||
@echo " make docker-down - Stop Docker containers"
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_planting_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_test_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test?schema=public
|
||||
JWT_SECRET: test-jwt-secret
|
||||
WALLET_SERVICE_URL: http://localhost:3002
|
||||
IDENTITY_SERVICE_URL: http://localhost:3001
|
||||
REFERRAL_SERVICE_URL: http://localhost:3004
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./test:/app/test
|
||||
- ./prisma:/app/prisma
|
||||
|
||||
volumes:
|
||||
postgres_test_data:
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
# Planting Service API 文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [认证](#认证)
|
||||
- [通用响应格式](#通用响应格式)
|
||||
- [API 端点](#api-端点)
|
||||
- [健康检查](#健康检查)
|
||||
- [订单管理](#订单管理)
|
||||
- [持仓查询](#持仓查询)
|
||||
- [错误码](#错误码)
|
||||
- [数据模型](#数据模型)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
- **Base URL**: `http://localhost:3003/api/v1`
|
||||
- **协议**: HTTP/HTTPS
|
||||
- **数据格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
|
||||
### Swagger 文档
|
||||
|
||||
启动服务后访问: `http://localhost:3003/api/docs`
|
||||
|
||||
---
|
||||
|
||||
## 认证
|
||||
|
||||
所有业务 API(除健康检查外)需要 JWT Bearer Token 认证。
|
||||
|
||||
### 请求头
|
||||
|
||||
```http
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
### Token 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"username": "user@example.com",
|
||||
"iat": 1699999999,
|
||||
"exp": 1700003599
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用响应格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"timestamp": "2024-11-30T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": "错误信息",
|
||||
"error": "Bad Request",
|
||||
"timestamp": "2024-11-30T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 端点
|
||||
|
||||
### 健康检查
|
||||
|
||||
#### GET /health
|
||||
|
||||
检查服务健康状态。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-11-30T10:00:00.000Z",
|
||||
"service": "planting-service"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /health/ready
|
||||
|
||||
检查服务就绪状态。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
GET /api/v1/health/ready
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"timestamp": "2024-11-30T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 订单管理
|
||||
|
||||
#### POST /planting/orders
|
||||
|
||||
创建认种订单。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
POST /api/v1/planting/orders
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"treeCount": 5
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| treeCount | number | 是 | 认种数量,1-1000 |
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"orderNo": "PO202411300001",
|
||||
"userId": "1",
|
||||
"treeCount": 5,
|
||||
"totalAmount": 10995,
|
||||
"status": "CREATED",
|
||||
"createdAt": "2024-11-30T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 参数错误(数量超限) |
|
||||
| 400 | 超过个人最大认种数量限制(1000棵)|
|
||||
| 400 | 余额不足 |
|
||||
| 401 | 未授权 |
|
||||
|
||||
---
|
||||
|
||||
#### GET /planting/orders
|
||||
|
||||
查询用户订单列表。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
GET /api/v1/planting/orders?page=1&pageSize=10
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|-----|------|-----|-------|------|
|
||||
| page | number | 否 | 1 | 页码 |
|
||||
| pageSize | number | 否 | 10 | 每页数量,最大100 |
|
||||
|
||||
**响应**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"orderNo": "PO202411300001",
|
||||
"treeCount": 5,
|
||||
"totalAmount": 10995,
|
||||
"status": "CREATED",
|
||||
"selectedProvince": null,
|
||||
"selectedCity": null,
|
||||
"createdAt": "2024-11-30T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"orderNo": "PO202411300002",
|
||||
"treeCount": 3,
|
||||
"totalAmount": 6597,
|
||||
"status": "MINING_ENABLED",
|
||||
"selectedProvince": "广东省",
|
||||
"selectedCity": "广州市",
|
||||
"createdAt": "2024-11-25T08:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /planting/orders/:orderNo
|
||||
|
||||
查询订单详情。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
GET /api/v1/planting/orders/PO202411300001
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"orderNo": "PO202411300001",
|
||||
"userId": "1",
|
||||
"treeCount": 5,
|
||||
"totalAmount": 10995,
|
||||
"status": "FUND_ALLOCATED",
|
||||
"selectedProvince": "广东省",
|
||||
"selectedCity": "广州市",
|
||||
"provinceCitySelectedAt": "2024-11-30T10:01:00.000Z",
|
||||
"provinceCityConfirmedAt": "2024-11-30T10:01:05.000Z",
|
||||
"paidAt": "2024-11-30T10:02:00.000Z",
|
||||
"fundAllocatedAt": "2024-11-30T10:02:01.000Z",
|
||||
"allocations": [
|
||||
{
|
||||
"targetType": "POOL",
|
||||
"amount": 9895.5
|
||||
},
|
||||
{
|
||||
"targetType": "OPERATION",
|
||||
"amount": 549.75
|
||||
}
|
||||
],
|
||||
"createdAt": "2024-11-30T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /planting/orders/:orderNo/select-province-city
|
||||
|
||||
选择省市(开始5秒倒计时)。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
POST /api/v1/planting/orders/PO202411300001/select-province-city
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"provinceCode": "440000",
|
||||
"provinceName": "广东省",
|
||||
"cityCode": "440100",
|
||||
"cityName": "广州市"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| provinceCode | string | 是 | 省份代码 |
|
||||
| provinceName | string | 是 | 省份名称 |
|
||||
| cityCode | string | 是 | 城市代码 |
|
||||
| cityName | string | 是 | 城市名称 |
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "省市选择成功,请在5秒后确认",
|
||||
"expiresAt": "2024-11-30T10:01:05.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 订单状态不允许选择省市 |
|
||||
| 404 | 订单不存在 |
|
||||
| 403 | 无权操作此订单 |
|
||||
|
||||
---
|
||||
|
||||
#### POST /planting/orders/:orderNo/confirm-province-city
|
||||
|
||||
确认省市选择(需在选择5秒后调用)。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
POST /api/v1/planting/orders/PO202411300001/confirm-province-city
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "省市确认成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 还需等待5秒才能确认 |
|
||||
| 400 | 订单状态不允许确认 |
|
||||
| 404 | 订单不存在 |
|
||||
|
||||
---
|
||||
|
||||
#### POST /planting/orders/:orderNo/pay
|
||||
|
||||
支付订单。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
POST /api/v1/planting/orders/PO202411300001/pay
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"orderNo": "PO202411300001",
|
||||
"status": "POOL_SCHEDULED",
|
||||
"paidAt": "2024-11-30T10:02:00.000Z",
|
||||
"totalAmount": 10995,
|
||||
"allocations": [
|
||||
{
|
||||
"targetType": "POOL",
|
||||
"amount": 9895.5,
|
||||
"description": "资金池"
|
||||
},
|
||||
{
|
||||
"targetType": "OPERATION",
|
||||
"amount": 549.75,
|
||||
"description": "运营费用"
|
||||
},
|
||||
{
|
||||
"targetType": "PROVINCE_AUTH",
|
||||
"amount": 65.97,
|
||||
"description": "省代奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "CITY_AUTH",
|
||||
"amount": 32.985,
|
||||
"description": "市代奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "COMMUNITY",
|
||||
"amount": 54.975,
|
||||
"description": "社区长奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "REFERRAL_L1",
|
||||
"amount": 164.925,
|
||||
"description": "一级推荐奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "REFERRAL_L2",
|
||||
"amount": 109.95,
|
||||
"description": "二级推荐奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "REFERRAL_L3",
|
||||
"amount": 54.975,
|
||||
"description": "三级推荐奖励"
|
||||
},
|
||||
{
|
||||
"targetType": "PLATFORM",
|
||||
"amount": 32.985,
|
||||
"description": "平台费用"
|
||||
},
|
||||
{
|
||||
"targetType": "RESERVE",
|
||||
"amount": 32.985,
|
||||
"description": "储备金"
|
||||
}
|
||||
],
|
||||
"batchInfo": {
|
||||
"batchNo": "BATCH202411300001",
|
||||
"scheduledInjectionTime": "2024-12-05T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 余额不足 |
|
||||
| 400 | 订单状态不允许支付 |
|
||||
| 400 | 请先确认省市选择 |
|
||||
| 404 | 订单不存在 |
|
||||
|
||||
---
|
||||
|
||||
#### POST /planting/orders/:orderNo/cancel
|
||||
|
||||
取消订单(仅未支付订单可取消)。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
POST /api/v1/planting/orders/PO202411300001/cancel
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "订单取消成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 订单状态不允许取消(已支付) |
|
||||
| 404 | 订单不存在 |
|
||||
| 403 | 无权操作此订单 |
|
||||
|
||||
---
|
||||
|
||||
### 持仓查询
|
||||
|
||||
#### GET /planting/position
|
||||
|
||||
查询用户持仓信息。
|
||||
|
||||
**请求**
|
||||
```http
|
||||
GET /api/v1/planting/position
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"totalTreeCount": 15,
|
||||
"effectiveTreeCount": 12,
|
||||
"pendingTreeCount": 3,
|
||||
"firstMiningStartAt": "2024-11-20T00:00:00.000Z",
|
||||
"distributions": [
|
||||
{
|
||||
"provinceCode": "440000",
|
||||
"provinceName": "广东省",
|
||||
"cityCode": "440100",
|
||||
"cityName": "广州市",
|
||||
"treeCount": 10
|
||||
},
|
||||
{
|
||||
"provinceCode": "310000",
|
||||
"provinceName": "上海市",
|
||||
"cityCode": "310100",
|
||||
"cityName": "上海市",
|
||||
"treeCount": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| totalTreeCount | number | 总认种数量 |
|
||||
| effectiveTreeCount | number | 有效数量(已开始挖矿)|
|
||||
| pendingTreeCount | number | 待生效数量 |
|
||||
| firstMiningStartAt | string | 首次挖矿开始时间 |
|
||||
| distributions | array | 按省市分布 |
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
### HTTP 状态码
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|-------|------|
|
||||
| 200 | 成功 |
|
||||
| 201 | 创建成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权 |
|
||||
| 403 | 禁止访问 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 业务错误码
|
||||
|
||||
| 错误信息 | 说明 |
|
||||
|---------|------|
|
||||
| 超过个人最大认种数量限制 | 单用户最多认种1000棵 |
|
||||
| 余额不足 | USDT 余额不足以支付 |
|
||||
| 订单不存在 | 订单号无效或已删除 |
|
||||
| 无权操作此订单 | 订单不属于当前用户 |
|
||||
| 订单状态不允许此操作 | 状态机校验失败 |
|
||||
| 还需等待5秒才能确认 | 省市选择5秒机制 |
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### PlantingOrder
|
||||
|
||||
```typescript
|
||||
interface PlantingOrder {
|
||||
orderNo: string; // 订单号,格式:PO + 年月日 + 序号
|
||||
userId: string; // 用户ID
|
||||
treeCount: number; // 认种数量
|
||||
totalAmount: number; // 总金额 (USDT)
|
||||
status: PlantingOrderStatus;
|
||||
selectedProvince?: string; // 选择的省份
|
||||
selectedCity?: string; // 选择的城市
|
||||
provinceCitySelectedAt?: Date;
|
||||
provinceCityConfirmedAt?: Date;
|
||||
paidAt?: Date;
|
||||
fundAllocatedAt?: Date;
|
||||
poolInjectionBatchId?: string;
|
||||
poolInjectionScheduledTime?: Date;
|
||||
poolInjectionActualTime?: Date;
|
||||
poolInjectionTxHash?: string;
|
||||
miningEnabledAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### PlantingOrderStatus
|
||||
|
||||
```typescript
|
||||
enum PlantingOrderStatus {
|
||||
CREATED = 'CREATED',
|
||||
PROVINCE_CITY_SELECTED = 'PROVINCE_CITY_SELECTED',
|
||||
PROVINCE_CITY_CONFIRMED = 'PROVINCE_CITY_CONFIRMED',
|
||||
PAID = 'PAID',
|
||||
FUND_ALLOCATED = 'FUND_ALLOCATED',
|
||||
POOL_SCHEDULED = 'POOL_SCHEDULED',
|
||||
POOL_INJECTED = 'POOL_INJECTED',
|
||||
MINING_ENABLED = 'MINING_ENABLED',
|
||||
CANCELLED = 'CANCELLED'
|
||||
}
|
||||
```
|
||||
|
||||
### FundAllocationTargetType
|
||||
|
||||
```typescript
|
||||
enum FundAllocationTargetType {
|
||||
POOL = 'POOL', // 资金池 90%
|
||||
OPERATION = 'OPERATION', // 运营 5%
|
||||
PROVINCE_AUTH = 'PROVINCE_AUTH', // 省代 0.6%
|
||||
CITY_AUTH = 'CITY_AUTH', // 市代 0.3%
|
||||
COMMUNITY = 'COMMUNITY', // 社区长 0.5%
|
||||
REFERRAL_L1 = 'REFERRAL_L1', // 一级推荐 1.5%
|
||||
REFERRAL_L2 = 'REFERRAL_L2', // 二级推荐 1.0%
|
||||
REFERRAL_L3 = 'REFERRAL_L3', // 三级推荐 0.5%
|
||||
PLATFORM = 'PLATFORM', // 平台 0.3%
|
||||
RESERVE = 'RESERVE' // 储备 0.3%
|
||||
}
|
||||
```
|
||||
|
||||
### PlantingPosition
|
||||
|
||||
```typescript
|
||||
interface PlantingPosition {
|
||||
userId: string;
|
||||
totalTreeCount: number;
|
||||
effectiveTreeCount: number;
|
||||
pendingTreeCount: number;
|
||||
firstMiningStartAt?: Date;
|
||||
distributions: PositionDistribution[];
|
||||
}
|
||||
|
||||
interface PositionDistribution {
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
treeCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
# 1. 创建订单
|
||||
curl -X POST http://localhost:3003/api/v1/planting/orders \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"treeCount": 5}'
|
||||
|
||||
# 2. 选择省市
|
||||
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/select-province-city \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"provinceCode": "440000",
|
||||
"provinceName": "广东省",
|
||||
"cityCode": "440100",
|
||||
"cityName": "广州市"
|
||||
}'
|
||||
|
||||
# 3. 等待5秒后确认省市
|
||||
sleep 5
|
||||
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/confirm-province-city \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. 支付订单
|
||||
curl -X POST http://localhost:3003/api/v1/planting/orders/PO202411300001/pay \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 5. 查询持仓
|
||||
curl http://localhost:3003/api/v1/planting/position \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript 示例
|
||||
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3003/api/v1',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 创建订单
|
||||
const { data: order } = await api.post('/planting/orders', { treeCount: 5 });
|
||||
|
||||
// 选择省市
|
||||
await api.post(`/planting/orders/${order.orderNo}/select-province-city`, {
|
||||
provinceCode: '440000',
|
||||
provinceName: '广东省',
|
||||
cityCode: '440100',
|
||||
cityName: '广州市'
|
||||
});
|
||||
|
||||
// 等待5秒
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// 确认省市
|
||||
await api.post(`/planting/orders/${order.orderNo}/confirm-province-city`);
|
||||
|
||||
// 支付
|
||||
const { data: payResult } = await api.post(`/planting/orders/${order.orderNo}/pay`);
|
||||
console.log('支付成功,资金分配:', payResult.allocations);
|
||||
```
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
# Planting Service 架构文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [架构模式](#架构模式)
|
||||
- [分层架构](#分层架构)
|
||||
- [领域模型](#领域模型)
|
||||
- [数据流](#数据流)
|
||||
- [技术栈](#技术栈)
|
||||
- [目录结构](#目录结构)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
Planting Service 是 RWA Durian Queen 平台的核心微服务之一,负责处理榴莲树认种业务的完整生命周期。该服务采用 **领域驱动设计 (DDD)** 结合 **六边形架构 (Hexagonal Architecture)** 进行设计和实现。
|
||||
|
||||
### 核心职责
|
||||
|
||||
1. **认种订单管理** - 创建、支付、取消订单
|
||||
2. **省市选择机制** - 5秒确认机制防止误操作
|
||||
3. **资金分配** - 10种目标的精确分配(总计2199 USDT/棵)
|
||||
4. **持仓管理** - 用户认种持仓的统计与追踪
|
||||
5. **资金池注入** - 5天批次管理机制
|
||||
|
||||
---
|
||||
|
||||
## 架构模式
|
||||
|
||||
### 领域驱动设计 (DDD)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (Controllers, DTOs) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Application Layer │
|
||||
│ (Application Services, Use Cases) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Domain Layer │
|
||||
│ (Aggregates, Entities, Value Objects, Domain Services) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
│ (Repositories, External Services, Persistence) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 六边形架构 (Ports & Adapters)
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ HTTP API │
|
||||
│ (Primary Adapter) │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
┌───────────▼─────────────┐
|
||||
│ │
|
||||
┌───────────────┤ Application Core ├───────────────┐
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ Domain Model │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ - Aggregates │ │ │
|
||||
│ │ │ - Services │ │ │
|
||||
│ │ │ - Events │ │ │
|
||||
│ │ └─────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ PostgreSQL│ │ Wallet Service│ │Referral Service│
|
||||
│ (Prisma) │ │ (HTTP) │ │ (HTTP) │
|
||||
└───────────┘ └───────────────┘ └───────────────┘
|
||||
Secondary Secondary Secondary
|
||||
Adapter Adapter Adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 分层架构
|
||||
|
||||
### 1. API 层 (`src/api/`)
|
||||
|
||||
**职责**: 处理 HTTP 请求/响应,输入验证,认证授权
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── controllers/ # 控制器
|
||||
│ ├── health.controller.ts
|
||||
│ ├── planting-order.controller.ts
|
||||
│ └── planting-position.controller.ts
|
||||
├── dto/ # 数据传输对象
|
||||
│ ├── request/ # 请求 DTO
|
||||
│ └── response/ # 响应 DTO
|
||||
├── guards/ # 守卫
|
||||
│ └── jwt-auth.guard.ts
|
||||
└── api.module.ts
|
||||
```
|
||||
|
||||
**设计原则**:
|
||||
- 控制器仅负责 HTTP 协议处理
|
||||
- DTO 用于数据验证和转换
|
||||
- 不包含业务逻辑
|
||||
|
||||
### 2. Application 层 (`src/application/`)
|
||||
|
||||
**职责**: 编排业务用例,协调领域对象,事务管理
|
||||
|
||||
```
|
||||
src/application/
|
||||
├── services/
|
||||
│ ├── planting-application.service.ts # 主应用服务
|
||||
│ └── pool-injection.service.ts # 资金池注入服务
|
||||
└── application.module.ts
|
||||
```
|
||||
|
||||
**核心职责**:
|
||||
- 用例编排
|
||||
- 跨聚合协调
|
||||
- 外部服务调用
|
||||
- 事务边界控制
|
||||
|
||||
### 3. Domain 层 (`src/domain/`)
|
||||
|
||||
**职责**: 核心业务逻辑,业务规则验证
|
||||
|
||||
```
|
||||
src/domain/
|
||||
├── aggregates/ # 聚合根
|
||||
│ ├── planting-order.aggregate.ts
|
||||
│ ├── planting-position.aggregate.ts
|
||||
│ └── pool-injection-batch.aggregate.ts
|
||||
├── value-objects/ # 值对象
|
||||
│ ├── tree-count.vo.ts
|
||||
│ ├── province-city-selection.vo.ts
|
||||
│ ├── fund-allocation.vo.ts
|
||||
│ └── money.vo.ts
|
||||
├── events/ # 领域事件
|
||||
│ ├── planting-order-created.event.ts
|
||||
│ ├── province-city-confirmed.event.ts
|
||||
│ └── funds-allocated.event.ts
|
||||
├── services/ # 领域服务
|
||||
│ └── fund-allocation.service.ts
|
||||
├── repositories/ # 仓储接口
|
||||
│ ├── planting-order.repository.interface.ts
|
||||
│ ├── planting-position.repository.interface.ts
|
||||
│ └── pool-injection-batch.repository.interface.ts
|
||||
└── domain.module.ts
|
||||
```
|
||||
|
||||
**设计原则**:
|
||||
- 聚合是事务一致性边界
|
||||
- 值对象不可变
|
||||
- 领域事件用于跨聚合通信
|
||||
- 仓储接口定义在领域层
|
||||
|
||||
### 4. Infrastructure 层 (`src/infrastructure/`)
|
||||
|
||||
**职责**: 技术实现,外部系统集成
|
||||
|
||||
```
|
||||
src/infrastructure/
|
||||
├── persistence/
|
||||
│ ├── prisma/
|
||||
│ │ └── prisma.service.ts
|
||||
│ ├── mappers/ # 领域对象<->持久化对象映射
|
||||
│ │ ├── planting-order.mapper.ts
|
||||
│ │ ├── planting-position.mapper.ts
|
||||
│ │ └── pool-injection-batch.mapper.ts
|
||||
│ └── repositories/ # 仓储实现
|
||||
│ ├── planting-order.repository.impl.ts
|
||||
│ ├── planting-position.repository.impl.ts
|
||||
│ └── pool-injection-batch.repository.impl.ts
|
||||
├── external/ # 外部服务客户端
|
||||
│ ├── wallet-service.client.ts
|
||||
│ └── referral-service.client.ts
|
||||
└── infrastructure.module.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 领域模型
|
||||
|
||||
### 聚合关系图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PlantingOrder │
|
||||
│ (聚合根 - 认种订单) │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ TreeCount │ │ ProvinceCitySelection│ │
|
||||
│ │ (认种数量) │ │ (省市选择) │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ FundAllocation[] │ │
|
||||
│ │ (资金分配列表) │ │
|
||||
│ │ │ │
|
||||
│ │ POOL(90%) | OPERATION(5%) | PROVINCE(0.6%) | CITY(0.3%) ... │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ 1:N
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PlantingPosition │
|
||||
│ (聚合根 - 用户持仓) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ PositionDistribution[] │ │
|
||||
│ │ (持仓分布 - 按省市统计) │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PoolInjectionBatch │
|
||||
│ (聚合根 - 资金池注入批次) │
|
||||
│ │
|
||||
│ - 5天为一个批次周期 │
|
||||
│ - 批量处理订单的资金池注入 │
|
||||
│ - 状态: OPEN -> SCHEDULED -> INJECTING -> COMPLETED │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 订单状态流转
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ CREATED │ ◄─── 创建订单
|
||||
└────┬─────┘
|
||||
│ selectProvinceCity()
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PROVINCE_CITY_SELECTED│ ◄─── 选择省市(5秒等待)
|
||||
└────┬─────────────────┘
|
||||
│ confirmProvinceCity() (5秒后)
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│PROVINCE_CITY_CONFIRMED│ ◄─── 确认省市
|
||||
└────┬─────────────────┘
|
||||
│ pay()
|
||||
▼
|
||||
┌──────────┐
|
||||
│ PAID │ ◄─── 支付完成
|
||||
└────┬─────┘
|
||||
│ allocateFunds()
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ FUND_ALLOCATED │ ◄─── 资金分配完成
|
||||
└────┬───────────┘
|
||||
│ scheduleToBatch()
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ POOL_SCHEDULED │ ◄─── 加入注入批次
|
||||
└────┬───────────┘
|
||||
│ markPoolInjected()
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ POOL_INJECTED │ ◄─── 资金池注入完成
|
||||
└────┬───────────┘
|
||||
│ enableMining()
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ MINING_ENABLED │ ◄─── 开始挖矿
|
||||
└────────────────┘
|
||||
|
||||
任意状态(PAID之前)可转为:
|
||||
┌───────────┐
|
||||
│ CANCELLED │ ◄─── 取消订单
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### 资金分配模型
|
||||
|
||||
每棵树 **2199 USDT** 分配到 10 个目标:
|
||||
|
||||
| 序号 | 目标类型 | 金额 (USDT) | 比例 |
|
||||
|-----|---------|------------|------|
|
||||
| 1 | POOL (资金池) | 1979.1 | 90% |
|
||||
| 2 | OPERATION (运营) | 109.95 | 5% |
|
||||
| 3 | PROVINCE_AUTH (省代) | 13.194 | 0.6% |
|
||||
| 4 | CITY_AUTH (市代) | 6.597 | 0.3% |
|
||||
| 5 | COMMUNITY (社区长) | 10.995 | 0.5% |
|
||||
| 6 | REFERRAL_L1 (一级推荐) | 32.985 | 1.5% |
|
||||
| 7 | REFERRAL_L2 (二级推荐) | 21.99 | 1.0% |
|
||||
| 8 | REFERRAL_L3 (三级推荐) | 10.995 | 0.5% |
|
||||
| 9 | PLATFORM (平台) | 6.597 | 0.3% |
|
||||
| 10 | RESERVE (储备) | 6.597 | 0.3% |
|
||||
| **总计** | | **2199** | **100%** |
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
### 创建订单流程
|
||||
|
||||
```
|
||||
┌──────┐ ┌────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||
│Client│────▶│ Controller │────▶│ Application Svc │────▶│ Domain Layer │
|
||||
└──────┘ └────────────┘ └─────────────────┘ └──────────────┘
|
||||
│ │ │ │
|
||||
│ POST /orders│ │ │
|
||||
│ {treeCount} │ │ │
|
||||
│ │ │ │
|
||||
│ │ createOrder() │ │
|
||||
│ │────────────────────▶│ │
|
||||
│ │ │ │
|
||||
│ │ │ 检查限购数量 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ │
|
||||
│ │ │ 检查余额 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Wallet Service) │
|
||||
│ │ │ │
|
||||
│ │ │ 创建订单聚合 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ │
|
||||
│ │ │ 保存订单 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Repository) │
|
||||
│ │ │ │
|
||||
│◀─────────────│◀────────────────────│◀─────────────────────│
|
||||
│ OrderDTO │ │ │
|
||||
```
|
||||
|
||||
### 支付订单流程
|
||||
|
||||
```
|
||||
┌──────┐ ┌────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||
│Client│────▶│ Controller │────▶│ Application Svc │────▶│ Domain Layer │
|
||||
└──────┘ └────────────┘ └─────────────────┘ └──────────────┘
|
||||
│ │ │ │
|
||||
│ POST /pay │ │ │
|
||||
│ │ payOrder() │ │
|
||||
│ │────────────────────▶│ │
|
||||
│ │ │ │
|
||||
│ │ │ 1. 扣款 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Wallet Service) │
|
||||
│ │ │ │
|
||||
│ │ │ 2. 获取推荐上下文 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Referral Service) │
|
||||
│ │ │ │
|
||||
│ │ │ 3. 计算资金分配 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (FundAllocationSvc) │
|
||||
│ │ │ │
|
||||
│ │ │ 4. 执行资金分配 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Wallet Service) │
|
||||
│ │ │ │
|
||||
│ │ │ 5. 更新用户持仓 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Position Repo) │
|
||||
│ │ │ │
|
||||
│ │ │ 6. 加入注入批次 │
|
||||
│ │ │─────────────────────▶│
|
||||
│ │ │ (Batch Repo) │
|
||||
│ │ │ │
|
||||
│◀─────────────│◀────────────────────│◀─────────────────────│
|
||||
│ PayResult │ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术选型 | 用途 |
|
||||
|-----|---------|------|
|
||||
| 框架 | NestJS 10.x | Web 框架 |
|
||||
| 语言 | TypeScript 5.x | 开发语言 |
|
||||
| 数据库 | PostgreSQL 16 | 主数据库 |
|
||||
| ORM | Prisma 5.x | 数据访问 |
|
||||
| 验证 | class-validator | 输入验证 |
|
||||
| 文档 | Swagger/OpenAPI | API 文档 |
|
||||
| 测试 | Jest + Supertest | 测试框架 |
|
||||
| 容器 | Docker | 容器化部署 |
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
planting-service/
|
||||
├── docs/ # 文档目录
|
||||
│ ├── ARCHITECTURE.md # 架构文档
|
||||
│ ├── API.md # API 文档
|
||||
│ ├── DEVELOPMENT.md # 开发指南
|
||||
│ ├── TESTING.md # 测试文档
|
||||
│ └── DEPLOYMENT.md # 部署文档
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # 数据库模型
|
||||
│ └── migrations/ # 数据库迁移
|
||||
├── src/
|
||||
│ ├── api/ # API 层
|
||||
│ ├── application/ # 应用层
|
||||
│ ├── domain/ # 领域层
|
||||
│ ├── infrastructure/ # 基础设施层
|
||||
│ ├── config/ # 配置
|
||||
│ ├── shared/ # 共享模块
|
||||
│ ├── app.module.ts # 根模块
|
||||
│ └── main.ts # 入口文件
|
||||
├── test/ # E2E 测试
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── docker-compose.test.yml # 测试环境编排
|
||||
├── Dockerfile # 生产镜像
|
||||
├── Dockerfile.test # 测试镜像
|
||||
├── Makefile # 构建脚本
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [领域驱动设计 (Eric Evans)](https://www.domainlanguage.com/ddd/)
|
||||
- [六边形架构 (Alistair Cockburn)](https://alistair.cockburn.us/hexagonal-architecture/)
|
||||
- [NestJS 官方文档](https://docs.nestjs.com/)
|
||||
- [Prisma 官方文档](https://www.prisma.io/docs/)
|
||||
|
|
@ -0,0 +1,760 @@
|
|||
# Planting Service 部署文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [部署概述](#部署概述)
|
||||
- [环境要求](#环境要求)
|
||||
- [配置管理](#配置管理)
|
||||
- [Docker 部署](#docker-部署)
|
||||
- [Kubernetes 部署](#kubernetes-部署)
|
||||
- [数据库迁移](#数据库迁移)
|
||||
- [健康检查](#健康检查)
|
||||
- [监控与日志](#监控与日志)
|
||||
- [故障排查](#故障排查)
|
||||
- [回滚策略](#回滚策略)
|
||||
|
||||
---
|
||||
|
||||
## 部署概述
|
||||
|
||||
### 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Load Balancer │
|
||||
│ (Nginx/ALB) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Planting Service│ │ Planting Service│ │ Planting Service│
|
||||
│ Instance 1 │ │ Instance 2 │ │ Instance 3 │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Primary/RDS) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 部署方式
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|-----|---------|-------|
|
||||
| Docker Compose | 开发/测试环境 | 低 |
|
||||
| Docker Swarm | 小规模生产 | 中 |
|
||||
| Kubernetes | 大规模生产 | 高 |
|
||||
| Cloud Run/ECS | 托管云服务 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 生产环境最低配置
|
||||
|
||||
| 资源 | 最低配置 | 推荐配置 |
|
||||
|-----|---------|---------|
|
||||
| CPU | 2 核 | 4 核 |
|
||||
| 内存 | 2 GB | 4 GB |
|
||||
| 磁盘 | 20 GB SSD | 50 GB SSD |
|
||||
| 网络 | 100 Mbps | 1 Gbps |
|
||||
|
||||
### 数据库配置
|
||||
|
||||
| 环境 | 实例类型 | 存储 |
|
||||
|-----|---------|------|
|
||||
| 开发 | db.t3.micro | 20 GB |
|
||||
| 测试 | db.t3.small | 50 GB |
|
||||
| 生产 | db.r5.large | 200 GB |
|
||||
|
||||
---
|
||||
|
||||
## 配置管理
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 基础配置
|
||||
NODE_ENV=production
|
||||
PORT=3003
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://user:password@host:5432/dbname?schema=public
|
||||
|
||||
# JWT 认证
|
||||
JWT_SECRET=<secure-random-string>
|
||||
|
||||
# 外部服务
|
||||
WALLET_SERVICE_URL=http://wallet-service:3002
|
||||
IDENTITY_SERVICE_URL=http://identity-service:3001
|
||||
REFERRAL_SERVICE_URL=http://referral-service:3004
|
||||
|
||||
# 日志
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 性能
|
||||
MAX_CONNECTIONS=100
|
||||
QUERY_TIMEOUT=30000
|
||||
```
|
||||
|
||||
### 配置文件示例
|
||||
|
||||
**.env.production**
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3003
|
||||
DATABASE_URL=postgresql://planting:${DB_PASSWORD}@db.prod.internal:5432/rwadurian_planting?schema=public&connection_limit=50
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
WALLET_SERVICE_URL=http://wallet-service.prod.internal:3002
|
||||
IDENTITY_SERVICE_URL=http://identity-service.prod.internal:3001
|
||||
REFERRAL_SERVICE_URL=http://referral-service.prod.internal:3004
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### 敏感信息管理
|
||||
|
||||
使用密钥管理服务:
|
||||
|
||||
```bash
|
||||
# AWS Secrets Manager
|
||||
aws secretsmanager get-secret-value --secret-id planting-service/prod
|
||||
|
||||
# Kubernetes Secrets
|
||||
kubectl create secret generic planting-secrets \
|
||||
--from-literal=DATABASE_URL='...' \
|
||||
--from-literal=JWT_SECRET='...'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
||||
### 生产 Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci
|
||||
|
||||
# 复制 Prisma schema 并生成客户端
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001 -G nodejs
|
||||
|
||||
# 复制依赖文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 仅安装生产依赖
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 复制 Prisma
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER nestjs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3003
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/api/v1/health || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["node", "dist/main"]
|
||||
```
|
||||
|
||||
### Docker Compose 生产配置
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
planting-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: planting-service:${VERSION:-latest}
|
||||
container_name: planting-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3003:3003"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- WALLET_SERVICE_URL=${WALLET_SERVICE_URL}
|
||||
- IDENTITY_SERVICE_URL=${IDENTITY_SERVICE_URL}
|
||||
- REFERRAL_SERVICE_URL=${REFERRAL_SERVICE_URL}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3003/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- rwadurian-network
|
||||
|
||||
networks:
|
||||
rwadurian-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
### 部署命令
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t planting-service:v1.0.0 .
|
||||
|
||||
# 推送到仓库
|
||||
docker tag planting-service:v1.0.0 registry.example.com/planting-service:v1.0.0
|
||||
docker push registry.example.com/planting-service:v1.0.0
|
||||
|
||||
# 部署
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose -f docker-compose.prod.yml logs -f planting-service
|
||||
|
||||
# 重启
|
||||
docker-compose -f docker-compose.prod.yml restart planting-service
|
||||
|
||||
# 停止
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes 部署
|
||||
|
||||
### Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: planting-service
|
||||
labels:
|
||||
app: planting-service
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: planting-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: planting-service
|
||||
spec:
|
||||
containers:
|
||||
- name: planting-service
|
||||
image: registry.example.com/planting-service:v1.0.0
|
||||
ports:
|
||||
- containerPort: 3003
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: PORT
|
||||
value: "3003"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: planting-secrets
|
||||
key: DATABASE_URL
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: planting-secrets
|
||||
key: JWT_SECRET
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2000m"
|
||||
memory: "2Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 3003
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/ready
|
||||
port: 3003
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
imagePullSecrets:
|
||||
- name: registry-credentials
|
||||
```
|
||||
|
||||
### Service
|
||||
|
||||
```yaml
|
||||
# k8s/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: planting-service
|
||||
spec:
|
||||
selector:
|
||||
app: planting-service
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3003
|
||||
targetPort: 3003
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
### Ingress
|
||||
|
||||
```yaml
|
||||
# k8s/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: planting-service-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.rwadurian.com
|
||||
secretName: api-tls
|
||||
rules:
|
||||
- host: api.rwadurian.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1/planting
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: planting-service
|
||||
port:
|
||||
number: 3003
|
||||
```
|
||||
|
||||
### HPA (Horizontal Pod Autoscaler)
|
||||
|
||||
```yaml
|
||||
# k8s/hpa.yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: planting-service-hpa
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: planting-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
|
||||
# 创建 secrets
|
||||
kubectl create secret generic planting-secrets \
|
||||
--from-literal=DATABASE_URL='postgresql://...' \
|
||||
--from-literal=JWT_SECRET='...'
|
||||
|
||||
# 应用配置
|
||||
kubectl apply -f k8s/
|
||||
|
||||
# 查看状态
|
||||
kubectl get pods -l app=planting-service
|
||||
kubectl get svc planting-service
|
||||
|
||||
# 查看日志
|
||||
kubectl logs -f -l app=planting-service
|
||||
|
||||
# 扩容
|
||||
kubectl scale deployment planting-service --replicas=5
|
||||
|
||||
# 回滚
|
||||
kubectl rollout undo deployment/planting-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
### 迁移策略
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据库迁移流程 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. 创建迁移脚本 (开发环境) │
|
||||
│ npx prisma migrate dev --name add_new_feature │
|
||||
│ │
|
||||
│ 2. 代码审查迁移脚本 │
|
||||
│ 检查 prisma/migrations/ 目录 │
|
||||
│ │
|
||||
│ 3. 测试环境验证 │
|
||||
│ npx prisma migrate deploy │
|
||||
│ │
|
||||
│ 4. 生产环境部署 │
|
||||
│ - 备份数据库 │
|
||||
│ - 运行迁移 (npx prisma migrate deploy) │
|
||||
│ - 部署新版本应用 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 迁移命令
|
||||
|
||||
```bash
|
||||
# 开发环境 - 创建迁移
|
||||
npx prisma migrate dev --name add_new_feature
|
||||
|
||||
# 生产环境 - 应用迁移
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 查看迁移状态
|
||||
npx prisma migrate status
|
||||
|
||||
# 重置数据库 (仅开发)
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
### 迁移最佳实践
|
||||
|
||||
1. **向后兼容**: 新版本应用应兼容旧数据库 schema
|
||||
2. **分步迁移**: 大型变更分多个小迁移执行
|
||||
3. **备份优先**: 生产迁移前必须备份
|
||||
4. **回滚脚本**: 准备对应的回滚 SQL
|
||||
|
||||
---
|
||||
|
||||
## 健康检查
|
||||
|
||||
### 端点说明
|
||||
|
||||
| 端点 | 用途 | 检查内容 |
|
||||
|-----|------|---------|
|
||||
| `/api/v1/health` | 存活检查 | 服务是否运行 |
|
||||
| `/api/v1/health/ready` | 就绪检查 | 服务是否可接收请求 |
|
||||
|
||||
### 健康检查实现
|
||||
|
||||
```typescript
|
||||
// src/api/controllers/health.controller.ts
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'planting-service',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
async ready() {
|
||||
// 可添加数据库连接检查
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 负载均衡器配置
|
||||
|
||||
**Nginx 配置**
|
||||
|
||||
```nginx
|
||||
upstream planting_service {
|
||||
server planting-service-1:3003;
|
||||
server planting-service-2:3003;
|
||||
server planting-service-3:3003;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.rwadurian.com;
|
||||
|
||||
location /api/v1/planting {
|
||||
proxy_pass http://planting_service;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# 健康检查
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://planting_service/api/v1/health;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控与日志
|
||||
|
||||
### 日志配置
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: process.env.NODE_ENV === 'production'
|
||||
? ['error', 'warn', 'log']
|
||||
: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 日志格式
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-11-30T10:00:00.000Z",
|
||||
"level": "info",
|
||||
"context": "PlantingApplicationService",
|
||||
"message": "Order created",
|
||||
"metadata": {
|
||||
"orderNo": "PO202411300001",
|
||||
"userId": "1",
|
||||
"treeCount": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prometheus 指标
|
||||
|
||||
```yaml
|
||||
# prometheus/scrape_configs
|
||||
- job_name: 'planting-service'
|
||||
static_configs:
|
||||
- targets: ['planting-service:3003']
|
||||
metrics_path: '/metrics'
|
||||
```
|
||||
|
||||
### Grafana 仪表板
|
||||
|
||||
关键指标:
|
||||
- 请求速率 (requests/second)
|
||||
- 响应时间 (p50, p95, p99)
|
||||
- 错误率
|
||||
- 数据库连接池状态
|
||||
- 订单创建数
|
||||
- 支付成功率
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 服务无法启动
|
||||
|
||||
```bash
|
||||
# 检查日志
|
||||
docker logs planting-service
|
||||
|
||||
# 常见原因
|
||||
# - 数据库连接失败
|
||||
# - 环境变量缺失
|
||||
# - 端口冲突
|
||||
```
|
||||
|
||||
#### 2. 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 检查连接
|
||||
psql $DATABASE_URL -c "SELECT 1"
|
||||
|
||||
# 检查 Prisma
|
||||
npx prisma db pull
|
||||
```
|
||||
|
||||
#### 3. 内存不足
|
||||
|
||||
```bash
|
||||
# 检查内存使用
|
||||
docker stats planting-service
|
||||
|
||||
# 调整 Node.js 内存限制
|
||||
NODE_OPTIONS="--max-old-space-size=4096" node dist/main
|
||||
```
|
||||
|
||||
#### 4. 高延迟
|
||||
|
||||
```bash
|
||||
# 检查数据库查询
|
||||
# 启用 Prisma 查询日志
|
||||
|
||||
# 检查外部服务响应
|
||||
curl -w "@curl-format.txt" http://wallet-service:3002/health
|
||||
```
|
||||
|
||||
### 调试命令
|
||||
|
||||
```bash
|
||||
# 进入容器
|
||||
docker exec -it planting-service sh
|
||||
|
||||
# 检查网络
|
||||
docker exec planting-service ping db
|
||||
|
||||
# 检查环境变量
|
||||
docker exec planting-service env | grep DATABASE
|
||||
|
||||
# 实时日志
|
||||
docker logs -f --tail 100 planting-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 回滚策略
|
||||
|
||||
### Docker 回滚
|
||||
|
||||
```bash
|
||||
# 停止当前版本
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 启动上一版本
|
||||
docker-compose -f docker-compose.prod.yml up -d --no-build
|
||||
```
|
||||
|
||||
### Kubernetes 回滚
|
||||
|
||||
```bash
|
||||
# 查看部署历史
|
||||
kubectl rollout history deployment/planting-service
|
||||
|
||||
# 回滚到上一版本
|
||||
kubectl rollout undo deployment/planting-service
|
||||
|
||||
# 回滚到指定版本
|
||||
kubectl rollout undo deployment/planting-service --to-revision=2
|
||||
|
||||
# 查看回滚状态
|
||||
kubectl rollout status deployment/planting-service
|
||||
```
|
||||
|
||||
### 数据库回滚
|
||||
|
||||
```sql
|
||||
-- 准备回滚脚本
|
||||
-- prisma/rollback/20241130_rollback.sql
|
||||
|
||||
-- 示例:回滚列添加
|
||||
ALTER TABLE "PlantingOrder" DROP COLUMN IF EXISTS "newColumn";
|
||||
```
|
||||
|
||||
### 回滚检查清单
|
||||
|
||||
- [ ] 确认问题根因
|
||||
- [ ] 通知相关团队
|
||||
- [ ] 执行回滚操作
|
||||
- [ ] 验证服务恢复
|
||||
- [ ] 检查数据一致性
|
||||
- [ ] 更新事故报告
|
||||
|
||||
---
|
||||
|
||||
## 部署检查清单
|
||||
|
||||
### 部署前
|
||||
|
||||
- [ ] 代码审查通过
|
||||
- [ ] 所有测试通过
|
||||
- [ ] 数据库迁移已测试
|
||||
- [ ] 环境变量已配置
|
||||
- [ ] 备份已完成
|
||||
|
||||
### 部署中
|
||||
|
||||
- [ ] 监控仪表板就绪
|
||||
- [ ] 日志收集正常
|
||||
- [ ] 渐进式部署 (金丝雀/蓝绿)
|
||||
- [ ] 健康检查通过
|
||||
|
||||
### 部署后
|
||||
|
||||
- [ ] 功能验证
|
||||
- [ ] 性能验证
|
||||
- [ ] 错误率监控
|
||||
- [ ] 用户反馈收集
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
# Planting Service 开发指南
|
||||
|
||||
## 目录
|
||||
|
||||
- [环境要求](#环境要求)
|
||||
- [项目设置](#项目设置)
|
||||
- [开发流程](#开发流程)
|
||||
- [代码规范](#代码规范)
|
||||
- [领域开发指南](#领域开发指南)
|
||||
- [常用命令](#常用命令)
|
||||
- [调试技巧](#调试技巧)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 必需工具
|
||||
|
||||
| 工具 | 版本 | 用途 |
|
||||
|-----|------|------|
|
||||
| Node.js | ≥ 20.x | 运行时 |
|
||||
| npm | ≥ 10.x | 包管理 |
|
||||
| PostgreSQL | ≥ 16.x | 数据库 |
|
||||
| Docker | ≥ 24.x | 容器化 (可选) |
|
||||
| Git | ≥ 2.x | 版本控制 |
|
||||
|
||||
### 推荐 IDE
|
||||
|
||||
- **VS Code** + 推荐插件:
|
||||
- ESLint
|
||||
- Prettier
|
||||
- Prisma
|
||||
- GitLens
|
||||
- REST Client
|
||||
|
||||
---
|
||||
|
||||
## 项目设置
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd backend/services/planting-service
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 或使用 make
|
||||
make install
|
||||
```
|
||||
|
||||
### 3. 环境配置
|
||||
|
||||
复制环境配置文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```env
|
||||
# 数据库
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting?schema=public"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="your-secret-key"
|
||||
|
||||
# 服务端口
|
||||
PORT=3003
|
||||
|
||||
# 外部服务
|
||||
WALLET_SERVICE_URL=http://localhost:3002
|
||||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||||
REFERRAL_SERVICE_URL=http://localhost:3004
|
||||
```
|
||||
|
||||
### 4. 数据库设置
|
||||
|
||||
```bash
|
||||
# 生成 Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# 运行数据库迁移
|
||||
npx prisma migrate dev
|
||||
|
||||
# (可选) 打开 Prisma Studio
|
||||
npx prisma studio
|
||||
```
|
||||
|
||||
### 5. 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run start:dev
|
||||
# 或使用 make
|
||||
make dev
|
||||
```
|
||||
|
||||
服务将在 `http://localhost:3003` 启动。
|
||||
|
||||
---
|
||||
|
||||
## 开发流程
|
||||
|
||||
### Git 分支策略
|
||||
|
||||
```
|
||||
main # 生产分支
|
||||
├── develop # 开发分支
|
||||
├── feature/* # 功能分支
|
||||
├── bugfix/* # 修复分支
|
||||
└── hotfix/* # 紧急修复分支
|
||||
```
|
||||
|
||||
### 开发流程
|
||||
|
||||
1. **创建功能分支**
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/new-feature
|
||||
```
|
||||
|
||||
2. **开发功能**
|
||||
- 编写代码
|
||||
- 编写测试
|
||||
- 运行测试确保通过
|
||||
|
||||
3. **提交代码**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
```
|
||||
|
||||
4. **推送并创建 PR**
|
||||
```bash
|
||||
git push origin feature/new-feature
|
||||
# 在 GitHub 创建 Pull Request
|
||||
```
|
||||
|
||||
### 提交规范
|
||||
|
||||
使用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**类型 (type)**:
|
||||
- `feat`: 新功能
|
||||
- `fix`: Bug 修复
|
||||
- `docs`: 文档更新
|
||||
- `style`: 代码格式
|
||||
- `refactor`: 重构
|
||||
- `test`: 测试
|
||||
- `chore`: 构建/工具
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
feat(order): add province-city selection
|
||||
fix(payment): fix balance check logic
|
||||
docs(api): update API documentation
|
||||
test(order): add integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码规范
|
||||
|
||||
### TypeScript 规范
|
||||
|
||||
```typescript
|
||||
// 1. 使用明确的类型声明
|
||||
function createOrder(userId: bigint, treeCount: number): PlantingOrder {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 2. 使用 readonly 保护不可变数据
|
||||
class TreeCount {
|
||||
constructor(private readonly _value: number) {}
|
||||
}
|
||||
|
||||
// 3. 使用接口定义契约
|
||||
interface IPlantingOrderRepository {
|
||||
save(order: PlantingOrder): Promise<void>;
|
||||
findById(id: bigint): Promise<PlantingOrder | null>;
|
||||
}
|
||||
|
||||
// 4. 使用枚举定义常量
|
||||
enum PlantingOrderStatus {
|
||||
CREATED = 'CREATED',
|
||||
PAID = 'PAID',
|
||||
}
|
||||
```
|
||||
|
||||
### 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|-----|------|------|
|
||||
| 类名 | PascalCase | `PlantingOrder` |
|
||||
| 接口 | I + PascalCase | `IPlantingOrderRepository` |
|
||||
| 方法 | camelCase | `createOrder` |
|
||||
| 常量 | UPPER_SNAKE_CASE | `MAX_TREE_COUNT` |
|
||||
| 文件 | kebab-case | `planting-order.aggregate.ts` |
|
||||
| 目录 | kebab-case | `value-objects` |
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
```
|
||||
*.aggregate.ts # 聚合根
|
||||
*.entity.ts # 实体
|
||||
*.vo.ts # 值对象
|
||||
*.service.ts # 服务
|
||||
*.repository.*.ts # 仓储
|
||||
*.controller.ts # 控制器
|
||||
*.dto.ts # 数据传输对象
|
||||
*.spec.ts # 单元测试
|
||||
*.integration.spec.ts # 集成测试
|
||||
*.e2e-spec.ts # E2E 测试
|
||||
*.mapper.ts # 映射器
|
||||
*.guard.ts # 守卫
|
||||
*.filter.ts # 过滤器
|
||||
```
|
||||
|
||||
### ESLint + Prettier
|
||||
|
||||
项目已配置 ESLint 和 Prettier:
|
||||
|
||||
```bash
|
||||
# 运行 lint
|
||||
npm run lint
|
||||
|
||||
# 格式化代码
|
||||
npm run format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 领域开发指南
|
||||
|
||||
### 创建新的聚合根
|
||||
|
||||
```typescript
|
||||
// src/domain/aggregates/new-aggregate.aggregate.ts
|
||||
|
||||
export interface NewAggregateData {
|
||||
id?: bigint;
|
||||
name: string;
|
||||
// ... 其他字段
|
||||
}
|
||||
|
||||
export class NewAggregate {
|
||||
private _id?: bigint;
|
||||
private _name: string;
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(data: NewAggregateData) {
|
||||
this._id = data.id;
|
||||
this._name = data.name;
|
||||
}
|
||||
|
||||
// 工厂方法 - 创建新实例
|
||||
static create(name: string): NewAggregate {
|
||||
const aggregate = new NewAggregate({ name });
|
||||
aggregate.addDomainEvent(new NewAggregateCreatedEvent(aggregate));
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
// 工厂方法 - 从持久化重建
|
||||
static reconstitute(data: NewAggregateData): NewAggregate {
|
||||
return new NewAggregate(data);
|
||||
}
|
||||
|
||||
// 业务方法
|
||||
doSomething(): void {
|
||||
// 业务逻辑
|
||||
this.addDomainEvent(new SomethingHappenedEvent(this));
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | undefined { return this._id; }
|
||||
get name(): string { return this._name; }
|
||||
|
||||
// 领域事件
|
||||
private addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
getDomainEvents(): DomainEvent[] {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建值对象
|
||||
|
||||
```typescript
|
||||
// src/domain/value-objects/email.vo.ts
|
||||
|
||||
export class Email {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: string): Email {
|
||||
if (!this.isValid(value)) {
|
||||
throw new Error('Invalid email format');
|
||||
}
|
||||
return new Email(value);
|
||||
}
|
||||
|
||||
private static isValid(value: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: Email): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建领域服务
|
||||
|
||||
```typescript
|
||||
// src/domain/services/pricing.service.ts
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PricingDomainService {
|
||||
private readonly PRICE_PER_TREE = 2199;
|
||||
|
||||
calculateTotalPrice(treeCount: number): number {
|
||||
return treeCount * this.PRICE_PER_TREE;
|
||||
}
|
||||
|
||||
calculateDiscount(treeCount: number): number {
|
||||
if (treeCount >= 100) return 0.05;
|
||||
if (treeCount >= 50) return 0.03;
|
||||
if (treeCount >= 10) return 0.01;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建仓储
|
||||
|
||||
**1. 定义接口 (领域层)**
|
||||
|
||||
```typescript
|
||||
// src/domain/repositories/new-aggregate.repository.interface.ts
|
||||
|
||||
export const NEW_AGGREGATE_REPOSITORY = Symbol('INewAggregateRepository');
|
||||
|
||||
export interface INewAggregateRepository {
|
||||
save(aggregate: NewAggregate): Promise<void>;
|
||||
findById(id: bigint): Promise<NewAggregate | null>;
|
||||
findByName(name: string): Promise<NewAggregate[]>;
|
||||
}
|
||||
```
|
||||
|
||||
**2. 实现仓储 (基础设施层)**
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/persistence/repositories/new-aggregate.repository.impl.ts
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { INewAggregateRepository } from '../../../domain/repositories/new-aggregate.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class NewAggregateRepositoryImpl implements INewAggregateRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(aggregate: NewAggregate): Promise<void> {
|
||||
const data = NewAggregateMapper.toPersistence(aggregate);
|
||||
|
||||
if (aggregate.id) {
|
||||
await this.prisma.newAggregate.update({
|
||||
where: { id: aggregate.id },
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
const created = await this.prisma.newAggregate.create({ data });
|
||||
aggregate.setId(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<NewAggregate | null> {
|
||||
const record = await this.prisma.newAggregate.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return record ? NewAggregateMapper.toDomain(record) : null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<NewAggregate[]> {
|
||||
const records = await this.prisma.newAggregate.findMany({
|
||||
where: { name: { contains: name } },
|
||||
});
|
||||
return records.map(NewAggregateMapper.toDomain);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 注册到模块**
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/infrastructure.module.ts
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: NEW_AGGREGATE_REPOSITORY,
|
||||
useClass: NewAggregateRepositoryImpl,
|
||||
},
|
||||
],
|
||||
exports: [NEW_AGGREGATE_REPOSITORY],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
### Makefile 命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
make install # 安装依赖
|
||||
make dev # 启动开发服务器
|
||||
make build # 构建项目
|
||||
|
||||
# 数据库
|
||||
make prisma-generate # 生成 Prisma Client
|
||||
make prisma-migrate # 运行迁移
|
||||
make prisma-studio # 打开 Prisma Studio
|
||||
make prisma-reset # 重置数据库
|
||||
|
||||
# 测试
|
||||
make test-unit # 单元测试
|
||||
make test-integration # 集成测试
|
||||
make test-e2e # E2E 测试
|
||||
make test-cov # 测试覆盖率
|
||||
make test-all # 运行所有测试
|
||||
|
||||
# Docker
|
||||
make docker-build # 构建 Docker 镜像
|
||||
make docker-up # 启动容器
|
||||
make docker-down # 停止容器
|
||||
make test-docker-all # Docker 中运行测试
|
||||
|
||||
# 代码质量
|
||||
make lint # 运行 ESLint
|
||||
make format # 格式化代码
|
||||
```
|
||||
|
||||
### npm 命令
|
||||
|
||||
```bash
|
||||
npm run start # 启动服务
|
||||
npm run start:dev # 开发模式
|
||||
npm run start:debug # 调试模式
|
||||
npm run build # 构建
|
||||
npm run test # 运行测试
|
||||
npm run test:watch # 监听模式测试
|
||||
npm run test:cov # 覆盖率测试
|
||||
npm run test:e2e # E2E 测试
|
||||
npm run lint # 代码检查
|
||||
npm run format # 代码格式化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### VS Code 调试配置
|
||||
|
||||
创建 `.vscode/launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug NestJS",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"--inspect-brk",
|
||||
"-r",
|
||||
"tsconfig-paths/register",
|
||||
"-r",
|
||||
"ts-node/register"
|
||||
],
|
||||
"args": ["${workspaceFolder}/src/main.ts"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"protocol": "inspector"
|
||||
},
|
||||
{
|
||||
"name": "Debug Jest Tests",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"--inspect-brk",
|
||||
"${workspaceRoot}/node_modules/.bin/jest",
|
||||
"--runInBand"
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Prisma 查询日志
|
||||
|
||||
在 `.env` 中启用:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://...?connection_limit=5"
|
||||
```
|
||||
|
||||
在 `prisma.service.ts` 中配置:
|
||||
|
||||
```typescript
|
||||
super({
|
||||
log: ['query', 'info', 'warn', 'error'],
|
||||
});
|
||||
```
|
||||
|
||||
### 请求日志
|
||||
|
||||
使用 NestJS Logger:
|
||||
|
||||
```typescript
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PlantingApplicationService {
|
||||
private readonly logger = new Logger(PlantingApplicationService.name);
|
||||
|
||||
async createOrder(userId: bigint, treeCount: number) {
|
||||
this.logger.log(`Creating order for user ${userId}, trees: ${treeCount}`);
|
||||
// ...
|
||||
this.logger.debug('Order created successfully', { orderNo });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: Prisma Client 未生成
|
||||
|
||||
```bash
|
||||
# 解决方案
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Q: 数据库连接失败
|
||||
|
||||
检查:
|
||||
1. PostgreSQL 服务是否启动
|
||||
2. `.env` 中 DATABASE_URL 是否正确
|
||||
3. 数据库是否存在
|
||||
|
||||
```bash
|
||||
# 创建数据库
|
||||
createdb rwadurian_planting
|
||||
```
|
||||
|
||||
### Q: BigInt 序列化错误
|
||||
|
||||
在返回 JSON 时,BigInt 需要转换为字符串:
|
||||
|
||||
```typescript
|
||||
// 在响应 DTO 中
|
||||
class OrderResponse {
|
||||
@Transform(({ value }) => value.toString())
|
||||
userId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 测试时数据库冲突
|
||||
|
||||
使用独立的测试数据库:
|
||||
|
||||
```env
|
||||
# .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test"
|
||||
```
|
||||
|
||||
### Q: 循环依赖错误
|
||||
|
||||
使用 `forwardRef`:
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [forwardRef(() => OtherModule)],
|
||||
})
|
||||
export class MyModule {}
|
||||
```
|
||||
|
||||
### Q: 热重载不工作
|
||||
|
||||
检查 `nest-cli.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": true,
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [NestJS 官方文档](https://docs.nestjs.com/)
|
||||
- [Prisma 官方文档](https://www.prisma.io/docs/)
|
||||
- [TypeScript 手册](https://www.typescriptlang.org/docs/)
|
||||
- [领域驱动设计精粹](https://www.domainlanguage.com/ddd/)
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
# Planting Service 文档中心
|
||||
|
||||
## 概述
|
||||
|
||||
Planting Service 是 RWA Durian Queen 平台的核心微服务,负责处理榴莲树认种业务的完整生命周期。
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **认种订单管理** - 创建、支付、取消订单
|
||||
- **省市选择机制** - 5秒确认机制防止误操作
|
||||
- **资金分配** - 10种目标的精确分配(2199 USDT/棵)
|
||||
- **持仓管理** - 用户认种持仓统计与追踪
|
||||
- **资金池注入** - 5天批次管理机制
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|-----|------|------|
|
||||
| NestJS | 10.x | Web 框架 |
|
||||
| TypeScript | 5.x | 开发语言 |
|
||||
| PostgreSQL | 16 | 数据库 |
|
||||
| Prisma | 5.x | ORM |
|
||||
| Jest | 29.x | 测试框架 |
|
||||
| Docker | 24.x | 容器化 |
|
||||
|
||||
---
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 文档 | 描述 | 适用人员 |
|
||||
|-----|------|---------|
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 系统架构设计 | 架构师、技术负责人 |
|
||||
| [API.md](./API.md) | API 接口文档 | 前端开发、测试 |
|
||||
| [DEVELOPMENT.md](./DEVELOPMENT.md) | 开发指南 | 后端开发 |
|
||||
| [TESTING.md](./TESTING.md) | 测试文档 | 开发、测试 |
|
||||
| [DEPLOYMENT.md](./DEPLOYMENT.md) | 部署文档 | DevOps、运维 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 要求
|
||||
Node.js >= 20.x
|
||||
PostgreSQL >= 16
|
||||
Docker >= 24.x (可选)
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 配置环境
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件配置数据库等
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
### 5. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run start:dev
|
||||
|
||||
# 生产模式
|
||||
npm run build
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
### 6. 访问服务
|
||||
|
||||
- 服务地址: http://localhost:3003
|
||||
- API 文档: http://localhost:3003/api/docs
|
||||
- 健康检查: http://localhost:3003/api/v1/health
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
make dev # 启动开发服务器
|
||||
make build # 构建项目
|
||||
|
||||
# 测试
|
||||
make test-unit # 单元测试
|
||||
make test-integration # 集成测试
|
||||
make test-e2e # E2E 测试
|
||||
make test-cov # 覆盖率测试
|
||||
make test-all # 所有测试
|
||||
|
||||
# 数据库
|
||||
make prisma-generate # 生成 Prisma Client
|
||||
make prisma-migrate # 运行迁移
|
||||
make prisma-studio # 打开 Prisma Studio
|
||||
|
||||
# Docker
|
||||
make docker-build # 构建镜像
|
||||
make docker-up # 启动容器
|
||||
make test-docker-all # Docker 测试
|
||||
|
||||
# 代码质量
|
||||
make lint # 代码检查
|
||||
make format # 代码格式化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
planting-service/
|
||||
├── docs/ # 文档目录
|
||||
│ ├── README.md # 文档索引
|
||||
│ ├── ARCHITECTURE.md # 架构文档
|
||||
│ ├── API.md # API 文档
|
||||
│ ├── DEVELOPMENT.md # 开发指南
|
||||
│ ├── TESTING.md # 测试文档
|
||||
│ └── DEPLOYMENT.md # 部署文档
|
||||
├── prisma/ # 数据库
|
||||
│ ├── schema.prisma # 模型定义
|
||||
│ └── migrations/ # 迁移文件
|
||||
├── src/ # 源代码
|
||||
│ ├── api/ # API 层
|
||||
│ ├── application/ # 应用层
|
||||
│ ├── domain/ # 领域层
|
||||
│ ├── infrastructure/ # 基础设施层
|
||||
│ └── main.ts # 入口
|
||||
├── test/ # E2E 测试
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── Dockerfile # 生产镜像
|
||||
└── Makefile # 构建脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 业务规则概览
|
||||
|
||||
### 定价
|
||||
|
||||
- 单价: **2199 USDT/棵**
|
||||
- 单次限制: 1-1000 棵
|
||||
- 个人限制: 最多 1000 棵
|
||||
|
||||
### 资金分配
|
||||
|
||||
| 目标 | 比例 | 金额 (USDT) |
|
||||
|-----|------|------------|
|
||||
| 资金池 | 90% | 1979.1 |
|
||||
| 运营 | 5% | 109.95 |
|
||||
| 省代 | 0.6% | 13.194 |
|
||||
| 市代 | 0.3% | 6.597 |
|
||||
| 社区长 | 0.5% | 10.995 |
|
||||
| 一级推荐 | 1.5% | 32.985 |
|
||||
| 二级推荐 | 1.0% | 21.99 |
|
||||
| 三级推荐 | 0.5% | 10.995 |
|
||||
| 平台 | 0.3% | 6.597 |
|
||||
| 储备 | 0.3% | 6.597 |
|
||||
|
||||
### 订单状态流转
|
||||
|
||||
```
|
||||
CREATED → PROVINCE_CITY_SELECTED → PROVINCE_CITY_CONFIRMED
|
||||
→ PAID → FUND_ALLOCATED → POOL_SCHEDULED
|
||||
→ POOL_INJECTED → MINING_ENABLED
|
||||
|
||||
任意状态(PAID前) → CANCELLED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试报告
|
||||
|
||||
### 测试概览
|
||||
|
||||
| 类型 | 数量 | 状态 |
|
||||
|-----|------|-----|
|
||||
| 单元测试 | 45+ | ✅ 通过 |
|
||||
| 集成测试 | 12+ | ✅ 通过 |
|
||||
| E2E 测试 | 17+ | ✅ 通过 |
|
||||
|
||||
### 覆盖率
|
||||
|
||||
| 模块 | 覆盖率 |
|
||||
|-----|-------|
|
||||
| 应用服务 | 89% |
|
||||
| 领域服务 | 93% |
|
||||
| 聚合根 | 69% |
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
### 提交规范
|
||||
|
||||
使用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
- `feat`: 新功能
|
||||
- `fix`: Bug 修复
|
||||
- `docs`: 文档更新
|
||||
- `test`: 测试
|
||||
- `refactor`: 重构
|
||||
|
||||
---
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [NestJS 文档](https://docs.nestjs.com/)
|
||||
- [Prisma 文档](https://www.prisma.io/docs/)
|
||||
- [TypeScript 文档](https://www.typescriptlang.org/docs/)
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 项目 Issues
|
||||
- 技术负责人邮箱
|
||||
|
|
@ -0,0 +1,896 @@
|
|||
# Planting Service 测试文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [测试策略](#测试策略)
|
||||
- [测试架构](#测试架构)
|
||||
- [单元测试](#单元测试)
|
||||
- [集成测试](#集成测试)
|
||||
- [端到端测试](#端到端测试)
|
||||
- [测试覆盖率](#测试覆盖率)
|
||||
- [Docker 测试](#docker-测试)
|
||||
- [CI/CD 集成](#cicd-集成)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试金字塔
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ E2E │ ◄── 少量,验证完整流程
|
||||
│ Tests │
|
||||
─┴─────────┴─
|
||||
┌─────────────┐
|
||||
│ Integration │ ◄── 中等数量,验证模块协作
|
||||
│ Tests │
|
||||
─┴─────────────┴─
|
||||
┌─────────────────┐
|
||||
│ Unit Tests │ ◄── 大量,验证业务逻辑
|
||||
│ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 测试分布
|
||||
|
||||
| 测试类型 | 数量 | 覆盖范围 | 执行时间 |
|
||||
|---------|------|---------|---------|
|
||||
| 单元测试 | 45+ | 领域逻辑、值对象 | < 10s |
|
||||
| 集成测试 | 12+ | 应用服务、用例 | < 30s |
|
||||
| E2E 测试 | 17+ | API 端点、认证 | < 60s |
|
||||
|
||||
---
|
||||
|
||||
## 测试架构
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
planting-service/
|
||||
├── src/
|
||||
│ ├── domain/
|
||||
│ │ ├── aggregates/
|
||||
│ │ │ ├── planting-order.aggregate.ts
|
||||
│ │ │ └── planting-order.aggregate.spec.ts # 单元测试
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── fund-allocation.service.ts
|
||||
│ │ │ └── fund-allocation.service.spec.ts # 单元测试
|
||||
│ │ └── value-objects/
|
||||
│ │ ├── tree-count.vo.ts
|
||||
│ │ └── tree-count.vo.spec.ts # 单元测试
|
||||
│ └── application/
|
||||
│ └── services/
|
||||
│ ├── planting-application.service.ts
|
||||
│ └── planting-application.service.integration.spec.ts # 集成测试
|
||||
└── test/
|
||||
├── app.e2e-spec.ts # E2E 测试
|
||||
└── jest-e2e.json # E2E 配置
|
||||
```
|
||||
|
||||
### 测试配置
|
||||
|
||||
**Jest 配置 (package.json)**
|
||||
|
||||
```json
|
||||
{
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**E2E 配置 (test/jest-e2e.json)**
|
||||
|
||||
```json
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 单元测试
|
||||
|
||||
### 概述
|
||||
|
||||
单元测试专注于测试独立的业务逻辑单元,不依赖外部服务或数据库。
|
||||
|
||||
### 测试领域聚合
|
||||
|
||||
```typescript
|
||||
// src/domain/aggregates/planting-order.aggregate.spec.ts
|
||||
|
||||
import { PlantingOrder } from './planting-order.aggregate';
|
||||
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
|
||||
|
||||
describe('PlantingOrder', () => {
|
||||
describe('create', () => {
|
||||
it('应该创建有效的订单', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 5);
|
||||
|
||||
expect(order.userId).toBe(BigInt(1));
|
||||
expect(order.treeCount).toBe(5);
|
||||
expect(order.totalAmount).toBe(5 * 2199);
|
||||
expect(order.status).toBe(PlantingOrderStatus.CREATED);
|
||||
});
|
||||
|
||||
it('应该生成唯一的订单号', () => {
|
||||
const order1 = PlantingOrder.create(BigInt(1), 1);
|
||||
const order2 = PlantingOrder.create(BigInt(1), 1);
|
||||
|
||||
expect(order1.orderNo).not.toBe(order2.orderNo);
|
||||
expect(order1.orderNo).toMatch(/^PO\d{14}$/);
|
||||
});
|
||||
|
||||
it('应该产生领域事件', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 5);
|
||||
const events = order.getDomainEvents();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].constructor.name).toBe('PlantingOrderCreatedEvent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectProvinceCity', () => {
|
||||
it('应该允许在CREATED状态下选择省市', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
expect(order.provinceCode).toBe('440000');
|
||||
expect(order.cityCode).toBe('440100');
|
||||
});
|
||||
|
||||
it('不应该允许在非CREATED状态下选择省市', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.cancel();
|
||||
|
||||
expect(() => {
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmProvinceCity', () => {
|
||||
it('应该在5秒后允许确认', () => {
|
||||
jest.useFakeTimers();
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(() => order.confirmProvinceCity()).not.toThrow();
|
||||
expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在5秒内拒绝确认', () => {
|
||||
jest.useFakeTimers();
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
jest.advanceTimersByTime(3000); // 只过了3秒
|
||||
|
||||
expect(() => order.confirmProvinceCity()).toThrow('还需等待');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 测试值对象
|
||||
|
||||
```typescript
|
||||
// src/domain/value-objects/tree-count.vo.spec.ts
|
||||
|
||||
import { TreeCount } from './tree-count.vo';
|
||||
|
||||
describe('TreeCount', () => {
|
||||
describe('create', () => {
|
||||
it('应该创建有效的数量', () => {
|
||||
const count = TreeCount.create(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('应该拒绝0或负数', () => {
|
||||
expect(() => TreeCount.create(0)).toThrow('认种数量必须大于0');
|
||||
expect(() => TreeCount.create(-1)).toThrow('认种数量必须大于0');
|
||||
});
|
||||
|
||||
it('应该拒绝超过最大限制', () => {
|
||||
expect(() => TreeCount.create(1001)).toThrow('单次认种数量不能超过1000');
|
||||
});
|
||||
|
||||
it('应该拒绝小数', () => {
|
||||
expect(() => TreeCount.create(1.5)).toThrow('认种数量必须为整数');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLimit', () => {
|
||||
it('应该正确检查限购', () => {
|
||||
expect(TreeCount.checkLimit(900, 100)).toBe(true); // 刚好1000
|
||||
expect(TreeCount.checkLimit(901, 100)).toBe(false); // 超过1000
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 测试领域服务
|
||||
|
||||
```typescript
|
||||
// src/domain/services/fund-allocation.service.spec.ts
|
||||
|
||||
import { FundAllocationDomainService } from './fund-allocation.service';
|
||||
|
||||
describe('FundAllocationDomainService', () => {
|
||||
let service: FundAllocationDomainService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new FundAllocationDomainService();
|
||||
});
|
||||
|
||||
describe('calculateAllocations', () => {
|
||||
it('应该返回10种分配', () => {
|
||||
const allocations = service.calculateAllocations(1, {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
});
|
||||
|
||||
expect(allocations).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('应该正确计算资金池分配', () => {
|
||||
const allocations = service.calculateAllocations(1, {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
});
|
||||
|
||||
const poolAllocation = allocations.find(a => a.targetType === 'POOL');
|
||||
expect(poolAllocation?.amount).toBe(1979.1); // 2199 * 90%
|
||||
});
|
||||
|
||||
it('应该正确计算总金额', () => {
|
||||
const allocations = service.calculateAllocations(5, {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
});
|
||||
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
expect(total).toBeCloseTo(5 * 2199, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
npm run test
|
||||
# 或
|
||||
make test-unit
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
|
||||
# 运行特定文件
|
||||
npm run test -- planting-order.aggregate.spec.ts
|
||||
|
||||
# 详细输出
|
||||
npm run test -- --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 集成测试
|
||||
|
||||
### 概述
|
||||
|
||||
集成测试验证多个组件协同工作,使用 Mock 替代外部依赖。
|
||||
|
||||
### 应用服务集成测试
|
||||
|
||||
```typescript
|
||||
// src/application/services/planting-application.service.integration.spec.ts
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PlantingApplicationService } from './planting-application.service';
|
||||
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
|
||||
import {
|
||||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-order.repository.interface';
|
||||
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
|
||||
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
|
||||
|
||||
describe('PlantingApplicationService (Integration)', () => {
|
||||
let service: PlantingApplicationService;
|
||||
let orderRepository: jest.Mocked<IPlantingOrderRepository>;
|
||||
let walletService: jest.Mocked<WalletServiceClient>;
|
||||
let referralService: jest.Mocked<ReferralServiceClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建 Mocks
|
||||
orderRepository = {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByOrderNo: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
countTreesByUserId: jest.fn(),
|
||||
} as any;
|
||||
|
||||
walletService = {
|
||||
getBalance: jest.fn(),
|
||||
deductForPlanting: jest.fn(),
|
||||
allocateFunds: jest.fn(),
|
||||
} as any;
|
||||
|
||||
referralService = {
|
||||
getReferralContext: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PlantingApplicationService,
|
||||
FundAllocationDomainService,
|
||||
{ provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository },
|
||||
{ provide: WalletServiceClient, useValue: walletService },
|
||||
{ provide: ReferralServiceClient, useValue: referralService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PlantingApplicationService>(PlantingApplicationService);
|
||||
});
|
||||
|
||||
describe('createOrder', () => {
|
||||
it('应该成功创建订单', async () => {
|
||||
// Arrange
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||||
walletService.getBalance.mockResolvedValue({
|
||||
userId: '1',
|
||||
available: 100000,
|
||||
locked: 0,
|
||||
currency: 'USDT',
|
||||
});
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await service.createOrder(BigInt(1), 5);
|
||||
|
||||
// Assert
|
||||
expect(result.treeCount).toBe(5);
|
||||
expect(result.totalAmount).toBe(5 * 2199);
|
||||
expect(orderRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝超过限购数量', async () => {
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(950);
|
||||
|
||||
await expect(service.createOrder(BigInt(1), 100))
|
||||
.rejects.toThrow('超过个人最大认种数量限制');
|
||||
});
|
||||
|
||||
it('应该拒绝余额不足', async () => {
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||||
walletService.getBalance.mockResolvedValue({
|
||||
userId: '1',
|
||||
available: 100, // 余额不足
|
||||
locked: 0,
|
||||
currency: 'USDT',
|
||||
});
|
||||
|
||||
await expect(service.createOrder(BigInt(1), 5))
|
||||
.rejects.toThrow('余额不足');
|
||||
});
|
||||
});
|
||||
|
||||
describe('payOrder', () => {
|
||||
it('应该成功支付订单并完成资金分配', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// 准备订单
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
// 设置 mocks
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
walletService.deductForPlanting.mockResolvedValue(true);
|
||||
referralService.getReferralContext.mockResolvedValue({
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
});
|
||||
walletService.allocateFunds.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
const result = await service.payOrder(order.orderNo, BigInt(1));
|
||||
|
||||
// Assert
|
||||
expect(result.allocations.length).toBe(10);
|
||||
expect(walletService.deductForPlanting).toHaveBeenCalled();
|
||||
expect(walletService.allocateFunds).toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 运行集成测试
|
||||
|
||||
```bash
|
||||
# 运行集成测试
|
||||
npm run test -- --testPathPattern=integration --runInBand
|
||||
# 或
|
||||
make test-integration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 端到端测试
|
||||
|
||||
### 概述
|
||||
|
||||
E2E 测试通过 HTTP 请求验证完整的 API 功能。
|
||||
|
||||
### E2E 测试实现
|
||||
|
||||
```typescript
|
||||
// test/app.e2e-spec.ts
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { HealthController } from '../src/api/controllers/health.controller';
|
||||
import { PlantingOrderController } from '../src/api/controllers/planting-order.controller';
|
||||
import { PlantingApplicationService } from '../src/application/services/planting-application.service';
|
||||
import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard';
|
||||
|
||||
// Mock 服务
|
||||
const mockPlantingService = {
|
||||
createOrder: jest.fn(),
|
||||
selectProvinceCity: jest.fn(),
|
||||
payOrder: jest.fn(),
|
||||
getUserOrders: jest.fn(),
|
||||
getUserPosition: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PlantingController (e2e) - Unauthorized', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController, PlantingOrderController],
|
||||
providers: [
|
||||
{ provide: PlantingApplicationService, useValue: mockPlantingService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => false })
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('/health (GET)', () => {
|
||||
it('应该返回健康状态', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/health')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders (POST)', () => {
|
||||
it('应该拒绝未认证的请求', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 1 })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantingController (e2e) - Authorized', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
// 设置认证通过的 Guard
|
||||
const mockAuthGuard = {
|
||||
canActivate: (context: ExecutionContext) => {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
req.user = { id: '1', username: 'testuser' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController, PlantingOrderController],
|
||||
providers: [
|
||||
{ provide: PlantingApplicationService, useValue: mockPlantingService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe('/planting/orders (POST)', () => {
|
||||
it('应该成功创建订单', async () => {
|
||||
mockPlantingService.createOrder.mockResolvedValue({
|
||||
orderNo: 'PO202411300001',
|
||||
treeCount: 5,
|
||||
totalAmount: 10995,
|
||||
status: 'CREATED',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 5 })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.orderNo).toBe('PO202411300001');
|
||||
});
|
||||
|
||||
it('应该验证 treeCount 必须为正整数', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 0 })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 运行 E2E 测试
|
||||
|
||||
```bash
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
# 或
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
### 生成覆盖率报告
|
||||
|
||||
```bash
|
||||
npm run test:cov
|
||||
# 或
|
||||
make test-cov
|
||||
```
|
||||
|
||||
### 覆盖率指标
|
||||
|
||||
| 指标 | 目标 | 当前 |
|
||||
|-----|------|-----|
|
||||
| 语句覆盖率 | > 80% | 34% |
|
||||
| 分支覆盖率 | > 70% | 17% |
|
||||
| 函数覆盖率 | > 80% | 36% |
|
||||
| 行覆盖率 | > 80% | 34% |
|
||||
|
||||
### 核心模块覆盖率
|
||||
|
||||
| 模块 | 覆盖率 | 说明 |
|
||||
|-----|-------|------|
|
||||
| planting-application.service | 89% | 核心应用服务 |
|
||||
| fund-allocation.service | 93% | 资金分配服务 |
|
||||
| planting-order.aggregate | 69% | 订单聚合 |
|
||||
| tree-count.vo | 100% | 数量值对象 |
|
||||
|
||||
### 覆盖率报告位置
|
||||
|
||||
覆盖率报告生成在 `coverage/` 目录:
|
||||
|
||||
```
|
||||
coverage/
|
||||
├── lcov-report/
|
||||
│ └── index.html # HTML 报告
|
||||
├── lcov.info # LCOV 格式
|
||||
└── clover.xml # Clover 格式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker 测试
|
||||
|
||||
### Docker 测试配置
|
||||
|
||||
**docker-compose.test.yml**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_planting_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test
|
||||
JWT_SECRET: test-jwt-secret
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./test:/app/test
|
||||
- ./prisma:/app/prisma
|
||||
```
|
||||
|
||||
**Dockerfile.test**
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "test"]
|
||||
```
|
||||
|
||||
### 运行 Docker 测试
|
||||
|
||||
```bash
|
||||
# 运行所有 Docker 测试
|
||||
make test-docker-all
|
||||
|
||||
# 分步运行
|
||||
docker-compose -f docker-compose.test.yml up -d db
|
||||
docker-compose -f docker-compose.test.yml run --rm test npm run test
|
||||
docker-compose -f docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions 示例
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_planting_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Run migrations
|
||||
run: npx prisma migrate deploy
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 测试命名规范
|
||||
|
||||
```typescript
|
||||
describe('被测试的单元', () => {
|
||||
describe('方法名', () => {
|
||||
it('应该 + 预期行为', () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. AAA 模式
|
||||
|
||||
```typescript
|
||||
it('应该创建有效的订单', () => {
|
||||
// Arrange - 准备
|
||||
const userId = BigInt(1);
|
||||
const treeCount = 5;
|
||||
|
||||
// Act - 执行
|
||||
const order = PlantingOrder.create(userId, treeCount);
|
||||
|
||||
// Assert - 断言
|
||||
expect(order.treeCount).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 避免测试实现细节
|
||||
|
||||
```typescript
|
||||
// 不好 - 测试实现细节
|
||||
it('应该调用 repository.save', () => {
|
||||
await service.createOrder(1, 5);
|
||||
expect(repository.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 好 - 测试行为
|
||||
it('应该返回创建的订单', () => {
|
||||
const result = await service.createOrder(1, 5);
|
||||
expect(result.orderNo).toBeDefined();
|
||||
expect(result.treeCount).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 独立的测试数据
|
||||
|
||||
```typescript
|
||||
// 每个测试使用独立数据
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// 使用工厂函数创建测试数据
|
||||
function createTestOrder(overrides = {}) {
|
||||
return PlantingOrder.create(BigInt(1), 1, {
|
||||
...defaultProps,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Mock 外部依赖
|
||||
|
||||
```typescript
|
||||
// 只 mock 必要的外部依赖
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ available: 100000 }),
|
||||
deductForPlanting: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用命令速查
|
||||
|
||||
```bash
|
||||
# 单元测试
|
||||
make test-unit # 运行单元测试
|
||||
npm run test -- --watch # 监听模式
|
||||
npm run test -- --verbose # 详细输出
|
||||
npm run test -- file.spec # 运行特定文件
|
||||
|
||||
# 集成测试
|
||||
make test-integration # 运行集成测试
|
||||
|
||||
# E2E 测试
|
||||
make test-e2e # 运行 E2E 测试
|
||||
|
||||
# 覆盖率
|
||||
make test-cov # 生成覆盖率报告
|
||||
|
||||
# Docker 测试
|
||||
make test-docker-all # Docker 中运行所有测试
|
||||
|
||||
# 所有测试
|
||||
make test-all # 运行所有测试
|
||||
```
|
||||
|
|
@ -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,92 @@
|
|||
{
|
||||
"name": "planting-service",
|
||||
"version": "1.0.0",
|
||||
"description": "RWA Durian Queen Planting Service",
|
||||
"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",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:migrate:prod": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@nestjs/axios": "^3.0.1",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 认种订单表 (状态表)
|
||||
// ============================================
|
||||
model PlantingOrder {
|
||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||
orderNo String @unique @map("order_no") @db.VarChar(50)
|
||||
userId BigInt @map("user_id")
|
||||
|
||||
// 认种信息
|
||||
treeCount Int @map("tree_count")
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
|
||||
|
||||
// 省市选择 (不可修改)
|
||||
selectedProvince String? @map("selected_province") @db.VarChar(10)
|
||||
selectedCity String? @map("selected_city") @db.VarChar(10)
|
||||
provinceCitySelectedAt DateTime? @map("province_city_selected_at")
|
||||
provinceCityConfirmedAt DateTime? @map("province_city_confirmed_at")
|
||||
|
||||
// 订单状态
|
||||
status String @default("CREATED") @map("status") @db.VarChar(30)
|
||||
|
||||
// 底池信息
|
||||
poolInjectionBatchId BigInt? @map("pool_injection_batch_id")
|
||||
poolInjectionScheduledTime DateTime? @map("pool_injection_scheduled_time")
|
||||
poolInjectionActualTime DateTime? @map("pool_injection_actual_time")
|
||||
poolInjectionTxHash String? @map("pool_injection_tx_hash") @db.VarChar(100)
|
||||
|
||||
// 挖矿
|
||||
miningEnabledAt DateTime? @map("mining_enabled_at")
|
||||
|
||||
// 时间戳
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
fundAllocatedAt DateTime? @map("fund_allocated_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
fundAllocations FundAllocation[]
|
||||
batch PoolInjectionBatch? @relation(fields: [poolInjectionBatchId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([orderNo])
|
||||
@@index([status])
|
||||
@@index([poolInjectionBatchId])
|
||||
@@index([selectedProvince, selectedCity])
|
||||
@@index([createdAt])
|
||||
@@index([paidAt])
|
||||
@@map("planting_orders")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 资金分配明细表 (行为表, append-only)
|
||||
// ============================================
|
||||
model FundAllocation {
|
||||
id BigInt @id @default(autoincrement()) @map("allocation_id")
|
||||
orderId BigInt @map("order_id")
|
||||
|
||||
// 分配信息
|
||||
targetType String @map("target_type") @db.VarChar(50)
|
||||
amount Decimal @map("amount") @db.Decimal(20, 8)
|
||||
targetAccountId String? @map("target_account_id") @db.VarChar(100)
|
||||
|
||||
// 元数据
|
||||
metadata Json? @map("metadata")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// 关联
|
||||
order PlantingOrder @relation(fields: [orderId], references: [id])
|
||||
|
||||
@@index([orderId])
|
||||
@@index([targetType, targetAccountId])
|
||||
@@index([createdAt])
|
||||
@@map("fund_allocations")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 用户持仓表 (状态表)
|
||||
// ============================================
|
||||
model PlantingPosition {
|
||||
id BigInt @id @default(autoincrement()) @map("position_id")
|
||||
userId BigInt @unique @map("user_id")
|
||||
|
||||
// 持仓统计
|
||||
totalTreeCount Int @default(0) @map("total_tree_count")
|
||||
effectiveTreeCount Int @default(0) @map("effective_tree_count")
|
||||
pendingTreeCount Int @default(0) @map("pending_tree_count")
|
||||
|
||||
// 挖矿状态
|
||||
firstMiningStartAt DateTime? @map("first_mining_start_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
distributions PositionDistribution[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([totalTreeCount])
|
||||
@@map("planting_positions")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 持仓省市分布表
|
||||
// ============================================
|
||||
model PositionDistribution {
|
||||
id BigInt @id @default(autoincrement()) @map("distribution_id")
|
||||
userId BigInt @map("user_id")
|
||||
|
||||
// 省市信息
|
||||
provinceCode String? @map("province_code") @db.VarChar(10)
|
||||
cityCode String? @map("city_code") @db.VarChar(10)
|
||||
|
||||
// 数量
|
||||
treeCount Int @default(0) @map("tree_count")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
position PlantingPosition @relation(fields: [userId], references: [userId])
|
||||
|
||||
@@unique([userId, provinceCode, cityCode])
|
||||
@@index([userId])
|
||||
@@index([provinceCode])
|
||||
@@index([cityCode])
|
||||
@@map("position_province_city_distribution")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 底池注入批次表 (状态表)
|
||||
// ============================================
|
||||
model PoolInjectionBatch {
|
||||
id BigInt @id @default(autoincrement()) @map("batch_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(50)
|
||||
|
||||
// 批次时间窗口 (5天)
|
||||
startDate DateTime @map("start_date") @db.Date
|
||||
endDate DateTime @map("end_date") @db.Date
|
||||
|
||||
// 统计信息
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(20, 8)
|
||||
|
||||
// 注入状态
|
||||
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||
scheduledInjectionTime DateTime? @map("scheduled_injection_time")
|
||||
actualInjectionTime DateTime? @map("actual_injection_time")
|
||||
injectionTxHash String? @map("injection_tx_hash") @db.VarChar(100)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
orders PlantingOrder[]
|
||||
|
||||
@@index([batchNo])
|
||||
@@index([startDate, endDate])
|
||||
@@index([status])
|
||||
@@index([scheduledInjectionTime])
|
||||
@@map("pool_injection_batches")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 认种事件表 (行为表, append-only)
|
||||
// ============================================
|
||||
model PlantingEvent {
|
||||
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")
|
||||
version Int @default(1) @map("version")
|
||||
|
||||
@@index([aggregateType, aggregateId])
|
||||
@@index([eventType])
|
||||
@@index([userId])
|
||||
@@index([occurredAt])
|
||||
@@map("planting_events")
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PlantingOrderController } from './controllers/planting-order.controller';
|
||||
import { PlantingPositionController } from './controllers/planting-position.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { ApplicationModule } from '../application/application.module';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [
|
||||
PlantingOrderController,
|
||||
PlantingPositionController,
|
||||
HealthController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('健康检查')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: '健康检查' })
|
||||
@ApiResponse({ status: 200, description: '服务正常' })
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'planting-service',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@ApiOperation({ summary: '就绪检查' })
|
||||
@ApiResponse({ status: 200, description: '服务就绪' })
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './planting-order.controller';
|
||||
export * from './planting-position.controller';
|
||||
export * from './health.controller';
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { PlantingApplicationService } from '../../application/services/planting-application.service';
|
||||
import { CreatePlantingOrderDto } from '../dto/request/create-planting-order.dto';
|
||||
import { SelectProvinceCityDto } from '../dto/request/select-province-city.dto';
|
||||
import { PaginationDto } from '../dto/request/pagination.dto';
|
||||
import {
|
||||
CreateOrderResponse,
|
||||
SelectProvinceCityResponse,
|
||||
ConfirmProvinceCityResponse,
|
||||
PayOrderResponse,
|
||||
PlantingOrderResponse,
|
||||
} from '../dto/response/planting-order.response';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@ApiTags('认种订单')
|
||||
@ApiBearerAuth()
|
||||
@Controller('planting')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlantingOrderController {
|
||||
constructor(
|
||||
private readonly plantingService: PlantingApplicationService,
|
||||
) {}
|
||||
|
||||
@Post('orders')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '创建认种订单' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.CREATED,
|
||||
description: '订单创建成功',
|
||||
type: CreateOrderResponse,
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '参数错误' })
|
||||
@ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '未授权' })
|
||||
async createOrder(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: CreatePlantingOrderDto,
|
||||
): Promise<CreateOrderResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.createOrder(userId, dto.treeCount);
|
||||
}
|
||||
|
||||
@Post('orders/:orderNo/select-province-city')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '选择省市' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '省市选择成功',
|
||||
type: SelectProvinceCityResponse,
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '参数错误' })
|
||||
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
|
||||
async selectProvinceCity(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('orderNo') orderNo: string,
|
||||
@Body() dto: SelectProvinceCityDto,
|
||||
): Promise<SelectProvinceCityResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.selectProvinceCity(
|
||||
orderNo,
|
||||
userId,
|
||||
dto.provinceCode,
|
||||
dto.provinceName,
|
||||
dto.cityCode,
|
||||
dto.cityName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('orders/:orderNo/confirm-province-city')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '确认省市选择(5秒后调用)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '省市确认成功',
|
||||
type: ConfirmProvinceCityResponse,
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '还需等待5秒' })
|
||||
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
|
||||
async confirmProvinceCity(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('orderNo') orderNo: string,
|
||||
): Promise<ConfirmProvinceCityResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.confirmProvinceCity(orderNo, userId);
|
||||
}
|
||||
|
||||
@Post('orders/:orderNo/pay')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '支付认种订单' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '支付成功',
|
||||
type: PayOrderResponse,
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '余额不足或状态错误' })
|
||||
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
|
||||
async payOrder(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('orderNo') orderNo: string,
|
||||
): Promise<PayOrderResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.payOrder(orderNo, userId);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
@ApiOperation({ summary: '查询我的订单列表' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '订单列表',
|
||||
type: [PlantingOrderResponse],
|
||||
})
|
||||
async getUserOrders(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<PlantingOrderResponse[]> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.getUserOrders(
|
||||
userId,
|
||||
pagination.page,
|
||||
pagination.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('orders/:orderNo')
|
||||
@ApiOperation({ summary: '查询订单详情' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '订单详情',
|
||||
type: PlantingOrderResponse,
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
|
||||
async getOrderDetail(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('orderNo') orderNo: string,
|
||||
): Promise<PlantingOrderResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
const order = await this.plantingService.getOrderDetail(orderNo, userId);
|
||||
if (!order) {
|
||||
throw new NotFoundException('订单不存在');
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
@Post('orders/:orderNo/cancel')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '取消订单' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '取消成功',
|
||||
})
|
||||
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '订单状态不允许取消' })
|
||||
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '订单不存在' })
|
||||
async cancelOrder(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('orderNo') orderNo: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.cancelOrder(orderNo, userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { PlantingApplicationService } from '../../application/services/planting-application.service';
|
||||
import { PlantingPositionResponse } from '../dto/response/planting-position.response';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@ApiTags('认种持仓')
|
||||
@ApiBearerAuth()
|
||||
@Controller('planting')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlantingPositionController {
|
||||
constructor(
|
||||
private readonly plantingService: PlantingApplicationService,
|
||||
) {}
|
||||
|
||||
@Get('position')
|
||||
@ApiOperation({ summary: '查询我的持仓' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '持仓信息',
|
||||
type: PlantingPositionResponse,
|
||||
})
|
||||
async getUserPosition(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<PlantingPositionResponse> {
|
||||
const userId = BigInt(req.user.id);
|
||||
return this.plantingService.getUserPosition(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './request';
|
||||
export * from './response';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { IsInt, Min, Max } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePlantingOrderDto {
|
||||
@ApiProperty({
|
||||
description: '认种数量',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
example: 1,
|
||||
})
|
||||
@IsInt({ message: '认种数量必须是整数' })
|
||||
@Min(1, { message: '认种数量最少为1棵' })
|
||||
@Max(100, { message: '单次认种数量最多为100棵' })
|
||||
treeCount: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-planting-order.dto';
|
||||
export * from './select-province-city.dto';
|
||||
export * from './pagination.dto';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '页码',
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 10;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SelectProvinceCityDto {
|
||||
@ApiProperty({
|
||||
description: '省份代码',
|
||||
example: '440000',
|
||||
})
|
||||
@IsString({ message: '省份代码必须是字符串' })
|
||||
@IsNotEmpty({ message: '省份代码不能为空' })
|
||||
@MaxLength(10, { message: '省份代码长度不能超过10' })
|
||||
provinceCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '省份名称',
|
||||
example: '广东省',
|
||||
})
|
||||
@IsString({ message: '省份名称必须是字符串' })
|
||||
@IsNotEmpty({ message: '省份名称不能为空' })
|
||||
@MaxLength(50, { message: '省份名称长度不能超过50' })
|
||||
provinceName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '城市代码',
|
||||
example: '440100',
|
||||
})
|
||||
@IsString({ message: '城市代码必须是字符串' })
|
||||
@IsNotEmpty({ message: '城市代码不能为空' })
|
||||
@MaxLength(10, { message: '城市代码长度不能超过10' })
|
||||
cityCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '城市名称',
|
||||
example: '广州市',
|
||||
})
|
||||
@IsString({ message: '城市名称必须是字符串' })
|
||||
@IsNotEmpty({ message: '城市名称不能为空' })
|
||||
@MaxLength(50, { message: '城市名称长度不能超过50' })
|
||||
cityName: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './planting-order.response';
|
||||
export * from './planting-position.response';
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PlantingOrderResponse {
|
||||
@ApiProperty({ description: '订单号' })
|
||||
orderNo: string;
|
||||
|
||||
@ApiProperty({ description: '认种数量' })
|
||||
treeCount: number;
|
||||
|
||||
@ApiProperty({ description: '订单总金额' })
|
||||
totalAmount: number;
|
||||
|
||||
@ApiProperty({ description: '订单状态' })
|
||||
status: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '省份代码' })
|
||||
provinceCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: '城市代码' })
|
||||
cityCode?: string | null;
|
||||
|
||||
@ApiProperty({ description: '是否已开启挖矿' })
|
||||
isMiningEnabled: boolean;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class CreateOrderResponse {
|
||||
@ApiProperty({ description: '订单号' })
|
||||
orderNo: string;
|
||||
|
||||
@ApiProperty({ description: '认种数量' })
|
||||
treeCount: number;
|
||||
|
||||
@ApiProperty({ description: '订单总金额' })
|
||||
totalAmount: number;
|
||||
|
||||
@ApiProperty({ description: '订单状态' })
|
||||
status: string;
|
||||
}
|
||||
|
||||
export class SelectProvinceCityResponse {
|
||||
@ApiProperty({ description: '是否成功' })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '选择时间' })
|
||||
selectedAt: Date;
|
||||
}
|
||||
|
||||
export class ConfirmProvinceCityResponse {
|
||||
@ApiProperty({ description: '是否成功' })
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class PayOrderResponse {
|
||||
@ApiProperty({ description: '订单号' })
|
||||
orderNo: string;
|
||||
|
||||
@ApiProperty({ description: '订单状态' })
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资金分配详情',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetType: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
targetAccountId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
allocations: Array<{
|
||||
targetType: string;
|
||||
amount: number;
|
||||
targetAccountId: string | null;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PositionDistributionResponse {
|
||||
@ApiProperty({ description: '省份代码', nullable: true })
|
||||
provinceCode: string | null;
|
||||
|
||||
@ApiProperty({ description: '城市代码', nullable: true })
|
||||
cityCode: string | null;
|
||||
|
||||
@ApiProperty({ description: '认种数量' })
|
||||
treeCount: number;
|
||||
}
|
||||
|
||||
export class PlantingPositionResponse {
|
||||
@ApiProperty({ description: '总认种数量' })
|
||||
totalTreeCount: number;
|
||||
|
||||
@ApiProperty({ description: '有效认种数量(已开启挖矿)' })
|
||||
effectiveTreeCount: number;
|
||||
|
||||
@ApiProperty({ description: '待生效认种数量' })
|
||||
pendingTreeCount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '省市分布',
|
||||
type: [PositionDistributionResponse],
|
||||
})
|
||||
distributions: PositionDistributionResponse[];
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './jwt-auth.guard';
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
userId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing authentication token');
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = this.configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET not configured');
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, secret) as JwtPayload;
|
||||
request.user = {
|
||||
id: payload.userId || payload.sub,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid authentication token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './controllers';
|
||||
export * from './dto';
|
||||
export * from './guards';
|
||||
export * from './api.module';
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { InfrastructureModule } from './infrastructure/infrastructure.module';
|
||||
import { DomainModule } from './domain/domain.module';
|
||||
import { ApplicationModule } from './application/application.module';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
||||
import configs from './config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.development', '.env'],
|
||||
load: configs,
|
||||
}),
|
||||
InfrastructureModule,
|
||||
DomainModule,
|
||||
ApplicationModule,
|
||||
ApiModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PlantingApplicationService } from './services/planting-application.service';
|
||||
import { PoolInjectionService } from './services/pool-injection.service';
|
||||
import { DomainModule } from '../domain/domain.module';
|
||||
|
||||
@Module({
|
||||
imports: [DomainModule],
|
||||
providers: [PlantingApplicationService, PoolInjectionService],
|
||||
exports: [PlantingApplicationService, PoolInjectionService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './services';
|
||||
export * from './application.module';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './planting-application.service';
|
||||
export * from './pool-injection.service';
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PlantingApplicationService } from './planting-application.service';
|
||||
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
|
||||
import {
|
||||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-order.repository.interface';
|
||||
import {
|
||||
IPlantingPositionRepository,
|
||||
PLANTING_POSITION_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-position.repository.interface';
|
||||
import {
|
||||
IPoolInjectionBatchRepository,
|
||||
POOL_INJECTION_BATCH_REPOSITORY,
|
||||
} from '../../domain/repositories/pool-injection-batch.repository.interface';
|
||||
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
|
||||
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
|
||||
import { PlantingOrder } from '../../domain/aggregates/planting-order.aggregate';
|
||||
import { PlantingPosition } from '../../domain/aggregates/planting-position.aggregate';
|
||||
import { PoolInjectionBatch } from '../../domain/aggregates/pool-injection-batch.aggregate';
|
||||
import { PlantingOrderStatus } from '../../domain/value-objects/planting-order-status.enum';
|
||||
|
||||
describe('PlantingApplicationService (Integration)', () => {
|
||||
let service: PlantingApplicationService;
|
||||
let orderRepository: jest.Mocked<IPlantingOrderRepository>;
|
||||
let positionRepository: jest.Mocked<IPlantingPositionRepository>;
|
||||
let batchRepository: jest.Mocked<IPoolInjectionBatchRepository>;
|
||||
let walletService: jest.Mocked<WalletServiceClient>;
|
||||
let referralService: jest.Mocked<ReferralServiceClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mocks
|
||||
orderRepository = {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByOrderNo: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
findByStatus: jest.fn(),
|
||||
findPendingPoolScheduling: jest.fn(),
|
||||
findByBatchId: jest.fn(),
|
||||
findReadyForMining: jest.fn(),
|
||||
countTreesByUserId: jest.fn(),
|
||||
countByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
positionRepository = {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
getOrCreate: jest.fn(),
|
||||
};
|
||||
|
||||
batchRepository = {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByBatchNo: jest.fn(),
|
||||
findByStatus: jest.fn(),
|
||||
findCurrentBatch: jest.fn(),
|
||||
findOrCreateCurrentBatch: jest.fn(),
|
||||
findScheduledBatchesReadyForInjection: jest.fn(),
|
||||
};
|
||||
|
||||
walletService = {
|
||||
getBalance: jest.fn(),
|
||||
deductForPlanting: jest.fn(),
|
||||
allocateFunds: jest.fn(),
|
||||
injectToPool: jest.fn(),
|
||||
} as any;
|
||||
|
||||
referralService = {
|
||||
getReferralContext: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PlantingApplicationService,
|
||||
FundAllocationDomainService,
|
||||
{ provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository },
|
||||
{ provide: PLANTING_POSITION_REPOSITORY, useValue: positionRepository },
|
||||
{ provide: POOL_INJECTION_BATCH_REPOSITORY, useValue: batchRepository },
|
||||
{ provide: WalletServiceClient, useValue: walletService },
|
||||
{ provide: ReferralServiceClient, useValue: referralService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PlantingApplicationService>(PlantingApplicationService);
|
||||
});
|
||||
|
||||
describe('createOrder', () => {
|
||||
it('应该成功创建订单', async () => {
|
||||
const userId = BigInt(1);
|
||||
const treeCount = 5;
|
||||
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||||
walletService.getBalance.mockResolvedValue({
|
||||
userId: '1',
|
||||
available: 100000,
|
||||
locked: 0,
|
||||
currency: 'USDT',
|
||||
});
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.createOrder(userId, treeCount);
|
||||
|
||||
expect(result.treeCount).toBe(5);
|
||||
expect(result.totalAmount).toBe(5 * 2199);
|
||||
expect(result.status).toBe(PlantingOrderStatus.CREATED);
|
||||
expect(orderRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝超过限购数量', async () => {
|
||||
const userId = BigInt(1);
|
||||
const treeCount = 100;
|
||||
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(950);
|
||||
|
||||
await expect(service.createOrder(userId, treeCount)).rejects.toThrow(
|
||||
'超过个人最大认种数量限制',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该拒绝余额不足', async () => {
|
||||
const userId = BigInt(1);
|
||||
const treeCount = 5;
|
||||
|
||||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||||
walletService.getBalance.mockResolvedValue({
|
||||
userId: '1',
|
||||
available: 100,
|
||||
locked: 0,
|
||||
currency: 'USDT',
|
||||
});
|
||||
|
||||
await expect(service.createOrder(userId, treeCount)).rejects.toThrow(
|
||||
'余额不足',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectProvinceCity', () => {
|
||||
it('应该成功选择省市', async () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.selectProvinceCity(
|
||||
order.orderNo,
|
||||
BigInt(1),
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(orderRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝不存在的订单', async () => {
|
||||
orderRepository.findByOrderNo.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.selectProvinceCity(
|
||||
'invalid',
|
||||
BigInt(1),
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
),
|
||||
).rejects.toThrow('订单不存在');
|
||||
});
|
||||
|
||||
it('应该拒绝无权操作的订单', async () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
|
||||
await expect(
|
||||
service.selectProvinceCity(
|
||||
order.orderNo,
|
||||
BigInt(999),
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
),
|
||||
).rejects.toThrow('无权操作此订单');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmProvinceCity', () => {
|
||||
it('应该在5秒后成功确认省市', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.confirmProvinceCity(order.orderNo, BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(orderRepository.save).toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('payOrder', () => {
|
||||
it('应该成功支付订单并完成资金分配', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
const position = PlantingPosition.create(BigInt(1));
|
||||
const batch = PoolInjectionBatch.create(new Date());
|
||||
(batch as any)._id = BigInt(1);
|
||||
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
walletService.deductForPlanting.mockResolvedValue(true);
|
||||
referralService.getReferralContext.mockResolvedValue({
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
});
|
||||
walletService.allocateFunds.mockResolvedValue(true);
|
||||
positionRepository.getOrCreate.mockResolvedValue(position);
|
||||
batchRepository.findOrCreateCurrentBatch.mockResolvedValue(batch);
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
positionRepository.save.mockResolvedValue(undefined);
|
||||
batchRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.payOrder(order.orderNo, BigInt(1));
|
||||
|
||||
expect(result.status).toBe(PlantingOrderStatus.POOL_SCHEDULED);
|
||||
expect(result.allocations.length).toBe(10);
|
||||
expect(walletService.deductForPlanting).toHaveBeenCalled();
|
||||
expect(walletService.allocateFunds).toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserOrders', () => {
|
||||
it('应该返回用户订单列表', async () => {
|
||||
const order1 = PlantingOrder.create(BigInt(1), 1);
|
||||
const order2 = PlantingOrder.create(BigInt(1), 2);
|
||||
|
||||
orderRepository.findByUserId.mockResolvedValue([order1, order2]);
|
||||
|
||||
const result = await service.getUserOrders(BigInt(1), 1, 10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].treeCount).toBe(1);
|
||||
expect(result[1].treeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserPosition', () => {
|
||||
it('应该返回用户持仓信息', async () => {
|
||||
const position = PlantingPosition.create(BigInt(1));
|
||||
position.addPlanting(5, '440000', '440100');
|
||||
|
||||
positionRepository.findByUserId.mockResolvedValue(position);
|
||||
|
||||
const result = await service.getUserPosition(BigInt(1));
|
||||
|
||||
expect(result.totalTreeCount).toBe(5);
|
||||
expect(result.pendingTreeCount).toBe(5);
|
||||
expect(result.distributions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('应该返回空持仓当用户无记录', async () => {
|
||||
positionRepository.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getUserPosition(BigInt(1));
|
||||
|
||||
expect(result.totalTreeCount).toBe(0);
|
||||
expect(result.effectiveTreeCount).toBe(0);
|
||||
expect(result.pendingTreeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelOrder', () => {
|
||||
it('应该成功取消未支付订单', async () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||||
orderRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.cancelOrder(order.orderNo, BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(orderRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { PlantingOrder } from '../../domain/aggregates/planting-order.aggregate';
|
||||
import {
|
||||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-order.repository.interface';
|
||||
import {
|
||||
IPlantingPositionRepository,
|
||||
PLANTING_POSITION_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-position.repository.interface';
|
||||
import {
|
||||
IPoolInjectionBatchRepository,
|
||||
POOL_INJECTION_BATCH_REPOSITORY,
|
||||
} from '../../domain/repositories/pool-injection-batch.repository.interface';
|
||||
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
|
||||
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
|
||||
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
|
||||
import { PRICE_PER_TREE } from '../../domain/value-objects/fund-allocation-target-type.enum';
|
||||
|
||||
// 个人最大认种数量限制
|
||||
const MAX_TREES_PER_USER = 1000;
|
||||
|
||||
export interface CreateOrderResult {
|
||||
orderNo: string;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OrderListItem {
|
||||
orderNo: string;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
provinceCode?: string | null;
|
||||
cityCode?: string | null;
|
||||
isMiningEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UserPositionResult {
|
||||
totalTreeCount: number;
|
||||
effectiveTreeCount: number;
|
||||
pendingTreeCount: number;
|
||||
distributions: Array<{
|
||||
provinceCode: string | null;
|
||||
cityCode: string | null;
|
||||
treeCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlantingApplicationService {
|
||||
private readonly logger = new Logger(PlantingApplicationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||
private readonly orderRepository: IPlantingOrderRepository,
|
||||
@Inject(PLANTING_POSITION_REPOSITORY)
|
||||
private readonly positionRepository: IPlantingPositionRepository,
|
||||
@Inject(POOL_INJECTION_BATCH_REPOSITORY)
|
||||
private readonly batchRepository: IPoolInjectionBatchRepository,
|
||||
private readonly fundAllocationService: FundAllocationDomainService,
|
||||
private readonly walletService: WalletServiceClient,
|
||||
private readonly referralService: ReferralServiceClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建认种订单
|
||||
*/
|
||||
async createOrder(
|
||||
userId: bigint,
|
||||
treeCount: number,
|
||||
): Promise<CreateOrderResult> {
|
||||
this.logger.log(`Creating order for user ${userId}, treeCount: ${treeCount}`);
|
||||
|
||||
// 风控检查
|
||||
await this.checkRiskControl(userId, treeCount);
|
||||
|
||||
// 检查余额
|
||||
const balance = await this.walletService.getBalance(userId.toString());
|
||||
const requiredAmount = treeCount * PRICE_PER_TREE;
|
||||
if (balance.available < requiredAmount) {
|
||||
throw new Error(
|
||||
`余额不足: 需要 ${requiredAmount} USDT, 当前可用 ${balance.available} USDT`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
const order = PlantingOrder.create(userId, treeCount);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
this.logger.log(`Order created: ${order.orderNo}`);
|
||||
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
treeCount: order.treeCount.value,
|
||||
totalAmount: order.totalAmount,
|
||||
status: order.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择省市
|
||||
*/
|
||||
async selectProvinceCity(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
provinceCode: string,
|
||||
provinceName: string,
|
||||
cityCode: string,
|
||||
cityName: string,
|
||||
): Promise<{ success: boolean; selectedAt: Date }> {
|
||||
const order = await this.orderRepository.findByOrderNo(orderNo);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
throw new Error('无权操作此订单');
|
||||
}
|
||||
|
||||
order.selectProvinceCity(provinceCode, provinceName, cityCode, cityName);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selectedAt: order.provinceCitySelection!.selectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认省市选择 (5秒后调用)
|
||||
*/
|
||||
async confirmProvinceCity(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
): Promise<{ success: boolean }> {
|
||||
const order = await this.orderRepository.findByOrderNo(orderNo);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
throw new Error('无权操作此订单');
|
||||
}
|
||||
|
||||
order.confirmProvinceCity();
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付认种订单
|
||||
*/
|
||||
async payOrder(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
): Promise<{
|
||||
orderNo: string;
|
||||
status: string;
|
||||
allocations: Array<{
|
||||
targetType: string;
|
||||
amount: number;
|
||||
targetAccountId: string | null;
|
||||
}>;
|
||||
}> {
|
||||
const order = await this.orderRepository.findByOrderNo(orderNo);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
throw new Error('无权操作此订单');
|
||||
}
|
||||
|
||||
const selection = order.provinceCitySelection;
|
||||
if (!selection) {
|
||||
throw new Error('请先选择并确认省市');
|
||||
}
|
||||
|
||||
// 调用钱包服务扣款
|
||||
await this.walletService.deductForPlanting({
|
||||
userId: userId.toString(),
|
||||
amount: order.totalAmount,
|
||||
orderId: order.orderNo,
|
||||
});
|
||||
|
||||
// 标记已支付
|
||||
order.markAsPaid();
|
||||
|
||||
// 获取推荐链上下文
|
||||
const referralContext = await this.referralService.getReferralContext(
|
||||
userId.toString(),
|
||||
selection.provinceCode,
|
||||
selection.cityCode,
|
||||
);
|
||||
|
||||
// 计算资金分配
|
||||
const allocations = this.fundAllocationService.calculateAllocations(
|
||||
order,
|
||||
referralContext,
|
||||
);
|
||||
|
||||
// 分配资金
|
||||
order.allocateFunds(allocations);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// 调用钱包服务执行资金分配
|
||||
await this.walletService.allocateFunds({
|
||||
orderId: order.orderNo,
|
||||
allocations: allocations.map((a) => a.toDTO()),
|
||||
});
|
||||
|
||||
// 更新用户持仓
|
||||
const position = await this.positionRepository.getOrCreate(userId);
|
||||
position.addPlanting(
|
||||
order.treeCount.value,
|
||||
selection.provinceCode,
|
||||
selection.cityCode,
|
||||
);
|
||||
await this.positionRepository.save(position);
|
||||
|
||||
// 安排底池注入批次
|
||||
await this.schedulePoolInjection(order);
|
||||
|
||||
this.logger.log(`Order paid: ${order.orderNo}`);
|
||||
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
status: order.status,
|
||||
allocations: allocations.map((a) => a.toDTO()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户订单列表
|
||||
*/
|
||||
async getUserOrders(
|
||||
userId: bigint,
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
): Promise<OrderListItem[]> {
|
||||
const orders = await this.orderRepository.findByUserId(
|
||||
userId,
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
return orders.map((o) => ({
|
||||
orderNo: o.orderNo,
|
||||
treeCount: o.treeCount.value,
|
||||
totalAmount: o.totalAmount,
|
||||
status: o.status,
|
||||
provinceCode: o.provinceCitySelection?.provinceCode,
|
||||
cityCode: o.provinceCitySelection?.cityCode,
|
||||
isMiningEnabled: o.isMiningEnabled,
|
||||
createdAt: o.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户持仓
|
||||
*/
|
||||
async getUserPosition(userId: bigint): Promise<UserPositionResult> {
|
||||
const position = await this.positionRepository.findByUserId(userId);
|
||||
if (!position) {
|
||||
return {
|
||||
totalTreeCount: 0,
|
||||
effectiveTreeCount: 0,
|
||||
pendingTreeCount: 0,
|
||||
distributions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalTreeCount: position.totalTreeCount,
|
||||
effectiveTreeCount: position.effectiveTreeCount,
|
||||
pendingTreeCount: position.pendingTreeCount,
|
||||
distributions: position.distributions.map((d) => ({
|
||||
provinceCode: d.provinceCode,
|
||||
cityCode: d.cityCode,
|
||||
treeCount: d.treeCount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单详情
|
||||
*/
|
||||
async getOrderDetail(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
): Promise<OrderListItem | null> {
|
||||
const order = await this.orderRepository.findByOrderNo(orderNo);
|
||||
if (!order || order.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
treeCount: order.treeCount.value,
|
||||
totalAmount: order.totalAmount,
|
||||
status: order.status,
|
||||
provinceCode: order.provinceCitySelection?.provinceCode,
|
||||
cityCode: order.provinceCitySelection?.cityCode,
|
||||
isMiningEnabled: order.isMiningEnabled,
|
||||
createdAt: order.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
async cancelOrder(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
): Promise<{ success: boolean }> {
|
||||
const order = await this.orderRepository.findByOrderNo(orderNo);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
if (order.userId !== userId) {
|
||||
throw new Error('无权操作此订单');
|
||||
}
|
||||
|
||||
order.cancel();
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 风控检查
|
||||
*/
|
||||
private async checkRiskControl(
|
||||
userId: bigint,
|
||||
treeCount: number,
|
||||
): Promise<void> {
|
||||
// 检查用户限购
|
||||
const existingCount = await this.orderRepository.countTreesByUserId(userId);
|
||||
if (existingCount + treeCount > MAX_TREES_PER_USER) {
|
||||
throw new Error(
|
||||
`超过个人最大认种数量限制: 已认种 ${existingCount} 棵, 本次 ${treeCount} 棵, 上限 ${MAX_TREES_PER_USER} 棵`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排底池注入批次
|
||||
*/
|
||||
private async schedulePoolInjection(order: PlantingOrder): Promise<void> {
|
||||
const batch = await this.batchRepository.findOrCreateCurrentBatch();
|
||||
const poolAmount = this.fundAllocationService.getPoolInjectionAmount(
|
||||
order.treeCount.value,
|
||||
);
|
||||
|
||||
batch.addOrder(poolAmount);
|
||||
await this.batchRepository.save(batch);
|
||||
|
||||
// 计算注入时间(批次结束后)
|
||||
const scheduledTime = new Date(batch.endDate);
|
||||
scheduledTime.setHours(scheduledTime.getHours() + 1);
|
||||
|
||||
order.schedulePoolInjection(batch.id!, scheduledTime);
|
||||
await this.orderRepository.save(order);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
IPlantingOrderRepository,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-order.repository.interface';
|
||||
import {
|
||||
IPlantingPositionRepository,
|
||||
PLANTING_POSITION_REPOSITORY,
|
||||
} from '../../domain/repositories/planting-position.repository.interface';
|
||||
import {
|
||||
IPoolInjectionBatchRepository,
|
||||
POOL_INJECTION_BATCH_REPOSITORY,
|
||||
} from '../../domain/repositories/pool-injection-batch.repository.interface';
|
||||
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
|
||||
import { BatchStatus } from '../../domain/value-objects/batch-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class PoolInjectionService {
|
||||
private readonly logger = new Logger(PoolInjectionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||
private readonly orderRepository: IPlantingOrderRepository,
|
||||
@Inject(PLANTING_POSITION_REPOSITORY)
|
||||
private readonly positionRepository: IPlantingPositionRepository,
|
||||
@Inject(POOL_INJECTION_BATCH_REPOSITORY)
|
||||
private readonly batchRepository: IPoolInjectionBatchRepository,
|
||||
private readonly walletService: WalletServiceClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 处理到期的底池注入批次
|
||||
* 此方法应由定时任务调用
|
||||
*/
|
||||
async processScheduledBatches(): Promise<void> {
|
||||
const batches =
|
||||
await this.batchRepository.findScheduledBatchesReadyForInjection();
|
||||
|
||||
for (const batch of batches) {
|
||||
try {
|
||||
await this.processBatch(batch.id!);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process batch ${batch.batchNo}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个批次的底池注入
|
||||
*/
|
||||
async processBatch(batchId: bigint): Promise<void> {
|
||||
const batch = await this.batchRepository.findById(batchId);
|
||||
if (!batch) {
|
||||
throw new Error('批次不存在');
|
||||
}
|
||||
|
||||
if (batch.status !== BatchStatus.SCHEDULED) {
|
||||
throw new Error('批次状态不正确');
|
||||
}
|
||||
|
||||
this.logger.log(`Processing batch ${batch.batchNo}`);
|
||||
|
||||
// 标记为注入中
|
||||
batch.startInjection();
|
||||
await this.batchRepository.save(batch);
|
||||
|
||||
try {
|
||||
// 调用钱包服务注入底池
|
||||
const result = await this.walletService.injectToPool(
|
||||
batch.batchNo,
|
||||
batch.totalAmount,
|
||||
);
|
||||
|
||||
// 完成注入
|
||||
batch.completeInjection(result.txHash);
|
||||
await this.batchRepository.save(batch);
|
||||
|
||||
// 更新批次内所有订单状态
|
||||
const orders = await this.orderRepository.findByBatchId(batchId);
|
||||
for (const order of orders) {
|
||||
order.confirmPoolInjection(result.txHash);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// 开启挖矿
|
||||
order.enableMining();
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// 更新用户持仓为有效
|
||||
const position = await this.positionRepository.findByUserId(
|
||||
order.userId,
|
||||
);
|
||||
if (position) {
|
||||
position.activateMining(order.treeCount.value);
|
||||
await this.positionRepository.save(position);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Batch ${batch.batchNo} processed successfully, txHash: ${result.txHash}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to inject batch ${batch.batchNo}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排待处理批次的注入时间
|
||||
* 批次结束后自动安排
|
||||
*/
|
||||
async schedulePendingBatches(): Promise<void> {
|
||||
const batches = await this.batchRepository.findByStatus(BatchStatus.PENDING);
|
||||
|
||||
const now = new Date();
|
||||
for (const batch of batches) {
|
||||
// 如果批次已过期,安排注入
|
||||
if (batch.endDate < now) {
|
||||
const scheduledTime = new Date();
|
||||
scheduledTime.setMinutes(scheduledTime.getMinutes() + 5);
|
||||
|
||||
batch.schedule(scheduledTime);
|
||||
await this.batchRepository.save(batch);
|
||||
|
||||
this.logger.log(
|
||||
`Batch ${batch.batchNo} scheduled for injection at ${scheduledTime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取批次状态
|
||||
*/
|
||||
async getBatchStatus(
|
||||
batchId: bigint,
|
||||
): Promise<{
|
||||
batchNo: string;
|
||||
status: string;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
scheduledInjectionTime: Date | null;
|
||||
actualInjectionTime: Date | null;
|
||||
injectionTxHash: string | null;
|
||||
} | null> {
|
||||
const batch = await this.batchRepository.findById(batchId);
|
||||
if (!batch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
batchNo: batch.batchNo,
|
||||
status: batch.status,
|
||||
orderCount: batch.orderCount,
|
||||
totalAmount: batch.totalAmount,
|
||||
scheduledInjectionTime: batch.scheduledInjectionTime,
|
||||
actualInjectionTime: batch.actualInjectionTime,
|
||||
injectionTxHash: batch.injectionTxHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.APP_PORT || '3003', 10),
|
||||
serviceName: 'planting-service',
|
||||
}));
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('external', () => ({
|
||||
walletServiceUrl:
|
||||
process.env.WALLET_SERVICE_URL || 'http://localhost:3002',
|
||||
identityServiceUrl:
|
||||
process.env.IDENTITY_SERVICE_URL || 'http://localhost:3001',
|
||||
referralServiceUrl:
|
||||
process.env.REFERRAL_SERVICE_URL || 'http://localhost:3004',
|
||||
}));
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import appConfig from './app.config';
|
||||
import jwtConfig from './jwt.config';
|
||||
import externalConfig from './external.config';
|
||||
|
||||
export default [appConfig, jwtConfig, externalConfig];
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret-change-me',
|
||||
}));
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './planting-order.aggregate';
|
||||
export * from './planting-position.aggregate';
|
||||
export * from './pool-injection-batch.aggregate';
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { PlantingOrder } from './planting-order.aggregate';
|
||||
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
|
||||
import { FundAllocation } from '../value-objects/fund-allocation.vo';
|
||||
import { FundAllocationTargetType } from '../value-objects/fund-allocation-target-type.enum';
|
||||
|
||||
describe('PlantingOrder', () => {
|
||||
describe('create', () => {
|
||||
it('应该成功创建认种订单', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 5);
|
||||
|
||||
expect(order.orderNo).toMatch(/^PLT/);
|
||||
expect(order.userId).toBe(BigInt(1));
|
||||
expect(order.treeCount.value).toBe(5);
|
||||
expect(order.totalAmount).toBe(5 * 2199);
|
||||
expect(order.status).toBe(PlantingOrderStatus.CREATED);
|
||||
expect(order.domainEvents.length).toBe(1);
|
||||
expect(order.domainEvents[0].type).toBe('PlantingOrderCreated');
|
||||
});
|
||||
|
||||
it('应该拒绝0棵树', () => {
|
||||
expect(() => PlantingOrder.create(BigInt(1), 0)).toThrow(
|
||||
'认种数量必须大于0',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该拒绝负数', () => {
|
||||
expect(() => PlantingOrder.create(BigInt(1), -1)).toThrow(
|
||||
'认种数量必须大于0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectProvinceCity', () => {
|
||||
it('应该成功选择省市', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
expect(order.provinceCitySelection).not.toBeNull();
|
||||
expect(order.provinceCitySelection?.provinceCode).toBe('440000');
|
||||
expect(order.provinceCitySelection?.cityCode).toBe('440100');
|
||||
expect(order.provinceCitySelection?.isConfirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该允许在确认前修改省市', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
order.selectProvinceCity('110000', '北京市', '110100', '北京市');
|
||||
|
||||
expect(order.provinceCitySelection?.provinceCode).toBe('110000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmProvinceCity', () => {
|
||||
it('应该在5秒后成功确认省市', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
order.confirmProvinceCity();
|
||||
|
||||
expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
|
||||
expect(order.provinceCitySelection?.isConfirmed).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在5秒前拒绝确认', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
|
||||
expect(() => order.confirmProvinceCity()).toThrow('请等待5秒确认时间');
|
||||
});
|
||||
|
||||
it('应该拒绝未选择省市的确认', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
|
||||
expect(() => order.confirmProvinceCity()).toThrow('请先选择省市');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPaid', () => {
|
||||
it('应该在省市确认后成功标记为已支付', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
order.markAsPaid();
|
||||
|
||||
expect(order.status).toBe(PlantingOrderStatus.PAID);
|
||||
expect(order.paidAt).not.toBeNull();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该拒绝未确认省市的支付', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
|
||||
expect(() => order.markAsPaid()).toThrow('订单状态错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allocateFunds', () => {
|
||||
it('应该成功分配资金', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
order.markAsPaid();
|
||||
|
||||
const allocations = [
|
||||
new FundAllocation(FundAllocationTargetType.COST_ACCOUNT, 400, 'ACC1'),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.OPERATION_ACCOUNT,
|
||||
300,
|
||||
'ACC2',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
|
||||
9,
|
||||
'ACC3',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.REFERRAL_RIGHTS,
|
||||
500,
|
||||
'ACC4',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_AREA_RIGHTS,
|
||||
15,
|
||||
'ACC5',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_TEAM_RIGHTS,
|
||||
20,
|
||||
'ACC6',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_AREA_RIGHTS,
|
||||
35,
|
||||
'ACC7',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_TEAM_RIGHTS,
|
||||
40,
|
||||
'ACC8',
|
||||
),
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COMMUNITY_RIGHTS,
|
||||
80,
|
||||
'ACC9',
|
||||
),
|
||||
new FundAllocation(FundAllocationTargetType.RWAD_POOL, 800, 'ACC10'),
|
||||
];
|
||||
|
||||
order.allocateFunds(allocations);
|
||||
|
||||
expect(order.status).toBe(PlantingOrderStatus.FUND_ALLOCATED);
|
||||
expect(order.fundAllocations.length).toBe(10);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该拒绝金额不匹配的分配', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
order.markAsPaid();
|
||||
|
||||
const allocations = [
|
||||
new FundAllocation(FundAllocationTargetType.COST_ACCOUNT, 100, 'ACC1'),
|
||||
];
|
||||
|
||||
expect(() => order.allocateFunds(allocations)).toThrow(
|
||||
'资金分配总额不匹配',
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('应该成功取消未支付的订单', () => {
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.cancel();
|
||||
|
||||
expect(order.status).toBe(PlantingOrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('应该拒绝取消已支付的订单', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
order.markAsPaid();
|
||||
|
||||
expect(() => order.cancel()).toThrow('只有未支付的订单才能取消');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
|
||||
import { ProvinceCitySelection } from '../value-objects/province-city-selection.vo';
|
||||
import { FundAllocation } from '../value-objects/fund-allocation.vo';
|
||||
import { TreeCount } from '../value-objects/tree-count.vo';
|
||||
import { PRICE_PER_TREE } from '../value-objects/fund-allocation-target-type.enum';
|
||||
import { DomainEvent } from '../events/domain-event.interface';
|
||||
import { PlantingOrderCreatedEvent } from '../events/planting-order-created.event';
|
||||
import { ProvinceCityConfirmedEvent } from '../events/province-city-confirmed.event';
|
||||
import { PlantingOrderPaidEvent } from '../events/planting-order-paid.event';
|
||||
import { FundsAllocatedEvent } from '../events/funds-allocated.event';
|
||||
import { PoolInjectedEvent } from '../events/pool-injected.event';
|
||||
import { MiningEnabledEvent } from '../events/mining-enabled.event';
|
||||
|
||||
export interface PlantingOrderData {
|
||||
id?: bigint;
|
||||
orderNo: string;
|
||||
userId: bigint;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
status: PlantingOrderStatus;
|
||||
selectedProvince?: string | null;
|
||||
selectedCity?: string | null;
|
||||
provinceCitySelectedAt?: Date | null;
|
||||
provinceCityConfirmedAt?: Date | null;
|
||||
poolInjectionBatchId?: bigint | null;
|
||||
poolInjectionScheduledTime?: Date | null;
|
||||
poolInjectionActualTime?: Date | null;
|
||||
poolInjectionTxHash?: string | null;
|
||||
miningEnabledAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
paidAt?: Date | null;
|
||||
fundAllocatedAt?: Date | null;
|
||||
}
|
||||
|
||||
export class PlantingOrder {
|
||||
private _id: bigint | null;
|
||||
private readonly _orderNo: string;
|
||||
private readonly _userId: bigint;
|
||||
private readonly _treeCount: TreeCount;
|
||||
private readonly _totalAmount: number;
|
||||
private _provinceCitySelection: ProvinceCitySelection | null;
|
||||
private _status: PlantingOrderStatus;
|
||||
private _fundAllocations: FundAllocation[];
|
||||
private _poolInjectionBatchId: bigint | null;
|
||||
private _poolInjectionScheduledTime: Date | null;
|
||||
private _poolInjectionActualTime: Date | null;
|
||||
private _poolInjectionTxHash: string | null;
|
||||
private _miningEnabledAt: Date | null;
|
||||
private readonly _createdAt: Date;
|
||||
private _paidAt: Date | null;
|
||||
private _fundAllocatedAt: Date | null;
|
||||
|
||||
// 领域事件
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(
|
||||
orderNo: string,
|
||||
userId: bigint,
|
||||
treeCount: TreeCount,
|
||||
totalAmount: number,
|
||||
createdAt?: Date,
|
||||
) {
|
||||
this._id = null;
|
||||
this._orderNo = orderNo;
|
||||
this._userId = userId;
|
||||
this._treeCount = treeCount;
|
||||
this._totalAmount = totalAmount;
|
||||
this._status = PlantingOrderStatus.CREATED;
|
||||
this._provinceCitySelection = null;
|
||||
this._fundAllocations = [];
|
||||
this._poolInjectionBatchId = null;
|
||||
this._poolInjectionScheduledTime = null;
|
||||
this._poolInjectionActualTime = null;
|
||||
this._poolInjectionTxHash = null;
|
||||
this._miningEnabledAt = null;
|
||||
this._createdAt = createdAt || new Date();
|
||||
this._paidAt = null;
|
||||
this._fundAllocatedAt = null;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
get orderNo(): string {
|
||||
return this._orderNo;
|
||||
}
|
||||
get userId(): bigint {
|
||||
return this._userId;
|
||||
}
|
||||
get treeCount(): TreeCount {
|
||||
return this._treeCount;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this._totalAmount;
|
||||
}
|
||||
get status(): PlantingOrderStatus {
|
||||
return this._status;
|
||||
}
|
||||
get provinceCitySelection(): ProvinceCitySelection | null {
|
||||
return this._provinceCitySelection;
|
||||
}
|
||||
get fundAllocations(): ReadonlyArray<FundAllocation> {
|
||||
return this._fundAllocations;
|
||||
}
|
||||
get poolInjectionBatchId(): bigint | null {
|
||||
return this._poolInjectionBatchId;
|
||||
}
|
||||
get poolInjectionScheduledTime(): Date | null {
|
||||
return this._poolInjectionScheduledTime;
|
||||
}
|
||||
get poolInjectionActualTime(): Date | null {
|
||||
return this._poolInjectionActualTime;
|
||||
}
|
||||
get poolInjectionTxHash(): string | null {
|
||||
return this._poolInjectionTxHash;
|
||||
}
|
||||
get miningEnabledAt(): Date | null {
|
||||
return this._miningEnabledAt;
|
||||
}
|
||||
get isMiningEnabled(): boolean {
|
||||
return this._miningEnabledAt !== null;
|
||||
}
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
get paidAt(): Date | null {
|
||||
return this._paidAt;
|
||||
}
|
||||
get fundAllocatedAt(): Date | null {
|
||||
return this._fundAllocatedAt;
|
||||
}
|
||||
get domainEvents(): ReadonlyArray<DomainEvent> {
|
||||
return this._domainEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工厂方法:创建认种订单
|
||||
*/
|
||||
static create(userId: bigint, treeCount: number): PlantingOrder {
|
||||
if (treeCount <= 0) {
|
||||
throw new Error('认种数量必须大于0');
|
||||
}
|
||||
|
||||
const orderNo = `PLT${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
const tree = TreeCount.create(treeCount);
|
||||
const totalAmount = treeCount * PRICE_PER_TREE;
|
||||
|
||||
const order = new PlantingOrder(orderNo, userId, tree, totalAmount);
|
||||
|
||||
// 发布领域事件
|
||||
order._domainEvents.push(
|
||||
new PlantingOrderCreatedEvent(orderNo, {
|
||||
orderNo: order.orderNo,
|
||||
userId: order.userId.toString(),
|
||||
treeCount: order.treeCount.value,
|
||||
totalAmount: order.totalAmount,
|
||||
}),
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择省市 (5秒倒计时前)
|
||||
*/
|
||||
selectProvinceCity(
|
||||
provinceCode: string,
|
||||
provinceName: string,
|
||||
cityCode: string,
|
||||
cityName: string,
|
||||
): void {
|
||||
this.ensureStatus(PlantingOrderStatus.CREATED);
|
||||
|
||||
if (this._provinceCitySelection?.isConfirmed) {
|
||||
throw new Error('省市已确认,不可修改');
|
||||
}
|
||||
|
||||
this._provinceCitySelection = ProvinceCitySelection.create(
|
||||
provinceCode,
|
||||
provinceName,
|
||||
cityCode,
|
||||
cityName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认省市选择 (5秒后)
|
||||
*/
|
||||
confirmProvinceCity(): void {
|
||||
this.ensureStatus(PlantingOrderStatus.CREATED);
|
||||
|
||||
if (!this._provinceCitySelection) {
|
||||
throw new Error('请先选择省市');
|
||||
}
|
||||
|
||||
if (!this._provinceCitySelection.canConfirm()) {
|
||||
throw new Error('请等待5秒确认时间');
|
||||
}
|
||||
|
||||
this._provinceCitySelection = this._provinceCitySelection.confirm();
|
||||
this._status = PlantingOrderStatus.PROVINCE_CITY_CONFIRMED;
|
||||
|
||||
this._domainEvents.push(
|
||||
new ProvinceCityConfirmedEvent(this.orderNo, {
|
||||
orderNo: this.orderNo,
|
||||
userId: this.userId.toString(),
|
||||
provinceCode: this._provinceCitySelection.provinceCode,
|
||||
provinceName: this._provinceCitySelection.provinceName,
|
||||
cityCode: this._provinceCitySelection.cityCode,
|
||||
cityName: this._provinceCitySelection.cityName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已支付
|
||||
*/
|
||||
markAsPaid(): void {
|
||||
this.ensureStatus(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
|
||||
|
||||
this._status = PlantingOrderStatus.PAID;
|
||||
this._paidAt = new Date();
|
||||
|
||||
this._domainEvents.push(
|
||||
new PlantingOrderPaidEvent(this.orderNo, {
|
||||
orderNo: this.orderNo,
|
||||
userId: this.userId.toString(),
|
||||
treeCount: this.treeCount.value,
|
||||
totalAmount: this.totalAmount,
|
||||
provinceCode: this._provinceCitySelection!.provinceCode,
|
||||
cityCode: this._provinceCitySelection!.cityCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配资金
|
||||
*/
|
||||
allocateFunds(allocations: FundAllocation[]): void {
|
||||
this.ensureStatus(PlantingOrderStatus.PAID);
|
||||
|
||||
// 验证分配总额
|
||||
const totalAllocated = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
if (Math.abs(totalAllocated - this.totalAmount) > 0.01) {
|
||||
throw new Error(
|
||||
`资金分配总额不匹配: 期望 ${this.totalAmount}, 实际 ${totalAllocated}`,
|
||||
);
|
||||
}
|
||||
|
||||
this._fundAllocations = allocations;
|
||||
this._status = PlantingOrderStatus.FUND_ALLOCATED;
|
||||
this._fundAllocatedAt = new Date();
|
||||
|
||||
this._domainEvents.push(
|
||||
new FundsAllocatedEvent(this.orderNo, {
|
||||
orderNo: this.orderNo,
|
||||
allocations: allocations.map((a) => a.toDTO()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排底池注入
|
||||
*/
|
||||
schedulePoolInjection(batchId: bigint, scheduledTime: Date): void {
|
||||
this.ensureStatus(PlantingOrderStatus.FUND_ALLOCATED);
|
||||
|
||||
this._poolInjectionBatchId = batchId;
|
||||
this._poolInjectionScheduledTime = scheduledTime;
|
||||
this._status = PlantingOrderStatus.POOL_SCHEDULED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认底池注入完成
|
||||
*/
|
||||
confirmPoolInjection(txHash: string): void {
|
||||
this.ensureStatus(PlantingOrderStatus.POOL_SCHEDULED);
|
||||
|
||||
this._poolInjectionActualTime = new Date();
|
||||
this._poolInjectionTxHash = txHash;
|
||||
this._status = PlantingOrderStatus.POOL_INJECTED;
|
||||
|
||||
this._domainEvents.push(
|
||||
new PoolInjectedEvent(this.orderNo, {
|
||||
orderNo: this.orderNo,
|
||||
userId: this.userId.toString(),
|
||||
amount: this.treeCount.value * 800, // 800 USDT/棵
|
||||
txHash,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启挖矿
|
||||
*/
|
||||
enableMining(): void {
|
||||
this.ensureStatus(PlantingOrderStatus.POOL_INJECTED);
|
||||
|
||||
this._miningEnabledAt = new Date();
|
||||
this._status = PlantingOrderStatus.MINING_ENABLED;
|
||||
|
||||
this._domainEvents.push(
|
||||
new MiningEnabledEvent(this.orderNo, {
|
||||
orderNo: this.orderNo,
|
||||
userId: this.userId.toString(),
|
||||
treeCount: this.treeCount.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
cancel(): void {
|
||||
if (
|
||||
this._status !== PlantingOrderStatus.CREATED &&
|
||||
this._status !== PlantingOrderStatus.PROVINCE_CITY_CONFIRMED
|
||||
) {
|
||||
throw new Error('只有未支付的订单才能取消');
|
||||
}
|
||||
this._status = PlantingOrderStatus.CANCELLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除领域事件
|
||||
*/
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
private ensureStatus(...allowedStatuses: PlantingOrderStatus[]): void {
|
||||
if (!allowedStatuses.includes(this._status)) {
|
||||
throw new Error(
|
||||
`订单状态错误: 当前 ${this._status}, 期望 ${allowedStatuses.join(' 或 ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置ID(用于持久化后回填)
|
||||
*/
|
||||
setId(id: bigint): void {
|
||||
if (this._id !== null) {
|
||||
throw new Error('ID已设置,不可修改');
|
||||
}
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于从数据库重建
|
||||
*/
|
||||
static reconstitute(data: PlantingOrderData): PlantingOrder {
|
||||
const order = new PlantingOrder(
|
||||
data.orderNo,
|
||||
data.userId,
|
||||
TreeCount.create(data.treeCount),
|
||||
data.totalAmount,
|
||||
data.createdAt,
|
||||
);
|
||||
|
||||
if (data.id) {
|
||||
order._id = data.id;
|
||||
}
|
||||
order._status = data.status;
|
||||
order._paidAt = data.paidAt || null;
|
||||
order._fundAllocatedAt = data.fundAllocatedAt || null;
|
||||
order._poolInjectionBatchId = data.poolInjectionBatchId || null;
|
||||
order._poolInjectionScheduledTime = data.poolInjectionScheduledTime || null;
|
||||
order._poolInjectionActualTime = data.poolInjectionActualTime || null;
|
||||
order._poolInjectionTxHash = data.poolInjectionTxHash || null;
|
||||
order._miningEnabledAt = data.miningEnabledAt || null;
|
||||
|
||||
if (data.selectedProvince && data.selectedCity) {
|
||||
order._provinceCitySelection = ProvinceCitySelection.reconstitute(
|
||||
data.selectedProvince,
|
||||
'',
|
||||
data.selectedCity,
|
||||
'',
|
||||
data.provinceCitySelectedAt || new Date(),
|
||||
data.provinceCityConfirmedAt || null,
|
||||
);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为可持久化的数据对象
|
||||
*/
|
||||
toPersistence(): PlantingOrderData {
|
||||
return {
|
||||
id: this._id || undefined,
|
||||
orderNo: this._orderNo,
|
||||
userId: this._userId,
|
||||
treeCount: this._treeCount.value,
|
||||
totalAmount: this._totalAmount,
|
||||
status: this._status,
|
||||
selectedProvince: this._provinceCitySelection?.provinceCode || null,
|
||||
selectedCity: this._provinceCitySelection?.cityCode || null,
|
||||
provinceCitySelectedAt: this._provinceCitySelection?.selectedAt || null,
|
||||
provinceCityConfirmedAt: this._provinceCitySelection?.confirmedAt || null,
|
||||
poolInjectionBatchId: this._poolInjectionBatchId,
|
||||
poolInjectionScheduledTime: this._poolInjectionScheduledTime,
|
||||
poolInjectionActualTime: this._poolInjectionActualTime,
|
||||
poolInjectionTxHash: this._poolInjectionTxHash,
|
||||
miningEnabledAt: this._miningEnabledAt,
|
||||
createdAt: this._createdAt,
|
||||
paidAt: this._paidAt,
|
||||
fundAllocatedAt: this._fundAllocatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
export interface PositionDistributionData {
|
||||
id?: bigint;
|
||||
userId: bigint;
|
||||
provinceCode: string | null;
|
||||
cityCode: string | null;
|
||||
treeCount: number;
|
||||
}
|
||||
|
||||
export interface PlantingPositionData {
|
||||
id?: bigint;
|
||||
userId: bigint;
|
||||
totalTreeCount: number;
|
||||
effectiveTreeCount: number;
|
||||
pendingTreeCount: number;
|
||||
firstMiningStartAt?: Date | null;
|
||||
distributions?: PositionDistributionData[];
|
||||
}
|
||||
|
||||
export class PositionDistribution {
|
||||
private _id: bigint | null;
|
||||
private readonly _userId: bigint;
|
||||
private readonly _provinceCode: string | null;
|
||||
private readonly _cityCode: string | null;
|
||||
private _treeCount: number;
|
||||
|
||||
constructor(
|
||||
userId: bigint,
|
||||
provinceCode: string | null,
|
||||
cityCode: string | null,
|
||||
treeCount: number = 0,
|
||||
id?: bigint,
|
||||
) {
|
||||
this._id = id || null;
|
||||
this._userId = userId;
|
||||
this._provinceCode = provinceCode;
|
||||
this._cityCode = cityCode;
|
||||
this._treeCount = treeCount;
|
||||
}
|
||||
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
get userId(): bigint {
|
||||
return this._userId;
|
||||
}
|
||||
get provinceCode(): string | null {
|
||||
return this._provinceCode;
|
||||
}
|
||||
get cityCode(): string | null {
|
||||
return this._cityCode;
|
||||
}
|
||||
get treeCount(): number {
|
||||
return this._treeCount;
|
||||
}
|
||||
|
||||
addTrees(count: number): void {
|
||||
if (count <= 0) {
|
||||
throw new Error('添加数量必须大于0');
|
||||
}
|
||||
this._treeCount += count;
|
||||
}
|
||||
|
||||
setId(id: bigint): void {
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
matchesLocation(provinceCode: string | null, cityCode: string | null): boolean {
|
||||
return this._provinceCode === provinceCode && this._cityCode === cityCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlantingPosition {
|
||||
private _id: bigint | null;
|
||||
private readonly _userId: bigint;
|
||||
private _totalTreeCount: number;
|
||||
private _effectiveTreeCount: number;
|
||||
private _pendingTreeCount: number;
|
||||
private _firstMiningStartAt: Date | null;
|
||||
private _distributions: PositionDistribution[];
|
||||
|
||||
private constructor(
|
||||
userId: bigint,
|
||||
totalTreeCount: number = 0,
|
||||
effectiveTreeCount: number = 0,
|
||||
pendingTreeCount: number = 0,
|
||||
) {
|
||||
this._id = null;
|
||||
this._userId = userId;
|
||||
this._totalTreeCount = totalTreeCount;
|
||||
this._effectiveTreeCount = effectiveTreeCount;
|
||||
this._pendingTreeCount = pendingTreeCount;
|
||||
this._firstMiningStartAt = null;
|
||||
this._distributions = [];
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
get userId(): bigint {
|
||||
return this._userId;
|
||||
}
|
||||
get totalTreeCount(): number {
|
||||
return this._totalTreeCount;
|
||||
}
|
||||
get effectiveTreeCount(): number {
|
||||
return this._effectiveTreeCount;
|
||||
}
|
||||
get pendingTreeCount(): number {
|
||||
return this._pendingTreeCount;
|
||||
}
|
||||
get firstMiningStartAt(): Date | null {
|
||||
return this._firstMiningStartAt;
|
||||
}
|
||||
get distributions(): ReadonlyArray<PositionDistribution> {
|
||||
return this._distributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工厂方法:创建新持仓
|
||||
*/
|
||||
static create(userId: bigint): PlantingPosition {
|
||||
return new PlantingPosition(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加认种
|
||||
*/
|
||||
addPlanting(
|
||||
treeCount: number,
|
||||
provinceCode: string | null,
|
||||
cityCode: string | null,
|
||||
): void {
|
||||
if (treeCount <= 0) {
|
||||
throw new Error('认种数量必须大于0');
|
||||
}
|
||||
|
||||
// 更新总数和待生效数
|
||||
this._totalTreeCount += treeCount;
|
||||
this._pendingTreeCount += treeCount;
|
||||
|
||||
// 更新省市分布
|
||||
let distribution = this._distributions.find((d) =>
|
||||
d.matchesLocation(provinceCode, cityCode),
|
||||
);
|
||||
|
||||
if (distribution) {
|
||||
distribution.addTrees(treeCount);
|
||||
} else {
|
||||
distribution = new PositionDistribution(
|
||||
this._userId,
|
||||
provinceCode,
|
||||
cityCode,
|
||||
treeCount,
|
||||
);
|
||||
this._distributions.push(distribution);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活挖矿(底池注入后)
|
||||
*/
|
||||
activateMining(treeCount: number): void {
|
||||
if (treeCount > this._pendingTreeCount) {
|
||||
throw new Error('激活数量不能超过待生效数量');
|
||||
}
|
||||
|
||||
this._pendingTreeCount -= treeCount;
|
||||
this._effectiveTreeCount += treeCount;
|
||||
|
||||
if (!this._firstMiningStartAt) {
|
||||
this._firstMiningStartAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置ID
|
||||
*/
|
||||
setId(id: bigint): void {
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库重建
|
||||
*/
|
||||
static reconstitute(data: PlantingPositionData): PlantingPosition {
|
||||
const position = new PlantingPosition(
|
||||
data.userId,
|
||||
data.totalTreeCount,
|
||||
data.effectiveTreeCount,
|
||||
data.pendingTreeCount,
|
||||
);
|
||||
|
||||
if (data.id) {
|
||||
position._id = data.id;
|
||||
}
|
||||
position._firstMiningStartAt = data.firstMiningStartAt || null;
|
||||
|
||||
if (data.distributions) {
|
||||
position._distributions = data.distributions.map(
|
||||
(d) =>
|
||||
new PositionDistribution(
|
||||
d.userId,
|
||||
d.provinceCode,
|
||||
d.cityCode,
|
||||
d.treeCount,
|
||||
d.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为可持久化的数据对象
|
||||
*/
|
||||
toPersistence(): PlantingPositionData {
|
||||
return {
|
||||
id: this._id || undefined,
|
||||
userId: this._userId,
|
||||
totalTreeCount: this._totalTreeCount,
|
||||
effectiveTreeCount: this._effectiveTreeCount,
|
||||
pendingTreeCount: this._pendingTreeCount,
|
||||
firstMiningStartAt: this._firstMiningStartAt,
|
||||
distributions: this._distributions.map((d) => ({
|
||||
id: d.id || undefined,
|
||||
userId: d.userId,
|
||||
provinceCode: d.provinceCode,
|
||||
cityCode: d.cityCode,
|
||||
treeCount: d.treeCount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { BatchStatus } from '../value-objects/batch-status.enum';
|
||||
|
||||
export interface PoolInjectionBatchData {
|
||||
id?: bigint;
|
||||
batchNo: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
status: BatchStatus;
|
||||
scheduledInjectionTime?: Date | null;
|
||||
actualInjectionTime?: Date | null;
|
||||
injectionTxHash?: string | null;
|
||||
}
|
||||
|
||||
export class PoolInjectionBatch {
|
||||
private _id: bigint | null;
|
||||
private readonly _batchNo: string;
|
||||
private readonly _startDate: Date;
|
||||
private readonly _endDate: Date;
|
||||
private _orderCount: number;
|
||||
private _totalAmount: number;
|
||||
private _status: BatchStatus;
|
||||
private _scheduledInjectionTime: Date | null;
|
||||
private _actualInjectionTime: Date | null;
|
||||
private _injectionTxHash: string | null;
|
||||
|
||||
private constructor(
|
||||
batchNo: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
orderCount: number = 0,
|
||||
totalAmount: number = 0,
|
||||
) {
|
||||
this._id = null;
|
||||
this._batchNo = batchNo;
|
||||
this._startDate = startDate;
|
||||
this._endDate = endDate;
|
||||
this._orderCount = orderCount;
|
||||
this._totalAmount = totalAmount;
|
||||
this._status = BatchStatus.PENDING;
|
||||
this._scheduledInjectionTime = null;
|
||||
this._actualInjectionTime = null;
|
||||
this._injectionTxHash = null;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
get batchNo(): string {
|
||||
return this._batchNo;
|
||||
}
|
||||
get startDate(): Date {
|
||||
return this._startDate;
|
||||
}
|
||||
get endDate(): Date {
|
||||
return this._endDate;
|
||||
}
|
||||
get orderCount(): number {
|
||||
return this._orderCount;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this._totalAmount;
|
||||
}
|
||||
get status(): BatchStatus {
|
||||
return this._status;
|
||||
}
|
||||
get scheduledInjectionTime(): Date | null {
|
||||
return this._scheduledInjectionTime;
|
||||
}
|
||||
get actualInjectionTime(): Date | null {
|
||||
return this._actualInjectionTime;
|
||||
}
|
||||
get injectionTxHash(): string | null {
|
||||
return this._injectionTxHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工厂方法:创建新批次
|
||||
* 批次时间窗口为5天
|
||||
*/
|
||||
static create(startDate: Date): PoolInjectionBatch {
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + 5);
|
||||
|
||||
const batchNo = `BATCH${startDate.getFullYear()}${String(startDate.getMonth() + 1).padStart(2, '0')}${String(startDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
return new PoolInjectionBatch(batchNo, startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加订单到批次
|
||||
*/
|
||||
addOrder(poolAmount: number): void {
|
||||
if (this._status !== BatchStatus.PENDING) {
|
||||
throw new Error('只有待处理状态的批次才能添加订单');
|
||||
}
|
||||
|
||||
this._orderCount += 1;
|
||||
this._totalAmount += poolAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排注入时间
|
||||
*/
|
||||
schedule(injectionTime: Date): void {
|
||||
if (this._status !== BatchStatus.PENDING) {
|
||||
throw new Error('只有待处理状态的批次才能安排注入');
|
||||
}
|
||||
|
||||
this._scheduledInjectionTime = injectionTime;
|
||||
this._status = BatchStatus.SCHEDULED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始注入
|
||||
*/
|
||||
startInjection(): void {
|
||||
if (this._status !== BatchStatus.SCHEDULED) {
|
||||
throw new Error('只有已排期状态的批次才能开始注入');
|
||||
}
|
||||
|
||||
this._status = BatchStatus.INJECTING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成注入
|
||||
*/
|
||||
completeInjection(txHash: string): void {
|
||||
if (this._status !== BatchStatus.INJECTING) {
|
||||
throw new Error('只有注入中状态的批次才能完成注入');
|
||||
}
|
||||
|
||||
this._actualInjectionTime = new Date();
|
||||
this._injectionTxHash = txHash;
|
||||
this._status = BatchStatus.INJECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查日期是否在批次时间窗口内
|
||||
*/
|
||||
isDateInWindow(date: Date): boolean {
|
||||
return date >= this._startDate && date <= this._endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置ID
|
||||
*/
|
||||
setId(id: bigint): void {
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库重建
|
||||
*/
|
||||
static reconstitute(data: PoolInjectionBatchData): PoolInjectionBatch {
|
||||
const batch = new PoolInjectionBatch(
|
||||
data.batchNo,
|
||||
data.startDate,
|
||||
data.endDate,
|
||||
data.orderCount,
|
||||
data.totalAmount,
|
||||
);
|
||||
|
||||
if (data.id) {
|
||||
batch._id = data.id;
|
||||
}
|
||||
batch._status = data.status;
|
||||
batch._scheduledInjectionTime = data.scheduledInjectionTime || null;
|
||||
batch._actualInjectionTime = data.actualInjectionTime || null;
|
||||
batch._injectionTxHash = data.injectionTxHash || null;
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为可持久化的数据对象
|
||||
*/
|
||||
toPersistence(): PoolInjectionBatchData {
|
||||
return {
|
||||
id: this._id || undefined,
|
||||
batchNo: this._batchNo,
|
||||
startDate: this._startDate,
|
||||
endDate: this._endDate,
|
||||
orderCount: this._orderCount,
|
||||
totalAmount: this._totalAmount,
|
||||
status: this._status,
|
||||
scheduledInjectionTime: this._scheduledInjectionTime,
|
||||
actualInjectionTime: this._actualInjectionTime,
|
||||
injectionTxHash: this._injectionTxHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FundAllocationDomainService } from './services/fund-allocation.service';
|
||||
|
||||
@Module({
|
||||
providers: [FundAllocationDomainService],
|
||||
exports: [FundAllocationDomainService],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export interface DomainEvent {
|
||||
type: string;
|
||||
aggregateId: string;
|
||||
aggregateType: string;
|
||||
occurredAt: Date;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
import { FundAllocationDTO } from '../value-objects/fund-allocation.vo';
|
||||
|
||||
export class FundsAllocatedEvent implements DomainEvent {
|
||||
readonly type = 'FundsAllocated';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
allocations: FundAllocationDTO[];
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export * from './domain-event.interface';
|
||||
export * from './planting-order-created.event';
|
||||
export * from './province-city-confirmed.event';
|
||||
export * from './planting-order-paid.event';
|
||||
export * from './funds-allocated.event';
|
||||
export * from './pool-injected.event';
|
||||
export * from './mining-enabled.event';
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
|
||||
export class MiningEnabledEvent implements DomainEvent {
|
||||
readonly type = 'MiningEnabled';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
treeCount: number;
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
|
||||
export class PlantingOrderCreatedEvent implements DomainEvent {
|
||||
readonly type = 'PlantingOrderCreated';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
|
||||
export class PlantingOrderPaidEvent implements DomainEvent {
|
||||
readonly type = 'PlantingOrderPaid';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
|
||||
export class PoolInjectedEvent implements DomainEvent {
|
||||
readonly type = 'PoolInjected';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
amount: number;
|
||||
txHash: string;
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { DomainEvent } from './domain-event.interface';
|
||||
|
||||
export class ProvinceCityConfirmedEvent implements DomainEvent {
|
||||
readonly type = 'ProvinceCityConfirmed';
|
||||
readonly aggregateType = 'PlantingOrder';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
},
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './aggregates';
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
export * from './value-objects';
|
||||
export * from './domain.module';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './planting-order.repository.interface';
|
||||
export * from './planting-position.repository.interface';
|
||||
export * from './pool-injection-batch.repository.interface';
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { PlantingOrder } from '../aggregates/planting-order.aggregate';
|
||||
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
|
||||
|
||||
export interface IPlantingOrderRepository {
|
||||
save(order: PlantingOrder): Promise<void>;
|
||||
findById(orderId: bigint): Promise<PlantingOrder | null>;
|
||||
findByOrderNo(orderNo: string): Promise<PlantingOrder | null>;
|
||||
findByUserId(
|
||||
userId: bigint,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<PlantingOrder[]>;
|
||||
findByStatus(
|
||||
status: PlantingOrderStatus,
|
||||
limit?: number,
|
||||
): Promise<PlantingOrder[]>;
|
||||
findPendingPoolScheduling(): Promise<PlantingOrder[]>;
|
||||
findByBatchId(batchId: bigint): Promise<PlantingOrder[]>;
|
||||
findReadyForMining(): Promise<PlantingOrder[]>;
|
||||
countTreesByUserId(userId: bigint): Promise<number>;
|
||||
countByUserId(userId: bigint): Promise<number>;
|
||||
}
|
||||
|
||||
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { PlantingPosition } from '../aggregates/planting-position.aggregate';
|
||||
|
||||
export interface IPlantingPositionRepository {
|
||||
save(position: PlantingPosition): Promise<void>;
|
||||
findById(positionId: bigint): Promise<PlantingPosition | null>;
|
||||
findByUserId(userId: bigint): Promise<PlantingPosition | null>;
|
||||
getOrCreate(userId: bigint): Promise<PlantingPosition>;
|
||||
}
|
||||
|
||||
export const PLANTING_POSITION_REPOSITORY = Symbol(
|
||||
'IPlantingPositionRepository',
|
||||
);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { PoolInjectionBatch } from '../aggregates/pool-injection-batch.aggregate';
|
||||
import { BatchStatus } from '../value-objects/batch-status.enum';
|
||||
|
||||
export interface IPoolInjectionBatchRepository {
|
||||
save(batch: PoolInjectionBatch): Promise<void>;
|
||||
findById(batchId: bigint): Promise<PoolInjectionBatch | null>;
|
||||
findByBatchNo(batchNo: string): Promise<PoolInjectionBatch | null>;
|
||||
findByStatus(status: BatchStatus): Promise<PoolInjectionBatch[]>;
|
||||
findCurrentBatch(): Promise<PoolInjectionBatch | null>;
|
||||
findOrCreateCurrentBatch(): Promise<PoolInjectionBatch>;
|
||||
findScheduledBatchesReadyForInjection(): Promise<PoolInjectionBatch[]>;
|
||||
}
|
||||
|
||||
export const POOL_INJECTION_BATCH_REPOSITORY = Symbol(
|
||||
'IPoolInjectionBatchRepository',
|
||||
);
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { FundAllocationDomainService } from './fund-allocation.service';
|
||||
import { PlantingOrder } from '../aggregates/planting-order.aggregate';
|
||||
import { FundAllocationTargetType } from '../value-objects/fund-allocation-target-type.enum';
|
||||
|
||||
describe('FundAllocationDomainService', () => {
|
||||
let service: FundAllocationDomainService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new FundAllocationDomainService();
|
||||
});
|
||||
|
||||
describe('calculateAllocations', () => {
|
||||
it('应该正确计算1棵树的资金分配', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
const context = {
|
||||
referralChain: ['ref1', 'ref2'],
|
||||
nearestProvinceAuth: 'province_auth_1',
|
||||
nearestCityAuth: 'city_auth_1',
|
||||
nearestCommunity: 'community_1',
|
||||
};
|
||||
|
||||
const allocations = service.calculateAllocations(order, context);
|
||||
|
||||
expect(allocations.length).toBe(10);
|
||||
|
||||
// 验证各项金额
|
||||
const costAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.COST_ACCOUNT,
|
||||
);
|
||||
expect(costAlloc?.amount).toBe(400);
|
||||
|
||||
const opAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.OPERATION_ACCOUNT,
|
||||
);
|
||||
expect(opAlloc?.amount).toBe(300);
|
||||
|
||||
const hqAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
|
||||
);
|
||||
expect(hqAlloc?.amount).toBe(9);
|
||||
|
||||
const referralAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.REFERRAL_RIGHTS,
|
||||
);
|
||||
expect(referralAlloc?.amount).toBe(500);
|
||||
expect(referralAlloc?.targetAccountId).toBe('ref1');
|
||||
|
||||
const poolAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.RWAD_POOL,
|
||||
);
|
||||
expect(poolAlloc?.amount).toBe(800);
|
||||
|
||||
// 验证总额
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
expect(total).toBe(2199);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该正确计算5棵树的资金分配', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 5);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
const context = {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
};
|
||||
|
||||
const allocations = service.calculateAllocations(order, context);
|
||||
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
expect(total).toBe(2199 * 5);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在没有推荐人时使用总部社区', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const order = PlantingOrder.create(BigInt(1), 1);
|
||||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||||
jest.advanceTimersByTime(5000);
|
||||
order.confirmProvinceCity();
|
||||
|
||||
const context = {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
};
|
||||
|
||||
const allocations = service.calculateAllocations(order, context);
|
||||
|
||||
const referralAlloc = allocations.find(
|
||||
(a) => a.targetType === FundAllocationTargetType.REFERRAL_RIGHTS,
|
||||
);
|
||||
expect(referralAlloc?.targetAccountId).toBe('SYSTEM_HEADQUARTERS_COMMUNITY');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPoolInjectionAmount', () => {
|
||||
it('应该返回正确的底池注入金额', () => {
|
||||
expect(service.getPoolInjectionAmount(1)).toBe(800);
|
||||
expect(service.getPoolInjectionAmount(5)).toBe(4000);
|
||||
expect(service.getPoolInjectionAmount(10)).toBe(8000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { FundAllocation } from '../value-objects/fund-allocation.vo';
|
||||
import {
|
||||
FundAllocationTargetType,
|
||||
FUND_ALLOCATION_AMOUNTS,
|
||||
} from '../value-objects/fund-allocation-target-type.enum';
|
||||
import { PlantingOrder } from '../aggregates/planting-order.aggregate';
|
||||
|
||||
export interface ReferralContext {
|
||||
referralChain: string[];
|
||||
nearestProvinceAuth: string | null;
|
||||
nearestCityAuth: string | null;
|
||||
nearestCommunity: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FundAllocationDomainService {
|
||||
/**
|
||||
* 计算认种订单的资金分配
|
||||
* 核心业务规则: 2199 USDT 的 10 个去向
|
||||
*/
|
||||
calculateAllocations(
|
||||
order: PlantingOrder,
|
||||
context: ReferralContext,
|
||||
): FundAllocation[] {
|
||||
const treeCount = order.treeCount.value;
|
||||
const allocations: FundAllocation[] = [];
|
||||
const selection = order.provinceCitySelection;
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('订单未选择省市,无法计算资金分配');
|
||||
}
|
||||
|
||||
// 1. 成本账户: 400 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COST_ACCOUNT,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.COST_ACCOUNT] *
|
||||
treeCount,
|
||||
'SYSTEM_COST_ACCOUNT',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. 运营账户: 300 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.OPERATION_ACCOUNT,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.OPERATION_ACCOUNT] *
|
||||
treeCount,
|
||||
'SYSTEM_OPERATION_ACCOUNT',
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 总部社区: 9 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
|
||||
FUND_ALLOCATION_AMOUNTS[
|
||||
FundAllocationTargetType.HEADQUARTERS_COMMUNITY
|
||||
] * treeCount,
|
||||
'SYSTEM_HEADQUARTERS_COMMUNITY',
|
||||
),
|
||||
);
|
||||
|
||||
// 4. 分享权益: 500 USDT/棵 (分配给推荐链)
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.REFERRAL_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.REFERRAL_RIGHTS] *
|
||||
treeCount,
|
||||
context.referralChain.length > 0
|
||||
? context.referralChain[0]
|
||||
: 'SYSTEM_HEADQUARTERS_COMMUNITY',
|
||||
{ referralChain: context.referralChain },
|
||||
),
|
||||
);
|
||||
|
||||
// 5. 省区域权益: 15 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_AREA_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.PROVINCE_AREA_RIGHTS] *
|
||||
treeCount,
|
||||
`SYSTEM_PROVINCE_${selection.provinceCode}`,
|
||||
),
|
||||
);
|
||||
|
||||
// 6. 省团队权益: 20 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_TEAM_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.PROVINCE_TEAM_RIGHTS] *
|
||||
treeCount,
|
||||
context.nearestProvinceAuth || 'SYSTEM_HEADQUARTERS_COMMUNITY',
|
||||
),
|
||||
);
|
||||
|
||||
// 7. 市区域权益: 35 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_AREA_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.CITY_AREA_RIGHTS] *
|
||||
treeCount,
|
||||
`SYSTEM_CITY_${selection.cityCode}`,
|
||||
),
|
||||
);
|
||||
|
||||
// 8. 市团队权益: 40 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_TEAM_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.CITY_TEAM_RIGHTS] *
|
||||
treeCount,
|
||||
context.nearestCityAuth || 'SYSTEM_HEADQUARTERS_COMMUNITY',
|
||||
),
|
||||
);
|
||||
|
||||
// 9. 社区权益: 80 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COMMUNITY_RIGHTS,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.COMMUNITY_RIGHTS] *
|
||||
treeCount,
|
||||
context.nearestCommunity || 'SYSTEM_HEADQUARTERS_COMMUNITY',
|
||||
),
|
||||
);
|
||||
|
||||
// 10. RWAD底池: 800 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.RWAD_POOL,
|
||||
FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.RWAD_POOL] * treeCount,
|
||||
'SYSTEM_RWAD_POOL',
|
||||
),
|
||||
);
|
||||
|
||||
// 验证总额
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
const expected = 2199 * treeCount;
|
||||
if (Math.abs(total - expected) > 0.01) {
|
||||
throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`);
|
||||
}
|
||||
|
||||
return allocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取底池注入金额
|
||||
*/
|
||||
getPoolInjectionAmount(treeCount: number): number {
|
||||
return FUND_ALLOCATION_AMOUNTS[FundAllocationTargetType.RWAD_POOL] * treeCount;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './fund-allocation.service';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export enum BatchStatus {
|
||||
PENDING = 'PENDING', // 待注入 (收集订单中)
|
||||
SCHEDULED = 'SCHEDULED', // 已排期
|
||||
INJECTING = 'INJECTING', // 注入中
|
||||
INJECTED = 'INJECTED', // 已注入
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
export enum FundAllocationTargetType {
|
||||
COST_ACCOUNT = 'COST_ACCOUNT', // 400 USDT - 成本账户
|
||||
OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 300 USDT - 运营账户
|
||||
HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY', // 9 USDT - 总部社区
|
||||
REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 500 USDT - 分享权益
|
||||
PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 15 USDT - 省区域权益
|
||||
PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 20 USDT - 省团队权益
|
||||
CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 35 USDT - 市区域权益
|
||||
CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 40 USDT - 市团队权益
|
||||
COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 80 USDT - 社区权益
|
||||
RWAD_POOL = 'RWAD_POOL', // 800 USDT - RWAD底池
|
||||
}
|
||||
|
||||
// 每棵树的资金分配规则 (总计 2199 USDT)
|
||||
export const FUND_ALLOCATION_AMOUNTS: Record<FundAllocationTargetType, number> =
|
||||
{
|
||||
[FundAllocationTargetType.COST_ACCOUNT]: 400,
|
||||
[FundAllocationTargetType.OPERATION_ACCOUNT]: 300,
|
||||
[FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 9,
|
||||
[FundAllocationTargetType.REFERRAL_RIGHTS]: 500,
|
||||
[FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 15,
|
||||
[FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 20,
|
||||
[FundAllocationTargetType.CITY_AREA_RIGHTS]: 35,
|
||||
[FundAllocationTargetType.CITY_TEAM_RIGHTS]: 40,
|
||||
[FundAllocationTargetType.COMMUNITY_RIGHTS]: 80,
|
||||
[FundAllocationTargetType.RWAD_POOL]: 800,
|
||||
};
|
||||
|
||||
// 每棵树价格
|
||||
export const PRICE_PER_TREE = 2199;
|
||||
|
||||
// 验证总额
|
||||
const TOTAL = Object.values(FUND_ALLOCATION_AMOUNTS).reduce((a, b) => a + b, 0);
|
||||
if (TOTAL !== PRICE_PER_TREE) {
|
||||
throw new Error(`资金分配配置错误: 总额 ${TOTAL} != ${PRICE_PER_TREE}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { FundAllocationTargetType } from './fund-allocation-target-type.enum';
|
||||
|
||||
export interface FundAllocationDTO {
|
||||
targetType: FundAllocationTargetType;
|
||||
amount: number;
|
||||
targetAccountId: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class FundAllocation {
|
||||
constructor(
|
||||
public readonly targetType: FundAllocationTargetType,
|
||||
public readonly amount: number,
|
||||
public readonly targetAccountId: string | null,
|
||||
public readonly metadata?: Record<string, unknown>,
|
||||
) {
|
||||
if (amount < 0) {
|
||||
throw new Error('分配金额不能为负数');
|
||||
}
|
||||
}
|
||||
|
||||
toDTO(): FundAllocationDTO {
|
||||
return {
|
||||
targetType: this.targetType,
|
||||
amount: this.amount,
|
||||
targetAccountId: this.targetAccountId,
|
||||
metadata: this.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: FundAllocation): boolean {
|
||||
return (
|
||||
this.targetType === other.targetType &&
|
||||
this.amount === other.amount &&
|
||||
this.targetAccountId === other.targetAccountId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export * from './planting-order-status.enum';
|
||||
export * from './fund-allocation-target-type.enum';
|
||||
export * from './batch-status.enum';
|
||||
export * from './tree-count.vo';
|
||||
export * from './province-city-selection.vo';
|
||||
export * from './fund-allocation.vo';
|
||||
export * from './money.vo';
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
export class Money {
|
||||
private constructor(
|
||||
public readonly amount: number,
|
||||
public readonly currency: string = 'USDT',
|
||||
) {
|
||||
if (amount < 0) {
|
||||
throw new Error('金额不能为负数');
|
||||
}
|
||||
}
|
||||
|
||||
static create(amount: number, currency: string = 'USDT'): Money {
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
static zero(currency: string = 'USDT'): Money {
|
||||
return new Money(0, currency);
|
||||
}
|
||||
|
||||
add(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
return new Money(this.amount + other.amount, this.currency);
|
||||
}
|
||||
|
||||
subtract(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
if (this.amount < other.amount) {
|
||||
throw new Error('余额不足');
|
||||
}
|
||||
return new Money(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;
|
||||
}
|
||||
|
||||
isGreaterThan(other: Money): boolean {
|
||||
this.ensureSameCurrency(other);
|
||||
return this.amount > other.amount;
|
||||
}
|
||||
|
||||
isLessThan(other: Money): boolean {
|
||||
this.ensureSameCurrency(other);
|
||||
return this.amount < other.amount;
|
||||
}
|
||||
|
||||
private ensureSameCurrency(other: Money): void {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new Error(`货币类型不匹配: ${this.currency} vs ${other.currency}`);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.amount} ${this.currency}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export enum PlantingOrderStatus {
|
||||
CREATED = 'CREATED', // 已创建
|
||||
PROVINCE_CITY_CONFIRMED = 'PROVINCE_CITY_CONFIRMED', // 省市已确认
|
||||
PAID = 'PAID', // 已支付
|
||||
FUND_ALLOCATED = 'FUND_ALLOCATED', // 资金已分配
|
||||
POOL_SCHEDULED = 'POOL_SCHEDULED', // 底池已排期
|
||||
POOL_INJECTED = 'POOL_INJECTED', // 底池已注入
|
||||
MINING_ENABLED = 'MINING_ENABLED', // 挖矿已开启
|
||||
CANCELLED = 'CANCELLED', // 已取消
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { ProvinceCitySelection } from './province-city-selection.vo';
|
||||
|
||||
describe('ProvinceCitySelection', () => {
|
||||
describe('create', () => {
|
||||
it('应该成功创建省市选择', () => {
|
||||
const selection = ProvinceCitySelection.create(
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
expect(selection.provinceCode).toBe('440000');
|
||||
expect(selection.provinceName).toBe('广东省');
|
||||
expect(selection.cityCode).toBe('440100');
|
||||
expect(selection.cityName).toBe('广州市');
|
||||
expect(selection.isConfirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空的省份代码', () => {
|
||||
expect(() =>
|
||||
ProvinceCitySelection.create('', '广东省', '440100', '广州市'),
|
||||
).toThrow('省市代码不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空的城市代码', () => {
|
||||
expect(() =>
|
||||
ProvinceCitySelection.create('440000', '广东省', '', '广州市'),
|
||||
).toThrow('省市代码不能为空');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('应该成功确认省市选择', () => {
|
||||
const selection = ProvinceCitySelection.create(
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
// 模拟等待5秒
|
||||
jest.useFakeTimers();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
const confirmed = selection.confirm();
|
||||
expect(confirmed.isConfirmed).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该拒绝重复确认', () => {
|
||||
const selection = ProvinceCitySelection.create(
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
const confirmed = selection.confirm();
|
||||
|
||||
expect(() => confirmed.confirm()).toThrow('省市已确认,不可重复确认');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canConfirm', () => {
|
||||
it('应该在5秒前返回false', () => {
|
||||
const selection = ProvinceCitySelection.create(
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
expect(selection.canConfirm()).toBe(false);
|
||||
});
|
||||
|
||||
it('应该在5秒后返回true', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const selection = ProvinceCitySelection.create(
|
||||
'440000',
|
||||
'广东省',
|
||||
'440100',
|
||||
'广州市',
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(selection.canConfirm()).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
export class ProvinceCitySelection {
|
||||
private constructor(
|
||||
public readonly provinceCode: string,
|
||||
public readonly provinceName: string,
|
||||
public readonly cityCode: string,
|
||||
public readonly cityName: string,
|
||||
public readonly selectedAt: Date,
|
||||
public readonly confirmedAt: Date | null,
|
||||
) {}
|
||||
|
||||
get isConfirmed(): boolean {
|
||||
return this.confirmedAt !== null;
|
||||
}
|
||||
|
||||
static create(
|
||||
provinceCode: string,
|
||||
provinceName: string,
|
||||
cityCode: string,
|
||||
cityName: string,
|
||||
): ProvinceCitySelection {
|
||||
if (!provinceCode || !cityCode) {
|
||||
throw new Error('省市代码不能为空');
|
||||
}
|
||||
|
||||
return new ProvinceCitySelection(
|
||||
provinceCode,
|
||||
provinceName,
|
||||
cityCode,
|
||||
cityName,
|
||||
new Date(),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(
|
||||
provinceCode: string,
|
||||
provinceName: string,
|
||||
cityCode: string,
|
||||
cityName: string,
|
||||
selectedAt: Date,
|
||||
confirmedAt: Date | null,
|
||||
): ProvinceCitySelection {
|
||||
return new ProvinceCitySelection(
|
||||
provinceCode,
|
||||
provinceName,
|
||||
cityCode,
|
||||
cityName,
|
||||
selectedAt,
|
||||
confirmedAt,
|
||||
);
|
||||
}
|
||||
|
||||
confirm(): ProvinceCitySelection {
|
||||
if (this.isConfirmed) {
|
||||
throw new Error('省市已确认,不可重复确认');
|
||||
}
|
||||
|
||||
return new ProvinceCitySelection(
|
||||
this.provinceCode,
|
||||
this.provinceName,
|
||||
this.cityCode,
|
||||
this.cityName,
|
||||
this.selectedAt,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已过5秒确认时间
|
||||
*/
|
||||
canConfirm(): boolean {
|
||||
const elapsed = Date.now() - this.selectedAt.getTime();
|
||||
return elapsed >= 5000; // 5秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余等待时间(毫秒)
|
||||
*/
|
||||
getRemainingWaitTime(): number {
|
||||
const elapsed = Date.now() - this.selectedAt.getTime();
|
||||
return Math.max(0, 5000 - elapsed);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { TreeCount } from './tree-count.vo';
|
||||
|
||||
describe('TreeCount', () => {
|
||||
describe('create', () => {
|
||||
it('应该成功创建有效的树数量', () => {
|
||||
const treeCount = TreeCount.create(5);
|
||||
expect(treeCount.value).toBe(5);
|
||||
});
|
||||
|
||||
it('应该拒绝0', () => {
|
||||
expect(() => TreeCount.create(0)).toThrow('认种数量必须是正整数');
|
||||
});
|
||||
|
||||
it('应该拒绝负数', () => {
|
||||
expect(() => TreeCount.create(-1)).toThrow('认种数量必须是正整数');
|
||||
});
|
||||
|
||||
it('应该拒绝小数', () => {
|
||||
expect(() => TreeCount.create(1.5)).toThrow('认种数量必须是正整数');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiply', () => {
|
||||
it('应该正确计算乘法', () => {
|
||||
const treeCount = TreeCount.create(5);
|
||||
expect(treeCount.multiply(2199)).toBe(10995);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('应该正确相加', () => {
|
||||
const a = TreeCount.create(3);
|
||||
const b = TreeCount.create(5);
|
||||
const result = a.add(b);
|
||||
expect(result.value).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('应该正确比较相等', () => {
|
||||
const a = TreeCount.create(5);
|
||||
const b = TreeCount.create(5);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确比较不相等', () => {
|
||||
const a = TreeCount.create(3);
|
||||
const b = TreeCount.create(5);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export class TreeCount {
|
||||
private constructor(public readonly value: number) {
|
||||
if (value <= 0 || !Number.isInteger(value)) {
|
||||
throw new Error('认种数量必须是正整数');
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: number): TreeCount {
|
||||
return new TreeCount(value);
|
||||
}
|
||||
|
||||
multiply(factor: number): number {
|
||||
return this.value * factor;
|
||||
}
|
||||
|
||||
add(other: TreeCount): TreeCount {
|
||||
return new TreeCount(this.value + other.value);
|
||||
}
|
||||
|
||||
equals(other: TreeCount): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './wallet-service.client';
|
||||
export * from './referral-service.client';
|
||||
73
backend/services/planting-service/src/infrastructure/external/referral-service.client.ts
vendored
Normal file
73
backend/services/planting-service/src/infrastructure/external/referral-service.client.ts
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ReferralContext } from '../../domain/services/fund-allocation.service';
|
||||
|
||||
export interface ReferralInfo {
|
||||
userId: string;
|
||||
referralChain: string[];
|
||||
nearestProvinceAuth: string | null;
|
||||
nearestCityAuth: string | null;
|
||||
nearestCommunity: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReferralServiceClient {
|
||||
private readonly logger = new Logger(ReferralServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
this.configService.get<string>('REFERRAL_SERVICE_URL') ||
|
||||
'http://localhost:3004';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的推荐链和权限上级信息
|
||||
*/
|
||||
async getReferralContext(
|
||||
userId: string,
|
||||
provinceCode: string,
|
||||
cityCode: string,
|
||||
): Promise<ReferralContext> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<ReferralInfo>(
|
||||
`${this.baseUrl}/api/v1/referrals/${userId}/context`,
|
||||
{
|
||||
params: { provinceCode, cityCode },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
referralChain: response.data.referralChain,
|
||||
nearestProvinceAuth: response.data.nearestProvinceAuth,
|
||||
nearestCityAuth: response.data.nearestCityAuth,
|
||||
nearestCommunity: response.data.nearestCommunity,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get referral context for user ${userId}`,
|
||||
error,
|
||||
);
|
||||
// 在开发环境返回默认空数据
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
this.logger.warn(
|
||||
'Development mode: returning empty referral context',
|
||||
);
|
||||
return {
|
||||
referralChain: [],
|
||||
nearestProvinceAuth: null,
|
||||
nearestCityAuth: null,
|
||||
nearestCommunity: null,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
backend/services/planting-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal file
144
backend/services/planting-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { FundAllocationDTO } from '../../domain/value-objects/fund-allocation.vo';
|
||||
|
||||
export interface DeductForPlantingRequest {
|
||||
userId: string;
|
||||
amount: number;
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export interface AllocateFundsRequest {
|
||||
orderId: string;
|
||||
allocations: FundAllocationDTO[];
|
||||
}
|
||||
|
||||
export interface WalletBalance {
|
||||
userId: string;
|
||||
available: number;
|
||||
locked: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WalletServiceClient {
|
||||
private readonly logger = new Logger(WalletServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
this.configService.get<string>('WALLET_SERVICE_URL') ||
|
||||
'http://localhost:3002';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户钱包余额
|
||||
*/
|
||||
async getBalance(userId: string): Promise<WalletBalance> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<WalletBalance>(
|
||||
`${this.baseUrl}/api/v1/wallets/${userId}/balance`,
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get balance for user ${userId}`, error);
|
||||
// 在开发环境返回模拟数据
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
return {
|
||||
userId,
|
||||
available: 100000,
|
||||
locked: 0,
|
||||
currency: 'USDT',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认种扣款
|
||||
*/
|
||||
async deductForPlanting(request: DeductForPlantingRequest): Promise<boolean> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.baseUrl}/api/v1/wallets/deduct-for-planting`,
|
||||
request,
|
||||
),
|
||||
);
|
||||
return response.data.success;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to deduct for planting: ${request.orderId}`,
|
||||
error,
|
||||
);
|
||||
// 在开发环境模拟成功
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
this.logger.warn('Development mode: simulating successful deduction');
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行资金分配
|
||||
*/
|
||||
async allocateFunds(request: AllocateFundsRequest): Promise<boolean> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.baseUrl}/api/v1/wallets/allocate-funds`,
|
||||
request,
|
||||
),
|
||||
);
|
||||
return response.data.success;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to allocate funds for order: ${request.orderId}`,
|
||||
error,
|
||||
);
|
||||
// 在开发环境模拟成功
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
this.logger.warn('Development mode: simulating successful allocation');
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入底池
|
||||
*/
|
||||
async injectToPool(
|
||||
batchId: string,
|
||||
amount: number,
|
||||
): Promise<{ txHash: string }> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{ txHash: string }>(
|
||||
`${this.baseUrl}/api/v1/pool/inject`,
|
||||
{ batchId, amount },
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to inject to pool: batch ${batchId}`, error);
|
||||
// 在开发环境返回模拟交易哈希
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
this.logger.warn('Development mode: simulating pool injection');
|
||||
return {
|
||||
txHash: `0x${Date.now().toString(16)}${Math.random().toString(16).substring(2)}`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './persistence/prisma/prisma.service';
|
||||
export * from './persistence/repositories';
|
||||
export * from './persistence/mappers';
|
||||
export * from './external';
|
||||
export * from './infrastructure.module';
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { PlantingOrderRepositoryImpl } from './persistence/repositories/planting-order.repository.impl';
|
||||
import { PlantingPositionRepositoryImpl } from './persistence/repositories/planting-position.repository.impl';
|
||||
import { PoolInjectionBatchRepositoryImpl } from './persistence/repositories/pool-injection-batch.repository.impl';
|
||||
import { WalletServiceClient } from './external/wallet-service.client';
|
||||
import { ReferralServiceClient } from './external/referral-service.client';
|
||||
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
||||
import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface';
|
||||
import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 5000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
{
|
||||
provide: PLANTING_ORDER_REPOSITORY,
|
||||
useClass: PlantingOrderRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: PLANTING_POSITION_REPOSITORY,
|
||||
useClass: PlantingPositionRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: POOL_INJECTION_BATCH_REPOSITORY,
|
||||
useClass: PoolInjectionBatchRepositoryImpl,
|
||||
},
|
||||
WalletServiceClient,
|
||||
ReferralServiceClient,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
PLANTING_ORDER_REPOSITORY,
|
||||
PLANTING_POSITION_REPOSITORY,
|
||||
POOL_INJECTION_BATCH_REPOSITORY,
|
||||
WalletServiceClient,
|
||||
ReferralServiceClient,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './planting-order.mapper';
|
||||
export * from './planting-position.mapper';
|
||||
export * from './pool-injection-batch.mapper';
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
PlantingOrder as PrismaPlantingOrder,
|
||||
FundAllocation as PrismaFundAllocation,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
PlantingOrder,
|
||||
PlantingOrderData,
|
||||
} from '../../../domain/aggregates/planting-order.aggregate';
|
||||
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
|
||||
import { FundAllocation } from '../../../domain/value-objects/fund-allocation.vo';
|
||||
import { FundAllocationTargetType } from '../../../domain/value-objects/fund-allocation-target-type.enum';
|
||||
|
||||
type PlantingOrderWithAllocations = PrismaPlantingOrder & {
|
||||
fundAllocations?: PrismaFundAllocation[];
|
||||
};
|
||||
|
||||
export class PlantingOrderMapper {
|
||||
static toDomain(prismaOrder: PlantingOrderWithAllocations): PlantingOrder {
|
||||
const data: PlantingOrderData = {
|
||||
id: prismaOrder.id,
|
||||
orderNo: prismaOrder.orderNo,
|
||||
userId: prismaOrder.userId,
|
||||
treeCount: prismaOrder.treeCount,
|
||||
totalAmount: Number(prismaOrder.totalAmount),
|
||||
status: prismaOrder.status as PlantingOrderStatus,
|
||||
selectedProvince: prismaOrder.selectedProvince,
|
||||
selectedCity: prismaOrder.selectedCity,
|
||||
provinceCitySelectedAt: prismaOrder.provinceCitySelectedAt,
|
||||
provinceCityConfirmedAt: prismaOrder.provinceCityConfirmedAt,
|
||||
poolInjectionBatchId: prismaOrder.poolInjectionBatchId,
|
||||
poolInjectionScheduledTime: prismaOrder.poolInjectionScheduledTime,
|
||||
poolInjectionActualTime: prismaOrder.poolInjectionActualTime,
|
||||
poolInjectionTxHash: prismaOrder.poolInjectionTxHash,
|
||||
miningEnabledAt: prismaOrder.miningEnabledAt,
|
||||
createdAt: prismaOrder.createdAt,
|
||||
paidAt: prismaOrder.paidAt,
|
||||
fundAllocatedAt: prismaOrder.fundAllocatedAt,
|
||||
};
|
||||
|
||||
return PlantingOrder.reconstitute(data);
|
||||
}
|
||||
|
||||
static toPersistence(order: PlantingOrder): {
|
||||
orderData: {
|
||||
id?: bigint;
|
||||
orderNo: string;
|
||||
userId: bigint;
|
||||
treeCount: number;
|
||||
totalAmount: Prisma.Decimal;
|
||||
selectedProvince: string | null;
|
||||
selectedCity: string | null;
|
||||
provinceCitySelectedAt: Date | null;
|
||||
provinceCityConfirmedAt: Date | null;
|
||||
status: string;
|
||||
poolInjectionBatchId: bigint | null;
|
||||
poolInjectionScheduledTime: Date | null;
|
||||
poolInjectionActualTime: Date | null;
|
||||
poolInjectionTxHash: string | null;
|
||||
miningEnabledAt: Date | null;
|
||||
createdAt: Date;
|
||||
paidAt: Date | null;
|
||||
fundAllocatedAt: Date | null;
|
||||
};
|
||||
allocations: {
|
||||
orderId: bigint;
|
||||
targetType: string;
|
||||
amount: Prisma.Decimal;
|
||||
targetAccountId: string | null;
|
||||
metadata: Prisma.InputJsonValue | null;
|
||||
}[];
|
||||
} {
|
||||
const data = order.toPersistence();
|
||||
|
||||
const orderData = {
|
||||
id: data.id,
|
||||
orderNo: data.orderNo,
|
||||
userId: data.userId,
|
||||
treeCount: data.treeCount,
|
||||
totalAmount: new Prisma.Decimal(data.totalAmount),
|
||||
selectedProvince: data.selectedProvince || null,
|
||||
selectedCity: data.selectedCity || null,
|
||||
provinceCitySelectedAt: data.provinceCitySelectedAt || null,
|
||||
provinceCityConfirmedAt: data.provinceCityConfirmedAt || null,
|
||||
status: data.status,
|
||||
poolInjectionBatchId: data.poolInjectionBatchId || null,
|
||||
poolInjectionScheduledTime: data.poolInjectionScheduledTime || null,
|
||||
poolInjectionActualTime: data.poolInjectionActualTime || null,
|
||||
poolInjectionTxHash: data.poolInjectionTxHash || null,
|
||||
miningEnabledAt: data.miningEnabledAt || null,
|
||||
createdAt: data.createdAt || new Date(),
|
||||
paidAt: data.paidAt || null,
|
||||
fundAllocatedAt: data.fundAllocatedAt || null,
|
||||
};
|
||||
|
||||
const allocations = order.fundAllocations.map((a) => ({
|
||||
orderId: data.id!,
|
||||
targetType: a.targetType,
|
||||
amount: new Prisma.Decimal(a.amount),
|
||||
targetAccountId: a.targetAccountId,
|
||||
metadata: (a.metadata as Prisma.InputJsonValue) || null,
|
||||
}));
|
||||
|
||||
return { orderData, allocations };
|
||||
}
|
||||
|
||||
static mapFundAllocation(prisma: PrismaFundAllocation): FundAllocation {
|
||||
return new FundAllocation(
|
||||
prisma.targetType as FundAllocationTargetType,
|
||||
Number(prisma.amount),
|
||||
prisma.targetAccountId,
|
||||
prisma.metadata as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
PlantingPosition as PrismaPlantingPosition,
|
||||
PositionDistribution as PrismaPositionDistribution,
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
PlantingPosition,
|
||||
PlantingPositionData,
|
||||
} from '../../../domain/aggregates/planting-position.aggregate';
|
||||
|
||||
type PlantingPositionWithDistributions = PrismaPlantingPosition & {
|
||||
distributions?: PrismaPositionDistribution[];
|
||||
};
|
||||
|
||||
export class PlantingPositionMapper {
|
||||
static toDomain(
|
||||
prismaPosition: PlantingPositionWithDistributions,
|
||||
): PlantingPosition {
|
||||
const data: PlantingPositionData = {
|
||||
id: prismaPosition.id,
|
||||
userId: prismaPosition.userId,
|
||||
totalTreeCount: prismaPosition.totalTreeCount,
|
||||
effectiveTreeCount: prismaPosition.effectiveTreeCount,
|
||||
pendingTreeCount: prismaPosition.pendingTreeCount,
|
||||
firstMiningStartAt: prismaPosition.firstMiningStartAt,
|
||||
distributions: prismaPosition.distributions?.map((d) => ({
|
||||
id: d.id,
|
||||
userId: d.userId,
|
||||
provinceCode: d.provinceCode,
|
||||
cityCode: d.cityCode,
|
||||
treeCount: d.treeCount,
|
||||
})),
|
||||
};
|
||||
|
||||
return PlantingPosition.reconstitute(data);
|
||||
}
|
||||
|
||||
static toPersistence(position: PlantingPosition): {
|
||||
positionData: Omit<PrismaPlantingPosition, 'id' | 'createdAt' | 'updatedAt'> & {
|
||||
id?: bigint;
|
||||
};
|
||||
distributions: Omit<
|
||||
PrismaPositionDistribution,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>[];
|
||||
} {
|
||||
const data = position.toPersistence();
|
||||
|
||||
const positionData = {
|
||||
id: data.id,
|
||||
userId: data.userId,
|
||||
totalTreeCount: data.totalTreeCount,
|
||||
effectiveTreeCount: data.effectiveTreeCount,
|
||||
pendingTreeCount: data.pendingTreeCount,
|
||||
firstMiningStartAt: data.firstMiningStartAt || null,
|
||||
};
|
||||
|
||||
const distributions =
|
||||
data.distributions?.map((d) => ({
|
||||
userId: d.userId,
|
||||
provinceCode: d.provinceCode,
|
||||
cityCode: d.cityCode,
|
||||
treeCount: d.treeCount,
|
||||
})) || [];
|
||||
|
||||
return { positionData, distributions };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { PoolInjectionBatch as PrismaPoolInjectionBatch, Prisma } from '@prisma/client';
|
||||
import {
|
||||
PoolInjectionBatch,
|
||||
PoolInjectionBatchData,
|
||||
} from '../../../domain/aggregates/pool-injection-batch.aggregate';
|
||||
import { BatchStatus } from '../../../domain/value-objects/batch-status.enum';
|
||||
|
||||
export class PoolInjectionBatchMapper {
|
||||
static toDomain(prismaBatch: PrismaPoolInjectionBatch): PoolInjectionBatch {
|
||||
const data: PoolInjectionBatchData = {
|
||||
id: prismaBatch.id,
|
||||
batchNo: prismaBatch.batchNo,
|
||||
startDate: prismaBatch.startDate,
|
||||
endDate: prismaBatch.endDate,
|
||||
orderCount: prismaBatch.orderCount,
|
||||
totalAmount: Number(prismaBatch.totalAmount),
|
||||
status: prismaBatch.status as BatchStatus,
|
||||
scheduledInjectionTime: prismaBatch.scheduledInjectionTime,
|
||||
actualInjectionTime: prismaBatch.actualInjectionTime,
|
||||
injectionTxHash: prismaBatch.injectionTxHash,
|
||||
};
|
||||
|
||||
return PoolInjectionBatch.reconstitute(data);
|
||||
}
|
||||
|
||||
static toPersistence(batch: PoolInjectionBatch): {
|
||||
id?: bigint;
|
||||
batchNo: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
orderCount: number;
|
||||
totalAmount: Prisma.Decimal;
|
||||
status: string;
|
||||
scheduledInjectionTime: Date | null;
|
||||
actualInjectionTime: Date | null;
|
||||
injectionTxHash: string | null;
|
||||
} {
|
||||
const data = batch.toPersistence();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
batchNo: data.batchNo,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
orderCount: data.orderCount,
|
||||
totalAmount: new Prisma.Decimal(data.totalAmount),
|
||||
status: data.status,
|
||||
scheduledInjectionTime: data.scheduledInjectionTime || null,
|
||||
actualInjectionTime: data.actualInjectionTime || null,
|
||||
injectionTxHash: data.injectionTxHash || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor() {
|
||||
super({
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ['query', 'info', 'warn', 'error']
|
||||
: ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
async cleanDatabase() {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('cleanDatabase can only be used in test environment');
|
||||
}
|
||||
|
||||
const tablenames = await this.$queryRaw<
|
||||
Array<{ tablename: string }>
|
||||
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
|
||||
|
||||
for (const { tablename } of tablenames) {
|
||||
if (tablename !== '_prisma_migrations') {
|
||||
await this.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './planting-order.repository.impl';
|
||||
export * from './planting-position.repository.impl';
|
||||
export * from './pool-injection-batch.repository.impl';
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IPlantingOrderRepository } from '../../../domain/repositories/planting-order.repository.interface';
|
||||
import { PlantingOrder } from '../../../domain/aggregates/planting-order.aggregate';
|
||||
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
|
||||
import { PlantingOrderMapper } from '../mappers/planting-order.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(order: PlantingOrder): Promise<void> {
|
||||
const { orderData, allocations } =
|
||||
PlantingOrderMapper.toPersistence(order);
|
||||
|
||||
if (order.id) {
|
||||
// 更新
|
||||
await this.prisma.plantingOrder.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
status: orderData.status,
|
||||
selectedProvince: orderData.selectedProvince,
|
||||
selectedCity: orderData.selectedCity,
|
||||
provinceCitySelectedAt: orderData.provinceCitySelectedAt,
|
||||
provinceCityConfirmedAt: orderData.provinceCityConfirmedAt,
|
||||
poolInjectionBatchId: orderData.poolInjectionBatchId,
|
||||
poolInjectionScheduledTime: orderData.poolInjectionScheduledTime,
|
||||
poolInjectionActualTime: orderData.poolInjectionActualTime,
|
||||
poolInjectionTxHash: orderData.poolInjectionTxHash,
|
||||
miningEnabledAt: orderData.miningEnabledAt,
|
||||
paidAt: orderData.paidAt,
|
||||
fundAllocatedAt: orderData.fundAllocatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 如果有新的资金分配,插入
|
||||
if (allocations.length > 0) {
|
||||
const existingAllocations = await this.prisma.fundAllocation.count({
|
||||
where: { orderId: order.id },
|
||||
});
|
||||
|
||||
if (existingAllocations === 0) {
|
||||
await this.prisma.fundAllocation.createMany({
|
||||
data: allocations.map((a) => ({
|
||||
orderId: order.id!,
|
||||
targetType: a.targetType,
|
||||
amount: a.amount,
|
||||
targetAccountId: a.targetAccountId,
|
||||
metadata: a.metadata ?? Prisma.DbNull,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const created = await this.prisma.plantingOrder.create({
|
||||
data: {
|
||||
orderNo: orderData.orderNo,
|
||||
userId: orderData.userId,
|
||||
treeCount: orderData.treeCount,
|
||||
totalAmount: orderData.totalAmount,
|
||||
status: orderData.status,
|
||||
selectedProvince: orderData.selectedProvince,
|
||||
selectedCity: orderData.selectedCity,
|
||||
provinceCitySelectedAt: orderData.provinceCitySelectedAt,
|
||||
provinceCityConfirmedAt: orderData.provinceCityConfirmedAt,
|
||||
poolInjectionBatchId: orderData.poolInjectionBatchId,
|
||||
poolInjectionScheduledTime: orderData.poolInjectionScheduledTime,
|
||||
poolInjectionActualTime: orderData.poolInjectionActualTime,
|
||||
poolInjectionTxHash: orderData.poolInjectionTxHash,
|
||||
miningEnabledAt: orderData.miningEnabledAt,
|
||||
paidAt: orderData.paidAt,
|
||||
fundAllocatedAt: orderData.fundAllocatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
order.setId(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(orderId: bigint): Promise<PlantingOrder | null> {
|
||||
const order = await this.prisma.plantingOrder.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { fundAllocations: true },
|
||||
});
|
||||
|
||||
return order ? PlantingOrderMapper.toDomain(order) : null;
|
||||
}
|
||||
|
||||
async findByOrderNo(orderNo: string): Promise<PlantingOrder | null> {
|
||||
const order = await this.prisma.plantingOrder.findUnique({
|
||||
where: { orderNo },
|
||||
include: { fundAllocations: true },
|
||||
});
|
||||
|
||||
return order ? PlantingOrderMapper.toDomain(order) : null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: bigint,
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
): Promise<PlantingOrder[]> {
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: { userId },
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
|
||||
async findByStatus(
|
||||
status: PlantingOrderStatus,
|
||||
limit: number = 100,
|
||||
): Promise<PlantingOrder[]> {
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: { status },
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
|
||||
async findPendingPoolScheduling(): Promise<PlantingOrder[]> {
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: {
|
||||
status: PlantingOrderStatus.FUND_ALLOCATED,
|
||||
poolInjectionBatchId: null,
|
||||
},
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
|
||||
async findByBatchId(batchId: bigint): Promise<PlantingOrder[]> {
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: { poolInjectionBatchId: batchId },
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
|
||||
async findReadyForMining(): Promise<PlantingOrder[]> {
|
||||
const orders = await this.prisma.plantingOrder.findMany({
|
||||
where: {
|
||||
status: PlantingOrderStatus.POOL_INJECTED,
|
||||
miningEnabledAt: null,
|
||||
},
|
||||
include: { fundAllocations: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return orders.map(PlantingOrderMapper.toDomain);
|
||||
}
|
||||
|
||||
async countTreesByUserId(userId: bigint): Promise<number> {
|
||||
const result = await this.prisma.plantingOrder.aggregate({
|
||||
where: {
|
||||
userId,
|
||||
status: {
|
||||
notIn: [PlantingOrderStatus.CANCELLED],
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
treeCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.treeCount || 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: bigint): Promise<number> {
|
||||
return this.prisma.plantingOrder.count({
|
||||
where: {
|
||||
userId,
|
||||
status: {
|
||||
notIn: [PlantingOrderStatus.CANCELLED],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IPlantingPositionRepository } from '../../../domain/repositories/planting-position.repository.interface';
|
||||
import { PlantingPosition } from '../../../domain/aggregates/planting-position.aggregate';
|
||||
import { PlantingPositionMapper } from '../mappers/planting-position.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class PlantingPositionRepositoryImpl
|
||||
implements IPlantingPositionRepository
|
||||
{
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(position: PlantingPosition): Promise<void> {
|
||||
const { positionData, distributions } =
|
||||
PlantingPositionMapper.toPersistence(position);
|
||||
|
||||
if (position.id) {
|
||||
// 更新持仓
|
||||
await this.prisma.plantingPosition.update({
|
||||
where: { id: position.id },
|
||||
data: {
|
||||
totalTreeCount: positionData.totalTreeCount,
|
||||
effectiveTreeCount: positionData.effectiveTreeCount,
|
||||
pendingTreeCount: positionData.pendingTreeCount,
|
||||
firstMiningStartAt: positionData.firstMiningStartAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 更新分布
|
||||
for (const dist of distributions) {
|
||||
await this.prisma.positionDistribution.upsert({
|
||||
where: {
|
||||
userId_provinceCode_cityCode: {
|
||||
userId: dist.userId,
|
||||
provinceCode: dist.provinceCode ?? '',
|
||||
cityCode: dist.cityCode ?? '',
|
||||
},
|
||||
},
|
||||
update: {
|
||||
treeCount: dist.treeCount,
|
||||
},
|
||||
create: {
|
||||
userId: dist.userId,
|
||||
provinceCode: dist.provinceCode,
|
||||
cityCode: dist.cityCode,
|
||||
treeCount: dist.treeCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const created = await this.prisma.plantingPosition.create({
|
||||
data: {
|
||||
userId: positionData.userId,
|
||||
totalTreeCount: positionData.totalTreeCount,
|
||||
effectiveTreeCount: positionData.effectiveTreeCount,
|
||||
pendingTreeCount: positionData.pendingTreeCount,
|
||||
firstMiningStartAt: positionData.firstMiningStartAt,
|
||||
},
|
||||
});
|
||||
|
||||
position.setId(created.id);
|
||||
|
||||
// 创建分布
|
||||
if (distributions.length > 0) {
|
||||
await this.prisma.positionDistribution.createMany({
|
||||
data: distributions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async findById(positionId: bigint): Promise<PlantingPosition | null> {
|
||||
const position = await this.prisma.plantingPosition.findUnique({
|
||||
where: { id: positionId },
|
||||
include: { distributions: true },
|
||||
});
|
||||
|
||||
return position ? PlantingPositionMapper.toDomain(position) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: bigint): Promise<PlantingPosition | null> {
|
||||
const position = await this.prisma.plantingPosition.findUnique({
|
||||
where: { userId },
|
||||
include: { distributions: true },
|
||||
});
|
||||
|
||||
return position ? PlantingPositionMapper.toDomain(position) : null;
|
||||
}
|
||||
|
||||
async getOrCreate(userId: bigint): Promise<PlantingPosition> {
|
||||
let position = await this.findByUserId(userId);
|
||||
|
||||
if (!position) {
|
||||
position = PlantingPosition.create(userId);
|
||||
await this.save(position);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IPoolInjectionBatchRepository } from '../../../domain/repositories/pool-injection-batch.repository.interface';
|
||||
import { PoolInjectionBatch } from '../../../domain/aggregates/pool-injection-batch.aggregate';
|
||||
import { BatchStatus } from '../../../domain/value-objects/batch-status.enum';
|
||||
import { PoolInjectionBatchMapper } from '../mappers/pool-injection-batch.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class PoolInjectionBatchRepositoryImpl
|
||||
implements IPoolInjectionBatchRepository
|
||||
{
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(batch: PoolInjectionBatch): Promise<void> {
|
||||
const data = PoolInjectionBatchMapper.toPersistence(batch);
|
||||
|
||||
if (batch.id) {
|
||||
// 更新
|
||||
await this.prisma.poolInjectionBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
orderCount: data.orderCount,
|
||||
totalAmount: data.totalAmount,
|
||||
status: data.status,
|
||||
scheduledInjectionTime: data.scheduledInjectionTime,
|
||||
actualInjectionTime: data.actualInjectionTime,
|
||||
injectionTxHash: data.injectionTxHash,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建
|
||||
const created = await this.prisma.poolInjectionBatch.create({
|
||||
data: {
|
||||
batchNo: data.batchNo,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
orderCount: data.orderCount,
|
||||
totalAmount: data.totalAmount,
|
||||
status: data.status,
|
||||
scheduledInjectionTime: data.scheduledInjectionTime,
|
||||
actualInjectionTime: data.actualInjectionTime,
|
||||
injectionTxHash: data.injectionTxHash,
|
||||
},
|
||||
});
|
||||
|
||||
batch.setId(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(batchId: bigint): Promise<PoolInjectionBatch | null> {
|
||||
const batch = await this.prisma.poolInjectionBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
});
|
||||
|
||||
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
|
||||
}
|
||||
|
||||
async findByBatchNo(batchNo: string): Promise<PoolInjectionBatch | null> {
|
||||
const batch = await this.prisma.poolInjectionBatch.findUnique({
|
||||
where: { batchNo },
|
||||
});
|
||||
|
||||
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
|
||||
}
|
||||
|
||||
async findByStatus(status: BatchStatus): Promise<PoolInjectionBatch[]> {
|
||||
const batches = await this.prisma.poolInjectionBatch.findMany({
|
||||
where: { status },
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
|
||||
return batches.map(PoolInjectionBatchMapper.toDomain);
|
||||
}
|
||||
|
||||
async findCurrentBatch(): Promise<PoolInjectionBatch | null> {
|
||||
const now = new Date();
|
||||
const batch = await this.prisma.poolInjectionBatch.findFirst({
|
||||
where: {
|
||||
status: BatchStatus.PENDING,
|
||||
startDate: { lte: now },
|
||||
endDate: { gte: now },
|
||||
},
|
||||
orderBy: { startDate: 'desc' },
|
||||
});
|
||||
|
||||
return batch ? PoolInjectionBatchMapper.toDomain(batch) : null;
|
||||
}
|
||||
|
||||
async findOrCreateCurrentBatch(): Promise<PoolInjectionBatch> {
|
||||
let batch = await this.findCurrentBatch();
|
||||
|
||||
if (!batch) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
batch = PoolInjectionBatch.create(today);
|
||||
await this.save(batch);
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
async findScheduledBatchesReadyForInjection(): Promise<PoolInjectionBatch[]> {
|
||||
const now = new Date();
|
||||
const batches = await this.prisma.poolInjectionBatch.findMany({
|
||||
where: {
|
||||
status: BatchStatus.SCHEDULED,
|
||||
scheduledInjectionTime: { lte: now },
|
||||
},
|
||||
orderBy: { scheduledInjectionTime: 'asc' },
|
||||
});
|
||||
|
||||
return batches.map(PoolInjectionBatchMapper.toDomain);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 全局前缀
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// CORS 配置
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Swagger API 文档
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Planting Service API')
|
||||
.setDescription('RWA 榴莲女皇平台认种服务 API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.addTag('认种订单', '认种订单相关接口')
|
||||
.addTag('认种持仓', '认种持仓相关接口')
|
||||
.addTag('健康检查', '服务健康检查接口')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.APP_PORT || 3003;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Planting Service is running on port ${port}`);
|
||||
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = '服务器内部错误';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exception.message;
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './global-exception.filter';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './filters';
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { HealthController } from '../src/api/controllers/health.controller';
|
||||
import { PlantingOrderController } from '../src/api/controllers/planting-order.controller';
|
||||
import { PlantingPositionController } from '../src/api/controllers/planting-position.controller';
|
||||
import { PlantingApplicationService } from '../src/application/services/planting-application.service';
|
||||
import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
// Mock for PlantingApplicationService
|
||||
const mockPlantingService = {
|
||||
createOrder: jest.fn(),
|
||||
selectProvinceCity: jest.fn(),
|
||||
confirmProvinceCity: jest.fn(),
|
||||
payOrder: jest.fn(),
|
||||
getUserOrders: jest.fn(),
|
||||
getOrderDetail: jest.fn(),
|
||||
getUserPosition: jest.fn(),
|
||||
cancelOrder: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock JwtAuthGuard that always rejects unauthorized requests
|
||||
const mockJwtAuthGuardReject = {
|
||||
canActivate: jest.fn().mockReturnValue(false),
|
||||
};
|
||||
|
||||
describe('PlantingController (e2e) - Unauthorized', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env.test',
|
||||
}),
|
||||
],
|
||||
controllers: [HealthController, PlantingOrderController, PlantingPositionController],
|
||||
providers: [
|
||||
{
|
||||
provide: PlantingApplicationService,
|
||||
useValue: mockPlantingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(mockJwtAuthGuardReject)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('/health (GET)', () => {
|
||||
it('应该返回健康状态', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/health')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.service).toBe('planting-service');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/health/ready (GET)', () => {
|
||||
it('应该返回就绪状态', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/health/ready')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders (POST)', () => {
|
||||
it('应该拒绝未认证的请求', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 1 })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders (GET)', () => {
|
||||
it('应该拒绝未认证的请求', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/planting/orders')
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/position (GET)', () => {
|
||||
it('应该拒绝未认证的请求', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/planting/position')
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantingController (e2e) - Authorized', () => {
|
||||
let app: INestApplication;
|
||||
const mockUser = { id: '1', username: 'testuser' };
|
||||
|
||||
beforeAll(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
const mockJwtAuthGuardAccept = {
|
||||
canActivate: (context: ExecutionContext) => {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
req.user = mockUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env.test',
|
||||
}),
|
||||
],
|
||||
controllers: [HealthController, PlantingOrderController, PlantingPositionController],
|
||||
providers: [
|
||||
{
|
||||
provide: PlantingApplicationService,
|
||||
useValue: mockPlantingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(mockJwtAuthGuardAccept)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/planting/orders (POST)', () => {
|
||||
it('应该成功创建订单', async () => {
|
||||
const mockOrder = {
|
||||
orderNo: 'PO202411300001',
|
||||
userId: '1',
|
||||
treeCount: 5,
|
||||
totalAmount: 10995,
|
||||
status: 'CREATED',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockPlantingService.createOrder.mockResolvedValue(mockOrder);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 5 })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.orderNo).toBe('PO202411300001');
|
||||
expect(response.body.treeCount).toBe(5);
|
||||
});
|
||||
|
||||
it('应该验证treeCount必须为正整数', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 0 })
|
||||
.expect(400);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: -1 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('应该验证treeCount不能超过最大限制', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders')
|
||||
.send({ treeCount: 1001 })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders (GET)', () => {
|
||||
it('应该返回用户订单列表', async () => {
|
||||
const mockOrders = [
|
||||
{
|
||||
orderNo: 'PO202411300001',
|
||||
treeCount: 5,
|
||||
totalAmount: 10995,
|
||||
status: 'CREATED',
|
||||
},
|
||||
{
|
||||
orderNo: 'PO202411300002',
|
||||
treeCount: 3,
|
||||
totalAmount: 6597,
|
||||
status: 'PAID',
|
||||
},
|
||||
];
|
||||
|
||||
mockPlantingService.getUserOrders.mockResolvedValue(mockOrders);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/planting/orders')
|
||||
.query({ page: 1, pageSize: 10 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders/:orderNo (GET)', () => {
|
||||
it('应该返回订单详情', async () => {
|
||||
const mockOrder = {
|
||||
orderNo: 'PO202411300001',
|
||||
treeCount: 5,
|
||||
totalAmount: 10995,
|
||||
status: 'CREATED',
|
||||
};
|
||||
|
||||
mockPlantingService.getOrderDetail.mockResolvedValue(mockOrder);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/planting/orders/PO202411300001')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.orderNo).toBe('PO202411300001');
|
||||
});
|
||||
|
||||
it('应该返回404当订单不存在', async () => {
|
||||
mockPlantingService.getOrderDetail.mockResolvedValue(null);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/planting/orders/NONEXISTENT')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/position (GET)', () => {
|
||||
it('应该返回用户持仓信息', async () => {
|
||||
const mockPosition = {
|
||||
totalTreeCount: 10,
|
||||
effectiveTreeCount: 8,
|
||||
pendingTreeCount: 2,
|
||||
distributions: [
|
||||
{ provinceCode: '440000', cityCode: '440100', treeCount: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
mockPlantingService.getUserPosition.mockResolvedValue(mockPosition);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/planting/position')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.totalTreeCount).toBe(10);
|
||||
expect(response.body.distributions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders/:orderNo/select-province-city (POST)', () => {
|
||||
it('应该成功选择省市', async () => {
|
||||
mockPlantingService.selectProvinceCity.mockResolvedValue({
|
||||
success: true,
|
||||
message: '省市选择成功',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders/PO202411300001/select-province-city')
|
||||
.send({
|
||||
provinceCode: '440000',
|
||||
provinceName: '广东省',
|
||||
cityCode: '440100',
|
||||
cityName: '广州市',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('应该验证省市参数', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders/PO202411300001/select-province-city')
|
||||
.send({
|
||||
provinceCode: '',
|
||||
provinceName: '广东省',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders/:orderNo/confirm-province-city (POST)', () => {
|
||||
it('应该成功确认省市', async () => {
|
||||
mockPlantingService.confirmProvinceCity.mockResolvedValue({
|
||||
success: true,
|
||||
message: '省市确认成功',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders/PO202411300001/confirm-province-city')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders/:orderNo/pay (POST)', () => {
|
||||
it('应该成功支付订单', async () => {
|
||||
const mockPayResult = {
|
||||
orderNo: 'PO202411300001',
|
||||
status: 'POOL_SCHEDULED',
|
||||
allocations: [
|
||||
{ targetType: 'POOL', amount: 1979.1 },
|
||||
{ targetType: 'OPERATION', amount: 109.95 },
|
||||
],
|
||||
};
|
||||
|
||||
mockPlantingService.payOrder.mockResolvedValue(mockPayResult);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders/PO202411300001/pay')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('POOL_SCHEDULED');
|
||||
expect(response.body.allocations).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/planting/orders/:orderNo/cancel (POST)', () => {
|
||||
it('应该成功取消订单', async () => {
|
||||
mockPlantingService.cancelOrder.mockResolvedValue({
|
||||
success: true,
|
||||
message: '订单取消成功',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/planting/orders/PO202411300001/cancel')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue