初始提交:岩美 pay 收款服务(支付宝当面付 + 多商户多渠道架构)
- Go/Gin/GORM + 纯 Go SQLite(无 cgo) - Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位 - 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底 - 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook - 密钥走环境变量/Bitwarden,不入库 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/wangjia/pay/internal/service"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
svc *service.OrderService
|
||||
}
|
||||
|
||||
func NewOrderHandler(svc *service.OrderService) *OrderHandler {
|
||||
return &OrderHandler{svc: svc}
|
||||
}
|
||||
|
||||
type createOrderRequest struct {
|
||||
ProductID uint64 `json:"product_id" binding:"required"`
|
||||
}
|
||||
|
||||
// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。
|
||||
func (h *OrderHandler) Create(c *gin.Context) {
|
||||
var req createOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP())
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrProductNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
|
||||
return
|
||||
}
|
||||
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{
|
||||
"pay_url": payURL,
|
||||
"out_trade_no": order.OutTradeNo,
|
||||
"amount": order.Amount,
|
||||
"subject": order.Subject,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateQR POST /api/v1/orders/qr —— 扫码下单,返回二维码码串(前端渲染成二维码)。
|
||||
func (h *OrderHandler) CreateQR(c *gin.Context) {
|
||||
var req createOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
qr, order, err := h.svc.CreateQR(c.Request.Context(), req.ProductID, c.ClientIP())
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrProductNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
|
||||
return
|
||||
}
|
||||
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{
|
||||
"qr_code": qr,
|
||||
"out_trade_no": order.OutTradeNo,
|
||||
"amount": order.Amount,
|
||||
"subject": order.Subject,
|
||||
})
|
||||
}
|
||||
|
||||
// AlipayNotify POST /api/v1/notify/alipay —— 异步回调。
|
||||
// 成功必须返回纯文本 "success",否则支付宝会按策略重发。
|
||||
func (h *OrderHandler) AlipayNotify(c *gin.Context) {
|
||||
if err := h.svc.HandleAlipayNotify(c.Request.Context(), c.Request); err != nil {
|
||||
c.String(http.StatusOK, "failure") // 回 failure 让支付宝重试(也可记录后人工处理)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。
|
||||
func (h *OrderHandler) GetStatus(c *gin.Context) {
|
||||
out := c.Param("out_trade_no")
|
||||
o, err := h.svc.GetByOutTradeNo(out)
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{
|
||||
"out_trade_no": o.OutTradeNo,
|
||||
"subject": o.Subject,
|
||||
"amount": o.Amount,
|
||||
"status": o.Status,
|
||||
"trade_no": o.TradeNo,
|
||||
"paid_at": o.PaidAt,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
)
|
||||
|
||||
type PageHandler struct {
|
||||
db *gorm.DB
|
||||
webDir string
|
||||
}
|
||||
|
||||
func NewPageHandler(db *gorm.DB, webDir string) *PageHandler {
|
||||
return &PageHandler{db: db, webDir: webDir}
|
||||
}
|
||||
|
||||
// PayPage GET / —— 收款页。
|
||||
func (h *PageHandler) PayPage(c *gin.Context) {
|
||||
c.File(h.webDir + "/pay.html")
|
||||
}
|
||||
|
||||
// ResultPage GET /result —— 支付结果页(return_url 落地)。
|
||||
func (h *PageHandler) ResultPage(c *gin.Context) {
|
||||
c.File(h.webDir + "/result.html")
|
||||
}
|
||||
|
||||
// QRCode GET /qrcode?text=xxx —— 把任意字符串渲染成二维码 PNG(用于展示支付宝扫码码串)。
|
||||
func (h *PageHandler) QRCode(c *gin.Context) {
|
||||
text := c.Query("text")
|
||||
if text == "" {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 text 参数")
|
||||
return
|
||||
}
|
||||
png, err := qrcode.Encode(text, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusInternalServerError, "qr_failed", err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/png", png)
|
||||
}
|
||||
|
||||
// ListProducts GET /api/v1/products —— 上架套餐列表(金额来自服务端)。
|
||||
func (h *PageHandler) ListProducts(c *gin.Context) {
|
||||
var products []model.Product
|
||||
if err := h.db.Where("active = ?", true).Order("sort asc, id asc").Find(&products).Error; err != nil {
|
||||
util.RespondError(c, http.StatusInternalServerError, "list_failed", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(products))
|
||||
for _, p := range products {
|
||||
out = append(out, gin.H{
|
||||
"id": p.ID,
|
||||
"name": p.Name,
|
||||
"description": p.Description,
|
||||
"price": p.Price,
|
||||
})
|
||||
}
|
||||
util.RespondSuccess(c, out)
|
||||
}
|
||||
Reference in New Issue
Block a user