service.xpcool.com/internal/cmd/cmd.go
夏犀麟 a337336a50 fix(wallpaper): 配置回填加固,兼容缺失配置段与未替换占位符
新增 needFill 辅助函数统一判断某个配置项是否「等于没配」:
配置项不存在、值为空串、或仍是未被替换的 ${PLACEHOLDER} 三种情况。

背景:gf v2.10.2 起不再自动替换 ${ENV} 占位符,且容器内跑的
config.yaml 可能是旧版(没有 wallpaper 配置段)。原先只判断
是否含 "${" 会在「配置段整体缺失」时漏判,导致 wallpaper.root
与 wallpaper.baseUrl 拿不到兜底值。

adapter.Get 返回 any 而非 *gvar.Var,故取值统一走 gconv.String。
2026-09-14 02:04:32 +08:00

220 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cmd
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/genv"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
adminctl "service.xpcool.com/internal/controller/admin"
housectl "service.xpcool.com/internal/controller/house"
jobctl "service.xpcool.com/internal/controller/job"
noticectl "service.xpcool.com/internal/controller/notice"
openctl "service.xpcool.com/internal/controller/open"
recruitmentctl "service.xpcool.com/internal/controller/recruitment"
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
userctl "service.xpcool.com/internal/controller/user"
wallpaperctl "service.xpcool.com/internal/controller/wallpaper"
"service.xpcool.com/internal/library/crypto"
"service.xpcool.com/internal/library/jwt"
"service.xpcool.com/internal/middleware"
admin "service.xpcool.com/internal/service/admin/admin/admin"
adminauth "service.xpcool.com/internal/service/admin/admin/login"
log "service.xpcool.com/internal/service/admin/base/log"
adminaudit "service.xpcool.com/internal/service/admin/system/log"
adminloginlog "service.xpcool.com/internal/service/admin/system/login_log"
adminmenu "service.xpcool.com/internal/service/admin/system/menu"
menu_manage "service.xpcool.com/internal/service/admin/system/menu_manage"
role "service.xpcool.com/internal/service/admin/system/role"
housecommunity "service.xpcool.com/internal/service/house/community"
housedashboard "service.xpcool.com/internal/service/house/dashboard"
houselisting "service.xpcool.com/internal/service/house/listing"
housepresale "service.xpcool.com/internal/service/house/presale"
housetransaction "service.xpcool.com/internal/service/house/transaction"
jobsvc "service.xpcool.com/internal/service/job"
noticesvc "service.xpcool.com/internal/service/notice"
recruitmentsvc "service.xpcool.com/internal/service/recruitment"
serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
userauth "service.xpcool.com/internal/service/user/auth"
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
)
// 开发环境默认值任何启动方式GoLand / 命令行 / go run漏注入环境变量时兜底
// 避免 ${DB_DSN} 占位符原样传入 gdb 导致全站接口 500。
// 生产GF_GCFG_ENV=prod不启用兜底配置缺失应显性报错。
//
// 注意:库名以本机实际建库为准(本项目为 service / recruitment
// 若你的本地库名或账号口令不同,请通过 DB_DSN / RECRUITMENT_DB_DSN 覆盖。
const (
devDefaultDBDSN = "mysql:service:3103ed27e98fc13b6d60e6571c4a8f76@tcp(127.0.0.1:3306)/service?loc=Local"
devDefaultRecruitmentDSN = "mysql:service:3103ed27e98fc13b6d60e6571c4a8f76@tcp(127.0.0.1:3306)/recruitment?loc=Local"
devDefaultJWTSecret = "dev-only-secret-not-for-production"
)
// setIfEmpty 仅当 env 存在时写入配置优先级env > 配置文件默认值)。
func setIfEmpty(adapter *gcfg.AdapterFile, key, envName string) {
if v := genv.Get(envName); !v.IsEmpty() {
_ = adapter.Set(key, v.String())
}
}
// needFill 判断某个配置项是否「等于没配」:不存在、为空串、或仍是未被替换的
// ${PLACEHOLDER}。gf v2.10.2 起不再自动替换占位符,容器里跑的 config.yaml
// 也可能是旧版(没有对应的配置段),这两种情况都必须回填兜底值。
//
// 注意 adapter.Get 返回的是 any 而不是 *gvar.Var取值统一走 gconv。
func needFill(ctx context.Context, adapter *gcfg.AdapterFile, key string) bool {
v, err := adapter.Get(ctx, key)
if err != nil || v == nil {
return true
}
s := gconv.String(v)
return s == "" || gstr.Contains(s, "${")
}
// injectEnv 手动把关键环境变量写入配置系统。
// 注意gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入,
// 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。
// 开发环境下若 env 缺失,回填本地默认值,保证「零配置可启动」。
func injectEnv(ctx context.Context) {
adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile)
if !ok {
return
}
isProd := genv.Get("GF_GCFG_ENV").String() == "prod"
if !isProd {
// 开发兜底:仅当配置项仍是未解析的 ${...} 占位符时才写默认值,
// 不覆盖 .env.dev / GoLand 运行配置注入的真实值。
if v, _ := adapter.Get(ctx, "database.default.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("database.default.link", devDefaultDBDSN)
}
if v, _ := adapter.Get(ctx, "database.recruitment.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("database.recruitment.link", devDefaultRecruitmentDSN)
}
if v, _ := adapter.Get(ctx, "jwt.secret"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("jwt.secret", devDefaultJWTSecret)
}
}
// 壁纸库路径同理,但要处理**三种**缺失形态:配置项不存在(容器里跑的
// config.yaml 可能是旧版、压根没有 wallpaper 段)、值为空、以及占位符
// 没被替换。任何一种漏掉都会掉进 NewStorage 的相对路径兜底
// (容器内 ./data/wallpaper表现为「上传成功但 nginx 找不到文件」,
// 而且重启容器就全丢 —— 这种故障极难排查,所以这里必须兜死。
// 生产兜底到 /data/www/wallpaper由 nginx 直出),开发兜底到仓库内 data/。
if needFill(ctx, adapter, "wallpaper.root") {
if isProd {
_ = adapter.Set("wallpaper.root", "/data/www/wallpaper")
} else {
_ = adapter.Set("wallpaper.root", "./data/wallpaper")
}
}
if needFill(ctx, adapter, "wallpaper.baseUrl") {
if isProd {
_ = adapter.Set("wallpaper.baseUrl", "https://xpcool.com/wallpaper")
} else {
_ = adapter.Set("wallpaper.baseUrl", "/wallpaper")
}
}
setIfEmpty(adapter, "database.default.link", "DB_DSN")
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
setIfEmpty(adapter, "database.recruitment.link", "RECRUITMENT_DB_DSN")
setIfEmpty(adapter, "bark.baseUrl", "BARK_BASE_URL")
setIfEmpty(adapter, "bark.deviceKey", "BARK_DEVICE_KEY")
setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
// 登录加密开关(直接跑二进制始终加载 config.yaml需 env 显式注入):
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密生产ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN")
// 壁纸图库:上传文件的落盘根目录与对外访问前缀。
// 生产为 /data/www/wallpaper + https://xpcool.com/wallpaper由 nginx 直出)。
setIfEmpty(adapter, "wallpaper.root", "WALLPAPER_ROOT")
setIfEmpty(adapter, "wallpaper.baseUrl", "WALLPAPER_BASE_URL")
}
var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
injectEnv(ctx)
s := g.Server()
tokens := jwt.New(ctx)
// 登录密码混合加密服务RSA + AES-GCM私钥加载/生成失败属致命错误,直接终止启动。
cryptoSvc, err := crypto.New(ctx)
if err != nil {
panic(err)
}
userauth.RegisterUserAuth(userauth.NewUserAuth(tokens, nil, nil))
adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens, cryptoSvc))
adminmenu.RegisterAdminMenu(adminmenu.NewAdminMenu())
admin.RegisterAdminManage(admin.NewAdminManage())
role.RegisterRoleManage(role.NewRoleManage())
menu_manage.RegisterMenuManage(menu_manage.NewMenuManage())
log.RegisterLogManage(log.NewLogManage())
adminaudit.RegisterAdminAudit(adminaudit.NewAdminAudit())
adminloginlog.RegisterAdminLoginLog(adminloginlog.NewAdminLoginLog())
housecommunity.RegisterCommunity(housecommunity.NewCommunity())
houselisting.RegisterListing(houselisting.NewListing())
housedashboard.RegisterDashboard(housedashboard.NewDashboard())
housetransaction.RegisterTransaction(housetransaction.NewTransaction())
housepresale.RegisterPresale(housepresale.NewPresale())
recruitmentsvc.RegisterRecruitment(recruitmentsvc.New())
noticesvc.RegisterNotice(noticesvc.New())
jobsvc.RegisterJob(jobsvc.New())
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
wallpapersvc.RegisterWallpaper(wallpapersvc.New(ctx))
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
// 壁纸前台读取xpcool.com 是纯静态站,浏览器直连本服务取数据,故必须免鉴权。
group.Bind(wallpaperctl.NewOpen())
})
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(userctl.New()) // 登录与刷新令牌接口公开。
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
})
s.Group("/api/service/admin", func(group *ghttp.RouterGroup) {
// 全量请求/响应加密(生产 encrypt.fullBody=trueCORS → APICrypto → Recover → HandlerResponse。
// Recover 置于 APICrypto 内层,使 500 异常响应同样被加密public-key 端点明文豁免。
group.Middleware(middleware.CORS, middleware.APICrypto(cryptoSvc), middleware.Recover, ghttp.MiddlewareHandlerResponse)
group.Bind(adminctl.NewAuth()) // 公开端点:仅登录。
group.Group("/", func(profile *ghttp.RouterGroup) {
// 仅登录端点:个人资料 / 权限码 / 菜单路由。
profile.Middleware(middleware.AdminAuthOnly(tokens))
profile.Bind(adminctl.NewProfile())
})
group.Group("/", func(protected *ghttp.RouterGroup) {
// 受权限保护端点RBAC 管理、日志等。
// 权限由后端按「方法+路径」自动匹配,无需前端传 X-Permission。
protected.Middleware(middleware.AdminAuth(tokens, adminauth.AdminAuth().PermissionForPath, adminauth.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int, userAgent, errorMessage string) {
adminaudit.AdminAudit().Record(ctx, adminaudit.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status, ErrorMessage: errorMessage, UserAgent: userAgent})
}))
protected.Bind(adminctl.New())
protected.Bind(housectl.New())
protected.Bind(recruitmentctl.New())
protected.Bind(noticectl.New())
protected.Bind(jobctl.New())
protected.Bind(serversecurityctl.NewManage())
protected.Bind(wallpaperctl.New())
})
})
// 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。
recruitmentsvc.RegisterTasks()
jobsvc.Job().StartScheduler(ctx)
s.Run()
return nil
},
}
)