Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-12-iot-sim-management/design.md
huang 034f00e2e7 实现 IoT SIM 管理模块数据模型和数据库结构
- 添加 IoT 核心业务表:运营商、IoT 卡、设备、号卡、套餐、订单等
- 添加分佣系统表:分佣规则、分佣记录、运营商结算等
- 添加轮询和流量管理表:轮询配置、流量使用记录等
- 添加财务和系统管理表:佣金提现、换卡申请等
- 实现完整的 GORM 模型和常量定义
- 添加数据库迁移脚本和详细文档
- 集成 OpenSpec 工作流工具(opsx 命令和 skills)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 15:44:23 +08:00

40 KiB

Context

背景

junhong_cmp_fiber 项目需要构建 IoT 卡管理系统,支持三大核心业务:

核心概念澄清:

  • IoT 卡 = 物联网卡 = SIM 卡 = 网卡 = 流量卡(同一个东西,不同叫法)
    • 普通卡: 需要实名认证才能激活使用,遵循运营商实名制要求
    • 行业卡: 不需要实名认证,可以直接激活使用,适用于企业/行业客户批量采购场景
  • 设备: 用户的物联网设备(如 GPS 追踪器、智能传感器),可绑定 1-4 张 IoT 卡,主要用于批量管理和设备操作(重启、修改密码等),不在卡管系统中销售
  • 号卡: 完全独立的业务线,从上游平台下单,不走我们平台激活和充值,只接收订单状态更新

三大核心业务:

  1. IoT 卡(IotCard): 平台自营销售和代理分销,通过购买套餐产生订单,使用 ICCID 作为唯一标识
  2. 设备(Device): 用户设备管理,可绑定 1-4 张 IoT 卡,支持设备级套餐购买(流量共享),不在卡管系统中销售
  3. 号卡(NumberCard): 运营商订单回传,使用虚拟商品编码映射,支持代理分销和分佣

当前状态

  • 已有用户体系:平台用户、代理用户、企业用户、个人用户(user_organizations, users 等表)
  • 已有认证和权限系统(auth, role-permission, data-permission)
  • 外部依赖:Gateway 项目提供 IoT 卡状态、实名、流量、停复机等 HTTP 接口

约束

  • 本阶段只设计数据模型层(域实体、ERD、表结构、Schema、GORM Models)
  • 不涉及 API/Handler/Service 层的实现
  • 不涉及计费系统、供应管理、事件系统的实现
  • 遵循项目规范:无外键约束、无 ORM 关联、手动维护关联关系

利益相关方

  • 平台用户:自营销售 IoT 卡、管理设备
  • 代理商:多级树形结构,分销 IoT 卡和分佣
  • 企业客户/个人客户:购买 IoT 卡套餐、管理设备、购买号卡
  • 运营商:号卡订单回传和套餐管理
  • 运营人员:通过设备维度批量管理投诉和代理要求,查看绑定的所有 IoT 卡

Goals / Non-Goals

Goals (本阶段目标)

  1. 设计完整的数据模型:

    • 定义核心实体:IoT 卡、设备、号卡、套餐、订单、代理分佣
    • 绘制 ERD(实体关系图)
    • 设计数据库表结构和 Schema
    • 实现 GORM 模型定义
  2. 支持核心业务流程:

    • 平台自营和代理分销模式(仅 IoT 卡)
    • 套餐购买订单流程(单卡套餐、设备级套餐)
    • 号卡运营商订单回传和虚拟商品编码映射
    • 多级代理分佣计算(组合分佣 OR 条件)
    • 设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
    • 设备级套餐流量共享机制
  3. 遵循项目规范:

    • 无数据库外键约束
    • 无 GORM ORM 关联标签(foreignKey, references, hasMany, belongsTo 等)
    • 所有字段显式指定 column: 标签
    • 字段类型和长度明确定义
    • 所有字段添加中文注释
  4. 预留扩展能力:

    • 支持未来集成 Gateway 项目(IoT 卡状态查询、停复机操作等)
    • 支持未来的计费和供应管理集成

Non-Goals (明确排除)

  • API 层设计(Handlers、路由、中间件)
  • 业务逻辑层设计(Services、业务规则实现)
  • 计费系统实现(Billing Engine)
  • 供应管理集成(Provisioning)
  • 事件系统集成(Events、消息队列)
  • 单元测试和集成测试
  • API 文档生成
  • Gateway 项目集成的具体实现(只设计数据模型字段预留)

Decisions

决策 1: 无外键约束的数据模型设计

选择: 所有表之间不使用数据库外键约束,通过存储关联 ID 字段手动维护关系。

理由:

  • 遵循项目既定规范(参考 CLAUDE.md 数据库设计原则)
  • 提高灵活性:业务逻辑完全在代码中控制
  • 提升性能:无数据库层面的引用完整性检查开销
  • 分布式友好:在微服务和分布式数据库场景下更易扩展
  • 简化迁移:数据库 schema 更简单,迁移更容易

替代方案:

  • 使用外键约束:会引入数据库层面的复杂性,限制灵活性,不符合项目规范

实施细节:

  • 所有关联关系通过 {entity}_id 字段存储(如 user_id, agent_id, device_id)
  • GORM 模型不使用 foreignKey, references, hasMany, belongsTo 等标签
  • 关联数据查询在 Service 层显式执行

决策 2: 平台自营和代理分销的统一建模

选择: 使用 owner_typeowner_id 字段统一建模平台自营和代理分销(仅 IoT 卡)。

理由:

  • IoT 卡既可以平台自营销售,也可以分销给代理
  • 设备不在卡管系统中销售,主要用于用户设备管理和运营人员管理投诉
  • 使用多态关联字段避免为平台和代理创建两套库存系统
  • 简化查询逻辑:通过 owner_type 区分所有者类型

字段设计:

owner_type: VARCHAR(20)  -- 值: "platform"-平台 | "agent"-代理 | "user"-用户 | "device"-设备
owner_id: BIGINT         -- 平台(0)、代理用户 ID、用户 ID 或设备 ID

替代方案:

  • 分别设计 platform_inventoryagent_inventory 表:重复代码,增加维护成本
  • 只用 agent_id 并用 NULL 表示平台:语义不清晰,查询复杂

决策 3: 号卡虚拟商品编码的设计

选择: 在 number_cards 表中增加 virtual_product_code 字段,用于映射运营商回传订单。

理由:

  • 号卡本身不是系统内真实的库存商品,而是运营商侧的订单
  • 需要一个"假的商品编码"来对应上游回调订单的商品标识
  • 虚拟编码作为号卡和运营商订单的桥梁

字段设计:

virtual_product_code: VARCHAR(100) UNIQUE  -- 虚拟商品编码,用于对应运营商订单
carrier_order_id: VARCHAR(255)            -- 运营商订单 ID
carrier_product_id: VARCHAR(100)          -- 运营商商品 ID

替代方案:

  • 直接使用运营商商品 ID:缺乏系统内部的统一标识
  • 创建独立的商品表:号卡不是真实库存,不应与网卡/设备商品化混淆

决策 4: 设备与 IoT 卡的多对多绑定关系

选择: 使用中间表 device_sim_bindings 管理设备与 IoT 卡的绑定关系。

理由:

  • 一个设备可以绑定 1-4 张 IoT 卡(多对多关系)
  • 中间表可以记录绑定时间、绑定状态、插槽位置等元数据
  • 支持历史绑定记录查询
  • 支持设备级套餐购买(套餐分配到所有绑定的 IoT 卡,流量共享)

表设计:

CREATE TABLE device_sim_bindings (
    id BIGSERIAL PRIMARY KEY,
    device_id BIGINT NOT NULL,        -- 设备 ID
    iot_card_id BIGINT NOT NULL,      -- IoT 卡 ID
    slot_position INT,                -- 插槽位置 (1, 2, 3, 4)
    bind_status INT DEFAULT 1,        -- 绑定状态 1-已绑定 2-已解绑
    bind_time TIMESTAMP,              -- 绑定时间
    unbind_time TIMESTAMP,            -- 解绑时间
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 在设备表存储 iot_card_ids JSON 字段:难以查询和维护,不支持元数据
  • 在 IoT 卡表存储 device_id:只能支持一对一,不支持多卡绑定

决策 5: 代理树形结构的设计

选择: 在 agent_hierarchies 表中使用 agent_id + parent_agent_id 表示树形关系。

理由:

  • 每个代理只有一个上级(单亲树)
  • 使用递归查询(CTE)可以获取整个代理链
  • 支持计算多级分佣

表设计:

CREATE TABLE agent_hierarchies (
    id BIGSERIAL PRIMARY KEY,
    agent_id BIGINT NOT NULL UNIQUE,    -- 代理用户 ID
    parent_agent_id BIGINT,             -- 上级代理用户 ID (NULL 表示顶级代理)
    level INT NOT NULL,                 -- 代理层级 (1, 2, 3...)
    path VARCHAR(500),                  -- 代理路径 (如: "1/5/12")
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 使用闭包表(Closure Table):过度设计,查询性能提升不明显
  • 使用嵌套集合(Nested Set):插入和移动节点复杂,不适合频繁变更

决策 6: 订单类型的统一建模

选择: 使用 order_type 字段区分两种订单类型,使用独立字段关联订单来源。

订单类型:

  1. 套餐订单 (order_type = 1): 用户为 IoT 卡或设备购买套餐
    • 单卡套餐订单: iot_card_id 有值,device_id 为 NULL
    • 设备级套餐订单: device_id 有值,iot_card_id 为 NULL(套餐分配到所有绑定的 IoT 卡,流量共享)
  2. 号卡订单 (order_type = 2): 运营商回传订单,number_card_id 有值

字段设计:

order_type: INT               -- 值: 1-套餐订单 2-号卡订单
iot_card_id: BIGINT           -- IoT 卡 ID(单卡套餐订单时有值)
device_id: BIGINT             -- 设备 ID(设备级套餐订单时有值)
number_card_id: BIGINT        -- 号卡 ID(号卡订单时有值)
package_id: BIGINT            -- 套餐 ID(套餐订单时有值)

理由:

  • 简化订单类型,只保留实际需要的两种订单类型
  • 移除 SIM 卡销售订单(IoT 卡不单独销售,只通过套餐订单管理)
  • 通过独立字段明确关联不同业务实体,比多态字段更清晰
  • 支持设备级套餐订单,流量共享机制

替代方案:

  • 创建 package_orders, number_card_orders 两张表:代码重复,维护成本高
  • 使用 source_type + source_id 多态字段:不够清晰,查询复杂

决策 7: IoT 卡状态字段预留 Gateway 集成

选择: 在 iot_cards 表中增加状态相关字段,但不在本阶段实现 Gateway 集成。

字段设计:

iccid: VARCHAR(50) UNIQUE              -- IoT 卡 ICCID(唯一标识)
activation_status: INT                 -- 激活状态 (0-未激活 1-已激活)
real_name_status: INT                  -- 实名状态 (0-未实名 1-已实名)
network_status: INT                    -- 网络状态 (0-停机 1-开机)
data_usage_mb: BIGINT DEFAULT 0        -- 累计流量使用(MB)
last_sync_time: TIMESTAMP              -- 最后一次与 Gateway 同步时间

理由:

  • 本阶段只设计数据模型,不实现具体的 Gateway 集成逻辑
  • 预留字段便于后续 Service 层调用 Gateway HTTP 接口并更新这些字段
  • 这些字段的数据来源是 Gateway 项目,不由本系统直接管理
  • IoT 卡 = SIM 卡 = 网卡 = 流量卡(同一个东西,不同叫法,统一使用 IoT 卡命名)

替代方案:

  • 不预留字段:后续集成需要修改表结构,涉及数据迁移
  • 在独立的 iot_card_status 表:过度规范化,增加查询复杂度

决策 8: 字段命名和类型规范

选择: 严格遵循项目规范,所有字段显式指定 column: 标签,类型和长度明确定义。

命名规范:

  • 数据库字段名:snake_case (如 user_id, created_at)
  • Go 结构体字段名:PascalCase (如 UserID, CreatedAt)
  • 必须显式指定 gorm:"column:字段名" 标签

类型规范:

  • ID 字段:BIGINT (对应 Go uintint64)
  • 短文本:VARCHAR(50-255)
  • 长文本:TEXT
  • 货币金额:DECIMAL(18,2) 或 BIGINT(分为单位)
  • 时间:TIMESTAMP (对应 Go time.Time)
  • 枚举:INT 或 VARCHAR,配合常量定义

示例:

type IotCard struct {
    ID        uint      `gorm:"column:id;primaryKey;comment:IoT 卡 ID" json:"id"`
    ICCID     string    `gorm:"column:iccid;type:varchar(50);uniqueIndex;not null;comment:ICCID" json:"iccid"`
    Status    int       `gorm:"column:status;type:int;default:1;comment:状态 1-在库 2-已分销 3-已激活" json:"status"`
    CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}

决策 9: 行业卡无需实名认证的设计

选择: 在 IoT 卡实体中增加 card_category 字段(枚举值:"normal"-普通卡 | "industry"-行业卡),行业卡可以在实名状态为 0(未实名)的情况下激活和使用。

业务规则:

  • 普通卡(normal): 必须完成实名认证(real_name_status 为 1)才能激活使用,遵循运营商实名制要求
  • 行业卡(industry): 不需要实名认证,可以在 real_name_status 为 0 的情况下激活使用,适用于企业/行业客户批量采购场景

分佣解冻规则调整:

  • 一次性分佣: 普通卡需要实名认证后才能解冻;行业卡无需实名认证,只需满足激活和充值条件
  • 长期分佣: 普通卡需要实名认证后才能开始长期分佣;行业卡无需实名认证,满足其他条件即可
  • 组合分佣: 行业卡的时间点条件从激活时开始计算(不是实名时)

轮询控制:

  • 行业卡的实名状态检查轮询应该被禁用或设置为低优先级
  • 行业卡的流量检查和套餐检查与普通卡相同

数据模型变更:

type IotCard struct {
    // ... 其他字段 ...
    CardCategory   string `gorm:"column:card_category;type:varchar(20);default:'normal';comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
    RealNameStatus int    `gorm:"column:real_name_status;type:int;default:0;comment:实名状态 0-未实名 1-已实名 (行业卡可以保持 0)" json:"real_name_status"`
    // ... 其他字段 ...
}

理由:

  • 符合企业/行业客户批量采购场景的实际需求
  • 简化行业卡的激活流程,提高用户体验
  • 分佣解冻逻辑需要区分普通卡和行业卡,避免行业卡因未实名而无法解冻

替代方案:

  • 为行业卡自动设置实名状态为 1:不真实,会导致数据统计错误
  • 创建独立的行业卡实体:增加系统复杂度,不利于统一管理

Risks / Trade-offs

风险 1: 无外键约束导致数据一致性问题

风险: 手动维护关联关系可能导致孤儿记录(如删除代理后,其分销的 IoT 卡 owner_id 仍然指向已删除的代理)。

缓解措施:

  • 在 Service 层实现软删除(soft delete),不物理删除关键实体
  • 在删除操作前检查关联记录
  • 定期运行数据一致性检查脚本

风险 2: 设备与 IoT 卡的多对多绑定复杂度

风险: 中间表 device_sim_bindings 的状态管理复杂,可能出现一个 IoT 卡被多个设备绑定的冲突。

缓解措施:

  • 在 Service 层实现业务规则:一个 IoT 卡同一时间只能绑定一个设备
  • 在绑定前查询 IoT 卡的当前绑定状态
  • 使用数据库唯一索引:CREATE UNIQUE INDEX idx_iot_card_active_binding ON device_sim_bindings(iot_card_id) WHERE bind_status = 1

风险 3: 号卡虚拟商品编码的唯一性冲突

风险: 多个号卡可能误用相同的虚拟商品编码,导致运营商订单映射错误。

缓解措施:

  • virtual_product_code 字段上创建唯一索引
  • 在创建号卡时自动生成虚拟商品编码(使用 UUID 或业务规则生成)
  • 在 Service 层校验虚拟商品编码的唯一性

风险 4: 多级代理分佣计算性能

风险: 递归查询代理树获取整个分佣链可能影响性能(特别是代理层级深时)。

缓解措施:

  • agent_hierarchies 表中增加 path 字段存储代理路径(如 "1/5/12"),避免递归查询
  • 在 Redis 中缓存代理树结构
  • 使用异步任务(Asynq)计算分佣,不阻塞订单创建

风险 5: Gateway 集成依赖的可用性

风险: IoT 卡状态、流量、停复机操作依赖 Gateway 项目 HTTP 接口,如果 Gateway 不可用会影响功能。

缓解措施:

  • 在数据库中缓存 IoT 卡状态字段,Gateway 不可用时返回缓存数据
  • 设置合理的 HTTP 超时和重试机制
  • 使用 Asynq 异步任务定期同步 IoT 卡状态,降低实时依赖

Trade-off 1: 单表订单 vs 多表订单

权衡: 选择单表存储两种订单类型,使用 order_type 区分。

优点:

  • 统一的订单查询和状态管理
  • 代码复用度高

缺点:

  • 表字段较多,某些字段只对特定订单类型有意义(如 carrier_order_id 只对号卡订单有意义)
  • 单表数据量大,可能影响查询性能

选择理由: 在当前业务规模下,单表方案的代码简洁性优于多表方案的性能优势。如果未来订单量巨大,可以考虑分表或分库。


Trade-off 2: 代理路径字段 vs 纯递归查询

权衡: 在 agent_hierarchies 表中增加 path 字段存储代理路径。

优点:

  • 避免递归查询,提升查询性能
  • 快速获取整个代理链

缺点:

  • 需要在代理关系变更时维护 path 字段
  • 增加存储空间

选择理由: 分佣计算是高频操作,牺牲少量存储空间换取查询性能提升是值得的。


Migration Plan

部署步骤

  1. 生成数据库迁移脚本:

    • 使用 golang-migrate 创建迁移脚本
    • 迁移脚本位置:migrations/ 目录
    • 命名格式:{timestamp}_create_iot_sim_tables.up.sql.down.sql
  2. 测试环境验证:

    • 在测试数据库执行 up 迁移
    • 验证所有表和索引创建成功
    • 插入测试数据验证约束和索引
  3. 生产环境部署:

    • 在生产数据库执行 up 迁移
    • 验证表结构和索引
    • 监控数据库性能
  4. GORM 模型代码部署:

    • 部署包含新 GORM 模型的代码版本
    • 验证 GORM AutoMigrate 不会修改已有表结构(禁用 AutoMigrate 或仅用于开发环境)

回滚策略

  1. 代码回滚:

    • 如果 GORM 模型有 Bug,回滚到上一个代码版本
  2. 数据库回滚:

    • 执行 .down.sql 迁移脚本删除新创建的表
    • 如果已有数据,需要先备份数据再回滚

数据迁移(如果需要)

  • 本次为新功能,不涉及旧数据迁移
  • 如果需要从旧系统导入数据,使用 ETL 脚本批量导入

Open Questions (已解决)

问题 1: 套餐定价和计费规则 (已解决)

结论:

  • 套餐基本为月套餐,年套餐通过设置月数实现(如 12 个月)
  • 流量单位为 MB
  • 流量分为真流量和虚流量两种类型,两者共存
  • 停机判断基于虚流量(虚流量用完后停机,即使真流量还有剩余)
  • 无复杂计费规则,只有固定的套餐价格

表设计影响:

duration_months: INT             -- 套餐时长(月数) 1-月套餐 12-年套餐
data_type: VARCHAR(20)           -- 流量类型 "real"(真流量) | "virtual"(虚流量)
data_amount_mb: BIGINT           -- 流量额度(MB)
real_data_mb: BIGINT             -- 真流量额度(MB,可选)
virtual_data_mb: BIGINT          -- 虚流量额度(MB,用于停机判断)
price: DECIMAL(10,2)             -- 套餐价格(元)

停机规则:

  • 虚流量用完后自动停机
  • 真流量和虚流量独立计算,共存在套餐中
  • 前端展示需要同时显示真流量和虚流量余额

问题 2: 代理分佣配置方式 (已解决)

结论: 分佣体系非常复杂,包含多种类型和触发条件:

分佣类型:

  1. 一次性分佣:

    • 作用于套餐系列
    • 激活(实名) + 达到首次充值金额后产生
    • 纯直接给钱(固定金额,不计算差价)
    • 冻结 N 天后解冻
    • 一次性佣金订单必须通过钱包付款
  2. 长期分佣:

    • 作用于具体套餐
    • 每个计费周期产生
    • 佣金 = 实际售价 - 平台成本价(代理看到的成本价是售价扣掉佣金)
    • 号卡:需要激活 + 充值 + 在网状态 + 三无校验(通过 Excel 导入解冻)
    • 物联网卡(流量卡):只要用户买了就按佣金返,无需在网状态和三无校验
  3. 组合分佣:

    • 物联网卡(流量卡/IoT 卡):
      • 先产生一次性佣金
      • 达到以下任一条件(OR 关系)后开始长期分佣:
        1. 某个时间点之后(例如:实名后 3 个月)
        2. OR 该 IoT 卡的套餐使用周期数达到阈值(例如:10 个套餐周期)
      • 注意: 套餐周期阈值是针对单张 IoT 卡的,不是设备级别
    • 号卡:
      • 连续在网多少个月后开始长期分佣

阶梯分佣:

  • 号卡: 只有激活量作为阶梯条件
  • 物联网卡(流量卡): 激活量 + 提货量作为阶梯条件
  • 达到阶梯条件后变更分佣值

关键业务规则:

  • 代理销售价格不能超过平台成本价的 2 倍
  • 长期分佣: 佣金 = 实际售价 - 平台成本价(阴阳菜单模式)
  • 一次性佣金: 纯直接给钱,不计算差价
  • 一次性佣金订单必须通过钱包付款

表设计影响:

  • 新增 commission_templates 表:分佣模板(常用分佣方案)
  • 新增 commission_rules 表:代理分佣规则配置(需区分号卡和 IoT 卡)
  • 新增 commission_records 表:分佣记录(冻结/解冻状态)
  • 新增 commission_ladder 表:阶梯分佣配置(号卡只支持激活量,IoT 卡支持激活量+提货量)
  • 新增 commission_approvals 表:分佣解冻审批
  • 新增 commission_combined_conditions 表:组合分佣条件配置(时间点、套餐周期数、连续在网月数),OR 关系解冻

问题 3: 号卡运营商订单回传数据格式 (已解决)

结论:

  • Gateway 项目统一转换各上游订单为 JSON 格式后回传
  • 号卡资金流不经过平台,直接支付给运营商
  • 平台接收运营商周期性结算的佣金总额,再分配给代理

表设计影响:

carrier_order_id: VARCHAR(255)   -- 运营商订单 ID
carrier_order_data: JSONB        -- 运营商订单原始数据(JSON)
settlement_status: INT           -- 结算状态 1-待结算 2-已结算
settlement_amount: DECIMAL(18,2) -- 运营商结算佣金金额

问题 4: IoT 卡绑定设备的插槽数量限制 (已解决)

结论: 一个设备最多插 4 张卡

表设计影响:

max_sim_slots: INT DEFAULT 4     -- 设备最大插槽数量(默认 4)

业务规则: 在 Service 层校验设备当前绑定的 IoT 卡数量不超过 max_sim_slots


问题 5: Gateway 集成的认证和授权 (已解决)

结论: Gateway 使用统一的加密传输协议

请求格式:

{
  "appId": "your_app_id",
  "data": "AES加密后的Base64字符串",
  "sign": "MD5签名(大写)",
  "timestamp": 1704067200
}

加密方案:

  • 数据加密:AES-128-ECB + PKCS5Padding,密钥为 MD5(appSecret) 的原始字节数组
  • 签名算法:MD5(appId + data + timestamp + appSecret),转大写
  • 时间戳:Unix 秒级时间戳,允许 ±5 分钟误差

配置文件影响:

gateway:
  base_url: "https://gateway.example.com"
  app_id: "your_app_id"
  app_secret: "your_app_secret"
  timeout: 30s

实现范围: 本阶段只设计数据模型,不实现 Gateway 集成的具体 HTTP 客户端代码


决策 9: 佣金提现和财务管理

选择: 设计独立的佣金提现申请流程和财务账户管理。

理由:

  • 代理需要将冻结/已发放的佣金提现到银行卡或支付宝
  • 需要审批流程控制提现风险
  • 需要记录提现历史和手续费

表设计:

-- 佣金提现申请表
CREATE TABLE commission_withdrawal_requests (
    id BIGSERIAL PRIMARY KEY,
    agent_id BIGINT NOT NULL,              -- 代理用户 ID
    amount DECIMAL(18,2) NOT NULL,         -- 提现金额
    fee DECIMAL(18,2) DEFAULT 0,           -- 手续费
    actual_amount DECIMAL(18,2),           -- 实际到账金额
    withdrawal_method VARCHAR(20),         -- 提现方式 "alipay" | "wechat" | "bank"
    account_info JSONB,                    -- 收款账户信息(姓名、账号等)
    status INT DEFAULT 1,                  -- 状态 1-待审核 2-已通过 3-已拒绝 4-已到账
    approved_by BIGINT,                    -- 审批人用户 ID
    approved_at TIMESTAMP,                 -- 审批时间
    paid_at TIMESTAMP,                     -- 到账时间
    reject_reason TEXT,                    -- 拒绝原因
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- 佣金提现设置表
CREATE TABLE commission_withdrawal_settings (
    id BIGSERIAL PRIMARY KEY,
    min_withdrawal_amount DECIMAL(10,2),  -- 最低提现金额
    fee_rate DECIMAL(5,4),                 -- 手续费率(如 0.01 表示 1%)
    arrival_days INT,                      -- 到账天数
    is_active BOOLEAN DEFAULT TRUE,        -- 是否生效(最新一条)
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 不设计提现流程:代理无法取出佣金,体验差

决策 10: 商品分配和套餐系列管理

选择: 设计套餐系列作为套餐的分组,用于一次性分佣规则配置。

理由:

  • 一次性分佣作用于套餐系列,而不是单个套餐
  • 套餐系列可以包含多个套餐(如"月套餐系列"包含 10GB、20GB、30GB 等月套餐)
  • 便于批量管理和分佣规则配置

表设计:

-- 套餐系列表
CREATE TABLE package_series (
    id BIGSERIAL PRIMARY KEY,
    series_name VARCHAR(255) NOT NULL,     -- 系列名称
    series_code VARCHAR(100) UNIQUE,       -- 系列编码
    description TEXT,                      -- 描述
    status INT DEFAULT 1,                  -- 状态 1-启用 2-禁用
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- 套餐表增加 series_id 字段
ALTER TABLE packages ADD COLUMN series_id BIGINT;

说明: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)

替代方案:

  • 不设计套餐系列:需要为每个套餐单独配置分佣规则,维护成本高

决策 11: 资产分配批量操作

选择: 设计批量资产分配接口,支持设备批量分配和 IoT 卡批量分配。

理由:

  • 代理商提货时通常批量分配大量 IoT 卡或设备
  • IoT 卡如果绑定了设备,分配时需要连同设备一起分配
  • 批量操作提高效率

业务规则:

  • 设备批量分配: 只分配设备,不影响设备绑定的 IoT 卡所有权
  • IoT 卡批量分配: 分配 IoT 卡,如果 IoT 卡有设备信息(device_id),则设备和 IoT 卡一起分配
  • 批量分配时需要校验数量和权限

表设计影响:

  • 复用现有的 iot_cardsdevices 表的 owner_typeowner_id 字段
  • 批量操作通过 Service 层事务处理

决策 12: 换卡申请管理

选择: 设计换卡申请表,记录客户的换卡请求和处理流程。

理由:

  • 客户的 IoT 卡损坏或丢失时需要换卡
  • 需要审批流程和旧卡/新卡 ICCID 映射
  • 换卡后需要转移套餐和流量余额

表设计:

-- 换卡申请表
CREATE TABLE card_replacement_requests (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,               -- 申请用户 ID
    old_iccid VARCHAR(50) NOT NULL,        -- 旧卡 ICCID
    new_iccid VARCHAR(50),                 -- 新卡 ICCID(审批时填充)
    reason TEXT,                           -- 换卡原因
    status INT DEFAULT 1,                  -- 状态 1-待处理 2-已通过 3-已拒绝 4-已完成
    approved_by BIGINT,                    -- 处理人用户 ID
    approved_at TIMESTAMP,                 -- 处理时间
    completed_at TIMESTAMP,                -- 完成时间(新卡激活时间)
    reject_reason TEXT,                    -- 拒绝原因
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 不设计换卡流程:客户无法自助换卡,需要人工处理,效率低

决策 13: 开发能力管理

选择: 设计开发能力管理表,存储 API 对接参数(AppID、AppSecret、回调地址等)。

理由:

  • 代理或平台需要通过 API 对接系统
  • 需要管理 API 凭证和回调配置
  • 支持多个应用(多套 AppID/AppSecret)

表设计:

-- 开发能力配置表
CREATE TABLE dev_capability_configs (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,               -- 用户 ID(平台或代理)
    app_name VARCHAR(255),                 -- 应用名称
    app_id VARCHAR(100) UNIQUE,            -- 应用 ID
    app_secret VARCHAR(255),               -- 应用密钥
    callback_url VARCHAR(500),             -- 回调地址
    ip_whitelist TEXT,                     -- IP 白名单(多个 IP 用逗号分隔)
    status INT DEFAULT 1,                  -- 状态 1-启用 2-禁用
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 不设计开发能力管理:无法支持 API 对接,限制系统扩展性

决策 14: 收款商户设置

选择: 设计收款商户设置表,存储代理的收款账户信息。

理由:

  • 代理提现时需要指定收款账户
  • 支持多种收款方式(支付宝、微信、银行卡)
  • 需要验证账户信息的真实性

表设计:

-- 收款商户设置表
CREATE TABLE payment_merchant_settings (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,               -- 用户 ID
    merchant_type VARCHAR(20),             -- 商户类型 "alipay" | "wechat" | "bank"
    account_name VARCHAR(255),             -- 账户名称
    account_number VARCHAR(255),           -- 账号
    bank_name VARCHAR(255),                -- 银行名称(仅银行卡)
    bank_branch VARCHAR(255),              -- 开户行(仅银行卡)
    is_verified BOOLEAN DEFAULT FALSE,     -- 是否已验证
    is_default BOOLEAN DEFAULT FALSE,      -- 是否默认账户
    status INT DEFAULT 1,                  -- 状态 1-启用 2-禁用
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

替代方案:

  • 每次提现时填写账户信息:重复录入,用户体验差

决策 15: IoT 卡轮询机制和流量管理

选择: 设计三个独立的轮询流程和相关的数据表支持卡流量监控和套餐流量管理。

理由:

  • IoT 卡需要轮询实名状态,实名后降低轮询频率
  • IoT 卡需要轮询流量使用情况,防止超额
  • 设备级套餐需要汇总设备所有卡的流量,判断是否超过套餐额度
  • 卡的流量轮询和套餐流量检查应该是两个独立的逻辑
  • 支持细粒度的轮询配置(按运营商、按卡状态配置不同的轮询策略)
  • 需要记录流量历史,便于查询和分析

新增表设计:

  1. 运营商表 (carriers):
CREATE TABLE carriers (
    id BIGSERIAL PRIMARY KEY,
    carrier_code VARCHAR(50) UNIQUE NOT NULL,  -- 运营商编码(CMCC/CUCC/CTCC)
    carrier_name VARCHAR(100) NOT NULL,        -- 运营商名称(中国移动/中国联通/中国电信)
    description VARCHAR(500),                  -- 运营商描述
    status INT NOT NULL DEFAULT 1,             -- 状态 1-启用 2-禁用
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- 初始数据
INSERT INTO carriers (carrier_code, carrier_name, status) VALUES
('CMCC', '中国移动', 1),
('CUCC', '中国联通', 1),
('CTCC', '中国电信', 1);
  1. 套餐使用情况表 (package_usages):
CREATE TABLE package_usages (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL,                  -- 订单 ID
    package_id BIGINT NOT NULL,                -- 套餐 ID
    usage_type VARCHAR(20) NOT NULL,           -- 使用类型 single_card-单卡套餐 device-设备级套餐
    iot_card_id BIGINT,                        -- IoT 卡 ID(单卡套餐时有值)
    device_id BIGINT,                          -- 设备 ID(设备级套餐时有值)
    data_limit_mb BIGINT NOT NULL,             -- 流量限额(MB)
    data_usage_mb BIGINT DEFAULT 0,            -- 已使用流量(MB)
    real_data_usage_mb BIGINT DEFAULT 0,       -- 真流量使用(MB)
    virtual_data_usage_mb BIGINT DEFAULT 0,    -- 虚流量使用(MB)
    activated_at TIMESTAMP NOT NULL,           -- 套餐生效时间
    expires_at TIMESTAMP NOT NULL,             -- 套餐过期时间
    status INT NOT NULL DEFAULT 1,             -- 状态 1-生效中 2-已用完 3-已过期
    last_package_check_at TIMESTAMP,           -- 最后一次套餐流量检查时间
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_package_usages_order ON package_usages(order_id);
CREATE INDEX idx_package_usages_package ON package_usages(package_id);
CREATE INDEX idx_package_usages_iot_card ON package_usages(iot_card_id);
CREATE INDEX idx_package_usages_device ON package_usages(device_id);
CREATE INDEX idx_package_usages_check ON package_usages(status, expires_at, last_package_check_at);
  1. 轮询配置表 (polling_configs):
CREATE TABLE polling_configs (
    id BIGSERIAL PRIMARY KEY,
    config_name VARCHAR(100) UNIQUE NOT NULL,  -- 配置名称(如 未实名卡、实名卡)
    description VARCHAR(500),                  -- 配置描述
    card_condition VARCHAR(50),                -- 卡状态条件(not_real_name | real_name | activated | suspended)
    carrier_id BIGINT,                         -- 运营商 ID(NULL 表示所有运营商)
    real_name_check_enabled BOOLEAN DEFAULT false,     -- 是否启用实名检查
    real_name_check_interval INT DEFAULT 60,           -- 实名检查间隔(秒)
    card_data_check_enabled BOOLEAN DEFAULT false,     -- 是否启用卡流量检查
    card_data_check_interval INT DEFAULT 60,           -- 卡流量检查间隔(秒)
    package_check_enabled BOOLEAN DEFAULT false,       -- 是否启用套餐流量检查
    package_check_interval INT DEFAULT 60,             -- 套餐流量检查间隔(秒)
    priority INT NOT NULL DEFAULT 100,                 -- 优先级(数字越小优先级越高)
    status INT NOT NULL DEFAULT 1,                     -- 状态 1-启用 2-禁用
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_polling_configs_match ON polling_configs(status, card_condition, carrier_id, priority);
  1. 流量使用记录表 (data_usage_records):
CREATE TABLE data_usage_records (
    id BIGSERIAL PRIMARY KEY,
    iot_card_id BIGINT NOT NULL,               -- IoT 卡 ID
    data_usage_mb BIGINT NOT NULL,             -- 流量使用量(MB)
    data_increase_mb BIGINT DEFAULT 0,         -- 相比上次的增量(MB)
    check_time TIMESTAMP NOT NULL,             -- 检查时间
    source VARCHAR(50) DEFAULT 'polling',      -- 数据来源(polling-轮询 manual-手动 gateway-回调)
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_data_usage_records_card_time ON data_usage_records(iot_card_id, check_time DESC);
CREATE INDEX idx_data_usage_records_time ON data_usage_records(check_time);

IoT 卡表调整:

-- 添加以下字段
carrier_id BIGINT NOT NULL,                    -- 运营商 ID(关联 carriers 表)
enable_polling BOOLEAN DEFAULT true,           -- 是否参与轮询(true-参与 false-不参与)
last_data_check_at TIMESTAMP,                  -- 最后一次流量检查时间
last_real_name_check_at TIMESTAMP,             -- 最后一次实名检查时间

-- 添加索引
CREATE INDEX idx_iot_cards_carrier ON iot_cards(carrier_id);
CREATE INDEX idx_iot_cards_data_check ON iot_cards(enable_polling, activation_status, last_data_check_at);
CREATE INDEX idx_iot_cards_real_name_check ON iot_cards(enable_polling, real_name_status, last_real_name_check_at);

轮询逻辑设计:

  1. 实名状态轮询:

    • 查询需要检查实名的卡(根据 polling_configs 匹配条件)
    • 调用 Gateway API 获取卡的实名状态
    • 更新 iot_cards.real_name_status 和 last_real_name_check_at
    • 实名通过后降低轮询频率(通过配置表实现梯度策略)
  2. 卡流量轮询:

    • 只轮询有生效套餐的卡(通过 package_usages 表 JOIN 查询)
    • 卡必须 enable_polling = true
    • 调用 Gateway API 获取卡的实时流量
    • 更新 iot_cards.data_usage_mb 和 last_data_check_at
    • 插入流量使用记录到 data_usage_records 表
  3. 套餐流量检查:

    • 查询需要检查的套餐使用记录(status = 1 且未过期)
    • 单卡套餐:直接读取关联卡的 data_usage_mb
    • 设备级套餐:汇总设备所有卡的 data_usage_mb
    • 更新 package_usages.data_usage_mb 和 last_package_check_at
    • 判断是否超额(data_usage_mb >= data_limit_mb)
    • 如果超额:调用 Gateway 停机(单卡停单卡,设备停所有卡)

配置示例:

┌─────┬──────────────┬───────────────┬─────────────┬──────────┬──────────┬────────────┬────────────┬──────────┬──────────┬────────┐
│ ID  │   配置名称   │    卡状态     │  运营商 ID  │ 实名检查 │ 实名间隔 │ 卡流量检查 │ 卡流量间隔 │ 套餐检查 │ 套餐间隔 │ 优先级 │
├─────┼──────────────┼───────────────┼─────────────┼──────────┼──────────┼────────────┼────────────┼──────────┼──────────┼────────┤
│ 1   │ 未实名移动卡 │ not_real_name │ 1 (移动)    │ ✅       │ 60秒     │ ❌         │ -          │ ❌       │ -        │ 10     │
├─────┼──────────────┼───────────────┼─────────────┼──────────┼──────────┼────────────┼────────────┼──────────┼──────────┼────────┤
│ 2   │ 未实名联通卡 │ not_real_name │ 2 (联通)    │ ✅       │ 120秒    │ ❌         │ -          │ ❌       │ -        │ 11     │
├─────┼──────────────┼───────────────┼─────────────┼──────────┼──────────┼────────────┼────────────┼──────────┼──────────┼────────┤
│ 3   │ 实名卡-通用  │ real_name     │ NULL (所有) │ ✅       │ 3600秒   │ ✅         │ 60秒       │ ✅       │ 60秒     │ 20     │
└─────┴──────────────┴───────────────┴─────────────┴──────────┴──────────┴────────────┴────────────┴──────────┴──────────┴────────┘

业务优势:

  • 套餐为核心:所有流量业务围绕 package_usages 表,清晰明确
  • 灵活的轮询配置:通过 polling_configs 表动态配置,不需要改代码
  • 梯度配置:未实名卡和实名卡使用不同的轮询策略
  • 细粒度控制:支持按运营商配置,支持手动禁用特定卡的轮询
  • 流量历史:data_usage_records 表记录所有流量检查历史,便于分析
  • 性能优化:只轮询有套餐的卡,通过 enable_polling 避免无效轮询
  • 独立流程:实名轮询、卡流量轮询、套餐流量检查三个独立流程,互不干扰

数据保留策略:

  • 流量使用记录表(data_usage_records)数据量会快速增长
  • 建议定期清理 90 天前的记录,或使用 PostgreSQL 分区表

替代方案:

  • 在设备表直接跟踪流量:设备和卡的逻辑应该独立,套餐才是业务核心
  • 不区分卡流量轮询和套餐流量检查:混在一起会导致逻辑复杂,难以维护
  • 使用固定的轮询频率:无法支持梯度策略,无法针对不同运营商优化