2905 lines
91 KiB
Markdown
2905 lines
91 KiB
Markdown
# Reporting & Analytics Service 开发指导
|
||
|
||
## 项目概述
|
||
|
||
Reporting & Analytics Service 是 RWA 榴莲皇后平台的报表分析微服务,负责多维报表生成、数据统计与分析、报表导出、数据大屏支持等功能。
|
||
|
||
### 核心职责 ✅
|
||
- 多维报表生成(按时间/地域/权益类型等维度)
|
||
- 龙虎榜数据统计(日榜、周榜、月榜排名导出)
|
||
- 榴莲树认种报表(日/周/月/季度/年度)
|
||
- 各省/市认种报表统计
|
||
- 社区数据统计(支持模糊查询、日期范围筛选)
|
||
- 系统省/市公司账户数据统计
|
||
- 收益来源细分统计与时间轴
|
||
- 报表导出(Excel/PDF/CSV/JSON)
|
||
- 数据大屏支持
|
||
|
||
### 不负责 ❌
|
||
- 实时业务数据存储(从其他上下文聚合)
|
||
- 权限控制(Authorization Context)
|
||
- 复杂计算(由源上下文提供)
|
||
- 业务逻辑处理(由各业务服务负责)
|
||
|
||
---
|
||
|
||
## 核心业务需求
|
||
|
||
### 1. 龙虎榜数据统计
|
||
```
|
||
报表类型:日榜、周榜、月榜
|
||
统计内容:排名、用户、分值、团队数据
|
||
功能要求:
|
||
- 可指定统计范围(时间段)
|
||
- 支持导出表格(Excel/CSV)
|
||
- 历史榜单数据查询
|
||
```
|
||
|
||
### 2. 榴莲树认种报表
|
||
```
|
||
报表周期:
|
||
- 日报表:按天统计
|
||
- 周报表:按周统计
|
||
- 月报表:按月统计
|
||
- 季度报表:按季度统计
|
||
- 年度报表:按年统计
|
||
|
||
统计内容:
|
||
- 认种数量(棵)
|
||
- 认种金额(USDT)
|
||
- 新增用户数
|
||
- 活跃用户数
|
||
- 同比/环比增长率
|
||
```
|
||
|
||
### 3. 各省/市认种报表
|
||
```
|
||
报表类型:
|
||
- 按省统计的认种报表(日/周/月/季度/年度)
|
||
- 按市统计的认种报表(日/周/月/季度/年度)
|
||
|
||
统计内容:
|
||
- 区域认种数量
|
||
- 区域认种金额
|
||
- 区域用户数
|
||
- 区域排名
|
||
```
|
||
|
||
### 4. 授权省/市公司第1名统计
|
||
```
|
||
统计内容:
|
||
- 各授权省公司的第1名用户及其完成数据
|
||
- 各授权市公司的第1名用户及其完成数据
|
||
- 包含认种量、团队数、收益等指标
|
||
```
|
||
|
||
### 5. 社区数据统计
|
||
```
|
||
统计维度:
|
||
- 本社区名称
|
||
- 上级社区
|
||
- 下级社区列表
|
||
- 社区认种总量
|
||
|
||
查询功能:
|
||
- 日新增认种量
|
||
- 周新增认种量
|
||
- 月新增认种量
|
||
- 指定日期范围查询
|
||
- 模糊查询(社区名、用户名)
|
||
```
|
||
|
||
### 6. 系统省/市公司账户月度统计
|
||
```
|
||
统计指标:
|
||
① 每月算力
|
||
② 累计算力
|
||
③ 每月挖矿量
|
||
④ 累计挖矿量
|
||
⑤ 每月佣金
|
||
⑥ 累计佣金
|
||
⑦ 每月累计认种提成
|
||
⑧ 累计佣金总额
|
||
|
||
输出格式:表格报表,支持导出
|
||
```
|
||
|
||
### 7. 系统账户收益来源细分统计
|
||
```
|
||
统计内容:
|
||
- 每笔收益的来源类型
|
||
- 每笔收益的金额
|
||
- 每笔收益的时间轴
|
||
|
||
筛选条件:
|
||
- 时间范围筛选
|
||
- 关键词筛选(地址、交易流水号等)
|
||
- 账户类型筛选(省公司/市公司)
|
||
```
|
||
|
||
---
|
||
|
||
## 技术栈
|
||
|
||
| 组件 | 技术选型 |
|
||
|------|----------|
|
||
| **框架** | NestJS 10.x |
|
||
| **数据库** | PostgreSQL + Prisma ORM |
|
||
| **架构** | DDD + Hexagonal Architecture (六边形架构) |
|
||
| **语言** | TypeScript 5.x |
|
||
| **消息队列** | Kafka (kafkajs) |
|
||
| **缓存** | Redis (ioredis) |
|
||
| **定时任务** | @nestjs/schedule |
|
||
| **报表导出** | ExcelJS (Excel), PDFKit (PDF) |
|
||
| **API文档** | Swagger (@nestjs/swagger) |
|
||
|
||
---
|
||
|
||
## 架构设计
|
||
|
||
```
|
||
reporting-service/
|
||
├── prisma/
|
||
│ ├── schema.prisma # 数据库模型定义
|
||
│ └── migrations/ # 数据库迁移文件
|
||
│
|
||
├── src/
|
||
│ ├── api/ # 🔵 Presentation Layer (表现层)
|
||
│ │ ├── controllers/
|
||
│ │ │ ├── health.controller.ts
|
||
│ │ │ ├── leaderboard-report.controller.ts
|
||
│ │ │ ├── planting-report.controller.ts
|
||
│ │ │ ├── regional-report.controller.ts
|
||
│ │ │ ├── community-report.controller.ts
|
||
│ │ │ ├── system-account-report.controller.ts
|
||
│ │ │ └── export.controller.ts
|
||
│ │ ├── dto/
|
||
│ │ │ ├── request/
|
||
│ │ │ │ ├── generate-report.dto.ts
|
||
│ │ │ │ ├── query-report.dto.ts
|
||
│ │ │ │ ├── export-report.dto.ts
|
||
│ │ │ │ └── date-range.dto.ts
|
||
│ │ │ └── response/
|
||
│ │ │ ├── report-snapshot.dto.ts
|
||
│ │ │ ├── planting-report.dto.ts
|
||
│ │ │ ├── regional-report.dto.ts
|
||
│ │ │ ├── community-report.dto.ts
|
||
│ │ │ └── system-account-report.dto.ts
|
||
│ │ └── api.module.ts
|
||
│ │
|
||
│ ├── application/ # 🟢 Application Layer (应用层)
|
||
│ │ ├── commands/
|
||
│ │ │ ├── generate-report/
|
||
│ │ │ │ ├── generate-report.command.ts
|
||
│ │ │ │ └── generate-report.handler.ts
|
||
│ │ │ ├── export-report/
|
||
│ │ │ │ ├── export-report.command.ts
|
||
│ │ │ │ └── export-report.handler.ts
|
||
│ │ │ ├── schedule-report/
|
||
│ │ │ │ ├── schedule-report.command.ts
|
||
│ │ │ │ └── schedule-report.handler.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── queries/
|
||
│ │ │ ├── get-leaderboard-report/
|
||
│ │ │ │ ├── get-leaderboard-report.query.ts
|
||
│ │ │ │ └── get-leaderboard-report.handler.ts
|
||
│ │ │ ├── get-planting-report/
|
||
│ │ │ │ ├── get-planting-report.query.ts
|
||
│ │ │ │ └── get-planting-report.handler.ts
|
||
│ │ │ ├── get-regional-report/
|
||
│ │ │ │ ├── get-regional-report.query.ts
|
||
│ │ │ │ └── get-regional-report.handler.ts
|
||
│ │ │ ├── get-community-report/
|
||
│ │ │ │ ├── get-community-report.query.ts
|
||
│ │ │ │ └── get-community-report.handler.ts
|
||
│ │ │ ├── get-system-account-report/
|
||
│ │ │ │ ├── get-system-account-report.query.ts
|
||
│ │ │ │ └── get-system-account-report.handler.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── services/
|
||
│ │ │ └── reporting-application.service.ts
|
||
│ │ ├── schedulers/
|
||
│ │ │ ├── report-generation.scheduler.ts
|
||
│ │ │ └── snapshot-cleanup.scheduler.ts
|
||
│ │ └── application.module.ts
|
||
│ │
|
||
│ ├── domain/ # 🟡 Domain Layer (领域层)
|
||
│ │ ├── aggregates/
|
||
│ │ │ ├── report-definition/
|
||
│ │ │ │ ├── report-definition.aggregate.ts
|
||
│ │ │ │ ├── report-definition.spec.ts
|
||
│ │ │ │ └── index.ts
|
||
│ │ │ └── report-snapshot/
|
||
│ │ │ ├── report-snapshot.aggregate.ts
|
||
│ │ │ ├── report-snapshot.spec.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── entities/
|
||
│ │ │ ├── analytics-metric.entity.ts
|
||
│ │ │ ├── report-file.entity.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── value-objects/
|
||
│ │ │ ├── report-type.enum.ts
|
||
│ │ │ ├── report-period.enum.ts
|
||
│ │ │ ├── report-dimension.enum.ts
|
||
│ │ │ ├── report-parameters.vo.ts
|
||
│ │ │ ├── report-schedule.vo.ts
|
||
│ │ │ ├── output-format.enum.ts
|
||
│ │ │ ├── snapshot-data.vo.ts
|
||
│ │ │ ├── data-source.vo.ts
|
||
│ │ │ ├── date-range.vo.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── events/
|
||
│ │ │ ├── domain-event.base.ts
|
||
│ │ │ ├── report-generated.event.ts
|
||
│ │ │ ├── report-exported.event.ts
|
||
│ │ │ ├── snapshot-created.event.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── repositories/
|
||
│ │ │ ├── report-definition.repository.interface.ts
|
||
│ │ │ ├── report-snapshot.repository.interface.ts
|
||
│ │ │ ├── analytics-metric.repository.interface.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── services/
|
||
│ │ │ ├── report-generation.service.ts
|
||
│ │ │ ├── report-export.service.ts
|
||
│ │ │ ├── data-aggregation.service.ts
|
||
│ │ │ └── index.ts
|
||
│ │ └── domain.module.ts
|
||
│ │
|
||
│ ├── infrastructure/ # 🔴 Infrastructure Layer (基础设施层)
|
||
│ │ ├── persistence/
|
||
│ │ │ ├── prisma/
|
||
│ │ │ │ └── prisma.service.ts
|
||
│ │ │ ├── mappers/
|
||
│ │ │ │ ├── report-definition.mapper.ts
|
||
│ │ │ │ ├── report-snapshot.mapper.ts
|
||
│ │ │ │ └── analytics-metric.mapper.ts
|
||
│ │ │ └── repositories/
|
||
│ │ │ ├── report-definition.repository.impl.ts
|
||
│ │ │ ├── report-snapshot.repository.impl.ts
|
||
│ │ │ └── analytics-metric.repository.impl.ts
|
||
│ │ ├── external/
|
||
│ │ │ ├── planting-service/
|
||
│ │ │ │ └── planting-service.client.ts
|
||
│ │ │ ├── reward-service/
|
||
│ │ │ │ └── reward-service.client.ts
|
||
│ │ │ ├── referral-service/
|
||
│ │ │ │ └── referral-service.client.ts
|
||
│ │ │ ├── leaderboard-service/
|
||
│ │ │ │ └── leaderboard-service.client.ts
|
||
│ │ │ ├── wallet-service/
|
||
│ │ │ │ └── wallet-service.client.ts
|
||
│ │ │ └── identity-service/
|
||
│ │ │ └── identity-service.client.ts
|
||
│ │ ├── export/
|
||
│ │ │ ├── excel-export.service.ts
|
||
│ │ │ ├── pdf-export.service.ts
|
||
│ │ │ ├── csv-export.service.ts
|
||
│ │ │ └── export.module.ts
|
||
│ │ ├── storage/
|
||
│ │ │ ├── file-storage.service.ts
|
||
│ │ │ └── storage.module.ts
|
||
│ │ ├── kafka/
|
||
│ │ │ ├── event-consumer.controller.ts
|
||
│ │ │ ├── event-publisher.service.ts
|
||
│ │ │ └── kafka.module.ts
|
||
│ │ ├── redis/
|
||
│ │ │ ├── redis.service.ts
|
||
│ │ │ ├── report-cache.service.ts
|
||
│ │ │ └── redis.module.ts
|
||
│ │ └── infrastructure.module.ts
|
||
│ │
|
||
│ ├── shared/ # 共享模块
|
||
│ │ ├── decorators/
|
||
│ │ │ ├── current-user.decorator.ts
|
||
│ │ │ ├── public.decorator.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── exceptions/
|
||
│ │ │ ├── domain.exception.ts
|
||
│ │ │ ├── application.exception.ts
|
||
│ │ │ └── index.ts
|
||
│ │ ├── filters/
|
||
│ │ │ ├── global-exception.filter.ts
|
||
│ │ │ └── domain-exception.filter.ts
|
||
│ │ ├── guards/
|
||
│ │ │ ├── jwt-auth.guard.ts
|
||
│ │ │ └── admin.guard.ts
|
||
│ │ ├── interceptors/
|
||
│ │ │ └── transform.interceptor.ts
|
||
│ │ └── strategies/
|
||
│ │ └── jwt.strategy.ts
|
||
│ │
|
||
│ ├── config/
|
||
│ │ ├── app.config.ts
|
||
│ │ ├── database.config.ts
|
||
│ │ ├── jwt.config.ts
|
||
│ │ ├── redis.config.ts
|
||
│ │ ├── kafka.config.ts
|
||
│ │ ├── storage.config.ts
|
||
│ │ └── index.ts
|
||
│ │
|
||
│ ├── app.module.ts
|
||
│ └── main.ts
|
||
│
|
||
├── test/
|
||
├── .env.example
|
||
├── .env.development
|
||
├── package.json
|
||
├── tsconfig.json
|
||
└── Dockerfile
|
||
```
|
||
|
||
---
|
||
|
||
## 第一阶段:项目初始化
|
||
|
||
### 1.1 创建 NestJS 项目
|
||
|
||
```bash
|
||
cd backend/services/reporting-service
|
||
npx @nestjs/cli new . --skip-git --package-manager npm
|
||
```
|
||
|
||
### 1.2 安装依赖
|
||
|
||
```bash
|
||
# 核心依赖
|
||
npm install @nestjs/config @nestjs/swagger @nestjs/jwt @nestjs/passport @nestjs/microservices @nestjs/schedule
|
||
npm install @prisma/client class-validator class-transformer uuid ioredis kafkajs
|
||
npm install passport passport-jwt
|
||
|
||
# 报表导出依赖
|
||
npm install exceljs pdfkit csv-stringify
|
||
|
||
# 开发依赖
|
||
npm install -D prisma @types/uuid @types/passport-jwt @types/pdfkit
|
||
npm install -D @nestjs/testing jest ts-jest @types/jest supertest @types/supertest
|
||
```
|
||
|
||
### 1.3 环境变量配置
|
||
|
||
创建 `.env.development`:
|
||
```env
|
||
# 应用配置
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
## 第二阶段:数据库设计 (Prisma Schema)
|
||
|
||
### 2.1 创建 prisma/schema.prisma
|
||
|
||
```prisma
|
||
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) // Cron表达式
|
||
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) // DAILY/WEEKLY/MONTHLY/QUARTERLY/YEARLY
|
||
periodKey String @map("period_key") @db.VarChar(30) // 2024-01-15 / 2024-W03 / 2024-01 / 2024-Q1 / 2024
|
||
|
||
// === 快照数据 ===
|
||
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")
|
||
|
||
@@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) // EXCEL/PDF/CSV/JSON
|
||
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")
|
||
|
||
@@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) // PROVINCE/CITY
|
||
accountName String @map("account_name") @db.VarChar(200)
|
||
regionCode String @map("region_code") @db.VarChar(10) // 省/市编码
|
||
|
||
// === 统计月份 ===
|
||
statMonth String @map("stat_month") @db.VarChar(7) // 2024-01
|
||
|
||
// === 月度数据 ===
|
||
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) // USDT/HASHPOWER
|
||
|
||
// === 来源信息 ===
|
||
sourceType String @map("source_type") @db.VarChar(50) // 来源类型
|
||
sourceId String? @map("source_id") @db.VarChar(100) // 来源ID
|
||
sourceUserId BigInt? @map("source_user_id") // 来源用户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")
|
||
}
|
||
```
|
||
|
||
### 2.2 初始化数据库和种子数据
|
||
|
||
```bash
|
||
npx prisma generate
|
||
npx prisma migrate dev --name init
|
||
```
|
||
|
||
创建 `prisma/seed.ts`:
|
||
```typescript
|
||
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 * * *', // 每日凌晨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 * * *', // 每日凌晨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 * *', // 每月1日0点
|
||
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();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 第三阶段:领域层实现 (Domain Layer)
|
||
|
||
### 3.1 值对象 (Value Objects)
|
||
|
||
#### 3.1.1 src/domain/value-objects/report-type.enum.ts
|
||
```typescript
|
||
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]: '系统账户收益来源报表',
|
||
};
|
||
```
|
||
|
||
#### 3.1.2 src/domain/value-objects/report-period.enum.ts
|
||
```typescript
|
||
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]: '自定义周期',
|
||
};
|
||
```
|
||
|
||
#### 3.1.3 src/domain/value-objects/report-dimension.enum.ts
|
||
```typescript
|
||
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', // 产品维度
|
||
}
|
||
```
|
||
|
||
#### 3.1.4 src/domain/value-objects/output-format.enum.ts
|
||
```typescript
|
||
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',
|
||
};
|
||
```
|
||
|
||
#### 3.1.5 src/domain/value-objects/date-range.vo.ts
|
||
```typescript
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 生成周期Key
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
|
||
import { ReportPeriod } from './report-period.enum';
|
||
```
|
||
|
||
#### 3.1.6 src/domain/value-objects/report-parameters.vo.ts
|
||
```typescript
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.7 src/domain/value-objects/report-schedule.vo.ts
|
||
```typescript
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.8 src/domain/value-objects/snapshot-data.vo.ts
|
||
```typescript
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 转换为JSON
|
||
*/
|
||
toJSON(): object {
|
||
return {
|
||
rows: this.rows,
|
||
summary: this.summary,
|
||
metadata: this.metadata,
|
||
rowCount: this.getRowCount(),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.9 src/domain/value-objects/data-source.vo.ts
|
||
```typescript
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.10 src/domain/value-objects/index.ts
|
||
```typescript
|
||
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';
|
||
```
|
||
|
||
### 3.2 领域事件 (Domain Events)
|
||
|
||
#### 3.2.1 src/domain/events/domain-event.base.ts
|
||
```typescript
|
||
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>;
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 src/domain/events/report-generated.event.ts
|
||
```typescript
|
||
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 };
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.3 src/domain/events/report-exported.event.ts
|
||
```typescript
|
||
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 };
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 聚合根 (Aggregates)
|
||
|
||
#### 3.3.1 src/domain/aggregates/report-definition/report-definition.aggregate.ts
|
||
```typescript
|
||
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';
|
||
|
||
/**
|
||
* 报表定义聚合根
|
||
*
|
||
* 不变式:
|
||
* 1. reportCode 必须唯一
|
||
* 2. 启用调度时必须有有效的 cron 表达式
|
||
* 3. 至少支持一种输出格式
|
||
*/
|
||
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,
|
||
) {
|
||
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 = new Date();
|
||
this._lastGeneratedAt = null;
|
||
}
|
||
|
||
// ============ 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]; }
|
||
|
||
get isScheduled(): boolean {
|
||
return this._schedule !== null && this._schedule.enabled;
|
||
}
|
||
|
||
// ============ 工厂方法 ============
|
||
|
||
static create(params: {
|
||
reportType: ReportType;
|
||
reportName: string;
|
||
reportCode: string;
|
||
description?: string;
|
||
parameters?: Record<string, any>;
|
||
schedule?: ReportSchedule;
|
||
outputFormats: OutputFormat[];
|
||
}): ReportDefinition {
|
||
if (params.outputFormats.length === 0) {
|
||
throw new Error('至少需要支持一种输出格式');
|
||
}
|
||
|
||
return new ReportDefinition(
|
||
params.reportType,
|
||
params.reportName,
|
||
params.reportCode,
|
||
params.description || '',
|
||
params.parameters || {},
|
||
params.schedule || null,
|
||
params.outputFormats,
|
||
true,
|
||
);
|
||
}
|
||
|
||
// ============ 领域行为 ============
|
||
|
||
/**
|
||
* 更新报表参数
|
||
*/
|
||
updateParameters(newParameters: Record<string, any>): void {
|
||
this._parameters = { ...newParameters };
|
||
}
|
||
|
||
/**
|
||
* 更新调度配置
|
||
*/
|
||
updateSchedule(newSchedule: ReportSchedule): void {
|
||
this._schedule = newSchedule;
|
||
}
|
||
|
||
/**
|
||
* 启用调度
|
||
*/
|
||
enableSchedule(): void {
|
||
if (!this._schedule) {
|
||
throw new Error('请先配置调度规则');
|
||
}
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 标记为已生成
|
||
*/
|
||
markAsGenerated(): void {
|
||
this._lastGeneratedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 检查是否支持指定格式
|
||
*/
|
||
supportsFormat(format: OutputFormat): boolean {
|
||
return this._outputFormats.includes(format);
|
||
}
|
||
|
||
setId(id: bigint): void {
|
||
this._id = id;
|
||
}
|
||
|
||
clearDomainEvents(): void {
|
||
this._domainEvents = [];
|
||
}
|
||
|
||
// ============ 重建 ============
|
||
|
||
static reconstitute(data: {
|
||
id: bigint;
|
||
reportType: ReportType;
|
||
reportName: string;
|
||
reportCode: string;
|
||
description: string;
|
||
parameters: Record<string, any>;
|
||
scheduleCron: string | null;
|
||
scheduleTimezone: string | null;
|
||
scheduleEnabled: boolean;
|
||
outputFormats: string[];
|
||
isActive: boolean;
|
||
createdAt: Date;
|
||
lastGeneratedAt: Date | null;
|
||
}): ReportDefinition {
|
||
const schedule = data.scheduleCron
|
||
? ReportSchedule.create(
|
||
data.scheduleCron,
|
||
data.scheduleTimezone || 'Asia/Shanghai',
|
||
data.scheduleEnabled,
|
||
)
|
||
: null;
|
||
|
||
const definition = new ReportDefinition(
|
||
data.reportType,
|
||
data.reportName,
|
||
data.reportCode,
|
||
data.description,
|
||
data.parameters,
|
||
schedule,
|
||
data.outputFormats as OutputFormat[],
|
||
data.isActive,
|
||
);
|
||
definition._id = data.id;
|
||
definition._lastGeneratedAt = data.lastGeneratedAt;
|
||
return definition;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.2 src/domain/aggregates/report-snapshot/report-snapshot.aggregate.ts
|
||
```typescript
|
||
import { DomainEvent } from '../../events/domain-event.base';
|
||
import { ReportGeneratedEvent } from '../../events/report-generated.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';
|
||
import { DateRange } from '../../value-objects/date-range.vo';
|
||
|
||
/**
|
||
* 报表快照聚合根 (读模型)
|
||
*
|
||
* 不变式:
|
||
* 1. 同一报表同一周期只能有一个快照
|
||
* 2. 快照数据创建后不可修改
|
||
* 3. 过期的快照应被清理
|
||
*/
|
||
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 readonly _snapshotData: SnapshotData;
|
||
private readonly _dataSource: DataSource;
|
||
private readonly _filterParams: Record<string, any> | null;
|
||
private readonly _dateRange: DateRange;
|
||
private readonly _generatedAt: Date;
|
||
private readonly _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,
|
||
dateRange: DateRange,
|
||
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._dateRange = dateRange;
|
||
this._generatedAt = generatedAt;
|
||
this._expiresAt = expiresAt;
|
||
}
|
||
|
||
// ============ 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; }
|
||
get dateRange(): DateRange { return this._dateRange; }
|
||
get generatedAt(): Date { return this._generatedAt; }
|
||
get expiresAt(): Date | null { return this._expiresAt; }
|
||
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||
|
||
get rowCount(): number {
|
||
return this._snapshotData.getRowCount();
|
||
}
|
||
|
||
get isExpired(): boolean {
|
||
if (!this._expiresAt) return false;
|
||
return new Date() > this._expiresAt;
|
||
}
|
||
|
||
// ============ 工厂方法 ============
|
||
|
||
static create(params: {
|
||
reportType: ReportType;
|
||
reportCode: string;
|
||
reportPeriod: ReportPeriod;
|
||
snapshotData: SnapshotData;
|
||
dataSource: DataSource;
|
||
dateRange: DateRange;
|
||
filterParams?: Record<string, any>;
|
||
expiresInHours?: number;
|
||
}): ReportSnapshot {
|
||
const now = new Date();
|
||
const periodKey = params.dateRange.toPeriodKey(params.reportPeriod);
|
||
const expiresAt = params.expiresInHours
|
||
? new Date(now.getTime() + params.expiresInHours * 3600 * 1000)
|
||
: null;
|
||
|
||
const snapshot = new ReportSnapshot(
|
||
params.reportType,
|
||
params.reportCode,
|
||
params.reportPeriod,
|
||
periodKey,
|
||
params.snapshotData,
|
||
params.dataSource,
|
||
params.filterParams || null,
|
||
params.dateRange,
|
||
now,
|
||
expiresAt,
|
||
);
|
||
|
||
snapshot._domainEvents.push(new ReportGeneratedEvent({
|
||
snapshotId: snapshot._id?.toString() || 'temp',
|
||
reportType: snapshot._reportType,
|
||
reportCode: snapshot._reportCode,
|
||
periodKey: snapshot._periodKey,
|
||
rowCount: snapshot.rowCount,
|
||
generatedAt: snapshot._generatedAt,
|
||
}));
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
// ============ 领域行为 ============
|
||
|
||
/**
|
||
* 检查数据是否新鲜
|
||
*/
|
||
isDataFresh(maxAgeSeconds: number): boolean {
|
||
return this._dataSource.isFresh(maxAgeSeconds);
|
||
}
|
||
|
||
/**
|
||
* 获取汇总数据
|
||
*/
|
||
getSummary(): Record<string, any> {
|
||
return this._snapshotData.summary;
|
||
}
|
||
|
||
/**
|
||
* 获取数据行
|
||
*/
|
||
getRows(): any[] {
|
||
return this._snapshotData.rows;
|
||
}
|
||
|
||
setId(id: bigint): void {
|
||
this._id = id;
|
||
}
|
||
|
||
clearDomainEvents(): void {
|
||
this._domainEvents = [];
|
||
}
|
||
|
||
// ============ 重建 ============
|
||
|
||
static reconstitute(data: {
|
||
id: bigint;
|
||
reportType: ReportType;
|
||
reportCode: string;
|
||
reportPeriod: ReportPeriod;
|
||
periodKey: string;
|
||
snapshotData: any;
|
||
summaryData: any;
|
||
dataSources: string[];
|
||
dataFreshness: number;
|
||
filterParams: any;
|
||
rowCount: number;
|
||
periodStartAt: Date;
|
||
periodEndAt: Date;
|
||
generatedAt: Date;
|
||
expiresAt: Date | null;
|
||
}): ReportSnapshot {
|
||
const snapshot = new ReportSnapshot(
|
||
data.reportType,
|
||
data.reportCode,
|
||
data.reportPeriod,
|
||
data.periodKey,
|
||
SnapshotData.create({
|
||
rows: data.snapshotData.rows || [],
|
||
summary: data.summaryData || {},
|
||
metadata: data.snapshotData.metadata || {},
|
||
}),
|
||
DataSource.withFreshness(data.dataSources, data.dataFreshness),
|
||
data.filterParams,
|
||
DateRange.create(data.periodStartAt, data.periodEndAt),
|
||
data.generatedAt,
|
||
data.expiresAt,
|
||
);
|
||
snapshot._id = data.id;
|
||
return snapshot;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 仓储接口 (Repository Interfaces)
|
||
|
||
#### 3.4.1 src/domain/repositories/report-definition.repository.interface.ts
|
||
```typescript
|
||
import { ReportDefinition } from '../aggregates/report-definition/report-definition.aggregate';
|
||
import { ReportType } from '../value-objects/report-type.enum';
|
||
|
||
export interface IReportDefinitionRepository {
|
||
save(definition: ReportDefinition): Promise<void>;
|
||
findById(id: bigint): Promise<ReportDefinition | null>;
|
||
findByCode(reportCode: string): Promise<ReportDefinition | null>;
|
||
findByType(reportType: ReportType): Promise<ReportDefinition[]>;
|
||
findActive(): Promise<ReportDefinition[]>;
|
||
findScheduled(): Promise<ReportDefinition[]>;
|
||
findAll(): Promise<ReportDefinition[]>;
|
||
}
|
||
|
||
export const REPORT_DEFINITION_REPOSITORY = Symbol('IReportDefinitionRepository');
|
||
```
|
||
|
||
#### 3.4.2 src/domain/repositories/report-snapshot.repository.interface.ts
|
||
```typescript
|
||
import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate';
|
||
import { ReportType } from '../value-objects/report-type.enum';
|
||
import { ReportPeriod } from '../value-objects/report-period.enum';
|
||
|
||
export interface IReportSnapshotRepository {
|
||
save(snapshot: ReportSnapshot): Promise<void>;
|
||
findById(id: bigint): Promise<ReportSnapshot | null>;
|
||
findByCodeAndPeriod(reportCode: string, periodKey: string): Promise<ReportSnapshot | null>;
|
||
findByType(reportType: ReportType, limit?: number): Promise<ReportSnapshot[]>;
|
||
findLatest(reportCode: string): Promise<ReportSnapshot | null>;
|
||
findByDateRange(
|
||
reportCode: string,
|
||
startDate: Date,
|
||
endDate: Date,
|
||
): Promise<ReportSnapshot[]>;
|
||
deleteExpired(): Promise<number>;
|
||
deleteOlderThan(date: Date): Promise<number>;
|
||
}
|
||
|
||
export const REPORT_SNAPSHOT_REPOSITORY = Symbol('IReportSnapshotRepository');
|
||
```
|
||
|
||
### 3.5 领域服务 (Domain Services)
|
||
|
||
#### 3.5.1 src/domain/services/report-generation.service.ts
|
||
```typescript
|
||
import { Injectable, Inject } from '@nestjs/common';
|
||
import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate';
|
||
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';
|
||
import { DateRange } from '../value-objects/date-range.vo';
|
||
import { ReportParameters } from '../value-objects/report-parameters.vo';
|
||
|
||
// 外部服务接口(防腐层)
|
||
export interface IPlantingServiceClient {
|
||
getPlantingStatistics(params: {
|
||
startDate: Date;
|
||
endDate: Date;
|
||
groupBy: string[];
|
||
filters?: Record<string, any>;
|
||
}): Promise<any[]>;
|
||
}
|
||
|
||
export interface ILeaderboardServiceClient {
|
||
getLeaderboardData(params: {
|
||
type: string;
|
||
periodKey: string;
|
||
limit: number;
|
||
}): Promise<any[]>;
|
||
}
|
||
|
||
export interface IReferralServiceClient {
|
||
getCommunityStatistics(params: {
|
||
communityId?: bigint;
|
||
communityName?: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
}): Promise<any[]>;
|
||
|
||
getAuthorizedCompanyTopUsers(params: {
|
||
companyType: 'PROVINCE' | 'CITY';
|
||
regionCode?: string;
|
||
}): Promise<any[]>;
|
||
}
|
||
|
||
export interface IRewardServiceClient {
|
||
getRewardStatistics(params: {
|
||
startDate: Date;
|
||
endDate: Date;
|
||
groupBy: string[];
|
||
filters?: Record<string, any>;
|
||
}): Promise<any[]>;
|
||
}
|
||
|
||
export interface IWalletServiceClient {
|
||
getSystemAccountStatistics(params: {
|
||
accountType: 'PROVINCE' | 'CITY';
|
||
statMonth: string;
|
||
}): Promise<any[]>;
|
||
|
||
getSystemAccountIncomeRecords(params: {
|
||
accountId?: bigint;
|
||
accountType?: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
keyword?: string;
|
||
}): Promise<any[]>;
|
||
}
|
||
|
||
export const PLANTING_SERVICE_CLIENT = Symbol('IPlantingServiceClient');
|
||
export const LEADERBOARD_SERVICE_CLIENT = Symbol('ILeaderboardServiceClient');
|
||
export const REFERRAL_SERVICE_CLIENT = Symbol('IReferralServiceClient');
|
||
export const REWARD_SERVICE_CLIENT = Symbol('IRewardServiceClient');
|
||
export const WALLET_SERVICE_CLIENT = Symbol('IWalletServiceClient');
|
||
|
||
@Injectable()
|
||
export class ReportGenerationService {
|
||
constructor(
|
||
@Inject(PLANTING_SERVICE_CLIENT)
|
||
private readonly plantingService: IPlantingServiceClient,
|
||
@Inject(LEADERBOARD_SERVICE_CLIENT)
|
||
private readonly leaderboardService: ILeaderboardServiceClient,
|
||
@Inject(REFERRAL_SERVICE_CLIENT)
|
||
private readonly referralService: IReferralServiceClient,
|
||
@Inject(REWARD_SERVICE_CLIENT)
|
||
private readonly rewardService: IRewardServiceClient,
|
||
@Inject(WALLET_SERVICE_CLIENT)
|
||
private readonly walletService: IWalletServiceClient,
|
||
) {}
|
||
|
||
/**
|
||
* 生成龙虎榜报表
|
||
*/
|
||
async generateLeaderboardReport(
|
||
period: ReportPeriod,
|
||
dateRange: DateRange,
|
||
): Promise<ReportSnapshot> {
|
||
const periodKey = dateRange.toPeriodKey(period);
|
||
const leaderboardType = this.mapPeriodToLeaderboardType(period);
|
||
|
||
// 获取龙虎榜数据
|
||
const leaderboardData = await this.leaderboardService.getLeaderboardData({
|
||
type: leaderboardType,
|
||
periodKey,
|
||
limit: 100,
|
||
});
|
||
|
||
const rows = leaderboardData.map((item, index) => ({
|
||
rank: index + 1,
|
||
userId: item.userId,
|
||
nickname: item.userSnapshot?.nickname || '',
|
||
effectiveScore: item.effectiveScore,
|
||
totalTeamPlanting: item.totalTeamPlanting,
|
||
maxDirectTeamPlanting: item.maxDirectTeamPlanting,
|
||
rankChange: item.rankChange || 0,
|
||
}));
|
||
|
||
const summary = {
|
||
totalParticipants: rows.length,
|
||
topScore: rows[0]?.effectiveScore || 0,
|
||
averageScore: rows.length > 0
|
||
? Math.round(rows.reduce((sum, r) => sum + r.effectiveScore, 0) / rows.length)
|
||
: 0,
|
||
periodKey,
|
||
leaderboardType,
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.LEADERBOARD_REPORT,
|
||
reportCode: 'RPT_LEADERBOARD',
|
||
reportPeriod: period,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['leaderboard-service']),
|
||
dateRange,
|
||
expiresInHours: 24,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成认种报表
|
||
*/
|
||
async generatePlantingReport(
|
||
period: ReportPeriod,
|
||
dateRange: DateRange,
|
||
filters?: { provinceCode?: string; cityCode?: string },
|
||
): Promise<ReportSnapshot> {
|
||
const groupBy = ['date'];
|
||
if (filters?.provinceCode) groupBy.push('province');
|
||
if (filters?.cityCode) groupBy.push('city');
|
||
|
||
const plantingData = await this.plantingService.getPlantingStatistics({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
groupBy,
|
||
filters,
|
||
});
|
||
|
||
const rows = plantingData.map(data => ({
|
||
date: data.date,
|
||
province: data.province || '全国',
|
||
city: data.city || '全部',
|
||
orderCount: data.orderCount,
|
||
treeCount: data.treeCount,
|
||
totalAmount: data.totalAmount,
|
||
newUserCount: data.newUserCount,
|
||
activeUserCount: data.activeUserCount,
|
||
}));
|
||
|
||
const summary = {
|
||
totalOrders: rows.reduce((sum, r) => sum + r.orderCount, 0),
|
||
totalTrees: rows.reduce((sum, r) => sum + r.treeCount, 0),
|
||
totalAmount: rows.reduce((sum, r) => sum + r.totalAmount, 0),
|
||
totalNewUsers: rows.reduce((sum, r) => sum + r.newUserCount, 0),
|
||
dateRange: {
|
||
start: dateRange.startDate,
|
||
end: dateRange.endDate,
|
||
},
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.PLANTING_REPORT,
|
||
reportCode: 'RPT_PLANTING',
|
||
reportPeriod: period,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['planting-service']),
|
||
dateRange,
|
||
filterParams: filters,
|
||
expiresInHours: 24,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成区域认种报表
|
||
*/
|
||
async generateRegionalPlantingReport(
|
||
period: ReportPeriod,
|
||
dateRange: DateRange,
|
||
regionType: 'PROVINCE' | 'CITY',
|
||
): Promise<ReportSnapshot> {
|
||
const groupBy = regionType === 'PROVINCE' ? ['province'] : ['province', 'city'];
|
||
|
||
const plantingData = await this.plantingService.getPlantingStatistics({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
groupBy,
|
||
});
|
||
|
||
const rows = plantingData.map(data => ({
|
||
province: data.province,
|
||
city: data.city || '',
|
||
orderCount: data.orderCount,
|
||
treeCount: data.treeCount,
|
||
totalAmount: data.totalAmount,
|
||
userCount: data.userCount,
|
||
}));
|
||
|
||
// 按认种量排序
|
||
rows.sort((a, b) => b.treeCount - a.treeCount);
|
||
|
||
// 添加排名
|
||
rows.forEach((row, index) => {
|
||
(row as any).rank = index + 1;
|
||
});
|
||
|
||
const summary = {
|
||
regionCount: rows.length,
|
||
totalTrees: rows.reduce((sum, r) => sum + r.treeCount, 0),
|
||
totalAmount: rows.reduce((sum, r) => sum + r.totalAmount, 0),
|
||
regionType,
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.REGIONAL_PLANTING_REPORT,
|
||
reportCode: 'RPT_REGIONAL_PLANTING',
|
||
reportPeriod: period,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['planting-service']),
|
||
dateRange,
|
||
filterParams: { regionType },
|
||
expiresInHours: 24,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成社区数据报表
|
||
*/
|
||
async generateCommunityReport(
|
||
dateRange: DateRange,
|
||
filters?: { communityId?: bigint; communityName?: string },
|
||
): Promise<ReportSnapshot> {
|
||
const communityData = await this.referralService.getCommunityStatistics({
|
||
communityId: filters?.communityId,
|
||
communityName: filters?.communityName,
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
});
|
||
|
||
const rows = communityData.map(data => ({
|
||
communityId: data.communityId,
|
||
communityName: data.communityName,
|
||
parentCommunityName: data.parentCommunityName || '无',
|
||
childCommunityCount: data.childCommunityCount,
|
||
totalPlanting: data.totalPlanting,
|
||
dailyPlanting: data.dailyPlanting,
|
||
weeklyPlanting: data.weeklyPlanting,
|
||
monthlyPlanting: data.monthlyPlanting,
|
||
memberCount: data.memberCount,
|
||
}));
|
||
|
||
const summary = {
|
||
totalCommunities: rows.length,
|
||
totalPlanting: rows.reduce((sum, r) => sum + r.totalPlanting, 0),
|
||
totalMembers: rows.reduce((sum, r) => sum + r.memberCount, 0),
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.COMMUNITY_REPORT,
|
||
reportCode: 'RPT_COMMUNITY',
|
||
reportPeriod: ReportPeriod.CUSTOM,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['referral-service']),
|
||
dateRange,
|
||
filterParams: filters,
|
||
expiresInHours: 1,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成授权公司第1名报表
|
||
*/
|
||
async generateAuthorizedCompanyTopReport(
|
||
companyType: 'PROVINCE' | 'CITY',
|
||
): Promise<ReportSnapshot> {
|
||
const topUsers = await this.referralService.getAuthorizedCompanyTopUsers({
|
||
companyType,
|
||
});
|
||
|
||
const rows = topUsers.map(data => ({
|
||
companyId: data.companyId,
|
||
companyName: data.companyName,
|
||
regionCode: data.regionCode,
|
||
topUserId: data.topUserId,
|
||
topUserName: data.topUserName,
|
||
topUserPlanting: data.topUserPlanting,
|
||
topUserTeamCount: data.topUserTeamCount,
|
||
topUserTotalReward: data.topUserTotalReward,
|
||
}));
|
||
|
||
const summary = {
|
||
companyCount: rows.length,
|
||
companyType,
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.AUTHORIZED_COMPANY_TOP_REPORT,
|
||
reportCode: 'RPT_COMPANY_TOP',
|
||
reportPeriod: ReportPeriod.CUSTOM,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['referral-service']),
|
||
dateRange: DateRange.today(),
|
||
filterParams: { companyType },
|
||
expiresInHours: 1,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成系统账户月度报表
|
||
*/
|
||
async generateSystemAccountMonthlyReport(
|
||
statMonth: string,
|
||
accountType: 'PROVINCE' | 'CITY',
|
||
): Promise<ReportSnapshot> {
|
||
const accountStats = await this.walletService.getSystemAccountStatistics({
|
||
accountType,
|
||
statMonth,
|
||
});
|
||
|
||
const rows = accountStats.map(data => ({
|
||
accountId: data.accountId,
|
||
accountName: data.accountName,
|
||
regionCode: data.regionCode,
|
||
monthlyHashpower: data.monthlyHashpower,
|
||
cumulativeHashpower: data.cumulativeHashpower,
|
||
monthlyMining: data.monthlyMining,
|
||
cumulativeMining: data.cumulativeMining,
|
||
monthlyCommission: data.monthlyCommission,
|
||
cumulativeCommission: data.cumulativeCommission,
|
||
monthlyPlantingBonus: data.monthlyPlantingBonus,
|
||
cumulativePlantingBonus: data.cumulativePlantingBonus,
|
||
}));
|
||
|
||
const summary = {
|
||
accountCount: rows.length,
|
||
totalMonthlyHashpower: rows.reduce((sum, r) => sum + r.monthlyHashpower, 0),
|
||
totalMonthlyMining: rows.reduce((sum, r) => sum + r.monthlyMining, 0),
|
||
totalMonthlyCommission: rows.reduce((sum, r) => sum + r.monthlyCommission, 0),
|
||
statMonth,
|
||
accountType,
|
||
};
|
||
|
||
// 解析月份为日期范围
|
||
const [year, month] = statMonth.split('-').map(Number);
|
||
const startDate = new Date(year, month - 1, 1);
|
||
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.SYSTEM_ACCOUNT_MONTHLY_REPORT,
|
||
reportCode: 'RPT_SYSTEM_ACCOUNT_MONTHLY',
|
||
reportPeriod: ReportPeriod.MONTHLY,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['wallet-service']),
|
||
dateRange: DateRange.create(startDate, endDate),
|
||
filterParams: { statMonth, accountType },
|
||
expiresInHours: 24,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成系统账户收益来源报表
|
||
*/
|
||
async generateSystemAccountIncomeReport(
|
||
dateRange: DateRange,
|
||
filters?: {
|
||
accountId?: bigint;
|
||
accountType?: string;
|
||
keyword?: string;
|
||
},
|
||
): Promise<ReportSnapshot> {
|
||
const incomeRecords = await this.walletService.getSystemAccountIncomeRecords({
|
||
accountId: filters?.accountId,
|
||
accountType: filters?.accountType,
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: filters?.keyword,
|
||
});
|
||
|
||
const rows = incomeRecords.map(data => ({
|
||
recordId: data.recordId,
|
||
accountId: data.accountId,
|
||
accountName: data.accountName,
|
||
accountType: data.accountType,
|
||
incomeType: data.incomeType,
|
||
incomeAmount: data.incomeAmount,
|
||
currency: data.currency,
|
||
sourceType: data.sourceType,
|
||
sourceId: data.sourceId,
|
||
sourceUserId: data.sourceUserId,
|
||
sourceAddress: data.sourceAddress,
|
||
transactionNo: data.transactionNo,
|
||
occurredAt: data.occurredAt,
|
||
memo: data.memo,
|
||
}));
|
||
|
||
const summary = {
|
||
totalRecords: rows.length,
|
||
totalIncome: rows.reduce((sum, r) => sum + r.incomeAmount, 0),
|
||
byIncomeType: this.groupByField(rows, 'incomeType', 'incomeAmount'),
|
||
bySourceType: this.groupByField(rows, 'sourceType', 'incomeAmount'),
|
||
};
|
||
|
||
return ReportSnapshot.create({
|
||
reportType: ReportType.SYSTEM_ACCOUNT_INCOME_REPORT,
|
||
reportCode: 'RPT_SYSTEM_ACCOUNT_INCOME',
|
||
reportPeriod: ReportPeriod.CUSTOM,
|
||
snapshotData: SnapshotData.create({ rows, summary }),
|
||
dataSource: DataSource.create(['wallet-service']),
|
||
dateRange,
|
||
filterParams: filters,
|
||
expiresInHours: 1,
|
||
});
|
||
}
|
||
|
||
// ============ 辅助方法 ============
|
||
|
||
private mapPeriodToLeaderboardType(period: ReportPeriod): string {
|
||
switch (period) {
|
||
case ReportPeriod.DAILY:
|
||
return 'DAILY';
|
||
case ReportPeriod.WEEKLY:
|
||
return 'WEEKLY';
|
||
case ReportPeriod.MONTHLY:
|
||
return 'MONTHLY';
|
||
default:
|
||
return 'DAILY';
|
||
}
|
||
}
|
||
|
||
private groupByField(
|
||
rows: any[],
|
||
groupField: string,
|
||
sumField: string,
|
||
): Record<string, number> {
|
||
return rows.reduce((acc, row) => {
|
||
const key = row[groupField] || 'unknown';
|
||
acc[key] = (acc[key] || 0) + row[sumField];
|
||
return acc;
|
||
}, {});
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.2 src/domain/services/report-export.service.ts
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate';
|
||
import { OutputFormat } from '../value-objects/output-format.enum';
|
||
|
||
export interface ExportResult {
|
||
buffer: Buffer;
|
||
fileName: string;
|
||
mimeType: string;
|
||
fileSize: number;
|
||
}
|
||
|
||
export interface IExcelExporter {
|
||
export(snapshot: ReportSnapshot): Promise<Buffer>;
|
||
}
|
||
|
||
export interface IPdfExporter {
|
||
export(snapshot: ReportSnapshot): Promise<Buffer>;
|
||
}
|
||
|
||
export interface ICsvExporter {
|
||
export(snapshot: ReportSnapshot): Promise<Buffer>;
|
||
}
|
||
|
||
export const EXCEL_EXPORTER = Symbol('IExcelExporter');
|
||
export const PDF_EXPORTER = Symbol('IPdfExporter');
|
||
export const CSV_EXPORTER = Symbol('ICsvExporter');
|
||
|
||
@Injectable()
|
||
export class ReportExportService {
|
||
constructor(
|
||
@Inject(EXCEL_EXPORTER) private readonly excelExporter: IExcelExporter,
|
||
@Inject(PDF_EXPORTER) private readonly pdfExporter: IPdfExporter,
|
||
@Inject(CSV_EXPORTER) private readonly csvExporter: ICsvExporter,
|
||
) {}
|
||
|
||
/**
|
||
* 导出报表
|
||
*/
|
||
async export(
|
||
snapshot: ReportSnapshot,
|
||
format: OutputFormat,
|
||
): Promise<ExportResult> {
|
||
let buffer: Buffer;
|
||
|
||
switch (format) {
|
||
case OutputFormat.EXCEL:
|
||
buffer = await this.excelExporter.export(snapshot);
|
||
break;
|
||
case OutputFormat.PDF:
|
||
buffer = await this.pdfExporter.export(snapshot);
|
||
break;
|
||
case OutputFormat.CSV:
|
||
buffer = await this.csvExporter.export(snapshot);
|
||
break;
|
||
case OutputFormat.JSON:
|
||
buffer = Buffer.from(JSON.stringify(snapshot.snapshotData.toJSON(), null, 2));
|
||
break;
|
||
default:
|
||
throw new Error(`不支持的导出格式: ${format}`);
|
||
}
|
||
|
||
const fileName = this.generateFileName(snapshot, format);
|
||
const mimeType = this.getMimeType(format);
|
||
|
||
return {
|
||
buffer,
|
||
fileName,
|
||
mimeType,
|
||
fileSize: buffer.length,
|
||
};
|
||
}
|
||
|
||
private generateFileName(snapshot: ReportSnapshot, format: OutputFormat): string {
|
||
const extension = this.getExtension(format);
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||
return `${snapshot.reportCode}_${snapshot.periodKey}_${timestamp}.${extension}`;
|
||
}
|
||
|
||
private getMimeType(format: OutputFormat): string {
|
||
switch (format) {
|
||
case OutputFormat.EXCEL:
|
||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||
case OutputFormat.PDF:
|
||
return 'application/pdf';
|
||
case OutputFormat.CSV:
|
||
return 'text/csv';
|
||
case OutputFormat.JSON:
|
||
return 'application/json';
|
||
default:
|
||
return 'application/octet-stream';
|
||
}
|
||
}
|
||
|
||
private getExtension(format: OutputFormat): string {
|
||
switch (format) {
|
||
case OutputFormat.EXCEL:
|
||
return 'xlsx';
|
||
case OutputFormat.PDF:
|
||
return 'pdf';
|
||
case OutputFormat.CSV:
|
||
return 'csv';
|
||
case OutputFormat.JSON:
|
||
return 'json';
|
||
default:
|
||
return 'bin';
|
||
}
|
||
}
|
||
}
|
||
|
||
import { Inject } from '@nestjs/common';
|
||
```
|
||
|
||
---
|
||
|
||
## 领域不变式 (Domain Invariants)
|
||
|
||
```typescript
|
||
class ReportingContextInvariants {
|
||
// 1. 报表代码必须唯一
|
||
static REPORT_CODE_MUST_BE_UNIQUE =
|
||
"报表代码在系统中必须唯一";
|
||
|
||
// 2. 同一报表同一周期只能有一个快照
|
||
static ONE_SNAPSHOT_PER_PERIOD =
|
||
"同一报表代码同一周期只能生成一个快照";
|
||
|
||
// 3. 快照数据创建后不可修改
|
||
static SNAPSHOT_DATA_IMMUTABLE =
|
||
"报表快照数据一旦创建不可修改";
|
||
|
||
// 4. 至少支持一种输出格式
|
||
static AT_LEAST_ONE_OUTPUT_FORMAT =
|
||
"报表定义至少需要支持一种输出格式";
|
||
|
||
// 5. 启用调度需要有效的cron表达式
|
||
static SCHEDULE_REQUIRES_CRON =
|
||
"启用调度时必须配置有效的cron表达式";
|
||
|
||
// 6. 过期快照应被清理
|
||
static EXPIRED_SNAPSHOTS_CLEANUP =
|
||
"过期的报表快照应定期清理以节省存储空间";
|
||
|
||
// 7. 数据来源必须明确
|
||
static DATA_SOURCE_REQUIRED =
|
||
"报表快照必须记录数据来源服务";
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## API 端点设计
|
||
|
||
| 方法 | 路径 | 描述 | 认证 | 权限 |
|
||
|------|------|------|------|------|
|
||
| GET | `/health` | 健康检查 | 否 | - |
|
||
| **龙虎榜报表** |
|
||
| GET | `/reports/leaderboard` | 获取龙虎榜报表 | JWT | 管理员 |
|
||
| POST | `/reports/leaderboard/generate` | 生成龙虎榜报表 | JWT | 管理员 |
|
||
| GET | `/reports/leaderboard/export` | 导出龙虎榜报表 | JWT | 管理员 |
|
||
| **认种报表** |
|
||
| GET | `/reports/planting` | 获取认种报表 | JWT | 管理员 |
|
||
| POST | `/reports/planting/generate` | 生成认种报表 | JWT | 管理员 |
|
||
| GET | `/reports/planting/export` | 导出认种报表 | JWT | 管理员 |
|
||
| **区域报表** |
|
||
| GET | `/reports/regional/province` | 获取省级认种报表 | JWT | 管理员 |
|
||
| GET | `/reports/regional/city` | 获取市级认种报表 | JWT | 管理员 |
|
||
| GET | `/reports/regional/export` | 导出区域报表 | JWT | 管理员 |
|
||
| **授权公司报表** |
|
||
| GET | `/reports/company/top` | 获取授权公司第1名统计 | JWT | 管理员 |
|
||
| GET | `/reports/company/top/export` | 导出授权公司报表 | JWT | 管理员 |
|
||
| **社区报表** |
|
||
| GET | `/reports/community` | 获取社区数据统计 | JWT | 管理员 |
|
||
| GET | `/reports/community/search` | 模糊搜索社区数据 | JWT | 管理员 |
|
||
| GET | `/reports/community/export` | 导出社区报表 | JWT | 管理员 |
|
||
| **系统账户报表** |
|
||
| GET | `/reports/system-account/monthly` | 获取系统账户月度报表 | JWT | 管理员 |
|
||
| GET | `/reports/system-account/income` | 获取系统账户收益来源报表 | JWT | 管理员 |
|
||
| GET | `/reports/system-account/income/timeline` | 获取收益时间轴 | JWT | 管理员 |
|
||
| GET | `/reports/system-account/export` | 导出系统账户报表 | JWT | 管理员 |
|
||
| **通用导出** |
|
||
| POST | `/reports/export` | 通用报表导出 | JWT | 管理员 |
|
||
| GET | `/reports/files/{fileId}` | 下载报表文件 | JWT | 管理员 |
|
||
| GET | `/reports/snapshots` | 获取报表快照列表 | JWT | 管理员 |
|
||
| GET | `/reports/snapshots/{snapshotId}` | 获取报表快照详情 | JWT | 管理员 |
|
||
|
||
---
|
||
|
||
## 定时任务
|
||
|
||
### 报表生成调度器
|
||
|
||
```typescript
|
||
// application/schedulers/report-generation.scheduler.ts
|
||
|
||
import { Injectable } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { ReportingApplicationService } from '../services/reporting-application.service';
|
||
import { ReportPeriod } from '../../domain/value-objects/report-period.enum';
|
||
|
||
@Injectable()
|
||
export class ReportGenerationScheduler {
|
||
constructor(
|
||
private readonly reportingService: ReportingApplicationService,
|
||
) {}
|
||
|
||
/**
|
||
* 每日凌晨1点生成日报表
|
||
*/
|
||
@Cron('0 1 * * *')
|
||
async generateDailyReports() {
|
||
console.log('开始生成日报表...');
|
||
|
||
try {
|
||
// 生成认种日报表
|
||
await this.reportingService.generatePlantingReport(ReportPeriod.DAILY);
|
||
|
||
// 生成区域认种日报表
|
||
await this.reportingService.generateRegionalPlantingReport(ReportPeriod.DAILY, 'PROVINCE');
|
||
await this.reportingService.generateRegionalPlantingReport(ReportPeriod.DAILY, 'CITY');
|
||
|
||
console.log('日报表生成完成');
|
||
} catch (error) {
|
||
console.error('日报表生成失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每周一凌晨2点生成周报表
|
||
*/
|
||
@Cron('0 2 * * 1')
|
||
async generateWeeklyReports() {
|
||
console.log('开始生成周报表...');
|
||
|
||
try {
|
||
await this.reportingService.generatePlantingReport(ReportPeriod.WEEKLY);
|
||
await this.reportingService.generateLeaderboardReport(ReportPeriod.WEEKLY);
|
||
|
||
console.log('周报表生成完成');
|
||
} catch (error) {
|
||
console.error('周报表生成失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每月1日凌晨3点生成月报表
|
||
*/
|
||
@Cron('0 3 1 * *')
|
||
async generateMonthlyReports() {
|
||
console.log('开始生成月报表...');
|
||
|
||
try {
|
||
await this.reportingService.generatePlantingReport(ReportPeriod.MONTHLY);
|
||
await this.reportingService.generateLeaderboardReport(ReportPeriod.MONTHLY);
|
||
await this.reportingService.generateSystemAccountMonthlyReport();
|
||
|
||
console.log('月报表生成完成');
|
||
} catch (error) {
|
||
console.error('月报表生成失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每季度第一天凌晨4点生成季度报表
|
||
*/
|
||
@Cron('0 4 1 1,4,7,10 *')
|
||
async generateQuarterlyReports() {
|
||
console.log('开始生成季度报表...');
|
||
|
||
try {
|
||
await this.reportingService.generatePlantingReport(ReportPeriod.QUARTERLY);
|
||
|
||
console.log('季度报表生成完成');
|
||
} catch (error) {
|
||
console.error('季度报表生成失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每年1月1日凌晨5点生成年度报表
|
||
*/
|
||
@Cron('0 5 1 1 *')
|
||
async generateYearlyReports() {
|
||
console.log('开始生成年度报表...');
|
||
|
||
try {
|
||
await this.reportingService.generatePlantingReport(ReportPeriod.YEARLY);
|
||
|
||
console.log('年度报表生成完成');
|
||
} catch (error) {
|
||
console.error('年度报表生成失败:', error);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 快照清理调度器
|
||
|
||
```typescript
|
||
// application/schedulers/snapshot-cleanup.scheduler.ts
|
||
|
||
import { Injectable } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { ReportingApplicationService } from '../services/reporting-application.service';
|
||
|
||
@Injectable()
|
||
export class SnapshotCleanupScheduler {
|
||
constructor(
|
||
private readonly reportingService: ReportingApplicationService,
|
||
) {}
|
||
|
||
/**
|
||
* 每天凌晨0点清理过期快照
|
||
*/
|
||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||
async cleanupExpiredSnapshots() {
|
||
console.log('开始清理过期报表快照...');
|
||
|
||
try {
|
||
const deletedCount = await this.reportingService.cleanupExpiredSnapshots();
|
||
console.log(`清理完成,共删除 ${deletedCount} 个过期快照`);
|
||
} catch (error) {
|
||
console.error('快照清理失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每周清理超过保留期的报表文件
|
||
*/
|
||
@Cron('0 6 * * 0')
|
||
async cleanupOldReportFiles() {
|
||
console.log('开始清理过期报表文件...');
|
||
|
||
try {
|
||
const deletedCount = await this.reportingService.cleanupOldReportFiles();
|
||
console.log(`清理完成,共删除 ${deletedCount} 个过期文件`);
|
||
} catch (error) {
|
||
console.error('报表文件清理失败:', error);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 事件订阅 (Kafka Events)
|
||
|
||
### 订阅的事件
|
||
|
||
| Topic | 事件类型 | 触发条件 | 处理逻辑 |
|
||
|-------|---------|---------|---------|
|
||
| `planting.order.paid` | PlantingOrderPaidEvent | 认种订单支付成功 | 更新认种日统计 |
|
||
| `reward.created` | RewardCreatedEvent | 奖励创建 | 更新收益统计 |
|
||
| `leaderboard.refreshed` | LeaderboardRefreshedEvent | 榜单刷新完成 | 缓存最新榜单数据 |
|
||
|
||
### 发布的事件
|
||
|
||
| Topic | 事件类型 | 触发条件 |
|
||
|-------|---------|---------|
|
||
| `report.generated` | ReportGeneratedEvent | 报表生成完成 |
|
||
| `report.exported` | ReportExportedEvent | 报表导出完成 |
|
||
|
||
---
|
||
|
||
## 与后台页面对应关系
|
||
|
||
### 1. 龙虎榜数据统计页面
|
||
```
|
||
页面元素:
|
||
- 榜单类型切换: 日榜 | 周榜 | 月榜
|
||
- 日期选择器: 选择统计范围
|
||
- 数据表格: 排名、用户、分值等
|
||
- 导出按钮: Excel/CSV
|
||
|
||
API调用:
|
||
- GET /reports/leaderboard?period=DAILY&startDate=2024-01-01&endDate=2024-01-15
|
||
- GET /reports/leaderboard/export?format=EXCEL
|
||
```
|
||
|
||
### 2. 榴莲树认种报表页面
|
||
```
|
||
页面元素:
|
||
- 报表类型切换: 日报 | 周报 | 月报 | 季度报 | 年度报
|
||
- 日期范围选择器
|
||
- 区域筛选: 省/市下拉
|
||
- 数据表格: 日期、认种数、金额、用户数等
|
||
- 导出按钮
|
||
|
||
API调用:
|
||
- GET /reports/planting?period=DAILY&startDate=2024-01-01&endDate=2024-01-31
|
||
- POST /reports/planting/generate
|
||
- GET /reports/planting/export?format=EXCEL
|
||
```
|
||
|
||
### 3. 区域认种报表页面
|
||
```
|
||
页面元素:
|
||
- 维度切换: 按省 | 按市
|
||
- 日期范围选择器
|
||
- 数据表格: 区域、认种数、金额、排名等
|
||
- 导出按钮
|
||
|
||
API调用:
|
||
- GET /reports/regional/province?period=MONTHLY
|
||
- GET /reports/regional/city?period=MONTHLY
|
||
- GET /reports/regional/export?format=CSV
|
||
```
|
||
|
||
### 4. 社区数据统计页面
|
||
```
|
||
页面元素:
|
||
- 搜索框: 支持社区名模糊搜索
|
||
- 日期选择: 日新增 | 周新增 | 月新增 | 自定义范围
|
||
- 数据表格: 社区名、上级、下级、认种总量、新增量
|
||
- 导出按钮
|
||
|
||
API调用:
|
||
- GET /reports/community?communityName=xxx
|
||
- GET /reports/community/search?keyword=xxx&startDate=xxx&endDate=xxx
|
||
- GET /reports/community/export?format=EXCEL
|
||
```
|
||
|
||
### 5. 系统账户月度报表页面
|
||
```
|
||
页面元素:
|
||
- 账户类型: 省公司 | 市公司
|
||
- 月份选择器
|
||
- 数据表格: 账户名、每月算力、累计算力、每月挖矿量、累计挖矿量等
|
||
- 导出按钮
|
||
|
||
API调用:
|
||
- GET /reports/system-account/monthly?accountType=PROVINCE&month=2024-01
|
||
- GET /reports/system-account/export?format=EXCEL
|
||
```
|
||
|
||
### 6. 系统账户收益来源页面
|
||
```
|
||
页面元素:
|
||
- 账户选择: 省公司/市公司下拉
|
||
- 日期范围选择器
|
||
- 关键词搜索: 地址、交易流水号
|
||
- 收益时间轴: 每笔收益的时间线展示
|
||
- 来源细分统计: 饼图/柱状图
|
||
- 数据表格: 时间、来源、金额、交易号等
|
||
- 导出按钮
|
||
|
||
API调用:
|
||
- GET /reports/system-account/income?accountId=xxx&startDate=xxx&endDate=xxx&keyword=xxx
|
||
- GET /reports/system-account/income/timeline?accountId=xxx
|
||
- GET /reports/system-account/export?format=EXCEL
|
||
```
|
||
|
||
---
|
||
|
||
## 开发顺序建议
|
||
|
||
1. **Phase 1: 项目初始化**
|
||
- 创建NestJS项目
|
||
- 安装依赖
|
||
- 配置环境变量
|
||
|
||
2. **Phase 2: 数据库层**
|
||
- 创建Prisma Schema
|
||
- 运行迁移和种子数据
|
||
- 创建PrismaService
|
||
|
||
3. **Phase 3: 领域层**
|
||
- 实现所有值对象
|
||
- 实现聚合根 (ReportDefinition, ReportSnapshot)
|
||
- 实现实体 (AnalyticsMetric, ReportFile)
|
||
- 实现领域事件
|
||
- 实现领域服务 (ReportGenerationService, ReportExportService)
|
||
- 编写单元测试
|
||
|
||
4. **Phase 4: 基础设施层**
|
||
- 实现仓储 (Repository Implementations)
|
||
- 实现外部服务客户端 (各Service Client)
|
||
- 实现导出服务 (Excel, PDF, CSV)
|
||
- 实现文件存储服务
|
||
- 实现Kafka消费者和发布者
|
||
- 实现Redis缓存服务
|
||
|
||
5. **Phase 5: 应用层**
|
||
- 实现应用服务 (ReportingApplicationService)
|
||
- 实现定时任务 (ReportGenerationScheduler, SnapshotCleanupScheduler)
|
||
- 实现Command/Query handlers
|
||
|
||
6. **Phase 6: API层**
|
||
- 实现DTO
|
||
- 实现Controllers
|
||
- 配置Swagger文档
|
||
- 配置JWT认证和管理员权限
|
||
|
||
7. **Phase 7: 测试和部署**
|
||
- 集成测试
|
||
- E2E测试
|
||
- Docker配置
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
1. **数据聚合**: 报表数据从多个微服务聚合,需要处理服务间的数据一致性
|
||
2. **性能优化**: 大数据量报表生成需要考虑分批处理和异步生成
|
||
3. **缓存策略**: 热门报表数据应缓存到Redis,减少重复计算
|
||
4. **文件管理**: 导出的报表文件需要定期清理,避免存储空间耗尽
|
||
5. **权限控制**: 所有报表接口都需要管理员权限
|
||
6. **导出限制**: 导出数据量过大时需要限制或采用异步导出
|
||
7. **时区处理**: 统一使用Asia/Shanghai时区进行日期计算
|
||
8. **模糊搜索**: 社区搜索支持模糊匹配,需要优化查询性能
|
||
9. **时间轴展示**: 收益来源时间轴需要支持分页加载
|
||
10. **幂等性**: 报表生成需要保证幂等性,避免重复生成
|