Files
lunar-mini/server/internal/handler/commerce.go
T
gouki deb32f8fab
Build and Publish Server / build (push) Successful in 4m30s
feat(server): 商业化支付与订单系统 + Harness 改进
- 新增商业化模块: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
2026-09-19 18:57:23 +00:00

273 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}