feat(settings): 运行配置中心——.env 业务参数统一到后台页面维护
CI / backend (push) Canceled after 0s

- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退
- 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径
- 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库
- 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志
- 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心
- 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认)
- .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
This commit is contained in:
Logan.Li committed 2026-11-01 06:17:58 +08:00
1 parent b0b611eee2
commit 83a3630404
17 files changed
+1204 -33

No files matched your search

@@ -0,0 +1,443 @@
package appconfig
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"backend/internal/dto"
"backend/internal/model"
"backend/internal/secretbox"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 配置来源标记 // Logan.Li
const (
SourcePage = "page" // 后台页面(app_configs 表)
SourceEnv = "env" // 环境变量 / .env
SourceDefault = "default" // 注册表默认值
SourceMissing = "missing" // 未配置
)
// Service 运行配置服务:负责读写 app_configs、解密缓存与生效值解析。 // Logan.Li
type Service struct {
db *gorm.DB
mu sync.RWMutex
rows map[string]model.AppConfig // 原始行(密文)
plain map[string]string // 解密后的明文缓存(仅内存)
}
// std 全局单例,供各消费点直接读取生效值(未初始化时退化为纯环境变量)。 // Logan.Li
var std atomic.Pointer[Service]
// Standard 返回全局已装载的服务;未初始化时用给定 db 兜底构建(不写全局) // Logan.Li
func Standard(db *gorm.DB) *Service {
if s := std.Load(); s != nil {
return s
}
s := &Service{db: db, rows: map[string]model.AppConfig{}, plain: map[string]string{}}
if db != nil {
_ = s.reload()
}
return s
}
// Init 装载配置中心(须在 database.Init 之后、路由注册之前调用) // Logan.Li
func Init(db *gorm.DB) error {
s := &Service{db: db}
if err := s.reload(); err != nil {
return err
}
std.Store(s)
return nil
}
// reload 从数据库重载全部配置(解密失败只告警不阻断启动) // Logan.Li
func (s *Service) reload() error {
var rows []model.AppConfig
if err := s.db.Find(&rows).Error; err != nil {
return fmt.Errorf("加载运行配置失败: %w", err)
}
plain := make(map[string]string, len(rows))
byKey := make(map[string]model.AppConfig, len(rows))
for _, row := range rows {
byKey[row.Key] = row
if !row.IsSecret {
plain[row.Key] = row.Value
continue
}
dec, err := secretbox.Decrypt(row.Value)
if err != nil {
// 主密钥更换后旧密文不可解:视为未配置,回退环境变量,避免整站起不来 // Logan.Li
fmt.Printf("[运行配置] %s 解密失败(主密钥可能已更换),已回退环境变量: %v\n", row.Key, err)
continue
}
plain[row.Key] = dec
}
s.mu.Lock()
s.rows = byKey
s.plain = plain
s.mu.Unlock()
return nil
}
// resolve 解析单个配置项的生效值与来源:DB → 环境变量 → 默认 // Logan.Li
func resolve(it Item) (string, string) {
if s := std.Load(); s != nil {
s.mu.RLock()
v, ok := s.plain[it.Key]
s.mu.RUnlock()
if ok && strings.TrimSpace(v) != "" {
return v, SourcePage
}
}
if v := strings.TrimSpace(os.Getenv(it.Key)); v != "" {
return v, SourceEnv
}
if strings.TrimSpace(it.Default) != "" {
return it.Default, SourceDefault
}
return "", SourceMissing
}
// ============ 消费点读取 API ============
// String 读取字符串配置(未注册的键直接读环境变量) // Logan.Li
func String(key string) string {
it, ok := Lookup(key)
if !ok {
return strings.TrimSpace(os.Getenv(key))
}
v, _ := resolve(it)
return v
}
// Bool 读取布尔配置(1/true/yes/on 视为真) // Logan.Li
func Bool(key string) bool {
switch strings.ToLower(strings.TrimSpace(String(key))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// List 读取逗号分隔列表(自动去空、去重、保序) // Logan.Li
func List(key string) []string {
raw := String(key)
if strings.TrimSpace(raw) == "" {
return nil
}
out := make([]string, 0, 4)
seen := map[string]bool{}
for _, p := range strings.Split(raw, ",") {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
// ============ 页面视图与写入 ============
// Overview 配置中心总览(按分组,机密项只给掩码) // Logan.Li
func (s *Service) Overview() *dto.RuntimeOverview {
groups := make([]dto.RuntimeGroupView, 0, len(GroupOrder))
index := make(map[string]int, len(GroupOrder))
for _, name := range GroupOrder {
index[name] = len(groups)
groups = append(groups, dto.RuntimeGroupView{Name: name, Items: make([]dto.RuntimeItemView, 0, 4)})
}
var bootstrapKeys, editableKeys []string
for _, it := range Items() {
val, source := resolve(it)
view := dto.RuntimeItemView{
Key: it.Key,
Label: it.Label,
Group: it.Group,
Description: it.Description,
Kind: it.Kind,
Value: val,
Masked: val,
Source: source,
Default: it.Default,
Editable: it.Editable,
RequiresRestart: it.RequiresRestart,
Danger: it.Danger,
ReadOnlyReason: it.ReadOnlyReason,
Placeholder: it.Placeholder,
Configured: strings.TrimSpace(val) != "",
}
if it.Kind == KindSecret {
view.Value = "" // 机密永不回传明文 // Logan.Li
view.Masked = maskSecret(val)
view.Fingerprint = fingerprint(val)
}
if row, ok := s.row(it.Key); ok {
view.UpdatedAt = row.UpdatedAt.Format("2006-01-02 15:04:05")
view.UpdatedBy = row.UpdatedBy
}
if it.Editable {
editableKeys = append(editableKeys, it.Key)
} else {
bootstrapKeys = append(bootstrapKeys, it.Key)
}
gi, ok := index[it.Group]
if !ok {
continue
}
groups[gi].Items = append(groups[gi].Items, view)
}
return &dto.RuntimeOverview{Groups: groups, BootstrapKeys: bootstrapKeys, PageEditableKeys: editableKeys}
}
// row 读取单行元数据 // Logan.Li
func (s *Service) row(key string) (model.AppConfig, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
row, ok := s.rows[key]
return row, ok
}
// Update 批量保存配置(先全量校验再落库,避免半成功) // Logan.Li
func (s *Service) Update(values map[string]string, actor, ip string) (*dto.RuntimeOverview, error) {
if len(values) == 0 {
return s.Overview(), nil
}
normalized := make(map[string]string, len(values))
items := make(map[string]Item, len(values))
for key, raw := range values {
it, ok := Lookup(key)
if !ok {
return nil, fmt.Errorf("未知配置项:%s", key)
}
if !it.Editable {
return nil, fmt.Errorf("「%s」不支持页面配置:%s", it.Label, it.ReadOnlyReason)
}
v, err := validate(it, raw)
if err != nil {
return nil, err
}
normalized[key] = v
items[key] = it
}
// 生产模式下禁止放行未验签回调 // Logan.Li
if v, ok := normalized["ALLOW_UNVERIFIED_WEBHOOK"]; ok && v == "1" && strings.EqualFold(os.Getenv("GIN_MODE"), "release") {
return nil, errors.New("生产模式(GIN_MODE=release)禁止开启「允许未验签回调」")
}
err := s.db.Transaction(func(tx *gorm.DB) error {
for key, v := range normalized {
it := items[key]
if v == "" {
// 空值 = 清除页面配置,回退环境变量/默认 // Logan.Li
if err := tx.Where("key = ?", key).Delete(&model.AppConfig{}).Error; err != nil {
return err
}
continue
}
stored, err := encodeValue(it, v)
if err != nil {
return err
}
row := model.AppConfig{
Key: key,
Value: stored,
IsSecret: it.Kind == KindSecret,
UpdatedBy: actor,
}
if err := tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, errors.New("保存运行配置失败:" + err.Error())
}
if err := s.reload(); err != nil {
return nil, err
}
s.audit(actor, ip, "update", normalized, items)
return s.Overview(), nil
}
// Reset 恢复默认:删除页面值,回退环境变量/注册表默认 // Logan.Li
func (s *Service) Reset(keys []string, actor, ip string) (*dto.RuntimeOverview, error) {
if len(keys) == 0 {
return s.Overview(), nil
}
for _, key := range keys {
it, ok := Lookup(key)
if !ok {
return nil, fmt.Errorf("未知配置项:%s", key)
}
if !it.Editable {
return nil, fmt.Errorf("「%s」不支持页面配置:%s", it.Label, it.ReadOnlyReason)
}
}
if err := s.db.Where("key IN ?", keys).Delete(&model.AppConfig{}).Error; err != nil {
return nil, errors.New("恢复默认失败")
}
if err := s.reload(); err != nil {
return nil, err
}
changed := make(map[string]string, len(keys))
for _, k := range keys {
changed[k] = ""
}
s.audit(actor, ip, "reset", changed, nil)
return s.Overview(), nil
}
// encodeValue 机密项落库前加密,其余原样 // Logan.Li
func encodeValue(it Item, v string) (string, error) {
if it.Kind != KindSecret {
return v, nil
}
enc, err := secretbox.Encrypt(v)
if err != nil {
return "", fmt.Errorf("加密「%s」失败: %w", it.Label, err)
}
return enc, nil
}
// audit 记录配置变更(机密项只记掩码) // Logan.Li
func (s *Service) audit(actor, ip, action string, changed map[string]string, items map[string]Item) {
if s.db == nil || len(changed) == 0 {
return
}
names := make([]string, 0, len(changed))
details := make([]string, 0, len(changed))
for key, v := range changed {
it, ok := items[key]
if !ok {
it, _ = Lookup(key)
}
names = append(names, it.Label)
if action == "reset" || v == "" {
details = append(details, it.Label+"=已恢复默认")
continue
}
if it.Kind == KindSecret {
details = append(details, it.Label+"=已更新("+maskSecret(v)+")")
continue
}
details = append(details, it.Label+"="+v)
}
logRow := model.AuditLog{
Username: actor,
Action: action,
TargetType: "app_config",
TargetName: strings.Join(names, "、"),
Detail: "运行配置变更:" + strings.Join(details, ";"),
Result: "success",
IP: ip,
}
_ = s.db.Create(&logRow).Error
}
// ============ 校验与展示工具 ============
// validate 按类型校验并归一化配置值(空串表示清除) // Logan.Li
func validate(it Item, raw string) (string, error) {
v := strings.TrimSpace(raw)
switch it.Kind {
case KindBool:
if v == "" {
return "", nil
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return "1", nil
case "0", "false", "no", "off":
return "0", nil
}
return "", fmt.Errorf("「%s」只能是开启或关闭", it.Label)
case KindList:
if v == "" {
return "", nil
}
parts := make([]string, 0, 4)
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
parts = append(parts, p)
}
}
if it.Key == "TRUSTED_PROXIES" {
for _, p := range parts {
if _, _, err := net.ParseCIDR(p); err != nil {
if _, aerr := netip.ParseAddr(p); aerr != nil {
return "", fmt.Errorf("「%s」中的 %s 不是合法 IP 或 CIDR", it.Label, p)
}
}
}
}
if it.Key == "CORS_ORIGINS" {
for _, p := range parts {
if !strings.Contains(p, "://") && p != "null" {
return "", fmt.Errorf("「%s」需带协议前缀(如 https://app.example.com),非法值:%s", it.Label, p)
}
}
}
return strings.Join(parts, ","), nil
case KindSecret:
if v == "" {
return "", nil
}
if len(v) < 16 {
return "", fmt.Errorf("「%s」长度不足 16 位,强度太低", it.Label)
}
return v, nil
default:
if len(v) > 500 {
return "", fmt.Errorf("「%s」过长(上限 500 字符)", it.Label)
}
if it.Key == "PAYMENT_PUBLIC_BASE_URL" || it.Key == "SUCCESS_URL" {
if v != "" {
u, err := url.Parse(v)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("「%s」需为完整的 http/https 地址", it.Label)
}
}
}
return v, nil
}
}
// maskSecret 机密值掩码:8 个圆点 + 末 4 位 // Logan.Li
func maskSecret(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
r := []rune(v)
if len(r) <= 8 {
return strings.Repeat("•", 8)
}
return strings.Repeat("•", 8) + string(r[len(r)-4:])
}
// fingerprint 机密值指纹(SHA256 前 4 字节 hex),用于人工核对是否为同一密钥 // Logan.Li
func fingerprint(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
sum := sha256.Sum256([]byte(v))
return strings.ToUpper(hex.EncodeToString(sum[:4]))
}