# 设计文档:修复 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 文档