Files
rond-with-you/internal/web/handlers_images.go
T
tamakyi 496c992e5e 图片不再随数据集删除:加「图片挂载恢复」,place_images 摘掉外键
用户报「之前上传的照片找不到了,应该是因为清了应用数据换基线」。查下来与 App 侧无关,
是站点自己两套口径打架:

- place_images.dataset_id 带 ON DELETE CASCADE,而读取按 place_key 坐标键、跨数据集。
- 于是删掉旧数据集时图片**行**被级联删了,**文件却留在 media 目录里**。
- 实测(本机):datasets 只剩 155 号、place_images 0 行、盘上 4 个 .webp 好端端躺着。
  同一批附属数据里备注(17)与结论(3+1)因为早就迁到坐标键,毫发无损——只有图片倒霉。

三件事:

1) 恢复:后台首页「图片挂载恢复 → 扫描并恢复」(POST /admin/images/recover,
   internal/web/image_recovery.go)。只插行、不删文件、不看数据集,按 object_key 判重可反复点。
   认地点两处来源:数据集还在就查它的 places;数据集已删就读它留下的 baselines/<id>.sqlite
   还原「地点号 → 坐标」。对象键两种历史布局都认(<ds>/<YYYYMM>/<srcPK>-<hash>.webp 与
   <ds>/<srcPK>/<hash>.webp);判据先看文件名有没有 `<数字>-` 前缀,再看中间目录——
   地点号也可能刚好 6 位,只看位数会认错。

2) 防复发:CREATE TABLE place_images 去掉外键 + 幂等 DROP CONSTRAINT;删图接口不再按
   dataset_id 过滤(数据集删了也得能删图);「删除数据集」的二次确认文案改成实话。

3) 报告同类陷阱(未改):topics 仍带 CASCADE,专题随数据集一起删且没有恢复来源。

测试踩到的坑:图片「按坐标键跨数据集读」+「按 object_key 判重」都不看数据集,用例之间会
互相污染两处——盘上文件(报「应当 2 张、实际 10 张」)与库里的行(Already:1 让兜底分支
根本没跑到,用例差点假绿)。加 isolateImageState(换空 DataDir + 按 place_key 清历史行),
并给夹具补收尾清理(外键没了必须自己清)。

新增用例 4 条;验收:./scripts/verify.sh 全量 PASS=323 FAIL=0 SKIP=0。
2026-09-30 19:45:51 +08:00

307 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/setting"
)
// 地点图片的几条硬约束。字节由浏览器转好 WebP 才发过来,服务端只做形状校验,
// 不重新编码(1 核机器扛不住解码大图)。
const (
imageMaxBytes = 6 << 20 // 单张上限 6MB:长边 1440px、按 220KB 目标降质的 WebP 通常不到 300KB
imageMaxPer = 12 // 单个地点最多留几张,防止误拖一堆进来
imageUploadMem = 8 << 20 // multipart 解析用的内存上限
)
// placeKeyExpr 把 places p 那一行的坐标换算成 place_key,图片、备注、结论共用这个键。
// **必须与 rond.PlaceCoordKey 以及 notes/votes 包里的同款表达式逐位一致**:
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
const placeKeyExpr = `to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')`
// PlaceImage 是模板里用的地点图片。
type PlaceImage struct {
ID int64
URL string
Width int
Height int
Bytes int64
}
// savePlaceImage 把一段已经转好的 WebP 字节存到当前后端,落一行记录,并返回可用的地址。
// 上传接口与照片导入共用这一条路径,省得两处各写一遍 key 规则。
// url 为空表示「存是存进去了,但没配 s3_public_base,浏览器取不到」——调用方要据此提示。
func (s *Server) savePlaceImage(ctx context.Context, datasetID int64, srcPK int, data []byte, w, h int) (int64, string, error) {
if !isWebP(data) {
return 0, "", fmt.Errorf("不是 WebP 数据")
}
backend, err := s.imageBackend(ctx)
if err != nil {
return 0, "", err
}
sum := sha256.Sum256(data)
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
// 按月份分目录:一次出行传几十张,按月归堆最好找、也最好整月备份/迁移;
// 若按地点分目录,几百个地点会摊出几百个大多只有零张图的空夹。
// 文件名带地点号:在对象存储控制台里一眼看出这张是谁的,不用回库查。
// 带内容哈希:同一张图重复上传落到同一个 key,天然去重。
key := fmt.Sprintf("%d/%s/%d-%s.webp", datasetID, time.Now().Format("200601"),
srcPK, hex.EncodeToString(sum[:])[:16])
if err := s.media.Save(ctx, backend, key, data, "image/webp"); err != nil {
return 0, "", err
}
pkey, err := s.placeKeyOf(ctx, datasetID, srcPK)
if err != nil {
return 0, "", err
}
var id int64
if err := s.db.QueryRowContext(ctx, `INSERT INTO place_images
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0))
ON CONFLICT (dataset_id, src_pk, object_key) DO UPDATE SET bytes=EXCLUDED.bytes
RETURNING id`,
datasetID, srcPK, pkey, backend, key, w, h, int64(len(data))).Scan(&id); err != nil {
return 0, "", err
}
return id, s.media.PublicURL(backend, key), nil
}
// adminPlaceImageUpload 收下浏览器转好的 WebP 并落库。
// 存哪边由后台设置决定;对象存储的凭据只在服务端,前端拿到的永远是最终 URL。
func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
// 必须先卡请求体:ParseMultipartForm 只限制「内存里留多少」,超出的部分会
// 无上限地落到临时文件,光靠后面 LimitReader 拦不住磁盘被写满。
r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
return
}
src, err := strconv.Atoi(strings.TrimSpace(r.FormValue("src_pk")))
if err != nil {
s.json(w, map[string]any{"error": "地点标识无效"})
return
}
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT true FROM places
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&exists); err != nil {
s.json(w, map[string]any{"error": "地点不存在"})
return
}
pkey, err := s.placeKeyOf(ctx, ds.ID, src)
if err != nil {
s.serverError(w, r, err)
return
}
var count int
// 按坐标键数:换数据集后图片仍跟着同一个地点,配额也要跟着
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images
WHERE place_key=$1`, pkey).Scan(&count); err != nil {
s.serverError(w, r, err)
return
}
if count >= imageMaxPer {
s.json(w, map[string]any{"error": fmt.Sprintf("一个地点最多 %d 张,先删几张再传", imageMaxPer)})
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
s.json(w, map[string]any{"error": "没有收到图片"})
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, imageMaxBytes+1))
if err != nil {
s.json(w, map[string]any{"error": "读取上传内容失败"})
return
}
if len(data) > imageMaxBytes {
s.json(w, map[string]any{"error": fmt.Sprintf("图片超过 %dMB", imageMaxBytes>>20)})
return
}
if !isWebP(data) {
// 前端没转成功(老浏览器不支持 canvas.toBlob('image/webp'))时给出明确提示
s.json(w, map[string]any{"error": "只接受 WebP:请用较新的浏览器,或在转换完成后再保存"})
return
}
id, url, err := s.savePlaceImage(ctx, ds.ID, src, data,
atoiOr(r.FormValue("w"), 0), atoiOr(r.FormValue("h"), 0))
if err != nil {
log.Printf("地点图片写入失败(src_pk=%d): %v", src, err)
s.json(w, map[string]any{"error": "保存图片失败:" + err.Error()})
return
}
backend, _ := s.imageBackend(ctx)
out := map[string]any{"ok": true, "id": id, "url": url, "bytes": len(data),
"name": hdr.Filename, "storage": backend}
if url == "" {
out["error"] = "图片已存进对象存储,但没配 s3_public_base,浏览器取不到;" +
"请在 conf/app.ini 里补上公开域名后重新上传"
}
s.json(w, out)
}
// adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。
//
// 按 id 就行,**不按数据集过滤**:图片认地点靠坐标键、与数据集无关,而且数据集可能已经被删
// (那时行还在、dataset_id 指向一个不存在的编号),带 dataset_id 过滤就永远删不掉了。
func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
// 前端是用 FormData 提交的(multipart),这里**不要**先 r.ParseForm():
// 它会把 r.Form 先填成「不含 multipart 字段」的那一份,之后再怎么读都是空的
// (FormValue 只在 r.Form == nil 时才去解析)。直接用 FormValue,它自己会挑对解析方式。
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil {
s.json(w, map[string]any{"error": "图片标识无效"})
return
}
var backend, key string
if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images
WHERE id=$1`, id).Scan(&backend, &key); err != nil {
s.json(w, map[string]any{"error": "图片不存在"})
return
}
if err := s.media.Delete(ctx, backend, key); err != nil {
// 对象删不掉就留着行,否则文件永远无人回收
log.Printf("删除图片对象失败(%s %s): %v", backend, key, err)
s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()})
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1`, id); err != nil {
s.serverError(w, r, err)
return
}
s.json(w, map[string]any{"ok": true})
}
// imageBackend 取当前设置的存储后端;选了对象存储但凭据不全时明确报出来,
// 不要静默退回本地(那样用户以为存到 R2 了,其实落在服务器磁盘上)。
func (s *Server) imageBackend(ctx context.Context) (string, error) {
st, err := s.sets.Load(ctx)
if err != nil {
return "", err
}
if st.MediaBackend == setting.MediaBackendS3 {
if !s.media.S3Ready() {
return "", fmt.Errorf("后台选了对象存储,但 conf/app.ini 里的 s3_* 没配齐")
}
return setting.MediaBackendS3, nil
}
return setting.MediaBackendLocal, nil
}
// placeKeyOf 把某个数据集里的地点编号换算成坐标键。
// 换数据集之后仍靠它认出「还是同一个地点」,所以图片、备注、结论都挂在这上面。
func (s *Server) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) {
var key string
err := s.db.QueryRowContext(ctx, `SELECT `+placeKeyExpr+` FROM places p
WHERE p.dataset_id=$1 AND p.src_pk=$2 AND p.lat IS NOT NULL AND p.lon IS NOT NULL`,
datasetID, srcPK).Scan(&key)
if err == sql.ErrNoRows {
return "", fmt.Errorf("地点不存在或没有坐标")
}
return key, err
}
// placeImagesOf 读某个地点的图片,按 position 排序。
// 按坐标键查而不是 (dataset_id, src_pk):换一份备份之后图片还跟着同一个地点。
func (s *Server) placeImagesOf(ctx context.Context, datasetID int64, srcPK int) ([]PlaceImage, error) {
pkey, err := s.placeKeyOf(ctx, datasetID, srcPK)
if err != nil {
return nil, nil // 地点没有坐标就当作没有图
}
rows, err := s.db.QueryContext(ctx, `SELECT id, storage, object_key, width, height, bytes
FROM place_images WHERE place_key=$1 ORDER BY position, id`, pkey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PlaceImage
for rows.Next() {
var img PlaceImage
var backend, key string
if err := rows.Scan(&img.ID, &backend, &key, &img.Width, &img.Height, &img.Bytes); err != nil {
return nil, err
}
url := s.media.PublicURL(backend, key)
if url == "" {
continue // 存储配置被撤掉后取不到址,跳过而不是给出破图
}
img.URL = url
out = append(out, img)
}
return out, rows.Err()
}
// serveMedia 提供本地存储的图片。key 是内容寻址的(带 sha1 前缀),
// 内容永不变化,所以可以长期强缓存。
func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/media/")
full, err := s.media.LocalPath(rel)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(full)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mimeOfExt(filepath.Ext(full)))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path.Base(full), fi.ModTime(), f)
}
func mimeOfExt(ext string) string {
switch strings.ToLower(ext) {
case ".webp":
return "image/webp"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
// isWebP 校验 RIFF....WEBP 头。前端已经转过一道,这里是防御性的最后一道。
func isWebP(b []byte) bool {
return len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP"
}