1194 lines
43 KiB
Go
1194 lines
43 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/payment"
|
||
"infinite-canvas/backend/internal/protocol"
|
||
"infinite-canvas/backend/internal/repository"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const (
|
||
maxActivePaymentOrdersPerUser = 5
|
||
maxTopupCreditsMicrocredits = int64(1_000_000_000) * CreditScale
|
||
)
|
||
|
||
type PaymentProviderView struct {
|
||
ID string `json:"id"`
|
||
PluginID string `json:"pluginId"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
CheckoutMode string `json:"checkoutMode"`
|
||
Enabled bool `json:"enabled"`
|
||
PluginEnabled bool `json:"pluginEnabled"`
|
||
Configured bool `json:"configured"`
|
||
CloseAfterMinutes int `json:"closeAfterMinutes"`
|
||
}
|
||
|
||
type AdminPaymentProviderView struct {
|
||
PaymentProviderView
|
||
ConfigID string `json:"configId,omitempty"`
|
||
ConfigEnabled bool `json:"configEnabled"`
|
||
Version int64 `json:"version"`
|
||
Values map[string]string `json:"values"`
|
||
SecretConfigured map[string]bool `json:"secretConfigured"`
|
||
ConfigFields []protocol.ManifestField `json:"configFields"`
|
||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||
}
|
||
|
||
type UpdatePaymentProviderConfigRequest struct {
|
||
Enabled bool `json:"enabled"`
|
||
CloseAfterMinutes int `json:"closeAfterMinutes"`
|
||
Values map[string]string `json:"values"`
|
||
}
|
||
|
||
type TopupProductRequest struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
AmountFen int64 `json:"amountFen"`
|
||
CreditsMicrocredits int64 `json:"creditsMicrocredits"`
|
||
Enabled bool `json:"enabled"`
|
||
SortOrder int `json:"sortOrder"`
|
||
SaleStrategy model.TopupSaleStrategy `json:"saleStrategy"`
|
||
PeriodDays int `json:"periodDays"`
|
||
PeriodPurchaseLimit int `json:"periodPurchaseLimit"`
|
||
StockTotal int64 `json:"stockTotal"`
|
||
SaleStartAt *time.Time `json:"saleStartAt"`
|
||
SaleEndAt *time.Time `json:"saleEndAt"`
|
||
}
|
||
|
||
type CreatePaymentOrderRequest struct {
|
||
ProductID string `json:"productId"`
|
||
ProviderID string `json:"providerId"`
|
||
IdempotencyKey string `json:"idempotencyKey"`
|
||
}
|
||
|
||
type PaymentCheckoutView struct {
|
||
Mode string `json:"mode"`
|
||
Value string `json:"value,omitempty"`
|
||
URL string `json:"url,omitempty"`
|
||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||
}
|
||
|
||
type PaymentOrderView struct {
|
||
ID string `json:"id"`
|
||
UserID string `json:"userId,omitempty"`
|
||
MerchantOrderNo string `json:"merchantOrderNo"`
|
||
ProductID string `json:"productId"`
|
||
ProductName string `json:"productName"`
|
||
ProviderID string `json:"providerId"`
|
||
AmountFen int64 `json:"amountFen"`
|
||
Currency string `json:"currency"`
|
||
CreditsMicrocredits int64 `json:"creditsMicrocredits"`
|
||
Status model.PaymentOrderStatus `json:"status"`
|
||
ProviderStatus string `json:"providerStatus,omitempty"`
|
||
ProviderTradeNo string `json:"providerTradeNo,omitempty"`
|
||
Checkout PaymentCheckoutView `json:"checkout"`
|
||
ExpiresAt time.Time `json:"expiresAt"`
|
||
ProviderPaidAt *time.Time `json:"providerPaidAt,omitempty"`
|
||
CreditedAt *time.Time `json:"creditedAt,omitempty"`
|
||
ClosedAt *time.Time `json:"closedAt,omitempty"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
}
|
||
|
||
type AdminPaymentOrderPage struct {
|
||
Orders []AdminPaymentOrderView `json:"orders"`
|
||
Total int64 `json:"total"`
|
||
Page int `json:"page"`
|
||
Limit int `json:"pageSize"`
|
||
}
|
||
|
||
type AdminPaymentOrderUser struct {
|
||
ID string `json:"id"`
|
||
Username string `json:"username"`
|
||
DisplayName string `json:"displayName"`
|
||
Email string `json:"email"`
|
||
}
|
||
|
||
type AdminPaymentOrderView struct {
|
||
PaymentOrderView
|
||
User *AdminPaymentOrderUser `json:"user"`
|
||
}
|
||
|
||
func (s *Service) PaymentNotificationResponse(providerID string, success bool) (int, string, string) {
|
||
return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, success, http.StatusInternalServerError)
|
||
}
|
||
|
||
func (s *Service) PaymentNotificationFailureResponse(providerID string, status int) (int, string, string) {
|
||
return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, false, status)
|
||
}
|
||
|
||
func PaymentNotificationResponseFor(providerID string, success bool, failureStatus int) (int, string, string) {
|
||
registry, _ := payment.NewRegistry()
|
||
return PaymentNotificationResponseForWithRegistry(registry, providerID, success, failureStatus)
|
||
}
|
||
|
||
func PaymentNotificationResponseForWithRegistry(registry *payment.Registry, providerID string, success bool, failureStatus int) (int, string, string) {
|
||
if registry != nil {
|
||
if provider, ok := registry.Get(providerID); ok {
|
||
descriptor := provider.Descriptor()
|
||
response := descriptor.NotificationFailure
|
||
if success {
|
||
response = descriptor.NotificationSuccess
|
||
}
|
||
status := response.Status
|
||
if status == 0 {
|
||
status = failureStatus
|
||
}
|
||
contentType, body := response.ContentType, response.Body
|
||
if contentType == "" {
|
||
contentType = "text/plain; charset=utf-8"
|
||
}
|
||
return status, contentType, body
|
||
}
|
||
}
|
||
if success {
|
||
return http.StatusNoContent, "", ""
|
||
}
|
||
return failureStatus, "", ""
|
||
}
|
||
|
||
func (s *Service) PaymentProviders(actor *model.User) ([]PaymentProviderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]PaymentProviderView, 0)
|
||
for _, descriptor := range s.paymentRegistry.Descriptors() {
|
||
view, _, err := s.paymentProviderView(descriptor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if view.Enabled && view.Configured {
|
||
items = append(items, view)
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Service) AdminPaymentProviders(actor *model.User) ([]AdminPaymentProviderView, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]AdminPaymentProviderView, 0)
|
||
for _, descriptor := range s.paymentRegistry.Descriptors() {
|
||
base, config, err := s.paymentProviderView(descriptor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
manifest, _ := s.paymentManifestForProvider(descriptor.ID)
|
||
view := AdminPaymentProviderView{PaymentProviderView: base, Values: map[string]string{}, SecretConfigured: map[string]bool{}, ConfigFields: manifest.Configuration.Fields}
|
||
if config != nil {
|
||
values, err := s.decryptPaymentConfig(config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view.ConfigID = config.ID
|
||
view.ConfigEnabled = config.Enabled
|
||
view.Version = config.Version
|
||
view.UpdatedAt = &config.CreatedAt
|
||
for _, field := range manifest.Configuration.Fields {
|
||
if field.Secret {
|
||
view.SecretConfigured[field.Name] = strings.TrimSpace(values[field.Name]) != ""
|
||
continue
|
||
}
|
||
view.Values[field.Name] = values[field.Name]
|
||
}
|
||
}
|
||
items = append(items, view)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID string, request UpdatePaymentProviderConfigRequest) (*AdminPaymentProviderView, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
provider, ok := s.paymentRegistry.Get(providerID)
|
||
if !ok {
|
||
return nil, BadAuthRequest("未知支付渠道")
|
||
}
|
||
descriptor := provider.Descriptor()
|
||
manifest, ok := s.paymentManifestForProvider(descriptor.ID)
|
||
if !ok {
|
||
return nil, BadAuthRequest("支付插件清单不存在")
|
||
}
|
||
policy, ok := paymentExpiryPolicy(manifest, descriptor.ID)
|
||
if !ok {
|
||
return nil, BadAuthRequest("支付插件清单不存在")
|
||
}
|
||
if request.CloseAfterMinutes < policy.MinMinutes || request.CloseAfterMinutes > policy.MaxMinutes {
|
||
return nil, BadAuthRequest(fmt.Sprintf("未支付关闭时间必须为 %d-%d 分钟", policy.MinMinutes, policy.MaxMinutes))
|
||
}
|
||
values := make(payment.Config)
|
||
previousIdentity := make(map[string]string)
|
||
current, err := s.repo.LatestPaymentProviderConfig(providerID)
|
||
if err == nil {
|
||
values, err = s.decryptPaymentConfig(current)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, field := range descriptor.IdentityFields {
|
||
previousIdentity[field] = strings.TrimSpace(values[field])
|
||
}
|
||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, err
|
||
}
|
||
for _, field := range manifest.Configuration.Fields {
|
||
value, supplied := request.Values[field.Name]
|
||
value = strings.TrimSpace(value)
|
||
if field.Secret && (!supplied || value == "") {
|
||
continue
|
||
}
|
||
if !supplied && field.Default != nil && strings.TrimSpace(values[field.Name]) == "" {
|
||
value = strings.TrimSpace(fmt.Sprint(field.Default))
|
||
}
|
||
values[field.Name] = value
|
||
}
|
||
changedIdentityField := ""
|
||
for field, previous := range previousIdentity {
|
||
if previous != "" && strings.TrimSpace(values[field]) != previous {
|
||
changedIdentityField = field
|
||
break
|
||
}
|
||
}
|
||
if changedIdentityField != "" {
|
||
orderCount, err := s.repo.PaymentOrderCountForProvider(providerID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if orderCount > 0 {
|
||
return nil, BadAuthRequest("该渠道已有历史订单,首期不支持切换商户身份;请保持 " + changedIdentityField + " 不变")
|
||
}
|
||
}
|
||
if base := values["publicBaseUrl"]; base != "" {
|
||
if err := validatePaymentPublicBaseURL(base); err != nil {
|
||
return nil, BadAuthRequest(err.Error())
|
||
}
|
||
}
|
||
if request.Enabled {
|
||
if strings.TrimSpace(values["publicBaseUrl"]) == "" {
|
||
return nil, BadAuthRequest("启用支付渠道前必须填写服务器公网地址")
|
||
}
|
||
if err := provider.ValidateConfig(values); err != nil {
|
||
return nil, BadAuthRequest(err.Error())
|
||
}
|
||
}
|
||
plain, err := json.Marshal(values)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ciphertext, err := s.encryptSettingSecret(string(plain))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
digest := sha256.Sum256(plain)
|
||
config := &model.PaymentProviderConfig{
|
||
ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID, PluginVersion: descriptor.PluginVersion,
|
||
Enabled: request.Enabled, CloseAfterMinutes: request.CloseAfterMinutes,
|
||
ConfigCipher: ciphertext, ConfigDigest: hex.EncodeToString(digest[:]), CreatedBy: actor.ID,
|
||
}
|
||
if err := s.repo.CreatePaymentProviderConfig(config); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.appendAdminAudit(actor, "payment_provider.config.update", "payment_provider", providerID, "更新支付渠道配置", map[string]any{
|
||
"version": config.Version, "enabled": config.Enabled, "closeAfterMinutes": config.CloseAfterMinutes,
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
items, err := s.AdminPaymentProviders(actor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range items {
|
||
if items[index].ID == providerID {
|
||
return &items[index], nil
|
||
}
|
||
}
|
||
return nil, errors.New("保存支付渠道配置后未找到渠道")
|
||
}
|
||
|
||
func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentProviderView, *model.PaymentProviderConfig, error) {
|
||
view := PaymentProviderView{ID: descriptor.ID, PluginID: descriptor.PluginID, Name: descriptor.Name, Icon: descriptor.Icon, CheckoutMode: descriptor.CheckoutMode}
|
||
if manifest, ok := s.paymentManifestForProvider(descriptor.ID); ok {
|
||
if policy, found := paymentExpiryPolicy(manifest, descriptor.ID); found {
|
||
view.CloseAfterMinutes = policy.DefaultMinutes
|
||
}
|
||
}
|
||
state, err := s.pluginStateForUser(nil, descriptor.PluginID, s.Plugins())
|
||
if err == nil {
|
||
view.PluginEnabled = state.PlatformAvailable
|
||
view.Enabled = state.PlatformAvailable
|
||
}
|
||
config, configErr := s.repo.LatestPaymentProviderConfig(descriptor.ID)
|
||
if errors.Is(configErr, gorm.ErrRecordNotFound) {
|
||
return view, nil, nil
|
||
}
|
||
if configErr != nil {
|
||
return view, nil, configErr
|
||
}
|
||
view.Configured = strings.TrimSpace(config.ConfigCipher) != ""
|
||
view.Enabled = view.Enabled && config.Enabled
|
||
view.CloseAfterMinutes = config.CloseAfterMinutes
|
||
return view, config, nil
|
||
}
|
||
|
||
func (s *Service) paymentManifestForProvider(providerID string) (protocol.Manifest, bool) {
|
||
providerID = strings.TrimSpace(providerID)
|
||
if providerID == "" {
|
||
return protocol.Manifest{}, false
|
||
}
|
||
if s != nil {
|
||
for _, plugin := range s.Plugins() {
|
||
for _, contribution := range plugin.Manifest.Contributes.PaymentProviders {
|
||
if contribution.ID == providerID {
|
||
return protocolManifestFromPluginView(plugin), true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return bundledPaymentManifestForProvider(providerID)
|
||
}
|
||
|
||
func bundledPaymentManifestForProvider(providerID string) (protocol.Manifest, bool) {
|
||
for _, manifest := range bundledPaymentPluginManifests() {
|
||
for _, contribution := range manifest.Contributes.PaymentProviders {
|
||
if contribution.ID == providerID {
|
||
return manifest, true
|
||
}
|
||
}
|
||
}
|
||
return protocol.Manifest{}, false
|
||
}
|
||
|
||
func protocolManifestFromPluginView(plugin PluginView) protocol.Manifest {
|
||
return protocol.Manifest{
|
||
APIVersion: plugin.Manifest.APIVersion,
|
||
Metadata: protocol.Metadata{
|
||
ID: plugin.Manifest.ID, Version: plugin.Manifest.Version, Name: plugin.Manifest.Name,
|
||
Vendor: plugin.Manifest.Author, Description: plugin.Manifest.Description,
|
||
Documentation: plugin.Manifest.Documentation,
|
||
},
|
||
Surfaces: plugin.Manifest.Surfaces,
|
||
Runtime: plugin.Manifest.Runtime,
|
||
Permissions: plugin.Manifest.Permissions,
|
||
Configuration: plugin.Manifest.Configuration,
|
||
Contributes: plugin.Manifest.Contributes,
|
||
}
|
||
}
|
||
|
||
func paymentExpiryPolicy(manifest protocol.Manifest, providerID string) (protocol.ManifestPaymentExpiryPolicy, bool) {
|
||
for _, contribution := range manifest.Contributes.PaymentProviders {
|
||
if contribution.ID == providerID {
|
||
return contribution.ExpiryPolicy, true
|
||
}
|
||
}
|
||
if len(manifest.Contributes.PaymentProviders) == 1 {
|
||
return manifest.Contributes.PaymentProviders[0].ExpiryPolicy, true
|
||
}
|
||
return protocol.ManifestPaymentExpiryPolicy{}, false
|
||
}
|
||
|
||
func (s *Service) decryptPaymentConfig(config *model.PaymentProviderConfig) (payment.Config, error) {
|
||
if config == nil {
|
||
return nil, errors.New("支付渠道配置不存在")
|
||
}
|
||
plain, err := s.decryptSettingSecret(config.ConfigCipher)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
values := make(payment.Config)
|
||
if err := json.Unmarshal([]byte(plain), &values); err != nil {
|
||
return nil, errors.New("支付渠道配置内容无效")
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func validatePaymentPublicBaseURL(value string) error {
|
||
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(value), "/"))
|
||
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||
return errors.New("服务器公网地址必须是有效的 HTTP(S) 根地址")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||
return nil, err
|
||
}
|
||
products, err := s.repo.TopupProducts(false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decorateTopupProducts(products, time.Now()), nil
|
||
}
|
||
|
||
func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
products, err := s.repo.TopupProducts(true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decorateTopupProducts(products, time.Now()), nil
|
||
}
|
||
|
||
func decorateTopupProducts(products []model.TopupProduct, now time.Time) []model.TopupProduct {
|
||
for index := range products {
|
||
product := &products[index]
|
||
if product.SaleStrategy == "" {
|
||
product.SaleStrategy = model.TopupSaleStrategyUnlimited
|
||
}
|
||
product.CanPurchase = product.Enabled
|
||
product.SaleStatus = "on_sale"
|
||
if !product.Enabled {
|
||
product.CanPurchase = false
|
||
product.SaleStatus = "disabled"
|
||
} else if product.SaleStrategy == model.TopupSaleStrategyInventory && product.StockRemaining <= 0 {
|
||
product.CanPurchase = false
|
||
product.SaleStatus = "sold_out"
|
||
} else if product.SaleStrategy == model.TopupSaleStrategyTimed {
|
||
if product.SaleStartAt != nil && now.Before(*product.SaleStartAt) {
|
||
product.CanPurchase = false
|
||
product.SaleStatus = "upcoming"
|
||
} else if product.SaleEndAt != nil && !now.Before(*product.SaleEndAt) {
|
||
product.CanPurchase = false
|
||
product.SaleStatus = "ended"
|
||
}
|
||
}
|
||
}
|
||
return products
|
||
}
|
||
|
||
func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
product, err := topupProductFromRequest(newID(), actor.ID, request, 0, 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.repo.CreateTopupProduct(product); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits, "saleStrategy": product.SaleStrategy}); err != nil {
|
||
return nil, err
|
||
}
|
||
return product, nil
|
||
}
|
||
|
||
func (s *Service) UpdateTopupProduct(actor *model.User, id string, request TopupProductRequest) (*model.TopupProduct, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
existing, err := s.repo.TopupProduct(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
consumed := existing.StockTotal - existing.StockRemaining
|
||
if existing.SaleStrategy != model.TopupSaleStrategyInventory {
|
||
consumed = 0
|
||
}
|
||
product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request, existing.StockTotal, consumed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.repo.UpdateTopupProduct(product); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled, "saleStrategy": product.SaleStrategy}); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.TopupProduct(product.ID)
|
||
}
|
||
|
||
func topupProductFromRequest(id, actorID string, request TopupProductRequest, stockArgs ...int64) (*model.TopupProduct, error) {
|
||
var consumed int64
|
||
if len(stockArgs) > 1 {
|
||
consumed = stockArgs[1]
|
||
}
|
||
name := strings.TrimSpace(request.Name)
|
||
if name == "" || len([]rune(name)) > 120 {
|
||
return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符")
|
||
}
|
||
if request.AmountFen <= 0 || request.AmountFen > 100_000_000 {
|
||
return nil, BadAuthRequest("充值金额必须为 1 分至 100 万元")
|
||
}
|
||
if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits {
|
||
return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分")
|
||
}
|
||
strategy := request.SaleStrategy
|
||
if strategy == "" {
|
||
strategy = model.TopupSaleStrategyUnlimited
|
||
}
|
||
if strategy != model.TopupSaleStrategyUnlimited && strategy != model.TopupSaleStrategyPeriodic && strategy != model.TopupSaleStrategyInventory && strategy != model.TopupSaleStrategyTimed {
|
||
return nil, BadAuthRequest("上架策略无效")
|
||
}
|
||
if strategy == model.TopupSaleStrategyPeriodic && (request.PeriodDays <= 0 || request.PeriodDays > 3650 || request.PeriodPurchaseLimit <= 0 || request.PeriodPurchaseLimit > 100000) {
|
||
return nil, BadAuthRequest("周期性商品需设置有效的周期天数和购买次数")
|
||
}
|
||
if strategy == model.TopupSaleStrategyInventory && request.StockTotal <= 0 {
|
||
return nil, BadAuthRequest("库存性商品库存必须大于 0")
|
||
}
|
||
if strategy == model.TopupSaleStrategyTimed && (request.SaleStartAt == nil || request.SaleEndAt == nil || !request.SaleEndAt.After(*request.SaleStartAt)) {
|
||
return nil, BadAuthRequest("限时商品必须设置有效的发售和结束时间")
|
||
}
|
||
stockRemaining := int64(0)
|
||
if strategy == model.TopupSaleStrategyInventory {
|
||
if consumed < 0 {
|
||
consumed = 0
|
||
}
|
||
if request.StockTotal < consumed {
|
||
return nil, BadAuthRequest(fmt.Sprintf("库存不能低于已售数量(至少 %d)", consumed))
|
||
}
|
||
stockRemaining = request.StockTotal - consumed
|
||
}
|
||
return &model.TopupProduct{
|
||
ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500),
|
||
AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits,
|
||
Enabled: request.Enabled, SortOrder: request.SortOrder, SaleStrategy: strategy,
|
||
PeriodDays: request.PeriodDays, PeriodPurchaseLimit: request.PeriodPurchaseLimit,
|
||
StockTotal: request.StockTotal, StockRemaining: stockRemaining,
|
||
SaleStartAt: request.SaleStartAt, SaleEndAt: request.SaleEndAt,
|
||
CreatedBy: actorID, UpdatedBy: actorID,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, request CreatePaymentOrderRequest) (*PaymentOrderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||
return nil, err
|
||
}
|
||
idempotencyKey := strings.TrimSpace(request.IdempotencyKey)
|
||
if idempotencyKey == "" {
|
||
return nil, BadAuthRequest("支付幂等标识不能为空")
|
||
}
|
||
if len(idempotencyKey) > 120 {
|
||
return nil, BadAuthRequest("支付幂等标识过长")
|
||
}
|
||
productID := strings.TrimSpace(request.ProductID)
|
||
providerID := strings.TrimSpace(request.ProviderID)
|
||
if existing, err := s.repo.PaymentOrderByIdempotency(actor.ID, idempotencyKey); err == nil {
|
||
if existing.ProductID != productID || existing.ProviderID != providerID {
|
||
return nil, NewAppError(http.StatusConflict, "支付幂等标识已用于不同的商品或支付渠道")
|
||
}
|
||
result := paymentOrderView(*existing)
|
||
return &result, nil
|
||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, err
|
||
}
|
||
product, err := s.repo.TopupProduct(productID)
|
||
if err != nil || !product.Enabled {
|
||
return nil, BadAuthRequest("充值商品不存在或已停用")
|
||
}
|
||
provider, ok := s.paymentRegistry.Get(providerID)
|
||
if !ok {
|
||
return nil, BadAuthRequest("未知支付渠道")
|
||
}
|
||
view, config, err := s.paymentProviderView(provider.Descriptor())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !view.Enabled || !view.Configured || config == nil {
|
||
return nil, Forbidden("支付渠道未启用或尚未配置")
|
||
}
|
||
activeCount, err := s.repo.ActivePaymentOrderCount(actor.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if activeCount >= maxActivePaymentOrdersPerUser {
|
||
return nil, NewAppError(http.StatusConflict, "未支付订单过多,请先完成或关闭已有订单")
|
||
}
|
||
now := time.Now()
|
||
order := &model.PaymentOrder{
|
||
ID: newID(), UserID: actor.ID, IdempotencyKey: idempotencyKey, MerchantOrderNo: newID(),
|
||
ProductID: product.ID, ProductName: product.Name, ProviderID: provider.Descriptor().ID,
|
||
PluginID: provider.Descriptor().PluginID, PluginVersion: provider.Descriptor().PluginVersion, ProviderConfigID: config.ID, ProviderConfigVersion: config.Version,
|
||
AmountFen: product.AmountFen, Currency: "CNY", CreditsMicrocredits: product.CreditsMicrocredits,
|
||
Status: model.PaymentOrderCreated, CheckoutMode: provider.Descriptor().CheckoutMode,
|
||
ExpiresAt: now.Add(time.Duration(config.CloseAfterMinutes) * time.Minute),
|
||
}
|
||
order, created, err := s.repo.CreatePaymentOrderWithProductReservation(order)
|
||
if errors.Is(err, repository.ErrTopupUnavailable) {
|
||
return nil, NewAppError(http.StatusConflict, "充值商品当前不可购买")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !created {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
values, err := s.decryptPaymentConfig(config)
|
||
if err != nil {
|
||
_ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err))
|
||
return nil, err
|
||
}
|
||
baseURL := strings.TrimRight(values["publicBaseUrl"], "/")
|
||
checkout, err := provider.CreateOrder(ctx, values, payment.CreateRequest{
|
||
MerchantOrderNo: order.MerchantOrderNo, Description: product.Name, AmountFen: order.AmountFen,
|
||
Currency: order.Currency, ExpiresAt: order.ExpiresAt,
|
||
NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID),
|
||
ReturnURL: baseURL + "/api/payments/return/" + url.PathEscape(order.ProviderID) + "?orderId=" + url.QueryEscape(order.ID),
|
||
})
|
||
if err != nil {
|
||
_ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err))
|
||
return nil, WrapAppError(http.StatusBadGateway, "支付渠道下单失败,请稍后重试", err)
|
||
}
|
||
if err := s.repo.SetPaymentOrderCheckout(order.ID, checkout.Mode, checkout.Value, checkout.ExpiresAt); err != nil {
|
||
_ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err))
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(order.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result := paymentOrderView(*order)
|
||
return &result, nil
|
||
}
|
||
|
||
func (s *Service) PaymentOrder(actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
order, err := s.repo.PaymentOrderForUser(actor.ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) PaymentCheckout(actor *model.User, id string) (string, error) {
|
||
if actor == nil {
|
||
return "", Unauthorized("请先登录")
|
||
}
|
||
order, err := s.repo.PaymentOrderForUser(actor.ID, id)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if order.Status != model.PaymentOrderPending || order.CheckoutMode != "redirect" || strings.TrimSpace(order.CheckoutValue) == "" || !order.ExpiresAt.After(time.Now()) {
|
||
return "", BadAuthRequest("支付订单当前不能跳转收银台")
|
||
}
|
||
return order.CheckoutValue, nil
|
||
}
|
||
|
||
// RefreshPaymentCheckout reuses the original merchant order number and order
|
||
// snapshot. It always queries the provider before rebuilding a checkout so an
|
||
// ambiguous create response cannot turn into a duplicate payment attempt.
|
||
func (s *Service) RefreshPaymentCheckout(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
order, err := s.repo.PaymentOrderForUser(actor.ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if (order.CheckoutMode != "qr_code" && order.CheckoutMode != "redirect") || (order.Status != model.PaymentOrderPending && order.Status != model.PaymentOrderCreateFailed) || !order.ExpiresAt.After(time.Now()) {
|
||
return nil, BadAuthRequest("支付订单当前不能刷新收银台")
|
||
}
|
||
provider, config, values, err := s.paymentRuntimeForOrder(order)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
providerResult, queryErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo})
|
||
if queryErr == nil {
|
||
if strings.TrimSpace(providerResult.MerchantOrderNo) != order.MerchantOrderNo {
|
||
return nil, repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
_ = s.repo.RecordPaymentQuery(order.ID, providerResult.ProviderStatus)
|
||
updated, applyErr := s.applyPaymentResult(order.ProviderID, providerResult)
|
||
if applyErr != nil {
|
||
return nil, applyErr
|
||
}
|
||
if providerResult.Paid || providerResult.Closed {
|
||
view := paymentOrderView(*updated)
|
||
return &view, nil
|
||
}
|
||
} else if !errors.Is(queryErr, payment.ErrOrderNotFound) {
|
||
return nil, WrapAppError(http.StatusBadGateway, "刷新支付收银台前查单失败,请稍后重试", queryErr)
|
||
}
|
||
baseURL := strings.TrimRight(values["publicBaseUrl"], "/")
|
||
checkout, err := provider.CreateOrder(ctx, values, payment.CreateRequest{
|
||
MerchantOrderNo: order.MerchantOrderNo, Description: order.ProductName, AmountFen: order.AmountFen,
|
||
Currency: order.Currency, ExpiresAt: order.ExpiresAt,
|
||
NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID),
|
||
ReturnURL: baseURL + "/api/payments/return/" + url.PathEscape(order.ProviderID) + "?orderId=" + url.QueryEscape(order.ID),
|
||
})
|
||
if err != nil {
|
||
return nil, WrapAppError(http.StatusBadGateway, "刷新支付收银台失败,请稍后重试", err)
|
||
}
|
||
if err := s.repo.SetPaymentOrderCheckout(order.ID, checkout.Mode, checkout.Value, checkout.ExpiresAt); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(order.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) QueryPaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
order, err := s.repo.PaymentOrderForUser(actor.ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
if order.LastQueriedAt != nil && time.Since(*order.LastQueriedAt) < 2*time.Second {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
if err := s.queryPaymentOrder(ctx, order); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(order.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) ClosePaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if actor == nil {
|
||
return nil, Unauthorized("请先登录")
|
||
}
|
||
order, err := s.repo.PaymentOrderForUser(actor.ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
if err := s.closePaymentOrder(ctx, order); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(order.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) AcceptPaymentNotification(ctx context.Context, providerID, configID string, headers http.Header, rawBody []byte) error {
|
||
provider, ok := s.paymentRegistry.Get(providerID)
|
||
if !ok {
|
||
return BadAuthRequest("未知支付通知渠道")
|
||
}
|
||
config, err := s.repo.PaymentProviderConfig(configID)
|
||
if err != nil || config.ProviderID != providerID {
|
||
return BadAuthRequest("支付通知配置版本不存在")
|
||
}
|
||
values, err := s.decryptPaymentConfig(config)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
notification, err := provider.VerifyNotification(ctx, values, headers, rawBody)
|
||
if err != nil {
|
||
return BadAuthRequest("支付通知验签失败")
|
||
}
|
||
order, err := s.repo.PaymentOrderByMerchant(providerID, notification.MerchantOrderNo)
|
||
if err != nil || order.ProviderConfigID != config.ID {
|
||
return BadAuthRequest("支付通知订单不存在或配置版本不匹配")
|
||
}
|
||
normalized, err := json.Marshal(notification.Result)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payloadCipher, err := s.encryptSettingSecret(string(rawBody))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
digest := sha256.Sum256(rawBody)
|
||
inbox := &model.PaymentNotification{
|
||
ID: newID(), ProviderID: providerID, ProviderEventID: truncateRunes(notification.EventID, 160),
|
||
ProviderConfigID: config.ID, MerchantOrderNo: order.MerchantOrderNo, PaymentOrderID: order.ID,
|
||
PayloadDigest: hex.EncodeToString(digest[:]), PayloadCipher: payloadCipher, NormalizedJSON: string(normalized),
|
||
Status: model.PaymentNotificationPending, NextAttemptAt: time.Now(),
|
||
}
|
||
created, err := s.repo.SaveVerifiedPaymentNotification(inbox)
|
||
if err != nil || !created {
|
||
return err
|
||
}
|
||
// Keep the callback path fast but try once immediately. A durable inbox row
|
||
// remains for the worker if the credit transaction cannot complete now.
|
||
if err := s.processPaymentNotification(inbox); err != nil {
|
||
_ = s.repo.RetryPaymentNotification(inbox.ID, safePaymentError(err), time.Now().Add(5*time.Second))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) processPaymentNotification(notification *model.PaymentNotification) error {
|
||
var result payment.Result
|
||
if err := json.Unmarshal([]byte(notification.NormalizedJSON), &result); err != nil {
|
||
return err
|
||
}
|
||
if !result.Paid {
|
||
return errors.New("支付通知不是成功状态")
|
||
}
|
||
if _, err := s.applyPaymentResult(notification.ProviderID, result); err != nil {
|
||
return err
|
||
}
|
||
return s.repo.CompletePaymentNotification(notification.ID)
|
||
}
|
||
|
||
func (s *Service) queryPaymentOrder(ctx context.Context, order *model.PaymentOrder) error {
|
||
provider, config, values, err := s.paymentRuntimeForOrder(order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_ = config
|
||
result, err := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo})
|
||
if errors.Is(err, payment.ErrOrderNotFound) {
|
||
_ = s.repo.RecordPaymentQuery(order.ID, "NOT_FOUND")
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return WrapAppError(http.StatusBadGateway, "支付渠道查单失败,请稍后重试", err)
|
||
}
|
||
if strings.TrimSpace(result.MerchantOrderNo) != order.MerchantOrderNo {
|
||
return repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
_ = s.repo.RecordPaymentQuery(order.ID, result.ProviderStatus)
|
||
_, err = s.applyPaymentResult(order.ProviderID, result)
|
||
return err
|
||
}
|
||
|
||
func (s *Service) closePaymentOrder(ctx context.Context, order *model.PaymentOrder) error {
|
||
provider, _, values, err := s.paymentRuntimeForOrder(order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
queryNotFound := false
|
||
result, queryErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo})
|
||
if queryErr == nil {
|
||
if strings.TrimSpace(result.MerchantOrderNo) != order.MerchantOrderNo {
|
||
return repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
_ = s.repo.RecordPaymentQuery(order.ID, result.ProviderStatus)
|
||
if _, err := s.applyPaymentResult(order.ProviderID, result); err != nil {
|
||
return err
|
||
}
|
||
if result.Paid || result.Closed {
|
||
return nil
|
||
}
|
||
} else if errors.Is(queryErr, payment.ErrOrderNotFound) {
|
||
queryNotFound = true
|
||
} else {
|
||
return WrapAppError(http.StatusBadGateway, "关单前查单失败,请稍后重试", queryErr)
|
||
}
|
||
closed, err := provider.CloseOrder(ctx, values, payment.CloseRequest{MerchantOrderNo: order.MerchantOrderNo})
|
||
if err == nil {
|
||
if closed.MerchantOrderNo == "" {
|
||
closed.MerchantOrderNo = order.MerchantOrderNo
|
||
}
|
||
if strings.TrimSpace(closed.MerchantOrderNo) != order.MerchantOrderNo {
|
||
return repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
if closed.Paid || closed.Closed {
|
||
_, applyErr := s.applyPaymentResult(order.ProviderID, closed)
|
||
return applyErr
|
||
}
|
||
return s.repo.MarkPaymentOrderClosed(order.ID, closed.ProviderStatus)
|
||
}
|
||
|
||
// A payment can complete after the first query and immediately before the
|
||
// close request. Query once more on every close failure so a successful
|
||
// payment is credited instead of being left in a closing retry loop.
|
||
recheck, recheckErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo})
|
||
if recheckErr == nil {
|
||
if strings.TrimSpace(recheck.MerchantOrderNo) != order.MerchantOrderNo {
|
||
return repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
_ = s.repo.RecordPaymentQuery(order.ID, recheck.ProviderStatus)
|
||
if _, applyErr := s.applyPaymentResult(order.ProviderID, recheck); applyErr != nil {
|
||
return applyErr
|
||
}
|
||
if recheck.Paid || recheck.Closed {
|
||
return nil
|
||
}
|
||
}
|
||
if queryNotFound && errors.Is(err, payment.ErrOrderNotFound) && errors.Is(recheckErr, payment.ErrOrderNotFound) {
|
||
return s.repo.MarkPaymentOrderClosed(order.ID, "NOT_FOUND")
|
||
}
|
||
return WrapAppError(http.StatusBadGateway, "支付渠道关单失败,请稍后重试", err)
|
||
}
|
||
|
||
func (s *Service) applyPaymentResult(providerID string, result payment.Result) (*model.PaymentOrder, error) {
|
||
order, err := s.repo.PaymentOrderByMerchant(providerID, result.MerchantOrderNo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if result.Paid {
|
||
if result.AmountFen != order.AmountFen || result.Currency != order.Currency {
|
||
return nil, repository.ErrPaymentEvidenceMismatch
|
||
}
|
||
completed, _, err := s.repo.CompletePaymentOrder(providerID, result.MerchantOrderNo, repository.PaymentEvidence{
|
||
ProviderTradeNo: result.ProviderTradeNo, ProviderStatus: result.ProviderStatus,
|
||
AmountFen: result.AmountFen, Currency: result.Currency, PaidAt: result.PaidAt,
|
||
})
|
||
return completed, err
|
||
}
|
||
if result.Closed {
|
||
if err := s.repo.MarkPaymentOrderClosed(order.ID, result.ProviderStatus); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.PaymentOrder(order.ID)
|
||
}
|
||
return order, nil
|
||
}
|
||
|
||
func (s *Service) paymentRuntimeForOrder(order *model.PaymentOrder) (payment.Provider, *model.PaymentProviderConfig, payment.Config, error) {
|
||
if order == nil {
|
||
return nil, nil, nil, errors.New("支付订单不存在")
|
||
}
|
||
provider, ok := s.paymentRegistry.Get(order.ProviderID)
|
||
if !ok {
|
||
return nil, nil, nil, errors.New("支付宿主适配器不存在")
|
||
}
|
||
config, err := s.repo.PaymentProviderConfig(order.ProviderConfigID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
if config.ProviderID != order.ProviderID || config.PluginID != order.PluginID || (config.PluginVersion != "" && order.PluginVersion != "" && config.PluginVersion != order.PluginVersion) || config.Version != order.ProviderConfigVersion {
|
||
return nil, nil, nil, repository.ErrPaymentOrderStateConflict
|
||
}
|
||
values, err := s.decryptPaymentConfig(config)
|
||
return provider, config, values, err
|
||
}
|
||
|
||
func paymentOrderView(order model.PaymentOrder) PaymentOrderView {
|
||
providerTradeNo := ""
|
||
if order.ProviderTradeNo != nil {
|
||
providerTradeNo = *order.ProviderTradeNo
|
||
}
|
||
checkout := PaymentCheckoutView{Mode: order.CheckoutMode, ExpiresAt: order.CheckoutExpiresAt}
|
||
if order.Status == model.PaymentOrderPending && order.ExpiresAt.After(time.Now()) {
|
||
if order.CheckoutMode == "qr_code" {
|
||
checkout.Value = order.CheckoutValue
|
||
} else if order.CheckoutMode == "redirect" {
|
||
checkout.URL = "/api/payments/orders/" + url.PathEscape(order.ID) + "/checkout"
|
||
}
|
||
}
|
||
return PaymentOrderView{
|
||
ID: order.ID, UserID: order.UserID, MerchantOrderNo: order.MerchantOrderNo, ProductID: order.ProductID, ProductName: order.ProductName,
|
||
ProviderID: order.ProviderID, AmountFen: order.AmountFen, Currency: order.Currency,
|
||
CreditsMicrocredits: order.CreditsMicrocredits, Status: order.Status, ProviderStatus: order.ProviderStatus,
|
||
ProviderTradeNo: providerTradeNo, Checkout: checkout, ExpiresAt: order.ExpiresAt,
|
||
ProviderPaidAt: order.ProviderPaidAt, CreditedAt: order.CreditedAt, ClosedAt: order.ClosedAt,
|
||
CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func (s *Service) AdminPaymentOrderPage(actor *model.User, query PaymentOrderQuery, page, limit int) (*AdminPaymentOrderPage, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
page, limit = normalizeAdminPage(page, limit)
|
||
filter, err := query.filter()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
orders, total, err := s.repo.AdminPaymentOrders(filter, limit, (page-1)*limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userIDs := make([]string, 0, len(orders))
|
||
for _, order := range orders {
|
||
userIDs = append(userIDs, order.UserID)
|
||
}
|
||
users, err := s.repo.UsersByIDs(userIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
views := make([]AdminPaymentOrderView, 0, len(orders))
|
||
for _, order := range orders {
|
||
view := AdminPaymentOrderView{PaymentOrderView: paymentOrderView(order)}
|
||
if user, ok := users[order.UserID]; ok {
|
||
view.User = &AdminPaymentOrderUser{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Email: user.Email}
|
||
}
|
||
views = append(views, view)
|
||
}
|
||
return &AdminPaymentOrderPage{Orders: views, Total: total, Page: page, Limit: limit}, nil
|
||
}
|
||
|
||
func (s *Service) AdminQueryPaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err := s.repo.PaymentOrder(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
if err := s.queryPaymentOrder(ctx, order); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) AdminClosePaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err := s.repo.PaymentOrder(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed {
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
if err := s.closePaymentOrder(ctx, order); err != nil {
|
||
return nil, err
|
||
}
|
||
order, err = s.repo.PaymentOrder(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
view := paymentOrderView(*order)
|
||
return &view, nil
|
||
}
|
||
|
||
func (s *Service) startPaymentWorker(ctx context.Context) {
|
||
s.runWorkerLoop(func(ctx context.Context) {
|
||
s.drainPaymentNotifications()
|
||
notificationTicker := time.NewTicker(15 * time.Second)
|
||
defer notificationTicker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-notificationTicker.C:
|
||
s.drainPaymentNotifications()
|
||
}
|
||
}
|
||
})
|
||
s.runWorkerLoop(func(ctx context.Context) {
|
||
s.reconcileExpiredPaymentOrders(ctx)
|
||
orderTicker := time.NewTicker(15 * time.Second)
|
||
defer orderTicker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-orderTicker.C:
|
||
s.reconcileExpiredPaymentOrders(ctx)
|
||
s.queryPendingPaymentOrders(ctx)
|
||
}
|
||
}
|
||
})
|
||
s.runWorkerLoop(func(ctx context.Context) {
|
||
s.maybeRunDailyPaymentReconciliation(ctx)
|
||
reconciliationTicker := time.NewTicker(30 * time.Minute)
|
||
defer reconciliationTicker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-reconciliationTicker.C:
|
||
s.maybeRunDailyPaymentReconciliation(ctx)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func (s *Service) drainPaymentNotifications() {
|
||
items, err := s.repo.PendingPaymentNotifications(32)
|
||
if err != nil {
|
||
log.Printf("payment notification query failed: %v", err)
|
||
return
|
||
}
|
||
for index := range items {
|
||
item := &items[index]
|
||
if err := s.processPaymentNotification(item); err != nil {
|
||
delay := time.Duration(math.Pow(2, math.Min(float64(item.Attempts), 8))) * 5 * time.Second
|
||
_ = s.repo.RetryPaymentNotification(item.ID, safePaymentError(err), time.Now().Add(delay))
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) reconcileExpiredPaymentOrders(ctx context.Context) {
|
||
orders, err := s.repo.ClaimExpiredPaymentOrders(32)
|
||
if err != nil {
|
||
log.Printf("expired payment order claim failed: %v", err)
|
||
return
|
||
}
|
||
for index := range orders {
|
||
order := &orders[index]
|
||
operationContext, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||
err := s.closePaymentOrder(operationContext, order)
|
||
cancel()
|
||
if err != nil {
|
||
_ = s.repo.RestoreClosingPaymentOrder(order.ID, safePaymentError(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) queryPendingPaymentOrders(ctx context.Context) {
|
||
orders, err := s.repo.PaymentOrdersNeedingQuery(time.Now().Add(-30*time.Second), 32)
|
||
if err != nil {
|
||
log.Printf("pending payment order query failed: %v", err)
|
||
return
|
||
}
|
||
for index := range orders {
|
||
operationContext, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
err := s.queryPaymentOrder(operationContext, &orders[index])
|
||
cancel()
|
||
if err != nil {
|
||
log.Printf("payment order compensation query failed: order=%s error_type=%T", orders[index].ID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func safePaymentError(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
var providerErr *payment.ProviderError
|
||
if errors.As(err, &providerErr) && providerErr.Code != "" {
|
||
return truncateRunes(providerErr.Code, 1000)
|
||
}
|
||
return truncateRunes(fmt.Sprintf("%T", err), 1000)
|
||
}
|