# 需求22:套餐临期提醒 > 状态:待评审 --- ## 业务规则 ### 临期定义 当资产的**当前生效主套餐**(`PackageUsage.status=1`)按 `Asia/Shanghai` 自然日计算的剩余天数在 `0~15` 天时,该资产进入临期状态。 资产有可接续的排队主套餐时不进入临期提醒,因为服务不会在当前套餐到期后中断;需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。 ### 查询与通知职责 - 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。 - 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。 - `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。 ### 颜色规则(Version 2) | 剩余天数 | 颜色 | |---------|------| | ≤ 15天 | 粉红色 | | ≤ 7天 | 紫色 | | ≤ 3天 | 黄色 | ### 各端提醒规则 | 场景 | 提醒方式 | 触发节点 | |------|---------|---------| | **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 | | **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 | | **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | | **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | ```mermaid 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 记录已发送或已创建的通知: ```sql -- 临期推送记录(防重) 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`) 响应新增字段: ```go type IotCardListItem struct { // ...原有字段... DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"` IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` } ``` **计算方式**(在 SQL 层计算,避免 N+1): ```sql -- 列表查询时 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`。设备查询复用同一规则,不能另写一套日期计算。 #### 资产列表筛选条件新增 ```go type IotCardListRequest struct { // ...原有字段... ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"` } ``` #### 资产详情接口 后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。 新增字段: ```go // AssetResolveResponse 追加 DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"` IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` ``` ### 2. 新增临期列表接口(独立页面) ``` GET /api/admin/expiring-assets ``` 查询参数: ```go 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"` } ``` 响应: ```go 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. 每日定时任务(仅通知) ```go // 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 } ``` 定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `0~15` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。 --- ## 导出功能(临期列表) 使用统一导出任务: ```text POST /api/admin/export-tasks scene=expiring_asset ``` 导出字段: | 字段 | 说明 | |------|------| | 资产标识 | ICCID/设备号 | | 资产类型 | 卡/设备 | | 店铺名称 | | | 套餐名称 | | | 套餐到期时间 | | | 剩余天数 | | | 资产状态 | | | 已用流量(MB) | | | 剩余流量(MB) | | --- ## C端接口变更 ### 公众号首页 现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: ```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` ``` ### 代理端首页 在首页数据接口新增字段(需要确认代理端首页接口): ```go type AgentDashboardResponse struct { // ...原有字段... ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"` ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"` } ``` 前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`