From b8c439deac984ed72183b2f77cd4b5203a6b90c7 Mon Sep 17 00:00:00 2001 From: gouki Date: Sun, 9 Aug 2026 00:09:24 +0000 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E4=B8=8E=E7=89=88=E6=9C=AC=E9=93=BE=E8=B7=AF=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 微信登录改为真实 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 白名单配置 --- server/cmd/main.go | 43 +++++-- server/internal/config/config.go | 20 +++- server/internal/handler/admin.go | 101 ++++++++++++---- server/internal/handler/order.go | 17 +-- server/internal/handler/pay.go | 141 +++++++++++++++++------ server/internal/handler/user.go | 132 ++++++++++++++++----- server/internal/handler/version.go | 33 ++---- server/internal/middleware/middleware.go | 120 +++++++++++++------ server/internal/service/order.go | 66 +++++++++-- server/internal/service/user.go | 43 ++++++- server/scripts/build.sh | 12 +- 11 files changed, 549 insertions(+), 179 deletions(-) diff --git a/server/cmd/main.go b/server/cmd/main.go index cc10667..e5c07ba 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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) + } } // 启动服务器 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 02dfd70..d1be239 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,11 +10,13 @@ type Config struct { Redis RedisConfig JWT JWTConfig Wechat WechatConfig + Admin AdminConfig } type ServerConfig struct { - Port string - Env string + 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"), + 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", ""), + }, } } diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index 0a75e69..24b7aee 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -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") } diff --git a/server/internal/handler/order.go b/server/internal/handler/order.go index 58b3af7..99d6a93 100644 --- a/server/internal/handler/order.go +++ b/server/internal/handler/order.go @@ -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 } diff --git a/server/internal/handler/pay.go b/server/internal/handler/pay.go index 020f910..f9bd370 100644 --- a/server/internal/handler/pay.go +++ b/server/internal/handler/pay.go @@ -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 { @@ -20,11 +26,9 @@ 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"` // 许愿内容(许愿类型需要) + Type string `json:"type" binding:"required"` // wish:许愿 vip:会员 + ProductID uint `json:"productId" binding:"required"` // 商品ID + Content string `json:"content"` // 许愿内容(许愿类型需要) } if err := c.ShouldBindJSON(&req); err != nil { @@ -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,15 +146,32 @@ func PayNotify(c *gin.Context) { return } - if req.Status == "SUCCESS" { - orderService := service.NewOrderService() - if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": "FAIL", - "msg": "处理失败", - }) - return - } + // 验签:恒定时间比较,防时序攻击 + 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{ + "code": "FAIL", + "msg": "处理失败", + }) + return } c.JSON(http.StatusOK, gin.H{ @@ -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, diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go index fd94769..97cdb09 100644 --- a/server/internal/handler/user.go +++ b/server/internal/handler/user.go @@ -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 { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": "创建用户失败", - }) - return - } + 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 微信授权(仅返回 openid,session_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", + "openid": openID, }, }) } diff --git a/server/internal/handler/version.go b/server/internal/handler/version.go index 2c4860f..06d6b2c 100644 --- a/server/internal/handler/version.go +++ b/server/internal/handler/version.go @@ -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(), }) } diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index 2f89ca3..7f97609 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -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) { - c.Header("Access-Control-Allow-Origin", "*") - 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") + 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, X-Pay-Sign") + } if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) @@ -25,40 +54,38 @@ func CORS() gin.HandlerFunc { } } -// Logger 日志中间件 -func Logger() gin.HandlerFunc { - return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { - return "" - }) +// 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{ + "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认证中间件 func Auth() gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{ - "code": 401, - "msg": "未授权", - }) - c.Abort() + tokenString, ok := bearerToken(c) + if !ok { 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() 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 的 JWT(Authorization 头或 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() } } diff --git a/server/internal/service/order.go b/server/internal/service/order.go index 271f043..3106b87 100644 --- a/server/internal/service/order.go +++ b/server/internal/service/order.go @@ -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 } diff --git a/server/internal/service/user.go b/server/internal/service/user.go index e996f94..13d2ce8 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -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 签发管理员 JWT(12 小时过期) +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" +} diff --git a/server/scripts/build.sh b/server/scripts/build.sh index 5c5907d..813fde9 100644 --- a/server/scripts/build.sh +++ b/server/scripts/build.sh @@ -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"