diff --git a/yunke-server/migrations/versions/b41e8fa0c7d2_mysql_fulltext_message_text.py b/yunke-server/migrations/versions/b41e8fa0c7d2_mysql_fulltext_message_text.py new file mode 100644 index 0000000..d38201d --- /dev/null +++ b/yunke-server/migrations/versions/b41e8fa0c7d2_mysql_fulltext_message_text.py @@ -0,0 +1,68 @@ +"""MySQL message.text 建 ngram FULLTEXT 索引(搜索 LIKE 全扫 16s 回归修复) + +Revision ID: b41e8fa0c7d2 +Revises: fe63c6789e94 +Create Date: 2026-08-27 15:55:00.000000 + +内容 +---- +``_search_messages_mysql``(search router)早就在用 ``MATCH(m.text) AGAINST`` +查询,但**没有任何环节在 MySQL 上建过 FULLTEXT 索引**——migrate_sqlite_to_mysql +按模型 create_all 建表(不含 FULLTEXT),迁移链也没有——索引缺失时 MATCH 报 +错、代码回退 LIKE 全扫:2026-08-27 实测 860k 行 ``LIKE '%q%'`` 要 16.3s,前端 +axios 15s 超时直接 ECONNABORTED,搜索页全空(切 Docker MySQL 单库后暴露)。 + +本迁移补上索引: + +* 仅 MySQL 方言执行;SQLite 跳过(那边 FTS5 虚表走 app/db/fts.py 的 + ensure_fts 体系,与本索引无关)。 +* ``WITH PARSER ngram``:中文/俄文等无空格分词文本按 bigram 切词,子串式 + 匹配语义最接近原 LIKE 路径。 +* 幂等:``sa.inspect`` 先查同名索引,存在即跳过(重放安全)。 + +注意:86 万行建 ngram 索引约 1-3 分钟(表重建),避开抓取窗口执行。 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b41e8fa0c7d2' +down_revision: str | Sequence[str] | None = 'fe63c6789e94' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_INDEX_NAME = 'ft_message_text' + + +def _has_index(table: str, index: str) -> bool: + insp = sa.inspect(op.get_bind()) + return index in {i['name'] for i in insp.get_indexes(table)} + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + if bind.dialect.name != 'mysql': + return # SQLite 的 FTS5 虚表由 app/db/fts.py ensure_fts 管,与此无关 + if _has_index('message', _INDEX_NAME): + return # 已建过,幂等跳过 + # 原生 DDL:本版 SQLAlchemy 的 Index 不认 WITH PARSER 方言参数 + bind.execute( + sa.text( + f"CREATE FULLTEXT INDEX {_INDEX_NAME} ON message (text) " + f"WITH PARSER ngram" + ) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + bind = op.get_bind() + if bind.dialect.name != 'mysql': + return + if not _has_index('message', _INDEX_NAME): + return + bind.execute(sa.text(f"DROP INDEX {_INDEX_NAME} ON message"))