# Clover 修改处理方案 · 第二批（🟡 做了一半）

> 现状代码均已逐行读核。每项含现状/改后/文件/风险/验收。

---

## 修6 · ai_call_logs 记录不全

**根因**：
- `pipeline_io.py:78-91` 只在文案后写1条，且 `prompt_tokens/completion_tokens` 从不填（httpx响应没解析usage）。
- `run_image_generation`(L122-216) 整个函数**无任何 AiCallLog 写入**——生图调用零留痕。

**现状代码**（pipeline_io.py:78-91，文案日志）：
```python
log = AiCallLog(
    workspace_id=workspace_id, user_id=task.operator_id, key_id=key_id,
    task_id=task.id, provider="apiports", model=clients._model,
    call_type="text", success=llm_success, latency_ms=latency_ms,
)   # 缺 prompt_tokens / completion_tokens
```

**改后**：分两处。
**① 文案日志补token**（依赖_call_llm返回usage，需text_variants.py一并改透传usage，工作量上升）。一期可降级：token填None保持现状，但**至少把生图日志补上**。

**② 生图日志新增**（run_image_generation 末尾，db.commit前）：
```python
# 生图调用留痕（与文案对称）
try:
    img_log = AiCallLog(
        workspace_id=workspace_id, user_id=task.operator_id, key_id=None,
        task_id=task.id, provider=getattr(clients, "_image_provider", "apiports"),
        model="gpt-image-2", call_type="image",
        success=any(not r.get("error") for r in image_results),
        latency_ms=image_latency_ms,   # 需在gather前后记时间
    )
    db.add(img_log)
except Exception as e:
    logger.warning("生图 ai_call_logs 写入失败(非阻断): %s", e)
```
gather 前加 `t0 = time.monotonic()`，后算 `image_latency_ms`。

**改动文件**：`pipeline_io.py`（生图日志，约12行）；token数透传需 text_variants.py + 调整_call_llm返回（约20行，建议二期）。
**风险**：飞轮一期是否强依赖token数？若不依赖，token降级、只补生图成败/耗时/provider即可。
**验收**：跑一次任务，ai_call_logs 表里文案+生图各一条，生图条 provider/model/success/latency 齐。
**待拍板**：token数这期补不补？建议先补生图留痕，token二期。

---

## 修7 · 文案 Gemini 兜底层未接

**根因**：现链路 LLM(Claude)→本地模板 两层（text_variants.py:89-91）。`gemini_factory.gemini_generate` 只服务生图，文案无Gemini通道。

**现状代码**（text_variants.py:89-91）：
```python
copies: list[dict] = await _generate_in_batches(llm_client, product, count, extra)
if not copies:
    copies = list(build_local_drafts(product, count))   # 直接掉本地兜底
```

**改后**：Claude失败后、本地兜底前插Gemini文案。需 gemini_factory 加 chat 方法（现只有图）。
```python
copies = await _generate_in_batches(llm_client, product, count, extra)
if not copies:
    logger.warning("Claude文案失败,尝试Gemini兜底")
    try:
        copies = await _generate_via_gemini(product, count, extra)  # 新函数
    except Exception as e:
        logger.error("Gemini文案兜底也失败: %s", e)
if not copies:
    copies = list(build_local_drafts(product, count))   # 最终本地兜底
```
新增 `_generate_via_gemini`：调 gemini chat → 复用 parse_json_array。

**改动文件**：`text_variants.py`（约15行）、`gemini_factory.py`（加chat_complete方法，约15行）。
**风险**：①Gemini文案质量/JSON格式遵守度要验，可能不如Claude ②Gemini key 是否各operator都配了？没配则这层形同虚设。
**验收**：停Claude(模拟503)，确认走Gemini出文案而非直接本地模板。
**待拍板**：一期是否需要三层？我建议——若operator普遍没配Gemini key，先不接，留两层(Claude+本地)即可。

---

## 修8 · 上传只查 content-type 无 magic number

**根因**：`products.py:174` 只查 `file.content_type`（HTTP header，可伪造）。无字节头校验。

**现状代码**（api/v1/products.py:174-179）：
```python
if file.content_type not in _ALLOWED_CONTENT_TYPES:
    raise_business(f"不支持的文件类型 {file.content_type}...")
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
    raise_business("文件超过 10 MB 限制")
```

**改后**：读完data后加真实字节头校验。
```python
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
    raise_business("文件超过 10 MB 限制")
# magic number 校验真实文件类型(防伪造content-type)
if not (data[:3] == b"\xff\xd8\xff"          # JPEG
        or data[:8] == b"\x89PNG\r\n\x1a\n"  # PNG
        or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")):  # WebP
    raise_business("文件内容不是有效的 JPEG/PNG/WebP 图片")
```

**改动文件**：`backend/app/api/v1/products.py`（约6行）。
**风险**：无。
**验收**：把 .exe 改名 .jpg + 伪造 content-type 上传 → 被拒。

---

## 修9 · 文案优化轮默认关（确认题，非bug）

**现状**（text_variants.py:107-110）：`TEXT_OPTIMIZE_ENABLED=false` 默认关。
注释写明：apiports 60s网关下，优化轮的失败要白等60s才503，拖慢出文案+100s+。

**这是有意为之的工程权衡，不是bug。** 两个选择：
- **A（建议）保持关**：等北哥prompt方案到位再开，架构已留位（改环境变量即开）。
- **B 改造绕60s**：优化轮也走分批小请求（≤4条/批），降低单请求触发60s概率。工作量M。

**改动文件**：A=0改动；B=text_variants.py优化轮分批（约20行）。
**待拍板**：A还是B？我建议A。

---

## 修10 · 无 key 不置灰引导

**根因**：配置页有"暂无Key请录入"提示（ApiKeysTab.tsx:92），但 `/tasks/new` 开任务页不检测key、不置灰入口。用户没录key也能点开始→任务必然失败。

**现状**：`NewTaskForm.tsx:38,112-115` 只处理"产品入镜但无参考图"的置灰(blockedByNoImage)，无key检测。

**改后**：开任务页加载时查当前用户是否有key，无则置灰"开始"+引导。
```tsx
// NewTaskForm 加载时
const { data: keys } = useApiKeys();   // 复用配置页的key查询
const noKey = !keys || keys.length === 0;
const blocked = blockedByNoImage || noKey;
// 开始按钮 disabled={blocked}
{noKey && <Banner>请先到「配置中心」录入 API Key 才能开任务 <Link href="/config">去录入</Link></Banner>}
```

**改动文件**：`frontend/src/components/tasks/NewTaskForm.tsx`（约10行）、可能复用key查询hook。
**风险**：无。
**验收**：清空key→开任务页"开始"置灰+引导文案+跳配置链接。

---

> 第二批小结：修8（magic number）无歧义可直接做；修6（生图日志）建议做、token降级；修7/修9需你拍板（多半可降级/保持现状）；修10纯前端体验补强。
