迭代计划准备

This commit is contained in:
2026-07-16 15:07:59 +08:00
parent 1a9db9328e
commit c4f430ccb3
22 changed files with 2969 additions and 1569 deletions

View File

@@ -1,14 +1,22 @@
# 需求22套餐临期提醒
> 状态:待评审
---
## 业务规则
### 临期定义
当资产的**当前生效套餐**`PackageUsage.status=1` `expires_at` ≤ 15天后,该资产进入临期状态。
当资产的**当前生效套餐**`PackageUsage.status=1` `Asia/Shanghai` 自然日计算的剩余天数在 `015` 天时,该资产进入临期状态。
**例外**:资产若有**排队中status=0的套餐**,不属于临期,不触发提醒
资产有可接续的排队主套餐时不进入临期提醒因为服务不会在当前套餐到期后中断需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期
### 查询与通知职责
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
### 颜色规则Version 2
@@ -27,34 +35,59 @@
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
| **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 记录已推送记录
每日临期通知记录需防重,用一个 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)
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 /admin/iot-cards` / `GET /admin/devices`
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`
响应新增字段:
```go
@@ -73,17 +106,22 @@ 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 GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT)
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
@@ -107,7 +145,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期≤15天且
### 2. 新增临期列表接口(独立页面)
```
GET /admin/expiring-assets
GET /api/admin/expiring-assets
```
查询参数:
@@ -140,49 +178,58 @@ type ExpiringAssetItem struct {
}
```
### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧
### 3. 每日定时任务(仅通知
```go
// internal/task/expiry_reminder_handler.go
// HandleExpiryReminder 每日03:00执行,生成临期数据 + 写站内消息
// 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 }
assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
if err != nil { return err }
// 获取对应业务员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,
})
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,
)
// 记录推送防重
h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, 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 天通知,不补发多条过期阈值。
---
## 导出功能(临期列表)
```
GET /admin/expiring-assets/export
使用统一导出任务:
```text
POST /api/admin/export-tasks
scene=expiring_asset
```
导出字段:
@@ -205,7 +252,7 @@ GET /admin/expiring-assets/export
### 公众号首页
现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse``internal/model/dto/client_asset_dto.go`)新增字段:
现有接口(`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"`
@@ -248,7 +295,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期"`
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
操作:导出按钮 → GET /admin/expiring-assets/export
操作:导出按钮 → `POST /api/admin/export-tasks``scene=expiring_asset`
```
### 代理端首页