# FinOps Agent Platform — Phase 2 任务拆解

Phase 2 目标：**订阅推送 + 报告**。完成后用户可以创建定期报告订阅，系统按频率自动查询 BigQuery 生成 HTML 报告，通过 Web 通知中心提醒用户查看历史报告。

**前置文档**: [FinOps Agent 平台设计](./finops-agent-platform-design.md)
**前置完成**: Phase 1（PostgreSQL + Prisma + 用户系统 + 会话持久化 + 图表可视化 + 导出增强）

**验收标准**: 用户订阅后 → 按频率自动生成报告 → Web 通知提醒 → 可查看历史报告

---

## 依赖关系

```
Step 1: Subscription CRUD (数据模型 + API + 前端)
  │
  ├──→ Step 2: Report SQL Templates (SQL 模板 + 模板引擎)
  │      │
  │      └──→ Step 3: Report Generation Pipeline (依赖 Subscription + SQL 模板)
  │             │
  │             ├──→ Step 4: HTML Report Rendering (依赖报告数据)
  │             │
  │             └──→ Step 5: Notification System (依赖报告生成事件)
  │                    │
  │                    └──→ Step 6: Cron Scheduling + Integration (依赖全链路)
  │
  └──→ Step 5: Notification System (通知中心 UI 可与 Step 2/3 并行)
```

**可并行**: Step 2（SQL 模板）和 Step 5 的前端通知中心 UI 不互相依赖，可与 Step 1 完成后同时开发。Step 4（HTML 渲染）和 Step 5（通知系统）在 Step 3 完成后可并行开发。

---

## Step 1: Subscription CRUD（数据模型 + API + 前端）

**目标**: 实现订阅的完整 CRUD，用户可在 Web 端创建、编辑、启停、删除报告订阅。

### 任务清单

- [ ] **1.1** 添加 Prisma Migration — Subscription + Report + Notification 模型
  - **Scope**: 仅添加 Phase 2 所需的 3 个模型，不含 UserProfile / AnomalyRule（Phase 3）
  - Subscription: 过滤条件（accountIds, services, regions）、频率、模板 ID、通知渠道
  - Report: 关联 Subscription，存储生成状态、参数、数据、HTML
  - **Notification**: 使用 `sourceType` + `sourceId` 通用多态模式（替代 alertId/reportId 多列方案）:
    ```prisma
    model Notification {
      id         String   @id @default(cuid())
      userId     String   @map("user_id")
      channel    String   // 'web' | 'feishu'
      type       String   // 'report' | 'anomaly' | 'system'
      title      String
      body       String
      read       Boolean  @default(false)
      sentAt     DateTime? @map("sent_at")

      // 通用多态关联 — 替代 alertId/reportId 多列方案
      sourceType String?  @map("source_type")  // 'report' | 'alert' | null
      sourceId   String?  @map("source_id")    // 关联记录的 ID

      createdAt  DateTime @default(now()) @map("created_at")
      user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)

      @@index([userId, read, createdAt(sort: Desc)])
      @@index([channel, sentAt])
      @@index([sourceType, sourceId])
      @@map("notifications")
    }
    ```
    优势: Phase 3 新增 AnomalyAlert 时无需 migration 修改 Notification 表
  - `npx prisma migrate dev --name add_subscription_report_notification`
- [ ] **1.2** 定义 zod 校验 Schema
  - `data-agent/src/lib/validators/subscription.ts`
  - `CreateSubscriptionSchema`: name, accountIds, services, regions, frequency, templateId, channels, timezone
  - `UpdateSubscriptionSchema`: 所有字段 optional（partial）
  - frequency 枚举: `z.enum(['daily', 'weekly', 'monthly'])`
  - templateId 枚举: `z.enum(['cost_summary', 'service_breakdown', 'trend_analysis', 'savings_report', 'tag_compliance'])`
  - channels 数组: `z.array(z.enum(['web', 'feishu']))`
- [ ] **1.3** 实现 `GET /api/subscriptions`
  - 返回当前用户的订阅列表（按 updatedAt 降序）
  - 包含每个订阅的最近一次 Report 状态（lastReportStatus, lastReportAt）
  - 使用 `getCurrentUser` 鉴权
- [ ] **1.4** 实现 `POST /api/subscriptions`
  - 使用 zod 校验请求体
  - 创建 Subscription，关联当前 userId
  - 默认 enabled = true
  - 返回创建的订阅（含 id）
- [ ] **1.5** 实现 `PATCH /api/subscriptions/[id]`
  - 使用 zod 校验（partial schema）
  - 校验 ownership（subscription.userId === currentUser.id）
  - 支持更新: name, enabled, accountIds, services, regions, frequency, templateId, channels, timezone
- [ ] **1.6** 实现 `DELETE /api/subscriptions/[id]`
  - 校验 ownership
  - 级联删除关联的 Report 和 Notification（Prisma onDelete: Cascade）
- [ ] **1.7** 实现订阅管理页面
  - `data-agent/src/app/subscriptions/page.tsx` — 订阅列表页
  - 展示: 订阅名称、模板类型、频率、启用状态、最近报告时间
  - 操作: 新建、编辑、启停 toggle、删除
  - 空状态引导: "创建你的第一个报告订阅"
- [ ] **1.8** 实现订阅创建/编辑表单
  - `data-agent/src/components/subscription-form.tsx`
  - 表单字段: 名称、报告模板（下拉）、频率（radio）、账户选择（多选）、服务选择（多选）、区域选择（多选）、通知渠道（checkbox）、时区
  - 模板选择后展示模板说明和适用频率
  - 账户列表从 BigQuery account_mapping 动态加载（复用现有 BigQuery 查询能力）
- [ ] **1.9** 添加导航入口
  - 在主布局侧边栏或顶部导航添加 "订阅管理" 入口
  - 使用 Lucide 图标: `Bell` 或 `CalendarClock`

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/prisma/schema.prisma` | 修改 — 添加 Subscription, Report, Notification 模型 |
| `data-agent/prisma/migrations/` | 新建 — Migration 文件 |
| `data-agent/src/lib/validators/subscription.ts` | 新建 — zod 校验 Schema |
| `data-agent/src/app/api/subscriptions/route.ts` | 新建 — 订阅列表 + 创建 |
| `data-agent/src/app/api/subscriptions/[id]/route.ts` | 新建 — 订阅更新 + 删除 |
| `data-agent/src/app/subscriptions/page.tsx` | 新建 — 订阅管理页面 |
| `data-agent/src/components/subscription-form.tsx` | 新建 — 订阅表单组件 |
| `data-agent/src/app/layout.tsx` 或导航组件 | 修改 — 添加导航入口 |

### 验证标准

- [ ] `npx prisma migrate dev` 执行成功，3 个新表已创建
- [ ] `POST /api/subscriptions` 创建订阅 → 返回 201 + 订阅 JSON
- [ ] `GET /api/subscriptions` 返回当前用户的订阅列表（不返回其他用户数据）
- [ ] `PATCH /api/subscriptions/[id]` 可更新频率、启用状态等
- [ ] `DELETE /api/subscriptions/[id]` 删除订阅 + 级联删除关联数据
- [ ] 尝试操作其他用户的订阅 → 返回 403
- [ ] 未登录状态访问 → 返回 401
- [ ] 订阅管理页面正常展示列表、创建、编辑、删除
- [ ] zod 校验: 传入非法 frequency → 返回 400 + 具体错误
- [ ] `make lint` && `make build` 通过

---

## Step 2: Report SQL Templates（SQL 模板 + 模板引擎）

**目标**: 实现 5 个报告模板的 SQL 定义和参数化查询引擎，为报告生成提供数据层支撑。

### 任务清单

- [ ] **2.1** 定义模板注册表
  - `data-agent/src/lib/report/templates.ts`
  - 每个模板定义: id, name, description, supportedFrequencies, requiredParams, sqlTemplate
  - 导出 `REPORT_TEMPLATES` 常量 Map
  - 提供 `getTemplate(templateId)` 查询函数
- [ ] **2.2** 实现 `cost_summary` 模板 SQL
  - 按月/按账户汇总: billing_period_month, account_id, account_display_name, actual_cost, list_cost, savings_rate
  - 参数: period_start, period_end, account_ids (可选), services (可选)
  - 数据源: `finops_cloud_billing.mart_cost_summary`
  - 包含环比计算: 对比上期同维度数据
- [ ] **2.3** 实现 `service_breakdown` 模板 SQL
  - 按服务/区域明细: service, region, actual_cost, percentage, cost_rank
  - 包含 Top N 服务排名 + "其他" 汇总行
  - 数据源: `finops_cloud_billing.mart_cost_monthly_by_region_service`
- [ ] **2.4** 实现 `trend_analysis` 模板 SQL
  - 时间序列: 最近 6 个月按月汇总 + 环比 (MoM) + 同比 (YoY)
  - 输出: billing_period_month, total_cost, mom_change_pct, yoy_change_pct
  - 按账户维度和总计两个层级输出
- [ ] **2.5** 实现 `savings_report` 模板 SQL
  - SP/RI 覆盖率: pricing_model 维度分组，OnDemand vs Reserved vs SavingsPlan
  - 节省金额: list_cost - actual_cost
  - 整体 savings_rate
  - 数据源: `finops_cloud_billing.mart_cost_summary` (pricing_model 维度)
- [ ] **2.6** 实现 `tag_compliance` 模板 SQL
  - 标签覆盖率: 有 tenant 标签行数 / 总行数
  - 按账户/服务维度展示缺失率
  - 数据源: `finops_cloud_billing.mart_cost_monthly_by_service` 或相关 mart
- [ ] **2.7** 实现 SQL 参数填充引擎
  - `data-agent/src/lib/report/sql-engine.ts`
  - 接收: templateId, parameters (date range, filters)
  - 输出: 填充参数后的完整 SQL
  - 使用 BigQuery 参数化查询（`@param` 占位符），防止注入
  - 处理可选参数（accountIds 为空时不加过滤条件）
- [ ] **2.8** 实现报告周期计算工具
  - `data-agent/src/lib/report/period.ts`
  - `calculateReportPeriod(frequency, referenceDate, timezone)` → { periodStart, periodEnd }
  - daily: 前一天 00:00 ~ 23:59
  - weekly: 上周一 ~ 上周日
  - monthly: 上月 1 日 ~ 上月最后一天
  - 正确处理时区（Asia/Shanghai = CST）

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/src/lib/report/templates.ts` | 新建 — 模板注册表 + 5 个 SQL 模板 |
| `data-agent/src/lib/report/sql-engine.ts` | 新建 — SQL 参数填充 + BigQuery 执行 |
| `data-agent/src/lib/report/period.ts` | 新建 — 报告周期计算 |
| `data-agent/src/lib/report/index.ts` | 新建 — 模块导出入口 |

### 验证标准

- [ ] `getTemplate('cost_summary')` 返回完整模板定义
- [ ] `getTemplate('invalid')` 返回 null 或抛出明确错误
- [ ] `calculateReportPeriod('weekly', new Date('2026-03-10'), 'Asia/Shanghai')` 返回上周一到周日
- [ ] `calculateReportPeriod('monthly', new Date('2026-03-10'), 'Asia/Shanghai')` 返回 2026-02-01 ~ 2026-02-28
- [ ] 各模板 SQL 可在 BigQuery 中直接执行（手动验证）
- [ ] SQL 参数填充正确处理空数组（不加 WHERE 条件）
- [ ] `make lint` && `make build` 通过

---

## Step 3: Report Generation Pipeline（报告生成管道）

**目标**: 实现报告生成核心管道 — 从 Subscription 出发，执行 SQL 查询，存储结果数据，管理生成状态。

### 任务清单

- [ ] **3.1** 实现报告生成核心函数
  - `data-agent/src/lib/report/generator.ts`
  - `generateReport(subscriptionId)` — 单个订阅的报告生成全流程:
    1. 读取 Subscription 配置
    2. 计算报告周期（periodStart, periodEnd）
    3. 创建 Report 记录（status: 'pending'）
    4. 更新 status → 'generating'
    5. 根据 templateId 获取 SQL 模板
    6. 填充参数（accountIds, services, regions, date range）
    6.5. **BigQuery dry-run 成本预检**
       - 执行 dry-run 获取预估扫描量
       - 单次查询上限 10GB，超限标记 Report 为 failed + 错误消息 "查询扫描量过大"
       - 每日累计上限 500GB（所有 cron 查询合计）
       - 实现: `data-agent/src/lib/bigquery-budget.ts`（Phase 3 复用）
    7. 执行 BigQuery 查询
    8. 存储查询结果到 Report.data (JSON)
    9. 更新 status → 'completed', generatedAt = now()
    10. 错误处理: status → 'failed', errorMessage 记录错误详情
  - 返回生成的 Report 对象
- [ ] **3.2** 实现批量报告生成函数
  - `generateReportsForFrequency(frequency)` — 查询所有 enabled + 指定 frequency 的 Subscription
  - 检查是否已有当期 Report（去重: 同一 Subscription + 同一 periodStart 不重复生成）
  - 逐个调用 `generateReport`，记录成功/失败统计
  - 返回 `{ total, success, failed, skipped }` 统计
- [ ] **3.3** 实现 `GET /api/reports`
  - 返回当前用户的报告列表（通过 Subscription.userId 关联）
  - 支持分页: `?page=1&limit=20`
  - 支持过滤: `?subscriptionId=xxx&status=completed`
  - 按 createdAt 降序排列
  - 列表项包含: id, templateId, status, periodStart, periodEnd, generatedAt, subscription.name
- [ ] **3.4** 实现 `GET /api/reports/[id]`
  - 返回报告详情，包含 data (JSON) 和 htmlContent
  - 校验 ownership（报告关联的 Subscription.userId === currentUser.id）
  - 如果 status !== 'completed'，返回当前状态（pending/generating/failed + errorMessage）
- [ ] **3.5** 实现 `POST /api/reports/generate`
  - 手动触发报告生成（用于测试和按需生成）
  - 请求体: `{ subscriptionId: string }`
  - 校验 ownership
  - 调用 `generateReport(subscriptionId)`
  - 返回生成的 Report
- [ ] **3.6** 添加 zod 校验
  - `data-agent/src/lib/validators/report.ts`
  - `ReportQuerySchema`: page, limit, subscriptionId, status
  - `GenerateReportSchema`: subscriptionId

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/src/lib/report/generator.ts` | 新建 — 报告生成核心逻辑 |
| `data-agent/src/app/api/reports/route.ts` | 新建 — 报告列表 |
| `data-agent/src/app/api/reports/[id]/route.ts` | 新建 — 报告详情 |
| `data-agent/src/app/api/reports/generate/route.ts` | 新建 — 手动触发生成 |
| `data-agent/src/lib/validators/report.ts` | 新建 — zod 校验 |
| `data-agent/src/lib/bigquery-request.ts` | 修改 — 提取可复用的 BigQuery 查询函数 |

### 验证标准

- [ ] 创建 Subscription → `POST /api/reports/generate` → Report status 变为 'completed'
- [ ] Report.data 包含正确的 BigQuery 查询结果（JSON 格式）
- [ ] BigQuery 查询失败 → Report status 变为 'failed' + errorMessage 记录错误
- [ ] `GET /api/reports` 返回当前用户的报告列表（分页正确）
- [ ] `GET /api/reports/[id]` 返回完整报告数据
- [ ] 重复触发同一 Subscription 的同一期报告 → 跳过（不重复生成）
- [ ] 非 owner 访问报告 → 403
- [ ] `make lint` && `make build` 通过

---

## Step 4: HTML Report Rendering（HTML 渲染 + 飞书卡片）

**目标**: 将报告数据渲染为可阅读的 HTML 页面，支持 Web 查看和飞书消息卡片格式化。

### 任务清单

- [ ] **4.1** 实现 HTML 模板引擎
  - `data-agent/src/lib/report/html-renderer.ts`
  - `renderReportHTML(templateId, data, metadata)` → HTML string
  - 使用模板字符串或轻量模板引擎（如内联 JSX → renderToString）
  - 基础 HTML 结构: 标题、摘要统计、数据表格、图表（SVG）、页脚
  - 内联 CSS 样式（邮件/飞书兼容，不依赖外部样式表）
  - **图表渲染**: 使用 ECharts SVG 服务端渲染（`echarts/ssr`）
    - `data-agent/src/lib/report/chart-ssr.ts` — ECharts SSR 封装
    - 使用 `echarts.init(null, null, { ssr: true, renderer: 'svg' })` 生成 SVG 字符串
    - 不依赖 `node-canvas` 或 `puppeteer`，纯 Node.js 环境运行
    - 生成的 SVG 直接嵌入 HTML 模板（内联，无外部依赖）
    - 支持趋势图（折线）、占比图（饼图/柱状图）等常见报告图表
- [ ] **4.2** 实现 `cost_summary` HTML 模板
  - 摘要区: 总费用、环比变化、节省金额
  - 数据表格: 按账户维度展示费用明细
  - 高亮: 环比增长 > 20% 标红、节省率 > 30% 标绿
  - 底部注释: 报告期间、数据源、生成时间
- [ ] **4.3** 实现 `service_breakdown` HTML 模板
  - 摘要区: Top 5 服务 + 占比
  - 数据表格: 服务、区域、费用、占比
  - 可选: 内联 SVG 饼图（简单占比可视化）
- [ ] **4.4** 实现 `trend_analysis` HTML 模板
  - 摘要区: 最新月份费用 + 整体趋势描述
  - 数据表格: 月份、费用、MoM 变化、YoY 变化
  - 趋势指标: 箭头 + 百分比，增长标红、下降标绿
- [ ] **4.5** 实现 `savings_report` HTML 模板
  - 摘要区: 整体节省金额、整体 savings_rate
  - 分类明细: OnDemand / Reserved / SavingsPlan 占比
  - 建议区: 基于 SP/RI 覆盖率给出文字建议
- [ ] **4.6** 实现 `tag_compliance` HTML 模板
  - 摘要区: 整体标签覆盖率、不合规费用金额
  - 数据表格: 按账户/服务的缺失率排名
  - 高亮: 缺失率 > 10% 标红
- [ ] **4.7** 集成 HTML 渲染到报告生成管道
  - 修改 `generator.ts`: BigQuery 查询成功后，调用 `renderReportHTML` 生成 HTML
  - 存储 htmlContent 到 Report 记录
  - 确保 status 更新顺序: pending → generating → completed（含 data + htmlContent）
- [ ] **4.8** 实现报告查看页面
  - `data-agent/src/app/reports/page.tsx` — 报告列表页
  - 展示: 报告名称（来源订阅名）、模板类型、状态、报告期间、生成时间
  - 操作: 查看详情、重新生成（failed 状态）
- [ ] **4.9** 实现报告详情页
  - `data-agent/src/app/reports/[id]/page.tsx` — 报告详情页
  - 直接嵌入渲染 htmlContent（使用 dangerouslySetInnerHTML 或 iframe sandbox）
  - 顶部元信息: 报告名称、期间、生成时间、状态
  - 操作: 返回列表、下载 HTML
- [ ] **4.10** 实现飞书消息卡片格式化（Phase 3 调用，Phase 2 先实现接口）
  - `data-agent/src/lib/report/feishu-card.ts`
  - `formatReportCard(templateId, data, metadata)` → 飞书交互卡片 JSON
  - 仅包含摘要数据（飞书卡片有长度限制）
  - 添加 "查看完整报告" 按钮，链接到 Web 报告详情页

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/src/lib/report/html-renderer.ts` | 新建 — HTML 模板引擎 + 5 个模板 |
| `data-agent/src/lib/report/chart-ssr.ts` | 新建 — ECharts SVG 服务端渲染 |
| `data-agent/src/lib/report/feishu-card.ts` | 新建 — 飞书卡片格式化（接口占位） |
| `data-agent/src/lib/report/generator.ts` | 修改 — 集成 HTML 渲染步骤 |
| `data-agent/src/app/reports/page.tsx` | 新建 — 报告列表页 |
| `data-agent/src/app/reports/[id]/page.tsx` | 新建 — 报告详情页 |

### 验证标准

- [ ] `renderReportHTML('cost_summary', sampleData, metadata)` 返回合法 HTML
- [ ] HTML 在浏览器中直接打开 → 表格和样式正确展示
- [ ] 环比增长 > 20% 的行正确标红
- [ ] 报告生成完成后 Report.htmlContent 非空
- [ ] 报告详情页正确展示 HTML 内容
- [ ] 报告列表页展示当前用户的所有报告
- [ ] failed 状态的报告显示错误信息并提供重新生成按钮
- [ ] HTML 内联样式，无外部依赖（在飞书 WebView 中也能正常展示）
- [ ] `formatReportCard` 返回合法飞书卡片 JSON 结构
- [ ] `make lint` && `make build` 通过

---

## Step 5: Notification System（Web 通知中心）

**目标**: 实现应用内 Web 通知系统，报告生成完成后自动创建通知，用户可在通知中心查看和管理通知。

### 任务清单

- [ ] **5.1** 实现 NotificationHub 事件中枢
  - `data-agent/src/lib/notification-hub.ts`
  - 基于 Node.js `EventEmitter` 实现进程内事件总线:
    ```typescript
    // 事件契约
    interface NotificationEvent {
      type: 'report:completed' | 'report:failed' | 'anomaly:detected'
      userId: string
      channels: ('web' | 'feishu')[]
      title: string
      body: string
      sourceType: 'report' | 'alert'
      sourceId: string
      payload?: Record<string, unknown>
    }

    class NotificationHub extends EventEmitter {
      notify(event: NotificationEvent): void {
        // 1. 创建 Notification 记录（web 渠道）
        // 2. 如果 channels 含 'feishu'，调用飞书推送（Phase 3 实现）
        // 3. emit 事件供其他模块监听
      }
    }
    ```
  - Phase 2 使用 `EventEmitter`（单进程），Phase 3 可切换为 Redis Pub/Sub（跨进程，feishu-worker）
  - `createReportNotification(report, subscription)` — 报告完成时调用
    - 根据 subscription.channels 决定创建 web / feishu 通知
    - title: `报告已生成: ${subscription.name}`
    - body: 包含报告期间和关键摘要数据
  - `notifySubscriptionOwner(subscription, report)` — 封装通知创建 + 渠道分发
- [ ] **5.2** 实现 `GET /api/notifications`
  - 返回当前用户的通知列表
  - 支持分页: `?page=1&limit=20`
  - 支持过滤: `?read=false` (仅未读), `?type=report`
  - 按 createdAt 降序排列
  - 返回未读总数: `unreadCount`
- [ ] **5.3** 实现 `PATCH /api/notifications/[id]`
  - 标记单条通知已读: `{ read: true }`
  - 校验 ownership
- [ ] **5.4** 实现 `POST /api/notifications/read-all`
  - 批量标记当前用户所有未读通知为已读
  - 返回更新数量: `{ updated: number }`
- [ ] **5.5** 实现通知中心 UI — Bell 图标 + 下拉面板
  - `data-agent/src/components/notification-bell.tsx`
  - 顶部导航栏的 Bell 图标（Lucide `Bell`）
  - 未读数 badge（红色圆点或数字）
  - 点击展开下拉面板: 最近 10 条通知
  - 每条通知: 类型图标、标题、时间、已读/未读状态
  - 点击通知 → 跳转到关联页面（报告详情页等）
  - "全部标记已读" 按钮
  - "查看全部" 链接 → 通知列表页
- [ ] **5.6** 实现通知列表页
  - `data-agent/src/app/notifications/page.tsx`
  - 完整的通知列表（分页）
  - 筛选: 全部 / 未读 / 报告 / 异常 / 系统
  - 批量操作: 全部标记已读
- [ ] **5.7** 实现通知轮询（或 SSE）
  - 客户端定时轮询 `GET /api/notifications?read=false&limit=1` 获取未读数
  - 轮询间隔: 60 秒（低频，足够报告级通知）
  - 可选: 使用 Server-Sent Events (SSE) 替代轮询（性能更好，但实现复杂度高）
  - 封装为 React Hook: `useNotificationCount()`
- [ ] **5.8** 集成通知到报告生成管道
  - 修改 `generator.ts`: 报告生成成功后调用 `notifySubscriptionOwner`
  - 报告失败时也创建通知（type: 'system', body 包含错误摘要）

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/src/lib/notification-hub.ts` | 新建 — NotificationHub 事件中枢 + 通知分发逻辑 |
| `data-agent/src/app/api/notifications/route.ts` | 新建 — 通知列表 |
| `data-agent/src/app/api/notifications/[id]/route.ts` | 新建 — 标记已读 |
| `data-agent/src/app/api/notifications/read-all/route.ts` | 新建 — 批量已读 |
| `data-agent/src/components/notification-bell.tsx` | 新建 — 通知铃铛 + 下拉面板 |
| `data-agent/src/hooks/use-notification-count.ts` | 新建 — 未读数轮询 Hook |
| `data-agent/src/app/notifications/page.tsx` | 新建 — 通知列表页 |
| `data-agent/src/lib/report/generator.ts` | 修改 — 生成完成后创建通知 |
| `data-agent/src/app/layout.tsx` 或导航组件 | 修改 — 添加 NotificationBell |

### 验证标准

- [ ] 报告生成成功 → 自动创建 web 通知（channel: 'web', type: 'report'）
- [ ] 报告生成失败 → 自动创建系统通知（type: 'system'）
- [ ] `GET /api/notifications` 返回当前用户的通知列表 + unreadCount
- [ ] `GET /api/notifications?read=false` 仅返回未读通知
- [ ] `PATCH /api/notifications/[id]` 标记已读 → read 变为 true
- [ ] `POST /api/notifications/read-all` → 所有未读变为已读
- [ ] Bell 图标展示未读数 badge
- [ ] 点击通知 → 跳转报告详情页
- [ ] 通知轮询 60 秒刷新一次未读数
- [ ] 未登录状态 → 401
- [ ] `make lint` && `make build` 通过

---

## Step 6: Cron Scheduling + Integration（定时调度 + 集成测试）

**目标**: 实现外部 Cron 触发的定时报告生成端点，完成全链路集成验证。

### 任务清单

- [ ] **6.1** 实现 `POST /api/cron/generate-reports`
  - **安全鉴权**: HMAC 请求签名（替代简单 Bearer token）
    - 请求头: `X-Cron-Timestamp` + `X-Cron-Signature`
    - 签名: `HMAC-SHA256(timestamp + requestBody, CRON_SECRET)`
    - 验证: timestamp 在 5 分钟内 + 签名匹配
    - 实现: `data-agent/src/lib/cron-auth.ts`（Phase 3 复用）
  - **幂等性检查**:
    - 记录每次执行到 `cron_execution_log`（内存 Map 或 DB 表）
    - 同一 frequency + 同一小时内重复调用 → 返回上次结果，不重复执行
  - **速率限制**: 每小时最多 3 次调用（防止误配置的外部 cron 轰炸）
  - 请求体: `{ frequency: 'daily' | 'weekly' | 'monthly' }` (可选，不传则按当前时间推断)
  - 推断逻辑:
    - 每天调用: 检测今天是否是周一（触发 weekly）、是否是月初 3 号（触发 monthly），以及 daily
  - 调用 `generateReportsForFrequency(frequency)`
  - 返回执行统计: `{ frequency, total, success, failed, skipped, duration_ms }`
  - 超时保护: 单次请求最多处理 50 个订阅，超出分批
- [ ] **6.2** 添加 CRON_SECRET 环境变量
  - `data-agent/.env.example` 添加 `CRON_SECRET`
  - `docker-compose.yml` 添加 `CRON_SECRET` 环境变量
  - 文档说明: 生产环境必须设置强密码
- [ ] **6.3** 配置 docker-compose cron 服务（开发环境）
  - 添加 `cron` 服务（基于 alpine + curl）
  - crontab 配置:
    - `0 9 * * * curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://data-agent:3000/api/cron/generate-reports`
  - 或使用 `ofelia` (Docker-native cron scheduler) 作为 sidecar
- [ ] **6.4** 添加 cron 请求日志
  - 每次 cron 触发记录: 时间、frequency、处理数量、成功/失败、耗时
  - 使用 `console.log` 结构化日志（JSON 格式）
  - 便于 `docker-compose logs -f data-agent` 监控
- [ ] **6.5** 实现健康检查端点
  - `GET /api/cron/health` — 返回最近一次 cron 执行状态
  - 查询最近一条 Report 的 createdAt，判断是否有延迟
  - 用于生产监控: 如果超过预期时间没有新报告 → 告警
- [ ] **6.6** 全链路集成验证
  - 手动验证流程:
    1. 创建 Subscription（cost_summary, weekly, 指定测试账户）
    2. 调用 `POST /api/reports/generate` 手动触发
    3. 检查 Report status → completed
    4. 检查 Report.data 包含正确查询结果
    5. 检查 Report.htmlContent 渲染正确
    6. 检查 Notification 已创建
    7. Web 通知中心 → 显示新通知
    8. 点击通知 → 跳转报告详情页 → HTML 正常展示
  - 模拟 cron 触发:
    1. `curl -X POST -H "Authorization: Bearer dev_cron_secret" -H "Content-Type: application/json" -d '{"frequency":"weekly"}' http://localhost:3000/api/cron/generate-reports`
    2. 验证批量生成正确
- [ ] **6.7** 编写集成测试脚本
  - `data-agent/scripts/test-phase2-integration.sh`
  - 自动化全链路验证（创建订阅 → 触发生成 → 检查结果）
  - 使用 curl + jq 验证 API 响应
- [ ] **6.8** 更新 Makefile
  - `make cron-test` — 手动触发 cron 端点（开发环境）
  - `make report-generate` — 手动为指定订阅生成报告

### 涉及文件

| 文件 | 操作 |
|------|------|
| `data-agent/src/app/api/cron/generate-reports/route.ts` | 新建 — Cron 触发报告生成 |
| `data-agent/src/app/api/cron/health/route.ts` | 新建 — 健康检查 |
| `data-agent/.env.example` | 修改 — 添加 CRON_SECRET |
| `docker-compose.yml` | 修改 — 添加 CRON_SECRET 环境变量、可选 cron 服务 |
| `data-agent/scripts/test-phase2-integration.sh` | 新建 — 集成测试脚本 |
| `data-agent/Makefile` | 修改 — 添加 cron/report 相关命令 |

### 验证标准

- [ ] `POST /api/cron/generate-reports` 无 Bearer token → 403
- [ ] `POST /api/cron/generate-reports` 带正确 token → 执行成功，返回统计
- [ ] cron 触发 weekly → 所有 enabled + frequency=weekly 的订阅生成报告
- [ ] 已有当期报告的订阅被跳过（skipped）
- [ ] 单个订阅生成失败不影响其他订阅（隔离错误）
- [ ] 全链路验证: 订阅 → 生成 → HTML → 通知 → 查看 → 完整流程通顺
- [ ] `GET /api/cron/health` 返回最近 cron 执行状态
- [ ] `docker-compose up -d --build` 完整启动成功（含 cron 配置）
- [ ] `make lint` && `make build` 通过

---

## 风险与注意事项

### 技术风险

| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| BigQuery 查询超时 | 报告生成失败，阻塞 cron 批次 | 单个查询设置 30s 超时，失败后标记 Report 为 failed，不阻塞后续 |
| 报告数据量过大 | Report.data (JSON) 存储成本高，PG 性能下降 | 限制查询返回行数（MAX 1000 行），JSON 压缩，定期清理 90 天前的 data 字段 |
| HTML 渲染 XSS 风险 | 报告详情页使用 dangerouslySetInnerHTML | 报告 HTML 由服务端模板生成（非用户输入），使用 DOMPurify 二次消毒 |
| cron 重复触发 | 同一期报告生成多次 | 通过 subscriptionId + periodStart 唯一约束去重 |
| Next.js API Route 超时 | Vercel/db9.ai 可能有 30s 请求超时限制 | 单次 cron 请求限制 50 个订阅，超出分批；大量订阅时考虑异步队列 |
| 通知轮询开销 | 大量用户同时轮询增加 DB 压力 | 60s 间隔已足够低频；未来可改 SSE 或 WebSocket |

### 开发注意事项

1. **Phase 1 前置**: Step 1 的 Prisma Migration 依赖 Phase 1 已完成的 PostgreSQL + Prisma 基础设施，确认 User, ChatSession, Message 模型已存在
2. **BigQuery 参数化查询**: 所有 SQL 模板必须使用 `@param` 参数化，严禁字符串拼接，防止 SQL 注入
3. **时区处理**: 报告周期计算必须正确处理 Asia/Shanghai 时区，特别是跨月和跨年边界
4. **Prisma 关联查询**: 获取报告列表时通过 `include: { subscription: true }` 获取订阅信息，避免 N+1 查询
5. **HTML 内联样式**: 报告 HTML 不可依赖外部 CSS 文件，所有样式必须内联（兼容飞书 WebView 和邮件客户端）
6. **CRON_SECRET 安全**: 生产环境必须设置高强度 secret，不可使用默认值 `dev_cron_secret`
7. **飞书卡片接口占位**: Step 4 中 feishu-card.ts 仅定义接口和数据结构，不实际调用飞书 API（Phase 3 实现）
8. **错误隔离**: cron 批量生成时，单个订阅失败不应影响其他订阅的处理
9. **Migration 部署**: 生产环境使用 `npx prisma migrate deploy`（不是 `migrate dev`）

---

## 完成后检查

- [ ] `make lint` 通过
- [ ] `make build` 通过
- [ ] `npx prisma migrate deploy` 在全新数据库上执行成功
- [ ] `docker-compose up -d --build` 完整启动成功
- [ ] 所有现有功能不受影响（Phase 1 回归测试: NL 查询、图表、会话、导出）
- [ ] 全链路验证通过: 创建订阅 → 手动触发生成 → 报告完成 → 通知到达 → 查看报告
- [ ] cron 模拟触发验证通过
- [ ] 新增 API 路由全部需要认证（session 或 Bearer token）
- [ ] 无敏感信息硬编码（CRON_SECRET 从环境变量读取）
- [ ] BigQuery SQL 全部使用参数化查询
- [ ] 报告 HTML 通过 DOMPurify 消毒或使用安全的模板引擎
- [ ] `data-agent/design/` 文档已更新

---

*Document Status: Draft*
*Created: 2026-03-10*
*Depends on: Phase 1 (finops-agent-phase1-tasks.md)*
