Files
rond-with-you/internal/setting/setting.go
T
2026-09-18 13:05:49 +08:00

264 lines
9.2 KiB
Go

package setting
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// Settings 站点级配置,管理员可在后台调整。
type Settings struct {
SiteTitle string `json:"site_title"`
SiteSubtitle string `json:"site_subtitle"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
OwnerBio string `json:"owner_bio"`
Modules []string `json:"modules"`
// Pages 是对访客开放的页面;管理员始终可以访问全部页面
Pages []string `json:"pages"`
// Regions 是访客的区域限制列表(省 / 市 / 区任意层级);
// 空表示不限制,管理员始终看到全部数据。方向由 RegionsMode 决定。
Regions []string `json:"regions"`
// RegionsMode 决定 Regions 是白名单还是黑名单:
// whitelist = 访客只看得到命中这些区域的记录;blacklist = 命中这些区域的记录对访客隐藏。
RegionsMode string `json:"regions_mode"`
HideHome bool `json:"hide_home"`
HideWork bool `json:"hide_work"`
// NotesPublic 决定地点备注是否对访客展示;默认只站长自己可见
NotesPublic bool `json:"notes_public"`
// MarksPublic 决定「推荐 / 踩雷」标记与票数是否对访客展示;默认公开
MarksPublic bool `json:"marks_public"`
MinDwellMin int `json:"min_dwell_min"`
AccentColor string `json:"accent_color"`
// VisitorHome 是访客打开站点根路径时的落地页;VisitorHomeMain 表示就停在主页。
// 站长自己访问根路径始终看主页,不受这里影响。
VisitorHome string `json:"visitor_home"`
// MapDefaultBase 是地图默认铺哪张底图,取值是底图图层的 Key(如 amap-roadnet)。
// 空 = 用第一张。点位密集时换成「纯路网」可以去掉底图自带的 POI 与路名标注,
// 自己的点看得更清楚;访客在图上手动切换过的话,浏览器会记住他自己的选择。
MapDefaultBase string `json:"map_default_base"`
// MediaBackend 是地点图片存哪:local(服务器磁盘)或 s3(S3 兼容对象存储,如 R2)。
// 对象存储的凭据在 conf/app.ini 里(s3_*),不进数据库。
MediaBackend string `json:"media_backend"`
}
// 地点图片的两种存储后端。
const (
MediaBackendLocal = "local"
MediaBackendS3 = "s3"
)
// VisitorHomeMain 表示访客落地到主页本身。
const VisitorHomeMain = "home"
// Regions 的两种方向。
const (
RegionsWhitelist = "whitelist"
RegionsBlacklist = "blacklist"
)
// AllModules 主页可开关的区块。
var AllModules = []Module{
{Key: "overview", Name: "数据总览", Desc: "去过的地方、城市、天数、总里程等核心数字"},
{Key: "map", Name: "足迹地图", Desc: "所有去过的地点在地图上的分布"},
{Key: "types", Name: "类型分布", Desc: "按「餐厅 / 商场 / 景点」等类型统计"},
{Key: "cities", Name: "城市排行", Desc: "按城市汇总到访次数与停留时长"},
{Key: "timeline", Name: "最近足迹", Desc: "最新若干条到访记录"},
{Key: "heatmap", Name: "活跃日历", Desc: "记录热度按天分布"},
{Key: "transport", Name: "出行方式", Desc: "各类交通方式的次数与里程"},
{Key: "hourly", Name: "作息分布", Desc: "到访时间在一天中的分布"},
{Key: "tags", Name: "我的标签", Desc: "自定义标签的统计"},
{Key: "weather", Name: "天气足迹", Desc: "记录期间的气温与天气"},
}
// AllPages 可控制访客可见性的页面。
var AllPages = []Module{
{Key: "map", Name: "足迹地图", Desc: "交互式地图与行程轨迹"},
{Key: "places", Name: "地点库", Desc: "全部地点的列表与详情"},
{Key: "timeline", Name: "时间线", Desc: "按天浏览到访记录"},
{Key: "stats", Name: "数据统计", Desc: "各维度统计图表与迷雾覆盖"},
{Key: "report", Name: "年度报告", Desc: "按年份汇总的足迹报告"},
}
type Module struct {
Key string
Name string
Desc string
}
func Default() Settings {
mods := make([]string, 0, len(AllModules))
for _, m := range AllModules {
mods = append(mods, m.Key)
}
pages := make([]string, 0, len(AllPages))
for _, p := range AllPages {
pages = append(pages, p.Key)
}
return Settings{
SiteTitle: "我的足迹地图",
SiteSubtitle: "记录我去过的每一个地方",
OwnerName: "站长",
OwnerAvatar: "🐾",
OwnerBio: "",
Modules: mods,
Pages: pages,
// 「家」和「工作」暴露的是常住地,默认不对访客展示
HideHome: true,
HideWork: true,
// 「推荐 / 踩雷」属于评价类信息,默认对访客公开
MarksPublic: true,
// 区域限制默认按黑名单:列出要藏起来的区域,其余照常对访客开放
RegionsMode: RegionsBlacklist,
MinDwellMin: 0,
AccentColor: "#2f7d6e",
// 默认所有人都从主页进;想让人一进来就看到地图时再改
VisitorHome: VisitorHomeMain,
// 图片默认存服务器本地磁盘;配好 R2 之后再在后台切过去
MediaBackend: MediaBackendLocal,
}
}
func (s Settings) Has(module string) bool {
for _, m := range s.Modules {
if m == module {
return true
}
}
return false
}
// HasPage 报告某页面是否对访客开放。
func (s Settings) HasPage(page string) bool {
for _, p := range s.Pages {
if p == page {
return true
}
}
return false
}
// VisitorHomePath 返回访客访问根路径时应跳转到的路径,空串表示不跳。
//
// 只在目标页面已对访客开放时才返回路径:否则相当于把访客送到一个他自己看不到的
// 页面(那里会 404),比留在主页更糟。
func (s Settings) VisitorHomePath() string {
if s.VisitorHome == "" || s.VisitorHome == VisitorHomeMain {
return ""
}
for _, p := range AllPages {
if p.Key == s.VisitorHome && s.HasPage(p.Key) {
return "/" + p.Key
}
}
return ""
}
// NormalizeVisitorHome 把表单值收敛到合法取值(主页或 AllPages 里的页)。
// 表单被改坏时回落到主页——这是个「跳错地方」比不跳更糟的开关。
func NormalizeVisitorHome(v string) string {
for _, p := range AllPages {
if p.Key == v {
return v
}
}
return VisitorHomeMain
}
// RegionBlacklist 报告 Regions 是黑名单(命中即对访客隐藏)还是白名单(命中才放行)。
func (s Settings) RegionBlacklist() bool { return s.RegionsMode == RegionsBlacklist }
type Store struct{ DB *sql.DB }
func (st *Store) Load(ctx context.Context) (Settings, error) {
cur := Default()
var raw []byte
err := st.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&raw)
if err == sql.ErrNoRows {
return cur, nil
}
if err != nil {
return cur, err
}
// 先看原始 JSON 里出现过哪些键:布尔项的默认值是 true,
// 直接用零值覆盖会让「历史配置里没这一项」被误判成「显式关掉了」。
var present map[string]json.RawMessage
if err := json.Unmarshal(raw, &present); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
var saved Settings
if err := json.Unmarshal(raw, &saved); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
// 逐项覆盖,保证新增字段拿到默认值
if saved.SiteTitle != "" {
cur.SiteTitle = saved.SiteTitle
}
if saved.SiteSubtitle != "" {
cur.SiteSubtitle = saved.SiteSubtitle
}
if saved.OwnerName != "" {
cur.OwnerName = saved.OwnerName
}
if saved.OwnerAvatar != "" {
cur.OwnerAvatar = saved.OwnerAvatar
}
cur.OwnerBio = saved.OwnerBio
if saved.Modules != nil {
cur.Modules = saved.Modules
}
// pages 键出现即视为显式配置:空数组表示站长明确关闭了所有页面
if _, ok := present["pages"]; ok {
cur.Pages = saved.Pages
}
if _, ok := present["regions"]; ok {
cur.Regions = saved.Regions
}
// regions_mode 是后加的键,而它出现之前 regions 一直是白名单。
// 缺键时若直接套用新默认值(黑名单),历史配置里填过的白名单会静默反转成黑名单
// —— 本该放行的区域全被藏起来,所以缺键一律按白名单解释。
if _, ok := present["regions_mode"]; ok && saved.RegionsMode == RegionsBlacklist {
cur.RegionsMode = RegionsBlacklist
} else {
cur.RegionsMode = RegionsWhitelist
}
if _, ok := present["hide_home"]; ok {
cur.HideHome = saved.HideHome
}
if _, ok := present["hide_work"]; ok {
cur.HideWork = saved.HideWork
}
if _, ok := present["notes_public"]; ok {
cur.NotesPublic = saved.NotesPublic
}
if _, ok := present["marks_public"]; ok {
cur.MarksPublic = saved.MarksPublic
}
if saved.VisitorHome != "" {
cur.VisitorHome = NormalizeVisitorHome(saved.VisitorHome)
}
// 空串是合法值(= 跟随第一张),所以直接赋值,不做非空判断
cur.MapDefaultBase = saved.MapDefaultBase
if saved.MediaBackend == MediaBackendS3 {
cur.MediaBackend = MediaBackendS3
} else {
cur.MediaBackend = MediaBackendLocal
}
cur.MinDwellMin = saved.MinDwellMin
if saved.AccentColor != "" {
cur.AccentColor = saved.AccentColor
}
return cur, nil
}
func (st *Store) Save(ctx context.Context, s Settings) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
_, err = st.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
ON CONFLICT (key) DO UPDATE SET value=$1, updated_at=now()`, raw)
return err
}