激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
*.pyc
|
||||||
|
data
|
||||||
|
backups
|
||||||
|
tests
|
||||||
|
*.xlsx
|
||||||
|
.DS_Store
|
||||||
@@ -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
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
release.env
|
||||||
|
release/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
backups/*
|
||||||
|
!backups/.gitkeep
|
||||||
|
.DS_Store
|
||||||
|
# 原始数据表:含实测数据,按需本地保留,不入库
|
||||||
|
材料性能测试数据表.xlsx
|
||||||
+12
@@ -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"]
|
||||||
@@ -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` 只读用户 | ✓ | — | — | — |
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models import BackupRecord
|
||||||
|
|
||||||
|
|
||||||
|
def file_sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_cli_url(url: str) -> str:
|
||||||
|
"""PostgreSQL CLI 不识别 SQLAlchemy 的 +psycopg 驱动标记。"""
|
||||||
|
return url.replace("postgresql+psycopg://", "postgresql://", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(db: Session, user_id: int | None = None) -> BackupRecord:
|
||||||
|
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||||||
|
url = settings.database_url
|
||||||
|
if url.startswith("sqlite"):
|
||||||
|
filename = f"database-{stamp}.sqlite3"
|
||||||
|
target = settings.backup_dir / filename
|
||||||
|
source_path = Path(url.split("///", 1)[1])
|
||||||
|
if not source_path.is_absolute():
|
||||||
|
source_path = Path.cwd() / source_path
|
||||||
|
source = sqlite3.connect(str(source_path))
|
||||||
|
destination = sqlite3.connect(str(target))
|
||||||
|
try:
|
||||||
|
source.backup(destination)
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
database_type = "sqlite"
|
||||||
|
elif url.startswith(("postgresql", "postgres")):
|
||||||
|
filename = f"database-{stamp}.dump"
|
||||||
|
target = settings.backup_dir / filename
|
||||||
|
result = subprocess.run(["pg_dump", "--format=custom", "--file", str(target), postgres_cli_url(url)], capture_output=True, text=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"pg_dump 失败:{result.stderr.strip()}")
|
||||||
|
database_type = "postgresql"
|
||||||
|
else:
|
||||||
|
raise RuntimeError("当前数据库类型暂不支持自动备份")
|
||||||
|
record = BackupRecord(filename=filename, database_type=database_type, size_bytes=target.stat().st_size, sha256=file_sha256(target), created_by=user_id)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
cleanup_old_backups(db)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_backups(db: Session) -> None:
|
||||||
|
cutoff = datetime.now().astimezone() - timedelta(days=settings.backup_keep_days)
|
||||||
|
for record in db.query(BackupRecord).filter(BackupRecord.created_at < cutoff).all():
|
||||||
|
path = settings.backup_dir / record.filename
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(path: Path) -> None:
|
||||||
|
if not path.resolve().is_relative_to(settings.backup_dir.resolve()):
|
||||||
|
raise ValueError("备份文件必须位于 backups 目录")
|
||||||
|
url = settings.database_url
|
||||||
|
if url.startswith("sqlite"):
|
||||||
|
source = sqlite3.connect(str(path))
|
||||||
|
raw_path = Path(url.split("///", 1)[1])
|
||||||
|
target_path = raw_path if raw_path.is_absolute() else Path.cwd() / raw_path
|
||||||
|
temporary = target_path.with_suffix(".restore.tmp")
|
||||||
|
destination = sqlite3.connect(str(temporary))
|
||||||
|
try:
|
||||||
|
source.backup(destination)
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
shutil.move(temporary, target_path)
|
||||||
|
elif url.startswith(("postgresql", "postgres")):
|
||||||
|
result = subprocess.run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--dbname", postgres_cli_url(url), str(path)], capture_output=True, text=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"pg_restore 失败:{result.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
raise RuntimeError("当前数据库类型暂不支持恢复")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_name: str = os.getenv("APP_NAME", "激光材料数据平台")
|
||||||
|
secret: str = os.getenv("APP_SECRET", "dev-only-change-this-secret")
|
||||||
|
database_url: str = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'laser_data.db'}")
|
||||||
|
admin_username: str = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
|
admin_password: str = os.getenv("ADMIN_PASSWORD", "Admin@123456")
|
||||||
|
session_hours: int = int(os.getenv("SESSION_HOURS", "12"))
|
||||||
|
backup_keep_days: int = int(os.getenv("BACKUP_KEEP_DAYS", "30"))
|
||||||
|
backup_dir: Path = BASE_DIR / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
for directory in (BASE_DIR / "data", settings.backup_dir):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||||
|
engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True)
|
||||||
|
if settings.database_url.startswith("sqlite"):
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import CustomFieldDefinition, CustomFieldValue, MaterialRecord
|
||||||
|
|
||||||
|
|
||||||
|
def options_for(definition: CustomFieldDefinition) -> list[str]:
|
||||||
|
try:
|
||||||
|
return json.loads(definition.options_json) if definition.options_json else []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def definition_json(definition: CustomFieldDefinition, usage_count: int | None = None) -> dict[str, Any]:
|
||||||
|
data = {
|
||||||
|
"id": definition.id,
|
||||||
|
"field_key": definition.field_key,
|
||||||
|
"label": definition.label,
|
||||||
|
"input_type": definition.input_type,
|
||||||
|
"is_required": definition.is_required,
|
||||||
|
"is_active": definition.is_active,
|
||||||
|
"is_public": definition.is_public,
|
||||||
|
"unit": definition.unit,
|
||||||
|
"options": options_for(definition),
|
||||||
|
"sort_order": definition.sort_order,
|
||||||
|
"created_at": definition.created_at.isoformat(),
|
||||||
|
"updated_at": definition.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
if usage_count is not None:
|
||||||
|
data["usage_count"] = usage_count
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def active_definitions(db: Session) -> list[CustomFieldDefinition]:
|
||||||
|
return list(db.scalars(select(CustomFieldDefinition).where(CustomFieldDefinition.is_active.is_(True)).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all())
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(value: Any) -> bool:
|
||||||
|
return value is None or (isinstance(value, str) and not value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_raw_value(definition: CustomFieldDefinition, value: Any) -> str | None:
|
||||||
|
if _empty(value):
|
||||||
|
if definition.is_required:
|
||||||
|
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
||||||
|
return None
|
||||||
|
raw = str(value).strip()
|
||||||
|
try:
|
||||||
|
if definition.input_type == "number":
|
||||||
|
if not Decimal(raw).is_finite():
|
||||||
|
raise ValueError
|
||||||
|
elif definition.input_type == "integer":
|
||||||
|
number = Decimal(raw)
|
||||||
|
if isinstance(value, bool) or not number.is_finite() or number != number.to_integral_value():
|
||||||
|
raise ValueError
|
||||||
|
elif definition.input_type == "date":
|
||||||
|
date.fromisoformat(raw)
|
||||||
|
elif definition.input_type == "boolean":
|
||||||
|
if raw.lower() not in {"true", "false", "1", "0"}:
|
||||||
|
raise ValueError
|
||||||
|
raw = "true" if raw.lower() in {"true", "1"} else "false"
|
||||||
|
elif definition.input_type == "select" and raw not in options_for(definition):
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, InvalidOperation):
|
||||||
|
raise HTTPException(422, f"{definition.label}:数据格式不符合当前字段类型")
|
||||||
|
if len(raw) > 10000:
|
||||||
|
raise HTTPException(422, f"{definition.label}:内容不能超过 10000 个字符")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def validate_custom_values(db: Session, submitted: dict[str, Any], existing_record: MaterialRecord | None = None) -> dict[CustomFieldDefinition, str | None]:
|
||||||
|
definitions = active_definitions(db)
|
||||||
|
by_key = {item.field_key: item for item in definitions}
|
||||||
|
unknown = set(submitted) - set(by_key)
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(422, "包含未知或已停用的自定义字段")
|
||||||
|
existing_keys = {value.definition.field_key for value in existing_record.custom_values} if existing_record else set()
|
||||||
|
validated: dict[CustomFieldDefinition, str | None] = {}
|
||||||
|
for definition in definitions:
|
||||||
|
if definition.field_key not in submitted and existing_record is not None:
|
||||||
|
if definition.is_required and definition.field_key not in existing_keys:
|
||||||
|
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
||||||
|
continue
|
||||||
|
validated[definition] = validate_raw_value(definition, submitted.get(definition.field_key))
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
def apply_custom_values(db: Session, record: MaterialRecord, values: dict[CustomFieldDefinition, str | None]) -> None:
|
||||||
|
existing = {item.field_id: item for item in record.custom_values}
|
||||||
|
for definition, raw_value in values.items():
|
||||||
|
item = existing.get(definition.id)
|
||||||
|
if raw_value is None:
|
||||||
|
if item:
|
||||||
|
db.delete(item)
|
||||||
|
continue
|
||||||
|
if item:
|
||||||
|
item.raw_value = raw_value
|
||||||
|
else:
|
||||||
|
db.add(CustomFieldValue(record_id=record.id, field_id=definition.id, raw_value=raw_value))
|
||||||
|
|
||||||
|
|
||||||
|
def render_custom_value(value: CustomFieldValue) -> dict[str, Any]:
|
||||||
|
definition, raw = value.definition, value.raw_value or ""
|
||||||
|
compatible = True
|
||||||
|
processed: Any = raw
|
||||||
|
try:
|
||||||
|
if definition.input_type == "number":
|
||||||
|
number = Decimal(raw)
|
||||||
|
if not number.is_finite():
|
||||||
|
raise ValueError
|
||||||
|
processed = float(number)
|
||||||
|
elif definition.input_type == "integer":
|
||||||
|
number = Decimal(raw)
|
||||||
|
if number != number.to_integral_value():
|
||||||
|
raise ValueError
|
||||||
|
processed = int(number)
|
||||||
|
elif definition.input_type == "date":
|
||||||
|
processed = date.fromisoformat(raw).isoformat()
|
||||||
|
elif definition.input_type == "boolean":
|
||||||
|
if raw.lower() not in {"true", "false", "1", "0"}:
|
||||||
|
raise ValueError
|
||||||
|
processed = raw.lower() in {"true", "1"}
|
||||||
|
elif definition.input_type == "select" and raw not in options_for(definition):
|
||||||
|
compatible = False
|
||||||
|
except (ValueError, InvalidOperation):
|
||||||
|
compatible = False
|
||||||
|
processed = raw
|
||||||
|
display = "是" if processed is True else "否" if processed is False else str(processed)
|
||||||
|
if definition.unit and display:
|
||||||
|
display = f"{display} {definition.unit}"
|
||||||
|
return {
|
||||||
|
"field_key": definition.field_key,
|
||||||
|
"label": definition.label,
|
||||||
|
"input_type": definition.input_type,
|
||||||
|
"unit": definition.unit,
|
||||||
|
"raw_value": raw,
|
||||||
|
"value": processed,
|
||||||
|
"display_value": display,
|
||||||
|
"compatible": compatible,
|
||||||
|
"is_active": definition.is_active,
|
||||||
|
"is_public": definition.is_public,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def definition_usage_counts(db: Session) -> dict[int, int]:
|
||||||
|
rows = db.execute(select(CustomFieldValue.field_id, func.count(CustomFieldValue.id)).group_by(CustomFieldValue.field_id)).all()
|
||||||
|
return {field_id: count for field_id, count in rows}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""图片校验与元信息解析。不依赖 Pillow,只读文件头,避免解码带来的攻击面和额外依赖。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_THUMBNAIL_BYTES = 512 * 1024
|
||||||
|
MAX_IMAGES_PER_RECORD = 6
|
||||||
|
MAX_PIXELS = 60_000_000
|
||||||
|
MAX_EDGE = 20_000
|
||||||
|
# 只收 JPEG 和 PNG:客户端一律用 canvas 转成 JPEG(小尺寸 PNG 原样透传),
|
||||||
|
# WebP 没有任何收益,而它的 RIFF 头几乎不校验内容,很容易被拿来夹带任意二进制。
|
||||||
|
ALLOWED_TYPES = ("image/jpeg", "image/png")
|
||||||
|
EXTENSIONS = {"image/jpeg": ".jpg", "image/png": ".png"}
|
||||||
|
|
||||||
|
|
||||||
|
def _png(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR":
|
||||||
|
return None
|
||||||
|
return struct.unpack(">II", data[16:24])
|
||||||
|
|
||||||
|
|
||||||
|
JPEG_SOF_MARKERS = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg(data: bytes) -> tuple[int, int] | None:
|
||||||
|
if data[:3] != b"\xff\xd8\xff":
|
||||||
|
return None
|
||||||
|
index, size = 2, len(data)
|
||||||
|
while index + 3 < size:
|
||||||
|
if data[index] != 0xFF:
|
||||||
|
return None
|
||||||
|
marker = data[index + 1]
|
||||||
|
if marker == 0xFF:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if marker == 0x01 or 0xD0 <= marker <= 0xD9:
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
length = struct.unpack(">H", data[index + 2:index + 4])[0]
|
||||||
|
if length < 2:
|
||||||
|
return None
|
||||||
|
if marker in JPEG_SOF_MARKERS:
|
||||||
|
if index + 9 > size:
|
||||||
|
return None
|
||||||
|
height, width = struct.unpack(">HH", data[index + 5:index + 9])
|
||||||
|
return width, height
|
||||||
|
if marker == 0xDA:
|
||||||
|
return None
|
||||||
|
index += 2 + length
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
PARSERS = (("image/png", _png), ("image/jpeg", _jpeg))
|
||||||
|
|
||||||
|
|
||||||
|
def trim_trailing_payload(data: bytes, media_type: str) -> bytes:
|
||||||
|
"""截掉图像结束标记之后的内容。
|
||||||
|
|
||||||
|
否则可以在一张合法图片后面附上任意字节,把本站变成任意文件的托管点;
|
||||||
|
结束标记之后的数据本来就不属于图像,丢掉不会影响任何正常图片。
|
||||||
|
"""
|
||||||
|
if media_type == "image/jpeg":
|
||||||
|
end = data.rfind(b"\xff\xd9")
|
||||||
|
return data[:end + 2] if end > 0 else data
|
||||||
|
if media_type == "image/png":
|
||||||
|
end = data.rfind(b"IEND")
|
||||||
|
return data[:end + 8] if end > 0 else data # IEND 后面还有 4 字节 CRC
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def sniff(data: bytes) -> tuple[str, int, int] | None:
|
||||||
|
"""按文件头识别类型并取出尺寸;无法解析即视为非法图片。"""
|
||||||
|
for media_type, parser in PARSERS:
|
||||||
|
try:
|
||||||
|
size = parser(data)
|
||||||
|
except (struct.error, IndexError):
|
||||||
|
size = None
|
||||||
|
if size and size[0] > 0 and size[1] > 0:
|
||||||
|
return media_type, size[0], size[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
UNSAFE_NAME_CHARS = re.compile(r"[^\w.\-一-鿿()() ]")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_original_name(raw: str | None, content_type: str) -> str:
|
||||||
|
"""文件名完全由客户端 multipart 头控制,可能带引号、尖括号等用于注入的字符,入库前统一清洗。"""
|
||||||
|
name = (raw or "").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
name = UNSAFE_NAME_CHARS.sub("_", name).strip("._ ")[:120]
|
||||||
|
return name or f"image{EXTENSIONS.get(content_type, '.jpg')}"
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_image(data: bytes, limit: int = MAX_IMAGE_BYTES, label: str = "图片") -> tuple[str, int, int]:
|
||||||
|
"""校验图片字节并返回 (content_type, width, height),任何不合规都抛出中文错误。"""
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, f"{label}内容为空")
|
||||||
|
if len(data) > limit:
|
||||||
|
raise HTTPException(413, f"{label}不能超过 {limit // (1024 * 1024) or 1} MB")
|
||||||
|
result = sniff(data)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(415, f"{label}格式不支持,仅接受 JPEG 或 PNG")
|
||||||
|
media_type, width, height = result
|
||||||
|
if width > MAX_EDGE or height > MAX_EDGE or width * height > MAX_PIXELS:
|
||||||
|
raise HTTPException(413, f"{label}分辨率过大,请压缩后再上传")
|
||||||
|
return media_type, width, height
|
||||||
+830
@@ -0,0 +1,830 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import delete, func, inspect, or_, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.datastructures import UploadFile as FormUpload # request.form() 产出的是 starlette 的类型,不是 fastapi 的子类
|
||||||
|
|
||||||
|
from .backup_service import create_backup
|
||||||
|
from .config import BASE_DIR, settings
|
||||||
|
from .db import Base, SessionLocal, engine, get_db
|
||||||
|
from .dynamic_fields import active_definitions, apply_custom_values, definition_json, definition_usage_counts, render_custom_value, validate_custom_values
|
||||||
|
from .images import EXTENSIONS, MAX_IMAGE_BYTES, MAX_IMAGES_PER_RECORD, MAX_THUMBNAIL_BYTES, inspect_image, safe_original_name, trim_trailing_payload
|
||||||
|
from .models import AuditLog, BackupRecord, CustomFieldDefinition, CustomFieldValue, EntryBatch, MaterialRecord, RecordImage, User
|
||||||
|
from .schemas import RECORD_ONLY_FIELDS, CustomFieldDefinitionInput, EntryRecordSubmission, MaterialRecordInput
|
||||||
|
from .security import create_entry_session, create_session, entry_key_hash, get_entry_session_user, get_session_user, hash_password, revoke_entry_session, revoke_session, revoke_user_entry_sessions, validate_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||||||
|
SESSION_COOKIE = "laser_session"
|
||||||
|
ENTRY_COOKIE = "laser_entry_session"
|
||||||
|
UPLOAD_PATHS = ("/api/uploads/images", "/api/entry/uploads/images")
|
||||||
|
MAX_UPLOAD_BODY = MAX_IMAGE_BYTES + MAX_THUMBNAIL_BYTES + 16 * 1024
|
||||||
|
MAX_PENDING_IMAGES = 24
|
||||||
|
PENDING_IMAGE_HOURS = 2
|
||||||
|
UPLOAD_WINDOW_SECONDS = 600
|
||||||
|
UPLOAD_WINDOW_LIMIT = 90
|
||||||
|
_entry_attempts: dict[str, list[float]] = {}
|
||||||
|
_entry_attempts_lock = threading.Lock()
|
||||||
|
_upload_attempts: dict[int, list[float]] = {}
|
||||||
|
_upload_attempts_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_database() -> None:
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
user_columns = {column["name"] for column in inspect(engine).get_columns("users")}
|
||||||
|
with engine.begin() as connection:
|
||||||
|
if "entry_key_hash" not in user_columns:
|
||||||
|
connection.execute(text("ALTER TABLE users ADD COLUMN entry_key_hash VARCHAR(64)"))
|
||||||
|
if "entry_key_updated_at" not in user_columns:
|
||||||
|
connection.execute(text("ALTER TABLE users ADD COLUMN entry_key_updated_at TIMESTAMP"))
|
||||||
|
connection.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_entry_key_hash ON users(entry_key_hash)"))
|
||||||
|
if engine.dialect.name == "postgresql": # 老库的 integer 存不下超过 2 GiB 的备份,图片入库后很快会撞上
|
||||||
|
connection.execute(text("ALTER TABLE backup_records ALTER COLUMN size_bytes TYPE BIGINT"))
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if not db.scalar(select(User).limit(1)):
|
||||||
|
db.add(User(username=settings.admin_username, display_name="系统管理员", password_hash=hash_password(settings.admin_password), role="admin", must_change_password=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
initialize_database()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name, version="1.3.0", description="激光雕刻及材料物理特性数据管理接口", lifespan=lifespan)
|
||||||
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def limit_upload_body(request: Request, call_next):
|
||||||
|
"""FastAPI 在进入路由前就会把 multipart 落到临时文件,而且那发生在鉴权之前,所以体积必须在中间件里先挡掉。
|
||||||
|
|
||||||
|
没有 Content-Length(chunked)时无法预判体积,Starlette 又不限制文件 part 大小,
|
||||||
|
等于放任未登录请求往磁盘里写任意数据,因此直接拒绝。浏览器提交 FormData 一定带 Content-Length。
|
||||||
|
"""
|
||||||
|
if request.url.path in UPLOAD_PATHS and request.method == "POST":
|
||||||
|
declared = request.headers.get("content-length", "")
|
||||||
|
if not declared.isdigit():
|
||||||
|
return JSONResponse({"ok": False, "message": "上传请求缺少 Content-Length"}, status_code=411)
|
||||||
|
if int(declared) > MAX_UPLOAD_BODY:
|
||||||
|
return JSONResponse({"ok": False, "message": "上传内容过大,请压缩后重试"}, status_code=413)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str | None:
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_for(request: Request) -> str:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE, "")
|
||||||
|
return hmac.new(settings.secret.encode(), token.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def entry_csrf_for(request: Request) -> str:
|
||||||
|
token = request.cookies.get(ENTRY_COOKIE, "")
|
||||||
|
return hmac.new(settings.secret.encode(), f"entry:{token}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def check_csrf(request: Request) -> None:
|
||||||
|
supplied = request.headers.get("x-csrf-token") or request.query_params.get("csrf_token")
|
||||||
|
if not supplied or not hmac.compare_digest(supplied, csrf_for(request)):
|
||||||
|
raise HTTPException(403, "安全令牌无效,请刷新页面后重试")
|
||||||
|
|
||||||
|
|
||||||
|
def check_entry_csrf(request: Request) -> None:
|
||||||
|
supplied = request.headers.get("x-csrf-token")
|
||||||
|
if not supplied or not hmac.compare_digest(supplied, entry_csrf_for(request)):
|
||||||
|
raise HTTPException(403, "录入安全令牌无效,请刷新页面后重试")
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
|
user = get_session_user(db, request.cookies.get(SESSION_COOKIE))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def current_entry_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
|
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "录入授权已失效,请重新输入四位 Key")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: str):
|
||||||
|
def dependency(user: User = Depends(current_user)) -> User:
|
||||||
|
if user.role not in roles:
|
||||||
|
raise HTTPException(403, "当前账号无权执行此操作")
|
||||||
|
return user
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def audit(db: Session, request: Request, action: str, resource_type: str, resource_id: str | None = None, detail: dict | str | None = None, user_id: int | None = None) -> None:
|
||||||
|
rendered = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else detail
|
||||||
|
db.add(AuditLog(user_id=user_id, action=action, resource_type=resource_type, resource_id=resource_id, detail=rendered, ip_address=client_ip(request)))
|
||||||
|
|
||||||
|
|
||||||
|
def user_json(user: User) -> dict:
|
||||||
|
return {"id": user.id, "username": user.username, "display_name": user.display_name, "role": user.role, "is_active": user.is_active, "must_change_password": user.must_change_password, "entry_key_configured": bool(user.entry_key_hash), "created_at": user.created_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_entry_key_rate_limit(ip: str) -> None:
|
||||||
|
now_value = time.monotonic()
|
||||||
|
with _entry_attempts_lock:
|
||||||
|
attempts = [stamp for stamp in _entry_attempts.get(ip, []) if now_value - stamp < 900]
|
||||||
|
_entry_attempts[ip] = attempts
|
||||||
|
if len(attempts) >= 5:
|
||||||
|
raise HTTPException(429, "尝试次数过多,请 15 分钟后再试")
|
||||||
|
|
||||||
|
|
||||||
|
def record_failed_entry_key(ip: str) -> None:
|
||||||
|
with _entry_attempts_lock:
|
||||||
|
_entry_attempts.setdefault(ip, []).append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def clear_entry_key_attempts(ip: str) -> None:
|
||||||
|
with _entry_attempts_lock:
|
||||||
|
_entry_attempts.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_upload_rate_limit(user_id: int) -> None:
|
||||||
|
now_value = time.monotonic()
|
||||||
|
with _upload_attempts_lock:
|
||||||
|
attempts = [stamp for stamp in _upload_attempts.get(user_id, []) if now_value - stamp < UPLOAD_WINDOW_SECONDS]
|
||||||
|
if len(attempts) >= UPLOAD_WINDOW_LIMIT:
|
||||||
|
_upload_attempts[user_id] = attempts
|
||||||
|
raise HTTPException(429, "上传过于频繁,请稍后再试")
|
||||||
|
attempts.append(now_value)
|
||||||
|
_upload_attempts[user_id] = attempts
|
||||||
|
|
||||||
|
|
||||||
|
def purge_pending_images(db: Session) -> None:
|
||||||
|
"""清理没有挂到记录上的临时图片,避免中途放弃的录入长期占用数据库。
|
||||||
|
|
||||||
|
单独提交:后续校验若抛错会回滚整个事务,清理结果不能跟着一起丢,否则用户会被自己的旧图卡住。
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now().astimezone() - timedelta(hours=PENDING_IMAGE_HOURS)
|
||||||
|
db.execute(delete(RecordImage).where(RecordImage.record_id.is_(None), RecordImage.created_at < cutoff))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def image_json(image: RecordImage, public: bool = False) -> dict:
|
||||||
|
# 带上内容指纹:SQLite 删行后会复用 rowid,没有它,浏览器缓存的 immutable 图片可能张冠李戴。
|
||||||
|
version = image.sha256[:12]
|
||||||
|
full = f"/api/images/{image.id}?v={version}"
|
||||||
|
data = {
|
||||||
|
"id": image.id, "width": image.width, "height": image.height,
|
||||||
|
# 用 thumbnail_type 判断有无缩略图,绝不能读 image.thumbnail:那是 deferred 列,一读就把二进制拉进内存。
|
||||||
|
"url": full, "thumb_url": f"/api/images/{image.id}?variant=thumb&v={version}" if image.thumbnail_type else full,
|
||||||
|
}
|
||||||
|
if not public: # 原始文件名可能带姓名、车间、项目代号,公开看板不返回
|
||||||
|
data |= {"size_bytes": image.size_bytes, "content_type": image.content_type, "original_name": image.original_name}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def read_upload(upload: UploadFile, limit: int, label: str) -> bytes:
|
||||||
|
"""有界读取:多读 1 字节即可判定超限,避免把超大文件整个吃进内存。"""
|
||||||
|
data = upload.file.read(limit + 1)
|
||||||
|
if len(data) > limit:
|
||||||
|
raise HTTPException(413, f"{label}不能超过 {limit // (1024 * 1024) or 1} MB")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_image_upload(request: Request, db: Session, user: User) -> dict:
|
||||||
|
"""鉴权通过后才解析 multipart。
|
||||||
|
|
||||||
|
若把 UploadFile 写进端点签名,FastAPI 会在依赖(也就是鉴权和 CSRF 校验)之前就把整个请求体缓冲到临时文件,
|
||||||
|
等于让未登录请求也能驱动服务器落盘。手动解析可以把顺序倒过来。
|
||||||
|
"""
|
||||||
|
async with request.form(max_files=2, max_fields=2) as form:
|
||||||
|
upload, thumbnail = form.get("file"), form.get("thumbnail")
|
||||||
|
if not isinstance(upload, FormUpload):
|
||||||
|
raise HTTPException(400, "缺少图片文件")
|
||||||
|
if not isinstance(thumbnail, FormUpload):
|
||||||
|
thumbnail = None
|
||||||
|
# BLOB 落库是同步阻塞的,放回线程池,别把事件循环连同 /health 一起冻住
|
||||||
|
image = await run_in_threadpool(store_upload, db, request, user, upload, thumbnail)
|
||||||
|
return {"ok": True, "message": "图片已上传", "data": {**image_json(image), "token": image.token}}
|
||||||
|
|
||||||
|
|
||||||
|
def store_upload(db: Session, request: Request, user: User, upload: UploadFile, thumbnail: UploadFile | None) -> RecordImage:
|
||||||
|
purge_pending_images(db)
|
||||||
|
enforce_upload_rate_limit(user.id)
|
||||||
|
pending = db.scalar(select(func.count()).select_from(RecordImage).where(RecordImage.record_id.is_(None), RecordImage.created_by == user.id)) or 0
|
||||||
|
if pending >= MAX_PENDING_IMAGES:
|
||||||
|
raise HTTPException(429, f"未提交的图片已达 {MAX_PENDING_IMAGES} 张,请先保存记录,或等待 {PENDING_IMAGE_HOURS} 小时后自动清理")
|
||||||
|
data = read_upload(upload, MAX_IMAGE_BYTES, "图片")
|
||||||
|
content_type, width, height = inspect_image(data, MAX_IMAGE_BYTES, "图片")
|
||||||
|
data = trim_trailing_payload(data, content_type)
|
||||||
|
thumb_data = thumb_type = None
|
||||||
|
if thumbnail is not None and thumbnail.filename:
|
||||||
|
# 缩略图纯属加速,坏了就丢掉回落到原图,不能因为它让整次上传失败。
|
||||||
|
try:
|
||||||
|
raw = read_upload(thumbnail, MAX_THUMBNAIL_BYTES, "缩略图")
|
||||||
|
if raw:
|
||||||
|
thumb_type = inspect_image(raw, MAX_THUMBNAIL_BYTES, "缩略图")[0]
|
||||||
|
thumb_data = trim_trailing_payload(raw, thumb_type)
|
||||||
|
except HTTPException:
|
||||||
|
thumb_data = thumb_type = None
|
||||||
|
image = RecordImage(
|
||||||
|
token=secrets.token_urlsafe(32)[:64], content_type=content_type, original_name=safe_original_name(upload.filename, content_type), size_bytes=len(data),
|
||||||
|
width=width, height=height, sha256=hashlib.sha256(data).hexdigest(), data=data, thumbnail=thumb_data,
|
||||||
|
thumbnail_type=thumb_type, created_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
db.flush()
|
||||||
|
audit(db, request, "record_image_uploaded", "record_image", str(image.id), {"size_bytes": image.size_bytes, "content_type": content_type}, user.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(image)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def discard_pending_upload(db: Session, user: User, token: str) -> None:
|
||||||
|
"""撤回一张尚未挂到记录上的图片。用户在页面上删掉重拍时调用,及时释放待提交名额。"""
|
||||||
|
image = db.scalar(select(RecordImage).where(RecordImage.token == token, RecordImage.record_id.is_(None), RecordImage.created_by == user.id))
|
||||||
|
if not image:
|
||||||
|
raise HTTPException(404, "图片不存在或已提交")
|
||||||
|
db.delete(image)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_record_images(db: Session, record: MaterialRecord, user: User, tokens: list[str], keep_ids: list[int], editing: bool) -> int:
|
||||||
|
"""挂载新上传的图片并按前端给出的顺序重排;编辑时未列入 keep_ids 的旧图会被删除。"""
|
||||||
|
existing = {image.id: image for image in record.images} if record.id else {}
|
||||||
|
missing = [image_id for image_id in keep_ids if image_id not in existing]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(404, "部分图片不存在或不属于该记录")
|
||||||
|
if editing:
|
||||||
|
for image_id, image in existing.items():
|
||||||
|
if image_id not in keep_ids:
|
||||||
|
db.delete(image)
|
||||||
|
ordered = [existing[image_id] for image_id in keep_ids]
|
||||||
|
for token in tokens:
|
||||||
|
image = db.scalar(select(RecordImage).where(RecordImage.token == token, RecordImage.record_id.is_(None), RecordImage.created_by == user.id))
|
||||||
|
if not image:
|
||||||
|
raise HTTPException(404, "图片上传已失效,请重新上传后再提交")
|
||||||
|
ordered.append(image)
|
||||||
|
if len(ordered) > MAX_IMAGES_PER_RECORD:
|
||||||
|
raise HTTPException(400, f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片")
|
||||||
|
for position, image in enumerate(ordered):
|
||||||
|
image.record_id = record.id
|
||||||
|
image.sort_order = position
|
||||||
|
return len(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def save_material_record(db: Session, values: dict, user: User) -> MaterialRecord:
|
||||||
|
experiment_id = values["experiment_id"]
|
||||||
|
if db.scalar(select(MaterialRecord).where(MaterialRecord.experiment_id == experiment_id)):
|
||||||
|
raise HTTPException(409, "实验 ID 已存在,请使用唯一编号")
|
||||||
|
batch = EntryBatch(entry_no=datetime.now().strftime("ENT%Y%m%d") + secrets.token_hex(4).upper(), record_count=1, created_by=user.id)
|
||||||
|
db.add(batch)
|
||||||
|
db.flush()
|
||||||
|
record = MaterialRecord(entry_batch_id=batch.id, **values)
|
||||||
|
db.add(record)
|
||||||
|
db.flush()
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def apply_definition_input(definition: CustomFieldDefinition, payload: CustomFieldDefinitionInput) -> None:
|
||||||
|
if payload.input_type == "select" and not payload.options:
|
||||||
|
raise HTTPException(400, "下拉选项字段至少需要一个选项")
|
||||||
|
definition.label = payload.label
|
||||||
|
definition.input_type = payload.input_type
|
||||||
|
definition.is_required = payload.is_required
|
||||||
|
definition.is_active = payload.is_active
|
||||||
|
definition.is_public = payload.is_public
|
||||||
|
definition.unit = payload.unit
|
||||||
|
definition.options_json = json.dumps(payload.options if payload.input_type == "select" else [], ensure_ascii=False)
|
||||||
|
definition.sort_order = payload.sort_order
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_error(request: Request, exc: HTTPException):
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
return JSONResponse({"ok": False, "message": exc.detail}, status_code=exc.status_code)
|
||||||
|
return templates.TemplateResponse(request, "error.html", {"message": exc.detail}, status_code=exc.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_error(request: Request, exc: RequestValidationError):
|
||||||
|
errors = []
|
||||||
|
for item in exc.errors():
|
||||||
|
field = ".".join(str(x) for x in item.get("loc", [])[1:]) or "请求"
|
||||||
|
errors.append(f"{field}:{item.get('msg', '格式错误')}")
|
||||||
|
return JSONResponse({"ok": False, "message": ";".join(errors)}, status_code=422)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["system"])
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "ok", "time": datetime.now().astimezone().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
if get_session_user(db, request.cookies.get(SESSION_COOKIE)):
|
||||||
|
return RedirectResponse("/admin", 303)
|
||||||
|
return templates.TemplateResponse(request, "login.html", {"app_name": settings.app_name})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login", tags=["auth"])
|
||||||
|
def login(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)):
|
||||||
|
user = db.scalar(select(User).where(User.username == username.strip()))
|
||||||
|
if not user or not user.is_active or not verify_password(password, user.password_hash):
|
||||||
|
audit(db, request, "login_failed", "auth", detail={"username": username})
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(401, "用户名或密码错误")
|
||||||
|
token = create_session(db, user, client_ip(request), request.headers.get("user-agent"))
|
||||||
|
audit(db, request, "login", "auth", user_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
response = JSONResponse({"ok": True, "message": "登录成功", "must_change_password": user.must_change_password})
|
||||||
|
response.set_cookie(SESSION_COOKIE, token, max_age=settings.session_hours * 3600, httponly=True, samesite="lax", secure=request.url.scheme == "https")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout", tags=["auth"])
|
||||||
|
def logout(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
check_csrf(request)
|
||||||
|
audit(db, request, "logout", "auth", user_id=user.id)
|
||||||
|
revoke_session(db, request.cookies.get(SESSION_COOKIE))
|
||||||
|
response = JSONResponse({"ok": True})
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me", tags=["auth"])
|
||||||
|
def me(user: User = Depends(current_user)):
|
||||||
|
return {"ok": True, "data": user_json(user)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/me/password", tags=["auth"])
|
||||||
|
async def change_password(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
check_csrf(request)
|
||||||
|
payload = await request.json()
|
||||||
|
old_password, new_password = payload.get("old_password", ""), payload.get("new_password", "")
|
||||||
|
if not verify_password(old_password, user.password_hash):
|
||||||
|
raise HTTPException(400, "原密码不正确")
|
||||||
|
if error := validate_password(new_password):
|
||||||
|
raise HTTPException(400, error)
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
audit(db, request, "password_changed", "user", str(user.id), user_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "密码已更新"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/me/entry-key", tags=["auth"])
|
||||||
|
async def set_entry_key(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||||||
|
check_csrf(request)
|
||||||
|
payload = await request.json()
|
||||||
|
password, key = str(payload.get("password", "")), str(payload.get("key", ""))
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
raise HTTPException(400, "登录密码不正确")
|
||||||
|
if not key.isdigit() or len(key) != 4:
|
||||||
|
raise HTTPException(400, "录入 Key 必须是四位数字")
|
||||||
|
digest = entry_key_hash(key)
|
||||||
|
owner = db.scalar(select(User).where(User.entry_key_hash == digest, User.id != user.id))
|
||||||
|
if owner:
|
||||||
|
raise HTTPException(409, "该 Key 已被使用,请选择其他四位数字")
|
||||||
|
user.entry_key_hash = digest
|
||||||
|
user.entry_key_updated_at = datetime.now().astimezone()
|
||||||
|
revoke_user_entry_sessions(db, user.id)
|
||||||
|
audit(db, request, "entry_key_changed", "user", str(user.id), user_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "四位录入 Key 已更新,原录入授权已失效"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/me/entry-key", tags=["auth"])
|
||||||
|
async def remove_entry_key(request: Request, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
check_csrf(request)
|
||||||
|
payload = await request.json()
|
||||||
|
if not verify_password(str(payload.get("password", "")), user.password_hash):
|
||||||
|
raise HTTPException(400, "登录密码不正确")
|
||||||
|
user.entry_key_hash = None
|
||||||
|
user.entry_key_updated_at = None
|
||||||
|
revoke_user_entry_sessions(db, user.id)
|
||||||
|
audit(db, request, "entry_key_removed", "user", str(user.id), user_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "录入 Key 已停用"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def public_dashboard(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "public_dashboard.html", {"app_name": settings.app_name})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/entry", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def entry_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||||||
|
return templates.TemplateResponse(request, "entry.html", {"app_name": settings.app_name, "entry_authorized": bool(user), "entry_csrf_token": entry_csrf_for(request) if user else ""})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_session_user(db, request.cookies.get(SESSION_COOKIE))
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
counts = {
|
||||||
|
"records": db.scalar(select(func.count()).select_from(MaterialRecord)) or 0,
|
||||||
|
"batches": db.scalar(select(func.count()).select_from(EntryBatch)) or 0,
|
||||||
|
"users": db.scalar(select(func.count()).select_from(User).where(User.is_active.is_(True))) or 0,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", {"app_name": settings.app_name, "user": user, "counts": counts, "csrf_token": csrf_for(request)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/public/dashboard", tags=["public"])
|
||||||
|
def public_data(q: str = "", category: str = "", page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db)):
|
||||||
|
conditions = []
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
conditions.append(or_(MaterialRecord.experiment_id.ilike(pattern), MaterialRecord.material_name.ilike(pattern)))
|
||||||
|
if category:
|
||||||
|
conditions.append(MaterialRecord.material_category == category)
|
||||||
|
query = select(MaterialRecord)
|
||||||
|
count_query = select(func.count()).select_from(MaterialRecord)
|
||||||
|
for condition in conditions:
|
||||||
|
query = query.where(condition)
|
||||||
|
count_query = count_query.where(condition)
|
||||||
|
total = db.scalar(count_query) or 0
|
||||||
|
rows = db.scalars(query.order_by(MaterialRecord.test_date.desc(), MaterialRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||||||
|
categories = db.scalars(select(MaterialRecord.material_category).distinct().order_by(MaterialRecord.material_category)).all()
|
||||||
|
recent_count = db.scalar(select(func.count()).select_from(MaterialRecord).where(MaterialRecord.created_at >= datetime.now().astimezone() - timedelta(days=30))) or 0
|
||||||
|
average_clarity = db.scalar(select(func.avg(MaterialRecord.pattern_clarity_score)))
|
||||||
|
data = [{
|
||||||
|
"id": row.id, "experiment_id": row.experiment_id, "test_date": row.test_date.isoformat(),
|
||||||
|
"material_category": row.material_category, "material_name": row.material_name,
|
||||||
|
"material_thickness": row.material_thickness, "actual_output_power": row.actual_output_power,
|
||||||
|
"scanning_speed": row.scanning_speed, "is_cut_through": row.is_cut_through,
|
||||||
|
"pattern_clarity_score": row.pattern_clarity_score, "presentation_balance_score": row.presentation_balance_score,
|
||||||
|
} for row in rows]
|
||||||
|
return {"ok": True, "data": data, "stats": {"total": total, "category_count": len(categories), "recent_count": recent_count, "average_clarity": round(float(average_clarity), 2) if average_clarity is not None else None}, "categories": categories, "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/public/records/{record_id}", tags=["public"])
|
||||||
|
def public_record_detail(record_id: int, db: Session = Depends(get_db)):
|
||||||
|
row = db.get(MaterialRecord, record_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "数据记录不存在")
|
||||||
|
fields = (
|
||||||
|
"experiment_id", "test_date", "material_category", "material_name", "apparent_density", "uv_absorption_355nm",
|
||||||
|
"material_thickness", "moisture_content", "thermal_conductivity", "initial_decomposition_temp",
|
||||||
|
"melting_vaporization_temp", "specific_heat_capacity", "carbon_residue_rate", "surface_roughness", "hardness",
|
||||||
|
"actual_output_power", "display_current", "scanning_speed", "pulse_frequency", "pulse_width", "defocus_amount",
|
||||||
|
"scan_line_spacing", "filling_method", "processing_size", "is_cut_through", "carbonized_edge_width", "etching_depth",
|
||||||
|
"is_fire_smolder", "pattern_clarity_score", "presentation_balance_score",
|
||||||
|
)
|
||||||
|
data = {field: (getattr(row, field).isoformat() if hasattr(getattr(row, field), "isoformat") else getattr(row, field)) for field in fields}
|
||||||
|
data["custom_fields"] = [render_custom_value(value) for value in row.custom_values if value.definition.is_public]
|
||||||
|
data["images"] = [image_json(image, public=True) for image in row.images]
|
||||||
|
return {"ok": True, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/form-fields", tags=["fields"])
|
||||||
|
def form_fields(db: Session = Depends(get_db)):
|
||||||
|
return {"ok": True, "data": [definition_json(item) for item in active_definitions(db)]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/entry/auth", tags=["entry"])
|
||||||
|
async def authorize_entry(request: Request, db: Session = Depends(get_db)):
|
||||||
|
payload = await request.json()
|
||||||
|
key = str(payload.get("key", ""))
|
||||||
|
if not key.isdigit() or len(key) != 4:
|
||||||
|
raise HTTPException(400, "Key 必须是四位数字")
|
||||||
|
ip = client_ip(request) or "unknown"
|
||||||
|
enforce_entry_key_rate_limit(ip)
|
||||||
|
user = db.scalar(select(User).where(User.entry_key_hash == entry_key_hash(key), User.is_active.is_(True), User.role.in_(("admin", "uploader"))))
|
||||||
|
if not user:
|
||||||
|
record_failed_entry_key(ip)
|
||||||
|
audit(db, request, "entry_key_failed", "entry_access", detail={"remaining_attempts": max(0, 4 - len(_entry_attempts.get(ip, [])))})
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(401, "Key 不正确")
|
||||||
|
clear_entry_key_attempts(ip)
|
||||||
|
token = create_entry_session(db, user, ip)
|
||||||
|
audit(db, request, "entry_key_verified", "entry_access", user_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
response = JSONResponse({"ok": True, "message": "验证成功"})
|
||||||
|
response.set_cookie(ENTRY_COOKIE, token, max_age=1800, httponly=True, samesite="strict", secure=request.url.scheme == "https")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/entry/session", tags=["entry"])
|
||||||
|
def entry_session(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||||||
|
return {"ok": True, "authorized": bool(user), "csrf_token": entry_csrf_for(request) if user else None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/entry/uploads/images", tags=["entry"])
|
||||||
|
async def upload_entry_image(request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||||||
|
"""multipart 表单:file(必填)、thumbnail(可选)。"""
|
||||||
|
check_entry_csrf(request)
|
||||||
|
return await handle_image_upload(request, db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/entry/records", tags=["entry"])
|
||||||
|
def create_entry_record(payload: EntryRecordSubmission, request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||||||
|
check_entry_csrf(request)
|
||||||
|
if not hmac.compare_digest(payload.confirm_username.strip(), user.username):
|
||||||
|
raise HTTPException(403, "用户名与 Key 所属账号不一致")
|
||||||
|
if payload.image_ids:
|
||||||
|
raise HTTPException(400, "新建记录不能引用已有图片")
|
||||||
|
custom_values = validate_custom_values(db, payload.custom_fields)
|
||||||
|
values = payload.model_dump(exclude={"confirm_username", *RECORD_ONLY_FIELDS})
|
||||||
|
record = save_material_record(db, values, user)
|
||||||
|
apply_custom_values(db, record, custom_values)
|
||||||
|
image_count = apply_record_images(db, record, user, payload.image_tokens, [], editing=False)
|
||||||
|
audit(db, request, "entry_record_created", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "实验数据已保存", "data": {"id": record.id, "experiment_id": record.experiment_id}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/entry/logout", tags=["entry"])
|
||||||
|
def entry_logout(request: Request, db: Session = Depends(get_db)):
|
||||||
|
check_entry_csrf(request)
|
||||||
|
revoke_entry_session(db, request.cookies.get(ENTRY_COOKIE))
|
||||||
|
response = JSONResponse({"ok": True})
|
||||||
|
response.delete_cookie(ENTRY_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records", tags=["records"])
|
||||||
|
def records(q: str = "", category: str = "", page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
query = select(MaterialRecord)
|
||||||
|
count_query = select(func.count()).select_from(MaterialRecord)
|
||||||
|
conditions = []
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
conditions.append(or_(MaterialRecord.experiment_id.ilike(pattern), MaterialRecord.material_name.ilike(pattern)))
|
||||||
|
if category:
|
||||||
|
conditions.append(MaterialRecord.material_category == category)
|
||||||
|
for condition in conditions:
|
||||||
|
query = query.where(condition)
|
||||||
|
count_query = count_query.where(condition)
|
||||||
|
total = db.scalar(count_query) or 0
|
||||||
|
rows = db.scalars(query.order_by(MaterialRecord.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||||||
|
data = [{
|
||||||
|
"id": x.id, "experiment_id": x.experiment_id, "test_date": x.test_date.isoformat(), "material_category": x.material_category,
|
||||||
|
"material_name": x.material_name, "material_thickness": x.material_thickness, "actual_output_power": x.actual_output_power,
|
||||||
|
"scanning_speed": x.scanning_speed, "is_cut_through": x.is_cut_through, "pattern_clarity_score": x.pattern_clarity_score,
|
||||||
|
} for x in rows]
|
||||||
|
return {"ok": True, "data": data, "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/uploads/images", tags=["records"])
|
||||||
|
async def upload_record_image(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||||||
|
"""multipart 表单:file(必填)、thumbnail(可选)。"""
|
||||||
|
check_csrf(request)
|
||||||
|
return await handle_image_upload(request, db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/uploads/images/{token}", tags=["records"])
|
||||||
|
def discard_record_image(token: str, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||||||
|
check_csrf(request)
|
||||||
|
discard_pending_upload(db, user, token)
|
||||||
|
return {"ok": True, "message": "图片已撤回"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/entry/uploads/images/{token}", tags=["entry"])
|
||||||
|
def discard_entry_image(token: str, request: Request, db: Session = Depends(get_db), user: User = Depends(current_entry_user)):
|
||||||
|
check_entry_csrf(request)
|
||||||
|
discard_pending_upload(db, user, token)
|
||||||
|
return {"ok": True, "message": "图片已撤回"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/images/{image_id}", tags=["records"])
|
||||||
|
def record_image(image_id: int, request: Request, variant: str = Query("full", pattern="^(full|thumb)$"), v: str = Query("", max_length=64), db: Session = Depends(get_db)):
|
||||||
|
# 先只取元信息:命中 304 或只要缩略图时,没必要把整张原图读进内存。
|
||||||
|
meta = db.execute(select(
|
||||||
|
RecordImage.record_id, RecordImage.created_by, RecordImage.sha256, RecordImage.content_type,
|
||||||
|
RecordImage.thumbnail_type, func.length(RecordImage.thumbnail),
|
||||||
|
).where(RecordImage.id == image_id)).first()
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, "图片不存在")
|
||||||
|
record_id, created_by, sha256, content_type, thumbnail_type, thumbnail_size = meta
|
||||||
|
if record_id is None:
|
||||||
|
viewer = get_session_user(db, request.cookies.get(SESSION_COOKIE)) or get_entry_session_user(db, request.cookies.get(ENTRY_COOKIE))
|
||||||
|
if not viewer or viewer.id != created_by:
|
||||||
|
raise HTTPException(404, "图片不存在") # 不用 403,避免泄露"这个编号确实存在"
|
||||||
|
use_thumb = variant == "thumb" and bool(thumbnail_size)
|
||||||
|
media_type = (thumbnail_type or content_type) if use_thumb else content_type
|
||||||
|
headers = {
|
||||||
|
"ETag": f'"{sha256[:32]}-{"thumb" if use_thumb else "full"}"',
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable" if record_id else "private, no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||||
|
"Content-Disposition": f'inline; filename="image-{image_id}{EXTENSIONS.get(media_type, ".bin")}"',
|
||||||
|
}
|
||||||
|
if request.headers.get("if-none-match") == headers["ETag"]:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
payload = db.scalar(select(RecordImage.thumbnail if use_thumb else RecordImage.data).where(RecordImage.id == image_id))
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(404, "图片不存在")
|
||||||
|
return Response(payload, media_type=media_type, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/records", tags=["records"])
|
||||||
|
def create_record(payload: MaterialRecordInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||||||
|
check_csrf(request)
|
||||||
|
if payload.image_ids:
|
||||||
|
raise HTTPException(400, "新建记录不能引用已有图片")
|
||||||
|
custom_values = validate_custom_values(db, payload.custom_fields)
|
||||||
|
record = save_material_record(db, payload.model_dump(exclude=RECORD_ONLY_FIELDS), user)
|
||||||
|
apply_custom_values(db, record, custom_values)
|
||||||
|
image_count = apply_record_images(db, record, user, payload.image_tokens, [], editing=False)
|
||||||
|
audit(db, request, "record_created", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return {"ok": True, "message": "实验数据已保存", "data": {"id": record.id, "experiment_id": record.experiment_id}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records/{record_id}", tags=["records"])
|
||||||
|
def record_detail(record_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
row = db.get(MaterialRecord, record_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "数据记录不存在")
|
||||||
|
excluded = {"_sa_instance_state", "batch", "entry_batch", "custom_values", "images"}
|
||||||
|
data = {key: (value.isoformat() if isinstance(value, (datetime,)) or hasattr(value, "isoformat") else value) for key, value in vars(row).items() if key not in excluded}
|
||||||
|
data["entry_no"] = row.entry_batch.entry_no
|
||||||
|
data["custom_fields"] = {value.definition.field_key: render_custom_value(value) for value in row.custom_values}
|
||||||
|
data["images"] = [image_json(image) for image in row.images]
|
||||||
|
return {"ok": True, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/records/{record_id}", tags=["records"])
|
||||||
|
def update_record(record_id: int, payload: MaterialRecordInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin", "uploader"))):
|
||||||
|
check_csrf(request)
|
||||||
|
record = db.get(MaterialRecord, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(404, "数据记录不存在")
|
||||||
|
duplicate = db.scalar(select(MaterialRecord).where(MaterialRecord.experiment_id == payload.experiment_id, MaterialRecord.id != record_id))
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(409, "实验 ID 已被其他记录使用")
|
||||||
|
custom_values = validate_custom_values(db, payload.custom_fields, existing_record=record)
|
||||||
|
for field, value in payload.model_dump(exclude=RECORD_ONLY_FIELDS).items():
|
||||||
|
setattr(record, field, value)
|
||||||
|
apply_custom_values(db, record, custom_values)
|
||||||
|
keep_ids = payload.image_ids if payload.image_ids is not None else [image.id for image in record.images]
|
||||||
|
image_count = apply_record_images(db, record, user, payload.image_tokens, keep_ids, editing=True)
|
||||||
|
audit(db, request, "record_updated", "material_record", str(record.id), {"experiment_id": record.experiment_id, "images": image_count}, user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "实验数据已更新", "data": {"id": record.id}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/records/{record_id}", tags=["records"])
|
||||||
|
def delete_record(record_id: int, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
record = db.get(MaterialRecord, record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(404, "数据记录不存在")
|
||||||
|
batch = record.entry_batch
|
||||||
|
audit(db, request, "record_deleted", "material_record", str(record.id), {"experiment_id": record.experiment_id}, user.id)
|
||||||
|
db.delete(record)
|
||||||
|
db.flush()
|
||||||
|
db.delete(batch)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "实验数据已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/field-definitions", tags=["fields"])
|
||||||
|
def field_definitions(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
counts = definition_usage_counts(db)
|
||||||
|
rows = db.scalars(select(CustomFieldDefinition).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all()
|
||||||
|
return {"ok": True, "data": [definition_json(item, counts.get(item.id, 0)) for item in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/field-definitions", tags=["fields"])
|
||||||
|
def create_field_definition(payload: CustomFieldDefinitionInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
definition = CustomFieldDefinition(field_key=f"custom_{secrets.token_hex(6)}", label=payload.label, input_type=payload.input_type, created_by=user.id)
|
||||||
|
apply_definition_input(definition, payload)
|
||||||
|
db.add(definition)
|
||||||
|
db.flush()
|
||||||
|
audit(db, request, "field_definition_created", "custom_field_definition", str(definition.id), {"field_key": definition.field_key, "label": definition.label, "input_type": definition.input_type}, user.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(definition)
|
||||||
|
return {"ok": True, "message": "数据字段已添加", "data": definition_json(definition, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/field-definitions/{field_id}", tags=["fields"])
|
||||||
|
def update_field_definition(field_id: int, payload: CustomFieldDefinitionInput, request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
definition = db.get(CustomFieldDefinition, field_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(404, "数据字段不存在")
|
||||||
|
before = {"label": definition.label, "input_type": definition.input_type, "is_required": definition.is_required, "is_active": definition.is_active}
|
||||||
|
apply_definition_input(definition, payload)
|
||||||
|
usage_count = db.scalar(select(func.count()).select_from(CustomFieldValue).where(CustomFieldValue.field_id == definition.id)) or 0
|
||||||
|
audit(db, request, "field_definition_updated", "custom_field_definition", str(definition.id), {"before": before, "after": {"label": definition.label, "input_type": definition.input_type, "is_required": definition.is_required, "is_active": definition.is_active}, "preserved_values": usage_count}, user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": f"字段设置已更新,保留了 {usage_count} 条历史值", "data": definition_json(definition, usage_count)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users", tags=["users"])
|
||||||
|
def users(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
return {"ok": True, "data": [user_json(x) for x in db.scalars(select(User).order_by(User.created_at.desc())).all()]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users", tags=["users"])
|
||||||
|
async def create_user(request: Request, db: Session = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
payload = await request.json()
|
||||||
|
username, password = str(payload.get("username", "")).strip(), str(payload.get("password", ""))
|
||||||
|
role = payload.get("role", "uploader")
|
||||||
|
if not username or len(username) > 64 or not username.replace("_", "").replace("-", "").isalnum():
|
||||||
|
raise HTTPException(400, "用户名仅可包含字母、数字、下划线和短横线")
|
||||||
|
if role not in {"admin", "uploader", "viewer"}:
|
||||||
|
raise HTTPException(400, "无效角色")
|
||||||
|
if error := validate_password(password):
|
||||||
|
raise HTTPException(400, error)
|
||||||
|
if db.scalar(select(User).where(User.username == username)):
|
||||||
|
raise HTTPException(409, "用户名已存在")
|
||||||
|
new_user = User(username=username, display_name=str(payload.get("display_name", username)).strip()[:100], password_hash=hash_password(password), role=role, must_change_password=True)
|
||||||
|
db.add(new_user)
|
||||||
|
db.flush()
|
||||||
|
audit(db, request, "user_created", "user", str(new_user.id), {"username": username, "role": role}, admin.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
return {"ok": True, "message": "用户已创建", "data": user_json(new_user)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/users/{user_id}", tags=["users"])
|
||||||
|
async def update_user(user_id: int, request: Request, db: Session = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
target = db.get(User, user_id)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "用户不存在")
|
||||||
|
payload = await request.json()
|
||||||
|
if "role" in payload:
|
||||||
|
if payload["role"] not in {"admin", "uploader", "viewer"}:
|
||||||
|
raise HTTPException(400, "无效角色")
|
||||||
|
if target.role == "admin" and payload["role"] != "admin":
|
||||||
|
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True))) or 0
|
||||||
|
if admin_count <= 1:
|
||||||
|
raise HTTPException(400, "系统至少需要保留一名启用的管理员")
|
||||||
|
target.role = payload["role"]
|
||||||
|
if "is_active" in payload:
|
||||||
|
if target.id == admin.id and not payload["is_active"]:
|
||||||
|
raise HTTPException(400, "不能停用当前登录账号")
|
||||||
|
if target.role == "admin" and target.is_active and not payload["is_active"]:
|
||||||
|
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True))) or 0
|
||||||
|
if admin_count <= 1:
|
||||||
|
raise HTTPException(400, "系统至少需要保留一名启用的管理员")
|
||||||
|
target.is_active = bool(payload["is_active"])
|
||||||
|
if payload.get("new_password"):
|
||||||
|
if error := validate_password(payload["new_password"]):
|
||||||
|
raise HTTPException(400, error)
|
||||||
|
target.password_hash = hash_password(payload["new_password"])
|
||||||
|
target.must_change_password = True
|
||||||
|
audit(db, request, "user_updated", "user", str(target.id), {"role": target.role, "is_active": target.is_active}, admin.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "用户已更新", "data": user_json(target)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/audits", tags=["audit"])
|
||||||
|
def audits(page: int = Query(1, ge=1), page_size: int = Query(30, ge=1, le=100), db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
||||||
|
rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
||||||
|
return {"ok": True, "data": [{"id": x.id, "user": x.user.display_name if x.user else "匿名", "action": x.action, "resource_type": x.resource_type, "resource_id": x.resource_id, "detail": x.detail, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in rows], "pagination": {"page": page, "page_size": page_size, "total": total}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/backups", tags=["backups"])
|
||||||
|
def backups(db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
rows = db.scalars(select(BackupRecord).order_by(BackupRecord.created_at.desc())).all()
|
||||||
|
return {"ok": True, "data": [{"id": x.id, "filename": x.filename, "database_type": x.database_type, "size_bytes": x.size_bytes, "sha256": x.sha256, "creator": x.creator.display_name if x.creator else "定时任务", "created_at": x.created_at.isoformat()} for x in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/backups", tags=["backups"])
|
||||||
|
def backup_now(request: Request, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
check_csrf(request)
|
||||||
|
try:
|
||||||
|
item = create_backup(db, user.id)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(500, str(exc))
|
||||||
|
audit(db, request, "backup_created", "backup", str(item.id), {"filename": item.filename}, user.id)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "备份创建成功", "data": {"id": item.id, "filename": item.filename}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/backups/{backup_id}/download", tags=["backups"])
|
||||||
|
def download_backup(backup_id: int, db: Session = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||||
|
item = db.get(BackupRecord, backup_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "备份不存在")
|
||||||
|
path = settings.backup_dir / item.filename
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "备份文件已丢失")
|
||||||
|
return FileResponse(path, filename=item.filename, media_type="application/octet-stream")
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, deferred, mapped_column, relationship
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now().astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(100))
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(300))
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="uploader", index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
entry_key_hash: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
||||||
|
entry_key_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSession(Base):
|
||||||
|
__tablename__ = "login_sessions"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class EntrySession(Base):
|
||||||
|
__tablename__ = "entry_sessions"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class EntryBatch(Base):
|
||||||
|
__tablename__ = "entry_batches"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
entry_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||||
|
record_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="success", index=True)
|
||||||
|
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||||
|
creator: Mapped[User] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialRecord(Base):
|
||||||
|
__tablename__ = "material_records"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entry_batch_id", "experiment_id", name="uq_entry_experiment"),
|
||||||
|
Index("ix_record_material_date", "material_category", "test_date"),
|
||||||
|
)
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
entry_batch_id: Mapped[int] = mapped_column(ForeignKey("entry_batches.id", ondelete="CASCADE"), index=True)
|
||||||
|
experiment_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
test_date: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
material_category: Mapped[str] = mapped_column(String(100), index=True)
|
||||||
|
material_name: Mapped[str] = mapped_column(String(200), index=True)
|
||||||
|
apparent_density: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
uv_absorption_355nm: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
material_thickness: Mapped[float] = mapped_column(Float)
|
||||||
|
moisture_content: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
thermal_conductivity: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
initial_decomposition_temp: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
melting_vaporization_temp: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
specific_heat_capacity: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
carbon_residue_rate: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
surface_roughness: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
hardness: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
|
actual_output_power: Mapped[float] = mapped_column(Float)
|
||||||
|
display_current: Mapped[float] = mapped_column(Float)
|
||||||
|
scanning_speed: Mapped[float] = mapped_column(Float)
|
||||||
|
pulse_frequency: Mapped[float] = mapped_column(Float)
|
||||||
|
pulse_width: Mapped[float] = mapped_column(Float)
|
||||||
|
defocus_amount: Mapped[float] = mapped_column(Float)
|
||||||
|
scan_line_spacing: Mapped[float] = mapped_column(Float)
|
||||||
|
filling_method: Mapped[str] = mapped_column(String(100))
|
||||||
|
processing_size: Mapped[str] = mapped_column(String(100))
|
||||||
|
is_cut_through: Mapped[bool] = mapped_column(Boolean)
|
||||||
|
carbonized_edge_width: Mapped[float] = mapped_column(Float)
|
||||||
|
etching_depth: Mapped[float] = mapped_column(Float)
|
||||||
|
is_fire_smolder: Mapped[bool] = mapped_column(Boolean)
|
||||||
|
pattern_clarity_score: Mapped[float] = mapped_column(Float)
|
||||||
|
presentation_balance_score: Mapped[float] = mapped_column(Float)
|
||||||
|
finished_image_filename: Mapped[str] = mapped_column(String(255))
|
||||||
|
remarks: Mapped[str] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
entry_batch: Mapped[EntryBatch] = relationship()
|
||||||
|
custom_values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="record", cascade="all, delete-orphan")
|
||||||
|
images: Mapped[list["RecordImage"]] = relationship(back_populates="record", cascade="all, delete-orphan", passive_deletes=True, order_by="RecordImage.sort_order, RecordImage.id")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordImage(Base):
|
||||||
|
"""实验成品图。二进制直接入库,现有数据库备份即可覆盖图片,无需额外挂载卷。"""
|
||||||
|
|
||||||
|
__tablename__ = "record_images"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
record_id: Mapped[Optional[int]] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(40))
|
||||||
|
original_name: Mapped[str] = mapped_column(String(255))
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer)
|
||||||
|
width: Mapped[int] = mapped_column(Integer)
|
||||||
|
height: Mapped[int] = mapped_column(Integer)
|
||||||
|
sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
# deferred:详情接口只需要元信息,默认加载会把每条记录的几 MB 图片全读进内存,
|
||||||
|
# 而公开详情是匿名可访问的,等于把看板变成内存放大器。
|
||||||
|
data: Mapped[bytes] = deferred(mapped_column(LargeBinary))
|
||||||
|
thumbnail: Mapped[Optional[bytes]] = deferred(mapped_column(LargeBinary, nullable=True))
|
||||||
|
thumbnail_type: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||||
|
record: Mapped[Optional[MaterialRecord]] = relationship(back_populates="images")
|
||||||
|
creator: Mapped[User] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldDefinition(Base):
|
||||||
|
__tablename__ = "custom_field_definitions"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
field_key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(100))
|
||||||
|
input_type: Mapped[str] = mapped_column(String(20), index=True)
|
||||||
|
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
options_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||||
|
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||||
|
creator: Mapped[User] = relationship()
|
||||||
|
values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="definition")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldValue(Base):
|
||||||
|
__tablename__ = "custom_field_values"
|
||||||
|
__table_args__ = (UniqueConstraint("record_id", "field_id", name="uq_record_custom_field"),)
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
record_id: Mapped[int] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), index=True)
|
||||||
|
field_id: Mapped[int] = mapped_column(ForeignKey("custom_field_definitions.id", ondelete="RESTRICT"), index=True)
|
||||||
|
raw_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||||
|
record: Mapped[MaterialRecord] = relationship(back_populates="custom_values")
|
||||||
|
definition: Mapped[CustomFieldDefinition] = relationship(back_populates="values")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
resource_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||||
|
user: Mapped[Optional[User]] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRecord(Base):
|
||||||
|
__tablename__ = "backup_records"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), unique=True)
|
||||||
|
database_type: Mapped[str] = mapped_column(String(30))
|
||||||
|
# BigInteger:图片入库后备份很容易超过 2 GiB,PostgreSQL 的 integer 到那里就溢出了
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
sha256: Mapped[str] = mapped_column(String(64))
|
||||||
|
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||||
|
creator: Mapped[Optional[User]] = relationship()
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from .images import MAX_IMAGES_PER_RECORD
|
||||||
|
|
||||||
|
|
||||||
|
RECORD_ONLY_FIELDS = {"custom_fields", "image_tokens", "image_ids"}
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialRecordInput(BaseModel):
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
experiment_id: str = Field(min_length=1, max_length=100)
|
||||||
|
test_date: date
|
||||||
|
material_category: str = Field(min_length=1, max_length=100)
|
||||||
|
material_name: str = Field(min_length=1, max_length=200)
|
||||||
|
apparent_density: float | None = Field(default=None, ge=0)
|
||||||
|
uv_absorption_355nm: float | None = Field(default=None, ge=0, le=100)
|
||||||
|
material_thickness: float = Field(gt=0)
|
||||||
|
moisture_content: float | None = Field(default=None, ge=0, le=100)
|
||||||
|
thermal_conductivity: float | None = Field(default=None, ge=0)
|
||||||
|
initial_decomposition_temp: float | None = None
|
||||||
|
melting_vaporization_temp: float | None = None
|
||||||
|
specific_heat_capacity: float | None = Field(default=None, ge=0)
|
||||||
|
carbon_residue_rate: float | None = Field(default=None, ge=0, le=100)
|
||||||
|
surface_roughness: float | None = Field(default=None, ge=0)
|
||||||
|
hardness: float | None = Field(default=None, ge=0)
|
||||||
|
actual_output_power: float = Field(ge=0)
|
||||||
|
display_current: float = Field(ge=0)
|
||||||
|
scanning_speed: float = Field(gt=0)
|
||||||
|
pulse_frequency: float = Field(ge=0)
|
||||||
|
pulse_width: float = Field(ge=0)
|
||||||
|
defocus_amount: float
|
||||||
|
scan_line_spacing: float = Field(gt=0)
|
||||||
|
filling_method: str = Field(min_length=1, max_length=100)
|
||||||
|
processing_size: str = Field(min_length=1, max_length=100)
|
||||||
|
is_cut_through: bool
|
||||||
|
carbonized_edge_width: float = Field(ge=0)
|
||||||
|
etching_depth: float = Field(ge=0)
|
||||||
|
is_fire_smolder: bool
|
||||||
|
pattern_clarity_score: float = Field(ge=0, le=10)
|
||||||
|
presentation_balance_score: float = Field(ge=0, le=10)
|
||||||
|
finished_image_filename: str = Field(min_length=1, max_length=255)
|
||||||
|
remarks: str = Field(min_length=1, max_length=5000)
|
||||||
|
custom_fields: dict[str, str | float | int | bool | None] = Field(default_factory=dict)
|
||||||
|
image_tokens: list[str] = Field(default_factory=list, max_length=MAX_IMAGES_PER_RECORD)
|
||||||
|
# 省略 image_ids 表示"不动既有图片",显式传 [] 才是"全部删除"。默认值若是 [],
|
||||||
|
# 任何不了解图片字段的旧页面或外部脚本,一次 PUT 就会静默删光整条记录的图。
|
||||||
|
image_ids: list[int] | None = Field(default=None, max_length=MAX_IMAGES_PER_RECORD)
|
||||||
|
|
||||||
|
@field_validator("experiment_id", "material_category", "material_name", "filling_method", "processing_size", "finished_image_filename", "remarks")
|
||||||
|
@classmethod
|
||||||
|
def reject_blank(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("不能为空")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
@field_validator("image_tokens")
|
||||||
|
@classmethod
|
||||||
|
def validate_image_tokens(cls, values: list[str]) -> list[str]:
|
||||||
|
cleaned = [value.strip() for value in values if value.strip()]
|
||||||
|
if len(cleaned) != len(set(cleaned)):
|
||||||
|
raise ValueError("上传令牌重复")
|
||||||
|
if any(len(value) > 64 for value in cleaned):
|
||||||
|
raise ValueError("上传令牌格式错误")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
@field_validator("image_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_image_ids(cls, values: list[int] | None) -> list[int] | None:
|
||||||
|
if values is None:
|
||||||
|
return None
|
||||||
|
if len(values) != len(set(values)):
|
||||||
|
raise ValueError("图片编号重复")
|
||||||
|
if any(value <= 0 for value in values):
|
||||||
|
raise ValueError("图片编号无效")
|
||||||
|
return values
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def limit_total_images(self):
|
||||||
|
if len(self.image_tokens) + len(self.image_ids or []) > MAX_IMAGES_PER_RECORD:
|
||||||
|
raise ValueError(f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EntryRecordSubmission(MaterialRecordInput):
|
||||||
|
confirm_username: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
FieldInputType = Literal["text", "long_text", "number", "integer", "date", "boolean", "select"]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldDefinitionInput(BaseModel):
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
input_type: FieldInputType
|
||||||
|
is_required: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
is_public: bool = True
|
||||||
|
unit: str | None = Field(default=None, max_length=50)
|
||||||
|
options: list[str] = Field(default_factory=list, max_length=50)
|
||||||
|
sort_order: int = Field(default=0, ge=0, le=10000)
|
||||||
|
|
||||||
|
@field_validator("options")
|
||||||
|
@classmethod
|
||||||
|
def validate_options(cls, values: list[str]) -> list[str]:
|
||||||
|
cleaned = [value.strip() for value in values if value.strip()]
|
||||||
|
if len(cleaned) != len(set(cleaned)):
|
||||||
|
raise ValueError("选项不能重复")
|
||||||
|
if any(len(value) > 100 for value in cleaned):
|
||||||
|
raise ValueError("单个选项不能超过 100 个字符")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
@field_validator("unit")
|
||||||
|
@classmethod
|
||||||
|
def normalize_unit(cls, value: str | None) -> str | None:
|
||||||
|
return value.strip() or None if value is not None else None
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models import EntrySession, LoginSession, User
|
||||||
|
|
||||||
|
|
||||||
|
PBKDF2_ITERATIONS = 310_000
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
|
||||||
|
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, encoded: str) -> bool:
|
||||||
|
try:
|
||||||
|
algorithm, iterations, salt, expected = encoded.split("$", 3)
|
||||||
|
if algorithm != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
|
||||||
|
return hmac.compare_digest(actual.hex(), expected)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(password: str) -> str | None:
|
||||||
|
if len(password) < 10:
|
||||||
|
return "密码至少需要 10 个字符"
|
||||||
|
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
|
||||||
|
return "密码必须同时包含字母和数字"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256((settings.secret + token).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def entry_key_hash(key: str) -> str:
|
||||||
|
return hmac.new(settings.secret.encode(), f"entry-key:{key}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(db: Session, user: User, ip: str | None, user_agent: str | None) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now().astimezone() + timedelta(hours=settings.session_hours)
|
||||||
|
db.add(LoginSession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip, user_agent=(user_agent or "")[:300]))
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user(db: Session, token: str | None) -> User | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
current = datetime.now().astimezone()
|
||||||
|
session = db.scalar(select(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
expires = session.expires_at
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=current.tzinfo)
|
||||||
|
if expires <= current or not session.user.is_active:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
return session.user
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(db: Session, token: str | None) -> None:
|
||||||
|
if token:
|
||||||
|
db.execute(delete(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry_session(db: Session, user: User, ip: str | None) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now().astimezone() + timedelta(minutes=30)
|
||||||
|
db.add(EntrySession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip))
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_entry_session_user(db: Session, token: str | None) -> User | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
current = datetime.now().astimezone()
|
||||||
|
session = db.scalar(select(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
expires = session.expires_at
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=current.tzinfo)
|
||||||
|
if expires <= current or not session.user.is_active or session.user.role not in {"admin", "uploader"} or not session.user.entry_key_hash:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
return session.user
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_entry_session(db: Session, token: str | None) -> None:
|
||||||
|
if token:
|
||||||
|
db.execute(delete(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_entry_sessions(db: Session, user_id: int) -> None:
|
||||||
|
db.execute(delete(EntrySession).where(EntrySession.user_id == user_id))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
@@ -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`、管理员密码和数据库密码。
|
||||||
|
- 已启用 HTTPS,HTTP 自动跳转 HTTPS。
|
||||||
|
- 数据库端口未暴露公网。
|
||||||
|
- `.env` 权限受限且未进入版本库。
|
||||||
|
- 已创建个人账号并停用不必要账号。
|
||||||
|
- 已为需要快速录入的用户单独设置 Key,且未使用弱组合。
|
||||||
|
- 已确认成品图片可以公开;如属内部资料,已收紧 `GET /api/images/{id}` 权限。
|
||||||
|
- 已按预期图片量核算备份磁盘容量。
|
||||||
|
- 每日自动备份已配置,异机副本和恢复演练已完成。
|
||||||
|
- 服务器与容器镜像有定期补丁计划。
|
||||||
+268
@@ -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-SHA256(310,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 | 可选、0–100 |
|
||||||
|
| `material_thickness` | material_thickness | double | 必填、>0 |
|
||||||
|
| `moisture_content` | moisture_content | double | 可选、0–100 |
|
||||||
|
| `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 | 可选、0–100 |
|
||||||
|
| `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 | 必填、0–10 |
|
||||||
|
| `presentation_balance_score` | presentation_balance_score | double | 必填、0–10 |
|
||||||
|
| `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 会复用被删除行的 rowid,URL 必须带 `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 API,PostgreSQL 使用 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`。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
httpx==0.28.1
|
||||||
|
pytest==8.4.1
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
@@ -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}")
|
||||||
|
|
||||||
@@ -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}")
|
||||||
|
|
||||||
@@ -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 Desktop:https://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
|
||||||
File diff suppressed because one or more lines are too long
@@ -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 解析器会先把 ' 解回单引号),那种地方一律改用 data-* + 监听器。
|
||||||
|
function escapeHtml(value) { return String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||||
|
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');
|
||||||
@@ -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};
|
||||||
|
})();
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||||||
|
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});
|
||||||
|
}
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
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&¤tHeight>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};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
(function(){
|
||||||
|
// 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
|
||||||
|
const escapeHtml=value=>String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
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};
|
||||||
|
})();
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const $ = (selector, root=document) => root.querySelector(selector);
|
||||||
|
// 自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。
|
||||||
|
const escapeHtml = value => String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
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();
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"')
|
||||||
@@ -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)
|
||||||
@@ -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 GiB,PostgreSQL 的 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 不应被读取"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user