// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. 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] 段)。 // // 文案留空即关闭公告;Start / End 留空表示该侧不限。 // 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失, // 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。 type announcementConfig struct { // 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:"-"` 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", "2006-01-02", time.RFC3339, } // sanitizeAnnouncement 解析公告时间窗。 // // 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站 // 502;但必须留下能定位的日志 —— 否则现象就是「配了却不显示」,和未知键被静默 // 忽略属于同一类坑(见 unknown.go)。 func sanitizeAnnouncement() { Announcement.Content = strings.TrimSpace(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 } disable := func(key, value, reason string) { logrus.WithFields(logrus.Fields{ "section": "announcement", "key": key, "value": value, "reason": reason, }).Warn("公告配置无效,已关闭公告(站点其它功能不受影响)") 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, 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, loc); !ok { disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339") return } if !Announcement.StartAt.IsZero() && !Announcement.EndAt.IsZero() && !Announcement.EndAt.After(Announcement.StartAt) { disable("end", Announcement.End, "end 必须晚于 start,否则公告永远不会展示") return } logrus.WithFields(logrus.Fields{ "zone": loc.String(), "languages": strings.Join(Announcement.configuredLanguages(), ","), "start": announcementBoundLabel(Announcement.StartAt), "end": announcementBoundLabel(Announcement.EndAt), "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 解析单个时间点;空串返回零值(表示不限)且视为合法。 // 带偏移的 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, loc); err == nil { return t, true } } return time.Time{}, false } // announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对 // 「我写的 10:00 到底被当成了几点」—— 裸时间按 [announcement] timezone 解释。 func announcementBoundLabel(t time.Time) string { if t.IsZero() { return "不限" } return t.Format("2006-01-02 15:04:05 -07:00") }