#!/usr/bin/env python """从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。 云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到, 2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/ sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖 所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。 未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id 命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。 幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。 用法: 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/sender_head_url: # MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口 # 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为 SQL = sa_text(""" SELECT room_id, talker_id, sender_name, sender_head_url, last_ts, msg_count FROM ( SELECT room_id, talker_id, sender_name, sender_head_url, 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 """) # 未发言成员兜底:member_id 命中 customer(本司好友)时补 NULL 的昵称/头像。 # customer 全局一人一行(PK=friend_id),join 不放大行数;COALESCE 只补不清。 CUSTOMER_FALLBACK = sa_text(""" UPDATE group_member gm JOIN customer c ON c.friend_id = gm.member_id AND c.channel = gm.channel SET gm.nickname = COALESCE(gm.nickname, c.friend_nickname), gm.avatar = COALESCE(gm.avatar, c.head_image_url) WHERE gm.channel = 'wechat' AND (gm.nickname IS NULL OR gm.avatar IS NULL) """) 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, head_url, last_ts, msg_count in rows: await _upsert_one(session, GroupMember, { "channel": "wechat", "room_id": room_id, "member_id": talker_id, "nickname": sender_name, "avatar": head_url, # raw_json 附加派生统计,抽屉可见 "raw_json": { "derived_from": "group_messages", "msg_count": msg_count, "last_msg_ts": last_ts, }, }) await session.commit() # 兜底补未发言成员(独立事务,幂等) async with async_engine.begin() as conn: res = await conn.execute(CUSTOMER_FALLBACK) log.info(f"customer 兜底补全 {res.rowcount} 行昵称/头像") log.info("DONE: group_member upsert 完成") if __name__ == "__main__": asyncio.run(main())