4 Commits
Author SHA1 Message Date
shenleiandClaude Opus 4.8 605c09c88a feat: 皮肤包上传区支持拖拽上传
- 抽出 uploadSkinFile 复用点击与拖拽两种上传方式
- 拖入时高亮上传区并提示,松开即触发上传

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:20:04 +09:00
shenleiandClaude Opus 4.8 5014376b81 feat: 支持皮肤包就地更新与新建即传
- 新增皮肤包更新接口,覆盖内容保持文件名不变,避免 APP 配置的主题引用失效
- 上传新皮肤后自动为空主题目录的证书选中该皮肤
- 新建 APP 保存后不关弹窗并回填 upload_key,可直接上传皮肤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:14:08 +09:00
shenlei bb8a0afeac feat: 自动隐藏被覆盖的打包历史 2026-07-24 12:05:37 +09:00
shenlei 4dccc4d96b feat: 增加操作审计与受控下载 2026-07-22 18:12:19 +09:00
23 changed files with 536 additions and 22 deletions
+1
View File
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败 # 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1 BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ---- # ---- Watchdog 健康监测 ----
# 健康检查间隔(秒) # 健康检查间隔(秒)
+2
View File
@@ -146,6 +146,8 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production") JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24 JWT_EXPIRE_HOURS = 24
# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
def validate_production_security() -> None: def validate_production_security() -> None:
+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 -1
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .database import init_db from .database import init_db
from .routers import config, apps, tasks, auth, users from .routers import config, apps, tasks, auth, users, operations
from .services.log_streamer import log_streamer from .services.log_streamer import log_streamer
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user from .deps import decode_current_user
@@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}") @app.websocket("/ws/tasks/{task_id}")
+21
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)
@@ -60,3 +62,22 @@ class User(Base):
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow) created_at = Column(TIMESTAMP, default=datetime.utcnow)
class OperationLog(Base):
"""用户关键操作的审计与统计记录。"""
__tablename__ = "operation_logs"
id = Column(String, primary_key=True)
created_at = Column(TIMESTAMP, default=datetime.utcnow, nullable=False)
username = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
action = Column(String, nullable=False)
task_id = Column(String)
app_id = Column(String)
app_name = Column(String)
resource_type = Column(String)
resource_name = Column(String)
detail_json = Column(Text)
client_ip = Column(String)
user_agent = Column(String)
+28 -1
View File
@@ -316,7 +316,7 @@ async def create_app(app: dict):
apps[new_id] = app apps[new_id] = app
config["apps"] = apps config["apps"] = apps
save_config(config) save_config(config)
return {"id": new_id, "message": "App 已创建"} return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
@router.put("/apps/{app_id}") @router.put("/apps/{app_id}")
@@ -410,6 +410,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
return {"message": "皮肤包上传成功", "name": file.filename} return {"message": "皮肤包上传成功", "name": file.filename}
@router.put("/skins/{upload_key}/{skin_name}")
async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
"""替换已有皮肤包内容,保持原文件名不变。
APP 配置的 theme 通过文件名(去掉 .zip)引用皮肤包,若上传新文件名会新增一个皮肤而非更新,
导致原引用失效。此接口就地覆盖指定皮肤,确保被引用的皮肤可以更新。
"""
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
config = load_config()
app_id, _ = _find_app_by_upload_key(config, upload_key)
if not app_id:
raise HTTPException(status_code=404, detail="App 不存在")
app_skins_dir = SKINS_DIR / upload_key
skin_path = app_skins_dir / skin_name
if not skin_path.exists():
raise HTTPException(status_code=404, detail="皮肤包不存在")
content = await file.read()
with open(skin_path, "wb") as f:
f.write(content)
return {"message": "皮肤包已更新", "name": skin_name}
@router.delete("/skins/{upload_key}/{skin_name}") @router.delete("/skins/{upload_key}/{skin_name}")
async def delete_skin(upload_key: str, skin_name: str): async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包""" """删除指定皮肤包"""
+65
View File
@@ -0,0 +1,65 @@
"""操作日志与统计 API(仅管理员)。"""
import csv
import io
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import OperationLog
router = APIRouter(prefix="/api/operations", tags=["operations"])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _query_logs(db: Session, days: int, username: str = "", action: str = ""):
since = datetime.utcnow() - timedelta(days=max(1, min(days, 365)))
query = db.query(OperationLog).filter(OperationLog.created_at >= since)
if username:
query = query.filter(OperationLog.username == username)
if action:
query = query.filter(OperationLog.action == action)
return query
@router.get("/summary")
async def operation_summary(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
query = _query_logs(db, days)
total = query.count()
action_counts = dict(query.with_entities(OperationLog.action, func.count()).group_by(OperationLog.action).all())
users = query.with_entities(OperationLog.username, func.count()).group_by(OperationLog.username).order_by(func.count().desc()).limit(10).all()
apps = query.filter(OperationLog.app_name.isnot(None)).with_entities(OperationLog.app_name, func.count()).group_by(OperationLog.app_name).order_by(func.count().desc()).limit(10).all()
return {"days": days, "total": total, "action_counts": action_counts,
"top_users": [{"name": name, "count": count} for name, count in users],
"top_apps": [{"name": name, "count": count} for name, count in apps]}
@router.get("")
async def list_operations(days: int = Query(30, ge=1, le=365), username: str = "", action: str = "", limit: int = Query(100, ge=1, le=500), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
logs = _query_logs(db, days, username, action).order_by(OperationLog.created_at.desc()).limit(limit).all()
return [{"id": log.id, "created_at": log.created_at, "username": log.username,
"action": log.action, "app_name": log.app_name, "task_id": log.task_id,
"resource_type": log.resource_type, "resource_name": log.resource_name,
"detail": json.loads(log.detail_json or "{}"), "client_ip": log.client_ip} for log in logs]
@router.get("/export")
async def export_operations(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["时间", "用户", "操作", "App", "资源类型", "资源名称", "IP", "详情"])
for log in _query_logs(db, days).order_by(OperationLog.created_at.desc()).all():
writer.writerow([log.created_at, log.username, log.action, log.app_name or "", log.resource_type or "", log.resource_name or "", log.client_ip or "", log.detail_json or ""])
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=operation-logs.csv"})
+55 -6
View File
@@ -6,7 +6,7 @@ import tempfile
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
@router.post("", response_model=TaskResponse) @router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, db: Session = Depends(get_db)): async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建打包任务""" """创建打包任务"""
from .config import load_config from .config import load_config
@@ -63,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
status="pending", status="pending",
) )
db.add(db_task) db.add(db_task)
from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task,
detail={"branch": task.branch, "build_type": task.build_type})
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
@@ -100,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()
@@ -122,7 +132,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}") @router.delete("/{task_id}")
async def cancel_task(task_id: str, db: Session = Depends(get_db)): async def cancel_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""取消任务""" """取消任务"""
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
@@ -132,6 +142,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="任务已完成或已取消") raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled" task.status = "cancelled"
from ..services.audit_log import record_operation
record_operation(db, user, "build_cancelled", request=request, task=task)
db.commit() db.commit()
# 尝试取消运行中的任务 # 尝试取消运行中的任务
@@ -153,7 +165,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}/delete") @router.delete("/{task_id}/delete")
async def delete_task(task_id: str, db: Session = Depends(get_db)): async def delete_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除打包记录""" """删除打包记录"""
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -199,6 +211,8 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if build_path.exists(): if build_path.exists():
shutil.rmtree(build_path, ignore_errors=True) shutil.rmtree(build_path, ignore_errors=True)
from ..services.audit_log import record_operation
record_operation(db, user, "task_deleted", request=request, task=task)
db.delete(task) db.delete(task)
db.commit() db.commit()
@@ -243,8 +257,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
return {"log": ""} return {"log": ""}
@router.get("/{task_id}/download-link")
async def get_download_link(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""记录已登录用户发起 IPA 下载,并返回 OSS 公开链接。"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.oss_url:
raise HTTPException(status_code=404, detail="下载文件不存在")
try:
from .config import load_config
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
from ..services.distribution import DistributionError, get_published_download_url
download_url = get_published_download_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.oss_url,
)
except (json.JSONDecodeError, DistributionError) as 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,
resource_type="ipa", resource_name=task.oss_url,
detail={"controlled": True})
db.commit()
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
@router.get("/{task_id}/dsym") @router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, db: Session = Depends(get_db)): async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载 dSYM 文件""" """下载 dSYM 文件"""
import shutil import shutil
import zipfile import zipfile
@@ -255,6 +296,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path: if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在") raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path dsym_path = task.dsym_path
if not os.path.exists(dsym_path): if not os.path.exists(dsym_path):
@@ -275,13 +320,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
@router.get("/{task_id}/obfuscation-maps") @router.get("/{task_id}/obfuscation-maps")
async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)): async def download_obfuscation_maps(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载混淆映射表""" """下载混淆映射表"""
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.obfuscation_maps_path: if not task.obfuscation_maps_path:
raise HTTPException(status_code=404, detail="混淆映射表不存在") raise HTTPException(status_code=404, detail="混淆映射表不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "obfuscation_maps_downloaded", request=request, task=task,
resource_type="obfuscation_maps", resource_name=os.path.basename(task.obfuscation_maps_path))
db.commit()
maps_path = task.obfuscation_maps_path maps_path = task.obfuscation_maps_path
if not os.path.exists(maps_path): if not os.path.exists(maps_path):
+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]
+23
View File
@@ -0,0 +1,23 @@
"""操作审计日志。"""
import json
import uuid
from datetime import datetime
from ..models import OperationLog
def record_operation(db, user: dict, action: str, *, request=None, task=None,
resource_type: str = "", resource_name: str = "", detail: dict | None = None):
"""将已登录用户的关键操作写入当前数据库事务。"""
client_ip = request.client.host if request and request.client else ""
user_agent = request.headers.get("user-agent", "")[:500] if request else ""
db.add(OperationLog(
id=str(uuid.uuid4()), created_at=datetime.utcnow(), username=user.get("username", ""),
is_admin=bool(user.get("is_admin")), action=action,
task_id=task.id if task else None,
app_id=task.app_id if task else None,
app_name=task.app_name if task else None,
resource_type=resource_type, resource_name=resource_name,
detail_json=json.dumps(detail or {}, ensure_ascii=False),
client_ip=client_ip, user_agent=user_agent,
))
+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}")
+26
View File
@@ -9,6 +9,8 @@ from urllib.parse import quote, unquote, urlparse
import httpx import httpx
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
class DistributionError(Exception): class DistributionError(Exception):
"""分发配置或上传过程失败。""" """分发配置或上传过程失败。"""
@@ -167,6 +169,30 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths) deleter(upload_config, remote_paths)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
oss = upload_config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required):
raise DistributionError("OSS 配置不完整")
try:
import oss2
except ImportError as exc:
raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc
bucket = oss2.Bucket(
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
)
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = "" path = ""
for segment in remote_path.strip("/").split("/")[:-1]: for segment in remote_path.strip("/").split("/")[:-1]:
+1
View File
@@ -7,6 +7,7 @@
<router-link to="/build">打包</router-link> <router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link> <router-link to="/history">历史</router-link>
<router-link to="/admin">管理</router-link> <router-link to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
</nav> </nav>
<div v-if="!isLoggedIn" class="user-info"> <div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button> <button class="btn-login" @click="showLogin = true">登录</button>
@@ -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()
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import OperationLogView from '../views/OperationLogView.vue'
global.fetch = vi.fn()
describe('OperationLogView.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
fetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ total: 4, action_counts: { build_created: 2, ipa_download_requested: 1 }, top_users: [{ name: 'admin', count: 4 }], top_apps: [{ name: '测试App', count: 3 }] }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([{ id: '1', created_at: '2026-07-22T08:00:00', username: 'admin', action: 'build_created', app_name: '测试App', resource_type: '', client_ip: '127.0.0.1' }]) })
})
it('管理员可查看统计和操作明细', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(true), getToken: () => 'token' } } })
await flushPromises()
expect(wrapper.text()).toContain('操作日志')
expect(wrapper.text()).toContain('操作总数')
expect(wrapper.text()).toContain('发起打包')
expect(wrapper.text()).toContain('测试App')
})
it('普通用户不渲染操作日志页面', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(false) } } })
await flushPromises()
expect(wrapper.find('.panel').exists()).toBe(false)
})
})
+2
View File
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import BuildView from '../views/BuildView.vue' import BuildView from '../views/BuildView.vue'
import HistoryView from '../views/HistoryView.vue' import HistoryView from '../views/HistoryView.vue'
import ConfigView from '../views/ConfigView.vue' import ConfigView from '../views/ConfigView.vue'
import OperationLogView from '../views/OperationLogView.vue'
const routes = [ const routes = [
{ path: '/', redirect: '/build' }, { path: '/', redirect: '/build' },
{ path: '/build', name: 'Build', component: BuildView }, { path: '/build', name: 'Build', component: BuildView },
{ path: '/history', name: 'History', component: HistoryView }, { path: '/history', name: 'History', component: HistoryView },
{ path: '/admin', name: 'Admin', component: ConfigView }, { path: '/admin', name: 'Admin', component: ConfigView },
{ path: '/operations', name: 'Operations', component: OperationLogView },
] ]
const router = createRouter({ const router = createRouter({
+74 -8
View File
@@ -519,12 +519,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item"> <div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span> <span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span> <span class="skin-size">{{ formatSize(skin.size) }}</span>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button> <button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div> </div>
</div> </div>
<div v-else class="skins-hint">暂无皮肤包</div> <div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }"> <label class="btn btn-outline skin-upload-btn"
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }} :class="{ disabled: skinUploading, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip或拖拽到此处)') }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden> <input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label> </label>
</div> </div>
@@ -615,6 +624,7 @@ const editingAppId = ref(null)
const appForm = ref({}) const appForm = ref({})
const appSkins = ref([]) const appSkins = ref([])
const skinUploading = ref(false) const skinUploading = ref(false)
const skinDragover = ref(false)
const showSchemeModal = ref(false) const showSchemeModal = ref(false)
const editingSchemeId = ref(null) const editingSchemeId = ref(null)
@@ -862,9 +872,9 @@ const loadSkins = async () => {
} catch { appSkins.value = [] } } catch { appSkins.value = [] }
} }
const uploadSkin = async (event) => { const uploadSkinFile = async (file) => {
const file = event.target.files[0]
if (!file) return if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return } if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true skinUploading.value = true
@@ -880,7 +890,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败') throw new Error(err.detail || '上传失败')
} }
await loadSkins() await loadSkins()
event.target.value = '' // 若某证书的主题目录还是空的,自动选中刚上传的皮肤
const skinTheme = file.name.replace(/\.zip$/i, '')
const certs = appForm.value.certificates || {}
Object.keys(certs).forEach(certType => {
if (!certs[certType].theme) certs[certType].theme = skinTheme
})
} catch (e) { } catch (e) {
alert('上传失败: ' + e.message) alert('上传失败: ' + e.message)
} finally { } finally {
@@ -888,6 +903,44 @@ const uploadSkin = async (event) => {
} }
} }
const uploadSkin = async (event) => {
await uploadSkinFile(event.target.files[0])
event.target.value = ''
}
const onSkinDrop = (event) => {
skinDragover.value = false
if (skinUploading.value) return
const file = event.dataTransfer.files[0]
uploadSkinFile(file)
}
const updateSkin = async (skinName, event) => {
const file = event.target.files[0]
if (!file) return
const uploadKey = appForm.value.upload_key
if (!uploadKey) { event.target.value = ''; return }
skinUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, {
method: 'PUT',
body: formData,
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '更新失败')
}
await loadSkins()
} catch (e) {
alert('更新失败: ' + e.message)
} finally {
event.target.value = ''
skinUploading.value = false
}
}
const deleteSkin = async (skinName) => { const deleteSkin = async (skinName) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
@@ -948,9 +1001,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' })) const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败') throw new Error(err.detail || '保存失败')
} }
showAppModal.value = false
await loadData() await loadData()
alert('保存成功') if (!editingAppId.value) {
// 新建成功:不关弹窗,转为编辑态并回填 upload_key,让皮肤包上传区立即可用
const data = await res.json().catch(() => ({}))
editingAppId.value = data.id || editingAppId.value
if (data.upload_key) appForm.value.upload_key = data.upload_key
await loadSkins()
alert('保存成功,现在可以上传皮肤包了')
} else {
showAppModal.value = false
alert('保存成功')
}
} catch (e) { } catch (e) {
alert('保存失败: ' + e.message) alert('保存失败: ' + e.message)
} }
@@ -1160,9 +1222,13 @@ const formatJson = () => {
.btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; } .btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.btn-danger-icon { background: #fff1f0; color: #ff4d4f; } .btn-danger-icon { background: #fff1f0; color: #ff4d4f; }
.btn-danger-icon:hover { background: #ff4d4f; color: white; } .btn-danger-icon:hover { background: #ff4d4f; color: white; }
.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
.skin-update-btn:hover { background: #1890ff; color: white; }
.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; } .btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
.btn-outline:hover { background: #e6f7ff; } .btn-outline:hover { background: #e6f7ff; }
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; } .btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
.skin-upload-btn { margin-top: 8px; } .skin-upload-btn { margin-top: 8px; transition: background 0.15s, border-color 0.15s; }
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; } .theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style> </style>
+21 -5
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">
@@ -55,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)">
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a> <button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
@@ -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)
@@ -308,6 +312,17 @@ const downloadDsym = (taskId) => {
window.open(`/api/tasks/${taskId}/dsym`) window.open(`/api/tasks/${taskId}/dsym`)
} }
const downloadIpa = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error()
const { url } = await res.json()
window.open(url, '_blank')
} catch {
alert('下载失败,请稍后重试')
}
}
const downloadObfMaps = (taskId) => { const downloadObfMaps = (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) window.open(`/api/tasks/${taskId}/obfuscation-maps`)
} }
@@ -457,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 {
@@ -506,7 +522,7 @@ const errorCategoryLabel = (cat) => {
transition: all 0.2s; transition: all 0.2s;
} }
.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; } .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; }
.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; }
+24
View File
@@ -0,0 +1,24 @@
<template>
<div v-if="isAdmin" class="container">
<div class="panel">
<div class="header-row"><h2>操作日志</h2><div><select v-model.number="days" @change="load"><option :value="7"> 7 </option><option :value="30"> 30 </option><option :value="90"> 90 </option></select><button @click="exportCsv">导出 CSV</button></div></div>
<div class="cards"><div><strong>{{ summary.total || 0 }}</strong><span>操作总数</span></div><div><strong>{{ summary.action_counts?.build_created || 0 }}</strong><span>发起打包</span></div><div><strong>{{ summary.action_counts?.ipa_download_requested || 0 }}</strong><span>发起 IPA 下载</span></div></div>
<div class="columns"><section><h3>活跃用户</h3><p v-for="item in summary.top_users || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section><section><h3>App 操作量</h3><p v-for="item in summary.top_apps || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section></div>
<table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>App</th><th>资源</th><th>IP</th></tr></thead><tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.created_at) }}</td><td>{{ log.username }}</td><td>{{ actionText(log.action) }}</td><td>{{ log.app_name || '-' }}</td><td>{{ log.resource_type || '-' }}</td><td>{{ log.client_ip || '-' }}</td></tr><tr v-if="!logs.length"><td colspan="6">暂无操作记录</td></tr></tbody></table>
</div>
</div>
</template>
<script setup>
import { inject, ref, onMounted } from 'vue'
const isAdmin = inject('isAdmin', ref(false)); const getToken = inject('getToken', () => '')
const days = ref(30); const summary = ref({}); const logs = ref([])
const authFetch = (url) => fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
const load = async () => { const [summaryRes, logsRes] = await Promise.all([authFetch(`/api/operations/summary?days=${days.value}`), authFetch(`/api/operations?days=${days.value}`)]); if (summaryRes.ok) summary.value = await summaryRes.json(); if (logsRes.ok) logs.value = await logsRes.json() }
const exportCsv = async () => { const res = await authFetch(`/api/operations/export?days=${days.value}`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'operation-logs.csv'; link.click(); URL.revokeObjectURL(url) }
const formatTime = value => value ? new Date(value.endsWith('Z') ? value : `${value}Z`).toLocaleString('zh-CN', { hour12: false }) : '-'
const actionText = action => ({ build_created: '发起打包', build_cancelled: '取消打包', task_deleted: '删除记录', ipa_download_requested: '发起 IPA 下载', dsym_downloaded: '下载 dSYM', obfuscation_maps_downloaded: '下载混淆映射' }[action] || action)
onMounted(load)
</script>
<style scoped>
.container{max-width:1200px;margin:0 auto;padding:24px}.panel{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px #00000014}.header-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header-row h2{margin:0}.header-row select,.header-row button{padding:8px 12px;margin-left:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff}.header-row button{color:#1890ff;cursor:pointer}.cards,.columns{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.cards div,.columns section{padding:16px;border-radius:8px;background:#fafafa}.cards strong{display:block;font-size:24px;color:#1890ff}.cards span{font-size:13px;color:#666}.columns{grid-template-columns:1fr 1fr}.columns h3{margin:0 0 10px;font-size:14px}.columns p{display:flex;justify-content:space-between;margin:8px 0;color:#555}table{width:100%;border-collapse:collapse}th,td{padding:11px;border-bottom:1px solid #eee;text-align:left;font-size:13px}th{color:#666;background:#fafafa}@media(max-width:700px){.cards{grid-template-columns:1fr}.columns{grid-template-columns:1fr}.header-row{align-items:flex-start;gap:12px;flex-direction:column}}
</style>
+47
View File
@@ -0,0 +1,47 @@
"""操作审计日志接口测试。"""
from unittest.mock import AsyncMock, patch
@patch("backend.services.build_queue.build_queue")
def test_build_creation_is_recorded_and_visible_to_admin(mock_queue, client, tmp_config):
mock_queue.submit = AsyncMock()
response = client.post("/api/tasks", json={"app_id": "1", "scheme_id": "1", "build_type": "Ad_Hoc"})
assert response.status_code == 200
summary = client.get("/api/operations/summary")
assert summary.status_code == 200
assert summary.json()["action_counts"]["build_created"] == 1
logs = client.get("/api/operations").json()
assert logs[0]["username"] == "admin"
assert logs[0]["action"] == "build_created"
def test_download_link_is_recorded(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
db.add(Task(id="download-task", app_id="1", app_name="测试App", build_type="App_Store", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.ipa"))
db.commit()
db.close()
with patch("backend.services.distribution.get_published_download_url", return_value="https://signed.example.com/app.ipa?signature=1"):
response = client.get("/api/tasks/download-task/download-link")
assert response.status_code == 200
assert "signature=1" in response.json()["url"]
assert client.get("/api/operations").json()[0]["action"] == "ipa_download_requested"
def test_regular_user_cannot_view_operation_logs(client):
from backend.database import SessionLocal
from backend.models import User
from backend.security import hash_password
db = SessionLocal()
db.add(User(id="regular-user", username="reader", password_hash=hash_password("reader-password"), is_admin=False))
db.commit()
db.close()
login = client.post("/api/auth/login", json={"username": "reader", "password": "reader-password"})
client.headers.update({"Authorization": f"Bearer {login.json()['token']}"})
assert client.get("/api/operations").status_code == 403
assert client.get("/api/operations/summary").status_code == 403
+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()
+20
View File
@@ -9,6 +9,7 @@ from backend.services.distribution import (
_write_download_page, _write_download_page,
_write_manifest, _write_manifest,
publish_ipa, publish_ipa,
get_published_download_url,
) )
@@ -119,3 +120,22 @@ def test_delete_uses_saved_url_for_legacy_artifact_name():
"readoor/iOS/100_2_0_0_0.html", "readoor/iOS/100_2_0_0_0.html",
"readoor/iOS/100_2_0_0_0.png", "readoor/iOS/100_2_0_0_0.png",
] ]
def test_oss_download_url_is_short_lived_and_uses_actual_object_key(monkeypatch):
class FakeBucket:
def __init__(self, *_args, **_kwargs): pass
def sign_url(self, method, key, expires, **kwargs):
assert (method, key, expires, kwargs) == ("GET", "readoor/iOS/100_2_0_0_0.ipa", 600, {"slash_safe": True})
return "https://signed.example.com/app.ipa?signature=1"
class FakeOss:
Auth = staticmethod(lambda *_args: object())
Bucket = FakeBucket
monkeypatch.setitem(__import__("sys").modules, "oss2", FakeOss)
url = get_published_download_url(
{"BUILD_TYPE": "App_Store"},
{"mode": "oss", "oss": {"access_key_id": "id", "access_key_secret": "secret", "endpoint": "oss.example.com", "bucket_name": "bucket", "base_url": "https://files.example.com"}},
"https://files.example.com/readoor/iOS/100_2_0_0_0.ipa",
)
assert "signature=1" in url