对访客公开的措辞按身份分支:访客不该看到「我的结论」

用户发现未登录也显示「推荐 & 踩雷(我的结论)」,访客没法解读「谁的我的」。
顺着把访客可见模板与 static/js 全过了一遍,同病共四处——都是**写模板时站在站长视角下笔、
而那一块又对访客公开**:

1. stats.html:标题 → {{if .IsAdmin}}我的结论{{else}}站长结论{{end}};
   副标题「只统计你自己…」→ 站长「你自己」/ 访客「站长」;两列「我推荐/我踩雷的地点」同理。
2. stats.html 访客块:「与我的结论互不影响:你标你的,访客投访客的」——对访客说不通,
   改成对两边都成立的「与上面的站长结论互不影响:各存各的表,谁也不会覆盖谁」。
3. home.html:「我的标签」→「足迹标签」(同级卡片全是中性的:足迹地图/城市足迹/天气足迹…)。
4. report.html:「这一年里,你第一次踏足的这些城市」→ 去掉人称(年报是站长的回顾)。

已经写对的照它学:place.html 的投票区早就是 {{if .IsAdmin}} 分支(站长「我的结论」、
访客「大家怎么说」),rond-map.js 的气泡同理;「含家/工作」chip 与一串「只有你自己能看到」
本来就在站长分支里。

门禁 TestVisitorPagesUseThirdPersonCopy:以**访客 ctx(无 user)**渲染 /、/stats、/report,
逐个禁用词断言,同时断言站长那份**仍旧说「我的」**(别把站长自己的视角也改掉);
前置需打开 marks_public(访客才看得到那一块),用完还原。

⚠️ 变异验证时踩了老坑:直接 `go test -run …` 因缺 ROND_TEST_CONF/ROND_SRC 会**静默 skip 并报 ok**
(0.17s 就 ok,一眼假绿)。改用 ./scripts/verify.sh 后变异被正确抓住
(visitor_copy_test.go:85 报「我的结论」),还原后转绿。

全量验收 PASS=339 FAIL=0 SKIP=0。
This commit is contained in:
tamakyi
2026-10-01 02:15:49 +08:00
parent ff46a9cbb8
commit 5c7cf1a0e1
7 changed files with 174 additions and 10 deletions
+1 -1
View File
@@ -113,7 +113,7 @@
{{if and (.Settings.Has "tags") .Tags}}
<div class="card">
<h2>我的标签</h2>
<h2>足迹标签</h2>
<div class="card-sub">记录时打上的自定义标签</div>
<div class="chips">
{{range .Tags}}
+1 -1
View File
@@ -31,7 +31,7 @@
{{if .Cities}}
<div class="card" style="margin-bottom:16px">
<h2>🏅 首次到达的城市</h2>
<div class="card-sub">这一年里,你第一次踏足的这些城市(按到达日期排序)</div>
<div class="card-sub">这一年里第一次踏足的这些城市(按到达日期排序)</div>
<div class="chips">
{{range .Cities}}
<span class="chip on" title="{{.Province}} · 首次到达 {{.First}}">
+8 -6
View File
@@ -21,18 +21,20 @@
</div>
{{if or .IsAdmin .Settings.MarksPublic}}
{{/* 第一块:站长自己的结论。只有站长能标,所以这里只说「我的」。 */}}
{{/* 第一块:站长的结论。
措辞必须**按身份分支**:访客看到「我的结论」只会一头雾水(谁的「我」),
地点页的投票区早就是这么分的(站长看「我的结论」、访客看「大家怎么说」),这里跟上。 */}}
<div class="card" style="margin-bottom:16px">
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
<h2 style="margin:0">推荐 &amp; 踩雷(我的结论)</h2>
<h2 style="margin:0">推荐 &amp; 踩雷({{if .IsAdmin}}我的结论{{else}}站长结论{{end}})</h2>
<span class="muted" style="font-size:12.5px">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
</div>
<div class="card-sub">
只统计<strong>你自己</strong>在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
只统计<strong>{{if .IsAdmin}}你自己{{else}}站长{{end}}</strong>在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
</div>
<div class="grid g2">
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 我推荐的地点</div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 {{if .IsAdmin}}我{{else}}站长{{end}}推荐的地点</div>
{{if .Recommended}}
<div class="chips">
{{range .Recommended}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
@@ -40,7 +42,7 @@
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 我踩雷的地点</div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 {{if .IsAdmin}}我{{else}}站长{{end}}踩雷的地点</div>
{{if .Avoided}}
<div class="chips">
{{range .Avoided}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
@@ -57,7 +59,7 @@
<span class="muted" style="font-size:12.5px">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
</div>
<div class="card-sub">
访客在地点页点的 👍/👎,一人一票、可改可撤。与「我的结论」互不影响:你标你的,访客投访客的。
访客在地点页点的 👍/👎,一人一票、可改可撤。与上面的站长结论<strong>互不影响</strong>:各存各的表,谁也不会覆盖谁。
</div>
<div class="grid g2">
<div>
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------- 措辞要分清「站长视角」与「访客视角」 ----------
//
// 用户实测反馈:未登录时统计页也显示「推荐 & 踩雷(我的结论)」,访客会困惑「谁的我的」。
// 这类毛病都出在同一处:**写模板时站在站长视角下笔**(我/你自己),而那一块又对访客公开。
// 地点页的投票区早就是按身份分支的(站长看「我的结论」、访客看「大家怎么说」),
// 这里把它做成回归用例:以**访客身份**取回页面,逐个钉死那些只有站长读得懂的说法。
// renderAsVisitor 以访客身份取回页面(ctx 里没有登录态)。
func renderAsVisitor(t *testing.T, f *movementFixture, h http.HandlerFunc, path string) string {
t.Helper()
rec := httptest.NewRecorder()
// 访客就是「没有 user 的 ctx」:IsAdmin() 看的是 p.User 是否为 nil
req := httptest.NewRequest("GET", path, nil).WithContext(context.Background())
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("访客取 %s 状态码 %d:%s", path, rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
t.Fatalf("访客取 %s 没渲染到底:…%s", path, body[len(body)-300:])
}
return body
}
// renderAsAdmin 以站长身份取回页面。
func renderAsAdmin(t *testing.T, f *movementFixture, h http.HandlerFunc, path string) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", path, nil).WithContext(f.ctx)
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("站长取 %s 状态码 %d:%s", path, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// forceMarksPublic 把「推荐 / 踩雷对访客公开」打开——访客版页面里那一块才会渲染出来。
// 用例结束后还原设置(站点设置是全局一行,别把后面的用例带偏)。
func forceMarksPublic(t *testing.T, f *movementFixture) {
t.Helper()
var old sql.NullString
if err := f.pg.QueryRowContext(f.ctx, `SELECT value::text FROM settings WHERE key='site'`).Scan(&old); err != nil && err != sql.ErrNoRows {
t.Fatalf("读站点设置失败: %v", err)
}
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO settings (key, value)
VALUES ('site', '{"marks_public":true}'::jsonb)
ON CONFLICT (key) DO UPDATE SET value = jsonb_set(settings.value, '{marks_public}', 'true'::jsonb, true)`); err != nil {
t.Fatalf("打开「对访客公开」失败: %v", err)
}
t.Cleanup(func() {
if old.Valid {
f.pg.ExecContext(f.ctx, `UPDATE settings SET value=$1::jsonb WHERE key='site'`, old.String)
return
}
f.pg.ExecContext(f.ctx, `DELETE FROM settings WHERE key='site'`)
})
}
// visitorCopyForbidden 是**只有站长读得懂**的说法:访客看到「我 / 你自己」会一头雾水。
var visitorCopyForbidden = []string{"我的结论", "你自己", "我推荐的地点", "我踩雷的地点", "你标你的", "我的标签"}
func TestVisitorPagesUseThirdPersonCopy(t *testing.T) {
f := newMovementFixture(t)
forceMarksPublic(t, f)
// ① 统计页:站长仍旧说「我的」,访客一律第三人称
adminStats := renderAsAdmin(t, f, f.srv.statsPage, "/stats")
if !strings.Contains(adminStats, "我的结论") {
t.Error("站长自己看时应当说「我的结论」——按身份分支,不该把站长那份也改成第三人称")
}
visitorStats := renderAsVisitor(t, f, f.srv.statsPage, "/stats")
for _, bad := range visitorCopyForbidden {
if strings.Contains(visitorStats, bad) {
t.Errorf("访客版统计页出现了只有站长读得懂的说法:%q", bad)
}
}
if !strings.Contains(visitorStats, "站长结论") {
t.Error("访客版统计页应当用「站长结论」这类第三人称说法")
}
// 访客那一块自己得说清楚「谁投的」
if !strings.Contains(visitorStats, "访客点赞") {
t.Error("访客版统计页应当有独立的「访客点赞」一块")
}
// ② 首页:标签卡原先写「我的标签」,访客看不出是谁的
visitorHome := renderAsVisitor(t, f, f.srv.home, "/")
for _, bad := range visitorCopyForbidden {
if strings.Contains(visitorHome, bad) {
t.Errorf("访客版首页出现了只有站长读得懂的说法:%q", bad)
}
}
// ③ 年报:那是站长的年度回顾,不该对访客说「你这一年…」
visitorReport := renderAsVisitor(t, f, f.srv.reportPage, "/report")
if strings.Contains(visitorReport, "你第一次踏足") {
t.Error("访客版年报里把站长的一年说成了「你」的")
}
}