feat(payment): 发送支付成功通知邮件

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
benjamin 2026-05-20 11:07:13 +08:00
parent 55b13cd7b4
commit 903ef7b592
4 changed files with 100 additions and 12 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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 {

View File

@ -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.