From 83a3630404ac6ca99aa78bfde8581bda05b6e984 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Sun, 1 Nov 2026 06:17:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E8=BF=90=E8=A1=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E5=BF=83=E2=80=94=E2=80=94.env=20=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=8F=82=E6=95=B0=E7=BB=9F=E4=B8=80=E5=88=B0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E9=A1=B5=E9=9D=A2=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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、哪些已收敛到页面 --- .env.example | 22 + backend/database/database.go | 11 +- .../appconfig/appconfig_controller.go | 56 +++ .../controller/order/order_controller.go | 5 +- backend/internal/dto/runtime_config.go | 51 ++ backend/internal/model/app_config.go | 23 + .../internal/service/appconfig/registry.go | 229 +++++++++ backend/internal/service/appconfig/service.go | 443 ++++++++++++++++++ .../internal/service/payment/config_store.go | 6 +- backend/internal/service/payment/waffo.go | 33 +- backend/main.go | 12 +- backend/router/router.go | 14 +- backend/utils/geoip.go | 4 +- frontend/src/api/runtime.js | 18 + frontend/src/layouts/MainLayout.vue | 3 + frontend/src/router/index.js | 6 + frontend/src/views/settings/Runtime.vue | 301 ++++++++++++ 17 files changed, 1204 insertions(+), 33 deletions(-) create mode 100644 backend/internal/controller/appconfig/appconfig_controller.go create mode 100644 backend/internal/dto/runtime_config.go create mode 100644 backend/internal/model/app_config.go create mode 100644 backend/internal/service/appconfig/registry.go create mode 100644 backend/internal/service/appconfig/service.go create mode 100644 frontend/src/api/runtime.js create mode 100644 frontend/src/views/settings/Runtime.vue diff --git a/.env.example b/.env.example index 337f1e9..45b58b5 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,7 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success # 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs # PAYMENT_CERT_DIR=/data/payment-certs # 对外基址(用于生成微信/支付宝回调地址展示) +# 已收敛到后台「系统设置 → 运行配置」,此处仅作兜底(页面值优先) # PAYMENT_PUBLIC_BASE_URL=https://licence.rlwj.cn # 宿主机映射端口(容器内固定 9001) HOST_PORT=9001 @@ -131,3 +132,24 @@ VITE_API_BASE_URL=http://localhost:9001/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 容器内证书目录(数据卷路径) diff --git a/backend/database/database.go b/backend/database/database.go index c5d7d1c..81159a2 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -62,12 +62,13 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) { &model.LicenseTemplate{}, // License 模板 // Logan.Li &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li &model.PaymentConfig{}, // 支付配置(微信/支付宝,敏感字段 AES-GCM 加密) // Logan.Li + &model.AppConfig{}, // 运行配置中心(页面可配参数,机密项加密) // Logan.Li // 核心授权域 - &model.Product{}, // 产品 // Logan.Li - &model.Customer{}, // 客户 // Logan.Li - &model.CustomerProduct{}, // 客户-产品关联 // Logan.Li - &model.License{}, // License // Logan.Li - &model.Activation{}, // 激活记录 // Logan.Li + &model.Product{}, // 产品 // Logan.Li + &model.Customer{}, // 客户 // Logan.Li + &model.CustomerProduct{}, // 客户-产品关联 // Logan.Li + &model.License{}, // License // Logan.Li + &model.Activation{}, // 激活记录 // Logan.Li // 定价与订单 &model.Plan{}, // 定价计划 // Logan.Li &model.Order{}, // 订单 // Logan.Li diff --git a/backend/internal/controller/appconfig/appconfig_controller.go b/backend/internal/controller/appconfig/appconfig_controller.go new file mode 100644 index 0000000..cbf6266 --- /dev/null +++ b/backend/internal/controller/appconfig/appconfig_controller.go @@ -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}) +} diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index ebd892f..495d1b7 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -3,6 +3,7 @@ package order import ( "backend/internal/dto" "backend/internal/model" + "backend/internal/service/appconfig" orderservice "backend/internal/service/order" "backend/internal/service/payment" "backend/utils" @@ -107,7 +108,7 @@ func (ctrl *OrderController) ClaimPurchase(c *gin.Context) { // PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { - secret := strings.TrimSpace(os.Getenv("INTERNAL_WEBHOOK_SECRET")) + secret := appconfig.String("INTERNAL_WEBHOOK_SECRET") if secret == "" { var gw model.PaymentGatewayConfig 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 == "" { // fail-closed:未配置密钥一律拒绝;仅当显式声明 ALLOW_UNVERIFIED_WEBHOOK=1 // (本地调试)才放行,防止调试配置带进生产 // 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 密钥,已拒绝"}) return } diff --git a/backend/internal/dto/runtime_config.go b/backend/internal/dto/runtime_config.go new file mode 100644 index 0000000..bab375e --- /dev/null +++ b/backend/internal/dto/runtime_config.go @@ -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"` +} diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go new file mode 100644 index 0000000..7f8f5e3 --- /dev/null +++ b/backend/internal/model/app_config.go @@ -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" +} diff --git a/backend/internal/service/appconfig/registry.go b/backend/internal/service/appconfig/registry.go new file mode 100644 index 0000000..f362e3e --- /dev/null +++ b/backend/internal/service/appconfig/registry.go @@ -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 +} diff --git a/backend/internal/service/appconfig/service.go b/backend/internal/service/appconfig/service.go new file mode 100644 index 0000000..0fa5957 --- /dev/null +++ b/backend/internal/service/appconfig/service.go @@ -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])) +} diff --git a/backend/internal/service/payment/config_store.go b/backend/internal/service/payment/config_store.go index bc85042..ce53633 100644 --- a/backend/internal/service/payment/config_store.go +++ b/backend/internal/service/payment/config_store.go @@ -4,10 +4,10 @@ import ( "backend/config" "backend/internal/dto" "backend/internal/model" + "backend/internal/service/appconfig" "errors" "fmt" "net/url" - "os" "strconv" "strings" @@ -327,9 +327,9 @@ func (s *ConfigService) alipayPublicKey(row *model.PaymentConfig) (string, error // ============ 通用工具 ============ -// publicBaseURL 对外访问基址(用于生成回调地址) // Logan.Li +// publicBaseURL 对外访问基址(后台「运行配置」优先,其次环境变量,最后默认) // Logan.Li func publicBaseURL() string { - if v := strings.TrimSpace(os.Getenv(EnvPublicBaseURL)); v != "" { + if v := strings.TrimSpace(appconfig.String(EnvPublicBaseURL)); v != "" { return strings.TrimRight(v, "/") } return defaultPublicBaseURL diff --git a/backend/internal/service/payment/waffo.go b/backend/internal/service/payment/waffo.go index 23c4845..ab7b8f1 100644 --- a/backend/internal/service/payment/waffo.go +++ b/backend/internal/service/payment/waffo.go @@ -8,6 +8,7 @@ import ( "strings" "backend/internal/model" + "backend/internal/service/appconfig" "gorm.io/gorm" ) @@ -42,21 +43,21 @@ type WaffoProductMapping struct { // WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li type WaffoPublicMeta struct { - MerchantID string `json:"merchant_id"` - StoreID string `json:"store_id"` - StoreSlug string `json:"store_slug"` - ProductID string `json:"product_id"` - ProductIDYearly string `json:"product_id_yearly"` - ProductIDPerpetual string `json:"product_id_perpetual"` - ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li - ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li - EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // Logan.Li - SuccessURL string `json:"success_url"` - Currency string `json:"currency"` - CurrencyYearly string `json:"currency_yearly"` - CurrencyPerpetual string `json:"currency_perpetual"` - Environment string `json:"environment"` - CheckoutURL string `json:"checkout_url"` + MerchantID string `json:"merchant_id"` + StoreID string `json:"store_id"` + StoreSlug string `json:"store_slug"` + ProductID string `json:"product_id"` + ProductIDYearly string `json:"product_id_yearly"` + ProductIDPerpetual string `json:"product_id_perpetual"` + ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li + ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li + EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // Logan.Li + SuccessURL string `json:"success_url"` + Currency string `json:"currency"` + CurrencyYearly string `json:"currency_yearly"` + CurrencyPerpetual string `json:"currency_perpetual"` + Environment string `json:"environment"` + CheckoutURL string `json:"checkout_url"` } // DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li @@ -85,7 +86,7 @@ func loadWaffoFromEnv() WaffoConfig { if env == "" { env = "test" } - success := strings.TrimSpace(os.Getenv("SUCCESS_URL")) + success := strings.TrimSpace(appconfig.String("SUCCESS_URL")) if success == "" { success = DefaultSuccessURL } diff --git a/backend/main.go b/backend/main.go index 7884640..07278c6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,8 +3,9 @@ package main import ( "backend/config" "backend/database" - licenseservice "backend/internal/service/license" "backend/internal/secretbox" + "backend/internal/service/appconfig" + licenseservice "backend/internal/service/license" syncservice "backend/internal/service/sync" "backend/router" "context" @@ -36,6 +37,15 @@ func main() { 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 licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes) diff --git a/backend/router/router.go b/backend/router/router.go index d1c3c53..9098697 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -3,6 +3,7 @@ package router import ( "backend/config" "backend/internal/controller/analytics" + appconfigctl "backend/internal/controller/appconfig" "backend/internal/controller/auth" "backend/internal/controller/customer" "backend/internal/controller/entitlement" @@ -13,8 +14,8 @@ import ( "backend/internal/controller/product" "backend/internal/controller/setting" synccontroller "backend/internal/controller/sync" + "backend/internal/service/appconfig" "backend/middleware" - "os" "strings" "github.com/gin-contrib/gzip" @@ -64,9 +65,9 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { r.RedirectFixedPath = false // 信任代理:默认不信任任何代理头(X-Forwarded-For 可伪造,会绕过按 IP 限流); - // 经 nginx 反代的节点用 TRUSTED_PROXIES 指定其内网 CIDR,多个用逗号分隔 // Logan.Li - if proxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES")); proxies != "" { - if err := r.SetTrustedProxies(strings.Split(proxies, ",")); err != nil { + // 经 nginx 反代的节点用「可信反代网段」指定其内网 CIDR(后台可配,重启生效) // Logan.Li + if proxies := appconfig.List("TRUSTED_PROXIES"); len(proxies) > 0 { + if err := r.SetTrustedProxies(proxies); err != nil { panic("TRUSTED_PROXIES 配置非法: " + err.Error()) } } 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) syncController := synccontroller.NewController(db, cfg) paymentConfigController := paymentctl.NewPaymentConfigController(db, cfg) + appConfigController := appconfigctl.NewController(db) 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, "PUT", "/waffo", settingController.UpdateWaffo) 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 diff --git a/backend/utils/geoip.go b/backend/utils/geoip.go index e1a1b38..ddb041c 100644 --- a/backend/utils/geoip.go +++ b/backend/utils/geoip.go @@ -4,8 +4,8 @@ package utils // 数据文件默认 /data/geoip/GeoLite2-City.mmdb(容器卷内),缺失时静默返回空串,不影响业务。 // Logan.Li import ( + "backend/internal/service/appconfig" "net/netip" - "os" "strings" "sync" @@ -27,7 +27,7 @@ func LookupIPLocation(value string) string { return "内网" } geoIPReader.once.Do(func() { - path := os.Getenv("GEOIP_DB_PATH") + path := appconfig.String("GEOIP_DB_PATH") if path == "" { path = "/data/geoip/GeoLite2-City.mmdb" } diff --git a/frontend/src/api/runtime.js b/frontend/src/api/runtime.js new file mode 100644 index 0000000..aa3552d --- /dev/null +++ b/frontend/src/api/runtime.js @@ -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 } }) +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 8029326..34beb94 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -70,6 +70,9 @@ 系统设置 + + 运行配置 + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e6322d6..2f9e003 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -89,6 +89,12 @@ const routes = [ component: () => import('@/views/Settings.vue'), meta: { title: '系统设置', icon: 'Setting' }, }, + { + path: 'settings/runtime', + name: 'RuntimeConfig', + component: () => import('@/views/settings/Runtime.vue'), + meta: { title: '运行配置', icon: 'SetUp' }, + }, ], }, { diff --git a/frontend/src/views/settings/Runtime.vue b/frontend/src/views/settings/Runtime.vue new file mode 100644 index 0000000..2415ddb --- /dev/null +++ b/frontend/src/views/settings/Runtime.vue @@ -0,0 +1,301 @@ + + + + +