feat: 项目删除前确认+归档备份——delete_project(confirm二次确认)删除前自动备份tgz到机构工作目录_archive/,含工作目录+全关联表数据记录

This commit is contained in:
ymq 2026-08-18 15:47:55 +08:00
parent a00ba32430
commit 06ae6bb1ac
2 changed files with 198 additions and 0 deletions

View File

@ -12,6 +12,13 @@ org_id 用于 list 过滤租户隔离audit 的 tenant_id 用 project_id
"""
import logging
import os
import json
import re
import tarfile
import tempfile
import shutil
from datetime import datetime
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
@ -176,3 +183,167 @@ def _rec_to_dict(rec):
except Exception:
pass
return {}
async def _dump_project_records(sor, project_id):
"""导出项目及关联表全部记录(归档备份 JSON 用)。"""
dump = {}
# 直接关联project_id / tenant_id
direct = {
'sd_projects': "SELECT * FROM sd_projects WHERE id=${pid}$",
'sd_iterations': "SELECT * FROM sd_iterations WHERE project_id=${pid}$",
'pipeline_tasks': "SELECT * FROM pipeline_tasks WHERE tenant_id=${pid}$",
'pipeline_deliverables': "SELECT * FROM pipeline_deliverables WHERE project_id=${pid}$",
'pipeline_project_agents': "SELECT * FROM pipeline_project_agents WHERE project_id=${pid}$",
'pipeline_agent_questions': "SELECT * FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
'sd_deploy_envs': "SELECT * FROM sd_deploy_envs WHERE project_id=${pid}$",
'sd_features': "SELECT * FROM sd_features WHERE project_id=${pid}$",
'sd_project_repos': "SELECT * FROM sd_project_repos WHERE project_id=${pid}$",
}
for tbl, sql in direct.items():
try:
recs = await sor.sqlExe(sql, {"pid": project_id})
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
except Exception as e:
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
# 间接关联iteration_id 通过子查询pipeline_conversations 的 iteration_id 存的是 project_id
indirect = {
'sd_bugs': "SELECT * FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_plans': "SELECT * FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_cases': "SELECT * FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
'sd_conversations': "SELECT * FROM sd_conversations WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'pipeline_conversations': "SELECT * FROM pipeline_conversations WHERE iteration_id=${pid}$ OR iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'audit_log': "SELECT * FROM audit_log WHERE tenant_id=${pid}$ ORDER BY created_at ASC",
}
for tbl, sql in indirect.items():
try:
recs = await sor.sqlExe(sql, {"pid": project_id})
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
except Exception as e:
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
return dump
async def backup_project(project_id, who=None, agent_id=None):
"""归档备份:打包项目工作目录 + 导出数据库记录 → tgz 到「机构工作目录/_archive/」。
返回 (ok, tgz_path_or_message)删除项目前必须调用保证可回退
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
p = recs[0]
pname = getattr(p, 'name', '') or project_id
org_id = getattr(p, 'org_id', '0') or '0'
ws = getattr(p, 'workspace_dir', '') or ''
status = getattr(p, 'status', '')
from .workspace import get_workspace_base
workspace_base = await get_workspace_base(sor)
if not (ws or '').startswith('/'):
ws = os.path.join(workspace_base, str(org_id), pname)
# 机构工作目录下的备份归档目录
archive_dir = os.path.join(workspace_base, str(org_id), '_archive')
os.makedirs(archive_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = re.sub(r'[^\w\u4e00-\u9fff.-]', '_', pname)
tgz_path = os.path.join(archive_dir, f"{safe_name}_{timestamp}.tgz")
# 导出数据库记录
dump = await _dump_project_records(sor, project_id)
try:
with tarfile.open(tgz_path, 'w:gz') as tar:
# 数据库记录 JSON
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as f:
json.dump(dump, f, ensure_ascii=False, default=str)
tmp_json = f.name
tar.add(tmp_json, arcname='project_data.json')
os.unlink(tmp_json)
# 工作目录文件(若存在)
if ws and os.path.isdir(ws):
tar.add(ws, arcname='workspace')
except Exception as e:
logger.error("backup_project tar failed: %s err=%s", project_id, e)
return False, f"打包失败: {type(e).__name__}: {str(e)[:200]}"
await record_audit(project_id, TABLE, project_id, 'backup',
to_state=status, who=_normalize_role(who),
agent_id=agent_id, detail=tgz_path, sor=sor)
logger.info("backup_project: %s -> %s", project_id, tgz_path)
return True, tgz_path
async def delete_project(project_id, who=None, agent_id=None, confirm=False):
"""删除项目:先归档备份,再删除关联表记录 + sd_projects + 工作目录。
confirm 必须为 True删除前二次确认备份失败则中止删除
返回 (ok, message)
"""
if not confirm:
return False, "删除项目需二次确认confirm=true请先确认再执行"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 1. 归档备份(失败则中止删除)
ok, backup_msg = await backup_project(project_id, who=who, agent_id=agent_id)
if not ok:
return False, f"归档备份失败,已中止删除:{backup_msg}"
# 读工作目录(删除数据库后清理物理文件)
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
p = recs[0]
pname = getattr(p, 'name', '') or project_id
org_id = getattr(p, 'org_id', '0') or '0'
ws = getattr(p, 'workspace_dir', '') or ''
from .workspace import get_workspace_base
workspace_base = await get_workspace_base(sor)
if not (ws or '').startswith('/'):
ws = os.path.join(workspace_base, str(org_id), pname)
# 2. 删除间接关联表(依赖迭代)
indirect = [
"DELETE FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
"DELETE FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM sd_conversations WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM pipeline_conversations WHERE iteration_id=${pid}$ OR iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
]
for sql in indirect:
await sor.sqlExe(sql, {"pid": project_id})
# 3. 删除直接关联表project_id / tenant_id
direct = [
"DELETE FROM pipeline_deliverables WHERE project_id=${pid}$",
"DELETE FROM pipeline_project_agents WHERE project_id=${pid}$",
"DELETE FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
"DELETE FROM sd_deploy_envs WHERE project_id=${pid}$",
"DELETE FROM sd_features WHERE project_id=${pid}$",
"DELETE FROM sd_project_repos WHERE project_id=${pid}$",
"DELETE FROM pipeline_tasks WHERE tenant_id=${pid}$",
"DELETE FROM audit_log WHERE tenant_id=${pid}$",
"DELETE FROM sd_iterations WHERE project_id=${pid}$",
"DELETE FROM sd_projects WHERE id=${pid}$",
]
for sql in direct:
await sor.sqlExe(sql, {"pid": project_id})
await sor.sqlExe("COMMIT", {})
# 4. 删除工作目录(物理文件,已备份)
if ws and os.path.isdir(ws):
try:
shutil.rmtree(ws, ignore_errors=True)
except Exception as e:
logger.warning("delete_project rmtree failed: %s err=%s", ws, e)
logger.info("delete_project: %s done, backup=%s", project_id, backup_msg)
return True, f"项目已删除,归档备份在 {backup_msg}"

View File

@ -158,6 +158,8 @@ SDL_TOOLS = [
ToolDefinition(name="complete_project", description="完成项目(active→completed)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="archive_project", description="归档项目(completed→archived)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="reopen_project", description="重新打开归档项目(archived→active)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="backup_project", description="归档备份项目(打包工作目录+数据记录为tgz到机构工作目录_archive/)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="delete_project", description="删除项目(需二次确认confirm=true删除前自动归档备份tgz)", parameters={"project_id": "项目ID", "confirm": "二次确认必须为true"}, category="project"),
# ── 迭代 ──
ToolDefinition(name="list_iterations", description="查看当前项目迭代列表", parameters={"status": "按状态筛选(可选)"}, category="iteration"),
ToolDefinition(name="create_iteration", description="创建迭代(planning)", parameters={"iteration_name": "迭代名称", "iteration_type": "迭代类型(可选)", "scope": "迭代范围(可选)"}, category="iteration"),
@ -963,6 +965,29 @@ async def _h_reopen_project(sor, p, ctx):
return f"OK: {msg}" if ok else f"ERROR: {msg}"
async def _h_backup_project(sor, p, ctx):
pid = (p.get("project_id", "") or "").strip()
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
from .project_capability import backup_project
ok, msg = await backup_project(pid, who="agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
async def _h_delete_project(sor, p, ctx):
pid = (p.get("project_id", "") or "").strip()
if not pid:
return "需要项目ID"
confirm = (p.get("confirm", "") or "").strip().lower()
if confirm != "true":
return "ERROR: 删除项目需二次确认,请传 confirm=true 后再执行(删除前会自动归档备份到机构工作目录 _archive/"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
from .project_capability import delete_project
ok, msg = await delete_project(pid, who="agent.main_agent", confirm=True)
return f"OK: {msg}" if ok else f"ERROR: {msg}"
# ── 迭代 handler ──
async def _h_list_iterations(sor, p, ctx):
@ -1465,6 +1490,8 @@ SDL_HANDLERS = {
"complete_project": _h_complete_project,
"archive_project": _h_archive_project,
"reopen_project": _h_reopen_project,
"backup_project": _h_backup_project,
"delete_project": _h_delete_project,
# ── 迭代 ──
"list_iterations": _h_list_iterations,
"create_iteration": _h_create_iteration,