- 新增商业化模块: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
This commit is contained in:
@@ -92,9 +92,14 @@ func AdminUsers(c *gin.Context) {
|
||||
adminPage(c, "用户管理", "users")
|
||||
}
|
||||
|
||||
// AdminOrders 订单管理
|
||||
// AdminOrders PC 商业版订单管理
|
||||
func AdminOrders(c *gin.Context) {
|
||||
adminPage(c, "订单管理", "orders")
|
||||
adminPage(c, "PC 报告订单", "orders")
|
||||
}
|
||||
|
||||
// AdminCharts 排盘记录
|
||||
func AdminCharts(c *gin.Context) {
|
||||
adminPage(c, "排盘记录", "charts")
|
||||
}
|
||||
|
||||
// AdminWishes 许愿管理
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
"github.com/gouki/lunar-server/internal/middleware"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
func TestCommerceCheckoutRequiresAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"result":"test"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":401`) {
|
||||
t.Fatalf("unexpected response body: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeWebhookRejectsUnsignedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(`{"id":"evt_invalid"}`))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailLoginRejectsMalformedBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/auth/email/request", RequestEmailLoginCode)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":400`) {
|
||||
t.Fatalf("unexpected response body: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stripeAPI := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(response, `{"id":"cs_test_flow","url":"https://checkout.stripe.test/flow"}`)
|
||||
}))
|
||||
defer stripeAPI.Close()
|
||||
|
||||
t.Setenv("DB_TYPE", "sqlite")
|
||||
t.Setenv("DB_NAME", "file:commerce-http-test?mode=memory&cache=shared")
|
||||
t.Setenv("JWT_SECRET", "http-test-secret")
|
||||
t.Setenv("STRIPE_SECRET_KEY", "sk_test")
|
||||
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_http_test")
|
||||
t.Setenv("STRIPE_API_BASE_URL", stripeAPI.URL)
|
||||
t.Setenv("WEB_APP_URL", "https://example.test")
|
||||
if err := config.InitDB(config.Load()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{OpenID: "email:http-test", Email: "buyer@example.com", Nickname: "buyer", Status: 1}
|
||||
if err := config.GetDB().Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := service.NewUserService().GenerateToken(user.ID, "http-test-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
|
||||
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
|
||||
router.GET("/api/commerce/orders/:orderNo", middleware.Auth(), GetCommerceOrder)
|
||||
router.GET("/api/commerce/reports/:reportId/access", middleware.Auth(), GetReportAccess)
|
||||
router.GET("/api/commerce/reports/recover/:accessCode", middleware.Auth(), GetReportByAccessCode)
|
||||
|
||||
checkoutRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"id":"ZM-ABCDEFG","result":{"eightChar":{"dayMaster":"甲","dayMasterElement":"木"},"decadeFortunes":[{"ganzhi":"丙寅","startAge":8}]},"elements":{"dominant":"木","weakest":"金"},"shensha":[],"sections":[]}}`))
|
||||
checkoutRequest.Header.Set("Content-Type", "application/json")
|
||||
checkoutRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
checkoutResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(checkoutResponse, checkoutRequest)
|
||||
if checkoutResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected checkout 200, got %d: %s", checkoutResponse.Code, checkoutResponse.Body.String())
|
||||
}
|
||||
var checkoutPayload struct {
|
||||
Data struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(checkoutResponse.Body.Bytes(), &checkoutPayload); err != nil || checkoutPayload.Data.OrderNo == "" {
|
||||
t.Fatalf("invalid checkout response: %s", checkoutResponse.Body.String())
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
webhookBody := []byte(fmt.Sprintf(`{"id":"evt_http_flow","type":"checkout.session.completed","data":{"object":{"id":"cs_test_flow","payment_intent":"pi_http_flow","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"%s"}}}}`, checkoutPayload.Data.OrderNo))
|
||||
mac := hmac.New(sha256.New, []byte("whsec_http_test"))
|
||||
mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), webhookBody)))
|
||||
webhookRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(string(webhookBody)))
|
||||
webhookRequest.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil))))
|
||||
webhookResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(webhookResponse, webhookRequest)
|
||||
if webhookResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected webhook 200, got %d: %s", webhookResponse.Code, webhookResponse.Body.String())
|
||||
}
|
||||
|
||||
accessRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/ZM-ABCDEFG/access", nil)
|
||||
accessRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
accessResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(accessResponse, accessRequest)
|
||||
if accessResponse.Code != http.StatusOK || !strings.Contains(accessResponse.Body.String(), `"unlocked":true`) {
|
||||
t.Fatalf("expected active entitlement, got %d: %s", accessResponse.Code, accessResponse.Body.String())
|
||||
}
|
||||
|
||||
orderRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/orders/"+checkoutPayload.Data.OrderNo, nil)
|
||||
orderRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
orderResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(orderResponse, orderRequest)
|
||||
var orderPayload struct {
|
||||
Data struct {
|
||||
AccessCode string `json:"accessCode"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(orderResponse.Body.Bytes(), &orderPayload); err != nil || orderPayload.Data.AccessCode == "" {
|
||||
t.Fatalf("expected server recovery code, got %s", orderResponse.Body.String())
|
||||
}
|
||||
|
||||
recoverRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/recover/"+orderPayload.Data.AccessCode, nil)
|
||||
recoverRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
recoverResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(recoverResponse, recoverRequest)
|
||||
if recoverResponse.Code != http.StatusOK || !strings.Contains(recoverResponse.Body.String(), `"title":"性格底色"`) {
|
||||
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
func RequestEmailLoginCode(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"})
|
||||
return
|
||||
}
|
||||
err := service.NewEmailAuthService().RequestCode(request.Email)
|
||||
if errors.Is(err, service.ErrInvalidEmail) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLoginCodeRateLimited) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请稍后再获取验证码"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "验证码暂时无法发送"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "验证码已发送"})
|
||||
}
|
||||
|
||||
func VerifyEmailLoginCode(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
|
||||
return
|
||||
}
|
||||
user, token, err := service.NewEmailAuthService().VerifyCode(request.Email, request.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "验证码无效或已过期"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"token": token, "user": user}})
|
||||
}
|
||||
@@ -183,6 +183,7 @@ func PayNotify(c *gin.Context) {
|
||||
// GetPayStatus 获取支付状态(支持订单号或订单 ID)
|
||||
func GetPayStatus(c *gin.Context) {
|
||||
param := c.Param("orderId")
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
order, err := orderService.GetOrderByOrderNo(param)
|
||||
@@ -192,7 +193,7 @@ func GetPayStatus(c *gin.Context) {
|
||||
order, err = orderService.GetOrderByID(uint(id))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil || order.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"msg": "订单不存在",
|
||||
|
||||
Reference in New Issue
Block a user