From 4dccc4d96bdcea3b49c136a73c38d0f420c1a1ee Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 22 Jul 2026 18:12:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=B8=8E=E5=8F=97=E6=8E=A7=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + backend/config.py | 2 + backend/main.py | 3 +- backend/models.py | 19 ++++++ backend/routers/operations.py | 65 +++++++++++++++++++ backend/routers/tasks.py | 54 +++++++++++++-- backend/services/audit_log.py | 23 +++++++ backend/services/distribution.py | 26 ++++++++ frontend/src/App.vue | 1 + .../src/__tests__/OperationLogView.test.js | 30 +++++++++ frontend/src/router/index.js | 2 + frontend/src/views/HistoryView.vue | 15 ++++- frontend/src/views/OperationLogView.vue | 24 +++++++ tests/test_api_operations.py | 47 ++++++++++++++ tests/test_distribution.py | 20 ++++++ 15 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 backend/routers/operations.py create mode 100644 backend/services/audit_log.py create mode 100644 frontend/src/__tests__/OperationLogView.test.js create mode 100644 frontend/src/views/OperationLogView.vue create mode 100644 tests/test_api_operations.py 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 @@ 打包 历史 管理 + 操作日志
diff --git a/frontend/src/__tests__/OperationLogView.test.js b/frontend/src/__tests__/OperationLogView.test.js new file mode 100644 index 0000000..11a811c --- /dev/null +++ b/frontend/src/__tests__/OperationLogView.test.js @@ -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) + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 4292b81..b5e5347 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router' import BuildView from '../views/BuildView.vue' import HistoryView from '../views/HistoryView.vue' import ConfigView from '../views/ConfigView.vue' +import OperationLogView from '../views/OperationLogView.vue' const routes = [ { path: '/', redirect: '/build' }, { path: '/build', name: 'Build', component: BuildView }, { path: '/history', name: 'History', component: HistoryView }, { path: '/admin', name: 'Admin', component: ConfigView }, + { path: '/operations', name: 'Operations', component: OperationLogView }, ] const router = createRouter({ diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index 45be151..988158a 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -55,7 +55,7 @@ QR - 下载 IPA + - @@ -308,6 +308,17 @@ const downloadDsym = (taskId) => { 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) => { window.open(`/api/tasks/${taskId}/obfuscation-maps`) } @@ -506,7 +517,7 @@ const errorCategoryLabel = (cat) => { transition: all 0.2s; } .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; } .text-muted { color: #999; font-size: 13px; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } diff --git a/frontend/src/views/OperationLogView.vue b/frontend/src/views/OperationLogView.vue new file mode 100644 index 0000000..66d24b4 --- /dev/null +++ b/frontend/src/views/OperationLogView.vue @@ -0,0 +1,24 @@ + + + diff --git a/tests/test_api_operations.py b/tests/test_api_operations.py new file mode 100644 index 0000000..ca70e12 --- /dev/null +++ b/tests/test_api_operations.py @@ -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 diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 9f3a83f..37089d3 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -9,6 +9,7 @@ from backend.services.distribution import ( _write_download_page, _write_manifest, 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.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