Files
junhong_cmp_fiber/docs/7月迭代/需求22-套餐临期提醒.md
2026-07-13 12:01:18 +09:00

267 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 需求22套餐临期提醒
---
## 业务规则
### 临期定义
当资产的**当前生效套餐**`PackageUsage.status=1`)的 `expires_at` ≤ 15天后该资产进入临期状态。
**例外**:资产若有**排队中status=0的套餐**,不属于临期,不触发提醒。
### 颜色规则Version 2
| 剩余天数 | 颜色 |
|---------|------|
| ≤ 15天 | 粉红色 |
| ≤ 7天 | 紫色 |
| ≤ 3天 | 黄色 |
### 各端提醒规则
| 场景 | 提醒方式 | 触发节点 |
|------|---------|---------|
| **后台管理** | 列表加临期天数列 + 高亮详情加临期字段≤15天高亮 | 实时计算 |
| **企业客户** | 企微推送Phase 2后台每日生成临期列表 | 15天/7天/3天节点 |
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
| **C端公众号** | 套餐到期提醒模块≤15天展示 | 实时展示 |
---
## 数据库变更
无新表。临期数据实时计算,不存储(避免数据陈旧)。
企业客户的**每日临期推送记录**需防重,用一个 key 记录已推送记录:
```sql
-- 临期推送记录(防重)
CREATE TABLE tb_expiry_push_record (
id BIGSERIAL PRIMARY KEY,
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
asset_id BIGINT NOT NULL,
push_node INT NOT NULL, -- 推送节点3/7/15天
push_date DATE NOT NULL, -- 推送日期
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_expiry_push UNIQUE (asset_type, asset_id, push_node, push_date)
);
```
---
## 后端实现
### 1. 现有接口新增临期字段
#### 资产列表接口(`GET /admin/iot-cards` / `GET /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 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 GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT)
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
```
#### 资产列表筛选条件新增
```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 /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. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧)
```go
// internal/task/expiry_reminder_handler.go
// HandleExpiryReminder 每日03:00执行生成临期数据 + 写站内消息
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
nodes := []int{15, 7, 3}
for _, node := range nodes {
assets, err := h.packageUsageStore.GetExpiringAssets(ctx, node)
if err != nil { return err }
for _, asset := range assets {
// 防重检查(同一资产同一节点今天已推送过)
today := time.Now().Format("2006-01-02")
pushed, _ := h.expiryPushStore.AlreadyPushed(ctx, asset.AssetType, asset.AssetID, node, today)
if pushed { continue }
// 获取对应业务员ID企业客户场景
// 此处先写站内消息Phase 2 改为企微推送
h.notifyPublisher.Publish(ctx, notification.SendPayload{
RecipientIDs: asset.SalesmanIDs,
Type: constants.NotifyTypePackageExpiring,
Title: fmt.Sprintf("套餐临期提醒(%d天", node),
Body: fmt.Sprintf("资产 %s 的套餐将在 %d 天后到期", asset.Identifier, node),
RefType: "asset",
RefID: asset.AssetID,
})
// 记录推送防重
h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, today)
}
}
return nil
}
```
---
## 导出功能(临期列表)
```
GET /admin/expiring-assets/export
```
导出字段:
| 字段 | 说明 |
|------|------|
| 资产标识 | ICCID/设备号 |
| 资产类型 | 卡/设备 |
| 店铺名称 | |
| 套餐名称 | |
| 套餐到期时间 | |
| 剩余天数 | |
| 资产状态 | |
| 已用流量(MB) | |
| 剩余流量(MB) | |
---
## C端接口变更
### 公众号首页
现有接口(`GET /app/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`
```
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
操作:导出按钮 → GET /admin/expiring-assets/export
```
### 代理端首页
在首页数据接口新增字段(需要确认代理端首页接口):
```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`