Files
rond-with-you/internal/web/image_recovery.go
T
tamakyi 6b665b6056 图片恢复:坐标来源全部合并,认不出时说清已查过哪些来源
真数据实测(扫描 40 个对象、只挂回 7 张)暴露了第一版的漏洞:
报错「数据集 #1 里没有编号 389 的地点」——来源能读出来,却不是上传那会儿的编号空间。

根因:srcPKCoords 是「第一个能读到的来源就返回」,而**换基线会覆盖 baselines/<id>.sqlite**,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。
教训很直白:**「能读出数据」不等于「读对了那一版」**。

改了三处:
1. 来源全部读入合并(先读的优先):库里 places → entity_raw 里 ZLOCATION 的整行留档
   (站点上删掉的地点,places 没了、留档可能还在)→ baselines/<id>.sqlite 及其每一份 .bak-*。
   读不动的文件跳过换下一个,不再「第一份成功就返回」。
2. 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止
   (站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出)。
3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条按**数据集归并**上报
   (「数据集 #1:31 张(编号 389、390…),已查来源 …」),不再刷「另有 31 张」;
   完整清单(前 30 条)进服务端日志。

新增用例:当前底座缺编号、换基线留档里才有(正是用户遇到的形状)⇒ 必须救得回;
以及「真的认不出」时要带出已查来源。全量验收 PASS=332 FAIL=0 SKIP=0。
2026-10-01 00:11:04 +08:00

633 lines
21 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
}
// 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)
}