需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),PC / 移动双端一致。 设计要点(受服务器 5M 出口带宽约束): - 开源平台图片一律走官方 CDN 直链,后端只代理元数据,零带宽消耗。 - 自建图库落盘到 /data/www/wallpaper,由 nginx 的 /wallpaper/ 直出, Go 服务不参与传图;三档尺寸(480 缩略 / 1920 预览 / 原图仅供下载)。 - 内存缓存平台响应(Bing 1h、搜索类 10min),避免撞第三方配额。 内容: - 平台插件:Bing 每日一图、Picsum、Unsplash、Pexels、Wallhaven。 新增平台 = 写一个 provider_xxx.go 并在 allProviders() 加一行, 刻意不用 init() 自注册,避免隐式副作用。所有适配器支持 config.baseUrl 覆盖,用于绕开 DNS 污染(实测 wallhaven.cc 解析到境外无关 IP)。 - 自建图库:上传(MD5 秒传去重 / 尺寸前置校验 / 失败清理孤儿文件)、 元信息编辑、删除、统计;随机取图用「数总数 → 随机偏移」而非 ORDER BY RAND()。 - 接口:open 组 4 个(sources / list / random / download-track), admin 组 8 个(list / upload / save / delete / stats / source.list|save|test)。 全部 POST 且 URL 无参数,符合工作空间接口规范;上传走 multipart 例外。 - 安全:purity 默认锁死 SFW;apiKey 只回传布尔不回传明文; 保存时留空表示保留旧值;open 组错误信息对外脱敏。 - 配置:clientMaxBodySize 提到 64M(gf 默认 8M 会截断 20MB 原图); wallpaper.root / baseUrl 支持环境变量注入并在占位符未替换时兜底。 依赖:golang.org/x/image@v0.23.0(仅用于缩略图缩放,保持 go 1.23.0 不变)。
203 lines
10 KiB
Go
203 lines
10 KiB
Go
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())
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
// 壁纸库路径同理:占位符没被注入时要回填,否则会真的创建一个名为
|
||
// "${WALLPAPER_ROOT}" 的目录,图片全落到那里且难以察觉。
|
||
// 生产兜底到 /data/www/wallpaper(由 nginx 直出),开发兜底到仓库内 data/。
|
||
if v, _ := adapter.Get(ctx, "wallpaper.root"); v != nil && gstr.Contains(gconv.String(v), "${") {
|
||
if isProd {
|
||
_ = adapter.Set("wallpaper.root", "/data/www/wallpaper")
|
||
} else {
|
||
_ = adapter.Set("wallpaper.root", "./data/wallpaper")
|
||
}
|
||
}
|
||
if v, _ := adapter.Get(ctx, "wallpaper.baseUrl"); v != nil && gstr.Contains(gconv.String(v), "${") {
|
||
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=true):CORS → 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
|
||
},
|
||
}
|
||
)
|