Files
jiu/backend/internal/handler/pay.go
T
wangjia 18925d7d15 feat(backend): 在线购买/续费对接 pay 收款中枢——下单/webhook 验签入账/续期叠加/查单兜底
- POST /license/purchase(仅管理员)建单并调 pay 下单,返回收银台 pay_url
- POST /pay/callback 公开接收器:HMAC 验签+时间戳窗口+按 out_trade_no 幂等+金额逐分核对,同事务续期
- 续期与兑换券同口径:未过期从到期日叠加、已过期从现在起算,写入 tier/max_devices/features
- 后台每 60s 查单兜底防 webhook 丢失;closed 标 failed
- 新表 license_purchases(schema.sql/AutoMigrate/testutil 同步);配置 PAY_SECRET/PAY_BASE_URL/PAY_RETURN_URL
- 契约真相源 ~/code/pay-contract openapi.yaml v1.0.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 20:40:06 +08:00

91 lines
3.0 KiB
Go

package handler
import (
"errors"
"log"
"net/http"
"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"
)
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"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode)
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()})
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)
}
// 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"})
}