All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
142 lines
5.3 KiB
Go
142 lines
5.3 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 将临期节点事实转换为个人客户通知 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}
|
|
}
|
|
|
|
type recipientRow struct {
|
|
AssetID uint
|
|
CustomerID uint
|
|
}
|
|
|
|
// 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 未配置")
|
|
}
|
|
recipients, err := p.loadRecipients(ctx, candidates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
for _, candidate := range candidates {
|
|
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil {
|
|
continue
|
|
}
|
|
key := recipientKey(candidate.AssetType, candidate.AssetID)
|
|
for customerID := range recipients[key] {
|
|
if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil {
|
|
return 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)
|
|
for _, candidate := range candidates {
|
|
if candidate.AssetType == constants.AssetTypeIotCard {
|
|
cardIDs = append(cardIDs, candidate.AssetID)
|
|
} else if candidate.AssetType == constants.AssetTypeDevice {
|
|
deviceIDs = append(deviceIDs, candidate.AssetID)
|
|
}
|
|
}
|
|
result := make(map[string]map[uint]struct{})
|
|
queries := []struct {
|
|
assetType string
|
|
ids []uint
|
|
sql string
|
|
}{
|
|
{constants.AssetTypeIotCard, cardIDs, `
|
|
SELECT c.id AS asset_id, b.customer_id
|
|
FROM tb_iot_card c
|
|
JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no
|
|
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
|
{constants.AssetTypeIotCard, cardIDs, `
|
|
SELECT c.id AS asset_id, b.customer_id
|
|
FROM tb_iot_card c
|
|
JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19))
|
|
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
|
{constants.AssetTypeDevice, deviceIDs, `
|
|
SELECT d.id AS asset_id, b.customer_id
|
|
FROM tb_device d
|
|
JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END
|
|
WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
|
}
|
|
for _, query := range queries {
|
|
if len(query.ids) == 0 {
|
|
continue
|
|
}
|
|
var rows []recipientRow
|
|
if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败")
|
|
}
|
|
for _, row := range rows {
|
|
key := recipientKey(query.assetType, row.AssetID)
|
|
if result[key] == nil {
|
|
result[key] = make(map[uint]struct{})
|
|
}
|
|
result[key][row.CustomerID] = struct{}{}
|
|
}
|
|
}
|
|
return result, 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: map[string]string{}, RefType: constants.NotificationRefTypeAsset,
|
|
RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func recipientKey(assetType string, assetID uint) string {
|
|
return fmt.Sprintf("%s:%d", assetType, assetID)
|
|
}
|