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

+22
View File
@@ -117,6 +117,7 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success
# 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs # 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs
# PAYMENT_CERT_DIR=/data/payment-certs # PAYMENT_CERT_DIR=/data/payment-certs
# 对外基址(用于生成微信/支付宝回调地址展示) # 对外基址(用于生成微信/支付宝回调地址展示)
# 已收敛到后台「系统设置 → 运行配置」,此处仅作兜底(页面值优先)
# PAYMENT_PUBLIC_BASE_URL=https://licence.rlwj.cn # PAYMENT_PUBLIC_BASE_URL=https://licence.rlwj.cn
# 宿主机映射端口(容器内固定 9001) # 宿主机映射端口(容器内固定 9001)
HOST_PORT=9001 HOST_PORT=9001
@@ -131,3 +132,24 @@ VITE_API_BASE_URL=http://localhost:9001/api
# 生产环境 API 地址(构建时注入) # 生产环境 API 地址(构建时注入)
# VITE_APP_BASE_API=/api # VITE_APP_BASE_API=/api
# ============================================
# 运行配置中心(后台「系统设置 → 运行配置」)
# ============================================
# 读取优先级:app_configs 表(页面) → 环境变量 → 注册表默认值。
# 下面的业务参数建议只在页面维护,.env 里的同名值会被页面值覆盖
# (页面点「恢复默认」即删除库值、回退到这里):
# PAYMENT_PUBLIC_BASE_URL 支付回调对外基址
# SUCCESS_URL 支付成功回跳地址
# INTERNAL_WEBHOOK_SECRET 通用回调验签密钥(页面加密入库)
# ALLOW_UNVERIFIED_WEBHOOK 放行未验签回调(仅调试;release 下页面禁止开启)
# CORS_ORIGINS 跨域白名单(改后需重启)
# TRUSTED_PROXIES 可信反代网段(改后需重启)
# GEOIP_DB_PATH GeoIP 库路径(改后需重启)
#
# 以下引导密钥【必须】留在 .env,页面只读展示指纹,不接受页面写入:
# PORT / GIN_MODE / DB_DRIVER / DB_NAME 进程启动参数(数据库之前就要用)
# JWT_SECRET 直接决定后台登录令牌签发能力
# OPSLIC_MASTER_KEY 解开库内全部密文的根密钥(不入库)
# OFFLINE_LICENSE_SEED 离线授权签名种子(轮换将使旧离线证失效)
# PAYMENT_CERT_DIR 容器内证书目录(数据卷路径)
+6 -5
View File
@@ -62,12 +62,13 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
&model.LicenseTemplate{}, // License 模板 // Logan.Li &model.LicenseTemplate{}, // License 模板 // Logan.Li
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
&model.PaymentConfig{}, // 支付配置(微信/支付宝,敏感字段 AES-GCM 加密) // Logan.Li &model.PaymentConfig{}, // 支付配置(微信/支付宝,敏感字段 AES-GCM 加密) // Logan.Li
&model.AppConfig{}, // 运行配置中心(页面可配参数,机密项加密) // Logan.Li
// 核心授权域 // 核心授权域
&model.Product{}, // 产品 // Logan.Li &model.Product{}, // 产品 // Logan.Li
&model.Customer{}, // 客户 // Logan.Li &model.Customer{}, // 客户 // Logan.Li
&model.CustomerProduct{}, // 客户-产品关联 // Logan.Li &model.CustomerProduct{}, // 客户-产品关联 // Logan.Li
&model.License{}, // License // Logan.Li &model.License{}, // License // Logan.Li
&model.Activation{}, // 激活记录 // Logan.Li &model.Activation{}, // 激活记录 // Logan.Li
// 定价与订单 // 定价与订单
&model.Plan{}, // 定价计划 // Logan.Li &model.Plan{}, // 定价计划 // Logan.Li
&model.Order{}, // 订单 // Logan.Li &model.Order{}, // 订单 // Logan.Li
@@ -0,0 +1,56 @@
package appconfig
import (
"net/http"
"backend/internal/dto"
appconfigservice "backend/internal/service/appconfig"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Controller 运行配置中心控制器(仅管理员,挂在 JWT 保护组下) // Logan.Li
type Controller struct {
svc *appconfigservice.Service
}
// NewController 创建控制器(复用全局已装载的服务实例,保证读写同一份缓存) // Logan.Li
func NewController(db *gorm.DB) *Controller {
return &Controller{svc: appconfigservice.Standard(db)}
}
// Overview 读取全部配置(机密项掩码) // Logan.Li
func (ctrl *Controller) Overview(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": ctrl.svc.Overview()})
}
// Update 批量保存配置 // Logan.Li
func (ctrl *Controller) Update(c *gin.Context) {
var req dto.RuntimeConfigUpdate
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
data, err := ctrl.svc.Update(req.Values, c.GetString("username"), c.ClientIP())
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "运行配置已保存", "data": data})
}
// Reset 恢复默认(删除页面值) // Logan.Li
func (ctrl *Controller) Reset(c *gin.Context) {
var req dto.RuntimeResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
data, err := ctrl.svc.Reset(req.Keys, c.GetString("username"), c.ClientIP())
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "已恢复环境变量/默认值", "data": data})
}
@@ -3,6 +3,7 @@ package order
import ( import (
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
"backend/internal/service/appconfig"
orderservice "backend/internal/service/order" orderservice "backend/internal/service/order"
"backend/internal/service/payment" "backend/internal/service/payment"
"backend/utils" "backend/utils"
@@ -107,7 +108,7 @@ func (ctrl *OrderController) ClaimPurchase(c *gin.Context) {
// PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li // PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
secret := strings.TrimSpace(os.Getenv("INTERNAL_WEBHOOK_SECRET")) secret := appconfig.String("INTERNAL_WEBHOOK_SECRET")
if secret == "" { if secret == "" {
var gw model.PaymentGatewayConfig var gw model.PaymentGatewayConfig
if err := ctrl.db.Where("enabled = ?", true).Order("priority ASC").First(&gw).Error; err == nil { if err := ctrl.db.Where("enabled = ?", true).Order("priority ASC").First(&gw).Error; err == nil {
@@ -123,7 +124,7 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
if secret == "" { if secret == "" {
// fail-closed:未配置密钥一律拒绝;仅当显式声明 ALLOW_UNVERIFIED_WEBHOOK=1 // fail-closed:未配置密钥一律拒绝;仅当显式声明 ALLOW_UNVERIFIED_WEBHOOK=1
// (本地调试)才放行,防止调试配置带进生产 // Logan.Li // (本地调试)才放行,防止调试配置带进生产 // Logan.Li
if release || os.Getenv("ALLOW_UNVERIFIED_WEBHOOK") != "1" { if release || !appconfig.Bool("ALLOW_UNVERIFIED_WEBHOOK") {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"}) c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"})
return return
} }
+51
View File
@@ -0,0 +1,51 @@
package dto
// 运行配置中心(后台「系统设置 → 运行配置」)的请求/响应结构。
// 机密项只回显掩码 + 指纹,永不回传明文。 // Logan.Li
// RuntimeItemView 单个配置项视图 // Logan.Li
type RuntimeItemView struct {
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
Description string `json:"description"`
Kind string `json:"kind"` // string / bool / list / secret
Value string `json:"value"` // 非机密项的当前生效值
Masked string `json:"masked"` // 展示用值(机密项为掩码)
Source string `json:"source"` // page / env / default / missing
Default string `json:"default"` // 注册表默认值
Editable bool `json:"editable"` // 是否可页面编辑
RequiresRestart bool `json:"requires_restart"` // 是否需重启生效
Danger bool `json:"danger"` // 高风险开关
ReadOnlyReason string `json:"read_only_reason"`
Placeholder string `json:"placeholder"`
Configured bool `json:"configured"` // 当前是否有生效值
Fingerprint string `json:"fingerprint"` // 机密项指纹(SHA256 前 4 字节 hex)
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
}
// RuntimeGroupView 分组视图 // Logan.Li
type RuntimeGroupView struct {
Name string `json:"name"`
Items []RuntimeItemView `json:"items"`
}
// RuntimeConfigUpdate 批量保存(空字符串表示清除页面值、回退环境变量/默认) // Logan.Li
type RuntimeConfigUpdate struct {
Values map[string]string `json:"values"`
}
// RuntimeResetRequest 恢复默认(删除页面值,回退环境变量/默认) // Logan.Li
type RuntimeResetRequest struct {
Keys []string `json:"keys"`
}
// RuntimeOverview 配置中心总览 // Logan.Li
type RuntimeOverview struct {
Groups []RuntimeGroupView `json:"groups"`
// BootstrapKeys 必须留在 .env 的引导密钥键名(页面只读展示) // Logan.Li
BootstrapKeys []string `json:"bootstrap_keys"`
// PageEditableKeys 可在页面维护的键名(.env 里可以删掉) // Logan.Li
PageEditableKeys []string `json:"page_editable_keys"`
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "time"
// AppConfig 运行配置中心(键值表)。
//
// 设计要点:
// 1. 只存「页面可配置」的业务参数,启动引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY /
// OFFLINE_LICENSE_SEED)永远只从环境变量读取,不入库;
// 2. 机密项(webhook 密钥等)落库前经 secretbox 信封加密,明文只在内存中出现;
// 3. 读取优先级:本表 → 环境变量 → 注册表默认值,删除行即回退到环境变量。 // Logan.Li
type AppConfig struct {
Key string `json:"key" gorm:"primaryKey;size:100"`
Value string `json:"-" gorm:"type:text"` // 明文或 enc:v1: 密文,JSON 永不导出
IsSecret bool `json:"is_secret" gorm:"default:false"`
UpdatedBy string `json:"updated_by" gorm:"size:100"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名 // Logan.Li
func (AppConfig) TableName() string {
return "app_configs"
}
@@ -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]))
}
@@ -4,10 +4,10 @@ import (
"backend/config" "backend/config"
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
"backend/internal/service/appconfig"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
@@ -327,9 +327,9 @@ func (s *ConfigService) alipayPublicKey(row *model.PaymentConfig) (string, error
// ============ 通用工具 ============ // ============ 通用工具 ============
// publicBaseURL 对外访问基址(用于生成回调地址) // Logan.Li // publicBaseURL 对外访问基址(后台「运行配置」优先,其次环境变量,最后默认) // Logan.Li
func publicBaseURL() string { func publicBaseURL() string {
if v := strings.TrimSpace(os.Getenv(EnvPublicBaseURL)); v != "" { if v := strings.TrimSpace(appconfig.String(EnvPublicBaseURL)); v != "" {
return strings.TrimRight(v, "/") return strings.TrimRight(v, "/")
} }
return defaultPublicBaseURL return defaultPublicBaseURL
+17 -16
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"backend/internal/model" "backend/internal/model"
"backend/internal/service/appconfig"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -42,21 +43,21 @@ type WaffoProductMapping struct {
// WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li // WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li
type WaffoPublicMeta struct { type WaffoPublicMeta struct {
MerchantID string `json:"merchant_id"` MerchantID string `json:"merchant_id"`
StoreID string `json:"store_id"` StoreID string `json:"store_id"`
StoreSlug string `json:"store_slug"` StoreSlug string `json:"store_slug"`
ProductID string `json:"product_id"` ProductID string `json:"product_id"`
ProductIDYearly string `json:"product_id_yearly"` ProductIDYearly string `json:"product_id_yearly"`
ProductIDPerpetual string `json:"product_id_perpetual"` ProductIDPerpetual string `json:"product_id_perpetual"`
ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li
EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // Logan.Li EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // Logan.Li
SuccessURL string `json:"success_url"` SuccessURL string `json:"success_url"`
Currency string `json:"currency"` Currency string `json:"currency"`
CurrencyYearly string `json:"currency_yearly"` CurrencyYearly string `json:"currency_yearly"`
CurrencyPerpetual string `json:"currency_perpetual"` CurrencyPerpetual string `json:"currency_perpetual"`
Environment string `json:"environment"` Environment string `json:"environment"`
CheckoutURL string `json:"checkout_url"` CheckoutURL string `json:"checkout_url"`
} }
// DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li // DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li
@@ -85,7 +86,7 @@ func loadWaffoFromEnv() WaffoConfig {
if env == "" { if env == "" {
env = "test" env = "test"
} }
success := strings.TrimSpace(os.Getenv("SUCCESS_URL")) success := strings.TrimSpace(appconfig.String("SUCCESS_URL"))
if success == "" { if success == "" {
success = DefaultSuccessURL success = DefaultSuccessURL
} }
+11 -1
View File
@@ -3,8 +3,9 @@ package main
import ( import (
"backend/config" "backend/config"
"backend/database" "backend/database"
licenseservice "backend/internal/service/license"
"backend/internal/secretbox" "backend/internal/secretbox"
"backend/internal/service/appconfig"
licenseservice "backend/internal/service/license"
syncservice "backend/internal/service/sync" syncservice "backend/internal/service/sync"
"backend/router" "backend/router"
"context" "context"
@@ -36,6 +37,15 @@ func main() {
log.Fatal("机密字段迁移失败:", err) log.Fatal("机密字段迁移失败:", err)
} }
// 运行配置中心:DB → 环境变量 → 默认值(须在路由注册前装载) // Logan.Li
if err := appconfig.Init(db); err != nil {
log.Fatal("运行配置初始化失败:", err)
}
// 跨域白名单以页面配置为准(后台改动需重启生效) // Logan.Li
if origins := appconfig.List("CORS_ORIGINS"); len(origins) > 0 {
cfg.CORSOrigins = origins
}
// 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li // 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li
licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes) licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes)
+10 -4
View File
@@ -3,6 +3,7 @@ package router
import ( import (
"backend/config" "backend/config"
"backend/internal/controller/analytics" "backend/internal/controller/analytics"
appconfigctl "backend/internal/controller/appconfig"
"backend/internal/controller/auth" "backend/internal/controller/auth"
"backend/internal/controller/customer" "backend/internal/controller/customer"
"backend/internal/controller/entitlement" "backend/internal/controller/entitlement"
@@ -13,8 +14,8 @@ import (
"backend/internal/controller/product" "backend/internal/controller/product"
"backend/internal/controller/setting" "backend/internal/controller/setting"
synccontroller "backend/internal/controller/sync" synccontroller "backend/internal/controller/sync"
"backend/internal/service/appconfig"
"backend/middleware" "backend/middleware"
"os"
"strings" "strings"
"github.com/gin-contrib/gzip" "github.com/gin-contrib/gzip"
@@ -64,9 +65,9 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
r.RedirectFixedPath = false r.RedirectFixedPath = false
// 信任代理:默认不信任任何代理头(X-Forwarded-For 可伪造,会绕过按 IP 限流); // 信任代理:默认不信任任何代理头(X-Forwarded-For 可伪造,会绕过按 IP 限流);
// 经 nginx 反代的节点用 TRUSTED_PROXIES 指定其内网 CIDR,多个用逗号分隔 // Logan.Li // 经 nginx 反代的节点用「可信反代网段」指定其内网 CIDR(后台可配,重启生效) // Logan.Li
if proxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES")); proxies != "" { if proxies := appconfig.List("TRUSTED_PROXIES"); len(proxies) > 0 {
if err := r.SetTrustedProxies(strings.Split(proxies, ",")); err != nil { if err := r.SetTrustedProxies(proxies); err != nil {
panic("TRUSTED_PROXIES 配置非法: " + err.Error()) panic("TRUSTED_PROXIES 配置非法: " + err.Error())
} }
} else if err := r.SetTrustedProxies(nil); err != nil { } else if err := r.SetTrustedProxies(nil); err != nil {
@@ -108,6 +109,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
settingController := setting.NewSettingController(db) settingController := setting.NewSettingController(db)
syncController := synccontroller.NewController(db, cfg) syncController := synccontroller.NewController(db, cfg)
paymentConfigController := paymentctl.NewPaymentConfigController(db, cfg) paymentConfigController := paymentctl.NewPaymentConfigController(db, cfg)
appConfigController := appconfigctl.NewController(db)
api := r.Group("/api") api := r.Group("/api")
{ {
@@ -272,6 +274,10 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
register(system, "GET", "/waffo", settingController.GetWaffo) register(system, "GET", "/waffo", settingController.GetWaffo)
register(system, "PUT", "/waffo", settingController.UpdateWaffo) register(system, "PUT", "/waffo", settingController.UpdateWaffo)
register(system, "POST", "/waffo/test", settingController.DebugWaffo) register(system, "POST", "/waffo/test", settingController.DebugWaffo)
// 运行配置中心(统一在页面维护 .env 里的业务参数) // Logan.Li
register(system, "GET", "/runtime", appConfigController.Overview)
register(system, "PUT", "/runtime", appConfigController.Update)
register(system, "POST", "/runtime/reset", appConfigController.Reset)
} }
// 节点互联管理(双向授权同步) // Logan.Li // 节点互联管理(双向授权同步) // Logan.Li
+2 -2
View File
@@ -4,8 +4,8 @@ package utils
// 数据文件默认 /data/geoip/GeoLite2-City.mmdb(容器卷内),缺失时静默返回空串,不影响业务。 // Logan.Li // 数据文件默认 /data/geoip/GeoLite2-City.mmdb(容器卷内),缺失时静默返回空串,不影响业务。 // Logan.Li
import ( import (
"backend/internal/service/appconfig"
"net/netip" "net/netip"
"os"
"strings" "strings"
"sync" "sync"
@@ -27,7 +27,7 @@ func LookupIPLocation(value string) string {
return "内网" return "内网"
} }
geoIPReader.once.Do(func() { geoIPReader.once.Do(func() {
path := os.Getenv("GEOIP_DB_PATH") path := appconfig.String("GEOIP_DB_PATH")
if path == "" { if path == "" {
path = "/data/geoip/GeoLite2-City.mmdb" path = "/data/geoip/GeoLite2-City.mmdb"
} }
+18
View File
@@ -0,0 +1,18 @@
import request from './request'
// 运行配置中心 API(.env 里的业务参数统一到页面维护) // Logan.Li
// 读取全部配置(机密项只返回掩码 + 指纹)
export function getRuntimeConfig() {
return request({ url: '/settings/runtime', method: 'get' })
}
// 批量保存:values 为 { 配置键: 值 },空字符串表示清除并回退 .env
export function updateRuntimeConfig(values) {
return request({ url: '/settings/runtime', method: 'put', data: { values } })
}
// 恢复默认:删除页面值,回退 .env / 注册表默认
export function resetRuntimeConfig(keys) {
return request({ url: '/settings/runtime/reset', method: 'post', data: { keys } })
}
+3
View File
@@ -70,6 +70,9 @@
<el-menu-item index="/settings"> <el-menu-item index="/settings">
<el-icon><Setting /></el-icon>系统设置 <el-icon><Setting /></el-icon>系统设置
</el-menu-item> </el-menu-item>
<el-menu-item index="/settings/runtime">
<el-icon><SetUp /></el-icon>运行配置
</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/integration"> <el-menu-item index="/integration">
+6
View File
@@ -89,6 +89,12 @@ const routes = [
component: () => import('@/views/Settings.vue'), component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置', icon: 'Setting' }, meta: { title: '系统设置', icon: 'Setting' },
}, },
{
path: 'settings/runtime',
name: 'RuntimeConfig',
component: () => import('@/views/settings/Runtime.vue'),
meta: { title: '运行配置', icon: 'SetUp' },
},
], ],
}, },
{ {
+301
View File
@@ -0,0 +1,301 @@
<template>
<div class="runtime-page">
<!-- 说明与统计 -->
<el-card shadow="never" class="intro-card">
<template #header>
<div class="card-header">
<span class="title">运行配置</span>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
</template>
<el-alert type="info" :closable="false" show-icon>
<template #title>
<span class="alert-title">.env 只保留启动必需项,其余业务参数在本页维护</span>
</template>
<div class="alert-body">
读取优先级:<b>本页配置</b> → <b>.env</b> → <b>默认值</b>;「恢复默认」即删除本页值回退 .env。
登录令牌密钥、加密主密钥、离线签名种子等<b>引导密钥</b>必须留在 .env(数据库不可用前就要用到,
且入库等于把钥匙锁进被加密的箱子),本页仅只读展示指纹供核对。
</div>
</el-alert>
<div class="summary">
<div class="sum-item"><b>{{ stats.editable }}</b><span>可在页面配置</span></div>
<div class="sum-item"><b>{{ stats.envOnly }}</b><span>仅限 .env</span></div>
<div class="sum-item"><b>{{ stats.fromPage }}</b><span>当前取自本页</span></div>
<div class="sum-item"><b>{{ stats.restart }}</b><span>改动需重启</span></div>
</div>
</el-card>
<!-- 分组配置 -->
<el-card v-for="group in groups" :key="group.name" shadow="never" class="group-card">
<template #header>
<div class="card-header">
<span class="title">{{ group.name }}</span>
<el-tag v-if="groupEnvOnly(group)" size="small" type="info">仅 .env</el-tag>
<el-tag v-else size="small">{{ group.items.length }} 项</el-tag>
</div>
</template>
<el-table :data="group.items" size="small" class="cfg-table">
<el-table-column label="配置项" min-width="330">
<template #default="{ row }">
<div class="item-label">
{{ row.label }}
<el-tag v-if="row.danger" size="small" type="danger">高风险</el-tag>
</div>
<code class="item-key">{{ row.key }}</code>
<div class="item-desc">{{ row.description }}</div>
</template>
</el-table-column>
<el-table-column label="当前值" min-width="240">
<template #default="{ row }">
<span v-if="!row.configured" class="muted">未配置</span>
<el-tag v-else-if="row.kind === 'bool'" size="small" :type="row.value === '1' ? 'danger' : 'info'">
{{ row.value === '1' ? '已开启' : '已关闭' }}
</el-tag>
<code v-else class="val">{{ row.masked }}</code>
<div v-if="row.kind === 'secret' && row.fingerprint" class="hint">
指纹 {{ row.fingerprint }}
</div>
</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">
<el-tag size="small" :type="sourceMeta(row.source).type">
{{ sourceMeta(row.source).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="生效方式" width="110">
<template #default="{ row }">
<span :class="{ muted: !row.requires_restart }">
{{ row.requires_restart ? '需重启' : '立即生效' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="right">
<template #default="{ row }">
<el-tooltip
v-if="!row.editable"
:content="row.read_only_reason"
placement="top"
>
<span class="lock-hint"><el-icon><Lock /></el-icon>只读</span>
</el-tooltip>
<template v-else>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.source === 'page'"
link
type="warning"
@click="resetOne(row)"
>恢复默认</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="dialog.visible" :title="`编辑:${dialog.row?.label || ''}`" width="560px">
<template v-if="dialog.row">
<code class="item-key dialog-key">{{ dialog.row.key }}</code>
<p class="item-desc dialog-desc">{{ dialog.row.description }}</p>
<el-form label-width="96px" @submit.prevent>
<el-form-item v-if="dialog.row.kind === 'bool'" label="开关">
<el-switch
v-model="dialog.boolValue"
inline-prompt
active-text="开"
inactive-text="关"
/>
<div v-if="dialog.row.danger" class="hint danger">
高风险:开启后未验签的回调也会被放行,生产模式下保存会被拒绝。
</div>
</el-form-item>
<el-form-item v-else-if="dialog.row.kind === 'list'" label="取值">
<el-input
v-model="dialog.textValue"
type="textarea"
:rows="3"
:placeholder="dialog.row.placeholder"
/>
<div class="hint">多个值用英文逗号分隔</div>
</el-form-item>
<el-form-item v-else :label="dialog.row.kind === 'secret' ? '新值' : '取值'">
<el-input
v-model="dialog.textValue"
:type="dialog.row.kind === 'secret' ? 'password' : 'text'"
:show-password="dialog.row.kind === 'secret'"
:placeholder="dialog.row.kind === 'secret' ? '留空表示清除(回退 .env / 未配置)' : dialog.row.placeholder"
/>
<div v-if="dialog.row.kind === 'secret'" class="hint">
当前指纹 {{ dialog.row.fingerprint || '—' }},保存后不可查看明文
</div>
</el-form-item>
</el-form>
<el-alert
v-if="dialog.row.requires_restart"
type="warning"
:closable="false"
show-icon
title="该项改动需重启容器后生效"
/>
</template>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Lock } from '@element-plus/icons-vue'
import { getRuntimeConfig, updateRuntimeConfig, resetRuntimeConfig } from '@/api/runtime'
const loading = ref(false)
const saving = ref(false)
const groups = ref([])
const bootstrapKeys = ref([])
const dialog = reactive({
visible: false,
row: null,
textValue: '',
boolValue: false,
})
const sourceMap = {
page: { label: '本页配置', type: 'success' },
env: { label: '.env', type: 'info' },
default: { label: '默认值', type: 'info' },
missing: { label: '未配置', type: 'warning' },
}
function sourceMeta(source) {
return sourceMap[source] || { label: source || '—', type: 'info' }
}
function groupEnvOnly(group) {
return group.items.length > 0 && group.items.every((it) => !it.editable)
}
const stats = computed(() => {
const all = groups.value.flatMap((g) => g.items)
return {
editable: all.filter((it) => it.editable).length,
envOnly: all.filter((it) => !it.editable).length,
fromPage: all.filter((it) => it.source === 'page').length,
restart: all.filter((it) => it.requires_restart).length,
}
})
async function load() {
loading.value = true
try {
const res = await getRuntimeConfig()
groups.value = res.data?.groups || []
bootstrapKeys.value = res.data?.bootstrap_keys || []
} finally {
loading.value = false
}
}
function openEdit(row) {
dialog.row = row
dialog.textValue = row.kind === 'secret' ? '' : row.value || ''
dialog.boolValue = row.value === '1'
dialog.visible = true
}
async function save() {
const row = dialog.row
if (!row) return
let value
if (row.kind === 'bool') {
value = dialog.boolValue ? '1' : '0'
if (row.danger && dialog.boolValue) {
try {
await ElMessageBox.confirm(
'开启后任何未验签的支付回调都会被当作已付款处理,仅限本地调试。确认继续?',
'高风险操作',
{ type: 'warning', confirmButtonText: '确认开启', cancelButtonText: '取消' },
)
} catch {
return
}
}
} else {
value = (dialog.textValue || '').trim()
}
saving.value = true
try {
const res = await updateRuntimeConfig({ [row.key]: value })
applyOverview(res.data)
dialog.visible = false
ElMessage.success('已保存')
} finally {
saving.value = false
}
}
async function resetOne(row) {
try {
await ElMessageBox.confirm(
`将删除本页保存的「${row.label}」,回退到 .env 或默认值。确认恢复?`,
'恢复默认',
{ type: 'warning', confirmButtonText: '恢复', cancelButtonText: '取消' },
)
} catch {
return
}
const res = await resetRuntimeConfig([row.key])
applyOverview(res.data)
ElMessage.success('已恢复 .env / 默认值')
}
function applyOverview(data) {
if (!data) return
groups.value = data.groups || []
bootstrapKeys.value = data.bootstrap_keys || []
}
onMounted(load)
</script>
<style scoped>
.runtime-page { display: flex; flex-direction: column; gap: 16px; }
.card-header { display: flex; align-items: center; gap: 8px; }
.card-header .title { font-size: 15px; font-weight: 600; color: #303133; }
.card-header > :last-child { margin-left: auto; }
.alert-title { font-weight: 600; }
.alert-body { font-size: 12px; line-height: 1.7; color: #606266; }
.summary { display: flex; gap: 32px; margin-top: 16px; flex-wrap: wrap; }
.sum-item { display: flex; flex-direction: column; }
.sum-item b { font-size: 20px; color: #303133; }
.sum-item span { font-size: 12px; color: #909399; }
.item-label { font-size: 13px; font-weight: 600; color: #303133; display: flex; align-items: center; gap: 6px; }
.item-key { font-size: 11px; color: #909399; font-family: ui-monospace, Menlo, Consolas, monospace; }
.item-desc { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 2px; }
.dialog-desc { margin: 4px 0 16px; }
.dialog-key { display: block; margin-top: 4px; }
.val { font-size: 12px; color: #303133; word-break: break-all; }
.muted { color: #c0c4cc; font-size: 12px; }
.hint { font-size: 11px; color: #909399; line-height: 1.6; margin-top: 2px; }
.hint.danger { color: #e6a23c; }
.lock-hint { font-size: 12px; color: #c0c4cc; display: inline-flex; align-items: center; gap: 4px; }
.cfg-table :deep(.el-table__cell) { vertical-align: top; }
</style>