签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 16:06:40 +08:00
parent cda803f19b
commit 5e034a6e64
10 changed files with 253 additions and 16 deletions
+4 -4
View File
@@ -59,7 +59,7 @@ RUN mkdir -p /opt/apps
WORKDIR /opt/apps WORKDIR /opt/apps
# 拷贝运行时资产(四件缺一不可,路径相对项目根) # 拷贝运行时资产(四件缺一不可,路径相对项目根)
# manifest/config 运行时配置(敏感项运行时经 GF_GENV_ 环境变量覆盖,不改镜像内文件) # manifest/config 运行时配置(敏感项运行时经 GF_ 环境变量覆盖,不改镜像内文件)
# manifest/data ip2region.xdb 归属地库 # manifest/data ip2region.xdb 归属地库
# resource 验证码字体、swagger 页面等静态资源 # resource 验证码字体、swagger 页面等静态资源
# templates 代码生成器模板(tool 域运行时读取) # templates 代码生成器模板(tool 域运行时读取)
@@ -84,7 +84,7 @@ RUN set -eux; \
# 启动命令 # 启动命令
# ${GOFILE} 为编译产物文件名(= 产品名) # ${GOFILE} 为编译产物文件名(= 产品名)
# 配置覆盖示例(部署阶段注入,勿写死镜像): # 配置覆盖示例(部署阶段注入,勿写死镜像):
# -e GF_GENV_DATABASE_DEFAULT_LINK="mysql:<用户>:<密码>@tcp(<主机>:<端口>)/<库名>?charset=utf8mb4&parseTime=true&loc=Local" # -e GF_DATABASE_DEFAULT_LINK="mysql:<用户>:<密码>@tcp(<主机>:<端口>)/<库名>?charset=utf8mb4&parseTime=true&loc=Local"
# -e GF_GENV_REDIS_DEFAULT_ADDRESS="<主机>:<端口>" # -e GF_REDIS_DEFAULT_ADDRESS="<主机>:<端口>"
# -e GF_GENV_FILE_UPLOADDIR="/data/upload" # 容器内必须覆盖(配置默认为开发机路径) # -e GF_FILE_UPLOADDIR="/data/upload" # 容器内必须覆盖(配置默认为开发机路径)
CMD ["sh", "-c", "./${GOFILE}"] CMD ["sh", "-c", "./${GOFILE}"]
+1
View File
@@ -16,6 +16,7 @@ GoFrame v2 后端已按 `document/architecture.md`(唯一权威定稿)完整
- **主键统一 int 数据库自增**(2026-10-13 定稿):禁用雪花算法(`utility/id` 已删除);新增后用 `result.LastInsertId()` 回读;hooks 不填 id - **主键统一 int 数据库自增**(2026-10-13 定稿):禁用雪花算法(`utility/id` 已删除);新增后用 `result.LastInsertId()` 回读;hooks 不填 id
- 软删列统一 `del_flag`;表前缀 `goframe_` - 软删列统一 `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)` - 新增模块四件套平铺放置(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 可覆盖) - **定时任务处理方法在 `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` 全量可用 - **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` 全量可用
+1 -1
View File
@@ -105,7 +105,7 @@ EasyGoAdmin_GoFrame_EleVue/
│ └── ip2region/ response/ ua # IP 归属地 / 统一响应 / 设备识别 │ └── ip2region/ response/ ua # IP 归属地 / 统一响应 / 设备识别
├── templates/ # 代码生成器模板(后端 {{ }} 定界;ui* 前端 [[ ]] 定界) ├── templates/ # 代码生成器模板(后端 {{ }} 定界;ui* 前端 [[ ]] 定界)
├── manifest/ ├── manifest/
│ ├── config/config.yaml # 运行时配置(唯一配置文件,GF_GENV_ 环境变量可覆盖) │ ├── config/config.yaml # 运行时配置(唯一配置文件,GF_ 环境变量可覆盖)
│ └── data/ip2region.xdb # IP 归属地库 │ └── data/ip2region.xdb # IP 归属地库
├── resource/ ├── resource/
│ ├── static/ # 验证码字体等静态资源 │ ├── static/ # 验证码字体等静态资源
+5
View File
@@ -2,6 +2,11 @@
# Docker Compose 环境变量模板 # Docker Compose 环境变量模板
# 用法:复制为同目录 .env(同目录被 .gitignore 与 .dockerignore 双重排除) # 用法:复制为同目录 .env(同目录被 .gitignore 与 .dockerignore 双重排除)
# copy .env.example .env # copy .env.example .env
# 说明:本文件使用 MYSQL_HOST 等友好名,由 docker-compose.yml 的
# environment 块映射为 GF_ 规范键(如 GF_DATABASE_DEFAULT_LINK)
# 注入容器——应用只认 GF_ 前缀键,勿将友好名原样透传给应用
# 注意:值中含 $ 须写成 $$(compose 变量插值会吞掉单 $,
# 如密码 abc#$^ 应写为 abc#$$^)
# ============================================================ # ============================================================
# ===== 应用 ===== # ===== 应用 =====
+1 -1
View File
@@ -8,7 +8,7 @@
# - builder 只负责编译,runtime 仅含二进制 + 运行时资产,镜像最小化 # - builder 只负责编译,runtime 仅含二进制 + 运行时资产,镜像最小化
# - 运行时资产四件:manifest/config、manifest/data(ip2region.xdb)、 # - 运行时资产四件:manifest/config、manifest/data(ip2region.xdb)、
# resource(字体/swagger)、templates(代码生成器模板) # resource(字体/swagger)、templates(代码生成器模板)
# - 敏感配置不改镜像内文件,运行时用 GF_GENV_ 前缀环境变量覆盖 # - 敏感配置不改镜像内文件,运行时用 GF_ 前缀环境变量覆盖
# (见 manifest/config/config.yaml 头部说明) # (见 manifest/config/config.yaml 头部说明)
# - 源码 COPY 放在依赖下载之后,保护 go mod 层缓存 # - 源码 COPY 放在依赖下载之后,保护 go mod 层缓存
# #
+9 -9
View File
@@ -21,17 +21,17 @@ services:
restart: always restart: always
ports: ports:
- "${APP_PORT:-8000}:8000" - "${APP_PORT:-8000}:8000"
# 敏感/环境相关配置一律 GF_GENV_ 前缀覆盖,不改镜像内 config.yaml # 敏感/环境相关配置一律 GF_ 前缀覆盖,不改镜像内 config.yaml
# (GF_GENV_ 键名 = 配置路径大写下划线,如 file.uploadDir → GF_GENV_FILE_UPLOADDIR) # (GF_ 键名 = 配置路径大写下划线,如 file.uploadDir → GF_FILE_UPLOADDIR)
environment: environment:
TZ: Asia/Shanghai 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_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_REDIS_DEFAULT_ADDRESS: "${REDIS_HOST:-redis}:${REDIS_HOST_PORT:-6379}"
GF_GENV_REDIS_DEFAULT_PASS: "${REDIS_PASSWORD:-123456}" GF_REDIS_DEFAULT_PASS: "${REDIS_PASSWORD:-123456}"
GF_GENV_FILE_UPLOADDIR: "/data/upload" GF_FILE_UPLOADDIR: "/data/upload"
GF_GENV_JWT_SECRET: "${JWT_SECRET:-please-change-this-jwt-secret}" GF_JWT_SECRET: "${JWT_SECRET:-please-change-this-jwt-secret}"
GF_GENV_CAPTCHA_BYPASSCODE: "" # 生产环境清空万能验证码 GF_CAPTCHA_BYPASSCODE: "" # 生产环境清空万能验证码
GF_GENV_APP_ERRORMSG: "false" # 生产环境关闭详细错误信息 GF_APP_ERRORMSG: "false" # 生产环境关闭详细错误信息
volumes: volumes:
# 上传文件持久化(两段式上传的 temp 与正式目录都在 uploadDir 下) # 上传文件持久化(两段式上传的 temp 与正式目录都在 uploadDir 下)
- ../upload:/data/upload - ../upload:/data/upload
+5
View File
@@ -51,6 +51,7 @@ import (
"easygoadmin/internal/logic" "easygoadmin/internal/logic"
"easygoadmin/internal/router" "easygoadmin/internal/router"
"easygoadmin/utility/cache" "easygoadmin/utility/cache"
"easygoadmin/utility/config"
// 空白导入:触发定时任务处理器 init 自注册 // 空白导入:触发定时任务处理器 init 自注册
_ "easygoadmin/internal/task" _ "easygoadmin/internal/task"
@@ -64,6 +65,10 @@ import (
// 3. 注册中间件链与全部路由 // 3. 注册中间件链与全部路由
// 4. 阻塞运行 HTTP 服务 // 4. 阻塞运行 HTTP 服务
func Run(ctx context.Context) { func Run(ctx context.Context) {
// 配置环境变量覆盖接线(GF_ 前缀环境变量 > 配置文件,12-Factor 优先级)
// 须最先执行:缓存/数据库/Redis 初始化都依赖配置读取
config.RegisterEnvOverride()
// 缓存初始化(JWT黑名单/验证码/字典/身份缓存) // 缓存初始化(JWT黑名单/验证码/字典/身份缓存)
cache.Init(ctx) cache.Init(ctx)
+5 -1
View File
@@ -1,6 +1,10 @@
# ====================================================================== # ======================================================================
# EasyGoAdmin 运行时配置 # EasyGoAdmin 运行时配置
# 环境变量覆盖规则:使用 GF_GENV_ 前缀,例如 GF_GENV_DATABASE_DEFAULT_LINK # 环境变量覆盖规则(12-Factor:环境变量 > 配置文件):
# 键名 = 配置路径大写、点换下划线并加 GF_ 前缀,
# 例如 database.default.link → GF_DATABASE_DEFAULT_LINK;
# 覆盖经 utility/config 在启动时接线(RegisterEnvOverride),
# 仅覆盖本文件中已存在的键,空值视为未设置
# ====================================================================== # ======================================================================
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
+97
View File
@@ -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)
}
}
}
+125
View File
@@ -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)
}
}