Commit Graph

13 Commits

Author SHA1 Message Date
浅念 6ff288f1d3 feat(groups): chat_group 补 6 群主/建群列 + 复合PK迁移修复 + 存量回填
对齐云客员工聊天窗口·微信群 tab(2026-08-25 真机验证 raw_json 键):
- ChatGroup 加列 owner_name/owner_wxid/owner_alias/owner_avatar/
  friend_count/create_time(全 nullable)
- scraper _normalize_chat_group 同名提取;createTime 兼容 ms-int 与
  'YYYY-MM-DD HH:mm:ss'(smoke.db 实测 1105 行全为后者),复用
  _parse_add_time 归一 ms-epoch
- 迁移 099ac2097fa2:加 6 列 + 修复 Task1 评审遗留 drift——PK 由单列
  room_id 变更为复合 (room_id, channel, owner_account_id) 且
  owner_account_id NOT NULL(对齐模型 8a99940;smoke.db 1105 行无 NULL,
  未兜底编造数据);SQLite 走 batch 重建(copy_from 显式旧结构),
  MySQL 渲染为直接 ALTER(离线 --sql 已验证)
- 回填脚本 scripts/backfill_chat_group_fields.py(幂等,逐列只补空);
  smoke.db 实跑 backfilled 1105/1105,重跑 0/1105
- GroupOut 加 6 字段(model_validate 自动输出,路由无改)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 14:32:12 +08:00
浅念 d220fa1a42 fix(scraper): 群抓取改用 friend_list/es+friendTypeYunke=2(替换 getChatroomList)
getChatroomList 在云客真机返回 0 行(已 Playwright 2026-08-10 验证)。
群与好友共用 /wechatweb/wxFriends/friend_list/es,仅以 friendTypeYunke 区分
(1=好友 / 2=群),响应信封一致(basicResult),复用 _parse_total/_parse_rows。

- 删除 CHATROOM_LIST_PATH / _parse_chatroom_total / _parse_chatroom_rows
  / _build_chatroom_body / _parse_member_ids(改写后已无引用)
- 新增 _build_group_body(pageNum/pageSize/friendTypeYunke="2")
- 重写 _normalize_chat_group 映射 friend_list/es 字段
  (friendWxId->room_id, friendWxNickname->room_name, friendNum->member_count,
   wxId/ownerName/userId, phone||wechatPhone, messageCount/sendNum/lastChatTime)
- friend_list/es 不返回成员明细,member_ids/member_ids_list 固定 None/[]
  (群列表可用;成员镜像延后)
- 删除 tests/cassettes/chatroom_fixture.py,改复用 wxFriends_fixture 信封
- 重写 test_scrape_wechat_groups:断言新端点 URL + friendTypeYunke="2" body
  + 新字段映射;member_ids 边界用例改为"始终 None/[]"

scrape_wechat_groups 签名不变(client + page_size),orchestrator/scheduler 未改。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 15:33:59 +08:00
浅念 79b89bc919 fix(scraper): voice body 加 departmentId/isRoom/时间窗 + sync 解析 root department_id
实测 queryWeChatVoiceList 必须带 callSource+departmentId+isRoom+startTime+endTime,
否则 200 但 data 空(0 数据)。WA callSource=4 / 微信 callSource=0,宽窗抓全量。
sync_*_voice_calls 照 sync_wechat_accounts 模式解析 _find_root_department_id。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 09:49:31 +08:00
浅念 8494d564ef feat(scraper): map customer add_time from raw_json
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 18:44:04 +08:00
浅念 0df53c6b1a fix: wechat account scrape (form-data+dates) + frontend staff page
Backend (scrape_wechat_accounts):
- Send wxUser/list as form-urlencoded (not JSON) with the REQUIRED filters
  isOnJob / departmentId / startdate / enddate + empty userId/salesname/
  weChatNickName, matching the live request (empty fails with code -90000).
- scrape_wechat_accounts now takes department_id (the company-root dept id)
  plus optional start/end defaulting to a recent 7-day UTC window.
- paginate_by_page gains a form=True mode (data= + form content-type).
- Orchestrator sync_wechat_accounts resolves the root dept from the
  Department table (or accepts one) and skips with a warning when unknown.
- seed_accounts.py resolves the root via scrape_departments.

Frontend (yunkes):
- Add StaffList.vue (/staff) with is_on_job filter + pagination.
- Add /staff route and a 员工 nav item between 客户 and 群.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:11:17 +08:00
浅念 4dcfc66741 fix: drop source header (breaks msg/chats) + context-aware talker_id
Live validation found two real bugs:
- YunkeClient sent 'source: yunkepc' on every request; msg/chats binds
  source as Integer -> 500. Removed (cookie auth suffices, per Phase 0).
- WeChat message talker_id used the masked 'talker' field; the timeline
  joins on the full peer id. Now: 1:1 -> oriTalker (full), group -> talker
  (member). ori_talker keeps the raw oriTalker (peer/chatroom).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 16:52:48 +08:00
浅念 44d64dd237 feat: phone-call + sms scrapers
M4 (/pc/ CRM module): mirror phone-call + SMS endpoints.

Scraper (app/scraper/call.py):
- scrape_phone_calls: POST /pc/callLog/list -> PhoneCall (channel='phone_call')
  Envelope data.data / data.totalCount / data.pageCount; page-number pagination.
  incomingCall (1=incoming/0=outgoing) stored DIRECTLY (no inversion, unlike
  voice-call isSend); callSeconds preferred over legacy callDuration string;
  startCallTime (YYYY-MM-DD HH:MM:SS) -> ms epoch.
- scrape_sms: POST /pc/customer/smsLogsList -> Sms (channel='sms')
  Envelope data.data / data.dataCount (NOT totalCount), no pageCount;
  relies on empty-page stop signal. createTime (YYYY-MM-DD HH:MM) -> ms epoch;
  empty smsSub normalized to None content_preview.

Shared helper: extracted parse_datetime_to_ms(value, formats) to
app/scraper/parsers.py and refactored _parse_voice_start_time to delegate
(DRY across all three call/SMS datetime sources).

Repos: upsert_phone_call + upsert_sms (idempotent Core upsert by call_id /
sms_id). Added create_time to _INT_FIELDS for defensive numeric-str coercion,
matching start_call_time's existing treatment.

Orchestrator: sync_phone_calls + sync_sms (full re-scrape + idempotent upsert
+ SyncRun log + watermark), mirroring sync_wechat_voice_calls. Entity types
'phone_call' and 'sms'.

Tests (+15, suite 211 -> 226): respx-mocked scraper tests (2-page fixtures
exercising pagination + full field mapping), repo idempotency + coercion
tests, orchestrator SyncRun/watermark tests with monkeypatched scrapers.
ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 15:59:25 +08:00
浅念 41b9ec2571 feat: wechat voice-call scraper + group message coverage
- scrape_wechat_voice_calls: wraps POST /wechatweb/callRecord/queryWeChatVoiceList
  with page-number pagination (reuses paginate_by_page); normalizes records to
  the PhoneCall column shape with channel='voice_call'. Field mapping per
  phase0-findings §4: id->call_id, duration(str s)->call_duration(int),
  startTime(YYYY-MM-DD HH:MM:SS)->start_call_time(ms epoch), callStatus->
  call_status, talker->call_number, talkerNickName->plan_customer_name,
  ossFileName->record_file (may be null). isSend (0=in/1=out) is inverted to
  incoming_call (1=in/0=out) so the column's meaning matches the phone_call
  channel.
- upsert_voice_call: idempotent Core upsert into PhoneCall by call_id
  (sqlite/mysql dialect-aware). Adds call_duration/start_call_time to the
  _INT_FIELDS coercion net (scraper already emits ints; defensive for
  hand-built records).
- Group-message test (Task 2.3): locks in that scrape_wechat_messages already
  handles chatroom conversations (friendId=@chatroom, roomid set, talker=member
  wxid) — yields room_id populated and talker_id = the sending member. No
  scraper change required.

Suite: 123 -> 130 passing. ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:03:47 +08:00
浅念 2a74d3888d feat: wechat group+member and talker scrapers
Add scrape_wechat_groups (POST /wechatweb/chatroom/getChatroomList) and
scrape_wechat_talkers (POST /wechatweb/wxFriends/talkerList), both reusing
the page-number paginator. Groups parse the semicolon-joined memberIds into
a member_ids_list so upsert_chat_group can mirror GroupMember rows
(composite PK channel/room_id/member_id) idempotently alongside the
ChatGroup row. Talkers are a sync-planning input (member_count>0 marks a
group conversation); no dedicated table.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 13:54:16 +08:00
浅念 2b79e7a232 feat: department + wechat account/staff scrapers
M2 organization scrapers for yunke-server:

- scrape_departments: POST /usercenter/dept/tree. Single request (no
  pagination). Defensively unwraps the envelope (data is node list OR an
  object wrapping nodes under children), flattens BOTH nested and flat
  shapes, and reconstructs the materialized path by walking the pId chain
  (ancestor names joined with /). Maps id/pId/name -> dept_id/parent_id/name.

- scrape_wechat_accounts: POST /wechatweb/wxUser/list. Page-number
  pagination via the existing paginate_by_page helper. Envelope differs
  from friend_list (no basicResult wrapper): data.data/data.total. Maps
  wechatId/nickName/alias/phone/userId/userName/contactsNum/groupNum/
  sendNum/receiveNum/isOnJob/dbSize/onlineStatus/lastUpdate/version to
  WechatAccount columns; carries embedded staff pair for the repo layer.

Repos:
- upsert_department: idempotent upsert into Department (PK dept_id).
- upsert_staff: primitive idempotent staff upsert (PK user_id, is_on_job
  coerced to bool).
- upsert_wechat_account: now ALSO upserts the embedded Staff row via
  upsert_staff_from_account (best-effort; skips when account has no user_id).
- upsert_staff_from_account: refactored to delegate to upsert_staff and
  forward phone/sale_phone/is_on_job when present.

Tests (TDD red->green): test_scrape_departments (nested + flat + wrapped-data
+ empty), test_scrape_wechat_accounts (2-page pagination + body advance +
empty first page), test_repos extended (dept idempotency/root parent, direct
staff upsert + bool coercion, account-also-upserts-staff, re-upsert updates
both, no-user_id skips staff). Cassettes: dept_tree_fixture (same 4 nodes in
both nested + flat shapes), wxUser_fixture (4 capture-shaped accounts incl.
departed + orphan).

Suite: 97 -> 111 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 13:46:23 +08:00
浅念 5a2ff58af3 fix: split message type into normalized label + raw code
The scraper was writing a normalized type label ("text"/"unknown"/...) into
Message.type, but the column was Integer, so the storage layer coerced it to
NULL and the raw code was only recoverable from raw_json. Align with design
§4.3, which specifies two fields: `type` (normalized enum) and `type_raw`
(original code).

- Model (Message): type -> String(32) normalized label; add type_raw Integer
  for the raw upstream type code.
- Scraper: _normalize_message sets both `type` (label) and `type_raw`
  (coerced int from messageType/type); _is_divider now checks raw -2.
- Repos: type_raw joins _INT_FIELDS; Message.type stored as-is (string).
- Migration 96975050fa79: add type_raw + alter type Integer->String(32),
  wrapped in batch_alter_table so it runs on SQLite and MySQL.
- env.py: enable render_as_batch on SQLite (dev/test) for portable batch
  migrations; MySQL still uses direct ALTER.
- Tests updated end-to-end (scraper, repos, models).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:04:08 +08:00
浅念 903286f379 feat: wechat message scraper
Adds Task 1.6: a backward-cursor paginator in base.py, a message-type
normalizer in parsers.py, and the WeChat chat-message scraper in wechat.py.

- app/scraper/parsers.py: normalize_wechat_type() maps 1 -> "text",
  -2 -> "divider", everything else -> "unknown". Total (never raises);
  unknown codes pass through so no real message is silently dropped.
- app/scraper/base.py: paginate_by_cursor() generic async generator. Takes
  a fetch(cursor) -> (rows, next_cursor, has_more) coroutine; advances the
  cursor and stops on has_more=False OR an empty page (defensive). Same
  RateLimiter integration as paginate_by_page.
- app/scraper/wechat.py: scrape_wechat_messages() wraps
  POST /wechatweb/msg/chats. Body is form-encoded (NOT JSON) — content-type
  is overridden per request to application/x-www-form-urlencoded via the
  YunkeClient.post(headers=...) override hook. Pages backward by setting
  end = data.end - 1 each batch, repeats until hasLast=false. Drops any
  record whose type/messageType is -2 (date divider UI chrome). Yields
  normalized dicts (snake_case Message columns + channel="wechat" +
  raw_json); the raw type code is preserved on raw_json for the
  persistence layer (Task 1.7).

Tests: 13 new (10 parser parametrized + 3 scraper). respx-mocked
msg/chats with two backward pages — asserts [m1, m2, m3] yielded (the -2
divider dropped), full field mapping vs the live-capture names, the
content-type is form-encoded (not JSON), and the 2nd request body's
end == E1 - 1 (proving the backward cursor advance).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:39:55 +08:00
浅念 4a47e824c7 feat: wechat friend scraper
Add the first list scraper for the yunke data-mirror system, establishing
the reusable pagination pattern all later list scrapers will build on.

- app/scraper/base.py: generic `paginate_by_page` async generator that walks
  page-numbered endpoints until a page comes back empty (the yunke CRM's
  primary "no more rows" signal), with a defensive over-fetch ceiling keyed
  off the declared total (strict `>` so an honest source yields exactly
  `total`). Plus a tiny `RateLimiter` enforcing a min interval between calls.
- app/scraper/wechat.py: `scrape_wechat_friends` wraps
  `POST /wechatweb/wxFriends/friend_list/es`, maps each record to the
  `Customer` column shape (snake_case) with `channel="wechat"` and a verbatim
  `raw_json` copy. Optional `department_id`/`user_id` filters pass through to
  the request body.
- tests/cassettes/wxFriends_fixture.py: two realistic, capture-shaped friend
  records + a response-envelope builder, reused by the scraper tests.
- tests/test_scrape_wechat_friends.py: drives the real pagination +
  normalization logic through respx (page1 returns both records, page2 empty
  -> stop). Asserts exact field mappings, `raw_json` fidelity, and exactly 2
  upstream calls.
- tests/test_scrape_base.py: smoke tests for the RateLimiter spacing.

Persistence (upsert into `Customer`) is deliberately deferred to Task 1.7;
scrapers remain pure producers of normalized dicts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:31:28 +08:00