fix: 皮肤包删除后被自动迁移逻辑复活,IPA 下载连击刷屏操作日志

_migrate_old_themes 之前每次 load_config() 都会重跑,只要证书仍引用某皮肤
且旧版 themes 目录还在,删除后会被立即重新生成;现改为仅首次执行一次并
在 config.json 中打标记跳过后续调用。

打包历史下载 IPA 按钮此前无连击防护,前端加 in-flight 禁用态,后端对同一
用户同一任务的下载操作日志增加 5 秒去重,避免连击刷屏审计日志。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-08-24 14:19:00 +09:00
co-authored by Claude Sonnet 5
parent 84a2d3db01
commit c17b7d5ad9
3 changed files with 40 additions and 11 deletions
+10 -4
View File
@@ -300,8 +300,14 @@ def _ensure_upload_keys(config: dict) -> bool:
def _migrate_old_themes(config: dict) -> bool: def _migrate_old_themes(config: dict) -> bool:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更""" """将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
changed = False
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes" themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items(): for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "") upload_key = app.get("upload_key", "")
@@ -336,8 +342,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins app["skins"] = skins
if theme != skin_name: if theme != skin_name:
cert["theme"] = skin_name cert["theme"] = skin_name
changed = True config["_legacy_themes_migrated"] = True
return changed return True
def load_config() -> dict: def load_config() -> dict:
+21 -5
View File
@@ -3,6 +3,7 @@ import os
import json import json
import shutil import shutil
import tempfile import tempfile
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
@@ -18,6 +19,10 @@ from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
_recent_download_logs: dict[str, float] = {}
@router.post("", response_model=TaskResponse) @router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)): async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -276,11 +281,22 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
) )
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task, debounce_key = f"{user.get('username')}:{task_id}"
resource_type="ipa", resource_name=task.oss_url, now = time.monotonic()
detail={"controlled": True}) last = _recent_download_logs.get(debounce_key)
db.commit() if last is None or now - last > _DOWNLOAD_LOG_DEBOUNCE_SECONDS:
if len(_recent_download_logs) > 500:
cutoff = now - _DOWNLOAD_LOG_DEBOUNCE_SECONDS
for key, ts in list(_recent_download_logs.items()):
if ts < cutoff:
del _recent_download_logs[key]
_recent_download_logs[debounce_key] = now
from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url,
detail={"controlled": True})
db.commit()
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS} return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
+9 -2
View File
@@ -56,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path" <img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb" :src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)"> @click="showQrPreview(task)">
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button> <button v-if="task.build_type === 'App_Store'" class="download-link" :disabled="!!downloadingIds[task.id]" @click="downloadIpa(task.id)">{{ downloadingIds[task.id] ? '下载中...' : '下载 IPA' }}</button>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
@@ -159,7 +159,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, reactive, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken', () => '') const getToken = inject('getToken', () => '')
const isAdmin = inject('isAdmin', ref(false)) const isAdmin = inject('isAdmin', ref(false))
@@ -182,6 +182,7 @@ const logContainer = ref(null)
const showVerboseLogs = ref(false) const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
const showSuperseded = ref(false) const showSuperseded = ref(false)
const downloadingIds = reactive({})
let logWs = null let logWs = null
const getAppVersion = (task) => { const getAppVersion = (task) => {
@@ -313,6 +314,8 @@ const downloadDsym = (taskId) => {
} }
const downloadIpa = async (taskId) => { const downloadIpa = async (taskId) => {
if (downloadingIds[taskId]) return
downloadingIds[taskId] = true
try { try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`) const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error() if (!res.ok) throw new Error()
@@ -320,6 +323,8 @@ const downloadIpa = async (taskId) => {
window.open(url, '_blank') window.open(url, '_blank')
} catch { } catch {
alert('下载失败,请稍后重试') alert('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
} }
} }
@@ -524,6 +529,8 @@ const errorCategoryLabel = (cat) => {
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; } .download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
.download-link:hover { text-decoration: underline; } .download-link:hover { text-decoration: underline; }
.download-link:disabled { color: #999; cursor: default; }
.download-link:disabled:hover { text-decoration: none; }
.text-muted { color: #999; font-size: 13px; } .text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.qr-thumb:hover { border-color: #1890ff; } .qr-thumb:hover { border-color: #1890ff; }