# 数据库与接口文档 版本: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`。