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
+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
}