rwadurian/backend/services/reporting-service/DEVELOPMENT_GUIDE.md

91 KiB
Raw Blame History

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

开发顺序建议

  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. 幂等性: 报表生成需要保证幂等性,避免重复生成