用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:
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。
664 lines
22 KiB
Go
664 lines
22 KiB
Go
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)
|
||
}
|