diff --git a/.jenkins/Dockerfile b/.jenkins/Dockerfile index 5beaefc..85c376f 100644 --- a/.jenkins/Dockerfile +++ b/.jenkins/Dockerfile @@ -59,7 +59,7 @@ RUN mkdir -p /opt/apps WORKDIR /opt/apps # 拷贝运行时资产(四件缺一不可,路径相对项目根) -# manifest/config 运行时配置(敏感项运行时经 GF_GENV_ 环境变量覆盖,不改镜像内文件) +# manifest/config 运行时配置(敏感项运行时经 GF_ 环境变量覆盖,不改镜像内文件) # manifest/data ip2region.xdb 归属地库 # resource 验证码字体、swagger 页面等静态资源 # templates 代码生成器模板(tool 域运行时读取) @@ -84,7 +84,7 @@ RUN set -eux; \ # 启动命令 # ${GOFILE} 为编译产物文件名(= 产品名) # 配置覆盖示例(部署阶段注入,勿写死镜像): -# -e GF_GENV_DATABASE_DEFAULT_LINK="mysql:<用户>:<密码>@tcp(<主机>:<端口>)/<库名>?charset=utf8mb4&parseTime=true&loc=Local" -# -e GF_GENV_REDIS_DEFAULT_ADDRESS="<主机>:<端口>" -# -e GF_GENV_FILE_UPLOADDIR="/data/upload" # 容器内必须覆盖(配置默认为开发机路径) +# -e GF_DATABASE_DEFAULT_LINK="mysql:<用户>:<密码>@tcp(<主机>:<端口>)/<库名>?charset=utf8mb4&parseTime=true&loc=Local" +# -e GF_REDIS_DEFAULT_ADDRESS="<主机>:<端口>" +# -e GF_FILE_UPLOADDIR="/data/upload" # 容器内必须覆盖(配置默认为开发机路径) CMD ["sh", "-c", "./${GOFILE}"] diff --git a/CLAUDE.md b/CLAUDE.md index 0da775d..296b95a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ GoFrame v2 后端已按 `document/architecture.md`(唯一权威定稿)完整 - **主键统一 int 数据库自增**(2026-10-13 定稿):禁用雪花算法(`utility/id` 已删除);新增后用 `result.LastInsertId()` 回读;hooks 不填 id - 软删列统一 `del_flag`;表前缀 `goframe_` +- **配置环境变量覆盖**(2026-10-20 定稿):`utility/config.RegisterEnvOverride()` 在 cmd.Run 最先接线 env-first 适配器(包装默认文件适配器,gcfg.Adapter 官方扩展点),12-Factor 优先级 GF_ 环境变量 > config.yaml;键名=配置路径大写下划线(`database.default.link` → `GF_DATABASE_DEFAULT_LINK`,**不存在 GF_GENV_ 前缀**);仅覆盖配置文件已有键,空值视为未设置;docker 部署经 compose environment 块把 .env 友好名(MYSQL_HOST 等)映射为 GF_ 规范键,勿原名透传 - 新增模块四件套平铺放置(api/v1、controller、logic、service 各一文件,同包 init 自动注册,无需 blank import);service 接口手维护;路由注册由生成器自动写入或手动 `router.go` `Bind(controller.CXxx)` - **定时任务处理方法在 `internal/task`**:调度器与注册表在 internal/logic 的 job 模块(`logic.RegisterJobHandler`);任务实现平铺 `internal/task/{任务名}.go`,init 中自注册(注册名=url 字段值,http(s) 地址除外),依赖单向 task→logic(任务可复用 base 共享能力,logic 禁止反向 import task);由 `internal/cmd` 空白导入触发加载;调度消费 execute_policy(1并行/2单例/3放弃)与 is_sync(1同步/0异步),策略2/3叠加 `job:lock:{id}` 执行锁(utility/cache SET NX,多实例共享,TTL 300s 兜底);内置 LogClean 任务每日 02:30 分批物理清理三张日志表(保留天数 `job.logRetentionDays` 默认90,任务参数 retentionDays 可覆盖) - **monitor 域三个无表模块**:在线用户(`/onlineUser/page`+`forceLogout`,登录 MarkOnline/登出 MarkOffline 写 `online:jti` zset+info 键,踢出=拉黑 jti)、服务器监控(`/server/info`:runtime/磁盘分平台文件 server_monitor_windows/other.go/MySQL/Redis 连通)、缓存监控(`/cache/info`:Redis INFO 解析,内存降级时返回 mode=memory);前端 `ui/src/views/monitor/{online,server,cache}`;OpenAPI 经 bind.go 补注册后 `/api.json` 全量可用 diff --git a/README.md b/README.md index 76286c9..e0fa085 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ EasyGoAdmin_GoFrame_EleVue/ │ └── ip2region/ response/ ua # IP 归属地 / 统一响应 / 设备识别 ├── templates/ # 代码生成器模板(后端 {{ }} 定界;ui* 前端 [[ ]] 定界) ├── manifest/ -│ ├── config/config.yaml # 运行时配置(唯一配置文件,GF_GENV_ 环境变量可覆盖) +│ ├── config/config.yaml # 运行时配置(唯一配置文件,GF_ 环境变量可覆盖) │ └── data/ip2region.xdb # IP 归属地库 ├── resource/ │ ├── static/ # 验证码字体等静态资源 diff --git a/docker/.env.example b/docker/.env.example index 1bedcbd..a5c9252 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -2,6 +2,11 @@ # Docker Compose 环境变量模板 # 用法:复制为同目录 .env(同目录被 .gitignore 与 .dockerignore 双重排除) # copy .env.example .env +# 说明:本文件使用 MYSQL_HOST 等友好名,由 docker-compose.yml 的 +# environment 块映射为 GF_ 规范键(如 GF_DATABASE_DEFAULT_LINK) +# 注入容器——应用只认 GF_ 前缀键,勿将友好名原样透传给应用 +# 注意:值中含 $ 须写成 $$(compose 变量插值会吞掉单 $, +# 如密码 abc#$^ 应写为 abc#$$^) # ============================================================ # ===== 应用 ===== diff --git a/docker/Dockerfile b/docker/Dockerfile index be94851..285dc5f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,7 +8,7 @@ # - builder 只负责编译,runtime 仅含二进制 + 运行时资产,镜像最小化 # - 运行时资产四件:manifest/config、manifest/data(ip2region.xdb)、 # resource(字体/swagger)、templates(代码生成器模板) -# - 敏感配置不改镜像内文件,运行时用 GF_GENV_ 前缀环境变量覆盖 +# - 敏感配置不改镜像内文件,运行时用 GF_ 前缀环境变量覆盖 # (见 manifest/config/config.yaml 头部说明) # - 源码 COPY 放在依赖下载之后,保护 go mod 层缓存 # diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1caf678..1789362 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,17 +21,17 @@ services: restart: always ports: - "${APP_PORT:-8000}:8000" - # 敏感/环境相关配置一律 GF_GENV_ 前缀覆盖,不改镜像内 config.yaml - # (GF_GENV_ 键名 = 配置路径大写下划线,如 file.uploadDir → GF_GENV_FILE_UPLOADDIR) + # 敏感/环境相关配置一律 GF_ 前缀覆盖,不改镜像内 config.yaml + # (GF_ 键名 = 配置路径大写下划线,如 file.uploadDir → GF_FILE_UPLOADDIR) environment: TZ: Asia/Shanghai - GF_GENV_DATABASE_DEFAULT_LINK: "mysql:${MYSQL_USER:-root}:${MYSQL_PASSWORD:-root}@tcp(${MYSQL_HOST:-mysql}:${MYSQL_HOST_PORT:-3306})/${MYSQL_DATABASE:-easygoadmin.goframe.elevue}?charset=utf8mb4&parseTime=true&loc=Local" - GF_GENV_REDIS_DEFAULT_ADDRESS: "${REDIS_HOST:-redis}:${REDIS_HOST_PORT:-6379}" - GF_GENV_REDIS_DEFAULT_PASS: "${REDIS_PASSWORD:-123456}" - GF_GENV_FILE_UPLOADDIR: "/data/upload" - GF_GENV_JWT_SECRET: "${JWT_SECRET:-please-change-this-jwt-secret}" - GF_GENV_CAPTCHA_BYPASSCODE: "" # 生产环境清空万能验证码 - GF_GENV_APP_ERRORMSG: "false" # 生产环境关闭详细错误信息 + GF_DATABASE_DEFAULT_LINK: "mysql:${MYSQL_USER:-root}:${MYSQL_PASSWORD:-root}@tcp(${MYSQL_HOST:-mysql}:${MYSQL_HOST_PORT:-3306})/${MYSQL_DATABASE:-easygoadmin.goframe.elevue}?charset=utf8mb4&parseTime=true&loc=Local" + GF_REDIS_DEFAULT_ADDRESS: "${REDIS_HOST:-redis}:${REDIS_HOST_PORT:-6379}" + GF_REDIS_DEFAULT_PASS: "${REDIS_PASSWORD:-123456}" + GF_FILE_UPLOADDIR: "/data/upload" + GF_JWT_SECRET: "${JWT_SECRET:-please-change-this-jwt-secret}" + GF_CAPTCHA_BYPASSCODE: "" # 生产环境清空万能验证码 + GF_APP_ERRORMSG: "false" # 生产环境关闭详细错误信息 volumes: # 上传文件持久化(两段式上传的 temp 与正式目录都在 uploadDir 下) - ../upload:/data/upload diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 1847608..43a45a1 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -51,6 +51,7 @@ import ( "easygoadmin/internal/logic" "easygoadmin/internal/router" "easygoadmin/utility/cache" + "easygoadmin/utility/config" // 空白导入:触发定时任务处理器 init 自注册 _ "easygoadmin/internal/task" @@ -64,6 +65,10 @@ import ( // 3. 注册中间件链与全部路由 // 4. 阻塞运行 HTTP 服务 func Run(ctx context.Context) { + // 配置环境变量覆盖接线(GF_ 前缀环境变量 > 配置文件,12-Factor 优先级) + // 须最先执行:缓存/数据库/Redis 初始化都依赖配置读取 + config.RegisterEnvOverride() + // 缓存初始化(JWT黑名单/验证码/字典/身份缓存) cache.Init(ctx) diff --git a/manifest/config/config.yaml b/manifest/config/config.yaml index 668e7f1..6d31dd6 100644 --- a/manifest/config/config.yaml +++ b/manifest/config/config.yaml @@ -1,6 +1,10 @@ # ====================================================================== # EasyGoAdmin 运行时配置 -# 环境变量覆盖规则:使用 GF_GENV_ 前缀,例如 GF_GENV_DATABASE_DEFAULT_LINK +# 环境变量覆盖规则(12-Factor:环境变量 > 配置文件): +# 键名 = 配置路径大写、点换下划线并加 GF_ 前缀, +# 例如 database.default.link → GF_DATABASE_DEFAULT_LINK; +# 覆盖经 utility/config 在启动时接线(RegisterEnvOverride), +# 仅覆盖本文件中已存在的键,空值视为未设置 # ====================================================================== # ---------------------------------------------------------------------------- diff --git a/utility/config/config.go b/utility/config/config.go new file mode 100644 index 0000000..cdc9392 --- /dev/null +++ b/utility/config/config.go @@ -0,0 +1,97 @@ +// Package config 提供配置读取的环境变量覆盖接线 +// +// 覆盖规则遵循 12-Factor 标准优先级:GF_ 前缀环境变量 > 配置文件。 +// 键名规则:配置路径大写、点换下划线并加 GF_ 前缀 +// (database.default.link → GF_DATABASE_DEFAULT_LINK)。 +// 实现方式为包装默认文件适配器(gcfg.Adapter 是框架官方扩展点), +// 注册后全部 Get/MustGet 读取点零改动地获得覆盖能力。 +package config + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/genv" +) + +// adapterEnvFirst 环境变量优先的配置适配器 +// +// 仅覆盖配置文件中已存在的键:环境变量名由配置路径正向推导(无歧义), +// 而反向拆分下划线无法还原 uploadDir 这类驼峰键名,故不做补键注入。 +// 文件返回的 map 会被就地叠加环境变量值,进程内环境恒定,重复叠加幂等。 +type adapterEnvFirst struct { + base gcfg.Adapter // 默认文件适配器,环境未命中时回落 +} + +// RegisterEnvOverride 注册环境变量优先的配置适配器(包装并替换默认文件适配器) +// +// 须在首次读取配置之前调用(服务启动入口最前,含缓存/数据库/Redis 初始化); +// 未设置任何 GF_ 环境变量时行为与默认文件适配器完全一致。 +func RegisterEnvOverride() { + gcfg.Instance().SetAdapter(&adapterEnvFirst{base: gcfg.Instance().GetAdapter()}) +} + +// Available 委托文件适配器 +func (a *adapterEnvFirst) Available(ctx context.Context, resource ...string) bool { + return a.base.Available(ctx, resource...) +} + +// Get 先查 GF_ 前缀环境变量,未命中回落文件适配器; +// 文件返回 map(如 database 整节点)时对已有键逐叶叠加环境变量覆盖 +func (a *adapterEnvFirst) Get(ctx context.Context, pattern string) (any, error) { + if v := envValue(pattern); v != nil { + return v, nil + } + value, err := a.base.Get(ctx, pattern) + if err != nil || value == nil { + return value, err + } + if m, ok := value.(map[string]any); ok { + overlayMap(m, pattern) + } + return value, nil +} + +// Data 返回全量配置并叠加环境变量覆盖 +func (a *adapterEnvFirst) Data(ctx context.Context) (map[string]any, error) { + data, err := a.base.Data(ctx) + if err != nil || data == nil { + return data, err + } + overlayMap(data, "") + return data, nil +} + +// envValue 按键路径取 GF_ 前缀环境变量;未设置或空值返回 nil(空串视为未设置, +// 避免容器编排注入的空占位把配置文件中的有效值清空) +func envValue(pattern string) any { + if pattern == "" || pattern == "." { + return nil + } + v := genv.Get("GF_" + strings.ToUpper(strings.ReplaceAll(pattern, ".", "_"))) + if v == nil || v.String() == "" { + return nil + } + return v.Val() +} + +// overlayMap 对 map 中已存在的键递归叠加环境变量覆盖 +// +// prefix 为当前层级的配置路径(顶层为空);仅处理标量覆盖, +// 切片/数组不支持环境变量替换(本项目可覆盖键均为标量) +func overlayMap(m map[string]any, prefix string) { + for k, val := range m { + path := k + if prefix != "" && prefix != "." { + path = prefix + "." + k + } + if env := envValue(path); env != nil { + m[k] = env + continue + } + if child, ok := val.(map[string]any); ok { + overlayMap(child, path) + } + } +} diff --git a/utility/config/config_test.go b/utility/config/config_test.go new file mode 100644 index 0000000..612cc42 --- /dev/null +++ b/utility/config/config_test.go @@ -0,0 +1,125 @@ +package config + +import ( + "context" + "os" + "testing" + + "github.com/gogf/gf/v2/os/gcfg" +) + +// fakeBase 模拟文件适配器,返回固定配置树 +type fakeBase struct { + data map[string]any +} + +func (f *fakeBase) Available(ctx context.Context, resource ...string) bool { return true } + +func (f *fakeBase) Get(ctx context.Context, pattern string) (any, error) { + if pattern == "" || pattern == "." { + return f.data, nil + } + var cur any = f.data + for _, part := range splitPattern(pattern) { + m, ok := cur.(map[string]any) + if !ok { + return nil, nil + } + cur = m[part] + } + return cur, nil +} + +func (f *fakeBase) Data(ctx context.Context) (map[string]any, error) { return f.data, nil } + +func splitPattern(pattern string) []string { + var parts []string + start := 0 + for i := 0; i < len(pattern); i++ { + if pattern[i] == '.' { + parts = append(parts, pattern[start:i]) + start = i + 1 + } + } + return append(parts, pattern[start:]) +} + +// TestAdapterEnvFirst 覆盖优先级核心行为: +// - 设置 GF_ 环境变量 → 覆盖文件值(含 map 整节点逐叶叠加) +// - 未设置 → 回落文件值 +// - 空值环境变量 → 视为未设置,不清空文件值 +func TestAdapterEnvFirst(t *testing.T) { + ctx := context.Background() + base := &fakeBase{data: map[string]any{ + "database": map[string]any{ + "default": map[string]any{ + "type": "mysql", + "link": "file-link", + }, + }, + "jwt": map[string]any{"secret": "file-secret"}, + }} + a := &adapterEnvFirst{base: base} + + // 1. 未设置环境变量:文件值原样返回 + if v, _ := a.Get(ctx, "database.default.link"); v != "file-link" { + t.Fatalf("未设环境变量时应返回文件值,得到: %v", v) + } + + // 2. 标量覆盖 + os.Setenv("GF_JWT_SECRET", "env-secret") + defer os.Unsetenv("GF_JWT_SECRET") + if v, _ := a.Get(ctx, "jwt.secret"); v != "env-secret" { + t.Fatalf("环境变量应覆盖文件值,得到: %v", v) + } + + // 3. map 整节点逐叶叠加(gdb 读 database 整节点的场景) + os.Setenv("GF_DATABASE_DEFAULT_LINK", "env-link") + defer os.Unsetenv("GF_DATABASE_DEFAULT_LINK") + v, _ := a.Get(ctx, "database") + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("database 节点应返回 map,得到: %T", v) + } + def := m["default"].(map[string]any) + if def["link"] != "env-link" { + t.Fatalf("整节点读取时 link 应被环境变量覆盖,得到: %v", def["link"]) + } + if def["type"] != "mysql" { + t.Fatalf("未覆盖的键应保持文件值,得到: %v", def["type"]) + } + + // 4. 空值环境变量视为未设置(不清空文件值) + os.Setenv("GF_DATABASE_DEFAULT_TYPE", "") + defer os.Unsetenv("GF_DATABASE_DEFAULT_TYPE") + if v, _ := a.Get(ctx, "database.default.type"); v != "mysql" { + t.Fatalf("空环境变量应视为未设置,得到: %v", v) + } + + // 5. Data 全量叠加 + data, _ := a.Data(ctx) + defData := data["database"].(map[string]any)["default"].(map[string]any) + if defData["link"] != "env-link" { + t.Fatalf("Data 应叠加环境变量覆盖,得到: %v", defData["link"]) + } +} + +// TestRegisterEnvOverride 注册后 g.Cfg() 读取链路获得覆盖能力 +func TestRegisterEnvOverride(t *testing.T) { + adapter, err := gcfg.NewAdapterContent(`database: + default: + link: file-link +`) + if err != nil { + t.Fatalf("构造内容适配器失败: %v", err) + } + gcfg.Instance().SetAdapter(adapter) + RegisterEnvOverride() + ctx := context.Background() + + os.Setenv("GF_DATABASE_DEFAULT_LINK", "env-link") + defer os.Unsetenv("GF_DATABASE_DEFAULT_LINK") + if v := gcfg.Instance().MustGet(ctx, "database.default.link").String(); v != "env-link" { + t.Fatalf("注册后 g.Cfg 读取应获得环境变量覆盖,得到: %v", v) + } +}