激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .models import BackupRecord
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def postgres_cli_url(url: str) -> str:
|
||||
"""PostgreSQL CLI 不识别 SQLAlchemy 的 +psycopg 驱动标记。"""
|
||||
return url.replace("postgresql+psycopg://", "postgresql://", 1)
|
||||
|
||||
|
||||
def create_backup(db: Session, user_id: int | None = None) -> BackupRecord:
|
||||
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
filename = f"database-{stamp}.sqlite3"
|
||||
target = settings.backup_dir / filename
|
||||
source_path = Path(url.split("///", 1)[1])
|
||||
if not source_path.is_absolute():
|
||||
source_path = Path.cwd() / source_path
|
||||
source = sqlite3.connect(str(source_path))
|
||||
destination = sqlite3.connect(str(target))
|
||||
try:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
destination.close()
|
||||
source.close()
|
||||
database_type = "sqlite"
|
||||
elif url.startswith(("postgresql", "postgres")):
|
||||
filename = f"database-{stamp}.dump"
|
||||
target = settings.backup_dir / filename
|
||||
result = subprocess.run(["pg_dump", "--format=custom", "--file", str(target), postgres_cli_url(url)], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"pg_dump 失败:{result.stderr.strip()}")
|
||||
database_type = "postgresql"
|
||||
else:
|
||||
raise RuntimeError("当前数据库类型暂不支持自动备份")
|
||||
record = BackupRecord(filename=filename, database_type=database_type, size_bytes=target.stat().st_size, sha256=file_sha256(target), created_by=user_id)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
cleanup_old_backups(db)
|
||||
return record
|
||||
|
||||
|
||||
def cleanup_old_backups(db: Session) -> None:
|
||||
cutoff = datetime.now().astimezone() - timedelta(days=settings.backup_keep_days)
|
||||
for record in db.query(BackupRecord).filter(BackupRecord.created_at < cutoff).all():
|
||||
path = settings.backup_dir / record.filename
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
|
||||
|
||||
def restore_backup(path: Path) -> None:
|
||||
if not path.resolve().is_relative_to(settings.backup_dir.resolve()):
|
||||
raise ValueError("备份文件必须位于 backups 目录")
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
source = sqlite3.connect(str(path))
|
||||
raw_path = Path(url.split("///", 1)[1])
|
||||
target_path = raw_path if raw_path.is_absolute() else Path.cwd() / raw_path
|
||||
temporary = target_path.with_suffix(".restore.tmp")
|
||||
destination = sqlite3.connect(str(temporary))
|
||||
try:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
destination.close()
|
||||
source.close()
|
||||
shutil.move(temporary, target_path)
|
||||
elif url.startswith(("postgresql", "postgres")):
|
||||
result = subprocess.run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--dbname", postgres_cli_url(url), str(path)], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"pg_restore 失败:{result.stderr.strip()}")
|
||||
else:
|
||||
raise RuntimeError("当前数据库类型暂不支持恢复")
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = os.getenv("APP_NAME", "激光材料数据平台")
|
||||
secret: str = os.getenv("APP_SECRET", "dev-only-change-this-secret")
|
||||
database_url: str = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'laser_data.db'}")
|
||||
admin_username: str = os.getenv("ADMIN_USERNAME", "admin")
|
||||
admin_password: str = os.getenv("ADMIN_PASSWORD", "Admin@123456")
|
||||
session_hours: int = int(os.getenv("SESSION_HOURS", "12"))
|
||||
backup_keep_days: int = int(os.getenv("BACKUP_KEEP_DAYS", "30"))
|
||||
backup_dir: Path = BASE_DIR / "backups"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
for directory in (BASE_DIR / "data", settings.backup_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True)
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
@event.listens_for(engine, "connect")
|
||||
def enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CustomFieldDefinition, CustomFieldValue, MaterialRecord
|
||||
|
||||
|
||||
def options_for(definition: CustomFieldDefinition) -> list[str]:
|
||||
try:
|
||||
return json.loads(definition.options_json) if definition.options_json else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def definition_json(definition: CustomFieldDefinition, usage_count: int | None = None) -> dict[str, Any]:
|
||||
data = {
|
||||
"id": definition.id,
|
||||
"field_key": definition.field_key,
|
||||
"label": definition.label,
|
||||
"input_type": definition.input_type,
|
||||
"is_required": definition.is_required,
|
||||
"is_active": definition.is_active,
|
||||
"is_public": definition.is_public,
|
||||
"unit": definition.unit,
|
||||
"options": options_for(definition),
|
||||
"sort_order": definition.sort_order,
|
||||
"created_at": definition.created_at.isoformat(),
|
||||
"updated_at": definition.updated_at.isoformat(),
|
||||
}
|
||||
if usage_count is not None:
|
||||
data["usage_count"] = usage_count
|
||||
return data
|
||||
|
||||
|
||||
def active_definitions(db: Session) -> list[CustomFieldDefinition]:
|
||||
return list(db.scalars(select(CustomFieldDefinition).where(CustomFieldDefinition.is_active.is_(True)).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all())
|
||||
|
||||
|
||||
def _empty(value: Any) -> bool:
|
||||
return value is None or (isinstance(value, str) and not value.strip())
|
||||
|
||||
|
||||
def validate_raw_value(definition: CustomFieldDefinition, value: Any) -> str | None:
|
||||
if _empty(value):
|
||||
if definition.is_required:
|
||||
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
try:
|
||||
if definition.input_type == "number":
|
||||
if not Decimal(raw).is_finite():
|
||||
raise ValueError
|
||||
elif definition.input_type == "integer":
|
||||
number = Decimal(raw)
|
||||
if isinstance(value, bool) or not number.is_finite() or number != number.to_integral_value():
|
||||
raise ValueError
|
||||
elif definition.input_type == "date":
|
||||
date.fromisoformat(raw)
|
||||
elif definition.input_type == "boolean":
|
||||
if raw.lower() not in {"true", "false", "1", "0"}:
|
||||
raise ValueError
|
||||
raw = "true" if raw.lower() in {"true", "1"} else "false"
|
||||
elif definition.input_type == "select" and raw not in options_for(definition):
|
||||
raise ValueError
|
||||
except (ValueError, InvalidOperation):
|
||||
raise HTTPException(422, f"{definition.label}:数据格式不符合当前字段类型")
|
||||
if len(raw) > 10000:
|
||||
raise HTTPException(422, f"{definition.label}:内容不能超过 10000 个字符")
|
||||
return raw
|
||||
|
||||
|
||||
def validate_custom_values(db: Session, submitted: dict[str, Any], existing_record: MaterialRecord | None = None) -> dict[CustomFieldDefinition, str | None]:
|
||||
definitions = active_definitions(db)
|
||||
by_key = {item.field_key: item for item in definitions}
|
||||
unknown = set(submitted) - set(by_key)
|
||||
if unknown:
|
||||
raise HTTPException(422, "包含未知或已停用的自定义字段")
|
||||
existing_keys = {value.definition.field_key for value in existing_record.custom_values} if existing_record else set()
|
||||
validated: dict[CustomFieldDefinition, str | None] = {}
|
||||
for definition in definitions:
|
||||
if definition.field_key not in submitted and existing_record is not None:
|
||||
if definition.is_required and definition.field_key not in existing_keys:
|
||||
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
||||
continue
|
||||
validated[definition] = validate_raw_value(definition, submitted.get(definition.field_key))
|
||||
return validated
|
||||
|
||||
|
||||
def apply_custom_values(db: Session, record: MaterialRecord, values: dict[CustomFieldDefinition, str | None]) -> None:
|
||||
existing = {item.field_id: item for item in record.custom_values}
|
||||
for definition, raw_value in values.items():
|
||||
item = existing.get(definition.id)
|
||||
if raw_value is None:
|
||||
if item:
|
||||
db.delete(item)
|
||||
continue
|
||||
if item:
|
||||
item.raw_value = raw_value
|
||||
else:
|
||||
db.add(CustomFieldValue(record_id=record.id, field_id=definition.id, raw_value=raw_value))
|
||||
|
||||
|
||||
def render_custom_value(value: CustomFieldValue) -> dict[str, Any]:
|
||||
definition, raw = value.definition, value.raw_value or ""
|
||||
compatible = True
|
||||
processed: Any = raw
|
||||
try:
|
||||
if definition.input_type == "number":
|
||||
number = Decimal(raw)
|
||||
if not number.is_finite():
|
||||
raise ValueError
|
||||
processed = float(number)
|
||||
elif definition.input_type == "integer":
|
||||
number = Decimal(raw)
|
||||
if number != number.to_integral_value():
|
||||
raise ValueError
|
||||
processed = int(number)
|
||||
elif definition.input_type == "date":
|
||||
processed = date.fromisoformat(raw).isoformat()
|
||||
elif definition.input_type == "boolean":
|
||||
if raw.lower() not in {"true", "false", "1", "0"}:
|
||||
raise ValueError
|
||||
processed = raw.lower() in {"true", "1"}
|
||||
elif definition.input_type == "select" and raw not in options_for(definition):
|
||||
compatible = False
|
||||
except (ValueError, InvalidOperation):
|
||||
compatible = False
|
||||
processed = raw
|
||||
display = "是" if processed is True else "否" if processed is False else str(processed)
|
||||
if definition.unit and display:
|
||||
display = f"{display} {definition.unit}"
|
||||
return {
|
||||
"field_key": definition.field_key,
|
||||
"label": definition.label,
|
||||
"input_type": definition.input_type,
|
||||
"unit": definition.unit,
|
||||
"raw_value": raw,
|
||||
"value": processed,
|
||||
"display_value": display,
|
||||
"compatible": compatible,
|
||||
"is_active": definition.is_active,
|
||||
"is_public": definition.is_public,
|
||||
}
|
||||
|
||||
|
||||
def definition_usage_counts(db: Session) -> dict[int, int]:
|
||||
rows = db.execute(select(CustomFieldValue.field_id, func.count(CustomFieldValue.id)).group_by(CustomFieldValue.field_id)).all()
|
||||
return {field_id: count for field_id, count in rows}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""图片校验与元信息解析。不依赖 Pillow,只读文件头,避免解码带来的攻击面和额外依赖。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
MAX_THUMBNAIL_BYTES = 512 * 1024
|
||||
MAX_IMAGES_PER_RECORD = 6
|
||||
MAX_PIXELS = 60_000_000
|
||||
MAX_EDGE = 20_000
|
||||
# 只收 JPEG 和 PNG:客户端一律用 canvas 转成 JPEG(小尺寸 PNG 原样透传),
|
||||
# WebP 没有任何收益,而它的 RIFF 头几乎不校验内容,很容易被拿来夹带任意二进制。
|
||||
ALLOWED_TYPES = ("image/jpeg", "image/png")
|
||||
EXTENSIONS = {"image/jpeg": ".jpg", "image/png": ".png"}
|
||||
|
||||
|
||||
def _png(data: bytes) -> tuple[int, int] | None:
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR":
|
||||
return None
|
||||
return struct.unpack(">II", data[16:24])
|
||||
|
||||
|
||||
JPEG_SOF_MARKERS = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
||||
|
||||
|
||||
def _jpeg(data: bytes) -> tuple[int, int] | None:
|
||||
if data[:3] != b"\xff\xd8\xff":
|
||||
return None
|
||||
index, size = 2, len(data)
|
||||
while index + 3 < size:
|
||||
if data[index] != 0xFF:
|
||||
return None
|
||||
marker = data[index + 1]
|
||||
if marker == 0xFF:
|
||||
index += 1
|
||||
continue
|
||||
if marker == 0x01 or 0xD0 <= marker <= 0xD9:
|
||||
index += 2
|
||||
continue
|
||||
length = struct.unpack(">H", data[index + 2:index + 4])[0]
|
||||
if length < 2:
|
||||
return None
|
||||
if marker in JPEG_SOF_MARKERS:
|
||||
if index + 9 > size:
|
||||
return None
|
||||
height, width = struct.unpack(">HH", data[index + 5:index + 9])
|
||||
return width, height
|
||||
if marker == 0xDA:
|
||||
return None
|
||||
index += 2 + length
|
||||
return None
|
||||
|
||||
|
||||
PARSERS = (("image/png", _png), ("image/jpeg", _jpeg))
|
||||
|
||||
|
||||
def trim_trailing_payload(data: bytes, media_type: str) -> bytes:
|
||||
"""截掉图像结束标记之后的内容。
|
||||
|
||||
否则可以在一张合法图片后面附上任意字节,把本站变成任意文件的托管点;
|
||||
结束标记之后的数据本来就不属于图像,丢掉不会影响任何正常图片。
|
||||
"""
|
||||
if media_type == "image/jpeg":
|
||||
end = data.rfind(b"\xff\xd9")
|
||||
return data[:end + 2] if end > 0 else data
|
||||
if media_type == "image/png":
|
||||
end = data.rfind(b"IEND")
|
||||
return data[:end + 8] if end > 0 else data # IEND 后面还有 4 字节 CRC
|
||||
return data
|
||||
|
||||
|
||||
def sniff(data: bytes) -> tuple[str, int, int] | None:
|
||||
"""按文件头识别类型并取出尺寸;无法解析即视为非法图片。"""
|
||||
for media_type, parser in PARSERS:
|
||||
try:
|
||||
size = parser(data)
|
||||
except (struct.error, IndexError):
|
||||
size = None
|
||||
if size and size[0] > 0 and size[1] > 0:
|
||||
return media_type, size[0], size[1]
|
||||
return None
|
||||
|
||||
|
||||
UNSAFE_NAME_CHARS = re.compile(r"[^\w.\-一-鿿()() ]")
|
||||
|
||||
|
||||
def safe_original_name(raw: str | None, content_type: str) -> str:
|
||||
"""文件名完全由客户端 multipart 头控制,可能带引号、尖括号等用于注入的字符,入库前统一清洗。"""
|
||||
name = (raw or "").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = UNSAFE_NAME_CHARS.sub("_", name).strip("._ ")[:120]
|
||||
return name or f"image{EXTENSIONS.get(content_type, '.jpg')}"
|
||||
|
||||
|
||||
def inspect_image(data: bytes, limit: int = MAX_IMAGE_BYTES, label: str = "图片") -> tuple[str, int, int]:
|
||||
"""校验图片字节并返回 (content_type, width, height),任何不合规都抛出中文错误。"""
|
||||
if not data:
|
||||
raise HTTPException(400, f"{label}内容为空")
|
||||
if len(data) > limit:
|
||||
raise HTTPException(413, f"{label}不能超过 {limit // (1024 * 1024) or 1} MB")
|
||||
result = sniff(data)
|
||||
if not result:
|
||||
raise HTTPException(415, f"{label}格式不支持,仅接受 JPEG 或 PNG")
|
||||
media_type, width, height = result
|
||||
if width > MAX_EDGE or height > MAX_EDGE or width * height > MAX_PIXELS:
|
||||
raise HTTPException(413, f"{label}分辨率过大,请压缩后再上传")
|
||||
return media_type, width, height
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
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")
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, deferred, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now().astimezone()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(100))
|
||||
password_hash: Mapped[str] = mapped_column(String(300))
|
||||
role: Mapped[str] = mapped_column(String(20), default="uploader", index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
entry_key_hash: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
||||
entry_key_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
|
||||
|
||||
class LoginSession(Base):
|
||||
__tablename__ = "login_sessions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class EntrySession(Base):
|
||||
__tablename__ = "entry_sessions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class EntryBatch(Base):
|
||||
__tablename__ = "entry_batches"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entry_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
record_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
status: Mapped[str] = mapped_column(String(20), default="success", index=True)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
creator: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class MaterialRecord(Base):
|
||||
__tablename__ = "material_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entry_batch_id", "experiment_id", name="uq_entry_experiment"),
|
||||
Index("ix_record_material_date", "material_category", "test_date"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entry_batch_id: Mapped[int] = mapped_column(ForeignKey("entry_batches.id", ondelete="CASCADE"), index=True)
|
||||
experiment_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
test_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
material_category: Mapped[str] = mapped_column(String(100), index=True)
|
||||
material_name: Mapped[str] = mapped_column(String(200), index=True)
|
||||
apparent_density: Mapped[Optional[float]] = mapped_column(Float)
|
||||
uv_absorption_355nm: Mapped[Optional[float]] = mapped_column(Float)
|
||||
material_thickness: Mapped[float] = mapped_column(Float)
|
||||
moisture_content: Mapped[Optional[float]] = mapped_column(Float)
|
||||
thermal_conductivity: Mapped[Optional[float]] = mapped_column(Float)
|
||||
initial_decomposition_temp: Mapped[Optional[float]] = mapped_column(Float)
|
||||
melting_vaporization_temp: Mapped[Optional[float]] = mapped_column(Float)
|
||||
specific_heat_capacity: Mapped[Optional[float]] = mapped_column(Float)
|
||||
carbon_residue_rate: Mapped[Optional[float]] = mapped_column(Float)
|
||||
surface_roughness: Mapped[Optional[float]] = mapped_column(Float)
|
||||
hardness: Mapped[Optional[float]] = mapped_column(Float)
|
||||
actual_output_power: Mapped[float] = mapped_column(Float)
|
||||
display_current: Mapped[float] = mapped_column(Float)
|
||||
scanning_speed: Mapped[float] = mapped_column(Float)
|
||||
pulse_frequency: Mapped[float] = mapped_column(Float)
|
||||
pulse_width: Mapped[float] = mapped_column(Float)
|
||||
defocus_amount: Mapped[float] = mapped_column(Float)
|
||||
scan_line_spacing: Mapped[float] = mapped_column(Float)
|
||||
filling_method: Mapped[str] = mapped_column(String(100))
|
||||
processing_size: Mapped[str] = mapped_column(String(100))
|
||||
is_cut_through: Mapped[bool] = mapped_column(Boolean)
|
||||
carbonized_edge_width: Mapped[float] = mapped_column(Float)
|
||||
etching_depth: Mapped[float] = mapped_column(Float)
|
||||
is_fire_smolder: Mapped[bool] = mapped_column(Boolean)
|
||||
pattern_clarity_score: Mapped[float] = mapped_column(Float)
|
||||
presentation_balance_score: Mapped[float] = mapped_column(Float)
|
||||
finished_image_filename: Mapped[str] = mapped_column(String(255))
|
||||
remarks: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
entry_batch: Mapped[EntryBatch] = relationship()
|
||||
custom_values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="record", cascade="all, delete-orphan")
|
||||
images: Mapped[list["RecordImage"]] = relationship(back_populates="record", cascade="all, delete-orphan", passive_deletes=True, order_by="RecordImage.sort_order, RecordImage.id")
|
||||
|
||||
|
||||
class RecordImage(Base):
|
||||
"""实验成品图。二进制直接入库,现有数据库备份即可覆盖图片,无需额外挂载卷。"""
|
||||
|
||||
__tablename__ = "record_images"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
record_id: Mapped[Optional[int]] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
content_type: Mapped[str] = mapped_column(String(40))
|
||||
original_name: Mapped[str] = mapped_column(String(255))
|
||||
size_bytes: Mapped[int] = mapped_column(Integer)
|
||||
width: Mapped[int] = mapped_column(Integer)
|
||||
height: Mapped[int] = mapped_column(Integer)
|
||||
sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||
# deferred:详情接口只需要元信息,默认加载会把每条记录的几 MB 图片全读进内存,
|
||||
# 而公开详情是匿名可访问的,等于把看板变成内存放大器。
|
||||
data: Mapped[bytes] = deferred(mapped_column(LargeBinary))
|
||||
thumbnail: Mapped[Optional[bytes]] = deferred(mapped_column(LargeBinary, nullable=True))
|
||||
thumbnail_type: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
record: Mapped[Optional[MaterialRecord]] = relationship(back_populates="images")
|
||||
creator: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class CustomFieldDefinition(Base):
|
||||
__tablename__ = "custom_field_definitions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
field_key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(100))
|
||||
input_type: Mapped[str] = mapped_column(String(20), index=True)
|
||||
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
options_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
creator: Mapped[User] = relationship()
|
||||
values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="definition")
|
||||
|
||||
|
||||
class CustomFieldValue(Base):
|
||||
__tablename__ = "custom_field_values"
|
||||
__table_args__ = (UniqueConstraint("record_id", "field_id", name="uq_record_custom_field"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
record_id: Mapped[int] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), index=True)
|
||||
field_id: Mapped[int] = mapped_column(ForeignKey("custom_field_definitions.id", ondelete="RESTRICT"), index=True)
|
||||
raw_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
record: Mapped[MaterialRecord] = relationship(back_populates="custom_values")
|
||||
definition: Mapped[CustomFieldDefinition] = relationship(back_populates="values")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(80), index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
resource_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
user: Mapped[Optional[User]] = relationship()
|
||||
|
||||
|
||||
class BackupRecord(Base):
|
||||
__tablename__ = "backup_records"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
database_type: Mapped[str] = mapped_column(String(30))
|
||||
# BigInteger:图片入库后备份很容易超过 2 GiB,PostgreSQL 的 integer 到那里就溢出了
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
sha256: Mapped[str] = mapped_column(String(64))
|
||||
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
creator: Mapped[Optional[User]] = relationship()
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from .images import MAX_IMAGES_PER_RECORD
|
||||
|
||||
|
||||
RECORD_ONLY_FIELDS = {"custom_fields", "image_tokens", "image_ids"}
|
||||
|
||||
|
||||
class MaterialRecordInput(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
experiment_id: str = Field(min_length=1, max_length=100)
|
||||
test_date: date
|
||||
material_category: str = Field(min_length=1, max_length=100)
|
||||
material_name: str = Field(min_length=1, max_length=200)
|
||||
apparent_density: float | None = Field(default=None, ge=0)
|
||||
uv_absorption_355nm: float | None = Field(default=None, ge=0, le=100)
|
||||
material_thickness: float = Field(gt=0)
|
||||
moisture_content: float | None = Field(default=None, ge=0, le=100)
|
||||
thermal_conductivity: float | None = Field(default=None, ge=0)
|
||||
initial_decomposition_temp: float | None = None
|
||||
melting_vaporization_temp: float | None = None
|
||||
specific_heat_capacity: float | None = Field(default=None, ge=0)
|
||||
carbon_residue_rate: float | None = Field(default=None, ge=0, le=100)
|
||||
surface_roughness: float | None = Field(default=None, ge=0)
|
||||
hardness: float | None = Field(default=None, ge=0)
|
||||
actual_output_power: float = Field(ge=0)
|
||||
display_current: float = Field(ge=0)
|
||||
scanning_speed: float = Field(gt=0)
|
||||
pulse_frequency: float = Field(ge=0)
|
||||
pulse_width: float = Field(ge=0)
|
||||
defocus_amount: float
|
||||
scan_line_spacing: float = Field(gt=0)
|
||||
filling_method: str = Field(min_length=1, max_length=100)
|
||||
processing_size: str = Field(min_length=1, max_length=100)
|
||||
is_cut_through: bool
|
||||
carbonized_edge_width: float = Field(ge=0)
|
||||
etching_depth: float = Field(ge=0)
|
||||
is_fire_smolder: bool
|
||||
pattern_clarity_score: float = Field(ge=0, le=10)
|
||||
presentation_balance_score: float = Field(ge=0, le=10)
|
||||
finished_image_filename: str = Field(min_length=1, max_length=255)
|
||||
remarks: str = Field(min_length=1, max_length=5000)
|
||||
custom_fields: dict[str, str | float | int | bool | None] = Field(default_factory=dict)
|
||||
image_tokens: list[str] = Field(default_factory=list, max_length=MAX_IMAGES_PER_RECORD)
|
||||
# 省略 image_ids 表示"不动既有图片",显式传 [] 才是"全部删除"。默认值若是 [],
|
||||
# 任何不了解图片字段的旧页面或外部脚本,一次 PUT 就会静默删光整条记录的图。
|
||||
image_ids: list[int] | None = Field(default=None, max_length=MAX_IMAGES_PER_RECORD)
|
||||
|
||||
@field_validator("experiment_id", "material_category", "material_name", "filling_method", "processing_size", "finished_image_filename", "remarks")
|
||||
@classmethod
|
||||
def reject_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("不能为空")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("image_tokens")
|
||||
@classmethod
|
||||
def validate_image_tokens(cls, values: list[str]) -> list[str]:
|
||||
cleaned = [value.strip() for value in values if value.strip()]
|
||||
if len(cleaned) != len(set(cleaned)):
|
||||
raise ValueError("上传令牌重复")
|
||||
if any(len(value) > 64 for value in cleaned):
|
||||
raise ValueError("上传令牌格式错误")
|
||||
return cleaned
|
||||
|
||||
@field_validator("image_ids")
|
||||
@classmethod
|
||||
def validate_image_ids(cls, values: list[int] | None) -> list[int] | None:
|
||||
if values is None:
|
||||
return None
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("图片编号重复")
|
||||
if any(value <= 0 for value in values):
|
||||
raise ValueError("图片编号无效")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def limit_total_images(self):
|
||||
if len(self.image_tokens) + len(self.image_ids or []) > MAX_IMAGES_PER_RECORD:
|
||||
raise ValueError(f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片")
|
||||
return self
|
||||
|
||||
|
||||
class EntryRecordSubmission(MaterialRecordInput):
|
||||
confirm_username: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
FieldInputType = Literal["text", "long_text", "number", "integer", "date", "boolean", "select"]
|
||||
|
||||
|
||||
class CustomFieldDefinitionInput(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
input_type: FieldInputType
|
||||
is_required: bool = False
|
||||
is_active: bool = True
|
||||
is_public: bool = True
|
||||
unit: str | None = Field(default=None, max_length=50)
|
||||
options: list[str] = Field(default_factory=list, max_length=50)
|
||||
sort_order: int = Field(default=0, ge=0, le=10000)
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, values: list[str]) -> list[str]:
|
||||
cleaned = [value.strip() for value in values if value.strip()]
|
||||
if len(cleaned) != len(set(cleaned)):
|
||||
raise ValueError("选项不能重复")
|
||||
if any(len(value) > 100 for value in cleaned):
|
||||
raise ValueError("单个选项不能超过 100 个字符")
|
||||
return cleaned
|
||||
|
||||
@field_validator("unit")
|
||||
@classmethod
|
||||
def normalize_unit(cls, value: str | None) -> str | None:
|
||||
return value.strip() or None if value is not None else None
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .models import EntrySession, LoginSession, User
|
||||
|
||||
|
||||
PBKDF2_ITERATIONS = 310_000
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt, expected = encoded.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
|
||||
return hmac.compare_digest(actual.hex(), expected)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_password(password: str) -> str | None:
|
||||
if len(password) < 10:
|
||||
return "密码至少需要 10 个字符"
|
||||
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
|
||||
return "密码必须同时包含字母和数字"
|
||||
return None
|
||||
|
||||
|
||||
def token_hash(token: str) -> str:
|
||||
return hashlib.sha256((settings.secret + token).encode()).hexdigest()
|
||||
|
||||
|
||||
def entry_key_hash(key: str) -> str:
|
||||
return hmac.new(settings.secret.encode(), f"entry-key:{key}".encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def create_session(db: Session, user: User, ip: str | None, user_agent: str | None) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = datetime.now().astimezone() + timedelta(hours=settings.session_hours)
|
||||
db.add(LoginSession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip, user_agent=(user_agent or "")[:300]))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def get_session_user(db: Session, token: str | None) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
current = datetime.now().astimezone()
|
||||
session = db.scalar(select(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
||||
if not session:
|
||||
return None
|
||||
expires = session.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=current.tzinfo)
|
||||
if expires <= current or not session.user.is_active:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
return None
|
||||
return session.user
|
||||
|
||||
|
||||
def revoke_session(db: Session, token: str | None) -> None:
|
||||
if token:
|
||||
db.execute(delete(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_entry_session(db: Session, user: User, ip: str | None) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = datetime.now().astimezone() + timedelta(minutes=30)
|
||||
db.add(EntrySession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def get_entry_session_user(db: Session, token: str | None) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
current = datetime.now().astimezone()
|
||||
session = db.scalar(select(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
||||
if not session:
|
||||
return None
|
||||
expires = session.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=current.tzinfo)
|
||||
if expires <= current or not session.user.is_active or session.user.role not in {"admin", "uploader"} or not session.user.entry_key_hash:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
return None
|
||||
return session.user
|
||||
|
||||
|
||||
def revoke_entry_session(db: Session, token: str | None) -> None:
|
||||
if token:
|
||||
db.execute(delete(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def revoke_user_entry_sessions(db: Session, user_id: int) -> None:
|
||||
db.execute(delete(EntrySession).where(EntrySession.user_id == user_id))
|
||||
Reference in New Issue
Block a user