临时备份一次

This commit is contained in:
2026-07-13 12:01:18 +09:00
parent 5cdcdad534
commit 2e130b98f5
17 changed files with 3938 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
# 需求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`