- 新增 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:
1 parent
b0b611eee2
commit
83a3630404
17 files changed
+1204
-33
No files matched your search
@@ -0,0 +1,229 @@
|
||||
package appconfig
|
||||
|
||||
// 运行配置注册表:项目里所有「运维/业务参数」的唯一清单。
|
||||
//
|
||||
// 约定(重要):
|
||||
// - Editable=true 的项可在后台「系统设置 → 运行配置」页维护,落库 app_configs,
|
||||
// 读取优先级 DB → 环境变量 → Default;
|
||||
// - Editable=false 的项只做展示(值来自环境变量),用于回答「哪些配置必须留在 .env」,
|
||||
// 尤其是启动引导密钥:它们决定能否解开库里的密文 / 能否签发令牌,
|
||||
// 放进数据库等于把钥匙锁进自己锁的箱子,因此永不入库; // Logan.Li
|
||||
// - 新增配置时先在这里登记,再在消费点用 String/Bool/List 读取,避免散落的 os.Getenv。 // Logan.Li
|
||||
|
||||
// Kind 配置值类型 // Logan.Li
|
||||
const (
|
||||
KindString = "string" // 普通字符串
|
||||
KindBool = "bool" // 开关,存 "1" / "0"
|
||||
KindList = "list" // 逗号分隔列表
|
||||
KindSecret = "secret" // 机密字符串(落库 AES-GCM 加密,页面只回显掩码)
|
||||
)
|
||||
|
||||
// 配置分组顺序(页面按此顺序渲染) // Logan.Li
|
||||
var GroupOrder = []string{"支付与回调", "安全与访问", "引导密钥(仅环境变量)", "部署参数(仅环境变量)"}
|
||||
|
||||
// Item 单个配置项的元数据 // Logan.Li
|
||||
type Item struct {
|
||||
Key string // 键名 = 环境变量名
|
||||
Label string // 中文名
|
||||
Group string // 分组
|
||||
Description string // 说明(页面直接展示)
|
||||
Kind string // 见 Kind 常量
|
||||
Default string // 环境变量与数据库都缺省时的值
|
||||
Editable bool // 是否允许页面编辑
|
||||
RequiresRestart bool // 改动是否需要重启容器才生效
|
||||
Danger bool // 高风险开关,页面二次确认
|
||||
ReadOnlyReason string // Editable=false 时的原因说明
|
||||
Placeholder string // 输入提示
|
||||
}
|
||||
|
||||
// items 全量注册表(顺序即页面顺序) // Logan.Li
|
||||
var items = []Item{
|
||||
// ============ 支付与回调 ============
|
||||
{
|
||||
Key: "PAYMENT_PUBLIC_BASE_URL",
|
||||
Label: "支付回调对外基址",
|
||||
Group: "支付与回调",
|
||||
Description: "生成微信/支付宝回调地址(notify_url)用的对外域名,需与浏览器访问域名一致且公网可达。",
|
||||
Kind: KindString,
|
||||
Default: "https://licence.rlwj.cn",
|
||||
Editable: true,
|
||||
Placeholder: "https://licence.rlwj.cn",
|
||||
},
|
||||
{
|
||||
Key: "SUCCESS_URL",
|
||||
Label: "支付成功回跳地址",
|
||||
Group: "支付与回调",
|
||||
Description: "买家付款完成后跳回的页面。Waffo 页签填写了同名字段时以页签为准,此项作为兜底。",
|
||||
Kind: KindString,
|
||||
Default: "",
|
||||
Editable: true,
|
||||
Placeholder: "https://licence.rlwj.cn/pay/success",
|
||||
},
|
||||
{
|
||||
Key: "PAYMENT_CERT_DIR",
|
||||
Label: "支付证书目录",
|
||||
Group: "支付与回调",
|
||||
Description: "容器内证书/私钥落地目录(0700),必须在数据卷内,否则重建容器即丢证书。",
|
||||
Kind: KindString,
|
||||
Default: "/data/payment-certs",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "属于容器部署参数(数据卷路径),随镜像/编排交付,改错会导致证书丢失",
|
||||
},
|
||||
|
||||
// ============ 安全与访问 ============
|
||||
{
|
||||
Key: "INTERNAL_WEBHOOK_SECRET",
|
||||
Label: "内部 Webhook 共享密钥",
|
||||
Group: "安全与访问",
|
||||
Description: "通用支付回调 /api/v1/webhooks/payment 的验签密钥;未配置时回落到已启用网关的 WebhookSecret。留空表示不单独配置。",
|
||||
Kind: KindSecret,
|
||||
Default: "",
|
||||
Editable: true,
|
||||
Placeholder: "至少 16 位随机串",
|
||||
},
|
||||
{
|
||||
Key: "ALLOW_UNVERIFIED_WEBHOOK",
|
||||
Label: "允许未验签回调",
|
||||
Group: "安全与访问",
|
||||
Description: "仅本地调试使用。开启后未配置密钥也放行回调,任何人都能伪造「已付款」订单;生产(GIN_MODE=release)下禁止开启。",
|
||||
Kind: KindBool,
|
||||
Default: "0",
|
||||
Editable: true,
|
||||
Danger: true,
|
||||
},
|
||||
{
|
||||
Key: "CORS_ORIGINS",
|
||||
Label: "跨域白名单",
|
||||
Group: "安全与访问",
|
||||
Description: "允许跨域访问 API 的 Origin 列表(逗号分隔,含协议与端口)。留空 = 不限制来源。",
|
||||
Kind: KindList,
|
||||
Default: "",
|
||||
Editable: true,
|
||||
RequiresRestart: true,
|
||||
Placeholder: "https://app.example.com,tauri://localhost",
|
||||
},
|
||||
{
|
||||
Key: "TRUSTED_PROXIES",
|
||||
Label: "可信反代网段",
|
||||
Group: "安全与访问",
|
||||
Description: "反向代理的内网 CIDR(逗号分隔),用于取真实客户端 IP;配置错误会导致限流与审计 IP 失真。",
|
||||
Kind: KindList,
|
||||
Default: "",
|
||||
Editable: true,
|
||||
RequiresRestart: true,
|
||||
Placeholder: "127.0.0.1,172.16.0.0/12",
|
||||
},
|
||||
{
|
||||
Key: "GEOIP_DB_PATH",
|
||||
Label: "GeoIP 库路径",
|
||||
Group: "安全与访问",
|
||||
Description: "GeoLite2-City.mmdb 在容器内的路径,用于订单/激活的地理分布统计;文件不存在时静默降级。",
|
||||
Kind: KindString,
|
||||
Default: "/data/geoip/GeoLite2-City.mmdb",
|
||||
Editable: true,
|
||||
RequiresRestart: true,
|
||||
Placeholder: "/data/geoip/GeoLite2-City.mmdb",
|
||||
},
|
||||
|
||||
// ============ 引导密钥(仅环境变量) ============
|
||||
{
|
||||
Key: "JWT_SECRET",
|
||||
Label: "登录令牌签名密钥",
|
||||
Group: "引导密钥(仅环境变量)",
|
||||
Description: "签发/校验管理员登录令牌。入库即与历史令牌脱钩,且数据库泄露等于直接交出后台登录能力,故只允许环境变量。",
|
||||
Kind: KindSecret,
|
||||
Editable: false,
|
||||
ReadOnlyReason: "启动引导密钥:登录鉴权在数据库可用之前就要用它,且必须与历史令牌保持一致",
|
||||
},
|
||||
{
|
||||
Key: "OPSLIC_MASTER_KEY",
|
||||
Label: "机密字段信封加密主密钥",
|
||||
Group: "引导密钥(仅环境变量)",
|
||||
Description: "解开库里全部密文(支付私钥、证书口令、本页机密项)的根密钥,64 位 hex。丢失=所有密文不可解,泄露=所有密文失效。",
|
||||
Kind: KindSecret,
|
||||
Editable: false,
|
||||
ReadOnlyReason: "根密钥必须独立于数据库存放,否则加密形同虚设;更换需先离线重加密全部密文",
|
||||
},
|
||||
{
|
||||
Key: "OFFLINE_LICENSE_SEED",
|
||||
Label: "离线授权签名种子",
|
||||
Group: "引导密钥(仅环境变量)",
|
||||
Description: "Ed25519 种子(64 位 hex),签发离线授权证。更换后已发出的离线证将无法验证,需同步桌面端公钥,故不提供页面修改。",
|
||||
Kind: KindSecret,
|
||||
Editable: false,
|
||||
ReadOnlyReason: "更换会使既有离线授权证全部失效(桌面端内置公钥需同步升级),须人工走轮换流程",
|
||||
},
|
||||
|
||||
// ============ 部署参数(仅环境变量) ============
|
||||
{
|
||||
Key: "PORT",
|
||||
Label: "服务监听端口",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "9001",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "进程启动参数:端口在读取数据库之前就已绑定",
|
||||
},
|
||||
{
|
||||
Key: "GIN_MODE",
|
||||
Label: "运行模式",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "debug",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "进程启动参数:决定日志级别与安全开关的 fail-closed 行为",
|
||||
},
|
||||
{
|
||||
Key: "DB_DRIVER",
|
||||
Label: "数据库驱动",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "sqlite",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "启动引导参数:数据库连接建立前无法读取本表",
|
||||
},
|
||||
{
|
||||
Key: "DB_NAME",
|
||||
Label: "数据库文件 / 库名",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "db.sqlite3",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "启动引导参数:数据库连接建立前无法读取本表",
|
||||
},
|
||||
{
|
||||
Key: "HOST_PORT",
|
||||
Label: "宿主机映射端口",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "9001",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "docker compose 编排参数,由宿主机侧读取,进程内不可见",
|
||||
},
|
||||
{
|
||||
Key: "VERSION",
|
||||
Label: "版本标记",
|
||||
Group: "部署参数(仅环境变量)",
|
||||
Kind: KindString,
|
||||
Default: "",
|
||||
Editable: false,
|
||||
ReadOnlyReason: "构建期注入的镜像版本标记,运行时只读",
|
||||
},
|
||||
}
|
||||
|
||||
// Items 返回注册表副本(调用方可自由修改,不污染全局) // Logan.Li
|
||||
func Items() []Item {
|
||||
out := make([]Item, len(items))
|
||||
copy(out, items)
|
||||
return out
|
||||
}
|
||||
|
||||
// Lookup 按键查注册项 // Logan.Li
|
||||
func Lookup(key string) (Item, bool) {
|
||||
for _, it := range items {
|
||||
if it.Key == key {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return Item{}, false
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
Reference in new issue
Block a user