# 云客员工聊天窗口对齐(StaffChat 五视图)实施计划 > **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:** 新建 `/staff-chat/:ownerId/:tab/:talkerId?` 页面,完整对齐云客员工聊天窗口的 5 个视图(单聊/群聊/朋友圈/通讯录/统计分析),含数据抓取与 API。 **Architecture:** 方案 A——独立新页 `StaffChat.vue` + 5 视图子组件(`yunkes/src/components/staffchat/`),旧 `ChatTimeline.vue` 保留给 WA 不动。后端新增 `moments_post` 表+`moments` 抓取实体、`chat_group` 补列、消息类型归一化补全+回填、`/api/moments`+`/api/staff-stats` 两个新端点、sessions/accounts/messages 增量改动。全部本地数据,不访问云客(抓取器除外)。 **Tech Stack:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0 async + alembic + Playwright 页面抓取;前端 Vue3 + TS + Element Plus;测试 pytest(`make_client` fixture)/ vitest。 **Spec:** `docs/superpowers/specs/2026-08-25-yunke-staff-chat-window-design.md`(含云客真机接口事实 §2,实现时对齐口径以它为准) **约定(全任务适用):** - 后端测试命令:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -v`(Windows Git Bash) - 前端测试/构建:`cd yunkes && npm run test 2>/dev/null || npx vitest run `;类型检查 `npx vue-tsc --noEmit` - 每任务一 commit,消息格式沿用仓库惯例(`feat(chat): ...` 中文描述) - alembic 迁移文件名随机 hex 由 `alembic revision` 生成,plan 中用占位名 - **TDD**:先写测试看失败,再实现看通过 --- ## Phase 1 后端 ### Task 1: `moments_post` 表 + repo upsert **Files:** - Modify: `yunke-server/app/db/models.py`(文件末尾附近、`GroupMember` 之后加模型) - Create: `yunke-server/migrations/versions/_moments_post_table.py` - Modify: `yunke-server/app/storage/repos.py`(加 `upsert_moments_posts`) - Test: `yunke-server/tests/test_models.py`(追加) - [ ] **Step 1: 写失败测试**(`tests/test_models.py` 追加) ```python async def test_moments_post_model_roundtrip(db_session): # fixture 沿用该文件现有 db_session 惯例 from app.db.models import MomentsPost row = MomentsPost( id="69e082a3c3524a7fe3ac53b4", wechat_id="wxid_x", sns_id="-3546042415332912522", author_name="Yoyo", author_wxid="wxid_x", author_avatar="https://wx.qlogo.cn/...", content="Welcome to HESU", type_raw="1", create_time=1776302051000, praise_json=[{"name": "E**U"}], comment_json=[], image_urls=["https://wx.oss.../a.jpg"], raw_json={"id": "69e082a3c3524a7fe3ac53b4"}, ) db_session.add(row) await db_session.commit() got = (await db_session.execute(sa.select(MomentsPost))).scalar_one() assert got.praise_json[0]["name"] == "E**U" assert got.create_time == 1776302051000 ``` (若 `test_models.py` 无 `db_session` fixture,参照其现有的建表/会话模式补一个;conftest 只有 `make_client`,模型级测试在该文件里已有先例,遵循它。) - [ ] **Step 2: 跑测试确认失败** Run: `.venv/Scripts/python.exe -m pytest tests/test_models.py -v -k moments` Expected: FAIL `ImportError: cannot import name 'MomentsPost'` - [ ] **Step 3: 加模型**(`models.py`,字段对齐 spec §4.1;注意 **create_time 是 ms**,云客秒×1000 在 scraper 做) ```python class MomentsPost(Base): """员工朋友圈帖子 — source: ``POST /wechatweb/wechatFriendster/getFriendsterInfo``. PK = 云客 data.id。create_time 统一 ms epoch(云客返回秒,scraper ×1000)。 """ __tablename__ = "moments_post" id: Mapped[str] = mapped_column(String(64), primary_key=True) wechat_id: Mapped[str] = mapped_column(String(128), index=True) sns_id: Mapped[str | None] = mapped_column(String(64), nullable=True) author_name: Mapped[str | None] = mapped_column(String(255), nullable=True) author_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True) author_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True) content: Mapped[str | None] = mapped_column(Text, nullable=True) type_raw: Mapped[str | None] = mapped_column(String(16), nullable=True) create_time: Mapped[int | None] = mapped_column(BigInteger, index=True) praise_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True) comment_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True) image_urls: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) fetched_at: Mapped[dt.datetime] = _fetched_at() updated_at: Mapped[dt.datetime] = _updated_at() ``` - [ ] **Step 4: 生成迁移并跑**(沿用现有迁移惯例,参照 `3892dbfab839_customer_add_time.py` 的写法) ```bash .venv/Scripts/python.exe -m alembic revision --autogenerate -m "moments_post table" # autogenerate 不可用则手写 op.create_table .venv/Scripts/python.exe -m alembic upgrade head ``` Expected: `Running upgrade ... -> , moments_post table`。注意迁移需同时兼容空库(tests 用 `Base.metadata.create_all`,不依赖迁移,但 MySQL 生产走迁移)。 - [ ] **Step 5: repo upsert**(`repos.py`,镜像 `upsert_customers` 模式) ```python async def upsert_moments_posts(session: AsyncSession, recs: list[dict[str, Any]]) -> int: """Batch idempotent upsert for :class:`MomentsPost` (PK = ``id``).""" for rec in recs: await _upsert_one(session, MomentsPost, rec) return len(recs) ``` (`_upsert_one` 的列白名单机制若按类自动取列则无需注册;若存在显式 `_INT_FIELDS`/列集合,补 `create_time` 到 int 强转集合。检查 `repos.py:95-110` 现有集合。) - [ ] **Step 6: 跑全部模型测试通过** Run: `.venv/Scripts/python.exe -m pytest tests/test_models.py tests/test_repos.py -v` Expected: PASS - [ ] **Step 7: Commit** ```bash git add yunke-server/app/db/models.py yunke-server/app/storage/repos.py yunke-server/migrations/ yunke-server/tests/test_models.py git commit -m "feat(db): moments_post 表+upsert(朋友圈实体,PK=云客data.id,create_time统一ms)" ``` --- ### Task 2: `moments` 抓取实体(页面 fetch) **Files:** - Modify: `yunke-server/scripts/scrape_via_page.py`(加 `_normalize_moments` + `scrape_moments` + CLI 注册,~1870 行后) - Modify: `yunke-server/scripts/full_sync_all.sh`(media 步之前加一步) - Test: `yunke-server/tests/test_scrape_moments.py`(新) 云客接口事实(spec §2.2):`POST /wechatweb/wechatFriendster/getFriendsterInfo`,form-data `startTime/endTime(空串)/pageNum/pageSize/wechatId`;响应 `data.data[]`(`id/snsId/friendsterName/friendsterWXId/friendsterWXIdImg/content/createTime(秒)/type/praise[]/comment[]/imgUrlsList[]`)+ `data.hashNext`。 - [ ] **Step 1: 写失败测试**(fixture 用真机 JSON 片段) ```python """scrape_moments 归一化 + 抓取循环测试(不启浏览器,mock _fetch_pages 侧的 HTTP)。""" import asyncio from unittest.mock import AsyncMock, patch from scripts.scrape_via_page import _normalize_moments RAW = { # 2026-08-25 真机抓包样本(截取关键字段) "id": "69e082a3c3524a7fe3ac53b4", "wechatId": "wxid_wo97u1u2x11w22", "snsId": "-3546042415332912522", "friendsterName": "Yoyo-HESU", "friendsterWXId": "wxid_wo97u1u2x11w22", "friendsterWXIdImg": "https://wx.qlogo.cn/mmhead/ver_1/vsHn.../0", "content": "Welcome to HESU", "type": "1", "createTime": "1776302051", # 秒! "praise": [{"name": "E**U", "praiseId": "wxid***r822", "praiseImg": "https://wx.qlogo.cn/p/0"}], "comment": [], "imgUrlsList": ["https://wx.oss.yunkecn.com/friendstr/8m0atd/16/b1/ea/xx.jpg"], } def test_normalize_moments_converts_seconds_to_ms(): rec = _normalize_moments(RAW) assert rec["id"] == "69e082a3c3524a7fe3ac53b4" assert rec["create_time"] == 1776302051 * 1000 # 秒→ms assert rec["author_name"] == "Yoyo-HESU" assert rec["praise_json"][0]["name"] == "E**U" assert rec["image_urls"] == RAW["imgUrlsList"] assert rec["raw_json"]["snsId"] == "-3546042415332912522" def test_normalize_moments_null_safe(): rec = _normalize_moments({}) assert rec["id"] is None # 缺 id 行由调用方丢弃 ``` - [ ] **Step 2: 跑测试确认失败** Run: `.venv/Scripts/python.exe -m pytest tests/test_scrape_moments.py -v` Expected: FAIL `ImportError: cannot import name '_normalize_moments'` - [ ] **Step 3: 实现 `_normalize_moments` + `scrape_moments`** ```python def _normalize_moments(record: dict[str, Any]) -> dict[str, Any]: """云客朋友圈行 → moments_post 记录。createTime 秒 → ms(×1000)。缺 id 返回 id=None 由调用方丢。""" ct = record.get("createTime") try: ct_ms = int(ct) * 1000 if ct is not None else None except (TypeError, ValueError): ct_ms = None return { "id": record.get("id"), "wechat_id": record.get("wechatId"), "sns_id": record.get("snsId"), "author_name": record.get("friendsterName"), "author_wxid": record.get("friendsterWXId"), "author_avatar": record.get("friendsterWXIdImg"), "content": record.get("content"), "type_raw": str(record.get("type")) if record.get("type") is not None else None, "create_time": ct_ms, "praise_json": record.get("praise") or [], "comment_json": record.get("comment") or [], "image_urls": record.get("imgUrlsList") or [], "raw_json": record, } async def scrape_moments(cookie_str: str, page_size: int = 100, max_pages_per_staff: int = 50): """逐员工抓朋友圈(浏览器 fetch getFriendsterInfo,hashNext 翻页)。返回统计 dict。""" # 结构完全镜像 scrape_wa_groups(browser fetch + form 翻页): # 1) 启动浏览器上下文(复用 _browser_context helper,参照 scrape_wa_groups 开头) # 2) SELECT wx_id FROM wechat_account WHERE channel 条件(表本身仅微信,全量遍历) # 3) 每员工循环 pageNum=1..N:fetch POST form {startTime:"", endTime:"", pageNum, pageSize, wechatId} # resp["data"]["data"] → _normalize_moments → 丢 id=None 行 → 累积 # resp["data"]["hashNext"] 为 False 即止;页间 asyncio.sleep(1) # 4) 批量 upsert_moments_posts(async_session 引用 scrape_wa_groups 的 DB 打开方式) ... ``` **实现要点**(执行者抄 `scrape_wa_groups` 1326-1384 行为模板):form 字段名精确为 `startTime/endTime/pageNum/pageSize/wechatId`(云客 form-data 小驼峰);URL `https://crm2.yunkecn.com/wechatweb/wechatFriendster/getFriendsterInfo`;每员工结束打 log `DONE moments +N`。 - [ ] **Step 4: CLI 注册**:`choices` 列表加 `"moments", # 员工朋友圈 (browser fetch getFriendsterInfo)`;dispatch 加分支 `elif args.entity == "moments": await scrape_moments(cookie, args.max_pages)`(复用 `--max-pages` 参数语义为"每员工翻页上限")。 - [ ] **Step 5: 抓取循环单测**(mock 浏览器 fetch 层——若 `_fetch_pages` 不适合本实体(它是通用翻页),则把 scrape_moments 的翻页内核拆一个 `_iter_moments_pages(fetch, wechat_id, page_size)` 纯函数接受 fetch 回调,单测喂两页样本断言翻页终止与累积) - [ ] **Step 6: 跑测试 + 手动冒烟(可选,需 .env cookie)** Run: `.venv/Scripts/python.exe -m pytest tests/test_scrape_moments.py -v` → PASS 可选真机:`python scripts/scrape_via_page.py --entity moments`,检查 `moments_post` 行数 > 0 - [ ] **Step 7: full_sync_all.sh 加步**(media 步前):`echo "== moments ==" && python scripts/scrape_via_page.py --entity moments || true`(沿用脚本内既有步骤格式) - [ ] **Step 7b: 调度接入 + 图片本地化**(spec §4.2 两项) - 调度:`app/sync/scheduler.py` 的 4h job 是子进程跑 `--entity messages`——在该 job 的子进程序列**追加一次** `--entity moments --max-pages 3`(浅扫第一页有新才继续,朋友圈更新频率低);镜像现有 `_page_subprocess` 调用模式,勿新增 job - 图片:`moments_post.image_urls` 是 OSS 直链(`wx.oss.yunkecn.com/friendstr/...`)。在 `scrape_media` 的媒体发现段加一个 moments 源(SELECT 未处理的 moments 行,image_urls 灌入 media 表,复用 message 图片的 URL 型下载路径);若 media 表按 `(kind, source_id)` 去重,kind 用 `moments_image`。执行时先读 `scrape_via_page.py:1838` 的 scrape_media 结构再动笔 - [ ] **Step 7c: 上面两处各加最小测试**(scheduler 若为纯配置列表则断言 moments 在序列;media 源加一行 fixtures 用例断言 URL 入 media 表) - [ ] **Step 8: Commit** ```bash git add yunke-server/scripts/scrape_via_page.py yunke-server/scripts/full_sync_all.sh yunke-server/tests/test_scrape_moments.py git commit -m "feat(scraper): moments 实体(浏览器fetch getFriendsterInfo,秒→ms,hashNext翻页,接入full_sync)" ``` --- ### Task 3: `chat_group` 补列(群主/好友数/建群时间)+ 存量回填 + groups API **Files:** - Modify: `yunke-server/app/db/models.py`(`ChatGroup` 加 6 列) - Create: `yunke-server/migrations/versions/_chat_group_owner_cols.py` - Create: `yunke-server/scripts/backfill_chat_group_fields.py` - Modify: `yunke-server/app/api/routers/groups.py`(`GroupOut` 输出新列;schemas.py 同步) - Test: `yunke-server/tests/test_api_groups.py`(追加) raw_json 键(真机验证):`ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime`(createTime ms 已是)。 - [ ] **Step 1: 写失败测试**(`test_api_groups.py` 追加;`make_client` seed 一个带 raw_json 的 ChatGroup) ```python async def test_groups_owner_fields(make_client): async def seed(session): session.add(ChatGroup(room_id="123@chatroom", channel="wechat", room_name="测试群", owner_account_id="wxid_x", member_count=10, raw_json={"ownerName": "Q**e", "ownerWchatId": "RL3*****108", "ownerAlias": "Fr******er", "ownerImage": "https://wx.qlogo.cn/o/0", "friendCount": 22, "createTime": 1740000000000})) await session.commit() client = await make_client(seed) r = await client.get("/api/groups", params={"room_id": "123@chatroom"}) item = r.json()["items"][0] assert item["owner_name"] == "Q**e" assert item["owner_wxid"] == "RL3*****108" assert item["friend_count"] == 22 assert item["create_time"] == 1740000000000 ``` - [ ] **Step 2: 跑测试确认失败**(`KeyError: 'owner_name'`——schema 无此字段) - [ ] **Step 3: 模型加列**(`ChatGroup`,nullable 全部) ```python # 通讯录·微信群 tab 对齐云客的补充列(2026-08-25,从 raw_json 提取) owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主昵称(上游脱敏) owner_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True) owner_alias: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主微信号 owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True) friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数 create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms ``` 同时:`yunke-server/app/scraper/wechat.py` 的 `_normalize_chat_group`(864 行)加同名字段提取(`record.get("ownerName")` 等),保证增量抓取持续写入。 - [ ] **Step 4: alembic 迁移**(`op.add_column` ×6,镜像 `3892dbfab839_customer_add_time.py` 写法)+ `upgrade head` - [ ] **Step 5: 回填脚本** `scripts/backfill_chat_group_fields.py`——镜像 `backfill_customer_add_time.py` 模式:SELECT 全部群,`raw_json` 缺列则提取 UPDATE;幂等;末尾打印 `backfilled N/M rows` - [ ] **Step 6: schemas.py `GroupOut` 加 6 字段**——groups.py 用的是 `GroupOut.model_validate(row, from_attributes=True)`(groups.py:75 模式),**只需在 schema 加字段即可自动输出**,路由无需改 - [ ] **Step 7: 跑测试 + 回填存量** Run: `.venv/Scripts/python.exe -m pytest tests/test_api_groups.py -v` → PASS Run: `.venv/Scripts/python.exe scripts/backfill_chat_group_fields.py` → `backfilled ~870/876 rows` - [ ] **Step 8: Commit** ```bash git add -A yunke-server git commit -m "feat(groups): chat_group补群主/好友数/建群时间6列(raw_json提取)+回填+API输出" ``` --- ### Task 4: 消息类型归一化判别 spike + 映射扩展 + 存量回填 + messages API 过滤 **Files:** - Modify: `yunke-server/scripts/scrape_via_page.py:972`(`_normalize_msg_type`) - Create: `yunke-server/scripts/backfill_msg_types.py` - Modify: `yunke-server/app/api/routers/messages.py`(`msg_type` + `q` 参数) - Test: `yunke-server/tests/test_api_messages.py`(追加 msg_type/q 用例);`yunke-server/tests/test_scrape_msg_type.py`(新,映射单测) - [ ] **Step 1: Spike——判别 unknown 大头语义**(交互式 SQL 抽样,**产出写进本文件底部"判别记录"**) ```bash .venv/Scripts/python.exe -c " import sqlite3, json conn = sqlite3.connect('smoke.db') for tr in (21, 15, 9, 10, 4, 18, 8, 6, 22, 13, 14, 5, 11, 20, 10000, 7): row = conn.execute(\"SELECT text, raw_json FROM message WHERE channel='wechat' AND type_raw=? AND text IS NOT NULL AND length(text)>0 LIMIT 1\", (tr,)).fetchone() t = (row[0][:80] if row else 'N/A') print(f'type_raw={tr}: {t}') " ``` 按样本判读(微信惯例 + 云客 `raw_json.type` 子码双重印证:49 行看 `raw_json.type`,10=链接 21=? 2001=红包 2000=转账 33=小程序 6=文件 19=合并转发…)。**判别结论填入下方表格后再写映射**: | type_raw | 样本摘要 | 判定 | |---|---|---| | 21 | (spike 填) | ? | | ... | | | - [ ] **Step 2: 写映射单测**(`test_scrape_msg_type.py`,含 49 红包/转账拆分) ```python from scripts.scrape_via_page import _normalize_msg_type def test_basic_mapping_unchanged(): assert _normalize_msg_type("1") == "text" assert _normalize_msg_type("2") == "image" assert _normalize_msg_type("34") == "voice" assert _normalize_msg_type("43") == "video" def test_new_mappings(): # 具体期望值以 Step 1 判别为准,此处为占位断言 assert _normalize_msg_type("21") != "unknown" # ... 每个 spike 判别出的 code 一条断言 def test_type_49_redpacket_vs_transfer(): # 49 拆分需要行级信息(paymsgid/appmsg type),映射函数升级为可带 row: assert _normalize_msg_type("49", row={"paymsgid": "x"}) in ("red_packet", "transfer") assert _normalize_msg_type("49", row={"text": "2001"}) == "red_packet" ``` - [ ] **Step 3: 跑测试确认失败 → 扩展实现** `_normalize_msg_type(raw_type, row=None)`:保留现有 7 项映射;按 spike 结论补 21/15/9/10/4/18/8/6/22/13/14/5/11/20(候选语义:链接 link/视频通话 video_call/语音通话 voice_call/文件 file/表情 emoji/位置 location/名片 card/系统 system——**以样本为准,不猜**);49 特判:`row.paymsgid` 非空或 text 含 `>2001<` → `red_packet`,`>2000<` → `transfer`,其余 49 → 现有 `file`。调用点(735/827 行)把整个 row 传入。 - [ ] **Step 4: 回填脚本** `backfill_msg_types.py`——SELECT `type='unknown' OR type IS NULL` 分批(5000/批),逐行调新 `_normalize_msg_type` UPDATE;幂等;打印各 type 计数。**注意 SQLite 参数上限,复用分批模式。** - [ ] **Step 5: messages API 加参数**(`messages.py`,`msg_type: str | None` + `q: str | None`) ```python if msg_type is not None: # 前端 10 类下拉 → 本地 type 集合(云客"图片"含新旧图,"文本"=text) _MSG_TYPE_FILTER = { "text": ("text",), "image": ("image", "image_legacy"), "voice": ("voice",), "video": ("video",), "link": ("link",), "file": ("file",), "red_packet": ("red_packet",), "transfer": ("transfer",), "voice_call": ("voice_call",), "video_call": ("video_call",), } conditions.append(Message.type.in_(_MSG_TYPE_FILTER[msg_type])) if q is not None: conditions.append(Message.text.like(f"%{q}%")) ``` (值域校验:未知 msg_type 返回 422——Literal 或手动检查;`q` 走 LIKE,MySQL/SQLite 双方言一致。) - [ ] **Step 6: API 测试**(`test_api_messages.py` 追加:seed text+image+red_packet 各一条,断言 `msg_type=image` 只回 image;`q=你好` 只回含词行) - [ ] **Step 7: 全量验证 + 回填存量** Run: `pytest tests/test_api_messages.py tests/test_scrape_msg_type.py -v` → PASS Run: `python scripts/backfill_msg_types.py` → unknown 从 ~87k 降到纯未知残余,打印计数 - [ ] **Step 8: Commit** ```bash git add -A yunke-server git commit -m "feat(msg): 类型归一化扩展(spike判别+49拆红包/转账)+存量回填+API msg_type/q过滤" ``` --- ### Task 5: sessions API `is_del`/`pinned` + accounts API `avatar` **Files:** - Modify: `yunke-server/app/api/routers/sessions.py`(`is_del` 参数 + join customer.delete_flag + pinned) - Modify: `yunke-server/app/api/schemas.py`(`SessionOut.pinned`;`AccountOut.avatar`) - Modify: `yunke-server/app/api/routers/organization.py`(accounts 构建 dict 处提取 avatar) - Test: `yunke-server/tests/test_api_sessions.py`(追加);`test_api_organization.py`(追加 avatar) - [ ] **Step 1: 写失败测试** sessions(追加): ```python async def test_sessions_is_del_filter(make_client): async def seed(session): # 员工 wxid_o 两个好友:delete_flag=0/1,各有一条消息 session.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wxid_o", delete_flag=0)) session.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wxid_o", delete_flag=1)) session.add(Message(msg_id="m1", channel="wechat", owner_account_id="wxid_o", talker_id="f1", timestamp=1)) session.add(Message(msg_id="m2", channel="wechat", owner_account_id="wxid_o", talker_id="f2", timestamp=2)) await session.commit() client = await make_client(seed) live = await client.get("/api/sessions", params={"owner_account_id": "wxid_o", "channel": "wechat", "is_del": 0}) deleted = await client.get("/api/sessions", params={"owner_account_id": "wxid_o", "channel": "wechat", "is_del": 1}) assert [s["talker_id"] for s in live.json()["sessions"]] == ["f1"] assert [s["talker_id"] for s in deleted.json()["sessions"]] == ["f2"] async def test_sessions_pinned_filehelper(make_client): # filehelper 会话返回 pinned=True(对齐云客置顶区) ...seed 一条 talker_id="filehelper" 消息... r = await client.get("/api/sessions", ...) fh = next(s for s in r.json()["sessions"] if s["talker_id"] == "filehelper") assert fh["pinned"] is True ``` organization(追加): ```python async def test_accounts_avatar_from_raw_json(make_client): # wechat_account raw_json.headImageUrl → avatar 字段;WA 行 raw_json 无该键 → None ... ``` - [ ] **Step 2: 确认失败 → 实现** sessions.py 实现要点: - `is_del: Annotated[int | None, Query()] = None` 参数;`is_del=0` 时排除 `delete_flag=1` 的 talker(注意 NULL 视为 0:`sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0)`);`is_del=1` 时只要 `delete_flag=1` - join 方式:现有 `_meta` 已查 customer——把 delete_flag 一并带出,在 Python 侧过滤(比 SQL LEFT JOIN 简单且 `_meta` 本来就每 talker 一行;注意**无 customer 行的 talker(如 filehelper)**在 is_del=0 时保留、is_del=1 时排除) - `pinned`:`sessions.append(SessionOut(..., pinned=(r.talker_id == "filehelper")))`;群不置顶 organization.py:accounts 构建 dict 处(`AccountOut` 显式构建)加 `avatar=(row.raw_json or {}).get("headImageUrl") if channel=='wechat' else None`(WA 的 raw_json 有无头像键以实际为准,有就同样取)。 - [ ] **Step 3: 跑测试通过 → Commit** ```bash git add yunke-server/app/api/routers/sessions.py yunke-server/app/api/routers/organization.py yunke-server/app/api/schemas.py yunke-server/tests/ git commit -m "feat(api): sessions加is_del过滤+filehelper置顶标记,accounts加avatar(raw_json提取)" ``` --- ### Task 6: `GET /api/moments` 端点 **Files:** - Create: `yunke-server/app/api/routers/moments.py` - Modify: `yunke-server/app/api/schemas.py`(`MomentsPostOut`/`MomentsListOut`);`yunke-server/app/api/main.py`(注册 router) - Test: `yunke-server/tests/test_api_moments.py` - [ ] **Step 1: 写失败测试** ```python async def test_moments_list_desc_with_header(make_client): async def seed(session): session.add(WechatAccount(wx_id="wxid_o", nick_name="员工昵称", raw_json={"headImageUrl": "https://wx.qlogo.cn/a/0"})) session.add(MomentsPost(id="p1", wechat_id="wxid_o", content="新帖", create_time=200)) session.add(MomentsPost(id="p2", wechat_id="wxid_o", content="旧帖", create_time=100)) session.add(MomentsPost(id="p3", wechat_id="wxid_other", content="别人", create_time=300)) await session.commit() client = await make_client(seed) r = await client.get("/api/moments", params={"wechat_id": "wxid_o", "page": 1, "page_size": 10}) body = r.json() assert body["header"] == {"nickname": "员工昵称", "avatar": "https://wx.qlogo.cn/a/0", "alias": None} assert [p["id"] for p in body["items"]] == ["p1", "p2"] # desc assert body["total"] == 2 async def test_moments_empty_staff(make_client): # 无帖子: header 仍返回账号信息, items 空 ... ``` - [ ] **Step 2: 确认失败 → 实现** ```python """GET /api/moments — 员工朋友圈帖子列表(create_time desc)+账号头部。全本地。""" router = APIRouter(prefix="/api", tags=["moments"]) @router.get("/moments") async def list_moments(session: SessionDep, wechat_id: Annotated[str, Query(min_length=1)], page: PageParam = 1, page_size: PageSizeParam = 20) -> MomentsListOut: base = [MomentsPost.wechat_id == wechat_id] total = (await session.execute(sa.select(sa.func.count()).select_from(MomentsPost).where(*base))).scalar_one() rows = (await session.execute( sa.select(MomentsPost).where(*base).order_by(MomentsPost.create_time.desc().nulls_last()) .offset((page - 1) * page_size).limit(page_size))).scalars().all() acc = (await session.execute(sa.select(WechatAccount).where(WechatAccount.wx_id == wechat_id))).scalar_one_or_none() header = {"nickname": acc.nick_name if acc else None, "avatar": (acc.raw_json or {}).get("headImageUrl") if acc else None, "alias": acc.alias if acc else None} return MomentsListOut(header=header, total=total, items=[MomentsPostOut.model_validate(r, from_attributes=True) for r in rows]) ``` (`nulls_last` 在 MySQL 会被 compat 补丁剥掉但 DESC 语义等价——2026-08-22 已验证,直接用。`PageParam/PageSizeParam` 沿用 messages.py 的 Annotated 别名。) - [ ] **Step 3: main.py 注册 `app.include_router(moments.router)` → 跑测试通过 → Commit** ```bash git add yunke-server/app/api/routers/moments.py yunke-server/app/api/schemas.py yunke-server/app/api/main.py yunke-server/tests/test_api_moments.py git commit -m "feat(api): GET /api/moments 朋友圈列表(desc分页+账号头部)" ``` --- ### Task 7: `GET /api/staff-stats` 端点(7 类计数 + 分型明细) **Files:** - Create: `yunke-server/app/api/routers/staff_stats.py` - Modify: `yunke-server/app/api/schemas.py`(计数/明细 schema);`main.py` 注册 - Test: `yunke-server/tests/test_api_staff_stats.py` 口径(spec §4.5,全部本地聚合): - 计数 `GET /api/staff-stats?wechat_id=&start=&end=` → `{new_customers, new_groups, red_transfer, del_customers, blocked_customers, voice_calls, sensitive_words: 0}` - 明细 `&kind=new_customers|new_groups|red_transfer|del_customers|blocked_customers|voice_calls` → 分型行(每 kind 字段不同) - **语音通话必须 wxId→userId 转换**:`wechat_account.user_id` join 后查 `phone_call.user_id`(直查 wxId 恒 0 行——实测坑) - 红包转账:`type_raw=49 AND type IN ('red_packet','transfer')`(Task 4 回填后) - 新客户:`add_time` 窗内 OR(add_time IS NULL AND 该对话首条消息在窗内) - [ ] **Step 1: 写失败测试**(核心口径各一条 + 转换坑一条) ```python async def test_staff_stats_counts_and_voice_user_id_join(make_client): async def seed(session): session.add(WechatAccount(wx_id="wxid_o", user_id="U32HEX")) # 新客户: add_time 窗内 1 + 兜底首消息窗内 1 session.add(Customer(friend_id="c1", channel="wechat", owner_account_id="wxid_o", add_time=1_800_000_000_000)) session.add(Customer(friend_id="c2", channel="wechat", owner_account_id="wxid_o", add_time=None)) session.add(Message(msg_id="m1", channel="wechat", owner_account_id="wxid_o", talker_id="c2", timestamp=1_800_000_000_001)) # 红包(回填后 type) session.add(Message(msg_id="m2", channel="wechat", owner_account_id="wxid_o", talker_id="c1", timestamp=1_800_000_000_002, type_raw=49, type="red_packet", text="2001")) # 语音通话: user_id 是 32HEX 非 wxId! session.add(PhoneCall(id="pc1", channel="voice_call", user_id="U32HEX", start_call_time=1_800_000_000_003, call_number="c1")) await session.commit() client = await make_client(seed) r = await client.get("/api/staff-stats", params={ "wechat_id": "wxid_o", "start": 1_700_000_000_000, "end": 1_900_000_000_000}) body = r.json() assert body["new_customers"] == 2 # add_time + 首消息兜底 assert body["red_transfer"] == 1 assert body["voice_calls"] == 1 # 直查 wxid 会得 0 —— join user_id async def test_staff_stats_detail_red_transfer_fields(make_client): # kind=red_transfer 行含 op_type/amount/time/msg_id(前端定位用) ... async def test_staff_stats_detail_new_customers_link_fields(make_client): # kind=new_customers 行含 talker_id/nickname(可点跳聊天) ... ``` - [ ] **Step 2: 确认失败 → 实现**(`staff_stats.py` 镜像 `staff_chat_stats.py` 结构:SessionDep + 分节聚合函数) ```python async def _voice_calls(session, wxid, start, end, detail: bool): row = (await session.execute(sa.select(WechatAccount.user_id).where(WechatAccount.wx_id == wxid))).scalar_one_or_none() conds = [PhoneCall.channel == "voice_call", PhoneCall.start_call_time >= start, PhoneCall.start_call_time <= end] if row: # 关键: user_id 是 32HEX 员工ID,不是 wxId conds.append(PhoneCall.user_id == row) ... ``` 明细行 schema(按 kind 分型,`extra="allow"` 或 Union;推荐统一 `rows: list[dict]`——前端按 kind 配置列,后端只管字段名稳定):`new_customers`→`{talker_id,nickname,avatar,remark,time}`;`red_transfer`→`{op_type,talker_id,is_room,room_id,nickname,amount,time,msg_id}`(金额从 text XML ``/`` 正则提取,失败 None);`new_groups`→`{room_id,room_name,time}`;`del/blocked_customers`→`{talker_id,nickname,time}`(time=updated_at 毫秒);`voice_calls`→`{id,peer,type,incoming,connected,duration,start_time,media_url}`(列对齐 CallList)。 窗口默认(不带 start/end 时):近 7 天(对齐云客统计默认)。 - [ ] **Step 3: 注册 + 跑测试通过 → Commit** ```bash git add yunke-server/app/api/routers/staff_stats.py yunke-server/app/api/schemas.py yunke-server/app/api/main.py yunke-server/tests/test_api_staff_stats.py git commit -m "feat(api): GET /api/staff-stats 7类计数+分型明细(voice走user_id join,新客户add_time兜底,红包金额提取)" ``` --- ## Phase 2 前端骨架 + 单聊 ### Task 8: 还原旧页 WIP + 路由 + API client + 工具函数 **Files:** - Modify: `yunkes/src/views/ChatTimeline.vue`(还原未提交 WIP:删 `ownerPhone/ownerAvatar/sidebarProfile` 三处 + 模板 `:staff-profile` 行) - Modify: `yunkes/src/api/index.ts`(还原 `delete_flag` 行;新增 getMoments/getStaffStats/getSessions 参数扩展) - Modify: `yunkes/src/api/types.ts`(MomentsPost/MomentsList/StaffStats/ChatSession.pinned/Account.avatar) - Modify: `yunkes/src/router/index.ts`(staff-chat 路由) - Modify: `yunkes/src/utils/format.ts`(`relativeChatTime`) - [ ] **Step 1: 还原两个文件的 WIP** ```bash cd /d/refactor/yunke && git checkout -- yunkes/src/views/ChatTimeline.vue yunkes/src/api/index.ts git status # 确认这两文件回到干净 ``` - [ ] **Step 2: 路由**(router/index.ts,chat 路由块后加) ```ts { // 员工聊天窗口(对齐云客 wechat-window 五视图): tab=single|group|moments|contacts|stats // tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转) path: '/staff-chat/:ownerId/:tab(single|group|moments|contacts|stats)?/:talkerId?', name: 'staff-chat', component: () => import('@/views/StaffChat.vue'), props: true, meta: { title: '员工聊天窗口' }, }, ``` - [ ] **Step 3: types.ts 追加** ```ts export interface ChatSession { pinned?: boolean } // 追加字段到现有 interface export interface MomentsPost { id: string; wechat_id: string; content?: string | null; type_raw?: string | null create_time?: number | null; author_name?: string | null; author_avatar?: string | null praise_json?: { name?: string }[] | null; comment_json?: unknown[] | null image_urls?: string[] | null } export interface MomentsList { header: { nickname: string | null; avatar: string | null; alias: string | null }; total: number; items: MomentsPost[] } export interface StaffStatsCounts { new_customers: number; new_groups: number; red_transfer: number; sensitive_words: number; del_customers: number; blocked_customers: number; voice_calls: number } export interface StaffStatsDetail { rows: Record[] } ``` - [ ] **Step 4: api/index.ts 追加**(getJson/clean 模式与现有一致) ```ts export interface SessionQuery { owner_account_id: string; channel?: Channel; is_del?: number } // getSessions 传参加 is_del: query.is_del export function getMoments(query: { wechat_id: string; page?: number; page_size?: number }): Promise { return getJson('/moments', clean({ wechat_id: query.wechat_id, page: query.page, page_size: query.page_size })) } export function getStaffStatsCounts(query: { wechat_id: string; start?: number; end?: number }): Promise { return getJson('/staff-stats', clean({ wechat_id: query.wechat_id, start: query.start, end: query.end })) } export function getStaffStatsDetail(query: { wechat_id: string; kind: string; start?: number; end?: number; page?: number; page_size?: number }): Promise { return getJson('/staff-stats', clean({ ...query })) } ``` (`getMessages` 的 query 类型补 `msg_type?: string; q?: string`。) - [ ] **Step 5: format.ts 加 `relativeChatTime`**(云客相对时间格式) ```ts /** 云客聊天窗口相对时间: 今天→HH:mm, 昨天→'昨天', 今年→M月D日, 往年→YYYY年MM月DD日 */ export function relativeChatTime(ms: number): string { const d = new Date(ms), now = new Date() const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString() const yest = new Date(now); yest.setDate(now.getDate() - 1) if (sameDay(d, now)) return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` if (sameDay(d, yest)) return '昨天' if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日` return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日` } ``` vitest 单测(若仓库有 format 测试文件则追加,无则新建 `src/utils/__tests__/format.spec.ts`):今天 0 点边界/昨天/今年/跨年 4 例。 - [ ] **Step 6: 类型检查 + Commit** ```bash cd yunkes && npx vue-tsc --noEmit git add -A yunkes/src && git commit -m "feat(staffchat): 路由+API client+类型+relativeChatTime(还原旧页WIP)" ``` --- ### Task 9: `StaffChat.vue` 骨架 + `StaffChatSide.vue`(资料卡+5导航+折叠) **Files:** - Create: `yunkes/src/views/StaffChat.vue` - Create: `yunkes/src/components/staffchat/StaffChatSide.vue` - [ ] **Step 1: StaffChatSide.vue**(布局参照云客:头像行+昵称大字/真名·电话/微信号小字+竖排 5 导航+底部折叠按钮) ```vue ``` (`ownerIdFallback` 用 props 传 ownerId 兜底昵称空;样式沿用 ChatSidebar 的墨玉主题变量与手法,scoped。) - [ ] **Step 2: StaffChat.vue 骨架**(路由驱动 tab + lazy 组件 + 折叠态) ```vue ``` - [ ] **Step 3: 其余 5 个子组件先建**最简占位(``),让骨架可跑 - [ ] **Step 4: `npm run dev` 手动访问 `/staff-chat/wxid_wo97u1u2x11w22`**——左栏资料卡真实数据、5 导航切换 URL 变化、折叠/展开可用 - [ ] **Step 5: vue-tsc 通过 → Commit** `feat(staffchat): 页面骨架+左侧资料卡/5导航/折叠` --- ### Task 10: `SessionPane.vue`(中列会话:双模式/已删除tab/置顶区) **Files:** - Create: `yunkes/src/components/staffchat/SessionPane.vue` 对齐云客:顶部「好友/群|聊天记录」下拉+搜索框;single 时 `聊天好友|已删除好友` tab;置顶区(filehelper+折叠条);会话卡=头像+昵称+今日N+relativeChatTime。 - [ ] **Step 1: 实现**(从 `ChatSidebar.vue` 复制改造——样式类直接沿用) ```vue ``` 模板结构(对齐云客三块):`mode 下拉(el-select 两项)` + `搜索框(list 模式 placeholder='客户昵称,微信号' 前端过滤昵称/ID; search 模式 placeholder='请输入关键词搜索' 回车调 runSearch)` + `single 时 el-tabs 聊天好友|已删除好友` + `置顶区(pinnedOpen 控制展开/折叠条文案'折叠置顶聊天')` + `会话卡列表(头像+昵称+今日N 红badge+relativeChatTime(last_chat_time))`。active 高亮按 `activeId === (talker_id ?? room_id)`。 - [ ] **Step 2: 手动验证**:单聊显示置顶区(文件传输助手)+聊天好友列表;切「已删除好友」重查(is_del=1);群聊 tab 只显群;「聊天记录」模式搜索关键词出会话 - [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 会话中列(双模式搜索+已删除好友tab+置顶区+相对时间)` --- ### Task 11: `ChatMain.vue`(右列消息流:近14天窗/类型10类/msgid 定位/导出) **Files:** - Create: `yunkes/src/components/staffchat/ChatMain.vue` 从 `ChatTimeline.vue` 主区(消息加载分页/气泡/详情 drawer/导出)复制改造;差异点: - **默认近 14 天窗**:`dateRange = ref<[Date, Date] | null>([今天-13天 00:00:00, 今天 23:59:59])`(云客默认;清空=全部时间) - **类型下拉 10 类**(值=后端 `msg_type`):文本 text/图片 image/语音消息 voice/语音通话 voice_call/视频通话 video_call/文件 file/小程序名片 mini_program→(映射 link)/链接 link/红包 red_packet/转账 transfer——**取 Task 4 最终映射对齐命名**,查询带 `msg_type` - **msgid 定位**:`route.query.msgid` 存在时,加载完成后在已加载消息中找 `msg_id` 匹配→`scrollIntoView`+高亮 class;未找到提示「该消息不在当前页,请加载更多」 - **标题**:single=`与 {对方昵称} 的聊天记录`,group=`群聊记录 · {群名}`(昵称获取逻辑从 ChatTimeline 的 loadTalkerName 复制) - 保留:加载更多/上一页(锚定滚动)、日期分隔条(MessageBubble 内已有)、导出按钮(`downloadBlob('/export/chat-html', ...)`)、消息详情 drawer(buildMessageSections) - group 时查询 `room_id=talkerId`(ChatTimeline 的 isGroup 分支逻辑复制) - [ ] **Step 1: 实现**(代码主体=ChatTimeline.vue 178-335 行的 script 迁移+上述 4 点差异;模板同构) - [ ] **Step 2: 手动验证**:默认窗生效(老对话被过滤,清空后可见);类型下拉过滤图片只剩图片;日期窗/关键词/导出复用正常;带 `?msgid=` 进入滚动定位 - [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 消息右列(近14天默认窗+10类过滤+msgid定位+导出)` --- ## Phase 3 其余视图 ### Task 12: `MomentsPane.vue`(朋友圈 feed) **Files:** - Create: `yunkes/src/components/staffchat/MomentsPane.vue` - [ ] **Step 1: 实现**:头部(`header.nickname`+avatar+alias)+ 帖卡流(`el-card` 列表:作者头像+名 → content 文本 → `el-image` 九宫格(`preview-src-list`)→ `formatTime(create_time)` → 点赞人行 `❤ name1; name2...`);滚动到底加载 `page+1`(IntersectionObserver 或 scroll 事件,`items.length < total` 时);空态「该员工暂无朋友圈数据,请先运行 moments 抓取」;图片空数组跳过九宫格 - [ ] **Step 2: 手动验证**(若库里暂无 moments 数据,先跑一次 `--entity moments` 或用 seed 脚本插两条假数据目检渲染) - [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 朋友圈feed(九宫格预览+点赞列表+滚动翻页)` ### Task 13: `ContactsPane.vue`(通讯录:客户表+群表+批量操作) **Files:** - Create: `yunkes/src/components/staffchat/ContactsPane.vue` - [ ] **Step 1: 实现** 顶部:`微信客户|微信群` el-tabs + 搜索框 + 按钮 `查询/导出(CSV复用 csvExport.ts 模式)/导出聊天记录(勾选后启用,串行 downloadBlob /export/chat-html)`。 **搜索实现前置**:`/api/customers`(customer.py)现状**没有**任何文本搜索参数(只有 channel/department_id/owner_account_id/friend_id/user_id/add_time_since/last_chat_since)。本任务先给 customer.py 加 `q: str | None`(`sa.or_(friend_nickname.like, friend_remark.like, friend_id.like, phone.like)`,几行+1 测试),客户 tab 搜索走它(placeholder 对齐云客「支持微信昵称、微信号、备注名、手机号搜索」);群 tab placeholder「请输入群名称」→ `/api/groups` 无 room_name 参数则前端过滤当前页。 客户表列(对齐云客 7+1):复选框(el-table selection)/客户微信(头像+昵称+friend_id 复用 StaffList 富单元格手法)/数据来源(固定「工作手机」)/客户备注名(friend_remark)/最近沟通时间(last_chat_time)/发送条数(send_num)/接收条数(receive_num)/操作(查看聊天记录→emit 跳 single+talkerId;导出HTML聊天记录→downloadBlob)。 群表列(对齐云客 8+1,Task 3 新列):复选框/群信息(room_name)/数据来源/群主信息(owner_name+owner_wxid)/群成员数量(member_count)/群成员好友数量(friend_count)/消息数量(message_count)/操作(查看聊天记录→跳 group+roomId;导出HTML)。 分页:`el-pagination` 总数=API total,10 条/页(对齐云客)。 - [ ] **Step 2: 手动验证**:两 tab 数据/搜索/分页/勾选批量导出启用态/操作列两个按钮行为 - [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 通讯录(客户表+群表+批量勾选导出,列对齐云客真机)` ### Task 14: `StatsPane.vue`(统计分析 7 tab) **Files:** - Create: `yunkes/src/components/staffchat/StatsPane.vue` - [ ] **Step 1: 实现** - 顶部 7 个 tab(`el-tabs`,label 带 `(N)` 计数——进入时 `getStaffStatsCounts` 拉一次,日期窗变更重拉) - 日期窗默认近 7 天(`[今天-6天 00:00, 今天 23:59:59]`),`clearable` - 明细表**列配置按 kind 驱动**(渲染通用 `el-table :columns` 思路): ```ts const COLS: Record = { new_customers: [ { prop: 'nickname', label: '客户微信', kind: 'link' }, { prop: 'remark', label: '客户备注' }, { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '时间' }, ], new_groups: [{ prop: 'room_name', label: '微信群信息', kind: 'link' }, { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '时间' }], red_transfer: [ { prop: 'op_type', label: '操作类型' }, { prop: 'nickname', label: '客户微信', kind: 'link' }, { prop: 'staff', label: '员工微信' }, { prop: 'amount', label: '金额' }, { prop: 'time', label: '发生时间' }, { prop: '_action', label: '操作' }, ], del_customers: [{ prop: 'nickname', label: '客户微信', kind: 'link' }, { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '发生时间' }], blocked_customers: /* 同 del_customers */, voice_calls: /* CallList 列:通话人/通话类型/来源/呼入呼出/接通状态/通话录音/通话时间/操作——从 CallList.vue 抄列模板 */, sensitive: [], } ``` - `link` 列:客户→跳 single+talkerId;群→跳 group+roomId;red_transfer `_action`→跳 single/group + `?msgid=` 定位 - 敏感词 tab:空态卡「词库未配置,待 C1 敏感词功能接入」 - 金额列 `amount ?? '—'`(云客本条也显 `-`) - time 格式化 formatTime - [ ] **Step 2: 手动验证**:7 tab 计数徽标(对本地库算的数抽查 1-2 个 SQL 核对);red_transfer 行点查看跳聊天并定位;日期窗变更重查 - [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 统计分析7tab(分型明细列+msgid定位+近7天默认窗)` --- ## Phase 4 入口 + 走查 ### Task 15: 入口改造 + Playwright 对照走查 **Files:** - Modify: `yunkes/src/views/StaffList.vue`(微信行 viewChat→staff-chat;modal openCustomerChat→staff-chat) - Modify: `yunkes/src/views/CustomerList.vue`(微信行查看聊天→staff-chat) - [ ] **Step 1: StaffList.vue 两处跳转改** ```ts async function viewChat(row: Account) { // 微信走五视图聊天窗口(2026-08-25 对齐云客);WA 保持旧双栏 if (row.channel === 'wechat') { await router.push({ name: 'staff-chat', params: { ownerId: row.account_id, tab: 'single' } }) } else { await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } }) } } // openCustomerChat 同理 wechat 分支 → { name: 'staff-chat', params: { ownerId, tab: 'single', talkerId } } + query 保留 start/end/msgid ``` - [ ] **Step 2: CustomerList.vue** 微信 tab「查看聊天记录」同样 wechat 分支切 staff-chat(现有跳转在 ~line 252-267 的 viewChat 及其 drawer 包装处,以「查看聊天记录」grep 定位为准) - [ ] **Step 3: 全链路手动/Playwright 走查**(对照云客真机,逐项勾): - 员工列表昵称→新页;资料卡四要素;5 导航切换 URL/内容 - 单聊:置顶区/已删除 tab/双模式搜索/默认窗/类型过滤/导出/msgid 定位 - 群聊:无已删除 tab,点群右侧群消息带发送者 - 朋友圈:feed 渲染/九宫格预览/翻页 - 通讯录:两 tab 列/搜索/分页/勾选批量/操作列 - 统计:7 tab 计数/明细列型/链接跳转 - WA 侧回归:WA 员工→旧 ChatTimeline 正常(未受影响) - [ ] **Step 4: 后端全量测试 + 前端构建** ```bash cd yunke-server && .venv/Scripts/python.exe -m pytest -q # 全绿 cd ../yunkes && npx vue-tsc --noEmit && npm run build # 通过 ``` - [ ] **Step 5: Commit** `feat(staffchat): 入口改造(StaffList/CustomerList微信行)+全链路走查` --- ## 附:判别记录(Task 4 Step 1 spike 结论,2026-08-25 实测 smoke.db,每类 ≥3 样本) **核心发现:云客 API 双编码**——`raw_json.messageType`=经典微信协议码(1/3/34/43/47/42/48/49/50/10000+合成大数),`raw_json.type`=云客自编码;DB `type_raw` 列两者混合。归一化采用 **messageType 优先 → 49/appmsg 行级拆分 → 云客码兜底** 三层。 | type_raw(云客码) | messageType | 行数 | 样本摘要 | 判定 type | 置信度 | |---|---|---|---|---|---| | 1 | '1' | 473,922 | 普通会话文本 | text | 强 | | 2 | '3' | 50,154 | md5+OSS fileTh URL | image | 强 | | 3 | **'34'** | **8,754** | **file=语音hex键、text=时长数字** | **voice(旧 3→image_legacy 系误标,已回填修正)** | 强 | | 3 | '3' | 201 | md5+OSS 图片 | image | 强 | | 4 | '43' | 4,835 | picType/md5 视频 | video | 强 | | 5 | '436207665' | 625 | file=wx.gtimg.com/hongbao 封面 | red_packet | 强 | | 6 | '50' | 2,077 | VOIP+duration 无 text | voice_call | 中(6/7 同为 mt=50 无区分字段,按频率先验;最坏 77 行互换) | | 7 | '50' | 77 | 同上 | video_call | 中(同上) | | 8 | '47' | 4,224 | tc.qq.com 表情 CDN | emoji | 强 | | 9 | '49' | 9,075 | xlsx/pdf 文件名+OSS file | file | 强 | | 10 | '49' | 6,868 | 92% 带封面图(车源模板文本) | link | 强 | | 11 | '419430449' | 386 | text="发送转账:¥100.00" | transfer | 强 | | 13 | '42' | 950 | text=昵称,file=头像 | namecard | 强 | | 14 | '48' | 647 | text=地址,file="lat,lng" | location | 强 | | 15 / 10000 | '10000'/合成码 | 18,489 | "你已添加了…打招呼消息" | system | 强 | | 18 | '49' | 4,792 | 99.8% 公众号封面 mmbiz.qpic.cn | link | 强 | | 20 | '754974769' | 129 | 视频号封面+话题#标签 | link | 中(视频号分享,非原生视频) | | 21 非XML | '822083633' | 30,165 | 全部带 referMsg(引用回复) | text | 强 | | 21 XML | '49' | 1,701 | `19` 聊天记录 | merged_forward | 强 | | 22 | '922746929' | 1,877 | 零 text,带 patMsgRecordNum/patMsgs(拍一拍) | unknown(保守——拍一拍无规范类型,system 会渲染空) | 证据不足留 unknown | | 19 | '-1879048186' | 5 | 空文本无字段 | unknown | 证据不足 | | 49 旧行 | '49' | 202 | raw_json.type 子码 10/21/18/9 | link(143)/file(52)/merged_forward(7) | 强 | - paymsgid 实测数据全空(红包/转账真实判据=hongbao 封面 URL 与"发送转账"文本);paymsgid 非空且无 XML type → transfer(转账语义,比 plan 草稿更准) - 回填结果(2026-08-25 实跑):scanned 620,279 / updated 94,268 / unknown 87,198→1,885 / image_legacy 8,955→0;终态 text 504,174 · system 18,489 · image 50,355 · link 11,932 · file 9,075 · voice 8,815 · video 4,835 · emoji 4,242 · voice_call 2,077 · merged_forward 1,704 · namecard 953 · location 651 · red_packet 625 · transfer 390 · video_call 77 - **范围外遗留**:`app/scraper/parsers.py normalize_wechat_type`(orchestrator 旧路径)仍写旧标签——orchestrator 运行后需重跑回填自愈;WhatsApp 渠道 unknown ~8,800 行是另一套编码,建议独立任务 - **前端 Task 11 注意**:时间线会出现下拉之外的 type 标签(system/emoji/namecard/location/merged_forward/unknown)——气泡渲染需兜底;mini_program 无样本行(查空属数据现实)