Files
laser-data-hub/app/main.py
T
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

831 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-Lengthchunked)时无法预判体积,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")