feat: 增加操作审计与受控下载

This commit is contained in:
shenlei
2026-07-22 18:12:19 +09:00
parent 624f534b0f
commit 4dccc4d96b
15 changed files with 323 additions and 9 deletions
+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_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:
+2 -1
View File
@@ -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}")
+19
View File
@@ -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)
+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"})
+48 -6
View File
@@ -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):
+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,
))
+26
View File
@@ -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]: