commit edfb21656129dac9f6d202fdc739ab069fe7c2ed Author: obroccolio Date: Sun Jul 26 22:06:02 2026 +0800 激光材料数据平台 1.3.0 公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ca3ca5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.env +.git +.venv +__pycache__ +.pytest_cache +*.pyc +data +backups +tests +*.xlsx +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..768e0b2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd29859 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +release.env +release/ +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc +data/*.db +data/*.db-* +backups/* +!backups/.gitkeep +.DS_Store +# 原始数据表:含实测数据,按需本地保留,不入库 +材料性能测试数据表.xlsx diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0c9c56 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a03bab8 --- /dev/null +++ b/README.md @@ -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` 只读用户 | ✓ | — | — | — | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..451c842 --- /dev/null +++ b/RELEASE_NOTES.md @@ -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` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f0bb29e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.3.0 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/backup_service.py b/app/backup_service.py new file mode 100644 index 0000000..b02d428 --- /dev/null +++ b/app/backup_service.py @@ -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("当前数据库类型暂不支持恢复") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1b80ddb --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..11c03aa --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/dynamic_fields.py b/app/dynamic_fields.py new file mode 100644 index 0000000..af20421 --- /dev/null +++ b/app/dynamic_fields.py @@ -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} diff --git a/app/images.py b/app/images.py new file mode 100644 index 0000000..a62ad8f --- /dev/null +++ b/app/images.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..20daae4 --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e1e76c4 --- /dev/null +++ b/app/models.py @@ -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() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..6b18b8a --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..8ff58c5 --- /dev/null +++ b/app/security.py @@ -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)) diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backups/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..82b1f5f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/操作手册.md b/docs/操作手册.md new file mode 100644 index 0000000..c0cfcab --- /dev/null +++ b/docs/操作手册.md @@ -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}` 权限。 +- 已按预期图片量核算备份磁盘容量。 +- 每日自动备份已配置,异机副本和恢复演练已完成。 +- 服务器与容器镜像有定期补丁计划。 diff --git a/docs/数据库接口文档.md b/docs/数据库接口文档.md new file mode 100644 index 0000000..2382b79 --- /dev/null +++ b/docs/数据库接口文档.md @@ -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`。 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c4658a5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +httpx==0.28.1 +pytest==8.4.1 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..088a552 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..5c7dcc5 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +import uvicorn + + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) + diff --git a/scripts/db_backup.py b/scripts/db_backup.py new file mode 100644 index 0000000..fae5e4e --- /dev/null +++ b/scripts/db_backup.py @@ -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}") + diff --git a/scripts/db_restore.py b/scripts/db_restore.py new file mode 100644 index 0000000..afac4ac --- /dev/null +++ b/scripts/db_restore.py @@ -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}") + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..4868ebc --- /dev/null +++ b/start.sh @@ -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" </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 diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..5198944 --- /dev/null +++ b/static/app.css @@ -0,0 +1,18 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap'); +:root{--navy:#142c36;--navy2:#0f252e;--ink:#18313a;--muted:#718087;--line:#dfe6e8;--bg:#f3f6f6;--white:#fff;--orange:#ef6b4d;--orange2:#ff8a66;--green:#3d9c7c;--blue:#4b85b5;--shadow:0 14px 38px rgba(20,44,54,.08);font-family:"DM Sans","Noto Sans SC",sans-serif;color:var(--ink)} +*{box-sizing:border-box}body{margin:0;background:var(--bg)}button,input{font:inherit}button,a{transition:.2s ease}button{cursor:pointer}.eyebrow{font-size:11px;font-weight:700;letter-spacing:.16em;margin:0 0 8px;color:var(--orange)}.muted{color:var(--muted)} +.login-body{min-height:100vh;background:#e9efef;padding:28px;display:grid;place-items:center}.login-shell{width:min(1120px,100%);min-height:680px;display:grid;grid-template-columns:1.08fr .92fr;border-radius:28px;overflow:hidden;background:white;box-shadow:0 30px 90px rgba(20,44,54,.18)}.login-brand{position:relative;padding:64px;background:linear-gradient(145deg,rgba(16,42,51,.92),rgba(22,57,66,.96)),url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100"%3E%3Cpath d="M0 70L70 0M30 100L100 30" stroke="%23fff" stroke-opacity=".05"/%3E%3C/svg%3E');color:white;display:flex;flex-direction:column;justify-content:center}.login-brand:after{content:"";position:absolute;width:280px;height:280px;border:1px solid rgba(255,255,255,.08);border-radius:50%;right:-110px;top:-90px;box-shadow:0 0 0 50px rgba(255,255,255,.025),0 0 0 100px rgba(255,255,255,.02)}.brand-mark{width:42px;height:42px;border-radius:12px;background:var(--orange);color:white;display:grid;place-items:center;font-weight:800;font-size:20px;box-shadow:0 8px 18px rgba(239,107,77,.28)}.login-brand .brand-mark{margin-bottom:90px}.login-brand h1{font-size:44px;line-height:1.22;margin:8px 0 22px;letter-spacing:-.04em}.login-brand>p:not(.eyebrow){color:#b8c8cc;line-height:1.8;max-width:440px}.brand-stats{display:flex;gap:38px;margin-top:58px;color:#9fb2b7;font-size:12px}.brand-stats span{display:grid;gap:5px}.brand-stats b{font-size:20px;color:#fff}.login-card{padding:72px;display:flex;flex-direction:column;justify-content:center}.login-card h2{font-size:32px;margin:0 0 4px}.login-card form{margin-top:28px;display:grid;gap:20px}.login-card label{display:grid;gap:8px;font-size:13px;font-weight:600}.login-card input,.filters input{border:1px solid var(--line);border-radius:10px;padding:13px 15px;outline:none;background:#fbfcfc}.login-card input:focus,.filters input:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(239,107,77,.1)}.primary{border:0;background:var(--orange);color:#fff;border-radius:10px;padding:12px 18px;font-weight:700;box-shadow:0 8px 18px rgba(239,107,77,.18)}.primary:hover{background:#dc5d41;transform:translateY(-1px)}.primary:disabled{opacity:.45;cursor:not-allowed;transform:none}.wide{width:100%;display:flex;justify-content:space-between}.form-error{background:#fff0ed;color:#c7472f;border-radius:8px;padding:10px 12px;margin:0;font-size:13px}.login-foot{text-align:center;color:#9aa5a9;font-size:11px;margin-top:28px}.mobile-logo{display:none} +.app-body{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:250px;background:var(--navy);color:#fff;padding:25px 18px 18px;display:flex;flex-direction:column;z-index:20}.logo{display:flex;align-items:center;gap:12px;padding:0 9px 28px}.logo>div:last-child{display:grid}.logo strong{font-size:14px;letter-spacing:.08em}.logo span{font-size:10px;color:#829ba2;margin-top:3px}.sidebar nav{display:grid;gap:5px}.nav-item{border:0;background:transparent;color:#9bb0b6;border-radius:10px;padding:11px 13px;display:flex;align-items:center;gap:12px;text-align:left}.nav-item i{font-style:normal;width:20px;text-align:center;font-size:17px}.nav-item:hover,.nav-item.active{background:rgba(255,255,255,.09);color:#fff}.nav-item.active{box-shadow:inset 3px 0 var(--orange)}.nav-label{font-size:9px;letter-spacing:.18em;color:#607b82;margin:24px 13px 6px}.side-help{margin-top:auto;padding:15px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.06);border-radius:12px;display:grid;gap:6px}.side-help b{font-size:12px}.side-help span{font-size:10px;color:#7f999f}.side-help a{color:#ff947b;font-size:10px;text-decoration:none}.side-user{display:flex;align-items:center;gap:9px;border-top:1px solid rgba(255,255,255,.08);margin-top:14px;padding:15px 7px 0}.avatar{width:34px;height:34px;display:grid;place-items:center;border-radius:50%;background:#dce8e5;color:var(--navy);font-weight:700}.side-user>div:nth-child(2){display:grid;flex:1}.side-user b{font-size:11px}.side-user span{font-size:9px;color:#799198}.side-user button{border:0;background:none;color:#8ba0a5;font-size:17px}.content{margin-left:250px;padding:35px 42px 60px;max-width:1650px}.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}.topbar h1{font-size:26px;margin:0}.top-actions{display:flex;align-items:center;gap:14px}.status-dot{font-size:11px;color:#5c7770}.status-dot:before{content:"";display:inline-block;width:7px;height:7px;background:#45b48e;border-radius:50%;margin-right:7px;box-shadow:0 0 0 4px rgba(69,180,142,.1)}.ghost,.secondary{border:1px solid var(--line);background:white;color:var(--ink);border-radius:9px;padding:9px 13px;font-size:12px}.view{display:none}.view.active{display:block}.notice{background:#fff7e7;border:1px solid #f1d9a4;border-radius:12px;padding:13px 16px;margin-bottom:18px;display:flex;align-items:center;gap:10px;font-size:12px}.notice span{color:#7c715a;flex:1}.notice button{border:0;background:none;color:#b76926;font-weight:700}.hero-card{min-height:205px;padding:42px 48px;border-radius:20px;background:radial-gradient(circle at 80% 50%,rgba(255,255,255,.08),transparent 35%),linear-gradient(120deg,#173640,#214c55);color:#fff;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow);overflow:hidden;position:relative}.hero-card:after{content:"";position:absolute;right:18%;top:28px;width:140px;height:140px;border-radius:50%;border:1px solid rgba(255,255,255,.07);box-shadow:0 0 0 35px rgba(255,255,255,.018),0 0 0 70px rgba(255,255,255,.014)}.hero-card h2{font-size:30px;margin:4px 0 9px}.hero-card p:last-child{color:#9eb4ba;font-size:13px}.light-button{z-index:1;border:0;border-radius:10px;background:#fff;color:var(--navy);font-weight:700;padding:13px 17px}.light-button span{margin-left:20px;color:var(--orange)}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:18px 0}.metric{background:#fff;border-radius:14px;padding:21px;display:flex;align-items:center;gap:15px;box-shadow:0 5px 22px rgba(20,44,54,.04)}.metric-icon{width:43px;height:43px;display:grid;place-items:center;border-radius:12px;font-size:19px}.metric-icon.coral{color:#d45c44;background:#fff0ed}.metric-icon.blue{color:#397ca9;background:#eaf4fb}.metric-icon.green{color:#308d6f;background:#e8f7f1}.metric>div{display:grid;grid-template-columns:auto 1fr;align-items:end;gap:0 8px}.metric p{grid-column:1/-1;color:var(--muted);font-size:11px;margin:0 0 3px}.metric strong{font-size:27px}.metric small{font-size:10px;color:#9aa6aa;padding-bottom:5px}.panel-grid{display:grid;grid-template-columns:1.5fr .8fr;gap:18px}.panel,.field-guide{background:#fff;border-radius:14px;padding:22px;box-shadow:0 5px 22px rgba(20,44,54,.04)}.panel-head{display:flex;align-items:center;justify-content:space-between}.panel h3,.field-guide h3{margin:0;font-size:17px}.text-button{border:0;background:none;color:var(--orange);font-size:11px}.batch-mini{display:grid;grid-template-columns:1fr auto;gap:5px;padding:14px 0;border-bottom:1px solid #edf1f2}.batch-mini:last-child{border-bottom:0}.batch-mini b{font-size:12px}.batch-mini span,.batch-mini small{color:var(--muted);font-size:10px}.batch-mini span{grid-row:1/3;grid-column:2}.quick-panel ol{padding:0;list-style:none;counter-reset:step;margin:21px 0 0}.quick-panel li{counter-increment:step;position:relative;padding:0 0 20px 42px;display:grid}.quick-panel li:before{content:counter(step);position:absolute;left:0;width:25px;height:25px;border-radius:50%;background:#eef3f3;color:var(--navy);display:grid;place-items:center;font-size:11px;font-weight:700}.quick-panel li:not(:last-child):after{content:"";position:absolute;left:12px;top:28px;height:17px;border-left:1px dashed #c7d2d5}.quick-panel b{font-size:12px}.quick-panel span{font-size:10px;color:var(--muted);margin-top:3px}.loading,.empty{padding:35px;text-align:center;color:#89979b;font-size:12px}.section-intro{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.section-intro h2{font-size:22px;margin:0 0 5px}.section-intro p{font-size:12px;color:var(--muted);margin:0}.button-link{text-decoration:none;display:inline-block}.secondary{font-weight:600}.upload-card{background:white;border-radius:16px;padding:24px;box-shadow:var(--shadow)}.upload-card form{display:grid;justify-items:center;gap:18px}.drop-zone{border:1.5px dashed #bdcbd0;background:#fafcfc;border-radius:14px;width:100%;min-height:300px;display:flex;flex-direction:column;justify-content:center;align-items:center;transition:.2s}.drop-zone.drag,.drop-zone:hover{border-color:var(--orange);background:#fff9f7}.upload-icon{width:58px;height:58px;border-radius:50%;display:grid;place-items:center;background:#fff0ed;color:var(--orange);font-size:26px}.drop-zone h3{font-size:17px;margin:18px 0 7px}.drop-zone p{color:var(--muted);font-size:11px}.drop-zone u{color:var(--orange);font-weight:700}.file-pill{margin-top:12px;background:#eef5f4;color:#36585e;border-radius:8px;padding:8px 12px;font-size:11px}.field-guide{margin-top:18px}.optional-key{font-size:10px;color:#e05f48}.guide-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:18px}.guide-grid>div{background:#f7f9f9;border-radius:10px;padding:15px;display:grid;gap:6px}.guide-grid b{font-size:11px}.guide-grid span{font-size:9px;line-height:1.55;color:var(--muted)}.filters{display:flex;gap:10px;margin-bottom:18px}.filters input{flex:1;padding:10px 12px;font-size:12px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th{text-align:left;color:#829095;font-size:9px;letter-spacing:.08em;text-transform:uppercase;padding:11px;border-bottom:1px solid var(--line)}td{padding:13px 11px;border-bottom:1px solid #edf1f2;font-size:11px}td b{font-size:11px}.tag{font-size:9px;padding:4px 7px;border-radius:30px;background:#edf5f2;color:#28795f}.tag.off{background:#f2f3f3;color:#78868a}.danger-link{color:#ce4e38;background:none;border:0;font-size:10px}.table-action{border:0;background:#f1f5f5;border-radius:6px;font-size:9px;padding:5px 8px}.hash{max-width:120px;overflow:hidden;text-overflow:ellipsis;display:block}.pager{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding-top:17px;color:var(--muted);font-size:10px}.pager button{border:1px solid var(--line);background:white;border-radius:6px;padding:5px 9px}.modal[hidden]{display:none}.modal{position:fixed;inset:0;z-index:100;display:grid;place-items:center;padding:20px}.modal-backdrop{position:absolute;inset:0;background:rgba(6,23,29,.56);backdrop-filter:blur(2px)}.modal-card{position:relative;width:min(470px,100%);background:white;border-radius:16px;padding:27px;box-shadow:0 30px 80px rgba(0,0,0,.2)}.modal-close{position:absolute;right:13px;top:10px;border:0;background:none;color:#829095;font-size:22px}.modal h2{font-size:20px;margin:0 0 5px}.modal form{display:grid;gap:14px;margin-top:20px}.modal label{display:grid;gap:6px;font-size:11px;font-weight:700}.modal input,.modal select{border:1px solid var(--line);border-radius:8px;padding:10px 12px}.toast-stack{position:fixed;right:24px;bottom:24px;z-index:120;display:grid;gap:9px}.toast{min-width:280px;max-width:440px;background:var(--navy);color:white;border-radius:10px;padding:13px 15px;box-shadow:var(--shadow);font-size:11px;animation:toast-in .2s ease}.toast.error{background:#b74431}.toast b{display:block;margin-bottom:3px}@keyframes toast-in{from{transform:translateY(8px);opacity:0}}.center-page{min-height:100vh;display:grid;place-items:center}.empty-card{text-align:center;background:#fff;padding:50px;border-radius:18px;box-shadow:var(--shadow)}.empty-icon{margin:auto;width:50px;height:50px;border-radius:50%;background:#fff0ed;color:var(--orange);display:grid;place-items:center;font-size:25px;font-weight:bold}.empty-card p{color:var(--muted);margin-bottom:25px}.mobile-bar{display:none} +@media(max-width:900px){.login-body{padding:0}.login-shell{min-height:100vh;border-radius:0;display:block}.login-brand{display:none}.login-card{min-height:100vh;padding:35px 26px}.mobile-logo{display:flex;align-items:center;gap:12px;margin-bottom:70px}.sidebar{transform:translateX(-100%);transition:.25s}.sidebar.open{transform:translateX(0);box-shadow:20px 0 50px rgba(0,0,0,.2)}.mobile-bar{height:62px;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 17px;position:sticky;top:0;z-index:15}.mobile-bar button{border:0;background:none;color:white;font-size:20px}.mobile-bar .logo{padding:0}.mobile-bar .brand-mark{width:30px;height:30px;border-radius:8px;font-size:14px}.mobile-bar .avatar{width:30px;height:30px}.content{margin-left:0;padding:23px 16px 45px}.topbar{align-items:flex-start}.top-actions .status-dot{display:none}.hero-card{padding:28px;align-items:flex-end;min-height:225px}.hero-card h2{font-size:24px}.hero-card p:last-child{max-width:230px}.metric-grid{grid-template-columns:1fr}.panel-grid{grid-template-columns:1fr}.guide-grid{grid-template-columns:1fr 1fr}.section-intro{align-items:flex-start;gap:15px}.drop-zone{min-height:260px;padding:25px;text-align:center}.filters{display:grid}.notice{align-items:flex-start;flex-wrap:wrap}.notice span{flex-basis:70%}} +@media(max-width:520px){.topbar h1{font-size:22px}.top-actions{gap:5px}.ghost{padding:7px 9px}.hero-card{display:block}.hero-card .light-button{margin-top:20px}.guide-grid{grid-template-columns:1fr}.section-intro{display:grid}.metric strong{font-size:24px}.panel{padding:16px}.toast-stack{left:15px;right:15px;bottom:15px}.toast{min-width:0}.brand-stats{gap:20px}} +.record-form{display:grid;gap:16px}.form-section{background:#fff;border-radius:14px;padding:24px;box-shadow:0 5px 22px rgba(20,44,54,.04)}.form-section-head{display:flex;align-items:flex-start;gap:13px;padding-bottom:18px;border-bottom:1px solid #edf1f2;margin-bottom:19px}.form-section-head>span{width:30px;height:30px;border-radius:9px;background:#eaf1f1;color:var(--navy);font-size:10px;font-weight:700;display:grid;place-items:center}.optional-section .form-section-head>span{background:#fff0ed;color:var(--orange)}.form-section-head h3{font-size:15px;margin:0}.form-section-head h3 b{font-size:9px;color:var(--orange);background:#fff0ed;padding:4px 7px;border-radius:20px;margin-left:8px}.form-section-head p{font-size:9px;color:var(--muted);margin:4px 0 0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:17px 15px}.form-grid label{display:grid;grid-template-columns:1fr auto;gap:7px;font-size:10px;font-weight:700;color:#52656b}.form-grid label em{color:var(--orange);font-style:normal}.form-grid label small{font-size:8px;color:var(--orange);font-weight:600}.form-grid input,.form-grid select,.form-grid textarea{grid-column:1/-1;width:100%;border:1px solid var(--line);background:#fbfcfc;border-radius:8px;padding:10px 11px;outline:none;color:var(--ink);font-size:11px}.form-grid input:focus,.form-grid select:focus,.form-grid textarea:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(239,107,77,.09)}.form-grid input:disabled,.form-grid select:disabled,.form-grid textarea:disabled{background:#f0f3f3;color:#718087}.form-grid .span-2{grid-column:span 2}.form-actions{position:sticky;bottom:14px;z-index:5;background:rgba(255,255,255,.94);backdrop-filter:blur(8px);border:1px solid var(--line);border-radius:12px;padding:13px 16px;display:flex;align-items:center;justify-content:flex-end;gap:18px;box-shadow:0 10px 30px rgba(20,44,54,.1)}.form-actions span{font-size:10px;color:var(--muted)}.required-star{color:var(--orange);font-weight:700} +@media(max-width:900px){.form-grid{grid-template-columns:1fr 1fr}.form-grid .span-2{grid-column:1/-1}} +@media(max-width:520px){.form-section{padding:17px}.form-grid{grid-template-columns:1fr}.form-grid .span-2{grid-column:auto}.form-actions{align-items:stretch;display:grid}.form-actions span{display:none}.form-actions .primary{width:100%}} +@media(max-width:700px){.topbar{gap:12px}.top-actions{flex-wrap:wrap;justify-content:flex-end}.top-actions .ghost{padding:7px 9px;font-size:10px}} +.cell-note{display:block;color:#829095;font-size:8px;margin-top:3px}.schema-warning{background:#fff7e7;border:1px solid #f1d9a4;color:#735f38;border-radius:8px;padding:10px 12px;font-size:10px}.check-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.check-grid label{display:flex!important;align-items:center;gap:6px!important;background:#f6f8f8;border-radius:8px;padding:9px}.check-grid input{width:auto!important}.field-format-warning{grid-column:1/-1;color:#b76926;font-size:8px}.historical-custom-value{background:#fff7e7;border:1px solid #f1d9a4;border-radius:8px;padding:10px;display:grid;gap:4px}.historical-custom-value small{color:#8a7247;font-size:8px}.historical-custom-value b{font-size:11px}.historical-custom-value span{color:#b76926;font-size:8px} +@media(max-width:520px){.check-grid{grid-template-columns:1fr}} +.image-uploader{display:grid;gap:14px}.image-drop{border:1.5px dashed #c3d0d4;background:#fafcfc;border-radius:12px;padding:26px 18px;display:grid;justify-items:center;gap:10px;text-align:center;transition:.2s}.image-drop.compact{padding:16px}.image-drop.drag{border-color:var(--orange);background:#fff9f7}.image-drop-icon{width:40px;height:40px;border-radius:11px;background:#fff0ed;color:var(--orange);display:grid;place-items:center;font-size:18px}.image-drop p{margin:0;display:grid;gap:4px}.image-drop b{font-size:12px}.image-drop small{font-size:9px;color:var(--muted)}.image-drop-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}.image-drop-actions .secondary{padding:8px 16px;font-size:11px}.image-drop-actions .secondary:disabled{opacity:.45;cursor:not-allowed}.image-drop-hint{font-size:9px;color:var(--muted)} +.image-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:10px}.image-card{position:relative;margin:0;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#f7f9f9;display:grid}.image-card img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;background:#eef2f2}.image-card-open{display:block;padding:0;border:0;background:none;width:100%;cursor:zoom-in}.image-card-open:focus-visible{outline:2px solid var(--orange);outline-offset:-2px}.image-card.uploading img{opacity:.55}.image-card.error{border-color:#e8b4a8}.image-card-blank{aspect-ratio:4/3;display:grid;place-items:center;color:#9aa6aa}.image-card figcaption{padding:6px 8px;font-size:9px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.image-card-progress{position:absolute;left:0;right:0;bottom:22px;height:3px;background:rgba(20,44,54,.14)}.image-card-progress i{display:block;height:100%;background:var(--orange);transition:width .18s}.image-card-error{position:absolute;left:6px;right:6px;top:6px;background:rgba(183,68,49,.94);color:#fff;border-radius:6px;padding:5px 7px;font-size:8px;line-height:1.45}.image-card-remove{position:absolute;right:5px;top:5px;width:22px;height:22px;border:0;border-radius:50%;background:rgba(6,23,29,.62);color:#fff;font-size:14px;line-height:1;display:grid;place-items:center}.image-card-remove:hover{background:#b74431}.image-card-retry{position:absolute;left:50%;bottom:28px;transform:translateX(-50%);border:0;border-radius:6px;background:#fff;color:var(--ink);font-size:9px;padding:4px 9px;box-shadow:0 4px 12px rgba(0,0,0,.18)} +.image-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px}.gallery-thumb{position:relative;padding:0;border:1px solid var(--line);border-radius:9px;overflow:hidden;background:#f2f5f5;cursor:zoom-in;display:block}.gallery-thumb:hover{border-color:var(--orange)}.gallery-thumb img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}.gallery-fallback:not([hidden]){display:grid;place-items:center;aspect-ratio:4/3;font-size:9px;color:#9aa6aa}.gallery-empty{padding:16px;text-align:center;color:#89979b;font-size:10px;background:#fff;border:1px dashed var(--line);border-radius:9px} +html.viewer-open,html.viewer-open body{overflow:hidden}.image-viewer[hidden]{display:none}.image-viewer{position:fixed;inset:0;z-index:200;display:grid;place-items:center;padding:44px 16px}.viewer-backdrop{position:absolute;inset:0;background:rgba(4,16,20,.9)}.viewer-stage{position:relative;margin:0;max-width:min(1200px,100%);max-height:100%;display:grid;gap:10px;justify-items:center}.viewer-stage img{max-width:100%;max-height:calc(100vh - 130px);max-height:calc(100dvh - 130px);object-fit:contain;border-radius:8px;background:#0b1c22}.viewer-stage img[hidden]{display:none}.viewer-state{margin:0;padding:40px 24px;color:#9fb2b7;font-size:11px;text-align:center}.viewer-state[hidden]{display:none}.viewer-stage figcaption{color:#c4d2d6;font-size:10px;text-align:center}.viewer-close{position:absolute;right:14px;top:12px;z-index:2;width:36px;height:36px;border:0;border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font-size:22px;line-height:1}.viewer-close:hover{background:rgba(255,255,255,.26)}.viewer-nav{position:absolute;top:50%;transform:translateY(-50%);z-index:2;width:42px;height:42px;border:0;border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1}.viewer-nav:hover{background:rgba(255,255,255,.26)}.viewer-nav.prev{left:14px}.viewer-nav.next{right:14px}.viewer-nav[hidden]{display:none}.viewer-counter{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);color:#9fb2b7;font-size:10px;letter-spacing:.1em} +@media(max-width:560px){.image-card-grid{grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}.image-gallery{grid-template-columns:repeat(auto-fill,minmax(92px,1fr))}.image-drop{padding:18px 12px}.image-drop-actions{width:100%;display:grid;grid-template-columns:1fr 1fr}.image-viewer{padding:52px 8px}.viewer-nav{width:36px;height:36px;font-size:22px}.viewer-nav.prev{left:4px}.viewer-nav.next{right:4px}} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e05b54b --- /dev/null +++ b/static/app.js @@ -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=`${error?'操作未完成':'操作成功'}${escapeHtml(typeof message==='string'?message:JSON.stringify(message))}`; + $('#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,'''); } +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=>`
${escapeHtml(x.experiment_id)} · ${escapeHtml(x.material_name)}${escapeHtml(x.material_category)} · ${x.actual_output_power} W · ${x.scanning_speed} mm/s${x.test_date}
`).join(''):'
暂无录入记录
';}catch(e){target.textContent=e.message;} +} +let recordPage=1; +async function loadRecords(page=1){ + const body=$('#recordRows');if(!body)return;recordPage=page;body.innerHTML='正在加载…'; + 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=>`${escapeHtml(x.experiment_id)}${x.test_date}${escapeHtml(x.material_category)} · ${escapeHtml(x.material_name)}${x.material_thickness} mm${x.actual_output_power} W${x.scanning_speed} mm/s${x.is_cut_through?'是':'否'}${x.pattern_clarity_score}${window.APP.role==='admin'?` `:''}`).join(''):'未找到数据'; + 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=`第 ${page} / ${pages} 页,共 ${res.pagination.total} 条`; + }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='正在加载…';try{const {data}=await api('/api/field-definitions');fieldDefinitionCache=new Map(data.map(field=>[field.id,field]));body.innerHTML=data.length?data.map(field=>`${escapeHtml(field.label)}${field.unit?`单位:${escapeHtml(field.unit)}`:''}${fieldTypeLabels[field.input_type]}${field.is_required?'必填':'可选'}${field.is_public?'公开':'内部'}${field.is_active?'启用':'停用'}${field.usage_count}${field.sort_order}`).join(''):'尚未添加扩展字段';}catch(error){toast(error.message,true);}} +function fieldDefinitionModal(field=null){const editing=Boolean(field);openModal(`

DATA FIELD SCHEMA

${editing?'修改数据字段':'添加数据字段'}

${editing&&field.usage_count?`

该字段已有 ${field.usage_count} 条数据。修改不会删除或改写原值,无法适配新类型的值会按历史格式显示。

`:'

字段添加后会自动出现在管理录入和 Key 录入面板。

'}
`);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(`

ACCOUNT SECURITY

修改登录密码

新密码至少 10 位,且同时包含字母和数字。

`);$('#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(`

QUICK ENTRY ACCESS

四位录入 Key

当前状态:${configured?'已启用':'未设置'}。Key 全局唯一,修改后旧的录入授权立即失效。

${configured?'':''}
`);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=>`${escapeHtml(x.display_name)}${escapeHtml(x.username)}${({admin:'管理员',uploader:'录入员',viewer:'只读'})[x.role]}${x.is_active?'启用':'停用'}${formatDate(x.created_at)}`).join('');}catch(e){toast(e.message,true);}} +$('#newUserBtn')?.addEventListener('click',()=>{openModal(`

ACCESS CONTROL

新建用户

`);$('#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(`

USER MANAGEMENT

管理 ${escapeHtml(x.display_name)}

`);$('#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=>`${escapeHtml(x.filename)}${x.database_type}${formatBytes(x.size_bytes)}${escapeHtml(x.creator)}${formatDate(x.created_at)}${x.sha256}下载`).join(''):'暂无备份';}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=>`${formatDate(x.created_at)}${escapeHtml(x.user)}${escapeHtml(x.action)}${escapeHtml(x.resource_type)} ${escapeHtml(x.resource_id||'')}${escapeHtml(x.ip_address||'—')}${escapeHtml(x.detail||'—')}`).join(''):'暂无日志';}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'); diff --git a/static/dynamic-fields.js b/static/dynamic-fields.js new file mode 100644 index 0000000..f7e3a5e --- /dev/null +++ b/static/dynamic-fields.js @@ -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 ``; + if(definition.input_type==='boolean')return ``; + if(definition.input_type==='select')return ``; + 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 ``; + } + 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?`历史值“${escapeHtml(raw)}”与当前类型不兼容;留空会保留原值,填写新值后替换`:'';return ``;}).join(''); + const historical=Object.entries(values).filter(([key,value])=>!activeKeys.has(key)&&value&&typeof value==='object').map(([,value])=>`
已停用字段 · ${escapeHtml(value.label)}${escapeHtml(value.display_value||value.raw_value||'未填写')}${value.compatible===false?'历史格式':''}
`).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}; +})(); diff --git a/static/entry.js b/static/entry.js new file mode 100644 index 0000000..dc956f7 --- /dev/null +++ b/static/entry.js @@ -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,''');} +function toast(message,error=false){const node=document.createElement('div');node.className=`toast ${error?'error':''}`;node.innerHTML=`${error?'操作未完成':'保存成功'}${escapeHtml(message)}`;$('#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}); +} diff --git a/static/image-upload.js b/static/image-upload.js new file mode 100644 index 0000000..40a0107 --- /dev/null +++ b/static/image-upload.js @@ -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,'''); + 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{ + // 是主路径:现代浏览器会自动按 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;indexIOS_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||'待上传图片'); + // 已上传的用 ` + : `${label}`) + : ''; + return `
+ ${preview_markup} + ${busy?``:''} + ${failed?`${escapeHtml(item.message||'上传失败')}`:''} +
${escapeHtml(item.original_name||item.name||'')}
+ ${readonly?'':``} + ${failed&&!readonly?``:''} +
`; + }).join(''); + const dropzone=readonly?'':`
+ +

添加成品图JPG / PNG,最多 ${MAX_IMAGES} 张,上传前自动压缩

+ + + ${CAN_CAPTURE?``:''} + + ${remaining>0?`还可添加 ${remaining} 张`:'已达数量上限'} +
`; + container.innerHTML=`${dropzone}
${cards||(readonly?'':'')}
`; + 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}; +})(); diff --git a/static/image-viewer.js b/static/image-viewer.js new file mode 100644 index 0000000..e0dd55f --- /dev/null +++ b/static/image-viewer.js @@ -0,0 +1,80 @@ +(function(){ + // 必须自己转义引号:textContent→innerHTML 只处理 & < >,插进 attr="…" 时引号会被用来逃逸出属性。 + const escapeHtml=value=>String(value??'').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='
'; + 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 ``; + // 不用 loading="lazy":缩略图是在隐藏的详情弹窗里解析的,惰性加载会一直不触发;单张只有几 KB,最多 6 张。 + return ``; + } + 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}; +})(); diff --git a/static/public.css b/static/public.css new file mode 100644 index 0000000..9de6f16 --- /dev/null +++ b/static/public.css @@ -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} diff --git a/static/public.js b/static/public.js new file mode 100644 index 0000000..42f7970 --- /dev/null +++ b/static/public.js @@ -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,'''); +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='正在加载数据…'; + 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=''+result.categories.map(x=>``).join(''); select.value=selected; + body.innerHTML=result.data.length?result.data.map(row=>`${escapeHtml(row.experiment_id)}${row.test_date}${escapeHtml(row.material_category)}${escapeHtml(row.material_name)}${row.material_thickness} mm${row.actual_output_power} W${row.scanning_speed} mm/s${row.is_cut_through?'是':'否'}${row.pattern_clarity_score}`).join(''):'未找到符合条件的数据'; + 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=`第 ${page} / ${pages} 页,共 ${result.pagination.total} 条`; + $('#publicPager').querySelectorAll('button:not([disabled])').forEach(button=>button.onclick=()=>loadPublicData(Number(button.dataset.page))); + } catch(error) { body.innerHTML=`${escapeHtml(error.message)}`; } +} + +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 `

${title}

${fields.map(key=>{let value=data[key];if(typeof value==='boolean')value=value?'是':'否';if(value===null||value==='')value='未填写';return `
${labels[key]}${escapeHtml(value)}
`}).join('')}
`;} +function renderCustomGroup(values){if(!values?.length)return '';return `

扩展数据

${values.map(item=>`
${item.is_active?'':`历史字段 · `}${escapeHtml(item.label)}${escapeHtml(item.display_value||item.raw_value||'未填写')}${item.compatible===false?'历史格式':''}
`).join('')}
`;} +function renderImageGroup(images){return `

成品图片

`;} +async function showDetail(id){ + try{const {data}=await getJson(`/api/public/records/${id}`);$('#publicModalContent').innerHTML=`

EXPERIMENT DETAIL

${escapeHtml(data.experiment_id)}

${escapeHtml(data.test_date)} · ${escapeHtml(data.material_category)} · ${escapeHtml(data.material_name)}

${renderImageGroup(data.images)}${renderGroup('材料物理特性',physical,data)}${renderGroup('激光雕刻参数',laser,data)}${renderGroup('实验结果',resultFields,data)}${renderCustomGroup(data.custom_fields)}
`;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(); diff --git a/templates/_record_fields.html b/templates/_record_fields.html new file mode 100644 index 0000000..54ac040 --- /dev/null +++ b/templates/_record_fields.html @@ -0,0 +1,42 @@ +
01

实验与材料

用于识别本次测试和材料样品

+ + + + + +
+
02

材料物理特性 均为可选

未测试的物性参数可以留空

+ + + + + + + + + + +
+
03

激光雕刻参数

设备输出与扫描设置

+ + + + + + + + + +
+
04

实验结果

加工结果、质量评分与备注

+ + + + + + + + +
+
05

成品图片 可选

可从相册选择或直接拍照,最多 6 张,提交后可在详情中放大查看

+ diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..b9e843e --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,58 @@ + + +{{ app_name }} + + +
{{ user.display_name[0] }}
+
+

MATERIAL INTELLIGENCE

数据概览

系统运行正常公开看板{% if user.role in ['admin','uploader'] %}{% endif %}
+ +
+ {% if user.must_change_password %}
首次登录请修改初始密码完成密码更新后,此提示将自动消失。
{% endif %} +

GOOD DATA, BETTER DECISIONS

下午好,{{ user.display_name }}

每一组可靠的数据,都让下一次雕刻更接近理想结果。

{% if user.role in ['admin','uploader'] %}{% endif %}
+
+

数据总量

{{ counts.records }}条实验记录
+

录入次数

{{ counts.batches }}次成功保存
+

活跃用户

{{ counts.users }}个有效账号
+
+

RECENT ENTRIES

最近录入

正在加载…
+

QUICK START

规范录入三步走

  1. 填写材料身份实验 ID 必须唯一
  2. 补充参数与结果标记“可选”的物性字段可留空
  3. 校验并保存系统自动检查格式并记录操作者
+
+ +
+

手动录入实验数据

* 为必填;标记“可选”的字段可留空。

+
+ + {% include "_record_fields.html" %} +
保存后可在数据中心继续编辑
+
+
+ +

数据中心

按实验 ID、材料名称或分类快速查找、编辑和管理实验记录。

{% if user.role in ['admin','uploader'] %}{% endif %}
实验 ID测试日期材料厚度功率速度切透清晰度操作
+ {% if user.role == 'admin' %} +

数据字段配置

添加扩展数据种类,设置输入形式、必填规则和公开性;历史值不会因配置修改而删除。

字段名称填写形式规则公开状态已有值排序
+

用户与权限

管理员负责账号创建、角色分配、停用与密码重置。

用户用户名角色状态创建时间
+

备份管理

支持 SQLite 在线一致性备份与 PostgreSQL pg_dump,默认保留 30 天。

文件名数据库大小创建人创建时间SHA-256
+

审计日志

记录登录、录入、修改、删除、账号及备份等关键动作。

时间操作者动作资源IP 地址详情
+ {% endif %} +
+ +
+ + diff --git a/templates/entry.html b/templates/entry.html new file mode 100644 index 0000000..4ac2e86 --- /dev/null +++ b/templates/entry.html @@ -0,0 +1,21 @@ + +数据录入 · {{ app_name }} + +
LLASER DATA材料实验数据库
+
+
+
••••

ENTRY ACCESS

输入四位录入 Key

Key 由用户在管理后台的个人设置中创建

+
+
+
+

NEW EXPERIMENT

录入实验数据

* 为必填,材料物理特性可按实际检测情况填写。

+
+ {% include "_record_fields.html" %} +
提交前需要再次确认用户名
+
+
+
+ +
+ + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..f1f9ea0 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,3 @@ +出错了 +
!

操作未完成

{{ message }}

返回工作台
+ diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..46016e2 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,37 @@ + + + + + 登录 · {{ app_name }} + + +
+ + +
+ + diff --git a/templates/public_dashboard.html b/templates/public_dashboard.html new file mode 100644 index 0000000..711702e --- /dev/null +++ b/templates/public_dashboard.html @@ -0,0 +1,22 @@ + +公开数据 · {{ app_name }} + +
LLASER DATA材料实验数据库
+
+

OPEN MATERIAL DATA

激光材料实验数据

激光雕刻参数、材料物性与加工结果的公开数据视图

录入实验数据 →
+
+
DATA
实验记录
+
TYPE
材料分类
+
30D
近 30 天新增
+
SCORE
平均清晰度
+
+
+

EXPERIMENT RECORDS

实验记录

+
实验 ID日期材料分类材料名称 / 型号厚度功率速度切透清晰度
正在加载数据…
+
+
+
+ + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4ba11e --- /dev/null +++ b/tests/conftest.py @@ -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} diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..e144ceb --- /dev/null +++ b/tests/test_backup.py @@ -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 diff --git a/tests/test_dynamic_fields.py b/tests/test_dynamic_fields.py new file mode 100644 index 0000000..feaedef --- /dev/null +++ b/tests/test_dynamic_fields.py @@ -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 diff --git a/tests/test_entry_images.py b/tests/test_entry_images.py new file mode 100644 index 0000000..67d5078 --- /dev/null +++ b/tests/test_entry_images.py @@ -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"') diff --git a/tests/test_entry_security.py b/tests/test_entry_security.py new file mode 100644 index 0000000..628bf79 --- /dev/null +++ b/tests/test_entry_security.py @@ -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) diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..6c2150c --- /dev/null +++ b/tests/test_images.py @@ -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'', + b"

not an image

", + 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"", 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"" * 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"