重构:完善 IoT 模型架构规范和数据库设计

- 完善 GORM 模型规范:货币字段使用 int64(分为单位)、JSONB 字段规范、模型结构规范
- 修复所有 IoT 模型的架构违规问题
- 更新 CLAUDE.md 开发指南,补充完整的数据库设计规范和模型示例
- 添加数据库迁移脚本(000006)用于架构重构
- 归档 OpenSpec 变更文档(2026-01-12-fix-iot-models-violations)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 17:43:12 +08:00
parent 4507de577b
commit 2150fb6ab9
21 changed files with 2774 additions and 263 deletions

View File

@@ -0,0 +1,537 @@
# 设计文档:修复 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 数据库迁移策略
**场景 1IoT 模块尚未部署(推荐)**
- 删除旧的迁移脚本(如果已创建)
- 生成新的初始迁移脚本
- 重新运行迁移
**场景 2IoT 模块已有测试数据**
- 保留旧的迁移脚本
- 生成新的迁移脚本(包含表重命名、字段修改)
- 编写数据转换脚本(金额单位转换等)
### 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 文档