From 903ef7b592358a46312054620ab4e840f91bd59b Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 11:07:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E5=8F=91=E9=80=81=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E6=88=90=E5=8A=9F=E9=80=9A=E7=9F=A5=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/internal/handler/payment_handler.go | 1 + .../internal/service/payment_fulfillment.go | 78 +++++++++++++++++++ backend/internal/service/payment_order.go | 3 + backend/internal/service/payment_service.go | 30 ++++--- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index f293c2f2..27a16f59 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -264,6 +264,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) { PaymentSource: req.PaymentSource, OrderType: req.OrderType, PlanID: req.PlanID, + Locale: c.GetHeader("Accept-Language"), }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 8a26e868..b6b19ca0 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -310,9 +310,87 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde "creditedAmount": o.Amount, "payAmount": o.PayAmount, }) + s.dispatchPaymentFulfillmentNotification(o, auditAction) return nil } +func (s *PaymentService) dispatchPaymentFulfillmentNotification(o *dbent.PaymentOrder, auditAction string) { + if s == nil || s.notificationEmailService == nil || o == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + defer cancel() + var err error + switch auditAction { + case "RECHARGE_SUCCESS": + err = s.sendBalanceRechargeSuccessNotification(ctx, o) + case "SUBSCRIPTION_SUCCESS": + err = s.sendSubscriptionPurchaseSuccessNotification(ctx, o) + default: + return + } + if err != nil { + slog.Warn("payment fulfillment notification email failed", "order_id", o.ID, "action", auditAction, "err", err.Error()) + } + }() +} + +func (s *PaymentService) sendBalanceRechargeSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error { + currentBalance := "" + if s.userRepo != nil { + if user, err := s.userRepo.GetByID(ctx, o.UserID); err == nil && user != nil { + currentBalance = fmt.Sprintf("%.2f", user.Balance) + } + } + return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventBalanceRechargeSuccess, + RecipientEmail: o.UserEmail, + RecipientName: firstNonEmpty(o.UserName, o.UserEmail), + UserID: o.UserID, + SourceType: "payment_order", + SourceID: strconv.FormatInt(o.ID, 10), + Variables: map[string]string{ + "recharge_amount": fmt.Sprintf("%.2f", o.Amount), + "current_balance": currentBalance, + "order_id": strconv.FormatInt(o.ID, 10), + }, + }) +} + +func (s *PaymentService) sendSubscriptionPurchaseSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error { + variables := map[string]string{ + "subscription_group": "Subscription", + "subscription_days": "", + "expiry_time": "", + "order_id": strconv.FormatInt(o.ID, 10), + } + if o.SubscriptionDays != nil { + variables["subscription_days"] = strconv.Itoa(*o.SubscriptionDays) + } + if o.SubscriptionGroupID != nil { + if s.groupRepo != nil { + if group, err := s.groupRepo.GetByID(ctx, *o.SubscriptionGroupID); err == nil && group != nil && strings.TrimSpace(group.Name) != "" { + variables["subscription_group"] = group.Name + } + } + if s.subscriptionSvc != nil { + if sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID); err == nil && sub != nil { + variables["expiry_time"] = sub.ExpiresAt.Format("2006-01-02 15:04") + } + } + } + return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventSubscriptionPurchaseSuccess, + RecipientEmail: o.UserEmail, + RecipientName: firstNonEmpty(o.UserName, o.UserEmail), + UserID: o.UserID, + SourceType: "payment_order", + SourceID: strconv.FormatInt(o.ID, 10), + Variables: variables, + }) +} + func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid int64) error { o, err := s.entClient.PaymentOrder.Get(ctx, oid) if err != nil { diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index e6cc4b3c..83edb9e1 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -48,6 +48,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if user.Status != payment.EntityStatusActive { return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled") } + if s.notificationEmailService != nil { + s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale) + } orderAmount := req.Amount limitAmount := req.Amount if plan != nil { diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 42553840..2759aba1 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -83,6 +83,7 @@ type CreateOrderRequest struct { PaymentSource string OrderType string PlanID int64 + Locale string } type CreateOrderResponse struct { @@ -174,18 +175,19 @@ type TopUserStat struct { // --- Service --- type PaymentService struct { - providerMu sync.Mutex - providersLoaded bool - entClient *dbent.Client - registry *payment.Registry - loadBalancer payment.LoadBalancer - redeemService *RedeemService - subscriptionSvc *SubscriptionService - configService *PaymentConfigService - userRepo UserRepository - groupRepo GroupRepository - resumeService *PaymentResumeService - affiliateService *AffiliateService + providerMu sync.Mutex + providersLoaded bool + entClient *dbent.Client + registry *payment.Registry + loadBalancer payment.LoadBalancer + redeemService *RedeemService + subscriptionSvc *SubscriptionService + configService *PaymentConfigService + userRepo UserRepository + groupRepo GroupRepository + resumeService *PaymentResumeService + affiliateService *AffiliateService + notificationEmailService *NotificationEmailService } func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService) *PaymentService { @@ -194,6 +196,10 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load return svc } +func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + // --- Provider Registry --- // EnsureProviders lazily initializes the provider registry on first call.