From b939d26ff0043123846a63c030a67409a7890f4d Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 22 Oct 2026 17:49:25 +0800 Subject: [PATCH] fix templates v2 --- .workbuddy/memory/2026-10-22.md | 38 ++++++ conf/app.sample.ini | 18 ++- internal/conf/announcement.go | 158 ++++++++++++++++++++----- internal/conf/announcement_test.go | 144 +++++++++++++++++++--- internal/db/db.go | 3 + internal/template/template.go | 5 +- route/question/admin_backquery_test.go | 56 +++++++++ route/question/question.go | 8 +- templates/announcement_render_test.go | 62 ++++++++++ templates/base/header.html | 2 +- templates/home.html | 2 +- 11 files changed, 436 insertions(+), 60 deletions(-) create mode 100644 route/question/admin_backquery_test.go create mode 100644 templates/announcement_render_test.go diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index 64e8273..788d0d5 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -245,3 +245,41 @@ - 文案定为「回答并进入下一条未读」(en: Answer & open next unread / ja: 回答して次の未読を開く)。 实测宽度:该按钮 202px;661px 视口(桌面态最窄)行宽 526 ≥ 90+202+132+20=444,仍单行; 640px 断点两侧切换干净,无需再调。 + +## 近期新增功能审阅(10-18~10-22) + +- 编译(s3 overlay 桩)+ conf/template/theme/route/security/templates 全测试通过,无 P0。 +- 🔴 **P1 实测复现**:`NextQuery` 不含 `from=admin`,而 `adminBackQuery` 首行要求它 + → 「回答并下一条未读」/「下一条未读」POST 后筛选上下文全丢,「返回」掉回箱主页。 + 实测:`POST /answer?sort=new&status=unread` → `from="" backQuery=""`; + 带 `?from=admin` 才返回 `sort=..`。修法:`adminBackQuery` 里 `backQuery.Set("from","admin")`, + 并把 `PublishAnswer` 普通分支(question.go:279)也改用 `questionPath`。 +- P2:公告单语言(四语站点);裸时间按 time.Local(容器 UTC)易偏 8h; + NextUnread 无 `(user_id,is_read,created_at)` 复合索引且忽略后台筛选;公告出现在 pixel 画板页 + (xsmall 容器 vs 全宽画布);new-sakura 磨砂顶栏 backdrop-filter 低端机掉帧风险; + `conf/app.sample.ini:296` 注释行尾残留 `,shiroko`。 +- 蜜罐仅做代码审阅(用户要求不进用户说明):无正常用户误封路径,前提是 `trust_proxy` 已开。 +- 交付:`outputs/2026-10-新增功能审阅报告.md`、`outputs/2026-10-功能说明.md`(面向一般用户)。 + +## 修 P1:后台上下文丢失(已落地) + +- `adminBackQuery` 改为 `url.Values{"from": {"admin"}}` 起手:返回串同时用于模板 action + 与 `questionPath`,POST 回来才能重新认出来源(Flamego `ctx.Query` 只读 URL query)。 +- `PublishAnswer` 末尾裸重定向 → `questionPath`,让「回答」这条也保留筛选。 +- 新增 `route/question/admin_backquery_test.go` 两条用例;build + route/templates/conf/template 测试全绿。 +- 副作用(可接受):`BackTo` 拼成 `/user/questions?from=admin&sort=..`,列表页不读 from。 +- `question.go` 是 CRLF:改完跑 `python dev/gofmt_keep_crlf.py`,diff 只有 6 增 2 删。 + +## 修 P2:公告分语言 / 东八区 / 未读索引(已落地) + +- **公告分语言**:新增 `content_zh_cn/_zh_tw/_en/_ja`,`content` 降级为默认回退; + `hasContent()` 改成「任一语言非空即开启」(只看 content 会把「只写英文」误判成未配置)。 + 模板函数签名变为 `announcement .LangCode`(header.html 同步),判定仍在服务端。 +- **时区**:新增 `[announcement] timezone`,默认 `Asia/Shanghai`;`import _ "time/tzdata"` + 保证容器无 zoneinfo 也能 LoadLocation。RFC3339 仍以自带偏移为准。 + 启动日志扩成 `zone/languages/start/end/active_now`,解析结果直接带 `+08:00`。 +- **索引**:`idx_questions_user_unread_created (user_id, is_read, created_at)` 加进 + `ensureFrequentlyUsedIndexes`;本机测试库 psql 实跑两次验证语法 + 幂等。 +- 测试:conf 侧 7 组(语言回退/归一化/东八区/时区覆盖/无效时区)、 + templates 侧 2 条渲染断言(zh-CN 与 en 只出现各自文案、窗口外不渲染 `tb-announce`)。 +- 未改:`NextUnread` 仍不看后台筛选(产品取舍);画板页公告问题按用户确认忽略(画板已下线)。 diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 33df01a..255155a 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -264,21 +264,31 @@ daily_message_limit_max = 999 [announcement] ; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ────────────────────── -; content :公告正文,留空 = 关闭公告。用 \n 表示换行;http(s) 地址会自动变成链接; +; content :默认文案,留空 = 关闭公告(任一语言文案非空也视为开启)。 +; 用 \n 表示换行;http(s) 地址会自动变成链接; ; 内容按纯文本渲染,不支持 HTML 标签(写 会原样显示)。 +; content_zh_cn / content_zh_tw / content_en / content_ja: +; 分语言文案,留空则回退到 content;content 也留空时该语言不显示公告。 +; timezone:start / end 里不带偏移的「裸时间」按哪个时区解释,默认 Asia/Shanghai。 +; 可填任意 IANA 时区名(UTC / America/New_York …);二进制已内嵌 tzdata, +; 容器不需要另装时区包。带偏移的 RFC3339 以自带偏移为准。 ; start / end:展示时间窗,左闭右开 —— 到 end 那一刻下线。留空表示该侧不限: ; 只写 start = 定时上线;只写 end = 现在起展示到某时刻;都留空 = 一直展示。 ; 支持 2026-10-25 02:00 / 2026-10-25 02:00:59 / 2026-10-25 / RFC3339 ; (2026-10-25T02:00:00+08:00)。 -; ⚠️ 不带时区偏移的时间按【服务器本地时区】解释(容器默认 UTC), -; 要精确到分钟请写 RFC3339;启动日志会打出解析后的时间与时区,可据此核对。 -; 时间格式写错不会导致启动失败:日志 WARN 后关闭公告。 +; 时间或时区写错不会导致启动失败:日志 WARN 后关闭公告。 ; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one" +; content_en = "Maintenance 02:00-04:00 on Oct 25 (UTC+8). Details: https://status.shiroko.one" ; start = "2026-10-24 00:00" ; end = "2026-10-25 04:00" content = "" +content_zh_cn = "" +content_zh_tw = "" +content_en = "" +content_ja = "" start = "" end = "" +timezone = Asia/Shanghai [theme] ; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。 diff --git a/internal/conf/announcement.go b/internal/conf/announcement.go index 4ff0ded..f4c4e20 100644 --- a/internal/conf/announcement.go +++ b/internal/conf/announcement.go @@ -7,27 +7,117 @@ package conf import ( "strings" "time" + // 容器镜像通常不带 tzdata,import 这个包会把一份时区库编进二进制, + // LoadLocation("Asia/Shanghai") 才不会因为找不到 zoneinfo 而失败。 + _ "time/tzdata" "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/i18n" ) +// defaultAnnouncementZone 裸时间(不带偏移)的默认解释时区。 +// 站点面向中文用户,运维写「10-25 02:00」时心里想的是北京时间, +// 而容器里的 time.Local 是 UTC —— 默认按东八区解释可以避免整体偏移 8 小时。 +const defaultAnnouncementZone = "Asia/Shanghai" + // announcementConfig 站内公告([announcement] 段)。 // -// Content 留空即关闭公告;Start / End 留空表示该侧不限。 +// 文案留空即关闭公告;Start / End 留空表示该侧不限。 // 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失, // 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。 type announcementConfig struct { - Content string `ini:"content"` - Start string `ini:"start"` - End string `ini:"end"` + // Content 是默认文案,也是分语言文案缺失时的回退。 + Content string `ini:"content"` + ContentZhCN string `ini:"content_zh_cn"` + ContentZhTW string `ini:"content_zh_tw"` + ContentEn string `ini:"content_en"` + ContentJa string `ini:"content_ja"` + + Start string `ini:"start"` + End string `ini:"end"` + // Timezone 解释裸时间用的时区名,留空取 defaultAnnouncementZone。 + Timezone string `ini:"timezone"` // 解析后的时间点,零值表示该侧不限。 // ini tag 为 "-":既不参与 ini 解析,也不会被未知键检测登记为合法键名。 - StartAt time.Time `ini:"-"` - EndAt time.Time `ini:"-"` + StartAt time.Time `ini:"-"` + EndAt time.Time `ini:"-"` + loc *time.Location `ini:"-"` } -// 支持的时间格式。裸时间(不带时区偏移)按服务器本地时区解释。 +// Active 判断给定时刻是否处于公告展示窗口,并返回该语言的正文。 +// 窗口为左闭右开 [Start, End),两侧留空表示不限。 +func (a announcementConfig) Active(now time.Time, lang string) (string, bool) { + content := a.contentFor(lang) + if content == "" || !a.inWindow(now) { + return "", false + } + return content, true +} + +// contentFor 按语言取文案;该语言没配就回退到 Content。 +// Content 也没配则返回空 —— 即「没翻译的语言不显示」,而不是硬塞一门语言。 +func (a announcementConfig) contentFor(lang string) string { + switch i18n.Resolve(lang) { + case i18n.LangZhCN: + if a.ContentZhCN != "" { + return a.ContentZhCN + } + case i18n.LangZhTW: + if a.ContentZhTW != "" { + return a.ContentZhTW + } + case i18n.LangEn: + if a.ContentEn != "" { + return a.ContentEn + } + case i18n.LangJa: + if a.ContentJa != "" { + return a.ContentJa + } + } + return a.Content +} + +// inWindow 只判断时间窗,不看语言 —— 启动日志要知道「现在到点了吗」。 +func (a announcementConfig) inWindow(now time.Time) bool { + if !a.StartAt.IsZero() && now.Before(a.StartAt) { + return false + } + if !a.EndAt.IsZero() && !now.Before(a.EndAt) { + return false + } + return true +} + +// hasContent 报告是否配了任何语言的文案。 +// 只看 Content 会误判「只写了英文文案」为未配置,进而把整条公告关掉。 +func (a announcementConfig) hasContent() bool { + return a.Content != "" || a.ContentZhCN != "" || a.ContentZhTW != "" || + a.ContentEn != "" || a.ContentJa != "" +} + +// configuredLanguages 列出真正配了文案的语言,供启动日志核对。 +func (a announcementConfig) configuredLanguages() []string { + var langs []string + if a.Content != "" { + langs = append(langs, "fallback") + } + for _, l := range []struct{ code, content string }{ + {i18n.LangZhCN, a.ContentZhCN}, + {i18n.LangZhTW, a.ContentZhTW}, + {i18n.LangEn, a.ContentEn}, + {i18n.LangJa, a.ContentJa}, + } { + if l.content != "" { + langs = append(langs, l.code) + } + } + return langs +} + +// 支持的时间格式。裸时间(不带时区偏移)按 [announcement] timezone 解释。 var announcementTimeLayouts = []string{ "2006-01-02 15:04", "2006-01-02 15:04:05", @@ -35,21 +125,6 @@ var announcementTimeLayouts = []string{ time.RFC3339, } -// Active 判断给定时刻是否处于公告展示窗口,并返回公告正文。 -// 窗口为左闭右开 [Start, End),两侧留空表示不限。 -func (a announcementConfig) Active(now time.Time) (string, bool) { - if a.Content == "" { - return "", false - } - if !a.StartAt.IsZero() && now.Before(a.StartAt) { - return "", false - } - if !a.EndAt.IsZero() && !now.Before(a.EndAt) { - return "", false - } - return a.Content, true -} - // sanitizeAnnouncement 解析公告时间窗。 // // 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站 @@ -57,7 +132,11 @@ func (a announcementConfig) Active(now time.Time) (string, bool) { // 忽略属于同一类坑(见 unknown.go)。 func sanitizeAnnouncement() { Announcement.Content = strings.TrimSpace(Announcement.Content) - if Announcement.Content == "" { + Announcement.ContentZhCN = strings.TrimSpace(Announcement.ContentZhCN) + Announcement.ContentZhTW = strings.TrimSpace(Announcement.ContentZhTW) + Announcement.ContentEn = strings.TrimSpace(Announcement.ContentEn) + Announcement.ContentJa = strings.TrimSpace(Announcement.ContentJa) + if !Announcement.hasContent() { return } @@ -71,12 +150,19 @@ func sanitizeAnnouncement() { Announcement = announcementConfig{} } + loc, err := loadAnnouncementLocation(Announcement.Timezone) + if err != nil { + disable("timezone", Announcement.Timezone, "不是合法的 IANA 时区名(如 Asia/Shanghai / UTC)") + return + } + Announcement.loc = loc + var ok bool - if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start); !ok { + if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start, loc); !ok { disable("start", Announcement.Start, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339") return } - if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End); !ok { + if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End, loc); !ok { disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339") return } @@ -85,23 +171,33 @@ func sanitizeAnnouncement() { return } - _, activeNow := Announcement.Active(time.Now()) logrus.WithFields(logrus.Fields{ + "zone": loc.String(), + "languages": strings.Join(Announcement.configuredLanguages(), ","), "start": announcementBoundLabel(Announcement.StartAt), "end": announcementBoundLabel(Announcement.EndAt), - "zone": time.Local.String(), - "active_now": activeNow, + "active_now": Announcement.inWindow(time.Now()), }).Info("站内公告已配置") } +// loadAnnouncementLocation 解析时区名;空值取默认时区(东八区)。 +func loadAnnouncementLocation(name string) (*time.Location, error) { + name = strings.TrimSpace(name) + if name == "" { + name = defaultAnnouncementZone + } + return time.LoadLocation(name) +} + // parseAnnouncementTime 解析单个时间点;空串返回零值(表示不限)且视为合法。 -func parseAnnouncementTime(raw string) (time.Time, bool) { +// 带偏移的 RFC3339 以自带偏移为准,loc 只用于裸时间。 +func parseAnnouncementTime(raw string, loc *time.Location) (time.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, true } for _, layout := range announcementTimeLayouts { - if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + if t, err := time.ParseInLocation(layout, raw, loc); err == nil { return t, true } } @@ -109,7 +205,7 @@ func parseAnnouncementTime(raw string) (time.Time, bool) { } // announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对 -// 「我写的 10:00 到底被当成了几点」——裸时间按服务器本地时区解释,容器多为 UTC。 +// 「我写的 10:00 到底被当成了几点」—— 裸时间按 [announcement] timezone 解释。 func announcementBoundLabel(t time.Time) string { if t.IsZero() { return "不限" diff --git a/internal/conf/announcement_test.go b/internal/conf/announcement_test.go index 94d1efa..a285132 100644 --- a/internal/conf/announcement_test.go +++ b/internal/conf/announcement_test.go @@ -7,9 +7,12 @@ package conf import ( "testing" "time" + + "github.com/tamakyi/TamaBox/internal/i18n" ) // setAnnouncement 在测试期间覆盖全局公告配置,结束后恢复。 +// 不设 Timezone,即走默认的东八区。 func setAnnouncement(t *testing.T, content, start, end string) { t.Helper() old := Announcement @@ -18,9 +21,22 @@ func setAnnouncement(t *testing.T, content, start, end string) { sanitizeAnnouncement() } -func mustParse(t *testing.T, layout, value string) time.Time { +// setAnnouncementConfig 用完整配置覆盖,供分语言 / 时区用例使用。 +func setAnnouncementConfig(t *testing.T, cfg announcementConfig) { t.Helper() - parsed, err := time.ParseInLocation(layout, value, time.Local) + old := Announcement + t.Cleanup(func() { Announcement = old }) + Announcement = cfg + sanitizeAnnouncement() +} + +func mustParseIn(t *testing.T, layout, value string) time.Time { + t.Helper() + loc, err := time.LoadLocation(defaultAnnouncementZone) + if err != nil { + t.Fatalf("load zone %s: %v", defaultAnnouncementZone, err) + } + parsed, err := time.ParseInLocation(layout, value, loc) if err != nil { t.Fatalf("parse %q: %v", value, err) } @@ -30,16 +46,16 @@ func mustParse(t *testing.T, layout, value string) time.Time { func TestAnnouncementDisabledWhenContentEmpty(t *testing.T) { setAnnouncement(t, " ", "2026-10-24 00:00", "2026-10-26 00:00") - if _, ok := Announcement.Active(time.Now()); ok { - t.Fatal("content 为空时公告应关闭") + if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok { + t.Fatal("所有语言文案都为空时公告应关闭") } } func TestAnnouncementWindowIsHalfOpen(t *testing.T) { setAnnouncement(t, "维护通知", "2026-10-24 00:00", "2026-10-26 00:00") - start := mustParse(t, "2006-01-02 15:04", "2026-10-24 00:00") - end := mustParse(t, "2006-01-02 15:04", "2026-10-26 00:00") + start := mustParseIn(t, "2006-01-02 15:04", "2026-10-24 00:00") + end := mustParseIn(t, "2006-01-02 15:04", "2026-10-26 00:00") cases := []struct { name string @@ -54,7 +70,7 @@ func TestAnnouncementWindowIsHalfOpen(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - content, ok := Announcement.Active(c.now) + content, ok := Announcement.Active(c.now, i18n.LangZhCN) if ok != c.want { t.Fatalf("Active(%s) = %v, want %v", c.now, ok, c.want) } @@ -67,33 +83,71 @@ func TestAnnouncementWindowIsHalfOpen(t *testing.T) { // 只写一侧的边界必须保持「该侧不限」,不能因为另一侧为空就整体失效。 func TestAnnouncementOneSidedWindow(t *testing.T) { - now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.Local) + now := mustParseIn(t, "2006-01-02 15:04", "2026-10-22 12:00") setAnnouncement(t, "只有开始", "2026-10-23 00:00", "") - if _, ok := Announcement.Active(now); ok { + if _, ok := Announcement.Active(now, i18n.LangZhCN); ok { t.Fatal("只写 start 且尚未到点时应不展示") } - if _, ok := Announcement.Active(now.Add(48 * time.Hour)); !ok { + if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); !ok { t.Fatal("只写 start 时到点后应一直展示") } setAnnouncement(t, "只有结束", "", "2026-10-23 00:00") - if _, ok := Announcement.Active(now); !ok { + if _, ok := Announcement.Active(now, i18n.LangZhCN); !ok { t.Fatal("只写 end 且未到点时应立即展示") } - if _, ok := Announcement.Active(now.Add(48 * time.Hour)); ok { + if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); ok { t.Fatal("只写 end 时过点后应下线") } setAnnouncement(t, "一直展示", "", "") - if _, ok := Announcement.Active(now.AddDate(5, 0, 0)); !ok { + if _, ok := Announcement.Active(now.AddDate(5, 0, 0), i18n.LangZhCN); !ok { t.Fatal("起止都留空时应一直展示") } } +// 分语言文案:配了就用自己的,没配回退 content;content 也没配则该语言不显示。 +func TestAnnouncementLanguageSelection(t *testing.T) { + setAnnouncementConfig(t, announcementConfig{ + Content: "fallback", + ContentZhCN: "简中", + ContentEn: "english", + ContentJa: "日本語", + }) + + cases := []struct{ lang, want string }{ + {i18n.LangZhCN, "简中"}, + {i18n.LangEn, "english"}, + {i18n.LangJa, "日本語"}, + {i18n.LangZhTW, "fallback"}, // 没配繁中 → 回退默认文案 + {"en-US", "english"}, // Resolve 会归一化 + } + for _, c := range cases { + got, ok := Announcement.Active(time.Now(), c.lang) + if !ok || got != c.want { + t.Fatalf("Active(lang=%s) = %q ok=%v, want %q", c.lang, got, ok, c.want) + } + } + + // 只配英文、content 留空:简中访客不应看到英文公告。 + setAnnouncementConfig(t, announcementConfig{ContentEn: "english only"}) + if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok { + t.Fatal("该语言未配文案且无默认文案时不应展示") + } + if got, ok := Announcement.Active(time.Now(), i18n.LangEn); !ok || got != "english only" { + t.Fatalf("英文应正常展示,got %q ok=%v", got, ok) + } +} + func TestAnnouncementTimeFormats(t *testing.T) { - // 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层本地时区。 - at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00") + loc, err := time.LoadLocation(defaultAnnouncementZone) + if err != nil { + t.Fatalf("load zone: %v", err) + } + + // 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层默认时区。 + at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00", loc) if !ok { t.Fatal("RFC3339 应可解析") } @@ -102,20 +156,72 @@ func TestAnnouncementTimeFormats(t *testing.T) { t.Fatalf("解析结果 = %s, want %s", at.UTC(), want) } - if at, ok := parseAnnouncementTime("2026-10-25"); !ok || at.Hour() != 0 { + if at, ok := parseAnnouncementTime("2026-10-25", loc); !ok || at.Hour() != 0 { t.Fatalf("纯日期应解析为当天 00:00,got %s ok=%v", at, ok) } - if at, ok := parseAnnouncementTime("2026-10-25 02:00:59"); !ok || at.Second() != 59 { + if at, ok := parseAnnouncementTime("2026-10-25 02:00:59", loc); !ok || at.Second() != 59 { t.Fatalf("秒级格式应可解析,got %s ok=%v", at, ok) } - if _, ok := parseAnnouncementTime("2026/10/25 02:00"); ok { + if _, ok := parseAnnouncementTime("2026/10/25 02:00", loc); ok { t.Fatal("错误格式应解析失败") } - if _, ok := parseAnnouncementTime("2026-13-01"); ok { + if _, ok := parseAnnouncementTime("2026-13-01", loc); ok { t.Fatal("非法月份应解析失败") } } +// 裸时间必须按东八区解释:运维写「10-25 02:00」指北京时间, +// 而容器里的 time.Local 是 UTC(旧实现整体偏 8 小时)。 +func TestAnnouncementBareTimeUsesBeijing(t *testing.T) { + setAnnouncement(t, "维护通知", "2026-10-25 02:00", "") + + want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC) // 北京 10-25 02:00 + if !Announcement.StartAt.Equal(want) { + t.Fatalf("裸时间应按 +08:00 解释,got %s, want %s", Announcement.StartAt.UTC(), want) + } + if _, offset := Announcement.StartAt.Zone(); offset != 8*60*60 { + t.Fatalf("解析后的偏移 = %d 秒, want 28800", offset) + } +} + +// timezone 显式指定时以它为准,不能继续套东八区。 +func TestAnnouncementTimezoneOverride(t *testing.T) { + setAnnouncementConfig(t, announcementConfig{ + Content: "维护通知", + Start: "2026-10-25 00:00", + Timezone: "UTC", + }) + + want := time.Date(2026, 10, 25, 0, 0, 0, 0, time.UTC) + if !Announcement.StartAt.Equal(want) { + t.Fatalf("timezone=UTC 时应按 UTC 解析,got %s", Announcement.StartAt.UTC()) + } + + setAnnouncementConfig(t, announcementConfig{ + Content: "维护通知", + Start: "2026-10-25 00:00", + Timezone: "America/New_York", + }) + // 纽约 10-25 00:00 = UTC 10-25 04:00(EDT,UTC-4) + wantNY := time.Date(2026, 10, 25, 4, 0, 0, 0, time.UTC) + if !Announcement.StartAt.Equal(wantNY) { + t.Fatalf("timezone=America/New_York 解析错误,got %s", Announcement.StartAt.UTC()) + } +} + +// 时区名写错和日期写错一样:只关公告,不阻断启动。 +func TestAnnouncementInvalidTimezoneDisables(t *testing.T) { + setAnnouncementConfig(t, announcementConfig{ + Content: "维护通知", + Start: "2026-10-25 00:00", + Timezone: "Mars/Olympus", + }) + + if Announcement.Content != "" { + t.Fatal("时区无效时公告应被关闭") + } +} + // 时间写错只关公告,不能让站点起不来;同时必须清空解析结果,避免残留时间窗。 func TestAnnouncementInvalidTimeDisables(t *testing.T) { for _, c := range []struct{ name, start, end string }{ diff --git a/internal/db/db.go b/internal/db/db.go index 5de835e..b42297e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -218,6 +218,9 @@ func ensureFrequentlyUsedIndexes(db *gorm.DB) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`, `CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`, + // 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。 + // 上面那条中间隔着 is_private,帮不上这个排序。 + `CREATE INDEX IF NOT EXISTS idx_questions_user_unread_created ON questions(user_id, is_read, created_at)`, `CREATE INDEX IF NOT EXISTS idx_users_show_in_home_created ON users(show_in_home, created_at)`, `CREATE INDEX IF NOT EXISTS idx_votes_status_visibility_created ON votes(status, visibility, created_at)`, `CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`, diff --git a/internal/template/template.go b/internal/template/template.go index e0e32b0..ec55fb3 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -67,8 +67,9 @@ func FuncMap() []template.FuncMap { }, // announcement 返回当前应展示的公告(已转义的安全 HTML),窗口外返回空串。 // 时间窗判定放在服务端:不依赖访客时钟,也不会先渲染出来再被脚本抹掉。 - "announcement": func() template.HTML { - content, ok := conf.Announcement.Active(time.Now()) + // 按访客语言取文案,未翻译的语言回退到 content。 + "announcement": func(lang string) template.HTML { + content, ok := conf.Announcement.Active(time.Now(), lang) if !ok { return "" } diff --git a/route/question/admin_backquery_test.go b/route/question/admin_backquery_test.go new file mode 100644 index 0000000..efce53d --- /dev/null +++ b/route/question/admin_backquery_test.go @@ -0,0 +1,56 @@ +package question + +import ( + "net/http/httptest" + "testing" + + "github.com/flamego/flamego" + "github.com/flamego/template" + + "github.com/tamakyi/TamaBox/internal/context" +) + +// 从后台进入单题页时的筛选参数会写进表单 action 的 query,POST 回来要靠 +// from=admin 重新认出来源。丢了它,「回答并下一条」/「下一条未读」跳转后 +// 页脚「返回」会掉回箱主页(实测过)。 +func TestAdminBackQueryKeepsFrom(t *testing.T) { + cases := []struct { + name string + url string + want string + }{ + {"非后台来源", "/answer?sort=new", ""}, + {"后台来源无筛选", "/answer?from=admin", "from=admin"}, + {"后台来源带筛选", "/answer?from=admin&sort=new&read=2", "from=admin&read=2&sort=new"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got string + f := flamego.New() + f.Post("/answer", func(fc flamego.Context) { + ctx := context.Context{Context: fc, Data: template.Data{}} + got = adminBackQuery(ctx) + }) + f.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", c.url, nil)) + if got != c.want { + t.Fatalf("adminBackQuery(%q) = %q, want %q", c.url, got, c.want) + } + }) + } +} + +// 跳转地址必须带回完整的后台上下文,否则下一条页面认不出来源。 +func TestQuestionPathKeepsAdminContext(t *testing.T) { + var got string + f := flamego.New() + f.Post("/next-unread", func(fc flamego.Context) { + ctx := context.Context{Context: fc, Data: template.Data{}} + got = questionPath(ctx, "box", 7) + }) + f.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/next-unread?from=admin&sort=new", nil)) + + want := "/_/box/7?from=admin&sort=new" + if got != want { + t.Fatalf("questionPath() = %q, want %q", got, want) + } +} diff --git a/route/question/question.go b/route/question/question.go index 6bf11cc..f313d45 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -276,7 +276,7 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question return } } - ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID)) + ctx.Redirect(questionPath(ctx, pageUser.Domain, question.ID)) } func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) { @@ -459,12 +459,16 @@ func questionPath(ctx context.Context, domain string, questionID uint) string { // adminBackQuery 提取后台列表的筛选参数(仅 from=admin 时有效),供单题页的 // 「返回」「下一条未读」沿用。POST 请求从表单 action 的 query 里读取。 +// 返回值始终带 from=admin:调用方会把它重新写进表单 action 与跳转地址, +// 少一个 from,下一次 POST 就认不出来源了。 func adminBackQuery(ctx context.Context) string { if ctx.Query("from") != "admin" { return "" } - backQuery := url.Values{} + // from 必须随表单 action 回到 POST:ctx.Query 只读 URL query, + // 少了它就认不出来源,「返回」会掉回箱主页。 + backQuery := url.Values{"from": {"admin"}} for _, key := range []string{"sort", "status", "type", "read", "q"} { if v := ctx.Query(key); v != "" { backQuery.Set(key, v) diff --git a/templates/announcement_render_test.go b/templates/announcement_render_test.go new file mode 100644 index 0000000..22e3628 --- /dev/null +++ b/templates/announcement_render_test.go @@ -0,0 +1,62 @@ +package templates + +import ( + "strings" + "testing" + "time" + + "github.com/tamakyi/TamaBox/internal/conf" +) + +// 公告文案要按访客语言取:header 里传的是 .LangCode, +// 接错了就是「英文界面看到中文公告」或者干脆整块不渲染。 +func TestAnnouncementRenderedPerLanguage(t *testing.T) { + old := conf.Announcement + t.Cleanup(func() { conf.Announcement = old }) + + conf.Announcement.ContentZhCN = "简中公告" + conf.Announcement.ContentEn = "English notice" + + cases := []struct { + lang string + want string + notWant string + }{ + {"zh-CN", "简中公告", "English notice"}, + {"en", "English notice", "简中公告"}, + } + for _, c := range cases { + t.Run(c.lang, func(t *testing.T) { + data := publicBaseData() + data["LangCode"] = c.lang + out, err := renderToString(t, "question/list", data) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(out, c.want) { + t.Fatalf("%s 界面应显示 %q", c.lang, c.want) + } + if strings.Contains(out, c.notWant) { + t.Fatalf("%s 界面不应显示 %q", c.lang, c.notWant) + } + }) + } +} + +// 窗口外整块不渲染:不能先出 HTML 再靠 CSS 藏,否则会留下空白占位。 +func TestAnnouncementHiddenOutsideWindow(t *testing.T) { + old := conf.Announcement + t.Cleanup(func() { conf.Announcement = old }) + + conf.Announcement.ContentZhCN = "简中公告" + conf.Announcement.StartAt = time.Now().Add(time.Hour) + + data := publicBaseData() + out, err := renderToString(t, "question/list", data) + if err != nil { + t.Fatalf("render: %v", err) + } + if strings.Contains(out, "tb-announce") { + t.Fatal("未到展示时间时不应渲染公告块") + } +} diff --git a/templates/base/header.html b/templates/base/header.html index 87b3e67..2db6fe8 100644 --- a/templates/base/header.html +++ b/templates/base/header.html @@ -371,7 +371,7 @@ document.addEventListener("DOMContentLoaded", function() { })(); -{{ with announcement }} +{{ with announcement .LangCode }}
diff --git a/templates/home.html b/templates/home.html index c7d42dc..4670923 100644 --- a/templates/home.html +++ b/templates/home.html @@ -187,7 +187,7 @@
近期更新