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 }