c21758dfb2
Co-Authored-By: Claude <noreply@anthropic.com>
1024 lines
57 KiB
Markdown
1024 lines
57 KiB
Markdown
# 云客员工聊天窗口对齐(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/<file> -v`(Windows Git Bash)
|
||
- 前端测试/构建:`cd yunkes && npm run test 2>/dev/null || npx vitest run <file>`;类型检查 `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/<rev>_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 ... -> <rev>, 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 <wxid> +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/<rev>_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": "<msg><appmsg><type>2001</type>"}) == "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="<msg><appmsg><type>2001</type>"))
|
||
# 语音通话: 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 `<feedesc>`/`<totalamount>` 正则提取,失败 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<string, unknown>[] }
|
||
```
|
||
|
||
- [ ] **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<MomentsList> {
|
||
return getJson<MomentsList>('/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<StaffStatsCounts> {
|
||
return getJson<StaffStatsCounts>('/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<StaffStatsDetail> {
|
||
return getJson<StaffStatsDetail>('/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
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import type { Account } from '@/api/types'
|
||
|
||
const props = defineProps<{ profile: Account | null; tab: string }>()
|
||
const emit = defineEmits<{ select: [tab: string]; collapse: [] }>()
|
||
const NAVS = [
|
||
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
|
||
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
|
||
] as const
|
||
const phone = computed(() => props.profile?.phone || '')
|
||
</script>
|
||
|
||
<template>
|
||
<div class="sc-side">
|
||
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客) -->
|
||
<div class="sc-side__card">
|
||
<el-avatar :size="44" :src="profile?.avatar ?? undefined">{{ profile?.nickname?.slice(0, 1) }}</el-avatar>
|
||
<div class="sc-side__who">
|
||
<div class="sc-side__nick">{{ profile?.nickname || ownerIdFallback }}</div>
|
||
<div class="sc-side__meta">{{ profile?.user_name }} {{ phone }}</div>
|
||
<div class="sc-side__meta">{{ profile?.account_id }}</div>
|
||
</div>
|
||
</div>
|
||
<nav class="sc-side__nav">
|
||
<button v-for="n in NAVS" :key="n.key" class="sc-side__navbtn" :class="{ 'is-active': tab === n.key }" @click="emit('select', n.key)">{{ n.label }}</button>
|
||
</nav>
|
||
<button class="sc-side__collapse" @click="emit('collapse')">收起侧边栏</button>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
(`ownerIdFallback` 用 props 传 ownerId 兜底昵称空;样式沿用 ChatSidebar 的墨玉主题变量与手法,scoped。)
|
||
|
||
- [ ] **Step 2: StaffChat.vue 骨架**(路由驱动 tab + lazy 组件 + 折叠态)
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { getAccounts } from '@/api'
|
||
import type { Account } from '@/api/types'
|
||
import StaffChatSide from '@/components/staffchat/StaffChatSide.vue'
|
||
import SessionPane from '@/components/staffchat/SessionPane.vue'
|
||
import ChatMain from '@/components/staffchat/ChatMain.vue'
|
||
import MomentsPane from '@/components/staffchat/MomentsPane.vue'
|
||
import ContactsPane from '@/components/staffchat/ContactsPane.vue'
|
||
import StatsPane from '@/components/staffchat/StatsPane.vue'
|
||
|
||
const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
|
||
const route = useRoute(); const router = useRouter()
|
||
const VALID = ['single', 'group', 'moments', 'contacts', 'stats']
|
||
const tab = computed(() => (props.tab && VALID.includes(props.tab) ? props.tab : 'single'))
|
||
// 非法 tab 重定向
|
||
watch(() => props.tab, (t) => { if (t && !VALID.includes(t)) router.replace({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: 'single' } }) }, { immediate: true })
|
||
|
||
const profile = ref<Account | null>(null)
|
||
const collapsed = ref(false)
|
||
async function loadProfile() {
|
||
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||
profile.value = res.items.find(a => a.account_id === props.ownerId) ?? null
|
||
}
|
||
watch(() => props.ownerId, loadProfile, { immediate: true })
|
||
|
||
/** 导航切换: 保留 ownerId, 清 talkerId(跨视图无意义); single↔group 保留 id 语义转换不做(云客也清) */
|
||
function onSelectTab(t: string) { router.push({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: t } }) }
|
||
/** 会话点击: single→talkerId, group→roomId 塞进 talkerId 段 */
|
||
function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | null; room_id?: string | null }) {
|
||
router.push({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: s.type === 'group' ? 'group' : 'single', talkerId: s.talker_id ?? s.room_id ?? '' }, query: route.query.msgid ? { msgid: route.query.msgid } : {} })
|
||
}
|
||
const chatMode = computed(() => tab.value === 'single' || tab.value === 'group')
|
||
</script>
|
||
|
||
<template>
|
||
<div class="sc-root" :class="{ 'is-collapsed': collapsed }">
|
||
<StaffChatSide v-if="!collapsed" :profile="profile" :owner-id-fallback="ownerId" :tab="tab" @select="onSelectTab" @collapse="collapsed = true" />
|
||
<button v-else class="sc-root__expand" @click="collapsed = false">展开</button>
|
||
<SessionPane v-if="chatMode" :channel="'wechat'" :owner-id="ownerId" :kind="tab" :active-id="talkerId" @select="onSelectSession" />
|
||
<ChatMain v-if="chatMode" :owner-id="ownerId" :kind="tab" :talker-id="talkerId" />
|
||
<MomentsPane v-else-if="tab === 'moments'" :owner-id="ownerId" />
|
||
<ContactsPane v-else-if="tab === 'contacts'" :owner-id="ownerId" />
|
||
<StatsPane v-else-if="tab === 'stats'" :owner-id="ownerId" />
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 3: 其余 5 个子组件先建**最简占位(`<template><div class="yk-empty">实现于 Task N</div></template>`),让骨架可跑
|
||
- [ ] **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
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { getMessages, getSessions } from '@/api'
|
||
import type { ChatSession } from '@/api/types'
|
||
import { relativeChatTime } from '@/utils/format'
|
||
|
||
const props = defineProps<{ channel: string; ownerId: string; kind: 'single' | 'group'; activeId?: string }>()
|
||
const emit = defineEmits<{ select: [ChatSession] }>()
|
||
|
||
const mode = ref<'list' | 'search'>('list') // 好友/群 | 聊天记录
|
||
const friendTab = ref<'live' | 'deleted'>('live') // 聊天好友 | 已删除好友 (仅 single)
|
||
const keyword = ref('')
|
||
const sessions = ref<ChatSession[]>([])
|
||
const loading = ref(false)
|
||
const pinnedOpen = ref(true)
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await getSessions({ owner_account_id: props.ownerId, channel: props.channel as never,
|
||
...(props.kind === 'single' ? { is_del: friendTab.value === 'deleted' ? 1 : 0 } : {}) })
|
||
sessions.value = res.sessions
|
||
} finally { loading.value = false }
|
||
}
|
||
watch(() => [props.ownerId, props.kind, friendTab.value] as const, load, { immediate: true })
|
||
|
||
/** group 视图复用同一列表但只显群(sessions 已含 type);kind=group 时服务端 type 参数后端 sessions 不分——前端过滤即可 */
|
||
const visible = computed(() => {
|
||
let list = mode.value === 'list' ? sessions.value : searchResults.value
|
||
if (props.kind === 'group') list = list.filter(s => s.type === 'group')
|
||
else list = list.filter(s => s.type === 'friend')
|
||
if (props.kind !== 'single' || friendTab.value !== 'deleted') {
|
||
// 聊天好友视图: 置顶单独一区,不重复出现在列表
|
||
return list.filter(s => !s.pinned)
|
||
}
|
||
return list
|
||
})
|
||
const pinned = computed(() => (props.kind === 'single' ? sessions.value.filter(s => s.pinned) : []))
|
||
|
||
/** 聊天记录模式: 关键词全量搜消息(q 参数),结果按会话聚合去重显示 */
|
||
const searchResults = ref<ChatSession[]>([])
|
||
async function runSearch() {
|
||
const k = keyword.value.trim()
|
||
if (mode.value !== 'search' || !k) { searchResults.value = []; return }
|
||
const res = await getMessages({ channel: props.channel as never, owner_account_id: props.ownerId, q: k, page: 1, page_size: 200 } as never)
|
||
const seen = new Map<string, ChatSession>()
|
||
for (const m of res.items) {
|
||
const key = m.room_id ?? m.talker_id ?? ''
|
||
if (!seen.has(key)) seen.set(key, { type: m.room_id ? 'group' : 'friend', talker_id: m.talker_id, room_id: m.room_id, name: m.talker_id, today_count: 0, last_chat_time: m.timestamp })
|
||
}
|
||
searchResults.value = [...seen.values()]
|
||
}
|
||
</script>
|
||
```
|
||
|
||
模板结构(对齐云客三块):`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<string, { prop: string; label: string; kind?: 'link' | 'amount' }[]> = {
|
||
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 | `<type>19</type>` 聊天记录 | 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 无样本行(查空属数据现实)
|