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/middleware"
"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() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.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()
@@ -28,16 +46,16 @@ func main() {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由
// 创建路由gin.Default 自带访问日志与 Recovery
r := gin.Default()
// 中间件
r.Use(middleware.CORS())
r.Use(middleware.Logger())
// 静态文件
// 静态文件与管理后台模板
r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html")
// API 路由
api := r.Group("/api")
@@ -87,13 +105,20 @@ func main() {
// 管理后台路由(Inertia.js
admin := r.Group("/admin")
admin.Use(middleware.AdminAuth())
{
admin.GET("/", handler.AdminDashboard)
admin.GET("/users", handler.AdminUsers)
admin.GET("/orders", handler.AdminOrders)
admin.GET("/wishes", handler.AdminWishes)
admin.GET("/settings", handler.AdminSettings)
// 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
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("/wishes", handler.AdminWishes)
authed.GET("/settings", handler.AdminSettings)
}
}
// 启动服务器
+12
View File
@@ -10,11 +10,13 @@ type Config struct {
Redis RedisConfig
JWT JWTConfig
Wechat WechatConfig
Admin AdminConfig
}
type ServerConfig struct {
Port string
Env string
CORSOrigins string
}
type DatabaseConfig struct {
@@ -43,11 +45,18 @@ type WechatConfig struct {
MchID string
}
// AdminConfig 管理后台配置
type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录
Password string
}
func Load() *Config {
return &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Env: getEnv("SERVER_ENV", "development"),
CORSOrigins: getEnv("CORS_ORIGINS", ""),
},
Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"),
@@ -71,6 +80,9 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""),
},
Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""),
},
}
}
+81 -20
View File
@@ -1,47 +1,108 @@
package handler
import (
"crypto/subtle"
"net/http"
"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 管理后台首页
func AdminDashboard(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "管理后台",
"page": "dashboard",
})
adminPage(c, "管理后台", "dashboard")
}
// AdminUsers 用户管理
func AdminUsers(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "用户管理",
"page": "users",
})
adminPage(c, "用户管理", "users")
}
// AdminOrders 订单管理
func AdminOrders(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "订单管理",
"page": "orders",
})
adminPage(c, "订单管理", "orders")
}
// AdminWishes 许愿管理
func AdminWishes(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "许愿管理",
"page": "wishes",
})
adminPage(c, "许愿管理", "wishes")
}
// AdminSettings 系统设置
func AdminSettings(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "系统设置",
"page": "settings",
})
adminPage(c, "系统设置", "settings")
}
+10 -7
View File
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
})
}
// GetOrderDetail 获取订单详情
// GetOrderDetail 获取订单详情(仅限本人订单)
func GetOrderDetail(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id")
orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService()
order, err := orderService.GetOrderByID(uint(orderID))
if err != nil {
if err != nil || order.UserID != userID.(uint) {
// 不存在与无权访问统一返回 404,避免枚举他人订单
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
"msg": "订单不存在",
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
})
}
// CancelOrder 取消订单
// CancelOrder 取消订单(仅限本人待支付订单)
func CancelOrder(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id")
orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService()
if err := orderService.CancelOrder(uint(orderID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "取消订单失败",
if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": err.Error(),
})
return
}
+93 -28
View File
@@ -1,14 +1,20 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"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/service"
)
// CreateOrder 创建订单
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
func CreateOrder(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
@@ -22,8 +28,6 @@ func CreateOrder(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
ProductID uint `json:"productId" binding:"required"` // 商品ID
ProductName string `json:"productName" binding:"required"` // 商品名称
Amount int `json:"amount" binding:"required"` // 金额(分)
Content string `json:"content"` // 许愿内容(许愿类型需要)
}
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
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{
UserID: userID.(uint),
Type: req.Type,
ProductID: req.ProductID,
ProductName: req.ProductName,
Amount: req.Amount,
ProductID: product.ID,
ProductName: product.Name,
Amount: product.Price, // 金额以商品表为准
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
}
if err := orderService.CreateOrder(order); err != nil {
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
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, "")
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) {
// TODO: 验证微信支付回调签名
cfg := config.Load()
if cfg.Wechat.PayKey == "" {
// 未配置支付密钥时拒绝一切回调,避免裸奔
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": "FAIL",
"msg": "支付服务未配置",
})
return
}
var req struct {
OrderNo string `json:"orderNo"`
TransactionID string `json:"transactionId"`
OrderNo string `json:"orderNo" binding:"required"`
TransactionID string `json:"transactionId" binding:"required"`
Status string `json:"status"`
}
@@ -104,7 +146,25 @@ func PayNotify(c *gin.Context) {
return
}
if req.Status == "SUCCESS" {
// 验签:恒定时间比较,防时序攻击
sign := c.GetHeader("X-Pay-Sign")
expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": "FAIL",
"msg": "签名验证失败",
})
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{
@@ -113,7 +173,6 @@ func PayNotify(c *gin.Context) {
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"code": "SUCCESS",
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
})
}
// GetPayStatus 获取支付状态
// GetPayStatus 获取支付状态(支持订单号或订单 ID
func GetPayStatus(c *gin.Context) {
orderID := c.Param("orderId")
param := c.Param("orderId")
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 {
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
+94 -24
View File
@@ -1,7 +1,12 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
@@ -9,7 +14,71 @@ import (
"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) {
var req struct {
Code string `json:"code" binding:"required"`
@@ -23,38 +92,35 @@ func UserLogin(c *gin.Context) {
return
}
// TODO: 调用微信接口获取 openid
// 这里模拟返回
openID := "mock_openid_" + req.Code
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
openID, unionID, err := code2Session(req.Code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "服务器错误",
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "微信登录失败",
})
return
}
// 如果用户不存在,创建新用户
if user == nil {
user = &model.User{
OpenID: openID,
Nickname: "微信用户",
Status: 1,
}
if err := userService.CreateUser(user); err != nil {
user, err := upsertUser(openID, unionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "创建用户失败",
})
return
}
// 禁用用户不允许登录
if user.Status != 1 {
c.JSON(http.StatusForbidden, gin.H{
"code": 403,
"msg": "账号已被禁用",
})
return
}
// 生成 token
cfg := config.Load()
userService := service.NewUserService()
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -217,7 +283,7 @@ func UpdateUserProfile(c *gin.Context) {
})
}
// WechatAuth 微信授权
// WechatAuth 微信授权(仅返回 openidsession_key 属敏感凭证不下发)
func WechatAuth(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
@@ -231,16 +297,20 @@ func WechatAuth(c *gin.Context) {
return
}
// TODO: 调用微信接口获取 openid 和 session_key
// 这里模拟返回
openID := "mock_openid_" + req.Code
openID, _, err := code2Session(req.Code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "微信授权失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"openid": openID,
"sessionKey": "mock_session_key",
},
})
}
+12 -21
View File
@@ -2,11 +2,18 @@ package handler
import (
"net/http"
"os"
"runtime"
"github.com/gin-gonic/gin"
)
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// VersionInfo 版本信息
type VersionInfo struct {
Version string `json:"version"`
@@ -17,26 +24,10 @@ type VersionInfo struct {
// GetVersion 获取服务器版本信息
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{
Version: version,
BuildTime: buildTime,
CommitSha: commitSha,
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取
Version: Version,
BuildTime: BuildTime,
CommitSha: CommitSha,
GoVersion: runtime.Version(),
})
}
+74 -20
View File
@@ -9,12 +9,41 @@ import (
"github.com/gouki/lunar-server/internal/service"
)
// CORS 跨域中间件
// CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
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) {
origin := c.GetHeader("Origin")
if origin != "" {
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")
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" {
c.AbortWithStatus(http.StatusNoContent)
@@ -25,16 +54,8 @@ func CORS() gin.HandlerFunc {
}
}
// Logger 日志中间件
func Logger() gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return ""
})
}
// Auth JWT认证中间件
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
// bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
func bearerToken(c *gin.Context) (string, bool) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
@@ -42,10 +63,9 @@ func Auth() gin.HandlerFunc {
"msg": "未授权",
})
c.Abort()
return
return "", false
}
// 解析 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
@@ -53,12 +73,19 @@ func Auth() gin.HandlerFunc {
"msg": "token格式错误",
})
c.Abort()
return "", false
}
return parts[1], true
}
// Auth JWT认证中间件
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, ok := bearerToken(c)
if !ok {
return
}
tokenString := parts[1]
// 解析 token
cfg := config.Load()
userService := service.NewUserService()
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
return
}
// 将用户ID存入上下文
c.Set("userID", userID)
c.Next()
}
}
// AdminAuth 管理员认证中间件
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWTAuthorization 头或 HttpOnly Cookie
func AdminAuth() gin.HandlerFunc {
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()
}
}
+58 -8
View File
@@ -3,7 +3,9 @@ package service
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"log"
"math/rand"
"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
}
// CancelOrder 取消订单
func (s *OrderService) CancelOrder(id uint) error {
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error
// CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
func (s *OrderService) CancelOrder(userID, id uint) 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 检查订单是否过期
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
return hex.EncodeToString(hash[:])
}
// HandlePayNotify 处理支付回调
// HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
// 更新订单状态
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil {
return err
order, err := s.GetOrderByOrderNo(orderNo)
if err != nil {
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
}
+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 {
userID := uint(claims["user_id"].(float64))
return userID, nil
// 管理员 token 不绑定具体用户,不能当普通用户 token 使用
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")
}
// 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
# 编译 Go 后端
# 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
echo "Building Go backend..."
go build -o bin/lunar-server cmd/main.go
go build -o bin/server cmd/main.go
# 编译前端(如果存在
if [ -d "web" ]; then
# 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建
if [ -f "web/package.json" ]; then
echo "Building frontend..."
cd web
npm install
npm ci
npm run build
cd ..
fi
echo "Build complete!"
echo "Output: bin/lunar-server"
echo "Output: bin/server"