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

17 KiB
Raw Blame History

设计文档:修复 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:0not null
  • 注释中明确标注"(分)"

设计理由:

  • 整数存储避免浮点精度问题(金融领域最佳实践)
  • 分为单位便于精确计算和货币转换

枚举字段:

Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
  • 使用 int 类型(不是 string
  • 必须在注释中列举所有枚举值
  • 必须指定 defaultnot null

关联 ID 字段:

UserID uint `gorm:"column:user_id;type:bigint;not null;index;comment:用户ID" json:"user_id"`
  • 使用 uint 类型(与 gorm.Model 的 ID 类型一致)
  • 数据库类型使用 bigintPostgreSQL
  • 必须添加 index 索引
  • 禁止使用 GORM 关联标签(foreignKeyreferences

可选关联 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
  • 不使用 autoCreateTimeautoUpdateTime(这些由 gorm.Model 提供)
  • JSON 标签使用 omitempty

JSONB 字段PostgreSQL

Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;comment:元数据" json:"metadata,omitempty"`
  • 使用 gorm.io/datatypes.JSON 类型
  • 数据库类型使用 jsonbPostgreSQL 优化存储)
  • 使用 omitempty

2.3 表名和索引命名规范

表名:

  • 格式:tb_{model_name}(单数)
  • 示例:tb_iot_cardtb_devicetb_order

索引名:

  • 普通索引:idx_{table}_{field}
  • 唯一索引:idx_{table}_{field}uniq_{table}_{field}
  • 复合索引:idx_{table}_{field1}_{field2}

设计理由:

  • 统一前缀便于识别业务表(与系统表区分)
  • 单数形式符合 Go 惯用命名(类型名为单数)
  • 索引名清晰表达用途和字段

2.4 软删除支持

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

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

不需要软删除的表:

  • 纯配置表(如 PollingConfigCommissionWithdrawalSetting
  • 日志表(如 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

  • IotCardIoT 卡)
  • 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.CostPriceIotCard.DistributePrice
  • NumberCard.Price
  • Package.Price
  • AgentPackageAllocation.CostPriceAgentPackageAllocation.RetailPrice
  • Order.Amount
  • CommissionRule.CommissionValue
  • CommissionLadder.CommissionValue
  • CommissionCombinedCondition.OneTimeCommissionValueCommissionCombinedCondition.LongTermCommissionValue
  • CommissionRecord.Amount
  • CommissionTemplate.CommissionValue
  • CarrierSettlement.SettlementAmount
  • CommissionWithdrawalRequest.AmountCommissionWithdrawalRequest.FeeCommissionWithdrawalRequest.ActualAmount
  • CommissionWithdrawalSetting.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_idslot_position 的联合索引

7. 迁移路径

7.1 代码修改顺序

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

7.2 数据库迁移策略

场景 1IoT 模块尚未部署(推荐)

  • 删除旧的迁移脚本(如果已创建)
  • 生成新的初始迁移脚本
  • 重新运行迁移

场景 2IoT 模块已有测试数据

  • 保留旧的迁移脚本
  • 生成新的迁移脚本(包含表重命名、字段修改)
  • 编写数据转换脚本(金额单位转换等)

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 或手动定义 IDCreatedAtUpdatedAt
  • 所有业务模型嵌入 BaseModelCreatorUpdater
  • 所有字段显式指定 column 标签
  • 所有字符串字段指定类型和长度(type:varchar(N)
  • 所有金额字段使用 int64 类型和 type:bigint
  • 所有必填字段指定 not null
  • 所有字段添加中文 comment
  • 所有唯一字段添加 uniqueIndex 并包含 where:deleted_at IS NULL
  • 所有关联字段添加 index
  • 所有表名使用 tb_ 前缀 + 单数
  • 所有 JSONB 字段使用 datatypes.JSON 类型
  • 所有模型与现有 AccountPersonalCustomer 模型风格一致

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 文档