- 新增商业化模块: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:
@@ -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", ""),
|
||||
},
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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": "订单不存在",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"` // 数量
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user