补充一些通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s

This commit is contained in:
2026-07-28 15:50:12 +08:00
parent 9303af3d46
commit 1fee19feeb
12 changed files with 151 additions and 27 deletions

View File

@@ -295,6 +295,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)

View File

@@ -64,10 +64,36 @@ func NewRegistry() *Registry {
TitleTemplate: "套餐即将到期",
BodyTemplate: "您的套餐即将到期,请及时查看并处理。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
RecipientKinds: map[string]struct{}{
constants.NotificationRecipientKindAccount: {},
constants.NotificationRecipientKindPersonalCustomer: {},
},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
constants.NotificationRefTypeExpiringAsset: {},
},
},
constants.NotificationTypeAgentRechargeCompleted: {
Type: constants.NotificationTypeAgentRechargeCompleted, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "店铺充值已入账",
BodyTemplate: "店铺充值已成功入账,请进入充值详情查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAgentRecharge: {},
},
},
constants.NotificationTypeRefundCompleted: {
Type: constants.NotificationTypeRefundCompleted, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "店铺退款已完成",
BodyTemplate: "店铺退款已完成,请进入退款详情查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeRefund: {},
},
},
constants.NotificationTypeExchangeShippingCreated: {

View File

@@ -18,7 +18,7 @@ import (
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。
// ReminderPublisher 将临期节点事实转换为店铺和个人客户通知 Outbox。
type ReminderPublisher struct {
db *gorm.DB
outbox *outbox.Repository
@@ -34,7 +34,7 @@ type recipientRow struct {
CustomerID uint
}
// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。
// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加店铺和个人通知事件。
func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error {
if p == nil || p.db == nil || p.outbox == nil {
return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置")
@@ -49,6 +49,11 @@ func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.Expiri
continue
}
key := recipientKey(candidate.AssetType, candidate.AssetID)
if candidate.ShopID != nil {
if err := p.appendShopNotification(ctx, tx, candidate, *candidate.ShopID); err != nil {
return err
}
}
for customerID := range recipients[key] {
if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil {
return err
@@ -59,6 +64,35 @@ func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.Expiri
})
}
// appendShopNotification 按资产所属店铺写入一条动态接收人通知事件。
func (p *ReminderPublisher) appendShopNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, shopID uint) error {
node := *candidate.DaysUntilFinalExpiry
expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102")
assetCode := "c"
if candidate.AssetType == constants.AssetTypeDevice {
assetCode = "d"
}
eventID := fmt.Sprintf("pex:%s:%d:%s:%d:shop:%d", assetCode, candidate.AssetID, expiryDate, node, shopID)
assetID := strconv.FormatUint(uint64(candidate.AssetID), 10)
shopIDText := strconv.FormatUint(uint64(shopID), 10)
_, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10),
ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: shopID,
NotificationType: constants.NotificationTypePackageExpiring, TemplateData: map[string]string{},
RefType: constants.NotificationRefTypeExpiringAsset, RefID: shopIDText,
ExpiresAt: candidate.EstimatedFinalExpiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入店铺套餐临期通知事件失败")
}
return nil
}
func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) {
cardIDs := make([]uint, 0)
deviceIDs := make([]uint, 0)

View File

@@ -13,7 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecipientResolver 按店铺当前独立归属解析账号和可用平台业务员。
// RecipientResolver 按店铺当前独立归属解析店铺账号和可用平台业务员。
type RecipientResolver struct {
db *gorm.DB
}
@@ -25,7 +25,7 @@ func NewRecipientResolver(db *gorm.DB) *RecipientResolver {
return &RecipientResolver{db: db}
}
// ResolveNotificationRecipients 返回启用的店铺账号和当前可用业务员账号 ID。
// ResolveNotificationRecipients 返回全部启用的店铺账号和当前可用业务员账号 ID。
//
// 解析只读取目标店铺当前保存的业务员 ID不读取父店铺、祖先店铺或创建人。
func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) {
@@ -46,8 +46,8 @@ func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, s
query := r.db.WithContext(ctx).Model(&model.Account{}).
Select("id").
Where("status = ? AND shop_id = ? AND is_primary = ? AND user_type = ?",
constants.StatusEnabled, shopID, true, constants.UserTypeAgent)
Where("status = ? AND shop_id = ? AND user_type = ?",
constants.StatusEnabled, shopID, constants.UserTypeAgent)
if target.BusinessOwnerAccountID != nil {
query = query.Or("id = ? AND status = ? AND user_type = ?",
*target.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform)

View File

@@ -4,6 +4,7 @@ import (
"context"
"strconv"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -33,5 +34,22 @@ func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walle
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
if err != nil || event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge {
return err
}
rechargeID := strconv.FormatUint(uint64(event.ReferenceID), 10)
notificationEventID := "agent-recharge:" + rechargeID + ":completed"
_, err = w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "agent_recharge", AggregateID: rechargeID,
ResourceType: constants.NotificationRefTypeAgentRecharge, ResourceID: rechargeID,
BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: event.ShopID,
NotificationType: constants.NotificationTypeAgentRechargeCompleted, TemplateData: map[string]string{},
RefType: constants.NotificationRefTypeAgentRecharge, RefID: rechargeID,
},
})
return err
}

View File

@@ -85,7 +85,10 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
default:
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
}
return s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID)
if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil {
return err
}
return s.appendCompletedNotification(ctx, tx, &refund)
})
if err != nil {
return err

View File

@@ -14,8 +14,10 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -47,6 +49,7 @@ type Service struct {
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
refundApprovalCreation *refundapprovalapp.CreationService
notificationOutbox *outbox.Repository
logger *zap.Logger
}
@@ -93,6 +96,11 @@ func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.Cr
s.refundApprovalCreation = service
}
// SetNotificationOutbox 注入退款完成后的可靠店铺通知 Outbox。
func (s *Service) SetNotificationOutbox(repository *outbox.Repository) {
s.notificationOutbox = repository
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
@@ -306,8 +314,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
return err
}
return nil
return s.appendCompletedNotification(ctx, tx, refund)
}); err != nil {
return err
}
@@ -326,6 +333,34 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
return nil
}
// appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。
func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
if refund == nil || refund.ShopID == nil {
return nil
}
if s.notificationOutbox == nil {
return errors.New(errors.CodeInternalError, "退款通知 Outbox 未配置")
}
refundID := fmt.Sprintf("%d", refund.ID)
eventID := "refund:" + refundID + ":completed"
_, err := s.notificationOutbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "refund", AggregateID: refundID,
ResourceType: constants.NotificationRefTypeRefund, ResourceID: refundID,
BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: *refund.ShopID,
NotificationType: constants.NotificationTypeRefundCompleted, TemplateData: map[string]string{},
RefType: constants.NotificationRefTypeRefund, RefID: refundID, RefKey: refund.RefundNo,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款完成通知事件失败")
}
return nil
}
// refundWalletPayment 处理钱包支付订单的退款回款。
func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
if order.PaymentMethod != model.PaymentMethodWallet {