feat: 自动隐藏被覆盖的打包历史
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<label v-if="isAdmin" class="superseded-toggle"><input v-model="showSuperseded" type="checkbox" @change="loadTasks"> 显示已覆盖</label>
|
||||
</div>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user