- 完善 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>
17 KiB
设计文档:修复 IoT 模型架构违规
1. 设计目标
将所有 IoT 相关数据模型重构为符合项目开发规范的标准模型,确保代码一致性、可维护性和长期可扩展性。
2. 核心设计原则
2.1 统一模型结构
所有数据模型必须遵循以下标准结构:
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 字段定义规范
字符串字段:
Name string `gorm:"column:name;type:varchar(100);not null;comment:名称" json:"name"`
- 必须显式指定
column标签 - 必须指定
type:varchar(N)和长度 - 必须指定
not null(如果必填) - 必须添加中文
comment
货币金额字段:
Amount int64 `gorm:"column:amount;type:bigint;default:0;not null;comment:金额(分)" json:"amount"`
- 使用
int64类型(不是float64) - 单位为"分"(1元 = 100分)
- 必须指定
type:bigint - 必须指定
default:0和not null - 注释中明确标注"(分)"
设计理由:
- 整数存储避免浮点精度问题(金融领域最佳实践)
- 分为单位便于精确计算和货币转换
枚举字段:
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
- 使用
int类型(不是string) - 必须在注释中列举所有枚举值
- 必须指定
default和not null
关联 ID 字段:
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 字段:
ShopID *uint `gorm:"column:shop_id;type:bigint;index;comment:店铺ID(可选)" json:"shop_id,omitempty"`
- 使用指针类型
*uint(可为 NULL) - 不指定
not null - 仍需添加
index索引 - JSON 标签使用
omitempty
唯一索引字段:
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}
时间字段:
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at,omitempty"`
- 可选时间字段使用指针类型
*time.Time - 不使用
autoCreateTime或autoUpdateTime(这些由 gorm.Model 提供) - JSON 标签使用
omitempty
JSONB 字段(PostgreSQL):
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 软删除支持
所有业务数据表都应支持软删除:
type BusinessModel struct {
gorm.Model // 包含 DeletedAt 字段
// ...
}
不需要软删除的表:
- 纯配置表(如
PollingConfig、CommissionWithdrawalSetting) - 日志表(如
DataUsageRecord) - 中间表(如
DeviceSimBinding可选支持)
对于不需要软删除的表,可以手动定义字段:
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.DistributePriceNumberCard.PricePackage.PriceAgentPackageAllocation.CostPrice、AgentPackageAllocation.RetailPriceOrder.AmountCommissionRule.CommissionValueCommissionLadder.CommissionValueCommissionCombinedCondition.OneTimeCommissionValue、CommissionCombinedCondition.LongTermCommissionValueCommissionRecord.AmountCommissionTemplate.CommissionValueCarrierSettlement.SettlementAmountCommissionWithdrawalRequest.Amount、CommissionWithdrawalRequest.Fee、CommissionWithdrawalRequest.ActualAmountCommissionWithdrawalSetting.MinWithdrawalAmount
4.2 业务逻辑调整
API 输入输出:
- API 接收的金额仍为
float64(元) - Handler 层负责单位转换:元 → 分(乘以 100)
- 响应时转换回:分 → 元(除以 100)
示例:
// 输入: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 数据库迁移
对于已有测试数据:
-- 金额从 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:
CarrierOrderData pq.StringArray `gorm:"column:carrier_order_data;type:jsonb;..."`
这是类型不匹配的:pq.StringArray 是 PostgreSQL 数组类型,不是 JSONB。
5.2 解决方案
使用 GORM 的 datatypes.JSON 类型:
import "gorm.io/datatypes"
type Order struct {
// ...
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data,omitempty"`
// ...
}
业务层使用:
// 写入
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、订单号、虚拟商品编码):
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):
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)
对于联合查询的字段组合:
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 代码修改顺序
- 修改所有模型文件(
internal/model/*.go) - 更新模型的单元测试(如有)
- 生成新的数据库迁移脚本
- 在开发环境测试迁移脚本
- 验证所有模型定义正确
7.2 数据库迁移策略
场景 1:IoT 模块尚未部署(推荐)
- 删除旧的迁移脚本(如果已创建)
- 生成新的初始迁移脚本
- 重新运行迁移
场景 2:IoT 模块已有测试数据
- 保留旧的迁移脚本
- 生成新的迁移脚本(包含表重命名、字段修改)
- 编写数据转换脚本(金额单位转换等)
7.3 迁移脚本示例
-- 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 模型字段规范
在"数据库设计原则"部分添加详细的字段定义规范:
**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过滤条件 - 示例:
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 - 示例:
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):
// 数据库存储 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 文档