Files
rond-with-you/internal/web/place_image_test.go
T
tamakyi 30c502ce7b 备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」:

- 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上
  用绿/灰/红标出会新增 / 不变 / 会丢的区域
- 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人),
  新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行;
  合入的行写回 entity_raw,保证再传同一份备份时差异归零
- 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的
  到访全变成假冲突
- 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联;
  访客票没有 dataset_id,回填按活跃数据集认领
- 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释

导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
2026-09-19 16:04:00 +08:00

356 lines
13 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// webpBytes 造一段带正确 RIFF/WEBP 魔数的假数据。服务端只校验文件头
// (转码在浏览器做),所以这里不需要真的 WebP 编码器。
func webpBytes(n int) []byte {
b := make([]byte, 12+n)
copy(b[0:4], "RIFF")
copy(b[8:12], "WEBP")
for i := 12; i < len(b); i++ {
b[i] = byte(i)
}
return b
}
// postMultipart 按浏览器 FormData 的形态发一个 multipart 请求。
func postMultipart(t *testing.T, f *movementFixture, handler http.HandlerFunc, fields map[string]string, files map[string][]byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatal(err)
}
}
for field, data := range files {
part, err := w.CreateFormFile(field, "image.webp")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/x", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
// setMediaBackend 把「图片存哪边」临时改成 backend,测试结束再恢复。
// 多个测试共用一个库,不还原就会互相干扰(前一个设了 s3,后一个的本地上传就失败了)。
func setMediaBackend(t *testing.T, f *movementFixture, backend string) {
t.Helper()
var prev string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(value->>'media_backend','local')
FROM settings WHERE key='site'`).Scan(&prev); err != nil {
t.Fatalf("读图片存储设置失败: %v", err)
}
write := func(v string) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, `UPDATE settings
SET value = jsonb_set(value, '{media_backend}', to_jsonb($1::text)) WHERE key='site'`, v); err != nil {
t.Fatalf("写图片存储设置失败: %v", err)
}
}
write(backend)
t.Cleanup(func() { write(prev) })
}
// TestPlaceImageUploadDelete 端到端跑一遍浏览器那条路:
// multipart 上传 → 落库 + 落盘 → /media 取回同样的字节 → multipart 删除 → 两处都没了。
//
// 这里刻意用 multipart 而不是 urlencoded:前端是用 FormData 发的,
// 而「先 ParseForm 再读 multipart」会把字段读成空——这个坑真踩过(删除按钮点了没反应)。
func TestPlaceImageUploadDelete(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir() // 图片写进临时目录,别脏了 storage/
setMediaBackend(t, f, "local")
// 借一个现成的地点
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(64)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src), "w": "1200", "h": "900"},
map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
ID int64 `json:"id"`
URL string `json:"url"`
Bytes int `json:"bytes"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "/media/") {
t.Fatalf("本地存储的地址应以 /media/ 开头,实际 %q", up.URL)
}
// 落盘 + 落库
full, err := f.srv.media.LocalPath(strings.TrimPrefix(up.URL, "/media/"))
if err != nil {
t.Fatalf("取路径失败: %v", err)
}
if got, err := os.ReadFile(full); err != nil || !bytes.Equal(got, raw) {
t.Fatalf("落盘的字节与上传的不一致: %v", err)
}
// /media 直出同样的字节
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", up.URL, nil).WithContext(f.ctx)
f.srv.serveMedia(rec, req)
if rec.Code != 200 {
t.Fatalf("取图返回 %d", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), raw) {
t.Fatal("取回的字节与上传的不一致")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Errorf("Content-Type 不对: %q", ct)
}
// 非 WebP 必须被挡住(前端转码失败时会发来 PNG)
rec = postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)},
map[string][]byte{"file": append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)})
if !strings.Contains(rec.Body.String(), "只接受 WebP") {
t.Errorf("非 WebP 没被拒绝: %s", rec.Body.String())
}
// 删除:同样走 multipart
rec = postMultipart(t, f, f.srv.adminPlaceImageDelete,
map[string]string{"id": fmt.Sprint(up.ID)}, nil)
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Fatalf("删除失败: %s", rec.Body.String())
}
if _, err := os.Stat(full); !os.IsNotExist(err) {
t.Errorf("文件没删掉: %v", err)
}
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE id=$1`, up.ID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("库里的记录没删掉")
}
}
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
// 并且图片挂在正确的 src_pk 上。
func TestPhotoImportWithImage(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
setMediaBackend(t, f, "local")
raw := webpBytes(48)
rec := postMultipart(t, f, f.srv.adminPhotosImport, map[string]string{
"n": "1", "coord": "wgs84", "keep_0": "on",
"lat_0": "30.123456", "lon_0": "120.123456",
"arrival_0": "2026-04-18T18:14", "name_0": "带图的导入点",
"country_0": "CN", "timezone_0": "Asia/Shanghai",
"image_w_0": "1200", "image_h_0": "900",
}, map[string][]byte{"image_0": raw})
if rec.Code != http.StatusSeeOther {
t.Fatalf("导入没返回重定向: %d %s", rec.Code, rec.Body.String())
}
loc, err := url.QueryUnescape(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(loc, "err=") {
t.Fatalf("导入报错: %s", loc)
}
if !strings.Contains(loc, "配了 1 张图") {
t.Errorf("结果消息里应提到配了图: %s", loc)
}
var src int
var w, h int
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, pi.width, pi.height FROM places p
JOIN place_images pi ON pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk
WHERE p.dataset_id=$1 AND p.name='带图的导入点'`, f.dsID).Scan(&src, &w, &h); err != nil {
t.Fatalf("配图没挂到地点上: %v", err)
}
if w != 1200 || h != 900 {
t.Errorf("宽高没记对: %dx%d", w, h)
}
// 字节确实落盘了:目录是 <数据集>/<上传月份>/,文件名是 <地点号>-<哈希>.webp
dir := filepath.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID), time.Now().Format("200601"))
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("月份目录不存在: %v", err)
}
if len(entries) != 1 {
t.Fatalf("期望落盘 1 个文件,实际 %d", len(entries))
}
if name := entries[0].Name(); !strings.HasPrefix(name, fmt.Sprint(src)+"-") ||
!strings.HasSuffix(name, ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", name)
}
}
// TestPlaceImageS3Backend 走一遍对象存储那条路:用 httptest 假装 S3 端点,
// 断言请求打到「路径式 + 桶名 + 对象名」上,并带上了 SigV4 的 Authorization。
// 真去校验签名的是 media 包里的官方向量测试,这里盯的是接线对不对。
func TestPlaceImageS3Backend(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
var gotPath, gotAuth, gotCT string
var gotBody []byte
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer fake.Close()
f.cfg.S3Endpoint = fake.URL
f.cfg.S3Region = "auto"
f.cfg.S3Bucket = "pics"
f.cfg.S3AccessKey = "AKIATEST"
f.cfg.S3SecretKey = "secret"
f.cfg.S3PublicBase = "https://img.example.com"
setMediaBackend(t, f, "s3")
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(32)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
URL string `json:"url"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("对象存储上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "https://img.example.com/") {
t.Errorf("对象存储的地址应当用 s3_public_base 拼,实际 %q", up.URL)
}
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
parts := strings.Split(strings.TrimPrefix(gotPath, "/pics/"), "/")
if len(parts) != 3 {
t.Fatalf("对象名层级不对(应为 数据集/月份/文件名): %q", gotPath)
}
if parts[0] != fmt.Sprint(f.dsID) {
t.Errorf("第一层应是数据集号,实际 %q", parts[0])
}
if len(parts[1]) != 6 || !strings.HasPrefix(parts[1], time.Now().Format("2006")) {
t.Errorf("第二层应是上传月份(YYYYMM),实际 %q", parts[1])
}
if !strings.HasPrefix(parts[2], fmt.Sprint(src)+"-") || !strings.HasSuffix(parts[2], ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", parts[2])
}
if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256 Credential=AKIATEST/") ||
!strings.Contains(gotAuth, "/auto/s3/aws4_request") {
t.Errorf("没带上 SigV4 签名: %q", gotAuth)
}
if gotCT != "image/webp" {
t.Errorf("Content-Type 不对: %q", gotCT)
}
if !bytes.Equal(gotBody, raw) {
t.Error("发出去的字节与上传的不一致")
}
// 存储被切到对象存储后,本地不应再落盘
entries, _ := os.ReadDir(path.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID)))
if len(entries) != 0 {
t.Errorf("切到对象存储后不该再写本地磁盘,却发现 %d 个条目", len(entries))
}
}
// TestVisitorPlacePageCarriesImageURLs 盯住「访客在详情页也能看到图片入口」。
//
// 画廊的地址以前只从 #imgs-grid 反推,而那个缩略图墙是站长专属的 DOM,访客页里
// 根本没有——于是 syncGallery() 数到 0 张,把服务端已经渲染好的「图片 N」按钮
// remove 掉(表现成刷新后按钮闪一下就没了)。站长自己打开一切正常,所以只有按访客
// 身份把页面渲出来才验得到:**访客页必须自带地址列表,不能依赖那一块 DOM**。
func TestVisitorPlacePageCarriesImageURLs(t *testing.T) {
f := newMovementFixture(t)
var placeID int64
var srcPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT id, src_pk FROM places
WHERE dataset_id=$1 AND lat IS NOT NULL ORDER BY id LIMIT 1`, f.dsID).Scan(&placeID, &srcPK); err != nil {
t.Fatalf("找不到地点: %v", err)
}
const key = "9/visitor-page-test.webp"
// 这里只验发下去的地址,不验能否取回字节,所以不用真的写文件。
// place_key 得照真实写入路径一并算好——读取是认坐标键的,不认 (dataset_id, src_pk)。
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO place_images
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
SELECT $1, $2, `+placeKeyExpr+`, 'local', $3, 1200, 900, 1024, 0
FROM places p WHERE p.dataset_id=$1 AND p.src_pk=$2
ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`,
f.dsID, srcPK, key); err != nil {
t.Fatalf("插入图片记录失败: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx,
`DELETE FROM place_images WHERE dataset_id=$1 AND object_key=$2`, f.dsID, key)
})
req := httptest.NewRequest("GET", "/places/x", nil)
req = req.WithContext(context.Background()) // 访客 = 上下文里没有登录态
req.SetPathValue("id", strconv.FormatInt(placeID, 10))
rec := httptest.NewRecorder()
f.srv.placePage(rec, req)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("访客访问详情页返回 %d:%s", rec.Code, firstLine(body))
}
if !strings.Contains(body, `id="imgs-open"`) {
t.Error("访客页面里没有「图片」按钮")
}
want := f.srv.media.PublicURL("local", key)
if !strings.Contains(body, want) {
t.Errorf("访客页面没有把图片地址发给浏览器(缺 %s)", want)
}
}