fix: 第1批 M9 套餐临期提醒按资产、扫码进入时触发
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:
邹进
2026-10-12 00:15:27 +09:00
parent 9ced14fa1b
commit 0f1ea5bc3e
18 changed files with 418 additions and 100 deletions

View File

@@ -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(&notifications).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
}

View 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))
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()),

View File

@@ -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)
}

View File

@@ -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:"当前登录用户绑定的手机号"`

View File

@@ -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
}

View File

@@ -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", "红色"
}

View File

@@ -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{

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11

View File

@@ -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
- 资产信息响应中提醒字段的具体命名在实现时确定,并写入接口文档。

View File

@@ -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`)。
- 无数据库迁移。

View File

@@ -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` 类型的通知照常返回

View File

@@ -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`。

View File

@@ -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 表示代理充值详情资源引用。

View File

@@ -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)