公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
831 lines
45 KiB
Python
831 lines
45 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import secrets
|
||
import threading
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timedelta
|
||
|
||
from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, UploadFile
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from sqlalchemy import delete, func, inspect, or_, select, text
|
||
from sqlalchemy.orm import Session
|
||
from starlette.datastructures import UploadFile as FormUpload # request.form() 产出的是 starlette 的类型,不是 fastapi 的子类
|
||
|
||
from .backup_service import create_backup
|
||
from .config import BASE_DIR, settings
|
||
from .db import Base, SessionLocal, engine, get_db
|
||
from .dynamic_fields import active_definitions, apply_custom_values, definition_json, definition_usage_counts, render_custom_value, validate_custom_values
|
||
from .images import EXTENSIONS, MAX_IMAGE_BYTES, MAX_IMAGES_PER_RECORD, MAX_THUMBNAIL_BYTES, inspect_image, safe_original_name, trim_trailing_payload
|
||
from .models import AuditLog, BackupRecord, CustomFieldDefinition, CustomFieldValue, EntryBatch, MaterialRecord, RecordImage, User
|
||
from .schemas import RECORD_ONLY_FIELDS, CustomFieldDefinitionInput, EntryRecordSubmission, MaterialRecordInput
|
||
from .security import create_entry_session, create_session, entry_key_hash, get_entry_session_user, get_session_user, hash_password, revoke_entry_session, revoke_session, revoke_user_entry_sessions, validate_password, verify_password
|
||
|
||
|
||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||
SESSION_COOKIE = "laser_session"
|
||
ENTRY_COOKIE = "laser_entry_session"
|
||
UPLOAD_PATHS = ("/api/uploads/images", "/api/entry/uploads/images")
|
||
MAX_UPLOAD_BODY = MAX_IMAGE_BYTES + MAX_THUMBNAIL_BYTES + 16 * 1024
|
||
MAX_PENDING_IMAGES = 24
|
||
PENDING_IMAGE_HOURS = 2
|
||
UPLOAD_WINDOW_SECONDS = 600
|
||
UPLOAD_WINDOW_LIMIT = 90
|
||
_entry_attempts: dict[str, list[float]] = {}
|
||
_entry_attempts_lock = threading.Lock()
|
||
_upload_attempts: dict[int, list[float]] = {}
|
||
_upload_attempts_lock = threading.Lock()
|
||
|
||
|
||
def initialize_database() -> None:
|
||
Base.metadata.create_all(engine)
|
||
user_columns = {column["name"] for column in inspect(engine).get_columns("users")}
|
||
with engine.begin() as connection:
|
||
if "entry_key_hash" not in user_columns:
|
||
connection.execute(text("ALTER TABLE users ADD COLUMN entry_key_hash VARCHAR(64)"))
|
||
if "entry_key_updated_at" not in user_columns:
|
||
connection.execute(text("ALTER TABLE users ADD COLUMN entry_key_updated_at TIMESTAMP"))
|
||
connection.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_entry_key_hash ON users(entry_key_hash)"))
|
||
if engine.dialect.name == "postgresql": # 老库的 integer 存不下超过 2 GiB 的备份,图片入库后很快会撞上
|
||
connection.execute(text("ALTER TABLE backup_records ALTER COLUMN size_bytes TYPE BIGINT"))
|
||
with SessionLocal() as db:
|
||
if not db.scalar(select(User).limit(1)):
|
||
db.add(User(username=settings.admin_username, display_name="系统管理员", password_hash=hash_password(settings.admin_password), role="admin", must_change_password=True))
|
||
db.commit()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app: FastAPI):
|
||
initialize_database()
|
||
yield
|
||
|
||
|
||
app = FastAPI(title=settings.app_name, version="1.3.0", description="激光雕刻及材料物理特性数据管理接口", lifespan=lifespan)
|
||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||
|
||
|
||
@app.middleware("http")
|
||
async def limit_upload_body(request: Request, call_next):
|
||
"""FastAPI 在进入路由前就会把 multipart 落到临时文件,而且那发生在鉴权之前,所以体积必须在中间件里先挡掉。
|
||
|
||
没有 Content-Length(chunked)时无法预判体积,Starlette 又不限制文件 part 大小,
|
||
等于放任未登录请求往磁盘里写任意数据,因此直接拒绝。浏览器提交 FormData 一定带 Content-Length。
|
||
"""
|
||
if request.url.path in UPLOAD_PATHS and request.method == "POST":
|
||
declared = request.headers.get("content-length", "")
|
||
if not declared.isdigit():
|
||
return JSONResponse({"ok": False, "message": "上传请求缺少 Content-Length"}, status_code=411)
|
||
if int(declared) > MAX_UPLOAD_BODY:
|
||
return JSONResponse({"ok": False, "message": "上传内容过大,请压缩后重试"}, status_code=413)
|
||
return await call_next(request)
|
||
|
||
|
||
def client_ip(request: Request) -> str | None:
|
||
return request.client.host if request.client else None
|
||
|
||
|
||
def csrf_for(request: Request) -> str:
|
||
token = request.cookies.get(SESSION_COOKIE, "")
|
||
return hmac.new(settings.secret.encode(), token.encode(), hashlib.sha256).hexdigest()
|
||
|
||
|
||
def entry_csrf_for(request: Request) -> str:
|
||
token = request.cookies.get(ENTRY_COOKIE, "")
|
||
return hmac.new(settings.secret.encode(), f"entry:{token}".encode(), hashlib.sha256).hexdigest()
|
||
|
||
|
||
def check_csrf(request: Request) -> None:
|
||
supplied = request.headers.get("x-csrf-token") or request.query_params.get("csrf_token")
|
||
if not supplied or not hmac.compare_digest(supplied, csrf_for(request)):
|
||
raise HTTPException(403, "安全令牌无效,请刷新页面后重试")
|
||
|
||
|
||
def check_entry_csrf(request: Request) -> None:
|
||
supplied = request.headers.get("x-csrf-token")
|
||
if not supplied or not hmac.compare_digest(supplied, entry_csrf_for(request)):
|
||
raise HTTPException(403, "录入安全令牌无效,请刷新页面后重试")
|
||
|
||
|
||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||
user = get_session_user(db, request.cookies.get(SESSION_COOKIE))
|
||
if not user:
|
||
raise HTTPException(401, "请先登录")
|
||
return user
|
||
|
||
|
||
def current_entry_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||
if not user:
|
||
raise HTTPException(401, "录入授权已失效,请重新输入四位 Key")
|
||
return user
|
||
|
||
|
||
def require_roles(*roles: str):
|
||
def dependency(user: User = Depends(current_user)) -> User:
|
||
if user.role not in roles:
|
||
raise HTTPException(403, "当前账号无权执行此操作")
|
||
return user
|
||
return dependency
|
||
|
||
|
||
def audit(db: Session, request: Request, action: str, resource_type: str, resource_id: str | None = None, detail: dict | str | None = None, user_id: int | None = None) -> None:
|
||
rendered = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else detail
|
||
db.add(AuditLog(user_id=user_id, action=action, resource_type=resource_type, resource_id=resource_id, detail=rendered, ip_address=client_ip(request)))
|
||
|
||
|
||
def user_json(user: User) -> dict:
|
||
return {"id": user.id, "username": user.username, "display_name": user.display_name, "role": user.role, "is_active": user.is_active, "must_change_password": user.must_change_password, "entry_key_configured": bool(user.entry_key_hash), "created_at": user.created_at.isoformat()}
|
||
|
||
|
||
def enforce_entry_key_rate_limit(ip: str) -> None:
|
||
now_value = time.monotonic()
|
||
with _entry_attempts_lock:
|
||
attempts = [stamp for stamp in _entry_attempts.get(ip, []) if now_value - stamp < 900]
|
||
_entry_attempts[ip] = attempts
|
||
if len(attempts) >= 5:
|
||
raise HTTPException(429, "尝试次数过多,请 15 分钟后再试")
|
||
|
||
|
||
def record_failed_entry_key(ip: str) -> None:
|
||
with _entry_attempts_lock:
|
||
_entry_attempts.setdefault(ip, []).append(time.monotonic())
|
||
|
||
|
||
def clear_entry_key_attempts(ip: str) -> None:
|
||
with _entry_attempts_lock:
|
||
_entry_attempts.pop(ip, None)
|
||
|
||
|
||
def enforce_upload_rate_limit(user_id: int) -> None:
|
||
now_value = time.monotonic()
|
||
with _upload_attempts_lock:
|
||
attempts = [stamp for stamp in _upload_attempts.get(user_id, []) if now_value - stamp < UPLOAD_WINDOW_SECONDS]
|
||
if len(attempts) >= UPLOAD_WINDOW_LIMIT:
|
||
_upload_attempts[user_id] = attempts
|
||
raise HTTPException(429, "上传过于频繁,请稍后再试")
|
||
attempts.append(now_value)
|
||
_upload_attempts[user_id] = attempts
|
||
|
||
|
||
def purge_pending_images(db: Session) -> None:
|
||
"""清理没有挂到记录上的临时图片,避免中途放弃的录入长期占用数据库。
|
||
|
||
单独提交:后续校验若抛错会回滚整个事务,清理结果不能跟着一起丢,否则用户会被自己的旧图卡住。
|
||
"""
|
||
cutoff = datetime.now().astimezone() - timedelta(hours=PENDING_IMAGE_HOURS)
|
||
db.execute(delete(RecordImage).where(RecordImage.record_id.is_(None), RecordImage.created_at < cutoff))
|
||
db.commit()
|
||
|
||
|
||
def image_json(image: RecordImage, public: bool = False) -> dict:
|
||
# 带上内容指纹:SQLite 删行后会复用 rowid,没有它,浏览器缓存的 immutable 图片可能张冠李戴。
|
||
version = image.sha256[:12]
|
||
full = f"/api/images/{image.id}?v={version}"
|
||
data = {
|
||
"id": image.id, "width": image.width, "height": image.height,
|
||
# 用 thumbnail_type 判断有无缩略图,绝不能读 image.thumbnail:那是 deferred 列,一读就把二进制拉进内存。
|
||
"url": full, "thumb_url": f"/api/images/{image.id}?variant=thumb&v={version}" if image.thumbnail_type else full,
|
||
}
|
||
if not public: # 原始文件名可能带姓名、车间、项目代号,公开看板不返回
|
||
data |= {"size_bytes": image.size_bytes, "content_type": image.content_type, "original_name": image.original_name}
|
||
return data
|
||
|
||
|
||
def read_upload(upload: UploadFile, limit: int, label: str) -> bytes:
|
||
"""有界读取:多读 1 字节即可判定超限,避免把超大文件整个吃进内存。"""
|
||
data = upload.file.read(limit + 1)
|
||
if len(data) > limit:
|
||
raise HTTPException(413, f"{label}不能超过 {limit // (1024 * 1024) or 1} MB")
|
||
return data
|
||
|
||
|
||
async def handle_image_upload(request: Request, db: Session, user: User) -> dict:
|
||
"""鉴权通过后才解析 multipart。
|
||
|
||
若把 UploadFile 写进端点签名,FastAPI 会在依赖(也就是鉴权和 CSRF 校验)之前就把整个请求体缓冲到临时文件,
|
||
等于让未登录请求也能驱动服务器落盘。手动解析可以把顺序倒过来。
|
||
"""
|
||
async with request.form(max_files=2, max_fields=2) as form:
|
||
upload, thumbnail = form.get("file"), form.get("thumbnail")
|
||
if not isinstance(upload, FormUpload):
|
||
raise HTTPException(400, "缺少图片文件")
|
||
if not isinstance(thumbnail, FormUpload):
|
||
thumbnail = None
|
||
# BLOB 落库是同步阻塞的,放回线程池,别把事件循环连同 /health 一起冻住
|
||
image = await run_in_threadpool(store_upload, db, request, user, upload, thumbnail)
|
||
return {"ok": True, "message": "图片已上传", "data": {**image_json(image), "token": image.token}}
|
||
|
||
|
||
def store_upload(db: Session, request: Request, user: User, upload: UploadFile, thumbnail: UploadFile | None) -> RecordImage:
|
||
purge_pending_images(db)
|
||
enforce_upload_rate_limit(user.id)
|
||
pending = db.scalar(select(func.count()).select_from(RecordImage).where(RecordImage.record_id.is_(None), RecordImage.created_by == user.id)) or 0
|
||
if pending >= MAX_PENDING_IMAGES:
|
||
raise HTTPException(429, f"未提交的图片已达 {MAX_PENDING_IMAGES} 张,请先保存记录,或等待 {PENDING_IMAGE_HOURS} 小时后自动清理")
|
||
data = read_upload(upload, MAX_IMAGE_BYTES, "图片")
|
||
content_type, width, height = inspect_image(data, MAX_IMAGE_BYTES, "图片")
|
||
data = trim_trailing_payload(data, content_type)
|
||
thumb_data = thumb_type = None
|
||
if thumbnail is not None and thumbnail.filename:
|
||
# 缩略图纯属加速,坏了就丢掉回落到原图,不能因为它让整次上传失败。
|
||
try:
|
||
raw = read_upload(thumbnail, MAX_THUMBNAIL_BYTES, "缩略图")
|
||
if raw:
|
||
thumb_type = inspect_image(raw, MAX_THUMBNAIL_BYTES, "缩略图")[0]
|
||
thumb_data = trim_trailing_payload(raw, thumb_type)
|
||
except HTTPException:
|
||
thumb_data = thumb_type = None
|
||
image = RecordImage(
|
||
token=secrets.token_urlsafe(32)[:64], content_type=content_type, original_name=safe_original_name(upload.filename, content_type), size_bytes=len(data),
|
||
width=width, height=height, sha256=hashlib.sha256(data).hexdigest(), data=data, thumbnail=thumb_data,
|
||
thumbnail_type=thumb_type, created_by=user.id,
|
||
)
|
||
db.add(image)
|
||
db.flush()
|
||
audit(db, request, "record_image_uploaded", "record_image", str(image.id), {"size_bytes": image.size_bytes, "content_type": content_type}, user.id)
|
||
db.commit()
|
||
db.refresh(image)
|
||
return image
|
||
|
||
|
||
def discard_pending_upload(db: Session, user: User, token: str) -> None:
|
||
"""撤回一张尚未挂到记录上的图片。用户在页面上删掉重拍时调用,及时释放待提交名额。"""
|
||
image = db.scalar(select(RecordImage).where(RecordImage.token == token, RecordImage.record_id.is_(None), RecordImage.created_by == user.id))
|
||
if not image:
|
||
raise HTTPException(404, "图片不存在或已提交")
|
||
db.delete(image)
|
||
db.commit()
|
||
|
||
|
||
def apply_record_images(db: Session, record: MaterialRecord, user: User, tokens: list[str], keep_ids: list[int], editing: bool) -> int:
|
||
"""挂载新上传的图片并按前端给出的顺序重排;编辑时未列入 keep_ids 的旧图会被删除。"""
|
||
existing = {image.id: image for image in record.images} if record.id else {}
|
||
missing = [image_id for image_id in keep_ids if image_id not in existing]
|
||
if missing:
|
||
raise HTTPException(404, "部分图片不存在或不属于该记录")
|
||
if editing:
|
||
for image_id, image in existing.items():
|
||
if image_id not in keep_ids:
|
||
db.delete(image)
|
||
ordered = [existing[image_id] for image_id in keep_ids]
|
||
for token in tokens:
|
||
image = db.scalar(select(RecordImage).where(RecordImage.token == token, RecordImage.record_id.is_(None), RecordImage.created_by == user.id))
|
||
if not image:
|
||
raise HTTPException(404, "图片上传已失效,请重新上传后再提交")
|
||
ordered.append(image)
|
||
if len(ordered) > MAX_IMAGES_PER_RECORD:
|
||
raise HTTPException(400, f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片")
|
||
for position, image in enumerate(ordered):
|
||
image.record_id = record.id
|
||
image.sort_order = position
|
||
return len(ordered)
|
||
|
||
|
||
def save_material_record(db: Session, values: dict, user: User) -> MaterialRecord:
|
||
experiment_id = values["experiment_id"]
|
||
if db.scalar(select(MaterialRecord).where(MaterialRecord.experiment_id == experiment_id)):
|
||
raise HTTPException(409, "实验 ID 已存在,请使用唯一编号")
|
||
batch = EntryBatch(entry_no=datetime.now().strftime("ENT%Y%m%d") + secrets.token_hex(4).upper(), record_count=1, created_by=user.id)
|
||
db.add(batch)
|
||
db.flush()
|
||
record = MaterialRecord(entry_batch_id=batch.id, **values)
|
||
db.add(record)
|
||
db.flush()
|
||
return record
|
||
|
||
|
||
def apply_definition_input(definition: CustomFieldDefinition, payload: CustomFieldDefinitionInput) -> None:
|
||
if payload.input_type == "select" and not payload.options:
|
||
raise HTTPException(400, "下拉选项字段至少需要一个选项")
|
||
definition.label = payload.label
|
||
definition.input_type = payload.input_type
|
||
definition.is_required = payload.is_required
|
||
definition.is_active = payload.is_active
|
||
definition.is_public = payload.is_public
|
||
definition.unit = payload.unit
|
||
definition.options_json = json.dumps(payload.options if payload.input_type == "select" else [], ensure_ascii=False)
|
||
definition.sort_order = payload.sort_order
|
||
|
||
|
||
@app.exception_handler(HTTPException)
|
||
async def http_error(request: Request, exc: HTTPException):
|
||
if request.url.path.startswith("/api/"):
|
||
return JSONResponse({"ok": False, "message": exc.detail}, status_code=exc.status_code)
|
||
return templates.TemplateResponse(request, "error.html", {"message": exc.detail}, status_code=exc.status_code)
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_error(request: Request, exc: RequestValidationError):
|
||
errors = []
|
||
for item in exc.errors():
|
||
field = ".".join(str(x) for x in item.get("loc", [])[1:]) or "请求"
|
||
errors.append(f"{field}:{item.get('msg', '格式错误')}")
|
||
return JSONResponse({"ok": False, "message": ";".join(errors)}, status_code=422)
|
||
|
||
|
||
@app.get("/health", tags=["system"])
|
||
def health() -> dict:
|
||
return {"status": "ok", "time": datetime.now().astimezone().isoformat()}
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||
if get_session_user(db, request.cookies.get(SESSION_COOKIE)):
|
||
return RedirectResponse("/admin", 303)
|
||
return templates.TemplateResponse(request, "login.html", {"app_name": settings.app_name})
|
||
|
||
|
||
@app.post("/api/auth/login", tags=["auth"])
|
||
def login(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)):
|
||
user = db.scalar(select(User).where(User.username == username.strip()))
|
||
if not user or not user.is_active or not verify_password(password, user.password_hash):
|
||
audit(db, request, "login_failed", "auth", detail={"username": username})
|
||
db.commit()
|
||
raise HTTPException(401, "用户名或密码错误")
|
||
token = create_session(db, user, client_ip(request), request.headers.get("user-agent"))
|
||
audit(db, request, "login", "auth", user_id=user.id)
|
||
db.commit()
|
||
response = JSONResponse({"ok": True, "message": "登录成功", "must_change_password": user.must_change_password})
|
||
response.set_cookie(SESSION_COOKIE, token, max_age=settings.session_hours * 3600, httponly=True, samesite="lax", secure=request.url.scheme == "https")
|
||
return response
|
||
|
||
|
||
@app.post("/api/auth/logout", tags=["auth"])
|
||
def logout(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
check_csrf(request)
|
||
audit(db, request, "logout", "auth", user_id=user.id)
|
||
revoke_session(db, request.cookies.get(SESSION_COOKIE))
|
||
response = JSONResponse({"ok": True})
|
||
response.delete_cookie(SESSION_COOKIE)
|
||
return response
|
||
|
||
|
||
@app.get("/api/me", tags=["auth"])
|
||
def me(user: User = Depends(current_user)):
|
||
return {"ok": True, "data": user_json(user)}
|
||
|
||
|
||
@app.put("/api/me/password", tags=["auth"])
|
||
async def change_password(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
check_csrf(request)
|
||
payload = await request.json()
|
||
old_password, new_password = payload.get("old_password", ""), payload.get("new_password", "")
|
||
if not verify_password(old_password, user.password_hash):
|
||
raise HTTPException(400, "原密码不正确")
|
||
if error := validate_password(new_password):
|
||
raise HTTPException(400, error)
|
||
user.password_hash = hash_password(new_password)
|
||
user.must_change_password = False
|
||
audit(db, request, "password_changed", "user", str(user.id), user_id=user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "密码已更新"}
|
||
|
||
|
||
@app.put("/api/me/entry-key", tags=["auth"])
|
||
async def set_entry_key(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||
check_csrf(request)
|
||
payload = await request.json()
|
||
password, key = str(payload.get("password", "")), str(payload.get("key", ""))
|
||
if not verify_password(password, user.password_hash):
|
||
raise HTTPException(400, "登录密码不正确")
|
||
if not key.isdigit() or len(key) != 4:
|
||
raise HTTPException(400, "录入 Key 必须是四位数字")
|
||
digest = entry_key_hash(key)
|
||
owner = db.scalar(select(User).where(User.entry_key_hash == digest, User.id != user.id))
|
||
if owner:
|
||
raise HTTPException(409, "该 Key 已被使用,请选择其他四位数字")
|
||
user.entry_key_hash = digest
|
||
user.entry_key_updated_at = datetime.now().astimezone()
|
||
revoke_user_entry_sessions(db, user.id)
|
||
audit(db, request, "entry_key_changed", "user", str(user.id), user_id=user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "四位录入 Key 已更新,原录入授权已失效"}
|
||
|
||
|
||
@app.delete("/api/me/entry-key", tags=["auth"])
|
||
async def remove_entry_key(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
check_csrf(request)
|
||
payload = await request.json()
|
||
if not verify_password(str(payload.get("password", "")), user.password_hash):
|
||
raise HTTPException(400, "登录密码不正确")
|
||
user.entry_key_hash = None
|
||
user.entry_key_updated_at = None
|
||
revoke_user_entry_sessions(db, user.id)
|
||
audit(db, request, "entry_key_removed", "user", str(user.id), user_id=user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "录入 Key 已停用"}
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||
def public_dashboard(request: Request):
|
||
return templates.TemplateResponse(request, "public_dashboard.html", {"app_name": settings.app_name})
|
||
|
||
|
||
@app.get("/entry", response_class=HTMLResponse, include_in_schema=False)
|
||
def entry_page(request: Request, db: Session = Depends(get_db)):
|
||
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||
return templates.TemplateResponse(request, "entry.html", {"app_name": settings.app_name, "entry_authorized": bool(user), "entry_csrf_token": entry_csrf_for(request) if user else ""})
|
||
|
||
|
||
@app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
|
||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||
user = get_session_user(db, request.cookies.get(SESSION_COOKIE))
|
||
if not user:
|
||
return RedirectResponse("/login", 303)
|
||
counts = {
|
||
"records": db.scalar(select(func.count()).select_from(MaterialRecord)) or 0,
|
||
"batches": db.scalar(select(func.count()).select_from(EntryBatch)) or 0,
|
||
"users": db.scalar(select(func.count()).select_from(User).where(User.is_active.is_(True))) or 0,
|
||
}
|
||
return templates.TemplateResponse(request, "dashboard.html", {"app_name": settings.app_name, "user": user, "counts": counts, "csrf_token": csrf_for(request)})
|
||
|
||
|
||
@app.get("/api/public/dashboard", tags=["public"])
|
||
def public_data(q: str = "", category: str = "", page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db)):
|
||
conditions = []
|
||
if q:
|
||
pattern = f"%{q.strip()}%"
|
||
conditions.append(or_(MaterialRecord.experiment_id.ilike(pattern), MaterialRecord.material_name.ilike(pattern)))
|
||
if category:
|
||
conditions.append(MaterialRecord.material_category == category)
|
||
query = select(MaterialRecord)
|
||
count_query = select(func.count()).select_from(MaterialRecord)
|
||
for condition in conditions:
|
||
query = query.where(condition)
|
||
count_query = count_query.where(condition)
|
||
total = db.scalar(count_query) or 0
|
||
rows = db.scalars(query.order_by(MaterialRecord.test_date.desc(), MaterialRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||
categories = db.scalars(select(MaterialRecord.material_category).distinct().order_by(MaterialRecord.material_category)).all()
|
||
recent_count = db.scalar(select(func.count()).select_from(MaterialRecord).where(MaterialRecord.created_at >= datetime.now().astimezone() - timedelta(days=30))) or 0
|
||
average_clarity = db.scalar(select(func.avg(MaterialRecord.pattern_clarity_score)))
|
||
data = [{
|
||
"id": row.id, "experiment_id": row.experiment_id, "test_date": row.test_date.isoformat(),
|
||
"material_category": row.material_category, "material_name": row.material_name,
|
||
"material_thickness": row.material_thickness, "actual_output_power": row.actual_output_power,
|
||
"scanning_speed": row.scanning_speed, "is_cut_through": row.is_cut_through,
|
||
"pattern_clarity_score": row.pattern_clarity_score, "presentation_balance_score": row.presentation_balance_score,
|
||
} for row in rows]
|
||
return {"ok": True, "data": data, "stats": {"total": total, "category_count": len(categories), "recent_count": recent_count, "average_clarity": round(float(average_clarity), 2) if average_clarity is not None else None}, "categories": categories, "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||
|
||
|
||
@app.get("/api/public/records/{record_id}", tags=["public"])
|
||
def public_record_detail(record_id: int, db: Session = Depends(get_db)):
|
||
row = db.get(MaterialRecord, record_id)
|
||
if not row:
|
||
raise HTTPException(404, "数据记录不存在")
|
||
fields = (
|
||
"experiment_id", "test_date", "material_category", "material_name", "apparent_density", "uv_absorption_355nm",
|
||
"material_thickness", "moisture_content", "thermal_conductivity", "initial_decomposition_temp",
|
||
"melting_vaporization_temp", "specific_heat_capacity", "carbon_residue_rate", "surface_roughness", "hardness",
|
||
"actual_output_power", "display_current", "scanning_speed", "pulse_frequency", "pulse_width", "defocus_amount",
|
||
"scan_line_spacing", "filling_method", "processing_size", "is_cut_through", "carbonized_edge_width", "etching_depth",
|
||
"is_fire_smolder", "pattern_clarity_score", "presentation_balance_score",
|
||
)
|
||
data = {field: (getattr(row, field).isoformat() if hasattr(getattr(row, field), "isoformat") else getattr(row, field)) for field in fields}
|
||
data["custom_fields"] = [render_custom_value(value) for value in row.custom_values if value.definition.is_public]
|
||
data["images"] = [image_json(image, public=True) for image in row.images]
|
||
return {"ok": True, "data": data}
|
||
|
||
|
||
@app.get("/api/form-fields", tags=["fields"])
|
||
def form_fields(db: Session = Depends(get_db)):
|
||
return {"ok": True, "data": [definition_json(item) for item in active_definitions(db)]}
|
||
|
||
|
||
@app.post("/api/entry/auth", tags=["entry"])
|
||
async def authorize_entry(request: Request, db: Session = Depends(get_db)):
|
||
payload = await request.json()
|
||
key = str(payload.get("key", ""))
|
||
if not key.isdigit() or len(key) != 4:
|
||
raise HTTPException(400, "Key 必须是四位数字")
|
||
ip = client_ip(request) or "unknown"
|
||
enforce_entry_key_rate_limit(ip)
|
||
user = db.scalar(select(User).where(User.entry_key_hash == entry_key_hash(key), User.is_active.is_(True), User.role.in_(("admin", "uploader"))))
|
||
if not user:
|
||
record_failed_entry_key(ip)
|
||
audit(db, request, "entry_key_failed", "entry_access", detail={"remaining_attempts": max(0, 4 - len(_entry_attempts.get(ip, [])))})
|
||
db.commit()
|
||
raise HTTPException(401, "Key 不正确")
|
||
clear_entry_key_attempts(ip)
|
||
token = create_entry_session(db, user, ip)
|
||
audit(db, request, "entry_key_verified", "entry_access", user_id=user.id)
|
||
db.commit()
|
||
response = JSONResponse({"ok": True, "message": "验证成功"})
|
||
response.set_cookie(ENTRY_COOKIE, token, max_age=1800, httponly=True, samesite="strict", secure=request.url.scheme == "https")
|
||
return response
|
||
|
||
|
||
@app.get("/api/entry/session", tags=["entry"])
|
||
def entry_session(request: Request, db: Session = Depends(get_db)):
|
||
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||
return {"ok": True, "authorized": bool(user), "csrf_token": entry_csrf_for(request) if user else None}
|
||
|
||
|
||
@app.post("/api/entry/uploads/images", tags=["entry"])
|
||
async def upload_entry_image(request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||
"""multipart 表单:file(必填)、thumbnail(可选)。"""
|
||
check_entry_csrf(request)
|
||
return await handle_image_upload(request, db, user)
|
||
|
||
|
||
@app.post("/api/entry/records", tags=["entry"])
|
||
def create_entry_record(payload: EntryRecordSubmission, request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||
check_entry_csrf(request)
|
||
if not hmac.compare_digest(payload.confirm_username.strip(), user.username):
|
||
raise HTTPException(403, "用户名与 Key 所属账号不一致")
|
||
if payload.image_ids:
|
||
raise HTTPException(400, "新建记录不能引用已有图片")
|
||
custom_values = validate_custom_values(db, payload.custom_fields)
|
||
values = payload.model_dump(exclude={"confirm_username", *RECORD_ONLY_FIELDS})
|
||
record = save_material_record(db, values, user)
|
||
apply_custom_values(db, record, custom_values)
|
||
image_count = apply_record_images(db, record, user, payload.image_tokens, [], editing=False)
|
||
audit(db, request, "entry_record_created", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "实验数据已保存", "data": {"id": record.id, "experiment_id": record.experiment_id}}
|
||
|
||
|
||
@app.post("/api/entry/logout", tags=["entry"])
|
||
def entry_logout(request: Request, db: Session = Depends(get_db)):
|
||
check_entry_csrf(request)
|
||
revoke_entry_session(db, request.cookies.get(ENTRY_COOKIE))
|
||
response = JSONResponse({"ok": True})
|
||
response.delete_cookie(ENTRY_COOKIE)
|
||
return response
|
||
|
||
|
||
@app.get("/api/records", tags=["records"])
|
||
def records(q: str = "", category: str = "", page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
query = select(MaterialRecord)
|
||
count_query = select(func.count()).select_from(MaterialRecord)
|
||
conditions = []
|
||
if q:
|
||
pattern = f"%{q.strip()}%"
|
||
conditions.append(or_(MaterialRecord.experiment_id.ilike(pattern), MaterialRecord.material_name.ilike(pattern)))
|
||
if category:
|
||
conditions.append(MaterialRecord.material_category == category)
|
||
for condition in conditions:
|
||
query = query.where(condition)
|
||
count_query = count_query.where(condition)
|
||
total = db.scalar(count_query) or 0
|
||
rows = db.scalars(query.order_by(MaterialRecord.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||
data = [{
|
||
"id": x.id, "experiment_id": x.experiment_id, "test_date": x.test_date.isoformat(), "material_category": x.material_category,
|
||
"material_name": x.material_name, "material_thickness": x.material_thickness, "actual_output_power": x.actual_output_power,
|
||
"scanning_speed": x.scanning_speed, "is_cut_through": x.is_cut_through, "pattern_clarity_score": x.pattern_clarity_score,
|
||
} for x in rows]
|
||
return {"ok": True, "data": data, "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||
|
||
|
||
@app.post("/api/uploads/images", tags=["records"])
|
||
async def upload_record_image(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||
"""multipart 表单:file(必填)、thumbnail(可选)。"""
|
||
check_csrf(request)
|
||
return await handle_image_upload(request, db, user)
|
||
|
||
|
||
@app.delete("/api/uploads/images/{token}", tags=["records"])
|
||
def discard_record_image(token: str, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||
check_csrf(request)
|
||
discard_pending_upload(db, user, token)
|
||
return {"ok": True, "message": "图片已撤回"}
|
||
|
||
|
||
@app.delete("/api/entry/uploads/images/{token}", tags=["entry"])
|
||
def discard_entry_image(token: str, request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||
check_entry_csrf(request)
|
||
discard_pending_upload(db, user, token)
|
||
return {"ok": True, "message": "图片已撤回"}
|
||
|
||
|
||
@app.get("/api/images/{image_id}", tags=["records"])
|
||
def record_image(image_id: int, request: Request, variant: str = Query("full", pattern="^(full|thumb)$"), v: str = Query("", max_length=64), db: Session = Depends(get_db)):
|
||
# 先只取元信息:命中 304 或只要缩略图时,没必要把整张原图读进内存。
|
||
meta = db.execute(select(
|
||
RecordImage.record_id, RecordImage.created_by, RecordImage.sha256, RecordImage.content_type,
|
||
RecordImage.thumbnail_type, func.length(RecordImage.thumbnail),
|
||
).where(RecordImage.id == image_id)).first()
|
||
if not meta:
|
||
raise HTTPException(404, "图片不存在")
|
||
record_id, created_by, sha256, content_type, thumbnail_type, thumbnail_size = meta
|
||
if record_id is None:
|
||
viewer = get_session_user(db, request.cookies.get(SESSION_COOKIE)) or get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||
if not viewer or viewer.id != created_by:
|
||
raise HTTPException(404, "图片不存在") # 不用 403,避免泄露"这个编号确实存在"
|
||
use_thumb = variant == "thumb" and bool(thumbnail_size)
|
||
media_type = (thumbnail_type or content_type) if use_thumb else content_type
|
||
headers = {
|
||
"ETag": f'"{sha256[:32]}-{"thumb" if use_thumb else "full"}"',
|
||
"Cache-Control": "public, max-age=31536000, immutable" if record_id else "private, no-store",
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||
"Content-Disposition": f'inline; filename="image-{image_id}{EXTENSIONS.get(media_type, ".bin")}"',
|
||
}
|
||
if request.headers.get("if-none-match") == headers["ETag"]:
|
||
return Response(status_code=304, headers=headers)
|
||
payload = db.scalar(select(RecordImage.thumbnail if use_thumb else RecordImage.data).where(RecordImage.id == image_id))
|
||
if payload is None:
|
||
raise HTTPException(404, "图片不存在")
|
||
return Response(payload, media_type=media_type, headers=headers)
|
||
|
||
|
||
@app.post("/api/records", tags=["records"])
|
||
def create_record(payload: MaterialRecordInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||
check_csrf(request)
|
||
if payload.image_ids:
|
||
raise HTTPException(400, "新建记录不能引用已有图片")
|
||
custom_values = validate_custom_values(db, payload.custom_fields)
|
||
record = save_material_record(db, payload.model_dump(exclude=RECORD_ONLY_FIELDS), user)
|
||
apply_custom_values(db, record, custom_values)
|
||
image_count = apply_record_images(db, record, user, payload.image_tokens, [], editing=False)
|
||
audit(db, request, "record_created", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||
db.commit()
|
||
db.refresh(record)
|
||
return {"ok": True, "message": "实验数据已保存", "data": {"id": record.id, "experiment_id": record.experiment_id}}
|
||
|
||
|
||
@app.get("/api/records/{record_id}", tags=["records"])
|
||
def record_detail(record_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
row = db.get(MaterialRecord, record_id)
|
||
if not row:
|
||
raise HTTPException(404, "数据记录不存在")
|
||
excluded = {"_sa_instance_state", "batch", "entry_batch", "custom_values", "images"}
|
||
data = {key: (value.isoformat() if isinstance(value, (datetime,)) or hasattr(value, "isoformat") else value) for key, value in vars(row).items() if key not in excluded}
|
||
data["entry_no"] = row.entry_batch.entry_no
|
||
data["custom_fields"] = {value.definition.field_key: render_custom_value(value) for value in row.custom_values}
|
||
data["images"] = [image_json(image) for image in row.images]
|
||
return {"ok": True, "data": data}
|
||
|
||
|
||
@app.put("/api/records/{record_id}", tags=["records"])
|
||
def update_record(record_id: int, payload: MaterialRecordInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||
check_csrf(request)
|
||
record = db.get(MaterialRecord, record_id)
|
||
if not record:
|
||
raise HTTPException(404, "数据记录不存在")
|
||
duplicate = db.scalar(select(MaterialRecord).where(MaterialRecord.experiment_id == payload.experiment_id, MaterialRecord.id != record_id))
|
||
if duplicate:
|
||
raise HTTPException(409, "实验 ID 已被其他记录使用")
|
||
custom_values = validate_custom_values(db, payload.custom_fields, existing_record=record)
|
||
for field, value in payload.model_dump(exclude=RECORD_ONLY_FIELDS).items():
|
||
setattr(record, field, value)
|
||
apply_custom_values(db, record, custom_values)
|
||
keep_ids = payload.image_ids if payload.image_ids is not None else [image.id for image in record.images]
|
||
image_count = apply_record_images(db, record, user, payload.image_tokens, keep_ids, editing=True)
|
||
audit(db, request, "record_updated", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "实验数据已更新", "data": {"id": record.id}}
|
||
|
||
|
||
@app.delete("/api/records/{record_id}", tags=["records"])
|
||
def delete_record(record_id: int, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
record = db.get(MaterialRecord, record_id)
|
||
if not record:
|
||
raise HTTPException(404, "数据记录不存在")
|
||
batch = record.entry_batch
|
||
audit(db, request, "record_deleted", "material_record", str(record.id), {"experiment_id": record.experiment_id}, user.id)
|
||
db.delete(record)
|
||
db.flush()
|
||
db.delete(batch)
|
||
db.commit()
|
||
return {"ok": True, "message": "实验数据已删除"}
|
||
|
||
|
||
@app.get("/api/field-definitions", tags=["fields"])
|
||
def field_definitions(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
counts = definition_usage_counts(db)
|
||
rows = db.scalars(select(CustomFieldDefinition).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all()
|
||
return {"ok": True, "data": [definition_json(item, counts.get(item.id, 0)) for item in rows]}
|
||
|
||
|
||
@app.post("/api/field-definitions", tags=["fields"])
|
||
def create_field_definition(payload: CustomFieldDefinitionInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
definition = CustomFieldDefinition(field_key=f"custom_{secrets.token_hex(6)}", label=payload.label, input_type=payload.input_type, created_by=user.id)
|
||
apply_definition_input(definition, payload)
|
||
db.add(definition)
|
||
db.flush()
|
||
audit(db, request, "field_definition_created", "custom_field_definition", str(definition.id), {"field_key": definition.field_key, "label": definition.label, "input_type": definition.input_type}, user.id)
|
||
db.commit()
|
||
db.refresh(definition)
|
||
return {"ok": True, "message": "数据字段已添加", "data": definition_json(definition, 0)}
|
||
|
||
|
||
@app.put("/api/field-definitions/{field_id}", tags=["fields"])
|
||
def update_field_definition(field_id: int, payload: CustomFieldDefinitionInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
definition = db.get(CustomFieldDefinition, field_id)
|
||
if not definition:
|
||
raise HTTPException(404, "数据字段不存在")
|
||
before = {"label": definition.label, "input_type": definition.input_type, "is_required": definition.is_required, "is_active": definition.is_active}
|
||
apply_definition_input(definition, payload)
|
||
usage_count = db.scalar(select(func.count()).select_from(CustomFieldValue).where(CustomFieldValue.field_id == definition.id)) or 0
|
||
audit(db, request, "field_definition_updated", "custom_field_definition", str(definition.id), {"before": before, "after": {"label": definition.label, "input_type": definition.input_type, "is_required": definition.is_required, "is_active": definition.is_active}, "preserved_values": usage_count}, user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": f"字段设置已更新,保留了 {usage_count} 条历史值", "data": definition_json(definition, usage_count)}
|
||
|
||
|
||
@app.get("/api/users", tags=["users"])
|
||
def users(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
return {"ok": True, "data": [user_json(x) for x in db.scalars(select(User).order_by(User.created_at.desc())).all()]}
|
||
|
||
|
||
@app.post("/api/users", tags=["users"])
|
||
async def create_user(request: Request, db: Session = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
payload = await request.json()
|
||
username, password = str(payload.get("username", "")).strip(), str(payload.get("password", ""))
|
||
role = payload.get("role", "uploader")
|
||
if not username or len(username) > 64 or not username.replace("_", "").replace("-", "").isalnum():
|
||
raise HTTPException(400, "用户名仅可包含字母、数字、下划线和短横线")
|
||
if role not in {"admin", "uploader", "viewer"}:
|
||
raise HTTPException(400, "无效角色")
|
||
if error := validate_password(password):
|
||
raise HTTPException(400, error)
|
||
if db.scalar(select(User).where(User.username == username)):
|
||
raise HTTPException(409, "用户名已存在")
|
||
new_user = User(username=username, display_name=str(payload.get("display_name", username)).strip()[:100], password_hash=hash_password(password), role=role, must_change_password=True)
|
||
db.add(new_user)
|
||
db.flush()
|
||
audit(db, request, "user_created", "user", str(new_user.id), {"username": username, "role": role}, admin.id)
|
||
db.commit()
|
||
db.refresh(new_user)
|
||
return {"ok": True, "message": "用户已创建", "data": user_json(new_user)}
|
||
|
||
|
||
@app.patch("/api/users/{user_id}", tags=["users"])
|
||
async def update_user(user_id: int, request: Request, db: Session = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
target = db.get(User, user_id)
|
||
if not target:
|
||
raise HTTPException(404, "用户不存在")
|
||
payload = await request.json()
|
||
if "role" in payload:
|
||
if payload["role"] not in {"admin", "uploader", "viewer"}:
|
||
raise HTTPException(400, "无效角色")
|
||
if target.role == "admin" and payload["role"] != "admin":
|
||
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True))) or 0
|
||
if admin_count <= 1:
|
||
raise HTTPException(400, "系统至少需要保留一名启用的管理员")
|
||
target.role = payload["role"]
|
||
if "is_active" in payload:
|
||
if target.id == admin.id and not payload["is_active"]:
|
||
raise HTTPException(400, "不能停用当前登录账号")
|
||
if target.role == "admin" and target.is_active and not payload["is_active"]:
|
||
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True))) or 0
|
||
if admin_count <= 1:
|
||
raise HTTPException(400, "系统至少需要保留一名启用的管理员")
|
||
target.is_active = bool(payload["is_active"])
|
||
if payload.get("new_password"):
|
||
if error := validate_password(payload["new_password"]):
|
||
raise HTTPException(400, error)
|
||
target.password_hash = hash_password(payload["new_password"])
|
||
target.must_change_password = True
|
||
audit(db, request, "user_updated", "user", str(target.id), {"role": target.role, "is_active": target.is_active}, admin.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "用户已更新", "data": user_json(target)}
|
||
|
||
|
||
@app.get("/api/audits", tags=["audit"])
|
||
def audits(page: int = Query(1, ge=1), page_size: int = Query(30, ge=1, le=100), db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
||
rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||
return {"ok": True, "data": [{"id": x.id, "user": x.user.display_name if x.user else "匿名", "action": x.action, "resource_type": x.resource_type, "resource_id": x.resource_id, "detail": x.detail, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in rows], "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||
|
||
|
||
@app.get("/api/backups", tags=["backups"])
|
||
def backups(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
rows = db.scalars(select(BackupRecord).order_by(BackupRecord.created_at.desc())).all()
|
||
return {"ok": True, "data": [{"id": x.id, "filename": x.filename, "database_type": x.database_type, "size_bytes": x.size_bytes, "sha256": x.sha256, "creator": x.creator.display_name if x.creator else "定时任务", "created_at": x.created_at.isoformat()} for x in rows]}
|
||
|
||
|
||
@app.post("/api/backups", tags=["backups"])
|
||
def backup_now(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
check_csrf(request)
|
||
try:
|
||
item = create_backup(db, user.id)
|
||
except RuntimeError as exc:
|
||
raise HTTPException(500, str(exc))
|
||
audit(db, request, "backup_created", "backup", str(item.id), {"filename": item.filename}, user.id)
|
||
db.commit()
|
||
return {"ok": True, "message": "备份创建成功", "data": {"id": item.id, "filename": item.filename}}
|
||
|
||
|
||
@app.get("/api/backups/{backup_id}/download", tags=["backups"])
|
||
def download_backup(backup_id: int, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||
item = db.get(BackupRecord, backup_id)
|
||
if not item:
|
||
raise HTTPException(404, "备份不存在")
|
||
path = settings.backup_dir / item.filename
|
||
if not path.exists():
|
||
raise HTTPException(404, "备份文件已丢失")
|
||
return FileResponse(path, filename=item.filename, media_type="application/octet-stream")
|