298 lines
11 KiB
Markdown
298 lines
11 KiB
Markdown
# 需求22:套餐临期提醒
|
||
|
||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||
|
||
---
|
||
|
||
## 业务规则
|
||
|
||
### 临期定义
|
||
|
||
当资产的当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**按 `Asia/Shanghai` 自然日计算处于 `0~15` 天时,该资产进入临期状态。预计最终到期时间与需求06/11共用同一个 Query,不再维护“当前套餐临期”和“最终到期”两套口径。
|
||
|
||
没有生效套餐且队首套餐仍等待无法确定时间的实名激活时,返回不可预计状态,不进入临期。已过期资产不属于临期。
|
||
|
||
### 查询与通知职责
|
||
|
||
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
|
||
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
|
||
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
|
||
|
||
### 颜色规则(Version 2)
|
||
|
||
| 剩余天数 | 颜色 |
|
||
|---------|------|
|
||
| ≤ 15天 | 粉红色 |
|
||
| ≤ 7天 | 紫色 |
|
||
| ≤ 3天 | 红色 |
|
||
|
||
### 各端提醒规则
|
||
|
||
| 场景 | 提醒方式 | 触发节点 |
|
||
|------|---------|---------|
|
||
| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 |
|
||
| **企业客户** | 对应店铺业务员接收站内通知;后台每日生成临期列表 | 15天/7天/3天节点 |
|
||
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
|
||
| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 |
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Schedule[每日定时任务] --> Query[计算预计最终到期时间]
|
||
Query --> Predictable{可以推算?}
|
||
Predictable -->|否| Skip[不进入临期提醒]
|
||
Predictable -->|是| Days[计算最终剩余自然日]
|
||
Days --> Node{命中 15/7/3 天节点?}
|
||
Node -->|否| End[本次不发送]
|
||
Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
|
||
Upsert --> Notification[站内消息]
|
||
```
|
||
|
||
---
|
||
|
||
## 数据库变更
|
||
|
||
临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。
|
||
|
||
每日临期通知记录需防重,用一个 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
|
||
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 天提醒。本期只使用站内通知渠道。
|
||
|
||
---
|
||
|
||
## 后端实现
|
||
|
||
### 1. 现有接口新增临期字段
|
||
|
||
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`)
|
||
|
||
响应新增字段:
|
||
```go
|
||
type IotCardListItem struct {
|
||
// ...原有字段...
|
||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
|
||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||
}
|
||
```
|
||
|
||
列表 Query 批量加载当前和排队主套餐,复用需求06/11的周期、时长快照推算逻辑,禁止逐资产查询。`is_expiring` 由 `days_until_final_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 追加
|
||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
|
||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||
```
|
||
|
||
### 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:"到期时间止"`
|
||
MaxDaysUntilFinalExpiry *int `query:"max_days_until_final_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"`
|
||
DaysUntilFinalExpiry int `json:"days_until_final_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.RecipientAccountIDs {
|
||
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
|
||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||
```
|
||
|
||
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
|
||
|
||
```
|
||
您的套餐即将到期
|
||
|
||
卡号/设备号:XXXX
|
||
剩余有效期:XX 天
|
||
|
||
为避免到期后影响正常使用,请您提前完成续费。
|
||
|
||
[立即续费]
|
||
```
|
||
|
||
---
|
||
|
||
## 前端对接(后台管理)
|
||
|
||
### 资产列表(IoT卡管理 / 设备管理)
|
||
|
||
1. 列表新增"剩余天数"列
|
||
2. 根据 `days_until_final_expiry` 高亮行:
|
||
- ≤ 15天:行背景粉红色
|
||
- ≤ 7天:行背景紫色
|
||
- ≤ 3天:行背景红色
|
||
3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天)
|
||
- 选中后传 `expiring_within_days=15`
|
||
|
||
### 临期资产独立列表页
|
||
|
||
路由:`/expiring-assets`
|
||
|
||
```
|
||
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
|
||
|
||
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
|
||
|
||
操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset`
|
||
```
|
||
|
||
临期独立列表将 `days_until_final_expiry <= 3` 的资产置顶,再按预计最终到期时间升序。普通卡列表和设备列表只按颜色高亮,不改变原有排序。
|
||
|
||
### 代理端首页
|
||
|
||
在首页数据接口新增字段(需要确认代理端首页接口):
|
||
|
||
```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`
|