215 lines
7.3 KiB
Go
215 lines
7.3 KiB
Go
// 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")
|
||
}
|