# statisticService 设计文档 > **创建日期**: 2026-06-04 > **状态**: 规格自审通过 > **前置文档**: `docs/figma-analysis-data-dashboard.md`(前端设计分析) > **范围**: 为数据看板新建后端微服务,支持未来扩展(埋点、未知需求) --- ## 〇、文档信息 | 项 | 内容 | |------|------| | 服务名 | `statisticService` | | 端口 | `tri://127.0.0.1:20009`(Dubbo triple) | | HTTP 路径 | `/api/v1/dashboard/*`(经 gateway,前端已固定) | | 数据库 | PostgreSQL(复用现有实例,新建 `statistic` schema) | | 缓存 | Redis(复用现有实例) | | 范围 | 看板 7 RPC + 事件采集框架 + 预留扩展(MQ 通道、SDK 端点、OLAP 通道、实时通道) | | 目标读者 | 后端开发、前端联调、运维、架构师 | ### 核心设计原则 1. **单一服务,内部模块化** - 看板 + 事件采集 + 预聚合,职责清晰,易重构 2. **EventSink 抽象** - 预留 MQ / 双写 / 采样等扩展点 3. **物化视图 + 预聚合表混用** - 历史数据走 MV,实时性要求高的走预聚合表 4. **按日分区** - events 表按 received_at 按日分区,30 天滚动清理 5. **准实时 + 预留实时** - 5 分钟刷新,可切换到同步通道 6. **JWT + 粉丝身份双校验** - 经 gateway 鉴权,业务侧校验 fan_profile ### 核心特点(4 大设计亮点) | # | 特点 | 价值 | |---|------|------| | 1 | **多层缓存 + 读写分离** | 看板读 90% 命中 Redis 5min TTL,不进 DB;事件写 channel 非阻塞,异步批量落库 | | 2 | **MV + 预聚合混用** | 历史数据走 MV(全量重算简单);实时性窗口数据(week_rank / recent_level_ups)走预聚合表 Worker 增量维护 | | 3 | **EventSink 抽象 + 开关** | 后续 OLAP/实时/MQ 都是 `cfg.EnableXxx` 开关,无需重构;本期只实现最简版本 | | 4 | **按日分区 + 自动管理** | 7 天预创建 + 30 天滚动清理,运维零干预;单分区 50M 行,操作快 | ### 与 figma 文档 4.2 节关键差异 | 项 | figma 文档 | statisticService(本期) | 理由 | |---|----------|-------------------|------| | HTTP 路径前缀 | `/api/v1/dashboard/*` | **保持** | 前端已固定,改前端成本巨大 | | 响应包装 | figma 没明确 | `{code: 200, data: }` | 对齐前端 `.data` 访问 | | `week_rank` | figma 写"可选" | **完整实现** | 用户确认 | | `week_total_users` | 无 | **新增** | 用于"击败 X%"展示 | | 鉴权字段 | 请求带 user_id | 从 JWT 取 | 安全 | --- ## 〇·一、本期实施范围(2026-06-08 追加) > **状态**: 本期聚焦看板 7 RPC + 事件采集框架两个范围,业务侧 5 服务集成在本期内,OLAP/SDK/实时/采样/多赛季/灰度发布策略等延后。本节不替换原设计,只标注"本期做 vs 不做",并给出阶段划分与特性清单,作为实施依据。 ### 0.1.1 范围矩阵 | # | 类别 | 项目 | 本期 | 来源章节 | |---|------|------|------|----------| | 1 | **看板 7 RPC** | `GetTodayOverview` / `Get7DayIncomeCurve` / `GetExhibitionIncomeSummary` / `GetLikeIncomeByLevel` / `GetTopAssetsByEarning` / `GetAssetLevelDistribution` / `GetAssetUpgradeProgress` | ✅ | §2.3、§3.4-3.5、§4.2 | | 2 | **事件采集** | `TrackEvent` / `BatchTrackEvent` + EventSink 抽象 + Worker 批量落库 | ✅ | §2.2、§3.2、§4.3 | | 3 | **预聚合表** | `metric_weekly_user_income` / `metric_recent_level_ups` / `metric_upcoming_level_ups` | ✅ | §3.5 | | 4 | **物化视图** | `mv_daily_user_income` / `mv_daily_exhibition_revenue` / `mv_daily_like_income` / `mv_asset_level_distribution` | ✅ | §3.4 | | 5 | **分区管理** | events 表按日分区 + 7 天预创建 + 30 天清理 | ✅ | §3.3、§4.5 | | 6 | **Gateway 路由** | 7 个 `/api/v1/dashboard/*` 路由 + JWT 鉴权 + 粉丝身份校验 | ✅ | §2.4-2.6、§4.2 | | 7 | **业务侧集成** | 5 个服务改造(social/asset/gallery/task/user)逐步加 TrackEvent | ✅ | §5.7 | | 8 | **可观测性** | Prometheus 指标(§4.7 全量)+ healthz + refresh_log | ✅ | §4.7、§4.8 | | 9 | **OLAP 双写** | ClickHouse 集成 | ❌ | §3.10、§6.2 | | 10 | **实时通道** | 同步 TrackEvent 实时通道 | ❌ | §3.10、§6.2 | | 11 | **SDK 端点** | HTTP `/api/v1/dashboard/track` 暴露 | ❌ | §2.4、§3.10 | | 12 | **采样** | 客户端采样 + `EnableSampling` | ❌ | §3.8、§4.9 | | 13 | **多赛季** | `season_id` 字段 / 赛季 Tab | ❌ | §3.10、§6.2 | | 14 | **灰度发布** | 10%→100% 灰度策略 + Stage 1-6 步骤 | ❌(保留机制但简化执行) | §5.8 | | 15 | **完整告警实现** | 钉钉/飞书 webhook(10 条规则) | ❌(指标埋点保留,告警由监控平台处理) | §4.8 | | 16 | **Channel 满载 Level 2-5** | 加大缓冲 / Worker 并发 / 解耦预聚合 / 降级 / 采样 | ❌(仅 Level 1 监控) | §4.9 | ### 0.1.2 阶段划分(4 阶段 / 事件先看板后) | 阶段 | 名称 | 周期(估) | 关键产出 | 关键验证 | |------|------|-----------|---------|---------| | **P1** | 服务骨架 | 2-3 天 | go.mod / proto / main.go / config / healthz / 启动接入 go.work / 10 个 SQL 迁移 | `go build` 通过 + `go test ./...` 通过 + 契约测试(proto json schema 校验)+ DB 迁移可重放 + P1 末预检查清单通过 | | **P2** | 事件采集 | 4-5 天 | events 表(已建)+ TrackEvent/BatchTrackEvent RPC + EventSink 接口 + event_flusher Worker + 3 个预聚表 + partitioner | 单元测试 + 集成测试(dockertest 真实 PG)+ 业务侧 socialService 一个服务联调通 | | **P3** | 看板 7 RPC | 4-5 天 | 4 个 MV + 7 个 RPC service + main.go 启动预热 7 cache 逻辑 + gateway 7 路由 + Redis 5min TTL | 集成测试(7 RPC 全覆盖)+ gateway 端到端 + 性能测试(k6/wrk 缓存命中/未命中 P99)+ 前端切流量 + cache hit rate > 80% | | **P4** | 业务侧补全 | 2-3 天 | 4 个服务集成(galleryService→taskService→assetService→userService)+ 各服务单元测试 + 联调 | 各服务 `go test` 通过 + 看板数据逐步出现 7 个事件类型 | **关键依赖 / 决策:** - **P2 不依赖 P3**:事件写入和看板读取在 service 层完全解耦(service 写 events,dashboard 读 MV),但都需要 PG schema + proto + config - **P3 启动时预热**:dashboard 7 个 RPC 各走独立 cache key + 启动 hook 预热 7 个 cache(防冷启动雪崩) - **P4 顺序**:galleryService(exhibition.start/end)→ taskService(exhibition.revenue)→ assetService(asset.mint/level_up)→ userService(crystal.change)。先做高频路径(gallery 展出是核心场景),后做低频路径 **对外接口冻结点:** - P1 末:proto 文件定稿(7 RPC + 2 事件 RPC,事件 RPC 内部可用) - P2 末:TrackEvent RPC 内部可用,业务侧可接入 - P3 末:前端可切流量 ### 0.1.3 特性清单(每阶段具体做什么) #### P1 · 服务骨架 **目标:** 起个空服务能跑起来,迁移能跑通。 | 任务 | 涉及文件 | 备注 | |------|---------|------| | go.mod 创建 | `backend/services/statisticService/go.mod` | 沿用 taskService 的依赖(pkg/database / pkg/redis / pkg/dubbo / pkg/metrics) | | go.work 集成 | `backend/go.work` | 加 `./services/statisticService` | | proto 定义 | `backend/proto/event.proto`、`backend/proto/statistic.proto` | **关键:冻结 proto**,7 RPC + 2 事件 RPC | | 配置 | `backend/services/statisticService/config/statistic_config.go` | 端口 20009 + schema=statistic + Redis + 刷新间隔 + Channel 配置 + 4 个 EnableXxx=false(OLAPDualWrite / RealtimeChannel / SDKEndpoint / Sampling) | | main.go | `backend/services/statisticService/main.go` | Dubbo triple 注册 + HTTP healthz + 启动 Worker hook | | DB 迁移 10 个 SQL | `backend/migrations/2026_06_08_001_statistic_events.sql` 等 10 个 | events 分区表 + 4 MV + 3 预聚表 + refresh_log + 初始 7 天分区 | | healthz 端点 | `backend/services/statisticService/handler/healthz.go` | 暴露 6 个关键指标(DB ping / Redis ping / Worker 状态 / 事件 channel 大小 / 预聚表行数) | | metrics 埋点骨架 | `backend/services/statisticService/metrics/metrics.go` | §4.7 全部 20+ 指标先声明,本期不全部埋数据 | | 启动验证 | 本地 + CI | `go build` + `go test ./...` + DB 迁移重放 + healthz 200 | **P1 末预检查清单**(P2 开工前必须验证): | # | 检查项 | 来源 | 验证方式 | |---|--------|------|---------| | 1 | socialService 有 `LikeAsset` 方法(或等价的写 asset_likes 入口) | §2.2 事件类型 | `grep -rn "asset_likes\|LikeAsset" backend/services/socialService/` | | 2 | galleryService 有 `PlaceAsset`(开始展出)/ `RemoveFromSlot`(结束展出)方法 | §2.2 | `grep -rn "^func.*PlaceAsset\|^func.*RemoveFromSlot" backend/services/galleryService/service/` | | 3 | taskService 有 `OnExhibitionCompleted` 方法(派发展览收益) | §2.2 | `grep -n "OnExhibitionCompleted" backend/services/taskService/service/revenue_service.go` | | 4 | assetService 有 `CreateMintOrder`(铸造)/ `CheckUpgrade` 或 `logLevelChange`(升级)方法 | §2.2 | `grep -n "CreateMintOrder\|CheckUpgrade\|logLevelChange" backend/services/assetService/service/{mint_service,asset_level_service}.go`;**实际等级变更点需 P1 末向 assetService 同学确认** | | 5 | userService 有 `UpdateCrystalBalance` 方法(水晶账本变动) | §2.2 | `grep -n "UpdateCrystalBalance" backend/services/userService/service/user_service.go` | | 6 | public 库的 `assets` 表有 `level` 字段、`status='active'` 软删除状态、`deleted_at IS NULL` 约定(MV4 假设) | §3.4 MV4 | `\d+ public.assets` 或 DBeaver 看 schema | | 7 | public 库的 `asset_likes` / `crystal_log` / `level_up_log` 表存在 | §3.4、§3.5 | `\dt public.*` | | 8 | Dubbo triple 端口 20009 未被占用 | §1.4 | `lsof -i :20009` | | 9 | 业务侧 5 个服务的 Dubbo 注册名(`tri://...:20000/20001/20002/20003/20006`)可达 | §1.2、§5.5 | `nc -zv` 或本地 e2e 调通 | | 10 | PostgreSQL `statistic` schema 创建权限 OK | §3.1 | 用业务账号 `CREATE SCHEMA statistic` 测试 | **核对不过则不进 P2。** #### P2 · 事件采集框架 **目标:** 服务能收事件、能落库、能维护预聚表;socialService 一个业务方联调通。 | 模块 | 涉及文件 | 备注 | |------|---------|------| | **model** | `model/event.go` | Event 结构 + JSONB 序列化 | | **repository** | `repository/event_repo.go` | 批量 INSERT ON CONFLICT DO NOTHING + event_id 去重 | | **repository** | `repository/metric_repo.go` | 3 个预聚表读写 | | **sink** | `sink/event_sink.go`(接口)+ `sink/channel_sink.go`(本期实现) | EventSink 接口预留 OLAP/实时/采样实现点 | | **service** | `service/event_service.go` | 校验(event_id 格式 / user_id 存在 / event_type 白名单 / properties < 1KB)+ 推入 channel | | **worker** | `worker/event_flusher.go` | 攒批 100 条/1s + 批量落库 + 触发 metric_recent_level_ups 同步更新 | | **worker** | `worker/metric_recent_level_ups_updater.go` | 同步更新(被 event_flusher 调用) | | **worker** | `worker/metric_upcoming_level_ups_updater.go` | 15min ticker(不依赖 event) | | **worker** | `worker/metric_weekly_user_income_updater.go` | 5min ticker,pg_try_advisory_lock 防多实例重复 | | **worker** | `worker/partitioner.go` | 启动时 + 每天 00:05 创建未来 7 天 / 每天 00:30 清理 30 天前 | | **proto handler** | `handler/track_event.go` | TrackEvent / BatchTrackEvent RPC 实现 | | **业务侧集成** | socialService 的 `like_income_log` 写入后 | 调用 `statisticClient.TrackEvent` 异步(fire-and-forget) | | **测试** | `service/event_service_test.go`、`worker/event_flusher_test.go`、`integration/track_event_test.go` | 单元 + 集成(dockertest 真实 PG) | **EventSink 接口设计(关键):** ```go type EventSink interface { Submit(ctx context.Context, e *event.Event) error SubmitBatch(ctx context.Context, es []*event.Event) error Close() error } // 本期实现:ChannelEventSink(推到 channel,由 event_flusher 消费) // 未来扩展:KafkaEventSink / ClickHouseDualWriteSink / SamplingEventSink ``` #### P3 · 看板 7 RPC **目标:** 7 个看板端到端通,前端可切流量。 | 模块 | 涉及文件 | 备注 | |------|---------|------| | **model** | `model/metric.go` | 7 个响应结构对齐 proto | | **repository** | `repository/dashboard_repo.go` | 7 个聚合 SQL(读 MV/预聚表,公共 schema 关联 assets/exhibitions) | | **service** | `service/dashboard_service.go` | 7 个 RPC 业务逻辑 + Redis 5min TTL + 缓存穿透防护 + 跨服务调用降级(userService.crystal_balance 失败时用 stale 标记) | | **service** | `service/metric_service.go` | MV 刷新协调(被 materializer 调用) | | **worker** | `worker/materializer.go` | 4 个 MV 用 REFRESH MATERIALIZED VIEW CONCURRENTLY + pg_try_advisory_lock + 错开执行时间(避免 DB 瞬时压力) + refresh_log 记录 | | **cache** | `service/cache.go` | 封装 pkg/redis,cache key 格式 `dash:{rpc}:{star_id}:{user_id}` | | **gateway 路由** | `backend/gateway/router/router.go` | 7 个 GET 路由 | | **gateway controller** | `backend/gateway/controller/statistic_controller.go` | 7 个方法 + JWT 鉴权 + 粉丝身份校验(调 userService.fan_profile)+ 响应包装 `{code:200, data:resp}` | | **业务侧消费** | gateway 调 7 个 RPC,前端 dashboardApi 已固定 | 切流量前确认前端 mock 数据准确性 | | **测试** | `service/dashboard_service_test.go`(7 RPC 单测)、`integration/dashboard_rpc_test.go`(7 RPC dockertest)、`integration/gateway_test.go`(端到端) | 100% RPC 覆盖 | **看板冷启动优化(启动 hook 预热):** ```go // main.go 启动时 go func() { for _, starID := range getTopNStars(100) { // 取前 100 star for _, rpc := range dashboardRPCs { go callAndCache(rpc, starID, sampleUserID) } } }() ``` #### P4 · 业务侧补全(4 个服务) | 顺序 | 服务 | 事件类型 | 集成位置 | |------|------|---------|---------| | 1 | galleryService | `exhibition.start`、`exhibition.end` | `PlaceAsset`(开始)/ `RemoveFromSlot`(结束)后 | | 2 | taskService | `exhibition.revenue` | `OnExhibitionCompleted` 调用后 | | 3 | assetService | `asset.mint`、`asset.level_up` | `CreateMintOrder`(铸造)/ `CheckUpgrade`(升级)后 | | 4 | userService | `crystal.change` | `UpdateCrystalBalance` 调用后 | **集成模式(统一封装):** ```go // 各服务引入 pkg/statistic 客户端 type StatisticClient interface { TrackEvent(ctx context.Context, e *event.Event) error BatchTrackEvent(ctx context.Context, es []*event.Event) error } // 业务调用方用 defer + recover 保证 fire-and-forget 不影响主流程 defer func() { if r := recover(); r != nil { log.Errorf("statistic track panic: %v", r) } go statisticClient.TrackEvent(bgCtx, buildEvent(...)) }() ``` **每个服务必做:** - 加 `statisticClient` 注入 - 在指定业务方法后加 `defer go TrackEvent` - 单测验证 `TrackEvent` 被调用(用 mock client) - 联调验证事件能落库 + 看板数据能出现 --- ## 一、架构总览 ### 1.1 服务定位 `statisticService` 是 TopFans 平台的数据统计与看板服务,提供两类核心能力: | 能力 | 描述 | 读者 | |------|------|------| | **数据看板** | 为 figma 数据看板页面提供 7 个聚合查询 RPC | 前端 `dashboard.vue` | | **事件采集** | 提供业务事件接入通道(EventSink 抽象),用于埋点 | 内部各服务 + 未来前端 SDK | ### 1.2 服务位置 ``` backend/services/statisticService/ # 新建 backend/proto/statistic.proto # 新建 backend/proto/event.proto # 新建(通用事件类型) backend/gateway/router/router.go # 修改:注册 /api/v1/dashboard/* 路由 backend/gateway/controller/statistic_controller.go # 新建 backend/go.work # 修改:加入 statisticService backend/migrations/ # 新建:statistic 表迁移(10 个 SQL) ``` ### 1.3 目录结构(参照 taskService) ``` backend/services/statisticService/ ├── main.go ├── go.mod / go.sum ├── config/ │ └── statistic_config.go # 配置: 端口、DB、Redis、刷新间隔、扩展开关 ├── provider/ │ ├── statistic_internal_provider.go # 内部服务调用方(其他服务 TrackEvent) │ └── statistic_mobile_provider.go # 移动端/前端调用方(看板 7 RPC) ├── service/ # 业务逻辑层 │ ├── dashboard_service.go # 看板 7 RPC 业务逻辑 │ ├── event_service.go # 事件采集业务逻辑(批量落库) │ └── metric_service.go # 预聚合/物化视图管理 ├── repository/ # 数据访问层 │ ├── event_repo.go # events 表操作 │ ├── metric_repo.go # 预聚合表查询 │ └── dashboard_repo.go # 看板聚合 SQL ├── model/ # 数据模型 │ ├── event.go # 通用事件模型 │ └── metric.go # 预聚合模型 ├── worker/ # 后台任务 │ ├── materializer.go # 物化视图定时刷新 │ ├── event_flusher.go # 事件批量落库 worker │ └── partitioner.go # events 按日分区自动管理 ├── client/ # 调用其他服务 │ ├── gallery_client.go │ ├── asset_client.go │ ├── user_client.go │ └── task_client.go └── docs/ # 服务级文档 ├── EVENT_TYPES.md # 事件类型注册表 ├── RUNBOOK.md # 运维手册 └── FAQ.md # 常见问题 ``` ### 1.4 端口 - **Dubbo triple 端口**: `tri://127.0.0.1:20009` - **HTTP 经 gateway**: `http://gateway:8080/api/v1/dashboard/*` - 端口冲突检查: 20000-20006, 20008 已被其他服务占用,20007 / 20009 空闲,选定 20009 ### 1.5 与其他服务的关系 ``` [Frontend dashboard.vue] ↓ HTTP (USE_MOCK_API=false) [Gateway :8080] ↓ gRPC [statisticService :20009] ↓ gRPC 调用 ┌───────────────┼───────────────┐ ↓ ↓ ↓ [galleryService] [assetService] [userService] [taskService] [socialService] :20001 :20003 :20000 :20006 :20002 ↓ ↓ ↓ ↓ ↓ [PostgreSQL public schema: assets / exhibitions / users / level_up_logs / crystal_logs / like_income_logs] ↓ [statisticService 物化视图 + 预聚合] ↓ [PostgreSQL: statistic schema] ``` **职责分工:** - `statisticService` 主要是**只读聚合 + 事件写入**,不持有核心业务数据 - 通过 gRPC 调用其他服务获取实时数据(如 `crystal_balance` 走 userService) - 通过 PostgreSQL 物化视图做预聚合,看板查询走 MV - 通过 Redis 缓存看板结果(5 分钟 TTL) --- ## 二、Proto + HTTP 设计 ### 2.1 路径与服务命名约定 | 层 | 名称 | 说明 | |------|------|------| | **Dubbo 服务名** | `statistic.StatisticService` | gRPC 服务注册名 | | **服务端口** | `tri://127.0.0.1:20009` | Dubbo triple 协议 | | **HTTP 路径前缀(看板)** | `/api/v1/dashboard/*` | **经 gateway 暴露给前端,前端已固定** | | **HTTP 路径前缀(预留 SDK)** | `/api/v1/dashboard/track` | POST,经 gateway,本期不实现 | > 关键决策: gRPC 服务名 = `statistic`,HTTP 路径 = `/dashboard`(因前端已固定,改名成本巨大) ### 2.2 event.proto(通用事件,**独立文件**) ```protobuf syntax = "proto3"; package event; option go_package = "github.com/topfans/backend/pkg/proto/event"; message Event { string event_id = 1; // 事件 ID(UUID,客户端生成,用于去重) int64 user_id = 2; // 用户 ID int64 star_id = 3; // 顶粉星城 ID string event_type = 4; // 事件类型(如 "asset.like", "exhibition.start") int64 occurred_at = 5; // 事件发生时间(ms timestamp) int64 received_at = 6; // 服务端接收时间(ms,服务端填充) map properties = 7; // 自定义属性(扁平 key-value) } message BatchEventRequest { repeated Event events = 1; } ``` **为什么独立 event.proto?** - 跨服务复用(其他服务可只引用 event.proto,不必引用 statistic.proto) - 避免循环依赖 - 未来独立升级(`event/v2.proto` 可并存) - 与项目 `common.proto` 风格一致(共享类型单独) **事件类型字符串规范(枚举化,但用字符串便于扩展):** | 事件类型 | 来源服务 | properties 示例 | |---------|---------|----------------| | `asset.like` | socialService | `{"asset_id": "123", "level": "SSR", "amount": "10"}` | | `asset.mint` | assetService | `{"asset_id": "123", "level": "UR"}` | | `exhibition.start` | galleryService | `{"asset_id": "123", "slot_id": "1"}` | | `exhibition.end` | galleryService | `{"asset_id": "123", "duration_ms": "86400000"}` | | `exhibition.revenue` | taskService | `{"asset_id": "123", "amount": "100", "duration_ms": "60000"}` | | `asset.level_up` | assetService | `{"asset_id": "123", "from": "SR", "to": "SSR"}` | | `crystal.change` | userService | `{"amount": "+100", "reason": "exhibition"}` | **事件类型注册表:** `backend/services/statisticService/docs/EVENT_TYPES.md`(不在代码里硬编码) ### 2.3 statistic.proto(主服务协议,字段对齐前端) ```protobuf syntax = "proto3"; package statistic; option go_package = "github.com/topfans/backend/pkg/proto/statistic"; import "event.proto"; service StatisticService { // ============ 看板 7 RPC(经 gateway 暴露) ============ rpc GetTodayOverview(GetTodayOverviewRequest) returns (GetTodayOverviewResponse); rpc Get7DayIncomeCurve(Get7DayIncomeCurveRequest) returns (Get7DayIncomeCurveResponse); rpc GetExhibitionIncomeSummary(GetExhibitionIncomeSummaryRequest) returns (GetExhibitionIncomeSummaryResponse); rpc GetLikeIncomeByLevel(GetLikeIncomeByLevelRequest) returns (GetLikeIncomeByLevelResponse); rpc GetTopAssetsByEarning(GetTopAssetsByEarningRequest) returns (GetTopAssetsByEarningResponse); rpc GetAssetLevelDistribution(GetAssetLevelDistributionRequest) returns (GetAssetLevelDistributionResponse); rpc GetAssetUpgradeProgress(GetAssetUpgradeProgressRequest) returns (GetAssetUpgradeProgressResponse); // ============ 事件采集(内部 RPC) ============ rpc TrackEvent(event.Event) returns (TrackEventResponse); rpc BatchTrackEvent(event.BatchEventRequest) returns (TrackEventResponse); } // ====== 1. 今日概览 ====== message GetTodayOverviewRequest { int64 star_id = 1; } message GetTodayOverviewResponse { int64 crystal_balance = 1; int64 today_income = 2; int32 week_rank = 3; // 本期完整实现 int32 week_total_users = 4; // 用于"击败 X%" } // ====== 2. 七日收益曲线 ====== message Get7DayIncomeCurveRequest { int64 star_id = 1; } message DailyIncomePoint { string date = 1; int64 income = 2; bool is_today = 3; bool is_peak = 4; } message Get7DayIncomeCurveResponse { repeated DailyIncomePoint points = 1; int64 total_income = 2; int64 avg_income = 3; } // ====== 3. 展出收益中心 ====== message GetExhibitionIncomeSummaryRequest { int64 star_id = 1; } message TopExhibitionItem { int64 asset_id = 1; string asset_name = 2; string asset_thumb = 3; string duration_7d = 4; int64 earnings_7d = 5; int32 avg_earnings = 6; } message GetExhibitionIncomeSummaryResponse { int32 exhibiting_count = 1; int32 starbook_count = 2; string total_duration = 3; // 格式: "D:HH:MM:SS"(< 24h 时省略 D) / Mock 样例: "712:13:56" int64 total_earnings = 4; repeated TopExhibitionItem top5 = 5; } // ====== 4. 点赞收益按等级 ====== message GetLikeIncomeByLevelRequest { int64 star_id = 1; } message LikeIncomeLevelItem { string level = 1; int32 asset_count = 2; int64 total_income = 3; string thumb = 4; } message GetLikeIncomeByLevelResponse { int64 total_like_count = 1; int64 total_income = 2; repeated LikeIncomeLevelItem levels = 3; } // ====== 5. 藏品 TOP5 ====== message GetTopAssetsByEarningRequest { int64 star_id = 1; } message TopAssetItem { int64 asset_id = 1; string asset_name = 2; string asset_thumb = 3; int64 total_earnings = 4; int32 rank = 5; } message GetTopAssetsByEarningResponse { repeated TopAssetItem items = 1; } // ====== 6. 藏品等级分布 ====== message GetAssetLevelDistributionRequest { int64 star_id = 1; } message AssetLevelItem { string level = 1; int32 count = 2; int32 total = 3; } message GetAssetLevelDistributionResponse { repeated AssetLevelItem items = 1; } // ====== 7. 升级进度 ====== message GetAssetUpgradeProgressRequest { int64 star_id = 1; } message UpcomingLevelUpItem { int64 asset_id = 1; string asset_name = 2; string asset_thumb = 3; int32 like_progress = 4; int32 duration_progress = 5; } message RecentLevelUpItem { int64 asset_id = 1; string asset_name = 2; string asset_thumb = 3; string new_level = 4; int64 upgrade_time = 5; } message GetAssetUpgradeProgressResponse { repeated UpcomingLevelUpItem upcoming = 1; repeated RecentLevelUpItem recent = 2; } // ====== 事件采集响应 ====== message TrackEventResponse { int32 accepted = 1; int32 rejected = 2; } ``` ### 2.4 HTTP 路径与 gRPC 方法映射 | HTTP 路径 | HTTP 方法 | Gateway 调用的 gRPC | 前端 dashboardApi 方法 | |-----------|----------|---------------------|------------------------| | `/api/v1/dashboard/today-overview` | GET | `StatisticService.GetTodayOverview` | `dashboardApi.getTodayOverview(starId)` | | `/api/v1/dashboard/income-curve` | GET | `StatisticService.Get7DayIncomeCurve` | `dashboardApi.get7DayIncomeCurve` | | `/api/v1/dashboard/exhibition-summary` | GET | `StatisticService.GetExhibitionIncomeSummary` | `dashboardApi.getExhibitionSummary` | | `/api/v1/dashboard/like-income-by-level` | GET | `StatisticService.GetLikeIncomeByLevel` | `dashboardApi.getLikeIncomeByLevel` | | `/api/v1/dashboard/top-assets` | GET | `StatisticService.GetTopAssetsByEarning` | `dashboardApi.getTopAssets` | | `/api/v1/dashboard/level-distribution` | GET | `StatisticService.GetAssetLevelDistribution` | `dashboardApi.getLevelDistribution` | | `/api/v1/dashboard/upgrade-progress` | GET | `StatisticService.GetAssetUpgradeProgress` | `dashboardApi.getUpgradeProgress` | | `/api/v1/dashboard/track` (预留) | POST | `StatisticService.TrackEvent` | (未来 SDK) | ### 2.5 Gateway 响应包装 Gateway 在调用 gRPC 后,统一包 `{code: 200, data: }`: ```go // 伪代码 - gateway controller func (c *DashboardController) GetTodayOverview(ctx *gin.Context) { starID := parseInt64(ctx.Query("star_id")) userID := getUserIDFromJWT(ctx) // 鉴权后注入 if userID == 0 { respondUnauthorized(ctx); return } resp, err := c.statisticClient.GetTodayOverview(ctx, &pb.GetTodayOverviewRequest{ StarId: starID, }) if err != nil { respondError(ctx, 500, err); return } respondOK(ctx, gin.H{ "code": 200, "data": resp, // 直接把 gRPC 响应作为 data }) } ``` ### 2.6 鉴权 - **HTTP `/api/v1/dashboard/*`(看板 7 RPC)**: 经 gateway 的 JWT 中间件鉴权,从 token 取 user_id(注入到 ctx),star_id 从 query 取 - **HTTP `/api/v1/dashboard/track`(预留 SDK)**: 经 gateway JWT 鉴权,从 token 取 user_id - **gRPC `TrackEvent`(服务间内部)**: Dubbo 内网白名单 + 调用方服务名校验 ### 2.7 关键设计决策汇总 | 决策点 | 选择 | 理由 | |--------|------|------| | **请求字段** | 只传 `star_id`,不传 `user_id` | `user_id` 从 JWT 取,前端不可信 | | **批量上报** | 提供 `BatchTrackEvent` | SDK/服务可批量,减少 RPC 次数 | | **事件 ID** | 客户端生成(UUID) | 服务端去重,防止重试导致重复 | | **时间字段** | 双时间戳(occurred_at + received_at) | 区分业务时间和入仓时间 | | **属性存储** | `map` | 扁平 key-value,适合 JSONB 存储 | | **错误处理** | 走 rejected 计数 + log | 埋点不应阻塞业务 | | **响应包装** | `{code: 200, data: }` | 对齐前端 `.data` 访问 | --- ## 三、数据表设计 ### 3.1 数据库与 Schema - 复用现有 PostgreSQL 实例,新建 schema `statistic` - 数据库连接复用 `pkg/database`,只新增一个 schema 配置项 ``` [PostgreSQL] ├── public (现有 9 个服务的业务表,只读访问) └── statistic (本服务专用,可读写) ├── events # 事件原始表(按日分区) ├── materialized_views # 物化视图 ├── metric_* # 预聚合表 └── refresh_log # 物化视图刷新日志 ``` ### 3.2 核心表:`statistic.events`(按日分区) ```sql CREATE TABLE statistic.events ( id BIGSERIAL, event_id UUID NOT NULL, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), properties JSONB NOT NULL DEFAULT '{}', PRIMARY KEY (id, received_at) ) PARTITION BY RANGE (received_at); -- 按日分区示例(2026-06-04 这一天) CREATE TABLE statistic.events_2026_06_04 PARTITION OF events FOR VALUES FROM ('2026-06-04 00:00:00+08') TO ('2026-06-05 00:00:00+08'); -- 注意: 分区键是 TIMESTAMPTZ,要带时区 -- 唯一约束(去重):同一 event_id 不能重复 CREATE UNIQUE INDEX idx_events_event_id ON statistic.events (event_id, received_at); -- 看板查询主索引(覆盖 90% 查询) CREATE INDEX idx_events_user_star_type_time ON statistic.events (user_id, star_id, event_type, received_at DESC); -- 趋势分析索引 CREATE INDEX idx_events_star_type_time ON statistic.events (star_id, event_type, received_at DESC); -- JSONB 属性 GIN 索引 CREATE INDEX idx_events_properties_gin ON statistic.events USING GIN (properties); ``` **按日分区优劣:** | 优势 | 代价 | |------|------| | 看板 7 日查询只走 7 个 partition,极快 | partition 数多(30 天保留 ≈ 30 个) | | 旧数据可按天快速 DROP(DETACH+DROP) | 需要自动管理 partition 创建/删除 | | 单 partition 容量小(50M 行),操作快 | 时间函数计算稍多 | | 适合按日归档/分析 | - | ### 3.3 分区自动管理 ```go // worker/partitioner.go func EnsureFuturePartitions(ctx context.Context, db *sql.DB, days int) error { now := time.Now().In(AsiaShanghai) for i := 0; i <= days; i++ { day := now.AddDate(0, 0, i) next := day.AddDate(0, 0, 1) partitionName := fmt.Sprintf("events_%s", day.Format("2006_01_02")) sql := fmt.Sprintf(` CREATE TABLE IF NOT EXISTS statistic.%s PARTITION OF statistic.events FOR VALUES FROM ('%s 00:00:00+08') TO ('%s 00:00:00+08'); `, partitionName, day.Format("2006-01-02"), next.Format("2006-01-02")) if _, err := db.ExecContext(ctx, sql); err != nil { return err } } return nil } ``` **调度:** - Worker 启动时调用 1 次(确保未来 7 天 partition 存在) - 每天 00:05 调用 1 次(滚动创建) **分区保留策略:** - 默认保留 30 天(配置项 `PartitionRetentionDays`) - 超过保留期的旧分区用 `DETACH PARTITION ... DROP` 删除 - 避免磁盘无限增长 ### 3.4 物化视图(全量重算,准实时) **MV1: `statistic.mv_daily_user_income`(每日用户水晶收益)** ```sql CREATE MATERIALIZED VIEW statistic.mv_daily_user_income AS SELECT user_id, star_id, DATE(received_at AT TIME ZONE 'Asia/Shanghai') AS income_date, -- 只统计"产生水晶收入"的事件: 展出收益(任务派发) + 水晶账本变动 -- asset.level_up 不直接携带 amount,等级变更由 crystal.change 同步上报 SUM( CASE WHEN event_type IN ('exhibition.revenue', 'crystal.change') AND COALESCE((properties->>'amount')::BIGINT, 0) > 0 THEN COALESCE((properties->>'amount')::BIGINT, 0) ELSE 0 END ) AS total_crystal FROM statistic.events WHERE event_type IN ('exhibition.revenue', 'crystal.change') GROUP BY user_id, star_id, income_date; CREATE UNIQUE INDEX idx_mv_daily_user_income_pk ON statistic.mv_daily_user_income (user_id, star_id, income_date); ``` **服务于:** 七日收益曲线、今日收益 **MV2: `statistic.mv_daily_exhibition_revenue`(每日展出收益,按藏品)** ```sql CREATE MATERIALIZED VIEW statistic.mv_daily_exhibition_revenue AS SELECT user_id, star_id, (properties->>'asset_id')::BIGINT AS asset_id, DATE(received_at AT TIME ZONE 'Asia/Shanghai') AS revenue_date, SUM(COALESCE((properties->>'duration_ms')::BIGINT, 0)) AS total_duration_ms, SUM(COALESCE((properties->>'amount')::BIGINT, 0)) AS total_earnings FROM statistic.events WHERE event_type = 'exhibition.revenue' GROUP BY user_id, star_id, asset_id, revenue_date; CREATE UNIQUE INDEX idx_mv_exhibition_revenue_pk ON statistic.mv_daily_exhibition_revenue (user_id, star_id, asset_id, revenue_date); ``` **服务于:** 展出收益中心(top5)、藏品矩阵 TOP5 **MV3: `statistic.mv_daily_like_income`(每日点赞按等级)** ```sql CREATE MATERIALIZED VIEW statistic.mv_daily_like_income AS SELECT e.user_id, e.star_id, a.level AS asset_level, DATE(e.received_at AT TIME ZONE 'Asia/Shanghai') AS like_date, COUNT(*) AS like_count, SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)) AS total_crystal FROM statistic.events e JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT WHERE e.event_type = 'asset.like' GROUP BY e.user_id, e.star_id, a.level, like_date; CREATE UNIQUE INDEX idx_mv_like_income_pk ON statistic.mv_daily_like_income (user_id, star_id, asset_level, like_date); ``` **服务于:** 点赞收益按等级(累计) **MV4: `statistic.mv_asset_level_distribution`(藏品等级分布)** > **假设**: `public.assets` 表存在 `status='active'` 软删除状态字段和 `deleted_at IS NULL` 软删除约定(项目既有规范);若不成立,需调整 WHERE 条件。 ```sql CREATE MATERIALIZED VIEW statistic.mv_asset_level_distribution AS SELECT user_id, star_id, level AS asset_level, COUNT(*) AS asset_count FROM public.assets WHERE status = 'active' AND deleted_at IS NULL GROUP BY user_id, star_id, level; CREATE UNIQUE INDEX idx_mv_level_dist_pk ON statistic.mv_asset_level_distribution (user_id, star_id, asset_level); ``` **服务于:** 藏品等级分布环形图 ### 3.5 预聚合表(Worker 维护,实时性更高) **为什么部分用预聚合表?** - 物化视图全量重算,适合"全量数据 + 定期刷新"场景 - 预聚合表 Worker 增量维护,适合"实时性要求高"或"含时间窗口"场景 - week_rank / recent_level_ups 这类需要按时间窗口,物化视图做不到 **预聚合表:`statistic.metric_weekly_user_income`(本周收入 + 排名)** ```sql CREATE TABLE statistic.metric_weekly_user_income ( star_id BIGINT NOT NULL, user_id BIGINT NOT NULL, week_start DATE NOT NULL, -- 周一日期(Asia/Shanghai) total_crystal BIGINT NOT NULL DEFAULT 0, rank_in_star INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (star_id, user_id, week_start) ); CREATE INDEX idx_weekly_rank ON statistic.metric_weekly_user_income (star_id, week_start, rank_in_star); ``` **服务于:** GetTodayOverview 的 `week_rank` + `week_total_users` **week_rank 实现细节:** ```sql -- 本周收入用户排行(实际查询预聚合表,毫秒级) SELECT rank_in_star AS rank, total_crystal AS income FROM statistic.metric_weekly_user_income WHERE star_id = $1 AND user_id = $3 AND week_start = $2; -- 本周开始时间(周一 00:00,Asia/Shanghai) -- week_total_users: 本周 star 下有收益的用户总数 SELECT COUNT(*) FROM statistic.metric_weekly_user_income WHERE star_id = $1 AND week_start = $2 AND total_crystal > 0; -- 注: 上方 SQL 是概念示意;实际由 Worker 每 5 分钟从 public.crystal_log -- 全量重算写入 metric_weekly_user_income,并计算 rank_in_star。 -- 看板查询不直接读 crystal_log。 ``` - 预聚合表: `statistic.metric_weekly_user_income(star_id, user_id, week_start, total_crystal, rank_in_star)` - Worker 每 5 分钟刷新一次 - 看板查询走预聚合表,毫秒级返回 - Redis 缓存 5 分钟 - 边界: 用户本周无收益 → `week_rank = -1`,前端显示"暂无排名" - 跨周: worker 在周一 00:05 触发,清除上周数据 **预聚合表:`statistic.metric_recent_level_ups`(最近升级记录,只保留近 30 天)** ```sql CREATE TABLE statistic.metric_recent_level_ups ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, asset_id BIGINT NOT NULL, from_level VARCHAR(8) NOT NULL, to_level VARCHAR(8) NOT NULL, upgrade_time TIMESTAMPTZ NOT NULL, asset_name VARCHAR(128), asset_thumb VARCHAR(512) ); CREATE INDEX idx_recent_level_ups_user ON statistic.metric_recent_level_ups (user_id, star_id, upgrade_time DESC); -- 自动清理: 30 天前的记录定时 DELETE ``` **服务于:** GetAssetUpgradeProgress 的 `recent[]` **预聚合表:`statistic.metric_upcoming_level_ups`(即将升级进度)** ```sql CREATE TABLE statistic.metric_upcoming_level_ups ( user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, asset_id BIGINT NOT NULL, like_progress INT NOT NULL, -- 0-100 duration_progress INT NOT NULL, -- 0-100 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, star_id, asset_id) ); ``` **服务于:** GetAssetUpgradeProgress 的 `upcoming[]` ### 3.6 物化视图刷新日志 ```sql CREATE TABLE statistic.refresh_log ( id BIGSERIAL PRIMARY KEY, mv_name VARCHAR(128) NOT NULL, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ, row_count BIGINT, status VARCHAR(16) NOT NULL, -- 'running' / 'success' / 'failed' error_message TEXT ); CREATE INDEX idx_refresh_log_mv_time ON statistic.refresh_log (mv_name, started_at DESC); ``` **用于:** 监控物化视图刷新状态 + 失败告警 ### 3.7 刷新策略(Worker,可自定义) | 物化视图 / 预聚合表 | 默认刷新频率 | 配置项 | 优先级 | |----------|---------|--------|--------| | `mv_daily_user_income` | 5 分钟 | `RefreshIntervals.DailyUserIncome` | 高 | | `mv_daily_exhibition_revenue` | 5 分钟 | `RefreshIntervals.DailyExhibitionRevenue` | 中 | | `mv_daily_like_income` | 5 分钟 | `RefreshIntervals.DailyLikeIncome` | 中 | | `mv_asset_level_distribution` | 15 分钟 | `RefreshIntervals.AssetLevelDistribution` | 低 | | `metric_weekly_user_income` | 5 分钟 | `RefreshIntervals.WeeklyUserIncome` | 高 | | `metric_recent_level_ups` | 实时 | TrackEvent 同步触发 | 高 | | `metric_upcoming_level_ups` | 15 分钟 | `RefreshIntervals.UpcomingLevelUps` | 中 | | `metric_recent_level_ups` 清理 | 每天 00:35 | 固定 | 后台 | | 创建未来 N 天 partition | 每天 00:05 | 固定 | 后台 | | 清理过期 partition | 每天 00:30 | 固定 | 后台 | **实现技术:** - Go Worker 协程 + `time.Ticker` - 刷新用 `REFRESH MATERIALIZED VIEW CONCURRENTLY`(不阻塞读) - 并发控制: PostgreSQL Advisory Lock 防止多实例重复刷 ### 3.8 配置项(预留扩展开关) ```go // config/statistic_config.go type StatisticConfig struct { Port int DBUrl string DBSchema string // 默认 "statistic" RedisUrl string WorkerEnabled bool RefreshIntervals struct { // 可自定义刷新频率 DailyUserIncome time.Duration DailyExhibitionRevenue time.Duration DailyLikeIncome time.Duration AssetLevelDistribution time.Duration WeeklyUserIncome time.Duration UpcomingLevelUps time.Duration } // 分区管理 PartitionRetentionDays int PartitionPreCreateDays int // 事件 channel EventChannelCapacity int EventWorkerCount int EventBatchSize int EventBatchInterval time.Duration // 告警阈值 AlertChannelSizeRatio float64 // 默认 0.8 AlertDropRateThreshold float64 // 默认 0.01 AlertMVRefreshFailureCount int // 默认 3 // 预留扩展(本期默认 false) EnableOLAPDualWrite bool // 双写 ClickHouse EnableRealtimeChannel bool // 同步 TrackEvent 实时通道 EnableSDKEndpoint bool // 暴露 HTTP /track 端点 EnableSampling bool // 客户端采样 SampleRate float64 } ``` ### 3.9 性能预估 **数据量假设:** - 假设百万 DAU,平均每用户每日产生 50 个事件 - 每日事件量 ≈ 5000 万条 - 7 日事件量 ≈ 3.5 亿条 - 30 日事件量 ≈ 15 亿条 **索引评估:** - `idx_events_user_star_type_time` 单 user 查询: 命中 7 日分区,毫秒级 - `idx_events_star_type_time` 跨用户统计: 扫描本月分区(50M 行),秒级 - `mv_*` 物化视图查询: 毫秒级(预聚合) **瓶颈与应对:** - 看板冷启动 7 个并发请求,各走不同 MV,互不阻塞 ✅ - 物化视图刷新时,`REFRESH CONCURRENTLY` 允许读不阻塞 ✅ - week_rank 实时性: 5 分钟刷新,用户无感 ✅ ### 3.10 未来扩展预留(表层) | 预留 | 实现方式 | 启用时机 | |------|---------|---------| | **OLAP 双写** | `events` 表加 `cfg.EnableOLAPDualWrite`,Worker 加 ClickHouse 写入分支 | 数据量 > 1亿/天 | | **实时通道** | `cfg.EnableRealtimeChannel`,同步 TrackEvent 路径 | 实时性要求提高 | | **SDK 端点** | `cfg.EnableSDKEndpoint`,暴露 HTTP /track | 前端 SDK 启动 | | **多赛季** | 预聚合表加 `season_id` 字段,MV 加分区 | 赛季结束归档时 | --- ## 四、关键流程 ### 4.1 流程总览 ``` ┌─────────────────────────────────────────────────────────────┐ │ statisticService 核心流程 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 流程 A: 看板查询(读) │ │ Gateway → gRPC StatisticService.GetXxx → 查物化视图/MV │ │ │ │ 流程 B: 事件采集(写) │ │ 其他服务 → gRPC StatisticService.TrackEvent → 写events表 │ │ │ │ 流程 C: 物化视图刷新(后台) │ │ Worker 协程 → 定时器触发 → REFRESH MATERIALIZED VIEW │ │ │ │ 流程 D: 分区管理(后台) │ │ Worker 协程 → 每天 00:05 创建未来 7 天 / 00:30 清理过期分区 │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 4.2 流程 A:看板查询(读路径) **时序图(以 `GetTodayOverview` 为例):** ``` Frontend Gateway statisticService PostgreSQL / Redis │ │ │ │ │ GET /api/v1/dashboard/│ │ │ │ today-overview │ │ │ │ ?star_id=123 │ │ │ ├──────────────────────>│ │ │ │ │ 1. JWT 鉴权 │ │ │ │ 从 token 提 user_id │ │ │ │ 2. 解析 query │ │ │ │ star_id = 123 │ │ │ │ 3. 校验: │ │ │ │ user.fan_profile │ │ │ │ .star_id == 123 │ │ │ │ │ │ │ │ gRPC GetTodayOverview │ │ │ │ (tri://20009) │ │ │ ├──────────────────────>│ │ │ │ │ 1. 查 Redis 缓存 │ │ │ │ Key: dash:overview │ │ │ │ :123:user_456 │ │ │ ├──────────────────────────>│ │ │ │<────── HIT ────────────────│ │ │ │ │ │ │ │ (缓存 miss 则: │ │ │ │ 2. 查 metric_weekly │ │ │ │ user_income │ │ │ │ 3. 查 userService │ │ │ │ .crystal_balance) │ │ │ │ │ │ │ GetTodayOverviewResp │ │ │ │ { │ │ │ │ crystal_balance:2713│ │ │ │ today_income:213 │ │ │ │ week_rank:12 │ │ │ │ week_total_users:341│ │ │ │ } │ │ │ │<──────────────────────┤ │ │ │ │ │ │ │ HTTP 包装: │ │ │ │ { code: 200, │ │ │ │ data: { ... } } │ │ │ │ │ │ │ HTTP 200 OK │ │ │ │<──────────────────────┤ │ │ │ │ │ │ ``` **关键设计点:** 1. **JWT 鉴权** - Gateway 在路由层完成,service 收到的请求已带 user_id(注入到 ctx) 2. **粉丝身份校验** - 必须验证 user 真的加入了该 star(查 userService.fan_profile),防止越权 3. **Redis 缓存** - 5 分钟 TTL,缓存 Key 包含 star_id + user_id 4. **缓存穿透防护** - 缓存 miss 时查 DB,DB 也无数据则缓存空值 1 分钟 5. **多 RPC 并发** - 前端 `Promise.allSettled` 一次发 7 个,各走独立 cache key,互不阻塞 6. **降级兜底** - 跨服务调用(如 `userService.GetCrystalBalance`)失败时,该字段用上次缓存值并打 stale 标记,其他字段继续用 MV 返回,不阻断整个看板;连续 3 次失败再返回 5xx ### 4.3 流程 B:事件采集(写路径) **时序图(以 `socialService` 上报点赞事件为例):** ``` socialService statisticService PostgreSQL │ │ │ │ 用户点赞: │ │ │ 1. 写 like_income_log │ │ │ 2. 更新 asset.like_count │ │ │ │ │ │ (异步,失败不影响主流程) │ │ │ gRPC TrackEvent │ │ │ (tri://20009) │ │ │ { event_id: uuid, │ │ │ user_id: 10000001, │ │ │ star_id: 123, │ │ │ event_type: "asset.like", │ │ │ occurred_at: ts, │ │ │ properties: { │ │ │ "asset_id": "456", │ │ │ "amount": "10" │ │ │ } │ │ │ } │ │ ├────────────────────────────>│ │ │ │ 1. 校验: │ │ │ - event_id 格式 │ │ │ - user_id 存在 │ │ │ - event_type 在白名单 │ │ │ - properties 大小 < 1KB │ │ │ │ │ │ 2. 推入 channel(非阻塞) │ │ │ 缓冲 1000 条,满则降级 │ │ │ │ │ TrackEventResponse │ │ │ { accepted: 1, rejected: 0 }│ │ │<────────────────────────────┤ │ │ │ │ │ │ ── Worker 协程(独立) ── │ │ │ 3. 批量读 channel │ │ │ 攒批 100 条 / 1秒 │ │ │ 4. 去重: │ │ │ SELECT event_id FROM │ │ │ events WHERE event_id IN │ │ │ (?, ?, ...) │ │ ├─────────────────────────────>│ │ │<───── 已有 0 条 ─────────────│ │ │ │ │ │ 5. 批量 INSERT │ │ │ INSERT INTO events ... │ │ │ ON CONFLICT DO NOTHING │ │ ├─────────────────────────────>│ │ │<───── 100 行已写入 ──────────│ │ │ │ │ │ 6. 触发预聚合同步更新 │ │ │ metric_recent_level_ups │ │ │ (同步,不阻塞事件写入) │ │ │ 注: metric_upcoming │ │ │ _level_ups 走 15 分钟 │ │ │ Worker,不在此路径 │ ``` **关键设计点:** 1. **不阻塞业务** - socialService 调用 `TrackEvent` 是 fire-and-forget,gRPC 调用快速返回(< 50ms) 2. **批量落库** - Worker 协程攒批 100 条/1秒,降低 DB 压力 3. **去重** - 用 event_id 唯一约束 + `ON CONFLICT DO NOTHING`,客户端重试安全 4. **降级策略** - channel 满时拒绝新事件(返回 `rejected: 1`),但不影响主流程 5. **预聚合同步** - TrackEvent 同步触发 `metric_recent_level_ups` 更新(MV 刷新走 5 分钟定时器) **批量上报优化(预留):** ```go // 业务侧可一次性上报 100 条事件,减少 RPC 次数 resp, err := client.BatchTrackEvent(ctx, &event.BatchEventRequest{ Events: events, // 100 个 Event }) // 返回: accepted=100, rejected=0 ``` ### 4.4 流程 C:物化视图刷新(后台) **时序图(以 5 分钟定时刷新为例):** ``` statisticService Worker PostgreSQL │ │ │ (5 分钟 ticker 触发) │ │ │ │ 1. 抢 Advisory Lock │ │ pg_try_advisory_lock(1234) │ ├───────────────────────────────────>│ │<─────── true(抢到) ────────────────│ │ │ │ 2. REFRESH MATERIALIZED VIEW │ │ CONCURRENTLY │ │ mv_daily_user_income; │ ├───────────────────────────────────>│ │ (5-10 秒) │ │<─────── 完成,新数据可见 ───────────│ │ │ │ 3. 记录 refresh_log │ │ INSERT INTO refresh_log ... │ ├───────────────────────────────────>│ │ │ │ 4. 释放 Advisory Lock │ │ pg_advisory_unlock(1234) │ ├───────────────────────────────────>│ │ │ │ 5. 等待下一个 tick │ ``` **关键设计点:** 1. **多实例防重复** - 用 `pg_try_advisory_lock` 保证多实例下只有一个跑刷新 2. **不阻塞读** - `REFRESH CONCURRENTLY` 允许查询继续用旧数据 3. **失败重试** - 3 次重试,失败写 refresh_log 状态 'failed' 4. **告警** - refresh_log 连续 N 次失败触发告警(可对接钉钉/飞书) 5. **优先级** - 不同 MV 错开执行,避免 DB 瞬时压力大 ### 4.5 流程 D:分区管理(后台) - **Worker 启动时**: 调用 `EnsureFuturePartitions(7)`,创建未来 7 天分区 - **每天 00:05**: 调用 `EnsureFuturePartitions(7)`,滚动创建 - **每天 00:30**: 调用 `CleanupOldPartitions(30)`,清理 30 天前分区 ### 4.6 异常场景处理 | 场景 | 处理 | |------|------| | **DB 不可用** | 看板查询返回 500 + 日志告警;事件采集降级为"丢弃但记录指标" | | **Redis 不可用** | 缓存全部走 DB(降级),不阻塞 | | **物化视图刷新失败** | 重试 3 次 → 写 refresh_log failed → 告警 → 看板返回 5 分钟前的数据 | | **Advisory Lock 抢不到** | 本实例跳过本轮,下个 tick 再试 | | **事件 channel 满** | 返回 `rejected: N`,记录 `metric_event_dropped` 指标 | | **JWT 鉴权失败** | Gateway 返回 401,前端跳登录 | | **粉丝身份不匹配** | Gateway 返回 403 "未加入该顶粉星城" | | **限流** | 看板 RPC 100 QPS / user_id,超限返回 429;在 gateway 中间件用 Redis 滑动窗口实现(项目现有 `pkg/ratelimit` 组件) | ### 4.7 监控指标(Prometheus) ```go // 看板 RPC metrics.NewCounter("dashboard_rpc_total", []string{"rpc", "status"}) metrics.NewHistogram("dashboard_rpc_duration_seconds", []string{"rpc"}) metrics.NewGauge("dashboard_cache_hit_rate") // 事件采集 metrics.NewCounter("event_track_total", []string{"event_type", "result"}) // result: accepted/rejected metrics.NewGauge("event_channel_size") metrics.NewGauge("event_channel_capacity") metrics.NewCounter("event_dropped_total") metrics.NewHistogram("event_batch_size") metrics.NewCounter("event_db_insert_total", []string{"status"}) // 物化视图 metrics.NewCounter("mv_refresh_total", []string{"mv_name", "status"}) metrics.NewHistogram("mv_refresh_duration_seconds", []string{"mv_name"}) // Worker metrics.NewGauge("worker_running_count", []string{"worker_name"}) metrics.NewHistogram("worker_loop_duration", []string{"worker_name"}) // 分区管理 metrics.NewGauge("events_partition_count") metrics.NewCounter("events_partition_created_total") metrics.NewCounter("events_partition_dropped_total") ``` ### 4.8 告警规则(本期实现,Level 1) | 告警项 | 触发条件 | 严重度 | 通知方式 | |--------|---------|--------|---------| | **MV 刷新失败** | refresh_log.status='failed' 连续 3 次 | 高 | 飞书/钉钉 + 邮件 | | **事件 channel 满** | `event_channel_size` > 80% 容量 持续 1 分钟 | 中 | 飞书/钉钉 | | **事件丢失率过高** | `event_dropped_total / event_track_total` > 1% 持续 5 分钟 | 高 | 飞书/钉钉 + 邮件 | | **DB 写入慢** | `event_db_insert_duration` P99 > 500ms 持续 5 分钟 | 中 | 飞书/钉钉 | | **看板 RPC 失败率高** | `dashboard_rpc_total{status=error}` / `dashboard_rpc_total` > 5% 持续 5 分钟 | 中 | 飞书/钉钉 | | **缓存命中率低** | `dashboard_cache_hit_rate` < 50% 持续 15 分钟 | 低 | 飞书/钉钉 | | **Advisory Lock 抢不到** | 累计 10 次未抢到 | 中 | 飞书/钉钉 | | **Worker 不健康** | `worker_running_count=0` 持续 1 分钟 | 高 | 飞书/钉钉 + 邮件 | | **分区缺失** | 当前日期 partition 不存在 | 高 | 飞书/钉钉 + 邮件 | | **跨时区异常** | (本期不监控) | - | - | **告警实现:** - 本期不引入 AlertManager,只暴露 Prometheus 指标 - 告警规则由 Grafana / 运维侧的告警系统订阅执行(项目已有 Prometheus) - statisticService 内部加一个"健康度自检"端点 `GET /healthz`,暴露关键指标 ### 4.9 Channel 满载分析(本期) **为什么 channel 会满?(7 类原因)** 1. **生产速率 > 消费速率(最常见)** - 消费端慢:DB 慢查询、批量插入性能瓶颈、Worker 被阻塞 - 生产端快:业务高峰期、某服务异常、并发上报 2. **DB 突发压力**:业务高峰、DB 维护、慢 SQL 阻塞 3. **预聚合同步更新慢**:`metric_recent_level_ups` 同步写慢 4. **资源限制**:channel 容量偏小、Worker 数量少、连接池耗尽 5. **某个调用方异常**:死循环、retry storm、单事件过大 6. **GC 压力**:大量 Event 对象、频繁 GC 暂停 worker 7. **时区/分区问题**:`occurred_at` 路由错误、写入不存在分区 **本期承诺(Level 1):** - 默认 `EventChannelCapacity: 1000` + `EventWorkerCount: 1` + `EventBatchSize: 100` + `EventBatchInterval: 1s` - 加监控:`event_channel_size`、`event_dropped_total`、`worker_loop_duration` - **不预设 Level 2-5 的实现**(加大缓冲 / Worker 并发 / 解耦预聚合 / 降级 / 采样),留好配置项和代码 hook - 后续根据监控数据决定是否升级 ### 4.10 性能与 SLA #### 4.10.1 核心性能指标 | 指标 | 目标 | 实测方式 | |------|------|---------| | 看板单 RPC P99 延迟 | < 200ms | Prometheus + Grafana | | 看板 7 RPC 并发 P99 | < 500ms | e2e 测试 | | TrackEvent P99 延迟 | < 50ms | 调用方埋点 | | 物化视图刷新耗时 | < 30s/视图 | refresh_log 统计 | | 缓存命中率 | > 80% | Redis 监控 | **SLA:** - 可用性: 99.5%(中等,看板不是核心交易) - 数据延迟: 准实时(5 分钟内,可自定义) - 事件丢失率: < 0.1%(进程崩溃可能丢失 channel 内未落库事件) #### 4.10.2 读性能分析(7 RPC 详细) | RPC | 数据源 | 缓存命中 | 缓存未命中 | |-----|--------|---------|-----------| | GetTodayOverview | metric_weekly + userService RPC | <10ms | ~50ms(1 SQL + 1 gRPC) | | Get7DayIncomeCurve | mv_daily_user_income(扫 7 日分区) | <10ms | ~30ms(1 SQL) | | GetExhibitionIncomeSummary | mv_daily_exhibition_revenue + exhibitions | <10ms | ~50ms(2 SQL) | | GetLikeIncomeByLevel | mv_daily_like_income(JOIN assets) | <10ms | ~40ms(1 SQL + JOIN) | | GetTopAssetsByEarning | mv_daily_exhibition_revenue ORDER BY | <10ms | ~30ms | | GetAssetLevelDistribution | mv_asset_level_distribution | <10ms | ~20ms | | GetAssetUpgradeProgress | metric_recent + metric_upcoming | <10ms | ~40ms(2 SQL) | **7 RPC 并发 P99 预估: 200~400ms**(各自独立 cache key,无相互阻塞)。 #### 4.10.3 写性能分析(TrackEvent + Worker + 刷新) | 操作 | 路径 | 延迟 | 备注 | |------|------|------|------| | **TrackEvent 单条** | gRPC → channel(非阻塞)→ 返回 | **<5ms** | 不等落库,fire-and-forget | | **TrackEvent 批量 100 条** | gRPC → channel | <20ms | 减少 RPC 次数 | | **Worker 批量落库** | 攒批 100 条/1s → `INSERT ... ON CONFLICT DO NOTHING` | ~50ms/batch | **持续 50 万/分钟吞吐** | | **MV 刷新(单视图)** | `REFRESH MATERIALIZED VIEW CONCURRENTLY` | 5~15s | SHARE UPDATE EXCLUSIVE 锁 | | **预聚合表刷新** | Worker 增量写 | <5s | 单 SQL,取决于 star 数 | | **分区创建/清理** | 每天 1 次(7 天预创建 + 30 天清理) | <1s | 几乎无感 | #### 4.10.4 瓶颈与风险点 | # | 瓶颈 | 影响 | 缓解 | |---|------|------|------| | 1 | **MV 刷新阻塞 INSERT** | 刷新期间 events 表写入排队 1-2 万/5min(≈1% 事件丢失) | 错开不同 MV 刷新时间;`REFRESH CONCURRENTLY` 已有 | | 2 | **冷启动 7 RPC 并发** | 启动瞬间 14 个请求(含 userService)冲击 DB | 启动 hook 预热 7 个 cache;首屏请求合并 | | 3 | **JSONB GIN 索引膨胀** | 15 亿行 × 500B/properties × GIN 膨胀 ≈ 30-50GB 索引 | 30 天清理自然控制;高频字段可降级 btree | | 4 | **跨服务调用失败** | `crystal_balance` 取不到,看板数据不全 | 已有 stale 缓存兜底(§4.2 第 6 条);连续 3 次失败才 5xx | | 5 | **week_rank 全量重算** | 100 star × 1 万 user = 100 万行,5min 一次 | PostgreSQL 几秒搞定;预聚合表 + 索引已就绪 | | 6 | **磁盘 IO** | 30 天 × 5000 万/天 × 500B ≈ 750GB | 按日分区,旧分区可单独放慢盘;运维侧监控 | > **相关风险见 §6.1 风险登记表**(16 个风险中已覆盖大部分瓶颈)。 #### 4.10.5 一句话总结 **看板读 P99 控制在 200~400ms(全 Redis 命中场景下 <100ms);TrackEvent 完全不阻塞业务(<5ms 返回);MV 刷新阶段有 1% 事件丢失风险(可接受,看板非核心交易)**。最大扩展点是 EventSink 抽象让后续 OLAP/实时/SDK 接入零重构。 --- ## 五、测试与部署 ### 5.1 测试策略 | 层级 | 范围 | 工具 | 覆盖率 | |------|------|------|--------| | **单元测试** | service / repository / worker | Go testing + testify | > 80% | | **集成测试** | 7 个 RPC + TrackEvent 端到端 | Go testing + dockertest(postgres) | 100% RPC | | **Worker 测试** | 物化视图刷新 + 预聚合维护 | Go testing + sqlmock | > 70% | | **性能测试** | 7 RPC 并发 P99、TrackEvent 吞吐 | k6 / wrk | 关键路径 | | **E2E** | 前端 dashboard.vue → 网关 → 后端 | Playwright | 1 条主路径 | | **契约测试** | 7 个 RPC 响应 schema 不变 | json schema 校验 | 100% | **测试夹具(fixture):** - `testdata/events_sample.json`: 500 条样例事件,覆盖所有 event_type - `testdata/expected/`: 每个 RPC 的预期响应(对齐前端 mock) - 测试用 PostgreSQL: 每次 test 启动前 truncate,test 结束 teardown ### 5.2 单元测试重点 ```go // service/dashboard_service_test.go func TestGetTodayOverview_WeekRank(t *testing.T) { // 1. 准备测试数据(metric_weekly_user_income 表) // 2. 构造请求 // 3. 调用 service // 4. 验证响应字段 } func TestGet7DayIncomeCurve_EmptyWeek(t *testing.T) { // 边界: 本周无收益,返回空数组 } func TestTrackEvent_Deduplication(t *testing.T) { // 同一 event_id 两次上报,只入库一次 } func TestTrackEvent_ChannelFull(t *testing.T) { // channel 满时降级,返回 rejected > 0 } ``` ### 5.3 集成测试(7 RPC 全覆盖) ```go // integration/dashboard_rpc_test.go func TestDashboardService_GetTodayOverview_Integration(t *testing.T) { // 启动真实 PostgreSQL + statisticService // 调用 gRPC GetTodayOverview // 验证响应字段对齐前端 mock } func TestDashboardService_GetExhibitionIncomeSummary_Integration(t *testing.T) { // 验证 top5 排序 // 验证 total_duration 格式 } ``` ### 5.4 性能基准(关键路径) | 测试项 | 目标 | |--------|------| | GetTodayOverview 缓存命中 | < 10ms (P99) | | GetTodayOverview 缓存未命中 | < 200ms (P99) | | 7 RPC 并发 | < 500ms (P99) | | TrackEvent 单条 | < 50ms (P99) | | TrackEvent 批量 100 条 | < 100ms (P99) | | 物化视图刷新(单视图) | < 30s | | Channel 满载处理 | 不阻塞业务调用方 | ### 5.5 部署清单 **新增文件:** ``` backend/services/statisticService/ # 整个目录 backend/proto/statistic.proto backend/proto/event.proto backend/gateway/controller/statistic_controller.go backend/gateway/router/router.go # 修改:注册路由 backend/go.work # 修改:加入 statisticService backend/migrations/ # 新建:statistic 表迁移 2026_06_04_001_statistic_events.sql 2026_06_04_002_statistic_mv_daily_user_income.sql 2026_06_04_003_statistic_mv_daily_exhibition_revenue.sql 2026_06_04_004_statistic_mv_daily_like_income.sql 2026_06_04_005_statistic_mv_asset_level_distribution.sql 2026_06_04_006_statistic_metric_weekly_user_income.sql 2026_06_04_007_statistic_metric_recent_level_ups.sql 2026_06_04_008_statistic_metric_upcoming_level_ups.sql 2026_06_04_009_statistic_refresh_log.sql 2026_06_04_010_statistic_partitions_initial.sql docker/ # 修改:加 statisticService 容器 ``` **配置项(统计服务 .env):** ```bash # 服务端口 STATISTIC_SERVICE_PORT=20009 # 数据库 STATISTIC_DB_URL=postgres://user:pass@postgres:5432/topfans?sslmode=disable STATISTIC_DB_SCHEMA=statistic # Redis STATISTIC_REDIS_URL=redis://redis:6379/0 # 业务服务 URL(用于跨服务调用,获取实时数据) DUBBO_USER_SERVICE_URL=tri://userservice:20000 DUBBO_GALLERY_SERVICE_URL=tri://galleryservice:20001 DUBBO_ASSET_SERVICE_URL=tri://assetservice:20003 DUBBO_TASK_SERVICE_URL=tri://taskservice:20006 DUBBO_SOCIAL_SERVICE_URL=tri://socialservice:20002 # 事件 channel STATISTIC_EVENT_CHANNEL_CAPACITY=1000 STATISTIC_EVENT_WORKER_COUNT=1 STATISTIC_EVENT_BATCH_SIZE=100 STATISTIC_EVENT_BATCH_INTERVAL=1s # 物化视图刷新(可自定义) STATISTIC_REFRESH_DAILY_USER_INCOME=5m STATISTIC_REFRESH_DAILY_EXHIBITION_REVENUE=5m STATISTIC_REFRESH_DAILY_LIKE_INCOME=5m STATISTIC_REFRESH_ASSET_LEVEL_DISTRIBUTION=15m STATISTIC_REFRESH_WEEKLY_USER_INCOME=5m STATISTIC_REFRESH_UPCOMING_LEVEL_UPS=15m # 分区管理 STATISTIC_PARTITION_RETENTION_DAYS=30 STATISTIC_PARTITION_PRECREATE_DAYS=7 # 预留扩展(本期默认 false) STATISTIC_ENABLE_OLAP_DUAL_WRITE=false STATISTIC_ENABLE_REALTIME_CHANNEL=false STATISTIC_ENABLE_SDK_ENDPOINT=false ``` ### 5.6 部署顺序 1. **数据库迁移** - 先执行 10 个 SQL 迁移文件 2. **proto 编译** - `protoc` 生成 Go 代码 3. **服务构建** - `go build` statisticService 4. **gateway 路由注册** - 修改 `router.go`,加 7 个 dashboard 路由 5. **前端开关** - `frontend/utils/api.js` 顶部 `USE_MOCK_API = false` 6. **联调测试** - 7 个端点 e2e 通过 7. **切流量** - 灰度切换(先 10% 流量,再 100%) ### 5.7 业务侧集成清单(其他服务需要做的事) 需要调用 TrackEvent 的服务: | 服务 | 上报事件 | 集成位置 | |------|---------|---------| | **socialService** | `asset.like` | LikeAsset() 写 like_income_log 后 | | **galleryService** | `exhibition.start`, `exhibition.end` | StartExhibition() / EndExhibition() 后 | | **taskService** | `exhibition.revenue` | 定时任务派发收益后 | | **assetService** | `asset.mint`, `asset.level_up` | MintAsset() / LevelUp() 后 | | **userService** | `crystal.change` | 任何水晶变动后 | **集成方式:** 各服务加 `statisticClient.TrackEvent()`,fire-and-forget 异步调用,失败 log 但不影响主业务。 ### 5.8 灰度发布策略 | 阶段 | 范围 | 验证 | |------|------|------| | **Stage 1** | statisticService 内部测试 | 单元测试 + 集成测试 | | **Stage 2** | 网关联调(后端) | 7 个 RPC 端到端通 | | **Stage 3** | 前端切 10% 流量 | 监控看板 7 个数据准确性 | | **Stage 4** | 前端 100% 流量 | 监控稳定性 | | **Stage 5** | 业务侧集成(其他服务) | TrackEvent 上报,验证物化视图刷新 | | **Stage 6** | 移除 mock 依赖 | 关闭前端 USE_MOCK_API | --- ## 六、风险、后续与验收 ### 6.1 已知风险(16 个) | # | 风险 | 影响 | 概率 | 缓解措施 | |---|------|------|------|---------| | 1 | **粉丝身份越权** | 看到别人数据 | 低 | Gateway 校验 fan_profile | | 2 | **JSONB 注入** | DB 注入 / XSS | 中 | jsonb_build_object + 前端不直渲 | | 3 | **MV 刷新阻塞** | 看板延迟 | 低 | REFRESH CONCURRENTLY | | 4 | **事件表爆炸** | 磁盘满 | 中 | 按日分区 + 30 天清理 | | 5 | **week_rank 数据延迟** | 用户困惑 | 中 | UI 提示"5 分钟前更新" | | 6 | **分区缺失** | INSERT 失败 | 低 | Worker 自动创建 | | 7 | **多服务同时上报** | Channel 满 | 中 | Level 1 监控 + 告警 | | 8 | **gateway 路由冲突** | 其他路径异常 | 低 | 先查现有路由 | | 9 | **前端 30 分钟缓存** | 数据新鲜度 | 低 | 强制刷新 + 下拉刷新 | | 10 | **跨时区错位** | 看板日期/排名错误 | **中-高** | `received_at` 统一存 UTC,查询时 `AT TIME ZONE 'Asia/Shanghai'` | | 11 | **冷启动缓存穿透** | 启动瞬间 DB 压力大 | **中** | 启动时预热 7 个 RPC 缓存;首屏请求合并 | | 12 | **慢 SQL 锁表** | 写入阻塞 | **中** | REFRESH CONCURRENTLY 用 SHARE UPDATE EXCLUSIVE 锁;避开业务高峰 | | 13 | **TrackEvent 失败永久丢失** | 数据缺失 | **中-高** | 业务方本地 retry 队列;statisticService 落库前不返回成功 | | 14 | **proto 字段升级兼容** | 业务方调用报错 | **中** | 字段一律 optional / 带默认值;废弃字段保留 N 版本 | | 15 | **单用户大量事件**(脚本/爬虫) | Channel 倾斜 | **中** | 按 user_id 限流(每 user 100 QPS) | | 16 | **服务启动依赖** | 启动期 TrackEvent 失败 | **低** | 业务方重试;statisticService 启动不依赖其他服务 | ### 6.2 后续可扩展(不在本期范围) | 扩展项 | 触发条件 | 工作量 | 优先级 | |--------|---------|--------|--------| | **多赛季切换** | 赛季结束归档 | 1 周 | P2 | | **赛季总览 Tab** | 上面完成 + UI | 1 周 | P2 | | **前端埋点 SDK** | 前端有团队接 | 2-3 周 | P2 | | **OLAP 双写 ClickHouse** | 事件量 > 1亿/天 | 2 周 | P3 | | **实时通道**(同步 TrackEvent) | 业务要求实时 | 1 周 | P3 | | **Channel 满载优化**(Level 2-5) | channel 满载告警频繁 | 1 周 | P3 | | **数据看板分享海报** | 运营要求 | 1 周 | P4 | | **7 日曲线交互**(点击柱子弹明细) | UI 要求 | 3 天 | P4 | | **升级提醒推送** | 业务要求 | 1 周 | P4 | | **本周 vs 上周对比** | 运营要求 | 1 周 | P4 | | **业务方自定义事件查询** | 业务方有强需求 | 2 周 | P4 | | **数据准确性核对工具** | 数据出现问题时 | 3 天 | 工具 | ### 6.3 验收清单(本期) **功能性:** - [ ] 7 个看板 RPC 全部联通,数据准确(对照前端 mock) - [ ] `week_rank` 完整实现,排名准确 - [ ] `ExhibitionIncomeSummary.top5` 排序正确 - [ ] `Get7DayIncomeCurve.points[].is_peak` 标识正确 - [ ] TrackEvent 接收 + 去重 + 批量 - [ ] 物化视图刷新成功(4 个 MV + 3 个 metric 表: 2 个由 Worker 定时刷新 + 1 个由 TrackEvent 同步触发) - [ ] 预聚合表更新正确(recent_level_ups / upcoming_level_ups) - [ ] JWT 鉴权 + 粉丝身份校验 - [ ] 跨时区处理(Asia/Shanghai 转换) **性能:** - [ ] 看板单 RPC P99 < 200ms - [ ] 7 RPC 并发 P99 < 500ms - [ ] TrackEvent P99 < 50ms - [ ] 物化视图刷新 < 30s/视图 - [ ] 缓存命中率 > 80% - [ ] 冷启动不雪崩(预热 + 缓存穿透防护) **可靠性:** - [ ] 单元测试覆盖率 > 80% - [ ] 集成测试覆盖 100% RPC - [ ] Channel 满载不阻塞业务 - [ ] MV 刷新失败有告警 - [ ] partition 自动创建/清理 - [ ] 服务异常时不丢关键看板数据 - [ ] TrackEvent 失败业务方有 retry **可观测性:** - [ ] Prometheus 指标完整(20+ 指标) - [ ] 健康检查端点 - [ ] refresh_log 完整记录 - [ ] 关键告警规则配置(10 条) **集成:** - [ ] Gateway 路由注册正确(7 个 dashboard 路由) - [ ] 前端 `USE_MOCK_API = false` 切换 - [ ] 业务侧 5 个服务集成(TrackEvent 上报) - [ ] Dubbo 服务注册成功(20009) - [ ] 数据库迁移脚本可重放(10 个 SQL) **安全:** - [ ] 粉丝身份越权防护 - [ ] JSONB 注入防护 - [ ] 限流配置生效 - [ ] CORS 配置(预留 SDK 时) ### 6.4 文档维护 | 文档 | 位置 | 维护人 | |------|------|--------| | **本设计文档** | `docs/superpowers/specs/2026-06-04-statistic-service-design.md` | 后端主程 | | **API 文档(Swagger)** | `http://gateway:8080/swagger/index.html` | 自动生成 | | **事件类型注册表** | `backend/services/statisticService/docs/EVENT_TYPES.md` | 后端 + 业务方 | | **运维手册** | `backend/services/statisticService/docs/RUNBOOK.md` | 运维 | | **常见问题 FAQ** | `backend/services/statisticService/docs/FAQ.md` | 后端 | | **更新日志 CHANGELOG** | `backend/services/statisticService/CHANGELOG.md` | 后端 | **事件类型新增流程:** 1. 业务方提 PR 修改 `EVENT_TYPES.md` 2. 后端 review 3. 合并后业务方按规范上报 ### 6.5 不在本期范围(明确排除) - 多赛季历史数据 - 数据看板分享海报 - 7 日曲线点击弹窗 - 升级提醒推送 - 本周 vs 上周对比 - 前端埋点 SDK 实现(只预留后端 HTTP 端点) - 业务方自定义事件查询 API - ClickHouse 集成(只预留开关和 events 表设计) - BI 报表工具集成 - 实时数据流(Flink/Kafka) --- **文档结束。**