重构:完善 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,458 @@
# Capability: model-organization
## MODIFIED Requirements
### Requirement: Data models MUST follow unified structure conventions
All IoT data models MUST follow unified structure conventions. 所有 IoT 相关数据模型必须与现有用户体系模型Account、PersonalCustomer保持一致的架构风格和字段定义规范。
#### Scenario: IoT 卡模型结构规范化
**Given** 系统存在 IoT 卡数据模型
**When** 开发者定义或修改 IoT 卡模型
**Then** 模型必须:
- 嵌入 `gorm.Model`(提供 ID、CreatedAt、UpdatedAt、DeletedAt 字段)
- 嵌入 `BaseModel`(提供 Creator、Updater 审计字段)
- 所有字段显式指定 `gorm:"column:字段名"` 标签
- 所有字符串字段显式指定类型和长度(如 `type:varchar(100)`
- 所有金额字段使用 `int64` 类型和 `type:bigint`,单位为"分"
- 所有必填字段添加 `not null` 约束
- 所有字段添加中文 `comment` 注释
- 所有唯一字段添加 `uniqueIndex:索引名,where:deleted_at IS NULL`
- 所有关联 ID 字段添加 `index` 索引
- 表名使用 `tb_iot_card``tb_` 前缀 + 单数)
**Example:**
```go
package model
import "gorm.io/gorm"
type IotCard struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // Creator, Updater
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
CarrierID uint `gorm:"column:carrier_id;type:bigint;not null;index;comment:运营商ID" json:"carrier_id"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;not null;comment:成本价(分)" json:"cost_price"`
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;not null;comment:分销价(分)" json:"distribute_price"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户 device-设备" json:"owner_type"`
OwnerID uint `gorm:"column:owner_id;type:bigint;default:0;not null;index;comment:所有者ID" json:"owner_id"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at,omitempty"`
}
func (IotCard) TableName() string {
return "tb_iot_card"
}
```
#### Scenario: 设备模型结构规范化
**Given** 系统存在设备数据模型
**When** 开发者定义或修改设备模型
**Then** 模型必须遵循与 IoT 卡模型相同的规范gorm.Model + BaseModel + 字段标签)
**And** 表名使用 `tb_device`
#### Scenario: 号卡模型结构规范化
**Given** 系统存在号卡数据模型
**When** 开发者定义或修改号卡模型
**Then** 模型必须遵循与 IoT 卡模型相同的规范
**And** 表名使用 `tb_number_card`
**And** 价格字段使用 `int64` 类型(分为单位)
#### Scenario: 套餐相关模型结构规范化
**Given** 系统存在套餐系列、套餐、代理套餐分配、套餐使用情况等模型
**When** 开发者定义或修改套餐相关模型
**Then** 所有套餐相关模型必须遵循统一规范:
- 套餐系列:`tb_package_series`
- 套餐:`tb_package`
- 代理套餐分配:`tb_agent_package_allocation`
- 套餐使用情况:`tb_package_usage`
**And** 所有价格字段使用 `int64` 类型(分为单位)
#### Scenario: 订单模型结构规范化
**Given** 系统存在订单数据模型
**When** 开发者定义或修改订单模型
**Then** 模型必须遵循统一规范
**And** 表名使用 `tb_order`
**And** 金额字段使用 `int64` 类型(分为单位)
**And** JSONB 字段使用 `gorm.io/datatypes.JSON` 类型(不是 `pq.StringArray`
**Example:**
```go
import (
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Order struct {
gorm.Model
BaseModel `gorm:"embedded"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
OrderType int `gorm:"column:order_type;type:int;not null;index;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分)" json:"amount"`
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data,omitempty"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
}
func (Order) TableName() string {
return "tb_order"
}
```
#### Scenario: 分佣相关模型结构规范化
**Given** 系统存在代理层级、分佣规则、分佣记录等模型
**When** 开发者定义或修改分佣相关模型
**Then** 所有分佣相关模型必须遵循统一规范:
- 代理层级:`tb_agent_hierarchy`
- 分佣规则:`tb_commission_rule`
- 阶梯分佣配置:`tb_commission_ladder`
- 组合分佣条件:`tb_commission_combined_condition`
- 分佣记录:`tb_commission_record`
- 分佣审批:`tb_commission_approval`
- 分佣模板:`tb_commission_template`
- 运营商结算:`tb_carrier_settlement`
**And** 所有金额字段使用 `int64` 类型(分为单位)
#### Scenario: 财务相关模型结构规范化
**Given** 系统存在佣金提现申请、提现设置、收款商户设置等模型
**When** 开发者定义或修改财务相关模型
**Then** 所有财务相关模型必须遵循统一规范:
- 佣金提现申请:`tb_commission_withdrawal_request`
- 佣金提现设置:`tb_commission_withdrawal_setting`
- 收款商户设置:`tb_payment_merchant_setting`
**And** 所有金额字段使用 `int64` 类型(分为单位)
**And** JSONB 字段使用 `gorm.io/datatypes.JSON` 类型
#### Scenario: 系统配置和日志模型规范化
**Given** 系统存在运营商、轮询配置、流量记录、开发能力配置等模型
**When** 开发者定义或修改系统配置和日志模型
**Then** 模型必须遵循统一规范:
- 运营商:`tb_carrier`gorm.Model + BaseModel
- 轮询配置:`tb_polling_config`gorm.Model + BaseModel
- 流量记录:`tb_data_usage_record`(仅 ID + CreatedAt不需要 UpdatedAt 和 DeletedAt
- 开发能力配置:`tb_dev_capability_config`gorm.Model + BaseModel
- 换卡申请:`tb_card_replacement_request`gorm.Model + BaseModel
**Example (流量记录 - 简化模型):**
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}
```
#### Scenario: 设备-SIM 卡绑定关系模型规范化
**Given** 系统存在设备-IoT 卡绑定关系模型
**When** 开发者定义或修改绑定关系模型
**Then** 模型必须遵循统一规范
**And** 表名使用 `tb_device_sim_binding`
**And** 支持复合索引(`device_id` + `slot_position`
**Example:**
```go
type DeviceSimBinding struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceID uint `gorm:"column:device_id;type:bigint;not null;index:idx_device_slot;comment:设备ID" json:"device_id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;not null;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time,omitempty"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time,omitempty"`
}
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding"
}
```
### Requirement: Currency amount fields MUST use integer type (unit: cents)
All currency amount fields MUST use integer type (unit: cents). 所有货币金额字段必须使用 `int64` 类型存储,单位为"分"1 元 = 100 分),避免浮点精度问题。
#### Scenario: 金额字段定义规范
**Given** 模型包含货币金额字段(如价格、成本、佣金、提现金额等)
**When** 开发者定义金额字段
**Then** 字段必须:
- 使用 `int64` Go 类型(不是 `float64`
- 数据库类型为 `bigint`(不是 `decimal``numeric`
- 默认值为 `0`
- 添加 `not null` 约束
- 注释中明确标注"(分)"单位
**Example:**
```go
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;not null;comment:成本价(分)" json:"cost_price"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分)" json:"amount"`
```
#### Scenario: API 层金额单位转换
**Given** API 接收或返回金额数据
**When** Handler 层处理请求或响应
**Then** 必须进行单位转换:
- 输入API 接收 `float64`(元) → 业务层使用 `int64`(分)
- 输出:业务层返回 `int64`(分) → API 返回 `float64`(元)
**Example:**
```go
// 输入转换Handler 层)
type CreateOrderRequest struct {
Amount float64 `json:"amount"` // 元
}
func (h *OrderHandler) CreateOrder(c *fiber.Ctx) error {
var req CreateOrderRequest
// ... 解析请求 ...
// 转换:元 → 分
amountInCents := int64(req.Amount * 100)
// 调用 Service 层
order, err := h.orderService.CreateOrder(ctx, amountInCents, ...)
// ...
}
// 输出转换Handler 层)
type OrderResponse struct {
Amount float64 `json:"amount"` // 元
}
func (h *OrderHandler) GetOrder(c *fiber.Ctx) error {
order, err := h.orderService.GetOrder(ctx, orderID)
// ...
// 转换:分 → 元
resp := OrderResponse{
Amount: float64(order.Amount) / 100.0,
}
return response.Success(c, resp)
}
```
### Requirement: Table names MUST follow unified naming conventions
All database table names MUST follow unified naming conventions. 所有数据库表名必须遵循项目约定的 `tb_` 前缀 + 单数形式。
#### Scenario: 表名命名规范
**Given** 开发者定义数据模型
**When** 实现 `TableName()` 方法
**Then** 表名必须:
- 使用 `tb_` 前缀
- 使用单数形式(不是复数)
- 使用下划线命名法snake_case
**Example:**
```go
// ✅ 正确
func (IotCard) TableName() string {
return "tb_iot_card"
}
func (Device) TableName() string {
return "tb_device"
}
func (Order) TableName() string {
return "tb_order"
}
// ❌ 错误
func (IotCard) TableName() string {
return "iot_cards" // 缺少 tb_ 前缀,使用复数
}
func (Device) TableName() string {
return "devices" // 缺少 tb_ 前缀,使用复数
}
```
#### Scenario: 关联表和中间表命名
**Given** 模型表示多对多关系或绑定关系
**When** 定义关联表或中间表
**Then** 表名必须使用 `tb_` 前缀 + 完整描述性名称(单数)
**Example:**
```go
// 设备-SIM 卡绑定
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding" // 不是 tb_device_sim_bindings
}
// 代理套餐分配
func (AgentPackageAllocation) TableName() string {
return "tb_agent_package_allocation" // 不是 tb_agent_package_allocations
}
```
### Requirement: All fields MUST explicitly specify database column names and types
All model fields MUST explicitly specify database column names and types. 模型字段定义必须清晰明确,不依赖 GORM 的自动转换和推断。
#### Scenario: 字段 GORM 标签完整性检查
**Given** 模型包含业务字段
**When** 开发者定义字段
**Then** 每个字段必须包含:
- `column:字段名`(显式指定数据库列名)
- `type:数据类型`(显式指定数据库类型)
- `comment:中文注释`(说明业务含义)
- 可选:`not null``default:值``index``uniqueIndex` 等约束
**Example:**
```go
// ✅ 完整的字段定义
Username string `gorm:"column:username;type:varchar(50);uniqueIndex:idx_username,where:deleted_at IS NULL;not null;comment:用户名" json:"username"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-启用 2-禁用" json:"status"`
Phone string `gorm:"column:phone;type:varchar(20);comment:手机号码" json:"phone,omitempty"`
// ❌ 不完整的字段定义
Username string `gorm:"comment:用户名" json:"username"` // 缺少 column 和 type
Status int `gorm:"default:1" json:"status"` // 缺少 column、type 和 comment
```
#### Scenario: 唯一索引软删除兼容
**Given** 字段需要全局唯一(如 ICCID、订单号、虚拟商品编码
**When** 模型支持软删除(嵌入 `gorm.Model`
**Then** 唯一索引必须包含 `where:deleted_at IS NULL` 过滤条件
**Example:**
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号" json:"order_no"`
```
**Explanation:**
- 软删除后,`deleted_at` 不为 NULL
- 索引只对 `deleted_at IS NULL` 的记录生效
- 允许软删除后重新使用相同的唯一值
### Requirement: All models MUST support audit tracking (Creator and Updater)
All business data models MUST support audit tracking (Creator and Updater). 所有业务数据模型必须记录创建人和更新人,便于审计和追溯。
#### Scenario: 嵌入 BaseModel 提供审计字段
**Given** 模型表示业务数据实体
**When** 开发者定义模型
**Then** 模型必须嵌入 `BaseModel`
**And** `BaseModel` 提供 `Creator``Updater` 字段
**Example:**
```go
type IotCard struct {
gorm.Model
BaseModel `gorm:"embedded"` // 提供 Creator 和 Updater
// 业务字段...
}
// BaseModel 定义在 internal/model/base.go
type BaseModel struct {
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;comment:更新人ID" json:"updater"`
}
```
#### Scenario: 业务逻辑层自动填充审计字段
**Given** Service 层或 Store 层创建或更新数据
**When** 执行数据库插入或更新操作
**Then** 必须自动填充 `Creator``Updater` 字段(从上下文获取当前用户 ID
**Example:**
```go
// Service 层或 Store 层
func (s *IotCardService) CreateIotCard(ctx context.Context, req CreateIotCardRequest) (*IotCard, error) {
// 从上下文获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx)
card := &IotCard{
BaseModel: BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
ICCID: req.ICCID,
CardType: req.CardType,
// ...
}
if err := s.db.Create(card).Error; err != nil {
return nil, err
}
return card, nil
}
func (s *IotCardService) UpdateIotCard(ctx context.Context, id uint, req UpdateIotCardRequest) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
updates := map[string]interface{}{
"updater": currentUserID,
"card_type": req.CardType,
// ...
}
return s.db.Model(&IotCard{}).Where("id = ?", id).Updates(updates).Error
}
```
### Requirement: Log and record tables MUST use appropriate model structure
Append-only log and record tables MUST use simplified model structure. 对于只追加、不更新的日志表(如流量记录),必须使用简化的模型结构,不需要 `UpdatedAt``DeletedAt`
#### Scenario: 流量记录简化模型
**Given** 模型表示只追加的日志数据(不会被修改或删除)
**When** 开发者定义日志模型
**Then** 模型可以:
- 手动定义 `ID`(不嵌入 `gorm.Model`
- 只包含 `CreatedAt`(不需要 `UpdatedAt``DeletedAt`
- 不嵌入 `BaseModel`(如果不需要审计)
**Example:**
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
Source string `gorm:"column:source;type:varchar(50);default:'polling';comment:数据来源 polling-轮询 manual-手动 gateway-回调" json:"source"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}
```
**Explanation:**
- 流量记录只追加,不修改,不需要 `UpdatedAt`
- 流量记录不删除(或物理删除),不需要 `DeletedAt`
- 简化模型结构减少存储开销和查询复杂度