fix: 第1批 M9 套餐临期提醒按资产、扫码进入时触发
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m17s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m17s
- 每日扫描不再给个人客户写临期通知,店铺通知不变 - H5 资产信息在当前资产剩余 0~15 天且当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条临期通知(Redis 去重,失败不占键) - 个人通知列表、未读数、全部已读中临期提醒只处理当前资产(按 ref_key) - OpenSpec Change scope-expiry-reminder-to-current-asset:6/9,临期 fixture 端到端验证与每日扫描验证待测试部署后完成
This commit is contained in:
@@ -68,13 +68,17 @@ func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notifica
|
||||
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
|
||||
}
|
||||
|
||||
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
|
||||
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
|
||||
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读;
|
||||
// 套餐临期提醒只处理当前资产的,其他资产的临期提醒保持未读。
|
||||
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint, currentAssetKey string) (*dto.NotificationReadAllResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
|
||||
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
|
||||
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI, func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where(constants.NotificationPersonalExpiringAssetCondition,
|
||||
constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,7 +111,7 @@ func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, rec
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
|
||||
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string, scopes ...func(*gorm.DB) *gorm.DB) (int64, error) {
|
||||
if s.auditWriter == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
@@ -120,6 +124,9 @@ func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, rec
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
query = scope(query)
|
||||
}
|
||||
if err := query.Order("id ASC").Find(¬ifications).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
|
||||
}
|
||||
|
||||
98
internal/application/packageexpiry/entry_reminder.go
Normal file
98
internal/application/packageexpiry/entry_reminder.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// entryReminderWindowDays 临期提醒窗口:剩余 0~15 个上海自然日。
|
||||
const entryReminderWindowDays = 15
|
||||
|
||||
var entryReminderLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// PersonalReminderPublisher 为单个个人客户幂等写一条临期通知。
|
||||
type PersonalReminderPublisher interface {
|
||||
PublishPersonal(ctx context.Context, candidate dto.ExpiringAssetItem, customerID uint) error
|
||||
}
|
||||
|
||||
// EntryReminder 在 H5 进入资产时判定并发出套餐临期提醒,同一客户、同一资产、同一上海自然日至多一次。
|
||||
type EntryReminder struct {
|
||||
redis *redis.Client
|
||||
publisher PersonalReminderPublisher
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEntryReminder 创建进入资产时的临期提醒用例。
|
||||
func NewEntryReminder(redisClient *redis.Client, publisher PersonalReminderPublisher, logger *zap.Logger) *EntryReminder {
|
||||
return &EntryReminder{redis: redisClient, publisher: publisher, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Remind 返回本次应展示的临期提醒;不在窗口、当天已提醒或通知写入失败时返回 nil,不让资产信息接口失败。
|
||||
// 顺序:检查去重键 → 写通知 → SET NX → 抢到键才返回;Redis 不可用按未提醒处理,通知由事件号幂等。
|
||||
func (r *EntryReminder) Remind(ctx context.Context, customerID uint, candidate dto.ExpiringAssetItem) *dto.AssetExpiryReminder {
|
||||
if r == nil || r.publisher == nil || customerID == 0 || !inEntryReminderWindow(candidate.PackageExpiryEstimate) {
|
||||
return nil
|
||||
}
|
||||
now := r.now().In(entryReminderLocation)
|
||||
key := constants.RedisPackageExpiryEntryReminderKey(customerID, candidate.AssetType, candidate.Identifier, now.Format("20060102"))
|
||||
redisOK := r.redis != nil
|
||||
if redisOK {
|
||||
exists, err := r.redis.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
redisOK = false
|
||||
r.warn("检查临期提醒去重键失败,按未提醒处理", err)
|
||||
} else if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := r.publisher.PublishPersonal(ctx, candidate, customerID); err != nil {
|
||||
// 写通知失败不占键、不返回字段,下次进入重试。
|
||||
r.warn("写入进入资产临期通知失败", err)
|
||||
return nil
|
||||
}
|
||||
if redisOK {
|
||||
ttl := endOfShanghaiDay(now).Sub(now) + constants.PackageExpiryEntryReminderTTLBuffer
|
||||
acquired, err := r.redis.SetNX(ctx, key, "1", ttl).Result()
|
||||
if err != nil {
|
||||
r.warn("占用临期提醒去重键失败,按未提醒处理", err)
|
||||
} else if !acquired {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
days := *candidate.DaysUntilFinalExpiry
|
||||
level, levelName := packageexpiryquery.ExpiryLevel(days)
|
||||
return &dto.AssetExpiryReminder{
|
||||
RemainingDays: days, ExpiresAt: *candidate.EstimatedFinalExpiresAt,
|
||||
Level: level, LevelName: levelName,
|
||||
}
|
||||
}
|
||||
|
||||
// inEntryReminderWindow 判断最终到期可精确推算且剩余 0~15 个上海自然日。
|
||||
func inEntryReminderWindow(estimate dto.PackageExpiryEstimate) bool {
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact ||
|
||||
estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil {
|
||||
return false
|
||||
}
|
||||
days := *estimate.DaysUntilFinalExpiry
|
||||
return days >= 0 && days <= entryReminderWindowDays
|
||||
}
|
||||
|
||||
// endOfShanghaiDay 返回上海时间当天结束时刻(次日零点)。
|
||||
func endOfShanghaiDay(now time.Time) time.Time {
|
||||
year, month, day := now.In(entryReminderLocation).Date()
|
||||
return time.Date(year, month, day+1, 0, 0, 0, 0, entryReminderLocation)
|
||||
}
|
||||
|
||||
func (r *EntryReminder) warn(message string, err error) {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn(message, zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
|
||||
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
|
||||
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
|
||||
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
@@ -209,6 +212,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
|
||||
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
handler.SetForceRechargeChecker(svc.Recharge)
|
||||
handler.SetExpiryReminder(packageExpiryApp.NewEntryReminder(
|
||||
deps.Redis, packageExpiryInfra.NewReminderPublisher(deps.DB, outbox.NewRepository()), deps.Logger,
|
||||
))
|
||||
return handler
|
||||
}(),
|
||||
ClientWallet: func() *app.ClientWalletHandler {
|
||||
|
||||
@@ -46,6 +46,17 @@ type ClientAssetHandler struct {
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
paymentMethodPolicy ClientPaymentMethodPolicy
|
||||
forceRechargeChecker ClientForceRechargeChecker
|
||||
expiryReminder ClientExpiryReminder
|
||||
}
|
||||
|
||||
// ClientExpiryReminder 在 H5 进入资产时判定并发出套餐临期提醒。
|
||||
type ClientExpiryReminder interface {
|
||||
Remind(ctx context.Context, customerID uint, candidate dto.ExpiringAssetItem) *dto.AssetExpiryReminder
|
||||
}
|
||||
|
||||
// SetExpiryReminder 注入进入资产时的套餐临期提醒用例。
|
||||
func (h *ClientAssetHandler) SetExpiryReminder(reminder ClientExpiryReminder) {
|
||||
h.expiryReminder = reminder
|
||||
}
|
||||
|
||||
// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。
|
||||
@@ -279,6 +290,10 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
BoundDeviceName: resolved.Asset.BoundDeviceName,
|
||||
}
|
||||
|
||||
if h.expiryReminder != nil {
|
||||
resp.ExpiryReminder = h.expiryReminder.Remind(c.UserContext(), resolved.CustomerID, expiringCandidateOf(resolved.Asset))
|
||||
}
|
||||
|
||||
// 调用服务层获取设备实时 Gateway 数据(per D-11)
|
||||
// Gateway 失败时 DeviceRealtime 仅含 gateway_msg,不阻断主流程(per D-06)
|
||||
if resp.AssetType == "device" {
|
||||
@@ -851,3 +866,24 @@ func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealt
|
||||
info.GatewayMsg = g.GatewayMsg
|
||||
return info
|
||||
}
|
||||
|
||||
// expiringCandidateOf 把资产解析结果转换为临期提醒候选;资产标识与通知中心过滤口径一致(卡 ICCID;设备虚拟号,空则 IMEI)。
|
||||
func expiringCandidateOf(asset *dto.AssetResolveResponse) dto.ExpiringAssetItem {
|
||||
candidate := dto.ExpiringAssetItem{
|
||||
AssetID: asset.AssetID, ShopID: asset.ShopID, PackageExpiryEstimate: asset.PackageExpiryEstimate,
|
||||
}
|
||||
if asset.CurrentPackageUsageID != nil {
|
||||
candidate.PackageUsageID = *asset.CurrentPackageUsageID
|
||||
}
|
||||
if asset.AssetType == "device" {
|
||||
candidate.AssetType = constants.AssetTypeDevice
|
||||
candidate.Identifier = asset.VirtualNo
|
||||
if candidate.Identifier == "" {
|
||||
candidate.Identifier = asset.IMEI
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
candidate.AssetType = constants.AssetTypeIotCard
|
||||
candidate.Identifier = asset.ICCID
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ func (h *ClientNotificationHandler) UnreadCount(c *fiber.Ctx) error {
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID)
|
||||
assetKey, err := h.currentAssetKey(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID, assetKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,7 +65,11 @@ func (h *ClientNotificationHandler) List(c *fiber.Ctx) error {
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
result, err := h.query.PersonalList(c.UserContext(), customerID, request)
|
||||
assetKey, err := h.currentAssetKey(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.query.PersonalList(c.UserContext(), customerID, assetKey, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,7 +83,11 @@ func (h *ClientNotificationHandler) MarkAllRead(c *fiber.Ctx) error {
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID)
|
||||
assetKey, err := h.currentAssetKey(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID, assetKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,6 +111,15 @@ func (h *ClientNotificationHandler) MarkRead(c *fiber.Ctx) error {
|
||||
return response.Success(c, dto.NotificationReadResponse{Success: true})
|
||||
}
|
||||
|
||||
// currentAssetKey 读取令牌当前资产的通知引用标识,用于只展示当前资产的套餐临期提醒。
|
||||
func (h *ClientNotificationHandler) currentAssetKey(c *fiber.Ctx) (string, error) {
|
||||
assetType, assetID, ok := middleware.GetCurrentAsset(c)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return h.query.CurrentAssetRefKey(c.UserContext(), assetType, assetID)
|
||||
}
|
||||
|
||||
func logPersonalNotificationListValidationFailure(c *fiber.Ctx, request dto.PersonalNotificationListRequest, err error) {
|
||||
logger.GetAppLogger().Warn("个人客户站内通知列表参数验证失败",
|
||||
zap.String("method", c.Method()),
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ReminderPublisher 将每日临期事实转换为店铺和个人客户通知 Outbox。
|
||||
// ReminderPublisher 将临期事实转换为店铺通知(每日扫描)与个人客户通知(H5 进入资产时)Outbox。
|
||||
type ReminderPublisher struct {
|
||||
db *gorm.DB
|
||||
outbox *outbox.Repository
|
||||
@@ -29,41 +29,38 @@ func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderP
|
||||
return &ReminderPublisher{db: db, outbox: repository}
|
||||
}
|
||||
|
||||
type recipientRow struct {
|
||||
AssetID uint
|
||||
CustomerID uint
|
||||
}
|
||||
|
||||
// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加店铺和个人通知事件。
|
||||
// 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 未配置")
|
||||
}
|
||||
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 {
|
||||
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil || candidate.ShopID == nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
@@ -93,57 +90,6 @@ func (p *ReminderPublisher) appendShopNotification(ctx context.Context, tx *gorm
|
||||
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")
|
||||
@@ -191,7 +137,3 @@ func expiryTemplateData(candidate dto.ExpiringAssetItem) map[string]string {
|
||||
"expiry_date": expiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
func recipientKey(assetType string, assetID uint) string {
|
||||
return fmt.Sprintf("%s:%d", assetType, assetID)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,18 @@ type AssetInfoRequest struct {
|
||||
|
||||
// AssetInfoResponse B1 资产信息响应
|
||||
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit)
|
||||
// AssetExpiryReminder H5 进入资产时的套餐临期提醒;同一客户、同一资产、同一上海自然日至多返回一次。
|
||||
type AssetExpiryReminder struct {
|
||||
RemainingDays int `json:"remaining_days" description:"距最终到期的剩余上海自然日天数(0~15)"`
|
||||
ExpiresAt time.Time `json:"expires_at" description:"预计套餐最终到期时间"`
|
||||
Level string `json:"level" description:"提醒等级 (red:0至3天, purple:4至7天, pink:8至15天)"`
|
||||
LevelName string `json:"level_name" description:"提醒等级中文名称"`
|
||||
}
|
||||
|
||||
type AssetInfoResponse struct {
|
||||
PackageExpiryEstimate
|
||||
// ExpiryReminder 当前资产临期且当天首次进入时返回,否则不返回该字段
|
||||
ExpiryReminder *AssetExpiryReminder `json:"expiry_reminder,omitempty" description:"套餐临期提醒:当前资产剩余 0~15 天且该客户当天首次进入时返回,同时写一条该资产的临期通知;否则不返回"`
|
||||
// === 登录用户信息 ===
|
||||
BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"`
|
||||
|
||||
|
||||
@@ -84,13 +84,15 @@ func (q *Query) List(ctx context.Context, recipientID uint, request dto.Notifica
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数。
|
||||
func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto.NotificationUnreadCountResponse, error) {
|
||||
// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数;套餐临期提醒只计当前资产的。
|
||||
func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint, currentAssetKey string) (*dto.NotificationUnreadCountResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var count int64
|
||||
err := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()).
|
||||
Where(constants.NotificationPersonalExpiringAssetCondition,
|
||||
constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey).
|
||||
Where("is_read = ?", false).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
@@ -99,8 +101,8 @@ func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto.
|
||||
return newUnreadCountResponse(count), nil
|
||||
}
|
||||
|
||||
// PersonalList 查询当前个人客户可见的未过期业务通知简化列表。
|
||||
func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) {
|
||||
// PersonalList 查询当前个人客户可见的未过期业务通知简化列表;套餐临期提醒只含当前资产的。
|
||||
func (q *Query) PersonalList(ctx context.Context, customerID uint, currentAssetKey string, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
@@ -108,7 +110,9 @@ func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.P
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC())
|
||||
base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()).
|
||||
Where(constants.NotificationPersonalExpiringAssetCondition,
|
||||
constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey)
|
||||
if request.IsRead != nil {
|
||||
base = base.Where("is_read = ?", *request.IsRead)
|
||||
}
|
||||
@@ -238,3 +242,35 @@ func popupSnapshotItem(record model.Notification) *dto.NotificationPopupSnapshot
|
||||
ActionType: record.PopupSnapshot.ActionType,
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentAssetRefKey 返回个人客户令牌当前资产的通知引用标识:卡为 ICCID;设备优先虚拟号,为空则 IMEI。
|
||||
// 资产不存在或类型未知时返回空串,此时个人通知中心不展示任何套餐临期提醒。
|
||||
func (q *Query) CurrentAssetRefKey(ctx context.Context, assetType string, assetID uint) (string, error) {
|
||||
if assetID == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var row struct {
|
||||
ICCID string `gorm:"column:iccid"`
|
||||
VirtualNo string `gorm:"column:virtual_no"`
|
||||
IMEI string `gorm:"column:imei"`
|
||||
}
|
||||
var result *gorm.DB
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard, "card":
|
||||
result = q.db.WithContext(ctx).Model(&model.IotCard{}).Select("iccid").Where("id = ?", assetID).Limit(1).Scan(&row)
|
||||
case constants.AssetTypeDevice:
|
||||
result = q.db.WithContext(ctx).Model(&model.Device{}).Select("virtual_no", "imei").Where("id = ?", assetID).Limit(1).Scan(&row)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
if result.Error != nil {
|
||||
return "", errors.Wrap(errors.CodeDatabaseError, result.Error, "查询当前资产标识失败")
|
||||
}
|
||||
if row.ICCID != "" {
|
||||
return row.ICCID, nil
|
||||
}
|
||||
if row.VirtualNo != "" {
|
||||
return row.VirtualNo, nil
|
||||
}
|
||||
return row.IMEI, nil
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandida
|
||||
if filter.PackageID != nil && usage.PackageID != *filter.PackageID {
|
||||
continue
|
||||
}
|
||||
level, levelName := expiryLevel(days)
|
||||
level, levelName := ExpiryLevel(days)
|
||||
items = append(items, dto.ExpiringAssetItem{
|
||||
AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID,
|
||||
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName,
|
||||
@@ -363,7 +363,8 @@ func validateListFilter(filter ListFilter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func expiryLevel(days int) (string, string) {
|
||||
// ExpiryLevel 返回临期高亮等级与中文名称:≤3 红、≤7 紫、其余粉红。
|
||||
func ExpiryLevel(days int) (string, string) {
|
||||
if days <= 3 {
|
||||
return "red", "红色"
|
||||
}
|
||||
|
||||
@@ -149,11 +149,12 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
|
||||
})
|
||||
|
||||
Register(authGroup, doc, basePath, "GET", "/asset/info", handlers.ClientAsset.GetAssetInfo, RouteSpec{
|
||||
Summary: "资产信息",
|
||||
Tags: []string{"个人客户 - 资产"},
|
||||
Auth: true,
|
||||
Input: &dto.AssetInfoRequest{},
|
||||
Output: &dto.AssetInfoResponse{},
|
||||
Summary: "资产信息",
|
||||
Description: "资产剩余 0~15 个上海自然日且当前客户当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条套餐临期通知;同一客户、同一资产当天只返回一次。",
|
||||
Tags: []string{"个人客户 - 资产"},
|
||||
Auth: true,
|
||||
Input: &dto.AssetInfoRequest{},
|
||||
Output: &dto.AssetInfoResponse{},
|
||||
})
|
||||
|
||||
Register(authGroup, doc, basePath, "GET", "/asset/packages", handlers.ClientAsset.GetAvailablePackages, RouteSpec{
|
||||
|
||||
@@ -15,7 +15,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler
|
||||
|
||||
Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{
|
||||
Summary: "查询个人客户通知未读数",
|
||||
Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果。",
|
||||
Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果;套餐临期提醒只计当前登录资产的。",
|
||||
Tags: []string{"个人客户 - 站内通知"},
|
||||
Output: new(dto.NotificationUnreadCountResponse),
|
||||
Auth: true,
|
||||
@@ -23,7 +23,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler
|
||||
|
||||
Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "查询个人客户通知列表",
|
||||
Description: "仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。",
|
||||
Description: "仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序;套餐临期提醒只返回当前登录资产的(按 ref_key 匹配,卡为 ICCID、设备为虚拟号或 IMEI),其他类型不受影响。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。",
|
||||
Tags: []string{"个人客户 - 站内通知"},
|
||||
Input: new(dto.PersonalNotificationListRequest),
|
||||
Output: new(dto.PersonalNotificationListResponse),
|
||||
@@ -33,7 +33,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler
|
||||
// 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。
|
||||
Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{
|
||||
Summary: "全部标记个人客户通知已读",
|
||||
Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功。",
|
||||
Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功;其他资产的套餐临期提醒保持未读。",
|
||||
Tags: []string{"个人客户 - 站内通知"},
|
||||
Output: new(dto.NotificationReadAllResponse),
|
||||
Auth: true,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,31 @@
|
||||
## Context
|
||||
|
||||
承接《第1批-8-9月迭代缺陷修复技术方案》第 4 节。`packageexpiry` 的 `Publish` 在每日扫描中给店铺接收人(`appendShopNotification`)和所有绑定客户(`loadRecipients`)批量写通知;个人通知列表(`PersonalNotificationListRequest`,`internal/model/dto/notification_dto.go:94`)无资产过滤。H5 令牌带当前资产(`middleware.GetCurrentAsset`),资产信息接口为 `GET /api/c/v1/asset/info`。Outbox 写入按 `event_id` 冲突忽略(`internal/infrastructure/messaging/outbox/repository.go:103`)。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** 临期提醒只在客户进入该资产时出现,每天一次;通知中心只显示当前资产的临期提醒。
|
||||
|
||||
**Non-Goals:** 不改到期推算、提醒窗口与等级、店铺通知;不新增接口入参;无迁移。
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **进入时判定,复用最终到期推算。** 在资产信息接口内按当前资产调用 `packageexpiry` 的推算,与每日扫描同口径。
|
||||
2. **去重顺序:先查键 → 写通知 → `SET NX` → 抢到才返回字段。** 方案原顺序是“先写通知再 `SET NX`”,当天每次进入都会写一次 Outbox(虽冲突忽略,仍是无效写)。评审确认先用 Redis `EXISTS`/`GET` 检查,键存在直接返回,只有未提醒时才写通知。写通知成功后才占键,写失败不占键、不返回字段,避免“当天已去重但通知没发出”。两个并发请求都通过检查时,通知由事件号幂等,只有抢到键的一方返回字段。键定义放 `pkg/constants/redis.go`,TTL 到当天上海时间结束再加缓冲。
|
||||
3. **Redis 不可用按未提醒处理。** 字段可能当天多次返回,通知由事件号兜底不重复;接受该降级,不因 Redis 故障让资产信息接口失败。
|
||||
4. **只用 `ref_key` 过滤,列表、未读数、全部已读同一口径。** `ref_id` 不带资产类型,卡 5 与设备 5 冲突。过滤只作用于 `type = package.expiring AND ref_type = asset`。“全部已读”(`PUT /notifications/read-all`)也加同样的条件,否则会把客户在其他资产才能看到的临期提醒一并标为已读。
|
||||
5. **停用个人客户预发布。** 去掉 `Publish` 里给绑定客户批量写通知的循环,店铺通知不变。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [GET 接口产生写副作用] → 只在当天首次命中时写一次通知,其余请求只读 Redis。
|
||||
- [设备补虚拟号后历史提醒匹配不上] → 影响可接受,已在 spec 中写明。
|
||||
- [资产信息接口延迟增加] → 推算与 Redis 检查各一次;推算结果为非临期时不访问 Redis。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
无迁移。测试环境部署后,每日扫描不再产生个人客户临期通知;已有个人通知按过滤规则展示。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 资产信息响应中提醒字段的具体命名在实现时确定,并写入接口文档。
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
套餐临期提醒发给所有绑定过该资产的客户(`reminder_publisher.go` 的 `loadRecipients`),个人通知列表又没有资产过滤,客户在任一资产页都会看到名下全部资产的临期提醒(运营清单 26)。业务已确认改为按资产提醒:哪个资产被扫码进入就提醒哪个,同一客户、同一资产、同一天只提醒一次。依据为《第1批-8-9月迭代缺陷修复技术方案》第 4 节(M9)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- H5 资产信息接口 `GET /api/c/v1/asset/info` 在当前资产处于临期窗口(剩余 0~15 天)且当天未提醒过时,返回临期提醒字段(剩余天数、到期时间、等级),并为“当前客户、当前资产”写一条 `package.expiring` 个人通知;同一客户、同一资产、同一北京时间日期只提醒一次。
|
||||
- **BREAKING** 每日扫描不再给个人客户写临期通知;店铺通知不变。
|
||||
- **BREAKING** 个人通知列表、未读数与“全部已读”中,临期提醒只处理当前资产的;其他类型通知不受影响,接口入参不变。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `notification-delivery`:每日临期扫描只通知店铺接收人;新增 H5 进入资产时的临期提醒;个人通知中心临期提醒按当前资产过滤。
|
||||
|
||||
## Impact
|
||||
|
||||
- 代码:`internal/infrastructure/packageexpiry/reminder_publisher.go`(去掉个人客户批量写入)、H5 资产信息 Handler 与服务(复用 `packageexpiry` 最终到期推算、写 Outbox 个人通知)、个人通知列表与未读数查询、`pkg/constants/redis.go`(去重键)、资产信息响应 DTO。
|
||||
- 接口:`GET /api/c/v1/asset/info` 新增字段;个人通知列表与未读数结果范围变化;重新生成接口文档。
|
||||
- 依赖:Redis(去重键);Outbox 按 `event_id` 冲突忽略写入(`internal/infrastructure/messaging/outbox/repository.go`)。
|
||||
- 无数据库迁移。
|
||||
@@ -0,0 +1,65 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 套餐临期每日站内提醒
|
||||
系统 SHALL 在每日扫描时,向最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产所属店铺后台接收人创建套餐临期站内通知。系统 MUST 对同一资产、同一最终到期日期、同一剩余天数和同一接收人保持幂等。每日扫描 MUST NOT 向个人客户创建套餐临期通知;个人客户的临期提醒只按“H5 进入资产时的套餐临期提醒”产生。
|
||||
|
||||
#### Scenario: 临期窗口内连续两日提醒
|
||||
- **GIVEN** 一项资产的最终到期时间可精确推算,昨天剩余 3 个上海自然日,今天剩余 2 个上海自然日,且两日均有有效店铺接收人
|
||||
- **WHEN** 每日临期扫描分别执行
|
||||
- **THEN** 系统分别为店铺接收人创建昨天和今天的套餐临期通知
|
||||
|
||||
#### Scenario: 不在临期窗口的资产
|
||||
- **GIVEN** 一项资产剩余超过 15 个上海自然日、已经到期或最终到期时间不可精确推算
|
||||
- **WHEN** 每日临期扫描执行
|
||||
- **THEN** 系统不为该资产创建套餐临期通知
|
||||
|
||||
#### Scenario: 每日扫描不通知个人客户
|
||||
- **GIVEN** 一项临期资产绑定了个人客户
|
||||
- **WHEN** 每日临期扫描执行
|
||||
- **THEN** 系统只为店铺接收人创建通知,不为个人客户创建通知
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: H5 进入资产时的套餐临期提醒
|
||||
|
||||
个人客户访问 `GET /api/c/v1/asset/info` 时,系统 SHALL 以令牌中的当前资产,按与每日扫描相同的最终到期推算判断是否临期(剩余 0~15 个上海自然日,≤3 红、≤7 紫、≤15 粉红)。命中且该客户当天(上海自然日)尚未就该资产提醒过时,系统 MUST 在响应中返回临期提醒字段(剩余天数、到期时间、等级),并为当前客户、当前资产写一条 `package.expiring` 个人通知,通知幂等事件号 MUST 包含资产、到期日、剩余天数与客户。
|
||||
|
||||
同一客户、同一资产、同一上海自然日 MUST 至多返回一次提醒字段、至多产生一条通知;去重键为“客户 + 资产标识 + 日期”。系统 MUST 先检查去重键,已存在则不返回字段、不写通知;不存在时先写通知,写入成功后再占用去重键,抢占成功才返回字段。通知写入失败时 MUST NOT 占用去重键、MUST NOT 返回字段,下次进入重试。去重存储不可用时系统按“未提醒过”处理,提醒字段可能当天多次返回,通知由事件号幂等保证不重复。
|
||||
|
||||
#### Scenario: 当天首次进入临期资产
|
||||
- **WHEN** 客户首次在当天进入一项剩余 2 天的资产
|
||||
- **THEN** 响应返回剩余天数 2、到期时间与红色等级,并新增一条该资产的临期通知
|
||||
|
||||
#### Scenario: 同一天再次进入
|
||||
- **WHEN** 同一客户当天再次进入该资产
|
||||
- **THEN** 响应不返回临期提醒字段,不新增通知
|
||||
|
||||
#### Scenario: 第二天再次进入
|
||||
- **WHEN** 同一客户第二天进入该资产且仍在临期窗口
|
||||
- **THEN** 再次返回提醒字段并新增一条通知
|
||||
|
||||
#### Scenario: 不同客户进入同一资产
|
||||
- **WHEN** 客户 B 当天进入客户 A 已被提醒过的资产
|
||||
- **THEN** 客户 B 独立获得一次提醒
|
||||
|
||||
#### Scenario: 非临期资产
|
||||
- **WHEN** 客户进入剩余超过 15 天或最终到期不可精确推算的资产
|
||||
- **THEN** 响应不返回临期提醒字段,不写通知
|
||||
|
||||
### Requirement: 个人通知中心临期提醒按当前资产过滤
|
||||
|
||||
个人客户通知列表、未读数与“全部已读”(`PUT /api/c/v1/notifications/read-all`)SHALL 对 `type = package.expiring` 且 `ref_type = asset` 的通知,只处理 `ref_key` 等于当前资产标识的通知(卡为 ICCID;设备优先虚拟号,为空则 IMEI);MUST NOT 使用 `ref_id` 过滤。“全部已读”MUST NOT 把其他资产的临期提醒标为已读。其他类型通知 MUST 不受影响,接口入参不变。设备后补虚拟号前产生的历史提醒 `ref_key` 为 IMEI,补号后不再匹配,属可接受限制。
|
||||
|
||||
#### Scenario: 客户名下两个临期资产
|
||||
- **GIVEN** 客户名下资产 A、B 都有临期通知
|
||||
- **WHEN** 客户在资产 A 中查询通知列表与未读数
|
||||
- **THEN** 临期通知只含 A 的,未读数只计 A 的临期通知与其他类型通知
|
||||
|
||||
#### Scenario: 全部已读只处理当前资产的临期提醒
|
||||
- **GIVEN** 客户名下资产 A、B 都有未读临期通知
|
||||
- **WHEN** 客户在资产 A 中点“全部已读”
|
||||
- **THEN** A 的临期通知与其他类型通知变为已读,B 的临期通知仍为未读,客户进入 B 时仍能看到
|
||||
|
||||
#### Scenario: 非临期通知不受过滤
|
||||
- **WHEN** 客户在资产 A 中查询通知列表
|
||||
- **THEN** 非 `package.expiring` 类型的通知照常返回
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. 停用个人客户预发布
|
||||
|
||||
- [x] 1.1 `internal/infrastructure/packageexpiry/reminder_publisher.go` 的 `Publish` 去掉给绑定客户批量写个人通知的循环,保留 `appendShopNotification`。
|
||||
|
||||
## 2. 进入资产时提醒
|
||||
|
||||
- [x] 2.1 `pkg/constants/redis.go` 新增去重键(客户 ID + 资产标识 + 上海日期)及 TTL 计算(当天结束加缓冲)。
|
||||
- [x] 2.2 资产信息服务按当前资产复用 `packageexpiry` 最终到期推算;命中窗口时依次:检查键 → 经 Outbox 写 `package.expiring` 个人通知(事件号 `pex:资产:到期日:剩余天数:客户`)→ `SET NX` → 抢到才返回字段;写通知失败不占键不返回;Redis 不可用按未提醒处理。
|
||||
- [x] 2.3 资产信息响应 DTO 新增临期提醒字段(剩余天数、到期时间、等级),更新接口说明。
|
||||
|
||||
## 3. 通知中心过滤
|
||||
|
||||
- [x] 3.1 个人通知列表、未读数与“全部已读”对 `type = package.expiring AND ref_type = asset` 只保留 `ref_key` 等于当前资产标识(卡 ICCID;设备虚拟号,空则 IMEI)的记录,其他类型不变,入参不变。
|
||||
|
||||
## 4. 文档与测试环境验证
|
||||
|
||||
- [x] 4.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。
|
||||
- [ ] 4.2 按 ENG-TEST-001(测试库 `junhong_cmp_test`、Redis DB 6)构造同一客户绑定两个临期资产:进入 A 只返回 A 的字段并新增 A 的通知,通知中心与未读数只含 A;在 A 中“全部已读”后 B 的临期通知仍未读;同一天再次进入 A 不返回字段、不新增通知;清理键模拟第二天后再次提醒;客户 B 进入 A 独立提醒一次;非临期通知不受过滤。
|
||||
- [ ] 4.3 手动触发每日扫描:不再给个人客户写临期通知,店铺通知照常。
|
||||
- [ ] 4.4 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。
|
||||
@@ -59,6 +59,9 @@ const (
|
||||
NotificationRefTypePackage = "package"
|
||||
// NotificationRefTypeAsset 表示个人客户资产资源引用。
|
||||
NotificationRefTypeAsset = "asset"
|
||||
// NotificationPersonalExpiringAssetCondition 个人通知中心只保留当前资产的套餐临期提醒:
|
||||
// 参数依次为临期通知类型、资产引用类型、当前资产标识(卡 ICCID;设备虚拟号,空则 IMEI);不按 ref_id 过滤。
|
||||
NotificationPersonalExpiringAssetCondition = "(NOT (type = ? AND ref_type = ?) OR ref_key = ?)"
|
||||
// NotificationRefTypeRefund 表示退款详情资源引用。
|
||||
NotificationRefTypeRefund = "refund"
|
||||
// NotificationRefTypeAgentRecharge 表示代理充值详情资源引用。
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package constants
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 认证相关 Redis Key
|
||||
@@ -556,6 +559,16 @@ func RedisPollingConfigChangedChannel() string {
|
||||
// 系统全局配置 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisPackageExpiryEntryReminderKey H5 进入资产时套餐临期提醒的去重键
|
||||
// 用途:同一客户、同一资产、同一上海自然日至多返回一次提醒字段
|
||||
// 过期时间:当天上海时间结束后再加缓冲,由调用方计算
|
||||
func RedisPackageExpiryEntryReminderKey(customerID uint, assetType, assetIdentifier, shanghaiDate string) string {
|
||||
return fmt.Sprintf("package_expiry:entry_reminder:%d:%s:%s:%s", customerID, assetType, assetIdentifier, shanghaiDate)
|
||||
}
|
||||
|
||||
// PackageExpiryEntryReminderTTLBuffer 临期提醒去重键在当天结束后保留的缓冲时长。
|
||||
const PackageExpiryEntryReminderTTLBuffer = time.Hour
|
||||
|
||||
// RedisSystemOperationPasswordKey 全局操作密码 Redis 键
|
||||
// 用途:存储超级管理员设置的全局操作密码(bcrypt 哈希值)
|
||||
// 过期时间:永久存储(无 TTL)
|
||||
|
||||
Reference in New Issue
Block a user