- 新增商业化模块: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:
@@ -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