Files
rond-with-you/internal/web/image_recovery.go
T
tamakyi 4c3032cf48 防复发:把「删数据集带走站点自建数据」做成结构性门禁 + 常驻体检
用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:

1) 归属铁律(写进 schema.sql 抬头 + README 新章节)
   判断标准只有一句:**这份记录能不能从备份重新导入?**
   能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可挂
   `REFERENCES datasets(id) ON DELETE CASCADE`;
   不能(place_images/place_notes/place_votes/topics)⇒ 只留 dataset_id 当出处、**不许挂外键**。

2) 两道门禁(dataset_ownership_test.go),都做了变异验证:
   - 运行时:查 pg_constraint,凡挂 datasets 的表必须在白名单里;**反向也查**
     (白名单里的表不许被顺手摘掉,否则删数据集会留成片孤儿行)。
   - 静态:扫 schema.sql 的 CREATE TABLE。只看运行时有盲点——新建的库走 CREATE TABLE,
     开发库可能压根没跑过那次迁移。变异(把 topics 的 CASCADE 补回去)当场红。
   - 变异验证顺带发现:扫描必须先跳过 `--` 注释行,规矩的说明文字里就写着
     `REFERENCES datasets(id)`,不跳过会把自己写的文档当成违规。
   - 还发现运行时那道单独看**不够**:夹具每次跑迁移,迁移自己就把坏外键摘了,
     它永远不会在不健康的库上报警 ⇒ 补了「注入事故原样的外键再跑迁移,断言被摘掉」
     的自愈用例(并先确认变异真的生效,防假绿)。

3) 常驻体检:后台首页显示「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 >0 直接报警。
   事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,等照片看不见了才追。

自愈用例又抓出一个此前漏掉的缺口:place_notes / place_votes 上的同类外键迁移并不会摘
(原先只写了 place_images 与 topics 两句 DROP)⇒ 补成五张自建数据表全覆盖。

⚠️ 排序坑(本项目第二次踩):摘外键那段最初放在 place_images 建表**之前**,
开发库毫无症状、**全新库起不来**(ALTER 报「关系不存在」),被
TestFreshDatabaseServesAppQueries 抓住。整段已挪到 place_images 之后,并把坑写进注释。

顺带订正「删除数据集」的二次确认文案(专题改完之后它还在说专题会一起删)。
全量验收 PASS=336 FAIL=0 SKIP=0。
2026-10-01 01:15:04 +08:00

664 lines
22 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"
"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 是「旧数据集编号 → 坐标」的一次解析结果,顺带说明这份数据是从哪来的。
//
// refs 里同一个编号可能有**多组候选坐标**:站点上纠过偏、或者按来源不同算出的坐标不一样
// (实测「站点把坐标纠偏过」与「手机那侧改过」都会出现)。逐个试到能在当前数据集里对上为止。
type coordSource struct {
refs map[int][]coordRef
note string
}
type recoveredPlace struct {
Key string
Place string
Count int
}
// unresolvedImage 是一张认不出来的图。带 Dataset / SrcPK / Note 是为了**按数据集归并**上报:
// 一份包缺编号往往是整片整片的,逐条刷「另有 31 张」没用,得说清是哪个数据集、缺哪些编号、
// 已经查过哪些来源,站长才判断得出该去找哪份包。
type unresolvedImage struct {
ObjectKey string
Reason string
Dataset int
SrcPK int
Note string // 已查过的坐标来源
}
// unresolvedGroup 是按数据集归并后的认不出清单。
type unresolvedGroup struct {
Dataset int
Count int
SrcPKs []int
Note 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 从对象键里取出 (数据集编号, 地点编号)。
//
// 键有两种形状,历史原因并存,都要认:
//
// <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 汇总某个数据集里「地点编号 → 坐标」的**全部来源**并合并(先读的优先)。
//
// ⚠️ 不能「第一个能读的就算了」——实测 33 张认不出来就是这么来的:
// 换基线会**覆盖** baselines/<id>.sqlite,当前那份未必是上传那会儿的编号空间
// (报错长这样:「数据集 #1 里没有编号 389 的地点」:能读出 ZLOCATION,却没有当年那几个编号)。
// 而换基线留下的 .bak 里很可能还留着旧编号。所以下面几种来源**全部**读一遍,
// 按可靠性依次填表,缺哪个编号再用后面的来源补:
// 1. 库里该数据集的 places(最准,站点改过坐标也认)
// 2. entity_raw 里 ZLOCATION 的整行留档(站点上删掉的地点,places 没了、留档可能还在)
// 3. baselines/<id>.sqlite 以及换基线留下的每一份 .bak-*
func (s *Server) srcPKCoords(ctx context.Context, datasetID int64) (coordSource, error) {
refs := map[int][]coordRef{}
var notes []string
// 按可靠性依次追加(先来的候选排前面);不同来源给出不同坐标时全都留着,重复的跳过
add := func(m map[int]coordRef) {
for k, v := range m {
if !hasCoord(refs[k], v) {
refs[k] = append(refs[k], v)
}
}
}
if m, err := s.placesCoords(ctx, datasetID); err == nil && len(m) > 0 {
add(m)
notes = append(notes, "库里 places")
}
if m, err := s.rawCoords(ctx, datasetID); err == nil && len(m) > 0 {
add(m)
notes = append(notes, "原始行留档")
}
for _, p := range s.baselineCandidates(datasetID) {
m, err := readLocationCoords(ctx, p)
if err != nil {
continue // 读不动(文件坏了 / 不是那套表)就跳过,换下一个来源
}
add(m)
notes = append(notes, filepath.Base(p))
}
if len(refs) == 0 {
return coordSource{}, fmt.Errorf(
"数据集 #%d 找不到任何「地点编号 → 坐标」的来源(已试:库里的 places、原始行留档、baselines/%d.sqlite 及其 .bak)",
datasetID, datasetID)
}
return coordSource{refs: refs, note: strings.Join(notes, " / ")}, nil
}
// hasCoord 判断这组候选坐标里已经有没有同一个(去重用)。
func hasCoord(list []coordRef, want coordRef) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// placesCoords 读某数据集在库里的地点(只有数据集还在时才查得到)。
func (s *Server) placesCoords(ctx context.Context, datasetID int64) (map[int]coordRef, error) {
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 nil, err
}
defer rows.Close()
out := 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 nil, err
}
out[pk] = r
}
return out, rows.Err()
}
// rawCoords 从原始行留档里读地点坐标。站点上删掉地点后 places 里就没了,
// 但 entity_raw 可能还留着当年那一整行(导出也靠它),正好能补上这段编号。
func (s *Server) rawCoords(ctx context.Context, datasetID int64) (map[int]coordRef, error) {
rows, err := s.db.QueryContext(ctx, `SELECT (raw->>'Z_PK')::int,
NULLIF(raw->>'ZLATITUDE','')::float8, NULLIF(raw->>'ZLONGITUDE','')::float8,
COALESCE(raw->>'ZNAME_','')
FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION'
AND (raw->>'Z_PK') ~ '^[0-9]+$'
AND COALESCE(raw->>'ZLATITUDE','') <> '' AND COALESCE(raw->>'ZLONGITUDE','') <> ''`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
out := 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 nil, err
}
out[pk] = r
}
return out, rows.Err()
}
// baselineCandidates 列出某数据集可用的底座库:当前那份在先,换基线留下的 .bak 新的在后。
func (s *Server) baselineCandidates(datasetID int64) []string {
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...)
}
return cands
}
// 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{ObjectKey: key,
Reason: "对象键里看不出数据集/地点编号"})
continue
}
src, ok := cache[dsID]
if !ok {
if msg, bad := failed[dsID]; bad {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID,
SrcPK: srcPK, Reason: 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{ObjectKey: key, Dataset: dsID,
SrcPK: srcPK, Reason: msg})
continue
}
cache[dsID] = src
rep.Notes = append(rep.Notes, src.note)
}
cands, ok := src.refs[srcPK]
if !ok || len(cands) == 0 {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, SrcPK: srcPK,
Note: src.note,
Reason: fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点(已查来源:%s)", dsID, srcPK, src.note)})
continue
}
// 同一个编号可能有多组候选坐标(站点纠过偏、来源不同),逐个试到能对上为止
place, ok := PlaceRef{}, false
var pkey string
var tried []string
for _, ref := range cands {
k := rond.PlaceCoordKey(ref.Lat, ref.Lon)
if p, hit := cur[k]; hit {
place, pkey, ok = p, k, true
break
}
tried = append(tried, k)
}
if !ok {
// 编号找得到、坐标也算出来了,只是当前这份数据里没有这些坐标的地点了
// (多半是在 App 里被合并/删掉,或坐标在 App 那侧改了)。要把试过的坐标说出来才排查得动。
why := fmt.Sprintf("当前数据集里没有编号 %d 对应的地点(旧地点「%s」", srcPK, cands[0].Name)
if len(tried) > 0 {
why += fmt.Sprintf(";试过坐标 %s", strings.Join(tried, "、"))
}
why += fmt.Sprintf(";已查来源:%s)", src.note)
rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, SrcPK: srcPK,
Note: src.note,
Reason: why})
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
}
// groupUnresolved 按数据集归并(保持首次出现顺序)。
func groupUnresolved(list []unresolvedImage) []unresolvedGroup {
var out []unresolvedGroup
idx := map[int]int{}
for _, u := range list {
if u.Dataset == 0 {
continue
}
i, ok := idx[u.Dataset]
if !ok {
idx[u.Dataset] = len(out)
out = append(out, unresolvedGroup{Dataset: u.Dataset, Note: u.Note})
i = len(out) - 1
}
out[i].Count++
if u.SrcPK > 0 && !containsInt(out[i].SrcPKs, u.SrcPK) {
out[i].SrcPKs = append(out[i].SrcPKs, u.SrcPK)
}
}
for i := range out {
sort.Ints(out[i].SrcPKs)
}
return out
}
func containsInt(list []int, want int) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
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
}
// imageHealth 报告「库里的图片行」与「盘上的图片对象」对不对得上。
//
// 对不上(对象比行多)就说明有一批图其实还在、却看不见——正是那次事故的形状:
// 行被级联删了、文件留在盘上。**让它随时可见**,下次再漂移当场就能发现,不必等照片消失。
//
// 只扫本地磁盘(走一次目录,毫秒级,适合每次进后台都算);对象存储要真正列一次桶才知道,
// 那是一次 API 调用,放在「扫描并恢复」动作里,不放到每次进后台的开销里。
func (s *Server) imageHealth(ctx context.Context) (rows, objects, orphans int) {
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images`).Scan(&rows); err != nil {
return 0, 0, 0
}
keys, err := s.mediaKeys()
if err != nil {
return rows, 0, 0
}
objects = len(keys)
if objects == 0 {
return rows, 0, 0
}
attached, err := s.attachedImageKeys(ctx)
if err != nil {
return rows, objects, 0
}
for _, k := range keys {
if !attached[k] {
orphans++
}
}
return rows, objects, orphans
}
// 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))
// 按数据集归并:一份包缺编号往往是整片整片的,逐条刷「另有 31 张」看不出问题在哪,
// 得说清「哪个数据集缺哪些编号、已经查过哪些来源」。
groups := groupUnresolved(rep.Unresolved)
for i, g := range groups {
if i == 3 {
msg += fmt.Sprintf(",另有 %d 个数据集", len(groups)-i)
break
}
pks := g.SrcPKs
if len(pks) > 6 {
pks = pks[:6]
}
msg += fmt.Sprintf("。数据集 #%d:%d 张(编号 %v…)", g.Dataset, g.Count, pks)
if g.Note != "" {
msg += fmt.Sprintf(",已查来源 %s", g.Note)
}
}
// 提示条装不下全部,服务端日志里留一份(够排查)
for i, u := range rep.Unresolved {
if i >= 30 {
break
}
log.Printf("图片恢复:认不出 %s —— %s", u.ObjectKey, u.Reason)
}
}
s.adminFlash(w, r, kind, msg+tail)
}