fix(server): 安全加固与版本链路修复

- 微信登录改为真实 jscode2session,session_key 不再下发客户端
- 支付回调增加 HMAC 验签(X-Pay-Sign)与幂等处理,未配置密钥时拒绝回调
- 订单金额一律以服务端商品表定价,禁止客户端传入金额
- 付费许愿改为支付成功后创建,不再先许愿后付款
- 管理后台增加登录认证(ADMIN_PASSWORD + role=admin JWT + HttpOnly Cookie)
- 订单详情/取消增加本人归属校验,修复越权访问
- 版本信息改为 ldflags 注入单一链路,GoVersion 用 runtime.Version()
- 恢复 gin 默认访问日志(原 Logger 中间件输出为空)
- 加载 HTML 模板修复后台页面 500;godotenv 加载 .env.local
- CORS 支持 CORS_ORIGINS 白名单配置
This commit is contained in:
gouki
2026-08-09 00:09:24 +00:00
parent daebda22ee
commit b8c439deac
11 changed files with 549 additions and 179 deletions
+34 -9
View File
@@ -8,9 +8,27 @@ import (
"github.com/gouki/lunar-server/internal/handler" "github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware" "github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
"github.com/joho/godotenv"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
) )
func main() { func main() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
_ = godotenv.Load(".env.local", "../.env.local", ".env")
// 将构建时注入的版本信息传递给 handler
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
// 加载配置 // 加载配置
cfg := config.Load() cfg := config.Load()
@@ -28,16 +46,16 @@ func main() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
// 创建路由 // 创建路由gin.Default 自带访问日志与 Recovery
r := gin.Default() r := gin.Default()
// 中间件 // 中间件
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.Logger())
// 静态文件 // 静态文件与管理后台模板
r.Static("/static", "./web/static") r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html") r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html")
// API 路由 // API 路由
api := r.Group("/api") api := r.Group("/api")
@@ -87,13 +105,20 @@ func main() {
// 管理后台路由(Inertia.js // 管理后台路由(Inertia.js
admin := r.Group("/admin") admin := r.Group("/admin")
admin.Use(middleware.AdminAuth())
{ {
admin.GET("/", handler.AdminDashboard) // 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
admin.GET("/users", handler.AdminUsers) admin.POST("/login", handler.AdminLogin)
admin.GET("/orders", handler.AdminOrders) admin.POST("/logout", handler.AdminLogout)
admin.GET("/wishes", handler.AdminWishes)
admin.GET("/settings", handler.AdminSettings) authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/settings", handler.AdminSettings)
}
} }
// 启动服务器 // 启动服务器
+16 -4
View File
@@ -10,11 +10,13 @@ type Config struct {
Redis RedisConfig Redis RedisConfig
JWT JWTConfig JWT JWTConfig
Wechat WechatConfig Wechat WechatConfig
Admin AdminConfig
} }
type ServerConfig struct { type ServerConfig struct {
Port string Port string
Env string Env string
CORSOrigins string
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -43,11 +45,18 @@ type WechatConfig struct {
MchID string MchID string
} }
// AdminConfig 管理后台配置
type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录
Password string
}
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"), Port: getEnv("SERVER_PORT", "8080"),
Env: getEnv("SERVER_ENV", "development"), Env: getEnv("SERVER_ENV", "development"),
CORSOrigins: getEnv("CORS_ORIGINS", ""),
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"), Host: getEnv("DB_HOST", "localhost"),
@@ -71,6 +80,9 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""), PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""), MchID: getEnv("WECHAT_PAY_MCHID", ""),
}, },
Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""),
},
} }
} }
+81 -20
View File
@@ -1,47 +1,108 @@
package handler package handler
import ( import (
"crypto/subtle"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/service"
) )
// adminPage 统一的管理后台页面渲染(补充 Inertia 需要的 url 属性)
func adminPage(c *gin.Context, title, page string) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": title,
"page": page,
"url": c.Request.URL.Path,
})
}
// AdminLogin 管理员登录:密码正确签发 role=admin 的 JWT
func AdminLogin(c *gin.Context) {
cfg := config.Load()
if cfg.Admin.Password == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": 503,
"msg": "后台登录未启用",
})
return
}
var req struct {
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "参数错误",
})
return
}
// 恒定时间比较,防时序攻击
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(cfg.Admin.Password)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "密码错误",
})
return
}
userService := service.NewUserService()
token, err := userService.GenerateAdminToken(cfg.JWT.Secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "生成token失败",
})
return
}
// 同时写入 HttpOnly Cookie,支持浏览器直接导航后台页面
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(service.AdminTokenCookie, token, 12*3600, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"token": token,
},
})
}
// AdminLogout 管理员登出:清除会话 Cookie
func AdminLogout(c *gin.Context) {
cfg := config.Load()
c.SetCookie(service.AdminTokenCookie, "", -1, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
})
}
// AdminDashboard 管理后台首页 // AdminDashboard 管理后台首页
func AdminDashboard(c *gin.Context) { func AdminDashboard(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "管理后台", "dashboard")
"title": "管理后台",
"page": "dashboard",
})
} }
// AdminUsers 用户管理 // AdminUsers 用户管理
func AdminUsers(c *gin.Context) { func AdminUsers(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "用户管理", "users")
"title": "用户管理",
"page": "users",
})
} }
// AdminOrders 订单管理 // AdminOrders 订单管理
func AdminOrders(c *gin.Context) { func AdminOrders(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "订单管理", "orders")
"title": "订单管理",
"page": "orders",
})
} }
// AdminWishes 许愿管理 // AdminWishes 许愿管理
func AdminWishes(c *gin.Context) { func AdminWishes(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "许愿管理", "wishes")
"title": "许愿管理",
"page": "wishes",
})
} }
// AdminSettings 系统设置 // AdminSettings 系统设置
func AdminSettings(c *gin.Context) { func AdminSettings(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "系统设置", "settings")
"title": "系统设置",
"page": "settings",
})
} }
+10 -7
View File
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
}) })
} }
// GetOrderDetail 获取订单详情 // GetOrderDetail 获取订单详情(仅限本人订单)
func GetOrderDetail(c *gin.Context) { func GetOrderDetail(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByID(uint(orderID)) order, err := orderService.GetOrderByID(uint(orderID))
if err != nil { if err != nil || order.UserID != userID.(uint) {
// 不存在与无权访问统一返回 404,避免枚举他人订单
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
"msg": "订单不存在", "msg": "订单不存在",
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
}) })
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人待支付订单)
func CancelOrder(c *gin.Context) { func CancelOrder(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
if err := orderService.CancelOrder(uint(orderID)); err != nil { if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"code": 500, "code": 400,
"msg": "取消订单失败", "msg": err.Error(),
}) })
return return
} }
+103 -38
View File
@@ -1,14 +1,20 @@
package handler package handler
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model" "github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CreateOrder 创建订单 // CreateOrder 创建订单
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
func CreateOrder(c *gin.Context) { func CreateOrder(c *gin.Context) {
userID, exists := c.Get("userID") userID, exists := c.Get("userID")
if !exists { if !exists {
@@ -20,11 +26,9 @@ func CreateOrder(c *gin.Context) {
} }
var req struct { var req struct {
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员 Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
ProductID uint `json:"productId" binding:"required"` // 商品ID ProductID uint `json:"productId" binding:"required"` // 商品ID
ProductName string `json:"productName" binding:"required"` // 商品名称 Content string `json:"content"` // 许愿内容(许愿类型需要)
Amount int `json:"amount" binding:"required"` // 金额(分)
Content string `json:"content"` // 许愿内容(许愿类型需要)
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
return return
} }
orderService := service.NewOrderService() // 服务端定价:按商品 ID 查库取价格,防止客户端篡改金额
wishService := service.NewWishService()
product, err := wishService.GetWishProductByID(req.ProductID)
if err != nil || product.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "商品不存在或已下架",
})
return
}
if req.Type == "wish" {
if req.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "请输入许愿内容",
})
return
}
if len([]rune(req.Content)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "许愿内容超过 100 字限制",
})
return
}
} else {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "不支持的订单类型",
})
return
}
orderService := service.NewOrderService()
order := &model.Order{ order := &model.Order{
UserID: userID.(uint), UserID: userID.(uint),
Type: req.Type, Type: req.Type,
ProductID: req.ProductID, ProductID: product.ID,
ProductName: req.ProductName, ProductName: product.Name,
Amount: req.Amount, Amount: product.Price, // 金额以商品表为准
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
} }
if err := orderService.CreateOrder(order); err != nil { if err := orderService.CreateOrder(order); err != nil {
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
return return
} }
// 如果是许愿类型,创建许愿记录
if req.Type == "wish" && req.Content != "" {
wishService := service.NewWishService()
wish := &model.Wish{
UserID: userID.(uint),
TreeID: 1, // 默认许愿树
Content: req.Content,
Type: "paid",
Status: 1,
}
wishService.CreateWish(wish)
}
// 创建微信支付订单 // 创建微信支付订单
payParams, err := orderService.CreateWechatPayOrder(order, "") payParams, err := orderService.CreateWechatPayOrder(order, "")
if err != nil { if err != nil {
@@ -87,12 +112,29 @@ func CreateOrder(c *gin.Context) {
}) })
} }
// PayNotify 支付回调 // paySignOf 计算支付回调签名:HMAC-SHA256(orderNo|transactionId, APIKEY)
// 过渡方案:真实微信支付 V3 回调验签接入前的内部协议
func paySignOf(orderNo, transactionID, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(orderNo + "|" + transactionID))
return hex.EncodeToString(mac.Sum(nil))
}
// PayNotify 支付回调:必须携带 X-Pay-Sign 签名头,验签通过且幂等处理
func PayNotify(c *gin.Context) { func PayNotify(c *gin.Context) {
// TODO: 验证微信支付回调签名 cfg := config.Load()
if cfg.Wechat.PayKey == "" {
// 未配置支付密钥时拒绝一切回调,避免裸奔
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": "FAIL",
"msg": "支付服务未配置",
})
return
}
var req struct { var req struct {
OrderNo string `json:"orderNo"` OrderNo string `json:"orderNo" binding:"required"`
TransactionID string `json:"transactionId"` TransactionID string `json:"transactionId" binding:"required"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -104,15 +146,32 @@ func PayNotify(c *gin.Context) {
return return
} }
if req.Status == "SUCCESS" { // 验签:恒定时间比较,防时序攻击
orderService := service.NewOrderService() sign := c.GetHeader("X-Pay-Sign")
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil { expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
c.JSON(http.StatusInternalServerError, gin.H{ if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
"code": "FAIL", c.JSON(http.StatusUnauthorized, gin.H{
"msg": "处理失败", "code": "FAIL",
}) "msg": "签名验证失败",
return })
} return
}
if req.Status != "SUCCESS" {
c.JSON(http.StatusOK, gin.H{
"code": "SUCCESS",
"msg": "OK",
})
return
}
orderService := service.NewOrderService()
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": "FAIL",
"msg": "处理失败",
})
return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
}) })
} }
// GetPayStatus 获取支付状态 // GetPayStatus 获取支付状态(支持订单号或订单 ID
func GetPayStatus(c *gin.Context) { func GetPayStatus(c *gin.Context) {
orderID := c.Param("orderId") param := c.Param("orderId")
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByOrderNo(orderID) order, err := orderService.GetOrderByOrderNo(param)
if err != nil {
// 兼容传数字 ID 的调用方
if id, convErr := strconv.Atoi(param); convErr == nil {
order, err = orderService.GetOrderByID(uint(id))
}
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
+101 -31
View File
@@ -1,7 +1,12 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/url"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/config"
@@ -9,7 +14,71 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// UserLogin 用户登录 // code2Session 调用微信 jscode2session 接口换取 openid
// session_key 仅保存在服务端,绝不下发给客户端
func code2Session(code string) (openID, unionID string, err error) {
cfg := config.Load()
if cfg.Wechat.AppID == "" || cfg.Wechat.AppSecret == "" {
return "", "", fmt.Errorf("wechat appid/secret not configured")
}
query := url.Values{
"appid": {cfg.Wechat.AppID},
"secret": {cfg.Wechat.AppSecret},
"js_code": {code},
"grant_type": {"authorization_code"},
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.weixin.qq.com/sns/jscode2session?" + query.Encode())
if err != nil {
return "", "", fmt.Errorf("jscode2session request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", "", fmt.Errorf("read jscode2session response failed: %w", err)
}
var result struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("parse jscode2session response failed: %w", err)
}
if result.ErrCode != 0 || result.OpenID == "" {
return "", "", fmt.Errorf("wechat auth failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return result.OpenID, result.UnionID, nil
}
// upsertUser 根据 openid 获取用户,不存在则创建
func upsertUser(openID, unionID string) (*model.User, error) {
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil {
return nil, err
}
if user == nil {
user = &model.User{
OpenID: openID,
UnionID: unionID,
Nickname: "微信用户",
Status: 1,
}
if err := userService.CreateUser(user); err != nil {
return nil, err
}
}
return user, nil
}
// UserLogin 用户登录(小程序 wx.login 的 code 换取 token
func UserLogin(c *gin.Context) { func UserLogin(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -23,38 +92,35 @@ func UserLogin(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid openID, unionID, err := code2Session(req.Code)
// 这里模拟返回
openID := "mock_openid_" + req.Code
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"code": 500, "code": 401,
"msg": "服务器错误", "msg": "微信登录失败",
}) })
return return
} }
// 如果用户不存在,创建新用户 user, err := upsertUser(openID, unionID)
if user == nil { if err != nil {
user = &model.User{ c.JSON(http.StatusInternalServerError, gin.H{
OpenID: openID, "code": 500,
Nickname: "微信用户", "msg": "创建用户失败",
Status: 1, })
} return
if err := userService.CreateUser(user); err != nil { }
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, // 禁用用户不允许登录
"msg": "创建用户失败", if user.Status != 1 {
}) c.JSON(http.StatusForbidden, gin.H{
return "code": 403,
} "msg": "账号已被禁用",
})
return
} }
// 生成 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService()
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret) token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -217,7 +283,7 @@ func UpdateUserProfile(c *gin.Context) {
}) })
} }
// WechatAuth 微信授权 // WechatAuth 微信授权(仅返回 openidsession_key 属敏感凭证不下发)
func WechatAuth(c *gin.Context) { func WechatAuth(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -231,16 +297,20 @@ func WechatAuth(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid 和 session_key openID, _, err := code2Session(req.Code)
// 这里模拟返回 if err != nil {
openID := "mock_openid_" + req.Code c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "微信授权失败",
})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"msg": "success", "msg": "success",
"data": gin.H{ "data": gin.H{
"openid": openID, "openid": openID,
"sessionKey": "mock_session_key",
}, },
}) })
} }
+12 -21
View File
@@ -2,11 +2,18 @@ package handler
import ( import (
"net/http" "net/http"
"os" "runtime"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// VersionInfo 版本信息 // VersionInfo 版本信息
type VersionInfo struct { type VersionInfo struct {
Version string `json:"version"` Version string `json:"version"`
@@ -17,26 +24,10 @@ type VersionInfo struct {
// GetVersion 获取服务器版本信息 // GetVersion 获取服务器版本信息
func GetVersion(c *gin.Context) { func GetVersion(c *gin.Context) {
// 从环境变量读取版本信息(由 CI 构建时注入)
version := os.Getenv("APP_VERSION")
if version == "" {
version = "dev"
}
buildTime := os.Getenv("APP_BUILD_TIME")
if buildTime == "" {
buildTime = "unknown"
}
commitSha := os.Getenv("APP_COMMIT_SHA")
if commitSha == "" {
commitSha = "unknown"
}
c.JSON(http.StatusOK, VersionInfo{ c.JSON(http.StatusOK, VersionInfo{
Version: version, Version: Version,
BuildTime: buildTime, BuildTime: BuildTime,
CommitSha: commitSha, CommitSha: CommitSha,
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取 GoVersion: runtime.Version(),
}) })
} }
+87 -33
View File
@@ -9,12 +9,41 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CORS 跨域中间件 // CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
cfg := config.Load()
origins := strings.TrimSpace(cfg.Server.CORSOrigins)
allowAll := origins == ""
allowed := map[string]bool{}
if !allowAll {
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed[o] = true
}
}
}
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.GetHeader("Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if origin != "" {
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if allowed[origin] {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
} else {
// 非白名单源:不输出 CORS 头,浏览器会拦截跨域请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Pay-Sign")
}
if c.Request.Method == "OPTIONS" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent) c.AbortWithStatus(http.StatusNoContent)
@@ -25,40 +54,38 @@ func CORS() gin.HandlerFunc {
} }
} }
// Logger 日志中间件 // bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
func Logger() gin.HandlerFunc { func bearerToken(c *gin.Context) (string, bool) {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { authHeader := c.GetHeader("Authorization")
return "" if authHeader == "" {
}) c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return "", false
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return "", false
}
return parts[1], true
} }
// Auth JWT认证中间件 // Auth JWT认证中间件
func Auth() gin.HandlerFunc { func Auth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") tokenString, ok := bearerToken(c)
if authHeader == "" { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return return
} }
// 解析 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return
}
tokenString := parts[1]
// 解析 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService() userService := service.NewUserService()
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret) userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
return return
} }
// 将用户ID存入上下文
c.Set("userID", userID) c.Set("userID", userID)
c.Next() c.Next()
} }
} }
// AdminAuth 管理员认证中间件 // AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWTAuthorization 头或 HttpOnly Cookie
func AdminAuth() gin.HandlerFunc { func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// TODO: 实现管理员认证 tokenString := ""
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// 浏览器导航场景:登录时写入的 HttpOnly Cookie
tokenString, _ = c.Cookie(service.AdminTokenCookie)
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
cfg := config.Load()
userService := service.NewUserService()
if !userService.IsAdminToken(tokenString, cfg.JWT.Secret) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
c.Next() c.Next()
} }
} }
+58 -8
View File
@@ -3,7 +3,9 @@ package service
import ( import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"log"
"math/rand" "math/rand"
"time" "time"
@@ -98,9 +100,18 @@ func (s *OrderService) UpdateOrderStatus(orderNo, status string) error {
return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
func (s *OrderService) CancelOrder(id uint) error { func (s *OrderService) CancelOrder(userID, id uint) error {
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error result := s.db.Model(&model.Order{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, "pending").
Update("status", "cancelled")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("订单不存在或当前状态不可取消")
}
return nil
} }
// CheckOrderExpired 检查订单是否过期 // CheckOrderExpired 检查订单是否过期
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// HandlePayNotify 处理支付回调 // HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error { func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
// 更新订单状态 order, err := s.GetOrderByOrderNo(orderNo)
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil { if err != nil {
return err return fmt.Errorf("order not found: %w", err)
} }
// TODO: 处理业务逻辑(如创建许愿、开通会员等) // 幂等:已支付直接返回成功
if order.Status == "paid" {
return nil
}
if order.Status != "pending" {
return fmt.Errorf("order %s in unexpected status %s", orderNo, order.Status)
}
// 条件更新防止并发重复入账
updates := map[string]interface{}{
"status": "paid",
"pay_time": time.Now(),
"remark": order.Remark + " [tx:" + transactionID + "]",
}
result := s.db.Model(&model.Order{}).
Where("order_no = ? AND status = ?", orderNo, "pending").
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 并发下已被其他回调处理,视为幂等成功
return nil
}
// 支付成功后才创建付费许愿
if order.Type == "wish" && order.Remark != "" {
wishService := NewWishService()
wish := &model.Wish{
UserID: order.UserID,
TreeID: 1, // 默认祈福树
Content: order.Remark,
Type: "paid",
Status: 1,
}
if err := wishService.CreateWish(wish); err != nil {
// 许愿创建失败不回滚支付状态,由后台补处理
log.Printf("create paid wish for order %s failed: %v", orderNo, err)
}
}
return nil return nil
} }
+41 -2
View File
@@ -93,9 +93,48 @@ func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
} }
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := uint(claims["user_id"].(float64)) // 管理员 token 不绑定具体用户,不能当普通用户 token 使用
return userID, nil if role, _ := claims["role"].(string); role == "admin" {
return 0, errors.New("admin token")
}
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
return 0, errors.New("invalid token claims")
}
return uint(userIDFloat), nil
} }
return 0, errors.New("invalid token") return 0, errors.New("invalid token")
} }
// AdminTokenCookie 管理员会话 Cookie 名
const AdminTokenCookie = "lunar_admin_token"
// GenerateAdminToken 签发管理员 JWT12 小时过期)
func (s *UserService) GenerateAdminToken(secret string) (string, error) {
claims := jwt.MapClaims{
"role": "admin",
"exp": time.Now().Add(time.Hour * 12).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// IsAdminToken 校验是否为有效的管理员 JWT
func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return false
}
role, _ := claims["role"].(string)
return role == "admin"
}
+6 -6
View File
@@ -7,18 +7,18 @@ echo "Building Lunar Server..."
# 创建输出目录 # 创建输出目录
mkdir -p bin mkdir -p bin
# 编译 Go 后端 # 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
echo "Building Go backend..." echo "Building Go backend..."
go build -o bin/lunar-server cmd/main.go go build -o bin/server cmd/main.go
# 编译前端(如果存在 # 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建
if [ -d "web" ]; then if [ -f "web/package.json" ]; then
echo "Building frontend..." echo "Building frontend..."
cd web cd web
npm install npm ci
npm run build npm run build
cd .. cd ..
fi fi
echo "Build complete!" echo "Build complete!"
echo "Output: bin/lunar-server" echo "Output: bin/server"