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 @@ +