Files
opsx-licence/backend/internal/service/appconfig/service.go
T
Logan.Li 83a3630404
CI / backend (push) Canceled after 0s
feat(settings): 运行配置中心——.env 业务参数统一到后台页面维护
- 新增 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、哪些已收敛到页面
2026-11-01 06:17:58 +08:00

444 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]))
}