# 需求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`
