diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 539f51b..3e5dda9 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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 已带,直接透传): diff --git a/yunke-server/migrations/versions/fe63c6789e94_align_task14_model_type_fixes.py b/yunke-server/migrations/versions/fe63c6789e94_align_task14_model_type_fixes.py index bc84cc9..3d4f9a3 100644 --- a/yunke-server/migrations/versions/fe63c6789e94_align_task14_model_type_fixes.py +++ b/yunke-server/migrations/versions/fe63c6789e94_align_task14_model_type_fixes.py @@ -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() diff --git a/yunke-server/tests/test_api_groups.py b/yunke-server/tests/test_api_groups.py index 616cbe9..f1211c8 100644 --- a/yunke-server/tests/test_api_groups.py +++ b/yunke-server/tests/test_api_groups.py @@ -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