Files
junhong_cmp_fiber/docs/7月迭代/需求22-套餐临期提醒.md
2026-07-16 15:07:59 +08:00

11 KiB
Raw Blame History

需求22套餐临期提醒

状态:待评审


业务规则

临期定义

当资产的当前生效主套餐PackageUsage.status=1)按 Asia/Shanghai 自然日计算的剩余天数在 015 天时,该资产进入临期状态。

资产有可接续的排队主套餐时不进入临期提醒因为服务不会在当前套餐到期后中断需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。

查询与通知职责

  • 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
  • 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
  • tb_expiry_push_record 仅用于防止同一资产、接收人、渠道、阈值重复通知。

颜色规则Version 2

剩余天数 颜色
≤ 15天 粉红色
≤ 7天 紫色
≤ 3天 黄色

各端提醒规则

场景 提醒方式 触发节点
后台管理 列表加临期天数列 + 高亮详情加临期字段≤15天高亮 实时计算
企业客户 企微推送Phase 2后台每日生成临期列表 15天/7天/3天节点
代理端 首页展示临期卡/设备数量;列表高亮 实时计算
C端公众号 套餐到期提醒模块≤15天展示 实时展示
flowchart TD
    Schedule[每日定时任务] --> Query[批量查询实时临期候选]
    Query --> QueueCheck{存在排队套餐?}
    QueueCheck -->|是| Skip[不进入临期提醒]
    QueueCheck -->|否| Days[计算剩余自然日]
    Days --> Node{命中 15/7/3 天节点?}
    Node -->|否| End[本次不发送]
    Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
    Upsert --> Notification[站内消息]
    Upsert -. Phase 2 .-> WeCom[企业微信推送]

数据库变更

临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。

每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知:

-- 临期推送记录(防重)
CREATE TABLE tb_expiry_push_record (
    id             BIGSERIAL PRIMARY KEY,
    package_usage_id BIGINT NOT NULL,
    asset_type     VARCHAR(20) NOT NULL,  -- iot_card | device
    asset_id       BIGINT NOT NULL,
    recipient_id  BIGINT NOT NULL,
    channel        VARCHAR(20) NOT NULL,  -- notification | wecom
    push_node      INT NOT NULL,          -- 推送节点3/7/15天
    event_id       VARCHAR(64) NOT NULL,
    pushed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_expiry_push_idempotency
    ON tb_expiry_push_record(
        package_usage_id, recipient_id, channel, push_node
    );

CREATE UNIQUE INDEX idx_expiry_push_event
    ON tb_expiry_push_record(event_id);

同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。package_usage_id 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。


后端实现

1. 现有接口新增临期字段

资产列表接口(GET /api/admin/iot-cards / GET /api/admin/devices

响应新增字段:

type IotCardListItem struct {
    // ...原有字段...
    DaysUntilExpiry *int  `json:"days_until_expiry" description:"当前套餐剩余天数无生效套餐为null"`
    IsExpiring      bool  `json:"is_expiring" description:"是否临期≤15天且无排队套餐"`
}

计算方式(在 SQL 层计算,避免 N+1

-- 列表查询时 JOIN PackageUsage 获取剩余天数
SELECT
    ic.*,
    CASE
        WHEN pu.expires_at IS NULL THEN NULL
        WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date
             < (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL
        WHEN EXISTS (
            SELECT 1 FROM tb_package_usage q
            WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL
        ) THEN NULL  -- 有排队套餐,不临期
        ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date
             - (NOW() AT TIME ZONE 'Asia/Shanghai')::date
    END AS days_until_expiry
FROM tb_iot_card ic
LEFT JOIN tb_package_usage pu
    ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL

is_expiring 由同一 SQL 表达式派生:days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15。设备查询复用同一规则,不能另写一套日期计算。

资产列表筛选条件新增

type IotCardListRequest struct {
    // ...原有字段...
    ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天"`
}

资产详情接口

后台资产详情走 GET /api/admin/assets/resolve/:identifier,响应 DTO 为 AssetResolveResponseinternal/model/dto/asset_dto.go)。

新增字段:

// AssetResolveResponse 追加
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数无生效套餐或有排队套餐时为null"`
IsExpiring      bool `json:"is_expiring" description:"是否临期≤15天且无排队套餐"`

2. 新增临期列表接口(独立页面)

GET /api/admin/expiring-assets

查询参数:

type ExpiringAssetsRequest struct {
    AssetType          string `query:"asset_type" description:"资产类型 (iot_card/device)"`
    AssetIdentifier    string `query:"asset_identifier" description:"资产标识ICCID/设备号)"`
    PackageName        string `query:"package_name" description:"套餐名称"`
    ShopID             *uint  `query:"shop_id" description:"店铺ID"`
    ExpiresAtStart     string `query:"expires_at_start" description:"到期时间起"`
    ExpiresAtEnd       string `query:"expires_at_end" description:"到期时间止"`
    MaxDaysUntilExpiry *int   `query:"max_days_until_expiry" description:"最大剩余天数如15"`
    Page               int    `query:"page" default:"1"`
    PageSize           int    `query:"page_size" default:"20"`
}

响应:

type ExpiringAssetItem struct {
    AssetType         string     `json:"asset_type"`
    AssetIdentifier   string     `json:"asset_identifier"`
    AssetStatus       int        `json:"asset_status"`
    ShopName          string     `json:"shop_name"`
    PackageName       string     `json:"package_name"`
    DaysUntilExpiry   int        `json:"days_until_expiry"`
    ExpiresAt         time.Time  `json:"expires_at"`
    DataUsageMB       int64      `json:"data_usage_mb"`
    RemainingDataMB   int64      `json:"remaining_data_mb"`
}

3. 每日定时任务(仅通知)

// internal/task/expiry_reminder_handler.go

// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
    assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
    if err != nil { return err }

    for _, asset := range assets {
        node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3})
        if node == 0 {
            continue
        }
        today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化
        for _, recipientID := range asset.SalesmanIDs {
            eventID := fmt.Sprintf(
                "expiry:%d:%d:%d:%d:%s",
                asset.PackageUsageID, node, recipientID, asset.AssetID, today,
            )

            // 在事务内先写 expiry_push_record再通过 Outbox 发布站内消息。
            if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{
                EventID:       eventID,
                RecipientIDs:  []uint{recipientID},
                RecipientType: constants.NotifyRecipientAdmin,
                Type:          constants.NotifyTypePackageExpiring,
                Title:         fmt.Sprintf("套餐临期提醒(%d天节点", node),
                Body:          fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry),
                RefType:       asset.AssetType,
                RefID:         asset.AssetID,
            }); err != nil {
                return err
            }
        }
    }
    return nil
}

定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 015 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。


导出功能(临期列表)

使用统一导出任务:

POST /api/admin/export-tasks
scene=expiring_asset

导出字段:

字段 说明
资产标识 ICCID/设备号
资产类型 卡/设备
店铺名称
套餐名称
套餐到期时间
剩余天数
资产状态
已用流量(MB)
剩余流量(MB)

C端接口变更

公众号首页

现有接口(GET /api/c/v1/asset/info,通过 query param 传资产标识)的响应 DTO AssetInfoResponseinternal/model/dto/client_asset_dto.go)新增字段:

DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数≤15天时有值有排队套餐时为null"`
IsExpiring      bool `json:"is_expiring" description:"是否临期"`

前端逻辑:is_expiring=true 时展示续费提醒模块:

您的套餐即将到期

卡号/设备号XXXX
剩余有效期XX 天

为避免到期后影响正常使用,请您提前完成续费。

[立即续费]

前端对接(后台管理)

资产列表IoT卡管理 / 设备管理)

  1. 列表新增"剩余天数"列
  2. 根据 days_until_expiry 高亮行:
    • ≤ 15天行背景粉红色
    • ≤ 7天行背景紫色
    • ≤ 3天行背景黄色
  3. 筛选条件新增"临期天数"下拉≤15天/≤7天/≤3天
    • 选中后传 expiring_within_days=15

临期资产独立列表页

路由:/expiring-assets

筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺

列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量

操作:导出按钮 → `POST /api/admin/export-tasks``scene=expiring_asset`

代理端首页

在首页数据接口新增字段(需要确认代理端首页接口):

type AgentDashboardResponse struct {
    // ...原有字段...
    ExpiringCardCount   int `json:"expiring_card_count" description:"临期卡数量≤15天"`
    ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量≤15天"`
}

前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 expiring_within_days=15