feat(reporting-service): Implement complete reporting service with DDD architecture
- Domain layer: ReportDefinition/ReportSnapshot aggregates, value objects (DateRange, ReportPeriod, etc.) - Application layer: CQRS commands/queries, ReportingApplicationService - Infrastructure layer: Prisma repositories, Redis cache, export services (Excel/CSV/PDF) - API layer: REST controllers, DTOs with validation - Testing: Unit tests, integration tests, E2E tests, Docker test environment - Documentation: Architecture, API, Development, Testing, Deployment, Data Model docs - Supports scheduled report generation, multi-format export, and data caching 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ea03df9059
commit
1fe66f34fd
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env*
|
||||
!.env.example
|
||||
*.log
|
||||
.claude/
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3008
|
||||
APP_NAME=reporting-service
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_reporting?schema=public"
|
||||
|
||||
# JWT (与 identity-service 共享密钥)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_ACCESS_EXPIRES_IN=2h
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Kafka
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
KAFKA_GROUP_ID=reporting-service-group
|
||||
KAFKA_CLIENT_ID=reporting-service
|
||||
|
||||
# 外部服务
|
||||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||||
PLANTING_SERVICE_URL=http://localhost:3003
|
||||
REFERRAL_SERVICE_URL=http://localhost:3004
|
||||
REWARD_SERVICE_URL=http://localhost:3005
|
||||
LEADERBOARD_SERVICE_URL=http://localhost:3007
|
||||
WALLET_SERVICE_URL=http://localhost:3002
|
||||
|
||||
# 文件存储
|
||||
FILE_STORAGE_PATH=./storage/reports
|
||||
FILE_STORAGE_URL_PREFIX=http://localhost:3008/files
|
||||
|
||||
# 报表缓存过期时间(秒)
|
||||
REPORT_CACHE_TTL=3600
|
||||
|
||||
# 报表快照保留天数
|
||||
SNAPSHOT_RETENTION_DAYS=90
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Test Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma compatibility
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
# Install dependencies first for caching
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy Prisma schema
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Generate Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Default command runs tests
|
||||
CMD ["npm", "test"]
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
.PHONY: install build test test-unit test-integration test-e2e test-cov test-docker test-docker-all clean lint format db-migrate db-seed help
|
||||
|
||||
# Variables
|
||||
DOCKER_COMPOSE_TEST = docker compose -f docker-compose.test.yml
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make install - Install dependencies"
|
||||
@echo " make build - Build the project"
|
||||
@echo " make test - Run all tests"
|
||||
@echo " make test-unit - Run unit tests only"
|
||||
@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-docker - Run tests in Docker"
|
||||
@echo " make test-docker-all - Run all tests in Docker"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make format - Format code"
|
||||
@echo " make db-start - Start test database"
|
||||
@echo " make db-stop - Stop test database"
|
||||
@echo " make db-migrate - Run database migrations"
|
||||
@echo " make db-seed - Seed database"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
npm ci
|
||||
npx prisma generate
|
||||
|
||||
# Build
|
||||
build:
|
||||
npm run build
|
||||
|
||||
# Lint
|
||||
lint:
|
||||
npm run lint
|
||||
|
||||
# Format
|
||||
format:
|
||||
npm run format
|
||||
|
||||
# Clean
|
||||
clean:
|
||||
rm -rf dist coverage node_modules/.cache
|
||||
$(DOCKER_COMPOSE_TEST) down -v --remove-orphans 2>/dev/null || true
|
||||
|
||||
###############################
|
||||
# Testing
|
||||
###############################
|
||||
|
||||
# Run all tests (unit tests)
|
||||
test:
|
||||
npm test
|
||||
|
||||
# Run unit tests only (domain and application layer)
|
||||
test-unit:
|
||||
npm test -- --testPathPattern="(spec|test)\\.ts$$" --testPathIgnorePatterns="e2e"
|
||||
|
||||
# Run integration tests (requires database)
|
||||
test-integration: db-start db-migrate
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_PORT=6380 \
|
||||
npm test -- --testPathPattern="integration" --runInBand
|
||||
@$(MAKE) db-stop
|
||||
|
||||
# Run e2e tests (requires full stack)
|
||||
test-e2e: db-start db-migrate
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_PORT=6380 \
|
||||
npm run test:e2e -- --runInBand
|
||||
@$(MAKE) db-stop
|
||||
|
||||
# Run tests with coverage
|
||||
test-cov:
|
||||
npm run test:cov
|
||||
|
||||
###############################
|
||||
# Docker Testing
|
||||
###############################
|
||||
|
||||
# Start test infrastructure (postgres + redis)
|
||||
db-start:
|
||||
$(DOCKER_COMPOSE_TEST) up -d postgres-test redis-test
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@sleep 5
|
||||
|
||||
# Stop test infrastructure
|
||||
db-stop:
|
||||
$(DOCKER_COMPOSE_TEST) down -v
|
||||
|
||||
# Run database migrations
|
||||
db-migrate:
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
npx prisma migrate deploy 2>/dev/null || \
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
npx prisma db push --skip-generate
|
||||
|
||||
# Seed database
|
||||
db-seed:
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
npx ts-node prisma/seed.ts
|
||||
|
||||
# Build test Docker image
|
||||
docker-build-test:
|
||||
docker build -f Dockerfile.test -t reporting-service-test .
|
||||
|
||||
# Run unit tests in Docker
|
||||
test-docker: docker-build-test
|
||||
$(DOCKER_COMPOSE_TEST) run --rm reporting-service-test npm run test
|
||||
|
||||
# Run all tests in Docker (unit + integration + e2e)
|
||||
test-docker-all:
|
||||
$(DOCKER_COMPOSE_TEST) up --build --abort-on-container-exit --exit-code-from reporting-service-test
|
||||
$(DOCKER_COMPOSE_TEST) down -v
|
||||
|
||||
# Run tests with coverage in Docker
|
||||
test-docker-cov:
|
||||
$(DOCKER_COMPOSE_TEST) run --rm reporting-service-test npm run test:cov
|
||||
$(DOCKER_COMPOSE_TEST) down -v
|
||||
|
||||
###############################
|
||||
# CI/CD Helpers
|
||||
###############################
|
||||
|
||||
# CI test command
|
||||
ci-test: install test-cov lint
|
||||
|
||||
# Full test suite
|
||||
full-test: clean install lint test-unit test-docker-all
|
||||
@echo "All tests passed!"
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:15-alpine
|
||||
container_name: reporting-postgres-test
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_reporting_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
redis-test:
|
||||
image: redis:7-alpine
|
||||
container_name: reporting-redis-test
|
||||
ports:
|
||||
- "6380:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
reporting-service-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
container_name: reporting-service-test
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
redis-test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/rwadurian_reporting_test?schema=public
|
||||
REDIS_HOST: redis-test
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: test-secret-key
|
||||
volumes:
|
||||
- ./coverage:/app/coverage
|
||||
command: sh -c "npx prisma db push --skip-generate && npm run test:cov"
|
||||
|
|
@ -0,0 +1,506 @@
|
|||
# API 参考文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Reporting Service 提供 RESTful API,用于报表的生成、查询和导出。
|
||||
|
||||
**Base URL**: `/api/v1`
|
||||
|
||||
**响应格式**: 所有响应均使用统一的 JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应格式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Validation failed",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 健康检查 API
|
||||
|
||||
### 2.1 健康检查
|
||||
|
||||
**Endpoint**: `GET /health`
|
||||
|
||||
检查服务是否运行正常。
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"service": "reporting-service",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 就绪检查
|
||||
|
||||
**Endpoint**: `GET /health/ready`
|
||||
|
||||
检查服务是否准备好处理请求(包括数据库连接等)。
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "ready",
|
||||
"service": "reporting-service",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 报表定义 API
|
||||
|
||||
### 3.1 获取所有报表定义
|
||||
|
||||
**Endpoint**: `GET /reports/definitions`
|
||||
|
||||
获取所有可用的报表定义列表。
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportName": "排行榜报表",
|
||||
"reportType": "LEADERBOARD_REPORT",
|
||||
"description": "用户排行榜数据报表",
|
||||
"parameters": {
|
||||
"required": ["startDate", "endDate"],
|
||||
"optional": ["limit", "offset"]
|
||||
},
|
||||
"isActive": true,
|
||||
"scheduleCron": "0 0 1 * * *",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 获取单个报表定义
|
||||
|
||||
**Endpoint**: `GET /reports/definitions/:code`
|
||||
|
||||
根据报表代码获取报表定义详情。
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| code | string | 报表代码 (如 `RPT_LEADERBOARD`) |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "1",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportName": "排行榜报表",
|
||||
"reportType": "LEADERBOARD_REPORT",
|
||||
"description": "用户排行榜数据报表",
|
||||
"parameters": {
|
||||
"required": ["startDate", "endDate"],
|
||||
"optional": ["limit", "offset"]
|
||||
},
|
||||
"isActive": true,
|
||||
"scheduleCron": "0 0 1 * * *",
|
||||
"lastGeneratedAt": "2024-01-14T01:00:00.000Z",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2024-01-14T01:00:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (404):
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 404,
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Report definition not found: RPT_INVALID",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 报表生成 API
|
||||
|
||||
### 4.1 生成报表
|
||||
|
||||
**Endpoint**: `POST /reports/generate`
|
||||
|
||||
生成新的报表快照。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportPeriod": "DAILY",
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": "2024-01-01",
|
||||
"filterParams": {
|
||||
"limit": 100,
|
||||
"category": "all"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| reportCode | string | 是 | 报表代码 |
|
||||
| reportPeriod | enum | 是 | 报表周期: `DAILY`, `WEEKLY`, `MONTHLY`, `QUARTERLY`, `YEARLY`, `CUSTOM` |
|
||||
| startDate | string | 是 | 开始日期 (ISO 8601 格式) |
|
||||
| endDate | string | 是 | 结束日期 (ISO 8601 格式) |
|
||||
| filterParams | object | 否 | 额外筛选参数 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "12345",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportType": "LEADERBOARD_REPORT",
|
||||
"reportPeriod": "DAILY",
|
||||
"periodKey": "2024-01-01",
|
||||
"rowCount": 100,
|
||||
"status": "COMPLETED",
|
||||
"generatedAt": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**验证错误** (400):
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "reportPeriod must be one of the following values: DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY, CUSTOM",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 报表快照 API
|
||||
|
||||
### 5.1 查询报表快照列表
|
||||
|
||||
**Endpoint**: `GET /reports/snapshots`
|
||||
|
||||
查询报表快照列表,支持分页和筛选。
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| reportCode | string | 否 | 按报表代码筛选 |
|
||||
| reportPeriod | enum | 否 | 按报表周期筛选 |
|
||||
| startDate | string | 否 | 开始日期范围 |
|
||||
| endDate | string | 否 | 结束日期范围 |
|
||||
| page | number | 否 | 页码 (默认: 1) |
|
||||
| limit | number | 否 | 每页数量 (默认: 20, 最大: 100) |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```
|
||||
GET /reports/snapshots?reportCode=RPT_LEADERBOARD&reportPeriod=DAILY&page=1&limit=10
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "12345",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportType": "LEADERBOARD_REPORT",
|
||||
"reportPeriod": "DAILY",
|
||||
"periodKey": "2024-01-15",
|
||||
"rowCount": 100,
|
||||
"periodStartAt": "2024-01-15T00:00:00.000Z",
|
||||
"periodEndAt": "2024-01-15T23:59:59.999Z",
|
||||
"generatedAt": "2024-01-16T01:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 获取快照详情
|
||||
|
||||
**Endpoint**: `GET /reports/snapshots/:id`
|
||||
|
||||
根据快照ID获取详细数据。
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| id | string | 快照ID |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "12345",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportType": "LEADERBOARD_REPORT",
|
||||
"reportPeriod": "DAILY",
|
||||
"periodKey": "2024-01-15",
|
||||
"snapshotData": {
|
||||
"rows": [
|
||||
{ "rank": 1, "userId": 1001, "username": "user1", "score": 9850 },
|
||||
{ "rank": 2, "userId": 1002, "username": "user2", "score": 9720 }
|
||||
],
|
||||
"summary": {
|
||||
"totalEntries": 100,
|
||||
"averageScore": 5432,
|
||||
"maxScore": 9850
|
||||
}
|
||||
},
|
||||
"summaryData": {
|
||||
"totalEntries": 100,
|
||||
"generationTime": "2.5s"
|
||||
},
|
||||
"rowCount": 100,
|
||||
"dataSource": ["leaderboard-service"],
|
||||
"periodStartAt": "2024-01-15T00:00:00.000Z",
|
||||
"periodEndAt": "2024-01-15T23:59:59.999Z",
|
||||
"generatedAt": "2024-01-16T01:00:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 获取最新快照
|
||||
|
||||
**Endpoint**: `GET /reports/snapshots/:code/latest`
|
||||
|
||||
获取指定报表代码的最新快照。
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| code | string | 报表代码 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "12345",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"reportPeriod": "DAILY",
|
||||
"periodKey": "2024-01-15",
|
||||
"rowCount": 100,
|
||||
"generatedAt": "2024-01-16T01:00:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (404):
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 404,
|
||||
"code": "NOT_FOUND",
|
||||
"message": "No snapshot found for report: RPT_INVALID",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 导出 API
|
||||
|
||||
### 6.1 导出报表
|
||||
|
||||
**Endpoint**: `POST /export`
|
||||
|
||||
将报表快照导出为指定格式。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshotId": "12345",
|
||||
"format": "EXCEL",
|
||||
"options": {
|
||||
"includeCharts": true,
|
||||
"sheetName": "排行榜数据"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| snapshotId | string | 是 | 快照ID |
|
||||
| format | enum | 是 | 导出格式: `EXCEL`, `CSV`, `PDF`, `JSON` |
|
||||
| options | object | 否 | 导出选项 |
|
||||
|
||||
**导出选项 (按格式)**:
|
||||
|
||||
**Excel 选项**:
|
||||
| 选项 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| includeCharts | boolean | 是否包含图表 |
|
||||
| sheetName | string | 工作表名称 |
|
||||
|
||||
**PDF 选项**:
|
||||
| 选项 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| title | string | PDF标题 |
|
||||
| orientation | string | 方向: `portrait`, `landscape` |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"fileId": "file_67890",
|
||||
"fileName": "RPT_LEADERBOARD_2024-01-15.xlsx",
|
||||
"format": "EXCEL",
|
||||
"fileSize": 102400,
|
||||
"downloadUrl": "/api/v1/files/file_67890/download",
|
||||
"expiresAt": "2024-01-22T10:30:00.000Z",
|
||||
"createdAt": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 获取导出文件列表
|
||||
|
||||
**Endpoint**: `GET /export/files`
|
||||
|
||||
查询已导出的文件列表。
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| snapshotId | string | 否 | 按快照ID筛选 |
|
||||
| format | enum | 否 | 按格式筛选 |
|
||||
| page | number | 否 | 页码 |
|
||||
| limit | number | 否 | 每页数量 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"fileId": "file_67890",
|
||||
"fileName": "RPT_LEADERBOARD_2024-01-15.xlsx",
|
||||
"format": "EXCEL",
|
||||
"fileSize": 102400,
|
||||
"snapshotId": "12345",
|
||||
"createdAt": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 下载文件
|
||||
|
||||
**Endpoint**: `GET /files/:fileId/download`
|
||||
|
||||
下载导出的文件。
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| fileId | string | 文件ID |
|
||||
|
||||
**响应**: 文件二进制流
|
||||
|
||||
**响应头**:
|
||||
```
|
||||
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
Content-Disposition: attachment; filename="RPT_LEADERBOARD_2024-01-15.xlsx"
|
||||
```
|
||||
|
||||
## 7. 错误码参考
|
||||
|
||||
| HTTP状态码 | 错误码 | 描述 |
|
||||
|------------|--------|------|
|
||||
| 400 | BAD_REQUEST | 请求参数验证失败 |
|
||||
| 401 | UNAUTHORIZED | 未认证 |
|
||||
| 403 | FORBIDDEN | 无权限访问 |
|
||||
| 404 | NOT_FOUND | 资源不存在 |
|
||||
| 409 | CONFLICT | 资源冲突 (如重复生成) |
|
||||
| 422 | UNPROCESSABLE_ENTITY | 业务逻辑错误 |
|
||||
| 500 | INTERNAL_ERROR | 服务器内部错误 |
|
||||
| 503 | SERVICE_UNAVAILABLE | 服务不可用 |
|
||||
|
||||
## 8. 认证
|
||||
|
||||
API 支持 JWT Bearer Token 认证(可配置)。
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**未认证响应** (401):
|
||||
```json
|
||||
{
|
||||
"statusCode": 401,
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Unauthorized",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 限流
|
||||
|
||||
| 端点类型 | 限制 |
|
||||
|----------|------|
|
||||
| 读取操作 | 100 次/分钟 |
|
||||
| 生成报表 | 10 次/分钟 |
|
||||
| 导出操作 | 20 次/分钟 |
|
||||
|
||||
超出限制返回 HTTP 429 Too Many Requests。
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
# 架构设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Reporting Service 采用 **领域驱动设计 (DDD)** 结合 **六边形架构 (Hexagonal Architecture)** 模式构建,实现业务逻辑与技术实现的解耦。
|
||||
|
||||
## 2. 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (端口) │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ ReportController│ │ ExportController│ │ HealthController │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └─────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Application Layer ││
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ││
|
||||
│ │ │ Commands/Queries │ │ Schedulers │ │ Application Services │ ││
|
||||
│ │ │ - GenerateReport │ │ - CronJobs │ │ - ReportingAppService │ ││
|
||||
│ │ │ - ExportReport │ │ - Periodic Tasks │ │ │ ││
|
||||
│ │ └────────┬─────────┘ └──────────────────┘ └────────────────────────┘ ││
|
||||
│ └───────────┼──────────────────────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Domain Layer (核心) ││
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ││
|
||||
│ │ │ Aggregates │ │ Entities │ │ Value Objects │ ││
|
||||
│ │ │ - ReportDefinit. │ │ - ReportFile │ │ - DateRange │ ││
|
||||
│ │ │ - ReportSnapshot │ │ - AnalyticsMetric│ │ - ReportPeriod │ ││
|
||||
│ │ └──────────────────┘ └──────────────────┘ │ - SnapshotData │ ││
|
||||
│ │ └────────────────────────┘ ││
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ││
|
||||
│ │ │ Domain Events │ │ Domain Services │ │ Repository Ports │ ││
|
||||
│ │ │ - SnapshotCreated│ │ - ReportGenSvc │ │ (Interfaces Only) │ ││
|
||||
│ │ │ - ReportExported │ │ │ │ │ ││
|
||||
│ │ └──────────────────┘ └──────────────────┘ └────────────────────────┘ ││
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘│
|
||||
│ ▲ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Infrastructure Layer (适配器) ││
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ││
|
||||
│ │ │ Persistence │ │ External APIs │ │ Export Services │ ││
|
||||
│ │ │ - Prisma/PG │ │ - LeaderboardSvc │ │ - ExcelExport │ ││
|
||||
│ │ │ - Repositories │ │ - PlantingSvc │ │ - CSVExport │ ││
|
||||
│ │ │ - Mappers │ │ │ │ - PDFExport │ ││
|
||||
│ │ └──────────────────┘ └──────────────────┘ └────────────────────────┘ ││
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ││
|
||||
│ │ │ Redis Cache │ │ Kafka │ ││
|
||||
│ │ │ - ReportCache │ │ - EventPublisher │ ││
|
||||
│ │ └──────────────────┘ └──────────────────┘ ││
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API层 - 入站端口
|
||||
│ ├── controllers/ # HTTP控制器
|
||||
│ │ ├── report.controller.ts # 报表API
|
||||
│ │ ├── export.controller.ts # 导出API
|
||||
│ │ └── health.controller.ts # 健康检查
|
||||
│ └── dto/ # 数据传输对象
|
||||
│ ├── request/ # 请求DTO
|
||||
│ └── response/ # 响应DTO
|
||||
│
|
||||
├── application/ # 应用层 - 用例编排
|
||||
│ ├── commands/ # 命令处理器
|
||||
│ │ ├── generate-report/ # 生成报表命令
|
||||
│ │ └── export-report/ # 导出报表命令
|
||||
│ ├── queries/ # 查询处理器
|
||||
│ │ └── get-report-snapshot/ # 获取快照查询
|
||||
│ ├── schedulers/ # 定时任务
|
||||
│ │ └── report-generation.scheduler.ts
|
||||
│ └── services/ # 应用服务
|
||||
│ └── reporting-application.service.ts
|
||||
│
|
||||
├── domain/ # 领域层 - 业务核心
|
||||
│ ├── aggregates/ # 聚合根
|
||||
│ │ ├── report-definition/ # 报表定义聚合
|
||||
│ │ └── report-snapshot/ # 报表快照聚合
|
||||
│ ├── entities/ # 实体
|
||||
│ │ ├── report-file.entity.ts
|
||||
│ │ └── analytics-metric.entity.ts
|
||||
│ ├── value-objects/ # 值对象
|
||||
│ │ ├── date-range.vo.ts
|
||||
│ │ ├── report-period.enum.ts
|
||||
│ │ ├── report-type.enum.ts
|
||||
│ │ └── snapshot-data.vo.ts
|
||||
│ ├── events/ # 领域事件
|
||||
│ │ ├── snapshot-created.event.ts
|
||||
│ │ └── report-exported.event.ts
|
||||
│ ├── repositories/ # 仓储接口
|
||||
│ │ ├── report-definition.repository.interface.ts
|
||||
│ │ └── report-snapshot.repository.interface.ts
|
||||
│ └── services/ # 领域服务
|
||||
│ └── report-generation.service.ts
|
||||
│
|
||||
├── infrastructure/ # 基础设施层 - 出站适配器
|
||||
│ ├── persistence/ # 持久化
|
||||
│ │ ├── prisma/ # Prisma配置
|
||||
│ │ ├── repositories/ # 仓储实现
|
||||
│ │ └── mappers/ # 对象映射器
|
||||
│ ├── external/ # 外部服务客户端
|
||||
│ │ ├── leaderboard-service/
|
||||
│ │ └── planting-service/
|
||||
│ ├── export/ # 导出服务实现
|
||||
│ │ ├── excel-export.service.ts
|
||||
│ │ ├── csv-export.service.ts
|
||||
│ │ └── pdf-export.service.ts
|
||||
│ └── redis/ # Redis缓存
|
||||
│ └── report-cache.service.ts
|
||||
│
|
||||
├── shared/ # 共享模块
|
||||
│ ├── decorators/ # 自定义装饰器
|
||||
│ ├── filters/ # 异常过滤器
|
||||
│ ├── guards/ # 守卫
|
||||
│ ├── interceptors/ # 拦截器
|
||||
│ └── strategies/ # Passport策略
|
||||
│
|
||||
└── config/ # 配置
|
||||
├── app.config.ts
|
||||
├── database.config.ts
|
||||
└── redis.config.ts
|
||||
```
|
||||
|
||||
## 4. 核心设计原则
|
||||
|
||||
### 4.1 依赖倒置原则 (DIP)
|
||||
|
||||
```typescript
|
||||
// 领域层定义接口 (端口)
|
||||
export interface IReportSnapshotRepository {
|
||||
save(snapshot: ReportSnapshot): Promise<ReportSnapshot>;
|
||||
findById(id: bigint): Promise<ReportSnapshot | null>;
|
||||
findByCodeAndPeriodKey(code: string, periodKey: string): Promise<ReportSnapshot | null>;
|
||||
}
|
||||
|
||||
// 基础设施层实现接口 (适配器)
|
||||
@Injectable()
|
||||
export class ReportSnapshotRepository implements IReportSnapshotRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(snapshot: ReportSnapshot): Promise<ReportSnapshot> {
|
||||
// Prisma实现细节
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 聚合根设计
|
||||
|
||||
```typescript
|
||||
// ReportSnapshot 聚合根
|
||||
export class ReportSnapshot extends AggregateRoot {
|
||||
private readonly _id: bigint;
|
||||
private _reportType: ReportType;
|
||||
private _reportCode: string;
|
||||
private _reportPeriod: ReportPeriod;
|
||||
private _snapshotData: SnapshotData;
|
||||
private _events: DomainEvent[] = [];
|
||||
|
||||
// 工厂方法 - 创建新快照
|
||||
public static create(props: CreateSnapshotProps): ReportSnapshot {
|
||||
const snapshot = new ReportSnapshot(props);
|
||||
snapshot.addDomainEvent(new SnapshotCreatedEvent(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// 从持久化重建 - 不触发事件
|
||||
public static reconstitute(props: ReconstitutionProps): ReportSnapshot {
|
||||
return new ReportSnapshot(props);
|
||||
}
|
||||
|
||||
// 业务行为
|
||||
public updateData(newData: SnapshotData): void {
|
||||
this.validateDataUpdate(newData);
|
||||
this._snapshotData = newData;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 值对象不可变性
|
||||
|
||||
```typescript
|
||||
// DateRange 值对象
|
||||
export class DateRange {
|
||||
private readonly _startDate: Date;
|
||||
private readonly _endDate: Date;
|
||||
|
||||
private constructor(startDate: Date, endDate: Date) {
|
||||
this._startDate = startDate;
|
||||
this._endDate = endDate;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
public static create(startDate: Date, endDate: Date): DateRange {
|
||||
if (startDate > endDate) {
|
||||
throw new DomainException('Start date must be before end date');
|
||||
}
|
||||
return new DateRange(startDate, endDate);
|
||||
}
|
||||
|
||||
public equals(other: DateRange): boolean {
|
||||
return this._startDate.getTime() === other._startDate.getTime() &&
|
||||
this._endDate.getTime() === other._endDate.getTime();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据流
|
||||
|
||||
### 5.1 报表生成流程
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||
│ Client │───▶│ ReportCtrl │───▶│ GenerateHandler │───▶│ DomainSvc │
|
||||
└─────────┘ └──────────────┘ └─────────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌─────────────────────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ External Services │───▶│ ReportSnapshot │───▶│ Repository │
|
||||
│ (Leaderboard/Plant.) │ │ (Aggregate) │ │ (Save to DB) │
|
||||
└──────────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 报表导出流程
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Client │───▶│ ExportCtrl │───▶│ ExportHandler │
|
||||
└─────────┘ └──────────────┘ └────────┬────────┘
|
||||
│
|
||||
┌───────────────────────────────────┴───────────────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ ExcelExportSvc │ │ CSVExportSvc │ │ PDFExportSvc │
|
||||
│ (ExcelJS) │ │ (csv-stringify) │ │ (PDFKit) │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┴────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ ReportFile Entity │
|
||||
│ (Save to Storage) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## 6. 关键组件
|
||||
|
||||
### 6.1 报表类型 (ReportType)
|
||||
|
||||
| 类型 | 描述 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| LEADERBOARD_REPORT | 排行榜报表 | Leaderboard Service |
|
||||
| PLANTING_REPORT | 种植报表 | Planting Service |
|
||||
| COMMUNITY_REPORT | 社区报表 | Community Service |
|
||||
| SYSTEM_ACCOUNT_REPORT | 系统账户报表 | Account Service |
|
||||
| ANALYTICS_DASHBOARD | 分析仪表板 | 多数据源聚合 |
|
||||
|
||||
### 6.2 报表周期 (ReportPeriod)
|
||||
|
||||
| 周期 | 描述 | 用途 |
|
||||
|------|------|------|
|
||||
| DAILY | 日报 | 每日运营数据 |
|
||||
| WEEKLY | 周报 | 周度趋势分析 |
|
||||
| MONTHLY | 月报 | 月度业绩汇总 |
|
||||
| QUARTERLY | 季报 | 季度财务报表 |
|
||||
| YEARLY | 年报 | 年度总结 |
|
||||
| CUSTOM | 自定义 | 灵活时间范围 |
|
||||
|
||||
### 6.3 导出格式 (OutputFormat)
|
||||
|
||||
| 格式 | 实现库 | 特点 |
|
||||
|------|--------|------|
|
||||
| EXCEL | ExcelJS | 支持样式、图表、多Sheet |
|
||||
| CSV | csv-stringify | 轻量、通用 |
|
||||
| PDF | PDFKit | 适合打印、分发 |
|
||||
| JSON | 内置 | API集成、数据交换 |
|
||||
|
||||
## 7. 扩展点
|
||||
|
||||
### 7.1 添加新报表类型
|
||||
|
||||
1. 在 `ReportType` 枚举中添加新类型
|
||||
2. 创建对应的外部服务客户端 (如需要)
|
||||
3. 在 `GenerateReportHandler` 中添加数据获取逻辑
|
||||
4. 更新 `ReportDefinition` 种子数据
|
||||
|
||||
### 7.2 添加新导出格式
|
||||
|
||||
1. 在 `OutputFormat` 枚举中添加新格式
|
||||
2. 创建新的导出服务 (实现 `IExportService` 接口)
|
||||
3. 在 `ExportReportHandler` 中注册新服务
|
||||
|
||||
### 7.3 集成新数据源
|
||||
|
||||
1. 在 `infrastructure/external/` 下创建服务客户端
|
||||
2. 定义数据传输接口
|
||||
3. 在应用层注入并使用
|
||||
|
||||
## 8. 安全考虑
|
||||
|
||||
- **认证**: JWT Bearer Token (可配置)
|
||||
- **授权**: 基于角色的访问控制 (RBAC)
|
||||
- **数据脱敏**: 敏感数据在导出时进行脱敏处理
|
||||
- **审计日志**: 所有报表操作记录到审计表
|
||||
|
|
@ -0,0 +1,680 @@
|
|||
# 数据模型文档
|
||||
|
||||
## 1. 数据模型概述
|
||||
|
||||
### 1.1 实体关系图 (ER Diagram)
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ ReportDefinition │
|
||||
│ (报表定义) │
|
||||
│ ───────────────── │
|
||||
│ definition_id PK │
|
||||
│ report_code UK │
|
||||
│ report_type │
|
||||
│ report_name │
|
||||
│ parameters JSON │
|
||||
│ schedule_cron │
|
||||
│ output_formats[] │
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ ReportSnapshot │ 1───n │ ReportFile │
|
||||
│ (报表快照) │ │ (报表文件) │
|
||||
│ ───────────────── │ │ ───────────────── │
|
||||
│ snapshot_id PK │ │ file_id PK │
|
||||
│ report_code │ │ snapshot_id FK │
|
||||
│ report_period │ │ file_name │
|
||||
│ period_key UK(c) │ │ file_path │
|
||||
│ snapshot_data JSON │ │ file_format │
|
||||
│ summary_data JSON │ │ file_size │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ AnalyticsMetric │ │ PlantingDailyStat │
|
||||
│ (分析指标) │ │ (认种日统计) │
|
||||
│ ───────────────── │ │ ───────────────── │
|
||||
│ metric_id PK │ │ stat_id PK │
|
||||
│ metric_type │ │ stat_date │
|
||||
│ metric_code │ │ province_code │
|
||||
│ dimension_time │ │ city_code │
|
||||
│ metric_value DEC │ │ order_count │
|
||||
│ metric_data JSON │ │ total_amount DEC │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ CommunityStat │ │ SystemAccountMonthlyStat │
|
||||
│ (社区统计) │ │ (系统账户月度统计) │
|
||||
│ ───────────────── │ │ ───────────────────── │
|
||||
│ stat_id PK │ │ stat_id PK │
|
||||
│ community_id │ │ account_id │
|
||||
│ community_name │ │ account_type │
|
||||
│ stat_date │ │ stat_month │
|
||||
│ total_planting │ │ monthly_hashpower DEC │
|
||||
│ member_count │ │ cumulative_mining DEC │
|
||||
└─────────────────────┘ └──────────────────────────┘
|
||||
|
||||
┌──────────────────────────┐ ┌─────────────────────┐
|
||||
│ SystemAccountIncomeRecord│ │ ReportEvent │
|
||||
│ (系统账户收益流水) │ │ (报表事件) │
|
||||
│ ───────────────────── │ │ ───────────────── │
|
||||
│ record_id PK │ │ event_id PK │
|
||||
│ account_id │ │ event_type │
|
||||
│ income_type │ │ aggregate_id │
|
||||
│ income_amount DEC │ │ aggregate_type │
|
||||
│ source_type │ │ event_data JSON │
|
||||
│ occurred_at │ │ occurred_at │
|
||||
└──────────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 聚合根划分
|
||||
|
||||
| 聚合根 | 表 | 说明 |
|
||||
|--------|-----|------|
|
||||
| ReportDefinition | report_definitions | 报表定义,包含配置和调度规则 |
|
||||
| ReportSnapshot | report_snapshots, report_files | 报表快照,包含生成的文件 |
|
||||
| AnalyticsMetric | analytics_metrics | 独立的分析指标聚合 |
|
||||
|
||||
## 2. 核心表结构
|
||||
|
||||
### 2.1 ReportDefinition (报表定义表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_definitions (
|
||||
definition_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 报表基本信息
|
||||
report_type VARCHAR(50) NOT NULL,
|
||||
report_name VARCHAR(200) NOT NULL,
|
||||
report_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
|
||||
-- 报表参数 (JSON)
|
||||
parameters JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- 调度配置
|
||||
schedule_cron VARCHAR(100),
|
||||
schedule_timezone VARCHAR(50) DEFAULT 'Asia/Shanghai',
|
||||
schedule_enabled BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- 输出格式
|
||||
output_formats VARCHAR(20)[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_generated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_def_type ON report_definitions(report_type);
|
||||
CREATE INDEX idx_def_active ON report_definitions(is_active);
|
||||
CREATE INDEX idx_def_scheduled ON report_definitions(schedule_enabled);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| definition_id | BIGSERIAL | 主键,自增ID |
|
||||
| report_type | VARCHAR(50) | 报表类型: LEADERBOARD, PLANTING, ANALYTICS 等 |
|
||||
| report_name | VARCHAR(200) | 报表显示名称 |
|
||||
| report_code | VARCHAR(50) | 报表唯一编码,用于API调用 |
|
||||
| description | TEXT | 报表描述说明 |
|
||||
| parameters | JSONB | 报表参数配置 |
|
||||
| schedule_cron | VARCHAR(100) | Cron 表达式,定时生成 |
|
||||
| schedule_timezone | VARCHAR(50) | 调度时区 |
|
||||
| schedule_enabled | BOOLEAN | 是否启用定时生成 |
|
||||
| output_formats | VARCHAR(20)[] | 支持的输出格式数组 |
|
||||
| is_active | BOOLEAN | 是否激活 |
|
||||
| last_generated_at | TIMESTAMP | 最后生成时间 |
|
||||
|
||||
**parameters JSON 结构示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"dataSource": "leaderboard_rankings",
|
||||
"aggregation": "daily",
|
||||
"filters": {
|
||||
"regionLevel": "province",
|
||||
"minScore": 100
|
||||
},
|
||||
"columns": [
|
||||
{ "field": "rank", "label": "排名" },
|
||||
{ "field": "userName", "label": "用户名" },
|
||||
{ "field": "score", "label": "积分" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 ReportSnapshot (报表快照表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_snapshots (
|
||||
snapshot_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 报表信息
|
||||
report_type VARCHAR(50) NOT NULL,
|
||||
report_code VARCHAR(50) NOT NULL,
|
||||
report_period VARCHAR(20) NOT NULL,
|
||||
period_key VARCHAR(30) NOT NULL,
|
||||
|
||||
-- 快照数据
|
||||
snapshot_data JSONB NOT NULL,
|
||||
summary_data JSONB,
|
||||
|
||||
-- 数据来源
|
||||
data_sources VARCHAR(100)[] DEFAULT '{}',
|
||||
data_freshness INTEGER DEFAULT 0,
|
||||
|
||||
-- 过滤条件
|
||||
filter_params JSONB,
|
||||
|
||||
-- 统计信息
|
||||
row_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间范围
|
||||
period_start_at TIMESTAMP NOT NULL,
|
||||
period_end_at TIMESTAMP NOT NULL,
|
||||
|
||||
-- 时间戳
|
||||
generated_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP,
|
||||
|
||||
UNIQUE(report_code, period_key)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_snapshot_type ON report_snapshots(report_type);
|
||||
CREATE INDEX idx_snapshot_code ON report_snapshots(report_code);
|
||||
CREATE INDEX idx_snapshot_period ON report_snapshots(period_key);
|
||||
CREATE INDEX idx_snapshot_generated ON report_snapshots(generated_at DESC);
|
||||
CREATE INDEX idx_snapshot_expires ON report_snapshots(expires_at);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| snapshot_id | BIGSERIAL | 主键 |
|
||||
| report_type | VARCHAR(50) | 报表类型 |
|
||||
| report_code | VARCHAR(50) | 报表编码 |
|
||||
| report_period | VARCHAR(20) | 报表周期: DAILY, WEEKLY, MONTHLY, YEARLY |
|
||||
| period_key | VARCHAR(30) | 周期标识: 2024-01-15, 2024-W03, 2024-01 |
|
||||
| snapshot_data | JSONB | 报表数据内容 |
|
||||
| summary_data | JSONB | 汇总数据 |
|
||||
| data_sources | VARCHAR(100)[] | 数据来源服务列表 |
|
||||
| data_freshness | INTEGER | 数据新鲜度(秒) |
|
||||
| filter_params | JSONB | 应用的过滤参数 |
|
||||
| row_count | INTEGER | 数据行数 |
|
||||
| period_start_at | TIMESTAMP | 周期开始时间 |
|
||||
| period_end_at | TIMESTAMP | 周期结束时间 |
|
||||
| generated_at | TIMESTAMP | 生成时间 |
|
||||
| expires_at | TIMESTAMP | 过期时间 |
|
||||
|
||||
**snapshot_data JSON 结构示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"rank": 1,
|
||||
"userId": "12345",
|
||||
"userName": "张三",
|
||||
"score": 9850,
|
||||
"treeCount": 100,
|
||||
"region": "广东省"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"totalRecords": 1000,
|
||||
"generatedAt": "2024-01-15T08:00:00Z",
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 ReportFile (报表文件表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_files (
|
||||
file_id BIGSERIAL PRIMARY KEY,
|
||||
snapshot_id BIGINT NOT NULL REFERENCES report_snapshots(snapshot_id) ON DELETE CASCADE,
|
||||
|
||||
-- 文件信息
|
||||
file_name VARCHAR(500) NOT NULL,
|
||||
file_path VARCHAR(1000) NOT NULL,
|
||||
file_url VARCHAR(1000),
|
||||
file_size BIGINT NOT NULL,
|
||||
file_format VARCHAR(20) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
|
||||
-- 访问信息
|
||||
download_count INTEGER DEFAULT 0,
|
||||
last_download_at TIMESTAMP,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_file_snapshot ON report_files(snapshot_id);
|
||||
CREATE INDEX idx_file_format ON report_files(file_format);
|
||||
CREATE INDEX idx_file_created ON report_files(created_at DESC);
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| file_id | BIGSERIAL | 主键 |
|
||||
| snapshot_id | BIGINT | 关联的快照ID |
|
||||
| file_name | VARCHAR(500) | 文件名 |
|
||||
| file_path | VARCHAR(1000) | 文件存储路径 |
|
||||
| file_url | VARCHAR(1000) | 文件下载URL |
|
||||
| file_size | BIGINT | 文件大小(字节) |
|
||||
| file_format | VARCHAR(20) | 文件格式: EXCEL, CSV, PDF |
|
||||
| mime_type | VARCHAR(100) | MIME类型 |
|
||||
| download_count | INTEGER | 下载次数 |
|
||||
| last_download_at | TIMESTAMP | 最后下载时间 |
|
||||
|
||||
### 2.4 AnalyticsMetric (分析指标表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE analytics_metrics (
|
||||
metric_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 指标信息
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
metric_code VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 维度
|
||||
dimension_time DATE,
|
||||
dimension_region VARCHAR(100),
|
||||
dimension_user_type VARCHAR(50),
|
||||
dimension_right_type VARCHAR(50),
|
||||
|
||||
-- 指标值
|
||||
metric_value DECIMAL(20, 8) NOT NULL,
|
||||
metric_data JSONB,
|
||||
|
||||
-- 时间戳
|
||||
calculated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(metric_code, dimension_time, dimension_region, dimension_user_type, dimension_right_type)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_metric_type ON analytics_metrics(metric_type);
|
||||
CREATE INDEX idx_metric_code ON analytics_metrics(metric_code);
|
||||
CREATE INDEX idx_metric_time ON analytics_metrics(dimension_time);
|
||||
CREATE INDEX idx_metric_region ON analytics_metrics(dimension_region);
|
||||
```
|
||||
|
||||
**指标类型 (metric_type):**
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| PLANTING_COUNT | 认种数量 |
|
||||
| PLANTING_AMOUNT | 认种金额 |
|
||||
| USER_ACTIVE | 活跃用户数 |
|
||||
| REVENUE_TOTAL | 总收益 |
|
||||
| HASHPOWER_DAILY | 日算力 |
|
||||
|
||||
## 3. 统计表结构
|
||||
|
||||
### 3.1 PlantingDailyStat (认种日统计表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE planting_daily_stats (
|
||||
stat_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 统计日期
|
||||
stat_date DATE NOT NULL,
|
||||
|
||||
-- 区域维度
|
||||
province_code VARCHAR(10),
|
||||
city_code VARCHAR(10),
|
||||
|
||||
-- 统计数据
|
||||
order_count INTEGER DEFAULT 0,
|
||||
tree_count INTEGER DEFAULT 0,
|
||||
total_amount DECIMAL(20, 8) DEFAULT 0,
|
||||
new_user_count INTEGER DEFAULT 0,
|
||||
active_user_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(stat_date, province_code, city_code)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_pds_date ON planting_daily_stats(stat_date);
|
||||
CREATE INDEX idx_pds_province ON planting_daily_stats(province_code);
|
||||
CREATE INDEX idx_pds_city ON planting_daily_stats(city_code);
|
||||
```
|
||||
|
||||
### 3.2 CommunityStat (社区统计表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE community_stats (
|
||||
stat_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 社区信息
|
||||
community_id BIGINT NOT NULL,
|
||||
community_name VARCHAR(200) NOT NULL,
|
||||
parent_community_id BIGINT,
|
||||
|
||||
-- 统计日期
|
||||
stat_date DATE NOT NULL,
|
||||
|
||||
-- 统计数据
|
||||
total_planting INTEGER DEFAULT 0,
|
||||
daily_planting INTEGER DEFAULT 0,
|
||||
weekly_planting INTEGER DEFAULT 0,
|
||||
monthly_planting INTEGER DEFAULT 0,
|
||||
member_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(community_id, stat_date)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_cs_community ON community_stats(community_id);
|
||||
CREATE INDEX idx_cs_name ON community_stats(community_name);
|
||||
CREATE INDEX idx_cs_date ON community_stats(stat_date);
|
||||
CREATE INDEX idx_cs_parent ON community_stats(parent_community_id);
|
||||
```
|
||||
|
||||
### 3.3 SystemAccountMonthlyStat (系统账户月度统计表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE system_account_monthly_stats (
|
||||
stat_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 账户信息
|
||||
account_id BIGINT NOT NULL,
|
||||
account_type VARCHAR(30) NOT NULL,
|
||||
account_name VARCHAR(200) NOT NULL,
|
||||
region_code VARCHAR(10) NOT NULL,
|
||||
|
||||
-- 统计月份
|
||||
stat_month VARCHAR(7) NOT NULL, -- 格式: YYYY-MM
|
||||
|
||||
-- 月度数据
|
||||
monthly_hashpower DECIMAL(20, 8) DEFAULT 0,
|
||||
cumulative_hashpower DECIMAL(20, 8) DEFAULT 0,
|
||||
monthly_mining DECIMAL(20, 8) DEFAULT 0,
|
||||
cumulative_mining DECIMAL(20, 8) DEFAULT 0,
|
||||
monthly_commission DECIMAL(20, 8) DEFAULT 0,
|
||||
cumulative_commission DECIMAL(20, 8) DEFAULT 0,
|
||||
monthly_planting_bonus DECIMAL(20, 8) DEFAULT 0,
|
||||
cumulative_planting_bonus DECIMAL(20, 8) DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(account_id, stat_month)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_sams_type ON system_account_monthly_stats(account_type);
|
||||
CREATE INDEX idx_sams_month ON system_account_monthly_stats(stat_month);
|
||||
CREATE INDEX idx_sams_region ON system_account_monthly_stats(region_code);
|
||||
```
|
||||
|
||||
**account_type 枚举值:**
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----|------|
|
||||
| PROVINCE_COMPANY | 省公司账户 |
|
||||
| CITY_COMPANY | 市公司账户 |
|
||||
| PLATFORM | 平台账户 |
|
||||
|
||||
### 3.4 SystemAccountIncomeRecord (系统账户收益流水表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE system_account_income_records (
|
||||
record_id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 账户信息
|
||||
account_id BIGINT NOT NULL,
|
||||
account_type VARCHAR(30) NOT NULL,
|
||||
|
||||
-- 收益信息
|
||||
income_type VARCHAR(50) NOT NULL,
|
||||
income_amount DECIMAL(20, 8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
|
||||
-- 来源信息
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
source_id VARCHAR(100),
|
||||
source_user_id BIGINT,
|
||||
source_address VARCHAR(200),
|
||||
transaction_no VARCHAR(100),
|
||||
|
||||
-- 备注
|
||||
memo TEXT,
|
||||
|
||||
-- 时间戳
|
||||
occurred_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_sair_account ON system_account_income_records(account_id);
|
||||
CREATE INDEX idx_sair_type ON system_account_income_records(account_type);
|
||||
CREATE INDEX idx_sair_income_type ON system_account_income_records(income_type);
|
||||
CREATE INDEX idx_sair_source_type ON system_account_income_records(source_type);
|
||||
CREATE INDEX idx_sair_address ON system_account_income_records(source_address);
|
||||
CREATE INDEX idx_sair_txno ON system_account_income_records(transaction_no);
|
||||
CREATE INDEX idx_sair_occurred ON system_account_income_records(occurred_at DESC);
|
||||
```
|
||||
|
||||
**income_type 枚举值:**
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----|------|
|
||||
| MINING_REWARD | 挖矿奖励 |
|
||||
| COMMISSION | 佣金收入 |
|
||||
| PLANTING_BONUS | 认种奖励 |
|
||||
| REFERRAL_BONUS | 推荐奖励 |
|
||||
|
||||
**source_type 枚举值:**
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----|------|
|
||||
| PLANTING_ORDER | 认种订单 |
|
||||
| MINING_SETTLEMENT | 挖矿结算 |
|
||||
| COMMISSION_SETTLEMENT | 佣金结算 |
|
||||
| MANUAL_ADJUSTMENT | 手工调整 |
|
||||
|
||||
## 4. 事件表
|
||||
|
||||
### 4.1 ReportEvent (报表事件表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_events (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 聚合根信息
|
||||
aggregate_id VARCHAR(100) NOT NULL,
|
||||
aggregate_type VARCHAR(50) NOT NULL,
|
||||
|
||||
-- 事件数据
|
||||
event_data JSONB NOT NULL,
|
||||
|
||||
-- 元数据
|
||||
user_id BIGINT,
|
||||
occurred_at TIMESTAMP(6) DEFAULT NOW(),
|
||||
version INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_report_event_aggregate ON report_events(aggregate_type, aggregate_id);
|
||||
CREATE INDEX idx_report_event_type ON report_events(event_type);
|
||||
CREATE INDEX idx_report_event_occurred ON report_events(occurred_at);
|
||||
```
|
||||
|
||||
**event_type 枚举值:**
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----|------|
|
||||
| REPORT_DEFINITION_CREATED | 报表定义创建 |
|
||||
| REPORT_DEFINITION_UPDATED | 报表定义更新 |
|
||||
| REPORT_SNAPSHOT_GENERATED | 报表快照生成 |
|
||||
| REPORT_FILE_EXPORTED | 报表文件导出 |
|
||||
| REPORT_FILE_DOWNLOADED | 报表文件下载 |
|
||||
|
||||
**event_data 示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshotId": "12345",
|
||||
"reportCode": "RPT_LEADERBOARD",
|
||||
"period": "DAILY",
|
||||
"periodKey": "2024-01-15",
|
||||
"rowCount": 1000,
|
||||
"generatedAt": "2024-01-15T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据字典
|
||||
|
||||
### 5.1 报表周期 (ReportPeriod)
|
||||
|
||||
| 值 | 说明 | period_key 格式 |
|
||||
|-----|------|----------------|
|
||||
| DAILY | 日报 | 2024-01-15 |
|
||||
| WEEKLY | 周报 | 2024-W03 |
|
||||
| MONTHLY | 月报 | 2024-01 |
|
||||
| YEARLY | 年报 | 2024 |
|
||||
| CUSTOM | 自定义 | 2024-01-01_2024-01-31 |
|
||||
|
||||
### 5.2 导出格式 (ExportFormat)
|
||||
|
||||
| 值 | MIME类型 | 说明 |
|
||||
|-----|----------|------|
|
||||
| EXCEL | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | Excel 2007+ |
|
||||
| CSV | text/csv | 逗号分隔值 |
|
||||
| PDF | application/pdf | PDF 文档 |
|
||||
| JSON | application/json | JSON 格式 |
|
||||
|
||||
### 5.3 报表类型 (ReportType)
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----|------|
|
||||
| LEADERBOARD | 排行榜报表 |
|
||||
| PLANTING | 认种统计报表 |
|
||||
| ANALYTICS | 分析报表 |
|
||||
| COMMUNITY | 社区统计报表 |
|
||||
| SYSTEM_ACCOUNT | 系统账户报表 |
|
||||
| INCOME_DETAIL | 收益明细报表 |
|
||||
|
||||
## 6. 索引策略
|
||||
|
||||
### 6.1 查询模式分析
|
||||
|
||||
| 查询场景 | 使用索引 | 频率 |
|
||||
|---------|---------|------|
|
||||
| 按报表编码查询快照 | idx_snapshot_code | 高 |
|
||||
| 按时间范围查询快照 | idx_snapshot_generated | 高 |
|
||||
| 按周期查询快照 | idx_snapshot_period | 中 |
|
||||
| 按账户查询收益流水 | idx_sair_account | 高 |
|
||||
| 按日期查询统计数据 | idx_pds_date, idx_cs_date | 高 |
|
||||
|
||||
### 6.2 复合索引建议
|
||||
|
||||
```sql
|
||||
-- 高频查询: 按报表编码和周期查询最新快照
|
||||
CREATE INDEX idx_snapshot_code_generated
|
||||
ON report_snapshots(report_code, generated_at DESC);
|
||||
|
||||
-- 高频查询: 按账户和月份查询统计
|
||||
CREATE INDEX idx_sams_account_month
|
||||
ON system_account_monthly_stats(account_id, stat_month DESC);
|
||||
|
||||
-- 高频查询: 按日期和区域查询认种统计
|
||||
CREATE INDEX idx_pds_date_region
|
||||
ON planting_daily_stats(stat_date, province_code, city_code);
|
||||
```
|
||||
|
||||
## 7. 数据保留策略
|
||||
|
||||
### 7.1 保留周期
|
||||
|
||||
| 表 | 保留周期 | 清理策略 |
|
||||
|-----|---------|---------|
|
||||
| report_snapshots | 90天 | 按 expires_at 清理 |
|
||||
| report_files | 30天 | 按 expires_at 清理 |
|
||||
| analytics_metrics | 365天 | 按 calculated_at 归档 |
|
||||
| planting_daily_stats | 永久 | 按年归档 |
|
||||
| system_account_income_records | 永久 | 按年归档 |
|
||||
| report_events | 180天 | 按 occurred_at 清理 |
|
||||
|
||||
### 7.2 清理脚本
|
||||
|
||||
```sql
|
||||
-- 清理过期快照
|
||||
DELETE FROM report_snapshots
|
||||
WHERE expires_at IS NOT NULL AND expires_at < NOW();
|
||||
|
||||
-- 清理过期文件
|
||||
DELETE FROM report_files
|
||||
WHERE expires_at IS NOT NULL AND expires_at < NOW();
|
||||
|
||||
-- 清理旧事件
|
||||
DELETE FROM report_events
|
||||
WHERE occurred_at < NOW() - INTERVAL '180 days';
|
||||
```
|
||||
|
||||
## 8. Prisma Schema 映射
|
||||
|
||||
### 8.1 模型与表映射
|
||||
|
||||
| Prisma Model | 数据库表 | 主键映射 |
|
||||
|--------------|---------|---------|
|
||||
| ReportDefinition | report_definitions | definition_id |
|
||||
| ReportSnapshot | report_snapshots | snapshot_id |
|
||||
| ReportFile | report_files | file_id |
|
||||
| AnalyticsMetric | analytics_metrics | metric_id |
|
||||
| PlantingDailyStat | planting_daily_stats | stat_id |
|
||||
| CommunityStat | community_stats | stat_id |
|
||||
| SystemAccountMonthlyStat | system_account_monthly_stats | stat_id |
|
||||
| SystemAccountIncomeRecord | system_account_income_records | record_id |
|
||||
| ReportEvent | report_events | event_id |
|
||||
|
||||
### 8.2 关系映射
|
||||
|
||||
```prisma
|
||||
// ReportSnapshot 1:N ReportFile
|
||||
model ReportFile {
|
||||
snapshot ReportSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade)
|
||||
snapshotId BigInt @map("snapshot_id")
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 类型映射
|
||||
|
||||
| Prisma 类型 | PostgreSQL 类型 | 说明 |
|
||||
|------------|----------------|------|
|
||||
| BigInt | BIGSERIAL | 大整数主键 |
|
||||
| String @db.VarChar(n) | VARCHAR(n) | 变长字符串 |
|
||||
| String @db.Text | TEXT | 长文本 |
|
||||
| Json | JSONB | JSON 二进制 |
|
||||
| Decimal @db.Decimal(20,8) | DECIMAL(20,8) | 高精度数值 |
|
||||
| DateTime | TIMESTAMP | 时间戳 |
|
||||
| DateTime @db.Date | DATE | 日期 |
|
||||
| Boolean | BOOLEAN | 布尔值 |
|
||||
| String[] | VARCHAR(n)[] | 字符串数组 |
|
||||
|
|
@ -0,0 +1,849 @@
|
|||
# 部署指南
|
||||
|
||||
## 1. 部署架构
|
||||
|
||||
### 1.1 生产环境架构
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Load Balancer │
|
||||
│ (Nginx/ALB) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Reporting │ │ Reporting │ │ Reporting │
|
||||
│ Service #1 │ │ Service #2 │ │ Service #3 │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PostgreSQL │ │ Redis │ │ S3/MinIO │
|
||||
│ (Primary) │ │ Cluster │ │ (Exports) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 Kubernetes 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Kubernetes Cluster │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Namespace: rwadurian │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
|
||||
│ │ │ Ingress │ │ Service │ │ ConfigMap │ │ │
|
||||
│ │ │ Controller │ │ (ClusterIP)│ │ & Secrets │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └────────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ ▼ │ │
|
||||
│ │ ┌─────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Deployment │ │ │
|
||||
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
|
||||
│ │ │ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │ │ │
|
||||
│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. Docker 部署
|
||||
|
||||
### 2.1 生产 Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
# 复制依赖文件
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# 生成 Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk add --no-cache openssl dumb-init
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001
|
||||
|
||||
# 从构建阶段复制必要文件
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER nestjs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1
|
||||
|
||||
# 使用 dumb-init 启动
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
```
|
||||
|
||||
### 2.2 Docker Compose (生产)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
reporting-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: reporting-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}?schema=public
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- rwadurian-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: reporting-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- rwadurian-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: reporting-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- rwadurian-network
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
|
||||
networks:
|
||||
rwadurian-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 2.3 构建和运行
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t reporting-service:latest .
|
||||
|
||||
# 运行容器
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f reporting-service
|
||||
|
||||
# 检查健康状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## 3. Kubernetes 部署
|
||||
|
||||
### 3.1 Namespace
|
||||
|
||||
```yaml
|
||||
# k8s/namespace.yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: rwadurian
|
||||
labels:
|
||||
app.kubernetes.io/name: rwadurian
|
||||
```
|
||||
|
||||
### 3.2 ConfigMap
|
||||
|
||||
```yaml
|
||||
# k8s/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: reporting-service-config
|
||||
namespace: rwadurian
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3000"
|
||||
REDIS_HOST: "redis-service"
|
||||
REDIS_PORT: "6379"
|
||||
```
|
||||
|
||||
### 3.3 Secret
|
||||
|
||||
```yaml
|
||||
# k8s/secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: reporting-service-secret
|
||||
namespace: rwadurian
|
||||
type: Opaque
|
||||
stringData:
|
||||
DATABASE_URL: "postgresql://user:password@postgres-service:5432/rwadurian_reporting?schema=public"
|
||||
JWT_SECRET: "your-super-secret-jwt-key"
|
||||
```
|
||||
|
||||
### 3.4 Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: reporting-service
|
||||
namespace: rwadurian
|
||||
labels:
|
||||
app: reporting-service
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: reporting-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: reporting-service
|
||||
spec:
|
||||
containers:
|
||||
- name: reporting-service
|
||||
image: reporting-service:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: reporting-service-config
|
||||
- secretRef:
|
||||
name: reporting-service-secret
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "256Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "512Mi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 10"]
|
||||
terminationGracePeriodSeconds: 30
|
||||
```
|
||||
|
||||
### 3.5 Service
|
||||
|
||||
```yaml
|
||||
# k8s/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: reporting-service
|
||||
namespace: rwadurian
|
||||
labels:
|
||||
app: reporting-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: reporting-service
|
||||
```
|
||||
|
||||
### 3.6 Ingress
|
||||
|
||||
```yaml
|
||||
# k8s/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: reporting-service-ingress
|
||||
namespace: rwadurian
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- reporting.rwadurian.com
|
||||
secretName: reporting-tls-secret
|
||||
rules:
|
||||
- host: reporting.rwadurian.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: reporting-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
### 3.7 HPA (Horizontal Pod Autoscaler)
|
||||
|
||||
```yaml
|
||||
# k8s/hpa.yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: reporting-service-hpa
|
||||
namespace: rwadurian
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: reporting-service
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
```
|
||||
|
||||
### 3.8 部署命令
|
||||
|
||||
```bash
|
||||
# 创建命名空间
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# 应用配置
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
kubectl apply -f k8s/secret.yaml
|
||||
|
||||
# 部署应用
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
kubectl apply -f k8s/service.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
kubectl apply -f k8s/hpa.yaml
|
||||
|
||||
# 检查状态
|
||||
kubectl get pods -n rwadurian
|
||||
kubectl get svc -n rwadurian
|
||||
kubectl get ingress -n rwadurian
|
||||
|
||||
# 查看日志
|
||||
kubectl logs -f deployment/reporting-service -n rwadurian
|
||||
|
||||
# 扩缩容
|
||||
kubectl scale deployment reporting-service --replicas=5 -n rwadurian
|
||||
```
|
||||
|
||||
## 4. 数据库迁移
|
||||
|
||||
### 4.1 使用 Prisma Migrate
|
||||
|
||||
```bash
|
||||
# 创建迁移
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 应用迁移 (生产环境)
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 重置数据库 (开发环境)
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
### 4.2 迁移脚本
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/migrate.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
|
||||
# 等待数据库就绪
|
||||
until pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do
|
||||
echo "Waiting for database..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 运行迁移
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "Migrations completed successfully!"
|
||||
```
|
||||
|
||||
### 4.3 Kubernetes Job
|
||||
|
||||
```yaml
|
||||
# k8s/migration-job.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: reporting-service-migration
|
||||
namespace: rwadurian
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: migration
|
||||
image: reporting-service:latest
|
||||
command: ["npx", "prisma", "migrate", "deploy"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: reporting-service-secret
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
```
|
||||
|
||||
## 5. CI/CD 流程
|
||||
|
||||
### 5.1 GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}/reporting-service
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npx prisma generate
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.tags }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up kubectl
|
||||
uses: azure/setup-kubectl@v3
|
||||
|
||||
- name: Configure kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config
|
||||
|
||||
- name: Update deployment
|
||||
run: |
|
||||
kubectl set image deployment/reporting-service \
|
||||
reporting-service=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main-${{ github.sha }} \
|
||||
-n rwadurian
|
||||
kubectl rollout status deployment/reporting-service -n rwadurian
|
||||
```
|
||||
|
||||
### 5.2 GitLab CI/CD
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE/reporting-service
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: node:20-alpine
|
||||
script:
|
||||
- npm ci
|
||||
- npx prisma generate
|
||||
- npm test
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:24-dind
|
||||
script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA .
|
||||
- docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
|
||||
only:
|
||||
- main
|
||||
- tags
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
image: bitnami/kubectl:latest
|
||||
script:
|
||||
- kubectl set image deployment/reporting-service reporting-service=$DOCKER_IMAGE:$CI_COMMIT_SHA -n rwadurian
|
||||
- kubectl rollout status deployment/reporting-service -n rwadurian
|
||||
only:
|
||||
- main
|
||||
environment:
|
||||
name: production
|
||||
```
|
||||
|
||||
## 6. 监控和日志
|
||||
|
||||
### 6.1 Prometheus 指标
|
||||
|
||||
```yaml
|
||||
# k8s/servicemonitor.yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: reporting-service
|
||||
namespace: rwadurian
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: reporting-service
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
### 6.2 日志配置
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import * as winston from 'winston';
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: WinstonModule.createLogger({
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 6.3 Grafana 仪表板
|
||||
|
||||
关键指标:
|
||||
- 请求速率 (requests/sec)
|
||||
- 响应时间 (p50, p95, p99)
|
||||
- 错误率
|
||||
- CPU/内存使用率
|
||||
- 数据库连接池状态
|
||||
- Redis 缓存命中率
|
||||
|
||||
## 7. 安全配置
|
||||
|
||||
### 7.1 网络策略
|
||||
|
||||
```yaml
|
||||
# k8s/network-policy.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: reporting-service-network-policy
|
||||
namespace: rwadurian
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: reporting-service
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: ingress-nginx
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3000
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
```
|
||||
|
||||
### 7.2 Pod 安全策略
|
||||
|
||||
```yaml
|
||||
# k8s/pod-security.yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: reporting-service
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
fsGroup: 1001
|
||||
containers:
|
||||
- name: reporting-service
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
```
|
||||
|
||||
## 8. 备份和恢复
|
||||
|
||||
### 8.1 数据库备份
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/backup.sh
|
||||
|
||||
BACKUP_DIR="/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/reporting_${TIMESTAMP}.sql.gz"
|
||||
|
||||
pg_dump -h $DB_HOST -U $DB_USER -d $DB_NAME | gzip > $BACKUP_FILE
|
||||
|
||||
# 上传到 S3
|
||||
aws s3 cp $BACKUP_FILE s3://rwadurian-backups/reporting/
|
||||
|
||||
# 清理旧备份 (保留 7 天)
|
||||
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
|
||||
```
|
||||
|
||||
### 8.2 Kubernetes CronJob
|
||||
|
||||
```yaml
|
||||
# k8s/backup-cronjob.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: reporting-db-backup
|
||||
namespace: rwadurian
|
||||
spec:
|
||||
schedule: "0 2 * * *" # 每天凌晨 2 点
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backup
|
||||
image: postgres:15-alpine
|
||||
command: ["/scripts/backup.sh"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: reporting-service-secret
|
||||
volumeMounts:
|
||||
- name: backup-scripts
|
||||
mountPath: /scripts
|
||||
volumes:
|
||||
- name: backup-scripts
|
||||
configMap:
|
||||
name: backup-scripts
|
||||
defaultMode: 0755
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
## 9. 故障排除
|
||||
|
||||
### 9.1 常见问题
|
||||
|
||||
**Q: Pod 启动失败**
|
||||
```bash
|
||||
# 查看 Pod 状态
|
||||
kubectl describe pod <pod-name> -n rwadurian
|
||||
|
||||
# 查看日志
|
||||
kubectl logs <pod-name> -n rwadurian --previous
|
||||
```
|
||||
|
||||
**Q: 数据库连接失败**
|
||||
```bash
|
||||
# 检查网络策略
|
||||
kubectl get networkpolicy -n rwadurian
|
||||
|
||||
# 测试连接
|
||||
kubectl exec -it <pod-name> -n rwadurian -- nc -zv postgres-service 5432
|
||||
```
|
||||
|
||||
**Q: 内存不足**
|
||||
```bash
|
||||
# 查看资源使用
|
||||
kubectl top pod -n rwadurian
|
||||
|
||||
# 增加资源限制
|
||||
kubectl patch deployment reporting-service -n rwadurian \
|
||||
-p '{"spec":{"template":{"spec":{"containers":[{"name":"reporting-service","resources":{"limits":{"memory":"1Gi"}}}]}}}}'
|
||||
```
|
||||
|
||||
### 9.2 回滚
|
||||
|
||||
```bash
|
||||
# 查看部署历史
|
||||
kubectl rollout history deployment/reporting-service -n rwadurian
|
||||
|
||||
# 回滚到上一版本
|
||||
kubectl rollout undo deployment/reporting-service -n rwadurian
|
||||
|
||||
# 回滚到指定版本
|
||||
kubectl rollout undo deployment/reporting-service --to-revision=2 -n rwadurian
|
||||
```
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
# 开发指南
|
||||
|
||||
## 1. 环境要求
|
||||
|
||||
### 1.1 必需软件
|
||||
|
||||
| 软件 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Node.js | 20.x LTS | 运行时环境 |
|
||||
| npm | 10.x | 包管理器 |
|
||||
| Docker | 24.x+ | 容器化 |
|
||||
| Docker Compose | 2.x | 容器编排 |
|
||||
| PostgreSQL | 15.x | 数据库 |
|
||||
| Redis | 7.x | 缓存 |
|
||||
|
||||
### 1.2 推荐 IDE
|
||||
|
||||
- **VS Code** (推荐)
|
||||
- 插件: ESLint, Prettier, Prisma, TypeScript
|
||||
- **WebStorm**
|
||||
- **Cursor**
|
||||
|
||||
## 2. 项目设置
|
||||
|
||||
### 2.1 克隆项目
|
||||
|
||||
```bash
|
||||
cd backend/services
|
||||
git clone <repository-url> reporting-service
|
||||
cd reporting-service
|
||||
```
|
||||
|
||||
### 2.2 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2.3 环境配置
|
||||
|
||||
创建 `.env.development` 文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.development
|
||||
```
|
||||
|
||||
编辑 `.env.development`:
|
||||
|
||||
```env
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/rwadurian_reporting?schema=public
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-development-secret-key
|
||||
|
||||
# External Services (可选)
|
||||
LEADERBOARD_SERVICE_URL=http://localhost:3001
|
||||
PLANTING_SERVICE_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
### 2.4 数据库设置
|
||||
|
||||
启动 PostgreSQL (使用 Docker):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name reporting-postgres \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=rwadurian_reporting \
|
||||
-p 5432:5432 \
|
||||
postgres:15-alpine
|
||||
```
|
||||
|
||||
生成 Prisma Client:
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
同步数据库 Schema:
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
(可选) 填充种子数据:
|
||||
|
||||
```bash
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
### 2.5 启动 Redis
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name reporting-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine
|
||||
```
|
||||
|
||||
## 3. 开发工作流
|
||||
|
||||
### 3.1 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 热重载模式
|
||||
npm run start:dev
|
||||
|
||||
# 调试模式
|
||||
npm run start:debug
|
||||
```
|
||||
|
||||
服务启动后访问:
|
||||
- API: http://localhost:3000/api/v1
|
||||
- Swagger: http://localhost:3000/api (如已启用)
|
||||
|
||||
### 3.2 常用命令
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
npm run build
|
||||
|
||||
# 运行 Lint
|
||||
npm run lint
|
||||
|
||||
# 格式化代码
|
||||
npm run format
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 运行测试 (watch 模式)
|
||||
npm run test:watch
|
||||
|
||||
# 生成测试覆盖率
|
||||
npm run test:cov
|
||||
|
||||
# Prisma Studio (数据库可视化)
|
||||
npm run prisma:studio
|
||||
```
|
||||
|
||||
### 3.3 使用 Makefile
|
||||
|
||||
```bash
|
||||
# 查看所有可用命令
|
||||
make help
|
||||
|
||||
# 安装依赖
|
||||
make install
|
||||
|
||||
# 构建项目
|
||||
make build
|
||||
|
||||
# 运行所有测试
|
||||
make test
|
||||
|
||||
# 运行单元测试
|
||||
make test-unit
|
||||
|
||||
# 运行集成测试
|
||||
make test-integration
|
||||
|
||||
# 运行 E2E 测试
|
||||
make test-e2e
|
||||
|
||||
# Docker 测试
|
||||
make test-docker-all
|
||||
```
|
||||
|
||||
## 4. 代码规范
|
||||
|
||||
### 4.1 目录结构规范
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API层
|
||||
│ ├── controllers/ # 每个资源一个控制器文件
|
||||
│ └── dto/
|
||||
│ ├── request/ # 请求 DTO
|
||||
│ └── response/ # 响应 DTO
|
||||
├── application/ # 应用层
|
||||
│ ├── commands/ # 每个命令独立目录
|
||||
│ │ └── xxx/
|
||||
│ │ ├── xxx.command.ts
|
||||
│ │ └── xxx.handler.ts
|
||||
│ └── queries/ # 每个查询独立目录
|
||||
├── domain/ # 领域层
|
||||
│ ├── aggregates/ # 每个聚合独立目录
|
||||
│ │ └── xxx/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── xxx.aggregate.ts
|
||||
│ │ └── xxx.spec.ts # 单元测试
|
||||
│ ├── value-objects/ # 值对象
|
||||
│ │ ├── xxx.vo.ts
|
||||
│ │ └── xxx.spec.ts
|
||||
│ └── repositories/ # 仅接口定义
|
||||
└── infrastructure/ # 基础设施层
|
||||
```
|
||||
|
||||
### 4.2 命名规范
|
||||
|
||||
**文件命名**:
|
||||
- 使用 kebab-case: `report-definition.aggregate.ts`
|
||||
- 后缀约定:
|
||||
- `.aggregate.ts` - 聚合根
|
||||
- `.entity.ts` - 实体
|
||||
- `.vo.ts` - 值对象
|
||||
- `.service.ts` - 服务
|
||||
- `.controller.ts` - 控制器
|
||||
- `.dto.ts` - DTO
|
||||
- `.spec.ts` - 单元测试
|
||||
- `.e2e-spec.ts` - E2E 测试
|
||||
- `.integration.spec.ts` - 集成测试
|
||||
|
||||
**类命名**:
|
||||
- 使用 PascalCase
|
||||
- 聚合根: `ReportSnapshot`
|
||||
- 值对象: `DateRange`
|
||||
- 服务: `ReportingApplicationService`
|
||||
- 控制器: `ReportController`
|
||||
|
||||
**接口命名**:
|
||||
- 以 `I` 开头: `IReportSnapshotRepository`
|
||||
|
||||
### 4.3 代码风格
|
||||
|
||||
**TypeScript 配置** (`tsconfig.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ESLint 规则** (关键规则):
|
||||
|
||||
```javascript
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-console': 'warn'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 领域驱动设计规范
|
||||
|
||||
**聚合根规则**:
|
||||
|
||||
```typescript
|
||||
// 1. 使用私有属性 + getter
|
||||
export class ReportSnapshot {
|
||||
private readonly _id: bigint;
|
||||
private _snapshotData: SnapshotData;
|
||||
|
||||
get id(): bigint { return this._id; }
|
||||
get snapshotData(): SnapshotData { return this._snapshotData; }
|
||||
|
||||
// 2. 使用工厂方法创建
|
||||
public static create(props: CreateProps): ReportSnapshot {
|
||||
const snapshot = new ReportSnapshot(props);
|
||||
snapshot.addDomainEvent(new SnapshotCreatedEvent(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// 3. 使用 reconstitute 重建 (不触发事件)
|
||||
public static reconstitute(props: ReconstitutionProps): ReportSnapshot {
|
||||
return new ReportSnapshot(props);
|
||||
}
|
||||
|
||||
// 4. 业务方法封装状态变更
|
||||
public updateData(newData: SnapshotData): void {
|
||||
this.validateDataUpdate(newData);
|
||||
this._snapshotData = newData;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
// 5. 私有构造函数
|
||||
private constructor(props: Props) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**值对象规则**:
|
||||
|
||||
```typescript
|
||||
// 1. 不可变
|
||||
export class DateRange {
|
||||
private readonly _startDate: Date;
|
||||
private readonly _endDate: Date;
|
||||
|
||||
private constructor(startDate: Date, endDate: Date) {
|
||||
this._startDate = startDate;
|
||||
this._endDate = endDate;
|
||||
Object.freeze(this); // 冻结对象
|
||||
}
|
||||
|
||||
// 2. 工厂方法 + 验证
|
||||
public static create(startDate: Date, endDate: Date): DateRange {
|
||||
if (startDate > endDate) {
|
||||
throw new DomainException('Invalid date range');
|
||||
}
|
||||
return new DateRange(startDate, endDate);
|
||||
}
|
||||
|
||||
// 3. 实现 equals 方法
|
||||
public equals(other: DateRange): boolean {
|
||||
return this._startDate.getTime() === other._startDate.getTime() &&
|
||||
this._endDate.getTime() === other._endDate.getTime();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Git 工作流
|
||||
|
||||
### 5.1 分支策略
|
||||
|
||||
```
|
||||
main # 生产分支
|
||||
├── develop # 开发分支
|
||||
│ ├── feature/xxx # 功能分支
|
||||
│ ├── bugfix/xxx # 修复分支
|
||||
│ └── refactor/xxx # 重构分支
|
||||
└── release/x.x.x # 发布分支
|
||||
```
|
||||
|
||||
### 5.2 提交规范
|
||||
|
||||
使用 Conventional Commits:
|
||||
|
||||
```bash
|
||||
# 格式
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
# 示例
|
||||
feat(report): add daily report generation
|
||||
fix(export): resolve Excel formatting issue
|
||||
refactor(domain): extract value object for date range
|
||||
test(e2e): add report generation tests
|
||||
docs(api): update API documentation
|
||||
```
|
||||
|
||||
**Type 类型**:
|
||||
- `feat`: 新功能
|
||||
- `fix`: Bug 修复
|
||||
- `refactor`: 代码重构
|
||||
- `test`: 测试相关
|
||||
- `docs`: 文档更新
|
||||
- `chore`: 构建/配置变更
|
||||
- `perf`: 性能优化
|
||||
|
||||
### 5.3 PR 规范
|
||||
|
||||
PR 标题遵循提交规范,PR 描述需包含:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
简要说明本次变更内容
|
||||
|
||||
## Changes
|
||||
- 变更点 1
|
||||
- 变更点 2
|
||||
|
||||
## Test Plan
|
||||
- [ ] 单元测试通过
|
||||
- [ ] 集成测试通过
|
||||
- [ ] 手动测试场景
|
||||
```
|
||||
|
||||
## 6. 调试技巧
|
||||
|
||||
### 6.1 VS Code 调试配置
|
||||
|
||||
`.vscode/launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug NestJS",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"args": ["${workspaceFolder}/src/main.ts"],
|
||||
"sourceMaps": true,
|
||||
"envFile": "${workspaceFolder}/.env.development"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Jest Tests",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||
"args": ["--runInBand", "--watchAll=false"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 日志调试
|
||||
|
||||
```typescript
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ReportingApplicationService {
|
||||
private readonly logger = new Logger(ReportingApplicationService.name);
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
this.logger.debug(`Generating report: ${dto.reportCode}`);
|
||||
this.logger.log(`Report generated successfully`);
|
||||
this.logger.warn(`Performance warning: slow query`);
|
||||
this.logger.error(`Failed to generate report`, error.stack);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Prisma 调试
|
||||
|
||||
启用查询日志:
|
||||
|
||||
```typescript
|
||||
// prisma.service.ts
|
||||
const prisma = new PrismaClient({
|
||||
log: [
|
||||
{ level: 'query', emit: 'event' },
|
||||
{ level: 'error', emit: 'stdout' },
|
||||
{ level: 'warn', emit: 'stdout' },
|
||||
],
|
||||
});
|
||||
|
||||
prisma.$on('query', (e) => {
|
||||
console.log('Query: ' + e.query);
|
||||
console.log('Params: ' + e.params);
|
||||
console.log('Duration: ' + e.duration + 'ms');
|
||||
});
|
||||
```
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### Q: Prisma Client 未生成
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Q: 数据库连接失败
|
||||
|
||||
1. 检查 PostgreSQL 是否运行: `docker ps`
|
||||
2. 检查 DATABASE_URL 环境变量
|
||||
3. 检查网络连接和端口
|
||||
|
||||
### Q: 测试失败 - 表不存在
|
||||
|
||||
```bash
|
||||
# 推送 schema 到测试数据库
|
||||
DATABASE_URL="postgresql://..." npx prisma db push
|
||||
```
|
||||
|
||||
### Q: TypeScript 编译错误
|
||||
|
||||
```bash
|
||||
# 清理并重新构建
|
||||
rm -rf dist
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Q: 端口被占用
|
||||
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
lsof -i :3000
|
||||
# 或在 Windows
|
||||
netstat -ano | findstr :3000
|
||||
```
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Reporting Service 文档索引
|
||||
|
||||
## 概述
|
||||
|
||||
Reporting Service 是 RWA Durian 平台的报表与分析服务,负责生成、存储和导出各类业务报表。
|
||||
|
||||
## 文档目录
|
||||
|
||||
| 文档 | 描述 |
|
||||
|------|------|
|
||||
| [架构设计](./ARCHITECTURE.md) | 服务整体架构、DDD分层、六边形架构 |
|
||||
| [API参考](./API.md) | RESTful API 接口详细说明 |
|
||||
| [开发指南](./DEVELOPMENT.md) | 本地开发环境搭建、代码规范 |
|
||||
| [测试指南](./TESTING.md) | 单元测试、集成测试、E2E测试说明 |
|
||||
| [部署指南](./DEPLOYMENT.md) | 容器化部署、环境配置、CI/CD |
|
||||
| [数据模型](./DATA-MODEL.md) | 数据库表结构、实体关系 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 生成 Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# 启动开发服务器
|
||||
npm run start:dev
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: NestJS 10.x
|
||||
- **语言**: TypeScript 5.x
|
||||
- **数据库**: PostgreSQL 15 + Prisma ORM
|
||||
- **缓存**: Redis 7
|
||||
- **消息队列**: Kafka (可选)
|
||||
- **导出格式**: Excel, CSV, PDF, JSON
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系开发团队或提交 Issue。
|
||||
|
|
@ -0,0 +1,888 @@
|
|||
# 测试指南
|
||||
|
||||
## 1. 测试策略概述
|
||||
|
||||
### 1.1 测试金字塔
|
||||
|
||||
```
|
||||
┌───────────┐
|
||||
│ E2E │ ← 端到端测试 (少量)
|
||||
│ Tests │
|
||||
─┴───────────┴─
|
||||
┌───────────────┐
|
||||
│ Integration │ ← 集成测试 (中等)
|
||||
│ Tests │
|
||||
─┴───────────────┴─
|
||||
┌───────────────────┐
|
||||
│ Unit Tests │ ← 单元测试 (大量)
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 测试类型分布
|
||||
|
||||
| 测试类型 | 数量 | 覆盖范围 | 执行时间 |
|
||||
|---------|------|---------|---------|
|
||||
| 单元测试 | 20+ | 领域层、值对象、聚合根 | ~2s |
|
||||
| 集成测试 | 11+ | 数据库仓储、应用服务 | ~10s |
|
||||
| E2E 测试 | 12+ | HTTP API、完整流程 | ~15s |
|
||||
| Docker 测试 | 31+ | 容器化环境全量测试 | ~60s |
|
||||
|
||||
## 2. 单元测试
|
||||
|
||||
### 2.1 测试范围
|
||||
|
||||
单元测试主要覆盖:
|
||||
- **值对象 (Value Objects)**: 不可变性、验证逻辑、相等性比较
|
||||
- **聚合根 (Aggregates)**: 业务规则、状态变更、领域事件
|
||||
- **领域服务**: 业务逻辑计算
|
||||
|
||||
### 2.2 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/
|
||||
│ ├── value-objects/
|
||||
│ │ ├── date-range.vo.ts
|
||||
│ │ ├── date-range.spec.ts ← 值对象单元测试
|
||||
│ │ ├── report-period.vo.ts
|
||||
│ │ ├── report-period.spec.ts
|
||||
│ │ ├── export-format.vo.ts
|
||||
│ │ └── export-format.spec.ts
|
||||
│ └── aggregates/
|
||||
│ ├── report-definition/
|
||||
│ │ ├── report-definition.aggregate.ts
|
||||
│ │ └── report-definition.spec.ts ← 聚合根单元测试
|
||||
│ └── report-snapshot/
|
||||
│ ├── report-snapshot.aggregate.ts
|
||||
│ └── report-snapshot.spec.ts
|
||||
```
|
||||
|
||||
### 2.3 值对象测试示例
|
||||
|
||||
```typescript
|
||||
// src/domain/value-objects/date-range.spec.ts
|
||||
describe('DateRange Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create valid date range', () => {
|
||||
const startDate = new Date('2024-01-01');
|
||||
const endDate = new Date('2024-01-31');
|
||||
|
||||
const dateRange = DateRange.create(startDate, endDate);
|
||||
|
||||
expect(dateRange.startDate).toEqual(startDate);
|
||||
expect(dateRange.endDate).toEqual(endDate);
|
||||
});
|
||||
|
||||
it('should throw error when start date is after end date', () => {
|
||||
const startDate = new Date('2024-01-31');
|
||||
const endDate = new Date('2024-01-01');
|
||||
|
||||
expect(() => DateRange.create(startDate, endDate))
|
||||
.toThrow('Start date cannot be after end date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal date ranges', () => {
|
||||
const range1 = DateRange.create(
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-31')
|
||||
);
|
||||
const range2 = DateRange.create(
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-31')
|
||||
);
|
||||
|
||||
expect(range1.equals(range2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDays', () => {
|
||||
it('should calculate correct number of days', () => {
|
||||
const dateRange = DateRange.create(
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-31')
|
||||
);
|
||||
|
||||
expect(dateRange.getDays()).toBe(31);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.4 聚合根测试示例
|
||||
|
||||
```typescript
|
||||
// src/domain/aggregates/report-snapshot/report-snapshot.spec.ts
|
||||
describe('ReportSnapshot Aggregate', () => {
|
||||
const mockDefinition = ReportDefinition.reconstitute({
|
||||
id: 1n,
|
||||
code: 'RPT_TEST',
|
||||
name: 'Test Report',
|
||||
description: 'Test description',
|
||||
category: 'TEST',
|
||||
dataSource: 'test_table',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create snapshot with pending status', () => {
|
||||
const snapshot = ReportSnapshot.create({
|
||||
definition: mockDefinition,
|
||||
period: ReportPeriod.DAILY,
|
||||
dateRange: DateRange.create(
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-01')
|
||||
),
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe(SnapshotStatus.PENDING);
|
||||
expect(snapshot.reportCode).toBe('RPT_TEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsProcessing', () => {
|
||||
it('should transition from pending to processing', () => {
|
||||
const snapshot = createPendingSnapshot();
|
||||
|
||||
snapshot.markAsProcessing();
|
||||
|
||||
expect(snapshot.status).toBe(SnapshotStatus.PROCESSING);
|
||||
});
|
||||
|
||||
it('should throw error if not in pending status', () => {
|
||||
const snapshot = createCompletedSnapshot();
|
||||
|
||||
expect(() => snapshot.markAsProcessing())
|
||||
.toThrow('Cannot start processing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should set data and mark as completed', () => {
|
||||
const snapshot = createProcessingSnapshot();
|
||||
const data = { items: [{ id: 1 }], total: 1 };
|
||||
|
||||
snapshot.complete(data);
|
||||
|
||||
expect(snapshot.status).toBe(SnapshotStatus.COMPLETED);
|
||||
expect(snapshot.snapshotData).toEqual(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.5 运行单元测试
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
npm test
|
||||
|
||||
# 运行特定文件
|
||||
npm test -- date-range.spec.ts
|
||||
|
||||
# Watch 模式
|
||||
npm run test:watch
|
||||
|
||||
# 带覆盖率
|
||||
npm run test:cov
|
||||
```
|
||||
|
||||
## 3. 集成测试
|
||||
|
||||
### 3.1 测试范围
|
||||
|
||||
集成测试覆盖:
|
||||
- **仓储实现**: Prisma 数据库操作
|
||||
- **应用服务**: 命令/查询处理
|
||||
- **缓存服务**: Redis 缓存操作
|
||||
|
||||
### 3.2 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── infrastructure/
|
||||
│ └── repositories/
|
||||
│ ├── prisma-report-definition.repository.ts
|
||||
│ └── prisma-report-definition.repository.integration.spec.ts
|
||||
├── application/
|
||||
│ └── services/
|
||||
│ ├── reporting-application.service.ts
|
||||
│ └── reporting-application.service.integration.spec.ts
|
||||
```
|
||||
|
||||
### 3.3 数据库集成测试
|
||||
|
||||
```typescript
|
||||
// prisma-report-definition.repository.integration.spec.ts
|
||||
describe('PrismaReportDefinitionRepository (Integration)', () => {
|
||||
let repository: PrismaReportDefinitionRepository;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
PrismaReportDefinitionRepository,
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get(PrismaReportDefinitionRepository);
|
||||
prismaService = module.get(PrismaService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理测试数据
|
||||
await prismaService.reportSnapshot.deleteMany();
|
||||
await prismaService.reportDefinition.deleteMany();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaService.$disconnect();
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('should persist report definition to database', async () => {
|
||||
const definition = ReportDefinition.create({
|
||||
code: 'RPT_TEST',
|
||||
name: 'Test Report',
|
||||
description: 'Test',
|
||||
category: 'TEST',
|
||||
dataSource: 'test_table',
|
||||
});
|
||||
|
||||
await repository.save(definition);
|
||||
|
||||
const found = await prismaService.reportDefinition.findUnique({
|
||||
where: { code: 'RPT_TEST' },
|
||||
});
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.name).toBe('Test Report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByCode', () => {
|
||||
it('should return null for non-existent code', async () => {
|
||||
const result = await repository.findByCode('NON_EXISTENT');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return definition when exists', async () => {
|
||||
// 创建测试数据
|
||||
await prismaService.reportDefinition.create({
|
||||
data: {
|
||||
code: 'RPT_FIND',
|
||||
name: 'Find Test',
|
||||
description: 'Test',
|
||||
category: 'TEST',
|
||||
dataSource: 'test_table',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await repository.findByCode('RPT_FIND');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.code).toBe('RPT_FIND');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3.4 应用服务集成测试
|
||||
|
||||
```typescript
|
||||
// reporting-application.service.integration.spec.ts
|
||||
describe('ReportingApplicationService (Integration)', () => {
|
||||
let service: ReportingApplicationService;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ReportingApplicationService);
|
||||
prismaService = module.get(PrismaService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase(prismaService);
|
||||
await seedTestData(prismaService);
|
||||
});
|
||||
|
||||
describe('generateReport', () => {
|
||||
it('should create snapshot for valid report', async () => {
|
||||
const result = await service.generateReport({
|
||||
reportCode: 'RPT_LEADERBOARD',
|
||||
reportPeriod: ReportPeriod.DAILY,
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-01-01'),
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe(SnapshotStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should throw when report definition not found', async () => {
|
||||
await expect(
|
||||
service.generateReport({
|
||||
reportCode: 'NON_EXISTENT',
|
||||
reportPeriod: ReportPeriod.DAILY,
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-01-01'),
|
||||
})
|
||||
).rejects.toThrow(ReportDefinitionNotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3.5 测试数据库配置
|
||||
|
||||
```env
|
||||
# .env.test
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
```
|
||||
|
||||
### 3.6 运行集成测试
|
||||
|
||||
```bash
|
||||
# 启动测试依赖
|
||||
docker compose -f docker-compose.test.yml up -d postgres-test redis-test
|
||||
|
||||
# 推送 Schema
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
|
||||
npx prisma db push
|
||||
|
||||
# 运行集成测试
|
||||
npm run test:integration
|
||||
|
||||
# 或使用 Makefile
|
||||
make test-integration
|
||||
```
|
||||
|
||||
## 4. E2E 测试
|
||||
|
||||
### 4.1 测试范围
|
||||
|
||||
E2E 测试覆盖:
|
||||
- HTTP API 端点
|
||||
- 请求验证
|
||||
- 响应格式
|
||||
- 错误处理
|
||||
- 完整业务流程
|
||||
|
||||
### 4.2 目录结构
|
||||
|
||||
```
|
||||
test/
|
||||
├── app.e2e-spec.ts # 主 E2E 测试文件
|
||||
├── jest-e2e.json # E2E Jest 配置
|
||||
└── setup-e2e.ts # E2E 测试设置
|
||||
```
|
||||
|
||||
### 4.3 E2E 测试配置
|
||||
|
||||
```json
|
||||
// test/jest-e2e.json
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"testTimeout": 30000,
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFilesAfterEnv": ["<rootDir>/setup-e2e.ts"],
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/../src/$1",
|
||||
"^@domain/(.*)$": "<rootDir>/../src/domain/$1",
|
||||
"^@application/(.*)$": "<rootDir>/../src/application/$1",
|
||||
"^@infrastructure/(.*)$": "<rootDir>/../src/infrastructure/$1",
|
||||
"^@api/(.*)$": "<rootDir>/../src/api/$1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// test/setup-e2e.ts
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test') });
|
||||
```
|
||||
|
||||
### 4.4 E2E 测试示例
|
||||
|
||||
```typescript
|
||||
// test/app.e2e-spec.ts
|
||||
describe('Reporting Service (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
// 注意: TransformInterceptor 已在 AppModule 中注册
|
||||
// 不要重复注册,避免响应双重包装
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
it('/api/v1/health (GET) should return ok status', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/health')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.status).toBe('ok');
|
||||
expect(res.body.data.service).toBe('reporting-service');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reports API', () => {
|
||||
describe('POST /api/v1/reports/generate', () => {
|
||||
it('should validate request body', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/reports/generate')
|
||||
.send({})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should reject invalid report period', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/reports/generate')
|
||||
.send({
|
||||
reportCode: 'RPT_LEADERBOARD',
|
||||
reportPeriod: 'INVALID_PERIOD',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export API', () => {
|
||||
describe('POST /api/v1/export', () => {
|
||||
it('should validate request body', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/export')
|
||||
.send({})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should reject invalid format', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/export')
|
||||
.send({
|
||||
snapshotId: '1',
|
||||
format: 'INVALID_FORMAT',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4.5 运行 E2E 测试
|
||||
|
||||
```bash
|
||||
# 确保测试数据库运行
|
||||
docker compose -f docker-compose.test.yml up -d postgres-test redis-test
|
||||
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
|
||||
# 或使用 Makefile
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
## 5. Docker 测试
|
||||
|
||||
### 5.1 测试架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Docker Network │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ PostgreSQL │ │ Redis │ │ Service │ │
|
||||
│ │ :5432 │ │ :6379 │ │ Test │ │
|
||||
│ │ (tmpfs) │ │ │ │ Container │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Docker Compose 配置
|
||||
|
||||
```yaml
|
||||
# docker-compose.test.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:15-alpine
|
||||
container_name: reporting-postgres-test
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_reporting_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data # 使用内存存储加速测试
|
||||
|
||||
redis-test:
|
||||
image: redis:7-alpine
|
||||
container_name: reporting-redis-test
|
||||
ports:
|
||||
- "6380:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
reporting-service-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
container_name: reporting-service-test
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
redis-test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/rwadurian_reporting_test?schema=public
|
||||
REDIS_HOST: redis-test
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: test-secret-key
|
||||
volumes:
|
||||
- ./coverage:/app/coverage
|
||||
command: sh -c "npx prisma db push --skip-generate && npm run test:cov"
|
||||
```
|
||||
|
||||
### 5.3 测试 Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.test
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 OpenSSL (Prisma 依赖)
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
# 安装依赖
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 复制 Prisma Schema
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# 生成 Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 默认运行测试
|
||||
CMD ["npm", "test"]
|
||||
```
|
||||
|
||||
### 5.4 .dockerignore
|
||||
|
||||
```
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env*
|
||||
!.env.example
|
||||
*.log
|
||||
.claude/
|
||||
```
|
||||
|
||||
### 5.5 运行 Docker 测试
|
||||
|
||||
```bash
|
||||
# 构建并运行所有测试
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
|
||||
# 查看测试结果
|
||||
docker compose -f docker-compose.test.yml logs reporting-service-test
|
||||
|
||||
# 清理
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
# 使用 Makefile
|
||||
make test-docker-all
|
||||
```
|
||||
|
||||
## 6. Makefile 命令
|
||||
|
||||
```makefile
|
||||
# Makefile
|
||||
.PHONY: test test-unit test-integration test-e2e test-docker-all
|
||||
|
||||
# 运行所有测试
|
||||
test: test-unit test-integration test-e2e
|
||||
|
||||
# 单元测试
|
||||
test-unit:
|
||||
npm test
|
||||
|
||||
# 集成测试
|
||||
test-integration:
|
||||
npm run test:integration
|
||||
|
||||
# E2E 测试
|
||||
test-e2e:
|
||||
npm run test:e2e
|
||||
|
||||
# Docker 中运行所有测试
|
||||
test-docker-all:
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
# 测试覆盖率
|
||||
test-cov:
|
||||
npm run test:cov
|
||||
|
||||
# 清理测试容器
|
||||
test-clean:
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
## 7. 测试最佳实践
|
||||
|
||||
### 7.1 测试命名规范
|
||||
|
||||
```typescript
|
||||
describe('ClassName', () => {
|
||||
describe('methodName', () => {
|
||||
it('should [expected behavior] when [condition]', () => {
|
||||
// test implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 AAA 模式
|
||||
|
||||
```typescript
|
||||
it('should create valid date range', () => {
|
||||
// Arrange - 准备测试数据
|
||||
const startDate = new Date('2024-01-01');
|
||||
const endDate = new Date('2024-01-31');
|
||||
|
||||
// Act - 执行被测试的操作
|
||||
const dateRange = DateRange.create(startDate, endDate);
|
||||
|
||||
// Assert - 验证结果
|
||||
expect(dateRange.startDate).toEqual(startDate);
|
||||
expect(dateRange.endDate).toEqual(endDate);
|
||||
});
|
||||
```
|
||||
|
||||
### 7.3 测试隔离
|
||||
|
||||
```typescript
|
||||
describe('Repository Integration Tests', () => {
|
||||
beforeEach(async () => {
|
||||
// 每个测试前清理数据
|
||||
await prismaService.reportSnapshot.deleteMany();
|
||||
await prismaService.reportDefinition.deleteMany();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// 测试结束后断开连接
|
||||
await prismaService.$disconnect();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 7.4 Mock 使用
|
||||
|
||||
```typescript
|
||||
// 使用 Jest mock
|
||||
const mockRepository = {
|
||||
findByCode: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call repository with correct parameters', async () => {
|
||||
mockRepository.findByCode.mockResolvedValue(mockDefinition);
|
||||
|
||||
await service.getReportDefinition('RPT_TEST');
|
||||
|
||||
expect(mockRepository.findByCode).toHaveBeenCalledWith('RPT_TEST');
|
||||
});
|
||||
```
|
||||
|
||||
## 8. CI/CD 集成
|
||||
|
||||
### 8.1 GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rwadurian_reporting_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--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: Push database schema
|
||||
run: npx prisma db push
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
```
|
||||
|
||||
## 9. 故障排除
|
||||
|
||||
### 9.1 常见问题
|
||||
|
||||
**Q: 测试找不到模块**
|
||||
```bash
|
||||
# 重新生成 Prisma Client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
**Q: 数据库连接失败**
|
||||
```bash
|
||||
# 检查容器状态
|
||||
docker ps
|
||||
|
||||
# 检查数据库日志
|
||||
docker logs reporting-postgres-test
|
||||
```
|
||||
|
||||
**Q: E2E 测试响应格式错误**
|
||||
```typescript
|
||||
// 确保不要重复注册 TransformInterceptor
|
||||
// AppModule 中已注册,测试中不需要再次注册
|
||||
```
|
||||
|
||||
**Q: Docker 测试 Prisma 错误**
|
||||
```dockerfile
|
||||
# 确保 Dockerfile.test 包含 OpenSSL
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
```
|
||||
|
||||
### 9.2 调试测试
|
||||
|
||||
```bash
|
||||
# 运行单个测试文件
|
||||
npm test -- --testPathPattern=date-range.spec.ts
|
||||
|
||||
# 详细输出
|
||||
npm test -- --verbose
|
||||
|
||||
# 调试模式
|
||||
node --inspect-brk node_modules/.bin/jest --runInBand
|
||||
```
|
||||
|
|
@ -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,105 @@
|
|||
{
|
||||
"name": "reporting-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Reporting & Analytics Service for RWA Durian Platform",
|
||||
"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:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/microservices": "^10.3.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"csv-stringify": "^6.4.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"kafkajs": "^2.2.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.14.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"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/node": "^20.3.1",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/pdfkit": "^0.13.3",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@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": ".",
|
||||
"roots": ["<rootDir>/src", "<rootDir>/test"],
|
||||
"testRegex": ".*\\.(spec|integration\\.spec)\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"^@domain/(.*)$": "<rootDir>/src/domain/$1",
|
||||
"^@application/(.*)$": "<rootDir>/src/application/$1",
|
||||
"^@infrastructure/(.*)$": "<rootDir>/src/infrastructure/$1",
|
||||
"^@api/(.*)$": "<rootDir>/src/api/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/src/shared/$1",
|
||||
"^@config/(.*)$": "<rootDir>/src/config/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 报表定义表 (聚合根1)
|
||||
// 定义各类报表的配置和调度规则
|
||||
// ============================================
|
||||
model ReportDefinition {
|
||||
id BigInt @id @default(autoincrement()) @map("definition_id")
|
||||
|
||||
// === 报表基本信息 ===
|
||||
reportType String @map("report_type") @db.VarChar(50)
|
||||
reportName String @map("report_name") @db.VarChar(200)
|
||||
reportCode String @unique @map("report_code") @db.VarChar(50)
|
||||
description String? @map("description") @db.Text
|
||||
|
||||
// === 报表参数 ===
|
||||
parameters Json @map("parameters")
|
||||
|
||||
// === 调度配置 ===
|
||||
scheduleCron String? @map("schedule_cron") @db.VarChar(100)
|
||||
scheduleTimezone String? @map("schedule_timezone") @db.VarChar(50) @default("Asia/Shanghai")
|
||||
scheduleEnabled Boolean @default(false) @map("schedule_enabled")
|
||||
|
||||
// === 输出格式 ===
|
||||
outputFormats String[] @map("output_formats")
|
||||
|
||||
// === 状态 ===
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// === 时间戳 ===
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastGeneratedAt DateTime? @map("last_generated_at")
|
||||
|
||||
@@map("report_definitions")
|
||||
@@index([reportType], name: "idx_def_type")
|
||||
@@index([isActive], name: "idx_def_active")
|
||||
@@index([scheduleEnabled], name: "idx_def_scheduled")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 报表快照表 (聚合根2 - 读模型)
|
||||
// 存储已生成的报表数据快照
|
||||
// ============================================
|
||||
model ReportSnapshot {
|
||||
id BigInt @id @default(autoincrement()) @map("snapshot_id")
|
||||
|
||||
// === 报表信息 ===
|
||||
reportType String @map("report_type") @db.VarChar(50)
|
||||
reportCode String @map("report_code") @db.VarChar(50)
|
||||
reportPeriod String @map("report_period") @db.VarChar(20)
|
||||
periodKey String @map("period_key") @db.VarChar(30)
|
||||
|
||||
// === 快照数据 ===
|
||||
snapshotData Json @map("snapshot_data")
|
||||
summaryData Json? @map("summary_data")
|
||||
|
||||
// === 数据来源 ===
|
||||
dataSources String[] @map("data_sources")
|
||||
dataFreshness Int @default(0) @map("data_freshness")
|
||||
|
||||
// === 过滤条件 ===
|
||||
filterParams Json? @map("filter_params")
|
||||
|
||||
// === 统计信息 ===
|
||||
rowCount Int @default(0) @map("row_count")
|
||||
|
||||
// === 时间戳 ===
|
||||
periodStartAt DateTime @map("period_start_at")
|
||||
periodEndAt DateTime @map("period_end_at")
|
||||
generatedAt DateTime @default(now()) @map("generated_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
// === 关联 ===
|
||||
files ReportFile[]
|
||||
|
||||
@@map("report_snapshots")
|
||||
@@unique([reportCode, periodKey], name: "uk_report_period")
|
||||
@@index([reportType], name: "idx_snapshot_type")
|
||||
@@index([reportCode], name: "idx_snapshot_code")
|
||||
@@index([periodKey], name: "idx_snapshot_period")
|
||||
@@index([generatedAt(sort: Desc)], name: "idx_snapshot_generated")
|
||||
@@index([expiresAt], name: "idx_snapshot_expires")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 报表文件表
|
||||
// 存储已导出的报表文件信息
|
||||
// ============================================
|
||||
model ReportFile {
|
||||
id BigInt @id @default(autoincrement()) @map("file_id")
|
||||
snapshotId BigInt @map("snapshot_id")
|
||||
|
||||
// === 文件信息 ===
|
||||
fileName String @map("file_name") @db.VarChar(500)
|
||||
filePath String @map("file_path") @db.VarChar(1000)
|
||||
fileUrl String? @map("file_url") @db.VarChar(1000)
|
||||
fileSize BigInt @map("file_size")
|
||||
fileFormat String @map("file_format") @db.VarChar(20)
|
||||
mimeType String @map("mime_type") @db.VarChar(100)
|
||||
|
||||
// === 访问信息 ===
|
||||
downloadCount Int @default(0) @map("download_count")
|
||||
lastDownloadAt DateTime? @map("last_download_at")
|
||||
|
||||
// === 时间戳 ===
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
// === 关联 ===
|
||||
snapshot ReportSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("report_files")
|
||||
@@index([snapshotId], name: "idx_file_snapshot")
|
||||
@@index([fileFormat], name: "idx_file_format")
|
||||
@@index([createdAt(sort: Desc)], name: "idx_file_created")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 分析指标表 (聚合数据)
|
||||
// 存储预聚合的分析指标数据
|
||||
// ============================================
|
||||
model AnalyticsMetric {
|
||||
id BigInt @id @default(autoincrement()) @map("metric_id")
|
||||
|
||||
// === 指标信息 ===
|
||||
metricType String @map("metric_type") @db.VarChar(50)
|
||||
metricCode String @map("metric_code") @db.VarChar(50)
|
||||
|
||||
// === 维度 ===
|
||||
dimensionTime DateTime? @map("dimension_time") @db.Date
|
||||
dimensionRegion String? @map("dimension_region") @db.VarChar(100)
|
||||
dimensionUserType String? @map("dimension_user_type") @db.VarChar(50)
|
||||
dimensionRightType String? @map("dimension_right_type") @db.VarChar(50)
|
||||
|
||||
// === 指标值 ===
|
||||
metricValue Decimal @map("metric_value") @db.Decimal(20, 8)
|
||||
metricData Json? @map("metric_data")
|
||||
|
||||
// === 时间戳 ===
|
||||
calculatedAt DateTime @default(now()) @map("calculated_at")
|
||||
|
||||
@@map("analytics_metrics")
|
||||
@@unique([metricCode, dimensionTime, dimensionRegion, dimensionUserType, dimensionRightType], name: "uk_metric_dimensions")
|
||||
@@index([metricType], name: "idx_metric_type")
|
||||
@@index([metricCode], name: "idx_metric_code")
|
||||
@@index([dimensionTime], name: "idx_metric_time")
|
||||
@@index([dimensionRegion], name: "idx_metric_region")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 认种统计日表 (每日聚合)
|
||||
// ============================================
|
||||
model PlantingDailyStat {
|
||||
id BigInt @id @default(autoincrement()) @map("stat_id")
|
||||
|
||||
// === 统计日期 ===
|
||||
statDate DateTime @map("stat_date") @db.Date
|
||||
|
||||
// === 区域维度 ===
|
||||
provinceCode String? @map("province_code") @db.VarChar(10)
|
||||
cityCode String? @map("city_code") @db.VarChar(10)
|
||||
|
||||
// === 统计数据 ===
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
treeCount Int @default(0) @map("tree_count")
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(20, 8)
|
||||
newUserCount Int @default(0) @map("new_user_count")
|
||||
activeUserCount Int @default(0) @map("active_user_count")
|
||||
|
||||
// === 时间戳 ===
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("planting_daily_stats")
|
||||
@@unique([statDate, provinceCode, cityCode], name: "uk_daily_stat")
|
||||
@@index([statDate], name: "idx_pds_date")
|
||||
@@index([provinceCode], name: "idx_pds_province")
|
||||
@@index([cityCode], name: "idx_pds_city")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 社区统计表
|
||||
// ============================================
|
||||
model CommunityStat {
|
||||
id BigInt @id @default(autoincrement()) @map("stat_id")
|
||||
|
||||
// === 社区信息 ===
|
||||
communityId BigInt @map("community_id")
|
||||
communityName String @map("community_name") @db.VarChar(200)
|
||||
parentCommunityId BigInt? @map("parent_community_id")
|
||||
|
||||
// === 统计日期 ===
|
||||
statDate DateTime @map("stat_date") @db.Date
|
||||
|
||||
// === 统计数据 ===
|
||||
totalPlanting Int @default(0) @map("total_planting")
|
||||
dailyPlanting Int @default(0) @map("daily_planting")
|
||||
weeklyPlanting Int @default(0) @map("weekly_planting")
|
||||
monthlyPlanting Int @default(0) @map("monthly_planting")
|
||||
memberCount Int @default(0) @map("member_count")
|
||||
|
||||
// === 时间戳 ===
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("community_stats")
|
||||
@@unique([communityId, statDate], name: "uk_community_stat")
|
||||
@@index([communityId], name: "idx_cs_community")
|
||||
@@index([communityName], name: "idx_cs_name")
|
||||
@@index([statDate], name: "idx_cs_date")
|
||||
@@index([parentCommunityId], name: "idx_cs_parent")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 系统账户月度统计表
|
||||
// 省公司/市公司账户的月度数据
|
||||
// ============================================
|
||||
model SystemAccountMonthlyStat {
|
||||
id BigInt @id @default(autoincrement()) @map("stat_id")
|
||||
|
||||
// === 账户信息 ===
|
||||
accountId BigInt @map("account_id")
|
||||
accountType String @map("account_type") @db.VarChar(30)
|
||||
accountName String @map("account_name") @db.VarChar(200)
|
||||
regionCode String @map("region_code") @db.VarChar(10)
|
||||
|
||||
// === 统计月份 ===
|
||||
statMonth String @map("stat_month") @db.VarChar(7)
|
||||
|
||||
// === 月度数据 ===
|
||||
monthlyHashpower Decimal @default(0) @map("monthly_hashpower") @db.Decimal(20, 8)
|
||||
cumulativeHashpower Decimal @default(0) @map("cumulative_hashpower") @db.Decimal(20, 8)
|
||||
monthlyMining Decimal @default(0) @map("monthly_mining") @db.Decimal(20, 8)
|
||||
cumulativeMining Decimal @default(0) @map("cumulative_mining") @db.Decimal(20, 8)
|
||||
monthlyCommission Decimal @default(0) @map("monthly_commission") @db.Decimal(20, 8)
|
||||
cumulativeCommission Decimal @default(0) @map("cumulative_commission") @db.Decimal(20, 8)
|
||||
monthlyPlantingBonus Decimal @default(0) @map("monthly_planting_bonus") @db.Decimal(20, 8)
|
||||
cumulativePlantingBonus Decimal @default(0) @map("cumulative_planting_bonus") @db.Decimal(20, 8)
|
||||
|
||||
// === 时间戳 ===
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("system_account_monthly_stats")
|
||||
@@unique([accountId, statMonth], name: "uk_account_month")
|
||||
@@index([accountType], name: "idx_sams_type")
|
||||
@@index([statMonth], name: "idx_sams_month")
|
||||
@@index([regionCode], name: "idx_sams_region")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 系统账户收益流水表
|
||||
// 记录每笔收益的来源和时间
|
||||
// ============================================
|
||||
model SystemAccountIncomeRecord {
|
||||
id BigInt @id @default(autoincrement()) @map("record_id")
|
||||
|
||||
// === 账户信息 ===
|
||||
accountId BigInt @map("account_id")
|
||||
accountType String @map("account_type") @db.VarChar(30)
|
||||
|
||||
// === 收益信息 ===
|
||||
incomeType String @map("income_type") @db.VarChar(50)
|
||||
incomeAmount Decimal @map("income_amount") @db.Decimal(20, 8)
|
||||
currency String @map("currency") @db.VarChar(10)
|
||||
|
||||
// === 来源信息 ===
|
||||
sourceType String @map("source_type") @db.VarChar(50)
|
||||
sourceId String? @map("source_id") @db.VarChar(100)
|
||||
sourceUserId BigInt? @map("source_user_id")
|
||||
sourceAddress String? @map("source_address") @db.VarChar(200)
|
||||
transactionNo String? @map("transaction_no") @db.VarChar(100)
|
||||
|
||||
// === 备注 ===
|
||||
memo String? @map("memo") @db.Text
|
||||
|
||||
// === 时间戳 ===
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("system_account_income_records")
|
||||
@@index([accountId], name: "idx_sair_account")
|
||||
@@index([accountType], name: "idx_sair_type")
|
||||
@@index([incomeType], name: "idx_sair_income_type")
|
||||
@@index([sourceType], name: "idx_sair_source_type")
|
||||
@@index([sourceAddress], name: "idx_sair_address")
|
||||
@@index([transactionNo], name: "idx_sair_txno")
|
||||
@@index([occurredAt(sort: Desc)], name: "idx_sair_occurred")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 报表事件表
|
||||
// ============================================
|
||||
model ReportEvent {
|
||||
id BigInt @id @default(autoincrement()) @map("event_id")
|
||||
eventType String @map("event_type") @db.VarChar(50)
|
||||
|
||||
// 聚合根信息
|
||||
aggregateId String @map("aggregate_id") @db.VarChar(100)
|
||||
aggregateType String @map("aggregate_type") @db.VarChar(50)
|
||||
|
||||
// 事件数据
|
||||
eventData Json @map("event_data")
|
||||
|
||||
// 元数据
|
||||
userId BigInt? @map("user_id")
|
||||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6)
|
||||
version Int @default(1) @map("version")
|
||||
|
||||
@@map("report_events")
|
||||
@@index([aggregateType, aggregateId], name: "idx_report_event_aggregate")
|
||||
@@index([eventType], name: "idx_report_event_type")
|
||||
@@index([occurredAt], name: "idx_report_event_occurred")
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// 初始化报表定义
|
||||
const reportDefinitions = [
|
||||
{
|
||||
reportType: 'LEADERBOARD_REPORT',
|
||||
reportName: '龙虎榜数据报表',
|
||||
reportCode: 'RPT_LEADERBOARD',
|
||||
description: '龙虎榜日榜/周榜/月榜排名数据统计',
|
||||
parameters: {
|
||||
dimensions: ['TIME', 'USER'],
|
||||
defaultPeriod: 'DAILY',
|
||||
},
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'PLANTING_REPORT',
|
||||
reportName: '榴莲树认种报表',
|
||||
reportCode: 'RPT_PLANTING',
|
||||
description: '榴莲树认种日/周/月/季度/年度报表',
|
||||
parameters: {
|
||||
dimensions: ['TIME', 'REGION'],
|
||||
defaultPeriod: 'DAILY',
|
||||
},
|
||||
scheduleCron: '0 1 * * *',
|
||||
scheduleEnabled: true,
|
||||
outputFormats: ['EXCEL', 'CSV', 'PDF'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'REGIONAL_PLANTING_REPORT',
|
||||
reportName: '区域认种报表',
|
||||
reportCode: 'RPT_REGIONAL_PLANTING',
|
||||
description: '按省/市统计的认种报表',
|
||||
parameters: {
|
||||
dimensions: ['REGION', 'TIME'],
|
||||
defaultPeriod: 'DAILY',
|
||||
},
|
||||
scheduleCron: '0 2 * * *',
|
||||
scheduleEnabled: true,
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'AUTHORIZED_COMPANY_TOP_REPORT',
|
||||
reportName: '授权公司第1名统计',
|
||||
reportCode: 'RPT_COMPANY_TOP',
|
||||
description: '各授权省公司和市公司的第1名及完成数据',
|
||||
parameters: {
|
||||
dimensions: ['REGION', 'USER'],
|
||||
includeProvince: true,
|
||||
includeCity: true,
|
||||
},
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'COMMUNITY_REPORT',
|
||||
reportName: '社区数据统计',
|
||||
reportCode: 'RPT_COMMUNITY',
|
||||
description: '社区认种总量、日/周/月新增、上下级社区统计',
|
||||
parameters: {
|
||||
dimensions: ['COMMUNITY', 'TIME'],
|
||||
supportFuzzySearch: true,
|
||||
},
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'SYSTEM_ACCOUNT_MONTHLY_REPORT',
|
||||
reportName: '系统账户月度报表',
|
||||
reportCode: 'RPT_SYSTEM_ACCOUNT_MONTHLY',
|
||||
description: '系统省/市公司账户每月各项数据统计',
|
||||
parameters: {
|
||||
dimensions: ['ACCOUNT', 'TIME'],
|
||||
metrics: [
|
||||
'monthlyHashpower',
|
||||
'cumulativeHashpower',
|
||||
'monthlyMining',
|
||||
'cumulativeMining',
|
||||
'monthlyCommission',
|
||||
'cumulativeCommission',
|
||||
'monthlyPlantingBonus',
|
||||
'cumulativePlantingBonus',
|
||||
],
|
||||
},
|
||||
scheduleCron: '0 0 1 * *',
|
||||
scheduleEnabled: true,
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
reportType: 'SYSTEM_ACCOUNT_INCOME_REPORT',
|
||||
reportName: '系统账户收益来源报表',
|
||||
reportCode: 'RPT_SYSTEM_ACCOUNT_INCOME',
|
||||
description: '系统省/市公司账户收益来源细分统计及时间轴',
|
||||
parameters: {
|
||||
dimensions: ['ACCOUNT', 'TIME', 'SOURCE'],
|
||||
supportTimeFilter: true,
|
||||
supportKeywordSearch: true,
|
||||
},
|
||||
outputFormats: ['EXCEL', 'CSV'],
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const def of reportDefinitions) {
|
||||
await prisma.reportDefinition.upsert({
|
||||
where: { reportCode: def.reportCode },
|
||||
update: def,
|
||||
create: def,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seed completed: Report definitions initialized');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ApplicationModule } from '../application/application.module';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { ReportController } from './controllers/report.controller';
|
||||
import { ExportController } from './controllers/export.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [HealthController, ReportController, ExportController],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Param,
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { ExportReportDto } from '../dto/request/export-report.dto';
|
||||
import { ReportFileResponseDto } from '../dto/response/report-file.dto';
|
||||
import { ExportReportHandler } from '../../application/commands/export-report/export-report.handler';
|
||||
import { ExportReportCommand } from '../../application/commands/export-report/export-report.command';
|
||||
|
||||
@ApiTags('Export')
|
||||
@Controller('export')
|
||||
@ApiBearerAuth()
|
||||
export class ExportController {
|
||||
constructor(private readonly exportReportHandler: ExportReportHandler) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '导出报表' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '导出成功',
|
||||
type: ReportFileResponseDto,
|
||||
})
|
||||
async exportReport(
|
||||
@Body() dto: ExportReportDto,
|
||||
): Promise<ReportFileResponseDto> {
|
||||
const command = new ExportReportCommand(BigInt(dto.snapshotId), dto.format);
|
||||
|
||||
const file = await this.exportReportHandler.execute(command);
|
||||
|
||||
return {
|
||||
id: file.id?.toString() || '',
|
||||
fileName: file.fileName,
|
||||
fileUrl: file.fileUrl || '',
|
||||
fileSize: file.fileSize.toString(),
|
||||
fileFormat: file.fileFormat,
|
||||
mimeType: file.mimeType,
|
||||
createdAt: file.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('download/:fileId')
|
||||
@ApiOperation({ summary: '下载报表文件' })
|
||||
@ApiResponse({ status: 200, description: '文件下载' })
|
||||
@ApiResponse({ status: 404, description: '文件不存在' })
|
||||
async downloadFile(
|
||||
@Param('fileId') fileId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
// In a real implementation, you would:
|
||||
// 1. Look up the file record from database
|
||||
// 2. Verify user has access
|
||||
// 3. Stream the file
|
||||
|
||||
// For now, return a placeholder response
|
||||
throw new NotFoundException(`File not found: ${fileId}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: '健康检查' })
|
||||
@ApiResponse({ status: 200, description: '服务健康' })
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'reporting-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@ApiOperation({ summary: '就绪检查' })
|
||||
@ApiResponse({ status: 200, description: '服务就绪' })
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
service: 'reporting-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { GenerateReportDto } from '../dto/request/generate-report.dto';
|
||||
import { QueryReportDto } from '../dto/request/query-report.dto';
|
||||
import { ReportSnapshotResponseDto } from '../dto/response/report-snapshot.dto';
|
||||
import { ReportDefinitionResponseDto } from '../dto/response/report-definition.dto';
|
||||
import { GenerateReportHandler } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { GenerateReportCommand } from '../../application/commands/generate-report/generate-report.command';
|
||||
import { ReportingApplicationService } from '../../application/services/reporting-application.service';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@ApiBearerAuth()
|
||||
export class ReportController {
|
||||
constructor(
|
||||
private readonly generateReportHandler: GenerateReportHandler,
|
||||
private readonly reportingService: ReportingApplicationService,
|
||||
) {}
|
||||
|
||||
@Get('definitions')
|
||||
@ApiOperation({ summary: '获取所有报表定义' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '报表定义列表',
|
||||
type: [ReportDefinitionResponseDto],
|
||||
})
|
||||
async getDefinitions(): Promise<ReportDefinitionResponseDto[]> {
|
||||
return this.reportingService.getReportDefinitions();
|
||||
}
|
||||
|
||||
@Get('definitions/:code')
|
||||
@ApiOperation({ summary: '获取报表定义详情' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '报表定义详情',
|
||||
type: ReportDefinitionResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '报表定义不存在' })
|
||||
async getDefinition(
|
||||
@Param('code') code: string,
|
||||
): Promise<ReportDefinitionResponseDto> {
|
||||
const definition = await this.reportingService.getReportDefinitionByCode(code);
|
||||
if (!definition) {
|
||||
throw new NotFoundException(`Report definition not found: ${code}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
@Post('generate')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '生成报表' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '报表生成成功',
|
||||
type: ReportSnapshotResponseDto,
|
||||
})
|
||||
async generateReport(
|
||||
@Body() dto: GenerateReportDto,
|
||||
): Promise<ReportSnapshotResponseDto> {
|
||||
const command = new GenerateReportCommand(
|
||||
dto.reportCode,
|
||||
dto.reportPeriod,
|
||||
new Date(dto.startDate),
|
||||
new Date(dto.endDate),
|
||||
dto.dimensions,
|
||||
dto.filters,
|
||||
);
|
||||
|
||||
const snapshot = await this.generateReportHandler.execute(command);
|
||||
|
||||
return {
|
||||
id: snapshot.id?.toString() || '',
|
||||
reportType: snapshot.reportType,
|
||||
reportCode: snapshot.reportCode,
|
||||
reportPeriod: snapshot.reportPeriod,
|
||||
periodKey: snapshot.periodKey,
|
||||
rowCount: snapshot.rowCount,
|
||||
summary: snapshot.snapshotData.summary,
|
||||
rows: snapshot.snapshotData.rows,
|
||||
periodStartAt: snapshot.periodStartAt,
|
||||
periodEndAt: snapshot.periodEndAt,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('snapshots')
|
||||
@ApiOperation({ summary: '查询报表快照' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '报表快照列表',
|
||||
type: [ReportSnapshotResponseDto],
|
||||
})
|
||||
async getSnapshots(
|
||||
@Query() query: QueryReportDto,
|
||||
): Promise<ReportSnapshotResponseDto[]> {
|
||||
if (query.reportCode) {
|
||||
const snapshots = await this.reportingService.getReportSnapshots(
|
||||
query.reportCode,
|
||||
);
|
||||
return snapshots.map((s) => ({
|
||||
...s,
|
||||
rows: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
if (query.reportPeriod) {
|
||||
const snapshots = await this.reportingService.getSnapshotsByPeriod(
|
||||
query.reportPeriod,
|
||||
);
|
||||
return snapshots.map((s) => ({
|
||||
...s,
|
||||
rows: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@Get('snapshots/:code/latest')
|
||||
@ApiOperation({ summary: '获取最新报表快照' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '最新报表快照',
|
||||
type: ReportSnapshotResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '快照不存在' })
|
||||
async getLatestSnapshot(
|
||||
@Param('code') code: string,
|
||||
): Promise<ReportSnapshotResponseDto> {
|
||||
const snapshot = await this.reportingService.getLatestSnapshot(code);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException(`No snapshot found for report: ${code}`);
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
rows: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsEnum } from 'class-validator';
|
||||
import { OutputFormat } from '../../../domain/value-objects';
|
||||
|
||||
export class ExportReportDto {
|
||||
@ApiProperty({ description: '快照ID', example: '1' })
|
||||
@IsString()
|
||||
snapshotId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '导出格式',
|
||||
enum: OutputFormat,
|
||||
example: OutputFormat.EXCEL,
|
||||
})
|
||||
@IsEnum(OutputFormat)
|
||||
format: OutputFormat;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import { ReportPeriod, ReportDimension } from '../../../domain/value-objects';
|
||||
|
||||
export class GenerateReportDto {
|
||||
@ApiProperty({ description: '报表代码', example: 'RPT_LEADERBOARD' })
|
||||
@IsString()
|
||||
reportCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '报表周期',
|
||||
enum: ReportPeriod,
|
||||
example: ReportPeriod.DAILY,
|
||||
})
|
||||
@IsEnum(ReportPeriod)
|
||||
reportPeriod: ReportPeriod;
|
||||
|
||||
@ApiProperty({ description: '开始日期', example: '2024-01-01' })
|
||||
@IsDateString()
|
||||
startDate: string;
|
||||
|
||||
@ApiProperty({ description: '结束日期', example: '2024-01-31' })
|
||||
@IsDateString()
|
||||
endDate: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '报表维度',
|
||||
enum: ReportDimension,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(ReportDimension, { each: true })
|
||||
dimensions?: ReportDimension[];
|
||||
|
||||
@ApiPropertyOptional({ description: '筛选条件' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filters?: Record<string, any>;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsEnum, IsDateString } from 'class-validator';
|
||||
import { ReportPeriod } from '../../../domain/value-objects';
|
||||
|
||||
export class QueryReportDto {
|
||||
@ApiPropertyOptional({ description: '报表代码' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reportCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '周期键', example: '2024-01-15' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
periodKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '报表周期',
|
||||
enum: ReportPeriod,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ReportPeriod)
|
||||
reportPeriod?: ReportPeriod;
|
||||
|
||||
@ApiPropertyOptional({ description: '开始日期' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '结束日期' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportDefinitionResponseDto {
|
||||
@ApiProperty({ description: '定义ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: '报表类型' })
|
||||
reportType: string;
|
||||
|
||||
@ApiProperty({ description: '报表名称' })
|
||||
reportName: string;
|
||||
|
||||
@ApiProperty({ description: '报表代码' })
|
||||
reportCode: string;
|
||||
|
||||
@ApiProperty({ description: '报表描述' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: '报表参数' })
|
||||
parameters: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional({ description: '调度Cron表达式' })
|
||||
scheduleCron?: string | null;
|
||||
|
||||
@ApiProperty({ description: '调度是否启用' })
|
||||
scheduleEnabled: boolean;
|
||||
|
||||
@ApiProperty({ description: '支持的输出格式', isArray: true })
|
||||
outputFormats: string[];
|
||||
|
||||
@ApiProperty({ description: '是否激活' })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '最后生成时间' })
|
||||
lastGeneratedAt?: Date | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReportFileResponseDto {
|
||||
@ApiProperty({ description: '文件ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: '文件名' })
|
||||
fileName: string;
|
||||
|
||||
@ApiProperty({ description: '文件URL' })
|
||||
fileUrl: string;
|
||||
|
||||
@ApiProperty({ description: '文件大小(字节)' })
|
||||
fileSize: string;
|
||||
|
||||
@ApiProperty({ description: '文件格式' })
|
||||
fileFormat: string;
|
||||
|
||||
@ApiProperty({ description: 'MIME类型' })
|
||||
mimeType: string;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportSnapshotResponseDto {
|
||||
@ApiProperty({ description: '快照ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: '报表类型' })
|
||||
reportType: string;
|
||||
|
||||
@ApiProperty({ description: '报表代码' })
|
||||
reportCode: string;
|
||||
|
||||
@ApiProperty({ description: '报表周期' })
|
||||
reportPeriod: string;
|
||||
|
||||
@ApiProperty({ description: '周期键' })
|
||||
periodKey: string;
|
||||
|
||||
@ApiProperty({ description: '数据行数' })
|
||||
rowCount: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '汇总数据' })
|
||||
summary?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional({ description: '数据行' })
|
||||
rows?: any[];
|
||||
|
||||
@ApiProperty({ description: '周期开始时间' })
|
||||
periodStartAt: Date;
|
||||
|
||||
@ApiProperty({ description: '周期结束时间' })
|
||||
periodEndAt: Date;
|
||||
|
||||
@ApiProperty({ description: '生成时间' })
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
import { JwtStrategy } from './shared/strategies/jwt.strategy';
|
||||
import {
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
} from './config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.development', '.env'],
|
||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
||||
}),
|
||||
ApiModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
// Uncomment to enable JWT auth globally
|
||||
// {
|
||||
// provide: APP_GUARD,
|
||||
// useClass: JwtAuthGuard,
|
||||
// },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { DomainModule } from '../domain/domain.module';
|
||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||
import { GenerateReportHandler } from './commands/generate-report/generate-report.handler';
|
||||
import { ExportReportHandler } from './commands/export-report/export-report.handler';
|
||||
import { GetReportSnapshotHandler } from './queries/get-report-snapshot/get-report-snapshot.handler';
|
||||
import { ReportingApplicationService } from './services/reporting-application.service';
|
||||
import { ReportGenerationScheduler } from './schedulers/report-generation.scheduler';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), DomainModule, InfrastructureModule],
|
||||
providers: [
|
||||
GenerateReportHandler,
|
||||
ExportReportHandler,
|
||||
GetReportSnapshotHandler,
|
||||
ReportingApplicationService,
|
||||
ReportGenerationScheduler,
|
||||
],
|
||||
exports: [
|
||||
GenerateReportHandler,
|
||||
ExportReportHandler,
|
||||
GetReportSnapshotHandler,
|
||||
ReportingApplicationService,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { OutputFormat } from '../../../domain/value-objects';
|
||||
|
||||
export class ExportReportCommand {
|
||||
constructor(
|
||||
public readonly snapshotId: bigint,
|
||||
public readonly format: OutputFormat,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExportReportCommand } from './export-report.command';
|
||||
import {
|
||||
IReportSnapshotRepository,
|
||||
IReportFileRepository,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
REPORT_FILE_REPOSITORY,
|
||||
} from '../../../domain/repositories';
|
||||
import { ReportFile } from '../../../domain/entities/report-file.entity';
|
||||
import {
|
||||
OutputFormat,
|
||||
OutputFormatMimeTypes,
|
||||
OutputFormatExtensions,
|
||||
} from '../../../domain/value-objects';
|
||||
import { ExcelExportService } from '../../../infrastructure/export/excel-export.service';
|
||||
import { CsvExportService } from '../../../infrastructure/export/csv-export.service';
|
||||
import { PdfExportService } from '../../../infrastructure/export/pdf-export.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExportReportHandler {
|
||||
private readonly logger = new Logger(ExportReportHandler.name);
|
||||
private readonly fileUrlPrefix: string;
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_SNAPSHOT_REPOSITORY)
|
||||
private readonly snapshotRepo: IReportSnapshotRepository,
|
||||
@Inject(REPORT_FILE_REPOSITORY)
|
||||
private readonly fileRepo: IReportFileRepository,
|
||||
private readonly excelExportService: ExcelExportService,
|
||||
private readonly csvExportService: CsvExportService,
|
||||
private readonly pdfExportService: PdfExportService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.fileUrlPrefix = this.configService.get<string>(
|
||||
'FILE_STORAGE_URL_PREFIX',
|
||||
'http://localhost:3008/files',
|
||||
);
|
||||
}
|
||||
|
||||
async execute(command: ExportReportCommand): Promise<ReportFile> {
|
||||
this.logger.log(
|
||||
`Exporting report snapshot ${command.snapshotId} to ${command.format}`,
|
||||
);
|
||||
|
||||
// Find snapshot
|
||||
const snapshot = await this.snapshotRepo.findById(command.snapshotId);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException(
|
||||
`Report snapshot not found: ${command.snapshotId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
const existingFile = await this.fileRepo.findBySnapshotIdAndFormat(
|
||||
command.snapshotId,
|
||||
command.format,
|
||||
);
|
||||
|
||||
if (existingFile && !existingFile.isExpired()) {
|
||||
this.logger.debug(`Using existing file for snapshot ${command.snapshotId}`);
|
||||
return existingFile;
|
||||
}
|
||||
|
||||
// Generate file name
|
||||
const extension = OutputFormatExtensions[command.format];
|
||||
const fileName = `${snapshot.reportCode}_${snapshot.periodKey}_${Date.now()}.${extension}`;
|
||||
|
||||
// Get columns from snapshot data
|
||||
const rows = snapshot.snapshotData.rows;
|
||||
const columns = this.inferColumns(rows);
|
||||
|
||||
// Export based on format
|
||||
let exportResult: { filePath: string; fileSize: number };
|
||||
|
||||
switch (command.format) {
|
||||
case OutputFormat.EXCEL:
|
||||
exportResult = await this.excelExportService.export(fileName, {
|
||||
sheetName: snapshot.reportCode,
|
||||
columns,
|
||||
data: rows,
|
||||
title: `${snapshot.reportCode} Report`,
|
||||
subtitle: `Period: ${snapshot.periodKey}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case OutputFormat.CSV:
|
||||
exportResult = await this.csvExportService.export(fileName, {
|
||||
columns,
|
||||
data: rows,
|
||||
});
|
||||
break;
|
||||
|
||||
case OutputFormat.PDF:
|
||||
exportResult = await this.pdfExportService.export(fileName, {
|
||||
columns,
|
||||
data: rows,
|
||||
title: `${snapshot.reportCode} Report`,
|
||||
subtitle: `Period: ${snapshot.periodKey}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case OutputFormat.JSON:
|
||||
exportResult = await this.exportJson(fileName, rows);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported format: ${command.format}`);
|
||||
}
|
||||
|
||||
// Create report file entity
|
||||
const reportFile = ReportFile.create({
|
||||
snapshotId: command.snapshotId,
|
||||
fileName,
|
||||
filePath: exportResult.filePath,
|
||||
fileUrl: `${this.fileUrlPrefix}/${fileName}`,
|
||||
fileSize: BigInt(exportResult.fileSize),
|
||||
fileFormat: command.format,
|
||||
mimeType: OutputFormatMimeTypes[command.format],
|
||||
});
|
||||
|
||||
// Save file record
|
||||
const savedFile = await this.fileRepo.save(reportFile);
|
||||
|
||||
this.logger.log(
|
||||
`Report exported: ${fileName} (${exportResult.fileSize} bytes)`,
|
||||
);
|
||||
|
||||
return savedFile;
|
||||
}
|
||||
|
||||
private inferColumns(
|
||||
rows: any[],
|
||||
): { header: string; key: string; width?: number }[] {
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstRow = rows[0];
|
||||
return Object.keys(firstRow).map((key) => ({
|
||||
header: this.formatHeader(key),
|
||||
key,
|
||||
width: 15,
|
||||
}));
|
||||
}
|
||||
|
||||
private formatHeader(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
private async exportJson(
|
||||
fileName: string,
|
||||
data: any[],
|
||||
): Promise<{ filePath: string; fileSize: number }> {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const storagePath = this.configService.get<string>(
|
||||
'FILE_STORAGE_PATH',
|
||||
'./storage/reports',
|
||||
);
|
||||
|
||||
const fullPath = path.join(storagePath, fileName);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
return {
|
||||
filePath: fullPath,
|
||||
fileSize: stats.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { ReportType, ReportPeriod, ReportDimension } from '../../../domain/value-objects';
|
||||
|
||||
export class GenerateReportCommand {
|
||||
constructor(
|
||||
public readonly reportCode: string,
|
||||
public readonly reportPeriod: ReportPeriod,
|
||||
public readonly startDate: Date,
|
||||
public readonly endDate: Date,
|
||||
public readonly dimensions?: ReportDimension[],
|
||||
public readonly filters?: Record<string, any>,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { GenerateReportCommand } from './generate-report.command';
|
||||
import {
|
||||
IReportDefinitionRepository,
|
||||
IReportSnapshotRepository,
|
||||
REPORT_DEFINITION_REPOSITORY,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
} from '../../../domain/repositories';
|
||||
import { ReportGenerationDomainService } from '../../../domain/services';
|
||||
import { ReportSnapshot } from '../../../domain/aggregates/report-snapshot';
|
||||
import { DateRange, ReportType } from '../../../domain/value-objects';
|
||||
import { LeaderboardServiceClient } from '../../../infrastructure/external/leaderboard-service/leaderboard-service.client';
|
||||
import { PlantingServiceClient } from '../../../infrastructure/external/planting-service/planting-service.client';
|
||||
import { ReportCacheService } from '../../../infrastructure/redis/report-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class GenerateReportHandler {
|
||||
private readonly logger = new Logger(GenerateReportHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_DEFINITION_REPOSITORY)
|
||||
private readonly definitionRepo: IReportDefinitionRepository,
|
||||
@Inject(REPORT_SNAPSHOT_REPOSITORY)
|
||||
private readonly snapshotRepo: IReportSnapshotRepository,
|
||||
private readonly generationService: ReportGenerationDomainService,
|
||||
private readonly leaderboardClient: LeaderboardServiceClient,
|
||||
private readonly plantingClient: PlantingServiceClient,
|
||||
private readonly cacheService: ReportCacheService,
|
||||
) {}
|
||||
|
||||
async execute(command: GenerateReportCommand): Promise<ReportSnapshot> {
|
||||
this.logger.log(`Generating report: ${command.reportCode}`);
|
||||
|
||||
// Find report definition
|
||||
const definition = await this.definitionRepo.findByCode(command.reportCode);
|
||||
if (!definition) {
|
||||
throw new NotFoundException(
|
||||
`Report definition not found: ${command.reportCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create date range and period key
|
||||
const dateRange = DateRange.create(command.startDate, command.endDate);
|
||||
const periodKey = dateRange.toPeriodKey(command.reportPeriod);
|
||||
|
||||
// Check cache first
|
||||
const cached = await this.cacheService.getCachedSnapshot<any>(
|
||||
command.reportCode,
|
||||
periodKey,
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
this.logger.debug(`Using cached snapshot for ${command.reportCode}:${periodKey}`);
|
||||
// Return cached snapshot if exists
|
||||
const existingSnapshot = await this.snapshotRepo.findByCodeAndPeriodKey(
|
||||
command.reportCode,
|
||||
periodKey,
|
||||
);
|
||||
if (existingSnapshot) {
|
||||
return existingSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate report data based on type
|
||||
const { rows, summary, dataSources } = await this.generateReportData(
|
||||
definition.reportType,
|
||||
command,
|
||||
);
|
||||
|
||||
// Create snapshot
|
||||
const snapshot = this.generationService.generateSnapshot({
|
||||
reportType: definition.reportType,
|
||||
reportCode: command.reportCode,
|
||||
reportPeriod: command.reportPeriod,
|
||||
periodKey,
|
||||
rows,
|
||||
summary,
|
||||
dataSources,
|
||||
filterParams: command.filters,
|
||||
periodStartAt: command.startDate,
|
||||
periodEndAt: command.endDate,
|
||||
});
|
||||
|
||||
// Save snapshot
|
||||
const savedSnapshot = await this.snapshotRepo.save(snapshot);
|
||||
|
||||
// Update definition's last generated timestamp
|
||||
definition.markGenerated();
|
||||
await this.definitionRepo.save(definition);
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.cacheSnapshot(
|
||||
command.reportCode,
|
||||
periodKey,
|
||||
savedSnapshot.snapshotData.toJSON(),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Report generated: ${command.reportCode}:${periodKey} with ${savedSnapshot.rowCount} rows`,
|
||||
);
|
||||
|
||||
return savedSnapshot;
|
||||
}
|
||||
|
||||
private async generateReportData(
|
||||
reportType: ReportType,
|
||||
command: GenerateReportCommand,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
summary: Record<string, any>;
|
||||
dataSources: string[];
|
||||
}> {
|
||||
switch (reportType) {
|
||||
case ReportType.LEADERBOARD_REPORT:
|
||||
return this.generateLeaderboardReport(command);
|
||||
|
||||
case ReportType.PLANTING_REPORT:
|
||||
return this.generatePlantingReport(command);
|
||||
|
||||
case ReportType.REGIONAL_PLANTING_REPORT:
|
||||
return this.generateRegionalPlantingReport(command);
|
||||
|
||||
default:
|
||||
return {
|
||||
rows: [],
|
||||
summary: {},
|
||||
dataSources: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async generateLeaderboardReport(
|
||||
command: GenerateReportCommand,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
summary: Record<string, any>;
|
||||
dataSources: string[];
|
||||
}> {
|
||||
let leaderboardData;
|
||||
|
||||
switch (command.reportPeriod) {
|
||||
case 'DAILY':
|
||||
leaderboardData = await this.leaderboardClient.getDailyLeaderboard(
|
||||
command.startDate,
|
||||
);
|
||||
break;
|
||||
case 'WEEKLY':
|
||||
leaderboardData = await this.leaderboardClient.getWeeklyLeaderboard();
|
||||
break;
|
||||
case 'MONTHLY':
|
||||
leaderboardData = await this.leaderboardClient.getMonthlyLeaderboard();
|
||||
break;
|
||||
default:
|
||||
leaderboardData = await this.leaderboardClient.getDailyLeaderboard(
|
||||
command.startDate,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: leaderboardData.entries,
|
||||
summary: {
|
||||
totalEntries: leaderboardData.entries.length,
|
||||
period: leaderboardData.period,
|
||||
type: leaderboardData.type,
|
||||
},
|
||||
dataSources: ['leaderboard-service'],
|
||||
};
|
||||
}
|
||||
|
||||
private async generatePlantingReport(
|
||||
command: GenerateReportCommand,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
summary: Record<string, any>;
|
||||
dataSources: string[];
|
||||
}> {
|
||||
const stats = await this.plantingClient.getStatsForDateRange(
|
||||
command.startDate,
|
||||
command.endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
rows: [stats],
|
||||
summary: {
|
||||
totalOrders: stats.totalOrders,
|
||||
totalTrees: stats.totalTrees,
|
||||
totalAmount: stats.totalAmount,
|
||||
newUsers: stats.newUsers,
|
||||
activeUsers: stats.activeUsers,
|
||||
},
|
||||
dataSources: ['planting-service'],
|
||||
};
|
||||
}
|
||||
|
||||
private async generateRegionalPlantingReport(
|
||||
command: GenerateReportCommand,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
summary: Record<string, any>;
|
||||
dataSources: string[];
|
||||
}> {
|
||||
const provincesStats = await this.plantingClient.getAllProvincesStats(
|
||||
command.startDate,
|
||||
command.endDate,
|
||||
);
|
||||
|
||||
const totalOrders = provincesStats.reduce(
|
||||
(sum, s) => sum + s.totalOrders,
|
||||
0,
|
||||
);
|
||||
const totalTrees = provincesStats.reduce((sum, s) => sum + s.totalTrees, 0);
|
||||
|
||||
return {
|
||||
rows: provincesStats,
|
||||
summary: {
|
||||
totalProvinces: provincesStats.length,
|
||||
totalOrders,
|
||||
totalTrees,
|
||||
},
|
||||
dataSources: ['planting-service'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './generate-report/generate-report.command';
|
||||
export * from './generate-report/generate-report.handler';
|
||||
export * from './export-report/export-report.command';
|
||||
export * from './export-report/export-report.handler';
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import {
|
||||
GetReportSnapshotQuery,
|
||||
GetReportSnapshotByIdQuery,
|
||||
GetLatestReportSnapshotQuery,
|
||||
} from './get-report-snapshot.query';
|
||||
import {
|
||||
IReportSnapshotRepository,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
} from '../../../domain/repositories';
|
||||
import { ReportSnapshot } from '../../../domain/aggregates/report-snapshot';
|
||||
|
||||
@Injectable()
|
||||
export class GetReportSnapshotHandler {
|
||||
constructor(
|
||||
@Inject(REPORT_SNAPSHOT_REPOSITORY)
|
||||
private readonly snapshotRepo: IReportSnapshotRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReportSnapshotQuery): Promise<ReportSnapshot | null> {
|
||||
return this.snapshotRepo.findByCodeAndPeriodKey(
|
||||
query.reportCode,
|
||||
query.periodKey,
|
||||
);
|
||||
}
|
||||
|
||||
async executeById(
|
||||
query: GetReportSnapshotByIdQuery,
|
||||
): Promise<ReportSnapshot | null> {
|
||||
return this.snapshotRepo.findById(query.id);
|
||||
}
|
||||
|
||||
async executeLatest(
|
||||
query: GetLatestReportSnapshotQuery,
|
||||
): Promise<ReportSnapshot | null> {
|
||||
return this.snapshotRepo.findLatestByCode(query.reportCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export class GetReportSnapshotQuery {
|
||||
constructor(
|
||||
public readonly reportCode: string,
|
||||
public readonly periodKey: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetReportSnapshotByIdQuery {
|
||||
constructor(public readonly id: bigint) {}
|
||||
}
|
||||
|
||||
export class GetLatestReportSnapshotQuery {
|
||||
constructor(public readonly reportCode: string) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './get-report-snapshot/get-report-snapshot.query';
|
||||
export * from './get-report-snapshot/get-report-snapshot.handler';
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
IReportDefinitionRepository,
|
||||
REPORT_DEFINITION_REPOSITORY,
|
||||
} from '../../domain/repositories';
|
||||
import { GenerateReportHandler } from '../commands/generate-report/generate-report.handler';
|
||||
import { GenerateReportCommand } from '../commands/generate-report/generate-report.command';
|
||||
import { DateRange, ReportPeriod } from '../../domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class ReportGenerationScheduler {
|
||||
private readonly logger = new Logger(ReportGenerationScheduler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_DEFINITION_REPOSITORY)
|
||||
private readonly definitionRepo: IReportDefinitionRepository,
|
||||
private readonly generateReportHandler: GenerateReportHandler,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||
async generateDailyReports(): Promise<void> {
|
||||
this.logger.log('Starting daily report generation...');
|
||||
|
||||
try {
|
||||
const definitions = await this.definitionRepo.findScheduled();
|
||||
const yesterday = this.getYesterday();
|
||||
const dateRange = DateRange.create(yesterday.start, yesterday.end);
|
||||
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const command = new GenerateReportCommand(
|
||||
definition.reportCode,
|
||||
ReportPeriod.DAILY,
|
||||
yesterday.start,
|
||||
yesterday.end,
|
||||
);
|
||||
|
||||
await this.generateReportHandler.execute(command);
|
||||
this.logger.log(`Daily report generated: ${definition.reportCode}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to generate daily report: ${definition.reportCode}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Daily report generation completed');
|
||||
} catch (error) {
|
||||
this.logger.error('Daily report generation failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEK)
|
||||
async generateWeeklyReports(): Promise<void> {
|
||||
this.logger.log('Starting weekly report generation...');
|
||||
|
||||
try {
|
||||
const definitions = await this.definitionRepo.findScheduled();
|
||||
const lastWeek = this.getLastWeek();
|
||||
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const command = new GenerateReportCommand(
|
||||
definition.reportCode,
|
||||
ReportPeriod.WEEKLY,
|
||||
lastWeek.start,
|
||||
lastWeek.end,
|
||||
);
|
||||
|
||||
await this.generateReportHandler.execute(command);
|
||||
this.logger.log(`Weekly report generated: ${definition.reportCode}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to generate weekly report: ${definition.reportCode}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Weekly report generation completed');
|
||||
} catch (error) {
|
||||
this.logger.error('Weekly report generation failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron('0 0 1 * *') // First day of month at midnight
|
||||
async generateMonthlyReports(): Promise<void> {
|
||||
this.logger.log('Starting monthly report generation...');
|
||||
|
||||
try {
|
||||
const definitions = await this.definitionRepo.findScheduled();
|
||||
const lastMonth = this.getLastMonth();
|
||||
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const command = new GenerateReportCommand(
|
||||
definition.reportCode,
|
||||
ReportPeriod.MONTHLY,
|
||||
lastMonth.start,
|
||||
lastMonth.end,
|
||||
);
|
||||
|
||||
await this.generateReportHandler.execute(command);
|
||||
this.logger.log(`Monthly report generated: ${definition.reportCode}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to generate monthly report: ${definition.reportCode}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Monthly report generation completed');
|
||||
} catch (error) {
|
||||
this.logger.error('Monthly report generation failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getYesterday(): { start: Date; end: Date } {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const start = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const end = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
private getLastWeek(): { start: Date; end: Date } {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diffToLastMonday = dayOfWeek === 0 ? -13 : -6 - dayOfWeek;
|
||||
|
||||
const start = new Date(now);
|
||||
start.setDate(now.getDate() + diffToLastMonday);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
private getLastMonth(): { start: Date; end: Date } {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0);
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
IReportDefinitionRepository,
|
||||
IReportSnapshotRepository,
|
||||
REPORT_DEFINITION_REPOSITORY,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
} from '../../domain/repositories';
|
||||
import { ReportDefinition } from '../../domain/aggregates/report-definition';
|
||||
import { ReportSnapshot } from '../../domain/aggregates/report-snapshot';
|
||||
import { ReportType, ReportPeriod } from '../../domain/value-objects';
|
||||
|
||||
export interface ReportDefinitionDto {
|
||||
id: string;
|
||||
reportType: string;
|
||||
reportName: string;
|
||||
reportCode: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
scheduleCron: string | null;
|
||||
scheduleEnabled: boolean;
|
||||
outputFormats: string[];
|
||||
isActive: boolean;
|
||||
lastGeneratedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ReportSnapshotDto {
|
||||
id: string;
|
||||
reportType: string;
|
||||
reportCode: string;
|
||||
reportPeriod: string;
|
||||
periodKey: string;
|
||||
rowCount: number;
|
||||
summary: Record<string, any>;
|
||||
periodStartAt: Date;
|
||||
periodEndAt: Date;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportingApplicationService {
|
||||
private readonly logger = new Logger(ReportingApplicationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_DEFINITION_REPOSITORY)
|
||||
private readonly definitionRepo: IReportDefinitionRepository,
|
||||
@Inject(REPORT_SNAPSHOT_REPOSITORY)
|
||||
private readonly snapshotRepo: IReportSnapshotRepository,
|
||||
) {}
|
||||
|
||||
async getReportDefinitions(): Promise<ReportDefinitionDto[]> {
|
||||
const definitions = await this.definitionRepo.findActive();
|
||||
return definitions.map(this.mapDefinitionToDto);
|
||||
}
|
||||
|
||||
async getReportDefinitionByCode(
|
||||
code: string,
|
||||
): Promise<ReportDefinitionDto | null> {
|
||||
const definition = await this.definitionRepo.findByCode(code);
|
||||
return definition ? this.mapDefinitionToDto(definition) : null;
|
||||
}
|
||||
|
||||
async getReportSnapshots(reportCode: string): Promise<ReportSnapshotDto[]> {
|
||||
const snapshots = await this.snapshotRepo.findByCode(reportCode);
|
||||
return snapshots.map(this.mapSnapshotToDto);
|
||||
}
|
||||
|
||||
async getLatestSnapshot(reportCode: string): Promise<ReportSnapshotDto | null> {
|
||||
const snapshot = await this.snapshotRepo.findLatestByCode(reportCode);
|
||||
return snapshot ? this.mapSnapshotToDto(snapshot) : null;
|
||||
}
|
||||
|
||||
async getSnapshotsByPeriod(period: ReportPeriod): Promise<ReportSnapshotDto[]> {
|
||||
const snapshots = await this.snapshotRepo.findByPeriod(period);
|
||||
return snapshots.map(this.mapSnapshotToDto);
|
||||
}
|
||||
|
||||
private mapDefinitionToDto(definition: ReportDefinition): ReportDefinitionDto {
|
||||
return {
|
||||
id: definition.id?.toString() || '',
|
||||
reportType: definition.reportType,
|
||||
reportName: definition.reportName,
|
||||
reportCode: definition.reportCode,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
scheduleCron: definition.schedule?.cronExpression || null,
|
||||
scheduleEnabled: definition.schedule?.enabled || false,
|
||||
outputFormats: definition.outputFormats,
|
||||
isActive: definition.isActive,
|
||||
lastGeneratedAt: definition.lastGeneratedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapSnapshotToDto(snapshot: ReportSnapshot): ReportSnapshotDto {
|
||||
return {
|
||||
id: snapshot.id?.toString() || '',
|
||||
reportType: snapshot.reportType,
|
||||
reportCode: snapshot.reportCode,
|
||||
reportPeriod: snapshot.reportPeriod,
|
||||
periodKey: snapshot.periodKey,
|
||||
rowCount: snapshot.rowCount,
|
||||
summary: snapshot.snapshotData.summary,
|
||||
periodStartAt: snapshot.periodStartAt,
|
||||
periodEndAt: snapshot.periodEndAt,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
name: process.env.APP_NAME || 'reporting-service',
|
||||
port: parseInt(process.env.PORT || '3008', 10),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
}));
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('database', () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
}));
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { default as appConfig } from './app.config';
|
||||
export { default as databaseConfig } from './database.config';
|
||||
export { default as jwtConfig } from './jwt.config';
|
||||
export { default as redisConfig } from './redis.config';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
}));
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
}));
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './report-definition.aggregate';
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import { DomainEvent } from '../../events/domain-event.base';
|
||||
import { ReportType } from '../../value-objects/report-type.enum';
|
||||
import { ReportSchedule } from '../../value-objects/report-schedule.vo';
|
||||
import { OutputFormat } from '../../value-objects/output-format.enum';
|
||||
|
||||
export class ReportDefinition {
|
||||
private _id: bigint | null = null;
|
||||
private readonly _reportType: ReportType;
|
||||
private _reportName: string;
|
||||
private readonly _reportCode: string;
|
||||
private _description: string;
|
||||
private _parameters: Record<string, any>;
|
||||
private _schedule: ReportSchedule | null;
|
||||
private _outputFormats: OutputFormat[];
|
||||
private _isActive: boolean;
|
||||
private readonly _createdAt: Date;
|
||||
private _lastGeneratedAt: Date | null;
|
||||
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(
|
||||
reportType: ReportType,
|
||||
reportName: string,
|
||||
reportCode: string,
|
||||
description: string,
|
||||
parameters: Record<string, any>,
|
||||
schedule: ReportSchedule | null,
|
||||
outputFormats: OutputFormat[],
|
||||
isActive: boolean,
|
||||
createdAt: Date,
|
||||
lastGeneratedAt: Date | null,
|
||||
) {
|
||||
this._reportType = reportType;
|
||||
this._reportName = reportName;
|
||||
this._reportCode = reportCode;
|
||||
this._description = description;
|
||||
this._parameters = parameters;
|
||||
this._schedule = schedule;
|
||||
this._outputFormats = outputFormats;
|
||||
this._isActive = isActive;
|
||||
this._createdAt = createdAt;
|
||||
this._lastGeneratedAt = lastGeneratedAt;
|
||||
|
||||
this.validateInvariants();
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
reportType: ReportType;
|
||||
reportName: string;
|
||||
reportCode: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, any>;
|
||||
schedule?: ReportSchedule;
|
||||
outputFormats?: OutputFormat[];
|
||||
}): ReportDefinition {
|
||||
const outputFormats =
|
||||
params.outputFormats && params.outputFormats.length > 0
|
||||
? params.outputFormats
|
||||
: [OutputFormat.EXCEL];
|
||||
|
||||
return new ReportDefinition(
|
||||
params.reportType,
|
||||
params.reportName,
|
||||
params.reportCode,
|
||||
params.description || '',
|
||||
params.parameters || {},
|
||||
params.schedule || null,
|
||||
outputFormats,
|
||||
true,
|
||||
new Date(),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(params: {
|
||||
id: bigint;
|
||||
reportType: ReportType;
|
||||
reportName: string;
|
||||
reportCode: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
schedule: ReportSchedule | null;
|
||||
outputFormats: OutputFormat[];
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
lastGeneratedAt: Date | null;
|
||||
}): ReportDefinition {
|
||||
const definition = new ReportDefinition(
|
||||
params.reportType,
|
||||
params.reportName,
|
||||
params.reportCode,
|
||||
params.description,
|
||||
params.parameters,
|
||||
params.schedule,
|
||||
params.outputFormats,
|
||||
params.isActive,
|
||||
params.createdAt,
|
||||
params.lastGeneratedAt,
|
||||
);
|
||||
definition._id = params.id;
|
||||
return definition;
|
||||
}
|
||||
|
||||
private validateInvariants(): void {
|
||||
if (this._outputFormats.length === 0) {
|
||||
throw new Error('报表定义至少需要支持一种输出格式');
|
||||
}
|
||||
|
||||
if (this._schedule?.enabled && !this._schedule.cronExpression) {
|
||||
throw new Error('启用调度时必须有有效的 cron 表达式');
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get reportType(): ReportType {
|
||||
return this._reportType;
|
||||
}
|
||||
|
||||
get reportName(): string {
|
||||
return this._reportName;
|
||||
}
|
||||
|
||||
get reportCode(): string {
|
||||
return this._reportCode;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this._description;
|
||||
}
|
||||
|
||||
get parameters(): Record<string, any> {
|
||||
return { ...this._parameters };
|
||||
}
|
||||
|
||||
get schedule(): ReportSchedule | null {
|
||||
return this._schedule;
|
||||
}
|
||||
|
||||
get outputFormats(): OutputFormat[] {
|
||||
return [...this._outputFormats];
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get lastGeneratedAt(): Date | null {
|
||||
return this._lastGeneratedAt;
|
||||
}
|
||||
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
// Commands
|
||||
updateName(name: string): void {
|
||||
this._reportName = name;
|
||||
}
|
||||
|
||||
updateDescription(description: string): void {
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
updateParameters(parameters: Record<string, any>): void {
|
||||
this._parameters = parameters;
|
||||
}
|
||||
|
||||
setSchedule(schedule: ReportSchedule): void {
|
||||
this._schedule = schedule;
|
||||
this.validateInvariants();
|
||||
}
|
||||
|
||||
enableSchedule(): void {
|
||||
if (this._schedule) {
|
||||
this._schedule = this._schedule.enable();
|
||||
}
|
||||
}
|
||||
|
||||
disableSchedule(): void {
|
||||
if (this._schedule) {
|
||||
this._schedule = this._schedule.disable();
|
||||
}
|
||||
}
|
||||
|
||||
addOutputFormat(format: OutputFormat): void {
|
||||
if (!this._outputFormats.includes(format)) {
|
||||
this._outputFormats.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
removeOutputFormat(format: OutputFormat): void {
|
||||
if (this._outputFormats.length <= 1) {
|
||||
throw new Error('报表定义至少需要支持一种输出格式');
|
||||
}
|
||||
this._outputFormats = this._outputFormats.filter((f) => f !== format);
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this._isActive = true;
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
this._isActive = false;
|
||||
}
|
||||
|
||||
markGenerated(): void {
|
||||
this._lastGeneratedAt = new Date();
|
||||
}
|
||||
|
||||
supportsFormat(format: OutputFormat): boolean {
|
||||
return this._outputFormats.includes(format);
|
||||
}
|
||||
|
||||
isScheduled(): boolean {
|
||||
return this._schedule !== null && this._schedule.enabled;
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
protected addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { ReportDefinition } from './report-definition.aggregate';
|
||||
import { ReportType } from '../../value-objects/report-type.enum';
|
||||
import { OutputFormat } from '../../value-objects/output-format.enum';
|
||||
import { ReportSchedule } from '../../value-objects/report-schedule.vo';
|
||||
|
||||
describe('ReportDefinition Aggregate', () => {
|
||||
describe('create', () => {
|
||||
it('should create a report definition with required fields', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test Report',
|
||||
reportCode: 'TEST_001',
|
||||
});
|
||||
|
||||
expect(definition.reportType).toBe(ReportType.LEADERBOARD_REPORT);
|
||||
expect(definition.reportName).toBe('Test Report');
|
||||
expect(definition.reportCode).toBe('TEST_001');
|
||||
expect(definition.isActive).toBe(true);
|
||||
expect(definition.outputFormats).toContain(OutputFormat.EXCEL);
|
||||
});
|
||||
|
||||
it('should create a report definition with custom output formats', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.PLANTING_REPORT,
|
||||
reportName: 'Planting Report',
|
||||
reportCode: 'PLANTING_001',
|
||||
outputFormats: [OutputFormat.CSV, OutputFormat.PDF],
|
||||
});
|
||||
|
||||
expect(definition.outputFormats).toHaveLength(2);
|
||||
expect(definition.outputFormats).toContain(OutputFormat.CSV);
|
||||
expect(definition.outputFormats).toContain(OutputFormat.PDF);
|
||||
});
|
||||
|
||||
it('should create a report definition with schedule', () => {
|
||||
const schedule = ReportSchedule.daily(1, 0);
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.PLANTING_REPORT,
|
||||
reportName: 'Daily Planting Report',
|
||||
reportCode: 'PLANTING_DAILY',
|
||||
schedule,
|
||||
});
|
||||
|
||||
expect(definition.schedule).toBeDefined();
|
||||
expect(definition.schedule?.enabled).toBe(true);
|
||||
expect(definition.isScheduled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invariants', () => {
|
||||
it('should require at least one output format', () => {
|
||||
expect(() => {
|
||||
const def = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test',
|
||||
reportCode: 'TEST',
|
||||
outputFormats: [OutputFormat.EXCEL],
|
||||
});
|
||||
def.removeOutputFormat(OutputFormat.EXCEL);
|
||||
}).toThrow('报表定义至少需要支持一种输出格式');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commands', () => {
|
||||
it('should update name', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Original Name',
|
||||
reportCode: 'TEST',
|
||||
});
|
||||
|
||||
definition.updateName('New Name');
|
||||
expect(definition.reportName).toBe('New Name');
|
||||
});
|
||||
|
||||
it('should add output format', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test',
|
||||
reportCode: 'TEST',
|
||||
outputFormats: [OutputFormat.EXCEL],
|
||||
});
|
||||
|
||||
definition.addOutputFormat(OutputFormat.CSV);
|
||||
expect(definition.outputFormats).toContain(OutputFormat.CSV);
|
||||
});
|
||||
|
||||
it('should not add duplicate output format', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test',
|
||||
reportCode: 'TEST',
|
||||
outputFormats: [OutputFormat.EXCEL],
|
||||
});
|
||||
|
||||
definition.addOutputFormat(OutputFormat.EXCEL);
|
||||
expect(definition.outputFormats.filter(f => f === OutputFormat.EXCEL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should activate and deactivate', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test',
|
||||
reportCode: 'TEST',
|
||||
});
|
||||
|
||||
definition.deactivate();
|
||||
expect(definition.isActive).toBe(false);
|
||||
|
||||
definition.activate();
|
||||
expect(definition.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark as generated', () => {
|
||||
const definition = ReportDefinition.create({
|
||||
reportType: ReportType.LEADERBOARD_REPORT,
|
||||
reportName: 'Test',
|
||||
reportCode: 'TEST',
|
||||
});
|
||||
|
||||
expect(definition.lastGeneratedAt).toBeNull();
|
||||
|
||||
definition.markGenerated();
|
||||
expect(definition.lastGeneratedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './report-snapshot.aggregate';
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import { DomainEvent } from '../../events/domain-event.base';
|
||||
import { SnapshotCreatedEvent } from '../../events/snapshot-created.event';
|
||||
import { ReportType } from '../../value-objects/report-type.enum';
|
||||
import { ReportPeriod } from '../../value-objects/report-period.enum';
|
||||
import { SnapshotData } from '../../value-objects/snapshot-data.vo';
|
||||
import { DataSource } from '../../value-objects/data-source.vo';
|
||||
|
||||
export class ReportSnapshot {
|
||||
private _id: bigint | null = null;
|
||||
private readonly _reportType: ReportType;
|
||||
private readonly _reportCode: string;
|
||||
private readonly _reportPeriod: ReportPeriod;
|
||||
private readonly _periodKey: string;
|
||||
private _snapshotData: SnapshotData;
|
||||
private _dataSource: DataSource;
|
||||
private _filterParams: Record<string, any> | null;
|
||||
private readonly _periodStartAt: Date;
|
||||
private readonly _periodEndAt: Date;
|
||||
private readonly _generatedAt: Date;
|
||||
private _expiresAt: Date | null;
|
||||
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(
|
||||
reportType: ReportType,
|
||||
reportCode: string,
|
||||
reportPeriod: ReportPeriod,
|
||||
periodKey: string,
|
||||
snapshotData: SnapshotData,
|
||||
dataSource: DataSource,
|
||||
filterParams: Record<string, any> | null,
|
||||
periodStartAt: Date,
|
||||
periodEndAt: Date,
|
||||
generatedAt: Date,
|
||||
expiresAt: Date | null,
|
||||
) {
|
||||
this._reportType = reportType;
|
||||
this._reportCode = reportCode;
|
||||
this._reportPeriod = reportPeriod;
|
||||
this._periodKey = periodKey;
|
||||
this._snapshotData = snapshotData;
|
||||
this._dataSource = dataSource;
|
||||
this._filterParams = filterParams;
|
||||
this._periodStartAt = periodStartAt;
|
||||
this._periodEndAt = periodEndAt;
|
||||
this._generatedAt = generatedAt;
|
||||
this._expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
reportType: ReportType;
|
||||
reportCode: string;
|
||||
reportPeriod: ReportPeriod;
|
||||
periodKey: string;
|
||||
snapshotData: SnapshotData;
|
||||
dataSource: DataSource;
|
||||
filterParams?: Record<string, any>;
|
||||
periodStartAt: Date;
|
||||
periodEndAt: Date;
|
||||
expiresAt?: Date;
|
||||
}): ReportSnapshot {
|
||||
const snapshot = new ReportSnapshot(
|
||||
params.reportType,
|
||||
params.reportCode,
|
||||
params.reportPeriod,
|
||||
params.periodKey,
|
||||
params.snapshotData,
|
||||
params.dataSource,
|
||||
params.filterParams || null,
|
||||
params.periodStartAt,
|
||||
params.periodEndAt,
|
||||
new Date(),
|
||||
params.expiresAt || null,
|
||||
);
|
||||
|
||||
snapshot.addDomainEvent(
|
||||
new SnapshotCreatedEvent({
|
||||
snapshotId: params.periodKey,
|
||||
reportType: params.reportType,
|
||||
reportCode: params.reportCode,
|
||||
reportPeriod: params.reportPeriod,
|
||||
periodKey: params.periodKey,
|
||||
rowCount: params.snapshotData.getRowCount(),
|
||||
periodStartAt: params.periodStartAt,
|
||||
periodEndAt: params.periodEndAt,
|
||||
}),
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
static reconstitute(params: {
|
||||
id: bigint;
|
||||
reportType: ReportType;
|
||||
reportCode: string;
|
||||
reportPeriod: ReportPeriod;
|
||||
periodKey: string;
|
||||
snapshotData: SnapshotData;
|
||||
dataSource: DataSource;
|
||||
filterParams: Record<string, any> | null;
|
||||
periodStartAt: Date;
|
||||
periodEndAt: Date;
|
||||
generatedAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}): ReportSnapshot {
|
||||
const snapshot = new ReportSnapshot(
|
||||
params.reportType,
|
||||
params.reportCode,
|
||||
params.reportPeriod,
|
||||
params.periodKey,
|
||||
params.snapshotData,
|
||||
params.dataSource,
|
||||
params.filterParams,
|
||||
params.periodStartAt,
|
||||
params.periodEndAt,
|
||||
params.generatedAt,
|
||||
params.expiresAt,
|
||||
);
|
||||
snapshot._id = params.id;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get reportType(): ReportType {
|
||||
return this._reportType;
|
||||
}
|
||||
|
||||
get reportCode(): string {
|
||||
return this._reportCode;
|
||||
}
|
||||
|
||||
get reportPeriod(): ReportPeriod {
|
||||
return this._reportPeriod;
|
||||
}
|
||||
|
||||
get periodKey(): string {
|
||||
return this._periodKey;
|
||||
}
|
||||
|
||||
get snapshotData(): SnapshotData {
|
||||
return this._snapshotData;
|
||||
}
|
||||
|
||||
get dataSource(): DataSource {
|
||||
return this._dataSource;
|
||||
}
|
||||
|
||||
get filterParams(): Record<string, any> | null {
|
||||
return this._filterParams ? { ...this._filterParams } : null;
|
||||
}
|
||||
|
||||
get periodStartAt(): Date {
|
||||
return this._periodStartAt;
|
||||
}
|
||||
|
||||
get periodEndAt(): Date {
|
||||
return this._periodEndAt;
|
||||
}
|
||||
|
||||
get generatedAt(): Date {
|
||||
return this._generatedAt;
|
||||
}
|
||||
|
||||
get expiresAt(): Date | null {
|
||||
return this._expiresAt;
|
||||
}
|
||||
|
||||
get rowCount(): number {
|
||||
return this._snapshotData.getRowCount();
|
||||
}
|
||||
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
// Commands
|
||||
updateSnapshotData(data: SnapshotData): void {
|
||||
this._snapshotData = data;
|
||||
}
|
||||
|
||||
setExpiration(expiresAt: Date): void {
|
||||
this._expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
if (!this._expiresAt) {
|
||||
return false;
|
||||
}
|
||||
return new Date() > this._expiresAt;
|
||||
}
|
||||
|
||||
isFresh(maxAgeSeconds: number): boolean {
|
||||
return this._dataSource.isFresh(maxAgeSeconds);
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
protected addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReportGenerationDomainService } from './services/report-generation.service';
|
||||
|
||||
@Module({
|
||||
providers: [ReportGenerationDomainService],
|
||||
exports: [ReportGenerationDomainService],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
export class AnalyticsMetric {
|
||||
private _id: bigint | null = null;
|
||||
private readonly _metricType: string;
|
||||
private readonly _metricCode: string;
|
||||
private readonly _dimensionTime: Date | null;
|
||||
private readonly _dimensionRegion: string | null;
|
||||
private readonly _dimensionUserType: string | null;
|
||||
private readonly _dimensionRightType: string | null;
|
||||
private _metricValue: Decimal;
|
||||
private _metricData: Record<string, any> | null;
|
||||
private readonly _calculatedAt: Date;
|
||||
|
||||
private constructor(
|
||||
metricType: string,
|
||||
metricCode: string,
|
||||
dimensionTime: Date | null,
|
||||
dimensionRegion: string | null,
|
||||
dimensionUserType: string | null,
|
||||
dimensionRightType: string | null,
|
||||
metricValue: Decimal,
|
||||
metricData: Record<string, any> | null,
|
||||
calculatedAt: Date,
|
||||
) {
|
||||
this._metricType = metricType;
|
||||
this._metricCode = metricCode;
|
||||
this._dimensionTime = dimensionTime;
|
||||
this._dimensionRegion = dimensionRegion;
|
||||
this._dimensionUserType = dimensionUserType;
|
||||
this._dimensionRightType = dimensionRightType;
|
||||
this._metricValue = metricValue;
|
||||
this._metricData = metricData;
|
||||
this._calculatedAt = calculatedAt;
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
metricType: string;
|
||||
metricCode: string;
|
||||
dimensionTime?: Date;
|
||||
dimensionRegion?: string;
|
||||
dimensionUserType?: string;
|
||||
dimensionRightType?: string;
|
||||
metricValue: Decimal;
|
||||
metricData?: Record<string, any>;
|
||||
}): AnalyticsMetric {
|
||||
return new AnalyticsMetric(
|
||||
params.metricType,
|
||||
params.metricCode,
|
||||
params.dimensionTime || null,
|
||||
params.dimensionRegion || null,
|
||||
params.dimensionUserType || null,
|
||||
params.dimensionRightType || null,
|
||||
params.metricValue,
|
||||
params.metricData || null,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(params: {
|
||||
id: bigint;
|
||||
metricType: string;
|
||||
metricCode: string;
|
||||
dimensionTime: Date | null;
|
||||
dimensionRegion: string | null;
|
||||
dimensionUserType: string | null;
|
||||
dimensionRightType: string | null;
|
||||
metricValue: Decimal;
|
||||
metricData: Record<string, any> | null;
|
||||
calculatedAt: Date;
|
||||
}): AnalyticsMetric {
|
||||
const metric = new AnalyticsMetric(
|
||||
params.metricType,
|
||||
params.metricCode,
|
||||
params.dimensionTime,
|
||||
params.dimensionRegion,
|
||||
params.dimensionUserType,
|
||||
params.dimensionRightType,
|
||||
params.metricValue,
|
||||
params.metricData,
|
||||
params.calculatedAt,
|
||||
);
|
||||
metric._id = params.id;
|
||||
return metric;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get metricType(): string {
|
||||
return this._metricType;
|
||||
}
|
||||
|
||||
get metricCode(): string {
|
||||
return this._metricCode;
|
||||
}
|
||||
|
||||
get dimensionTime(): Date | null {
|
||||
return this._dimensionTime;
|
||||
}
|
||||
|
||||
get dimensionRegion(): string | null {
|
||||
return this._dimensionRegion;
|
||||
}
|
||||
|
||||
get dimensionUserType(): string | null {
|
||||
return this._dimensionUserType;
|
||||
}
|
||||
|
||||
get dimensionRightType(): string | null {
|
||||
return this._dimensionRightType;
|
||||
}
|
||||
|
||||
get metricValue(): Decimal {
|
||||
return this._metricValue;
|
||||
}
|
||||
|
||||
get metricData(): Record<string, any> | null {
|
||||
return this._metricData ? { ...this._metricData } : null;
|
||||
}
|
||||
|
||||
get calculatedAt(): Date {
|
||||
return this._calculatedAt;
|
||||
}
|
||||
|
||||
// Commands
|
||||
updateValue(value: Decimal, data?: Record<string, any>): void {
|
||||
this._metricValue = value;
|
||||
if (data) {
|
||||
this._metricData = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './report-file.entity';
|
||||
export * from './analytics-metric.entity';
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { OutputFormat } from '../value-objects/output-format.enum';
|
||||
|
||||
export class ReportFile {
|
||||
private _id: bigint | null = null;
|
||||
private readonly _snapshotId: bigint;
|
||||
private readonly _fileName: string;
|
||||
private readonly _filePath: string;
|
||||
private _fileUrl: string | null;
|
||||
private readonly _fileSize: bigint;
|
||||
private readonly _fileFormat: OutputFormat;
|
||||
private readonly _mimeType: string;
|
||||
private _downloadCount: number;
|
||||
private _lastDownloadAt: Date | null;
|
||||
private readonly _createdAt: Date;
|
||||
private _expiresAt: Date | null;
|
||||
|
||||
private constructor(
|
||||
snapshotId: bigint,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
fileUrl: string | null,
|
||||
fileSize: bigint,
|
||||
fileFormat: OutputFormat,
|
||||
mimeType: string,
|
||||
downloadCount: number,
|
||||
lastDownloadAt: Date | null,
|
||||
createdAt: Date,
|
||||
expiresAt: Date | null,
|
||||
) {
|
||||
this._snapshotId = snapshotId;
|
||||
this._fileName = fileName;
|
||||
this._filePath = filePath;
|
||||
this._fileUrl = fileUrl;
|
||||
this._fileSize = fileSize;
|
||||
this._fileFormat = fileFormat;
|
||||
this._mimeType = mimeType;
|
||||
this._downloadCount = downloadCount;
|
||||
this._lastDownloadAt = lastDownloadAt;
|
||||
this._createdAt = createdAt;
|
||||
this._expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
snapshotId: bigint;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileUrl?: string;
|
||||
fileSize: bigint;
|
||||
fileFormat: OutputFormat;
|
||||
mimeType: string;
|
||||
expiresAt?: Date;
|
||||
}): ReportFile {
|
||||
return new ReportFile(
|
||||
params.snapshotId,
|
||||
params.fileName,
|
||||
params.filePath,
|
||||
params.fileUrl || null,
|
||||
params.fileSize,
|
||||
params.fileFormat,
|
||||
params.mimeType,
|
||||
0,
|
||||
null,
|
||||
new Date(),
|
||||
params.expiresAt || null,
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(params: {
|
||||
id: bigint;
|
||||
snapshotId: bigint;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileUrl: string | null;
|
||||
fileSize: bigint;
|
||||
fileFormat: OutputFormat;
|
||||
mimeType: string;
|
||||
downloadCount: number;
|
||||
lastDownloadAt: Date | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}): ReportFile {
|
||||
const file = new ReportFile(
|
||||
params.snapshotId,
|
||||
params.fileName,
|
||||
params.filePath,
|
||||
params.fileUrl,
|
||||
params.fileSize,
|
||||
params.fileFormat,
|
||||
params.mimeType,
|
||||
params.downloadCount,
|
||||
params.lastDownloadAt,
|
||||
params.createdAt,
|
||||
params.expiresAt,
|
||||
);
|
||||
file._id = params.id;
|
||||
return file;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): bigint | null {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get snapshotId(): bigint {
|
||||
return this._snapshotId;
|
||||
}
|
||||
|
||||
get fileName(): string {
|
||||
return this._fileName;
|
||||
}
|
||||
|
||||
get filePath(): string {
|
||||
return this._filePath;
|
||||
}
|
||||
|
||||
get fileUrl(): string | null {
|
||||
return this._fileUrl;
|
||||
}
|
||||
|
||||
get fileSize(): bigint {
|
||||
return this._fileSize;
|
||||
}
|
||||
|
||||
get fileFormat(): OutputFormat {
|
||||
return this._fileFormat;
|
||||
}
|
||||
|
||||
get mimeType(): string {
|
||||
return this._mimeType;
|
||||
}
|
||||
|
||||
get downloadCount(): number {
|
||||
return this._downloadCount;
|
||||
}
|
||||
|
||||
get lastDownloadAt(): Date | null {
|
||||
return this._lastDownloadAt;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get expiresAt(): Date | null {
|
||||
return this._expiresAt;
|
||||
}
|
||||
|
||||
// Commands
|
||||
recordDownload(): void {
|
||||
this._downloadCount++;
|
||||
this._lastDownloadAt = new Date();
|
||||
}
|
||||
|
||||
setFileUrl(url: string): void {
|
||||
this._fileUrl = url;
|
||||
}
|
||||
|
||||
setExpiration(expiresAt: Date): void {
|
||||
this._expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
if (!this._expiresAt) {
|
||||
return false;
|
||||
}
|
||||
return new Date() > this._expiresAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly eventId: string;
|
||||
public readonly occurredAt: Date;
|
||||
public readonly version: number;
|
||||
|
||||
protected constructor(version: number = 1) {
|
||||
this.eventId = uuidv4();
|
||||
this.occurredAt = new Date();
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
abstract get aggregateId(): string;
|
||||
abstract get aggregateType(): string;
|
||||
abstract toPayload(): Record<string, any>;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './domain-event.base';
|
||||
export * from './report-generated.event';
|
||||
export * from './report-exported.event';
|
||||
export * from './snapshot-created.event';
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
import { OutputFormat } from '../value-objects/output-format.enum';
|
||||
|
||||
export interface ReportExportedPayload {
|
||||
fileId: string;
|
||||
snapshotId: string;
|
||||
format: OutputFormat;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
exportedAt: Date;
|
||||
}
|
||||
|
||||
export class ReportExportedEvent extends DomainEvent {
|
||||
constructor(private readonly payload: ReportExportedPayload) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ReportExported';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.payload.fileId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'ReportFile';
|
||||
}
|
||||
|
||||
toPayload(): ReportExportedPayload {
|
||||
return { ...this.payload };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
import { ReportType } from '../value-objects/report-type.enum';
|
||||
|
||||
export interface ReportGeneratedPayload {
|
||||
snapshotId: string;
|
||||
reportType: ReportType;
|
||||
reportCode: string;
|
||||
periodKey: string;
|
||||
rowCount: number;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
export class ReportGeneratedEvent extends DomainEvent {
|
||||
constructor(private readonly payload: ReportGeneratedPayload) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ReportGenerated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.payload.snapshotId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'ReportSnapshot';
|
||||
}
|
||||
|
||||
toPayload(): ReportGeneratedPayload {
|
||||
return { ...this.payload };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
import { ReportType } from '../value-objects/report-type.enum';
|
||||
import { ReportPeriod } from '../value-objects/report-period.enum';
|
||||
|
||||
export interface SnapshotCreatedPayload {
|
||||
snapshotId: string;
|
||||
reportType: ReportType;
|
||||
reportCode: string;
|
||||
reportPeriod: ReportPeriod;
|
||||
periodKey: string;
|
||||
rowCount: number;
|
||||
periodStartAt: Date;
|
||||
periodEndAt: Date;
|
||||
}
|
||||
|
||||
export class SnapshotCreatedEvent extends DomainEvent {
|
||||
constructor(private readonly payload: SnapshotCreatedPayload) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SnapshotCreated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.payload.snapshotId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'ReportSnapshot';
|
||||
}
|
||||
|
||||
toPayload(): SnapshotCreatedPayload {
|
||||
return { ...this.payload };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './report-definition.repository.interface';
|
||||
export * from './report-snapshot.repository.interface';
|
||||
export * from './report-file.repository.interface';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { ReportDefinition } from '../aggregates/report-definition';
|
||||
import { ReportType } from '../value-objects';
|
||||
|
||||
export const REPORT_DEFINITION_REPOSITORY = Symbol('REPORT_DEFINITION_REPOSITORY');
|
||||
|
||||
export interface IReportDefinitionRepository {
|
||||
save(definition: ReportDefinition): Promise<ReportDefinition>;
|
||||
findById(id: bigint): Promise<ReportDefinition | null>;
|
||||
findByCode(code: string): Promise<ReportDefinition | null>;
|
||||
findByType(type: ReportType): Promise<ReportDefinition[]>;
|
||||
findActive(): Promise<ReportDefinition[]>;
|
||||
findScheduled(): Promise<ReportDefinition[]>;
|
||||
findAll(): Promise<ReportDefinition[]>;
|
||||
delete(id: bigint): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { ReportFile } from '../entities/report-file.entity';
|
||||
import { OutputFormat } from '../value-objects';
|
||||
|
||||
export const REPORT_FILE_REPOSITORY = Symbol('REPORT_FILE_REPOSITORY');
|
||||
|
||||
export interface IReportFileRepository {
|
||||
save(file: ReportFile): Promise<ReportFile>;
|
||||
findById(id: bigint): Promise<ReportFile | null>;
|
||||
findBySnapshotId(snapshotId: bigint): Promise<ReportFile[]>;
|
||||
findBySnapshotIdAndFormat(
|
||||
snapshotId: bigint,
|
||||
format: OutputFormat,
|
||||
): Promise<ReportFile | null>;
|
||||
findExpired(): Promise<ReportFile[]>;
|
||||
deleteExpired(): Promise<number>;
|
||||
delete(id: bigint): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { ReportSnapshot } from '../aggregates/report-snapshot';
|
||||
import { ReportType, ReportPeriod } from '../value-objects';
|
||||
|
||||
export const REPORT_SNAPSHOT_REPOSITORY = Symbol('REPORT_SNAPSHOT_REPOSITORY');
|
||||
|
||||
export interface IReportSnapshotRepository {
|
||||
save(snapshot: ReportSnapshot): Promise<ReportSnapshot>;
|
||||
findById(id: bigint): Promise<ReportSnapshot | null>;
|
||||
findByCodeAndPeriodKey(
|
||||
reportCode: string,
|
||||
periodKey: string,
|
||||
): Promise<ReportSnapshot | null>;
|
||||
findByType(type: ReportType): Promise<ReportSnapshot[]>;
|
||||
findByCode(reportCode: string): Promise<ReportSnapshot[]>;
|
||||
findByPeriod(period: ReportPeriod): Promise<ReportSnapshot[]>;
|
||||
findLatestByCode(reportCode: string): Promise<ReportSnapshot | null>;
|
||||
findExpired(): Promise<ReportSnapshot[]>;
|
||||
deleteExpired(): Promise<number>;
|
||||
delete(id: bigint): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './report-generation.service';
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ReportSnapshot } from '../aggregates/report-snapshot';
|
||||
import { ReportType, ReportPeriod, SnapshotData, DataSource } from '../value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class ReportGenerationDomainService {
|
||||
generateSnapshot(params: {
|
||||
reportType: ReportType;
|
||||
reportCode: string;
|
||||
reportPeriod: ReportPeriod;
|
||||
periodKey: string;
|
||||
rows: any[];
|
||||
summary?: Record<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
dataSources: string[];
|
||||
filterParams?: Record<string, any>;
|
||||
periodStartAt: Date;
|
||||
periodEndAt: Date;
|
||||
expiresAt?: Date;
|
||||
}): ReportSnapshot {
|
||||
const snapshotData = SnapshotData.create({
|
||||
rows: params.rows,
|
||||
summary: params.summary,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
|
||||
const dataSource = DataSource.create(params.dataSources);
|
||||
|
||||
return ReportSnapshot.create({
|
||||
reportType: params.reportType,
|
||||
reportCode: params.reportCode,
|
||||
reportPeriod: params.reportPeriod,
|
||||
periodKey: params.periodKey,
|
||||
snapshotData,
|
||||
dataSource,
|
||||
filterParams: params.filterParams,
|
||||
periodStartAt: params.periodStartAt,
|
||||
periodEndAt: params.periodEndAt,
|
||||
expiresAt: params.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
calculateGrowthRate(
|
||||
currentValue: number,
|
||||
previousValue: number,
|
||||
): number {
|
||||
if (previousValue === 0) {
|
||||
return currentValue > 0 ? 100 : 0;
|
||||
}
|
||||
return ((currentValue - previousValue) / previousValue) * 100;
|
||||
}
|
||||
|
||||
calculateRank<T>(
|
||||
items: T[],
|
||||
valueGetter: (item: T) => number,
|
||||
): (T & { rank: number })[] {
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => valueGetter(b) - valueGetter(a),
|
||||
);
|
||||
|
||||
return sorted.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export class DataSource {
|
||||
private constructor(
|
||||
public readonly sources: string[],
|
||||
public readonly queryTime: Date,
|
||||
public readonly dataFreshness: number,
|
||||
) {}
|
||||
|
||||
static create(sources: string[]): DataSource {
|
||||
return new DataSource(sources, new Date(), 0);
|
||||
}
|
||||
|
||||
static withFreshness(sources: string[], freshnessSeconds: number): DataSource {
|
||||
return new DataSource(sources, new Date(), freshnessSeconds);
|
||||
}
|
||||
|
||||
isFresh(maxAgeSeconds: number): boolean {
|
||||
const age = (Date.now() - this.queryTime.getTime()) / 1000;
|
||||
return age <= maxAgeSeconds;
|
||||
}
|
||||
|
||||
addSource(source: string): DataSource {
|
||||
return new DataSource(
|
||||
[...this.sources, source],
|
||||
this.queryTime,
|
||||
this.dataFreshness,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { DateRange } from './date-range.vo';
|
||||
import { ReportPeriod } from './report-period.enum';
|
||||
|
||||
describe('DateRange Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create a date range', () => {
|
||||
const start = new Date(2024, 0, 1); // Jan 1, 2024
|
||||
const end = new Date(2024, 0, 31); // Jan 31, 2024
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.startDate).toEqual(start);
|
||||
expect(range.endDate).toEqual(end);
|
||||
});
|
||||
|
||||
it('should throw error if start date is after end date', () => {
|
||||
const start = new Date(2024, 1, 1); // Feb 1, 2024
|
||||
const end = new Date(2024, 0, 31); // Jan 31, 2024
|
||||
|
||||
expect(() => DateRange.create(start, end)).toThrow('开始日期不能大于结束日期');
|
||||
});
|
||||
});
|
||||
|
||||
describe('static factory methods', () => {
|
||||
it('should create today range', () => {
|
||||
const range = DateRange.today();
|
||||
const now = new Date();
|
||||
|
||||
expect(range.startDate.getDate()).toBe(now.getDate());
|
||||
expect(range.endDate.getDate()).toBe(now.getDate());
|
||||
});
|
||||
|
||||
it('should create this week range', () => {
|
||||
const range = DateRange.thisWeek();
|
||||
|
||||
expect(range.getDays()).toBe(7);
|
||||
});
|
||||
|
||||
it('should create this month range', () => {
|
||||
const range = DateRange.thisMonth();
|
||||
const now = new Date();
|
||||
|
||||
expect(range.startDate.getMonth()).toBe(now.getMonth());
|
||||
expect(range.startDate.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should create this year range', () => {
|
||||
const range = DateRange.thisYear();
|
||||
const now = new Date();
|
||||
|
||||
expect(range.startDate.getFullYear()).toBe(now.getFullYear());
|
||||
expect(range.startDate.getMonth()).toBe(0);
|
||||
expect(range.startDate.getDate()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
it('should calculate days correctly', () => {
|
||||
const start = new Date(2024, 0, 1, 0, 0, 0);
|
||||
const end = new Date(2024, 0, 10, 23, 59, 59);
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.getDays()).toBe(10);
|
||||
});
|
||||
|
||||
it('should check if date is contained', () => {
|
||||
const start = new Date(2024, 0, 1);
|
||||
const end = new Date(2024, 0, 31);
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.contains(new Date(2024, 0, 15))).toBe(true);
|
||||
expect(range.contains(new Date(2024, 1, 1))).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate period key for daily', () => {
|
||||
const start = new Date(2024, 0, 15);
|
||||
const end = new Date(2024, 0, 15);
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.toPeriodKey(ReportPeriod.DAILY)).toBe('2024-01-15');
|
||||
});
|
||||
|
||||
it('should generate period key for monthly', () => {
|
||||
const start = new Date(2024, 0, 1);
|
||||
const end = new Date(2024, 0, 31);
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.toPeriodKey(ReportPeriod.MONTHLY)).toBe('2024-01');
|
||||
});
|
||||
|
||||
it('should generate period key for yearly', () => {
|
||||
const start = new Date(2024, 0, 1);
|
||||
const end = new Date(2024, 11, 31);
|
||||
const range = DateRange.create(start, end);
|
||||
|
||||
expect(range.toPeriodKey(ReportPeriod.YEARLY)).toBe('2024');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { ReportPeriod } from './report-period.enum';
|
||||
|
||||
export class DateRange {
|
||||
private constructor(
|
||||
public readonly startDate: Date,
|
||||
public readonly endDate: Date,
|
||||
) {
|
||||
if (startDate > endDate) {
|
||||
throw new Error('开始日期不能大于结束日期');
|
||||
}
|
||||
}
|
||||
|
||||
static create(startDate: Date, endDate: Date): DateRange {
|
||||
return new DateRange(startDate, endDate);
|
||||
}
|
||||
|
||||
static today(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const end = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
static thisWeek(): DateRange {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diffToMonday);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
sunday.setHours(23, 59, 59, 999);
|
||||
|
||||
return new DateRange(monday, sunday);
|
||||
}
|
||||
|
||||
static thisMonth(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0);
|
||||
const end = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
static thisQuarter(): DateRange {
|
||||
const now = new Date();
|
||||
const quarter = Math.floor(now.getMonth() / 3);
|
||||
const start = new Date(now.getFullYear(), quarter * 3, 1, 0, 0, 0);
|
||||
const end = new Date(
|
||||
now.getFullYear(),
|
||||
quarter * 3 + 3,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
static thisYear(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0);
|
||||
const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
getDays(): number {
|
||||
const diff = this.endDate.getTime() - this.startDate.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
contains(date: Date): boolean {
|
||||
return date >= this.startDate && date <= this.endDate;
|
||||
}
|
||||
|
||||
toPeriodKey(period: ReportPeriod): string {
|
||||
const year = this.startDate.getFullYear();
|
||||
const month = (this.startDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = this.startDate.getDate().toString().padStart(2, '0');
|
||||
|
||||
switch (period) {
|
||||
case ReportPeriod.DAILY:
|
||||
return `${year}-${month}-${day}`;
|
||||
case ReportPeriod.WEEKLY:
|
||||
const weekNumber = this.getWeekNumber(this.startDate);
|
||||
return `${year}-W${weekNumber.toString().padStart(2, '0')}`;
|
||||
case ReportPeriod.MONTHLY:
|
||||
return `${year}-${month}`;
|
||||
case ReportPeriod.QUARTERLY:
|
||||
const quarter = Math.floor(this.startDate.getMonth() / 3) + 1;
|
||||
return `${year}-Q${quarter}`;
|
||||
case ReportPeriod.YEARLY:
|
||||
return `${year}`;
|
||||
default:
|
||||
return `${year}-${month}-${day}_to_${this.endDate.getFullYear()}-${(this.endDate.getMonth() + 1).toString().padStart(2, '0')}-${this.endDate.getDate().toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
private getWeekNumber(date: Date): number {
|
||||
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const pastDaysOfYear =
|
||||
(date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
||||
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export * from './report-type.enum';
|
||||
export * from './report-period.enum';
|
||||
export * from './report-dimension.enum';
|
||||
export * from './output-format.enum';
|
||||
export * from './date-range.vo';
|
||||
export * from './report-parameters.vo';
|
||||
export * from './report-schedule.vo';
|
||||
export * from './snapshot-data.vo';
|
||||
export * from './data-source.vo';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export enum OutputFormat {
|
||||
EXCEL = 'EXCEL',
|
||||
PDF = 'PDF',
|
||||
CSV = 'CSV',
|
||||
JSON = 'JSON',
|
||||
}
|
||||
|
||||
export const OutputFormatMimeTypes: Record<OutputFormat, string> = {
|
||||
[OutputFormat.EXCEL]:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
[OutputFormat.PDF]: 'application/pdf',
|
||||
[OutputFormat.CSV]: 'text/csv',
|
||||
[OutputFormat.JSON]: 'application/json',
|
||||
};
|
||||
|
||||
export const OutputFormatExtensions: Record<OutputFormat, string> = {
|
||||
[OutputFormat.EXCEL]: 'xlsx',
|
||||
[OutputFormat.PDF]: 'pdf',
|
||||
[OutputFormat.CSV]: 'csv',
|
||||
[OutputFormat.JSON]: 'json',
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export enum ReportDimension {
|
||||
TIME = 'TIME',
|
||||
REGION = 'REGION',
|
||||
USER = 'USER',
|
||||
USER_TYPE = 'USER_TYPE',
|
||||
RIGHT_TYPE = 'RIGHT_TYPE',
|
||||
COMMUNITY = 'COMMUNITY',
|
||||
ACCOUNT = 'ACCOUNT',
|
||||
SOURCE = 'SOURCE',
|
||||
PRODUCT = 'PRODUCT',
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { ReportDimension } from './report-dimension.enum';
|
||||
import { DateRange } from './date-range.vo';
|
||||
|
||||
export class ReportParameters {
|
||||
private constructor(
|
||||
public readonly dateRange: DateRange,
|
||||
public readonly dimensions: ReportDimension[],
|
||||
public readonly filters: Record<string, any>,
|
||||
public readonly groupBy: string[],
|
||||
public readonly orderBy: { field: string; direction: 'ASC' | 'DESC' }[],
|
||||
public readonly pagination: { page: number; pageSize: number } | null,
|
||||
) {}
|
||||
|
||||
static create(params: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dimensions?: ReportDimension[];
|
||||
filters?: Record<string, any>;
|
||||
groupBy?: string[];
|
||||
orderBy?: { field: string; direction: 'ASC' | 'DESC' }[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): ReportParameters {
|
||||
return new ReportParameters(
|
||||
DateRange.create(params.startDate, params.endDate),
|
||||
params.dimensions || [],
|
||||
params.filters || {},
|
||||
params.groupBy || [],
|
||||
params.orderBy || [],
|
||||
params.page && params.pageSize
|
||||
? { page: params.page, pageSize: params.pageSize }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
withFilter(key: string, value: any): ReportParameters {
|
||||
return new ReportParameters(
|
||||
this.dateRange,
|
||||
this.dimensions,
|
||||
{ ...this.filters, [key]: value },
|
||||
this.groupBy,
|
||||
this.orderBy,
|
||||
this.pagination,
|
||||
);
|
||||
}
|
||||
|
||||
withDimension(dimension: ReportDimension): ReportParameters {
|
||||
return new ReportParameters(
|
||||
this.dateRange,
|
||||
[...this.dimensions, dimension],
|
||||
this.filters,
|
||||
this.groupBy,
|
||||
this.orderBy,
|
||||
this.pagination,
|
||||
);
|
||||
}
|
||||
|
||||
hasFilter(key: string): boolean {
|
||||
return key in this.filters;
|
||||
}
|
||||
|
||||
getFilter<T>(key: string, defaultValue?: T): T | undefined {
|
||||
return this.filters[key] ?? defaultValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export enum ReportPeriod {
|
||||
DAILY = 'DAILY',
|
||||
WEEKLY = 'WEEKLY',
|
||||
MONTHLY = 'MONTHLY',
|
||||
QUARTERLY = 'QUARTERLY',
|
||||
YEARLY = 'YEARLY',
|
||||
CUSTOM = 'CUSTOM',
|
||||
}
|
||||
|
||||
export const ReportPeriodLabels: Record<ReportPeriod, string> = {
|
||||
[ReportPeriod.DAILY]: '日报表',
|
||||
[ReportPeriod.WEEKLY]: '周报表',
|
||||
[ReportPeriod.MONTHLY]: '月报表',
|
||||
[ReportPeriod.QUARTERLY]: '季度报表',
|
||||
[ReportPeriod.YEARLY]: '年度报表',
|
||||
[ReportPeriod.CUSTOM]: '自定义周期',
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
export class ReportSchedule {
|
||||
private constructor(
|
||||
public readonly cronExpression: string,
|
||||
public readonly timezone: string,
|
||||
public readonly enabled: boolean,
|
||||
) {}
|
||||
|
||||
static create(
|
||||
cronExpression: string,
|
||||
timezone: string = 'Asia/Shanghai',
|
||||
enabled: boolean = true,
|
||||
): ReportSchedule {
|
||||
return new ReportSchedule(cronExpression, timezone, enabled);
|
||||
}
|
||||
|
||||
static daily(hour: number = 0, minute: number = 0): ReportSchedule {
|
||||
return new ReportSchedule(`${minute} ${hour} * * *`, 'Asia/Shanghai', true);
|
||||
}
|
||||
|
||||
static weekly(
|
||||
dayOfWeek: number,
|
||||
hour: number = 0,
|
||||
minute: number = 0,
|
||||
): ReportSchedule {
|
||||
return new ReportSchedule(
|
||||
`${minute} ${hour} * * ${dayOfWeek}`,
|
||||
'Asia/Shanghai',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
static monthly(
|
||||
dayOfMonth: number,
|
||||
hour: number = 0,
|
||||
minute: number = 0,
|
||||
): ReportSchedule {
|
||||
return new ReportSchedule(
|
||||
`${minute} ${hour} ${dayOfMonth} * *`,
|
||||
'Asia/Shanghai',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
static quarterly(dayOfQuarter: number = 1, hour: number = 0): ReportSchedule {
|
||||
return new ReportSchedule(
|
||||
`0 ${hour} ${dayOfQuarter} 1,4,7,10 *`,
|
||||
'Asia/Shanghai',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
enable(): ReportSchedule {
|
||||
return new ReportSchedule(this.cronExpression, this.timezone, true);
|
||||
}
|
||||
|
||||
disable(): ReportSchedule {
|
||||
return new ReportSchedule(this.cronExpression, this.timezone, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export enum ReportType {
|
||||
// 龙虎榜报表
|
||||
LEADERBOARD_REPORT = 'LEADERBOARD_REPORT',
|
||||
|
||||
// 认种报表
|
||||
PLANTING_REPORT = 'PLANTING_REPORT',
|
||||
REGIONAL_PLANTING_REPORT = 'REGIONAL_PLANTING_REPORT',
|
||||
|
||||
// 授权公司报表
|
||||
AUTHORIZED_COMPANY_TOP_REPORT = 'AUTHORIZED_COMPANY_TOP_REPORT',
|
||||
|
||||
// 社区报表
|
||||
COMMUNITY_REPORT = 'COMMUNITY_REPORT',
|
||||
|
||||
// 系统账户报表
|
||||
SYSTEM_ACCOUNT_MONTHLY_REPORT = 'SYSTEM_ACCOUNT_MONTHLY_REPORT',
|
||||
SYSTEM_ACCOUNT_INCOME_REPORT = 'SYSTEM_ACCOUNT_INCOME_REPORT',
|
||||
}
|
||||
|
||||
export const ReportTypeLabels: Record<ReportType, string> = {
|
||||
[ReportType.LEADERBOARD_REPORT]: '龙虎榜数据报表',
|
||||
[ReportType.PLANTING_REPORT]: '榴莲树认种报表',
|
||||
[ReportType.REGIONAL_PLANTING_REPORT]: '区域认种报表',
|
||||
[ReportType.AUTHORIZED_COMPANY_TOP_REPORT]: '授权公司第1名统计',
|
||||
[ReportType.COMMUNITY_REPORT]: '社区数据统计',
|
||||
[ReportType.SYSTEM_ACCOUNT_MONTHLY_REPORT]: '系统账户月度报表',
|
||||
[ReportType.SYSTEM_ACCOUNT_INCOME_REPORT]: '系统账户收益来源报表',
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
export class SnapshotData {
|
||||
private constructor(
|
||||
public readonly rows: any[],
|
||||
public readonly summary: Record<string, any>,
|
||||
public readonly metadata: Record<string, any>,
|
||||
) {}
|
||||
|
||||
static create(params: {
|
||||
rows: any[];
|
||||
summary?: Record<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
}): SnapshotData {
|
||||
return new SnapshotData(
|
||||
params.rows,
|
||||
params.summary || {},
|
||||
params.metadata || {},
|
||||
);
|
||||
}
|
||||
|
||||
getRowCount(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.rows.length === 0;
|
||||
}
|
||||
|
||||
getSummary<T>(key: string, defaultValue?: T): T | undefined {
|
||||
return this.summary[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
getMetadata<T>(key: string, defaultValue?: T): T | undefined {
|
||||
return this.metadata[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
toJSON(): object {
|
||||
return {
|
||||
rows: this.rows,
|
||||
summary: this.summary,
|
||||
metadata: this.metadata,
|
||||
rowCount: this.getRowCount(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { stringify } from 'csv-stringify/sync';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface CsvColumn {
|
||||
header: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CsvExportOptions {
|
||||
columns: CsvColumn[];
|
||||
data: any[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CsvExportService {
|
||||
private readonly logger = new Logger(CsvExportService.name);
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.storagePath = this.configService.get<string>(
|
||||
'FILE_STORAGE_PATH',
|
||||
'./storage/reports',
|
||||
);
|
||||
}
|
||||
|
||||
async export(
|
||||
fileName: string,
|
||||
options: CsvExportOptions,
|
||||
): Promise<{ filePath: string; fileSize: number }> {
|
||||
const headers = options.columns.map((col) => col.header);
|
||||
const keys = options.columns.map((col) => col.key);
|
||||
|
||||
const rows = options.data.map((item) =>
|
||||
keys.map((key) => {
|
||||
const value = item[key];
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}),
|
||||
);
|
||||
|
||||
const csvContent = stringify([headers, ...rows]);
|
||||
|
||||
// Add BOM for Excel compatibility with Chinese characters
|
||||
const bom = '\uFEFF';
|
||||
const contentWithBom = bom + csvContent;
|
||||
|
||||
// Ensure storage directory exists
|
||||
const fullPath = path.join(this.storagePath, fileName);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
fs.writeFileSync(fullPath, contentWithBom, 'utf8');
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
this.logger.log(`CSV file exported: ${fullPath} (${stats.size} bytes)`);
|
||||
|
||||
return {
|
||||
filePath: fullPath,
|
||||
fileSize: stats.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface ExcelColumn {
|
||||
header: string;
|
||||
key: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ExcelExportOptions {
|
||||
sheetName?: string;
|
||||
columns: ExcelColumn[];
|
||||
data: any[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExcelExportService {
|
||||
private readonly logger = new Logger(ExcelExportService.name);
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.storagePath = this.configService.get<string>(
|
||||
'FILE_STORAGE_PATH',
|
||||
'./storage/reports',
|
||||
);
|
||||
}
|
||||
|
||||
async export(
|
||||
fileName: string,
|
||||
options: ExcelExportOptions,
|
||||
): Promise<{ filePath: string; fileSize: number }> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Reporting Service';
|
||||
workbook.created = new Date();
|
||||
|
||||
const worksheet = workbook.addWorksheet(options.sheetName || 'Report');
|
||||
|
||||
// Add title if provided
|
||||
if (options.title) {
|
||||
worksheet.addRow([options.title]);
|
||||
worksheet.mergeCells(1, 1, 1, options.columns.length);
|
||||
const titleRow = worksheet.getRow(1);
|
||||
titleRow.font = { bold: true, size: 16 };
|
||||
titleRow.alignment = { horizontal: 'center' };
|
||||
}
|
||||
|
||||
// Add subtitle if provided
|
||||
if (options.subtitle) {
|
||||
const subtitleRowNum = options.title ? 2 : 1;
|
||||
worksheet.addRow([options.subtitle]);
|
||||
worksheet.mergeCells(subtitleRowNum, 1, subtitleRowNum, options.columns.length);
|
||||
const subtitleRow = worksheet.getRow(subtitleRowNum);
|
||||
subtitleRow.font = { size: 12 };
|
||||
subtitleRow.alignment = { horizontal: 'center' };
|
||||
}
|
||||
|
||||
// Add empty row before headers
|
||||
if (options.title || options.subtitle) {
|
||||
worksheet.addRow([]);
|
||||
}
|
||||
|
||||
// Setup columns
|
||||
worksheet.columns = options.columns.map((col) => ({
|
||||
header: col.header,
|
||||
key: col.key,
|
||||
width: col.width || 15,
|
||||
}));
|
||||
|
||||
// Style header row
|
||||
const headerRowNum = (options.title ? 1 : 0) + (options.subtitle ? 1 : 0) + (options.title || options.subtitle ? 1 : 0) + 1;
|
||||
const headerRow = worksheet.getRow(headerRowNum);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
// Add data rows
|
||||
options.data.forEach((row) => {
|
||||
worksheet.addRow(row);
|
||||
});
|
||||
|
||||
// Ensure storage directory exists
|
||||
const fullPath = path.join(this.storagePath, fileName);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
await workbook.xlsx.writeFile(fullPath);
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
this.logger.log(`Excel file exported: ${fullPath} (${stats.size} bytes)`);
|
||||
|
||||
return {
|
||||
filePath: fullPath,
|
||||
fileSize: stats.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExcelExportService } from './excel-export.service';
|
||||
import { CsvExportService } from './csv-export.service';
|
||||
import { PdfExportService } from './pdf-export.service';
|
||||
|
||||
@Module({
|
||||
providers: [ExcelExportService, CsvExportService, PdfExportService],
|
||||
exports: [ExcelExportService, CsvExportService, PdfExportService],
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as PDFDocument from 'pdfkit';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface PdfColumn {
|
||||
header: string;
|
||||
key: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface PdfExportOptions {
|
||||
columns: PdfColumn[];
|
||||
data: any[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PdfExportService {
|
||||
private readonly logger = new Logger(PdfExportService.name);
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.storagePath = this.configService.get<string>(
|
||||
'FILE_STORAGE_PATH',
|
||||
'./storage/reports',
|
||||
);
|
||||
}
|
||||
|
||||
async export(
|
||||
fileName: string,
|
||||
options: PdfExportOptions,
|
||||
): Promise<{ filePath: string; fileSize: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: options.orientation || 'portrait',
|
||||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
||||
});
|
||||
|
||||
// Ensure storage directory exists
|
||||
const fullPath = path.join(this.storagePath, fileName);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(fullPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
// Title
|
||||
if (options.title) {
|
||||
doc.fontSize(18).text(options.title, { align: 'center' });
|
||||
doc.moveDown();
|
||||
}
|
||||
|
||||
// Subtitle
|
||||
if (options.subtitle) {
|
||||
doc.fontSize(12).text(options.subtitle, { align: 'center' });
|
||||
doc.moveDown();
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
const pageWidth =
|
||||
doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const columnCount = options.columns.length;
|
||||
const defaultWidth = pageWidth / columnCount;
|
||||
|
||||
// Draw table header
|
||||
doc.fontSize(10);
|
||||
let x = doc.page.margins.left;
|
||||
const headerY = doc.y;
|
||||
|
||||
// Header background
|
||||
doc
|
||||
.fillColor('#e0e0e0')
|
||||
.rect(x, headerY - 5, pageWidth, 20)
|
||||
.fill();
|
||||
|
||||
// Header text
|
||||
doc.fillColor('#000000');
|
||||
options.columns.forEach((col) => {
|
||||
const width = col.width || defaultWidth;
|
||||
doc.text(col.header, x, headerY, {
|
||||
width: width - 5,
|
||||
align: 'left',
|
||||
});
|
||||
x += width;
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
|
||||
// Draw data rows
|
||||
options.data.forEach((row, rowIndex) => {
|
||||
// Check if we need a new page
|
||||
if (doc.y > doc.page.height - 100) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
x = doc.page.margins.left;
|
||||
const rowY = doc.y;
|
||||
|
||||
// Alternate row background
|
||||
if (rowIndex % 2 === 1) {
|
||||
doc
|
||||
.fillColor('#f5f5f5')
|
||||
.rect(x, rowY - 5, pageWidth, 15)
|
||||
.fill();
|
||||
}
|
||||
|
||||
doc.fillColor('#000000');
|
||||
options.columns.forEach((col) => {
|
||||
const width = col.width || defaultWidth;
|
||||
const value = row[col.key];
|
||||
const displayValue =
|
||||
value === null || value === undefined ? '' : String(value);
|
||||
doc.text(displayValue, x, rowY, {
|
||||
width: width - 5,
|
||||
align: 'left',
|
||||
});
|
||||
x += width;
|
||||
});
|
||||
|
||||
doc.moveDown(0.5);
|
||||
});
|
||||
|
||||
// Footer
|
||||
doc
|
||||
.fontSize(8)
|
||||
.text(
|
||||
`Generated at: ${new Date().toISOString()}`,
|
||||
doc.page.margins.left,
|
||||
doc.page.height - 30,
|
||||
{ align: 'center' },
|
||||
);
|
||||
|
||||
doc.end();
|
||||
|
||||
stream.on('finish', () => {
|
||||
const stats = fs.statSync(fullPath);
|
||||
this.logger.log(`PDF file exported: ${fullPath} (${stats.size} bytes)`);
|
||||
resolve({
|
||||
filePath: fullPath,
|
||||
fileSize: stats.size,
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
userId: bigint;
|
||||
username: string;
|
||||
score: number;
|
||||
teamCount?: number;
|
||||
plantingCount?: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardData {
|
||||
type: string;
|
||||
period: string;
|
||||
entries: LeaderboardEntry[];
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LeaderboardServiceClient {
|
||||
private readonly logger = new Logger(LeaderboardServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
'LEADERBOARD_SERVICE_URL',
|
||||
'http://localhost:3007',
|
||||
);
|
||||
}
|
||||
|
||||
async getDailyLeaderboard(date?: Date): Promise<LeaderboardData> {
|
||||
// In production, this would make an HTTP call to leaderboard-service
|
||||
this.logger.debug(`Fetching daily leaderboard from ${this.baseUrl}`);
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
type: 'DAILY',
|
||||
period: (date || new Date()).toISOString().split('T')[0],
|
||||
entries: [],
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async getWeeklyLeaderboard(weekKey?: string): Promise<LeaderboardData> {
|
||||
this.logger.debug(`Fetching weekly leaderboard from ${this.baseUrl}`);
|
||||
|
||||
return {
|
||||
type: 'WEEKLY',
|
||||
period: weekKey || this.getCurrentWeekKey(),
|
||||
entries: [],
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async getMonthlyLeaderboard(monthKey?: string): Promise<LeaderboardData> {
|
||||
this.logger.debug(`Fetching monthly leaderboard from ${this.baseUrl}`);
|
||||
|
||||
return {
|
||||
type: 'MONTHLY',
|
||||
period: monthKey || this.getCurrentMonthKey(),
|
||||
entries: [],
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async getTopByRegion(
|
||||
regionCode: string,
|
||||
limit: number = 10,
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
this.logger.debug(
|
||||
`Fetching top ${limit} for region ${regionCode} from ${this.baseUrl}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private getCurrentWeekKey(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const weekNumber = Math.ceil(
|
||||
((now.getTime() - new Date(year, 0, 1).getTime()) / 86400000 +
|
||||
new Date(year, 0, 1).getDay() +
|
||||
1) /
|
||||
7,
|
||||
);
|
||||
return `${year}-W${weekNumber.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private getCurrentMonthKey(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface PlantingStats {
|
||||
totalOrders: number;
|
||||
totalTrees: number;
|
||||
totalAmount: string;
|
||||
newUsers: number;
|
||||
activeUsers: number;
|
||||
}
|
||||
|
||||
export interface RegionalPlantingStats extends PlantingStats {
|
||||
regionCode: string;
|
||||
regionName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlantingServiceClient {
|
||||
private readonly logger = new Logger(PlantingServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
'PLANTING_SERVICE_URL',
|
||||
'http://localhost:3003',
|
||||
);
|
||||
}
|
||||
|
||||
async getDailyStats(date: Date): Promise<PlantingStats> {
|
||||
this.logger.debug(`Fetching daily planting stats from ${this.baseUrl}`);
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
totalOrders: 0,
|
||||
totalTrees: 0,
|
||||
totalAmount: '0',
|
||||
newUsers: 0,
|
||||
activeUsers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatsForDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<PlantingStats> {
|
||||
this.logger.debug(
|
||||
`Fetching planting stats for range ${startDate} - ${endDate} from ${this.baseUrl}`,
|
||||
);
|
||||
|
||||
return {
|
||||
totalOrders: 0,
|
||||
totalTrees: 0,
|
||||
totalAmount: '0',
|
||||
newUsers: 0,
|
||||
activeUsers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getRegionalStats(
|
||||
regionCode: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<RegionalPlantingStats> {
|
||||
this.logger.debug(
|
||||
`Fetching regional planting stats for ${regionCode} from ${this.baseUrl}`,
|
||||
);
|
||||
|
||||
return {
|
||||
regionCode,
|
||||
regionName: '',
|
||||
totalOrders: 0,
|
||||
totalTrees: 0,
|
||||
totalAmount: '0',
|
||||
newUsers: 0,
|
||||
activeUsers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllProvincesStats(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<RegionalPlantingStats[]> {
|
||||
this.logger.debug(`Fetching all provinces planting stats from ${this.baseUrl}`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAllCitiesStats(
|
||||
provinceCode: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<RegionalPlantingStats[]> {
|
||||
this.logger.debug(
|
||||
`Fetching all cities planting stats for province ${provinceCode} from ${this.baseUrl}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { ReportDefinitionRepository } from './persistence/repositories/report-definition.repository.impl';
|
||||
import { ReportSnapshotRepository } from './persistence/repositories/report-snapshot.repository.impl';
|
||||
import { ReportFileRepository } from './persistence/repositories/report-file.repository.impl';
|
||||
import {
|
||||
REPORT_DEFINITION_REPOSITORY,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
REPORT_FILE_REPOSITORY,
|
||||
} from '../domain/repositories';
|
||||
import { LeaderboardServiceClient } from './external/leaderboard-service/leaderboard-service.client';
|
||||
import { PlantingServiceClient } from './external/planting-service/planting-service.client';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [ExportModule, RedisModule],
|
||||
providers: [
|
||||
PrismaService,
|
||||
{
|
||||
provide: REPORT_DEFINITION_REPOSITORY,
|
||||
useClass: ReportDefinitionRepository,
|
||||
},
|
||||
{
|
||||
provide: REPORT_SNAPSHOT_REPOSITORY,
|
||||
useClass: ReportSnapshotRepository,
|
||||
},
|
||||
{
|
||||
provide: REPORT_FILE_REPOSITORY,
|
||||
useClass: ReportFileRepository,
|
||||
},
|
||||
LeaderboardServiceClient,
|
||||
PlantingServiceClient,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
REPORT_DEFINITION_REPOSITORY,
|
||||
REPORT_SNAPSHOT_REPOSITORY,
|
||||
REPORT_FILE_REPOSITORY,
|
||||
LeaderboardServiceClient,
|
||||
PlantingServiceClient,
|
||||
ExportModule,
|
||||
RedisModule,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { ReportDefinition as PrismaReportDefinition } from '@prisma/client';
|
||||
import { ReportDefinition } from '../../../domain/aggregates/report-definition';
|
||||
import { ReportType, ReportSchedule, OutputFormat } from '../../../domain/value-objects';
|
||||
|
||||
export class ReportDefinitionMapper {
|
||||
static toDomain(raw: PrismaReportDefinition): ReportDefinition {
|
||||
const schedule =
|
||||
raw.scheduleCron
|
||||
? ReportSchedule.create(
|
||||
raw.scheduleCron,
|
||||
raw.scheduleTimezone || 'Asia/Shanghai',
|
||||
raw.scheduleEnabled,
|
||||
)
|
||||
: null;
|
||||
|
||||
return ReportDefinition.reconstitute({
|
||||
id: raw.id,
|
||||
reportType: raw.reportType as ReportType,
|
||||
reportName: raw.reportName,
|
||||
reportCode: raw.reportCode,
|
||||
description: raw.description || '',
|
||||
parameters: raw.parameters as Record<string, any>,
|
||||
schedule,
|
||||
outputFormats: raw.outputFormats as OutputFormat[],
|
||||
isActive: raw.isActive,
|
||||
createdAt: raw.createdAt,
|
||||
lastGeneratedAt: raw.lastGeneratedAt,
|
||||
});
|
||||
}
|
||||
|
||||
static toPersistence(domain: ReportDefinition): Omit<PrismaReportDefinition, 'id' | 'updatedAt'> & { id?: bigint } {
|
||||
return {
|
||||
id: domain.id || undefined,
|
||||
reportType: domain.reportType,
|
||||
reportName: domain.reportName,
|
||||
reportCode: domain.reportCode,
|
||||
description: domain.description,
|
||||
parameters: domain.parameters,
|
||||
scheduleCron: domain.schedule?.cronExpression || null,
|
||||
scheduleTimezone: domain.schedule?.timezone || 'Asia/Shanghai',
|
||||
scheduleEnabled: domain.schedule?.enabled || false,
|
||||
outputFormats: domain.outputFormats,
|
||||
isActive: domain.isActive,
|
||||
createdAt: domain.createdAt,
|
||||
lastGeneratedAt: domain.lastGeneratedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { ReportFile as PrismaReportFile } from '@prisma/client';
|
||||
import { ReportFile } from '../../../domain/entities/report-file.entity';
|
||||
import { OutputFormat } from '../../../domain/value-objects';
|
||||
|
||||
export class ReportFileMapper {
|
||||
static toDomain(raw: PrismaReportFile): ReportFile {
|
||||
return ReportFile.reconstitute({
|
||||
id: raw.id,
|
||||
snapshotId: raw.snapshotId,
|
||||
fileName: raw.fileName,
|
||||
filePath: raw.filePath,
|
||||
fileUrl: raw.fileUrl,
|
||||
fileSize: raw.fileSize,
|
||||
fileFormat: raw.fileFormat as OutputFormat,
|
||||
mimeType: raw.mimeType,
|
||||
downloadCount: raw.downloadCount,
|
||||
lastDownloadAt: raw.lastDownloadAt,
|
||||
createdAt: raw.createdAt,
|
||||
expiresAt: raw.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
static toPersistence(
|
||||
domain: ReportFile,
|
||||
): Omit<PrismaReportFile, 'id'> & { id?: bigint } {
|
||||
return {
|
||||
id: domain.id || undefined,
|
||||
snapshotId: domain.snapshotId,
|
||||
fileName: domain.fileName,
|
||||
filePath: domain.filePath,
|
||||
fileUrl: domain.fileUrl,
|
||||
fileSize: domain.fileSize,
|
||||
fileFormat: domain.fileFormat,
|
||||
mimeType: domain.mimeType,
|
||||
downloadCount: domain.downloadCount,
|
||||
lastDownloadAt: domain.lastDownloadAt,
|
||||
createdAt: domain.createdAt,
|
||||
expiresAt: domain.expiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { ReportSnapshot as PrismaReportSnapshot } from '@prisma/client';
|
||||
import { ReportSnapshot } from '../../../domain/aggregates/report-snapshot';
|
||||
import {
|
||||
ReportType,
|
||||
ReportPeriod,
|
||||
SnapshotData,
|
||||
DataSource,
|
||||
} from '../../../domain/value-objects';
|
||||
|
||||
export class ReportSnapshotMapper {
|
||||
static toDomain(raw: PrismaReportSnapshot): ReportSnapshot {
|
||||
const snapshotDataRaw = raw.snapshotData as {
|
||||
rows: any[];
|
||||
summary?: Record<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const snapshotData = SnapshotData.create({
|
||||
rows: snapshotDataRaw.rows || [],
|
||||
summary: snapshotDataRaw.summary,
|
||||
metadata: snapshotDataRaw.metadata,
|
||||
});
|
||||
|
||||
const dataSource = DataSource.withFreshness(
|
||||
raw.dataSources,
|
||||
raw.dataFreshness,
|
||||
);
|
||||
|
||||
return ReportSnapshot.reconstitute({
|
||||
id: raw.id,
|
||||
reportType: raw.reportType as ReportType,
|
||||
reportCode: raw.reportCode,
|
||||
reportPeriod: raw.reportPeriod as ReportPeriod,
|
||||
periodKey: raw.periodKey,
|
||||
snapshotData,
|
||||
dataSource,
|
||||
filterParams: raw.filterParams as Record<string, any> | null,
|
||||
periodStartAt: raw.periodStartAt,
|
||||
periodEndAt: raw.periodEndAt,
|
||||
generatedAt: raw.generatedAt,
|
||||
expiresAt: raw.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
static toPersistence(
|
||||
domain: ReportSnapshot,
|
||||
): Omit<PrismaReportSnapshot, 'id'> & { id?: bigint } {
|
||||
return {
|
||||
id: domain.id || undefined,
|
||||
reportType: domain.reportType,
|
||||
reportCode: domain.reportCode,
|
||||
reportPeriod: domain.reportPeriod,
|
||||
periodKey: domain.periodKey,
|
||||
snapshotData: domain.snapshotData.toJSON(),
|
||||
summaryData: domain.snapshotData.summary,
|
||||
dataSources: domain.dataSource.sources,
|
||||
dataFreshness: domain.dataSource.dataFreshness,
|
||||
filterParams: domain.filterParams,
|
||||
rowCount: domain.rowCount,
|
||||
periodStartAt: domain.periodStartAt,
|
||||
periodEndAt: domain.periodEndAt,
|
||||
generatedAt: domain.generatedAt,
|
||||
expiresAt: domain.expiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReportDefinition } from '../../../domain/aggregates/report-definition';
|
||||
import { IReportDefinitionRepository } from '../../../domain/repositories';
|
||||
import { ReportType } from '../../../domain/value-objects';
|
||||
import { ReportDefinitionMapper } from '../mappers/report-definition.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class ReportDefinitionRepository implements IReportDefinitionRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(definition: ReportDefinition): Promise<ReportDefinition> {
|
||||
const data = ReportDefinitionMapper.toPersistence(definition);
|
||||
|
||||
if (definition.id) {
|
||||
const updated = await this.prisma.reportDefinition.update({
|
||||
where: { id: definition.id },
|
||||
data: {
|
||||
reportName: data.reportName,
|
||||
description: data.description,
|
||||
parameters: data.parameters as Prisma.InputJsonValue,
|
||||
scheduleCron: data.scheduleCron,
|
||||
scheduleTimezone: data.scheduleTimezone,
|
||||
scheduleEnabled: data.scheduleEnabled,
|
||||
outputFormats: data.outputFormats,
|
||||
isActive: data.isActive,
|
||||
lastGeneratedAt: data.lastGeneratedAt,
|
||||
},
|
||||
});
|
||||
return ReportDefinitionMapper.toDomain(updated);
|
||||
}
|
||||
|
||||
const created = await this.prisma.reportDefinition.create({
|
||||
data: {
|
||||
reportType: data.reportType,
|
||||
reportName: data.reportName,
|
||||
reportCode: data.reportCode,
|
||||
description: data.description,
|
||||
parameters: data.parameters as Prisma.InputJsonValue,
|
||||
scheduleCron: data.scheduleCron,
|
||||
scheduleTimezone: data.scheduleTimezone,
|
||||
scheduleEnabled: data.scheduleEnabled,
|
||||
outputFormats: data.outputFormats,
|
||||
isActive: data.isActive,
|
||||
},
|
||||
});
|
||||
return ReportDefinitionMapper.toDomain(created);
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<ReportDefinition | null> {
|
||||
const found = await this.prisma.reportDefinition.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? ReportDefinitionMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<ReportDefinition | null> {
|
||||
const found = await this.prisma.reportDefinition.findUnique({
|
||||
where: { reportCode: code },
|
||||
});
|
||||
return found ? ReportDefinitionMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByType(type: ReportType): Promise<ReportDefinition[]> {
|
||||
const found = await this.prisma.reportDefinition.findMany({
|
||||
where: { reportType: type },
|
||||
});
|
||||
return found.map(ReportDefinitionMapper.toDomain);
|
||||
}
|
||||
|
||||
async findActive(): Promise<ReportDefinition[]> {
|
||||
const found = await this.prisma.reportDefinition.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
return found.map(ReportDefinitionMapper.toDomain);
|
||||
}
|
||||
|
||||
async findScheduled(): Promise<ReportDefinition[]> {
|
||||
const found = await this.prisma.reportDefinition.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
scheduleEnabled: true,
|
||||
},
|
||||
});
|
||||
return found.map(ReportDefinitionMapper.toDomain);
|
||||
}
|
||||
|
||||
async findAll(): Promise<ReportDefinition[]> {
|
||||
const found = await this.prisma.reportDefinition.findMany();
|
||||
return found.map(ReportDefinitionMapper.toDomain);
|
||||
}
|
||||
|
||||
async delete(id: bigint): Promise<void> {
|
||||
await this.prisma.reportDefinition.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReportFile } from '../../../domain/entities/report-file.entity';
|
||||
import { IReportFileRepository } from '../../../domain/repositories';
|
||||
import { OutputFormat } from '../../../domain/value-objects';
|
||||
import { ReportFileMapper } from '../mappers/report-file.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class ReportFileRepository implements IReportFileRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(file: ReportFile): Promise<ReportFile> {
|
||||
const data = ReportFileMapper.toPersistence(file);
|
||||
|
||||
if (file.id) {
|
||||
const updated = await this.prisma.reportFile.update({
|
||||
where: { id: file.id },
|
||||
data: {
|
||||
fileUrl: data.fileUrl,
|
||||
downloadCount: data.downloadCount,
|
||||
lastDownloadAt: data.lastDownloadAt,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
return ReportFileMapper.toDomain(updated);
|
||||
}
|
||||
|
||||
const created = await this.prisma.reportFile.create({
|
||||
data: {
|
||||
snapshotId: data.snapshotId,
|
||||
fileName: data.fileName,
|
||||
filePath: data.filePath,
|
||||
fileUrl: data.fileUrl,
|
||||
fileSize: data.fileSize,
|
||||
fileFormat: data.fileFormat,
|
||||
mimeType: data.mimeType,
|
||||
downloadCount: data.downloadCount,
|
||||
lastDownloadAt: data.lastDownloadAt,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
return ReportFileMapper.toDomain(created);
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<ReportFile | null> {
|
||||
const found = await this.prisma.reportFile.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? ReportFileMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findBySnapshotId(snapshotId: bigint): Promise<ReportFile[]> {
|
||||
const found = await this.prisma.reportFile.findMany({
|
||||
where: { snapshotId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map(ReportFileMapper.toDomain);
|
||||
}
|
||||
|
||||
async findBySnapshotIdAndFormat(
|
||||
snapshotId: bigint,
|
||||
format: OutputFormat,
|
||||
): Promise<ReportFile | null> {
|
||||
const found = await this.prisma.reportFile.findFirst({
|
||||
where: {
|
||||
snapshotId,
|
||||
fileFormat: format,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found ? ReportFileMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findExpired(): Promise<ReportFile[]> {
|
||||
const found = await this.prisma.reportFile.findMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return found.map(ReportFileMapper.toDomain);
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.prisma.reportFile.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async delete(id: bigint): Promise<void> {
|
||||
await this.prisma.reportFile.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReportSnapshot } from '../../../domain/aggregates/report-snapshot';
|
||||
import { IReportSnapshotRepository } from '../../../domain/repositories';
|
||||
import { ReportType, ReportPeriod } from '../../../domain/value-objects';
|
||||
import { ReportSnapshotMapper } from '../mappers/report-snapshot.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class ReportSnapshotRepository implements IReportSnapshotRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(snapshot: ReportSnapshot): Promise<ReportSnapshot> {
|
||||
const data = ReportSnapshotMapper.toPersistence(snapshot);
|
||||
|
||||
if (snapshot.id) {
|
||||
const updated = await this.prisma.reportSnapshot.update({
|
||||
where: { id: snapshot.id },
|
||||
data: {
|
||||
snapshotData: data.snapshotData as Prisma.InputJsonValue,
|
||||
summaryData: data.summaryData as Prisma.InputJsonValue | undefined,
|
||||
dataSources: data.dataSources,
|
||||
dataFreshness: data.dataFreshness,
|
||||
filterParams: data.filterParams as Prisma.InputJsonValue | undefined,
|
||||
rowCount: data.rowCount,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
return ReportSnapshotMapper.toDomain(updated);
|
||||
}
|
||||
|
||||
const created = await this.prisma.reportSnapshot.create({
|
||||
data: {
|
||||
reportType: data.reportType,
|
||||
reportCode: data.reportCode,
|
||||
reportPeriod: data.reportPeriod,
|
||||
periodKey: data.periodKey,
|
||||
snapshotData: data.snapshotData as Prisma.InputJsonValue,
|
||||
summaryData: data.summaryData as Prisma.InputJsonValue | undefined,
|
||||
dataSources: data.dataSources,
|
||||
dataFreshness: data.dataFreshness,
|
||||
filterParams: data.filterParams as Prisma.InputJsonValue | undefined,
|
||||
rowCount: data.rowCount,
|
||||
periodStartAt: data.periodStartAt,
|
||||
periodEndAt: data.periodEndAt,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
return ReportSnapshotMapper.toDomain(created);
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<ReportSnapshot | null> {
|
||||
const found = await this.prisma.reportSnapshot.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? ReportSnapshotMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByCodeAndPeriodKey(
|
||||
reportCode: string,
|
||||
periodKey: string,
|
||||
): Promise<ReportSnapshot | null> {
|
||||
const found = await this.prisma.reportSnapshot.findUnique({
|
||||
where: {
|
||||
uk_report_period: {
|
||||
reportCode,
|
||||
periodKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
return found ? ReportSnapshotMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByType(type: ReportType): Promise<ReportSnapshot[]> {
|
||||
const found = await this.prisma.reportSnapshot.findMany({
|
||||
where: { reportType: type },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
return found.map(ReportSnapshotMapper.toDomain);
|
||||
}
|
||||
|
||||
async findByCode(reportCode: string): Promise<ReportSnapshot[]> {
|
||||
const found = await this.prisma.reportSnapshot.findMany({
|
||||
where: { reportCode },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
return found.map(ReportSnapshotMapper.toDomain);
|
||||
}
|
||||
|
||||
async findByPeriod(period: ReportPeriod): Promise<ReportSnapshot[]> {
|
||||
const found = await this.prisma.reportSnapshot.findMany({
|
||||
where: { reportPeriod: period },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
return found.map(ReportSnapshotMapper.toDomain);
|
||||
}
|
||||
|
||||
async findLatestByCode(reportCode: string): Promise<ReportSnapshot | null> {
|
||||
const found = await this.prisma.reportSnapshot.findFirst({
|
||||
where: { reportCode },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
return found ? ReportSnapshotMapper.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findExpired(): Promise<ReportSnapshot[]> {
|
||||
const found = await this.prisma.reportSnapshot.findMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return found.map(ReportSnapshotMapper.toDomain);
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.prisma.reportSnapshot.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async delete(id: bigint): Promise<void> {
|
||||
await this.prisma.reportSnapshot.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
import { ReportCacheService } from './report-cache.service';
|
||||
|
||||
@Module({
|
||||
providers: [RedisService, ReportCacheService],
|
||||
exports: [RedisService, ReportCacheService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
private readonly client: Redis;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.client = new Redis({
|
||||
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.logger.log('Redis connected');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this.logger.error('Redis error', err);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.client.quit();
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
if (ttlSeconds) {
|
||||
await this.client.setex(key, ttlSeconds, value);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
async keys(pattern: string): Promise<string[]> {
|
||||
return this.client.keys(pattern);
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const value = await this.get(key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
await this.set(key, JSON.stringify(value), ttlSeconds);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReportCacheService {
|
||||
private readonly logger = new Logger(ReportCacheService.name);
|
||||
private readonly cachePrefix = 'report:';
|
||||
private readonly defaultTtl: number;
|
||||
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.defaultTtl = this.configService.get<number>('REPORT_CACHE_TTL', 3600);
|
||||
}
|
||||
|
||||
async cacheSnapshot(
|
||||
reportCode: string,
|
||||
periodKey: string,
|
||||
data: any,
|
||||
ttlSeconds?: number,
|
||||
): Promise<void> {
|
||||
const key = this.buildKey(reportCode, periodKey);
|
||||
await this.redisService.setJson(key, data, ttlSeconds || this.defaultTtl);
|
||||
this.logger.debug(`Cached report snapshot: ${key}`);
|
||||
}
|
||||
|
||||
async getCachedSnapshot<T>(
|
||||
reportCode: string,
|
||||
periodKey: string,
|
||||
): Promise<T | null> {
|
||||
const key = this.buildKey(reportCode, periodKey);
|
||||
const cached = await this.redisService.getJson<T>(key);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for report snapshot: ${key}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
async invalidateSnapshot(reportCode: string, periodKey: string): Promise<void> {
|
||||
const key = this.buildKey(reportCode, periodKey);
|
||||
await this.redisService.del(key);
|
||||
this.logger.debug(`Invalidated report cache: ${key}`);
|
||||
}
|
||||
|
||||
async invalidateReportCache(reportCode: string): Promise<void> {
|
||||
const pattern = `${this.cachePrefix}${reportCode}:*`;
|
||||
const keys = await this.redisService.keys(pattern);
|
||||
for (const key of keys) {
|
||||
await this.redisService.del(key);
|
||||
}
|
||||
this.logger.debug(`Invalidated all cache for report: ${reportCode}`);
|
||||
}
|
||||
|
||||
private buildKey(reportCode: string, periodKey: string): string {
|
||||
return `${this.cachePrefix}${reportCode}:${periodKey}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
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);
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Swagger setup
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Reporting & Analytics Service')
|
||||
.setDescription('RWA Durian Platform - Reporting & Analytics API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('Health', '健康检查')
|
||||
.addTag('Reports', '报表管理')
|
||||
.addTag('Export', '报表导出')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 3008;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`🚀 Reporting Service is running on: http://localhost:${port}`);
|
||||
logger.log(`📚 Swagger API docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
userId: bigint;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserPayload | null => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user || null;
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './public.decorator';
|
||||
export * from './current-user.decorator';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export class DomainException extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DomainException';
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotFoundException extends DomainException {
|
||||
constructor(entityName: string, id: string | bigint) {
|
||||
super(`${entityName} not found: ${id}`, 'ENTITY_NOT_FOUND');
|
||||
this.name = 'EntityNotFoundException';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidOperationException extends DomainException {
|
||||
constructor(message: string) {
|
||||
super(message, 'INVALID_OPERATION');
|
||||
this.name = 'InvalidOperationException';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './domain.exception';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { DomainException } from '../exceptions/domain.exception';
|
||||
|
||||
@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 = 'Internal server error';
|
||||
let code = 'INTERNAL_ERROR';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exception.message;
|
||||
code = (exceptionResponse as any).error || 'HTTP_ERROR';
|
||||
} else if (exception instanceof DomainException) {
|
||||
status = HttpStatus.BAD_REQUEST;
|
||||
message = exception.message;
|
||||
code = exception.code || 'DOMAIN_ERROR';
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Exception: ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
code,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue