- 前端免费排盘默认不保存,勾选同意后才调用保存接口 - 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口 - 付费结算单独披露报告快照与订单信息将由服务端存储 - 管理端展示同意版本与时间,历史无同意证据记录保留并标注 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
291 lines
12 KiB
Go
291 lines
12 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"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"`
|
||
ConsentVersion string `json:"consentVersion" binding:"required"`
|
||
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 600*1024)
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
|
||
return
|
||
}
|
||
snapshot := bytes.TrimSpace(request.Snapshot)
|
||
var snapshotObject map[string]json.RawMessage
|
||
birthDate, dateErr := time.Parse("2006-01-02", request.BirthDate)
|
||
_, timeErr := time.Parse("15:04", request.BirthTime)
|
||
if !reportIDPattern.MatchString(request.ReportID) || len(snapshot) < 2 || len(snapshot) > 512*1024 ||
|
||
json.Unmarshal(snapshot, &snapshotObject) != nil || snapshotObject == nil ||
|
||
dateErr != nil || birthDate.Year() < 1901 || birthDate.Year() > 2100 || timeErr != nil ||
|
||
(request.Gender != "male" && request.Gender != "female") || request.ConsentVersion != service.ChartRecordConsentVersion ||
|
||
utf8.RuneCountInString(request.Name) > 64 || utf8.RuneCountInString(request.City) > 64 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误或未同意保存"})
|
||
return
|
||
}
|
||
consentedAt := time.Now().UTC()
|
||
record := &model.BaziChartRecord{
|
||
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
|
||
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City,
|
||
ConsentVersion: request.ConsentVersion, ConsentAt: &consentedAt, Snapshot: string(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})
|
||
}
|