diff --git a/.env.example b/.env.example
index df80394..f1cf9ca 100644
--- a/.env.example
+++ b/.env.example
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
+DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ----
# 健康检查间隔(秒)
diff --git a/backend/config.py b/backend/config.py
index 5455b6d..d64850f 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -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_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
+# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
+DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
def validate_production_security() -> None:
diff --git a/backend/main.py b/backend/main.py
index 669bba7..e0d9ca9 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
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 .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user
@@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router)
app.include_router(apps.router)
app.include_router(tasks.router)
+app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}")
diff --git a/backend/models.py b/backend/models.py
index 4ab93e2..2f54917 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -60,3 +60,22 @@ class User(Base):
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
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)
diff --git a/backend/routers/operations.py b/backend/routers/operations.py
new file mode 100644
index 0000000..2e25395
--- /dev/null
+++ b/backend/routers/operations.py
@@ -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"})
diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py
index cf5ca80..a0bc3cd 100644
--- a/backend/routers/tasks.py
+++ b/backend/routers/tasks.py
@@ -6,7 +6,7 @@ import tempfile
import uuid
from datetime import datetime
from typing import List
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
@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
@@ -63,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
status="pending",
)
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.refresh(db_task)
@@ -122,7 +125,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
@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()
if not task:
@@ -132,6 +135,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled"
+ from ..services.audit_log import record_operation
+ record_operation(db, user, "build_cancelled", request=request, task=task)
db.commit()
# 尝试取消运行中的任务
@@ -153,7 +158,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
@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
from pathlib import Path
@@ -199,6 +204,8 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if build_path.exists():
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.commit()
@@ -243,8 +250,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
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")
-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 文件"""
import shutil
import zipfile
@@ -255,6 +289,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
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
if not os.path.exists(dsym_path):
@@ -275,13 +313,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
@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()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.obfuscation_maps_path:
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
if not os.path.exists(maps_path):
diff --git a/backend/services/audit_log.py b/backend/services/audit_log.py
new file mode 100644
index 0000000..d3edd5f
--- /dev/null
+++ b/backend/services/audit_log.py
@@ -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,
+ ))
diff --git a/backend/services/distribution.py b/backend/services/distribution.py
index b81a3fb..4982189 100644
--- a/backend/services/distribution.py
+++ b/backend/services/distribution.py
@@ -9,6 +9,8 @@ from urllib.parse import quote, unquote, urlparse
import httpx
+from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
+
class DistributionError(Exception):
"""分发配置或上传过程失败。"""
@@ -167,6 +169,30 @@ def delete_published_artifacts(
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):
path = ""
for segment in remote_path.strip("/").split("/")[:-1]:
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 27441dc..753ba2c 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -7,6 +7,7 @@