防复发:把「删数据集带走站点自建数据」做成结构性门禁 + 常驻体检
用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:
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。
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/db"
|
||||
)
|
||||
|
||||
// ---------- 归属口径:删数据集只许带走「从备份导入」的数据 ----------
|
||||
//
|
||||
// 事故(2026-09-30):place_images 与 topics 带 `dataset_id … ON DELETE CASCADE`,
|
||||
// 删掉旧数据集时把**站点自建**的记录一起删了——图片行没了、文件却还留在盘上(读取按坐标键、
|
||||
// 行按数据集级联,两套口径打架)。这类记录**不在备份里**,删掉就是永久丢失。
|
||||
//
|
||||
// 这不是靠记性能守住的事,所以做成结构性门禁:
|
||||
// 凡是要挂在 datasets 上的外键,表名必须在这份白名单里。新增一张带 dataset_id 的表时,
|
||||
// 先问一句「它的记录能不能从备份重新导入」——能,才允许随数据集删除;不能,就只留
|
||||
// dataset_id 当出处、不许挂外键(参考 place_images / topics 现在的写法)。
|
||||
|
||||
// backupDerivedTables 是从备份导入、删了还能再导回来的那些表。删数据集时它们跟着走是对的。
|
||||
var backupDerivedTables = []string{
|
||||
"places", "visits", "movements", "weather", "activities", "tags", "entity_raw",
|
||||
}
|
||||
|
||||
// userOwnedTables 是站点自建、删数据集**必须留下**的表(只有列、没有外键)。
|
||||
var userOwnedTables = []string{"place_images", "place_notes", "place_votes", "topics"}
|
||||
|
||||
// TestOnlyBackupDerivedTablesCascadeFromDatasets 运行时判据:查 pg_constraint。
|
||||
func TestOnlyBackupDerivedTablesCascadeFromDatasets(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
rows, err := f.pg.QueryContext(f.ctx, `SELECT conrelid::regclass::text, confdeltype
|
||||
FROM pg_constraint WHERE confrelid='datasets'::regclass`)
|
||||
if err != nil {
|
||||
t.Fatalf("查外键失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
allowed := map[string]bool{}
|
||||
for _, n := range backupDerivedTables {
|
||||
allowed[n] = true
|
||||
}
|
||||
got := map[string]string{} // 表 -> 删除行为
|
||||
for rows.Next() {
|
||||
var tbl, del string
|
||||
if err := rows.Scan(&tbl, &del); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[tbl] = del
|
||||
if !allowed[tbl] {
|
||||
t.Errorf("%s 挂了 datasets 的外键(删除行为 %s):它的记录不是从备份导入的,"+
|
||||
"删数据集会连它一起删掉、且无法恢复。只留 dataset_id 当出处,不要挂外键。", tbl, del)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 反向:白名单里的表必须还在。顺手去掉 places/visits 的级联会留下成片孤儿行,
|
||||
// 而删数据集的提示文案是按「这些都随数据集删除」写的。
|
||||
for _, n := range backupDerivedTables {
|
||||
if _, ok := got[n]; !ok {
|
||||
t.Errorf("%s 应当仍随数据集删除(现在没有 datasets 外键了):删数据集会留下一堆孤儿行,"+
|
||||
"或者外键变成 RESTRICT 之后干脆删不动数据集。", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationDropsStaleDatasetForeignKeys 「库里残留着旧外键」⇒ 跑一次迁移必须把它摘掉。
|
||||
//
|
||||
// 这条最要紧:**已经部署出去的库**上就带着那根 CASCADE 外键(本机库现在还是),
|
||||
// 光改 CREATE TABLE 救不了它——必须靠 schema.sql 里那句 ALTER TABLE … DROP CONSTRAINT。
|
||||
// 这里故意把事故原样的外键加回去,跑一次迁移,断言它们没了:迁移能自愈这件事被钉住。
|
||||
//
|
||||
// 顺带解释了为什么「运行时判据」单独看不够:测试夹具每次都会跑迁移,
|
||||
// 于是运行时那条永远不会在不健康的库上报警——真正的自愈靠这条。
|
||||
func TestMigrationDropsStaleDatasetForeignKeys(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx := f.ctx
|
||||
gone := func(table string) bool {
|
||||
var n int
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT count(*) FROM pg_constraint
|
||||
WHERE conrelid=$1::regclass AND confrelid='datasets'::regclass`, table).Scan(&n); err != nil {
|
||||
t.Fatalf("查 %s 的约束失败: %v", table, err)
|
||||
}
|
||||
return n == 0
|
||||
}
|
||||
// 先把事故原样的外键加回去(NOT VALID 跳过既有行的校验,只关心约束在不在)
|
||||
for _, tbl := range userOwnedTables {
|
||||
if !hasDatasetColumn(t, f, tbl) {
|
||||
continue
|
||||
}
|
||||
if _, err := f.pg.ExecContext(ctx, fmt.Sprintf(
|
||||
`ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s_dataset_id_fkey`, tbl, tbl)); err != nil {
|
||||
t.Fatalf("清理 %s 的旧约束失败: %v", tbl, err)
|
||||
}
|
||||
if _, err := f.pg.ExecContext(ctx, fmt.Sprintf(
|
||||
`ALTER TABLE %s ADD CONSTRAINT %s_dataset_id_fkey
|
||||
FOREIGN KEY (dataset_id) REFERENCES datasets(id) ON DELETE CASCADE NOT VALID`, tbl, tbl)); err != nil {
|
||||
t.Fatalf("给 %s 补回外键失败: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
// 确认变异生效(否则这条用例本身是假绿的)
|
||||
injected := false
|
||||
for _, tbl := range userOwnedTables {
|
||||
if hasDatasetColumn(t, f, tbl) && !gone(tbl) {
|
||||
injected = true
|
||||
}
|
||||
}
|
||||
if !injected {
|
||||
t.Fatal("变异没生效:没有任何一张自建数据表被挂上外键,这条用例等于没测")
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, f.pg); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
for _, tbl := range userOwnedTables {
|
||||
if hasDatasetColumn(t, f, tbl) && !gone(tbl) {
|
||||
t.Errorf("跑完迁移后 %s 上还挂着 datasets 的外键:那句 DROP CONSTRAINT 没生效,"+
|
||||
"已经部署出去的库就不会自愈。", tbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasDatasetColumn 判断某表有没有 dataset_id 列(不是所有自建数据表都有)。
|
||||
func hasDatasetColumn(t *testing.T, f *movementFixture, table string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM pg_attribute
|
||||
WHERE attrelid=$1::regclass AND attname='dataset_id' AND NOT attisdropped`,
|
||||
table).Scan(&n); err != nil {
|
||||
t.Fatalf("查 %s 的列失败: %v", table, err)
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// TestSchemaDeclaresNoDatasetFKForUserData 静态判据:直接扫 schema.sql。
|
||||
//
|
||||
// 只看运行时约束有个盲点:**新建的库走的是 CREATE TABLE**,而开发库可能压根没跑过那次迁移。
|
||||
// 两边都钉住才不会漏——新加一张表时,写 CREATE TABLE 的那一刻就会被这条拦住。
|
||||
func TestSchemaDeclaresNoDatasetFKForUserData(t *testing.T) {
|
||||
b, err := os.ReadFile(filepath.Join(moduleRoot(t), "internal", "db", "schema.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 schema.sql 失败: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
allowed := map[string]bool{}
|
||||
for _, n := range backupDerivedTables {
|
||||
allowed[n] = true
|
||||
}
|
||||
userOwned := map[string]bool{}
|
||||
for _, n := range userOwnedTables {
|
||||
userOwned[n] = true
|
||||
}
|
||||
|
||||
createRe := regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+)`)
|
||||
refRe := regexp.MustCompile(`(?i)REFERENCES\s+datasets\s*\(`)
|
||||
cur := ""
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
// 必须先跳过注释:这份规矩的说明文字里**就写着** REFERENCES datasets(id) 这几个字,
|
||||
// 不跳过会把自己的文档当成违规(实测就这么误报过一次)。
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
continue
|
||||
}
|
||||
if m := createRe.FindStringSubmatch(line); m != nil {
|
||||
cur = m[1]
|
||||
}
|
||||
if !refRe.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
if cur == "" {
|
||||
t.Errorf("有一条 datasets 的外键没归属到任何 CREATE TABLE:%s", strings.TrimSpace(line))
|
||||
continue
|
||||
}
|
||||
if !allowed[cur] {
|
||||
t.Errorf("schema.sql 里 %s 声明了 datasets 的外键:它的记录不是从备份导入的,"+
|
||||
"删数据集会连它一起删掉(%s)。", cur, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
// 反向:自建数据表必须**真的存在**且带 dataset_id 列(防止有人顺手删表、或漏建列,
|
||||
// 让这条门禁失去意义)
|
||||
for _, n := range userOwnedTables {
|
||||
if !regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?` + n + `\b`).MatchString(src) {
|
||||
t.Errorf("schema.sql 里找不到 %s 的建表语句", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminHomeReportsOrphanImages 后台首页要当场报出「有图片文件在库里没有记录」。
|
||||
//
|
||||
// 这才是真正的预防:那次事故不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,
|
||||
// 等到照片看不见了才追。让它每次进后台都能看到,同样的漂移下次当场就亮。
|
||||
func TestAdminHomeReportsOrphanImages(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)
|
||||
saveTwoImages(t, f, srcPK)
|
||||
|
||||
// 没漂移时:不该报警
|
||||
rows, objects, orphans := srv.imageHealth(ctx)
|
||||
if rows != 2 || objects != 2 || orphans != 0 {
|
||||
t.Fatalf("刚存完两张应当完全对得上,实际 行%d 对象%d 落单%d", rows, objects, orphans)
|
||||
}
|
||||
// 制造事故形状:行没了、文件还在
|
||||
if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, objects, orphans = srv.imageHealth(ctx)
|
||||
if rows != 0 || objects != 2 || orphans != 2 {
|
||||
t.Fatalf("行删掉之后应当报 落单 2,实际 行%d 对象%d 落单%d", rows, objects, orphans)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx)
|
||||
srv.adminHome(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("后台首页状态码 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "落单 2 个") {
|
||||
t.Error("后台首页没有报出落单数,漂移就还是静默的")
|
||||
}
|
||||
if !strings.Contains(body, "在库里没有记录") {
|
||||
t.Error("后台首页没解释「落单」是什么意思")
|
||||
}
|
||||
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
|
||||
t.Error("后台首页没渲染到底")
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,8 @@ func (s *Server) adminHome(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
d.FogMasks = len(s.masksFor())
|
||||
// 图片挂载体检:行与对象对不上就当场可见(见 imageHealth 的注释)
|
||||
d.ImgRows, d.ImgObjects, d.ImgOrphans = s.imageHealth(r.Context())
|
||||
var fogUpdated sql.NullTime
|
||||
if err := s.db.QueryRowContext(r.Context(),
|
||||
`SELECT max(updated_at) FROM fog_blocks`).Scan(&fogUpdated); err != nil {
|
||||
|
||||
@@ -562,6 +562,37 @@ func dedupStrings(in []string) []string {
|
||||
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)
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
而图片文件还留在磁盘里。点这里按坐标把它们挂回去(假如图片属于旧数据集,先别删它的底座文件——
|
||||
认地点要用它)。现在这版已经不会再有这个问题了。
|
||||
</div>
|
||||
<div class="card-sub" style="margin-top:10px">
|
||||
体检:库里 <b>{{num .ImgRows}}</b> 张 · 本地盘上 <b>{{num .ImgObjects}}</b> 个{{if gt .ImgOrphans 0}}
|
||||
· <b style="color:#b42318">落单 {{num .ImgOrphans}} 个</b>{{end}}
|
||||
</div>
|
||||
{{if gt .ImgOrphans 0}}
|
||||
<div class="alert err">
|
||||
有 <b>{{num .ImgOrphans}}</b> 个图片文件在库里没有记录——它们现在谁也看不到。
|
||||
点「扫描并恢复」按坐标挂回去(对象存储上的也会一并扫,需要 s3:ListBucket 权限)。
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -162,7 +172,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>
|
||||
|
||||
@@ -659,6 +659,9 @@ type AdminData struct {
|
||||
FogMasks int
|
||||
// DefaultPW 表示管理员仍在使用初始口令,模板顶部给出警示条
|
||||
DefaultPW bool
|
||||
// 图片挂载体检:库里的行数、盘上的对象数、以及「对象有而库里没行」的落单数。
|
||||
// 落单 > 0 就是「有一批图其实还在、却看不见」,模板据此给警示条。
|
||||
ImgRows, ImgObjects, ImgOrphans int
|
||||
}
|
||||
|
||||
// VisitRow 是一条到访摊平后的展示行。地点与活动写名称而不是编号——
|
||||
|
||||
Reference in New Issue
Block a user