Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
273 lines
11 KiB
Go
273 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
)
|
||
|
||
var reportIDPattern = regexp.MustCompile(`^ZM-[A-Z0-9]{7,32}$`)
|
||
var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7]{4}$`)
|
||
|
||
func SaveChartRecord(c *gin.Context) {
|
||
var request struct {
|
||
ReportID string `json:"reportId" binding:"required"`
|
||
Name string `json:"name"`
|
||
BirthDate string `json:"birthDate" binding:"required"`
|
||
BirthTime string `json:"birthTime" binding:"required"`
|
||
Gender string `json:"gender" binding:"required"`
|
||
City string `json:"city" binding:"required"`
|
||
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) < 2 || len(request.Snapshot) > 512*1024 || request.Snapshot[0] != '{' {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
|
||
return
|
||
}
|
||
record := &model.BaziChartRecord{
|
||
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
|
||
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, Snapshot: string(request.Snapshot),
|
||
}
|
||
if userID, exists := c.Get("userID"); exists {
|
||
if value, ok := userID.(uint); ok {
|
||
record.UserID = &value
|
||
}
|
||
}
|
||
if err := service.NewCommerceService().CreateChartRecord(record); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "保存排盘记录失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"recordId": record.ID}})
|
||
}
|
||
|
||
func CreateReportCheckout(c *gin.Context) {
|
||
userID, _ := c.Get("userID")
|
||
var request struct {
|
||
Provider string `json:"provider" binding:"required"`
|
||
ReportID string `json:"reportId" binding:"required"`
|
||
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) > 256*1024 || len(request.Snapshot) < 2 || request.Snapshot[0] != '{' {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "支付参数错误"})
|
||
return
|
||
}
|
||
user, err := service.NewUserService().GetUserByID(userID.(uint))
|
||
if err != nil || user.Email == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "请先完成邮箱登录"})
|
||
return
|
||
}
|
||
commerce := service.NewCommerceService()
|
||
product, err := commerce.GetActiveReportProduct()
|
||
if err != nil {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "商品暂不可购买"})
|
||
return
|
||
}
|
||
order, err := commerce.CreateReportOrder(user.ID, user.Email, request.ReportID, request.Provider, string(request.Snapshot), product)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()})
|
||
return
|
||
}
|
||
cfg := config.Load()
|
||
providerConfigured := (request.Provider == "stripe" && cfg.Stripe.SecretKey != "") ||
|
||
(request.Provider == "binance_pay" && cfg.BinancePay.APIKey != "")
|
||
// 开发模式:支付渠道未配置时走本地模拟支付,方便完整联调
|
||
if !providerConfigured && cfg.Server.Env != "production" {
|
||
mockURL := strings.TrimRight(cfg.Commerce.WebAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo)
|
||
if err := commerce.SetCheckout(order.OrderNo, "DEV-"+order.OrderNo, mockURL); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"})
|
||
return
|
||
}
|
||
// 模拟 webhook:立即发放权益(Stripe 金额用 cents,Binance 用 USDT 数量)
|
||
mockPaidAmount := order.ProviderAmount
|
||
if request.Provider == "stripe" {
|
||
mockPaidAmount = strconv.Itoa(order.Amount)
|
||
}
|
||
_ = commerce.CompletePayment(request.Provider, "dev-"+order.OrderNo, "dev.mock.paid", order.OrderNo, "DEV-"+order.OrderNo, "DEV-TX-"+order.OrderNo, mockPaidAmount, order.Currency, "dev-mock")
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{
|
||
"orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount,
|
||
"currency": order.Currency, "checkoutUrl": mockURL, "expiresAt": order.ExpireTime, "demo": true,
|
||
}})
|
||
return
|
||
}
|
||
var checkout *service.CheckoutResult
|
||
if request.Provider == "stripe" {
|
||
checkout, err = service.NewStripeProvider(cfg.Stripe).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL)
|
||
} else if request.Provider == "binance_pay" {
|
||
checkout, err = service.NewBinancePayProvider(cfg.BinancePay).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL, cfg.Commerce.PublicBaseURL)
|
||
} else {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "不支持的支付方式"})
|
||
return
|
||
}
|
||
if err != nil {
|
||
_ = commerce.MarkOrderStatus(request.Provider, "create-failed:"+order.OrderNo, "checkout.create_failed", order.OrderNo, "cancelled", "")
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "支付渠道暂时不可用"})
|
||
return
|
||
}
|
||
if err := commerce.SetCheckout(order.OrderNo, checkout.ProviderOrderID, checkout.CheckoutURL); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{
|
||
"orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount,
|
||
"currency": order.Currency, "checkoutUrl": checkout.CheckoutURL, "expiresAt": order.ExpireTime,
|
||
}})
|
||
}
|
||
|
||
func StripeWebhook(c *gin.Context) {
|
||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||
if err != nil {
|
||
c.Status(http.StatusBadRequest)
|
||
return
|
||
}
|
||
event, err := service.NewStripeProvider(config.Load().Stripe).ParseWebhook(raw, c.GetHeader("Stripe-Signature"), time.Now())
|
||
if err != nil {
|
||
c.Status(http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := processCommerceWebhook("stripe", event); err != nil {
|
||
c.Status(http.StatusInternalServerError)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"received": true})
|
||
}
|
||
|
||
func BinancePayWebhook(c *gin.Context) {
|
||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||
if err != nil {
|
||
c.Status(http.StatusBadRequest)
|
||
return
|
||
}
|
||
event, err := service.NewBinancePayProvider(config.Load().BinancePay).ParseWebhook(
|
||
raw, c.GetHeader("BinancePay-Timestamp"), c.GetHeader("BinancePay-Nonce"), c.GetHeader("BinancePay-Signature"),
|
||
)
|
||
if err != nil {
|
||
c.Status(http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := processCommerceWebhook("binance_pay", event); err != nil {
|
||
c.Status(http.StatusInternalServerError)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"returnCode": "SUCCESS", "returnMessage": nil})
|
||
}
|
||
|
||
func processCommerceWebhook(provider string, event *service.PaymentWebhook) error {
|
||
if event.Status == "ignored" {
|
||
return nil
|
||
}
|
||
commerce := service.NewCommerceService()
|
||
if event.Status == "refunded" {
|
||
return commerce.RefundPayment(provider, event.EventID, event.EventType, event.OrderNo, event.TransactionID, event.PayloadHash)
|
||
}
|
||
if event.OrderNo == "" {
|
||
return nil
|
||
}
|
||
if event.Status == "paid" {
|
||
return commerce.CompletePayment(provider, event.EventID, event.EventType, event.OrderNo, event.ProviderOrderID, event.TransactionID, event.Amount, event.Currency, event.PayloadHash)
|
||
}
|
||
return commerce.MarkOrderStatus(provider, event.EventID, event.EventType, event.OrderNo, event.Status, event.PayloadHash)
|
||
}
|
||
|
||
func GetCommerceOrder(c *gin.Context) {
|
||
userID, _ := c.Get("userID")
|
||
order, err := service.NewCommerceService().GetUserOrder(userID.(uint), c.Param("orderNo"))
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "订单不存在"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": order})
|
||
}
|
||
|
||
func GetReportAccess(c *gin.Context) {
|
||
userID, _ := c.Get("userID")
|
||
reportID := c.Param("reportId")
|
||
if !reportIDPattern.MatchString(reportID) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "报告编号错误"})
|
||
return
|
||
}
|
||
access, err := service.NewCommerceService().HasReportAccess(userID.(uint), reportID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "查询失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": reportID, "unlocked": access}})
|
||
}
|
||
|
||
func GetReportByAccessCode(c *gin.Context) {
|
||
userID, _ := c.Get("userID")
|
||
accessCode := c.Param("accessCode")
|
||
if !accessCodePattern.MatchString(accessCode) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "恢复码格式错误"})
|
||
return
|
||
}
|
||
record, err := service.NewCommerceService().GetReportSnapshot(userID.(uint), accessCode)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "未找到已购买报告"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": record.ReportID, "snapshot": json.RawMessage(record.Snapshot)}})
|
||
}
|
||
|
||
func ListPurchasedReports(c *gin.Context) {
|
||
userID, _ := c.Get("userID")
|
||
reports, err := service.NewCommerceService().ListPurchasedReports(userID.(uint))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取报告失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": reports}})
|
||
}
|
||
|
||
func AdminCommerceOrders(c *gin.Context) {
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
filter := service.AdminOrderFilter{Page: page, PageSize: pageSize, Status: c.Query("status"), Provider: c.Query("provider"), Search: c.Query("search")}
|
||
orders, total, err := service.NewCommerceService().AdminOrders(filter)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取订单失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": orders, "total": total, "page": page, "pageSize": pageSize}})
|
||
}
|
||
|
||
func AdminChartRecords(c *gin.Context) {
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 50
|
||
}
|
||
records, total, err := service.NewCommerceService().AdminChartRecords(c.Query("search"), page, pageSize)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取排盘记录失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": records, "total": total, "page": page, "pageSize": pageSize}})
|
||
}
|
||
|
||
func AdminCommerceStats(c *gin.Context) {
|
||
stats, err := service.NewCommerceService().AdminStats()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取统计失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": stats})
|
||
}
|