package handler import ( "errors" "log" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/util" ) // isMobileUA 判断是否手机浏览器(词表与 pay 侧 handler/order.go 保持一致)。 func isMobileUA(ua string) bool { ua = strings.ToLower(ua) for _, kw := range []string{"android", "iphone", "ipod", "mobile", "harmony", "windows phone"} { if strings.Contains(ua, kw) { return true } } return false } type PayHandler struct { svc *service.PayService } func NewPayHandler(svc *service.PayService) *PayHandler { return &PayHandler{svc: svc} } // Purchase POST /api/v1/license/purchase — 在线购买/续费下单,返回收银台 pay_url。 // 仅管理员可购买(handler 内判权,同 withdraw 模式)。 func (h *PayHandler) Purchase(c *gin.Context) { role := middleware.GetRole(c) if role != "admin" && role != "superadmin" { c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可购买授权"}) return } var req struct { BizCode string `json:"biz_code" binding:"required"` // 终端类型(可选,"pc"/"mobile"):App 显式声明优先;缺省按本请求 UA 判断 // (官网浏览器购买的 UA 是真实终端 UA)。透传给 pay 决定收银台形态。 ClientType string `json:"client_type" binding:"omitempty,oneof=pc mobile"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } clientType := req.ClientType if clientType == "" { if isMobileUA(c.Request.UserAgent()) { clientType = "mobile" } else { clientType = "pc" } } res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode, clientType) if err != nil { switch { case errors.Is(err, service.ErrPayNotConfigured): c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrUnknownPlan): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPromoUsed): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err) c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"}) } return } util.RespondSuccess(c, res) } // PurchaseStatus GET /api/v1/license/purchase/:out_trade_no — 结果页轮询购买单状态。 func (h *PayHandler) PurchaseStatus(c *gin.Context) { st, err := h.svc.Status(middleware.GetShopID(c), c.Param("out_trade_no")) if err != nil { if errors.Is(err, service.ErrPurchaseNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, st) } // PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。 func (h *PayHandler) PromoStatus(c *gin.Context) { used, err := h.svc.PromoUsed(middleware.GetShopID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, gin.H{"used": used}) } // Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。 // 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。 func (h *PayHandler) Callback(c *gin.Context) { rawBody, err := c.GetRawData() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "read body failed"}) return } err = h.svc.HandleCallback(rawBody, c.GetHeader("X-Pay-Timestamp"), c.GetHeader("X-Pay-Nonce"), c.GetHeader("X-Pay-Sign")) if err != nil { if errors.Is(err, service.ErrPaySignature) { c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "signature verification failed"}) return } // 其余错误(暂时性/金额不符/单不存在)回非 SUCCESS,让 pay 重试;金额不符会持续失败并留日志告警 log.Printf("[pay] callback rejected: %v", err) c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) }