7e99a7a7ee
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
6.1 KiB
Go
181 lines
6.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"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)
|
|
}
|
|
|
|
// Cancel POST /api/v1/license/purchase/:out_trade_no/cancel — 取消本店一笔购买单
|
|
// (防 pending 单堆积挤占 reconcile 每轮 50 条限额)。仅管理员可用(handler 内判权,同 Purchase)。
|
|
func (h *PayHandler) Cancel(c *gin.Context) {
|
|
role := middleware.GetRole(c)
|
|
if role != "admin" && role != "superadmin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可取消购买单"})
|
|
return
|
|
}
|
|
canceled, err := h.svc.CancelPurchase(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
|
|
}
|
|
log.Printf("[pay] cancel failed shop=%d out_trade_no=%s: %v", middleware.GetShopID(c), c.Param("out_trade_no"), err)
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "取消失败,请稍后重试"})
|
|
return
|
|
}
|
|
util.RespondSuccess(c, gin.H{"canceled": canceled})
|
|
}
|
|
|
|
// Purchases GET /api/v1/license/purchases — 订单管理 tab 数据源:分页/status 筛选/汇总。
|
|
// 仅管理员可查看(handler 内判权,同 Purchase)。
|
|
func (h *PayHandler) Purchases(c *gin.Context) {
|
|
role := middleware.GetRole(c)
|
|
if role != "admin" && role != "superadmin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可查看订单"})
|
|
return
|
|
}
|
|
page, _ := strconv.Atoi(c.Query("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
status := c.Query("status")
|
|
|
|
list, err := h.svc.ListPurchases(middleware.GetShopID(c), page, pageSize, status)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrInvalidStatus) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
util.RespondSuccess(c, list)
|
|
}
|
|
|
|
// 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"})
|
|
}
|