Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-12-fix-iot-models-violations/design.md
huang 2150fb6ab9 重构:完善 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>
2026-01-12 17:43:12 +08:00

538 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档:修复 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 文档