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
@@ -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")