公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
15 KiB
Markdown
269 lines
15 KiB
Markdown
# 数据库与接口文档
|
||
|
||
版本: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`。
|