All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
H5 进入资产时已同步弹窗展示临期提醒,通知中心再记未读会重复提醒; 通知按剩余天数每天新增一条,保持未读会持续累积红点。 直发通知载荷新增可选 mark_read,仅临期个人通知设置,其他直发通知与店铺临期通知仍为未读。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
6.2 KiB
Go
142 lines
6.2 KiB
Go
// Package packageexpiry 提供套餐临期提醒的 PostgreSQL 与 Outbox Adapter。
|
||
package packageexpiry
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strconv"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
|
||
// ReminderPublisher 将临期事实转换为店铺通知(每日扫描)与个人客户通知(H5 进入资产时)Outbox。
|
||
type ReminderPublisher struct {
|
||
db *gorm.DB
|
||
outbox *outbox.Repository
|
||
}
|
||
|
||
// NewReminderPublisher 创建套餐临期通知 Outbox Adapter。
|
||
func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderPublisher {
|
||
return &ReminderPublisher{db: db, outbox: repository}
|
||
}
|
||
|
||
// Publish 每日扫描在同一事务内幂等追加店铺通知事件。
|
||
// 不再给个人客户批量写临期通知:个人客户只在 H5 进入该资产时由 PublishPersonal 提醒。
|
||
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 未配置")
|
||
}
|
||
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
for _, candidate := range candidates {
|
||
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil || candidate.ShopID == nil {
|
||
continue
|
||
}
|
||
if err := p.appendShopNotification(ctx, tx, candidate, *candidate.ShopID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// PublishPersonal 为进入资产的个人客户幂等写一条临期通知;事件号含资产、到期日、剩余天数与客户。
|
||
func (p *ReminderPublisher) PublishPersonal(ctx context.Context, candidate dto.ExpiringAssetItem, customerID uint) error {
|
||
if p == nil || p.db == nil || p.outbox == nil {
|
||
return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置")
|
||
}
|
||
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil || customerID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "套餐临期个人通知参数不完整")
|
||
}
|
||
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
return p.appendNotification(ctx, tx, candidate, customerID)
|
||
})
|
||
}
|
||
|
||
// 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: expiryTemplateData(candidate),
|
||
RefType: constants.NotificationRefTypeExpiringAsset, RefID: shopIDText,
|
||
ExpiresAt: candidate.EstimatedFinalExpiresAt,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入店铺套餐临期通知事件失败")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID 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:%d", assetCode, candidate.AssetID, expiryDate, node, customerID)
|
||
assetID := strconv.FormatUint(uint64(candidate.AssetID), 10)
|
||
_, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
|
||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||
AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10),
|
||
ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID,
|
||
Payload: notificationapp.PersonalCustomerDirectPayload{
|
||
RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring,
|
||
TemplateData: expiryTemplateData(candidate), RefType: constants.NotificationRefTypeAsset,
|
||
RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt,
|
||
// 进入资产时 H5 已同步弹窗展示该提醒,通知写入即已读,避免通知中心重复红点。
|
||
MarkRead: true,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expiryTemplateData 为后台和个人客户生成同口径的资产到期文案数据。
|
||
func expiryTemplateData(candidate dto.ExpiringAssetItem) map[string]string {
|
||
status := "即将到期"
|
||
if candidate.DaysUntilFinalExpiry != nil {
|
||
switch {
|
||
case *candidate.DaysUntilFinalExpiry < 0:
|
||
status = "已到期"
|
||
case *candidate.DaysUntilFinalExpiry == 0:
|
||
status = "今天到期"
|
||
}
|
||
}
|
||
expiryDate := ""
|
||
if candidate.EstimatedFinalExpiresAt != nil {
|
||
expiryDate = candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("2006-01-02")
|
||
}
|
||
return map[string]string{
|
||
"asset_identifier": candidate.Identifier,
|
||
"expiry_status": status,
|
||
"expiry_date": expiryDate,
|
||
}
|
||
}
|