refactor(server): 拆分 cmd/mini 与 cmd/web 两个部署入口
Build and Publish Commercial Web / build (push) Failing after 1m25s
Build and Publish Server / build (push) Successful in 3m25s

小程序接口(lunar.neatcn.com)与商业版 Web(kepo.net)此前共用一个
二进制,导致小程序域名暴露商业后台与 Stripe Webhook,商业站点也
暴露了许愿树等小程序接口。现拆为两个入口:

- cmd/mini:微信登录、生辰档案、许愿树、百科、版本号
- cmd/web:邮箱登录、commerce、报告/订单、/admin 后台、商业前端
  静态托管与 SPA 回退
- internal/app 承载共用启动与路由注册;图片资源移至 internal/assets

CI:server-deploy.yml 改构建 cmd/mini 并随包移除后台静态资源;
新增 web-deploy.yml 构建 cmd/web + web-commercial 前端,发布
web-v* Release;deploy-watch.sh 支持 TAG_PREFIX/ASSET_NAME 变量;
新增 deploy-web.sh(systemd 版)与 kepo 一次性配置文档
.gitea/docs/PULL_DEPLOY_WEB.md。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
gouki
2026-10-18 11:31:51 +00:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 28309e0257
commit 12fa2b0d19
15 changed files with 872 additions and 212 deletions
+51
View File
@@ -0,0 +1,51 @@
// Package app 提供 lunar-server 两个部署形态(小程序接口 / 商业版 Web)
// 共用的启动逻辑与路由注册。
package app
import (
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/assets"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/joho/godotenv"
)
// Bootstrap 公共启动逻辑:加载 env、配置与数据库连接
func Bootstrap() *config.Config {
_ = godotenv.Load(".env.local", "../.env.local", ".env")
cfg := config.Load()
if err := config.InitDB(cfg); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
return cfg
}
// NewEngine 创建带通用中间件的 HTTP 引擎
func NewEngine() *gin.Engine {
r := gin.Default()
r.Use(middleware.CORS())
return r
}
// MountImages 挂载内嵌图片资源(许愿树背景图等)
func MountImages(r *gin.Engine) {
if imagesFS, err := fs.Sub(assets.FS, "images"); err == nil {
r.GET("/images/*path", gin.WrapH(http.StripPrefix("/images", http.FileServer(http.FS(imagesFS)))))
}
}
// Run 启动 HTTP 服务
func Run(r *gin.Engine, port string) {
addr := ":" + port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
)
// SetupMini 注册小程序接口路由与定时任务。
// 不包含商业版支付、邮箱登录与管理后台——这些只属于 cmd/web。
func SetupMini(r *gin.Engine, cfg *config.Config) {
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 内嵌图片资源(许愿树背景图等)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关(许愿付费;演示期保留接口占位)
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
}
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package app
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
)
// SetupWeb 注册商业版 Web 路由:邮箱登录、支付/订单、报告权益、
// 管理后台与商业前端静态文件。不包含小程序接口(许愿树、微信登录等)。
//
// 静态资源约定(相对 WorkingDirectory):
//
// ./frontend/ 商业前端构建产物(web-commercial/dist)
// ./web/ 管理后台静态资源与模板(server/web)
func SetupWeb(r *gin.Engine, cfg *config.Config) {
// 商业版前端静态文件;后台仍通过 /admin 访问。
r.Static("/assets", "./frontend/assets")
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
r.Static("/static", "./web/static")
r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(管理后台可能引用许愿树图片)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
auth := api.Group("/auth")
{
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
}
commerce := api.Group("/commerce")
{
commerce.POST("/charts", handler.SaveChartRecord)
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
authed.GET("/api/orders", handler.AdminCommerceOrders)
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
authed.GET("/api/charts", handler.AdminChartRecords)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
// SPA 回退:非 /api、/admin、/static、/images、/assets 的 GET 请求
// 一律返回商业前端 index.html,交给前端路由处理。
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
for _, prefix := range []string{"/api/", "/admin", "/static/", "/images/", "/assets/"} {
if strings.HasPrefix(path, prefix) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "not found"})
return
}
}
if _, err := os.Stat("./frontend/index.html"); err == nil {
c.File("./frontend/index.html")
return
}
c.String(http.StatusNotFound, "frontend not deployed")
})
}