"""一次性迁移:把已有的 taskId 会商记录从个人草稿表搬进独立的 task 存储。 背景 ==== 早期 taskId 深链会商记录混存在按用户分权的 ``roundtable_drafts`` 表里(带一个 ``task_id`` 列 + 放开归属校验)。现在 taskId 聊天记录有了独立、不分权的存储 ``roundtable_task_drafts``(只按 ``task_id`` 区分,跨用户共享)。本脚本把历史数据搬过去: 1. ``roundtable_drafts`` 中 ``task_id`` 非空的每一行 → 在 ``roundtable_task_drafts`` 建同 id 的记录(``created_by`` = 原 ``user_id``,审计用); 2. 这些草稿对应的 ``roundtable_jobs`` 行(``draft_id`` 匹配且 ``task_id`` 为空)补上 ``task_id``,使其转为「task 作业」(按 task 共享); 3. 从 ``roundtable_drafts`` 删除已迁走的行,使个人历史不再混入 task 记录。 幂等:目标表已存在同 id 记录则跳过;可重复运行。alembic 自动升级在运行时是关闭的 (见 ``persistence/engine.py``),所以这步数据搬迁用本脚本手动执行。 用法(在 backend 目录) ====================== :: # 预览(不写盘) PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py --dry-run # 执行 PYTHONPATH=. uv run python scripts/migrate_roundtable_task_drafts.py """ from __future__ import annotations import argparse import asyncio import logging import sys logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger("migrate_roundtable_task_drafts") async def _run(dry_run: bool) -> int: from sqlalchemy import select, update from deerflow.config import get_app_config from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config from deerflow.persistence.roundtable_drafts.model import RoundtableDraftRow from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow config = get_app_config() await init_engine_from_config(config.database) sf = get_session_factory() moved = 0 skipped = 0 jobs_tagged = 0 try: async with sf() as session: rows = ( await session.execute( select(RoundtableDraftRow).where(RoundtableDraftRow.task_id.is_not(None)) ) ).scalars().all() logger.info("发现 %d 条带 task_id 的个人草稿待迁移。", len(rows)) for row in rows: # 幂等:目标已存在同 id → 跳过(仍尝试给作业补 task_id + 删源行)。 exists = await session.get(RoundtableTaskDraftRow, row.id) if exists is None: if not dry_run: session.add( RoundtableTaskDraftRow( id=row.id, task_id=row.task_id, created_by=row.user_id, title=row.title or "", furthest_step=row.furthest_step or 1, step1=row.step1, step2=row.step2, step3=row.step3, created_at=row.created_at, updated_at=row.updated_at, ) ) moved += 1 logger.info(" 迁移 draft %s (task_id=%s)", row.id, row.task_id) else: skipped += 1 logger.info(" 跳过 draft %s (目标已存在)", row.id) # 关联后台作业补 task_id(未标记的才动)。 if not dry_run: res = await session.execute( update(RoundtableJobRow) .where(RoundtableJobRow.draft_id == row.id) .where(RoundtableJobRow.task_id.is_(None)) .values(task_id=row.task_id) ) jobs_tagged += int(res.rowcount or 0) # 删除源行(task 记录不再留在个人表)。 if not dry_run: await session.delete(row) if not dry_run: await session.commit() logger.info( "完成%s:迁移 %d 条,跳过 %d 条,关联作业打标 %d 条。", "(dry-run,未写盘)" if dry_run else "", moved, skipped, jobs_tagged, ) return 0 finally: await close_engine() def main() -> int: parser = argparse.ArgumentParser(description="迁移 taskId 会商记录到独立 task 存储") parser.add_argument("--dry-run", action="store_true", help="只预览,不写盘") args = parser.parse_args() return asyncio.run(_run(args.dry_run)) if __name__ == "__main__": sys.exit(main())