Files
junhong_cmp_fiber/internal/infrastructure/packageexpiry/reminder_publisher.go
邹进 713045a657
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
fix: 第1批 M9 H5 进入资产的临期个人通知写入即已读
H5 进入资产时已同步弹窗展示临期提醒,通知中心再记未读会重复提醒;
通知按剩余天数每天新增一条,保持未读会持续累积红点。
直发通知载荷新增可选 mark_read,仅临期个人通知设置,其他直发通知与店铺临期通知仍为未读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:45:20 +09:00

142 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
}
}