feat: 自动隐藏被覆盖的打包历史

This commit is contained in:
shenlei
2026-07-24 12:05:37 +09:00
parent 4dccc4d96b
commit bb8a0afeac
9 changed files with 111 additions and 4 deletions
+1
View File
@@ -24,6 +24,7 @@ def _migrate_db():
migrations = [ migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
+2
View File
@@ -25,6 +25,8 @@ class Task(Base):
created_at = Column(TIMESTAMP, default=datetime.utcnow) created_at = Column(TIMESTAMP, default=datetime.utcnow)
started_at = Column(TIMESTAMP) started_at = Column(TIMESTAMP)
completed_at = Column(TIMESTAMP) completed_at = Column(TIMESTAMP)
# 同一 OSS 对象被后续成功打包覆盖时标记;默认历史列表不展示。
superseded_at = Column(TIMESTAMP)
# 产物 # 产物
ipa_path = Column(String) ipa_path = Column(String)
+7
View File
@@ -103,12 +103,19 @@ def _enrich_tasks(tasks):
@router.get("", response_model=List[TaskResponse]) @router.get("", response_model=List[TaskResponse])
async def list_tasks( async def list_tasks(
status: str = None, status: str = None,
include_superseded: bool = False,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取任务列表""" """获取任务列表"""
query = db.query(Task) 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: if status:
query = query.filter(Task.status == status) query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
+1
View File
@@ -27,6 +27,7 @@ class TaskResponse(BaseModel):
created_at: Optional[datetime] created_at: Optional[datetime]
started_at: Optional[datetime] started_at: Optional[datetime]
completed_at: Optional[datetime] completed_at: Optional[datetime]
superseded_at: Optional[datetime] = None
ipa_path: Optional[str] ipa_path: Optional[str]
oss_url: Optional[str] oss_url: Optional[str]
dsym_path: Optional[str] dsym_path: Optional[str]
+47 -1
View File
@@ -6,7 +6,7 @@ import plistlib
import shutil import shutil
import subprocess import subprocess
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@@ -214,6 +214,7 @@ def _cleanup_old_builds(db):
base = BUILD_BASE_DIR base = BUILD_BASE_DIR
if not base.exists(): if not base.exists():
_cleanup_superseded_tasks(db)
return return
for d in base.iterdir(): for d in base.iterdir():
if d.is_dir() and d.name.startswith("build_readoor_"): if d.is_dir() and d.name.startswith("build_readoor_"):
@@ -222,6 +223,50 @@ def _cleanup_old_builds(db):
shutil.rmtree(d) shutil.rmtree(d)
except Exception: except Exception:
pass 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(): def _cleanup_old_logs():
@@ -439,6 +484,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(), status="completed", completed_at=datetime.utcnow(),
current_step=None) current_step=None)
await asyncio.to_thread(_mark_superseded_tasks, db, task)
await log_streamer.emit_step(task_id, "打包完成") await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}") await log_streamer.emit(task_id, f"IPA: {ipa_path}")
@@ -159,6 +159,18 @@ describe('HistoryView.vue', () => {
expect(wrapper.text()).not.toContain('日志') 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 () => { it('空列表显示提示', async () => {
fetch.mockResolvedValue({ json: () => Promise.resolve([]) }) fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
const router = createMockRouter() const router = createMockRouter()
+8 -3
View File
@@ -20,6 +20,7 @@
<option value="failed">失败</option> <option value="failed">失败</option>
<option value="cancelled">已取消</option> <option value="cancelled">已取消</option>
</select> </select>
<label v-if="isAdmin" class="superseded-toggle"><input v-model="showSuperseded" type="checkbox" @change="loadTasks"> 显示已覆盖</label>
</div> </div>
</div> </div>
<table class="config-table"> <table class="config-table">
@@ -180,6 +181,7 @@ const logLines = ref([])
const logContainer = ref(null) const logContainer = ref(null)
const showVerboseLogs = ref(false) const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
const showSuperseded = ref(false)
let logWs = null let logWs = null
const getAppVersion = (task) => { const getAppVersion = (task) => {
@@ -210,11 +212,13 @@ const filteredTasks = computed(() => {
}) })
}) })
onMounted(async () => { const loadTasks = async () => {
const res = await authFetch('/api/tasks?limit=100') const res = await authFetch(`/api/tasks?limit=100${showSuperseded.value ? '&include_superseded=true' : ''}`)
const data = await res.json() const data = await res.json()
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) })) tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
}) }
onMounted(loadTasks)
const viewLogs = async (taskId) => { const viewLogs = async (taskId) => {
const task = tasks.value.find(t => t.id === 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; } .meta-value { font-size: 13px; color: #333; font-weight: 500; }
.version-filter { width: 150px; } .version-filter { width: 150px; }
.superseded-toggle { display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; white-space: nowrap; }
/* 日志操作栏 */ /* 日志操作栏 */
.log-modal-actions { .log-modal-actions {
+16
View File
@@ -214,3 +214,19 @@ def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
assert resp.status_code == 200 assert resp.status_code == 200
assert "仍被其他记录引用" in resp.json()["message"] assert "仍被其他记录引用" in resp.json()["message"]
delete.assert_not_called() 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"}
+17
View File
@@ -20,6 +20,7 @@ from backend.services.build_service import (
run_pod_install, run_pod_install,
_patch_afnetworking_private_headers, _patch_afnetworking_private_headers,
_cleanup_old_builds, _cleanup_old_builds,
_mark_superseded_tasks,
_resolve_provisioning_profile, _resolve_provisioning_profile,
) )
from backend.services.log_streamer import LogStreamer from backend.services.log_streamer import LogStreamer
@@ -418,3 +419,19 @@ def test_cleanup_keeps_recent(tmp_path):
assert recent_dir.exists() assert recent_dir.exists()
db.close() 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()