# 图片上传与存储

## 已确认的实施方案

管理员配置本地、S3 兼容存储或自部署 Lsky Pro，启用一套默认上传目标；用户在头像、应用图标、商品图片字段直接选择、拖拽、粘贴图片。存储凭据仅后端加密保存。现有外部链接兼容保留。

图片经后端鉴权、格式与像素校验后上传，保持 PNG 透明通道。每张图片记录上传时的存储配置，对外使用稳定 `/api/v1/media/{id}` 地址。切换默认只影响新图；停用不影响旧图。已有配置的存储目标不可变，密钥可轮换。工单与发票保持私有本地附件流程。

本地指后端服务器磁盘。旧附件保持原 UPLOAD_DIR，新增存储可指定绝对目录。S3 使用私有 Bucket，由服务端读取。Lsky 首先适配开源 v2 API（Bearer Token、`/api/v1/upload`）；其他版本不得标称兼容，需真实接口验证。

管理能力包括配置管理、实际上传/读取/删除测试、图片列表、引用保护与未引用图片清理。上传不等于业务保存；取消编辑后临时图片延迟 24 小时清理。清理失败保留记录重试。

验证：应用直接本机运行；数据库只使用已检查状态及映射的现有 Postgres18 / RedisPwd。禁止创建或重建容器。后端 `./scripts/run-local.ps1 -Mode test`，前端 `npm run build`，再进行三种存储真实联调。没有配置的真实服务标记未验证。

接口参考：[官方仓库接口示例](https://github.com/lsky-org/lsky-pro/discussions/357)、[官方文档](https://docs.lsky.pro/)。

## 发布验收记录

2026-09-09 本机验证：

- `./scripts/run-local.ps1 -Mode test`：Go 测试、go vet 通过；其中没有配置 TEST_DATABASE_URL 的其他集成用例会跳过。
- `go test ./internal/app -run '^TestImageStorageIntegration$' -count=1 -v`：显式连接已检查的 Postgres18（127.0.0.1:5432），在临时 schema 执行全部迁移及图片上传、PNG 透明度、头像关联、引用保护、默认存储切换、旧图读取和删除测试，通过后清理临时 schema。本用例不使用 Redis；RedisPwd 已确认健康且映射 6379。
- `npm --prefix web run build`：通过。
- `node --test scripts/api-refresh-tests.cjs`：7 项通过。
- `node scripts/image-storage-ui-check.cjs`：本机前端 13140，使用 API fixtures 验证 1440/390 宽度三种配置弹窗、图片上传并保存应用图标、剪贴板粘贴，通过。脚本需要 Playwright 和 Edge；本机依赖置于忽略目录 `.local/ui-tools`，通过 NODE_PATH 指定。
- 本地目录读写、S3 签名请求及上传读取删除协议、Lsky Bearer/strategy/multipart/返回解析、错误与重定向拒绝：自动测试通过。S3 / Lsky 使用本机 HTTP 测试服务；没有真实 S3 Bucket 和 Lsky 实例配置，真实服务联调未执行，不能据此宣称所有图床版本兼容。

发布目标按现有 GitHub 仓库处理；当前未提供线上服务器和部署流程，不将源码发布描述为服务器已部署。

## 使用与升级

1. 备份数据库、DATA_KEY_FILE 和原本地上传目录，执行 `./scripts/run-local.ps1 -Mode migrate` 应用 `008_image_storage.sql`，然后重启 API。迁移只增加图片配置及附件元数据，不移动旧文件。
2. 本机 API 启动：`./scripts/run-local.ps1 -Mode api`；前端在 web 目录执行 `npm run dev`，访问 13140。升级期间不创建、替换任何容器。
3. 管理后台 → 系统设置 → 图片存储，新增配置、测试上传/读取/删除、设为默认。没有默认存储时禁止新图片上传；旧图片仍按原 UPLOAD_DIR 读取。
4. S3 桶保持私有，授予配置前缀（默认 `images/*`）的 PutObject/GetObject/DeleteObject 权限；无需浏览器 CORS。Lsky 填写站点地址和专用账号 Token；第一版只支持开源 v2 API。
5. PNG/JPEG 由后端校验并重新编码；浏览器接收 WebP 后转 PNG。头像最大 2MB，其他图片最大 10MB，宽高均最多 4096 像素。头像可选择居中方形裁剪。进度为上传忙碌状态，不显示虚假的百分比。
6. 新图片 24 小时未引用会清理，旧附件不自动清理；后台清理失败每分钟重试。Lsky 若远端已成功但网络中断且未返回图片标识，无法精确删除未知远端对象，需要在图床侧核对清理。测试删除失败会显示待清理标识。

## 管理 API

前缀 `/api/v1`，图片配置和管理需 `settings:write`；写操作沿用 Cookie、Origin、CSRF 校验。普通头像、商品和应用图片上传分别沿用用户身份、`products:write`、`applications:write` 权限；不会开放工单/发票附件。

| 接口 | 用途 |
| --- | --- |
| GET /image-policy | 获取当前图片规则 |
| PUT /admin/image-policy | 修改图片规则和外链开关 |
| GET /admin/image-storages | 列出配置，不返回密钥 |
| POST /admin/image-storages | 创建 local/s3/lsky 配置 |
| PUT /admin/image-storages/:id | 修改名称、密钥及 active；目标不能变 |
| POST /admin/image-storages/:id/test | 测试上传、图片读取、删除；分别返回状态 |
| DELETE /admin/image-storages/:id | 删除没有图片的停用配置 |
| GET /admin/images?page=1 | 每页 30 张图片及引用状态 |
| DELETE /admin/images/:id | 删除未引用图片；失败保留记录 |
| POST /attachments | multipart 的 purpose（avatar/product/application）和 file |
| GET /media/:id | 稳定展示地址，头像须已绑定 |
| GET /attachments/:id | 上传者或图片管理员预览；私有附件维持原权限 |

管理员可配置 S3 路径前缀及 CDN 域名、图片大小和尺寸、每用户图片总配额、外链开关；图片列表可按用途、存储、上传者 ID 筛选。默认路径前缀 `images/`、默认每用户配额 200MB，并保留既有每 IP 每小时 30 次上传限制。图片用量含未清理附件；降低配额不会删除旧图，但会阻止超额上传。门户首屏、图文板块和项目卡片使用同一上传链路与引用保护；图片上传后仍需保存对应门户方案才会成为长期引用。
