From 9ab7ff3ef16e7590e9923e925c50850439abd075 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 1 Dec 2025 03:54:55 -0800 Subject: [PATCH] . --- .../DEVELOPMENT_GUIDE.md | 3151 +++++++++++++++++ .../reporting-service/DEVELOPMENT_GUIDE.md | 2904 +++++++++++++++ 2 files changed, 6055 insertions(+) create mode 100644 backend/services/authorization-service/DEVELOPMENT_GUIDE.md create mode 100644 backend/services/reporting-service/DEVELOPMENT_GUIDE.md diff --git a/backend/services/authorization-service/DEVELOPMENT_GUIDE.md b/backend/services/authorization-service/DEVELOPMENT_GUIDE.md new file mode 100644 index 00000000..fb964c77 --- /dev/null +++ b/backend/services/authorization-service/DEVELOPMENT_GUIDE.md @@ -0,0 +1,3151 @@ +# Authorization Service 开发指导 + +## 项目概述 + +Authorization Service 是 RWA 榴莲女皇平台的授权管理微服务,负责社区授权、省/市公司授权、阶梯式考核管理、月度考核评估与排名、权益激活/失效管理等功能。 + +### 核心职责 ✅ +- 社区/省公司/市公司授权管理 +- 阶梯式考核规则执行 +- 月度考核评估与排名 +- 权益激活/失效管理 +- 单月免考核授权(豁免功能) +- 自有团队占比考核 +- 授权限制验证(团队内唯一性) +- 火柴人排名显示数据支持 +- 认种限制功能(账户限时限量、总量限制) + +### 不负责 ❌ +- 团队统计数据计算(Referral Context) +- 奖励金额计算(Reward Context) +- 用户基本信息管理(Identity Context) +- 实际认种操作(Planting Context) + +--- + +## 核心业务需求 + +### 1. 授权省公司功能 +``` +功能说明: +- 可以给账户授权成为《授权省公司》 +- 授权后前端头像显示金色字体,如"授权湖南省" +- 取消授权后头像标识立即消失 +- 可以给多个账号同时授权成为同一个省的《授权省公司》 + +团队权益: +- 省公司团队权益:20 USDT/棵 + +初始考核: +- 伞下团队认种总量达到500棵后权益自动生效 +- 考核的500棵权益归上级省公司所有(无上级则归总部社区账户) +``` + +### 2. 授权市公司功能 +``` +功能说明: +- 可以给账户授权成为《授权市公司》 +- 授权后前端头像显示金色字体,如"授权长沙市" +- 取消授权后头像标识立即消失 +- 可以给多个账号同时授权成为同一个市的《授权市公司》 + +团队权益: +- 市公司团队权益:40 USDT/棵 + +初始考核: +- 伞下团队认种总量达到100棵后权益自动生效 +- 考核的100棵权益归上级市公司所有(无上级则归总部社区账户) +``` + +### 3. 正式省公司授权 +``` +功能说明: +- 可以给账户授权成为《省公司》(正式) +- 授权后前端头像显示"湖南省"(无"授权"二字) +- 取消授权后头像标识立即消失 + +区域权益: +- 省区域权益:15 USDT/棵 + 1%算力 +- 授权后该省新认种产生的区域权益直接进入省公司账户 +- 取消后进入系统省公司账户 +``` + +### 4. 正式市公司授权 +``` +功能说明: +- 可以给账户授权成为《市公司》(正式) +- 授权后前端头像显示"长沙市"(无"授权"二字) +- 取消授权后头像标识立即消失 + +区域权益: +- 市区域权益:35 USDT/棵 + 2%算力 +- 授权后该市新认种产生的区域权益直接进入市公司账户 +- 取消后进入系统市公司账户 +``` + +### 5. 社区授权功能 +``` +功能说明: +- 可以给账户授权成为《量子社区》等社区 + +社区权益: +- 社区权益:80 USDT/棵 + +初始考核: +- 伞下团队认种总量达到10棵后权益自动生效 +- 考核的10棵权益归上级社区所有(无上级则归总部社区账户) + +月度考核: +- 每月需新增10棵 +- 上月未达成10棵则权益失效 +- 失效后重新从0开始考核10棵 +``` + +### 6. 阶梯式考核规则 + +#### 省代阶梯考核目标 +| 考核月 | 当月目标 | 累计目标 | +|--------|----------|----------| +| 第1个月 | 150 | 150 | +| 第2个月 | 300 | 450 | +| 第3个月 | 600 | 1,050 | +| 第4个月 | 1,200 | 2,250 | +| 第5个月 | 2,400 | 4,650 | +| 第6个月 | 4,800 | 9,450 | +| 第7个月 | 9,600 | 19,050 | +| 第8个月 | 19,200 | 38,250 | +| 第9个月 | 11,750 | 50,000 | + +#### 市代阶梯考核目标 +| 考核月 | 当月目标 | 累计目标 | +|--------|----------|----------| +| 第1个月 | 30 | 30 | +| 第2个月 | 60 | 90 | +| 第3个月 | 120 | 210 | +| 第4个月 | 240 | 450 | +| 第5个月 | 480 | 930 | +| 第6个月 | 960 | 1,890 | +| 第7个月 | 1,920 | 3,810 | +| 第8个月 | 3,840 | 7,650 | +| 第9个月 | 2,350 | 10,000 | + +### 7. 授权限制规则 +``` +限制规则: +① 一个账号只能申请一个省代或一个市代 +② 团队内唯一性:某账号申请某省/市授权后,其上级团队无法再申请同一省/市授权 + - 举例:D申请《授权长沙市》后,C、B、A都无法再授权《授权长沙市》 + - 申请时提示"本团队已有人申请" +③ 自有团队占比考核:本地用户(本省/市)占比默认5%才能参与评选第一名 +④ 可单独豁免:给单个账号授权《不考核自有团队本省/市公司占比》 +``` + +### 8. 火柴人排名显示 +``` +显示规则: +- 每个获得《授权省/市公司》的用户头像下方显示火柴人排名 +- 火柴人动态奔跑方式展示 +- 每个火柴人头上显示团队完成数量 +- 脚下显示昵称 +- 进度条目标:省50,000棵,市10,000棵 +- 目标处图标为红旗 +- 显示本月累计可发放收益(USDT与RWAD) +- 火柴人根据完成数量处于进度条对应位置 +- 即使只有一个账户申请也显示完整排名信息 + +排名规则: +- 两用户同时达成累计目标时,取超越累计目标占比最多者为第一名 +- 超越累计目标占比 = 实际完成目标 ÷ 累计目标 +- 占比一致时,取先完成的为第一名 +``` + +### 9. 单月豁免授权 +``` +单月社区权益授权: +① 只能对已授权的社区账户使用 +② 授权后该社区账号本月不需要完成考核目标直接享有社区权益 + +单月省代权益授权: +① 只能对已授权的《授权省公司》账户使用 +② 授权后该省代账号本月不需要完成累计考核目标直接享有团队权益 +③ 下月继续考核上月累计目标(举例:第3个月获得豁免,第4个月继续考核第3个月的累计目标) + +单月市代权益授权: +① 只能对已授权的《授权市公司》账户使用 +② 授权后该市代账号本月不需要完成累计考核目标直接享有团队权益 +③ 下月继续考核上月累计目标 +``` + +### 10. 认种限制功能 +``` +账户限时限认种量: +- 举例:限制5天之内每个账户只能认种1棵 +- 开关及参数可设置 + +总量限认种功能: +- 举例:设置为限制2天之内,总限种量为100棵 +- 认种达到100棵后,所有账户将无法认种 +- 直至超出设置时间或关闭此功能才能继续认种 +``` + +### 11. 后台管理分层级 +``` +管理规则: +- 涉及后台数据修改或授权需要三个后台账号授权通过才能修改 +- 后台记录各个账号授权的事件 +``` + +--- + +## 技术架构 + +### 架构模式 +- **DDD(领域驱动设计)** +- **六边形架构(Hexagonal Architecture)** + - Presentation Layer (API Controllers) + - Application Layer (Use Cases) + - Domain Layer (Entities, Aggregates, Services) + - Infrastructure Layer (Repos, External Services) + +### 技术栈 +- **框架**: NestJS + TypeScript +- **数据库**: PostgreSQL + Prisma ORM +- **缓存**: Redis +- **消息队列**: Kafka +- **定时任务**: @nestjs/schedule + +--- + +## 数据模型设计 + +### Prisma Schema + +```prisma +// schema.prisma + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============ 授权角色表 ============ +model AuthorizationRole { + id String @id @default(uuid()) + userId String @map("user_id") + roleType RoleType @map("role_type") + regionCode String @map("region_code") + regionName String @map("region_name") + status AuthorizationStatus @default(PENDING) + displayTitle String @map("display_title") + + // 授权信息 + authorizedAt DateTime? @map("authorized_at") + authorizedBy String? @map("authorized_by") + revokedAt DateTime? @map("revoked_at") + revokedBy String? @map("revoked_by") + revokeReason String? @map("revoke_reason") + + // 考核配置 + initialTargetTreeCount Int @map("initial_target_tree_count") + monthlyTargetType MonthlyTargetType @map("monthly_target_type") + + // 自有团队占比 + requireLocalPercentage Decimal @default(5.0) @map("require_local_percentage") @db.Decimal(5, 2) + exemptFromPercentageCheck Boolean @default(false) @map("exempt_from_percentage_check") + + // 权益状态 + benefitActive Boolean @default(false) @map("benefit_active") + benefitActivatedAt DateTime? @map("benefit_activated_at") + benefitDeactivatedAt DateTime? @map("benefit_deactivated_at") + + // 当前考核月份索引 + currentMonthIndex Int @default(0) @map("current_month_index") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + assessments MonthlyAssessment[] + bypassRecords MonthlyBypass[] + + @@unique([userId, roleType, regionCode]) + @@index([userId]) + @@index([roleType, regionCode]) + @@index([status]) + @@index([roleType, status]) + @@map("authorization_roles") +} + +// ============ 月度考核表 ============ +model MonthlyAssessment { + id String @id @default(uuid()) + authorizationId String @map("authorization_id") + userId String @map("user_id") + roleType RoleType @map("role_type") + regionCode String @map("region_code") + + // 考核月份 + assessmentMonth String @map("assessment_month") // YYYY-MM + monthIndex Int @map("month_index") // 第几个月考核 + + // 考核目标 + monthlyTarget Int @map("monthly_target") + cumulativeTarget Int @map("cumulative_target") + + // 完成情况 + monthlyCompleted Int @default(0) @map("monthly_completed") + cumulativeCompleted Int @default(0) @map("cumulative_completed") + completedAt DateTime? @map("completed_at") // 达标时间(用于排名) + + // 自有团队占比 + localTeamCount Int @default(0) @map("local_team_count") + totalTeamCount Int @default(0) @map("total_team_count") + localPercentage Decimal @default(0) @map("local_percentage") @db.Decimal(5, 2) + localPercentagePass Boolean @default(false) @map("local_percentage_pass") + + // 超越目标占比 + exceedRatio Decimal @default(0) @map("exceed_ratio") @db.Decimal(10, 4) + + // 考核结果 + result AssessmentResult @default(NOT_ASSESSED) + + // 排名 + rankingInRegion Int? @map("ranking_in_region") + isFirstPlace Boolean @default(false) @map("is_first_place") + + // 豁免 + isBypassed Boolean @default(false) @map("is_bypassed") + bypassedBy String? @map("bypassed_by") + bypassedAt DateTime? @map("bypassed_at") + + // 时间戳 + assessedAt DateTime? @map("assessed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + authorization AuthorizationRole @relation(fields: [authorizationId], references: [id]) + + @@unique([authorizationId, assessmentMonth]) + @@index([userId, assessmentMonth]) + @@index([roleType, regionCode, assessmentMonth]) + @@index([assessmentMonth, result]) + @@index([assessmentMonth, roleType, exceedRatio(sort: Desc)]) + @@map("monthly_assessments") +} + +// ============ 单月豁免记录表 ============ +model MonthlyBypass { + id String @id @default(uuid()) + authorizationId String @map("authorization_id") + userId String @map("user_id") + roleType RoleType @map("role_type") + bypassMonth String @map("bypass_month") // YYYY-MM + + // 授权信息 + grantedBy String @map("granted_by") + grantedAt DateTime @map("granted_at") + reason String? + + // 审批信息(三人授权) + approver1Id String @map("approver1_id") + approver1At DateTime @map("approver1_at") + approver2Id String? @map("approver2_id") + approver2At DateTime? @map("approver2_at") + approver3Id String? @map("approver3_id") + approver3At DateTime? @map("approver3_at") + approvalStatus ApprovalStatus @default(PENDING) @map("approval_status") + + createdAt DateTime @default(now()) @map("created_at") + + authorization AuthorizationRole @relation(fields: [authorizationId], references: [id]) + + @@unique([authorizationId, bypassMonth]) + @@index([userId, bypassMonth]) + @@map("monthly_bypasses") +} + +// ============ 阶梯考核目标配置表 ============ +model LadderTargetConfig { + id String @id @default(uuid()) + roleType RoleType @map("role_type") + monthIndex Int @map("month_index") + monthlyTarget Int @map("monthly_target") + cumulativeTarget Int @map("cumulative_target") + isActive Boolean @default(true) @map("is_active") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([roleType, monthIndex]) + @@map("ladder_target_configs") +} + +// ============ 认种限制配置表 ============ +model PlantingRestriction { + id String @id @default(uuid()) + restrictionType RestrictionType @map("restriction_type") + + // 账户限制配置 + accountLimitDays Int? @map("account_limit_days") + accountLimitCount Int? @map("account_limit_count") + + // 总量限制配置 + totalLimitDays Int? @map("total_limit_days") + totalLimitCount Int? @map("total_limit_count") + currentTotalCount Int @default(0) @map("current_total_count") + + // 生效时间 + startAt DateTime @map("start_at") + endAt DateTime @map("end_at") + + isActive Boolean @default(true) @map("is_active") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("planting_restrictions") +} + +// ============ 管理员授权审批表 ============ +model AdminApproval { + id String @id @default(uuid()) + operationType OperationType @map("operation_type") + targetId String @map("target_id") + targetType String @map("target_type") + requestData Json @map("request_data") + + // 审批状态 + status ApprovalStatus @default(PENDING) + + // 审批人 + requesterId String @map("requester_id") + approver1Id String? @map("approver1_id") + approver1At DateTime? @map("approver1_at") + approver2Id String? @map("approver2_id") + approver2At DateTime? @map("approver2_at") + approver3Id String? @map("approver3_id") + approver3At DateTime? @map("approver3_at") + + // 完成信息 + completedAt DateTime? @map("completed_at") + rejectedBy String? @map("rejected_by") + rejectedAt DateTime? @map("rejected_at") + rejectReason String? @map("reject_reason") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([status]) + @@index([targetId, targetType]) + @@map("admin_approvals") +} + +// ============ 授权操作日志表 ============ +model AuthorizationAuditLog { + id String @id @default(uuid()) + operationType String @map("operation_type") + targetUserId String @map("target_user_id") + targetRoleType RoleType? @map("target_role_type") + targetRegionCode String? @map("target_region_code") + + operatorId String @map("operator_id") + operatorRole String @map("operator_role") + + beforeState Json? @map("before_state") + afterState Json? @map("after_state") + + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([targetUserId]) + @@index([operatorId]) + @@index([createdAt]) + @@map("authorization_audit_logs") +} + +// ============ 省市热度统计表 ============ +model RegionHeatMap { + id String @id @default(uuid()) + regionCode String @map("region_code") + regionName String @map("region_name") + regionType RegionType @map("region_type") + + totalPlantings Int @default(0) @map("total_plantings") + monthlyPlantings Int @default(0) @map("monthly_plantings") + weeklyPlantings Int @default(0) @map("weekly_plantings") + dailyPlantings Int @default(0) @map("daily_plantings") + + activeUsers Int @default(0) @map("active_users") + authCompanyCount Int @default(0) @map("auth_company_count") + + heatScore Decimal @default(0) @map("heat_score") @db.Decimal(10, 2) + + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([regionCode, regionType]) + @@map("region_heat_maps") +} + +// ============ 火柴人排名视图数据表 ============ +model StickmanRanking { + id String @id @default(uuid()) + userId String @map("user_id") + authorizationId String @map("authorization_id") + roleType RoleType @map("role_type") + regionCode String @map("region_code") + regionName String @map("region_name") + + // 用户信息 + nickname String + avatarUrl String? @map("avatar_url") + + // 进度信息 + currentMonth String @map("current_month") + cumulativeCompleted Int @map("cumulative_completed") + cumulativeTarget Int @map("cumulative_target") + progressPercentage Decimal @map("progress_percentage") @db.Decimal(5, 2) + exceedRatio Decimal @map("exceed_ratio") @db.Decimal(10, 4) + + // 排名 + ranking Int + isFirstPlace Boolean @map("is_first_place") + + // 本月收益 + monthlyRewardUsdt Decimal @map("monthly_reward_usdt") @db.Decimal(18, 2) + monthlyRewardRwad Decimal @map("monthly_reward_rwad") @db.Decimal(18, 8) + + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([authorizationId, currentMonth]) + @@index([roleType, regionCode, currentMonth]) + @@map("stickman_rankings") +} + +// ============ 系统配置表 ============ +model AuthorizationConfig { + id String @id @default(uuid()) + configKey String @unique @map("config_key") + configValue String @map("config_value") + description String? + isActive Boolean @default(true) @map("is_active") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("authorization_configs") +} + +// ============ 枚举定义 ============ +enum RoleType { + COMMUNITY // 社区 + AUTH_PROVINCE_COMPANY // 授权省公司(团队权益20U) + PROVINCE_COMPANY // 正式省公司(区域权益15U+1%算力) + AUTH_CITY_COMPANY // 授权市公司(团队权益40U) + CITY_COMPANY // 正式市公司(区域权益35U+2%算力) +} + +enum AuthorizationStatus { + PENDING // 待授权/待考核 + AUTHORIZED // 已授权 + REVOKED // 已撤销 +} + +enum AssessmentResult { + NOT_ASSESSED // 未考核 + PASS // 达标 + FAIL // 未达标 + BYPASSED // 豁免 +} + +enum MonthlyTargetType { + NONE // 无月度考核(正式省市公司) + FIXED // 固定目标(社区:10棵/月) + LADDER // 阶梯目标(授权省市公司) +} + +enum RestrictionType { + ACCOUNT_LIMIT // 账户限时限量 + TOTAL_LIMIT // 总量限制 +} + +enum ApprovalStatus { + PENDING // 待审批 + APPROVED // 已通过 + REJECTED // 已拒绝 +} + +enum OperationType { + GRANT_AUTHORIZATION // 授予授权 + REVOKE_AUTHORIZATION // 撤销授权 + GRANT_BYPASS // 授予豁免 + EXEMPT_PERCENTAGE // 豁免占比考核 + MODIFY_CONFIG // 修改配置 +} + +enum RegionType { + PROVINCE // 省 + CITY // 市 +} +``` + +--- + +## 领域层设计 + +### 值对象(Value Objects) + +```typescript +// domain/value-objects/authorization-id.vo.ts +export class AuthorizationId { + constructor(public readonly value: string) { + if (!value) { + throw new DomainError('授权ID不能为空') + } + } + + static generate(): AuthorizationId { + return new AuthorizationId(uuidv4()) + } + + static create(value: string): AuthorizationId { + return new AuthorizationId(value) + } + + equals(other: AuthorizationId): boolean { + return this.value === other.value + } +} + +// domain/value-objects/region-code.vo.ts +export class RegionCode { + constructor(public readonly value: string) { + if (!value) { + throw new DomainError('区域代码不能为空') + } + } + + static create(value: string): RegionCode { + return new RegionCode(value) + } + + equals(other: RegionCode): boolean { + return this.value === other.value + } +} + +// domain/value-objects/month.vo.ts +export class Month { + constructor(public readonly value: string) { + if (!/^\d{4}-\d{2}$/.test(value)) { + throw new DomainError('月份格式错误,应为YYYY-MM') + } + } + + static current(): Month { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + return new Month(`${year}-${month}`) + } + + static create(value: string): Month { + return new Month(value) + } + + next(): Month { + const [year, month] = this.value.split('-').map(Number) + const nextMonth = month === 12 ? 1 : month + 1 + const nextYear = month === 12 ? year + 1 : year + return new Month(`${nextYear}-${String(nextMonth).padStart(2, '0')}`) + } + + previous(): Month { + const [year, month] = this.value.split('-').map(Number) + const prevMonth = month === 1 ? 12 : month - 1 + const prevYear = month === 1 ? year - 1 : year + return new Month(`${prevYear}-${String(prevMonth).padStart(2, '0')}`) + } + + equals(other: Month): boolean { + return this.value === other.value + } +} + +// domain/value-objects/assessment-config.vo.ts +export class AssessmentConfig { + constructor( + public readonly initialTargetTreeCount: number, + public readonly monthlyTargetType: MonthlyTargetType + ) {} + + static forCommunity(): AssessmentConfig { + return new AssessmentConfig(10, MonthlyTargetType.FIXED) + } + + static forAuthProvince(): AssessmentConfig { + return new AssessmentConfig(500, MonthlyTargetType.LADDER) + } + + static forProvince(): AssessmentConfig { + return new AssessmentConfig(0, MonthlyTargetType.NONE) + } + + static forAuthCity(): AssessmentConfig { + return new AssessmentConfig(100, MonthlyTargetType.LADDER) + } + + static forCity(): AssessmentConfig { + return new AssessmentConfig(0, MonthlyTargetType.NONE) + } +} + +// domain/value-objects/benefit-amount.vo.ts +export class BenefitAmount { + constructor( + public readonly usdtPerTree: number, + public readonly computingPowerPercentage: number + ) {} + + static forCommunity(): BenefitAmount { + return new BenefitAmount(80, 0) + } + + static forAuthProvince(): BenefitAmount { + return new BenefitAmount(20, 0) + } + + static forProvince(): BenefitAmount { + return new BenefitAmount(15, 1) + } + + static forAuthCity(): BenefitAmount { + return new BenefitAmount(40, 0) + } + + static forCity(): BenefitAmount { + return new BenefitAmount(35, 2) + } + + static forRoleType(roleType: RoleType): BenefitAmount { + switch (roleType) { + case RoleType.COMMUNITY: + return this.forCommunity() + case RoleType.AUTH_PROVINCE_COMPANY: + return this.forAuthProvince() + case RoleType.PROVINCE_COMPANY: + return this.forProvince() + case RoleType.AUTH_CITY_COMPANY: + return this.forAuthCity() + case RoleType.CITY_COMPANY: + return this.forCity() + default: + throw new DomainError(`未知角色类型: ${roleType}`) + } + } +} +``` + +### 聚合根(Aggregates) + +```typescript +// domain/aggregates/authorization-role.aggregate.ts +export class AuthorizationRole extends AggregateRoot { + private readonly _authorizationId: AuthorizationId + private readonly _userId: UserId + private readonly _roleType: RoleType + private readonly _regionCode: RegionCode + private readonly _regionName: string + private _status: AuthorizationStatus + private _displayTitle: string + + // 授权信息 + private _authorizedAt: Date | null + private _authorizedBy: AdminUserId | null + private _revokedAt: Date | null + private _revokedBy: AdminUserId | null + private _revokeReason: string | null + + // 考核配置 + private readonly _assessmentConfig: AssessmentConfig + + // 自有团队占比 + private _requireLocalPercentage: number + private _exemptFromPercentageCheck: boolean + + // 权益状态 + private _benefitActive: boolean + private _benefitActivatedAt: Date | null + private _benefitDeactivatedAt: Date | null + + // 当前考核月份索引 + private _currentMonthIndex: number + + private readonly _createdAt: Date + private _updatedAt: Date + + // Getters + get authorizationId(): AuthorizationId { return this._authorizationId } + get userId(): UserId { return this._userId } + get roleType(): RoleType { return this._roleType } + get regionCode(): RegionCode { return this._regionCode } + get regionName(): string { return this._regionName } + get status(): AuthorizationStatus { return this._status } + get displayTitle(): string { return this._displayTitle } + get assessmentConfig(): AssessmentConfig { return this._assessmentConfig } + get requireLocalPercentage(): number { return this._requireLocalPercentage } + get exemptFromPercentageCheck(): boolean { return this._exemptFromPercentageCheck } + get benefitActive(): boolean { return this._benefitActive } + get currentMonthIndex(): number { return this._currentMonthIndex } + get isActive(): boolean { return this._status === AuthorizationStatus.AUTHORIZED } + + // 工厂方法 + static createCommunityAuth(params: { + userId: UserId + communityName: string + }): AuthorizationRole { + const auth = new AuthorizationRole( + AuthorizationId.generate(), + params.userId, + RoleType.COMMUNITY, + RegionCode.create(params.communityName), + params.communityName, + AuthorizationStatus.PENDING, + params.communityName, + null, null, null, null, null, + AssessmentConfig.forCommunity(), + 0, true, + false, null, null, + 0, + new Date(), new Date() + ) + + auth.addDomainEvent(new CommunityAuthRequestedEvent({ + authorizationId: auth.authorizationId.value, + userId: params.userId.value, + communityName: params.communityName + })) + + return auth + } + + static createAuthProvinceCompany(params: { + userId: UserId + provinceCode: string + provinceName: string + }): AuthorizationRole { + const auth = new AuthorizationRole( + AuthorizationId.generate(), + params.userId, + RoleType.AUTH_PROVINCE_COMPANY, + RegionCode.create(params.provinceCode), + params.provinceName, + AuthorizationStatus.PENDING, + `授权${params.provinceName}`, + null, null, null, null, null, + AssessmentConfig.forAuthProvince(), + 5.0, false, + false, null, null, + 0, + new Date(), new Date() + ) + + auth.addDomainEvent(new AuthProvinceCompanyRequestedEvent({ + authorizationId: auth.authorizationId.value, + userId: params.userId.value, + provinceCode: params.provinceCode, + provinceName: params.provinceName + })) + + return auth + } + + static createProvinceCompany(params: { + userId: UserId + provinceCode: string + provinceName: string + adminId: AdminUserId + }): AuthorizationRole { + const auth = new AuthorizationRole( + AuthorizationId.generate(), + params.userId, + RoleType.PROVINCE_COMPANY, + RegionCode.create(params.provinceCode), + params.provinceName, + AuthorizationStatus.AUTHORIZED, + params.provinceName, + new Date(), params.adminId, null, null, null, + AssessmentConfig.forProvince(), + 0, true, + true, new Date(), null, + 0, + new Date(), new Date() + ) + + auth.addDomainEvent(new ProvinceCompanyAuthorizedEvent({ + authorizationId: auth.authorizationId.value, + userId: params.userId.value, + provinceCode: params.provinceCode, + provinceName: params.provinceName, + authorizedBy: params.adminId.value + })) + + return auth + } + + static createAuthCityCompany(params: { + userId: UserId + cityCode: string + cityName: string + }): AuthorizationRole { + const auth = new AuthorizationRole( + AuthorizationId.generate(), + params.userId, + RoleType.AUTH_CITY_COMPANY, + RegionCode.create(params.cityCode), + params.cityName, + AuthorizationStatus.PENDING, + `授权${params.cityName}`, + null, null, null, null, null, + AssessmentConfig.forAuthCity(), + 5.0, false, + false, null, null, + 0, + new Date(), new Date() + ) + + auth.addDomainEvent(new AuthCityCompanyRequestedEvent({ + authorizationId: auth.authorizationId.value, + userId: params.userId.value, + cityCode: params.cityCode, + cityName: params.cityName + })) + + return auth + } + + static createCityCompany(params: { + userId: UserId + cityCode: string + cityName: string + adminId: AdminUserId + }): AuthorizationRole { + const auth = new AuthorizationRole( + AuthorizationId.generate(), + params.userId, + RoleType.CITY_COMPANY, + RegionCode.create(params.cityCode), + params.cityName, + AuthorizationStatus.AUTHORIZED, + params.cityName, + new Date(), params.adminId, null, null, null, + AssessmentConfig.forCity(), + 0, true, + true, new Date(), null, + 0, + new Date(), new Date() + ) + + auth.addDomainEvent(new CityCompanyAuthorizedEvent({ + authorizationId: auth.authorizationId.value, + userId: params.userId.value, + cityCode: params.cityCode, + cityName: params.cityName, + authorizedBy: params.adminId.value + })) + + return auth + } + + // 核心领域行为 + + /** + * 激活权益(初始考核达标后) + */ + activateBenefit(): void { + if (this._benefitActive) { + throw new DomainError('权益已激活') + } + + this._status = AuthorizationStatus.AUTHORIZED + this._benefitActive = true + this._benefitActivatedAt = new Date() + this._currentMonthIndex = 1 + this._updatedAt = new Date() + + this.addDomainEvent(new BenefitActivatedEvent({ + authorizationId: this._authorizationId.value, + userId: this._userId.value, + roleType: this._roleType, + regionCode: this._regionCode.value + })) + } + + /** + * 失效权益(月度考核不达标) + */ + deactivateBenefit(reason: string): void { + if (!this._benefitActive) { + return + } + + this._benefitActive = false + this._benefitDeactivatedAt = new Date() + this._currentMonthIndex = 0 // 重置月份索引 + this._updatedAt = new Date() + + this.addDomainEvent(new BenefitDeactivatedEvent({ + authorizationId: this._authorizationId.value, + userId: this._userId.value, + roleType: this._roleType, + reason + })) + } + + /** + * 管理员授权 + */ + authorize(adminId: AdminUserId): void { + if (this._status === AuthorizationStatus.AUTHORIZED) { + throw new DomainError('已授权,无需重复授权') + } + + this._status = AuthorizationStatus.AUTHORIZED + this._authorizedAt = new Date() + this._authorizedBy = adminId + this._updatedAt = new Date() + + this.addDomainEvent(new RoleAuthorizedEvent({ + authorizationId: this._authorizationId.value, + userId: this._userId.value, + roleType: this._roleType, + regionCode: this._regionCode.value, + authorizedBy: adminId.value + })) + } + + /** + * 撤销授权 + */ + revoke(adminId: AdminUserId, reason: string): void { + if (this._status === AuthorizationStatus.REVOKED) { + throw new DomainError('已撤销') + } + + this._status = AuthorizationStatus.REVOKED + this._revokedAt = new Date() + this._revokedBy = adminId + this._revokeReason = reason + this._benefitActive = false + this._benefitDeactivatedAt = new Date() + this._updatedAt = new Date() + + this.addDomainEvent(new RoleRevokedEvent({ + authorizationId: this._authorizationId.value, + userId: this._userId.value, + roleType: this._roleType, + regionCode: this._regionCode.value, + reason, + revokedBy: adminId.value + })) + } + + /** + * 豁免占比考核 + */ + exemptLocalPercentageCheck(adminId: AdminUserId): void { + this._exemptFromPercentageCheck = true + this._updatedAt = new Date() + + this.addDomainEvent(new PercentageCheckExemptedEvent({ + authorizationId: this._authorizationId.value, + userId: this._userId.value, + exemptedBy: adminId.value + })) + } + + /** + * 取消豁免占比考核 + */ + cancelExemptLocalPercentageCheck(): void { + this._exemptFromPercentageCheck = false + this._updatedAt = new Date() + } + + /** + * 递增考核月份 + */ + incrementMonthIndex(): void { + this._currentMonthIndex += 1 + this._updatedAt = new Date() + } + + /** + * 获取初始考核目标 + */ + getInitialTarget(): number { + return this._assessmentConfig.initialTargetTreeCount + } + + /** + * 检查是否需要占比考核 + */ + needsLocalPercentageCheck(): boolean { + return !this._exemptFromPercentageCheck && + this._requireLocalPercentage > 0 && + (this._roleType === RoleType.AUTH_PROVINCE_COMPANY || + this._roleType === RoleType.AUTH_CITY_COMPANY) + } + + /** + * 检查是否需要阶梯考核 + */ + needsLadderAssessment(): boolean { + return this._assessmentConfig.monthlyTargetType === MonthlyTargetType.LADDER + } + + /** + * 检查是否需要固定月度考核 + */ + needsFixedAssessment(): boolean { + return this._assessmentConfig.monthlyTargetType === MonthlyTargetType.FIXED + } + + // 私有构造函数 + private constructor( + authorizationId: AuthorizationId, + userId: UserId, + roleType: RoleType, + regionCode: RegionCode, + regionName: string, + status: AuthorizationStatus, + displayTitle: string, + authorizedAt: Date | null, + authorizedBy: AdminUserId | null, + revokedAt: Date | null, + revokedBy: AdminUserId | null, + revokeReason: string | null, + assessmentConfig: AssessmentConfig, + requireLocalPercentage: number, + exemptFromPercentageCheck: boolean, + benefitActive: boolean, + benefitActivatedAt: Date | null, + benefitDeactivatedAt: Date | null, + currentMonthIndex: number, + createdAt: Date, + updatedAt: Date + ) { + super() + this._authorizationId = authorizationId + this._userId = userId + this._roleType = roleType + this._regionCode = regionCode + this._regionName = regionName + this._status = status + this._displayTitle = displayTitle + this._authorizedAt = authorizedAt + this._authorizedBy = authorizedBy + this._revokedAt = revokedAt + this._revokedBy = revokedBy + this._revokeReason = revokeReason + this._assessmentConfig = assessmentConfig + this._requireLocalPercentage = requireLocalPercentage + this._exemptFromPercentageCheck = exemptFromPercentageCheck + this._benefitActive = benefitActive + this._benefitActivatedAt = benefitActivatedAt + this._benefitDeactivatedAt = benefitDeactivatedAt + this._currentMonthIndex = currentMonthIndex + this._createdAt = createdAt + this._updatedAt = updatedAt + } +} +``` + +```typescript +// domain/aggregates/monthly-assessment.aggregate.ts +export class MonthlyAssessment extends AggregateRoot { + private readonly _assessmentId: AssessmentId + private readonly _authorizationId: AuthorizationId + private readonly _userId: UserId + private readonly _roleType: RoleType + private readonly _regionCode: RegionCode + + // 考核月份 + private readonly _assessmentMonth: Month + private readonly _monthIndex: number + + // 考核目标 + private readonly _monthlyTarget: number + private readonly _cumulativeTarget: number + + // 完成情况 + private _monthlyCompleted: number + private _cumulativeCompleted: number + private _completedAt: Date | null + + // 自有团队占比 + private _localTeamCount: number + private _totalTeamCount: number + private _localPercentage: number + private _localPercentagePass: boolean + + // 超越目标占比 + private _exceedRatio: number + + // 考核结果 + private _result: AssessmentResult + + // 排名 + private _rankingInRegion: number | null + private _isFirstPlace: boolean + + // 豁免 + private _isBypassed: boolean + private _bypassedBy: AdminUserId | null + private _bypassedAt: Date | null + + private _assessedAt: Date | null + private readonly _createdAt: Date + private _updatedAt: Date + + // Getters + get assessmentId(): AssessmentId { return this._assessmentId } + get authorizationId(): AuthorizationId { return this._authorizationId } + get userId(): UserId { return this._userId } + get roleType(): RoleType { return this._roleType } + get assessmentMonth(): Month { return this._assessmentMonth } + get monthIndex(): number { return this._monthIndex } + get monthlyTarget(): number { return this._monthlyTarget } + get cumulativeTarget(): number { return this._cumulativeTarget } + get cumulativeCompleted(): number { return this._cumulativeCompleted } + get result(): AssessmentResult { return this._result } + get exceedRatio(): number { return this._exceedRatio } + get rankingInRegion(): number | null { return this._rankingInRegion } + get isFirstPlace(): boolean { return this._isFirstPlace } + get completedAt(): Date | null { return this._completedAt } + + // 工厂方法 + static create(params: { + authorizationId: AuthorizationId + userId: UserId + roleType: RoleType + regionCode: RegionCode + assessmentMonth: Month + monthIndex: number + monthlyTarget: number + cumulativeTarget: number + }): MonthlyAssessment { + return new MonthlyAssessment( + AssessmentId.generate(), + params.authorizationId, + params.userId, + params.roleType, + params.regionCode, + params.assessmentMonth, + params.monthIndex, + params.monthlyTarget, + params.cumulativeTarget, + 0, 0, null, + 0, 0, 0, false, + 0, + AssessmentResult.NOT_ASSESSED, + null, false, + false, null, null, + null, + new Date(), new Date() + ) + } + + /** + * 执行考核评估 + */ + assess(params: { + cumulativeCompleted: number + localTeamCount: number + totalTeamCount: number + requireLocalPercentage: number + exemptFromPercentageCheck: boolean + }): void { + this._cumulativeCompleted = params.cumulativeCompleted + this._localTeamCount = params.localTeamCount + this._totalTeamCount = params.totalTeamCount + + // 计算本地占比 + if (params.totalTeamCount > 0) { + this._localPercentage = (params.localTeamCount / params.totalTeamCount) * 100 + } else { + this._localPercentage = 0 + } + + // 判断占比是否达标 + this._localPercentagePass = params.exemptFromPercentageCheck || + this._localPercentage >= params.requireLocalPercentage + + // 计算超越比例 + if (this._cumulativeTarget > 0) { + this._exceedRatio = params.cumulativeCompleted / this._cumulativeTarget + } else { + this._exceedRatio = 0 + } + + // 记录达标时间(用于同比例时的排名) + const cumulativePass = params.cumulativeCompleted >= this._cumulativeTarget + if (cumulativePass && !this._completedAt) { + this._completedAt = new Date() + } + + // 判断考核结果 + if (this._isBypassed) { + this._result = AssessmentResult.BYPASSED + } else if (cumulativePass && this._localPercentagePass) { + this._result = AssessmentResult.PASS + this.addDomainEvent(new MonthlyAssessmentPassedEvent({ + assessmentId: this._assessmentId.value, + userId: this._userId.value, + roleType: this._roleType, + month: this._assessmentMonth.value, + cumulativeCompleted: params.cumulativeCompleted, + cumulativeTarget: this._cumulativeTarget + })) + } else { + this._result = AssessmentResult.FAIL + this.addDomainEvent(new MonthlyAssessmentFailedEvent({ + assessmentId: this._assessmentId.value, + userId: this._userId.value, + roleType: this._roleType, + month: this._assessmentMonth.value, + cumulativeCompleted: params.cumulativeCompleted, + cumulativeTarget: this._cumulativeTarget, + reason: !cumulativePass ? '累计目标未达成' : '本地占比不足' + })) + } + + this._assessedAt = new Date() + this._updatedAt = new Date() + } + + /** + * 授予单月豁免 + */ + grantBypass(adminId: AdminUserId): void { + if (this._isBypassed) { + throw new DomainError('已授予豁免') + } + + this._isBypassed = true + this._bypassedBy = adminId + this._bypassedAt = new Date() + this._result = AssessmentResult.BYPASSED + this._updatedAt = new Date() + + this.addDomainEvent(new MonthlyBypassGrantedEvent({ + assessmentId: this._assessmentId.value, + userId: this._userId.value, + roleType: this._roleType, + month: this._assessmentMonth.value, + grantedBy: adminId.value + })) + } + + /** + * 设置排名 + */ + setRanking(rank: number, isFirst: boolean): void { + this._rankingInRegion = rank + this._isFirstPlace = isFirst + this._updatedAt = new Date() + + if (isFirst) { + this.addDomainEvent(new FirstPlaceAchievedEvent({ + assessmentId: this._assessmentId.value, + userId: this._userId.value, + roleType: this._roleType, + regionCode: this._regionCode.value, + month: this._assessmentMonth.value + })) + } + } + + /** + * 是否达标 + */ + isPassed(): boolean { + return this._result === AssessmentResult.PASS || + this._result === AssessmentResult.BYPASSED + } + + // 私有构造函数 + private constructor( + assessmentId: AssessmentId, + authorizationId: AuthorizationId, + userId: UserId, + roleType: RoleType, + regionCode: RegionCode, + assessmentMonth: Month, + monthIndex: number, + monthlyTarget: number, + cumulativeTarget: number, + monthlyCompleted: number, + cumulativeCompleted: number, + completedAt: Date | null, + localTeamCount: number, + totalTeamCount: number, + localPercentage: number, + localPercentagePass: boolean, + exceedRatio: number, + result: AssessmentResult, + rankingInRegion: number | null, + isFirstPlace: boolean, + isBypassed: boolean, + bypassedBy: AdminUserId | null, + bypassedAt: Date | null, + assessedAt: Date | null, + createdAt: Date, + updatedAt: Date + ) { + super() + this._assessmentId = assessmentId + this._authorizationId = authorizationId + this._userId = userId + this._roleType = roleType + this._regionCode = regionCode + this._assessmentMonth = assessmentMonth + this._monthIndex = monthIndex + this._monthlyTarget = monthlyTarget + this._cumulativeTarget = cumulativeTarget + this._monthlyCompleted = monthlyCompleted + this._cumulativeCompleted = cumulativeCompleted + this._completedAt = completedAt + this._localTeamCount = localTeamCount + this._totalTeamCount = totalTeamCount + this._localPercentage = localPercentage + this._localPercentagePass = localPercentagePass + this._exceedRatio = exceedRatio + this._result = result + this._rankingInRegion = rankingInRegion + this._isFirstPlace = isFirstPlace + this._isBypassed = isBypassed + this._bypassedBy = bypassedBy + this._bypassedAt = bypassedAt + this._assessedAt = assessedAt + this._createdAt = createdAt + this._updatedAt = updatedAt + } +} +``` + +### 实体(Entities) + +```typescript +// domain/entities/ladder-target-rule.entity.ts +export class LadderTargetRule { + constructor( + public readonly roleType: RoleType, + public readonly monthIndex: number, + public readonly monthlyTarget: number, + public readonly cumulativeTarget: number + ) {} + + // 省代阶梯目标表 + static readonly PROVINCE_LADDER: LadderTargetRule[] = [ + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 1, 150, 150), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 2, 300, 450), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 3, 600, 1050), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 4, 1200, 2250), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 5, 2400, 4650), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 6, 4800, 9450), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 7, 9600, 19050), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 8, 19200, 38250), + new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 9, 11750, 50000) + ] + + // 市代阶梯目标表 + static readonly CITY_LADDER: LadderTargetRule[] = [ + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 1, 30, 30), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 2, 60, 90), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 3, 120, 210), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 4, 240, 450), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 5, 480, 930), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 6, 960, 1890), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 7, 1920, 3810), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 8, 3840, 7650), + new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 9, 2350, 10000) + ] + + // 社区固定目标 + static readonly COMMUNITY_FIXED: LadderTargetRule = + new LadderTargetRule(RoleType.COMMUNITY, 1, 10, 10) + + /** + * 获取指定角色和月份的目标 + */ + static getTarget(roleType: RoleType, monthIndex: number): LadderTargetRule { + switch (roleType) { + case RoleType.AUTH_PROVINCE_COMPANY: + // 超过9个月后使用第9个月的目标 + const provinceIndex = Math.min(monthIndex, 9) - 1 + return this.PROVINCE_LADDER[provinceIndex] + + case RoleType.AUTH_CITY_COMPANY: + const cityIndex = Math.min(monthIndex, 9) - 1 + return this.CITY_LADDER[cityIndex] + + case RoleType.COMMUNITY: + return this.COMMUNITY_FIXED + + default: + throw new DomainError(`不支持的角色类型: ${roleType}`) + } + } + + /** + * 获取最终累计目标 + */ + static getFinalTarget(roleType: RoleType): number { + switch (roleType) { + case RoleType.AUTH_PROVINCE_COMPANY: + return 50000 + case RoleType.AUTH_CITY_COMPANY: + return 10000 + case RoleType.COMMUNITY: + return 10 + default: + return 0 + } + } +} +``` + +### 领域服务(Domain Services) + +```typescript +// domain/services/authorization-validator.service.ts +export class AuthorizationValidatorService { + /** + * 验证授权申请(团队内唯一性) + */ + async validateAuthorizationRequest( + userId: UserId, + roleType: RoleType, + regionCode: RegionCode, + referralRepository: IReferralRepository, + authorizationRepository: IAuthorizationRoleRepository + ): Promise { + // 1. 检查用户是否已有同类型授权 + const existingAuth = await authorizationRepository.findByUserIdAndRoleType( + userId, + roleType + ) + + if (existingAuth) { + return ValidationResult.failure('一个账号只能申请一个省或市的授权') + } + + // 2. 检查团队内唯一性(上下级不能重复) + const relationship = await referralRepository.findByUserId(userId) + if (!relationship) { + return ValidationResult.success() + } + + // 检查所有上级 + const ancestors = await referralRepository.getAllAncestors(userId) + for (const ancestorId of ancestors) { + const ancestorAuth = await authorizationRepository.findByUserIdRoleTypeAndRegion( + ancestorId, + roleType, + regionCode + ) + + if (ancestorAuth && ancestorAuth.status !== AuthorizationStatus.REVOKED) { + return ValidationResult.failure( + `本团队已有人申请该${this.getRoleTypeName(roleType)}授权` + ) + } + } + + // 检查所有下级 + const descendants = await referralRepository.getAllDescendants(userId) + for (const descendantId of descendants) { + const descendantAuth = await authorizationRepository.findByUserIdRoleTypeAndRegion( + descendantId, + roleType, + regionCode + ) + + if (descendantAuth && descendantAuth.status !== AuthorizationStatus.REVOKED) { + return ValidationResult.failure( + `本团队已有人申请该${this.getRoleTypeName(roleType)}授权` + ) + } + } + + return ValidationResult.success() + } + + private getRoleTypeName(roleType: RoleType): string { + switch (roleType) { + case RoleType.AUTH_PROVINCE_COMPANY: + return '省' + case RoleType.AUTH_CITY_COMPANY: + return '市' + default: + return '' + } + } +} + +// domain/value-objects/validation-result.vo.ts +export class ValidationResult { + private constructor( + public readonly isValid: boolean, + public readonly errorMessage: string | null + ) {} + + static success(): ValidationResult { + return new ValidationResult(true, null) + } + + static failure(message: string): ValidationResult { + return new ValidationResult(false, message) + } +} +``` + +```typescript +// domain/services/assessment-calculator.service.ts +export class AssessmentCalculatorService { + /** + * 计算月度考核 + */ + async calculateMonthlyAssessment( + authorization: AuthorizationRole, + assessmentMonth: Month, + teamStats: TeamStatistics, + repository: IMonthlyAssessmentRepository + ): Promise { + // 1. 查找或创建本月考核 + let assessment = await repository.findByAuthorizationAndMonth( + authorization.authorizationId, + assessmentMonth + ) + + if (!assessment) { + // 获取目标 + const monthIndex = authorization.currentMonthIndex || 1 + const target = LadderTargetRule.getTarget(authorization.roleType, monthIndex) + + assessment = MonthlyAssessment.create({ + authorizationId: authorization.authorizationId, + userId: authorization.userId, + roleType: authorization.roleType, + regionCode: authorization.regionCode, + assessmentMonth, + monthIndex, + monthlyTarget: target.monthlyTarget, + cumulativeTarget: target.cumulativeTarget + }) + } + + // 2. 执行考核 + const localTeamCount = this.getLocalTeamCount( + teamStats, + authorization.roleType, + authorization.regionCode + ) + + assessment.assess({ + cumulativeCompleted: teamStats.totalTeamPlantingCount, + localTeamCount, + totalTeamCount: teamStats.totalTeamPlantingCount, + requireLocalPercentage: authorization.requireLocalPercentage, + exemptFromPercentageCheck: authorization.exemptFromPercentageCheck + }) + + return assessment + } + + /** + * 批量评估并排名 + */ + async assessAndRankRegion( + roleType: RoleType, + regionCode: RegionCode, + assessmentMonth: Month, + authorizationRepository: IAuthorizationRoleRepository, + statsRepository: ITeamStatisticsRepository, + assessmentRepository: IMonthlyAssessmentRepository + ): Promise { + // 1. 查找该区域的所有激活授权 + const authorizations = await authorizationRepository.findActiveByRoleTypeAndRegion( + roleType, + regionCode + ) + + // 2. 计算所有考核 + const assessments: MonthlyAssessment[] = [] + + for (const auth of authorizations) { + const teamStats = await statsRepository.findByUserId(auth.userId) + if (!teamStats) continue + + const assessment = await this.calculateMonthlyAssessment( + auth, + assessmentMonth, + teamStats, + assessmentRepository + ) + + assessments.push(assessment) + } + + // 3. 排名规则: + // - 按超越比例降序排列 + // - 比例相同时,按达标时间升序排列(先完成的排前面) + assessments.sort((a, b) => { + // 先按超越比例降序 + if (b.exceedRatio !== a.exceedRatio) { + return b.exceedRatio - a.exceedRatio + } + // 比例相同时按达标时间升序 + if (a.completedAt && b.completedAt) { + return a.completedAt.getTime() - b.completedAt.getTime() + } + // 有达标时间的排前面 + if (a.completedAt) return -1 + if (b.completedAt) return 1 + return 0 + }) + + // 4. 设置排名 + assessments.forEach((assessment, index) => { + assessment.setRanking(index + 1, index === 0) + }) + + return assessments + } + + private getLocalTeamCount( + teamStats: TeamStatistics, + roleType: RoleType, + regionCode: RegionCode + ): number { + if (roleType === RoleType.AUTH_PROVINCE_COMPANY || + roleType === RoleType.PROVINCE_COMPANY) { + return teamStats.getProvinceTeamCount(regionCode.value) + } else if (roleType === RoleType.AUTH_CITY_COMPANY || + roleType === RoleType.CITY_COMPANY) { + return teamStats.getCityTeamCount(regionCode.value) + } + return 0 + } +} +``` + +```typescript +// domain/services/planting-restriction.service.ts +export class PlantingRestrictionService { + /** + * 检查用户是否可以认种 + */ + async canUserPlant( + userId: UserId, + treeCount: number, + restrictionRepository: IPlantingRestrictionRepository, + userPlantingRepository: IUserPlantingRecordRepository + ): Promise { + const now = new Date() + + // 1. 检查账户限时限量 + const accountRestriction = await restrictionRepository.findActiveAccountRestriction() + if (accountRestriction) { + const userPlantingCount = await userPlantingRepository.countUserPlantingsInPeriod( + userId, + accountRestriction.startAt, + accountRestriction.endAt + ) + + if (userPlantingCount + treeCount > accountRestriction.accountLimitCount!) { + return RestrictionCheckResult.blocked( + `限制期内每个账户只能认种${accountRestriction.accountLimitCount}棵,` + + `您已认种${userPlantingCount}棵` + ) + } + } + + // 2. 检查总量限制 + const totalRestriction = await restrictionRepository.findActiveTotalRestriction() + if (totalRestriction) { + if (totalRestriction.currentTotalCount + treeCount > totalRestriction.totalLimitCount!) { + return RestrictionCheckResult.blocked( + `系统限制期内总认种量为${totalRestriction.totalLimitCount}棵,` + + `当前已认种${totalRestriction.currentTotalCount}棵` + ) + } + } + + return RestrictionCheckResult.allowed() + } +} + +// domain/value-objects/restriction-check-result.vo.ts +export class RestrictionCheckResult { + private constructor( + public readonly allowed: boolean, + public readonly message: string | null + ) {} + + static allowed(): RestrictionCheckResult { + return new RestrictionCheckResult(true, null) + } + + static blocked(message: string): RestrictionCheckResult { + return new RestrictionCheckResult(false, message) + } +} +``` + +### 仓储接口(Repository Interfaces) + +```typescript +// domain/repositories/authorization-role.repository.ts +export interface IAuthorizationRoleRepository { + save(authorization: AuthorizationRole): Promise + findById(authorizationId: AuthorizationId): Promise + findByUserIdAndRoleType(userId: UserId, roleType: RoleType): Promise + findByUserIdRoleTypeAndRegion(userId: UserId, roleType: RoleType, regionCode: RegionCode): Promise + findByUserId(userId: UserId): Promise + findActiveByRoleTypeAndRegion(roleType: RoleType, regionCode: RegionCode): Promise + findAllActive(roleType?: RoleType): Promise + findPendingByUserId(userId: UserId): Promise +} + +// domain/repositories/monthly-assessment.repository.ts +export interface IMonthlyAssessmentRepository { + save(assessment: MonthlyAssessment): Promise + saveAll(assessments: MonthlyAssessment[]): Promise + findById(assessmentId: AssessmentId): Promise + findByAuthorizationAndMonth(authorizationId: AuthorizationId, month: Month): Promise + findByUserAndMonth(userId: UserId, month: Month): Promise + findFirstByAuthorization(authorizationId: AuthorizationId): Promise + findByMonthAndRegion(month: Month, roleType: RoleType, regionCode: RegionCode): Promise + findRankingsByMonthAndRegion(month: Month, roleType: RoleType, regionCode: RegionCode): Promise +} + +// domain/repositories/planting-restriction.repository.ts +export interface IPlantingRestrictionRepository { + save(restriction: PlantingRestriction): Promise + findActiveAccountRestriction(): Promise + findActiveTotalRestriction(): Promise + incrementTotalCount(restrictionId: string, count: number): Promise +} +``` + +--- + +## 应用层设计 + +### 应用服务 + +```typescript +// application/services/authorization-application.service.ts +@Injectable() +export class AuthorizationApplicationService { + constructor( + private readonly authorizationRepository: IAuthorizationRoleRepository, + private readonly assessmentRepository: IMonthlyAssessmentRepository, + private readonly referralRepository: IReferralRepository, + private readonly statsRepository: ITeamStatisticsRepository, + private readonly validatorService: AuthorizationValidatorService, + private readonly calculatorService: AssessmentCalculatorService, + private readonly eventBus: EventBus, + private readonly unitOfWork: UnitOfWork + ) {} + + /** + * 申请社区授权 + */ + async applyCommunityAuth(command: ApplyCommunityAuthCommand): Promise { + return await this.unitOfWork.execute(async () => { + const userId = UserId.create(command.userId) + + // 1. 检查是否已有社区授权 + const existing = await this.authorizationRepository.findByUserIdAndRoleType( + userId, + RoleType.COMMUNITY + ) + + if (existing) { + throw new ApplicationError('您已申请过社区授权') + } + + // 2. 创建社区授权 + const authorization = AuthorizationRole.createCommunityAuth({ + userId, + communityName: command.communityName + }) + + // 3. 检查初始考核(10棵) + const teamStats = await this.statsRepository.findByUserId(userId) + const totalTreeCount = teamStats?.totalTeamPlantingCount || 0 + + if (totalTreeCount >= authorization.getInitialTarget()) { + // 达标,激活权益 + authorization.activateBenefit() + } + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + authorizationId: authorization.authorizationId.value, + status: authorization.status, + benefitActive: authorization.benefitActive, + message: authorization.benefitActive + ? '社区权益已激活' + : `需要团队累计认种达到${authorization.getInitialTarget()}棵才能激活`, + currentTreeCount: totalTreeCount, + requiredTreeCount: authorization.getInitialTarget() + } + }) + } + + /** + * 申请授权省公司 + */ + async applyAuthProvinceCompany( + command: ApplyAuthProvinceCompanyCommand + ): Promise { + return await this.unitOfWork.execute(async () => { + const userId = UserId.create(command.userId) + const regionCode = RegionCode.create(command.provinceCode) + + // 1. 验证授权申请(团队内唯一性) + const validation = await this.validatorService.validateAuthorizationRequest( + userId, + RoleType.AUTH_PROVINCE_COMPANY, + regionCode, + this.referralRepository, + this.authorizationRepository + ) + + if (!validation.isValid) { + throw new ApplicationError(validation.errorMessage!) + } + + // 2. 创建授权 + const authorization = AuthorizationRole.createAuthProvinceCompany({ + userId, + provinceCode: command.provinceCode, + provinceName: command.provinceName + }) + + // 3. 检查初始考核(500棵) + const teamStats = await this.statsRepository.findByUserId(userId) + const totalTreeCount = teamStats?.totalTeamPlantingCount || 0 + + if (totalTreeCount >= authorization.getInitialTarget()) { + // 达标,激活权益并创建首月考核 + authorization.activateBenefit() + await this.createInitialAssessment(authorization, teamStats!) + } + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + authorizationId: authorization.authorizationId.value, + status: authorization.status, + benefitActive: authorization.benefitActive, + displayTitle: authorization.displayTitle, + message: authorization.benefitActive + ? '授权省公司权益已激活,开始阶梯考核' + : `需要团队累计认种达到${authorization.getInitialTarget()}棵才能激活`, + currentTreeCount: totalTreeCount, + requiredTreeCount: authorization.getInitialTarget() + } + }) + } + + /** + * 申请授权市公司 + */ + async applyAuthCityCompany( + command: ApplyAuthCityCompanyCommand + ): Promise { + return await this.unitOfWork.execute(async () => { + const userId = UserId.create(command.userId) + const regionCode = RegionCode.create(command.cityCode) + + // 1. 验证 + const validation = await this.validatorService.validateAuthorizationRequest( + userId, + RoleType.AUTH_CITY_COMPANY, + regionCode, + this.referralRepository, + this.authorizationRepository + ) + + if (!validation.isValid) { + throw new ApplicationError(validation.errorMessage!) + } + + // 2. 创建授权 + const authorization = AuthorizationRole.createAuthCityCompany({ + userId, + cityCode: command.cityCode, + cityName: command.cityName + }) + + // 3. 检查初始考核(100棵) + const teamStats = await this.statsRepository.findByUserId(userId) + const totalTreeCount = teamStats?.totalTeamPlantingCount || 0 + + if (totalTreeCount >= authorization.getInitialTarget()) { + authorization.activateBenefit() + await this.createInitialAssessment(authorization, teamStats!) + } + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + authorizationId: authorization.authorizationId.value, + status: authorization.status, + benefitActive: authorization.benefitActive, + displayTitle: authorization.displayTitle, + message: authorization.benefitActive + ? '授权市公司权益已激活,开始阶梯考核' + : `需要团队累计认种达到${authorization.getInitialTarget()}棵才能激活`, + currentTreeCount: totalTreeCount, + requiredTreeCount: authorization.getInitialTarget() + } + }) + } + + /** + * 管理员授权正式省公司 + */ + async grantProvinceCompany( + command: GrantProvinceCompanyCommand + ): Promise { + return await this.unitOfWork.execute(async () => { + const userId = UserId.create(command.userId) + const adminId = AdminUserId.create(command.adminId) + + const authorization = AuthorizationRole.createProvinceCompany({ + userId, + provinceCode: command.provinceCode, + provinceName: command.provinceName, + adminId + }) + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + }) + } + + /** + * 管理员授权正式市公司 + */ + async grantCityCompany( + command: GrantCityCompanyCommand + ): Promise { + return await this.unitOfWork.execute(async () => { + const userId = UserId.create(command.userId) + const adminId = AdminUserId.create(command.adminId) + + const authorization = AuthorizationRole.createCityCompany({ + userId, + cityCode: command.cityCode, + cityName: command.cityName, + adminId + }) + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + }) + } + + /** + * 撤销授权 + */ + async revokeAuthorization(command: RevokeAuthorizationCommand): Promise { + return await this.unitOfWork.execute(async () => { + const authorization = await this.authorizationRepository.findById( + AuthorizationId.create(command.authorizationId) + ) + + if (!authorization) { + throw new ApplicationError('授权不存在') + } + + authorization.revoke( + AdminUserId.create(command.adminId), + command.reason + ) + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + }) + } + + /** + * 授予单月豁免 + */ + async grantMonthlyBypass(command: GrantMonthlyBypassCommand): Promise { + return await this.unitOfWork.execute(async () => { + const assessment = await this.assessmentRepository.findByAuthorizationAndMonth( + AuthorizationId.create(command.authorizationId), + Month.create(command.month) + ) + + if (!assessment) { + throw new ApplicationError('考核记录不存在') + } + + assessment.grantBypass(AdminUserId.create(command.adminId)) + + await this.assessmentRepository.save(assessment) + await this.eventBus.publishAll(assessment.domainEvents) + assessment.clearDomainEvents() + }) + } + + /** + * 豁免占比考核 + */ + async exemptLocalPercentageCheck( + command: ExemptLocalPercentageCheckCommand + ): Promise { + return await this.unitOfWork.execute(async () => { + const authorization = await this.authorizationRepository.findById( + AuthorizationId.create(command.authorizationId) + ) + + if (!authorization) { + throw new ApplicationError('授权不存在') + } + + authorization.exemptLocalPercentageCheck( + AdminUserId.create(command.adminId) + ) + + await this.authorizationRepository.save(authorization) + await this.eventBus.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + }) + } + + /** + * 查询用户授权列表 + */ + async getUserAuthorizations(query: GetUserAuthorizationsQuery): Promise { + const authorizations = await this.authorizationRepository.findByUserId( + UserId.create(query.userId) + ) + + return authorizations.map(auth => this.toAuthorizationDTO(auth)) + } + + /** + * 查询火柴人排名数据 + */ + async getStickmanRanking(query: GetStickmanRankingQuery): Promise { + const assessments = await this.assessmentRepository.findRankingsByMonthAndRegion( + Month.create(query.month), + query.roleType, + RegionCode.create(query.regionCode) + ) + + // 获取用户信息并组装火柴人数据 + const rankings: StickmanRankingDTO[] = [] + + for (const assessment of assessments) { + const userInfo = await this.getUserInfo(assessment.userId) + const finalTarget = LadderTargetRule.getFinalTarget(assessment.roleType) + + rankings.push({ + userId: assessment.userId.value, + nickname: userInfo.nickname, + avatarUrl: userInfo.avatarUrl, + ranking: assessment.rankingInRegion!, + isFirstPlace: assessment.isFirstPlace, + cumulativeCompleted: assessment.cumulativeCompleted, + cumulativeTarget: assessment.cumulativeTarget, + finalTarget, + progressPercentage: (assessment.cumulativeCompleted / finalTarget) * 100, + exceedRatio: assessment.exceedRatio, + monthlyRewardUsdt: await this.calculateMonthlyReward(assessment), + monthlyRewardRwad: 0 // 需要从其他服务获取 + }) + } + + return rankings + } + + // 辅助方法 + private async createInitialAssessment( + authorization: AuthorizationRole, + teamStats: TeamStatistics + ): Promise { + const currentMonth = Month.current() + const target = LadderTargetRule.getTarget(authorization.roleType, 1) + + const assessment = MonthlyAssessment.create({ + authorizationId: authorization.authorizationId, + userId: authorization.userId, + roleType: authorization.roleType, + regionCode: authorization.regionCode, + assessmentMonth: currentMonth, + monthIndex: 1, + monthlyTarget: target.monthlyTarget, + cumulativeTarget: target.cumulativeTarget + }) + + // 立即评估首月 + const localTeamCount = this.getLocalTeamCount( + teamStats, + authorization.roleType, + authorization.regionCode + ) + + assessment.assess({ + cumulativeCompleted: teamStats.totalTeamPlantingCount, + localTeamCount, + totalTeamCount: teamStats.totalTeamPlantingCount, + requireLocalPercentage: authorization.requireLocalPercentage, + exemptFromPercentageCheck: authorization.exemptFromPercentageCheck + }) + + await this.assessmentRepository.save(assessment) + await this.eventBus.publishAll(assessment.domainEvents) + assessment.clearDomainEvents() + } + + private getLocalTeamCount( + teamStats: TeamStatistics, + roleType: RoleType, + regionCode: RegionCode + ): number { + if (roleType === RoleType.AUTH_PROVINCE_COMPANY) { + return teamStats.getProvinceTeamCount(regionCode.value) + } else if (roleType === RoleType.AUTH_CITY_COMPANY) { + return teamStats.getCityTeamCount(regionCode.value) + } + return 0 + } + + private toAuthorizationDTO(auth: AuthorizationRole): AuthorizationDTO { + return { + authorizationId: auth.authorizationId.value, + userId: auth.userId.value, + roleType: auth.roleType, + regionCode: auth.regionCode.value, + regionName: auth.regionName, + status: auth.status, + displayTitle: auth.displayTitle, + benefitActive: auth.benefitActive, + currentMonthIndex: auth.currentMonthIndex, + requireLocalPercentage: auth.requireLocalPercentage, + exemptFromPercentageCheck: auth.exemptFromPercentageCheck + } + } +} +``` + +### 定时任务 + +```typescript +// application/schedulers/monthly-assessment.scheduler.ts +@Injectable() +export class MonthlyAssessmentScheduler { + constructor( + private readonly authorizationRepository: IAuthorizationRoleRepository, + private readonly assessmentRepository: IMonthlyAssessmentRepository, + private readonly statsRepository: ITeamStatisticsRepository, + private readonly calculatorService: AssessmentCalculatorService, + private readonly eventBus: EventBus, + private readonly unitOfWork: UnitOfWork, + private readonly logger: Logger + ) {} + + /** + * 每月1号凌晨2点执行月度考核 + */ + @Cron('0 2 1 * *') + async executeMonthlyAssessment(): Promise { + this.logger.log('开始执行月度考核...') + + const previousMonth = Month.current().previous() + + try { + await this.unitOfWork.execute(async () => { + // 1. 获取所有激活的授权 + const activeAuths = await this.authorizationRepository.findAllActive() + + // 2. 按角色类型和区域分组处理 + const groupedByRoleAndRegion = this.groupByRoleAndRegion(activeAuths) + + for (const [key, auths] of groupedByRoleAndRegion) { + const [roleType, regionCode] = key.split('|') + + // 跳过正式省市公司(无月度考核) + if (roleType === RoleType.PROVINCE_COMPANY || + roleType === RoleType.CITY_COMPANY) { + continue + } + + // 执行考核并排名 + const assessments = await this.calculatorService.assessAndRankRegion( + roleType as RoleType, + RegionCode.create(regionCode), + previousMonth, + this.authorizationRepository, + this.statsRepository, + this.assessmentRepository + ) + + // 保存考核结果 + await this.assessmentRepository.saveAll(assessments) + + // 处理不达标的授权 + for (const assessment of assessments) { + if (assessment.result === AssessmentResult.FAIL) { + const auth = auths.find(a => + a.authorizationId.equals(assessment.authorizationId) + ) + + if (auth) { + // 权益失效 + auth.deactivateBenefit('月度考核不达标') + await this.authorizationRepository.save(auth) + + await this.eventBus.publishAll(auth.domainEvents) + auth.clearDomainEvents() + } + } else if (assessment.isPassed()) { + // 达标,递增月份索引 + const auth = auths.find(a => + a.authorizationId.equals(assessment.authorizationId) + ) + + if (auth) { + auth.incrementMonthIndex() + await this.authorizationRepository.save(auth) + } + } + + await this.eventBus.publishAll(assessment.domainEvents) + assessment.clearDomainEvents() + } + } + }) + + this.logger.log('月度考核执行完成') + } catch (error) { + this.logger.error('月度考核执行失败', error) + throw error + } + } + + /** + * 每天凌晨1点更新火柴人排名数据 + */ + @Cron('0 1 * * *') + async updateStickmanRankings(): Promise { + this.logger.log('开始更新火柴人排名数据...') + + const currentMonth = Month.current() + + try { + // 获取所有激活的授权省/市公司 + const activeAuths = await this.authorizationRepository.findAllActive() + + const provinceAuths = activeAuths.filter( + a => a.roleType === RoleType.AUTH_PROVINCE_COMPANY + ) + const cityAuths = activeAuths.filter( + a => a.roleType === RoleType.AUTH_CITY_COMPANY + ) + + // 按区域分组并更新排名 + // ... 实现排名更新逻辑 + + this.logger.log('火柴人排名数据更新完成') + } catch (error) { + this.logger.error('火柴人排名数据更新失败', error) + } + } + + private groupByRoleAndRegion( + authorizations: AuthorizationRole[] + ): Map { + const map = new Map() + + for (const auth of authorizations) { + const key = `${auth.roleType}|${auth.regionCode.value}` + if (!map.has(key)) { + map.set(key, []) + } + map.get(key)!.push(auth) + } + + return map + } +} +``` + +--- + +## API端点设计 + +### 用户端 API + +```typescript +// presentation/controllers/authorization.controller.ts +@Controller('api/v1/authorizations') +@UseGuards(JwtAuthGuard) +export class AuthorizationController { + constructor( + private readonly authorizationService: AuthorizationApplicationService + ) {} + + /** + * 申请社区授权 + */ + @Post('community') + async applyCommunityAuth( + @CurrentUser() user: UserPayload, + @Body() dto: ApplyCommunityAuthDTO + ): Promise> { + const result = await this.authorizationService.applyCommunityAuth({ + userId: user.userId, + communityName: dto.communityName + }) + return ApiResponse.success(result) + } + + /** + * 申请授权省公司 + */ + @Post('auth-province-company') + async applyAuthProvinceCompany( + @CurrentUser() user: UserPayload, + @Body() dto: ApplyAuthProvinceCompanyDTO + ): Promise> { + const result = await this.authorizationService.applyAuthProvinceCompany({ + userId: user.userId, + provinceCode: dto.provinceCode, + provinceName: dto.provinceName + }) + return ApiResponse.success(result) + } + + /** + * 申请授权市公司 + */ + @Post('auth-city-company') + async applyAuthCityCompany( + @CurrentUser() user: UserPayload, + @Body() dto: ApplyAuthCityCompanyDTO + ): Promise> { + const result = await this.authorizationService.applyAuthCityCompany({ + userId: user.userId, + cityCode: dto.cityCode, + cityName: dto.cityName + }) + return ApiResponse.success(result) + } + + /** + * 获取我的授权列表 + */ + @Get('my') + async getMyAuthorizations( + @CurrentUser() user: UserPayload + ): Promise> { + const result = await this.authorizationService.getUserAuthorizations({ + userId: user.userId + }) + return ApiResponse.success(result) + } + + /** + * 获取我的考核记录 + */ + @Get('my/assessments') + async getMyAssessments( + @CurrentUser() user: UserPayload, + @Query() query: GetAssessmentsQueryDTO + ): Promise> { + const result = await this.authorizationService.getUserAssessments({ + userId: user.userId, + page: query.page || 1, + pageSize: query.pageSize || 20 + }) + return ApiResponse.success(result) + } + + /** + * 获取火柴人排名(省公司) + */ + @Get('stickman-ranking/province/:provinceCode') + async getProvinceStickmanRanking( + @Param('provinceCode') provinceCode: string, + @Query('month') month?: string + ): Promise> { + const result = await this.authorizationService.getStickmanRanking({ + roleType: RoleType.AUTH_PROVINCE_COMPANY, + regionCode: provinceCode, + month: month || Month.current().value + }) + return ApiResponse.success(result) + } + + /** + * 获取火柴人排名(市公司) + */ + @Get('stickman-ranking/city/:cityCode') + async getCityStickmanRanking( + @Param('cityCode') cityCode: string, + @Query('month') month?: string + ): Promise> { + const result = await this.authorizationService.getStickmanRanking({ + roleType: RoleType.AUTH_CITY_COMPANY, + regionCode: cityCode, + month: month || Month.current().value + }) + return ApiResponse.success(result) + } + + /** + * 获取省市认种热度(可选是否允许查看) + */ + @Get('region-heat') + async getRegionHeat( + @Query() query: GetRegionHeatQueryDTO + ): Promise> { + const result = await this.authorizationService.getRegionHeat({ + regionType: query.regionType, + parentCode: query.parentCode + }) + return ApiResponse.success(result) + } +} +``` + +### 管理端 API + +```typescript +// presentation/controllers/admin-authorization.controller.ts +@Controller('api/v1/admin/authorizations') +@UseGuards(JwtAuthGuard, AdminGuard) +export class AdminAuthorizationController { + constructor( + private readonly authorizationService: AuthorizationApplicationService, + private readonly approvalService: AdminApprovalService + ) {} + + /** + * 授权正式省公司 + */ + @Post('province-company') + @RequireApproval(3) // 需要3人审批 + async grantProvinceCompany( + @CurrentAdmin() admin: AdminPayload, + @Body() dto: GrantProvinceCompanyDTO + ): Promise> { + await this.authorizationService.grantProvinceCompany({ + userId: dto.userId, + provinceCode: dto.provinceCode, + provinceName: dto.provinceName, + adminId: admin.adminId + }) + return ApiResponse.success() + } + + /** + * 授权正式市公司 + */ + @Post('city-company') + @RequireApproval(3) + async grantCityCompany( + @CurrentAdmin() admin: AdminPayload, + @Body() dto: GrantCityCompanyDTO + ): Promise> { + await this.authorizationService.grantCityCompany({ + userId: dto.userId, + cityCode: dto.cityCode, + cityName: dto.cityName, + adminId: admin.adminId + }) + return ApiResponse.success() + } + + /** + * 撤销授权 + */ + @Delete(':authorizationId') + @RequireApproval(3) + async revokeAuthorization( + @CurrentAdmin() admin: AdminPayload, + @Param('authorizationId') authorizationId: string, + @Body() dto: RevokeAuthorizationDTO + ): Promise> { + await this.authorizationService.revokeAuthorization({ + authorizationId, + adminId: admin.adminId, + reason: dto.reason + }) + return ApiResponse.success() + } + + /** + * 授予单月豁免 + */ + @Post(':authorizationId/bypass') + @RequireApproval(3) + async grantMonthlyBypass( + @CurrentAdmin() admin: AdminPayload, + @Param('authorizationId') authorizationId: string, + @Body() dto: GrantBypassDTO + ): Promise> { + await this.authorizationService.grantMonthlyBypass({ + authorizationId, + month: dto.month, + adminId: admin.adminId, + reason: dto.reason + }) + return ApiResponse.success() + } + + /** + * 豁免占比考核 + */ + @Post(':authorizationId/exempt-percentage') + @RequireApproval(3) + async exemptLocalPercentageCheck( + @CurrentAdmin() admin: AdminPayload, + @Param('authorizationId') authorizationId: string + ): Promise> { + await this.authorizationService.exemptLocalPercentageCheck({ + authorizationId, + adminId: admin.adminId + }) + return ApiResponse.success() + } + + /** + * 获取授权列表 + */ + @Get() + async getAuthorizations( + @Query() query: GetAuthorizationsQueryDTO + ): Promise>> { + const result = await this.authorizationService.getAuthorizationsList(query) + return ApiResponse.success(result) + } + + /** + * 获取考核列表 + */ + @Get('assessments') + async getAssessments( + @Query() query: GetAssessmentsQueryDTO + ): Promise>> { + const result = await this.authorizationService.getAssessmentsList(query) + return ApiResponse.success(result) + } + + /** + * 设置认种限制 + */ + @Post('planting-restrictions') + @RequireApproval(3) + async setPlantingRestriction( + @CurrentAdmin() admin: AdminPayload, + @Body() dto: SetPlantingRestrictionDTO + ): Promise> { + await this.authorizationService.setPlantingRestriction({ + ...dto, + adminId: admin.adminId + }) + return ApiResponse.success() + } + + /** + * 获取审批列表 + */ + @Get('approvals') + async getApprovals( + @Query() query: GetApprovalsQueryDTO + ): Promise>> { + const result = await this.approvalService.getApprovalsList(query) + return ApiResponse.success(result) + } + + /** + * 审批操作 + */ + @Post('approvals/:approvalId/approve') + async approveOperation( + @CurrentAdmin() admin: AdminPayload, + @Param('approvalId') approvalId: string + ): Promise> { + await this.approvalService.approve({ + approvalId, + adminId: admin.adminId + }) + return ApiResponse.success() + } + + /** + * 拒绝操作 + */ + @Post('approvals/:approvalId/reject') + async rejectOperation( + @CurrentAdmin() admin: AdminPayload, + @Param('approvalId') approvalId: string, + @Body() dto: RejectApprovalDTO + ): Promise> { + await this.approvalService.reject({ + approvalId, + adminId: admin.adminId, + reason: dto.reason + }) + return ApiResponse.success() + } +} +``` + +--- + +## 领域事件 + +```typescript +// domain/events/authorization.events.ts + +// 社区授权申请事件 +export class CommunityAuthRequestedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + communityName: string + }) { + super() + } +} + +// 授权省公司申请事件 +export class AuthProvinceCompanyRequestedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + provinceCode: string + provinceName: string + }) { + super() + } +} + +// 授权市公司申请事件 +export class AuthCityCompanyRequestedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + cityCode: string + cityName: string + }) { + super() + } +} + +// 正式省公司授权事件 +export class ProvinceCompanyAuthorizedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + provinceCode: string + provinceName: string + authorizedBy: string + }) { + super() + } +} + +// 正式市公司授权事件 +export class CityCompanyAuthorizedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + cityCode: string + cityName: string + authorizedBy: string + }) { + super() + } +} + +// 角色授权事件 +export class RoleAuthorizedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + roleType: RoleType + regionCode: string + authorizedBy: string + }) { + super() + } +} + +// 角色撤销事件 +export class RoleRevokedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + roleType: RoleType + regionCode: string + reason: string + revokedBy: string + }) { + super() + } +} + +// 权益激活事件 +export class BenefitActivatedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + roleType: RoleType + regionCode: string + }) { + super() + } +} + +// 权益失效事件 +export class BenefitDeactivatedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + roleType: RoleType + reason: string + }) { + super() + } +} + +// 月度考核通过事件 +export class MonthlyAssessmentPassedEvent extends DomainEvent { + constructor(public readonly payload: { + assessmentId: string + userId: string + roleType: RoleType + month: string + cumulativeCompleted: number + cumulativeTarget: number + }) { + super() + } +} + +// 月度考核失败事件 +export class MonthlyAssessmentFailedEvent extends DomainEvent { + constructor(public readonly payload: { + assessmentId: string + userId: string + roleType: RoleType + month: string + cumulativeCompleted: number + cumulativeTarget: number + reason: string + }) { + super() + } +} + +// 单月豁免授予事件 +export class MonthlyBypassGrantedEvent extends DomainEvent { + constructor(public readonly payload: { + assessmentId: string + userId: string + roleType: RoleType + month: string + grantedBy: string + }) { + super() + } +} + +// 占比考核豁免事件 +export class PercentageCheckExemptedEvent extends DomainEvent { + constructor(public readonly payload: { + authorizationId: string + userId: string + exemptedBy: string + }) { + super() + } +} + +// 第一名达成事件 +export class FirstPlaceAchievedEvent extends DomainEvent { + constructor(public readonly payload: { + assessmentId: string + userId: string + roleType: RoleType + regionCode: string + month: string + }) { + super() + } +} +``` + +--- + +## Kafka事件主题 + +```typescript +// 发布的事件主题 +export const AUTHORIZATION_TOPICS = { + // 授权相关 + AUTHORIZATION_REQUESTED: 'authorization.requested', + AUTHORIZATION_GRANTED: 'authorization.granted', + AUTHORIZATION_REVOKED: 'authorization.revoked', + + // 权益相关 + BENEFIT_ACTIVATED: 'authorization.benefit.activated', + BENEFIT_DEACTIVATED: 'authorization.benefit.deactivated', + + // 考核相关 + ASSESSMENT_PASSED: 'authorization.assessment.passed', + ASSESSMENT_FAILED: 'authorization.assessment.failed', + ASSESSMENT_BYPASSED: 'authorization.assessment.bypassed', + + // 排名相关 + FIRST_PLACE_ACHIEVED: 'authorization.ranking.first-place' +} + +// 订阅的事件主题 +export const SUBSCRIBED_TOPICS = { + // 从Planting Context订阅 + PLANTING_ORDER_PAID: 'planting.order.paid', + + // 从Referral Context订阅 + TEAM_STATS_UPDATED: 'referral.team-stats.updated' +} +``` + +--- + +## 跨上下文集成 + +### 与Referral Context集成 +```typescript +// 获取团队统计数据 +interface TeamStatistics { + userId: string + totalTeamPlantingCount: number + directReferralCount: number + teamMemberCount: number + provinceTeamCounts: Map // 各省团队认种数 + cityTeamCounts: Map // 各市团队认种数 + + getProvinceTeamCount(provinceCode: string): number + getCityTeamCount(cityCode: string): number +} +``` + +### 与Planting Context集成 +```typescript +// 监听认种事件,检查是否激活权益 +@EventHandler(PlantingOrderPaidEvent) +async handlePlantingOrderPaid(event: PlantingOrderPaidEvent): Promise { + // 检查用户是否有待激活的授权 + const pendingAuths = await this.authorizationRepository.findPendingByUserId( + UserId.create(event.userId) + ) + + for (const auth of pendingAuths) { + // 检查是否达到初始考核目标 + const teamStats = await this.statsRepository.findByUserId(auth.userId) + if (teamStats && teamStats.totalTeamPlantingCount >= auth.getInitialTarget()) { + auth.activateBenefit() + await this.authorizationRepository.save(auth) + // 发布权益激活事件 + } + } +} +``` + +### 与Reward Context集成 +```typescript +// 发布权益激活/失效事件,通知Reward Context调整奖励计算 +// BenefitActivatedEvent -> Reward Context开始计算该用户的权益收益 +// BenefitDeactivatedEvent -> Reward Context停止计算该用户的权益收益 +``` + +### 与Identity Context集成 +```typescript +// 获取用户信息用于显示 +interface UserInfo { + userId: string + nickname: string + avatarUrl: string + provinceCode: string + cityCode: string +} + +// 发布授权事件,更新用户头像显示标识 +// RoleAuthorizedEvent -> Identity Context更新用户displayTitle +// RoleRevokedEvent -> Identity Context清除用户displayTitle +``` + +--- + +## 目录结构 + +``` +authorization-service/ +├── src/ +│ ├── domain/ +│ │ ├── aggregates/ +│ │ │ ├── authorization-role.aggregate.ts +│ │ │ └── monthly-assessment.aggregate.ts +│ │ ├── entities/ +│ │ │ ├── ladder-target-rule.entity.ts +│ │ │ ├── planting-restriction.entity.ts +│ │ │ └── admin-approval.entity.ts +│ │ ├── value-objects/ +│ │ │ ├── authorization-id.vo.ts +│ │ │ ├── assessment-id.vo.ts +│ │ │ ├── region-code.vo.ts +│ │ │ ├── month.vo.ts +│ │ │ ├── assessment-config.vo.ts +│ │ │ ├── benefit-amount.vo.ts +│ │ │ ├── validation-result.vo.ts +│ │ │ └── restriction-check-result.vo.ts +│ │ ├── services/ +│ │ │ ├── authorization-validator.service.ts +│ │ │ ├── assessment-calculator.service.ts +│ │ │ └── planting-restriction.service.ts +│ │ ├── repositories/ +│ │ │ ├── authorization-role.repository.ts +│ │ │ ├── monthly-assessment.repository.ts +│ │ │ ├── planting-restriction.repository.ts +│ │ │ └── admin-approval.repository.ts +│ │ ├── events/ +│ │ │ └── authorization.events.ts +│ │ └── errors/ +│ │ └── domain-errors.ts +│ │ +│ ├── application/ +│ │ ├── services/ +│ │ │ ├── authorization-application.service.ts +│ │ │ └── admin-approval.service.ts +│ │ ├── commands/ +│ │ │ ├── apply-community-auth.command.ts +│ │ │ ├── apply-auth-province-company.command.ts +│ │ │ ├── apply-auth-city-company.command.ts +│ │ │ ├── grant-province-company.command.ts +│ │ │ ├── grant-city-company.command.ts +│ │ │ ├── revoke-authorization.command.ts +│ │ │ ├── grant-monthly-bypass.command.ts +│ │ │ └── exempt-percentage-check.command.ts +│ │ ├── queries/ +│ │ │ ├── get-user-authorizations.query.ts +│ │ │ ├── get-stickman-ranking.query.ts +│ │ │ └── get-region-heat.query.ts +│ │ ├── dtos/ +│ │ │ ├── authorization.dto.ts +│ │ │ ├── assessment.dto.ts +│ │ │ └── stickman-ranking.dto.ts +│ │ ├── schedulers/ +│ │ │ └── monthly-assessment.scheduler.ts +│ │ └── event-handlers/ +│ │ ├── planting-order-paid.handler.ts +│ │ └── team-stats-updated.handler.ts +│ │ +│ ├── infrastructure/ +│ │ ├── persistence/ +│ │ │ ├── prisma/ +│ │ │ │ └── schema.prisma +│ │ │ ├── repositories/ +│ │ │ │ ├── prisma-authorization-role.repository.ts +│ │ │ │ ├── prisma-monthly-assessment.repository.ts +│ │ │ │ └── prisma-admin-approval.repository.ts +│ │ │ └── mappers/ +│ │ │ ├── authorization-role.mapper.ts +│ │ │ └── monthly-assessment.mapper.ts +│ │ ├── messaging/ +│ │ │ ├── kafka/ +│ │ │ │ ├── kafka.module.ts +│ │ │ │ ├── kafka-producer.service.ts +│ │ │ │ └── kafka-consumer.service.ts +│ │ │ └── event-bus.ts +│ │ ├── cache/ +│ │ │ └── redis-cache.service.ts +│ │ └── external/ +│ │ ├── referral-context.client.ts +│ │ └── identity-context.client.ts +│ │ +│ ├── presentation/ +│ │ ├── controllers/ +│ │ │ ├── authorization.controller.ts +│ │ │ └── admin-authorization.controller.ts +│ │ ├── guards/ +│ │ │ ├── jwt-auth.guard.ts +│ │ │ └── admin.guard.ts +│ │ ├── decorators/ +│ │ │ ├── current-user.decorator.ts +│ │ │ ├── current-admin.decorator.ts +│ │ │ └── require-approval.decorator.ts +│ │ └── interceptors/ +│ │ └── approval.interceptor.ts +│ │ +│ ├── app.module.ts +│ └── main.ts +│ +├── test/ +│ ├── unit/ +│ │ ├── domain/ +│ │ └── application/ +│ └── integration/ +│ +├── prisma/ +│ ├── schema.prisma +│ └── migrations/ +│ +├── Dockerfile +├── package.json +├── tsconfig.json +└── DEVELOPMENT_GUIDE.md +``` + +--- + +## 注意事项 + +1. **阶梯考核逻辑**:豁免当月后,下月继续考核上月的累计目标,月份索引不递增 +2. **团队唯一性**:必须检查上下级整条推荐链,确保同一省/市授权在团队内唯一 +3. **占比考核**:自有团队占比5%是参与评选第一名的前提条件,可豁免 +4. **排名规则**:超越比例相同时,以达标时间先后排序 +5. **三人审批**:涉及数据修改或授权的管理操作需要三个管理员审批通过 +6. **权益失效**:月度考核不达标后权益失效,需重新完成初始考核才能重新激活 +7. **热度数据**:可通过配置控制是否允许未认种用户查看各省市认种热度 diff --git a/backend/services/reporting-service/DEVELOPMENT_GUIDE.md b/backend/services/reporting-service/DEVELOPMENT_GUIDE.md new file mode 100644 index 00000000..c0b7416e --- /dev/null +++ b/backend/services/reporting-service/DEVELOPMENT_GUIDE.md @@ -0,0 +1,2904 @@ +# 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. **幂等性**: 报表生成需要保证幂等性,避免重复生成