feat(server): 商业化支付与订单系统 + Harness 改进
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
This commit is contained in:
gouki
2026-09-19 18:57:23 +00:00
parent 241a04496b
commit deb32f8fab
52 changed files with 5419 additions and 339 deletions
+55 -7
View File
@@ -6,13 +6,17 @@ import (
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
JWT JWTConfig
Wechat WechatConfig
Admin AdminConfig
Wiki WikiConfig
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
JWT JWTConfig
Wechat WechatConfig
Stripe StripeConfig
BinancePay BinancePayConfig
Postmark PostmarkConfig
Commerce CommerceConfig
Admin AdminConfig
Wiki WikiConfig
}
type ServerConfig struct {
@@ -48,6 +52,30 @@ type WechatConfig struct {
MchID string
}
type StripeConfig struct {
SecretKey string
WebhookSecret string
APIBaseURL string
}
type BinancePayConfig struct {
APIKey string
SecretKey string
APIBaseURL string
WebhookPublicKey string
}
type PostmarkConfig struct {
ServerToken string
FromEmail string
APIBaseURL string
}
type CommerceConfig struct {
PublicBaseURL string
WebAppURL string
}
// AdminConfig 管理后台配置
type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录
@@ -92,6 +120,26 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""),
},
Stripe: StripeConfig{
SecretKey: getEnv("STRIPE_SECRET_KEY", ""),
WebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
APIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"),
},
BinancePay: BinancePayConfig{
APIKey: getEnv("BINANCE_PAY_API_KEY", ""),
SecretKey: getEnv("BINANCE_PAY_SECRET_KEY", ""),
APIBaseURL: getEnv("BINANCE_PAY_API_BASE_URL", "https://bpay.binanceapi.com"),
WebhookPublicKey: getEnv("BINANCE_PAY_WEBHOOK_PUBLIC_KEY", ""),
},
Postmark: PostmarkConfig{
ServerToken: getEnv("POSTMARK_SERVER_TOKEN", ""),
FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""),
APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"),
},
Commerce: CommerceConfig{
PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"),
WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"),
},
Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""),
},
+15 -1
View File
@@ -5,9 +5,9 @@ import (
"log"
"github.com/glebarez/sqlite"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/gouki/lunar-server/internal/model"
)
var DB *gorm.DB
@@ -56,6 +56,12 @@ func autoMigrate() error {
&model.BirthProfile{},
&model.Order{},
&model.OrderItem{},
&model.ReportProduct{},
&model.ReportEntitlement{},
&model.ReportSnapshot{},
&model.PaymentEvent{},
&model.EmailLoginCode{},
&model.BaziChartRecord{},
&model.WishTree{},
&model.Wish{},
&model.WishProduct{},
@@ -78,6 +84,14 @@ func autoMigrate() error {
// seedDefaultData 初始化默认数据
func seedDefaultData() error {
reportProduct := model.ReportProduct{
Code: "bazi_full_report", Name: "八字完整精批报告", Description: "六大主题与十步大运完整解读",
StripeAmount: 499, StripeCurrency: "USD", BinanceAmount: "5.00", BinanceCurrency: "USDT", Status: 1,
}
if err := DB.Where("code = ?", reportProduct.Code).FirstOrCreate(&reportProduct).Error; err != nil {
return err
}
// 补充已有许愿树的背景图(新增字段后老数据为空)
if err := DB.Model(&model.WishTree{}).
Where("image = '' OR image IS NULL").
+7 -2
View File
@@ -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 许愿管理
+272
View File
@@ -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})
}
+157
View File
@@ -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())
}
}
+50
View File
@@ -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}})
}
+2 -1
View File
@@ -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": "订单不存在",
+25 -1
View File
@@ -3,6 +3,8 @@ package middleware
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
@@ -146,10 +148,32 @@ func AdminAuth() gin.HandlerFunc {
}
}
var rateLimitStore = struct {
sync.Mutex
requests map[string][]time.Time
}{requests: make(map[string][]time.Time)}
// RateLimit 限流中间件
func RateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
// TODO: 实现限流
now := time.Now()
key := c.ClientIP() + "|" + c.FullPath()
rateLimitStore.Lock()
windowStart := now.Add(-time.Minute)
recent := rateLimitStore.requests[key][:0]
for _, requestTime := range rateLimitStore.requests[key] {
if requestTime.After(windowStart) {
recent = append(recent, requestTime)
}
}
if len(recent) >= 10 {
rateLimitStore.requests[key] = recent
rateLimitStore.Unlock()
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请求过于频繁"})
return
}
rateLimitStore.requests[key] = append(recent, now)
rateLimitStore.Unlock()
c.Next()
}
}
@@ -0,0 +1,152 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRateLimitAllowsUnderLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code)
}
}
}
func TestRateLimitBlocksOverLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 发送 11 个请求,第 11 个应被限流
for i := 0; i < 11; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.2:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if i < 10 && resp.Code != http.StatusOK {
t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code)
}
if i == 10 && resp.Code != http.StatusTooManyRequests {
t.Fatalf("request 11 should be rate limited, got %d", resp.Code)
}
}
}
func TestRateLimitWindowReset(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 先发送 10 个请求达到上限
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.3:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
}
// 模拟时间过去 61 秒(超过 1 分钟窗口)
// 注意:由于实现使用真实时间,这里只能验证当前窗口内的行为
// 实际窗口重置测试需要 mock 时间或等待
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.3:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusTooManyRequests {
t.Fatalf("should still be rate limited within window, got %d", resp.Code)
}
}
func TestAuthRejectsMissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth())
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.Code)
}
}
func TestAuthRejectsMalformedToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth())
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "InvalidFormat token123")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.Code)
}
}
func TestCORSAllowsConfiguredOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com")
router := gin.New()
router.Use(CORS())
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.Code)
}
if resp.Header().Get("Access-Control-Allow-Origin") != "https://example.com" {
t.Fatalf("expected CORS header for allowed origin, got %s", resp.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestCORSRejectsUnconfiguredOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("CORS_ORIGINS", "https://example.com")
router := gin.New()
router.Use(CORS())
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://evil.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// 非白名单源不应返回 CORS 头
if resp.Header().Get("Access-Control-Allow-Origin") == "https://evil.com" {
t.Fatal("should not allow unconfigured origin")
}
}
+74
View File
@@ -0,0 +1,74 @@
package model
import "time"
type BaziChartRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ReportID string `gorm:"index;size:64" json:"reportId"`
UserID *uint `gorm:"index" json:"userId,omitempty"`
Name string `gorm:"size:64" json:"name"`
BirthDate string `gorm:"size:16" json:"birthDate"`
BirthTime string `gorm:"size:8" json:"birthTime"`
Gender string `gorm:"size:16" json:"gender"`
City string `gorm:"size:64" json:"city"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportProduct struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"uniqueIndex;size:40" json:"code"`
Name string `gorm:"size:100" json:"name"`
Description string `gorm:"size:255" json:"description"`
StripeAmount int `json:"stripeAmount"`
StripeCurrency string `gorm:"size:10" json:"stripeCurrency"`
BinanceAmount string `gorm:"size:32" json:"binanceAmount"`
BinanceCurrency string `gorm:"size:10" json:"binanceCurrency"`
Status int `gorm:"default:1" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportEntitlement struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_report;index" json:"userId"`
ReportID string `gorm:"uniqueIndex:idx_user_report;size:64;index" json:"reportId"`
OrderID uint `gorm:"uniqueIndex" json:"orderId"`
Status string `gorm:"size:20;default:'active'" json:"status"`
GrantedAt time.Time `json:"grantedAt"`
RevokedAt *time.Time `json:"revokedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportSnapshot struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"userId"`
ReportID string `gorm:"size:64;index" json:"reportId"`
OrderID uint `gorm:"uniqueIndex;index" json:"orderId"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PaymentEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
Provider string `gorm:"uniqueIndex:idx_provider_event;size:30" json:"provider"`
ProviderEventID string `gorm:"uniqueIndex:idx_provider_event;size:128" json:"providerEventId"`
EventType string `gorm:"size:80" json:"eventType"`
OrderNo string `gorm:"size:32;index" json:"orderNo"`
PayloadHash string `gorm:"size:64" json:"payloadHash"`
ProcessedAt time.Time `json:"processedAt"`
CreatedAt time.Time `json:"createdAt"`
}
type EmailLoginCode struct {
ID uint `gorm:"primaryKey" json:"-"`
Email string `gorm:"size:254;index" json:"-"`
CodeHash string `gorm:"size:64" json:"-"`
Attempts int `gorm:"default:0" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"-"`
UsedAt *time.Time `json:"-"`
CreatedAt time.Time `gorm:"index" json:"-"`
}
+24 -15
View File
@@ -6,19 +6,28 @@ import (
// Order 订单模型
type Order struct {
ID uint `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
UserID uint `gorm:"index" json:"userId"`
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
ProductID uint `json:"productId"`
ProductName string `gorm:"size:100" json:"productName"`
Amount int `json:"amount"` // 金额(分)
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
PayTime *time.Time `json:"payTime"`
ExpireTime *time.Time `json:"expireTime"`
Remark string `gorm:"size:255" json:"remark"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
UserID uint `gorm:"index" json:"userId"`
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
ProductID uint `json:"productId"`
ProductName string `gorm:"size:100" json:"productName"`
Amount int `json:"amount"` // 金额(分)
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
Provider string `gorm:"size:30;index" json:"provider"`
ProviderAmount string `gorm:"size:32" json:"providerAmount"`
ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"`
ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"`
CustomerEmail string `gorm:"size:254;index" json:"customerEmail"`
ReportID string `gorm:"size:64;index" json:"reportId"`
AccessCode *string `gorm:"size:20;uniqueIndex" json:"accessCode,omitempty"`
CheckoutURL string `gorm:"size:1024" json:"checkoutUrl"`
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
PayTime *time.Time `json:"payTime"`
ExpireTime *time.Time `json:"expireTime"`
Remark string `gorm:"size:255" json:"remark"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// OrderItem 订单项
@@ -27,6 +36,6 @@ type OrderItem struct {
OrderID uint `gorm:"index" json:"orderId"`
ProductID uint `json:"productId"`
Name string `gorm:"size:100" json:"name"`
Price int `json:"price"` // 单价(分)
Quantity int `json:"quantity"` // 数量
Price int `json:"price"` // 单价(分)
Quantity int `json:"quantity"` // 数量
}
+30
View File
@@ -0,0 +1,30 @@
package service
import (
"errors"
"strings"
"github.com/gouki/lunar-server/internal/model"
)
func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error {
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" {
return errors.New("incomplete chart record")
}
return s.db.Create(record).Error
}
func (s *CommerceService) AdminChartRecords(search string, page, pageSize int) ([]model.BaziChartRecord, int64, error) {
query := s.db.Model(&model.BaziChartRecord{})
if strings.TrimSpace(search) != "" {
like := "%" + strings.TrimSpace(search) + "%"
query = query.Where("report_id LIKE ? OR name LIKE ? OR city LIKE ?", like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []model.BaziChartRecord
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error
return records, total, err
}
+330
View File
@@ -0,0 +1,330 @@
package service
import (
"crypto/rand"
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
type CommerceService struct {
db *gorm.DB
}
type AdminOrderFilter struct {
Page int
PageSize int
Status string
Provider string
Search string
}
type OrderStats struct {
TotalOrders int64 `json:"totalOrders"`
PaidOrders int64 `json:"paidOrders"`
Pending int64 `json:"pendingOrders"`
Refunded int64 `json:"refundedOrders"`
RevenueUSD int64 `json:"revenueUsdCents"`
}
func NewCommerceService() *CommerceService {
return &CommerceService{db: config.GetDB()}
}
func (s *CommerceService) GetActiveReportProduct() (*model.ReportProduct, error) {
var product model.ReportProduct
if err := s.db.Where("code = ? AND status = 1", "bazi_full_report").First(&product).Error; err != nil {
return nil, err
}
return &product, nil
}
func (s *CommerceService) CreateReportOrder(userID uint, email, reportID, provider, snapshot string, product *model.ReportProduct) (*model.Order, error) {
if provider != "stripe" && provider != "binance_pay" {
return nil, errors.New("unsupported payment provider")
}
var active model.ReportEntitlement
if err := s.db.Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").First(&active).Error; err == nil {
return nil, errors.New("report already unlocked")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
orderNo, err := secureOrderNo()
if err != nil {
return nil, err
}
order := &model.Order{
OrderNo: orderNo, UserID: userID, Type: "bazi_report", ProductID: product.ID,
ProductName: product.Name, Status: "pending", CustomerEmail: strings.ToLower(email), ReportID: reportID,
}
if provider == "stripe" {
order.Amount = product.StripeAmount
order.Currency = product.StripeCurrency
order.ProviderAmount = fmt.Sprintf("%.2f", float64(product.StripeAmount)/100)
} else {
order.Amount = 500
order.Currency = product.BinanceCurrency
order.ProviderAmount = product.BinanceAmount
}
order.Provider = provider
expires := time.Now().Add(30 * time.Minute)
order.ExpireTime = &expires
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
record := model.ReportSnapshot{UserID: userID, ReportID: reportID, OrderID: order.ID, Snapshot: snapshot}
if err := tx.Create(&record).Error; err != nil {
return err
}
return tx.Model(&model.BaziChartRecord{}).Where("report_id = ? AND user_id IS NULL", reportID).Update("user_id", userID).Error
})
if err != nil {
return nil, err
}
return order, nil
}
func (s *CommerceService) SetCheckout(orderNo, providerOrderID, checkoutURL string) error {
return s.db.Model(&model.Order{}).Where("order_no = ? AND status = ?", orderNo, "pending").Updates(map[string]interface{}{
"provider_order_id": providerOrderID,
"checkout_url": checkoutURL,
}).Error
}
func (s *CommerceService) CompletePayment(provider, eventID, eventType, orderNo, providerOrderID, transactionID, paidAmount, currency, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var existing model.PaymentEvent
if err := tx.Where("provider = ? AND provider_event_id = ?", provider, eventID).First(&existing).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var order model.Order
if err := tx.Where("order_no = ? AND provider = ?", orderNo, provider).First(&order).Error; err != nil {
return err
}
if providerOrderID == "" || providerOrderID != order.ProviderOrderID || !strings.EqualFold(currency, order.Currency) || !amountMatches(order, paidAmount) {
return errors.New("payment amount or provider order mismatch")
}
now := time.Now()
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: now}
if err := tx.Create(&event).Error; err != nil {
return err
}
if order.Status == "paid" {
return nil
}
if order.Status != "pending" {
return fmt.Errorf("order %s cannot transition from %s to paid", orderNo, order.Status)
}
accessCode, err := secureAccessCode()
if err != nil {
return err
}
result := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "pending").Updates(map[string]interface{}{
"status": "paid", "pay_time": now, "provider_transaction_id": transactionID, "access_code": accessCode,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("order payment transition conflict")
}
var snapshot model.ReportSnapshot
if err := tx.Where("order_id = ?", order.ID).First(&snapshot).Error; err != nil {
return err
}
premiumSnapshot, err := buildPremiumSnapshot(snapshot.Snapshot)
if err != nil {
return err
}
if err := tx.Model(&snapshot).Update("snapshot", premiumSnapshot).Error; err != nil {
return err
}
entitlement := model.ReportEntitlement{UserID: order.UserID, ReportID: order.ReportID, OrderID: order.ID, Status: "active", GrantedAt: now}
return tx.Where("user_id = ? AND report_id = ?", order.UserID, order.ReportID).FirstOrCreate(&entitlement).Error
})
}
func (s *CommerceService) RefundPayment(provider, eventID, eventType, orderNo, transactionID, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
query := tx.Where("provider = ?", provider)
if orderNo != "" {
query = query.Where("order_no = ?", orderNo)
} else {
query = query.Where("provider_transaction_id = ?", transactionID)
}
var order model.Order
if err := query.First(&order).Error; err != nil {
return err
}
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: order.OrderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()}
if err := tx.Create(&event).Error; err != nil {
return err
}
if order.Status == "refunded" {
return nil
}
if order.Status != "paid" {
return fmt.Errorf("order %s cannot transition from %s to refunded", order.OrderNo, order.Status)
}
now := time.Now()
if err := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "paid").Update("status", "refunded").Error; err != nil {
return err
}
return tx.Model(&model.ReportEntitlement{}).Where("order_id = ? AND status = ?", order.ID, "active").Updates(map[string]interface{}{"status": "revoked", "revoked_at": now}).Error
})
}
func (s *CommerceService) MarkOrderStatus(provider, eventID, eventType, orderNo, status, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()}
if err := tx.Create(&event).Error; err != nil {
return err
}
return tx.Model(&model.Order{}).Where("order_no = ? AND provider = ? AND status = ?", orderNo, provider, "pending").Update("status", status).Error
})
}
func (s *CommerceService) GetUserOrder(userID uint, orderNo string) (*model.Order, error) {
var order model.Order
if err := s.db.Where("user_id = ? AND order_no = ?", userID, orderNo).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
func (s *CommerceService) HasReportAccess(userID uint, reportID string) (bool, error) {
var count int64
err := s.db.Model(&model.ReportEntitlement{}).Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").Count(&count).Error
return count > 0, err
}
func (s *CommerceService) AdminOrders(filter AdminOrderFilter) ([]model.Order, int64, error) {
// PC 商业版后台只显示八字报告订单,隔离小程序许愿等旧业务订单。
query := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report")
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if filter.Provider != "" {
query = query.Where("provider = ?", filter.Provider)
}
if filter.Search != "" {
like := "%" + filter.Search + "%"
query = query.Where("order_no LIKE ? OR customer_email LIKE ? OR report_id LIKE ? OR provider_transaction_id LIKE ?", like, like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var orders []model.Order
err := query.Order("created_at DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&orders).Error
return orders, total, err
}
func (s *CommerceService) AdminStats() (OrderStats, error) {
var stats OrderStats
base := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report")
if err := base.Count(&stats.TotalOrders).Error; err != nil {
return stats, err
}
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "paid").Count(&stats.PaidOrders)
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "pending").Count(&stats.Pending)
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "refunded").Count(&stats.Refunded)
s.db.Model(&model.Order{}).Where("type = ? AND status = ? AND currency = ?", "bazi_report", "paid", "USD").Select("COALESCE(SUM(amount), 0)").Scan(&stats.RevenueUSD)
return stats, nil
}
type PurchasedReport struct {
ReportID string `json:"reportId"`
AccessCode string `json:"accessCode"`
ProductName string `json:"productName"`
PaidAt time.Time `json:"paidAt"`
}
func (s *CommerceService) GetReportSnapshot(userID uint, accessCode string) (*model.ReportSnapshot, error) {
var order model.Order
if err := s.db.Where("user_id = ? AND access_code = ? AND status = ?", userID, strings.ToUpper(accessCode), "paid").First(&order).Error; err != nil {
return nil, err
}
var entitlement model.ReportEntitlement
if err := s.db.Where("user_id = ? AND order_id = ? AND status = ?", userID, order.ID, "active").First(&entitlement).Error; err != nil {
return nil, err
}
var snapshot model.ReportSnapshot
if err := s.db.Where("user_id = ? AND order_id = ?", userID, order.ID).First(&snapshot).Error; err != nil {
return nil, err
}
return &snapshot, nil
}
func (s *CommerceService) ListPurchasedReports(userID uint) ([]PurchasedReport, error) {
var orders []model.Order
if err := s.db.Where("user_id = ? AND type = ? AND status = ? AND access_code IS NOT NULL", userID, "bazi_report", "paid").Order("pay_time DESC").Find(&orders).Error; err != nil {
return nil, err
}
reports := make([]PurchasedReport, 0, len(orders))
for _, order := range orders {
if order.AccessCode == nil || order.PayTime == nil {
continue
}
reports = append(reports, PurchasedReport{ReportID: order.ReportID, AccessCode: *order.AccessCode, ProductName: order.ProductName, PaidAt: *order.PayTime})
}
return reports, nil
}
func amountMatches(order model.Order, paidAmount string) bool {
expected := order.ProviderAmount
if order.Provider == "stripe" {
expected = strconv.Itoa(order.Amount)
}
expectedValue, ok := new(big.Rat).SetString(expected)
if !ok {
return false
}
paidValue, ok := new(big.Rat).SetString(paidAmount)
return ok && expectedValue.Cmp(paidValue) == 0
}
func secureAccessCode() (string, error) {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
return "ZM-" + encoded[:4] + "-" + encoded[4:8] + "-" + encoded[8:12], nil
}
func secureOrderNo() (string, error) {
bytes := make([]byte, 5)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return "R" + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes)), nil
}
+175
View File
@@ -0,0 +1,175 @@
package service
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/mail"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
var ErrInvalidEmail = errors.New("invalid email")
var ErrLoginCodeRateLimited = errors.New("please wait before requesting another code")
var ErrLoginCodeInvalid = errors.New("invalid or expired login code")
type EmailAuthService struct {
db *gorm.DB
config *config.Config
client *http.Client
}
func NewEmailAuthService() *EmailAuthService {
return &EmailAuthService{db: config.GetDB(), config: config.Load(), client: &http.Client{Timeout: 10 * time.Second}}
}
func NormalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value || len(value) > 254 {
return "", ErrInvalidEmail
}
return value, nil
}
func (s *EmailAuthService) RequestCode(email string) error {
email, err := NormalizeEmail(email)
if err != nil {
return err
}
var latest model.EmailLoginCode
if err := s.db.Where("email = ?", email).Order("created_at DESC").First(&latest).Error; err == nil && time.Since(latest.CreatedAt) < time.Minute {
return ErrLoginCodeRateLimited
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
code, err := randomSixDigitCode()
if err != nil {
return err
}
record := model.EmailLoginCode{Email: email, CodeHash: s.hashCode(email, code), ExpiresAt: time.Now().Add(10 * time.Minute)}
if err := s.db.Create(&record).Error; err != nil {
return err
}
if err := s.sendCode(email, code); err != nil {
now := time.Now()
s.db.Model(&record).Update("used_at", &now)
return err
}
return nil
}
func (s *EmailAuthService) VerifyCode(email, code string) (*model.User, string, error) {
email, err := NormalizeEmail(email)
if err != nil || len(code) != 6 {
return nil, "", ErrLoginCodeInvalid
}
var record model.EmailLoginCode
if err := s.db.Where("email = ? AND used_at IS NULL AND expires_at > ?", email, time.Now()).Order("created_at DESC").First(&record).Error; err != nil {
return nil, "", ErrLoginCodeInvalid
}
if record.Attempts >= 5 {
return nil, "", ErrLoginCodeInvalid
}
if !hmac.Equal([]byte(record.CodeHash), []byte(s.hashCode(email, code))) {
s.db.Model(&record).UpdateColumn("attempts", gorm.Expr("attempts + 1"))
return nil, "", ErrLoginCodeInvalid
}
var user model.User
err = s.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
result := tx.Model(&model.EmailLoginCode{}).Where("id = ? AND used_at IS NULL", record.ID).Update("used_at", &now)
if result.Error != nil || result.RowsAffected != 1 {
return ErrLoginCodeInvalid
}
if err := tx.Where("email = ?", email).First(&user).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
identity := sha256.Sum256([]byte(email))
user = model.User{OpenID: "email:" + hex.EncodeToString(identity[:])[:32], Email: email, Nickname: strings.Split(email, "@")[0], Status: 1}
return tx.Create(&user).Error
})
if err != nil {
return nil, "", err
}
if user.Status != 1 {
return nil, "", errors.New("account disabled")
}
token, err := NewUserService().GenerateToken(user.ID, s.config.JWT.Secret)
if err != nil {
return nil, "", err
}
return &user, token, nil
}
func (s *EmailAuthService) hashCode(email, code string) string {
mac := hmac.New(sha256.New, []byte(s.config.JWT.Secret))
mac.Write([]byte(email + "|" + code))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *EmailAuthService) sendCode(email, code string) error {
if s.config.Postmark.ServerToken == "" || s.config.Postmark.FromEmail == "" {
// 开发模式:Postmark 未配置时,验证码打印到服务端控制台,方便本地联调
if s.config.Server.Env != "production" {
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境将自动通过 Postmark 发送)", email, code)
return nil
}
return errors.New("email service is not configured")
}
payload := map[string]string{
"From": s.config.Postmark.FromEmail,
"To": email,
"Subject": "知命登录验证码",
"TextBody": fmt.Sprintf("你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。", code),
"HtmlBody": fmt.Sprintf("<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:auto;padding:32px\"><h2>知命登录验证码</h2><p>你的验证码是:</p><p style=\"font-size:32px;letter-spacing:8px;font-weight:bold\">%s</p><p>10 分钟内有效。若非本人操作,请忽略此邮件。</p></div>", code),
"MessageStream": "outbound",
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.config.Postmark.APIBaseURL, "/")+"/email", strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", s.config.Postmark.ServerToken)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("postmark send failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody))
}
return nil
}
func randomSixDigitCode() (string, error) {
bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
value := (uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])) % 1000000
return fmt.Sprintf("%06d", value), nil
}
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
)
type CheckoutResult struct {
ProviderOrderID string
CheckoutURL string
}
type PaymentWebhook struct {
EventID string
EventType string
OrderNo string
ProviderOrderID string
TransactionID string
Amount string
Currency string
Status string
PayloadHash string
}
type StripeProvider struct {
config config.StripeConfig
client *http.Client
}
type BinancePayProvider struct {
config config.BinancePayConfig
client *http.Client
}
func NewStripeProvider(cfg config.StripeConfig) *StripeProvider {
return &StripeProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}}
}
func NewBinancePayProvider(cfg config.BinancePayConfig) *BinancePayProvider {
return &BinancePayProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}}
}
func (p *StripeProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL string) (*CheckoutResult, error) {
if p.config.SecretKey == "" {
return nil, errors.New("stripe is not configured")
}
values := url.Values{}
values.Set("mode", "payment")
values.Set("success_url", strings.TrimRight(webAppURL, "/")+"/?payment=success&order="+url.QueryEscape(order.OrderNo))
values.Set("cancel_url", strings.TrimRight(webAppURL, "/")+"/?payment=cancelled&order="+url.QueryEscape(order.OrderNo))
values.Set("customer_email", order.CustomerEmail)
values.Set("line_items[0][quantity]", "1")
values.Set("line_items[0][price_data][currency]", strings.ToLower(order.Currency))
values.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(order.Amount))
values.Set("line_items[0][price_data][product_data][name]", order.ProductName)
values.Set("line_items[0][price_data][product_data][description]", "一次购买,永久解锁完整报告")
values.Set("metadata[order_no]", order.OrderNo)
values.Set("metadata[report_id]", order.ReportID)
values.Set("metadata[user_id]", strconv.FormatUint(uint64(order.UserID), 10))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/v1/checkout/sessions", strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.config.SecretKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Idempotency-Key", "checkout-"+order.OrderNo)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("stripe create checkout failed: status=%d body=%s", resp.StatusCode, safeProviderError(body))
}
var payload struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.ID == "" || payload.URL == "" {
return nil, errors.New("stripe returned an invalid checkout response")
}
return &CheckoutResult{ProviderOrderID: payload.ID, CheckoutURL: payload.URL}, nil
}
func (p *StripeProvider) ParseWebhook(raw []byte, signature string, now time.Time) (*PaymentWebhook, error) {
if p.config.WebhookSecret == "" {
return nil, errors.New("stripe webhook is not configured")
}
parts := strings.Split(signature, ",")
var timestamp int64
var signatures []string
for _, part := range parts {
pair := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(pair) != 2 {
continue
}
if pair[0] == "t" {
timestamp, _ = strconv.ParseInt(pair[1], 10, 64)
}
if pair[0] == "v1" {
signatures = append(signatures, pair[1])
}
}
if timestamp == 0 || len(signatures) == 0 || now.Sub(time.Unix(timestamp, 0)) > 5*time.Minute || time.Unix(timestamp, 0).Sub(now) > 5*time.Minute {
return nil, errors.New("invalid stripe webhook timestamp")
}
mac := hmac.New(sha256.New, []byte(p.config.WebhookSecret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10)))
mac.Write([]byte("."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
valid := false
for _, candidate := range signatures {
if hmac.Equal([]byte(expected), []byte(candidate)) {
valid = true
break
}
}
if !valid {
return nil, errors.New("invalid stripe webhook signature")
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data struct {
Object struct {
ID string `json:"id"`
PaymentIntent string `json:"payment_intent"`
PaymentStatus string `json:"payment_status"`
AmountTotal int64 `json:"amount_total"`
Currency string `json:"currency"`
Metadata map[string]string `json:"metadata"`
} `json:"object"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &event); err != nil || event.ID == "" {
return nil, errors.New("invalid stripe webhook body")
}
status := "ignored"
switch event.Type {
case "checkout.session.completed":
if event.Data.Object.PaymentStatus == "paid" {
status = "paid"
}
case "checkout.session.async_payment_succeeded":
status = "paid"
case "checkout.session.expired", "checkout.session.async_payment_failed":
status = "cancelled"
case "charge.refunded":
status = "refunded"
}
return &PaymentWebhook{
EventID: event.ID, EventType: event.Type, OrderNo: event.Data.Object.Metadata["order_no"],
ProviderOrderID: event.Data.Object.ID, TransactionID: event.Data.Object.PaymentIntent,
Amount: strconv.FormatInt(event.Data.Object.AmountTotal, 10), Currency: strings.ToUpper(event.Data.Object.Currency),
Status: status, PayloadHash: hashPayload(raw),
}, nil
}
func (p *BinancePayProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL, publicBaseURL string) (*CheckoutResult, error) {
if p.config.APIKey == "" || p.config.SecretKey == "" {
return nil, errors.New("binance pay is not configured")
}
payload := map[string]interface{}{
"env": map[string]string{"terminalType": "WEB"},
"merchantTradeNo": order.OrderNo,
"orderAmount": order.ProviderAmount,
"currency": order.Currency,
"goods": map[string]string{"goodsType": "02", "goodsCategory": "D000", "referenceGoodsId": order.ReportID, "goodsName": order.ProductName},
"returnUrl": strings.TrimRight(webAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo),
"cancelUrl": strings.TrimRight(webAppURL, "/") + "/?payment=cancelled&order=" + url.QueryEscape(order.OrderNo),
"webhookUrl": strings.TrimRight(publicBaseURL, "/") + "/api/commerce/webhooks/binance-pay",
"orderExpireTime": time.Now().Add(30 * time.Minute).UnixMilli(),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, err
}
nonce := hex.EncodeToString(nonceBytes)
mac := hmac.New(sha512.New, []byte(p.config.SecretKey))
mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n"))
signature := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/binancepay/openapi/v2/order", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("BinancePay-Timestamp", timestamp)
req.Header.Set("BinancePay-Nonce", nonce)
req.Header.Set("BinancePay-Certificate-SN", p.config.APIKey)
req.Header.Set("BinancePay-Signature", signature)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("binance pay create order failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody))
}
var response struct {
Status string `json:"status"`
Code string `json:"code"`
Data struct {
PrepayID string `json:"prepayId"`
UniversalURL string `json:"universalUrl"`
CheckoutURL string `json:"checkoutUrl"`
} `json:"data"`
}
if err := json.Unmarshal(responseBody, &response); err != nil || response.Status != "SUCCESS" || response.Data.PrepayID == "" {
return nil, fmt.Errorf("binance pay rejected order: code=%s", response.Code)
}
checkoutURL := response.Data.CheckoutURL
if checkoutURL == "" {
checkoutURL = response.Data.UniversalURL
}
if checkoutURL == "" {
return nil, errors.New("binance pay returned no checkout URL")
}
return &CheckoutResult{ProviderOrderID: response.Data.PrepayID, CheckoutURL: checkoutURL}, nil
}
func (p *BinancePayProvider) ParseWebhook(raw []byte, timestamp, nonce, signature string) (*PaymentWebhook, error) {
if p.config.WebhookPublicKey == "" {
return nil, errors.New("binance pay webhook public key is not configured")
}
block, _ := pem.Decode([]byte(strings.ReplaceAll(p.config.WebhookPublicKey, "\\n", "\n")))
if block == nil {
return nil, errors.New("invalid binance pay webhook public key")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
if certificate, certErr := x509.ParseCertificate(block.Bytes); certErr == nil {
parsed = certificate.PublicKey
} else if rsaKey, rsaErr := x509.ParsePKCS1PublicKey(block.Bytes); rsaErr == nil {
parsed = rsaKey
} else {
return nil, err
}
}
publicKey, ok := parsed.(*rsa.PublicKey)
if !ok {
return nil, errors.New("binance pay webhook key is not RSA")
}
decodedSignature, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return nil, errors.New("invalid binance pay webhook signature encoding")
}
payload := []byte(timestamp + "\n" + nonce + "\n" + string(raw) + "\n")
digest := sha256.Sum256(payload)
if err := rsa.VerifyPKCS1v15(publicKey, cryptoHashSHA256, digest[:], decodedSignature); err != nil {
return nil, errors.New("invalid binance pay webhook signature")
}
var envelope struct {
BizType string `json:"bizType"`
BizIDStr string `json:"bizIdStr"`
BizStatus string `json:"bizStatus"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.BizIDStr == "" {
return nil, errors.New("invalid binance pay webhook body")
}
var data struct {
MerchantTradeNo string `json:"merchantTradeNo"`
TransactionID string `json:"transactionId"`
TotalFee json.Number `json:"totalFee"`
Currency string `json:"currency"`
}
decoder := json.NewDecoder(strings.NewReader(envelope.Data))
decoder.UseNumber()
if err := decoder.Decode(&data); err != nil {
return nil, errors.New("invalid binance pay webhook data")
}
status := "ignored"
switch envelope.BizStatus {
case "PAY_SUCCESS":
status = "paid"
case "PAY_CLOSED":
status = "cancelled"
case "REFUND_SUCCESS":
status = "refunded"
}
return &PaymentWebhook{
EventID: envelope.BizIDStr + ":" + envelope.BizStatus, EventType: envelope.BizStatus,
OrderNo: data.MerchantTradeNo, ProviderOrderID: envelope.BizIDStr, TransactionID: data.TransactionID,
Amount: data.TotalFee.String(), Currency: strings.ToUpper(data.Currency), Status: status, PayloadHash: hashPayload(raw),
}, nil
}
const cryptoHashSHA256 = 5
func hashPayload(raw []byte) string {
digest := sha256.Sum256(raw)
return hex.EncodeToString(digest[:])
}
func safeProviderError(body []byte) string {
text := strings.TrimSpace(string(body))
if len(text) > 500 {
text = text[:500]
}
return text
}
@@ -0,0 +1,97 @@
package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
)
func TestStripeCreateCheckout(t *testing.T) {
var received url.Values
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer sk_test" {
t.Fatalf("missing Stripe authorization")
}
if request.Header.Get("Idempotency-Key") != "checkout-R123" {
t.Fatalf("missing idempotency key")
}
body, _ := io.ReadAll(request.Body)
received, _ = url.ParseQuery(string(body))
response.Header().Set("Content-Type", "application/json")
fmt.Fprint(response, `{"id":"cs_test_123","url":"https://checkout.stripe.test/session"}`)
}))
defer server.Close()
provider := NewStripeProvider(config.StripeConfig{SecretKey: "sk_test", APIBaseURL: server.URL})
order := &model.Order{OrderNo: "R123", UserID: 9, Amount: 499, Currency: "USD", CustomerEmail: "buyer@example.com", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"}
result, err := provider.CreateCheckout(context.Background(), order, "https://example.test")
if err != nil {
t.Fatal(err)
}
if result.ProviderOrderID != "cs_test_123" || result.CheckoutURL == "" {
t.Fatalf("unexpected checkout result: %#v", result)
}
if received.Get("metadata[order_no]") != order.OrderNo || received.Get("line_items[0][price_data][unit_amount]") != "499" {
t.Fatalf("unexpected Stripe form: %#v", received)
}
}
func TestStripeWebhookSignature(t *testing.T) {
now := time.Unix(1760000000, 0)
raw := []byte(`{"id":"evt_123","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","payment_intent":"pi_123","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"R123"}}}}`)
mac := hmac.New(sha256.New, []byte("whsec_test"))
mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), raw)))
signature := fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil)))
provider := NewStripeProvider(config.StripeConfig{WebhookSecret: "whsec_test"})
event, err := provider.ParseWebhook(raw, signature, now)
if err != nil {
t.Fatal(err)
}
if event.Status != "paid" || event.OrderNo != "R123" || event.TransactionID != "pi_123" {
t.Fatalf("unexpected event: %#v", event)
}
if _, err := provider.ParseWebhook(raw, signature+"bad", now); err == nil {
t.Fatal("expected invalid signature to fail")
}
}
func TestBinancePayCreateCheckoutSignature(t *testing.T) {
secret := "binance-secret"
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
body, _ := io.ReadAll(request.Body)
timestamp := request.Header.Get("BinancePay-Timestamp")
nonce := request.Header.Get("BinancePay-Nonce")
mac := hmac.New(sha512.New, []byte(secret))
mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n"))
expected := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
if !hmac.Equal([]byte(expected), []byte(request.Header.Get("BinancePay-Signature"))) {
t.Fatal("invalid Binance Pay signature")
}
response.Header().Set("Content-Type", "application/json")
fmt.Fprint(response, `{"status":"SUCCESS","code":"000000","data":{"prepayId":"123456","universalUrl":"https://app.binance.test/pay"}}`)
}))
defer server.Close()
provider := NewBinancePayProvider(config.BinancePayConfig{APIKey: "api-key", SecretKey: secret, APIBaseURL: server.URL})
order := &model.Order{OrderNo: "R123", ProviderAmount: "5.00", Currency: "USDT", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"}
result, err := provider.CreateCheckout(context.Background(), order, "https://example.test", "https://api.example.test")
if err != nil {
t.Fatal(err)
}
if result.ProviderOrderID != "123456" || result.CheckoutURL == "" {
t.Fatalf("unexpected checkout result: %#v", result)
}
}
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"encoding/json"
"errors"
"fmt"
)
type premiumTrait struct {
Gift string
Caution string
Field string
Rhythm string
}
type premiumSnapshot struct {
ID string `json:"id"`
Result struct {
EightChar struct {
DayMaster string `json:"dayMaster"`
DayMasterElement string `json:"dayMasterElement"`
} `json:"eightChar"`
DecadeFortunes []struct {
Ganzhi string `json:"ganzhi"`
StartAge int `json:"startAge"`
} `json:"decadeFortunes"`
} `json:"result"`
Elements struct {
Dominant string `json:"dominant"`
Weakest string `json:"weakest"`
} `json:"elements"`
Shensha []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"shensha"`
Sections []premiumSection `json:"sections"`
}
type premiumSection struct {
Index string `json:"index"`
Title string `json:"title"`
Kicker string `json:"kicker"`
Summary string `json:"summary"`
Details []string `json:"details"`
}
var premiumTraits = map[string]premiumTrait{
"木": {Gift: "成长力、规划感与同理心", Caution: "容易把长期目标变成眼前压力", Field: "教育、内容、设计与长期经营", Rhythm: "先建立根基,再持续放大优势"},
"火": {Gift: "表达力、行动力与感染力", Caution: "节奏过快时容易透支专注", Field: "传播、品牌、销售与创新项目", Rhythm: "抓住窗口,但为决策保留冷静期"},
"土": {Gift: "稳定性、承诺感与资源整合力", Caution: "求稳过度时会错过变化", Field: "管理、运营、资产与服务业", Rhythm: "稳中求变,用小步试错替代停滞"},
"金": {Gift: "判断力、边界感与执行标准", Caution: "标准过高时容易显得紧绷", Field: "技术、金融、法律与精细管理", Rhythm: "先定规则,再给关系留出弹性"},
"水": {Gift: "洞察力、适应力与信息敏感度", Caution: "选择过多时容易分散心力", Field: "研究、咨询、贸易与流动型工作", Rhythm: "聚焦一个主航道,让变化服务目标"},
}
var supportingElements = map[string]string{"木": "水", "火": "木", "土": "火", "金": "土", "水": "金"}
func buildPremiumSnapshot(raw string) (string, error) {
var snapshot premiumSnapshot
if err := json.Unmarshal([]byte(raw), &snapshot); err != nil {
return "", fmt.Errorf("invalid report snapshot: %w", err)
}
if snapshot.ID == "" || snapshot.Result.EightChar.DayMaster == "" || snapshot.Elements.Dominant == "" {
return "", errors.New("report snapshot is missing chart data")
}
dominant := premiumTraits[snapshot.Elements.Dominant]
dayMaster := premiumTraits[snapshot.Result.EightChar.DayMasterElement]
if dayMaster.Gift == "" {
dayMaster = dominant
}
helpful := supportingElements[snapshot.Result.EightChar.DayMasterElement]
if helpful == "" {
helpful = snapshot.Elements.Weakest
}
positiveStars := make([]string, 0, 3)
cautionStars := make([]string, 0, 2)
for _, star := range snapshot.Shensha {
if star.Type == "吉" && len(positiveStars) < 3 {
positiveStars = append(positiveStars, star.Name)
}
if star.Type == "凶" && len(cautionStars) < 2 {
cautionStars = append(cautionStars, star.Name)
}
}
starText := "命局本身的稳定结构"
if len(positiveStars) > 0 {
starText = joinChinese(positiveStars)
}
cautionText := "节奏失衡"
if len(cautionStars) > 0 {
cautionText = joinChinese(cautionStars)
}
firstFortune := "人生节奏呈阶段性变化。"
if len(snapshot.Result.DecadeFortunes) > 0 {
fortune := snapshot.Result.DecadeFortunes[0]
firstFortune = fmt.Sprintf("首步大运为%s,约从%d岁进入阶段转换。", fortune.Ganzhi, fortune.StartAge)
}
snapshot.Sections = []premiumSection{
{Index: "壹", Title: "性格底色", Kicker: snapshot.Result.EightChar.DayMaster + " · 内在驱动力", Summary: fmt.Sprintf("你的日主为%s,核心优势集中在%s。", snapshot.Result.EightChar.DayMaster, dayMaster.Gift), Details: []string{fmt.Sprintf("五行以%s较突出,做事更重视%s。", snapshot.Elements.Dominant, dominant.Gift), fmt.Sprintf("需要留意:%s。", dayMaster.Caution), fmt.Sprintf("真正适合你的成长方式是:%s。", dayMaster.Rhythm)}},
{Index: "贰", Title: "事业天赋", Kicker: "能力结构 · 职场定位", Summary: fmt.Sprintf("你的优势更容易在%s中形成复利。", dominant.Field), Details: []string{fmt.Sprintf("适合承担需要%s的角色。", dayMaster.Gift), fmt.Sprintf("命局中的%s,提示你应主动经营专业信用与协作关系。", starText), "比起频繁换方向,更适合建立可重复的方法与个人标签。"}},
{Index: "叁", Title: "财富节奏", Kicker: "积累方式 · 风险边界", Summary: fmt.Sprintf("财富策略宜围绕“%s”展开。", dominant.Rhythm), Details: []string{fmt.Sprintf("优先把%s转化成可计价的产品或服务。", dayMaster.Gift), fmt.Sprintf("遇到%s对应的冲动时,应延迟重大财务决定。", cautionText), "收入结构宜同时保留稳定现金流与小比例成长投入。"}},
{Index: "肆", Title: "关系模式", Kicker: "亲密关系 · 沟通课题", Summary: fmt.Sprintf("关系中的吸引力来自%s,长久则依赖清晰表达。", dayMaster.Gift), Details: []string{"你更需要能理解你节奏、也尊重边界的伴侣。", "情绪紧张时,先描述事实与需要,避免用结论代替沟通。", "重要关系中要刻意安排稳定相处,而不是只依赖临时热情。"}},
{Index: "伍", Title: "身心能量", Kicker: "五行平衡 · 日常建议", Summary: fmt.Sprintf("%s相对偏弱,日常可借%s的意象建立平衡感。", snapshot.Elements.Weakest, helpful), Details: []string{"把作息、运动和独处时间当作长期资产。", "高压期减少多线程任务,让注意力回到一个明确目标。", "所有健康问题应以正规医疗意见为准,本报告不替代诊断。"}},
{Index: "陆", Title: "大运路线", Kicker: "阶段重点 · 行动窗口", Summary: firstFortune, Details: []string{"大运用于观察阶段主题,不代表确定事件。", fmt.Sprintf("顺势的关键是持续放大%s,并管理%s。", dayMaster.Gift, dayMaster.Caution), "完整报告保留十步大运时间轴,方便长期回看。"}},
}
encoded, err := json.Marshal(snapshot)
if err != nil {
return "", fmt.Errorf("encode premium snapshot: %w", err)
}
return string(encoded), nil
}
func joinChinese(values []string) string {
result := ""
for index, value := range values {
if index > 0 {
result += "、"
}
result += value
}
return result
}
+2 -2
View File
@@ -116,7 +116,7 @@ func (s *UserService) GenerateToken(userID uint, secret string) (string, error)
func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return 0, err
@@ -156,7 +156,7 @@ func (s *UserService) GenerateAdminToken(secret string) (string, error) {
func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return false
}