系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启

- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
This commit is contained in:
Sakurasan committed 2026-10-01 12:14:10 +08:00
1 parent bf3ce934dd
commit e68400b389
23 files changed
+957 -126

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
// 系统设置的运行时解析层。
//
// 迁移自环境变量的设置(站点地址、OAuth 凭据、R2 存储、管理员账号)存进
// settings KV 表后,生效规则统一为「DB 有值用 DB,空值回落环境变量」:
// 老部署不改 env 也能照常跑,后台改过的值随时可以赢回来。
//
// Resolver 在 env Config 之上叠加 DB 值,给两个 API 提供按需解析的有效配置;
// 配置带短缓存(设置保存时主动失效),存储后端按配置签名惰性重建——
// 后台改完 R2 凭据不用重启,下一次上传就走新桶。
package config
import (
"strings"
"sync"
"time"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
// Overlay 返回 c 的副本:model.Settings 里非空的系统设置项覆盖 env 值。
// 每个字段独立回落——比如 client_id 在后台改了、secret 还是 env 的,
// 两个拼起来照样能用,不必逼站主一次填齐。
func (c *Config) Overlay(st model.Settings) *Config {
e := *c
pick := func(dst *string, v string) {
if t := strings.TrimSpace(v); t != "" {
*dst = t
}
}
pick(&e.SiteURL, st.SiteURL)
pick(&e.GitHubClientID, st.GitHubClientID)
pick(&e.GitHubClientSecret, st.GitHubClientSecret)
pick(&e.GoogleClientID, st.GoogleClientID)
pick(&e.GoogleClientSecret, st.GoogleClientSecret)
pick(&e.TelegramBot, st.TelegramBot)
pick(&e.TelegramToken, st.TelegramToken)
pick(&e.AdminUser, st.AdminUsername)
pick(&e.UploadsPublicBase, st.UploadsPublicBase)
pick(&e.S3Endpoint, st.S3Endpoint)
pick(&e.R2Bucket, st.R2Bucket)
pick(&e.R2AccessKey, st.R2AccessKey)
pick(&e.R2SecretKey, st.R2SecretKey)
// 存储端按「凭据是否齐全」重新判定,与 config.Load 的规则一致
if e.S3Endpoint != "" && e.R2Bucket != "" && e.R2AccessKey != "" && e.R2SecretKey != "" {
e.StorageDriver = "r2"
} else {
e.StorageDriver = "local"
}
return &e
}
// SettingsSource 是 Resolver 对 store 的最小依赖(测试可注入假实现)。
type SettingsSource interface {
GetSettings() (model.Settings, error)
}
// Resolver 提供带缓存的有效配置与按需重建的存储后端。零值不可用,
// 用 NewResolver 构造;API 拿不到 Resolver 时(旧测试)直接用 env 值。
type Resolver struct {
env *Config
st SettingsSource
mu sync.Mutex
cfg *Config
cfgAt time.Time
blobs storage.BlobStore
blobsKV string
}
// cacheTTL 只兜「绕过后台直接改库」这种少数派路径;正常保存走
// Invalidate() 立即生效,所以 TTL 可以放宽到不影响体验的量级。
const cacheTTL = 10 * time.Second
func NewResolver(env *Config, st SettingsSource) *Resolver {
return &Resolver{env: env, st: st}
}
// Config 返回叠加了 DB 设置的有效配置。读库失败时回落 env——
// 设置页不能因为 DB 抖动把整站带挂。
func (r *Resolver) Config() *Config {
if r == nil {
return r.env
}
r.mu.Lock()
defer r.mu.Unlock()
if r.cfg != nil && time.Since(r.cfgAt) < cacheTTL {
return r.cfg
}
st, err := r.st.GetSettings()
if err != nil {
if r.cfg == nil {
return r.env
}
return r.cfg // 沿用上次缓存,别让一次查询失败打断请求
}
r.cfg = r.env.Overlay(st)
r.cfgAt = time.Now()
return r.cfg
}
// Invalidate 丢弃缓存:后台保存设置后调用,改完立即生效。
func (r *Resolver) Invalidate() {
if r == nil {
return
}
r.mu.Lock()
r.cfg, r.cfgAt = nil, time.Time{}
r.mu.Unlock()
}
// Blobs 返回当前有效配置对应的存储后端,配置变了自动重建。
// 构造失败(R2 凭据不对等)返回 nil,调用方按「无存储」处理。
func (r *Resolver) Blobs() storage.BlobStore {
if r == nil {
return nil
}
c := r.Config()
kv := c.StorageDriver + "|" + c.S3Endpoint + "|" + c.R2Bucket + "|" +
c.R2AccessKey + "|" + c.R2SecretKey
r.mu.Lock()
defer r.mu.Unlock()
if r.blobs != nil && kv == r.blobsKV {
return r.blobs
}
var b storage.BlobStore
switch c.StorageDriver {
case "r2":
s, err := storage.NewR2(c.S3Endpoint, c.R2Bucket, c.R2AccessKey, c.R2SecretKey)
if err != nil {
return r.blobs // 重建失败沿用旧后端,至少不把上传/读取打断
}
b = s
default:
b = storage.NewLocal(c.DataDir + "/uploads")
}
r.blobs, r.blobsKV = b, kv
return b
}
// 静态断言:*store.Store 满足 SettingsSource(GetSettings 在 store 包里)。
var _ SettingsSource = (*store.Store)(nil)