# Reporting & Analytics Service 开发指导 ## 项目概述 Reporting & Analytics Service 是 RWA 榴莲皇后平台的报表分析微服务,负责多维报表生成、数据统计与分析、报表导出、数据大屏支持等功能。 ### 核心职责 ✅ - 多维报表生成(按时间/地域/权益类型等维度) - 龙虎榜数据统计(日榜、周榜、月榜排名导出) - 榴莲树认种报表(日/周/月/季度/年度) - 各省/市认种报表统计 - 社区数据统计(支持模糊查询、日期范围筛选) - 系统省/市公司账户数据统计 - 收益来源细分统计与时间轴 - 报表导出(Excel/PDF/CSV/JSON) - 数据大屏支持 ### 不负责 ❌ - 实时业务数据存储(从其他上下文聚合) - 权限控制(Authorization Context) - 复杂计算(由源上下文提供) - 业务逻辑处理(由各业务服务负责) --- ## 核心业务需求 ### 1. 龙虎榜数据统计 ``` 报表类型:日榜、周榜、月榜 统计内容:排名、用户、分值、团队数据 功能要求: - 可指定统计范围(时间段) - 支持导出表格(Excel/CSV) - 历史榜单数据查询 ``` ### 2. 榴莲树认种报表 ``` 报表周期: - 日报表:按天统计 - 周报表:按周统计 - 月报表:按月统计 - 季度报表:按季度统计 - 年度报表:按年统计 统计内容: - 认种数量(棵) - 认种金额(USDT) - 新增用户数 - 活跃用户数 - 同比/环比增长率 ``` ### 3. 各省/市认种报表 ``` 报表类型: - 按省统计的认种报表(日/周/月/季度/年度) - 按市统计的认种报表(日/周/月/季度/年度) 统计内容: - 区域认种数量 - 区域认种金额 - 区域用户数 - 区域排名 ``` ### 4. 授权省/市公司第1名统计 ``` 统计内容: - 各授权省公司的第1名用户及其完成数据 - 各授权市公司的第1名用户及其完成数据 - 包含认种量、团队数、收益等指标 ``` ### 5. 社区数据统计 ``` 统计维度: - 本社区名称 - 上级社区 - 下级社区列表 - 社区认种总量 查询功能: - 日新增认种量 - 周新增认种量 - 月新增认种量 - 指定日期范围查询 - 模糊查询(社区名、用户名) ``` ### 6. 系统省/市公司账户月度统计 ``` 统计指标: ① 每月算力 ② 累计算力 ③ 每月挖矿量 ④ 累计挖矿量 ⑤ 每月佣金 ⑥ 累计佣金 ⑦ 每月累计认种提成 ⑧ 累计佣金总额 输出格式:表格报表,支持导出 ``` ### 7. 系统账户收益来源细分统计 ``` 统计内容: - 每笔收益的来源类型 - 每笔收益的金额 - 每笔收益的时间轴 筛选条件: - 时间范围筛选 - 关键词筛选(地址、交易流水号等) - 账户类型筛选(省公司/市公司) ``` --- ## 技术栈 | 组件 | 技术选型 | |------|----------| | **框架** | NestJS 10.x | | **数据库** | PostgreSQL + Prisma ORM | | **架构** | DDD + Hexagonal Architecture (六边形架构) | | **语言** | TypeScript 5.x | | **消息队列** | Kafka (kafkajs) | | **缓存** | Redis (ioredis) | | **定时任务** | @nestjs/schedule | | **报表导出** | ExcelJS (Excel), PDFKit (PDF) | | **API文档** | Swagger (@nestjs/swagger) | --- ## 架构设计 ``` reporting-service/ ├── prisma/ │ ├── schema.prisma # 数据库模型定义 │ └── migrations/ # 数据库迁移文件 │ ├── src/ │ ├── api/ # 🔵 Presentation Layer (表现层) │ │ ├── controllers/ │ │ │ ├── health.controller.ts │ │ │ ├── leaderboard-report.controller.ts │ │ │ ├── planting-report.controller.ts │ │ │ ├── regional-report.controller.ts │ │ │ ├── community-report.controller.ts │ │ │ ├── system-account-report.controller.ts │ │ │ └── export.controller.ts │ │ ├── dto/ │ │ │ ├── request/ │ │ │ │ ├── generate-report.dto.ts │ │ │ │ ├── query-report.dto.ts │ │ │ │ ├── export-report.dto.ts │ │ │ │ └── date-range.dto.ts │ │ │ └── response/ │ │ │ ├── report-snapshot.dto.ts │ │ │ ├── planting-report.dto.ts │ │ │ ├── regional-report.dto.ts │ │ │ ├── community-report.dto.ts │ │ │ └── system-account-report.dto.ts │ │ └── api.module.ts │ │ │ ├── application/ # 🟢 Application Layer (应用层) │ │ ├── commands/ │ │ │ ├── generate-report/ │ │ │ │ ├── generate-report.command.ts │ │ │ │ └── generate-report.handler.ts │ │ │ ├── export-report/ │ │ │ │ ├── export-report.command.ts │ │ │ │ └── export-report.handler.ts │ │ │ ├── schedule-report/ │ │ │ │ ├── schedule-report.command.ts │ │ │ │ └── schedule-report.handler.ts │ │ │ └── index.ts │ │ ├── queries/ │ │ │ ├── get-leaderboard-report/ │ │ │ │ ├── get-leaderboard-report.query.ts │ │ │ │ └── get-leaderboard-report.handler.ts │ │ │ ├── get-planting-report/ │ │ │ │ ├── get-planting-report.query.ts │ │ │ │ └── get-planting-report.handler.ts │ │ │ ├── get-regional-report/ │ │ │ │ ├── get-regional-report.query.ts │ │ │ │ └── get-regional-report.handler.ts │ │ │ ├── get-community-report/ │ │ │ │ ├── get-community-report.query.ts │ │ │ │ └── get-community-report.handler.ts │ │ │ ├── get-system-account-report/ │ │ │ │ ├── get-system-account-report.query.ts │ │ │ │ └── get-system-account-report.handler.ts │ │ │ └── index.ts │ │ ├── services/ │ │ │ └── reporting-application.service.ts │ │ ├── schedulers/ │ │ │ ├── report-generation.scheduler.ts │ │ │ └── snapshot-cleanup.scheduler.ts │ │ └── application.module.ts │ │ │ ├── domain/ # 🟡 Domain Layer (领域层) │ │ ├── aggregates/ │ │ │ ├── report-definition/ │ │ │ │ ├── report-definition.aggregate.ts │ │ │ │ ├── report-definition.spec.ts │ │ │ │ └── index.ts │ │ │ └── report-snapshot/ │ │ │ ├── report-snapshot.aggregate.ts │ │ │ ├── report-snapshot.spec.ts │ │ │ └── index.ts │ │ ├── entities/ │ │ │ ├── analytics-metric.entity.ts │ │ │ ├── report-file.entity.ts │ │ │ └── index.ts │ │ ├── value-objects/ │ │ │ ├── report-type.enum.ts │ │ │ ├── report-period.enum.ts │ │ │ ├── report-dimension.enum.ts │ │ │ ├── report-parameters.vo.ts │ │ │ ├── report-schedule.vo.ts │ │ │ ├── output-format.enum.ts │ │ │ ├── snapshot-data.vo.ts │ │ │ ├── data-source.vo.ts │ │ │ ├── date-range.vo.ts │ │ │ └── index.ts │ │ ├── events/ │ │ │ ├── domain-event.base.ts │ │ │ ├── report-generated.event.ts │ │ │ ├── report-exported.event.ts │ │ │ ├── snapshot-created.event.ts │ │ │ └── index.ts │ │ ├── repositories/ │ │ │ ├── report-definition.repository.interface.ts │ │ │ ├── report-snapshot.repository.interface.ts │ │ │ ├── analytics-metric.repository.interface.ts │ │ │ └── index.ts │ │ ├── services/ │ │ │ ├── report-generation.service.ts │ │ │ ├── report-export.service.ts │ │ │ ├── data-aggregation.service.ts │ │ │ └── index.ts │ │ └── domain.module.ts │ │ │ ├── infrastructure/ # 🔴 Infrastructure Layer (基础设施层) │ │ ├── persistence/ │ │ │ ├── prisma/ │ │ │ │ └── prisma.service.ts │ │ │ ├── mappers/ │ │ │ │ ├── report-definition.mapper.ts │ │ │ │ ├── report-snapshot.mapper.ts │ │ │ │ └── analytics-metric.mapper.ts │ │ │ └── repositories/ │ │ │ ├── report-definition.repository.impl.ts │ │ │ ├── report-snapshot.repository.impl.ts │ │ │ └── analytics-metric.repository.impl.ts │ │ ├── external/ │ │ │ ├── planting-service/ │ │ │ │ └── planting-service.client.ts │ │ │ ├── reward-service/ │ │ │ │ └── reward-service.client.ts │ │ │ ├── referral-service/ │ │ │ │ └── referral-service.client.ts │ │ │ ├── leaderboard-service/ │ │ │ │ └── leaderboard-service.client.ts │ │ │ ├── wallet-service/ │ │ │ │ └── wallet-service.client.ts │ │ │ └── identity-service/ │ │ │ └── identity-service.client.ts │ │ ├── export/ │ │ │ ├── excel-export.service.ts │ │ │ ├── pdf-export.service.ts │ │ │ ├── csv-export.service.ts │ │ │ └── export.module.ts │ │ ├── storage/ │ │ │ ├── file-storage.service.ts │ │ │ └── storage.module.ts │ │ ├── kafka/ │ │ │ ├── event-consumer.controller.ts │ │ │ ├── event-publisher.service.ts │ │ │ └── kafka.module.ts │ │ ├── redis/ │ │ │ ├── redis.service.ts │ │ │ ├── report-cache.service.ts │ │ │ └── redis.module.ts │ │ └── infrastructure.module.ts │ │ │ ├── shared/ # 共享模块 │ │ ├── decorators/ │ │ │ ├── current-user.decorator.ts │ │ │ ├── public.decorator.ts │ │ │ └── index.ts │ │ ├── exceptions/ │ │ │ ├── domain.exception.ts │ │ │ ├── application.exception.ts │ │ │ └── index.ts │ │ ├── filters/ │ │ │ ├── global-exception.filter.ts │ │ │ └── domain-exception.filter.ts │ │ ├── guards/ │ │ │ ├── jwt-auth.guard.ts │ │ │ └── admin.guard.ts │ │ ├── interceptors/ │ │ │ └── transform.interceptor.ts │ │ └── strategies/ │ │ └── jwt.strategy.ts │ │ │ ├── config/ │ │ ├── app.config.ts │ │ ├── database.config.ts │ │ ├── jwt.config.ts │ │ ├── redis.config.ts │ │ ├── kafka.config.ts │ │ ├── storage.config.ts │ │ └── index.ts │ │ │ ├── app.module.ts │ └── main.ts │ ├── test/ ├── .env.example ├── .env.development ├── package.json ├── tsconfig.json └── Dockerfile ``` --- ## 第一阶段:项目初始化 ### 1.1 创建 NestJS 项目 ```bash cd backend/services/reporting-service npx @nestjs/cli new . --skip-git --package-manager npm ``` ### 1.2 安装依赖 ```bash # 核心依赖 npm install @nestjs/config @nestjs/swagger @nestjs/jwt @nestjs/passport @nestjs/microservices @nestjs/schedule npm install @prisma/client class-validator class-transformer uuid ioredis kafkajs npm install passport passport-jwt # 报表导出依赖 npm install exceljs pdfkit csv-stringify # 开发依赖 npm install -D prisma @types/uuid @types/passport-jwt @types/pdfkit npm install -D @nestjs/testing jest ts-jest @types/jest supertest @types/supertest ``` ### 1.3 环境变量配置 创建 `.env.development`: ```env # 应用配置 NODE_ENV=development PORT=3008 APP_NAME=reporting-service # 数据库 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_reporting?schema=public" # JWT (与 identity-service 共享密钥) JWT_SECRET=your-super-secret-jwt-key-change-in-production JWT_ACCESS_EXPIRES_IN=2h # Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= # Kafka KAFKA_BROKERS=localhost:9092 KAFKA_GROUP_ID=reporting-service-group KAFKA_CLIENT_ID=reporting-service # 外部服务 IDENTITY_SERVICE_URL=http://localhost:3001 PLANTING_SERVICE_URL=http://localhost:3003 REFERRAL_SERVICE_URL=http://localhost:3004 REWARD_SERVICE_URL=http://localhost:3005 LEADERBOARD_SERVICE_URL=http://localhost:3007 WALLET_SERVICE_URL=http://localhost:3002 # 文件存储 FILE_STORAGE_PATH=./storage/reports FILE_STORAGE_URL_PREFIX=http://localhost:3008/files # 报表缓存过期时间(秒) REPORT_CACHE_TTL=3600 # 报表快照保留天数 SNAPSHOT_RETENTION_DAYS=90 ``` --- ## 第二阶段:数据库设计 (Prisma Schema) ### 2.1 创建 prisma/schema.prisma ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ============================================ // 报表定义表 (聚合根1) // 定义各类报表的配置和调度规则 // ============================================ model ReportDefinition { id BigInt @id @default(autoincrement()) @map("definition_id") // === 报表基本信息 === reportType String @map("report_type") @db.VarChar(50) // 报表类型 reportName String @map("report_name") @db.VarChar(200) // 报表名称 reportCode String @unique @map("report_code") @db.VarChar(50) // 报表代码 description String? @map("description") @db.Text // 报表描述 // === 报表参数 === parameters Json @map("parameters") // 报表参数配置 // === 调度配置 === scheduleCron String? @map("schedule_cron") @db.VarChar(100) // Cron表达式 scheduleTimezone String? @map("schedule_timezone") @db.VarChar(50) @default("Asia/Shanghai") scheduleEnabled Boolean @default(false) @map("schedule_enabled") // === 输出格式 === outputFormats String[] @map("output_formats") // 支持的输出格式 // === 状态 === isActive Boolean @default(true) @map("is_active") // === 时间戳 === createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") lastGeneratedAt DateTime? @map("last_generated_at") @@map("report_definitions") @@index([reportType], name: "idx_def_type") @@index([isActive], name: "idx_def_active") @@index([scheduleEnabled], name: "idx_def_scheduled") } // ============================================ // 报表快照表 (聚合根2 - 读模型) // 存储已生成的报表数据快照 // ============================================ model ReportSnapshot { id BigInt @id @default(autoincrement()) @map("snapshot_id") // === 报表信息 === reportType String @map("report_type") @db.VarChar(50) reportCode String @map("report_code") @db.VarChar(50) reportPeriod String @map("report_period") @db.VarChar(20) // DAILY/WEEKLY/MONTHLY/QUARTERLY/YEARLY periodKey String @map("period_key") @db.VarChar(30) // 2024-01-15 / 2024-W03 / 2024-01 / 2024-Q1 / 2024 // === 快照数据 === snapshotData Json @map("snapshot_data") // 报表数据 summaryData Json? @map("summary_data") // 汇总数据 // === 数据来源 === dataSources String[] @map("data_sources") // 数据来源服务 dataFreshness Int @default(0) @map("data_freshness") // 数据新鲜度(秒) // === 过滤条件 === filterParams Json? @map("filter_params") // 筛选参数 // === 统计信息 === rowCount Int @default(0) @map("row_count") // 数据行数 // === 时间戳 === periodStartAt DateTime @map("period_start_at") periodEndAt DateTime @map("period_end_at") generatedAt DateTime @default(now()) @map("generated_at") expiresAt DateTime? @map("expires_at") @@map("report_snapshots") @@unique([reportCode, periodKey], name: "uk_report_period") @@index([reportType], name: "idx_snapshot_type") @@index([reportCode], name: "idx_snapshot_code") @@index([periodKey], name: "idx_snapshot_period") @@index([generatedAt(sort: Desc)], name: "idx_snapshot_generated") @@index([expiresAt], name: "idx_snapshot_expires") } // ============================================ // 报表文件表 // 存储已导出的报表文件信息 // ============================================ model ReportFile { id BigInt @id @default(autoincrement()) @map("file_id") snapshotId BigInt @map("snapshot_id") // === 文件信息 === fileName String @map("file_name") @db.VarChar(500) filePath String @map("file_path") @db.VarChar(1000) fileUrl String? @map("file_url") @db.VarChar(1000) fileSize BigInt @map("file_size") // 文件大小(字节) fileFormat String @map("file_format") @db.VarChar(20) // EXCEL/PDF/CSV/JSON mimeType String @map("mime_type") @db.VarChar(100) // === 访问信息 === downloadCount Int @default(0) @map("download_count") lastDownloadAt DateTime? @map("last_download_at") // === 时间戳 === createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime? @map("expires_at") @@map("report_files") @@index([snapshotId], name: "idx_file_snapshot") @@index([fileFormat], name: "idx_file_format") @@index([createdAt(sort: Desc)], name: "idx_file_created") } // ============================================ // 分析指标表 (聚合数据) // 存储预聚合的分析指标数据 // ============================================ model AnalyticsMetric { id BigInt @id @default(autoincrement()) @map("metric_id") // === 指标信息 === metricType String @map("metric_type") @db.VarChar(50) // 指标类型 metricCode String @map("metric_code") @db.VarChar(50) // 指标代码 // === 维度 === dimensionTime DateTime? @map("dimension_time") @db.Date // 时间维度 dimensionRegion String? @map("dimension_region") @db.VarChar(100) // 地域维度(省/市编码) dimensionUserType String? @map("dimension_user_type") @db.VarChar(50) // 用户类型维度 dimensionRightType String? @map("dimension_right_type") @db.VarChar(50) // 权益类型维度 // === 指标值 === metricValue Decimal @map("metric_value") @db.Decimal(20, 8) metricData Json? @map("metric_data") // 指标详情 // === 时间戳 === calculatedAt DateTime @default(now()) @map("calculated_at") @@map("analytics_metrics") @@unique([metricCode, dimensionTime, dimensionRegion, dimensionUserType, dimensionRightType], name: "uk_metric_dimensions") @@index([metricType], name: "idx_metric_type") @@index([metricCode], name: "idx_metric_code") @@index([dimensionTime], name: "idx_metric_time") @@index([dimensionRegion], name: "idx_metric_region") } // ============================================ // 认种统计日表 (每日聚合) // ============================================ model PlantingDailyStat { id BigInt @id @default(autoincrement()) @map("stat_id") // === 统计日期 === statDate DateTime @map("stat_date") @db.Date // === 区域维度 === provinceCode String? @map("province_code") @db.VarChar(10) cityCode String? @map("city_code") @db.VarChar(10) // === 统计数据 === orderCount Int @default(0) @map("order_count") // 订单数 treeCount Int @default(0) @map("tree_count") // 认种棵数 totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(20, 8) // 认种金额 newUserCount Int @default(0) @map("new_user_count") // 新增用户数 activeUserCount Int @default(0) @map("active_user_count") // 活跃用户数 // === 时间戳 === createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("planting_daily_stats") @@unique([statDate, provinceCode, cityCode], name: "uk_daily_stat") @@index([statDate], name: "idx_pds_date") @@index([provinceCode], name: "idx_pds_province") @@index([cityCode], name: "idx_pds_city") } // ============================================ // 社区统计表 // ============================================ model CommunityStat { id BigInt @id @default(autoincrement()) @map("stat_id") // === 社区信息 === communityId BigInt @map("community_id") communityName String @map("community_name") @db.VarChar(200) parentCommunityId BigInt? @map("parent_community_id") // === 统计日期 === statDate DateTime @map("stat_date") @db.Date // === 统计数据 === totalPlanting Int @default(0) @map("total_planting") // 累计认种量 dailyPlanting Int @default(0) @map("daily_planting") // 日新增认种 weeklyPlanting Int @default(0) @map("weekly_planting") // 周新增认种 monthlyPlanting Int @default(0) @map("monthly_planting") // 月新增认种 memberCount Int @default(0) @map("member_count") // 社区成员数 // === 时间戳 === createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("community_stats") @@unique([communityId, statDate], name: "uk_community_stat") @@index([communityId], name: "idx_cs_community") @@index([communityName], name: "idx_cs_name") @@index([statDate], name: "idx_cs_date") @@index([parentCommunityId], name: "idx_cs_parent") } // ============================================ // 系统账户月度统计表 // 省公司/市公司账户的月度数据 // ============================================ model SystemAccountMonthlyStat { id BigInt @id @default(autoincrement()) @map("stat_id") // === 账户信息 === accountId BigInt @map("account_id") accountType String @map("account_type") @db.VarChar(30) // PROVINCE/CITY accountName String @map("account_name") @db.VarChar(200) regionCode String @map("region_code") @db.VarChar(10) // 省/市编码 // === 统计月份 === statMonth String @map("stat_month") @db.VarChar(7) // 2024-01 // === 月度数据 === monthlyHashpower Decimal @default(0) @map("monthly_hashpower") @db.Decimal(20, 8) // 每月算力 cumulativeHashpower Decimal @default(0) @map("cumulative_hashpower") @db.Decimal(20, 8) // 累计算力 monthlyMining Decimal @default(0) @map("monthly_mining") @db.Decimal(20, 8) // 每月挖矿量 cumulativeMining Decimal @default(0) @map("cumulative_mining") @db.Decimal(20, 8) // 累计挖矿量 monthlyCommission Decimal @default(0) @map("monthly_commission") @db.Decimal(20, 8) // 每月佣金 cumulativeCommission Decimal @default(0) @map("cumulative_commission") @db.Decimal(20, 8) // 累计佣金 monthlyPlantingBonus Decimal @default(0) @map("monthly_planting_bonus") @db.Decimal(20, 8) // 每月认种提成 cumulativePlantingBonus Decimal @default(0) @map("cumulative_planting_bonus") @db.Decimal(20, 8) // 累计认种提成 // === 时间戳 === createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("system_account_monthly_stats") @@unique([accountId, statMonth], name: "uk_account_month") @@index([accountType], name: "idx_sams_type") @@index([statMonth], name: "idx_sams_month") @@index([regionCode], name: "idx_sams_region") } // ============================================ // 系统账户收益流水表 // 记录每笔收益的来源和时间 // ============================================ model SystemAccountIncomeRecord { id BigInt @id @default(autoincrement()) @map("record_id") // === 账户信息 === accountId BigInt @map("account_id") accountType String @map("account_type") @db.VarChar(30) // === 收益信息 === incomeType String @map("income_type") @db.VarChar(50) // 收益类型 incomeAmount Decimal @map("income_amount") @db.Decimal(20, 8) currency String @map("currency") @db.VarChar(10) // USDT/HASHPOWER // === 来源信息 === sourceType String @map("source_type") @db.VarChar(50) // 来源类型 sourceId String? @map("source_id") @db.VarChar(100) // 来源ID sourceUserId BigInt? @map("source_user_id") // 来源用户ID sourceAddress String? @map("source_address") @db.VarChar(200) // 来源地址 transactionNo String? @map("transaction_no") @db.VarChar(100) // 交易流水号 // === 备注 === memo String? @map("memo") @db.Text // === 时间戳 === occurredAt DateTime @map("occurred_at") createdAt DateTime @default(now()) @map("created_at") @@map("system_account_income_records") @@index([accountId], name: "idx_sair_account") @@index([accountType], name: "idx_sair_type") @@index([incomeType], name: "idx_sair_income_type") @@index([sourceType], name: "idx_sair_source_type") @@index([sourceAddress], name: "idx_sair_address") @@index([transactionNo], name: "idx_sair_txno") @@index([occurredAt(sort: Desc)], name: "idx_sair_occurred") } // ============================================ // 报表事件表 // ============================================ model ReportEvent { id BigInt @id @default(autoincrement()) @map("event_id") eventType String @map("event_type") @db.VarChar(50) // 聚合根信息 aggregateId String @map("aggregate_id") @db.VarChar(100) aggregateType String @map("aggregate_type") @db.VarChar(50) // 事件数据 eventData Json @map("event_data") // 元数据 userId BigInt? @map("user_id") occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6) version Int @default(1) @map("version") @@map("report_events") @@index([aggregateType, aggregateId], name: "idx_report_event_aggregate") @@index([eventType], name: "idx_report_event_type") @@index([occurredAt], name: "idx_report_event_occurred") } ``` ### 2.2 初始化数据库和种子数据 ```bash npx prisma generate npx prisma migrate dev --name init ``` 创建 `prisma/seed.ts`: ```typescript import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { // 初始化报表定义 const reportDefinitions = [ { reportType: 'LEADERBOARD_REPORT', reportName: '龙虎榜数据报表', reportCode: 'RPT_LEADERBOARD', description: '龙虎榜日榜/周榜/月榜排名数据统计', parameters: { dimensions: ['TIME', 'USER'], defaultPeriod: 'DAILY', }, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, { reportType: 'PLANTING_REPORT', reportName: '榴莲树认种报表', reportCode: 'RPT_PLANTING', description: '榴莲树认种日/周/月/季度/年度报表', parameters: { dimensions: ['TIME', 'REGION'], defaultPeriod: 'DAILY', }, scheduleCron: '0 1 * * *', // 每日凌晨1点 scheduleEnabled: true, outputFormats: ['EXCEL', 'CSV', 'PDF'], isActive: true, }, { reportType: 'REGIONAL_PLANTING_REPORT', reportName: '区域认种报表', reportCode: 'RPT_REGIONAL_PLANTING', description: '按省/市统计的认种报表', parameters: { dimensions: ['REGION', 'TIME'], defaultPeriod: 'DAILY', }, scheduleCron: '0 2 * * *', // 每日凌晨2点 scheduleEnabled: true, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, { reportType: 'AUTHORIZED_COMPANY_TOP_REPORT', reportName: '授权公司第1名统计', reportCode: 'RPT_COMPANY_TOP', description: '各授权省公司和市公司的第1名及完成数据', parameters: { dimensions: ['REGION', 'USER'], includeProvince: true, includeCity: true, }, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, { reportType: 'COMMUNITY_REPORT', reportName: '社区数据统计', reportCode: 'RPT_COMMUNITY', description: '社区认种总量、日/周/月新增、上下级社区统计', parameters: { dimensions: ['COMMUNITY', 'TIME'], supportFuzzySearch: true, }, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, { reportType: 'SYSTEM_ACCOUNT_MONTHLY_REPORT', reportName: '系统账户月度报表', reportCode: 'RPT_SYSTEM_ACCOUNT_MONTHLY', description: '系统省/市公司账户每月各项数据统计', parameters: { dimensions: ['ACCOUNT', 'TIME'], metrics: [ 'monthlyHashpower', 'cumulativeHashpower', 'monthlyMining', 'cumulativeMining', 'monthlyCommission', 'cumulativeCommission', 'monthlyPlantingBonus', 'cumulativePlantingBonus', ], }, scheduleCron: '0 0 1 * *', // 每月1日0点 scheduleEnabled: true, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, { reportType: 'SYSTEM_ACCOUNT_INCOME_REPORT', reportName: '系统账户收益来源报表', reportCode: 'RPT_SYSTEM_ACCOUNT_INCOME', description: '系统省/市公司账户收益来源细分统计及时间轴', parameters: { dimensions: ['ACCOUNT', 'TIME', 'SOURCE'], supportTimeFilter: true, supportKeywordSearch: true, }, outputFormats: ['EXCEL', 'CSV'], isActive: true, }, ]; for (const def of reportDefinitions) { await prisma.reportDefinition.upsert({ where: { reportCode: def.reportCode }, update: def, create: def, }); } console.log('Seed completed: Report definitions initialized'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); }); ``` --- ## 第三阶段:领域层实现 (Domain Layer) ### 3.1 值对象 (Value Objects) #### 3.1.1 src/domain/value-objects/report-type.enum.ts ```typescript export enum ReportType { // 龙虎榜报表 LEADERBOARD_REPORT = 'LEADERBOARD_REPORT', // 认种报表 PLANTING_REPORT = 'PLANTING_REPORT', REGIONAL_PLANTING_REPORT = 'REGIONAL_PLANTING_REPORT', // 授权公司报表 AUTHORIZED_COMPANY_TOP_REPORT = 'AUTHORIZED_COMPANY_TOP_REPORT', // 社区报表 COMMUNITY_REPORT = 'COMMUNITY_REPORT', // 系统账户报表 SYSTEM_ACCOUNT_MONTHLY_REPORT = 'SYSTEM_ACCOUNT_MONTHLY_REPORT', SYSTEM_ACCOUNT_INCOME_REPORT = 'SYSTEM_ACCOUNT_INCOME_REPORT', } export const ReportTypeLabels: Record = { [ReportType.LEADERBOARD_REPORT]: '龙虎榜数据报表', [ReportType.PLANTING_REPORT]: '榴莲树认种报表', [ReportType.REGIONAL_PLANTING_REPORT]: '区域认种报表', [ReportType.AUTHORIZED_COMPANY_TOP_REPORT]: '授权公司第1名统计', [ReportType.COMMUNITY_REPORT]: '社区数据统计', [ReportType.SYSTEM_ACCOUNT_MONTHLY_REPORT]: '系统账户月度报表', [ReportType.SYSTEM_ACCOUNT_INCOME_REPORT]: '系统账户收益来源报表', }; ``` #### 3.1.2 src/domain/value-objects/report-period.enum.ts ```typescript export enum ReportPeriod { DAILY = 'DAILY', // 日报表 WEEKLY = 'WEEKLY', // 周报表 MONTHLY = 'MONTHLY', // 月报表 QUARTERLY = 'QUARTERLY', // 季度报表 YEARLY = 'YEARLY', // 年度报表 CUSTOM = 'CUSTOM', // 自定义周期 } export const ReportPeriodLabels: Record = { [ReportPeriod.DAILY]: '日报表', [ReportPeriod.WEEKLY]: '周报表', [ReportPeriod.MONTHLY]: '月报表', [ReportPeriod.QUARTERLY]: '季度报表', [ReportPeriod.YEARLY]: '年度报表', [ReportPeriod.CUSTOM]: '自定义周期', }; ``` #### 3.1.3 src/domain/value-objects/report-dimension.enum.ts ```typescript export enum ReportDimension { TIME = 'TIME', // 时间维度 REGION = 'REGION', // 地域维度 (省/市) USER = 'USER', // 用户维度 USER_TYPE = 'USER_TYPE', // 用户类型维度 RIGHT_TYPE = 'RIGHT_TYPE', // 权益类型维度 COMMUNITY = 'COMMUNITY', // 社区维度 ACCOUNT = 'ACCOUNT', // 账户维度 SOURCE = 'SOURCE', // 来源维度 PRODUCT = 'PRODUCT', // 产品维度 } ``` #### 3.1.4 src/domain/value-objects/output-format.enum.ts ```typescript export enum OutputFormat { EXCEL = 'EXCEL', PDF = 'PDF', CSV = 'CSV', JSON = 'JSON', } export const OutputFormatMimeTypes: Record = { [OutputFormat.EXCEL]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', [OutputFormat.PDF]: 'application/pdf', [OutputFormat.CSV]: 'text/csv', [OutputFormat.JSON]: 'application/json', }; export const OutputFormatExtensions: Record = { [OutputFormat.EXCEL]: 'xlsx', [OutputFormat.PDF]: 'pdf', [OutputFormat.CSV]: 'csv', [OutputFormat.JSON]: 'json', }; ``` #### 3.1.5 src/domain/value-objects/date-range.vo.ts ```typescript export class DateRange { private constructor( public readonly startDate: Date, public readonly endDate: Date, ) { if (startDate > endDate) { throw new Error('开始日期不能大于结束日期'); } } static create(startDate: Date, endDate: Date): DateRange { return new DateRange(startDate, endDate); } /** * 创建今日范围 */ static today(): DateRange { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); return new DateRange(start, end); } /** * 创建本周范围 */ static thisWeek(): DateRange { const now = new Date(); const dayOfWeek = now.getDay(); const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const monday = new Date(now); monday.setDate(now.getDate() + diffToMonday); monday.setHours(0, 0, 0, 0); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); sunday.setHours(23, 59, 59, 999); return new DateRange(monday, sunday); } /** * 创建本月范围 */ static thisMonth(): DateRange { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); return new DateRange(start, end); } /** * 创建本季度范围 */ static thisQuarter(): DateRange { const now = new Date(); const quarter = Math.floor(now.getMonth() / 3); const start = new Date(now.getFullYear(), quarter * 3, 1, 0, 0, 0); const end = new Date(now.getFullYear(), quarter * 3 + 3, 0, 23, 59, 59, 999); return new DateRange(start, end); } /** * 创建本年范围 */ static thisYear(): DateRange { const now = new Date(); const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0); const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999); return new DateRange(start, end); } /** * 获取天数 */ getDays(): number { const diff = this.endDate.getTime() - this.startDate.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } /** * 检查日期是否在范围内 */ contains(date: Date): boolean { return date >= this.startDate && date <= this.endDate; } /** * 生成周期Key */ toPeriodKey(period: ReportPeriod): string { const year = this.startDate.getFullYear(); const month = (this.startDate.getMonth() + 1).toString().padStart(2, '0'); const day = this.startDate.getDate().toString().padStart(2, '0'); switch (period) { case ReportPeriod.DAILY: return `${year}-${month}-${day}`; case ReportPeriod.WEEKLY: const weekNumber = this.getWeekNumber(this.startDate); return `${year}-W${weekNumber.toString().padStart(2, '0')}`; case ReportPeriod.MONTHLY: return `${year}-${month}`; case ReportPeriod.QUARTERLY: const quarter = Math.floor(this.startDate.getMonth() / 3) + 1; return `${year}-Q${quarter}`; case ReportPeriod.YEARLY: return `${year}`; default: return `${year}-${month}-${day}_to_${this.endDate.getFullYear()}-${(this.endDate.getMonth() + 1).toString().padStart(2, '0')}-${this.endDate.getDate().toString().padStart(2, '0')}`; } } private getWeekNumber(date: Date): number { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000; return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); } } import { ReportPeriod } from './report-period.enum'; ``` #### 3.1.6 src/domain/value-objects/report-parameters.vo.ts ```typescript import { ReportDimension } from './report-dimension.enum'; import { DateRange } from './date-range.vo'; export class ReportParameters { private constructor( public readonly dateRange: DateRange, public readonly dimensions: ReportDimension[], public readonly filters: Record, 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; 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(key: string, defaultValue?: T): T | undefined { return this.filters[key] ?? defaultValue; } } ``` #### 3.1.7 src/domain/value-objects/report-schedule.vo.ts ```typescript export class ReportSchedule { private constructor( public readonly cronExpression: string, public readonly timezone: string, public readonly enabled: boolean, ) {} static create(cronExpression: string, timezone: string = 'Asia/Shanghai', enabled: boolean = true): ReportSchedule { return new ReportSchedule(cronExpression, timezone, enabled); } /** * 每日指定时间 */ static daily(hour: number = 0, minute: number = 0): ReportSchedule { return new ReportSchedule( `${minute} ${hour} * * *`, 'Asia/Shanghai', true, ); } /** * 每周指定时间 */ static weekly(dayOfWeek: number, hour: number = 0, minute: number = 0): ReportSchedule { return new ReportSchedule( `${minute} ${hour} * * ${dayOfWeek}`, 'Asia/Shanghai', true, ); } /** * 每月指定时间 */ static monthly(dayOfMonth: number, hour: number = 0, minute: number = 0): ReportSchedule { return new ReportSchedule( `${minute} ${hour} ${dayOfMonth} * *`, 'Asia/Shanghai', true, ); } /** * 每季度指定时间 */ static quarterly(dayOfQuarter: number = 1, hour: number = 0): ReportSchedule { // 每季度第一天的凌晨执行 return new ReportSchedule( `0 ${hour} ${dayOfQuarter} 1,4,7,10 *`, 'Asia/Shanghai', true, ); } /** * 启用调度 */ enable(): ReportSchedule { return new ReportSchedule(this.cronExpression, this.timezone, true); } /** * 禁用调度 */ disable(): ReportSchedule { return new ReportSchedule(this.cronExpression, this.timezone, false); } } ``` #### 3.1.8 src/domain/value-objects/snapshot-data.vo.ts ```typescript export class SnapshotData { private constructor( public readonly rows: any[], public readonly summary: Record, public readonly metadata: Record, ) {} static create(params: { rows: any[]; summary?: Record; metadata?: Record; }): SnapshotData { return new SnapshotData( params.rows, params.summary || {}, params.metadata || {}, ); } /** * 获取行数 */ getRowCount(): number { return this.rows.length; } /** * 是否为空 */ isEmpty(): boolean { return this.rows.length === 0; } /** * 获取汇总值 */ getSummary(key: string, defaultValue?: T): T | undefined { return this.summary[key] ?? defaultValue; } /** * 获取元数据值 */ getMetadata(key: string, defaultValue?: T): T | undefined { return this.metadata[key] ?? defaultValue; } /** * 转换为JSON */ toJSON(): object { return { rows: this.rows, summary: this.summary, metadata: this.metadata, rowCount: this.getRowCount(), }; } } ``` #### 3.1.9 src/domain/value-objects/data-source.vo.ts ```typescript export class DataSource { private constructor( public readonly sources: string[], public readonly queryTime: Date, public readonly dataFreshness: number, // 数据新鲜度(秒) ) {} static create(sources: string[]): DataSource { return new DataSource(sources, new Date(), 0); } static withFreshness(sources: string[], freshnessSeconds: number): DataSource { return new DataSource(sources, new Date(), freshnessSeconds); } /** * 检查数据是否新鲜 */ isFresh(maxAgeSeconds: number): boolean { const age = (Date.now() - this.queryTime.getTime()) / 1000; return age <= maxAgeSeconds; } /** * 添加数据源 */ addSource(source: string): DataSource { return new DataSource( [...this.sources, source], this.queryTime, this.dataFreshness, ); } } ``` #### 3.1.10 src/domain/value-objects/index.ts ```typescript export * from './report-type.enum'; export * from './report-period.enum'; export * from './report-dimension.enum'; export * from './output-format.enum'; export * from './date-range.vo'; export * from './report-parameters.vo'; export * from './report-schedule.vo'; export * from './snapshot-data.vo'; export * from './data-source.vo'; ``` ### 3.2 领域事件 (Domain Events) #### 3.2.1 src/domain/events/domain-event.base.ts ```typescript import { v4 as uuidv4 } from 'uuid'; export abstract class DomainEvent { public readonly eventId: string; public readonly occurredAt: Date; public readonly version: number; protected constructor(version: number = 1) { this.eventId = uuidv4(); this.occurredAt = new Date(); this.version = version; } abstract get eventType(): string; abstract get aggregateId(): string; abstract get aggregateType(): string; abstract toPayload(): Record; } ``` #### 3.2.2 src/domain/events/report-generated.event.ts ```typescript import { DomainEvent } from './domain-event.base'; import { ReportType } from '../value-objects/report-type.enum'; export interface ReportGeneratedPayload { snapshotId: string; reportType: ReportType; reportCode: string; periodKey: string; rowCount: number; generatedAt: Date; } export class ReportGeneratedEvent extends DomainEvent { constructor(private readonly payload: ReportGeneratedPayload) { super(); } get eventType(): string { return 'ReportGenerated'; } get aggregateId(): string { return this.payload.snapshotId; } get aggregateType(): string { return 'ReportSnapshot'; } toPayload(): ReportGeneratedPayload { return { ...this.payload }; } } ``` #### 3.2.3 src/domain/events/report-exported.event.ts ```typescript import { DomainEvent } from './domain-event.base'; import { OutputFormat } from '../value-objects/output-format.enum'; export interface ReportExportedPayload { fileId: string; snapshotId: string; format: OutputFormat; fileName: string; fileSize: number; exportedAt: Date; } export class ReportExportedEvent extends DomainEvent { constructor(private readonly payload: ReportExportedPayload) { super(); } get eventType(): string { return 'ReportExported'; } get aggregateId(): string { return this.payload.fileId; } get aggregateType(): string { return 'ReportFile'; } toPayload(): ReportExportedPayload { return { ...this.payload }; } } ``` ### 3.3 聚合根 (Aggregates) #### 3.3.1 src/domain/aggregates/report-definition/report-definition.aggregate.ts ```typescript import { DomainEvent } from '../../events/domain-event.base'; import { ReportType } from '../../value-objects/report-type.enum'; import { ReportSchedule } from '../../value-objects/report-schedule.vo'; import { OutputFormat } from '../../value-objects/output-format.enum'; /** * 报表定义聚合根 * * 不变式: * 1. reportCode 必须唯一 * 2. 启用调度时必须有有效的 cron 表达式 * 3. 至少支持一种输出格式 */ export class ReportDefinition { private _id: bigint | null = null; private readonly _reportType: ReportType; private _reportName: string; private readonly _reportCode: string; private _description: string; private _parameters: Record; 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, 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 { 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; 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): 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; scheduleCron: string | null; scheduleTimezone: string | null; scheduleEnabled: boolean; outputFormats: string[]; isActive: boolean; createdAt: Date; lastGeneratedAt: Date | null; }): ReportDefinition { const schedule = data.scheduleCron ? ReportSchedule.create( data.scheduleCron, data.scheduleTimezone || 'Asia/Shanghai', data.scheduleEnabled, ) : null; const definition = new ReportDefinition( data.reportType, data.reportName, data.reportCode, data.description, data.parameters, schedule, data.outputFormats as OutputFormat[], data.isActive, ); definition._id = data.id; definition._lastGeneratedAt = data.lastGeneratedAt; return definition; } } ``` #### 3.3.2 src/domain/aggregates/report-snapshot/report-snapshot.aggregate.ts ```typescript import { DomainEvent } from '../../events/domain-event.base'; import { ReportGeneratedEvent } from '../../events/report-generated.event'; import { ReportType } from '../../value-objects/report-type.enum'; import { ReportPeriod } from '../../value-objects/report-period.enum'; import { SnapshotData } from '../../value-objects/snapshot-data.vo'; import { DataSource } from '../../value-objects/data-source.vo'; import { DateRange } from '../../value-objects/date-range.vo'; /** * 报表快照聚合根 (读模型) * * 不变式: * 1. 同一报表同一周期只能有一个快照 * 2. 快照数据创建后不可修改 * 3. 过期的快照应被清理 */ export class ReportSnapshot { private _id: bigint | null = null; private readonly _reportType: ReportType; private readonly _reportCode: string; private readonly _reportPeriod: ReportPeriod; private readonly _periodKey: string; private readonly _snapshotData: SnapshotData; private readonly _dataSource: DataSource; private readonly _filterParams: Record | 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 | 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 | 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; 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 { return this._snapshotData.summary; } /** * 获取数据行 */ getRows(): any[] { return this._snapshotData.rows; } setId(id: bigint): void { this._id = id; } clearDomainEvents(): void { this._domainEvents = []; } // ============ 重建 ============ static reconstitute(data: { id: bigint; reportType: ReportType; reportCode: string; reportPeriod: ReportPeriod; periodKey: string; snapshotData: any; summaryData: any; dataSources: string[]; dataFreshness: number; filterParams: any; rowCount: number; periodStartAt: Date; periodEndAt: Date; generatedAt: Date; expiresAt: Date | null; }): ReportSnapshot { const snapshot = new ReportSnapshot( data.reportType, data.reportCode, data.reportPeriod, data.periodKey, SnapshotData.create({ rows: data.snapshotData.rows || [], summary: data.summaryData || {}, metadata: data.snapshotData.metadata || {}, }), DataSource.withFreshness(data.dataSources, data.dataFreshness), data.filterParams, DateRange.create(data.periodStartAt, data.periodEndAt), data.generatedAt, data.expiresAt, ); snapshot._id = data.id; return snapshot; } } ``` ### 3.4 仓储接口 (Repository Interfaces) #### 3.4.1 src/domain/repositories/report-definition.repository.interface.ts ```typescript import { ReportDefinition } from '../aggregates/report-definition/report-definition.aggregate'; import { ReportType } from '../value-objects/report-type.enum'; export interface IReportDefinitionRepository { save(definition: ReportDefinition): Promise; findById(id: bigint): Promise; findByCode(reportCode: string): Promise; findByType(reportType: ReportType): Promise; findActive(): Promise; findScheduled(): Promise; findAll(): Promise; } export const REPORT_DEFINITION_REPOSITORY = Symbol('IReportDefinitionRepository'); ``` #### 3.4.2 src/domain/repositories/report-snapshot.repository.interface.ts ```typescript import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate'; import { ReportType } from '../value-objects/report-type.enum'; import { ReportPeriod } from '../value-objects/report-period.enum'; export interface IReportSnapshotRepository { save(snapshot: ReportSnapshot): Promise; findById(id: bigint): Promise; findByCodeAndPeriod(reportCode: string, periodKey: string): Promise; findByType(reportType: ReportType, limit?: number): Promise; findLatest(reportCode: string): Promise; findByDateRange( reportCode: string, startDate: Date, endDate: Date, ): Promise; deleteExpired(): Promise; deleteOlderThan(date: Date): Promise; } export const REPORT_SNAPSHOT_REPOSITORY = Symbol('IReportSnapshotRepository'); ``` ### 3.5 领域服务 (Domain Services) #### 3.5.1 src/domain/services/report-generation.service.ts ```typescript import { Injectable, Inject } from '@nestjs/common'; import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate'; import { ReportType } from '../value-objects/report-type.enum'; import { ReportPeriod } from '../value-objects/report-period.enum'; import { SnapshotData } from '../value-objects/snapshot-data.vo'; import { DataSource } from '../value-objects/data-source.vo'; import { DateRange } from '../value-objects/date-range.vo'; import { ReportParameters } from '../value-objects/report-parameters.vo'; // 外部服务接口(防腐层) export interface IPlantingServiceClient { getPlantingStatistics(params: { startDate: Date; endDate: Date; groupBy: string[]; filters?: Record; }): Promise; } export interface ILeaderboardServiceClient { getLeaderboardData(params: { type: string; periodKey: string; limit: number; }): Promise; } export interface IReferralServiceClient { getCommunityStatistics(params: { communityId?: bigint; communityName?: string; startDate: Date; endDate: Date; }): Promise; getAuthorizedCompanyTopUsers(params: { companyType: 'PROVINCE' | 'CITY'; regionCode?: string; }): Promise; } export interface IRewardServiceClient { getRewardStatistics(params: { startDate: Date; endDate: Date; groupBy: string[]; filters?: Record; }): Promise; } export interface IWalletServiceClient { getSystemAccountStatistics(params: { accountType: 'PROVINCE' | 'CITY'; statMonth: string; }): Promise; getSystemAccountIncomeRecords(params: { accountId?: bigint; accountType?: string; startDate: Date; endDate: Date; keyword?: string; }): Promise; } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return rows.reduce((acc, row) => { const key = row[groupField] || 'unknown'; acc[key] = (acc[key] || 0) + row[sumField]; return acc; }, {}); } } ``` #### 3.5.2 src/domain/services/report-export.service.ts ```typescript import { Injectable } from '@nestjs/common'; import { ReportSnapshot } from '../aggregates/report-snapshot/report-snapshot.aggregate'; import { OutputFormat } from '../value-objects/output-format.enum'; export interface ExportResult { buffer: Buffer; fileName: string; mimeType: string; fileSize: number; } export interface IExcelExporter { export(snapshot: ReportSnapshot): Promise; } export interface IPdfExporter { export(snapshot: ReportSnapshot): Promise; } export interface ICsvExporter { export(snapshot: ReportSnapshot): Promise; } 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 { let buffer: Buffer; switch (format) { case OutputFormat.EXCEL: buffer = await this.excelExporter.export(snapshot); break; case OutputFormat.PDF: buffer = await this.pdfExporter.export(snapshot); break; case OutputFormat.CSV: buffer = await this.csvExporter.export(snapshot); break; case OutputFormat.JSON: buffer = Buffer.from(JSON.stringify(snapshot.snapshotData.toJSON(), null, 2)); break; default: throw new Error(`不支持的导出格式: ${format}`); } const fileName = this.generateFileName(snapshot, format); const mimeType = this.getMimeType(format); return { buffer, fileName, mimeType, fileSize: buffer.length, }; } private generateFileName(snapshot: ReportSnapshot, format: OutputFormat): string { const extension = this.getExtension(format); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return `${snapshot.reportCode}_${snapshot.periodKey}_${timestamp}.${extension}`; } private getMimeType(format: OutputFormat): string { switch (format) { case OutputFormat.EXCEL: return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; case OutputFormat.PDF: return 'application/pdf'; case OutputFormat.CSV: return 'text/csv'; case OutputFormat.JSON: return 'application/json'; default: return 'application/octet-stream'; } } private getExtension(format: OutputFormat): string { switch (format) { case OutputFormat.EXCEL: return 'xlsx'; case OutputFormat.PDF: return 'pdf'; case OutputFormat.CSV: return 'csv'; case OutputFormat.JSON: return 'json'; default: return 'bin'; } } } import { Inject } from '@nestjs/common'; ``` --- ## 领域不变式 (Domain Invariants) ```typescript class ReportingContextInvariants { // 1. 报表代码必须唯一 static REPORT_CODE_MUST_BE_UNIQUE = "报表代码在系统中必须唯一"; // 2. 同一报表同一周期只能有一个快照 static ONE_SNAPSHOT_PER_PERIOD = "同一报表代码同一周期只能生成一个快照"; // 3. 快照数据创建后不可修改 static SNAPSHOT_DATA_IMMUTABLE = "报表快照数据一旦创建不可修改"; // 4. 至少支持一种输出格式 static AT_LEAST_ONE_OUTPUT_FORMAT = "报表定义至少需要支持一种输出格式"; // 5. 启用调度需要有效的cron表达式 static SCHEDULE_REQUIRES_CRON = "启用调度时必须配置有效的cron表达式"; // 6. 过期快照应被清理 static EXPIRED_SNAPSHOTS_CLEANUP = "过期的报表快照应定期清理以节省存储空间"; // 7. 数据来源必须明确 static DATA_SOURCE_REQUIRED = "报表快照必须记录数据来源服务"; } ``` --- ## API 端点设计 | 方法 | 路径 | 描述 | 认证 | 权限 | |------|------|------|------|------| | GET | `/health` | 健康检查 | 否 | - | | **龙虎榜报表** | | GET | `/reports/leaderboard` | 获取龙虎榜报表 | JWT | 管理员 | | POST | `/reports/leaderboard/generate` | 生成龙虎榜报表 | JWT | 管理员 | | GET | `/reports/leaderboard/export` | 导出龙虎榜报表 | JWT | 管理员 | | **认种报表** | | GET | `/reports/planting` | 获取认种报表 | JWT | 管理员 | | POST | `/reports/planting/generate` | 生成认种报表 | JWT | 管理员 | | GET | `/reports/planting/export` | 导出认种报表 | JWT | 管理员 | | **区域报表** | | GET | `/reports/regional/province` | 获取省级认种报表 | JWT | 管理员 | | GET | `/reports/regional/city` | 获取市级认种报表 | JWT | 管理员 | | GET | `/reports/regional/export` | 导出区域报表 | JWT | 管理员 | | **授权公司报表** | | GET | `/reports/company/top` | 获取授权公司第1名统计 | JWT | 管理员 | | GET | `/reports/company/top/export` | 导出授权公司报表 | JWT | 管理员 | | **社区报表** | | GET | `/reports/community` | 获取社区数据统计 | JWT | 管理员 | | GET | `/reports/community/search` | 模糊搜索社区数据 | JWT | 管理员 | | GET | `/reports/community/export` | 导出社区报表 | JWT | 管理员 | | **系统账户报表** | | GET | `/reports/system-account/monthly` | 获取系统账户月度报表 | JWT | 管理员 | | GET | `/reports/system-account/income` | 获取系统账户收益来源报表 | JWT | 管理员 | | GET | `/reports/system-account/income/timeline` | 获取收益时间轴 | JWT | 管理员 | | GET | `/reports/system-account/export` | 导出系统账户报表 | JWT | 管理员 | | **通用导出** | | POST | `/reports/export` | 通用报表导出 | JWT | 管理员 | | GET | `/reports/files/{fileId}` | 下载报表文件 | JWT | 管理员 | | GET | `/reports/snapshots` | 获取报表快照列表 | JWT | 管理员 | | GET | `/reports/snapshots/{snapshotId}` | 获取报表快照详情 | JWT | 管理员 | --- ## 定时任务 ### 报表生成调度器 ```typescript // application/schedulers/report-generation.scheduler.ts import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ReportingApplicationService } from '../services/reporting-application.service'; import { ReportPeriod } from '../../domain/value-objects/report-period.enum'; @Injectable() export class ReportGenerationScheduler { constructor( private readonly reportingService: ReportingApplicationService, ) {} /** * 每日凌晨1点生成日报表 */ @Cron('0 1 * * *') async generateDailyReports() { console.log('开始生成日报表...'); try { // 生成认种日报表 await this.reportingService.generatePlantingReport(ReportPeriod.DAILY); // 生成区域认种日报表 await this.reportingService.generateRegionalPlantingReport(ReportPeriod.DAILY, 'PROVINCE'); await this.reportingService.generateRegionalPlantingReport(ReportPeriod.DAILY, 'CITY'); console.log('日报表生成完成'); } catch (error) { console.error('日报表生成失败:', error); } } /** * 每周一凌晨2点生成周报表 */ @Cron('0 2 * * 1') async generateWeeklyReports() { console.log('开始生成周报表...'); try { await this.reportingService.generatePlantingReport(ReportPeriod.WEEKLY); await this.reportingService.generateLeaderboardReport(ReportPeriod.WEEKLY); console.log('周报表生成完成'); } catch (error) { console.error('周报表生成失败:', error); } } /** * 每月1日凌晨3点生成月报表 */ @Cron('0 3 1 * *') async generateMonthlyReports() { console.log('开始生成月报表...'); try { await this.reportingService.generatePlantingReport(ReportPeriod.MONTHLY); await this.reportingService.generateLeaderboardReport(ReportPeriod.MONTHLY); await this.reportingService.generateSystemAccountMonthlyReport(); console.log('月报表生成完成'); } catch (error) { console.error('月报表生成失败:', error); } } /** * 每季度第一天凌晨4点生成季度报表 */ @Cron('0 4 1 1,4,7,10 *') async generateQuarterlyReports() { console.log('开始生成季度报表...'); try { await this.reportingService.generatePlantingReport(ReportPeriod.QUARTERLY); console.log('季度报表生成完成'); } catch (error) { console.error('季度报表生成失败:', error); } } /** * 每年1月1日凌晨5点生成年度报表 */ @Cron('0 5 1 1 *') async generateYearlyReports() { console.log('开始生成年度报表...'); try { await this.reportingService.generatePlantingReport(ReportPeriod.YEARLY); console.log('年度报表生成完成'); } catch (error) { console.error('年度报表生成失败:', error); } } } ``` ### 快照清理调度器 ```typescript // application/schedulers/snapshot-cleanup.scheduler.ts import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ReportingApplicationService } from '../services/reporting-application.service'; @Injectable() export class SnapshotCleanupScheduler { constructor( private readonly reportingService: ReportingApplicationService, ) {} /** * 每天凌晨0点清理过期快照 */ @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async cleanupExpiredSnapshots() { console.log('开始清理过期报表快照...'); try { const deletedCount = await this.reportingService.cleanupExpiredSnapshots(); console.log(`清理完成,共删除 ${deletedCount} 个过期快照`); } catch (error) { console.error('快照清理失败:', error); } } /** * 每周清理超过保留期的报表文件 */ @Cron('0 6 * * 0') async cleanupOldReportFiles() { console.log('开始清理过期报表文件...'); try { const deletedCount = await this.reportingService.cleanupOldReportFiles(); console.log(`清理完成,共删除 ${deletedCount} 个过期文件`); } catch (error) { console.error('报表文件清理失败:', error); } } } ``` --- ## 事件订阅 (Kafka Events) ### 订阅的事件 | Topic | 事件类型 | 触发条件 | 处理逻辑 | |-------|---------|---------|---------| | `planting.order.paid` | PlantingOrderPaidEvent | 认种订单支付成功 | 更新认种日统计 | | `reward.created` | RewardCreatedEvent | 奖励创建 | 更新收益统计 | | `leaderboard.refreshed` | LeaderboardRefreshedEvent | 榜单刷新完成 | 缓存最新榜单数据 | ### 发布的事件 | Topic | 事件类型 | 触发条件 | |-------|---------|---------| | `report.generated` | ReportGeneratedEvent | 报表生成完成 | | `report.exported` | ReportExportedEvent | 报表导出完成 | --- ## 与后台页面对应关系 ### 1. 龙虎榜数据统计页面 ``` 页面元素: - 榜单类型切换: 日榜 | 周榜 | 月榜 - 日期选择器: 选择统计范围 - 数据表格: 排名、用户、分值等 - 导出按钮: Excel/CSV API调用: - GET /reports/leaderboard?period=DAILY&startDate=2024-01-01&endDate=2024-01-15 - GET /reports/leaderboard/export?format=EXCEL ``` ### 2. 榴莲树认种报表页面 ``` 页面元素: - 报表类型切换: 日报 | 周报 | 月报 | 季度报 | 年度报 - 日期范围选择器 - 区域筛选: 省/市下拉 - 数据表格: 日期、认种数、金额、用户数等 - 导出按钮 API调用: - GET /reports/planting?period=DAILY&startDate=2024-01-01&endDate=2024-01-31 - POST /reports/planting/generate - GET /reports/planting/export?format=EXCEL ``` ### 3. 区域认种报表页面 ``` 页面元素: - 维度切换: 按省 | 按市 - 日期范围选择器 - 数据表格: 区域、认种数、金额、排名等 - 导出按钮 API调用: - GET /reports/regional/province?period=MONTHLY - GET /reports/regional/city?period=MONTHLY - GET /reports/regional/export?format=CSV ``` ### 4. 社区数据统计页面 ``` 页面元素: - 搜索框: 支持社区名模糊搜索 - 日期选择: 日新增 | 周新增 | 月新增 | 自定义范围 - 数据表格: 社区名、上级、下级、认种总量、新增量 - 导出按钮 API调用: - GET /reports/community?communityName=xxx - GET /reports/community/search?keyword=xxx&startDate=xxx&endDate=xxx - GET /reports/community/export?format=EXCEL ``` ### 5. 系统账户月度报表页面 ``` 页面元素: - 账户类型: 省公司 | 市公司 - 月份选择器 - 数据表格: 账户名、每月算力、累计算力、每月挖矿量、累计挖矿量等 - 导出按钮 API调用: - GET /reports/system-account/monthly?accountType=PROVINCE&month=2024-01 - GET /reports/system-account/export?format=EXCEL ``` ### 6. 系统账户收益来源页面 ``` 页面元素: - 账户选择: 省公司/市公司下拉 - 日期范围选择器 - 关键词搜索: 地址、交易流水号 - 收益时间轴: 每笔收益的时间线展示 - 来源细分统计: 饼图/柱状图 - 数据表格: 时间、来源、金额、交易号等 - 导出按钮 API调用: - GET /reports/system-account/income?accountId=xxx&startDate=xxx&endDate=xxx&keyword=xxx - GET /reports/system-account/income/timeline?accountId=xxx - GET /reports/system-account/export?format=EXCEL ``` --- ## 开发顺序建议 1. **Phase 1: 项目初始化** - 创建NestJS项目 - 安装依赖 - 配置环境变量 2. **Phase 2: 数据库层** - 创建Prisma Schema - 运行迁移和种子数据 - 创建PrismaService 3. **Phase 3: 领域层** - 实现所有值对象 - 实现聚合根 (ReportDefinition, ReportSnapshot) - 实现实体 (AnalyticsMetric, ReportFile) - 实现领域事件 - 实现领域服务 (ReportGenerationService, ReportExportService) - 编写单元测试 4. **Phase 4: 基础设施层** - 实现仓储 (Repository Implementations) - 实现外部服务客户端 (各Service Client) - 实现导出服务 (Excel, PDF, CSV) - 实现文件存储服务 - 实现Kafka消费者和发布者 - 实现Redis缓存服务 5. **Phase 5: 应用层** - 实现应用服务 (ReportingApplicationService) - 实现定时任务 (ReportGenerationScheduler, SnapshotCleanupScheduler) - 实现Command/Query handlers 6. **Phase 6: API层** - 实现DTO - 实现Controllers - 配置Swagger文档 - 配置JWT认证和管理员权限 7. **Phase 7: 测试和部署** - 集成测试 - E2E测试 - Docker配置 --- ## 注意事项 1. **数据聚合**: 报表数据从多个微服务聚合,需要处理服务间的数据一致性 2. **性能优化**: 大数据量报表生成需要考虑分批处理和异步生成 3. **缓存策略**: 热门报表数据应缓存到Redis,减少重复计算 4. **文件管理**: 导出的报表文件需要定期清理,避免存储空间耗尽 5. **权限控制**: 所有报表接口都需要管理员权限 6. **导出限制**: 导出数据量过大时需要限制或采用异步导出 7. **时区处理**: 统一使用Asia/Shanghai时区进行日期计算 8. **模糊搜索**: 社区搜索支持模糊匹配,需要优化查询性能 9. **时间轴展示**: 收益来源时间轴需要支持分页加载 10. **幂等性**: 报表生成需要保证幂等性,避免重复生成