diff --git a/backend/Dockerfile b/backend/Dockerfile index 7669e5f..848ddb0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -73,5 +73,6 @@ RUN mkdir -p /data/nex_docus_store/projects /data/nex_docus_store/temp logs # 暴露端口 EXPOSE 8000 -# 启动命令 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# 启动命令:先执行幂等的数据库初始化/迁移(建表、种子数据、字段补全),再启动服务。 +# 全新部署会自动建表并初始化角色/菜单/管理员;已有数据库会自动补列,均无需手动执行 SQL。 +CMD ["sh", "-c", "python scripts/init_db.py && exec uvicorn main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index fc877d1..aa34c27 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -37,6 +37,43 @@ logger = logging.getLogger(__name__) CHAT_HISTORY_MESSAGE_LIMIT = 20 +# 旧版本会在被中断的助手消息内容末尾追加 "interrupt" 标记。 +# 自 status 字段上线后新消息不再写该标记,判断中断只以 status 为准; +# 下面两个函数仅用于读取历史消息时的只读兼容(绝不修改数据库)。 +_LEGACY_INTERRUPTED_MARKER = "interrupt" + + +def _legacy_interrupted_content(content: str) -> bool: + """只读兼容旧数据:判断内容是否带旧版中断标记。""" + if not content: + return False + return content == _LEGACY_INTERRUPTED_MARKER or content.endswith( + f"\n\n{_LEGACY_INTERRUPTED_MARKER}" + ) + + +def _strip_legacy_interrupt_marker(content: str) -> str: + """只读兼容旧数据:返回去掉旧版中断标记的展示内容(不改数据库)。""" + if content == _LEGACY_INTERRUPTED_MARKER: + return "" + suffix = f"\n\n{_LEGACY_INTERRUPTED_MARKER}" + if content.endswith(suffix): + return content[: -len(suffix)].rstrip() + return content + + +def _is_interrupted_message(message) -> bool: + """判断助手消息是否已中断。 + + 新消息以 status 字段为准;旧数据(status 缺失或仍为 pending)仅做 + 只读兼容:按旧版内容标记判断,不修改数据库。 + """ + if message.role != "assistant": + return False + status = (message.status or "").strip() or "pending" + if status == "interrupted": + return True + return status == "pending" and _legacy_interrupted_content(message.content or "") class VectorizeRequest(BaseModel): """批量向量化请求""" @@ -416,8 +453,14 @@ async def get_session_messages( stored_refs = _parse_refs(message.referenced_files) content = message.content refs = stored_refs + status = message.status or "pending" if message.role == "assistant": content, refs = _canonicalize_message_citations(content, stored_refs) + # 旧数据只读兼容:status 仍为 pending 且内容带旧版中断标记时, + # 按“已中断”返回并在展示内容中移除标记文本(不修改数据库)。 + if status in ("", "pending") and _legacy_interrupted_content(content): + status = "interrupted" + content = _strip_legacy_interrupt_marker(content) thinking_log = [] if message.thinking_log: try: @@ -428,7 +471,7 @@ async def get_session_messages( "id": message.id, "role": message.role, "content": content, - "status": message.status or "pending", + "status": status, "duration_ms": message.duration_ms, "thinking_log": thinking_log, "referenced_files": refs, @@ -564,7 +607,7 @@ async def send_chat_message( {"role": m.role, "content": m.content} for m in prev_messages # 被中断的助手消息不参与上下文,避免污染后续模型输入 - if not (m.role == "assistant" and m.status == "interrupted") + if not _is_interrupted_message(m) ] start_time = time.monotonic() @@ -686,7 +729,7 @@ async def send_chat_message_stream( {"role": m.role, "content": m.content} for m in prev_messages # 被中断的助手消息不参与上下文,避免污染后续模型输入 - if not (m.role == "assistant" and m.status == "interrupted") + if not _is_interrupted_message(m) ] # 先持久化用户提问和空的助手消息占位行,再执行向量检索。 diff --git a/backend/app/core/migrations.py b/backend/app/core/migrations.py index 341f8d9..8d808f3 100644 --- a/backend/app/core/migrations.py +++ b/backend/app/core/migrations.py @@ -48,7 +48,11 @@ async def _column_exists(conn, table_name: str, column_name: str) -> bool: async def migrate_schema() -> None: - """为存量数据库补齐新增列,并为历史助手消息回填状态。""" + """为存量数据库补齐新增列。 + + 只做“新增列”这类非破坏性变更:不修改、不删除、不回填任何已有数据。 + 历史消息的中断状态由读取路径(get messages)按旧标记做只读兼容判断。 + """ from app.core.database import engine added = [] @@ -63,37 +67,6 @@ async def migrate_schema() -> None: await conn.execute(text(ddl)) added.append(f"{table_name}.{column_name}") - # 历史消息回填:旧的“中断”标记仅存在于 content 末尾,迁移后统一迁移到 status。 - # 之后不再用内容比对判断中断,status 字段作为唯一依据。 - await conn.execute( - text( - "UPDATE chat_message SET status = 'interrupted' " - "WHERE role = 'assistant' " - "AND (status IS NULL OR status = '' OR status = 'pending') " - "AND (content = 'interrupt' OR content LIKE '%\\n\\ninterrupt')" - ) - ) - # 清理历史“中断”标记文本:迁移后 status 是唯一依据,内容不再混入标记 - await conn.execute( - text( - "UPDATE chat_message SET content = '' " - "WHERE status = 'interrupted' AND content = 'interrupt'" - ) - ) - await conn.execute( - text( - "UPDATE chat_message SET content = TRIM(" - "LEFT(content, CHAR_LENGTH(content) - CHAR_LENGTH('\\n\\ninterrupt'))) " - "WHERE status = 'interrupted' AND content LIKE '%\\n\\ninterrupt'" - ) - ) - await conn.execute( - text( - "UPDATE chat_message SET status = 'completed' " - "WHERE (status IS NULL OR status = '' OR status = 'pending')" - ) - ) - if added: logger.info("数据库迁移完成,新增列: %s", ", ".join(added)) else: diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py index 5924b42..2d8ed79 100644 --- a/backend/scripts/init_db.py +++ b/backend/scripts/init_db.py @@ -80,17 +80,22 @@ async def init_roles(): async def init_menus(): - """初始化系统菜单""" + """初始化系统菜单(幂等、增量)。 + + 已有菜单保持原样,只插入缺失的菜单,并给超级管理员角色补授新菜单权限; + 避免在已有数据库上升级时新功能入口缺失,也不影响已有菜单与授权。 + """ print("正在初始化系统菜单...") async with async_session() as session: - # 检查是否已存在菜单 - result = await session.execute(text("SELECT COUNT(*) FROM system_menus")) - count = result.scalar() - - if count > 0: - print(" 菜单已存在,跳过初始化") - return + # 读取已存在的菜单,只补缺失项;按 id 与 (parent_id, menu_name) 双重去重, + # 避免老库已有同名菜单时插入重复入口。 + result = await session.execute( + text("SELECT id, parent_id, menu_name FROM system_menus") + ) + existing_rows = result.all() + existing_ids = {row[0] for row in existing_rows} + existing_names = {(row[1], row[2]) for row in existing_rows} # 创建系统菜单 menus = [ @@ -287,13 +292,53 @@ async def init_menus(): visible=1, status=1 ), + SystemMenu( + id=17, + parent_id=20, + menu_name="模型配置", + menu_code="system:model-configs", + menu_type=2, + path="/system/model-configs", + component="System/ModelConfigs", + icon="CloudServerOutlined", + sort_order=4, + visible=1, + status=1 + ), ] - for menu in menus: + new_menus = [ + menu + for menu in menus + if menu.id not in existing_ids + and (menu.parent_id, menu.menu_name) not in existing_names + ] + if not new_menus: + print(" 菜单已完整,跳过初始化") + return + + for menu in new_menus: session.add(menu) + await session.flush() + + # 为超级管理员角色补授新菜单权限(已存在的授权不受影响) + role_result = await session.execute( + text("SELECT id FROM roles WHERE role_code = 'super_admin' LIMIT 1") + ) + role_id = role_result.scalar() + if role_id: + for menu in new_menus: + await session.execute( + text( + "INSERT INTO role_menus (role_id, menu_id) " + "VALUES (:role_id, :menu_id) " + "ON DUPLICATE KEY UPDATE menu_id = menu_id" + ), + {"role_id": role_id, "menu_id": menu.id}, + ) await session.commit() - print("✓ 系统菜单初始化成功") + print(f"✓ 系统菜单初始化成功,新增 {len(new_menus)} 项") async def init_admin_user(): diff --git a/backend/tests/test_chat_citations.py b/backend/tests/test_chat_citations.py index 686a294..ce4aa0c 100644 --- a/backend/tests/test_chat_citations.py +++ b/backend/tests/test_chat_citations.py @@ -7,6 +7,30 @@ from app.services.rag_service import RAGService class ChatCitationTest(unittest.TestCase): + def test_legacy_interrupt_marker_is_read_only_compat(self): + from app.api.v1.chat import ( + _is_interrupted_message, + _legacy_interrupted_content, + _strip_legacy_interrupt_marker, + ) + + self.assertTrue(_legacy_interrupted_content("interrupt")) + self.assertTrue(_legacy_interrupted_content("内容\n\ninterrupt")) + self.assertFalse(_legacy_interrupted_content("正常内容")) + + self.assertEqual(_strip_legacy_interrupt_marker("interrupt"), "") + self.assertEqual(_strip_legacy_interrupt_marker("内容\n\ninterrupt"), "内容") + self.assertEqual(_strip_legacy_interrupt_marker("正常内容"), "正常内容") + + # 旧数据(status 缺失/pending + 旧标记)按已中断处理,但不修改数据库 + legacy = SimpleNamespace(role="assistant", status="pending", content="内容\n\ninterrupt") + self.assertTrue(_is_interrupted_message(legacy)) + # 新数据一律以 status 为准,不再比对内容 + fresh = SimpleNamespace(role="assistant", status="completed", content="内容\n\ninterrupt") + self.assertFalse(_is_interrupted_message(fresh)) + interrupted = SimpleNamespace(role="assistant", status="interrupted", content="") + self.assertTrue(_is_interrupted_message(interrupted)) + def test_duplicate_file_citations_are_renumbered_once(self): content, refs = _canonicalize_message_citations( "泰坦属于土星的卫星[2][4][5]。",