feat: 增加操作审计与受控下载
This commit is contained in:
parent
624f534b0f
commit
4dccc4d96b
@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
|
||||
|
||||
# 打包超时时间(小时),超时自动标记失败
|
||||
BUILD_TIMEOUT_HOURS=1
|
||||
DOWNLOAD_URL_EXPIRE_SECONDS=600
|
||||
|
||||
# ---- Watchdog 健康监测 ----
|
||||
# 健康检查间隔(秒)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
backend/routers/operations.py
Normal file
65
backend/routers/operations.py
Normal 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"})
|
||||
@ -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
backend/services/audit_log.py
Normal file
23
backend/services/audit_log.py
Normal 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,
|
||||
))
|
||||
@ -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]:
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
<router-link to="/build">打包</router-link>
|
||||
<router-link to="/history">历史</router-link>
|
||||
<router-link to="/admin">管理</router-link>
|
||||
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
|
||||
</nav>
|
||||
<div v-if="!isLoggedIn" class="user-info">
|
||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||
|
||||
30
frontend/src/__tests__/OperationLogView.test.js
Normal file
30
frontend/src/__tests__/OperationLogView.test.js
Normal file
@ -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,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({
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
||||
@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>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
@ -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; }
|
||||
|
||||
24
frontend/src/views/OperationLogView.vue
Normal file
24
frontend/src/views/OperationLogView.vue
Normal 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
tests/test_api_operations.py
Normal file
47
tests/test_api_operations.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user