#!/usr/bin/env python """从群消息派生 group_member(发言者=成员)。 云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到, 2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其 群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。 昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为, 取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。 用法: cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \ .venv/Scripts/python.exe -m scripts.backfill_group_members """ from __future__ import annotations import asyncio import logging from sqlalchemy import text as sa_text from app.db.base import async_engine, async_session_maker from app.db.models import GroupMember from app.storage.repos import _upsert_one logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("backfill_group_members") # row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的 # only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用 # 且语义严格(最新一条的昵称),不依赖方言碰巧行为 SQL = sa_text(""" SELECT room_id, talker_id, sender_name, last_ts, msg_count FROM ( SELECT room_id, talker_id, sender_name, timestamp AS last_ts, count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count, row_number() OVER ( PARTITION BY room_id, talker_id ORDER BY timestamp DESC ) AS rn FROM message WHERE channel = 'wechat' AND room_id IS NOT NULL AND room_id != '' AND talker_id IS NOT NULL AND talker_id != '' ) t WHERE rn = 1 """) async def main() -> None: async with async_engine.connect() as conn: rows = (await conn.execute(SQL)).fetchall() log.info(f"派生出 {len(rows)} 个 (群, 成员) 对") async with async_session_maker() as session: for room_id, talker_id, sender_name, last_ts, msg_count in rows: await _upsert_one(session, GroupMember, { "channel": "wechat", "room_id": room_id, "member_id": talker_id, "nickname": sender_name, # raw_json 附加派生统计,抽屉可见 "raw_json": { "derived_from": "group_messages", "msg_count": msg_count, "last_msg_ts": last_ts, }, }) await session.commit() log.info(f"DONE: group_member upserted={len(rows)}") if __name__ == "__main__": asyncio.run(main())