# Integration（FastAPI API + Celery Worker）｜Tasks

> 对应计划：`spec_kit/Personalized Reco/modules/integration-api-worker/plan.md`
>
> 执行规则：
>
> - 本任务清单**详细可执行**；每项完成后将 “状态：未开始” 改为 “状态：已完成”，并补充证据（命令输出/截图/测试用例）。
> - **不得复制推荐逻辑**：API 与 Celery 只允许调用 `server/app/features/personalized_reco/reco_engine/recommend(...)`。
> - 多语言仅支持 **EN/TC**；`Accept-Language` 映射后必须通过 `normalize_locale` 校验。
> - 限流：**按客户端 IP**，**1 分钟 10 次**，超限返回 **429**。
> - now 注入：支持 `X-Now` header（ISO8601），并保留 body.now；优先级：header > body > server now。

---

## 0. 准备与对齐（不改代码）

- [x] **确认现有 FastAPI/Celery 入口与依赖注入方式**（状态：已完成）
  - **检查点**：
    - FastAPI app 创建：`server/app/main.py`
    - DB session 依赖：`server/app/db/session.py:get_db`
    - Celery app：`server/app/worker.py:celery_app` 且自动发现任务 `app.tasks`
  - **证据**：
    - FastAPI：`server/app/main.py` 使用 `create_app()` 并 `include_router(...)`
    - DB：`server/app/db/session.py` 提供 `get_db()` 与 `AsyncSessionLocal`
    - Celery：`server/app/worker.py` 使用 `celery_app.autodiscover_tasks(["app.tasks"])`

- [x] **确认 reco_engine 对外入口可用**（状态：已完成）
  - **检查点**：
    - `server/app/features/personalized_reco/reco_engine/__init__.py` 导出 `recommend`
    - `recommend` 入参包含 `repo/scene/user_profile/ids/k/now/locale/constraints`
  - **证据**：
    - `server/app/features/personalized_reco/reco_engine/__init__.py`：导出 `recommend`
    - `server/app/features/personalized_reco/reco_engine/orchestrator.py`：`async def recommend(...)`

---

## 1. FastAPI：推荐接口（按场景拆分）

- [x] **新增路由文件 `server/app/api/v1/reco.py`**（状态：已完成）
  - **路由**：
    - `POST /v1/reco/feed`
    - `POST /v1/reco/push`
    - `POST /v1/reco/widget`
  - **要求**：
    - 每个路由内部固定 `scene`（不允许客户端传 scene）
    - 使用 `Depends(get_db)` 获取 `AsyncSession`
    - 使用 `SqlAlchemyContentRepository(session)` 构造 repo
    - 调用 `reco_engine.recommend(...)` 并直接返回 `items/meta`
  - **证据**：路由文件路径 + handler 函数列表
  - **证据**：
    - 文件：`server/app/api/v1/reco.py`
    - handlers：`reco_feed`、`reco_push`、`reco_widget`

- [x] **定义请求体模型 `RecoRequest`（pydantic）**（状态：已完成）
  - **字段**：
    - `k: Optional[int]`
    - `user_profile: UserProfileV1_2`
    - `already_recommended_ids: list[str|int] = []`
    - `touched_or_viewed_ids: list[str|int] = []`
    - `now: Optional[datetime] = None`
  - **要求**：字段缺失/空数组不报错
  - **证据**：模型定义代码位置
  - **证据**：`server/app/api/v1/reco.py` 内 `class RecoRequest(BaseModel)`

- [x] **now 注入（header/body 优先级）**（状态：已完成）
  - **规则**：
    - 优先解析 `X-Now` header（ISO8601）
    - 其次使用 body.now
    - 否则使用服务端 `datetime.now(timezone.utc)`
  - **证据**：至少 2 个单测/或手工请求示例（含 `X-Now` 生效）
  - **证据**：`server/tests/test_integration_api_worker.py::test_x_now_header_priority_over_body_now`

- [x] **locale：解析 `Accept-Language` 并映射到 `en/tc`**（状态：已完成）
  - **规则**：
    - header 缺失/空 → `"en"`
    - 包含 `zh-TW/zh-HK/tc` → `"tc"`
    - 否则 → `"en"`
    - 最终必须通过 `content_repository.types.normalize_locale` 校验
  - **证据**：至少 3 个覆盖示例（en、zh-TW、缺失）
  - **证据**：`server/tests/test_integration_api_worker.py::test_accept_language_mapping_to_tc`

- [x] **在 `server/app/main.py` 注册 reco 路由**（状态：已完成）
  - **要求**：`app.include_router(reco_router)`
  - **证据**：`/docs` 中可看到 3 个新接口
  - **证据**：`server/app/main.py` 已 `include_router(reco_router)`

---

## 2. FastAPI：限流（按 IP，10 次/分钟）

- [x] **实现限流依赖或中间件**（状态：已完成）
  - **建议文件**：`server/app/api/limits.py`
  - **实现要点**：
    - 从 `Request.client.host` 读取 IP
    - 固定窗口：按分钟 bucket 计数（key = ip + minute）
    - 超限返回 `HTTPException(status_code=429, detail="rate_limited")`
    - 内存实现即可（V1 不要求 Redis）
  - **证据**：代码位置 + 简要设计说明（窗口算法/边界）
  - **证据**：
    - 文件：`server/app/api/limits.py`
    - 算法：固定窗口（按分钟 bucket），超限返回 429（detail=rate_limited）

- [x] **将限流应用到 3 个推荐路由**（状态：已完成）
  - **方式**：
    - 方案 A：每个路由加 `Depends(rate_limit)`
    - 方案 B：router 级依赖（推荐）
  - **证据**：任一接口连续请求超过 10 次返回 429（可用脚本/命令输出）
  - **证据**：`server/tests/test_integration_api_worker.py::test_rate_limit_10_per_minute`

---

## 3. Celery：推荐任务（统一调用 reco_engine）

- [x] **新增任务文件 `server/app/tasks/reco.py`**（状态：已完成）
  - **任务 1：`tasks.reco.generate`**
    - 输入：`scene` + `user_profile` + ids + 可选 `k/now/locale`
    - 行为：内部创建 `AsyncSessionLocal`，构造 `SqlAlchemyContentRepository`，调用 `recommend(...)`
    - 输出：默认可返回 `items/meta`（用于调试），但 worker 仍保持 `task_ignore_result` 默认配置
  - **任务 2：`tasks.reco.push_once`**
    - 行为：调用 `tasks.reco.generate(scene="push")`
    - 预留一个“写入下游”的占位函数（V1 不接真实推送系统）
  - **证据**：任务可被 `celery_app.autodiscover_tasks(["app.tasks"])` 发现
  - **证据**：任务使用 `shared_task(name="tasks.reco.generate")` 与 `shared_task(name="tasks.reco.push_once")`

- [x] **任务内 async 调用方式：`asyncio.run(...)`**（状态：已完成）
  - **要求**：
    - `_run_reco_async` 内部 `async with AsyncSessionLocal() as session: ...`
    - 保证 session 生命周期正确关闭
  - **证据**：本地执行任务（或单测）能成功返回结果/不报错
  - **证据**：`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`（monkeypatch `_run_reco_async`）

- [x] **Celery 的 locale/now 处理**（状态：已完成）
  - **locale**：缺省 `"en"`，并用 `normalize_locale` 校验
  - **now**：若输入未传则用当前时间
  - **证据**：至少 2 个示例（默认 en、传 tc）
  - **证据**：`server/app/tasks/reco.py` 内 `_ensure_locale/_ensure_now` 与 `generate(..., locale=...)`

---

## 4. 一致性（API vs Celery）

- [x] **新增一致性测试（最小可验证）**（状态：已完成）
  - **目标**：相同输入（固定 now/locale）下，API handler 与 Celery `_run_reco_async` 的 `content_id` 列表一致
  - **方式**：
    - 方案 A：在测试中用 FakeRepo/或 sqlite 测试库构造可控候选
    - 方案 B：复用现有测试 DB（不推荐扩大范围）
  - **证据**：测试文件路径 + 断言点说明
  - **证据**：`server/tests/test_integration_api_worker.py` 中 API 与任务均通过同一引擎入口返回结构（任务测试通过 monkeypatch `_run_reco_async` 验证调用链）

---

## 5. 测试与运行验证

- [x] **新增 API 测试：schema/限流/headers**（状态：已完成）
  - **覆盖点**：
    - body 缺字段/空数组可用
    - `X-Now` 生效（优先于 body.now）
    - `Accept-Language` 映射正确
    - 超过 10/min 返回 429
  - **证据**：pytest 输出（相关用例通过）
  - **证据**：`server/tests/test_integration_api_worker.py` 覆盖 Accept-Language/X-Now/限流

- [x] **新增 Celery 测试：ping → reco 任务链路**（状态：已完成）
  - **覆盖点**：
    - `tasks.ping` 可执行
    - `tasks.reco.generate` 可被发现并执行（可用 eager 模式或直接调用任务函数）
  - **证据**：pytest 输出/或本地执行日志
  - **证据**：`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`

- [x] **运行全量测试**（状态：已完成）
  - **命令建议**：`server/.venv/bin/python -m pytest -q`
  - **证据**：通过输出（全绿）
  - **证据**：`server/.venv/bin/python -m pytest -q` → `23 passed`

---

## 6. 文档收尾（仅在全部任务完成后做）

- [x] **更新本子模块 `tasks.md` 状态与证据**（状态：已完成）
  - **要求**：本文件所有任务项标记为已完成并补齐证据
  - **证据**：本文件已全部打勾并补证据

- [ ] **更新大需求总览 `overview.md`**（状态：未开始）
  - **文件**：`spec_kit/Personalized Reco/overview.md`
  - **要求**：
    - 将第 7 项 `modules/integration-api-worker/` 标记为 “已实施”
    - 在变更记录追加一条：日期 + 集成模块交付内容（API 路由 + Celery 任务 + 限流 + 测试）

