# 需求03/07/11/12/13：简单改动合集

---

## 需求03：店铺列表搜索新增联系电话

### 后端

`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。

**文件**：`internal/store/postgres/shop_store.go`（列表查询 Store 方法）

```go
// 现有过滤条件基础上追加
if req.ContactPhone != "" {
    query = query.Where("contact_phone = ?", req.ContactPhone)
}
```

**DTO 变更**：`internal/model/dto/shop_dto.go` 的 `ShopListRequest` 新增：

```go
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话（精确匹配，11位）"`
```

### 前端

店铺列表搜索栏新增"联系电话"输入框，填入后带入 `contact_phone` 参数请求。

---

## 需求07：IoT卡/设备管理新增已实名/未实名筛选

### IoT 卡

`IotCard.real_name_status` 已有（0=未实名, 1=已实名），`ListStandaloneIotCardRequest` 无该过滤字段，需新增。

**DTO 变更**（`internal/model/dto/iot_card_dto.go` → `ListStandaloneIotCardRequest` 新增）：

```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```

**Store 追加**（`internal/store/postgres/iot_card_store.go`）：
```go
if req.RealNameStatus != nil {
    query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```

### 设备

设备本身目前无 `real_name_status` 字段。语义为：任意一张绑定卡已实名 = 设备已实名。

为避免列表查询时走 EXISTS 子查询，改为**快照方案**：在 `Device` 表落盘，轮询时维护。

#### 迁移

`tb_devices` 新增字段：

```sql
ALTER TABLE tb_devices ADD COLUMN real_name_status int NOT NULL DEFAULT 0
    COMMENT '实名状态快照(0=未实名,1=已实名)，任意绑定卡已实名则为1，由轮询异步维护';
```

**Model**（`internal/model/device.go`）：
```go
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名)，任意绑定卡已实名则为1" json:"real_name_status"`
```

#### 快照更新时机

以下两处卡实名状态变化时，需同步更新所属设备的快照：

**1. 轮询实名处理**（`internal/task/polling_realname_handler.go`）

卡状态变化后，已有 `triggerDeviceRealnameActivation` 查出 `deviceID`，在此同步更新设备快照：

```go
// statusChanged 时，如果卡属于某设备，重新计算并写入设备快照
if statusChanged {
    if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil {
        h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID)
    }
}
```

**2. 管理员手动修改卡实名状态**（`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`）

更新卡状态成功后，查所属设备并更新快照（同上逻辑）。

#### 快照计算

`DeviceStore.RefreshRealnameSnapshot`：

```go
// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照
func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error {
    var count int64
    s.db.WithContext(ctx).Raw(`
        SELECT COUNT(*) FROM tb_device_sim_binding dsb
        JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
        WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL
          AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
    `, deviceID).Scan(&count)
    status := 0
    if count > 0 {
        status = 1
    }
    return s.db.WithContext(ctx).Model(&model.Device{}).
        Where("id = ?", deviceID).
        Update("real_name_status", status).Error
}
```

#### DTO 变更

**请求**（`internal/model/dto/device_dto.go` → `ListDeviceRequest` 新增）：
```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```

**响应**（`DeviceResponse` 新增）：
```go
RealNameStatus     int    `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称（中文）"`
```

**Store 过滤**（直接 WHERE，无需 EXISTS）：
```go
if req.RealNameStatus != nil {
    query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```

### 前端

IoT卡管理筛选栏新增"实名状态"下拉（全部/已实名/未实名）→ 传 `real_name_status=0|1`。  
设备管理同上，列表展示 `real_name_status_name` 字段。

---

## 需求11：资产详情-套餐到期时间字段 + 15天高亮

### 后端

**无需改动。**

后台资产详情页实际调用的是：

```
GET /api/admin/assets/resolve/:identifier
```

该接口的 `AssetResolveResponse`（`internal/model/dto/asset_dto.go`）已包含：

```go
CurrentPackage            string     `json:"current_package"`             // 当前套餐名称
CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间
CurrentPackageExpiresAt   *time.Time `json:"current_package_expires_at"`   // 到期时间（无套餐为 null）
```

到期时间字段已有，前端直接读 `current_package_expires_at` 即可，不需要新增后端字段。

### 前端

资产详情页"套餐信息"板块展示到期时间，并在剩余 ≤15 天时高亮：

- 读取 `resolve` 接口返回的 `current_package_expires_at`
- 若为 `null`：展示"暂无套餐"
- 剩余天数由前端计算：`Math.ceil((expiresAt - now) / 86400000)`
- 剩余 ≤15 天：**红色/高亮**展示（建议红色文字 + 标签）

---

## 需求12：换货管理显示修复

### 背景

换货单表 `tb_exchange_order`：
- `old_asset_identifier` — 旧资产标识符快照
- `new_asset_identifier` — 新资产标识符快照
- `old_asset_id` / `new_asset_id` — 旧/新资产主键

### EXC-001/EXC-002：旧/新资产标识显示不一致

**根本原因**：后端创建换货单时快照逻辑有误（`internal/service/exchange/service.go`）。

- 卡的旧资产：快照了 `card.VirtualNo`（虚拟号），**应为 `card.ICCID`**
- 卡的新资产：快照了操作员输入的 identifier 原值，未规范化，**应统一为 `card.ICCID`**
- 设备：快照 `VirtualNo` 优先，没有则 `IMEI`，**逻辑正确，无需改动**

**修复**（`internal/service/exchange/service.go`）：

`resolveAssetByIdentifierWithTx` 及锁定资产路径中，卡的 `Identifier` 改为 `card.ICCID`：

```go
// 修复前
return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...}

// 修复后
return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...}
```

历史数据不回填，仅修正后续新建换货单的快照行为。

### EXC-003/EXC-004：旧/新资产搜索支持 ICCID/接入号/虚拟号

**方案**：拆分为独立的旧资产和新资产搜索，搜索逻辑用**两步查询**，不用 JOIN。

**DTO 变更**（`internal/model/dto/exchange_dto.go` → `ExchangeListRequest`）：

废弃原有 `Identifier` 字段，改为：
```go
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索（ICCID/接入号/虚拟号）"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索（ICCID/接入号/虚拟号）"`
```

**Store 修改**（`internal/store/postgres/exchange_order_store.go`）：

两步查询——先在资产表搜出 ID，再过滤换货表：

```go
// 步骤1：旧资产关键词搜索
if req.OldAssetKeyword != "" {
    kw := "%" + req.OldAssetKeyword + "%"
    var cardIDs []uint
    s.db.WithContext(ctx).Table("tb_iot_card").
        Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw).
        Pluck("id", &cardIDs)
    var deviceIDs []uint
    s.db.WithContext(ctx).Table("tb_device").
        Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw).
        Pluck("id", &deviceIDs)

    if len(cardIDs) == 0 && len(deviceIDs) == 0 {
        return &ExchangeListResult{}, nil // 无匹配，直接返回空
    }
    query = query.Where(
        "(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)",
        cardIDs, deviceIDs,
    )
}
// new_asset_keyword 同理，过滤 new_asset_id
```

### 前端

- EXC-001/002：后端修复后，`old_asset_identifier` 和 `new_asset_identifier` 均为 ICCID（卡）或设备号（设备），展示直接读这两个字段即可
- EXC-003/004：搜索栏拆分为"旧资产"和"新资产"两个独立输入框，分别传 `old_asset_keyword` 和 `new_asset_keyword`

---

## 需求13：列表字段新增

### 核心原则

提交人、审批人均在**写入时快照**到单据本身，读取时直接返回，不做额外查询。

---

### COL-003：换货管理列表新增提交人（待建）

> 需求文档原写"换号管理"，确认为"换货管理"（系统无"换号"概念）。

**迁移**：`tb_exchange_order` 新增字段：
```sql
ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```

**Model**（`internal/model/exchange_order.go`）：
```go
SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"`
```

**创建换货单时**（`internal/service/exchange/service.go`）快照当前操作人 username：
```go
SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username
```

**响应 DTO**（`internal/model/dto/exchange_dto.go` → `ExchangeOrderResponse` 新增）：
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
```

---

### COL-001：退款管理列表新增提交人、审批人（依赖审批流）

**迁移**：`tb_refund` 新增字段：
```sql
ALTER TABLE tb_refund ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
ALTER TABLE tb_refund ADD COLUMN dept_leader_name varchar(50) NOT NULL DEFAULT '';
ALTER TABLE tb_refund ADD COLUMN finance_approver_name varchar(50) NOT NULL DEFAULT '';
```

- `submitter_name`：创建退款单时快照操作人 username
- `dept_leader_name` / `finance_approver_name`：审批流各环节通过时快照对应审批人 username

> **实施依赖**：`dept_leader_name` / `finance_approver_name` 在审批流（需求20）实现后才能写入，未实现前返回空字符串。`submitter_name` 本迭代即可实现。

**响应 DTO**（退款列表响应新增）：
```go
SubmitterName       string `json:"submitter_name" description:"提交人账号名"`
DeptLeaderName      string `json:"dept_leader_name" description:"部门领导审批人账号名"`
FinanceApproverName string `json:"finance_approver_name" description:"财务审批人账号名"`
```

---

### COL-002：代理充值列表新增提交人、审批人（依赖审批流）

与 COL-001 同理，`tb_agent_recharge`（充值单表）新增同样三个字段，快照逻辑一致。

> **实施依赖**：`submitter_name` 本迭代可实现；审批人字段依赖需求21（充值审批流）。

---

### 前端

各列表页新增对应字段列，展示即可，无额外交互。
