diff --git a/backend/database.py b/backend/database.py index f0e2aa4..d57646e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -24,6 +24,7 @@ def _migrate_db(): migrations = [ ("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "error_category", "VARCHAR"), + ("tasks", "superseded_at", "TIMESTAMP"), ] with engine.connect() as conn: for table, column, col_type in migrations: diff --git a/backend/models.py b/backend/models.py index 2f54917..5d1f134 100644 --- a/backend/models.py +++ b/backend/models.py @@ -25,6 +25,8 @@ class Task(Base): created_at = Column(TIMESTAMP, default=datetime.utcnow) started_at = Column(TIMESTAMP) completed_at = Column(TIMESTAMP) + # 同一 OSS 对象被后续成功打包覆盖时标记;默认历史列表不展示。 + superseded_at = Column(TIMESTAMP) # 产物 ipa_path = Column(String) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index a0bc3cd..b0a94f9 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -103,12 +103,19 @@ def _enrich_tasks(tasks): @router.get("", response_model=List[TaskResponse]) async def list_tasks( status: str = None, + include_superseded: bool = False, limit: int = 20, offset: int = 0, + user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """获取任务列表""" query = db.query(Task) + if include_superseded: + if not user.get("is_admin"): + raise HTTPException(status_code=403, detail="需要管理员权限") + else: + query = query.filter(Task.superseded_at.is_(None)) if status: query = query.filter(Task.status == status) tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() diff --git a/backend/schemas.py b/backend/schemas.py index be8dcc0..6e9bba0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -27,6 +27,7 @@ class TaskResponse(BaseModel): created_at: Optional[datetime] started_at: Optional[datetime] completed_at: Optional[datetime] + superseded_at: Optional[datetime] = None ipa_path: Optional[str] oss_url: Optional[str] dsym_path: Optional[str] diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 7397c2a..92d4f2c 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -6,7 +6,7 @@ import plistlib import shutil import subprocess import zipfile -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import List @@ -214,6 +214,7 @@ def _cleanup_old_builds(db): base = BUILD_BASE_DIR if not base.exists(): + _cleanup_superseded_tasks(db) return for d in base.iterdir(): if d.is_dir() and d.name.startswith("build_readoor_"): @@ -222,6 +223,50 @@ def _cleanup_old_builds(db): shutil.rmtree(d) except Exception: pass + _cleanup_superseded_tasks(db) + + +def _cleanup_superseded_tasks(db): + """7 天后删除已被新 OSS 包覆盖的历史记录及仅本地保留的文件。""" + from ..models import Task + cutoff = datetime.utcnow() - timedelta(days=7) + tasks = db.query(Task).filter( + Task.superseded_at.isnot(None), Task.superseded_at < cutoff, + ).all() + for task in tasks: + for path_value in (task.build_dir, task.dsym_path, task.obfuscation_maps_path): + if not path_value: + continue + try: + path = Path(path_value) + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + elif path.exists(): + path.unlink() + except OSError: + pass + log_path = Path(__file__).parent.parent / "logs" / f"{task.id}.log" + try: + log_path.unlink(missing_ok=True) + except OSError: + pass + db.delete(task) + if tasks: + db.commit() + + +def _mark_superseded_tasks(db, latest_task): + """标记同一 OSS 对象的旧成功记录;OSS 文件由最新任务覆盖。""" + if not latest_task.oss_url: + return + from ..models import Task + db.query(Task).filter( + Task.id != latest_task.id, + Task.status == "completed", + Task.oss_url == latest_task.oss_url, + Task.superseded_at.is_(None), + ).update({Task.superseded_at: datetime.utcnow()}, synchronize_session=False) + db.commit() def _cleanup_old_logs(): @@ -439,6 +484,7 @@ async def run_build_task(task_id: str): await asyncio.to_thread(_db_update, db, task, status="completed", completed_at=datetime.utcnow(), current_step=None) + await asyncio.to_thread(_mark_superseded_tasks, db, task) await log_streamer.emit_step(task_id, "打包完成") await log_streamer.emit(task_id, f"IPA: {ipa_path}") diff --git a/frontend/src/__tests__/HistoryView.test.js b/frontend/src/__tests__/HistoryView.test.js index 5dbda1f..564c3c9 100644 --- a/frontend/src/__tests__/HistoryView.test.js +++ b/frontend/src/__tests__/HistoryView.test.js @@ -159,6 +159,18 @@ describe('HistoryView.vue', () => { expect(wrapper.text()).not.toContain('日志') }) + it('管理员可显示已覆盖记录', async () => { + const wrapper = mountHistory(true) + await flushPromises() + + const toggle = wrapper.find('.superseded-toggle input') + expect(toggle.exists()).toBe(true) + await toggle.setValue(true) + await flushPromises() + + expect(fetch).toHaveBeenLastCalledWith('/api/tasks?limit=100&include_superseded=true', expect.any(Object)) + }) + it('空列表显示提示', async () => { fetch.mockResolvedValue({ json: () => Promise.resolve([]) }) const router = createMockRouter() diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index 988158a..95ab8a0 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -20,6 +20,7 @@ + @@ -180,6 +181,7 @@ const logLines = ref([]) const logContainer = ref(null) const showVerboseLogs = ref(false) const qrPreview = ref(null) +const showSuperseded = ref(false) let logWs = null const getAppVersion = (task) => { @@ -210,11 +212,13 @@ const filteredTasks = computed(() => { }) }) -onMounted(async () => { - const res = await authFetch('/api/tasks?limit=100') +const loadTasks = async () => { + const res = await authFetch(`/api/tasks?limit=100${showSuperseded.value ? '&include_superseded=true' : ''}`) const data = await res.json() tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) })) -}) +} + +onMounted(loadTasks) const viewLogs = async (taskId) => { const task = tasks.value.find(t => t.id === taskId) @@ -468,6 +472,7 @@ const errorCategoryLabel = (cat) => { .meta-value { font-size: 13px; color: #333; font-weight: 500; } .version-filter { width: 150px; } +.superseded-toggle { display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; white-space: nowrap; } /* 日志操作栏 */ .log-modal-actions { diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index 9ac8bd6..51b687f 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -214,3 +214,19 @@ def test_delete_task_keeps_shared_remote_artifact(client, tmp_config): assert resp.status_code == 200 assert "仍被其他记录引用" in resp.json()["message"] delete.assert_not_called() + + +def test_history_hides_superseded_tasks_except_for_admin(client, tmp_config): + from datetime import datetime + from backend.database import SessionLocal + from backend.models import Task + db = SessionLocal() + db.add_all([ + Task(id="visible", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed"), + Task(id="covered", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", superseded_at=datetime.utcnow()), + ]) + db.commit() + db.close() + + assert [task["id"] for task in client.get("/api/tasks").json()] == ["visible"] + assert {task["id"] for task in client.get("/api/tasks?include_superseded=true").json()} == {"visible", "covered"} diff --git a/tests/test_build_service.py b/tests/test_build_service.py index 8f39914..52ca40e 100644 --- a/tests/test_build_service.py +++ b/tests/test_build_service.py @@ -20,6 +20,7 @@ from backend.services.build_service import ( run_pod_install, _patch_afnetworking_private_headers, _cleanup_old_builds, + _mark_superseded_tasks, _resolve_provisioning_profile, ) from backend.services.log_streamer import LogStreamer @@ -418,3 +419,19 @@ def test_cleanup_keeps_recent(tmp_path): assert recent_dir.exists() db.close() + + +def test_mark_superseded_tasks_for_same_oss_url(): + from backend.database import SessionLocal, init_db + from backend.models import Task + init_db() + db = SessionLocal() + old = Task(id="old", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html") + latest = Task(id="latest", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html") + db.add_all([old, latest]) + db.commit() + + _mark_superseded_tasks(db, latest) + assert db.get(Task, "old").superseded_at is not None + assert db.get(Task, "latest").superseded_at is None + db.close()