激光材料数据平台 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:
2026-07-26 22:06:02 +08:00
co-authored by Claude Fable 5
commit edfb216561
49 changed files with 3922 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.env
.git
.venv
__pycache__
.pytest_cache
*.pyc
data
backups
tests
*.xlsx
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
APP_NAME=激光材料数据平台
APP_PORT=5980
# PyPI 不可达时改成你们网络可访问的 Python 包镜像
PIP_INDEX_URL=https://pypi.org/simple
APP_SECRET=请替换为至少32位随机字符串
DATABASE_URL=sqlite:///./data/laser_data.db
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123456
POSTGRES_PASSWORD=请替换为数据库高强度密码
SESSION_HOURS=12
BACKUP_KEEP_DAYS=30
+14
View File
@@ -0,0 +1,14 @@
.env
release.env
release/
.venv/
__pycache__/
.pytest_cache/
*.pyc
data/*.db
data/*.db-*
backups/*
!backups/.gitkeep
.DS_Store
# 原始数据表:含实测数据,按需本地保留,不入库
材料性能测试数据表.xlsx
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
ARG PIP_INDEX_URL=https://pypi.org/simple
RUN python -m pip install --no-cache-dir --retries 8 --timeout 60 --index-url "$PIP_INDEX_URL" -r requirements.txt
COPY . .
RUN mkdir -p data backups && useradd -r -u 10001 app && chown -R app:app /app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+64
View File
@@ -0,0 +1,64 @@
# 激光材料数据平台
用于手动录入和管理激光雕刻参数、材料物理特性及实验结果的响应式网站。电脑和手机共用一套页面,包含登录、三级权限、数据校验、审计日志、备份与恢复。
录入时可上传成品图,手机上支持直接调起相机拍照,图片在浏览器内压缩后入库;查看数据详情时可以放大预览。
管理员可在运行期间添加扩展数据字段,配置必填/可选、公开性、排序、单位和输入形式。字段配置修改或停用不会删除历史数据。
系统入口:
- `/`:公开数据看板,无需登录。
- `/entry`:四位 Key 验证后的专用数据录入面板。
- `/admin`:登录后的管理后台。
## 一键部署(推荐)
要求:Docker Desktop 或 Docker Engine + Compose。
macOS / Linux 可直接执行:
```bash
./start.sh
```
脚本会自动生成安全配置、启动 Docker Desktop、构建服务、等待健康检查并打开浏览器。已有 `.env` 和数据库不会被覆盖。
也可以手动部署:
```bash
cp .env.example .env
# 编辑 .env,务必修改 APP_SECRET、ADMIN_PASSWORD,并增加 POSTGRES_PASSWORD
docker compose up -d --build
```
浏览器打开 `http://服务器地址:5980` 查看公开数据;管理登录地址为 `/login`。端口可通过 `.env``APP_PORT` 修改。首次登录使用 `.env` 中的 `ADMIN_USERNAME` / `ADMIN_PASSWORD`,系统会提示修改初始密码。
如果服务器无法访问官方 PyPI,可在 `.env` 设置可访问的内部镜像,例如 `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple`,然后重新执行 `docker compose up -d --build`
停止服务:`docker compose down`。查看日志:`docker compose logs -f web`。数据存放在 Docker 的 `postgres_data` 卷,备份文件在项目的 `backups/` 目录。
## 本地开发
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
cp .env.example .env
.venv/bin/python run.py
```
默认使用 `data/laser_data.db`(SQLite),适合单机开发;正式部署使用 Compose 中的 PostgreSQL 17。
## 文档
- [完整操作手册](docs/操作手册.md)
- [数据库与接口文档](docs/数据库接口文档.md)
- 服务启动后可访问 `/docs` 查看可交互 OpenAPI 文档,访问 `/openapi.json` 获取机器可读定义。
## 角色
| 角色 | 查看 | 新增/编辑 | 删除 | 用户/备份/审计 |
|---|---:|---:|---:|---:|
| `admin` 管理员 | ✓ | ✓ | ✓ | ✓ |
| `uploader` 录入员 | ✓ | ✓ | — | — |
| `viewer` 只读用户 | ✓ | — | — | — |
+37
View File
@@ -0,0 +1,37 @@
# Laser Material Data 1.3.0
## 本次更新:成品图上传与详情预览
- 录入表单新增“成品图片”,可从相册/文件选择,手机和平板上可直接调起系统相机拍照,电脑支持拖拽。
- 每条实验记录最多 6 张图,入库统一为 JPG / PNG(选中的 WebP、HEIC 会在浏览器内自动转成 JPG);服务端按文件头校验,拒收 SVG、GIF 及伪装成图片的文件。
- 图片在浏览器内压缩到最长边 1600 像素再上传,通常每张 200–400 KB,同时自动清除照片中的 GPS 等 EXIF 信息。
- 公开看板和管理后台的数据详情可查看缩略图,点击全屏放大,支持键盘方向键、手机左右滑动和 Esc 关闭。
- 图片二进制直接入库,现有 `pg_dump` / SQLite 备份自动包含图片,部署无需新增卷;备份体积会相应增长,详见操作手册第 9 节。
- 升级只需重新部署,新表在服务启动时自动创建,历史数据不受影响。
升级:
```bash
docker compose up -d --build
```
# Laser Material Data 1.2.1
- Docker 构建默认使用官方 PyPI,并支持通过 `PIP_INDEX_URL` 指定企业内部 Python 镜像。
- Ubuntu Docker Compose 一键部署,默认端口 5980。
- 公开数据看板,无需登录。
- 四位 Key 专用录入面板与用户名二次确认。
- 管理后台、三级权限、审计日志与数据库备份。
- 管理员动态字段配置,历史原始值永久保留并兼容显示。
- PostgreSQL 17 生产数据库,SQLite 本地开发兼容。
部署:
```bash
docker compose up -d --build
```
公开看板:`http://服务器IP:5980/`
管理登录:`http://服务器IP:5980/login`
+1
View File
@@ -0,0 +1 @@
1.3.0
+1
View File
@@ -0,0 +1 @@
+93
View File
@@ -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("当前数据库类型暂不支持恢复")
+25
View File
@@ -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)
+29
View File
@@ -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()
+155
View File
@@ -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
View File
@@ -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
View File
@@ -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-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")
+191
View File
@@ -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 GiBPostgreSQL 的 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
View File
@@ -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
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
+41
View File
@@ -0,0 +1,41 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: laser_data
POSTGRES_USER: laser_app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在.env中设置POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U laser_app -d laser_data"]
interval: 10s
timeout: 5s
retries: 5
web:
build:
context: .
args:
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple}
restart: unless-stopped
ports: ["${APP_PORT:-5980}:8000"]
environment:
DATABASE_URL: postgresql+psycopg://laser_app:${POSTGRES_PASSWORD}@db:5432/laser_data
APP_SECRET: ${APP_SECRET:?请在.env中设置APP_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?请在.env中设置ADMIN_PASSWORD}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-30}
volumes:
- ./backups:/app/backups
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
volumes:
postgres_data:
+197
View File
@@ -0,0 +1,197 @@
# 激光材料数据平台操作手册
版本:1.0 更新日期:2026-07-17
## 1. 部署前准备
生产环境建议至少 2 核 CPU、4 GB 内存、20 GB 可用磁盘,安装 Docker Engine 24+ 与 Docker Compose v2+。应准备域名、HTTPS 证书和一处异机备份存储。服务器只需对外开放反向代理的 80/443 端口;PostgreSQL 不应直接暴露到公网。
复制环境变量文件:
```bash
cp .env.example .env
```
至少修改以下内容:
```dotenv
APP_SECRET=使用密码生成器创建的32位以上随机字符串
ADMIN_USERNAME=admin
ADMIN_PASSWORD=首次登录使用的高强度密码
POSTGRES_PASSWORD=数据库高强度密码
BACKUP_KEEP_DAYS=30
```
`.env` 不得提交到 Git,也不要通过聊天或邮件发送。
## 2. 启动、升级与停止
首次启动:
```bash
docker compose up -d --build
docker compose ps
docker compose logs --tail=100 web
```
`web``db` 均为运行状态后,访问 `http://服务器IP:5980`。端口由 `.env``APP_PORT` 控制。生产环境应在前方配置 Nginx/Caddy HTTPS 反向代理。只有在明确限制受信代理 IP 后才应启用转发头解析,不能无条件信任公网请求提供的 `X-Forwarded-For`,否则会削弱 Key 尝试限速。
更新代码后:
```bash
docker compose up -d --build
```
停止但保留数据库:
```bash
docker compose down
```
不要执行 `docker compose down -v`,该命令会删除 PostgreSQL 数据卷。
## 3. 首次登录
1. 打开登录页,输入 `.env` 中的管理员账号和密码。
2. 首次登录后点击“立即修改”,设置至少 10 位且同时含字母和数字的新密码。
3. 在“用户与权限”中为实际使用人员创建独立账号。禁止多人共用管理员账号,否则审计日志无法追溯到个人。
登录会话默认 12 小时。退出时点击左下角退出按钮。连续失败登录会记入审计日志;生产环境还应在反向代理层启用限速。
## 4. 手动录入实验数据
系统提供两种手动录入入口:登录管理后台后点击左侧“手动录入”,或访问 `/entry` 使用四位录入 Key。两者使用相同的字段校验和数据库事务。
### 4.1 设置四位录入 Key
管理员或数据录入员登录 `/admin` 后点击右上角“录入 Key”,输入两次四位数字和当前登录密码。只读用户不能设置或使用录入 Key。Key 在所有用户中必须唯一,数据库只保存带应用密钥的摘要,不保存原值。更换或停用 Key 会立即撤销已签发的录入会话。
四位数字只有 10,000 种组合,因此该入口采用以下限制:同一 IP 在 15 分钟内最多失败 5 次;验证成功后授权仅持续 30 分钟;提交时必须再次输入 Key 所属账号的准确用户名。不要把 Key 设置成易猜的生日、年份或连续数字,也不要公开传播。
访问 `/entry`,输入 Key 后进入专用录入面板。数据填写完成后点击“检查并提交”,在弹窗中输入登录用户名(不是显示名称)再次确认。用户名或 Key 会话不匹配时,数据不会写入。
### 4.2 字段填写
表单分为五组:
1. 实验与材料:实验 ID、测试日期、材料大类、名称/型号、厚度。
2. 材料物理特性:原 Excel 红色表头对应的 10 项字段,全部可选。
3. 激光雕刻参数:功率、电流、速度、频率、脉宽、离焦、线间距、填充方式和加工尺寸。
4. 实验结果:切透、碳化边宽度、蚀刻深度、起火/阴燃、两项评分、成品图文件名和备注。
5. 成品图片:可选,最多 6 张,详见 4.3。
`*` 的项目必须填写。实验 ID 在整个系统内唯一;百分比必须位于 0–100,评分必须位于 0–10,是否类字段通过下拉框选择。点击“保存实验数据”后,后端会再次校验;任何字段错误都不会产生半条记录。
建议实验 ID 采用固定规则,例如 `LAS-2026-0001`。“成品图文件名”记录图片在单位文件服务器或对象存储中的归档名;上传第一张图片后系统会自动填入该字段(手机拍照没有有意义的文件名时,用实验 ID 补齐),需要时可以手动改写。
### 4.3 上传成品图片
在录入表单第 5 段“成品图片”里,点击 **选择图片** 从相册或文件中挑选(支持一次多选),手机和平板上还会多出一个 **拍照** 按钮,点击即调起系统相机直接拍摄。电脑上也可以把图片文件直接拖进虚线框。
要点:
- 每条记录最多 6 张。可以选择 JPG、PNG、WebP、HEIC 照片,浏览器会统一转成 JPG 或 PNG 再上传;出于安全考虑不接受 SVG、GIF 及任何伪装成图片的文件。
- 图片在浏览器里就会被压缩到最长边 1600 像素再上传,一般每张 200–400 KB,用手机流量提交也不慢。压缩过程顺带**清除了照片中的 GPS 定位等 EXIF 信息**。
- 图片是逐张依次上传的,缩略图右上角的 × 可以移除,失败的会显示原因并提供“重试”。**上传未完成时提交会被拦下**,请等所有缩略图不再显示进度条。
- iPhone 若拍出 HEIC 格式且浏览器无法转换,页面会提示到「设置 → 相机 → 格式」选「兼容性最佳」后重拍。
- 上传后未提交的图片属于临时状态,只有上传者本人能看到,2 小时后自动清理。所以**填完表单要及时提交**。
- 图片二进制保存在数据库中,日常数据库备份已经包含图片,不需要单独备份图片目录。
在管理后台编辑已有记录时,原有图片会显示在同一位置,可以删除或继续追加,保存后生效。只读账号只能查看,不能增删。
### 4.4 管理扩展数据字段
管理员进入 `/admin`,选择“数据字段配置”,可添加固定 32 项之外的数据种类。支持以下填写形式:
| 填写形式 | 用途 |
|---|---|
| 单行文本 | 型号、方法、短说明 |
| 多行文本 | 较长实验描述 |
| 数字 | 可含小数的测量值 |
| 整数 | 次数、等级编号 |
| 日期 | ISO 日期 |
| 是 / 否 | 二元状态 |
| 下拉选项 | 由管理员维护的固定选项 |
每个字段还可设置名称、单位、排序值、必填/可选、是否在公开看板显示以及启用/停用状态。排序值越小越靠前。
系统不允许从页面物理删除字段。字段名称、类型、选项或启用状态改变时,`custom_field_values` 中的原始值不被改写:
- 新增必填字段不会伪造旧记录的值;旧记录显示为未填写,后续编辑时需要补充。
- 类型改变后,能转换的历史值按新类型显示。
- 无法转换的值显示原文并标记“历史格式”;编辑其他字段时留空不会清除该原值,主动填写符合新类型的新值才会替换。
- 停用字段不再出现在新录入表单,但已有值仍在管理详情中显示;若字段允许公开,公开详情也会标记为历史字段。
- 调整下拉选项不会删除已不在新选项列表中的旧值。
## 5. 公开数据面板
访问 `/` 无需登录。页面显示总记录数、材料分类数、近 30 天新增、平均清晰度,并支持按实验 ID、材料名称和材料分类筛选。点击记录可查看成品图片、材料物性、激光参数和实验结果。详情弹窗顶部是成品图缩略图,点击任意一张即可全屏放大,支持左右方向键、手机左右滑动切换,按 Esc 或点击空白处关闭。
公开接口不会返回录入用户、备注、成品图文件名、审计日志或账号信息。**但成品图片本身是公开的**:任何人都能在公开看板看到并直接访问图片地址。请提醒录入人员不要拍进白板、工牌、屏幕等含内部信息的画面;若图片属于内部资料,应在正式上线前收紧 `GET /api/images/{id}` 的权限。扩展字段只有在管理员启用“公开看板显示”后才会公开。若某些科学参数也属于内部敏感数据,应在正式上线前调整公开字段白名单。
## 6. 查询、编辑与删除
“数据中心”支持按实验 ID、材料名称模糊搜索,或按材料大类精确筛选。
- 管理员:可查看、编辑、删除。
- 录入员:可查看、编辑,不能删除。
- 只读用户:只能查看。
点击“编辑”会将完整数据载入表单。删除前系统要求二次确认,删除动作不可从页面撤销,但会写入审计日志;如需找回,应通过数据库备份恢复到临时实例后导出目标记录,不建议直接覆盖生产库。
## 7. 用户和权限管理
管理员进入“用户与权限”后可创建账号、修改角色、停用账号或重置密码。被重置密码的用户下次登录会收到改密提示。系统阻止停用当前登录账号,并保证至少保留一名启用的管理员。
人员离职时应立即停用而不是复用账号。每季度检查一次管理员列表和长期未使用账号。
## 8. 审计日志
审计日志记录登录成功/失败、退出、密码修改、记录新增/修改/删除、用户管理及备份创建,包含操作者、资源、时间、IP 和摘要。只有管理员可查看。审计表应纳入数据库备份,日常不得手工修改。
## 9. 备份与恢复
管理员可在“备份管理”点击“立即备份”。生产 PostgreSQL 使用 `pg_dump --format=custom` 创建一致性备份;开发 SQLite 使用在线备份 API。每个备份记录大小和 SHA-256 摘要,默认保留 30 天。
建议用宿主机定时任务每日执行:
```bash
docker compose exec -T web python scripts/db_backup.py
```
项目内 `backups/` 只是第一份副本。应每天同步到另一台服务器或对象存储,并至少每季度做一次恢复演练。
成品图片保存在数据库中,上面两种备份都已包含图片,不需要单独备份图片目录,恢复后也不会出现“数据在、图片丢”。代价是备份体积会随图片增长:按每条记录 3 张、单张 300 KB 估算,每 1000 条记录约多出 0.9 GB,且 JPEG 几乎无法再压缩。请据此核算 `backups/` 所在磁盘容量,必要时调小 `BACKUP_KEEP_DAYS` 或缩短异机副本的保留周期。
恢复必须安排维护窗口:停止 Web 写入,确认备份摘要及时间,再执行:
```bash
docker compose stop web
docker compose run --rm web python scripts/db_restore.py database-YYYYMMDD-HHMMSS.dump --confirm
docker compose up -d web
```
恢复会覆盖当前数据库。生产恢复前先额外备份当前状态,并优先在临时数据库验证备份可用性。
## 10. 日常检查与故障处理
- 健康检查:访问 `/health`,应返回 `status: ok`
- 服务日志:`docker compose logs --tail=200 web`
- 数据库日志:`docker compose logs --tail=200 db`
- 磁盘检查:关注 PostgreSQL 数据卷、`backups/` 和 Docker 日志空间。
- 无法登录:确认账号未停用、系统时间正确、Cookie 未被浏览器禁止。
- 保存返回 422:按提示检查必填、数值范围和日期格式。
- 备份失败:确认容器内 `pg_dump` 可用、磁盘空间充足、数据库连接正常。
## 11. 上线安全清单
- 已修改 `APP_SECRET`、管理员密码和数据库密码。
- 已启用 HTTPSHTTP 自动跳转 HTTPS。
- 数据库端口未暴露公网。
- `.env` 权限受限且未进入版本库。
- 已创建个人账号并停用不必要账号。
- 已为需要快速录入的用户单独设置 Key,且未使用弱组合。
- 已确认成品图片可以公开;如属内部资料,已收紧 `GET /api/images/{id}` 权限。
- 已按预期图片量核算备份磁盘容量。
- 每日自动备份已配置,异机副本和恢复演练已完成。
- 服务器与容器镜像有定期补丁计划。
+268
View File
@@ -0,0 +1,268 @@
# 数据库与接口文档
版本:1.0 基础路径:`/api`
## 1. 技术与一致性
生产数据库为 PostgreSQL 17,开发环境可使用 SQLite。访问层使用 SQLAlchemy 2;所有手动录入以事务提交,校验失败时整次回滚。时间字段保存带时区时间,业务日期使用 `DATE`。外键在 PostgreSQL 和 SQLite 中均启用。
主要关系:
```text
users 1 ── n login_sessions
users 1 ── n entry_sessions
users 1 ── n entry_batches(手动录入事务批次)
entry_batches 1 ── n material_records
material_records 1 ── n custom_field_values
custom_field_definitions 1 ── n custom_field_values
material_records 1 ── n record_images(成品图,二进制入库)
users 1 ── n audit_logs
users 1 ── n backup_records
```
## 2. 数据表
### users
账号表。`username` 唯一;`password_hash` 为 PBKDF2-SHA256310,000 次)结果,不保存明文。`role``admin``uploader``viewer``is_active` 控制账号可用性;`must_change_password` 标识初始/重置密码。`entry_key_hash` 是使用 `APP_SECRET` 生成的 HMAC-SHA256 摘要并带唯一索引,`entry_key_updated_at` 记录设置时间。修改 `APP_SECRET` 会使现有录入 Key 全部失效。
### login_sessions
服务端会话表。浏览器只持有随机令牌,库内保存令牌加应用密钥后的 SHA-256 摘要。`expires_at` 控制过期,用户停用后已有会话立即失效。
### entry_sessions
专用录入授权表,与管理登录会话完全隔离。四位 Key 验证成功后签发随机令牌,默认 30 分钟过期;只允许调用录入接口,不能访问管理 API。用户更换/停用 Key 或账号停用后,已有授权失效。
### entry_batches
录入事务批次表。每次成功保存产生一个 `ENT...` 编号并记录创建人、记录数、状态和时间。保留该层用于来源追踪、事务分组及未来的批量手动录入扩展。
### material_records
实验主表。关键字段映射如下:
| API 字段 | 数据库字段 | 类型 | 约束 |
|---|---|---|---|
| `experiment_id` | experiment_id | varchar(100) | 全局唯一约束 + 接口校验 |
| `test_date` | test_date | date | 必填 |
| `material_category` | material_category | varchar(100) | 必填、索引 |
| `material_name` | material_name | varchar(200) | 必填、索引 |
| `apparent_density` | apparent_density | double | 可选、≥0 |
| `uv_absorption_355nm` | uv_absorption_355nm | double | 可选、0100 |
| `material_thickness` | material_thickness | double | 必填、>0 |
| `moisture_content` | moisture_content | double | 可选、0100 |
| `thermal_conductivity` | thermal_conductivity | double | 可选、≥0 |
| `initial_decomposition_temp` | initial_decomposition_temp | double | 可选 |
| `melting_vaporization_temp` | melting_vaporization_temp | double | 可选 |
| `specific_heat_capacity` | specific_heat_capacity | double | 可选、≥0 |
| `carbon_residue_rate` | carbon_residue_rate | double | 可选、0100 |
| `surface_roughness` | surface_roughness | double | 可选、≥0 |
| `hardness` | hardness | double | 可选、≥0 |
| `actual_output_power` | actual_output_power | double | 必填、≥0 |
| `display_current` | display_current | double | 必填、≥0 |
| `scanning_speed` | scanning_speed | double | 必填、>0 |
| `pulse_frequency` | pulse_frequency | double | 必填、≥0 |
| `pulse_width` | pulse_width | double | 必填、≥0 |
| `defocus_amount` | defocus_amount | double | 必填,可为负 |
| `scan_line_spacing` | scan_line_spacing | double | 必填、>0 |
| `filling_method` | filling_method | varchar(100) | 必填 |
| `processing_size` | processing_size | varchar(100) | 必填 |
| `is_cut_through` | is_cut_through | boolean | 必填 |
| `carbonized_edge_width` | carbonized_edge_width | double | 必填、≥0 |
| `etching_depth` | etching_depth | double | 必填、≥0 |
| `is_fire_smolder` | is_fire_smolder | boolean | 必填 |
| `pattern_clarity_score` | pattern_clarity_score | double | 必填、010 |
| `presentation_balance_score` | presentation_balance_score | double | 必填、010 |
| `finished_image_filename` | finished_image_filename | varchar(255) | 必填 |
| `remarks` | remarks | text | 必填、最多 5000 字符(接口) |
组合索引覆盖材料分类+测试日期,实验 ID、日期、材料名称、批次外键均建索引。
### record_images
实验成品图表。图片二进制直接保存在 `data` 列(PostgreSQL 为 `bytea`SQLite 为 `BLOB`),因此现有数据库备份天然包含图片,恢复后不会出现"数据在、图片丢"的情况,部署也无需额外挂载卷。
| 字段 | 类型 | 说明 |
|---|---|---|
| `record_id` | int,可空 | 为空表示"已上传但尚未提交"的临时图片,超过 2 小时由下一次上传顺手清理 |
| `token` | varchar(64) | 上传后返回给前端的一次性引用,提交记录时凭它完成挂载;挂载后即失效 |
| `content_type` | varchar(40) | 服务端按文件头判定,只允许 `image/jpeg``image/png`(WebP 的 RIFF 头几乎不校验内容,不予接收) |
| `original_name` | varchar(255) | 原始文件名,仅作展示,已去除路径部分 |
| `size_bytes` / `width` / `height` | int | 均由服务端解析得出,不信任客户端上报 |
| `sha256` | varchar(64) | 内容指纹,用于 ETag 和图片 URL 的版本参数 |
| `data` / `thumbnail` | 二进制 | 主图与缩略图;缩略图由客户端生成,缺失时接口回落到主图 |
| `sort_order` | int | 按提交时给出的顺序排列 |
单条记录最多 6 张图,单张上传上限 8 MB(前端会先压缩到最长边 1600px 的 JPEG,通常 200–400 KB)。删除实验记录会级联删除其图片。
### custom_field_definitions
动态字段定义表。`field_key` 是创建后永不改变的内部标识;`label` 可重命名;`input_type``text``long_text``number``integer``date``boolean``select``is_required` 控制新录入和后续编辑校验,`is_active` 控制是否出现在表单,`is_public` 控制公开详情,`options_json` 保存下拉选项,`sort_order` 控制顺序。
### custom_field_values
扩展字段原始值表,`record_id + field_id` 唯一。`raw_value` 始终保存用户最后提交的原始文本,字段类型修改时不做数据库迁移或批量转换。读取接口根据当前字段定义生成 `value``display_value``compatible`;转换失败时 `compatible=false` 并返回原文。字段停用不会级联删除值,只有实验记录被管理员删除时才随记录删除。
### audit_logs / backup_records
`audit_logs` 保存动作、资源类型/ID、JSON 摘要、IP、操作者和时间。`backup_records` 保存备份文件元数据、数据库类型、字节数和 SHA-256,不把备份二进制放入数据库。
## 3. 认证与通用约定
登录成功后服务器设置 HttpOnly、SameSite=Lax Cookie `laser_session`。除 GET 外的浏览器操作需要请求头 `X-CSRF-Token`;页面已自动注入该值。未登录返回 401,无权限返回 403,冲突返回 409,字段校验返回 422。
成功响应:
```json
{"ok": true, "message": "实验数据已保存", "data": {}}
```
失败响应:
```json
{"ok": false, "message": "experiment_id:不能为空"}
```
分页接口返回 `pagination: {page, page_size, total}``page_size` 最大 100。
## 4. 认证接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | `/api/auth/login` | 公开 | multipart 表单:`username``password` |
| POST | `/api/auth/logout` | 已登录 | 注销当前会话 |
| GET | `/api/me` | 已登录 | 当前用户 |
| PUT | `/api/me/password` | 已登录 | JSON`old_password``new_password` |
## 5. 实验数据接口
### POST `/api/records`
权限:`admin``uploader`。请求体为上表全部字段,可选字段可传 `null` 或省略。示例:
```json
{
"experiment_id": "LAS-2026-0001",
"test_date": "2026-07-17",
"material_category": "木材",
"material_name": "椴木板 A3",
"apparent_density": null,
"uv_absorption_355nm": null,
"material_thickness": 3,
"moisture_content": null,
"thermal_conductivity": null,
"initial_decomposition_temp": null,
"melting_vaporization_temp": null,
"specific_heat_capacity": null,
"carbon_residue_rate": null,
"surface_roughness": null,
"hardness": null,
"actual_output_power": 8.5,
"display_current": 2,
"scanning_speed": 100,
"pulse_frequency": 20,
"pulse_width": 5,
"defocus_amount": 0,
"scan_line_spacing": 0.1,
"filling_method": "双向填充",
"processing_size": "20×20",
"is_cut_through": true,
"carbonized_edge_width": 0.2,
"etching_depth": 30,
"is_fire_smolder": false,
"pattern_clarity_score": 9,
"presentation_balance_score": 8.5,
"finished_image_filename": "LAS-2026-0001.jpg",
"remarks": "加工稳定",
"custom_fields": {
"custom_a1b2c3d4e5f6": "12.5"
},
"image_tokens": ["QnR5c2hvdC10b2tlbg"],
"image_ids": []
}
```
`custom_fields` 的键来自 `GET /api/form-fields`。创建记录时所有启用的必填扩展字段必须提交。更新记录时,无法适配新类型的历史字段可以省略以保留原始值;提交 `null` 表示清空可选字段。
`image_tokens` 是先通过上传接口拿到的新图令牌;`image_ids` 是编辑时要保留的既有图片 ID。两者合计不能超过 6,数组顺序即展示顺序。**编辑记录时,未列入 `image_ids` 的既有图片会被删除**,所以 PUT 请求必须带上完整的保留列表;新建记录不接受 `image_ids`
### POST `/api/uploads/images`
权限:`admin``uploader`,需要 `X-CSRF-Token``multipart/form-data`,字段 `file`(必填)与 `thumbnail`(可选)。
服务端只按文件头判定类型,扩展名和客户端 `Content-Type` 一律不作数;SVG、GIF、HTML 等一律拒绝。单张上限 8 MB,超出的请求在中间件按 `Content-Length` 直接返回 413,不会落盘。同一账号未提交的图片累计 24 张时返回 429。
```json
{"ok": true, "message": "图片已上传", "data": {
"id": 12, "token": "…", "width": 1600, "height": 1067, "size_bytes": 305412,
"content_type": "image/jpeg", "original_name": "IMG_4821.jpg",
"url": "/api/images/12?v=3f9a1c2b7d4e", "thumb_url": "/api/images/12?variant=thumb&v=3f9a1c2b7d4e"}}
```
缩略图只是加速手段:即使它体积超限或格式非法也不会导致整次上传失败,服务端会丢弃并让 `thumb_url` 回落到原图。上传的字节在保存前会截断到图像结束标记(JPEG 的 `FFD9`、PNG 的 `IEND`)为止,防止在合法图片后面附带任意数据;`original_name` 也会做字符清洗后再入库。
对应的 Key 录入入口是 `POST /api/entry/uploads/images`,鉴权改用录入会话 Cookie 与录入 CSRF Token,其余行为一致。
### DELETE `/api/uploads/images/{token}`
撤回一张尚未挂到记录上的图片,及时释放该账号的待提交名额(用户在页面上删掉重拍时自动调用)。只能撤回本人上传且未挂载的图片,其余一律 404。Key 录入入口为 `DELETE /api/entry/uploads/images/{token}`
### GET `/api/images/{id}`
参数:`variant``full``thumb`)、`v`(内容指纹,仅用于绕开浏览器缓存)。已挂到记录上的图片公开可读;**尚未挂载的临时图片只有上传者本人能读,其他人一律 404**。
响应带 `X-Content-Type-Options: nosniff``Content-Security-Policy: default-src 'none'; sandbox``Content-Disposition: inline``ETag`,已挂载图片按 `immutable` 缓存一年。因为 SQLite 会复用被删除行的 rowidURL 必须带 `v` 参数,否则浏览器可能把旧缓存图误认成新图。
### GET `/api/records`
参数:`q`(实验 ID/材料名称模糊查询)、`category`(精确)、`page``page_size`。任何已登录角色可访问。
### GET / PUT / DELETE `/api/records/{id}`
GET 返回完整记录;PUT 请求体与 POST 相同,权限为管理员或录入员;DELETE 仅管理员可用,同时清理对应的单条手动录入批次并写审计日志。
## 6. 公开与 Key 录入接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/api/public/dashboard` | 公开 | 统计、分类、公开记录分页 |
| GET | `/api/public/records/{id}` | 公开 | 科学参数详情与成品图列表,不返回备注、图片文件名和用户 |
| GET | `/api/images/{id}` | 公开(临时图除外) | 图片二进制,`variant=thumb` 取缩略图 |
| POST | `/api/entry/uploads/images` | 录入会话 | multipart 上传成品图,返回挂载令牌 |
| DELETE | `/api/entry/uploads/images/{token}` | 录入会话 | 撤回尚未提交的图片 |
公开详情里的 `images` 只包含 `id``width``height``url``thumb_url` 五个字段;`original_name``size_bytes``content_type` 仅在登录后的 `GET /api/records/{id}` 返回。
| POST | `/api/entry/auth` | 公开、限速 | JSON `{key}`,成功设置 30 分钟 HttpOnly Cookie |
| GET | `/api/entry/session` | 公开 | 检查当前录入授权并获取 CSRF Token |
| POST | `/api/entry/records` | 录入会话 | 32 个数据字段加 `confirm_username` |
| POST | `/api/entry/logout` | 录入会话 | 撤销当前录入授权 |
| PUT | `/api/me/entry-key` | 管理登录 | 当前密码验证后设置/更换四位 Key |
| DELETE | `/api/me/entry-key` | 管理登录 | 当前密码验证后停用 Key |
| GET | `/api/form-fields` | 公开 | 当前启用的扩展字段定义,供录入表单渲染 |
`/api/entry/records` 会同时验证录入 Cookie、CSRF Token 和 `confirm_username`。用户名必须与 Key 所属账号的 `username` 精确一致。失败 Key 按来源 IP 限制为 15 分钟最多 5 次,失败与成功均写审计日志。
## 7. 管理接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/api/users` | admin | 用户列表 |
| GET | `/api/field-definitions` | admin | 全部动态字段及已有值数量 |
| POST | `/api/field-definitions` | admin | 新增动态字段 |
| PUT | `/api/field-definitions/{id}` | admin | 修改类型、规则、选项、排序或状态 |
| POST | `/api/users` | admin | 新建用户 |
| PATCH | `/api/users/{id}` | admin | 角色、状态、密码重置 |
| GET | `/api/audits` | admin | 审计日志分页 |
| GET | `/api/backups` | admin | 备份列表 |
| POST | `/api/backups` | admin | 立即创建备份 |
| GET | `/api/backups/{id}/download` | admin | 下载备份 |
| GET | `/health` | 公开 | 存活检查 |
完整请求/响应 Schema 以运行实例的 `/docs``/openapi.json` 为准。
## 8. 备份接口与保留策略
SQLite 使用官方在线 Backup APIPostgreSQL 使用 custom format `pg_dump`;生成后计算 SHA-256 并写 `backup_records`。超过 `BACKUP_KEEP_DAYS` 的本地备份在新备份完成后清理。恢复只通过停机命令行工具执行,不提供 Web 恢复接口,以降低误覆盖风险。
成品图存在数据库里,备份自然包含图片,代价是备份体积随图片增长:按每条记录平均 3 张、单张约 300 KB 估算,每 1000 条记录约增加 0.9 GB。`pg_dump --format=custom` 默认会压缩,但 JPEG 已经是压缩数据,几乎压不动。上线前请按预期记录量核算 `backups/` 所在磁盘容量,必要时调小 `BACKUP_KEEP_DAYS`
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
httpx==0.28.1
pytest==8.4.1
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
SQLAlchemy==2.0.41
psycopg[binary]==3.2.9
Jinja2==3.1.6
python-multipart==0.0.20
+6
View File
@@ -0,0 +1,6 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
from app.backup_service import create_backup
from app.db import SessionLocal
if __name__ == "__main__":
with SessionLocal() as db:
backup = create_backup(db)
print(f"backup={backup.filename} size={backup.size_bytes} sha256={backup.sha256}")
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
from app.backup_service import restore_backup
from app.config import settings
parser = argparse.ArgumentParser(description="在停机维护窗口恢复数据库备份")
parser.add_argument("filename", help="backups 目录内的备份文件名")
parser.add_argument("--confirm", action="store_true", help="确认覆盖当前数据库")
args = parser.parse_args()
if not args.confirm:
parser.error("恢复会覆盖当前数据库,请停机后添加 --confirm")
path = settings.backup_dir / Path(args.filename).name
if not path.exists():
parser.error(f"备份不存在:{path}")
restore_backup(path)
print(f"restored={path}")
Executable
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
ENV_FILE="$ROOT_DIR/.env"
CREATED_ENV=0
log() {
printf '\033[1;34m[laser-data]\033[0m %s\n' "$*"
}
fail() {
printf '\033[1;31m[laser-data] 启动失败:\033[0m%s\n' "$*" >&2
exit 1
}
random_value() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$1"
else
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
env_value() {
local key="$1"
sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1
}
ensure_env_value() {
local key="$1"
local value="$2"
if ! grep -q "^${key}=" "$ENV_FILE"; then
printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
}
on_error() {
printf '\n'
log "最近的容器日志:"
docker compose logs --tail=60 web db 2>/dev/null || true
}
trap on_error ERR
command -v docker >/dev/null 2>&1 || fail "未找到 Docker。请先安装 Docker Desktophttps://www.docker.com/products/docker-desktop/"
if ! docker info >/dev/null 2>&1; then
if [[ "$(uname -s)" == "Darwin" ]] && [[ -d "/Applications/Docker.app" ]]; then
log "Docker Desktop 尚未运行,正在启动..."
open -a Docker
for _ in $(seq 1 90); do
docker info >/dev/null 2>&1 && break
sleep 2
done
fi
fi
docker info >/dev/null 2>&1 || fail "Docker 服务未运行,请启动 Docker Desktop 后重新执行 ./start.sh"
docker compose version >/dev/null 2>&1 || fail "当前 Docker 未安装 Compose 插件"
if [[ ! -f "$ENV_FILE" ]]; then
CREATED_ENV=1
ADMIN_PASSWORD="Admin-$(random_value 8)"
cat > "$ENV_FILE" <<EOF
APP_NAME=激光材料数据平台
APP_PORT=5980
APP_SECRET=$(random_value 32)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
POSTGRES_PASSWORD=$(random_value 24)
SESSION_HOURS=12
BACKUP_KEEP_DAYS=30
EOF
chmod 600 "$ENV_FILE"
log "已生成 .env 和随机初始密码"
else
ensure_env_value "APP_SECRET" "$(random_value 32)"
ensure_env_value "APP_PORT" "5980"
ensure_env_value "ADMIN_USERNAME" "admin"
ensure_env_value "ADMIN_PASSWORD" "Admin-$(random_value 8)"
ensure_env_value "POSTGRES_PASSWORD" "$(random_value 24)"
ensure_env_value "SESSION_HOURS" "12"
ensure_env_value "BACKUP_KEEP_DAYS" "30"
chmod 600 "$ENV_FILE"
log "使用现有 .env 配置"
fi
mkdir -p backups
log "正在构建并启动网站与 PostgreSQL..."
docker compose up -d --build
APP_PORT="$(env_value APP_PORT)"
log "等待网站完成初始化..."
HEALTHY=0
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${APP_PORT}/health" >/dev/null 2>&1; then
HEALTHY=1
break
fi
sleep 2
done
if [[ "$HEALTHY" != "1" ]]; then
fail "网站在 120 秒内未通过健康检查,请查看上方日志"
fi
ADMIN_USERNAME="$(env_value ADMIN_USERNAME)"
ADMIN_PASSWORD="$(env_value ADMIN_PASSWORD)"
printf '\n'
printf '\033[1;32m网站已启动\033[0m\n'
printf '访问地址: http://localhost:%s\n' "$APP_PORT"
printf '管理员用户名: %s\n' "$ADMIN_USERNAME"
if [[ "$CREATED_ENV" == "1" ]]; then
printf '管理员初始密码:%s\n' "$ADMIN_PASSWORD"
printf '首次登录后请立即修改密码。\n'
else
printf '管理员密码: 使用现有 .env 中的 ADMIN_PASSWORD\n'
fi
printf '停止服务: docker compose down\n'
printf '查看日志: docker compose logs -f web\n\n'
if [[ "$(uname -s)" == "Darwin" ]]; then
open "http://localhost:${APP_PORT}" >/dev/null 2>&1 || true
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "http://localhost:${APP_PORT}" >/dev/null 2>&1 || true
fi
+18
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -0,0 +1,84 @@
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => [...root.querySelectorAll(s)];
const csrf = window.APP.csrf;
function toast(message, error=false) {
const node=document.createElement('div'); node.className=`toast ${error?'error':''}`;
node.innerHTML=`<b>${error?'操作未完成':'操作成功'}</b><span>${escapeHtml(typeof message==='string'?message:JSON.stringify(message))}</span>`;
$('#toasts').append(node); setTimeout(()=>node.remove(),4500);
}
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
// 注意这挡不住内联事件属性里的 JS 字符串(HTML 解析器会先把 &#39; 解回单引号),那种地方一律改用 data-* + 监听器。
function escapeHtml(value) { return String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function formatDate(value) { return value ? new Date(value).toLocaleString('zh-CN',{hour12:false}) : '—'; }
function formatBytes(bytes) { if(bytes<1024)return `${bytes} B`; if(bytes<1048576)return `${(bytes/1024).toFixed(1)} KB`; return `${(bytes/1048576).toFixed(1)} MB`; }
async function api(url, options={}) {
options.headers={...(options.headers||{}),'X-CSRF-Token':csrf};
if(options.json){options.body=JSON.stringify(options.json);options.headers['Content-Type']='application/json';delete options.json;}
const response=await fetch(url,options); let data;
try{data=await response.json();}catch{data={message:response.statusText};}
if(response.status===401){location.href='/login';throw new Error('登录已过期');}
if(!response.ok){const msg=typeof data.message==='object' ? `${data.message.summary}${(data.message.errors||[]).slice(0,8).join('')}` : data.message; throw new Error(msg||'请求失败');}
return data;
}
function showView(name) {
$$('.view').forEach(v=>v.classList.remove('active')); $$('.nav-item').forEach(v=>v.classList.toggle('active',v.dataset.view===name));
const view=$(`#view-${name}`); if(!view)return; view.classList.add('active');
$('#pageTitle').textContent=({overview:'数据概览',entry:'手动录入',records:'数据中心',fields:'数据字段配置',users:'用户与权限',backup:'备份管理',audit:'审计日志'})[name];
$('#sidebar').classList.remove('open'); history.replaceState(null,'',`#${name}`);
({overview:loadRecent,records:loadRecords,fields:loadFieldDefinitions,users:loadUsers,backup:loadBackups,audit:loadAudits}[name]||(()=>{}))();
}
$$('.nav-item').forEach(b=>b.onclick=()=>showView(b.dataset.view)); $$('[data-go]').forEach(b=>b.onclick=()=>showView(b.dataset.go));
$('#menuBtn').onclick=()=>$('#sidebar').classList.toggle('open');
async function loadRecent(){
const target=$('#recentRecords');if(!target)return;
try{const {data}=await api('/api/records?page_size=4'); target.innerHTML=data.length?data.map(x=>`<div class="batch-mini"><b>${escapeHtml(x.experiment_id)} · ${escapeHtml(x.material_name)}</b><small>${escapeHtml(x.material_category)} · ${x.actual_output_power} W · ${x.scanning_speed} mm/s</small><span>${x.test_date}</span></div>`).join(''):'<div class="empty">暂无录入记录</div>';}catch(e){target.textContent=e.message;}
}
let recordPage=1;
async function loadRecords(page=1){
const body=$('#recordRows');if(!body)return;recordPage=page;body.innerHTML='<tr><td colspan="9" class="loading">正在加载…</td></tr>';
const q=encodeURIComponent($('#recordSearch').value),category=encodeURIComponent($('#categorySearch').value);
try{const res=await api(`/api/records?page=${page}&page_size=20&q=${q}&category=${category}`);body.innerHTML=res.data.length?res.data.map(x=>`<tr><td><b>${escapeHtml(x.experiment_id)}</b></td><td>${x.test_date}</td><td>${escapeHtml(x.material_category)} · ${escapeHtml(x.material_name)}</td><td>${x.material_thickness} mm</td><td>${x.actual_output_power} W</td><td>${x.scanning_speed} mm/s</td><td><span class="tag ${x.is_cut_through?'':'off'}">${x.is_cut_through?'是':'否'}</span></td><td>${x.pattern_clarity_score}</td><td><button class="table-action" onclick="editRecord(${x.id})">${window.APP.role==='viewer'?'查看':'编辑'}</button>${window.APP.role==='admin'?` <button class="danger-link" data-delete="${x.id}" data-no="${escapeHtml(x.experiment_id)}">删除</button>`:''}</td></tr>`).join(''):'<tr><td colspan="9" class="empty">未找到数据</td></tr>';
body.querySelectorAll('[data-delete]').forEach(button=>button.onclick=()=>deleteRecord(Number(button.dataset.delete),button.dataset.no));
const pages=Math.max(1,Math.ceil(res.pagination.total/res.pagination.page_size));$('#recordPager').innerHTML=`<span>第 ${page} / ${pages} 页,共 ${res.pagination.total} 条</span><button ${page<=1?'disabled':''} onclick="loadRecords(${page-1})">上一页</button><button ${page>=pages?'disabled':''} onclick="loadRecords(${page+1})">下一页</button>`;
}catch(e){toast(e.message,true);}
}
window.loadRecords=loadRecords; if($('#searchBtn'))$('#searchBtn').onclick=()=>loadRecords(1);
const recordForm=$('#recordForm');
let customFieldDefinitions=[];
async function loadCustomFields(values={},disabled=false){if(!recordForm)return;try{customFieldDefinitions=await DynamicFields.load();DynamicFields.render($('#customFieldsGrid'),customFieldDefinitions,values,disabled);}catch(error){toast(error.message,true);}}
function resetRecordForm(){if(!recordForm)return;recordForm.reset();$('#recordId').value='';$('#entryTitle').textContent='手动录入实验数据';$('#saveRecordBtn').textContent='保存实验数据';recordForm.querySelectorAll('input,select,textarea').forEach(x=>x.disabled=false);DynamicFields.render($('#customFieldsGrid'),customFieldDefinitions,{});ImageUpload.reset();ImageUpload.setReadonly(window.APP.role==='viewer');}
$('#resetRecordBtn')?.addEventListener('click',resetRecordForm);
function recordPayload(form){
const data={}; const numeric=new Set(['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','carbonized_edge_width','etching_depth','pattern_clarity_score','presentation_balance_score']);
for(const [key,value] of new FormData(form)){if(numeric.has(key))data[key]=value===''?null:Number(value);else if(['is_cut_through','is_fire_smolder'].includes(key))data[key]=value==='true';else data[key]=value;}
data.custom_fields=DynamicFields.collect(form);return {...data,...ImageUpload.collect()};
}
if(recordForm)recordForm.onsubmit=async event=>{event.preventDefault();if(ImageUpload.pending()){toast('图片还在上传,请稍候再保存',true);return;}const id=$('#recordId').value,button=$('#saveRecordBtn');button.disabled=true;button.textContent='正在保存…';try{const data=await api(id?`/api/records/${id}`:'/api/records',{method:id?'PUT':'POST',json:recordPayload(event.target)});toast(data.message);resetRecordForm();showView('records');}catch(e){toast(e.message,true);}finally{button.disabled=false;button.textContent=id?'保存修改':'保存实验数据';}};
window.editRecord=async id=>{try{const {data}=await api(`/api/records/${id}`);resetRecordForm();await loadCustomFields(data.custom_fields||{},window.APP.role==='viewer');ImageUpload.load(data.images||[]);$('#recordId').value=id;for(const [key,value] of Object.entries(data)){const input=recordForm?.elements.namedItem(key);if(input&&value!==null)input.value=typeof value==='boolean'?String(value):value;}$('#entryTitle').textContent=window.APP.role==='viewer'?'查看实验数据':'编辑实验数据';$('#saveRecordBtn').textContent='保存修改';if(window.APP.role==='viewer')recordForm.querySelectorAll('input,select,textarea,button[type="submit"]').forEach(x=>x.disabled=true);showView('entry');}catch(e){toast(e.message,true);}};
window.deleteRecord=async(id,no)=>{if(!confirm(`确定删除实验记录 ${no} 吗?此操作会写入审计日志。`))return;try{const d=await api(`/api/records/${id}`,{method:'DELETE'});toast(d.message);loadRecords(recordPage);}catch(e){toast(e.message,true);}};
const fieldTypeLabels={text:'单行文本',long_text:'多行文本',number:'数字',integer:'整数',date:'日期',boolean:'是 / 否',select:'下拉选项'};
let fieldDefinitionCache=new Map();
async function loadFieldDefinitions(){const body=$('#fieldRows');if(!body)return;body.innerHTML='<tr><td colspan="8" class="loading">正在加载…</td></tr>';try{const {data}=await api('/api/field-definitions');fieldDefinitionCache=new Map(data.map(field=>[field.id,field]));body.innerHTML=data.length?data.map(field=>`<tr><td><b>${escapeHtml(field.label)}</b>${field.unit?`<small class="cell-note">单位:${escapeHtml(field.unit)}</small>`:''}</td><td>${fieldTypeLabels[field.input_type]}</td><td><span class="tag ${field.is_required?'':'off'}">${field.is_required?'必填':'可选'}</span></td><td>${field.is_public?'公开':'内部'}</td><td><span class="tag ${field.is_active?'':'off'}">${field.is_active?'启用':'停用'}</span></td><td>${field.usage_count}</td><td>${field.sort_order}</td><td><button class="table-action" onclick="editFieldDefinitionById(${field.id})">配置</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">尚未添加扩展字段</td></tr>';}catch(error){toast(error.message,true);}}
function fieldDefinitionModal(field=null){const editing=Boolean(field);openModal(`<p class="eyebrow">DATA FIELD SCHEMA</p><h2>${editing?'修改数据字段':'添加数据字段'}</h2>${editing&&field.usage_count?`<p class="schema-warning">该字段已有 <b>${field.usage_count}</b> 条数据。修改不会删除或改写原值,无法适配新类型的值会按历史格式显示。</p>`:'<p class="muted">字段添加后会自动出现在管理录入和 Key 录入面板。</p>'}<form id="fieldDefinitionForm"><label>字段名称<input name="label" maxlength="100" value="${escapeHtml(field?.label||'')}" required></label><label>填写形式<select name="input_type"><option value="text">单行文本</option><option value="long_text">多行文本</option><option value="number">数字</option><option value="integer">整数</option><option value="date">日期</option><option value="boolean">是 / 否</option><option value="select">下拉选项</option></select></label><label>单位(可留空)<input name="unit" maxlength="50" value="${escapeHtml(field?.unit||'')}" placeholder="例如 MPa、℃"></label><label>排序值<input type="number" name="sort_order" min="0" max="10000" value="${field?.sort_order??0}" required></label><label id="fieldOptionsLabel" hidden>下拉选项(每行一个)<textarea name="options" rows="5">${escapeHtml((field?.options||[]).join('\n'))}</textarea></label><div class="check-grid"><label><input type="checkbox" name="is_required" ${field?.is_required?'checked':''}> 必填字段</label><label><input type="checkbox" name="is_public" ${field?.is_public===false?'':'checked'}> 公开看板显示</label><label><input type="checkbox" name="is_active" ${field?.is_active===false?'':'checked'}> 启用字段</label></div><button class="primary">保存字段设置</button></form>`);const form=$('#fieldDefinitionForm'),elements=form.elements;elements.input_type.value=field?.input_type||'text';const toggleOptions=()=>$('#fieldOptionsLabel').hidden=elements.input_type.value!=='select';elements.input_type.onchange=toggleOptions;toggleOptions();form.onsubmit=async event=>{event.preventDefault();const payload={label:elements.label.value,input_type:elements.input_type.value,unit:elements.unit.value||null,sort_order:Number(elements.sort_order.value),options:elements.options.value.split('\n').map(x=>x.trim()).filter(Boolean),is_required:elements.is_required.checked,is_public:elements.is_public.checked,is_active:elements.is_active.checked};if(editing&&field.usage_count&&payload.input_type!==field.input_type&&!confirm(`字段类型将从“${fieldTypeLabels[field.input_type]}”改为“${fieldTypeLabels[payload.input_type]}”。${field.usage_count} 条历史值会保留,确定继续吗?`))return;try{const result=await api(editing?`/api/field-definitions/${field.id}`:'/api/field-definitions',{method:editing?'PUT':'POST',json:payload});toast(result.message);closeModal();loadFieldDefinitions();loadCustomFields();}catch(error){toast(error.message,true);}};}
$('#newFieldBtn')?.addEventListener('click',()=>fieldDefinitionModal());window.editFieldDefinitionById=id=>fieldDefinitionModal(fieldDefinitionCache.get(id));
function openModal(html){$('#modalContent').innerHTML=html;$('#modal').hidden=false;}function closeModal(){$('#modal').hidden=true;}$$('[data-close],.modal-backdrop').forEach(x=>x.onclick=closeModal);
function passwordModal(){openModal(`<p class="eyebrow">ACCOUNT SECURITY</p><h2>修改登录密码</h2><p class="muted">新密码至少 10 位,且同时包含字母和数字。</p><form id="passwordForm"><label>当前密码<input type="password" name="old" required></label><label>新密码<input type="password" name="new" minlength="10" required></label><label>确认新密码<input type="password" name="confirm" minlength="10" required></label><button class="primary">更新密码</button></form>`);$('#passwordForm').onsubmit=async e=>{e.preventDefault();const f=e.target;if(f.new.value!==f.confirm.value){toast('两次输入的新密码不一致',true);return;}try{const d=await api('/api/me/password',{method:'PUT',json:{old_password:f.old.value,new_password:f.new.value}});toast(d.message);closeModal();$('.notice')?.remove();}catch(err){toast(err.message,true);}};}
$('#passwordBtn').onclick=passwordModal;$('#noticePasswordBtn')?.addEventListener('click',passwordModal);
function entryKeyModal(){const configured=window.APP.entryKeyConfigured;openModal(`<p class="eyebrow">QUICK ENTRY ACCESS</p><h2>四位录入 Key</h2><p class="muted">当前状态:${configured?'已启用':'未设置'}。Key 全局唯一,修改后旧的录入授权立即失效。</p><form id="entryKeyForm"><label>四位数字 Key<input name="key" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" required></label><label>再次输入 Key<input name="confirm_key" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" required></label><label>当前登录密码<input type="password" name="password" autocomplete="current-password" required></label><button class="primary">${configured?'更换 Key':'启用 Key'}</button>${configured?'<button class="danger-link" type="button" id="removeEntryKeyBtn">停用录入 Key</button>':''}</form>`);const form=$('#entryKeyForm');form.querySelectorAll('input[inputmode="numeric"]').forEach(input=>input.oninput=()=>input.value=input.value.replace(/\D/g,'').slice(0,4));form.onsubmit=async event=>{event.preventDefault();if(form.key.value!==form.confirm_key.value){toast('两次输入的 Key 不一致',true);return;}try{const result=await api('/api/me/entry-key',{method:'PUT',json:{key:form.key.value,password:form.password.value}});window.APP.entryKeyConfigured=true;toast(result.message);closeModal();}catch(error){toast(error.message,true);}};$('#removeEntryKeyBtn')?.addEventListener('click',async()=>{if(!form.password.value){toast('请先输入当前登录密码',true);return;}if(!confirm('确定停用录入 Key 吗?现有录入会话将立即失效。'))return;try{const result=await api('/api/me/entry-key',{method:'DELETE',json:{password:form.password.value}});window.APP.entryKeyConfigured=false;toast(result.message);closeModal();}catch(error){toast(error.message,true);}});}
if($('#entryKeyBtn'))$('#entryKeyBtn').onclick=entryKeyModal;
$('#logoutBtn').onclick=async()=>{try{await api('/api/auth/logout',{method:'POST'});}finally{location.href='/login';}};
let userCache=new Map();async function loadUsers(){const body=$('#userRows');if(!body)return;try{const {data}=await api('/api/users');userCache=new Map(data.map(user=>[user.id,user]));body.innerHTML=data.map(x=>`<tr><td><b>${escapeHtml(x.display_name)}</b></td><td>${escapeHtml(x.username)}</td><td>${({admin:'管理员',uploader:'录入员',viewer:'只读'})[x.role]}</td><td><span class="tag ${x.is_active?'':'off'}">${x.is_active?'启用':'停用'}</span></td><td>${formatDate(x.created_at)}</td><td><button class="table-action" onclick="editUserById(${x.id})">管理</button></td></tr>`).join('');}catch(e){toast(e.message,true);}}
$('#newUserBtn')?.addEventListener('click',()=>{openModal(`<p class="eyebrow">ACCESS CONTROL</p><h2>新建用户</h2><form id="newUserForm"><label>用户名<input name="username" required></label><label>显示名称<input name="display_name" required></label><label>初始密码<input type="password" name="password" minlength="10" required></label><label>角色<select name="role"><option value="uploader">数据录入员</option><option value="viewer">只读访客</option><option value="admin">系统管理员</option></select></label><button class="primary">创建账号</button></form>`);$('#newUserForm').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);try{const d=await api('/api/users',{method:'POST',json:Object.fromEntries(f)});toast(d.message);closeModal();loadUsers();}catch(err){toast(err.message,true);}};});
window.editUserById=id=>{const x=userCache.get(id);openModal(`<p class="eyebrow">USER MANAGEMENT</p><h2>管理 ${escapeHtml(x.display_name)}</h2><form id="editUserForm"><label>角色<select name="role"><option value="uploader" ${x.role==='uploader'?'selected':''}>数据录入员</option><option value="viewer" ${x.role==='viewer'?'selected':''}>只读访客</option><option value="admin" ${x.role==='admin'?'selected':''}>系统管理员</option></select></label><label>账号状态<select name="is_active"><option value="true" ${x.is_active?'selected':''}>启用</option><option value="false" ${!x.is_active?'selected':''}>停用</option></select></label><label>重置密码(不修改请留空)<input type="password" name="new_password" minlength="10"></label><button class="primary">保存设置</button></form>`);$('#editUserForm').onsubmit=async e=>{e.preventDefault();const f=Object.fromEntries(new FormData(e.target));f.is_active=f.is_active==='true';try{const d=await api(`/api/users/${x.id}`,{method:'PATCH',json:f});toast(d.message);closeModal();loadUsers();}catch(err){toast(err.message,true);}};};
async function loadBackups(){const body=$('#backupRows');if(!body)return;try{const {data}=await api('/api/backups');body.innerHTML=data.length?data.map(x=>`<tr><td><b>${escapeHtml(x.filename)}</b></td><td>${x.database_type}</td><td>${formatBytes(x.size_bytes)}</td><td>${escapeHtml(x.creator)}</td><td>${formatDate(x.created_at)}</td><td><span class="hash" title="${x.sha256}">${x.sha256}</span></td><td><a class="table-action button-link" href="/api/backups/${x.id}/download">下载</a></td></tr>`).join(''):'<tr><td colspan="7" class="empty">暂无备份</td></tr>';}catch(e){toast(e.message,true);}}
$('#backupBtn')?.addEventListener('click',async e=>{e.target.disabled=true;try{const d=await api('/api/backups',{method:'POST'});toast(d.message);loadBackups();}catch(err){toast(err.message,true);}finally{e.target.disabled=false;}});
async function loadAudits(){const body=$('#auditRows');if(!body)return;try{const {data}=await api('/api/audits?page_size=100');body.innerHTML=data.length?data.map(x=>`<tr><td>${formatDate(x.created_at)}</td><td>${escapeHtml(x.user)}</td><td><b>${escapeHtml(x.action)}</b></td><td>${escapeHtml(x.resource_type)} ${escapeHtml(x.resource_id||'')}</td><td>${escapeHtml(x.ip_address||'—')}</td><td><span class="hash" title="${escapeHtml(x.detail||'')}">${escapeHtml(x.detail||'—')}</span></td></tr>`).join(''):'<tr><td colspan="6" class="empty">暂无日志</td></tr>';}catch(e){toast(e.message,true);}}
if(recordForm)ImageUpload.mount($('#recordImages'),{endpoint:'/api/uploads/images',getCsrf:()=>csrf,readonly:window.APP.role==='viewer',onError:message=>toast(message,true)});
loadCustomFields();showView(location.hash.slice(1)||'overview');
+20
View File
@@ -0,0 +1,20 @@
(function(){
const escapeHtml=value=>{const node=document.createElement('div');node.textContent=value??'';return node.innerHTML;};
async function load(){const response=await fetch('/api/form-fields');const data=await response.json();if(!response.ok)throw new Error(data.message||'扩展字段加载失败');return data.data;}
function inputHtml(definition,raw,disabled,preserveIfEmpty=false){
const name=escapeHtml(definition.field_key),value=raw??'',required=definition.is_required&&!preserveIfEmpty?'required':'',off=disabled?'disabled':'',preserve=preserveIfEmpty?'data-preserve-if-empty="true"':'',hint=preserveIfEmpty?`placeholder="原值:${escapeHtml(raw)}(输入新值可替换)"`:'';
if(definition.input_type==='long_text')return `<textarea data-custom-key="${name}" rows="4" ${required} ${off} ${preserve} ${hint}>${escapeHtml(value)}</textarea>`;
if(definition.input_type==='boolean')return `<select data-custom-key="${name}" ${required} ${off} ${preserve}><option value="">请选择</option><option value="true" ${String(value)==='true'?'selected':''}>是</option><option value="false" ${String(value)==='false'?'selected':''}>否</option></select>`;
if(definition.input_type==='select')return `<select data-custom-key="${name}" ${required} ${off} ${preserve}><option value="">请选择</option>${definition.options.map(option=>`<option value="${escapeHtml(option)}" ${String(value)===option?'selected':''}>${escapeHtml(option)}</option>`).join('')}</select>`;
const types={number:'number',integer:'number',date:'date',text:'text'};const step=definition.input_type==='number'?'step="any"':definition.input_type==='integer'?'step="1"':'';
return `<input type="${types[definition.input_type]||'text'}" data-custom-key="${name}" value="${escapeHtml(value)}" ${step} ${required} ${off} ${preserve} ${hint}>`;
}
function render(container,definitions,values={},disabled=false){
const section=container.closest('.custom-fields-section'),activeKeys=new Set(definitions.map(item=>item.field_key));
const active=definitions.map(definition=>{const saved=values[definition.field_key],incompatible=saved&&typeof saved==='object'&&saved.compatible===false,raw=saved&&typeof saved==='object'?saved.raw_value:saved,inputValue=incompatible?'':raw,warning=incompatible?`<span class="field-format-warning">历史值“${escapeHtml(raw)}”与当前类型不兼容;留空会保留原值,填写新值后替换</span>`:'';return `<label>${escapeHtml(definition.label)}${definition.unit?` (${escapeHtml(definition.unit)})`:''} ${definition.is_required?'<em>*</em>':'<small>可选</small>'}${inputHtml(definition,inputValue,disabled,incompatible)}${warning}</label>`;}).join('');
const historical=Object.entries(values).filter(([key,value])=>!activeKeys.has(key)&&value&&typeof value==='object').map(([,value])=>`<div class="historical-custom-value"><small>已停用字段 · ${escapeHtml(value.label)}</small><b>${escapeHtml(value.display_value||value.raw_value||'未填写')}</b>${value.compatible===false?'<span>历史格式</span>':''}</div>`).join('');
container.innerHTML=active+historical;section.hidden=!definitions.length&&!historical;
}
function collect(form){const result={};form.querySelectorAll('[data-custom-key]').forEach(input=>{if(input.value===''&&input.dataset.preserveIfEmpty==='true')return;result[input.dataset.customKey]=input.value===''?null:input.value;});return result;}
window.DynamicFields={load,render,collect};
})();
+62
View File
@@ -0,0 +1,62 @@
const $ = (selector, root=document) => root.querySelector(selector);
const $$ = (selector, root=document) => [...root.querySelectorAll(selector)];
let csrf=document.body.dataset.csrf;
let pendingPayload=null;
function escapeHtml(value){return String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function toast(message,error=false){const node=document.createElement('div');node.className=`toast ${error?'error':''}`;node.innerHTML=`<b>${error?'操作未完成':'保存成功'}</b><span>${escapeHtml(message)}</span>`;$('#toasts').append(node);setTimeout(()=>node.remove(),4500);}
class EntryExpired extends Error{constructor(){super('录入授权已过期');}}
async function api(url,options={}){
options.headers={...(options.headers||{}),'X-CSRF-Token':csrf};
if(options.json){options.body=JSON.stringify(options.json);options.headers['Content-Type']='application/json';delete options.json;}
const response=await fetch(url,options);
const data=await response.json().catch(()=>({})); // 反代返回 HTML 的 413/502 时不能让 JSON 解析吞掉真实状态码
if(response.status===401&&url!=='/api/entry/auth')throw new EntryExpired();
if(!response.ok)throw new Error(data.message||`请求失败(${response.status}`);
return data;
}
// 录入授权只有 30 分钟,而这张表有 30 多个必填项和最多 6 张照片,超时是常态。
// 过期后绝不能刷新页面:那会把填了半天的表单和已上传的图一起清空,只就地重新验证 Key。
async function reauthorize(){
const key=prompt('录入授权已过期(30 分钟)。请重新输入四位录入 Key,已填写的内容和已上传的图片都会保留:');
if(!key)return false;
try{
await api('/api/entry/auth',{method:'POST',json:{key:key.trim()}});
const session=await (await fetch('/api/entry/session')).json();
if(!session.authorized||!session.csrf_token)return false;
csrf=session.csrf_token; // cookie 换了,CSRF 是它的 HMAC,必须一起换掉
return true;
}catch(exception){toast(exception.message,true);return false;}
}
$('#keyForm')?.addEventListener('submit',async event=>{event.preventDefault();const button=event.target.querySelector('button'),error=$('#keyError');button.disabled=true;error.hidden=true;try{await api('/api/entry/auth',{method:'POST',json:{key:$('#entryKey').value}});location.reload();}catch(exception){error.textContent=exception.message;error.hidden=false;$('#entryKey').select();}finally{button.disabled=false;}});
$('#entryKey')?.addEventListener('input',event=>{event.target.value=event.target.value.replace(/\D/g,'').slice(0,4)});
const numeric=new Set(['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','carbonized_edge_width','etching_depth','pattern_clarity_score','presentation_balance_score']);
function formPayload(form){const data={};for(const [key,value] of new FormData(form)){if(numeric.has(key))data[key]=value===''?null:Number(value);else if(['is_cut_through','is_fire_smolder'].includes(key))data[key]=value==='true';else data[key]=value;}data.custom_fields=DynamicFields.collect(form);Object.assign(data,ImageUpload.collect());return data;}
$('#publicRecordForm')?.addEventListener('submit',event=>{event.preventDefault();if(ImageUpload.pending()){toast('图片还在上传,请稍候再提交',true);return;}pendingPayload=formPayload(event.target);$('#confirmModal').hidden=false;setTimeout(()=>$('#confirmForm input').focus(),50);});
$('#confirmForm')?.addEventListener('submit',async event=>{
event.preventDefault();
const button=event.target.querySelector('button'),username=event.target.username.value.trim();
button.disabled=true;
const submit=()=>api('/api/entry/records',{method:'POST',json:{...pendingPayload,confirm_username:username}});
try{
let result;
try{result=await submit();}
catch(exception){
if(!(exception instanceof EntryExpired))throw exception;
if(!await reauthorize())throw new Error('未重新验证 Key,数据尚未保存;表单内容仍然保留');
result=await submit();
}
toast(`${result.data.experiment_id} 已写入数据库`);
$('#confirmModal').hidden=true;event.target.reset();$('#publicRecordForm').reset();ImageUpload.reset();
pendingPayload=null;window.scrollTo({top:0,behavior:'smooth'});
}catch(exception){toast(exception.message,true);}
finally{button.disabled=false;}
});
$$('[data-close]').forEach(node=>node.onclick=()=>{$('#confirmModal').hidden=true;});
$('#entryLogoutBtn')?.addEventListener('click',async()=>{try{await api('/api/entry/logout',{method:'POST'});}catch{}finally{location.reload();}});
if($('#publicRecordForm')){
DynamicFields.load().then(definitions=>DynamicFields.render($('#customFieldsGrid'),definitions)).catch(error=>toast(error.message,true));
ImageUpload.mount($('#recordImages'),{endpoint:'/api/entry/uploads/images',getCsrf:()=>csrf,onError:message=>toast(message,true),onUnauthorized:reauthorize});
}
+279
View File
@@ -0,0 +1,279 @@
(function(){
const MAX_IMAGES=6,MAX_EDGE=1600,THUMB_EDGE=320,QUALITY=.82,THUMB_QUALITY=.72,MAX_UPLOAD_BYTES=8*1024*1024,PNG_PASSTHROUGH_BYTES=1.5*1024*1024,DECODE_TIMEOUT=20000,UPLOAD_TIMEOUT=120000,IOS_CANVAS_LIMIT=16777216;
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const GENERIC_NAMES=new Set(['image.jpg','image.jpeg','image.png','image.heic','photo.jpg','photo.jpeg','未命名.jpg']);
// 桌面浏览器会静默忽略 capture,摄像头按钮在那里只会打开同样的文件框,所以只在触摸设备渲染。
const CAN_CAPTURE=matchMedia('(pointer: coarse)').matches&&navigator.maxTouchPoints>0;
let config={endpoint:'',getCsrf:()=>'',onChange:null},container=null,items=[],readonly=false,serial=0,queue=Promise.resolve(),loadedExisting=false;
const releaseCanvas=canvas=>{canvas.width=canvas.height=0;};
const toJpeg=(canvas,quality)=>new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',quality));
// 只用 load/error 事件:img.decode() 在部分 Chromium 上对未插入文档的图片会永不 resolve,卡死上传队列。
function loadElement(url){
return new Promise((resolve,reject)=>{
const image=new Image();image.decoding='async';
const timer=setTimeout(()=>reject(new Error('decode-timeout')),DECODE_TIMEOUT);
image.onload=()=>{clearTimeout(timer);image.naturalWidth&&image.naturalHeight?resolve(image):reject(new Error('decode'));};
image.onerror=()=>{clearTimeout(timer);reject(new Error('decode'));};
image.src=url;
});
}
async function decodeImage(file){
const url=URL.createObjectURL(file);
try{
// <img> 是主路径:现代浏览器会自动按 EXIF 摆正,iOS Safari 还能直接解 HEIC。
const image=await loadElement(url);
return {source:image,width:image.naturalWidth,height:image.naturalHeight,release(){URL.revokeObjectURL(url);}};
}catch(error){
URL.revokeObjectURL(url);
if(typeof createImageBitmap!=='function')throw error;
const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});
return {source:bitmap,width:bitmap.width,height:bitmap.height,release(){bitmap.close?.();}};
}
}
function drawScaled(source,sourceWidth,sourceHeight,maxEdge){
const scale=Math.min(1,maxEdge/Math.max(sourceWidth,sourceHeight));
const width=Math.max(1,Math.round(sourceWidth*scale)),height=Math.max(1,Math.round(sourceHeight*scale));
let current=source,currentWidth=sourceWidth,currentHeight=sourceHeight;
while(currentWidth>width*2&&currentHeight>height*2){ // 大比例一次缩到底会产生锯齿,逐级折半更稳
currentWidth=Math.max(width,Math.round(currentWidth/2));currentHeight=Math.max(height,Math.round(currentHeight/2));
const step=document.createElement('canvas');step.width=currentWidth;step.height=currentHeight;
const stepContext=step.getContext('2d');stepContext.imageSmoothingQuality='high';
stepContext.drawImage(current,0,0,currentWidth,currentHeight);
if(current!==source)releaseCanvas(current);
current=step;
}
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
const context=canvas.getContext('2d');context.imageSmoothingQuality='high';
context.fillStyle='#ffffff';context.fillRect(0,0,width,height); // PNG 透明区直接转 JPEG 会变黑
context.drawImage(current,0,0,width,height);
if(current!==source)releaseCanvas(current);
return canvas;
}
function looksBlank(canvas){
try{
const probe=document.createElement('canvas');probe.width=probe.height=8;
const context=probe.getContext('2d',{willReadFrequently:true});
context.drawImage(canvas,0,0,8,8);
const {data}=context.getImageData(0,0,8,8);
let sum=0,squares=0;
for(let index=0;index<data.length;index+=4){
const luma=(data[index]*299+data[index+1]*587+data[index+2]*114)/1000;
sum+=luma;squares+=luma*luma;
}
releaseCanvas(probe);
return squares/64-(sum/64)**2<0.5; // 方差趋近 0 说明整张是纯色,正常照片不会这样
}catch{return false;}
}
async function prepare(file){
if(!file.type.startsWith('image/')&&!/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||''))throw new Error('只能上传图片文件');
let decoded;
try{decoded=await decodeImage(file);}
catch(error){
const isApple=/iP(hone|ad|od)/.test(navigator.userAgent);
throw new Error(/heic|heif/i.test(`${file.type} ${file.name}`)
? (isApple?'这张是 HEIC 高效格式且本机无法转换,请到「设置 → 相机 → 格式」选「兼容性最佳」后重拍':'这张是 HEIC/HEIF 格式,当前浏览器无法解码,请改用 JPEG 拍摄或先导出为 JPEG')
: '图片无法读取,请换一张或重新拍摄');
}
try{
// PNG 小图原样上传,线稿和截图重编码成 JPEG 会有振铃;PNG 没有 EXIF 方向问题,尺寸依然可信。
if(file.type==='image/png'&&file.size<=PNG_PASSTHROUGH_BYTES&&Math.max(decoded.width,decoded.height)<=MAX_EDGE){
const thumbCanvas=drawScaled(decoded.source,decoded.width,decoded.height,THUMB_EDGE);
const thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);
return {blob:file,name:file.name||'image.png',thumb};
}
const canvas=drawScaled(decoded.source,decoded.width,decoded.height,MAX_EDGE);
// 超过 iOS 画布像素上限的源图,Safari 不报错、直接给一张空白画布,会静默存进一张纯白"成品图"。
if(decoded.width*decoded.height>IOS_CANVAS_LIMIT&&looksBlank(canvas)){
releaseCanvas(canvas);
throw new Error('这张照片分辨率过高,本机无法压缩,请把相机分辨率调低后重拍');
}
const blob=await toJpeg(canvas,QUALITY);
if(!blob){releaseCanvas(canvas);throw new Error('图片压缩失败,请换一张试试');}
if(blob.size>MAX_UPLOAD_BYTES){releaseCanvas(canvas);throw new Error('图片压缩后仍然过大,请换一张');}
let thumb=null;
// 从已经缩到 1600 的画布再缩,而不是回到原图重跑一遍——那是整个流程里最吃内存的一段。
try{const thumbCanvas=drawScaled(canvas,canvas.width,canvas.height,THUMB_EDGE);thumb=await toJpeg(thumbCanvas,THUMB_QUALITY);releaseCanvas(thumbCanvas);}catch{thumb=null;}
releaseCanvas(canvas);
return {blob,name:(file.name||'photo.jpg').replace(/\.(heic|heif|png|webp)$/i,'.jpg'),thumb};
}finally{decoded.release();}
}
function send(prepared,onProgress,item){
return new Promise((resolve,reject)=>{
const form=new FormData();
form.append('file',prepared.blob,prepared.name);
if(prepared.thumb)form.append('thumbnail',prepared.thumb,'thumb.jpg');
const request=new XMLHttpRequest();
request.open('POST',config.endpoint);
// 必须显式设置:XHR 默认永不超时,移动网络下的半开连接会让这张图永远停在"上传中",从而一直挡住提交。
request.timeout=UPLOAD_TIMEOUT;
if(item)item.xhr=request;
request.setRequestHeader('X-CSRF-Token',config.getCsrf()||'');
request.upload.onprogress=event=>{if(event.lengthComputable)onProgress(event.loaded/event.total);};
request.onload=()=>{
let data=null;try{data=JSON.parse(request.responseText);}catch{}
if(request.status>=200&&request.status<300&&data?.ok)resolve(data.data);
else if(request.status===401)reject(Object.assign(new Error('录入授权已过期,请重新验证后再上传'),{unauthorized:true}));
else if(request.status===413)reject(new Error(data?.message||'图片超出大小限制'));
else reject(new Error(data?.message||`上传失败(${request.status}`));
};
request.onerror=()=>reject(new Error('网络中断,图片未上传'));
request.ontimeout=()=>reject(new Error('上传超时,请重试'));
request.send(form);
});
}
function discard(token){
// 尽力而为:撤回失败也不打扰用户,服务端 2 小时后会自动清理。
fetch(`${config.endpoint}/${encodeURIComponent(token)}`,{method:'DELETE',headers:{'X-CSRF-Token':config.getCsrf()||''}}).catch(()=>{});
}
function render(){
if(!container)return;
const remaining=MAX_IMAGES-items.length;
const cards=items.map(item=>{
const busy=item.status==='uploading',failed=item.status==='error';
const preview=item.thumb_url||item.url||item.localUrl||'';
const label=escapeHtml(item.original_name||'待上传图片');
// 已上传的用 <button> 包起来,键盘和读屏才进得去;上传中/失败的还没有大图可看,保持纯展示。
const preview_markup=preview
? (item.status==='ready'
? `<button type="button" class="image-card-open" data-open="${item.key}" aria-label="放大查看 ${label}"><img src="${escapeHtml(preview)}" alt="${label}"></button>`
: `<img src="${escapeHtml(preview)}" alt="${label}">`)
: '<span class="image-card-blank">…</span>';
return `<figure class="image-card ${item.status}" data-key="${item.key}">
${preview_markup}
${busy?`<span class="image-card-progress"><i style="width:${Math.round((item.progress||0)*100)}%"></i></span>`:''}
${failed?`<span class="image-card-error" title="${escapeHtml(item.message||'')}">${escapeHtml(item.message||'上传失败')}</span>`:''}
<figcaption>${escapeHtml(item.original_name||item.name||'')}</figcaption>
${readonly?'':`<button type="button" class="image-card-remove" data-remove="${item.key}" aria-label="移除这张图片">×</button>`}
${failed&&!readonly?`<button type="button" class="image-card-retry" data-retry="${item.key}">重试</button>`:''}
</figure>`;
}).join('');
const dropzone=readonly?'':`<div class="image-drop ${items.length?'compact':''}" data-drop>
<span class="image-drop-icon">▣</span>
<p><b>添加成品图</b><small>JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩</small></p>
<span class="image-drop-actions">
<button type="button" class="secondary" data-pick ${remaining<=0?'disabled':''}>选择图片</button>
${CAN_CAPTURE?`<button type="button" class="secondary" data-capture ${remaining<=0?'disabled':''}>拍照</button>`:''}
</span>
<small class="image-drop-hint">${remaining>0?`还可添加 ${remaining}`:'已达数量上限'}</small>
</div>`;
container.innerHTML=`${dropzone}<div class="image-card-grid">${cards||(readonly?'<div class="gallery-empty">暂无图片</div>':'')}</div>`;
wire();
config.onChange?.(items.slice());
}
function wire(){
const drop=container.querySelector('[data-drop]');
if(drop){
container.querySelector('[data-pick]')?.addEventListener('click',()=>openPicker(false));
container.querySelector('[data-capture]')?.addEventListener('click',()=>openPicker(true));
['dragenter','dragover'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.add('drag');}));
['dragleave','drop'].forEach(name=>drop.addEventListener(name,event=>{event.preventDefault();drop.classList.remove('drag');}));
drop.addEventListener('drop',event=>accept([...(event.dataTransfer?.files||[])]));
}
container.querySelectorAll('[data-remove]').forEach(button=>button.onclick=()=>{
const removed=items.find(item=>item.key===button.dataset.remove);
removed?.xhr?.abort(); // 传到一半就移除的,别让请求继续跑
if(removed?.localUrl)URL.revokeObjectURL(removed.localUrl);
if(removed?.token)discard(removed.token); // 传完又删掉的,通知服务端撤回,及时腾出待提交名额
items=items.filter(item=>item.key!==button.dataset.remove);render();
});
container.querySelectorAll('[data-retry]').forEach(button=>button.onclick=()=>{
const item=items.find(entry=>entry.key===button.dataset.retry);
if(item?.file){items=items.filter(entry=>entry.key!==item.key);render();accept([item.file]);}
});
container.querySelectorAll('[data-open]').forEach(button=>button.onclick=()=>{
const ready=items.filter(item=>item.status==='ready');
window.ImageViewer?.open(ready,ready.findIndex(item=>item.key===button.dataset.open));
});
}
function openPicker(capture){
const input=document.createElement('input');
input.type='file';input.accept='image/*';
if(capture)input.setAttribute('capture','environment');else input.multiple=true;
input.style.display='none';
input.onchange=()=>{accept([...input.files]);input.remove();};
document.body.append(input);input.click();
}
function accept(files){
if(readonly)return;
const usable=files.filter(file=>file&&(file.type.startsWith('image/')||/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name||'')));
if(!usable.length)return;
const room=MAX_IMAGES-items.length;
if(room<=0){config.onError?.(`每条记录最多 ${MAX_IMAGES} 张图片`);return;}
if(usable.length>room)config.onError?.(`最多还能添加 ${room} 张,多余的已忽略`);
usable.slice(0,room).forEach(file=>{
const item={key:`u${++serial}`,status:'uploading',progress:0,name:file.name||'照片',original_name:file.name||'照片',file};
items.push(item);
// 串行处理:多张大图并行解码会在手机上直接把页面挤爆内存
queue=queue.then(()=>process(item)).catch(()=>{});
});
render();
}
async function process(item){
if(!items.includes(item))return;
try{
const prepared=await prepare(item.file);
if(!items.includes(item))return;
item.localUrl=URL.createObjectURL(prepared.thumb||prepared.blob);
render();
const report=fraction=>{item.progress=fraction;const bar=container?.querySelector(`[data-key="${item.key}"] .image-card-progress i`);if(bar)bar.style.width=`${Math.round(fraction*100)}%`;};
let data;
try{data=await send(prepared,report,item);}
catch(error){
// 录入授权过期时就地重新验证再传一次,别让用户为此刷新页面丢掉整张表单。
if(!error.unauthorized||!config.onUnauthorized||!await config.onUnauthorized())throw error;
data=await send(prepared,report,item);
}
if(!items.includes(item)){return;}
Object.assign(item,data,{status:'ready',file:null});
if(item.localUrl){URL.revokeObjectURL(item.localUrl);item.localUrl=null;}
autoFillFilename(data);
}catch(error){
if(!items.includes(item))return;
item.status='error';item.message=error.message;
config.onError?.(error.message);
}finally{render();}
}
function autoFillFilename(data){
const field=document.querySelector('input[name="finished_image_filename"]');
if(!field||field.value.trim())return;
const original=(data.original_name||'').trim();
if(original&&!GENERIC_NAMES.has(original.toLowerCase())){field.value=original.slice(0,255);return;}
const experiment=document.querySelector('input[name="experiment_id"]')?.value.trim();
if(experiment)field.value=`${experiment}.jpg`.slice(0,255);
}
function mount(root,options={}){
container=root;config={...config,...options};items=[];readonly=Boolean(options.readonly);loadedExisting=false;
if(container)render();
}
function load(images=[]){
items=images.map((image,position)=>({...image,key:`s${image.id??position}`,status:'ready'}));
loadedExisting=true;render();
}
function reset(){items.forEach(item=>{if(item.localUrl)URL.revokeObjectURL(item.localUrl);item.xhr?.abort();});items=[];loadedExisting=false;render();}
function setReadonly(value){readonly=Boolean(value);render();}
function collect(){
const ready=items.filter(item=>item.status==='ready');
// 没载入过既有图片就回 null,让后端保持原样,避免新建表单的空列表被当成"删光全部图片"。
return {image_ids:loadedExisting?ready.filter(item=>item.id&&!item.token).map(item=>item.id):null,image_tokens:ready.filter(item=>item.token).map(item=>item.token)};
}
function pending(){return items.some(item=>item.status==='uploading');}
window.ImageUpload={mount,load,reset,setReadonly,collect,pending,MAX_IMAGES};
})();
+80
View File
@@ -0,0 +1,80 @@
(function(){
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let overlay=null,items=[],index=0,restoreFocus=null,touchStartX=null;
function build(){
overlay=document.createElement('div');overlay.className='image-viewer';overlay.hidden=true;
overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true');overlay.setAttribute('aria-label','图片预览');
overlay.innerHTML='<div class="viewer-backdrop" data-viewer-close></div><figure class="viewer-stage"><img alt=""><p class="viewer-state" hidden></p><figcaption></figcaption></figure><button class="viewer-close" data-viewer-close aria-label="关闭预览">×</button><button class="viewer-nav prev" aria-label="上一张"></button><button class="viewer-nav next" aria-label="下一张"></button><span class="viewer-counter"></span>';
document.body.append(overlay);
overlay.querySelectorAll('[data-viewer-close]').forEach(node=>node.onclick=close);
overlay.querySelector('.viewer-nav.prev').onclick=()=>step(-1);
overlay.querySelector('.viewer-nav.next').onclick=()=>step(1);
overlay.addEventListener('touchstart',event=>{touchStartX=event.changedTouches[0].clientX;},{passive:true});
overlay.addEventListener('touchend',event=>{if(touchStartX===null)return;const delta=event.changedTouches[0].clientX-touchStartX;touchStartX=null;if(Math.abs(delta)>60)step(delta<0?1:-1);},{passive:true});
document.addEventListener('keydown',event=>{
if(overlay.hidden)return;
if(event.key==='Escape'){event.preventDefault();close();}
else if(event.key==='ArrowLeft')step(-1);
else if(event.key==='ArrowRight')step(1);
else if(event.key==='Tab'){
// aria-modal 要求焦点留在对话框内,否则 Tab 会走到被遮罩盖住的背景控件上。
const focusable=[...overlay.querySelectorAll('button')].filter(node=>!node.hidden&&node.offsetParent!==null);
if(!focusable.length)return;
const first=focusable[0],last=focusable[focusable.length-1];
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
else if(!overlay.contains(document.activeElement)){event.preventDefault();first.focus();}
}
});
}
function show(){
const item=items[index]||{},image=overlay.querySelector('img'),state=overlay.querySelector('.viewer-state');
image.removeAttribute('src'); // 置空字符串会让部分浏览器重新请求当前页面地址
image.alt=item.original_name?`成品图:${item.original_name}`:'实验成品图';
image.hidden=true;state.hidden=false;state.textContent='正在载入…';
image.onload=()=>{image.hidden=false;state.hidden=true;};
image.onerror=()=>{image.hidden=true;state.hidden=false;state.textContent='图片加载失败,可能已被删除';};
if(item.url)image.src=item.url;else image.onerror();
overlay.querySelector('figcaption').textContent=[item.original_name,item.width&&item.height?`${item.width}×${item.height}`:''].filter(Boolean).join(' · ');
overlay.querySelector('.viewer-counter').textContent=items.length>1?`${index+1} / ${items.length}`:'';
overlay.querySelectorAll('.viewer-nav').forEach(node=>node.hidden=items.length<2);
}
function step(direction){if(items.length<2)return;index=(index+direction+items.length)%items.length;show();}
function open(list,start=0){
if(!list?.length)return;
if(!overlay)build();
items=list;index=Math.max(0,Math.min(start,list.length-1));
restoreFocus=document.activeElement;
document.documentElement.classList.add('viewer-open');
overlay.hidden=false;show();
overlay.querySelector('.viewer-close').focus();
}
function close(){
if(!overlay||overlay.hidden)return;
overlay.hidden=true;overlay.querySelector('img').removeAttribute('src');
document.documentElement.classList.remove('viewer-open');
if(restoreFocus?.focus)restoreFocus.focus();
restoreFocus=null;
}
function galleryHtml(images,emptyText='本条记录暂未上传图片'){
if(!images?.length)return `<div class="gallery-empty">${escapeHtml(emptyText)}</div>`;
// 不用 loading="lazy":缩略图是在隐藏的详情弹窗里解析的,惰性加载会一直不触发;单张只有几 KB,最多 6 张。
return `<div class="image-gallery">${images.map((item,position)=>`<button type="button" class="gallery-thumb" data-viewer-index="${position}" aria-label="查看第 ${position+1} 张图片"><img src="${escapeHtml(item.thumb_url||item.url)}" alt="${escapeHtml(item.original_name||`成品图 ${position+1}`)}"><span class="gallery-fallback" hidden>图片加载失败</span></button>`).join('')}</div>`;
}
function wire(root,images){
if(!root||!images?.length)return;
root.querySelectorAll('[data-viewer-index]').forEach(button=>{
button.onclick=()=>open(images,Number(button.dataset.viewerIndex));
const image=button.querySelector('img');
if(image)image.onerror=()=>{image.hidden=true;button.querySelector('.gallery-fallback').hidden=false;};
});
}
function render(root,images,emptyText){if(!root)return;root.innerHTML=galleryHtml(images,emptyText);wire(root,images);}
window.ImageViewer={open,close,galleryHtml,wire,render};
})();
+4
View File
@@ -0,0 +1,4 @@
.public-body{min-height:100vh;background:#f3f6f6}.public-nav{height:70px;padding:0 max(24px,calc((100vw - 1320px)/2));background:#142c36;color:#fff;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:30}.public-brand{display:flex;align-items:center;gap:11px;color:#fff;text-decoration:none}.public-brand .brand-mark{width:34px;height:34px;border-radius:9px;font-size:15px}.public-brand>span:last-child{display:grid}.public-brand b{font-size:12px;letter-spacing:.1em}.public-brand small{font-size:9px;color:#7f999f}.public-nav nav{display:flex;align-items:stretch;height:100%}.public-nav nav a{display:flex;align-items:center;padding:0 17px;color:#9fb1b6;text-decoration:none;font-size:11px;border-bottom:2px solid transparent}.public-nav nav a:hover,.public-nav nav a.active{color:#fff;border-color:#ef6b4d}.public-main,.entry-main{width:min(1320px,calc(100% - 48px));margin:0 auto;padding:44px 0 70px}.data-heading,.entry-heading{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:28px}.data-heading h1,.entry-heading h1{font-size:31px;margin:0 0 7px}.data-heading>div>p:last-child,.entry-heading>div>p:last-child{color:#718087;font-size:12px;margin:0}.public-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.public-metrics article{background:#fff;border-radius:10px;padding:20px;display:flex;align-items:center;gap:14px;border:1px solid #e7ecee}.public-metrics article>span{width:42px;height:42px;border-radius:9px;background:#eaf2f1;color:#3f7477;display:grid;place-items:center;font-size:9px;font-weight:800}.public-metrics article:nth-child(2)>span{background:#fff0ed;color:#d35d43}.public-metrics article:nth-child(3)>span{background:#eaf4fb;color:#397ca9}.public-metrics article:nth-child(4)>span{background:#fff7e7;color:#b47b27}.public-metrics article div{display:grid}.public-metrics small{font-size:9px;color:#829095}.public-metrics strong{font-size:24px;margin-top:2px}.public-table-panel{background:#fff;border:1px solid #e5ebed;border-radius:12px;padding:24px}.public-panel-head{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:19px}.public-panel-head h2{font-size:18px;margin:0}.public-filters{display:flex;gap:8px}.public-filters input,.public-filters select{border:1px solid #dfe6e8;background:#fbfcfc;border-radius:8px;padding:9px 11px;font-size:10px;min-width:180px}.public-filters .primary{padding:9px 16px}.public-table-panel tbody tr{cursor:pointer}.public-table-panel tbody tr:hover{background:#fafcfc}.detail-card{width:min(780px,100%);max-height:86vh;overflow:auto}.detail-title{border-bottom:1px solid #e7ecee;padding-bottom:15px;margin-bottom:15px}.detail-title h2{margin:0 0 4px}.detail-title p{margin:0;color:#718087;font-size:11px}.detail-groups{display:grid;gap:18px}.detail-group h3{font-size:11px;color:#ef6b4d;text-transform:uppercase;letter-spacing:.1em;margin:0 0 9px}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:#e7ecee;border:1px solid #e7ecee}.detail-grid div{background:#fff;padding:10px;display:grid;gap:3px}.detail-grid small{font-size:8px;color:#829095}.detail-grid b{font-size:11px;font-weight:600}.key-gate{width:min(430px,calc(100% - 32px));margin:10vh auto 0;background:#fff;border:1px solid #e3e9eb;border-radius:14px;padding:38px;text-align:center;box-shadow:0 18px 50px rgba(20,44,54,.08)}.key-symbol{margin:0 auto 24px;width:64px;height:64px;border-radius:16px;background:#142c36;color:#ef8065;display:grid;place-items:center;letter-spacing:.18em;font-weight:800}.key-gate h1{font-size:23px;margin:0 0 8px}.key-gate>p:not(.eyebrow){font-size:11px;color:#718087}.key-gate form{display:grid;gap:12px;margin-top:25px}.key-gate input{width:100%;border:1px solid #dfe6e8;border-radius:10px;padding:13px;text-align:center;font-size:25px;letter-spacing:.55em;text-indent:.55em;outline:none}.key-gate input:focus{border-color:#ef6b4d;box-shadow:0 0 0 3px rgba(239,107,77,.1)}.entry-main{width:min(1120px,calc(100% - 48px))}.entry-body .form-section{border:1px solid #e5ebed;box-shadow:none}.entry-body .modal form{display:grid;gap:13px}.entry-body .modal label{display:grid;gap:7px;font-size:11px;font-weight:700}.entry-body .modal input{border:1px solid #dfe6e8;border-radius:8px;padding:10px 12px}.entry-body .form-error{margin:0}
@media(max-width:900px){.public-main,.entry-main{width:calc(100% - 32px);padding-top:28px}.public-metrics{grid-template-columns:1fr 1fr}.public-panel-head{align-items:stretch;display:grid;gap:15px}.public-filters{display:grid;grid-template-columns:1fr 1fr}.public-filters input{grid-column:1/-1}.data-heading,.entry-heading{align-items:flex-start}.detail-grid{grid-template-columns:1fr 1fr}}
@media(max-width:560px){.public-nav{padding:0 14px;height:62px}.public-brand small{display:none}.public-nav nav a{padding:0 9px;font-size:9px}.public-main,.entry-main{width:calc(100% - 24px);padding-top:22px}.data-heading{display:grid;gap:17px}.data-heading h1,.entry-heading h1{font-size:24px}.public-metrics{grid-template-columns:1fr 1fr;gap:8px}.public-metrics article{padding:13px;gap:9px}.public-metrics article>span{width:34px;height:34px}.public-metrics strong{font-size:20px}.public-table-panel{padding:15px}.public-filters{grid-template-columns:1fr}.public-filters input,.public-filters select{grid-column:auto;min-width:0}.detail-grid{grid-template-columns:1fr}.key-gate{padding:27px 20px}.entry-heading{align-items:flex-start;gap:12px}}
.history-badge{font-size:7px;color:#b76926;background:#fff7e7;border-radius:12px;padding:2px 5px;width:max-content}
+42
View File
@@ -0,0 +1,42 @@
const $ = (selector, root=document) => root.querySelector(selector);
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
const escapeHtml = value => String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let currentPage=1;
async function getJson(url) {
const response=await fetch(url); const data=await response.json();
if(!response.ok) throw new Error(data.message||'数据加载失败'); return data;
}
async function loadPublicData(page=1) {
currentPage=page; const body=$('#publicRows'); body.innerHTML='<tr><td colspan="10" class="loading">正在加载数据…</td></tr>';
const q=encodeURIComponent($('#publicSearch').value.trim()), category=encodeURIComponent($('#publicCategory').value);
try {
const result=await getJson(`/api/public/dashboard?page=${page}&page_size=20&q=${q}&category=${category}`);
$('#statTotal').textContent=result.stats.total; $('#statCategories').textContent=result.stats.category_count;
$('#statRecent').textContent=result.stats.recent_count; $('#statClarity').textContent=result.stats.average_clarity??'—';
const select=$('#publicCategory'), selected=select.value;
select.innerHTML='<option value="">全部材料分类</option>'+result.categories.map(x=>`<option value="${escapeHtml(x)}">${escapeHtml(x)}</option>`).join(''); select.value=selected;
body.innerHTML=result.data.length?result.data.map(row=>`<tr data-id="${row.id}"><td><b>${escapeHtml(row.experiment_id)}</b></td><td>${row.test_date}</td><td>${escapeHtml(row.material_category)}</td><td>${escapeHtml(row.material_name)}</td><td>${row.material_thickness} mm</td><td>${row.actual_output_power} W</td><td>${row.scanning_speed} mm/s</td><td><span class="tag ${row.is_cut_through?'':'off'}">${row.is_cut_through?'是':'否'}</span></td><td>${row.pattern_clarity_score}</td><td><button class="table-action">详情</button></td></tr>`).join(''):'<tr><td colspan="10" class="empty">未找到符合条件的数据</td></tr>';
body.querySelectorAll('tr[data-id]').forEach(row=>row.onclick=()=>showDetail(row.dataset.id));
const pages=Math.max(1,Math.ceil(result.pagination.total/result.pagination.page_size));
$('#publicPager').innerHTML=`<span>第 ${page} / ${pages} 页,共 ${result.pagination.total} 条</span><button ${page<=1?'disabled':''} data-page="${page-1}">上一页</button><button ${page>=pages?'disabled':''} data-page="${page+1}">下一页</button>`;
$('#publicPager').querySelectorAll('button:not([disabled])').forEach(button=>button.onclick=()=>loadPublicData(Number(button.dataset.page)));
} catch(error) { body.innerHTML=`<tr><td colspan="10" class="empty">${escapeHtml(error.message)}</td></tr>`; }
}
const labels={
apparent_density:'表观密度 (g/cm³)',uv_absorption_355nm:'355nm 紫外吸收率 (%)',material_thickness:'材料厚度 (mm)',moisture_content:'含水率 (%)',thermal_conductivity:'热导率 (W/(m·K))',initial_decomposition_temp:'初始分解温度 (℃)',melting_vaporization_temp:'熔点 / 气化温度 (℃)',specific_heat_capacity:'比热容 (J/(kg·K))',carbon_residue_rate:'残炭率 (%)',surface_roughness:'表面粗糙度 (μm)',hardness:'硬度 (邵氏/HV)',actual_output_power:'实际输出功率 (W)',display_current:'设备显示电流 (A)',scanning_speed:'扫描速度 (mm/s)',pulse_frequency:'脉冲频率 (kHz)',pulse_width:'脉冲脉宽 (μs)',defocus_amount:'离焦量 (mm)',scan_line_spacing:'扫描线间距 (mm)',filling_method:'填充方式',processing_size:'加工尺寸',is_cut_through:'是否切透',carbonized_edge_width:'碳化边宽度 (mm)',etching_depth:'蚀刻深度 (μm)',is_fire_smolder:'是否起火 / 阴燃',pattern_clarity_score:'图案清晰度评分',presentation_balance_score:'呈现均衡性评分'
};
const physical=['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'];
const laser=['actual_output_power','display_current','scanning_speed','pulse_frequency','pulse_width','defocus_amount','scan_line_spacing','filling_method','processing_size'];
const resultFields=['is_cut_through','carbonized_edge_width','etching_depth','is_fire_smolder','pattern_clarity_score','presentation_balance_score'];
function renderGroup(title,fields,data){return `<section class="detail-group"><h3>${title}</h3><div class="detail-grid">${fields.map(key=>{let value=data[key];if(typeof value==='boolean')value=value?'是':'否';if(value===null||value==='')value='未填写';return `<div><small>${labels[key]}</small><b>${escapeHtml(value)}</b></div>`}).join('')}</div></section>`;}
function renderCustomGroup(values){if(!values?.length)return '';return `<section class="detail-group"><h3>扩展数据</h3><div class="detail-grid">${values.map(item=>`<div><small>${item.is_active?'':`历史字段 · `}${escapeHtml(item.label)}</small><b>${escapeHtml(item.display_value||item.raw_value||'未填写')}</b>${item.compatible===false?'<span class="history-badge">历史格式</span>':''}</div>`).join('')}</div></section>`;}
function renderImageGroup(images){return `<section class="detail-group"><h3>成品图片</h3><div class="detail-gallery">${ImageViewer.galleryHtml(images)}</div></section>`;}
async function showDetail(id){
try{const {data}=await getJson(`/api/public/records/${id}`);$('#publicModalContent').innerHTML=`<div class="detail-title"><p class="eyebrow">EXPERIMENT DETAIL</p><h2>${escapeHtml(data.experiment_id)}</h2><p>${escapeHtml(data.test_date)} · ${escapeHtml(data.material_category)} · ${escapeHtml(data.material_name)}</p></div><div class="detail-groups">${renderImageGroup(data.images)}${renderGroup('材料物理特性',physical,data)}${renderGroup('激光雕刻参数',laser,data)}${renderGroup('实验结果',resultFields,data)}${renderCustomGroup(data.custom_fields)}</div>`;ImageViewer.wire($('#publicModalContent'),data.images);$('#publicModal').hidden=false;}catch(error){alert(error.message);}
}
document.querySelectorAll('[data-close]').forEach(node=>node.onclick=()=>$('#publicModal').hidden=true);
$('#publicSearchBtn').onclick=()=>loadPublicData(1); $('#publicSearch').addEventListener('keydown',event=>{if(event.key==='Enter')loadPublicData(1)}); $('#publicCategory').onchange=()=>loadPublicData(1);
loadPublicData();
+42
View File
@@ -0,0 +1,42 @@
<section class="form-section"><div class="form-section-head"><span>01</span><div><h3>实验与材料</h3><p>用于识别本次测试和材料样品</p></div></div><div class="form-grid">
<label>实验 ID <em>*</em><input name="experiment_id" maxlength="100" placeholder="例如 LAS-2026-001" required></label>
<label>测试日期 <em>*</em><input type="date" name="test_date" required></label>
<label>材料大类 <em>*</em><input name="material_category" maxlength="100" placeholder="例如 木材" required></label>
<label>材料名称 / 型号 <em>*</em><input name="material_name" maxlength="200" placeholder="例如 椴木板 A3" required></label>
<label>材料厚度 (mm) <em>*</em><input type="number" name="material_thickness" min="0.000001" step="any" required></label>
</div></section>
<section class="form-section optional-section"><div class="form-section-head"><span>02</span><div><h3>材料物理特性 <b>均为可选</b></h3><p>未测试的物性参数可以留空</p></div></div><div class="form-grid">
<label>表观密度 (g/cm³) <small>可选</small><input type="number" name="apparent_density" min="0" step="any"></label>
<label>355nm 紫外吸收率 (%) <small>可选</small><input type="number" name="uv_absorption_355nm" min="0" max="100" step="any"></label>
<label>含水率 (%) <small>可选</small><input type="number" name="moisture_content" min="0" max="100" step="any"></label>
<label>热导率 (W/(m·K)) <small>可选</small><input type="number" name="thermal_conductivity" min="0" step="any"></label>
<label>初始分解温度 (℃) <small>可选</small><input type="number" name="initial_decomposition_temp" step="any"></label>
<label>熔点 / 气化温度 (℃) <small>可选</small><input type="number" name="melting_vaporization_temp" step="any"></label>
<label>比热容 (J/(kg·K)) <small>可选</small><input type="number" name="specific_heat_capacity" min="0" step="any"></label>
<label>残炭率 (%) <small>可选</small><input type="number" name="carbon_residue_rate" min="0" max="100" step="any"></label>
<label>表面粗糙度 (μm) <small>可选</small><input type="number" name="surface_roughness" min="0" step="any"></label>
<label>硬度 (邵氏/HV) <small>可选</small><input type="number" name="hardness" min="0" step="any"></label>
</div></section>
<section class="form-section"><div class="form-section-head"><span>03</span><div><h3>激光雕刻参数</h3><p>设备输出与扫描设置</p></div></div><div class="form-grid">
<label>实际输出功率 (W) <em>*</em><input type="number" name="actual_output_power" min="0" step="any" required></label>
<label>设备显示电流 (A) <em>*</em><input type="number" name="display_current" min="0" step="any" required></label>
<label>扫描速度 (mm/s) <em>*</em><input type="number" name="scanning_speed" min="0.000001" step="any" required></label>
<label>脉冲频率 (kHz) <em>*</em><input type="number" name="pulse_frequency" min="0" step="any" required></label>
<label>脉冲脉宽 (μs) <em>*</em><input type="number" name="pulse_width" min="0" step="any" required></label>
<label>离焦量 (mm) <em>*</em><input type="number" name="defocus_amount" step="any" required></label>
<label>扫描线间距 (mm) <em>*</em><input type="number" name="scan_line_spacing" min="0.000001" step="any" required></label>
<label>填充方式 <em>*</em><input name="filling_method" maxlength="100" placeholder="例如 双向填充" required></label>
<label>加工尺寸 (mm×mm) <em>*</em><input name="processing_size" maxlength="100" placeholder="例如 20×20" required></label>
</div></section>
<section class="form-section"><div class="form-section-head"><span>04</span><div><h3>实验结果</h3><p>加工结果、质量评分与备注</p></div></div><div class="form-grid">
<label>是否切透 <em>*</em><select name="is_cut_through" required><option value="">请选择</option><option value="true">是(1</option><option value="false">否(0</option></select></label>
<label>碳化边宽度 (mm) <em>*</em><input type="number" name="carbonized_edge_width" min="0" step="any" required></label>
<label>蚀刻深度 (μm) <em>*</em><input type="number" name="etching_depth" min="0" step="any" required></label>
<label>是否起火 / 阴燃 <em>*</em><select name="is_fire_smolder" required><option value="">请选择</option><option value="true">是(1</option><option value="false">否(0</option></select></label>
<label>图案清晰度评分 (0-10) <em>*</em><input type="number" name="pattern_clarity_score" min="0" max="10" step="0.1" required></label>
<label>呈现均衡性评分 (0-10) <em>*</em><input type="number" name="presentation_balance_score" min="0" max="10" step="0.1" required></label>
<label>成品图文件名 <em>*</em><input name="finished_image_filename" maxlength="255" placeholder="例如 LAS-2026-001.jpg" required></label>
<label class="span-2">备注 <em>*</em><textarea name="remarks" maxlength="5000" rows="4" placeholder="记录实验现象或其他说明" required></textarea></label>
</div></section>
<section class="form-section"><div class="form-section-head"><span>05</span><div><h3>成品图片 <b>可选</b></h3><p>可从相册选择或直接拍照,最多 6 张,提交后可在详情中放大查看</p></div></div><div class="image-uploader" id="recordImages"></div></section>
<section class="form-section custom-fields-section" id="customFieldsSection" hidden><div class="form-section-head"><span>06</span><div><h3>扩展数据</h3><p>由管理员配置的附加数据字段</p></div></div><div class="form-grid" id="customFieldsGrid"></div></section>
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ app_name }}</title><link rel="stylesheet" href="/static/app.css"></head>
<body class="app-body" data-role="{{ user.role }}" data-csrf="{{ csrf_token }}">
<aside class="sidebar" id="sidebar">
<div class="logo"><div class="brand-mark">L</div><div><strong>LASER DATA</strong><span>材料数据管理平台</span></div></div>
<nav>
<button class="nav-item active" data-view="overview"><i></i><span>数据概览</span></button>
{% if user.role in ['admin','uploader'] %}<button class="nav-item" data-view="entry"><i></i><span>手动录入</span></button>{% endif %}
<button class="nav-item" data-view="records"><i></i><span>数据中心</span></button>
{% if user.role == 'admin' %}
<p class="nav-label">系统管理</p>
<button class="nav-item" data-view="fields"><i></i><span>数据字段配置</span></button>
<button class="nav-item" data-view="users"><i></i><span>用户与权限</span></button>
<button class="nav-item" data-view="backup"><i></i><span>备份管理</span></button>
<button class="nav-item" data-view="audit"><i></i><span>审计日志</span></button>
{% endif %}
</nav>
<div class="side-help"><b>需要帮助?</b><span>查看操作手册和接口文档</span><div><a href="/docs" target="_blank">API 文档</a><a href="https://github.com" hidden>文档</a></div></div>
<div class="side-user"><div class="avatar">{{ user.display_name[0] }}</div><div><b>{{ user.display_name }}</b><span>{{ {'admin':'系统管理员','uploader':'数据录入员','viewer':'只读访客'}[user.role] }}</span></div><button id="logoutBtn" title="退出"></button></div>
</aside>
<div class="mobile-bar"><button id="menuBtn"></button><div class="logo"><div class="brand-mark">L</div><strong>LASER DATA</strong></div><div class="avatar">{{ user.display_name[0] }}</div></div>
<main class="content">
<header class="topbar"><div><p class="eyebrow">MATERIAL INTELLIGENCE</p><h1 id="pageTitle">数据概览</h1></div><div class="top-actions"><span class="status-dot">系统运行正常</span><a class="ghost button-link" href="/" target="_blank">公开看板</a>{% if user.role in ['admin','uploader'] %}<button class="ghost" id="entryKeyBtn">录入 Key</button>{% endif %}<button class="ghost" id="passwordBtn">修改密码</button></div></header>
<section class="view active" id="view-overview">
{% if user.must_change_password %}<div class="notice"><b>首次登录请修改初始密码</b><span>完成密码更新后,此提示将自动消失。</span><button id="noticePasswordBtn">立即修改</button></div>{% endif %}
<div class="hero-card"><div><p class="eyebrow">GOOD DATA, BETTER DECISIONS</p><h2>下午好,{{ user.display_name }}</h2><p>每一组可靠的数据,都让下一次雕刻更接近理想结果。</p></div>{% if user.role in ['admin','uploader'] %}<button class="light-button" data-go="entry">录入新数据 <span></span></button>{% endif %}</div>
<div class="metric-grid">
<article class="metric"><span class="metric-icon coral"></span><div><p>数据总量</p><strong>{{ counts.records }}</strong><small>条实验记录</small></div></article>
<article class="metric"><span class="metric-icon blue"></span><div><p>录入次数</p><strong>{{ counts.batches }}</strong><small>次成功保存</small></div></article>
<article class="metric"><span class="metric-icon green"></span><div><p>活跃用户</p><strong>{{ counts.users }}</strong><small>个有效账号</small></div></article>
</div>
<div class="panel-grid"><section class="panel"><div class="panel-head"><div><p class="eyebrow">RECENT ENTRIES</p><h3>最近录入</h3></div><button class="text-button" data-go="records">查看全部 →</button></div><div id="recentRecords" class="loading">正在加载…</div></section>
<section class="panel quick-panel"><p class="eyebrow">QUICK START</p><h3>规范录入三步走</h3><ol><li><b>填写材料身份</b><span>实验 ID 必须唯一</span></li><li><b>补充参数与结果</b><span>标记“可选”的物性字段可留空</span></li><li><b>校验并保存</b><span>系统自动检查格式并记录操作者</span></li></ol></section></div>
</section>
<section class="view" id="view-entry">
<div class="section-intro"><div><h2 id="entryTitle">手动录入实验数据</h2><p><span class="required-star">*</span> 为必填;标记“可选”的字段可留空。</p></div><button class="secondary" id="resetRecordBtn" type="button">清空表单</button></div>
<form id="recordForm" class="record-form">
<input type="hidden" id="recordId">
{% include "_record_fields.html" %}
<div class="form-actions"><span>保存后可在数据中心继续编辑</span><button class="primary" id="saveRecordBtn" type="submit">保存实验数据</button></div>
</form>
</section>
<section class="view" id="view-records"><div class="section-intro"><div><h2>数据中心</h2><p>按实验 ID、材料名称或分类快速查找、编辑和管理实验记录。</p></div>{% if user.role in ['admin','uploader'] %}<button class="primary" data-go="entry"> 新建记录</button>{% endif %}</div><section class="panel"><div class="filters"><input id="recordSearch" placeholder="搜索实验 ID 或材料名称"><input id="categorySearch" placeholder="材料分类(精确)"><button class="primary" id="searchBtn">查询</button></div><div class="table-wrap"><table><thead><tr><th>实验 ID</th><th>测试日期</th><th>材料</th><th>厚度</th><th>功率</th><th>速度</th><th>切透</th><th>清晰度</th><th>操作</th></tr></thead><tbody id="recordRows"></tbody></table></div><div class="pager" id="recordPager"></div></section></section>
{% if user.role == 'admin' %}
<section class="view" id="view-fields"><div class="section-intro"><div><h2>数据字段配置</h2><p>添加扩展数据种类,设置输入形式、必填规则和公开性;历史值不会因配置修改而删除。</p></div><button class="primary" id="newFieldBtn"> 添加数据字段</button></div><section class="panel"><div class="table-wrap"><table><thead><tr><th>字段名称</th><th>填写形式</th><th>规则</th><th>公开</th><th>状态</th><th>已有值</th><th>排序</th><th></th></tr></thead><tbody id="fieldRows"></tbody></table></div></section></section>
<section class="view" id="view-users"><div class="section-intro"><div><h2>用户与权限</h2><p>管理员负责账号创建、角色分配、停用与密码重置。</p></div><button class="primary" id="newUserBtn"> 新建用户</button></div><section class="panel"><div class="table-wrap"><table><thead><tr><th>用户</th><th>用户名</th><th>角色</th><th>状态</th><th>创建时间</th><th></th></tr></thead><tbody id="userRows"></tbody></table></div></section></section>
<section class="view" id="view-backup"><div class="section-intro"><div><h2>备份管理</h2><p>支持 SQLite 在线一致性备份与 PostgreSQL pg_dump,默认保留 30 天。</p></div><button class="primary" id="backupBtn">⟳ 立即备份</button></div><section class="panel"><div class="table-wrap"><table><thead><tr><th>文件名</th><th>数据库</th><th>大小</th><th>创建人</th><th>创建时间</th><th>SHA-256</th><th></th></tr></thead><tbody id="backupRows"></tbody></table></div></section></section>
<section class="view" id="view-audit"><div class="section-intro"><div><h2>审计日志</h2><p>记录登录、录入、修改、删除、账号及备份等关键动作。</p></div></div><section class="panel"><div class="table-wrap"><table><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>资源</th><th>IP 地址</th><th>详情</th></tr></thead><tbody id="auditRows"></tbody></table></div></section></section>
{% endif %}
</main>
<div class="modal" id="modal" hidden><div class="modal-backdrop"></div><div class="modal-card"><button class="modal-close" data-close>×</button><div id="modalContent"></div></div></div>
<div class="toast-stack" id="toasts"></div>
<script>window.APP={csrf:document.body.dataset.csrf,role:document.body.dataset.role,mustChange:{{ 'true' if user.must_change_password else 'false' }},entryKeyConfigured:{{ 'true' if user.entry_key_hash else 'false' }}};</script><script src="/static/dynamic-fields.js"></script><script src="/static/image-viewer.js"></script><script src="/static/image-upload.js"></script><script src="/static/app.js"></script>
</body></html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>数据录入 · {{ app_name }}</title><link rel="stylesheet" href="/static/app.css"><link rel="stylesheet" href="/static/public.css"></head>
<body class="public-body entry-body" data-authorized="{{ 'true' if entry_authorized else 'false' }}" data-csrf="{{ entry_csrf_token }}">
<header class="public-nav"><a class="public-brand" href="/"><span class="brand-mark">L</span><span><b>LASER DATA</b><small>材料实验数据库</small></span></a><nav><a href="/">数据看板</a><a class="active" href="/entry">数据录入</a><a href="/login">管理后台</a></nav></header>
<main class="entry-main">
<section id="keyGate" class="key-gate" {% if entry_authorized %}hidden{% endif %}>
<div class="key-symbol">••••</div><p class="eyebrow">ENTRY ACCESS</p><h1>输入四位录入 Key</h1><p>Key 由用户在管理后台的个人设置中创建</p>
<form id="keyForm"><input id="entryKey" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="one-time-code" aria-label="四位录入 Key" required><button class="primary" type="submit">进入录入面板</button><p id="keyError" class="form-error" hidden></p></form>
</section>
<section id="entryWorkspace" {% if not entry_authorized %}hidden{% endif %}>
<div class="entry-heading"><div><p class="eyebrow">NEW EXPERIMENT</p><h1>录入实验数据</h1><p><span class="required-star">*</span> 为必填,材料物理特性可按实际检测情况填写。</p></div><button class="secondary" id="entryLogoutBtn">退出录入</button></div>
<form id="publicRecordForm" class="record-form">
{% include "_record_fields.html" %}
<div class="form-actions"><span>提交前需要再次确认用户名</span><button class="primary" id="entrySubmitBtn" type="submit">检查并提交</button></div>
</form>
</section>
</main>
<div class="modal" id="confirmModal" hidden><div class="modal-backdrop" data-close></div><div class="modal-card"><button class="modal-close" data-close>×</button><p class="eyebrow">FINAL CONFIRMATION</p><h2>确认提交身份</h2><p class="muted">请输入此录入 Key 所属账号的用户名。</p><form id="confirmForm"><label>用户名<input name="username" autocomplete="username" required autofocus></label><button class="primary" type="submit">确认并保存</button></form></div></div>
<div class="toast-stack" id="toasts"></div>
<script src="/static/dynamic-fields.js"></script><script src="/static/image-viewer.js"></script><script src="/static/image-upload.js"></script><script src="/static/entry.js"></script>
</body></html>
+3
View File
@@ -0,0 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>出错了</title><link rel="stylesheet" href="/static/app.css"></head>
<body class="center-page"><section class="empty-card"><div class="empty-icon">!</div><h1>操作未完成</h1><p>{{ message }}</p><a class="primary button-link" href="/">返回工作台</a></section></body></html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 · {{ app_name }}</title><link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-body">
<main class="login-shell">
<section class="login-brand">
<div class="brand-mark">L</div>
<p class="eyebrow">LASER MATERIAL DATA</p>
<h1>让每一次激光实验<br>都有据可循</h1>
<p>集中管理雕刻参数、材料物性与实验结果,数据可查、操作可审、系统可恢复。</p>
<div class="brand-stats"><span><b>32</b> 个标准字段</span><span><b>3</b> 级权限</span><span><b>100%</b> 审计留痕</span></div>
</section>
<section class="login-card">
<div class="mobile-logo"><div class="brand-mark">L</div><strong>{{ app_name }}</strong></div>
<p class="eyebrow">WELCOME BACK</p><h2>登录工作台</h2><p class="muted">使用管理员分配的账号继续</p>
<form id="loginForm">
<label>用户名<input name="username" autocomplete="username" placeholder="请输入用户名" required autofocus></label>
<label>密码<input type="password" name="password" autocomplete="current-password" placeholder="请输入密码" required></label>
<button class="primary wide" type="submit">安全登录 <span></span></button>
<p id="loginError" class="form-error" hidden></p>
</form>
<p class="login-foot">系统采用加密会话与完整审计日志保护数据安全</p>
</section>
</main>
<script>
document.querySelector('#loginForm').addEventListener('submit', async (event) => {
event.preventDefault(); const button=event.target.querySelector('button'); const error=document.querySelector('#loginError');
button.disabled=true; button.firstChild.textContent='登录中… '; error.hidden=true;
try { const response=await fetch('/api/auth/login',{method:'POST',body:new FormData(event.target)}); const data=await response.json();
if(!response.ok) throw new Error(data.message||'登录失败'); location.href='/admin';
} catch(e) { error.textContent=e.message; error.hidden=false; button.disabled=false; button.firstChild.textContent='安全登录 '; }
});
</script>
</body></html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>公开数据 · {{ app_name }}</title><link rel="stylesheet" href="/static/app.css"><link rel="stylesheet" href="/static/public.css"></head>
<body class="public-body">
<header class="public-nav"><a class="public-brand" href="/"><span class="brand-mark">L</span><span><b>LASER DATA</b><small>材料实验数据库</small></span></a><nav><a class="active" href="/">数据看板</a><a href="/entry">数据录入</a><a href="/login">管理后台</a></nav></header>
<main class="public-main">
<section class="data-heading"><div><p class="eyebrow">OPEN MATERIAL DATA</p><h1>激光材料实验数据</h1><p>激光雕刻参数、材料物性与加工结果的公开数据视图</p></div><a class="primary button-link" href="/entry">录入实验数据 →</a></section>
<section class="public-metrics">
<article><span>DATA</span><div><small>实验记录</small><strong id="statTotal"></strong></div></article>
<article><span>TYPE</span><div><small>材料分类</small><strong id="statCategories"></strong></div></article>
<article><span>30D</span><div><small>近 30 天新增</small><strong id="statRecent"></strong></div></article>
<article><span>SCORE</span><div><small>平均清晰度</small><strong id="statClarity"></strong></div></article>
</section>
<section class="public-table-panel">
<div class="public-panel-head"><div><p class="eyebrow">EXPERIMENT RECORDS</p><h2>实验记录</h2></div><div class="public-filters"><input id="publicSearch" placeholder="实验 ID 或材料名称"><select id="publicCategory"><option value="">全部材料分类</option></select><button id="publicSearchBtn" class="primary">查询</button></div></div>
<div class="table-wrap"><table><thead><tr><th>实验 ID</th><th>日期</th><th>材料分类</th><th>材料名称 / 型号</th><th>厚度</th><th>功率</th><th>速度</th><th>切透</th><th>清晰度</th><th></th></tr></thead><tbody id="publicRows"><tr><td colspan="10" class="loading">正在加载数据…</td></tr></tbody></table></div>
<div class="pager" id="publicPager"></div>
</section>
</main>
<div class="modal" id="publicModal" hidden><div class="modal-backdrop" data-close></div><div class="modal-card detail-card"><button class="modal-close" data-close>×</button><div id="publicModalContent"></div></div></div>
<script src="/static/image-viewer.js"></script><script src="/static/public.js"></script>
</body></html>
+72
View File
@@ -0,0 +1,72 @@
"""接口测试用的独立环境:必须在导入 app 之前写好环境变量,配置是冻结的 dataclass。"""
from __future__ import annotations
import os
import struct
import tempfile
import zlib
from pathlib import Path
import pytest
os.environ.setdefault("APP_SECRET", "test-secret-0123456789abcdefghijkl")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ.setdefault("ADMIN_PASSWORD", "TestAdmin12345")
os.environ["DATABASE_URL"] = f"sqlite:///{Path(tempfile.mkdtemp(prefix='laser-test-')) / 'test.db'}"
def png_bytes(width: int = 24, height: int = 16) -> bytes:
"""不依赖 Pillow 手工拼一张真实可解码的 PNG。"""
def chunk(tag: bytes, payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + tag + payload + struct.pack(">I", zlib.crc32(tag + payload))
raw = b"".join(b"\x00" + b"\x20\x60\x90" * width for _ in range(height))
header = chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
return b"\x89PNG\r\n\x1a\n" + header + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
def jpeg_bytes(width: int = 32, height: int = 20) -> bytes:
"""只含 SOI/APP0/SOF0/EOI 的最小 JPEG,足以走完服务端的文件头校验。"""
app0 = b"\xff\xe0" + struct.pack(">H", 16) + b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
sof0 = b"\xff\xc0" + struct.pack(">HBHHB", 11, 8, height, width, 1) + b"\x01\x11\x00"
return b"\xff\xd8" + app0 + sof0 + b"\xff\xd9"
def record_payload(experiment_id: str = "LAS-TEST-001", **overrides) -> dict:
payload = {
"experiment_id": experiment_id, "test_date": "2026-07-01", "material_category": "木材", "material_name": "椴木板",
"material_thickness": 3, "actual_output_power": 8.5, "display_current": 2, "scanning_speed": 100,
"pulse_frequency": 20, "pulse_width": 5, "defocus_amount": 0, "scan_line_spacing": 0.1,
"filling_method": "双向填充", "processing_size": "20×20", "is_cut_through": True,
"carbonized_edge_width": 0.2, "etching_depth": 30, "is_fire_smolder": False,
"pattern_clarity_score": 9, "presentation_balance_score": 8,
"finished_image_filename": "LAS-TEST-001.jpg", "remarks": "测试记录",
}
payload.update(overrides)
return payload
@pytest.fixture(scope="session")
def client():
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app) as test_client:
yield test_client
@pytest.fixture()
def admin(client):
"""返回带管理员会话的客户端和对应 CSRF 令牌。"""
import hashlib
import hmac
from app.config import settings
from app.main import SESSION_COOKIE
client.cookies.clear()
response = client.post("/api/auth/login", data={"username": settings.admin_username, "password": settings.admin_password})
assert response.status_code == 200, response.text
token = client.cookies.get(SESSION_COOKIE)
csrf = hmac.new(settings.secret.encode(), token.encode(), hashlib.sha256).hexdigest()
return client, {"X-CSRF-Token": csrf}
+11
View File
@@ -0,0 +1,11 @@
from app.backup_service import postgres_cli_url
def test_sqlalchemy_postgres_url_is_accepted_by_cli():
value = "postgresql+psycopg://user:pass@db:5432/laser_data"
assert postgres_cli_url(value) == "postgresql://user:pass@db:5432/laser_data"
def test_standard_postgres_url_is_unchanged():
value = "postgresql://user:pass@db:5432/laser_data"
assert postgres_cli_url(value) == value
+43
View File
@@ -0,0 +1,43 @@
import json
import pytest
from fastapi import HTTPException
from app.dynamic_fields import render_custom_value, validate_raw_value
from app.models import CustomFieldDefinition, CustomFieldValue
def definition(input_type="text", required=False, options=None):
return CustomFieldDefinition(
field_key="custom_test", label="测试字段", input_type=input_type,
is_required=required, is_active=True, is_public=True,
options_json=json.dumps(options or []), sort_order=0, created_by=1,
)
def test_required_dynamic_field_rejects_empty_value():
with pytest.raises(HTTPException) as error:
validate_raw_value(definition(required=True), None)
assert error.value.status_code == 422
def test_select_rejects_value_outside_current_options():
with pytest.raises(HTTPException):
validate_raw_value(definition("select", options=["A", "B"]), "历史选项")
def test_type_change_preserves_incompatible_raw_value_for_display():
item = CustomFieldValue(raw_value="High")
item.definition = definition("number")
rendered = render_custom_value(item)
assert rendered["raw_value"] == "High"
assert rendered["display_value"] == "High"
assert rendered["compatible"] is False
def test_compatible_number_is_processed_for_display():
item = CustomFieldValue(raw_value="12.5")
item.definition = definition("number")
rendered = render_custom_value(item)
assert rendered["value"] == 12.5
assert rendered["compatible"] is True
+113
View File
@@ -0,0 +1,113 @@
"""四位 Key 录入面板的图片链路:这是手机端最主要的入口,必须整条走通。"""
import hashlib
import hmac
import pytest
from conftest import png_bytes, record_payload
@pytest.fixture()
def entry(client, admin):
"""用管理员账号配好四位 Key,再换成录入会话。"""
from app.config import settings
from app.main import ENTRY_COOKIE
admin_client, admin_headers = admin
response = admin_client.put("/api/me/entry-key", headers=admin_headers, json={"key": "1379", "password": settings.admin_password})
assert response.status_code == 200, response.text
client.cookies.clear()
assert client.post("/api/entry/auth", json={"key": "1379"}).status_code == 200
token = client.cookies.get(ENTRY_COOKIE)
csrf = hmac.new(settings.secret.encode(), f"entry:{token}".encode(), hashlib.sha256).hexdigest()
return client, {"X-CSRF-Token": csrf}
def entry_upload(client, headers, **kwargs):
files = {"file": ("field.png", png_bytes(48, 36), "image/png"), "thumbnail": ("t.png", png_bytes(12, 9), "image/png")}
return client.post("/api/entry/uploads/images", headers=headers, files=files, **kwargs)
def test_entry_upload_requires_the_entry_session(client):
client.cookies.clear()
assert client.post("/api/entry/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 401
def test_entry_upload_requires_the_entry_csrf_token(entry):
client, _ = entry
assert client.post("/api/entry/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 403
def test_admin_csrf_token_is_not_accepted_on_the_entry_endpoint(entry, admin):
"""两套会话的 CSRF 派生方式不同,管理端令牌不能拿来打录入接口。"""
client, _ = entry
_, admin_headers = admin
assert client.post("/api/entry/uploads/images", headers=admin_headers, files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 403
def test_entry_record_is_saved_with_its_images(entry):
from app.config import settings
client, headers = entry
token = entry_upload(client, headers).json()["data"]["token"]
payload = record_payload("LAS-ENTRY-001", image_tokens=[token], confirm_username=settings.admin_username)
created = client.post("/api/entry/records", headers=headers, json=payload)
assert created.status_code == 200, created.text
images = client.get(f"/api/public/records/{created.json()['data']['id']}").json()["data"]["images"]
assert len(images) == 1 and images[0]["width"] == 48
def test_entry_record_rejects_a_mismatched_username(entry):
client, headers = entry
token = entry_upload(client, headers).json()["data"]["token"]
payload = record_payload("LAS-ENTRY-002", image_tokens=[token], confirm_username="someone-else")
assert client.post("/api/entry/records", headers=headers, json=payload).status_code == 403
def test_entry_pending_image_can_be_discarded(entry):
client, headers = entry
token = entry_upload(client, headers).json()["data"]["token"]
assert client.delete(f"/api/entry/uploads/images/{token}", headers=headers).status_code == 200
assert client.delete(f"/api/entry/uploads/images/{token}", headers=headers).status_code == 404
def test_pending_image_of_another_account_cannot_be_claimed(client, admin):
"""令牌必须同时校验归属,否则拿到别人的令牌就能把图挂到自己的记录上。"""
from app.config import settings
from app.main import SESSION_COOKIE
admin_client, admin_headers = admin
created = admin_client.post("/api/users", headers=admin_headers, json={"username": "uploader9", "display_name": "录入九", "password": "UploaderPass123", "role": "uploader"})
assert created.status_code in (200, 409)
# 换成另一个账号上传,拿到属于它的令牌
client.cookies.clear()
client.post("/api/auth/login", data={"username": "uploader9", "password": "UploaderPass123"})
other_csrf = hmac.new(settings.secret.encode(), client.cookies.get(SESSION_COOKIE).encode(), hashlib.sha256).hexdigest()
stolen = client.post("/api/uploads/images", headers={"X-CSRF-Token": other_csrf}, files={"file": ("mine.png", png_bytes(20, 20), "image/png")}).json()["data"]["token"]
# 换回管理员,用别人的令牌建记录必须失败
client.cookies.clear()
client.post("/api/auth/login", data={"username": settings.admin_username, "password": settings.admin_password})
admin_csrf = hmac.new(settings.secret.encode(), client.cookies.get(SESSION_COOKIE).encode(), hashlib.sha256).hexdigest()
response = client.post("/api/records", headers={"X-CSRF-Token": admin_csrf}, json=record_payload("LAS-STEAL-001", image_tokens=[stolen]))
assert response.status_code == 404
def test_thumbnail_variant_reports_its_own_media_type(entry):
from app.config import settings
client, headers = entry
body = client.post("/api/entry/uploads/images", headers=headers, files={
"file": ("photo.jpg", open("/System/Library/CoreServices/DefaultBackground.jpg", "rb").read() if False else png_bytes(64, 64), "image/png"),
"thumbnail": ("t.png", png_bytes(16, 16), "image/png"),
}).json()
token = body["data"]["token"]
payload = record_payload("LAS-ENTRY-004", image_tokens=[token], confirm_username=settings.admin_username)
record_id = client.post("/api/entry/records", headers=headers, json=payload).json()["data"]["id"]
image = client.get(f"/api/public/records/{record_id}").json()["data"]["images"][0]
assert "variant=thumb" in image["thumb_url"]
response = client.get(image["thumb_url"])
assert response.status_code == 200 and response.headers["content-type"] == "image/png"
assert response.headers["etag"].endswith('-thumb"')
+19
View File
@@ -0,0 +1,19 @@
import uuid
import pytest
from fastapi import HTTPException
from app.main import clear_entry_key_attempts, enforce_entry_key_rate_limit, record_failed_entry_key
def test_entry_key_is_rate_limited_after_five_failures():
address = f"test-{uuid.uuid4()}"
try:
for _ in range(5):
enforce_entry_key_rate_limit(address)
record_failed_entry_key(address)
with pytest.raises(HTTPException) as error:
enforce_entry_key_rate_limit(address)
assert error.value.status_code == 429
finally:
clear_entry_key_attempts(address)
+324
View File
@@ -0,0 +1,324 @@
import pytest
from fastapi import HTTPException
from app.images import MAX_IMAGES_PER_RECORD, inspect_image, sniff
from conftest import jpeg_bytes, png_bytes, record_payload
def upload(client, headers, data=None, name="shot.png", content_type="image/png", thumbnail=True):
files = {"file": (name, data if data is not None else png_bytes(), content_type)}
if thumbnail:
files["thumbnail"] = ("thumb.png", png_bytes(8, 6), "image/png")
return client.post("/api/uploads/images", headers=headers, files=files)
def test_headers_identify_supported_formats():
assert sniff(png_bytes(40, 25)) == ("image/png", 40, 25)
assert sniff(jpeg_bytes(640, 480)) == ("image/jpeg", 640, 480)
@pytest.mark.parametrize("blob", [
b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
b"<!doctype html><h1>not an image</h1>",
b"GIF89a\x01\x00\x01\x00\x00\x00\x00", # GIF 不在白名单内
png_bytes()[:12], # 截断文件
b"\xff\xd8\xff" + b"\x00" * 40, # 伪造 JPEG 头但没有 SOF
])
def test_non_image_payloads_are_rejected(blob):
with pytest.raises(HTTPException) as error:
inspect_image(blob)
assert error.value.status_code in (400, 413, 415)
def test_oversized_payload_is_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(png_bytes(), limit=10)
assert error.value.status_code == 413
def test_absurd_dimensions_are_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(jpeg_bytes(30000, 30000))
assert error.value.status_code == 413
def test_upload_requires_authentication(client):
client.cookies.clear()
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 401
def test_upload_requires_csrf_token(admin):
client, _ = admin
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 403
def test_upload_returns_metadata_and_token(admin):
client, headers = admin
body = upload(client, headers, png_bytes(64, 48)).json()
assert body["ok"] and body["data"]["width"] == 64 and body["data"]["height"] == 48
assert body["data"]["content_type"] == "image/png" and body["data"]["token"]
def test_disguised_html_upload_is_rejected(admin):
client, headers = admin
response = upload(client, headers, b"<script>alert(1)</script>", name="evil.png")
assert response.status_code == 415
def test_pending_image_is_private_until_attached(admin):
client, headers = admin
image_id = upload(client, headers).json()["data"]["id"]
assert client.get(f"/api/images/{image_id}").status_code == 200
client.cookies.clear()
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_attached_image_is_public_and_cacheable(admin):
client, headers = admin
token = upload(client, headers, png_bytes(50, 40)).json()["data"]["token"]
created = client.post("/api/records", headers=headers, json=record_payload("LAS-PUB-001", image_tokens=[token]))
assert created.status_code == 200, created.text
record_id = created.json()["data"]["id"]
images = client.get(f"/api/public/records/{record_id}").json()["data"]["images"]
assert len(images) == 1
client.cookies.clear()
response = client.get(images[0]["url"])
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.headers["x-content-type-options"] == "nosniff"
assert "immutable" in response.headers["cache-control"]
assert client.get(response.url, headers={"If-None-Match": response.headers["etag"]}).status_code == 304
def test_record_detail_never_leaks_binary_payload(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-BLOB-001", image_tokens=[token])).json()["data"]["id"]
data = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]
assert "data" not in data and "thumbnail" not in data
assert data["images"][0]["url"].startswith("/api/images/")
def test_more_than_six_images_are_refused(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(MAX_IMAGES_PER_RECORD + 1)]
response = client.post("/api/records", headers=headers, json=record_payload("LAS-MANY-001", image_tokens=tokens))
assert response.status_code == 422
assert "6" in response.json()["message"]
def test_unknown_or_reused_token_is_refused(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-001", image_tokens=[token])).status_code == 200
reused = client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-002", image_tokens=[token]))
assert reused.status_code == 404
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-003", image_tokens=["nope"])).status_code == 404
def test_editing_drops_images_left_out_of_the_keep_list(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(2)]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-EDIT-001", image_tokens=tokens)).json()["data"]["id"]
images = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert len(images) == 2
kept = images[1]["id"]
updated = client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-EDIT-001", image_ids=[kept]))
assert updated.status_code == 200, updated.text
remaining = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert [item["id"] for item in remaining] == [kept]
assert client.get(f"/api/images/{images[0]['id']}").status_code == 404
def test_images_of_another_record_cannot_be_claimed(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
first = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{first}", headers=headers).json()["data"]["images"][0]["id"]
second = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-002")).json()["data"]["id"]
response = client.put(f"/api/records/{second}", headers=headers, json=record_payload("LAS-OWN-002", image_ids=[image_id]))
assert response.status_code == 404
def test_deleting_a_record_removes_its_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEL-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["id"]
assert client.delete(f"/api/records/{record_id}", headers=headers).status_code == 200
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_expired_pending_images_are_purged(admin):
from datetime import datetime, timedelta
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
stale_token = upload(client, headers).json()["data"]["token"]
with SessionLocal() as db:
stale = db.query(RecordImage).filter_by(token=stale_token).one()
stale.created_at = datetime.now().astimezone() - timedelta(hours=5)
db.commit()
assert upload(client, headers).status_code == 200 # 任何一次新上传都会顺手清理孤儿
with SessionLocal() as db: # 按 token 判断:SQLite 会复用被删除行的 rowid,按 id 判断会误判
assert db.query(RecordImage).filter_by(token=stale_token).count() == 0
def test_viewer_role_cannot_upload(admin):
client, headers = admin
created = client.post("/api/users", headers=headers, json={"username": "readonly1", "display_name": "只读", "password": "ViewerPass123", "role": "viewer"})
assert created.status_code in (200, 409)
client.cookies.clear()
client.post("/api/auth/login", data={"username": "readonly1", "password": "ViewerPass123"})
import hashlib
import hmac
from app.config import settings
from app.main import SESSION_COOKIE
csrf = hmac.new(settings.secret.encode(), client.cookies.get(SESSION_COOKIE).encode(), hashlib.sha256).hexdigest()
assert upload(client, {"X-CSRF-Token": csrf}).status_code == 403
def test_filename_injection_characters_are_stripped(admin):
client, headers = admin
files = {"file": ('x" onload="alert(1)".png', png_bytes(), "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files).json()
stored = body["data"]["original_name"]
assert '"' not in stored and "<" not in stored and ">" not in stored and "'" not in stored
def test_payload_appended_after_image_end_marker_is_discarded(admin):
client, headers = admin
smuggled = png_bytes() + b"<html><script>alert(1)</script></html>" * 20
body = client.post("/api/uploads/images", headers=headers, files={"file": ("ok.png", smuggled, "image/png")}).json()
assert body["data"]["size_bytes"] == len(png_bytes())
token = body["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-TRIM-001", image_tokens=[token])).json()["data"]["id"]
url = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["url"]
assert b"<script>" not in client.get(url).content
def test_webp_is_no_longer_accepted(admin):
"""WebP 的 RIFF 头几乎不校验内容,16 字节垃圾就能冒充一张图,索性不收。"""
import struct
client, headers = admin
fake = b"RIFF" + struct.pack("<I", 0) + b"WEBPVP8X" + b"\x00" * 20
assert client.post("/api/uploads/images", headers=headers, files={"file": ("a.webp", fake, "image/webp")}).status_code == 415
def test_update_without_image_ids_keeps_existing_images(admin):
"""省略 image_ids 必须是"不动图片",否则任何不了解该字段的旧页面或脚本都会静默删光图。"""
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-KEEP-001", image_tokens=[token])).json()["data"]["id"]
payload = record_payload("LAS-KEEP-001")
assert "image_ids" not in payload
assert client.put(f"/api/records/{record_id}", headers=headers, json=payload).status_code == 200
assert len(client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]) == 1
def test_explicit_empty_image_ids_clears_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-CLEAR-001", image_tokens=[token])).json()["data"]["id"]
client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-CLEAR-001", image_ids=[]))
assert client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"] == []
def test_upload_without_content_length_is_refused(admin):
"""没有 Content-Length 就无法预判体积,而 multipart 在鉴权之前就已落盘,必须直接拒绝。"""
client, headers = admin
response = client.post("/api/uploads/images", headers={**headers, "Content-Type": "multipart/form-data; boundary=B", "Transfer-Encoding": "chunked"}, content=iter([b"--B--\r\n"]))
assert response.status_code == 411
def test_detail_endpoints_do_not_load_image_binaries(admin):
"""图片二进制是 deferred 列:公开详情是匿名接口,一次请求把几 MB 图片读进内存会被用来打内存。"""
from sqlalchemy import event
from app.db import engine
client, headers = admin
token = upload(client, headers, png_bytes(120, 90)).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEFER-001", image_tokens=[token])).json()["data"]["id"]
statements = []
listener = lambda conn, cursor, statement, *rest: statements.append(statement)
event.listen(engine, "before_cursor_execute", listener)
try:
client.get(f"/api/public/records/{record_id}")
finally:
event.remove(engine, "before_cursor_execute", listener)
selected = " ".join(statements).lower()
assert "record_images.data" not in selected and "record_images.thumbnail\n" not in selected
def test_public_detail_hides_original_filename(admin):
"""原始文件名可能带姓名、车间、项目代号,公开看板不返回。"""
client, headers = admin
token = upload(client, headers, name="张三-三车间-复检.png").json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-PRIV-001", image_tokens=[token])).json()["data"]["id"]
public_image = client.get(f"/api/public/records/{record_id}").json()["data"]["images"][0]
assert "original_name" not in public_image and "size_bytes" not in public_image
assert set(public_image) == {"id", "width", "height", "url", "thumb_url"}
assert "original_name" in client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]
def test_broken_thumbnail_does_not_fail_the_upload(admin):
"""缩略图只是加速手段,坏了就回落到原图,不能把整次上传拖垮。"""
client, headers = admin
files = {"file": ("ok.png", png_bytes(), "image/png"), "thumbnail": ("bad.png", b"not an image at all", "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files)
assert body.status_code == 200
assert body.json()["data"]["thumb_url"] == body.json()["data"]["url"]
def test_pending_image_can_be_discarded_to_free_its_slot(admin):
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 200
with SessionLocal() as db:
assert db.query(RecordImage).filter_by(token=token).count() == 0
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_attached_image_cannot_be_discarded_through_the_upload_endpoint(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
client.post("/api/records", headers=headers, json=record_payload("LAS-DISC-001", image_tokens=[token]))
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_backup_size_column_holds_more_than_two_gigabytes():
"""图片入库后备份很容易超过 2 GiBPostgreSQL 的 integer 到那里就溢出了。"""
from sqlalchemy import BigInteger
from app.models import BackupRecord
assert isinstance(BackupRecord.__table__.c.size_bytes.type, BigInteger)
def test_unauthenticated_upload_never_buffers_the_request_body(client):
"""鉴权必须发生在 multipart 缓冲之前,否则未登录请求也能驱动服务器往磁盘写临时文件。"""
consumed = []
class Probe:
def __iter__(self):
consumed.append(True)
yield b'--B\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n' + b"x" * 200_000 + b"\r\n--B--\r\n"
client.cookies.clear()
response = client.post("/api/uploads/images", headers={"Content-Type": "multipart/form-data; boundary=B", "Content-Length": "200200"}, content=Probe())
assert response.status_code == 401
assert not consumed, "未登录请求的 body 不应被读取"
+45
View File
@@ -0,0 +1,45 @@
from datetime import date
import pytest
from pydantic import ValidationError
from app.schemas import EntryRecordSubmission, MaterialRecordInput
def valid_payload():
return {
"experiment_id": "LAS-2026-001", "test_date": date(2026, 7, 17), "material_category": "木材", "material_name": "椴木板",
"material_thickness": 3, "actual_output_power": 8.5, "display_current": 2, "scanning_speed": 100,
"pulse_frequency": 20, "pulse_width": 5, "defocus_amount": 0, "scan_line_spacing": .1,
"filling_method": "双向", "processing_size": "20×20", "is_cut_through": True,
"carbonized_edge_width": .2, "etching_depth": 30, "is_fire_smolder": False,
"pattern_clarity_score": 9, "presentation_balance_score": 8, "finished_image_filename": "LAS-2026-001.jpg", "remarks": "正常",
}
def test_all_optional_physical_properties_may_be_empty():
record = MaterialRecordInput(**valid_payload())
assert record.apparent_density is None
assert record.hardness is None
assert len(record.model_fields) - 3 == 32 # 32 fixed fields plus the dynamic-field map and the two image lists
def test_scores_are_limited_to_ten():
payload = valid_payload()
payload["pattern_clarity_score"] = 10.1
with pytest.raises(ValidationError):
MaterialRecordInput(**payload)
def test_percentage_is_limited_to_hundred():
payload = valid_payload()
payload["moisture_content"] = 101
with pytest.raises(ValidationError):
MaterialRecordInput(**payload)
def test_entry_submission_requires_confirmation_username():
payload = valid_payload()
with pytest.raises(ValidationError):
EntryRecordSubmission(**payload)
assert EntryRecordSubmission(**payload, confirm_username="operator").confirm_username == "operator"
+21
View File
@@ -0,0 +1,21 @@
from app.security import entry_key_hash, hash_password, validate_password, verify_password
def test_password_hash_round_trip():
encoded = hash_password("StrongPass123")
assert "StrongPass123" not in encoded
assert verify_password("StrongPass123", encoded)
assert not verify_password("wrong", encoded)
def test_password_policy():
assert validate_password("short")
assert validate_password("onlyletterslong")
assert validate_password("StrongPass123") is None
def test_entry_key_hash_is_deterministic_without_storing_plaintext():
first = entry_key_hash("1234")
assert first == entry_key_hash("1234")
assert first != entry_key_hash("4321")
assert "1234" not in first