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>
This commit is contained in:
浅念
2026-08-01 14:03:47 +08:00
parent 2a74d3888d
commit 41b9ec2571
6 changed files with 923 additions and 5 deletions
+83
View File
@@ -28,6 +28,7 @@ from app.db.models import (
Department,
GroupMember,
Message,
PhoneCall,
Staff,
WechatAccount,
)
@@ -41,6 +42,7 @@ from app.storage.repos import (
upsert_messages,
upsert_staff,
upsert_staff_from_account,
upsert_voice_call,
upsert_wechat_account,
)
@@ -885,3 +887,84 @@ async def test_upsert_chat_group_with_no_members_writes_group_only():
assert members == []
finally:
await engine.dispose()
# ---------------------------------------------------------------------------
# PhoneCall (voice_call) upsert (Task 2.4)
# ---------------------------------------------------------------------------
async def test_upsert_voice_call_is_idempotent_and_updates_record_file():
"""Same call_id twice with different record_file/version -> 1 row, refreshed."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec1 = {
"call_id": "vc_dup",
"channel": "voice_call",
"record_file": "https://example.com/call_v1.mp3",
"call_duration": 10,
"version": "v1",
}
rec2 = {
"call_id": "vc_dup",
"channel": "voice_call",
"record_file": "https://example.com/call_v2.mp3",
"call_duration": 12,
"version": "v2",
}
await upsert_voice_call(session, rec1)
await session.commit()
await upsert_voice_call(session, rec2)
await session.commit()
rows = (
await session.execute(
sa.select(PhoneCall)
.where(PhoneCall.call_id == "vc_dup")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(rows) == 1
assert rows[0].call_id == "vc_dup"
assert rows[0].channel == "voice_call"
assert rows[0].record_file == "https://example.com/call_v2.mp3"
assert rows[0].call_duration == 12
assert rows[0].version == "v2"
finally:
await engine.dispose()
async def test_upsert_voice_call_null_record_file_and_coerces_ints():
"""null record_file -> None; numeric-str call_duration/start_call_time/
call_status/incoming_call coerced to int."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"call_id": "vc_null",
"channel": "voice_call",
"record_file": None,
"call_duration": "151", # str -> int
"start_call_time": "1700000000123", # numeric str -> int (ms epoch)
"call_status": "1", # str -> int
"incoming_call": "1", # str -> int
}
await upsert_voice_call(session, rec)
await session.commit()
fetched = (
await session.execute(
sa.select(PhoneCall).where(PhoneCall.call_id == "vc_null")
)
).scalar_one()
assert fetched.record_file is None
assert fetched.call_duration == 151
assert fetched.start_call_time == 1700000000123
assert fetched.call_status == 1
assert fetched.incoming_call == 1
finally:
await engine.dispose()