Files
rond-with-you/internal/web/image_recovery.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

408 lines
13 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"
"database/sql"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"rond-with-you/internal/rond"
)
// 图片挂载恢复:把**盘上还在、但库里已经没有行**的地点图片按坐标重新挂回地点。
//
// 为什么会有这种孤儿:`place_images` 原本在 `dataset_id` 上带 ON DELETE CASCADE,
// 而读取用的是坐标键(换数据集之后同一个地点本就该看到同一张图)。
// 于是「删掉旧数据集」这件事会把图片行一起带走,**文件却留在 media 目录里**——
// 备注、结论早就改成坐标键了,只有图片还挂在数据集上,删数据集就它倒霉。
// 2026-09-30 实测:数据集 9 被删后 place_images 从 N 行变成 0 行,4 个 .webp 文件好端端躺在盘上。
//
// 这个动作只**插入**行:不删文件、不改文件、不碰已有行,可重复执行(幂等)。
// 真正的防复发在 schema 里:外键已去掉,删数据集不再带走图片行。
// coordRef 是一个地点在**某个数据集**里的坐标与名字。
type coordRef struct {
Lat, Lon float64
Name string
}
// coordSource 是「旧数据集编号 → 坐标表」的一次解析结果,顺带说明这份数据是从哪来的。
type coordSource struct {
refs map[int]coordRef
note string
}
type recoveredPlace struct {
Key string
Place string
Count int
}
type unresolvedImage struct {
ObjectKey string
Reason string
}
type imageRecoverReport struct {
Scanned int // 盘上扫到的图片文件数
Attached int // 本次新挂回的行数(apply=false 时是「可挂回」)
Already int // 库里已经有行、跳过的
Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的)
Unresolved []unresolvedImage
Notes []string // 坐标来源说明,便于核对「为什么是这么认的」
Applied bool
}
// parseImageKey 从对象键里取出 (数据集编号, 地点编号)。
//
// 键有两种形状,历史原因并存,都要认:
//
// <datasetID>/<YYYYMM>/<srcPK>-<内容哈希>.webp ← 现在的写法(见 savePlaceImage)
// <datasetID>/<srcPK>/<内容哈希>.webp ← 早期写法:中间那层目录名就是地点编号
//
// 判据不能只看「中间那层是不是 6 位数字」:地点编号也可能刚好是 6 位。
// 先看文件名里有没有 `<数字>-` 前缀(现在的写法),没有再把中间那层当地点编号。
func parseImageKey(key string) (datasetID, srcPK int, ok bool) {
parts := strings.Split(key, "/")
if len(parts) != 3 {
return 0, 0, false
}
dsID, err := strconv.Atoi(parts[0])
if err != nil || dsID <= 0 {
return 0, 0, false
}
base := strings.TrimSuffix(parts[2], filepath.Ext(parts[2]))
if i := strings.Index(base, "-"); i > 0 {
if pk, err := strconv.Atoi(base[:i]); err == nil && pk > 0 {
return dsID, pk, true
}
}
if pk, err := strconv.Atoi(parts[1]); err == nil && pk > 0 {
return dsID, pk, true
}
return 0, 0, false
}
// mediaKeys 列出本地媒体目录里的全部 .webp(正斜杠相对路径 = 对象键)。
func (s *Server) mediaKeys() ([]string, error) {
root := s.cfg.MediaDir()
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err // 目录读不动就报出来,别静默漏文件
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(p), ".webp") {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
out = append(out, filepath.ToSlash(rel))
return nil
})
if errors.Is(err, fs.ErrNotExist) {
return nil, nil // 还没用过本地图片
}
sort.Strings(out)
return out, err
}
// attachedImageKeys 读出库里已经挂着的对象键,用来跳过(幂等)。
func (s *Server) attachedImageKeys(ctx context.Context) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx, `SELECT object_key FROM place_images`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out[k] = true
}
return out, rows.Err()
}
// currentPlaceIndex 把当前数据集的地点按坐标键索引起来:图片就是靠它认回地点的。
func (s *Server) currentPlaceIndex(ctx context.Context, datasetID int64) (map[string]PlaceRef, error) {
rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]PlaceRef{}
for rows.Next() {
var r PlaceRef
if err := rows.Scan(&r.SrcPK, &r.Name, &r.Lat, &r.Lon); err != nil {
return nil, err
}
if r.Lat == 0 && r.Lon == 0 {
continue
}
out[rond.PlaceCoordKey(r.Lat, r.Lon)] = r
}
return out, rows.Err()
}
// PlaceRef 是恢复时用到的最小地点信息。
type PlaceRef struct {
SrcPK int
Name string
Lat, Lon float64
}
// srcPKCoords 取某个数据集里「地点编号 → 坐标 + 名字」。
//
// 两条来源,按可靠性排:
// 1. 该数据集还在库里 ⇒ 直接查它的 places 表(最准:站点改过坐标也认)。
// 2. 数据集已经删了 ⇒ 退到它留下的**底座库**(`baselines/<id>.sqlite`)。
// 删数据集不会删底座文件,这正是这些图片还能救回来的原因;底座也被清掉就只能报「认不出」。
func (s *Server) srcPKCoords(ctx context.Context, datasetID int64) (coordSource, error) {
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT true FROM datasets WHERE id=$1`, datasetID).Scan(&exists); err == nil {
rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(lat,0), COALESCE(lon,0), COALESCE(name,'')
FROM places WHERE dataset_id=$1`, datasetID)
if err != nil {
return coordSource{}, err
}
defer rows.Close()
refs := map[int]coordRef{}
for rows.Next() {
var pk int
var r coordRef
if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil {
return coordSource{}, err
}
refs[pk] = r
}
if err := rows.Err(); err != nil {
return coordSource{}, err
}
return coordSource{refs: refs, note: fmt.Sprintf("#%d:库里的 places 表", datasetID)}, nil
}
// 数据集没了:找它留下的底座库(换基线留下的 .bak 也算,按时间新的优先)
base := s.baselinePath(datasetID)
cands := []string{base}
if baks, _ := filepath.Glob(base + ".bak-*"); len(baks) > 0 {
sort.Sort(sort.Reverse(sort.StringSlice(baks)))
cands = append(cands, baks...)
}
for _, p := range cands {
refs, err := readLocationCoords(ctx, p)
if err != nil {
continue
}
return coordSource{refs: refs, note: fmt.Sprintf("#%d:底座库 %s", datasetID, filepath.Base(p))}, nil
}
return coordSource{}, fmt.Errorf("数据集 #%d 已删除,也没找到它留下的底座库(%s)", datasetID, base)
}
// readLocationCoords 从底座库里读「地点编号 → 坐标 + 名字」(Core Data 的 ZLOCATION)。
func readLocationCoords(ctx context.Context, path string) (map[int]coordRef, error) {
sdb, err := openBaselineRO(path)
if err != nil {
return nil, err
}
defer sdb.Close()
rows, err := sdb.QueryContext(ctx, `SELECT Z_PK, ZLATITUDE, ZLONGITUDE, COALESCE(ZNAME_,'')
FROM ZLOCATION`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]coordRef{}
for rows.Next() {
var pk int
var r coordRef
var lat, lon sql.NullFloat64
if err := rows.Scan(&pk, &lat, &lon, &r.Name); err != nil {
return nil, err
}
if !lat.Valid || !lon.Valid {
continue
}
r.Lat, r.Lon = lat.Float64, lon.Float64
out[pk] = r
}
return out, rows.Err()
}
// recoverPlaceImages 扫描媒体目录,把库里没有行的图片挂回当前数据集的地点上。
// apply=false 只统计不写库。
func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecoverReport, error) {
var rep imageRecoverReport
ds, err := s.datasetFor(ctx)
if err != nil {
return rep, err
}
if ds == nil {
return rep, errors.New("还没有生效的数据集,先导入一份备份")
}
keys, err := s.mediaKeys()
if err != nil {
return rep, fmt.Errorf("扫描图片目录失败:%w", err)
}
attached, err := s.attachedImageKeys(ctx)
if err != nil {
return rep, err
}
cur, err := s.currentPlaceIndex(ctx, ds.ID)
if err != nil {
return rep, err
}
byPlace := map[string]*recoveredPlace{}
cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表
failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍
for _, key := range keys {
rep.Scanned++
if attached[key] {
rep.Already++
continue
}
dsID, srcPK, ok := parseImageKey(key)
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, "对象键里看不出数据集/地点编号"})
continue
}
src, ok := cache[dsID]
if !ok {
if msg, bad := failed[dsID]; bad {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg})
continue
}
src, err = s.srcPKCoords(ctx, int64(dsID))
if err != nil {
msg := fmt.Sprintf("认不出这张图属于哪个地点:%v", err)
failed[dsID] = msg
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg})
continue
}
cache[dsID] = src
rep.Notes = append(rep.Notes, src.note)
}
ref, ok := src.refs[srcPK]
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key,
fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点", dsID, srcPK)})
continue
}
pkey := rond.PlaceCoordKey(ref.Lat, ref.Lon)
place, ok := cur[pkey]
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key,
fmt.Sprintf("当前数据集里没有坐标 %s 的地点(旧地点「%s」)", pkey, ref.Name)})
continue
}
entry := byPlace[pkey]
if entry == nil {
entry = &recoveredPlace{Key: pkey, Place: place.Name}
byPlace[pkey] = entry
}
entry.Count++
rep.Attached++
if !apply {
continue
}
fi, err := os.Stat(filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(key)))
var size int64
if err == nil {
size = fi.Size()
}
// storage 固定 local:能在这个目录里扫到,就说明它是本地后端的对象。
// width/height 无从得知(页面只用到 URL,留 0 不影响渲染),bytes 用文件大小。
if _, err := s.db.ExecContext(ctx, `INSERT INTO place_images
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
VALUES ($1,$2,$3,'local',$4,0,0,$5,
COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0))
ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`,
ds.ID, place.SrcPK, pkey, key, size); err != nil {
return rep, fmt.Errorf("挂回 %s 失败:%w", key, err)
}
attached[key] = true
}
for _, e := range byPlace {
rep.Recovered = append(rep.Recovered, *e)
}
sort.Slice(rep.Recovered, func(i, j int) bool {
if rep.Recovered[i].Count != rep.Recovered[j].Count {
return rep.Recovered[i].Count > rep.Recovered[j].Count
}
return rep.Recovered[i].Key < rep.Recovered[j].Key
})
rep.Notes = dedupStrings(rep.Notes)
rep.Applied = apply
return rep, nil
}
func dedupStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删文件,可反复点。
func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) {
rep, err := s.recoverPlaceImages(r.Context(), true)
if err != nil {
s.adminFlash(w, r, "err", "图片恢复失败:"+err.Error())
return
}
if rep.Attached == 0 && len(rep.Unresolved) == 0 {
s.adminFlash(w, r, "ok", fmt.Sprintf("盘上 %d 个图片文件都已在库里,无需恢复。", rep.Scanned))
return
}
msg := fmt.Sprintf("扫描 %d 个文件:新挂回 %d 张", rep.Scanned, rep.Attached)
if len(rep.Recovered) > 0 {
var parts []string
for i, e := range rep.Recovered {
if i == 3 {
parts = append(parts, fmt.Sprintf("等 %d 个地点", len(rep.Recovered)))
break
}
name := e.Place
if name == "" {
name = e.Key
}
parts = append(parts, fmt.Sprintf("%s ×%d", name, e.Count))
}
msg += "(" + strings.Join(parts, "、") + ")"
}
if rep.Already > 0 {
msg += fmt.Sprintf(";已在库 %d 张", rep.Already)
}
if len(rep.Unresolved) > 0 {
msg += fmt.Sprintf(";认不出 %d 张", len(rep.Unresolved))
for i, u := range rep.Unresolved {
if i == 2 {
msg += fmt.Sprintf(",另有 %d 张", len(rep.Unresolved)-i)
break
}
msg += fmt.Sprintf("。%s:%s", u.ObjectKey, u.Reason)
}
log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved),
rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason)
}
s.adminFlash(w, r, "ok", msg)
}