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>
This commit is contained in:
浅念
2026-08-25 14:32:12 +08:00
parent 90c28f4c7b
commit 6ff288f1d3
7 changed files with 342 additions and 1 deletions
+7
View File
@@ -182,6 +182,13 @@ class GroupOut(BaseModel):
member_count: int | None = None
message_count: int | None = None
last_chat_time: int | None = None
# 群主/建群补充列(2026-08-25 对齐云客微信群 tab;scraper/回填脚本提取)
owner_name: str | None = None
owner_wxid: str | None = None
owner_alias: str | None = None
owner_avatar: str | None = None
friend_count: int | None = None
create_time: int | None = None # 建群时间 ms epoch
raw_json: dict[str, Any] | None = None
+10
View File
@@ -290,6 +290,16 @@ class ChatGroup(Base):
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
update_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# 通讯录·微信群 tab 对齐云客的补充列(2026-08-25,从 raw_json 提取:
# ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime;
# createTime 兼容 ms-int 与 'YYYY-MM-DD HH:mm:SS' 两种上游形态,归一为 ms)
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 epoch
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at()
+18
View File
@@ -889,6 +889,17 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
Adds ``channel="wechat"`` and preserves the original record under
``raw_json``. ``None``-safe on every field: missing fields become ``None``
rather than raising, so a partial payload does not break the scrape.
2026-08-25 补充列(对齐云客 员工聊天窗口·微信群 tab,真机验证 raw_json 键):
* ``ownerName`` -> ``owner_name`` (群主昵称,上游脱敏)
* ``ownerWchatId`` -> ``owner_wxid`` (群主微信号,上游脱敏)
* ``ownerAlias`` -> ``owner_alias`` (群主微信号别名)
* ``ownerImage`` -> ``owner_avatar`` (群主头像)
* ``friendCount`` -> ``friend_count`` (群成员中员工好友数)
* ``createTime`` -> ``create_time`` (建群时间;实测 smoke.db 全量为
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
"""
return {
"room_id": record.get("friendWxId"),
@@ -908,6 +919,13 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
"add_members": None,
"last_chat_time": record.get("lastChatTime"),
"update_time": None,
# 群主/建群补充列(2026-08-25;raw_json 键 -> 同名列)
"owner_name": record.get("ownerName"),
"owner_wxid": record.get("ownerWchatId"),
"owner_alias": record.get("ownerAlias"),
"owner_avatar": record.get("ownerImage"),
"friend_count": _coerce_int_or_none(record.get("friendCount")),
"create_time": _parse_add_time(record.get("createTime")),
"version": record.get("version"),
"raw_json": record,
}
@@ -0,0 +1,108 @@
"""chat_group 群主补充列 + 复合主键修复
Revision ID: 099ac2097fa2
Revises: 524da8d2d597
Create Date: 2026-08-25 14:26:06.532289
内容
----
1. 对齐云客 员工聊天窗口·微信群 tab 的 6 个补充列(2026-08-25 真机验证
raw_json 键 ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/
createTime,由 scraper 与 scripts/backfill_chat_group_fields.py 提取;
createTime 实测为 'YYYY-MM-DD HH:mm:ss' 字符串,归一为 ms-epoch):
owner_name / owner_wxid / owner_alias / owner_avatar / friend_count /
create_time。
2. 修复模型-迁移 drift(Task 1 评审遗留):模型 PK 自 2026-08-24(commit
8a99940)起为复合键 (room_id, channel, owner_account_id),但迁移链
initial_schema 仍建单列 PrimaryKeyConstraint('room_id') 且
owner_account_id nullable。本迁移把 PK 变更为复合键并将
owner_account_id 置 NOT NULL(smoke.db 实测 1105 行无 NULL,可安全
变更;MySQL 存量若存在 NULL 行会在此报错——那是必须人工核对的数据
问题,不用兜底值编造)。
SQLite 不支持直接改 PK,batch_alter_table 自动走表重建;copy_from 显式
给出迁移前表结构(含命名 PK 与索引)以便 drop/recreate 定位约束。
MySQL 无需重建,渲染为直接 ALTER(ADD COLUMN / MODIFY / DROP PRIMARY
KEY / ADD PRIMARY KEY)。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '099ac2097fa2'
down_revision: str | Sequence[str] | None = '524da8d2d597'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 迁移前的 chat_group 结构(initial_schema 原样;PK 命名仅为 batch
# drop_constraint 定位用,实际库中为匿名约束)。
_chat_group_before = sa.Table(
"chat_group",
sa.MetaData(),
sa.Column("room_id", sa.String(128), nullable=False),
sa.Column("channel", sa.String(16), nullable=False),
sa.Column("room_name", sa.String(255), nullable=True),
sa.Column("owner_account_id", sa.String(128), nullable=True),
sa.Column("owner_account_nickname", sa.String(255), nullable=True),
sa.Column("member_count", sa.Integer(), nullable=True),
sa.Column("member_ids", sa.Text(), nullable=True),
sa.Column("head_image_url", sa.String(1024), nullable=True),
sa.Column("owner_user_id", sa.String(64), nullable=True),
sa.Column("owner_user_phone", sa.String(64), nullable=True),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("own_message_count", sa.Integer(), nullable=True),
sa.Column("loss_members", sa.Integer(), nullable=True),
sa.Column("add_members", sa.Integer(), nullable=True),
sa.Column("last_chat_time", sa.BigInteger(), nullable=True),
sa.Column("update_time", sa.BigInteger(), nullable=True),
sa.Column("version", sa.String(64), nullable=True),
sa.Column("raw_json", sa.JSON(), nullable=True),
sa.Column(
"fetched_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
),
sa.PrimaryKeyConstraint("room_id", name="pk_chat_group"),
sa.Index("ix_chat_group_owner", "channel", "owner_account_id", "room_id"),
)
def upgrade() -> None:
"""Upgrade schema."""
with op.batch_alter_table(
"chat_group", schema=None, copy_from=_chat_group_before
) as batch_op:
# 1) 群主/建群补充列(全部 nullable,存量默认 NULL,回填脚本再提取)
batch_op.add_column(sa.Column("owner_name", sa.String(255), nullable=True))
batch_op.add_column(sa.Column("owner_wxid", sa.String(128), nullable=True))
batch_op.add_column(sa.Column("owner_alias", sa.String(255), nullable=True))
batch_op.add_column(sa.Column("owner_avatar", sa.String(1024), nullable=True))
batch_op.add_column(sa.Column("friend_count", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("create_time", sa.BigInteger(), nullable=True))
# 2) 复合 PK 对齐模型(2026-08-24 8a99940):单列 PK 会把多员工同群
# 错误去重;owner_account_id 入键故需 NOT NULL。
batch_op.alter_column(
"owner_account_id", existing_type=sa.String(128), nullable=False
)
batch_op.drop_constraint("pk_chat_group", type_="primary")
batch_op.create_primary_key(
"pk_chat_group", ["room_id", "channel", "owner_account_id"]
)
def downgrade() -> None:
"""Downgrade schema."""
# 降级需先把可能存在的多 owner 同群行去重才能退回单列 PK——不做自动
# 删行,保留数据由人工处理;仅删列。
with op.batch_alter_table("chat_group", schema=None) as batch_op:
batch_op.drop_column("owner_name")
batch_op.drop_column("owner_wxid")
batch_op.drop_column("owner_alias")
batch_op.drop_column("owner_avatar")
batch_op.drop_column("friend_count")
batch_op.drop_column("create_time")
@@ -0,0 +1,103 @@
"""Backfill ChatGroup 群主/建群补充列 from raw_json (no re-scrape).
2026-08-25 对齐云客 员工聊天窗口·微信群 tab 的 6 个补充列(迁移
099ac2097fa2 加列):从既有行的 raw_json 提取 ownerName/ownerWchatId/
ownerAlias/ownerImage/friendCount/createTime 写入同名列。
幂等:逐列只在「列为空 且 raw_json 有可用值」时回填,重跑只补新空列。
createTime 兼容 ms-int 与 'YYYY-MM-DD HH:mm:ss'(smoke.db 实测全量为
后者),复用 scraper 层 _parse_add_time 归一为 ms-epoch int。
"""
import asyncio
import time
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import models # noqa: F401
from app.db.models import ChatGroup
from app.scraper.wechat import _coerce_int_or_none, _parse_add_time
BATCH = 200
# raw_json 键 -> 目标列(字符串直提)
_STR_FIELDS: dict[str, str] = {
"ownerName": "owner_name",
"ownerWchatId": "owner_wxid",
"ownerAlias": "owner_alias",
"ownerImage": "owner_avatar",
}
def _extract(raw: dict, current: dict) -> dict:
"""raw_json -> 列值(只为「当前为空」的列产出值)。"""
out: dict = {}
for key, col in _STR_FIELDS.items():
if current.get(col) in (None, "") and raw.get(key):
out[col] = raw[key]
if current.get("friend_count") is None:
v = _coerce_int_or_none(raw.get("friendCount"))
if v is not None:
out["friend_count"] = v
if current.get("create_time") is None:
v = _parse_add_time(raw.get("createTime"))
if v is not None:
out["create_time"] = v
return out
async def main(db_url: str = "sqlite+aiosqlite:///./smoke.db") -> None:
engine = create_async_engine(db_url)
sm = async_sessionmaker(engine, expire_on_commit=False)
scanned = updated = 0
async with sm() as s:
rows = (
await s.execute(
select(
ChatGroup.room_id,
ChatGroup.channel,
ChatGroup.owner_account_id,
ChatGroup.raw_json,
ChatGroup.owner_name,
ChatGroup.owner_wxid,
ChatGroup.owner_alias,
ChatGroup.owner_avatar,
ChatGroup.friend_count,
ChatGroup.create_time,
)
)
).all()
for rid, ch, ow, raw, *cols in rows:
scanned += 1
if not raw:
continue
current = dict(
zip(
("owner_name", "owner_wxid", "owner_alias", "owner_avatar",
"friend_count", "create_time"),
cols,
)
)
values = _extract(raw, current)
if not values:
continue
await s.execute(
update(ChatGroup)
.where(
ChatGroup.room_id == rid,
ChatGroup.channel == ch,
ChatGroup.owner_account_id == ow,
)
.values(**values)
)
updated += 1
if updated % BATCH == 0:
await s.commit()
print(f"[{time.strftime('%H:%M:%S')}] {updated} updated...")
await s.commit()
print(f"backfilled {updated}/{scanned} rows")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
+48
View File
@@ -255,3 +255,51 @@ async def test_room_id_filter_returns_single_group(make_client):
body = r.json()
assert body["total"] == 1
assert body["items"][0]["room_name"] == "群二"
# ---------------------------------------------------------------------------
# 群主/建群补充列(2026-08-25 对齐云客员工聊天窗口·微信群 tab)
# raw_json 键 ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime
# 由 scraper(_normalize_chat_group) 与 scripts/backfill_chat_group_fields.py
# 提取进同名列;API 直接输出列值。
# ---------------------------------------------------------------------------
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,
owner_name="Q**e", owner_wxid="RL3*****108", owner_alias="Fr******er",
owner_avatar="https://wx.qlogo.cn/o/0", friend_count=22,
create_time=1740000000000,
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"})
assert r.status_code == 200
item = r.json()["items"][0]
assert item["owner_name"] == "Q**e"
assert item["owner_wxid"] == "RL3*****108"
assert item["owner_alias"] == "Fr******er"
assert item["owner_avatar"] == "https://wx.qlogo.cn/o/0"
assert item["friend_count"] == 22
assert item["create_time"] == 1740000000000
async def test_groups_owner_fields_default_null(make_client):
"""未回填/上游缺失的行走 schema 默认值 null(不报错、不缺 key)。"""
async def seed(session):
session.add(ChatGroup(room_id="old@chatroom", channel="wechat",
owner_account_id="wxid_y"))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/groups", params={"room_id": "old@chatroom"})
assert r.status_code == 200
item = r.json()["items"][0]
for key in ("owner_name", "owner_wxid", "owner_alias",
"owner_avatar", "friend_count", "create_time"):
assert item[key] is None
@@ -32,7 +32,7 @@ from typing import Any
import httpx
import respx
from app.scraper.wechat import scrape_wechat_groups
from app.scraper.wechat import _normalize_chat_group, scrape_wechat_groups
from app.session.yunke_client import YunkeClient
from tests.cassettes.wxFriends_fixture import make_friend_list_response
@@ -277,3 +277,50 @@ async def test_scrape_wechat_groups_stops_on_empty_first_page():
assert results == []
assert route.call_count == 1
# ---------------------------------------------------------------------------
# 群主/建群补充列提取(2026-08-25 对齐云客员工聊天窗口·微信群 tab)
# raw_json 键: ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime
# smoke.db 实测: createTime 全部为 'YYYY-MM-DD HH:mm:ss' 字符串;ms int 直通。
# ---------------------------------------------------------------------------
def test_normalize_chat_group_extracts_owner_fields():
"""6 个补充键提取到同名列;date-str createTime 归一为 ms epoch。"""
record = {
"friendWxId": "123@chatroom",
"friendWxNickname": "测试群",
"friendNum": 25,
"wxId": "wxid_x",
"ownerName": "Q**e",
"ownerWchatId": "RL3*****108",
"ownerAlias": "Fr******er",
"ownerImage": "https://wx.qlogo.cn/o/0",
"friendCount": 22,
"createTime": "2025-10-27 13:30:55",
}
out = _normalize_chat_group(record)
assert out["owner_name"] == "Q**e"
assert out["owner_wxid"] == "RL3*****108"
assert out["owner_alias"] == "Fr******er"
assert out["owner_avatar"] == "https://wx.qlogo.cn/o/0"
assert out["friend_count"] == 22
# 'YYYY-MM-DD HH:mm:ss' -> ms epoch(naive UTC,同 addTime 约定)
assert out["create_time"] == 1761571855000
def test_normalize_chat_group_owner_fields_none_safe():
"""缺失全部 6 个键 -> 全 None(老 capture 不炸)。"""
out = _normalize_chat_group({"friendWxId": "r@chatroom"})
for key in ("owner_name", "owner_wxid", "owner_alias",
"owner_avatar", "friend_count", "create_time"):
assert out[key] is None
def test_normalize_chat_group_create_time_ms_passthrough():
"""ms-epoch int 直通(不做日期解析)。"""
out = _normalize_chat_group(
{"friendWxId": "r@chatroom", "createTime": 1740000000000}
)
assert out["create_time"] == 1740000000000