fix(migrate): 类型迁移SQLite内联修复FTS触发器+多owner群room_id测试+DEPLOY补stamp依据与锁表提示

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 20:55:18 +08:00
parent 35bfebd3e3
commit ef7f6d8a23
3 changed files with 88 additions and 5 deletions
+9
View File
@@ -99,12 +99,21 @@ docker compose exec backend alembic stamp d8eee96cd017
docker compose exec backend alembic upgrade head
```
stamp 版本号不是拍脑袋定的:`d8eee96cd017` 是与 2026-08-22
`migrate_sqlite_to_mysql`(按当时模型 `create_all` 建表)产物 schema
一致的链上位置——stamp 它即声明「到此为止已应用」,其后迁移(chat_group
复合 PK、群主列、类型修正等)逐一正常补上。其它日期建的旧库须自行核对
表结构选 stamp 点,不要照抄这个版本号。
**每次拉新代码后**:
```bash
docker compose exec backend alembic upgrade head
```
> ⚠️ 类型变更类迁移(如 fe63c6789e94)在 MySQL 上为 COPY 算法、会锁
> `message` 等大表写入——避开抓取窗口(每 4h 增量、02:00 全量)执行。
涉及存量数据回填的更新另跑回填脚本(幂等,重跑 0 改动;脚本默认连
`./smoke.db`,服务器上须显式传 DB_URL——容器 env 已带,直接透传):
@@ -22,11 +22,13 @@ migrate_sqlite_to_mysql(按当时模型 create_all,已是 Text/BIGINT)创建,
重放本迁移为无害 no-op,无需条件守卫。SQLite 走 batch_alter_table 表重建
(仅改声明类型,TEXT/INTEGER 亲和性不变,数据原样拷贝)。
⚠️ SQLite 侧副作用:message 表重建会丢其上的 FTS5 同步触发器、且
external-content 索引(message_fts 按 rowid 映射)会与新表 rowid 脱节。
MySQL(prod)无 FTS 不受影响;SQLite dev 库跑完本迁移后需执行
``ensure_fts``(重建触发器)+ ``rebuild_fts``(按新 rowid 重灌索引,
app/db/fts.py 现成函数)修复搜索。
⚠️ SQLite 侧副作用与自愈:message 表重建会丢其上的 FTS5 同步触发器、
且 external-content 索引(message_fts 按 rowid 映射)会与新表 rowid
脱节——而 message_fts 虚拟表本身还在,search 的 fts_available 门照常
放行,搜索会**静默**返回陈旧结果。因此 upgrade/downgrade 在 SQLite 上
就地修复(见 ``_repair_sqlite_fts``):重建 3 个同步触发器 + ``rebuild``
按新 rowid 重灌索引,跑完本迁移无需再手工 ensure_fts/rebuild_fts。
MySQL(prod)无 FTS5,该修复自动跳过。
"""
from collections.abc import Sequence
@@ -40,6 +42,55 @@ down_revision: str | Sequence[str] | None = '9b0d0c133035'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# FTS5 同步触发器 DDL —— 逐字镜像 app/db/fts.py 的 _FTS_DDL(仅触发器,
# 不含 CREATE VIRTUAL TABLE:本修复只服务已启用 FTS 的库,不主动引入
# 索引)。改 fts.py 时必须同步这里,否则修复出来的触发器与 ORM 侧漂移。
_FTS_TRIGGER_DDLS = [
"""
CREATE TRIGGER IF NOT EXISTS message_fts_ai AFTER INSERT ON message BEGIN
INSERT INTO message_fts(rowid, text) VALUES (new.rowid, new.text);
END
""",
"""
CREATE TRIGGER IF NOT EXISTS message_fts_au AFTER UPDATE ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
INSERT INTO message_fts(rowid, text) VALUES (new.rowid, new.text);
END
""",
"""
CREATE TRIGGER IF NOT EXISTS message_fts_ad AFTER DELETE ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
END
""",
]
def _repair_sqlite_fts() -> None:
"""修复 batch 重建 message 对 FTS5 索引的破坏;非 SQLite 方言 no-op。
batch_alter_table 重建 message(DROP 旧表连带其上触发器,新表 rowid
重新分配)后,message_fts 表仍在但触发器没了、rowid 映射全错——
fts_available 依然为真,搜索静默陈旧。就地补:重建 3 个触发器 +
``rebuild`` 按新 rowid 重灌 external-content 索引。仅在 message_fts
已存在(该库启用过 FTS)时执行;MySQL 无 FTS5 直接跳过。
"""
bind = op.get_bind()
if bind.dialect.name != "sqlite":
return
has_fts = bind.execute(
sa.text(
"SELECT count(*) FROM sqlite_master "
"WHERE type='table' AND name='message_fts'"
)
).scalar_one()
if not has_fts:
return
for ddl in _FTS_TRIGGER_DDLS:
bind.execute(sa.text(ddl))
bind.execute(sa.text("INSERT INTO message_fts(message_fts) VALUES('rebuild')"))
def upgrade() -> None:
"""Upgrade schema."""
@@ -76,6 +127,8 @@ def upgrade() -> None:
type_=sa.BigInteger(),
existing_nullable=True,
)
# message 已被上方 batch 重建 —— SQLite 上补回 FTS 触发器并重灌索引
_repair_sqlite_fts()
def downgrade() -> None:
@@ -113,3 +166,5 @@ def downgrade() -> None:
type_=sa.String(1024),
existing_nullable=True,
)
# downgrade 同样重建了 message 表 —— FTS 修复与 upgrade 对称
_repair_sqlite_fts()
+19
View File
@@ -257,6 +257,25 @@ async def test_room_id_filter_returns_matching_groups(make_client):
assert body["items"][0]["room_name"] == "群二"
async def test_room_id_filter_multi_owner_one_row_per_owner(make_client):
"""同 room_id 多群主(复合 PK 允许)→ room_id 精确过滤返回每个群主一行。"""
async def seed(session):
session.add_all([
ChatGroup(room_id="shared@chatroom", channel="wechat", room_name="共享群",
owner_account_id="owner_1", owner_user_id="u1"),
ChatGroup(room_id="shared@chatroom", channel="wechat", room_name="共享群",
owner_account_id="owner_2", owner_user_id="u2"),
])
await session.commit()
client = await make_client(seed)
r = await client.get("/api/groups", params={"room_id": "shared@chatroom"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert {it["owner_account_id"] for it in body["items"]} == {"owner_1", "owner_2"}
# ---------------------------------------------------------------------------
# 群主/建群补充列(2026-08-25 对齐云客员工聊天窗口·微信群 tab)
# raw_json 键 ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime