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 }