Files
rond-with-you/internal/web/place_image_test.go
T
tamakyi 96e76318ab 修复访客在地点详情页看不到「图片」按钮
全屏画廊的地址列表原先只从 #imgs-grid 反推,而那个缩略图墙整块包在
{{if .IsAdmin}} 里——访客页根本没有这个元素,于是 syncGallery() 数到 0 张,
把服务端已经渲染好的「图片 N」按钮 remove 掉(表现成刷新后按钮闪一下就没了)。
站长自己打开一切正常,所以只有按访客身份渲染才看得到。

首版 b327bc1 就有,不是新引入的。

- 新增 PlaceData.ImageURLs:随页面把地址列表发给浏览器,访客页自带数据源。
- cellURLs() 的判据改成「缩略图墙在不在」,而不是「墙里有几张」:
  站长把图删光时墙还在、只是空了,按张数回退会把删掉的图又数回来。
- 新增回归 TestVisitorPlacePageCarriesImageURLs:按访客身份渲染详情页,
  要求页面里有「图片」按钮、且把图片地址发给了浏览器(去掉数据源即失败)。

实测(390 访客,/places/2254 三张图):按钮回归、点开 1/3 → 2/3 → 回绕 3/3 →
关闭正常,第 2 张 1200×900 实际加载;320/390/430 横向溢出均为 0。
2026-09-18 19:42:52 +08:00

354 lines
12 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 ORDER BY id LIMIT 1`, f.dsID).Scan(&placeID, &srcPK); err != nil {
t.Fatalf("找不到地点: %v", err)
}
const key = "9/visitor-page-test.webp"
// 这里只验发下去的地址,不验能否取回字节,所以不用真的写文件
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO place_images
(dataset_id, src_pk, storage, object_key, width, height, bytes, position)
VALUES ($1,$2,'local',$3,1200,900,1024,0)
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)
}
}