Files
yunke/docs/superpowers/plans/2026-08-07-yunke-plan4a-wa-voice-scrape.md
T
2026-08-19 16:48:22 +08:00

390 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 4a: WA 语音通话抓取 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 新增 WhatsApp 语音通话抓取(`scrape_wa_voice_calls`),复用云客统一语音接口 `queryWeChatVoiceList` + `callSource:"4"`,存入 `PhoneCall` 表(`channel='wa_voice_call'`),并挂进 scheduler + smoke 实跑验证,为 Plan 4b(CallList UI 对齐 WA tab)提供数据。
**Architecture:** 复用现有 `scrape_wechat_voice_calls`(`app/scraper/wechat.py:1222`)的翻页/解析/upsert 模式,在 `app/scraper/whatsapp.py` 新增 `scrape_wa_voice_calls`——唯一差异:请求 body 加 `callSource:"4"`(=WA;微信=`"0"`)、归一化 `channel="wa_voice_call"`。**不加 PhoneCall 列、不做 Alembic 迁移**(yunke 用 `Base.metadata.create_all`,无 alembic 目录):WA 特有字段(`weChatId`=ownerWA/`callFileId`/`nextId`/`isRoom`)落 `raw_json`,员工用现有 `user_id` 列筛。
**Tech Stack:** Python 3.12 + httpx + respx(scraper 测试)+ FastAPI/SQLAlchemy(复用)。
**Spec/事实依据:**
- `docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md`(核对表 §6 WA 语音 5 列 + 操作)
- **Phase 0 浏览器采集(2026-08-06/07)**:WA `wavoicecalllist` 页数据请求 = `POST /wechatweb/callRecord/queryWeChatVoiceList`,body `{"pageNum":1,"pageSize":10,"startTime":"...","endTime":"...","departmentId":"...","isRoom":"0","callSource":"4","ids":[],"userId":""}`,response `data.total=143` + 每行字段(见下)。**微信页同接口 `callSource:"0"`**。
- **代码分析(Explore)**:`scrape_wechat_voice_calls`(wechat.py:1222)已实现且 URL 正确;smoke.db `phone_call` 0 行是因 `scripts/smoke_wechat.py` 没调语音 scraper(非代码错);whatsapp.py 无 voice scraper;`upsert_voice_call`(repos.py:515)复用 `_upsert_one(PhoneCall)`;PhoneCall 无 `owner_account_id` 列(weChatId 落 raw_json)。
---
## 范围
**本 plan 覆盖(4a 抓取)**:
- 新增 `scrape_wa_voice_calls`(whatsapp.py)+ TDD 测试
- 新增 `sync_wa_voice_calls`(sync 编排)+ scheduler `ENTITY_FUNCS["wa_voice_calls"]` + 接进 `_run_all_wechat`/`_run_all`
- smoke 脚本挂载 WA + 微信语音 scraper 实跑,验证填 smoke.db
**不在本 plan 范围**:
- CallList UI 对齐(WA tab/列结构/长语音 modal/统计卡片/录音异常检查/工具栏)—— **Plan 4b**(依赖本 plan 产出的真实数据字段)。
- 长语音片段抓取(`/wechatweb/call/list`)—— 随 Plan 4b 长语音 modal。
- 微信语音 scraper 改造(现状能用;若 smoke 实测返回含 WA 数据,本 plan Task 4 补 `callSource:"0"` 加固)。
- PhoneCall 加列 / Alembic 迁移(刻意避免:无 alembic 目录;channel 区分 + raw_json 足够)。
### WA 语音字段映射(Phase 0 实测 response → PhoneCall 列)
| 云客字段 | PhoneCall 列 | 说明 |
|---|---|---|
| `id` | `call_id` (PK) | |
| — | `channel` = `"wa_voice_call"` | 区分微信(`voice_call`)/电话(`phone_call`) |
| `startTime` `"2026-08-06 11:28:22"` | `start_call_time` | `%Y-%m-%d %H:%M:%S` → ms epoch(naive UTC,同微信语音) |
| `duration` `"82"` | `call_duration` | 字符串秒 → int |
| `callStatus` (1=接通有录音/2=未接通) | `call_status` | int |
| `isSend` (0=呼入/1=呼出) | `incoming_call` | **翻转** `1 - isSend`(同微信语音,对齐 PhoneCall 极性 1=呼入)|
| `userId` | `user_id` | 员工 |
| `userName` | `user_id_name` | 员工姓名 |
| `talker` (`584142462772@s.whatsapp.net`) | `call_number` | 客户 WA id |
| `talkerNickName` | `plan_customer_name` | 脱敏昵称 |
| `ossFileName` (mp3 URL 或 null) | `record_file` | 录音直链 |
| `version` | `version` | |
| `weChatId`/`callFileId`/`nextId`/`isRoom`/`from` 等 | `raw_json` | WA 特有,无对应列 |
---
## 运行命令(验证用)
- 单测:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -q`
- 全后端:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q`
- smoke 实跑(填 smoke.db):`cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity wa_voice_calls`(.env 有 YK_USERID/COMPANY/COOKIE)
- 查 smoke.db:`cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print(c.execute(\"select channel,count(*) from phone_call group by channel\").fetchall())"`
---
## File Map
- Modify `app/scraper/whatsapp.py` — 新增 `scrape_wa_voice_calls` + `_build_wa_voice_body`/`_normalize_wa_voice_call`/`_parse_wa_voice_total`/`_parse_wa_voice_rows` + 常量 + `__all__`
- Create `tests/test_scrape_wa_voice_calls.py` — TDD(respx + cassette,照 `test_scrape_wechat_voice_calls.py`)
- Create `tests/cassettes/wa_voice_fixture.py` — WA 响应 fixture(照 `voiceCall_fixture.py`)
- Modify sync 编排(`app/sync/` 下含 `sync_wechat_voice_calls` 的模块)— 新增 `sync_wa_voice_calls`
- Modify `app/sync/scheduler.py` — `ENTITY_FUNCS["wa_voice_calls"]` + 接进 `_run_all_wechat`/`_run_all`
- Modify `scripts/smoke_wechat.py`(或新 `scripts/smoke_calls.py`)— 挂载 voice/wa_voice/phone/sms scraper
---
## Task 1: WA 语音 scraper + 解析(TDD)
> 事实:`scrape_wechat_voice_calls`(wechat.py:1222)用 `paginate_by_page` + `VOICE_LIST_PATH="/wechatweb/callRecord/queryWeChatVoiceList"` + `_parse_voice_total`(读 `data.total`)/`_parse_voice_rows`(读 `data.data`)+ `_build_voice_body`(`{pageNum,pageSize}`)+ `_normalize_voice_call`。WA 用**同接口同 envelope**,仅 body 加 `callSource:"4"`、channel 改 `wa_voice_call`。`paginate_by_page` 签名见 `scraper/base.py`。`coerce_int_or_none` + `parse_datetime_to_ms` 都从 `app.scraper.parsers`(whatsapp.py:36 已 import,直接用)。
**Files:**
- Create: `tests/cassettes/wa_voice_fixture.py`
- Create: `tests/test_scrape_wa_voice_calls.py`
- Modify: `app/scraper/whatsapp.py`
- [ ] **Step 1: 写 WA 响应 fixture**(`tests/cassettes/wa_voice_fixture.py`,照 `tests/cassettes/voiceCall_fixture.py` 模式):导出 `WA_VOICE_RECORDS`(2-3 条 WA 记录,字段对齐 Phase 0 实测:`id`/`userId`/`userName`/`userWeChatNickName`/`userWeChatAlias`/`talker`/`talkerNickName`/`talkerAlias`/`callType`/`isSend`/`callStatus`/`ossFileName`/`duration`/`startTime`/`weChatId`/`callFileId`/`nextId`/`isRoom`/`version`)+ `make_wa_voice_list_response(records, total, page_num, page_size)`(返回 `{"message":"调用成功","code":"10000","data":{"pageNum":...,"pageSize":...,"total":total,"data":records}}` envelope)。先读 `voiceCall_fixture.py` 对齐 `make_voice_list_response` 的确切结构。
- [ ] **Step 2: 写失败测试**(`tests/test_scrape_wa_voice_calls.py`,照 `test_scrape_wechat_voice_calls.py` 的 respx 模式):
```python
from __future__ import annotations
import httpx, respx
from app.scraper.whatsapp import scrape_wa_voice_calls
from app.session.yunke_client import YunkeClient
from tests.cassettes.wa_voice_fixture import WA_VOICE_RECORDS, TOTAL, make_wa_voice_list_response
BASE = "https://crm2.yunkecn.com"
VOICE_URL = f"{BASE}/wechatweb/callRecord/queryWeChatVoiceList"
def _primed_client() -> YunkeClient:
return YunkeClient(base_url=BASE, userid="a"*32, company="8m0atd")
@respx.mock
async def test_scrape_wa_voice_calls_yields_records_and_paginates():
"""两页 + 空页终止 -> 全部记录;body 含 callSource=4;channel=wa_voice_call。"""
page1 = make_wa_voice_list_response(WA_VOICE_RECORDS[:2], total=TOTAL, page_num=1, page_size=2)
page2 = make_wa_voice_list_response(WA_VOICE_RECORDS[2:], total=TOTAL, page_num=2, page_size=2)
page3 = make_wa_voice_list_response([], total=TOTAL, page_num=3, page_size=2)
route = respx.post(VOICE_URL).mock(side_effect=[
httpx.Response(200, json=page1), httpx.Response(200, json=page2), httpx.Response(200, json=page3)])
client = _primed_client()
try:
results = [r async for r in scrape_wa_voice_calls(client, page_size=2)]
finally:
await client.aclose()
assert route.call_count == 3
assert len(results) == len(WA_VOICE_RECORDS)
import json as _json
body1 = _json.loads(route.calls[0].request.content.decode())
assert body1["callSource"] == "4" # 关键:WA 区分
assert body1["pageNum"] == 1 and body1["pageSize"] == 2
for result, record in zip(results, WA_VOICE_RECORDS, strict=True):
assert result["channel"] == "wa_voice_call"
assert result["call_id"] == record["id"]
assert result["call_number"] == record["talker"] # friendWA
assert result["record_file"] == record["ossFileName"]
assert result["call_status"] == record["callStatus"]
assert result["raw_json"] == record # weChatId/callFileId 保 raw_json
# incoming_call 翻转
assert result["incoming_call"] == 1 - record["isSend"]
@respx.mock
async def test_scrape_wa_voice_calls_stops_on_empty_first_page():
empty = make_wa_voice_list_response([], total=0, page_num=1)
route = respx.post(VOICE_URL).mock(return_value=httpx.Response(200, json=empty))
client = _primed_client()
try:
results = [r async for r in scrape_wa_voice_calls(client)]
finally:
await client.aclose()
assert results == [] and route.call_count == 1
```
> implementer:参照 `test_scrape_wechat_voice_calls.py` 的 `_ms`/断言风格补全(start_call_time 的 ms 断言等)。先确认 `voiceCall_fixture.py` 的 `TOTAL`/`DEFAULT_PAGE_SIZE` 命名,WA fixture 对齐。
- [ ] **Step 3: 跑测试确认 FAIL**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -x -q
```
预期:FAIL(`scrape_wa_voice_calls` 不存在,ImportError)。
- [ ] **Step 4: 实现 WA scraper**(`app/scraper/whatsapp.py`,加在文件末尾,import `paginate_by_page` 已在 L35):
```python
# ===========================================================================
# Voice-call scraper — POST /wechatweb/callRecord/queryWeChatVoiceList (callSource=4)
# 复用云客统一语音接口(微信 callSource=0 / WA callSource=4);envelope 同微信语音
# (data.data / data.total)。channel="wa_voice_call" 区分。
# ===========================================================================
WA_VOICE_LIST_PATH = "/wechatweb/callRecord/queryWeChatVoiceList"
def _parse_wa_voice_total(resp: Any) -> int | None:
"""data.total(同微信语音 envelope)。"""
try:
body = resp.json()
except (AttributeError, ValueError):
return None
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return None
total = data.get("total")
return int(total) if isinstance(total, (int, float)) and not isinstance(total, bool) else None
def _parse_wa_voice_rows(resp: Any) -> list[dict[str, Any]]:
"""data.data(同微信语音 envelope)。"""
try:
body = resp.json()
except (AttributeError, ValueError):
return []
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return []
rows = data.get("data")
return rows if isinstance(rows, list) else []
def _build_wa_voice_body(page_num: int, page_size: int) -> dict[str, Any]:
"""WA 语音请求体:分页 + callSource=4(WA)。"""
return {"pageNum": page_num, "pageSize": page_size, "callSource": "4"}
def _normalize_wa_voice_call(record: dict[str, Any]) -> dict[str, Any]:
"""Map one WA queryWeChatVoiceList record -> PhoneCall shape (channel="wa_voice_call")。
字段映射同微信语音(_normalize_voice_call),唯二差异:channel 标 wa_voice_call;
WA 特有字段(weChatId=ownerWA / callFileId / nextId / isRoom / from)无对应列,落 raw_json。
isSend 翻转极性(0=呼入 -> incoming_call=1)。None-safe。
"""
is_send = coerce_int_or_none(record.get("isSend"))
incoming_call = None if is_send is None else 1 - is_send
return {
"call_id": record.get("id"),
"channel": "wa_voice_call",
"start_call_time": parse_datetime_to_ms(record.get("startTime"), _WA_DATETIME_FORMATS),
"call_duration": coerce_int_or_none(record.get("duration")),
"call_status": coerce_int_or_none(record.get("callStatus")),
"incoming_call": incoming_call,
"user_id": record.get("userId"),
"user_id_name": record.get("userName"),
"call_number": record.get("talker"),
"plan_customer_name": record.get("talkerNickName"),
"record_file": record.get("ossFileName"),
"version": record.get("version"),
"raw_json": record,
}
async def scrape_wa_voice_calls(
client: YunkeClient,
*,
page_size: int = 50,
) -> AsyncIterator[dict[str, Any]]:
"""Yield normalized WA voice-call records (queryWeChatVoiceList + callSource=4)."""
async for record in paginate_by_page(
client,
WA_VOICE_LIST_PATH,
page_size=page_size,
parse_total=_parse_wa_voice_total,
parse_rows=_parse_wa_voice_rows,
build_body=_build_wa_voice_body,
):
yield _normalize_wa_voice_call(record)
```
> 注意:`coerce_int_or_none`/`parse_datetime_to_ms` 从 `app.scraper.parsers`(L36 已 import)。`_WA_DATETIME_FORMATS`(L50)含 `%Y-%m-%d %H:%M:%S`,WA startTime 是这格式——直接用(比微信的 `_parse_voice_start_time` 更简,复用 WA 既有格式列表)。把 `"scrape_wa_voice_calls"` 加入 `__all__`(L39-43)。
- [ ] **Step 5: 跑测试确认 PASS**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -x -q
```
- [ ] **Step 6: 全后端回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
预期:全绿(原 ≥333 + 新增)。
- [ ] **Step 7: Commit**
```bash
git add yunke-server/app/scraper/whatsapp.py yunke-server/tests/test_scrape_wa_voice_calls.py yunke-server/tests/cassettes/wa_voice_fixture.py
git commit -m "$(cat <<'EOF'
feat(scraper): WA 语音通话抓取 scrape_wa_voice_calls(callSource=4)
复用云客统一语音接口 queryWeChatVoiceList + callSource=4;channel=wa_voice_call
区分;WA 特有字段落 raw_json;复用 PhoneCall 表 + upsert_voice_call。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: sync 编排 + scheduler 注册
> 事实:`scheduler.py:149-152` `_run_voice_calls` → `sync_wechat_voice_calls`(在 `app/sync/` 下,grep 定位)。`ENTITY_FUNCS`(scheduler.py:212-225)含 `"voice_calls": _run_voice_calls`。`_run_all_wechat`(:182-193)顺序含 voice_calls;`_run_all`(:196-206)= _run_all_wechat + phone_calls + sms。`upsert_voice_call`(repos.py:515)。
**Files:**
- Modify: `app/sync/`(含 `sync_wechat_voice_calls` 的模块,grep 定位)
- Modify: `app/sync/scheduler.py`
- [ ] **Step 1: 照写 `sync_wa_voice_calls`(orchestrator.py)**。`sync_wechat_voice_calls` 在 `app/sync/orchestrator.py:487`(签名 `(session: AsyncSession, client: YunkeClient) -> SyncResult`,调 `scrape_wechat_voice_calls` + `upsert_voice_call`)。读它,紧邻照写 `sync_wa_voice_calls(session, client)`(调 `scrape_wa_voice_calls` + `upsert_voice_call`,其余 session/日志/计数/SyncResult 照搬)。import `scrape_wa_voice_calls` from `app.scraper.whatsapp`。
- [ ] **Step 2: 接进 daily 序列 + scheduler ENTITY_FUNCS**。
(a) **daily 序列在 orchestrator.py,不是 scheduler.py**:`sync_all_wechat`(orchestrator.py 约 :576-582)的串行序列里,在 `await sync_wechat_voice_calls(session, client)` 那行后插入 `await sync_wa_voice_calls(session, client),`。这让 `_run_all`/daily cron 覆盖 WA voice(scheduler 的 `_run_all_wechat` 只是委托给 `sync_all_wechat`,实体顺序在后者)。
(b) **scheduler ENTITY_FUNCS**(单实体 CLI 用):`scheduler.py` 加 helper——**照 `_run_voice_calls`(:149-152)签名 `(session, client, store) -> list[SyncResult]`,不是 `(client)->int`**:
```python
async def _run_wa_voice_calls(session, client, store) -> list[SyncResult]:
return [await sync_wa_voice_calls(session, client)]
```
`ENTITY_FUNCS` 加 `"wa_voice_calls": _run_wa_voice_calls`。import `sync_wa_voice_calls`(from orchestrator)+ `SyncResult`。
- [ ] **Step 3: 回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
(若 scheduler 有测试,确认 ENTITY_FUNCS 含新键;若无 scheduler 测试则全绿即可。)
- [ ] **Step 4: CLI 单实体冒烟(可选,需 cookie)**
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity wa_voice_calls
```
预期:不报错,抓取若干 WA 语音(143 total,按 page_size 翻页)。若 cookie 失效会报 401——则跳过,靠 Task 3 smoke 验证。
- [ ] **Step 5: Commit**
```bash
git add yunke-server/app/sync/ yunke-server/app/sync/scheduler.py
git commit -m "$(cat <<'EOF'
feat(sync): sync_wa_voice_calls + scheduler ENTITY_FUNCS wa_voice_calls
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: smoke 脚本挂载 + 实跑验证
> 事实:`scripts/smoke_wechat.py` 只调 `scrape_wechat_friends` + `scrape_wechat_messages`(smoke.db voice 0 行根因)。CLI `app.sync.cli sync --entity <name>` 走 ENTITY_FUNCS。
**Files:**
- Modify: `scripts/smoke_wechat.py`(或新建 `scripts/smoke_calls.py`)
- [ ] **Step 1: 挂载语音 scraper 到 smoke**。读 `scripts/smoke_wechat.py`,在 friends/messages 调用旁加 `voice_calls` + `wa_voice_calls`(+ 可选 phone_calls/sms)的 sync 调用。或新建 `scripts/smoke_calls.py` 调 `sync_wechat_voice_calls` + `sync_wa_voice_calls` + `sync_phone_calls` + `sync_sms`。参照现有 smoke 脚本的 client 构造(cookie from .env)+ DB_URL 写入 smoke.db 模式。
- [ ] **Step 2: 实跑填 smoke.db**(需 .env 有效 cookie):
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/smoke_calls.py
```
(或逐实体 CLI:`... sync --entity voice_calls` 然后 `wa_voice_calls`)
- [ ] **Step 3: 验证 smoke.db 有数据**
```bash
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print(c.execute(\"select channel,count(*) from phone_call group by channel\").fetchall())"
```
预期:含 `('voice_call', N)` + `('wa_voice_call', M)`(M≈143 若全抓)。若 voice_call 仍 0,说明微信 scraper 不带 callSource 返回空/异常 → 触发 Task 4。
- [ ] **Step 4: 后端 API 抽查**
```bash
curl -s "http://localhost:8000/api/calls?channel=wa_voice_call&page_size=3" | head -c 500
```
(需后端跑:`uvicorn app.api.main:app --port 8000`)预期:返回 WA 语音通话 items。
- [ ] **Step 5: Commit**
```bash
git add yunke-server/scripts/smoke_calls.py yunke-server/scripts/smoke_wechat.py
git commit -m "$(cat <<'EOF'
feat(scripts): smoke 挂载 voice/wa_voice/phone/sms scraper(验证抓取填 smoke.db)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 4(条件): 微信语音 callSource=0 加固
> **仅当 Task 3 实测发现**:微信 `scrape_wechat_voice_calls`(不带 callSource)返回的数据**含 WA 记录**(talker 是 `@s.whatsapp.net`)或返回空 → 才执行本 task。若微信语音正常返回(纯 wxid talker),跳过本 task。
**Files:** Modify `app/scraper/wechat.py` + `tests/test_scrape_wechat_voice_calls.py`
- [ ] **Step 1: 给 `_build_voice_body` 加 `call_source` 参数**(wechat.py:1143):
```python
def _build_voice_body(page_num: int, page_size: int, *, call_source: str = "0") -> dict[str, Any]:
return {"pageNum": page_num, "pageSize": page_size, "callSource": call_source}
```
`scrape_wechat_voice_calls` 调用处传 `call_source="0"`(或 paginate_by_page 的 build_body kwargs)。
- [ ] **Step 2: 更新现有测试**(`test_scrape_wechat_voice_calls.py` 的 body 断言,:232-238):加 `assert body1["callSource"] == "0"`。
- [ ] **Step 3: 回归 + smoke 复跑确认微信纯数据**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_voice_calls.py -q
```
- [ ] **Step 4: Commit**
```bash
git add yunke-server/app/scraper/wechat.py yunke-server/tests/test_scrape_wechat_voice_calls.py
git commit -m "$(cat <<'EOF'
fix(scraper): 微信语音 body 加 callSource=0 明确区分(防混入 WA)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## 收尾验收
- [ ] `pytest tests/ -q` 全绿(≥333 + 新增 WA voice 测试)。
- [ ] smoke.db `phone_call` 表含 `channel='wa_voice_call'` 行(实跑验证抓取链路通)。
- [ ] `GET /api/calls?channel=wa_voice_call` 返回 WA 语音数据。
- [ ] scheduler `ENTITY_FUNCS` 含 `wa_voice_calls`,`_run_all` 覆盖。
- [ ] **为 Plan 4b 留好的数据基础**:WA 语音字段(talker=friendWA、user_id=员工、record_file=录音URL、call_status/isSend)已结构化进 PhoneCall,raw_json 保留 weChatId/callFileId 供 4b modal/匹配用。
---
## 不在本 plan 范围(Plan 4b)
- CallList.vue 加 WA tab + WA 列结构(状态/客户信息/员工信息/通话时间和录音/操作)。
- 长语音列表 modal(`/wechatweb/call/list` 抓取 + voice_clip 存储 + 匹配 UI)。
- 统计卡片(todayStatistics)、录音异常检查(`/call/check/{id}` 代理)、批量语音下载/全部导出/高级筛选工具栏。
- 微信好友/微信群 tab(isRoom 区分)—— 若 4b 需要,从 raw_json.isRoom 读或加列。