package web import ( "context" "database/sql" "errors" "fmt" "io/fs" "log" "net/http" "os" "path" "path/filepath" "sort" "strconv" "strings" "rond-with-you/internal/media" "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 // 扫到的图片对象数(本地盘 + 对象存储) Remote int // 其中来自对象存储(R2/S3)的 Attached int // 本次新挂回的行数(apply=false 时是「可挂回」) Already int // 库里已经有行、跳过的 Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的) Unresolved []unresolvedImage Notes []string // 坐标来源说明,便于核对「为什么是这么认的」 Warnings []string // 非致命的问题(例如对象存储没扫成),要在页面上说出来 Applied bool } // orphanObject 是一个候选图片对象:本地盘上的文件,或对象存储里的键。 // Storage 决定恢复出来的行该记哪个后端——**行被删了就没有这一列可查**, // 只能按「对象实际在哪」判断。 type orphanObject struct { Key string Storage string Size int64 } // collectOrphanObjects 把「盘上」与「桶里」的候选对象汇总起来(本地优先,同键不重复)。 // // 对象存储扫不动时**不整体失败**:本地那部分照样能救回来,把原因作为 warning 带出去即可。 // 最常见的原因是凭据没有 s3:ListBucket 权限(R2 的 API Token 默认只给对象读写)。 func (s *Server) collectOrphanObjects(ctx context.Context) (objs []orphanObject, warnings []string, err error) { locals, err := s.mediaKeys() if err != nil { return nil, nil, fmt.Errorf("扫描图片目录失败:%w", err) } seen := make(map[string]int, len(locals)) // key -> objs 下标 root := s.cfg.MediaDir() for _, k := range locals { var size int64 if fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(k))); err == nil { size = fi.Size() } seen[k] = len(objs) objs = append(objs, orphanObject{Key: k, Storage: media.BackendLocal, Size: size}) } if !s.media.S3Ready() { return objs, warnings, nil } remote, rerr := s.media.ListObjects(ctx, "") if rerr != nil { warnings = append(warnings, fmt.Sprintf( "对象存储没扫成(只恢复了本地磁盘上的图):%v", rerr)) return objs, warnings, nil } for _, o := range remote { if !strings.EqualFold(path.Ext(o.Key), ".webp") { continue // 桶里可能有别的东西,不是我们放的图 } key := strings.TrimPrefix(o.Key, "/") if _, dup := seen[key]; dup { continue // 本地已经有同一个键:以本地那份为准(文件确实在) } seen[key] = len(objs) objs = append(objs, orphanObject{Key: key, Storage: media.BackendS3, Size: o.Size}) } return objs, warnings, nil } // parseImageKey 从对象键里取出 (数据集编号, 地点编号)。 // // 键有两种形状,历史原因并存,都要认: // // //-<内容哈希>.webp ← 现在的写法(见 savePlaceImage) // //<内容哈希>.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/.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("还没有生效的数据集,先导入一份备份") } objs, warns, err := s.collectOrphanObjects(ctx) if err != nil { return rep, err } rep.Warnings = warns 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 _, obj := range objs { key := obj.Key rep.Scanned++ if obj.Storage == media.BackendS3 { rep.Remote++ } 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 } // storage 取「对象实际在哪」:本地扫到的就是 local,只剩桶里有的就是 s3。 // 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,$4,$5,0,0,$6, 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, obj.Storage, key, obj.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 }) // 对象存储上的图挂回来了、却取不到公开地址时,行是有了页面却一片空白(placeImagesOf 会跳过 // 取不到址的行)。这个比「没恢复」更难懂,必须报出来。 if rep.Remote > 0 && s.cfg.S3PublicBase == "" { rep.Warnings = append(rep.Warnings, "对象存储上的图片已挂回,但没配 s3_public_base,"+ "浏览器取不到地址,页面会当作没图:请在 conf/app.ini 里补上公开域名") } 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 } // 扫描没扫全(比如对象存储没权限)时要显眼,否则站长会以为「恢复完了」而实际只救回本地那半 kind := "ok" if len(rep.Warnings) > 0 { kind = "err" } tail := "" for _, w := range rep.Warnings { tail += " " + w } if rep.Attached == 0 && len(rep.Unresolved) == 0 && len(rep.Warnings) == 0 { s.adminFlash(w, r, "ok", fmt.Sprintf("图片对象共 %d 个,都已在库里,无需恢复。", rep.Scanned)) return } msg := fmt.Sprintf("扫描 %d 个图片对象(其中对象存储 %d 个):新挂回 %d 张", rep.Scanned, rep.Remote, 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, kind, msg+tail) }