# 设计文档：修复 IoT 模型架构违规

## 1. 设计目标

将所有 IoT 相关数据模型重构为符合项目开发规范的标准模型，确保代码一致性、可维护性和长期可扩展性。

## 2. 核心设计原则

### 2.1 统一模型结构

所有数据模型必须遵循以下标准结构：

```go
type ModelName struct {
    gorm.Model                    // 标准字段：ID, CreatedAt, UpdatedAt, DeletedAt
    BaseModel    `gorm:"embedded"` // 基础字段：Creator, Updater

    // 业务字段（按字母顺序排列）
    Field1 Type `gorm:"column:field1;..." json:"field1"`
    Field2 Type `gorm:"column:field2;..." json:"field2"`
}

func (ModelName) TableName() string {
    return "tb_model_name" // tb_ 前缀 + 单数
}
```

**设计理由：**
- `gorm.Model`：提供标准的主键、时间戳、软删除支持
- `BaseModel`：提供审计字段，记录创建人和更新人
- 显式 `column` 标签：明确 Go 字段和数据库列的映射关系，避免依赖 GORM 自动转换
- `tb_` 前缀单数表名：项目统一规范，便于识别业务表

### 2.2 字段定义规范

**字符串字段：**
```go
Name string `gorm:"column:name;type:varchar(100);not null;comment:名称" json:"name"`
```
- 必须显式指定 `column` 标签
- 必须指定 `type:varchar(N)` 和长度
- 必须指定 `not null`（如果必填）
- 必须添加中文 `comment`

**货币金额字段：**
```go
Amount int64 `gorm:"column:amount;type:bigint;default:0;not null;comment:金额（分）" json:"amount"`
```
- 使用 `int64` 类型（不是 `float64`）
- 单位为"分"（1元 = 100分）
- 必须指定 `type:bigint`
- 必须指定 `default:0` 和 `not null`
- 注释中明确标注"（分）"

**设计理由：**
- 整数存储避免浮点精度问题（金融领域最佳实践）
- 分为单位便于精确计算和货币转换

**枚举字段：**
```go
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
```
- 使用 `int` 类型（不是 `string`）
- 必须在注释中列举所有枚举值
- 必须指定 `default` 和 `not null`

**关联 ID 字段：**
```go
UserID uint `gorm:"column:user_id;type:bigint;not null;index;comment:用户ID" json:"user_id"`
```
- 使用 `uint` 类型（与 `gorm.Model` 的 ID 类型一致）
- 数据库类型使用 `bigint`（PostgreSQL）
- 必须添加 `index` 索引
- 禁止使用 GORM 关联标签（`foreignKey`、`references`）

**可选关联 ID 字段：**
```go
ShopID *uint `gorm:"column:shop_id;type:bigint;index;comment:店铺ID（可选）" json:"shop_id,omitempty"`
```
- 使用指针类型 `*uint`（可为 NULL）
- 不指定 `not null`
- 仍需添加 `index` 索引
- JSON 标签使用 `omitempty`

**唯一索引字段：**
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
```
- 使用 `uniqueIndex` 标签
- 对于支持软删除的表，必须添加 `where:deleted_at IS NULL` 过滤条件
- 索引名命名规范：`idx_{table}_{field}` 或 `idx_{field}`

**时间字段：**
```go
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at,omitempty"`
```
- 可选时间字段使用指针类型 `*time.Time`
- 不使用 `autoCreateTime` 或 `autoUpdateTime`（这些由 gorm.Model 提供）
- JSON 标签使用 `omitempty`

**JSONB 字段（PostgreSQL）：**
```go
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;comment:元数据" json:"metadata,omitempty"`
```
- 使用 `gorm.io/datatypes.JSON` 类型
- 数据库类型使用 `jsonb`（PostgreSQL 优化存储）
- 使用 `omitempty`

### 2.3 表名和索引命名规范

**表名：**
- 格式：`tb_{model_name}`（单数）
- 示例：`tb_iot_card`、`tb_device`、`tb_order`

**索引名：**
- 普通索引：`idx_{table}_{field}`
- 唯一索引：`idx_{table}_{field}` 或 `uniq_{table}_{field}`
- 复合索引：`idx_{table}_{field1}_{field2}`

**设计理由：**
- 统一前缀便于识别业务表（与系统表区分）
- 单数形式符合 Go 惯用命名（类型名为单数）
- 索引名清晰表达用途和字段

### 2.4 软删除支持

所有业务数据表都应支持软删除：

```go
type BusinessModel struct {
    gorm.Model // 包含 DeletedAt 字段
    // ...
}
```

**不需要软删除的表：**
- 纯配置表（如 `PollingConfig`、`CommissionWithdrawalSetting`）
- 日志表（如 `DataUsageRecord`）
- 中间表（如 `DeviceSimBinding` 可选支持）

对于不需要软删除的表，可以手动定义字段：

```go
type ConfigModel struct {
    ID        uint      `gorm:"column:id;primaryKey;comment:ID" json:"id"`
    BaseModel `gorm:"embedded"`
    // ...
    CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
    UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
}
```

## 3. 模型分类和修复策略

### 3.1 核心业务实体（必须支持软删除）

**完整模型结构（gorm.Model + BaseModel）：**
- `IotCard`（IoT 卡）
- `Device`（设备）
- `NumberCard`（号卡）
- `PackageSeries`（套餐系列）
- `Package`（套餐）
- `AgentPackageAllocation`（代理套餐分配）
- `Order`（订单）
- `AgentHierarchy`（代理层级）
- `CommissionRule`（分佣规则）
- `CommissionTemplate`（分佣模板）
- `Carrier`（运营商）

### 3.2 关联和绑定表（可选软删除）

**完整模型结构（gorm.Model + BaseModel）：**
- `DeviceSimBinding`（设备-SIM 卡绑定）

### 3.3 使用记录和日志表（仅时间戳，不需要软删除）

**简化模型结构（手动定义 ID + BaseModel + CreatedAt/UpdatedAt）：**
- `PackageUsage`（套餐使用）- 保留 gorm.Model（需要软删除和更新）
- `DataUsageRecord`（流量记录）- 仅需 ID + CreatedAt（不需要 UpdatedAt 和 DeletedAt）

### 3.4 财务和审批表（必须支持软删除）

**完整模型结构（gorm.Model + BaseModel）：**
- `CommissionRecord`（分佣记录）
- `CommissionApproval`（分佣审批）
- `CommissionWithdrawalRequest`（佣金提现申请）
- `PaymentMerchantSetting`（收款商户设置）
- `CarrierSettlement`（运营商结算）
- `CardReplacementRequest`（换卡申请）

### 3.5 阶梯和条件配置表（可选软删除）

**完整模型结构（gorm.Model + BaseModel）：**
- `CommissionLadder`（阶梯分佣配置）
- `CommissionCombinedCondition`（组合分佣条件）

### 3.6 系统配置表（可选软删除）

**完整模型结构（gorm.Model + BaseModel）：**
- `CommissionWithdrawalSetting`（提现设置）
- `PollingConfig`（轮询配置）
- `DevCapabilityConfig`（开发能力配置）

## 4. 货币金额处理策略

### 4.1 金额字段映射

所有货币金额从 `float64`（元）改为 `int64`（分）：

| 原字段类型 | 新字段类型 | 原数据库类型 | 新数据库类型 | 说明 |
|-----------|-----------|------------|------------|-----|
| `float64` | `int64`   | `DECIMAL(10,2)` | `BIGINT` | 金额单位从元改为分 |

**影响的字段：**
- `IotCard.CostPrice`、`IotCard.DistributePrice`
- `NumberCard.Price`
- `Package.Price`
- `AgentPackageAllocation.CostPrice`、`AgentPackageAllocation.RetailPrice`
- `Order.Amount`
- `CommissionRule.CommissionValue`
- `CommissionLadder.CommissionValue`
- `CommissionCombinedCondition.OneTimeCommissionValue`、`CommissionCombinedCondition.LongTermCommissionValue`
- `CommissionRecord.Amount`
- `CommissionTemplate.CommissionValue`
- `CarrierSettlement.SettlementAmount`
- `CommissionWithdrawalRequest.Amount`、`CommissionWithdrawalRequest.Fee`、`CommissionWithdrawalRequest.ActualAmount`
- `CommissionWithdrawalSetting.MinWithdrawalAmount`

### 4.2 业务逻辑调整

**API 输入输出：**
- API 接收的金额仍为 `float64`（元）
- Handler 层负责单位转换：元 → 分（乘以 100）
- 响应时转换回：分 → 元（除以 100）

**示例：**
```go
// 输入：10.50 元
inputAmount := 10.50 // float64 (元)
dbAmount := int64(inputAmount * 100) // 1050 分

// 输出：10.50 元
dbAmount := int64(1050) // 分
outputAmount := float64(dbAmount) / 100.0 // 10.50 元
```

### 4.3 数据库迁移

对于已有测试数据：
```sql
-- 金额从 DECIMAL(元) 转为 BIGINT(分)
ALTER TABLE iot_cards RENAME COLUMN cost_price TO cost_price_old;
ALTER TABLE iot_cards ADD COLUMN cost_price BIGINT NOT NULL DEFAULT 0;
UPDATE iot_cards SET cost_price = CAST(cost_price_old * 100 AS BIGINT);
ALTER TABLE iot_cards DROP COLUMN cost_price_old;
```

## 5. JSONB 字段处理

### 5.1 问题

原模型使用 `pq.StringArray` 类型存储 JSONB：
```go
CarrierOrderData pq.StringArray `gorm:"column:carrier_order_data;type:jsonb;..."`
```

这是类型不匹配的：`pq.StringArray` 是 PostgreSQL 数组类型，不是 JSONB。

### 5.2 解决方案

使用 GORM 的 `datatypes.JSON` 类型：

```go
import "gorm.io/datatypes"

type Order struct {
    // ...
    CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data,omitempty"`
    // ...
}
```

**业务层使用：**
```go
// 写入
data := map[string]interface{}{
    "order_id": "123",
    "status": "paid",
}
order.CarrierOrderData, _ = json.Marshal(data)

// 读取
var data map[string]interface{}
json.Unmarshal(order.CarrierOrderData, &data)
```

## 6. 索引策略

### 6.1 唯一索引（Unique Index）

对于需要全局唯一的字段（如 ICCID、订单号、虚拟商品编码）：

```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
```

**关键点：**
- 必须添加 `where:deleted_at IS NULL` 过滤已软删除的记录
- 否则软删除后无法重新使用相同的唯一值

### 6.2 普通索引（Index）

对于频繁查询和过滤的字段（如状态、类型、关联 ID）：

```go
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态" json:"status"`
UserID uint `gorm:"column:user_id;type:bigint;not null;index;comment:用户ID" json:"user_id"`
```

### 6.3 复合索引（Composite Index）

对于联合查询的字段组合：

```go
type DeviceSimBinding struct {
    // ...
    DeviceID  uint `gorm:"column:device_id;type:bigint;not null;index:idx_device_slot;comment:设备ID" json:"device_id"`
    SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置" json:"slot_position"`
    // ...
}
```

**复合索引命名：**
- `idx_device_slot`：表示 `device_id` 和 `slot_position` 的联合索引

## 7. 迁移路径

### 7.1 代码修改顺序

1. 修改所有模型文件（`internal/model/*.go`）
2. 更新模型的单元测试（如有）
3. 生成新的数据库迁移脚本
4. 在开发环境测试迁移脚本
5. 验证所有模型定义正确

### 7.2 数据库迁移策略

**场景 1：IoT 模块尚未部署（推荐）**
- 删除旧的迁移脚本（如果已创建）
- 生成新的初始迁移脚本
- 重新运行迁移

**场景 2：IoT 模块已有测试数据**
- 保留旧的迁移脚本
- 生成新的迁移脚本（包含表重命名、字段修改）
- 编写数据转换脚本（金额单位转换等）

### 7.3 迁移脚本示例

```sql
-- 1. 重命名表（复数 → tb_ 前缀单数）
ALTER TABLE iot_cards RENAME TO tb_iot_card;
ALTER TABLE devices RENAME TO tb_device;
-- ...

-- 2. 添加新字段
ALTER TABLE tb_iot_card ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_iot_card ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_iot_card ADD COLUMN deleted_at TIMESTAMP;

-- 3. 修改金额字段（DECIMAL → BIGINT）
ALTER TABLE tb_iot_card RENAME COLUMN cost_price TO cost_price_old;
ALTER TABLE tb_iot_card ADD COLUMN cost_price BIGINT NOT NULL DEFAULT 0;
UPDATE tb_iot_card SET cost_price = CAST(cost_price_old * 100 AS BIGINT);
ALTER TABLE tb_iot_card DROP COLUMN cost_price_old;

-- 4. 添加索引
CREATE UNIQUE INDEX idx_iccid ON tb_iot_card(iccid) WHERE deleted_at IS NULL;
CREATE INDEX idx_status ON tb_iot_card(status);
CREATE INDEX idx_carrier_id ON tb_iot_card(carrier_id);
```

## 8. 验证清单

修复完成后需验证：

- [ ] 所有模型嵌入 `gorm.Model` 或手动定义 `ID`、`CreatedAt`、`UpdatedAt`
- [ ] 所有业务模型嵌入 `BaseModel`（`Creator`、`Updater`）
- [ ] 所有字段显式指定 `column` 标签
- [ ] 所有字符串字段指定类型和长度（`type:varchar(N)`）
- [ ] 所有金额字段使用 `int64` 类型和 `type:bigint`
- [ ] 所有必填字段指定 `not null`
- [ ] 所有字段添加中文 `comment`
- [ ] 所有唯一字段添加 `uniqueIndex` 并包含 `where:deleted_at IS NULL`
- [ ] 所有关联字段添加 `index`
- [ ] 所有表名使用 `tb_` 前缀 + 单数
- [ ] 所有 JSONB 字段使用 `datatypes.JSON` 类型
- [ ] 所有模型与现有 `Account`、`PersonalCustomer` 模型风格一致

## 9. 风险和注意事项

### 9.1 破坏性变更

- 表名变更会导致旧代码无法运行
- 金额单位变更需要业务逻辑适配
- 新增字段需要在业务逻辑中赋值

### 9.2 迁移风险

- 表重命名可能导致迁移失败（需谨慎测试）
- 金额转换可能出现精度问题（需验证）
- 索引重建可能耗时（大表需评估）

### 9.3 开发流程影响

- 修复期间 IoT 模块功能开发需暂停
- 所有依赖 IoT 模型的代码需同步修改
- 需要重新生成数据库迁移脚本

## 10. 全局规范文档更新

### 10.1 更新目标

确保项目规范文档（CLAUDE.md）与实际实现的模型完全一致，为未来开发提供清晰、准确的指导。

### 10.2 CLAUDE.md 更新内容

**1. 补充 GORM 模型字段规范**

在"数据库设计原则"部分添加详细的字段定义规范：

```markdown
**GORM 模型字段规范：**

**字段命名：**
- 数据库字段名必须使用下划线命名法（snake_case）：`user_id`、`email_address`、`created_at`
- Go 结构体字段名必须使用驼峰命名法（PascalCase）：`UserID`、`EmailAddress`、`CreatedAt`

**字段标签要求：**
- **所有字段必须显式指定数据库列名**：使用 `gorm:"column:字段名"` 标签
  - 示例：`UserID uint gorm:"column:user_id;not null" json:"user_id"`
  - 禁止省略 `column:` 标签，即使 GORM 能自动推断字段名
  - 这确保了 Go 字段名和数据库字段名的映射关系清晰可见，避免命名歧义
- **所有字符串字段必须显式指定类型和长度**：
  - 短文本：`type:varchar(100)` 或 `type:varchar(255)`
  - 中等文本：`type:varchar(500)` 或 `type:varchar(1000)`
  - 长文本：`type:text`
- **所有字段必须添加中文注释**：`comment:字段用途说明`

**货币金额字段规范：**
- **必须使用整数类型**：Go 类型 `int64`，数据库类型 `bigint`
- **单位必须为"分"**（1 元 = 100 分）
- **注释中必须明确标注单位**：`comment:金额（分）`
- **理由**：避免浮点精度问题，符合金融系统最佳实践

示例：
```go
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额（分）" json:"amount"`
```

**唯一索引软删除兼容性：**
- 对于支持软删除的表（嵌入 `gorm.Model`），唯一索引必须包含 `where:deleted_at IS NULL` 过滤条件
- 示例：
  ```go
  ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
  ```
- 理由：允许软删除后重新使用相同的唯一值

**JSONB 字段规范（PostgreSQL）：**
- 必须使用 `gorm.io/datatypes.JSON` 类型
- 数据库类型为 `jsonb`
- 示例：
  ```go
  import "gorm.io/datatypes"

  Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;comment:元数据" json:"metadata,omitempty"`
  ```
```

**2. 更新模型示例代码**

将现有的模型示例（如 Account）更新为包含完整字段标签的版本，确保所有示例都遵循规范。

**3. 添加金额单位转换说明**

在"API 设计规范"或"错误处理规范"附近添加：

```markdown
**API 层金额单位转换：**

- API 接收和返回的金额使用 `float64` 类型（元）
- 业务层和数据库使用 `int64` 类型（分）
- Handler 层负责单位转换

**输入转换（API → 业务层）：**
```go
// API 接收 10.50 元
inputAmount := 10.50 // float64 (元)
dbAmount := int64(inputAmount * 100) // 1050 分
```

**输出转换（业务层 → API）：**
```go
// 数据库存储 1050 分
dbAmount := int64(1050) // 分
outputAmount := float64(dbAmount) / 100.0 // 10.50 元
```

**注意事项：**
- 转换时注意四舍五入和边界情况
- 建议封装转换函数，避免重复代码
- 在金额字段的 DTO 注释中明确单位（元）
```

### 10.3 验证清单

更新完成后需验证：

- [ ] CLAUDE.md 中的所有模型示例包含完整的字段标签
- [ ] 所有字段定义规范清晰、完整、无歧义
- [ ] 金额字段整数存储的说明详细且易懂
- [ ] 唯一索引软删除兼容性规范已添加
- [ ] JSONB 字段使用规范已添加
- [ ] API 层金额单位转换说明已添加
- [ ] 规范文档与实际实现的模型完全一致

## 11. 后续任务

模型修复和规范文档更新完成后，需要：

1. 更新 DTO 模型（请求/响应结构体）
2. 调整 Store 层（数据访问层）
3. 调整 Service 层（业务逻辑层）- 金额单位转换
4. 调整 Handler 层（API 层）- 金额单位转换
5. 生成数据库迁移脚本
6. 编写单元测试验证模型定义
7. 更新 API 文档
