91 KiB
91 KiB
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 项目
cd backend/services/reporting-service
npx @nestjs/cli new . --skip-git --package-manager npm
1.2 安装依赖
# 核心依赖
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:
# 应用配置
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
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 初始化数据库和种子数据
npx prisma generate
npx prisma migrate dev --name init
创建 prisma/seed.ts:
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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)
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 | 管理员 |
定时任务
报表生成调度器
// 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);
}
}
}
快照清理调度器
// 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
开发顺序建议
-
Phase 1: 项目初始化
- 创建NestJS项目
- 安装依赖
- 配置环境变量
-
Phase 2: 数据库层
- 创建Prisma Schema
- 运行迁移和种子数据
- 创建PrismaService
-
Phase 3: 领域层
- 实现所有值对象
- 实现聚合根 (ReportDefinition, ReportSnapshot)
- 实现实体 (AnalyticsMetric, ReportFile)
- 实现领域事件
- 实现领域服务 (ReportGenerationService, ReportExportService)
- 编写单元测试
-
Phase 4: 基础设施层
- 实现仓储 (Repository Implementations)
- 实现外部服务客户端 (各Service Client)
- 实现导出服务 (Excel, PDF, CSV)
- 实现文件存储服务
- 实现Kafka消费者和发布者
- 实现Redis缓存服务
-
Phase 5: 应用层
- 实现应用服务 (ReportingApplicationService)
- 实现定时任务 (ReportGenerationScheduler, SnapshotCleanupScheduler)
- 实现Command/Query handlers
-
Phase 6: API层
- 实现DTO
- 实现Controllers
- 配置Swagger文档
- 配置JWT认证和管理员权限
-
Phase 7: 测试和部署
- 集成测试
- E2E测试
- Docker配置
注意事项
- 数据聚合: 报表数据从多个微服务聚合,需要处理服务间的数据一致性
- 性能优化: 大数据量报表生成需要考虑分批处理和异步生成
- 缓存策略: 热门报表数据应缓存到Redis,减少重复计算
- 文件管理: 导出的报表文件需要定期清理,避免存储空间耗尽
- 权限控制: 所有报表接口都需要管理员权限
- 导出限制: 导出数据量过大时需要限制或采用异步导出
- 时区处理: 统一使用Asia/Shanghai时区进行日期计算
- 模糊搜索: 社区搜索支持模糊匹配,需要优化查询性能
- 时间轴展示: 收益来源时间轴需要支持分页加载
- 幂等性: 报表生成需要保证幂等性,避免重复生成