first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
package config
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Listen string
DSN string
SecretKey string
UploadDir string
DataDir string
SessionDays int
Debug bool
// 底图。amap 走免密钥瓦片;填了 map_key 或选 tianditu 时改由服务端代理,
// 密钥不出现在页面源码里。
MapProvider string // amap | tianditu | custom
MapKey string
MapTileURL string // custom 模式下的瓦片模板,支持 {s} {x} {y} {z} {key}
MapCRS string // custom 模式下底图的坐标系:gcj02 | wgs84
// 地理编码(后台「按名称搜坐标」)。高德 Web 服务类型密钥,与瓦片用的
// JS 密钥不是同一个;只经服务端代理调用,不进入前端源码。留空则功能关闭。
GeocodeKey string
// TrackScript 是第三方统计 / 追踪脚本。填 URL 会包成 <script src>;
// 需要附加属性或初始化代码的服务(百度统计、Umami 等)可以把整段 <script> 贴进来。
TrackScript string
// 地点图片的对象存储(S3 兼容,Cloudflare R2 等)。存哪边由后台「图片存储」
// 设置决定,凭据只留在服务端、不进数据库也不下发前端。全部留空 = 只能用本地磁盘。
S3Endpoint string // R2 形如 https://<account>.r2.cloudflarestorage.com
S3Region string // R2 填 auto
S3Bucket string
S3AccessKey string
S3SecretKey string
S3PublicBase string // 公开访问前缀(自定义域名或 r2.dev),拼对象 key 即为 <img src>
}
// S3Ready 判断对象存储是否配置齐全(缺一不可,否则只能在本地存)。
func (c *Config) S3Ready() bool {
return c.S3Endpoint != "" && c.S3Bucket != "" && c.S3AccessKey != "" && c.S3SecretKey != ""
}
// MediaDir 是本地图片的落盘目录。
func (c *Config) MediaDir() string { return filepath.Join(c.DataDir, "media") }
func Load(path string) (*Config, error) {
kv := map[string]string{}
f, err := os.Open(path)
if err == nil {
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
kv[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("读取配置 %s: %w", path, err)
}
cfg := &Config{
Listen: get(kv, "listen", ":8080"),
DSN: get(kv, "dsn", ""),
SecretKey: get(kv, "secret_key", ""),
UploadDir: get(kv, "upload_dir", "storage/uploads"),
DataDir: get(kv, "data_dir", "storage/data"),
SessionDays: getInt(kv, "session_days", 14),
Debug: get(kv, "debug", "false") == "true",
MapProvider: strings.ToLower(get(kv, "map_provider", "amap")),
MapKey: get(kv, "map_key", ""),
MapTileURL: get(kv, "map_tile_url", ""),
MapCRS: strings.ToLower(get(kv, "map_crs", "gcj02")),
GeocodeKey: get(kv, "geocode_key", ""),
TrackScript: strings.TrimSpace(get(kv, "track_script", "")),
S3Endpoint: strings.TrimRight(get(kv, "s3_endpoint", ""), "/"),
S3Region: get(kv, "s3_region", "auto"),
S3Bucket: get(kv, "s3_bucket", ""),
S3AccessKey: get(kv, "s3_access_key", ""),
S3SecretKey: get(kv, "s3_secret_key", ""),
S3PublicBase: normalizeBaseURL(get(kv, "s3_public_base", "")),
}
if cfg.DSN == "" {
return nil, fmt.Errorf("配置缺少 dsn(PostgreSQL 连接串)")
}
if cfg.MapProvider != "amap" && cfg.MapProvider != "tianditu" && cfg.MapProvider != "custom" {
return nil, fmt.Errorf("map_provider 只支持 amap / tianditu / custom,当前为 %q", cfg.MapProvider)
}
if cfg.MapProvider == "tianditu" && cfg.MapKey == "" {
return nil, fmt.Errorf("map_provider = tianditu 时必须填写 map_key(天地图密钥)")
}
if cfg.MapProvider == "custom" && cfg.MapTileURL == "" {
return nil, fmt.Errorf("map_provider = custom 时必须填写 map_tile_url")
}
if cfg.MapCRS != "gcj02" && cfg.MapCRS != "wgs84" {
return nil, fmt.Errorf("map_crs 只支持 gcj02 / wgs84,当前为 %q", cfg.MapCRS)
}
if cfg.SecretKey == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
cfg.SecretKey = hex.EncodeToString(b)
}
for _, d := range []string{cfg.UploadDir, cfg.DataDir, cfg.MediaDir()} {
if err := os.MkdirAll(filepath.Clean(d), 0o755); err != nil {
return nil, fmt.Errorf("创建目录 %s: %w", d, err)
}
}
return cfg, nil
}
// normalizeBaseURL 把「域名」补成完整 URL。
// s3_public_base 是直接拼进 <img src> 的,写成裸域名(r2.example.com)会得到相对地址,
// 浏览器把当前页面的路径拼上去,图全变破图——这种写法太容易发生了,这里统一补 https。
func normalizeBaseURL(v string) string {
v = strings.TrimRight(strings.TrimSpace(v), "/")
if v == "" || strings.Contains(v, "://") {
return v
}
return "https://" + v
}
func get(kv map[string]string, key, def string) string {
if v, ok := kv[key]; ok && v != "" {
return v
}
return def
}
func getInt(kv map[string]string, key string, def int) int {
if v, ok := kv[key]; ok && v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"os"
"regexp"
"strings"
"testing"
)
// TestExampleConfigDocumentsEveryKey 守住「样例配置与代码不脱节」。
//
// app.example.ini 是用户唯一能看到的配置说明:里面没有的键,等于不存在。
// 反过来说,样例里写了代码不读的键(曾经有个 base_url)同样是坑——填了没反应,
// 用户只会怀疑自己写错了。两个方向都检查。
func TestExampleConfigDocumentsEveryKey(t *testing.T) {
src, err := os.ReadFile("config.go")
if err != nil {
t.Fatalf("读 config.go: %v", err)
}
// 以真正的读取点为准:get(kv, "...") / getInt(kv, "...")
// 注意键名要允许数字(s3_endpoint 这类),写成 [a-z_]+ 会把它们整批漏掉,
// 于是「样例里写了但代码不读」这种坑就查不出来了。
re := regexp.MustCompile(`get(?:Int)?\(kv,\s*"([a-z0-9_]+)"`)
used := map[string]bool{}
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
used[m[1]] = true
}
if len(used) < 10 {
t.Fatalf("只从 config.go 扫到 %d 个配置键,抽取规则可能已失效", len(used))
}
ex, err := os.ReadFile("../../conf/app.example.ini")
if err != nil {
t.Fatalf("读 app.example.ini: %v", err)
}
documented := map[string]bool{}
for _, line := range strings.Split(string(ex), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
k, _, ok := strings.Cut(line, "=")
if !ok {
continue
}
documented[strings.TrimSpace(k)] = true
}
for k := range used {
if !documented[k] {
t.Errorf("app.example.ini 缺少配置项 %s(代码会读它)", k)
}
}
for k := range documented {
if !used[k] {
t.Errorf("app.example.ini 里的 %s 代码并不读取,要么补上实现要么删掉", k)
}
}
}
// TestExampleConfigLoads 用示例配置真跑一遍解析,确认它开箱可用。
// dsn 是样例里的占位串,解析阶段不会去连库,所以这里能过。
func TestExampleConfigLoads(t *testing.T) {
cfg, err := Load("../../conf/app.example.ini")
if err != nil {
t.Fatalf("示例配置解析失败: %v", err)
}
// 挑几个「文档里承诺过」的默认值:改了代码或改了文档,这里都会响
if cfg.Listen != ":8080" {
t.Errorf("listen 默认值变了: %q", cfg.Listen)
}
if cfg.SessionDays != 14 {
t.Errorf("session_days 默认值变了: %d", cfg.SessionDays)
}
if cfg.MapProvider != "amap" || cfg.MapCRS != "gcj02" {
t.Errorf("底图默认值变了: provider=%q crs=%q", cfg.MapProvider, cfg.MapCRS)
}
if cfg.S3Region != "auto" || cfg.S3Ready() {
t.Errorf("对象存储默认应当是「未配置」且 region=auto,实际 ready=%v region=%q",
cfg.S3Ready(), cfg.S3Region)
}
if cfg.Debug {
t.Error("示例配置里 debug 应当是关的")
}
if cfg.SecretKey == "" {
t.Error("secret_key 留空时应自动生成,不该是空串")
}
}