Files
2026-10-22 17:49:25 +08:00

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