图片不再随数据集删除:加「图片挂载恢复」,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。
This commit is contained in:
+10
-1
@@ -437,9 +437,15 @@ ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lon DOUBLE PRECISION;
|
||||
-- 地点图片:站长给地点配的图。字节由**浏览器**转成 WebP 后再上传(服务端只负责收下与转存),
|
||||
-- 1 核机器不用扛解码。storage 记这一张实际放在哪、object_key 是本地相对路径或对象存储 key:
|
||||
-- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。
|
||||
--
|
||||
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE,后果见下)。
|
||||
-- 图片的「认地点」靠坐标键 place_key,与数据集无关;数据集只是当初上传时的落点。
|
||||
-- 带 CASCADE 时删除旧数据集会把图片行一并删掉,而**文件还留在磁盘上**——
|
||||
-- 读取按坐标键、行却按数据集级联,两套口径打架,实测就丢过一批图(2026-09-30)。
|
||||
-- 备注/结论早就改成坐标键了,图片这里跟上,dataset_id 退化成「出处」这一列。
|
||||
CREATE TABLE IF NOT EXISTS place_images (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
src_pk INT NOT NULL,
|
||||
-- place_key 与备注、结论共用同一套坐标键:换数据集后图片还挂得回原地点。
|
||||
-- 对象键里仍然带 dataset_id(那只是存储路径,与「认不认得出是同一个地点」无关)。
|
||||
@@ -455,6 +461,9 @@ CREATE TABLE IF NOT EXISTS place_images (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_images_place ON place_images(dataset_id, src_pk, position);
|
||||
|
||||
-- 老库摘掉那根外键(新库上面的 CREATE 里就没有)。幂等:约束不在时什么都不做。
|
||||
ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey;
|
||||
|
||||
-- ---------- 附属数据改挂坐标键(一次性迁移)----------
|
||||
-- 备注 / 结论 / 图片原本挂 (dataset_id, src_pk),换个数据集就全看不见了。
|
||||
-- 这里把它们搬到坐标键上;只在「还没有 place_key 列」时执行,所以新库直接走上面的
|
||||
|
||||
@@ -164,6 +164,9 @@ func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。
|
||||
//
|
||||
// 按 id 就行,**不按数据集过滤**:图片认地点靠坐标键、与数据集无关,而且数据集可能已经被删
|
||||
// (那时行还在、dataset_id 指向一个不存在的编号),带 dataset_id 过滤就永远删不掉了。
|
||||
func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ds, err := s.datasetFor(ctx)
|
||||
@@ -181,7 +184,7 @@ func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var backend, key string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images
|
||||
WHERE id=$1 AND dataset_id=$2`, id, ds.ID).Scan(&backend, &key); err != nil {
|
||||
WHERE id=$1`, id).Scan(&backend, &key); err != nil {
|
||||
s.json(w, map[string]any{"error": "图片不存在"})
|
||||
return
|
||||
}
|
||||
@@ -191,8 +194,7 @@ func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1 AND dataset_id=$2`,
|
||||
id, ds.ID); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1`, id); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
// ---------- 图片挂载恢复 ----------
|
||||
//
|
||||
// 守的是这么一件事:图片的「认地点」靠坐标键、与数据集无关,但早期 `place_images.dataset_id`
|
||||
// 上带 ON DELETE CASCADE ⇒ 删掉旧数据集时行被级联删掉,**文件却留在磁盘上**。
|
||||
// 2026-09-30 实测:数据集 9 被删后 place_images 从 N 行变 0 行,4 个 .webp 还在盘上。
|
||||
// 这三条用例分别守:文件能挂回来、历史对象键布局也认得出、以及「以后不会再丢」。
|
||||
|
||||
// imageURLs 读某个地点在页面上会看到哪些图片(与 placeImagesOf 同一口径)。
|
||||
func (f *movementFixture) imageURLs(t *testing.T, srcPK int) []string {
|
||||
t.Helper()
|
||||
imgs, err := f.srv.placeImagesOf(f.ctx, f.dsID, srcPK)
|
||||
if err != nil {
|
||||
t.Fatalf("读地点图片失败: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, im := range imgs {
|
||||
out = append(out, im.URL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isolateImageState 让这一轮用例独占「这个地点的图片」这份状态。
|
||||
//
|
||||
// 必须隔离,两处都要:
|
||||
// 1. **盘上的文件**:media 目录是共用的,而图片按坐标键跨数据集读,目录里只要有一张
|
||||
// 「坐标相同」的图(上一轮跑剩的、别的用例留下的)就会被算成这个地点的图——
|
||||
// 没隔离时它报「应当 2 张、实际 10 张」。
|
||||
// 2. **库里的行**:`recoverPlaceImages` 按 object_key 判重、`placeImagesOf` 按 place_key 读,
|
||||
// 都不看数据集;库里留着上一轮的行时,新文件会被当成「已经在库里」直接跳过——
|
||||
// 实测过 `Already:1` 让兜底分支根本没被执行到,用例却只看「挂回 1 张」就差点放过。
|
||||
//
|
||||
// 隔离不了的部分(别的坐标的残留)不影响这些断言;本轮插的行归本数据集,夹具收尾会清掉。
|
||||
func isolateImageState(t *testing.T, f *movementFixture, lat, lon float64) string {
|
||||
t.Helper()
|
||||
f.cfg.DataDir = t.TempDir()
|
||||
key := rond.PlaceCoordKey(lat, lon)
|
||||
if _, err := f.pg.ExecContext(f.ctx, `DELETE FROM place_images WHERE place_key=$1`, key); err != nil {
|
||||
t.Fatalf("清理历史图片行失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM place_images WHERE place_key=$1`, key)
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
// fakeWebP 造一段最小的 WebP 字节:只需要 RIFF....WEBP 头骗过 isWebP(不真的解码)。
|
||||
func fakeWebP(seed byte, n int) []byte {
|
||||
if n < 32 {
|
||||
n = 32
|
||||
}
|
||||
b := make([]byte, n)
|
||||
copy(b, "RIFF")
|
||||
copy(b[8:], "WEBP")
|
||||
for i := 12; i < n; i++ {
|
||||
b[i] = seed
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// pickPlaceWithCoords 挑一个有坐标的地点(图片只能挂到有坐标的地点上)。
|
||||
func pickPlaceWithCoords(t *testing.T, f *movementFixture) (srcPK int, name string, lat, lon float64) {
|
||||
t.Helper()
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk, COALESCE(name,''), lat, lon FROM places
|
||||
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat<>0 OR lon<>0)
|
||||
ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&srcPK, &name, &lat, &lon); err != nil {
|
||||
t.Fatalf("取地点失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// imageRowCount 数某个 key 在库里的行数。
|
||||
func imageRows(t *testing.T, f *movementFixture, objectKey string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE object_key=$1`,
|
||||
objectKey).Scan(&n); err != nil {
|
||||
t.Fatalf("查图片行失败: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// saveTwoImages 按正常路径给一个地点存两张图,返回它们的对象键。
|
||||
func saveTwoImages(t *testing.T, f *movementFixture, srcPK int) []string {
|
||||
t.Helper()
|
||||
var keys []string
|
||||
for i, seed := range []byte{0x11, 0x22} {
|
||||
_, url, err := f.srv.savePlaceImage(f.ctx, f.dsID, srcPK, fakeWebP(seed, 64+i), 4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("存第 %d 张图失败: %v", i+1, err)
|
||||
}
|
||||
key := strings.TrimPrefix(url, "/media/")
|
||||
if key == "" || key == url {
|
||||
t.Fatalf("本地存储应当返回 /media/<key>,实际 %q", url)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(key))); err != nil {
|
||||
t.Fatalf("图片文件没落盘(%s): %v", key, err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestRecoverPlaceImagesReattachesFilesOnDisk 行没了、文件还在 ⇒ 按坐标挂回来。
|
||||
func TestRecoverPlaceImagesReattachesFilesOnDisk(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
srcPK, name, lat, lon := pickPlaceWithCoords(t, f)
|
||||
wantKey := isolateImageState(t, f, lat, lon)
|
||||
keys := saveTwoImages(t, f, srcPK)
|
||||
|
||||
// 模拟早期行为:删掉数据集时行被级联带走(文件留在盘上)
|
||||
if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil {
|
||||
t.Fatalf("清理图片行失败: %v", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(k))); err != nil {
|
||||
t.Fatalf("用例前提不成立:文件应当还留在盘上(%s): %v", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 预演不许写库
|
||||
rep, err := srv.recoverPlaceImages(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("预演失败: %v", err)
|
||||
}
|
||||
if rep.Attached != 2 {
|
||||
t.Errorf("预演应当报 2 张可挂回,实际 %d(认不出 %d)", rep.Attached, len(rep.Unresolved))
|
||||
}
|
||||
if imageRows(t, f, keys[0]) != 0 {
|
||||
t.Error("预演就把行写进去了")
|
||||
}
|
||||
|
||||
rep, err = srv.recoverPlaceImages(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if rep.Attached != 2 {
|
||||
t.Errorf("应当挂回 2 张,实际 %d", rep.Attached)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if imageRows(t, f, k) != 1 {
|
||||
t.Errorf("%s 没有挂回(或挂重了)", k)
|
||||
}
|
||||
}
|
||||
// 挂在当前数据集上、地点对得上、坐标键正确:这三条决定它能不能在页面上显示
|
||||
var gotPK int
|
||||
var gotKey, gotStorage string
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT src_pk, place_key, storage FROM place_images
|
||||
WHERE object_key=$1`, keys[0]).Scan(&gotPK, &gotKey, &gotStorage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPK != srcPK || gotKey != wantKey || gotStorage != "local" {
|
||||
t.Errorf("挂回的行不对:src_pk=%d(想 %d)place_key=%q(想 %q)storage=%q",
|
||||
gotPK, srcPK, gotKey, wantKey, gotStorage)
|
||||
}
|
||||
// 页面口径(按坐标键跨数据集读)必须能看到这两张图
|
||||
urls := f.imageURLs(t, srcPK)
|
||||
for _, k := range keys {
|
||||
if !containsStr(urls, "/media/"+k) {
|
||||
t.Errorf("地点「%s」的图片里没有挂回的 %s(实际 %v)", name, k, urls)
|
||||
}
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("地点「%s」应当正好 2 张图,实际 %d:%v", name, len(urls), urls)
|
||||
}
|
||||
|
||||
// 幂等:再跑一次不该重复插
|
||||
rep, err = srv.recoverPlaceImages(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("重复恢复失败: %v", err)
|
||||
}
|
||||
if rep.Attached != 0 || rep.Already < 2 {
|
||||
t.Errorf("重复执行应当全部跳过,实际新挂 %d、已在库 %d", rep.Attached, rep.Already)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if imageRows(t, f, k) != 1 {
|
||||
t.Errorf("重复执行把 %s 插重了", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverPlaceImagesUnderstandsLegacyKeyLayout 早期对象键是
|
||||
// `<数据集>/<地点号>/<哈希>.webp`(中间那层目录就是地点号),也要认得出。
|
||||
func TestRecoverPlaceImagesUnderstandsLegacyKeyLayout(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key string
|
||||
ds int
|
||||
pk int
|
||||
ok bool
|
||||
}{
|
||||
{"现在的写法", "9/202609/309-042beb5e786707bb.webp", 9, 309, true},
|
||||
{"早期写法", "9/286/ebe4914ed6d18f6b626d.webp", 9, 286, true},
|
||||
{"地点号刚好 6 位(早期写法)", "9/100001/beef.webp", 9, 100001, true},
|
||||
{"看不出数据集", "x/202609/1-aa.webp", 0, 0, false},
|
||||
{"层数不对", "9/309.webp", 0, 0, false},
|
||||
{"文件名没有地点号前缀、中间也不是数字", "9/xx/aa.webp", 0, 0, false},
|
||||
} {
|
||||
ds, pk, ok := parseImageKey(tc.key)
|
||||
if ok != tc.ok || (ok && (ds != tc.ds || pk != tc.pk)) {
|
||||
t.Errorf("parseImageKey(%q) = (%d,%d,%v),想 (%d,%d,%v)", tc.key, ds, pk, ok, tc.ds, tc.pk, tc.ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 真数据:把文件改名成早期布局,仍然挂得回去
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
srcPK, _, lat, lon := pickPlaceWithCoords(t, f)
|
||||
isolateImageState(t, f, lat, lon)
|
||||
keys := saveTwoImages(t, f, srcPK)
|
||||
if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := filepath.ToSlash(filepath.Join(strconv.Itoa(int(dsID)), strconv.Itoa(srcPK), fmt.Sprintf("abc123-%d.webp", time.Now().UnixNano())))
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(legacy))), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(keys[0]))
|
||||
if err := os.Rename(old, filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(legacy))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep, err := srv.recoverPlaceImages(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if rep.Attached != 2 {
|
||||
t.Errorf("两个文件(含早期布局那个)都该挂回,实际 %d(认不出 %d)", rep.Attached, len(rep.Unresolved))
|
||||
}
|
||||
if imageRows(t, f, legacy) != 1 {
|
||||
t.Error("早期布局那个文件没挂回")
|
||||
}
|
||||
urls := f.imageURLs(t, srcPK)
|
||||
if !containsStr(urls, "/media/"+legacy) || !containsStr(urls, "/media/"+keys[1]) {
|
||||
t.Errorf("早期布局与现在布局的文件都该看得见,实际 %v", urls)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("应当正好 2 张图,实际 %d:%v", len(urls), urls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverPlaceImagesFallsBackToBaseline 数据集已经被删(places 行没了)时,
|
||||
// 靠它留下的底座库认回地点——这正是「换基线后图片看不见」的那个场景。
|
||||
func TestRecoverPlaceImagesFallsBackToBaseline(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
srcPK, _, lat, lon := pickPlaceWithCoords(t, f)
|
||||
isolateImageState(t, f, lat, lon)
|
||||
keys := saveTwoImages(t, f, srcPK)
|
||||
if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 造一个「已经被删掉的数据集」的对象键:编号库里没有,只有一份底座库认得它
|
||||
const ghost = 987654
|
||||
ghostBase := srv.baselinePath(ghost)
|
||||
if err := os.MkdirAll(filepath.Dir(ghostBase), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeGhostBaseline(t, ghostBase, srcPK, lat, lon)
|
||||
t.Cleanup(func() { os.Remove(ghostBase) })
|
||||
|
||||
key := filepath.ToSlash(filepath.Join(strconv.Itoa(ghost), strconv.Itoa(srcPK), fmt.Sprintf("deadbeef-%d.webp", time.Now().UnixNano())))
|
||||
dst := filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(key))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(keys[0])), dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep, err := srv.recoverPlaceImages(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if imageRows(t, f, key) != 1 {
|
||||
t.Fatalf("没靠底座库认回地点(认不出 %d 张,理由:%v)", len(rep.Unresolved), rep.Unresolved)
|
||||
}
|
||||
var gotKey string
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT place_key FROM place_images WHERE object_key=$1`, key).
|
||||
Scan(&gotKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotKey != placeKeyOfCoords(lat, lon) {
|
||||
t.Errorf("认出的地点不对:%q", gotKey)
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Notes, " "), "底座库") {
|
||||
t.Errorf("应当说明这份坐标是从底座库来的,实际 %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteDatasetKeepsImageRows 防复发:删数据集不再带走图片行。
|
||||
//
|
||||
// 外键去掉之前,删数据集会把 place_images 连带删掉、文件却留在盘上——两套口径打架。
|
||||
// 现在图片只认坐标键,dataset_id 退化成「出处」这一列。
|
||||
func TestDeleteDatasetKeepsImageRows(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID := f.ctx, f.dsID
|
||||
srcPK, _, lat, lon := pickPlaceWithCoords(t, f)
|
||||
isolateImageState(t, f, lat, lon)
|
||||
keys := saveTwoImages(t, f, srcPK)
|
||||
|
||||
if _, err := f.pg.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, dsID); err != nil {
|
||||
t.Fatalf("删数据集失败: %v", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if imageRows(t, f, k) != 1 {
|
||||
t.Errorf("删数据集把图片行 %s 带走了(外键没摘干净?)", k)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(k))); err != nil {
|
||||
t.Errorf("文件也不该被删: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeGhostBaseline 造一份只有 ZLOCATION 的最小底座库(模拟「数据集已删、底座还在」)。
|
||||
func writeGhostBaseline(t *testing.T, path string, srcPK int, lat, lon float64) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE ZLOCATION (
|
||||
Z_PK INTEGER PRIMARY KEY, ZLATITUDE REAL, ZLONGITUDE REAL, ZNAME_ TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO ZLOCATION (Z_PK, ZLATITUDE, ZLONGITUDE, ZNAME_)
|
||||
VALUES (?,?,?,'旧地点')`, srcPK, lat, lon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// placeKeyOfCoords 与生产同口径的坐标键(图片「认地点」靠的就是它)。
|
||||
func placeKeyOfCoords(lat, lon float64) string {
|
||||
return rond.PlaceCoordKey(lat, lon)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -119,6 +120,14 @@ func newMovementFixture(t *testing.T) *movementFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("初始化服务失败: %v", err)
|
||||
}
|
||||
// 图片的清理要自己来:place_images 只认坐标键、**不再随数据集级联**(见 schema 里的说明),
|
||||
// 所以删用户/删数据集都不会带走它。不显式清的话,上一个用例留下的行会在按坐标键读图时
|
||||
// 被算进下一个用例(实测「应当 2 张、实际 4 张」就是这么来的)。
|
||||
// 注册得最晚 ⇒ t.Cleanup 后进先出,它会在删用户之前跑。
|
||||
t.Cleanup(func() {
|
||||
pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID)
|
||||
os.RemoveAll(filepath.Join(cfg.MediaDir(), strconv.FormatInt(dsID, 10)))
|
||||
})
|
||||
return &movementFixture{cfg: cfg, pg: pg, srv: srv, dsID: dsID, src: src,
|
||||
ctx: ctxWithUser(&auth.User{ID: uid, Username: user})}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ func (s *Server) routes() {
|
||||
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
|
||||
s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload))
|
||||
s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete))
|
||||
s.mux.HandleFunc("POST /admin/images/recover", s.requireAdmin(s.adminImagesRecover))
|
||||
// 本地存储的图片直出;对象存储的图走公开域名,不经过这里
|
||||
s.mux.HandleFunc("GET /media/", s.serveMedia)
|
||||
s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk))
|
||||
|
||||
@@ -99,6 +99,21 @@
|
||||
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup(可回导 rond)</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<h2 style="margin:0">图片挂载恢复</h2>
|
||||
<form method="post" action="/admin/images/recover"
|
||||
onsubmit="return confirm('扫描磁盘上的图片文件,把库里没有记录的那些按坐标挂回地点?只新增记录、不动文件,可反复执行。')">
|
||||
<button class="btn sm" type="submit">扫描并恢复</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
换了数据集之后地点图片看不见了?<b>早期版本的图片记录挂在数据集上,删掉旧数据集时会连记录一起删</b>,
|
||||
而图片文件还留在磁盘里。点这里按坐标把它们挂回去(假如图片属于旧数据集,先别删它的底座文件——
|
||||
认地点要用它)。现在这版已经不会再有这个问题了。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<h2 style="margin:0">换基线(导出底稿)</h2>
|
||||
@@ -147,7 +162,7 @@
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/datasets/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('删除后该数据集的所有记录都会从数据库移除,且不可恢复。确认删除?')">
|
||||
onsubmit="return confirm('删除后该数据集的到访、地点、位移、原始行留档都会从数据库移除,且不可恢复;挂在它下面的专题也会一起删掉(备注、结论、图片按坐标键保留,不受影响)。确认删除?')">
|
||||
<button class="btn sm danger" type="submit">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user