Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-09-add-user-organization-model/design.md
huang a36e4a79c0 实现用户和组织模型(店铺、企业、个人客户)
核心功能:
- 实现 7 级店铺层级体系(Shop 模型 + 层级校验)
- 实现企业管理模型(Enterprise 模型)
- 实现个人客户管理模型(PersonalCustomer 模型)
- 重构 Account 模型关联关系(基于 EnterpriseID 而非 ParentID)
- 完整的 Store 层和 Service 层实现
- 递归查询下级店铺功能(含 Redis 缓存)
- 全面的单元测试覆盖(Shop/Enterprise/PersonalCustomer Store + Shop Service)

技术要点:
- 显式指定所有 GORM 模型的数据库字段名(column: 标签)
- 统一的字段命名规范(数据库用 snake_case,Go 用 PascalCase)
- 完整的中文字段注释和业务逻辑说明
- 100% 测试覆盖(20+ 测试用例全部通过)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:02:46 +08:00

8.2 KiB
Raw Blame History

Design: 用户和组织模型架构设计

Context

背景

系统需要支持以下四种用户类型和对应的登录端口:

用户类型 登录端口 组织归属 角色数量
平台用户 Web后台 无(平台级) 可分配多个角色
代理账号 Web后台 + H5 店铺 只能分配一种角色
企业账号 H5 企业 只能分配一种角色
个人客户 H5个人端 无角色无权限

组织层级关系

平台(系统)
├── 店铺A一级代理
│   ├── 店铺B二级代理最多7级
│   │   └── 企业X
│   └── 企业Y
├── 店铺C一级代理
│   └── ...
└── 企业Z平台直属企业

约束条件

  • 代理层级最多 7 级
  • 代理的上下级关系不可变更
  • 一个店铺多个账号(账号权限相同)
  • 一个企业目前只有一个账号
  • 个人客户独立表,不参与 RBAC 体系
  • 遵循项目的数据库设计原则:禁止外键、禁止 GORM 关联

Goals / Non-Goals

Goals

  1. 设计清晰的用户和组织模型,支持四种用户类型
  2. 建立店铺层级关系,支持 7 级代理
  3. 支持店铺、企业、个人客户的数据归属
  4. 为后续的角色权限体系打好基础
  5. 为后续的数据权限过滤打好基础

Non-Goals

  1. 本提案不实现角色权限体系(后续提案)
  2. 本提案不实现个人客户的微信登录(后续提案)
  3. 本提案不实现数据权限过滤逻辑(已在 004-rbac-data-permission 中定义)
  4. 本提案不处理资产绑定(未来功能)

Decisions

Decision 1: 账号统一存储 vs 分表存储

决策: 平台用户、代理账号、企业账号统一存储在 tb_account 表,个人客户独立存储在 tb_personal_customer 表。

理由:

  • 平台/代理/企业账号都参与 RBAC 体系,有相似的字段结构
  • 个人客户不参与 RBAC有独特的微信绑定需求
  • 统一存储便于账号管理和登录验证
  • 通过 user_type 字段区分账号类型

Decision 2: 代理层级关系的存储位置

决策: 代理层级关系存储在 tb_shop(店铺表)的 parent_id 字段,而非 tb_accountparent_id

理由:

  • 层级关系是店铺之间的关系,不是个人之间的关系
  • 一个店铺有多个账号,账号之间不应该有上下级关系
  • 现有 tb_account.parent_id 字段将重新定义用途或移除

变更:

  • tb_account.parent_id 字段移除或废弃
  • 新增 tb_shop.parent_id 表示店铺的上级店铺
  • 递归查询下级改为查询店铺的下级,而非账号的下级

Decision 3: 企业的归属关系

决策: 企业通过 owner_shop_id 字段表示归属于哪个店铺,NULL 表示平台直属。

理由:

  • 企业可以归属于任意级别的代理(店铺)
  • 企业也可以直接归属于平台
  • 上级代理能看到下级代理的企业数据

Decision 4: 账号与组织的关联方式

决策: 账号通过 shop_identerprise_id 字段关联到组织。

实现:

  • 平台用户:shop_id = NULL, enterprise_id = NULL
  • 代理账号:shop_id = 店铺ID, enterprise_id = NULL
  • 企业账号:shop_id = NULL, enterprise_id = 企业ID

Decision 5: 数据权限过滤的调整

决策: 数据权限过滤基于 shop_id(店铺归属)而非 owner_id(账号归属)。

理由:

  • 同一店铺的所有账号应该能看到店铺的所有数据
  • 上级店铺应该能看到下级店铺的数据
  • owner_id 字段保留用于记录数据的创建者(审计用途)

变更:

  • 递归查询改为查询店铺的下级店铺 ID 列表
  • 数据过滤条件改为 WHERE shop_id IN (当前店铺及下级店铺)
  • 平台用户(user_type = 1user_type = 2)跳过过滤

Data Models

Shop店铺

type Shop struct {
    gorm.Model
    BaseModel   `gorm:"embedded"`

    ShopName    string  `gorm:"not null;size:100"`     // 店铺名称
    ShopCode    string  `gorm:"uniqueIndex;size:50"`   // 店铺编号
    ParentID    *uint   `gorm:"index"`                 // 上级店铺IDNULL表示一级代理
    Level       int     `gorm:"not null;default:1"`    // 层级1-7
    ContactName string  `gorm:"size:50"`               // 联系人姓名
    ContactPhone string `gorm:"size:20"`               // 联系人电话
    Province    string  `gorm:"size:50"`               // 省份
    City        string  `gorm:"size:50"`               // 城市
    District    string  `gorm:"size:50"`               // 区县
    Address     string  `gorm:"size:255"`              // 详细地址
    Status      int     `gorm:"not null;default:1"`    // 状态 0=禁用 1=启用
}

Enterprise企业

type Enterprise struct {
    gorm.Model
    BaseModel       `gorm:"embedded"`

    EnterpriseName  string  `gorm:"not null;size:100"`  // 企业名称
    EnterpriseCode  string  `gorm:"uniqueIndex;size:50"` // 企业编号
    OwnerShopID     *uint   `gorm:"index"`              // 归属店铺IDNULL表示平台直属
    LegalPerson     string  `gorm:"size:50"`            // 法人代表
    ContactName     string  `gorm:"size:50"`            // 联系人姓名
    ContactPhone    string  `gorm:"size:20"`            // 联系人电话
    BusinessLicense string  `gorm:"size:100"`           // 营业执照号
    Province        string  `gorm:"size:50"`            // 省份
    City            string  `gorm:"size:50"`            // 城市
    District        string  `gorm:"size:50"`            // 区县
    Address         string  `gorm:"size:255"`           // 详细地址
    Status          int     `gorm:"not null;default:1"` // 状态 0=禁用 1=启用
}

PersonalCustomer个人客户

type PersonalCustomer struct {
    gorm.Model

    Phone       string  `gorm:"uniqueIndex;size:20"`   // 手机号(唯一标识)
    Nickname    string  `gorm:"size:50"`               // 昵称
    AvatarURL   string  `gorm:"size:255"`              // 头像URL
    WxOpenID    string  `gorm:"index;size:100"`        // 微信OpenID
    WxUnionID   string  `gorm:"index;size:100"`        // 微信UnionID
    Status      int     `gorm:"not null;default:1"`    // 状态 0=禁用 1=启用
}

Account账号- 修改

type Account struct {
    gorm.Model
    BaseModel    `gorm:"embedded"`

    Username     string  `gorm:"uniqueIndex;size:50"`  // 用户名
    Phone        string  `gorm:"uniqueIndex;size:20"`  // 手机号
    Password     string  `gorm:"not null;size:255" json:"-"` // 密码
    UserType     int     `gorm:"not null;index"`       // 用户类型 1=超级管理员 2=平台用户 3=代理账号 4=企业账号
    ShopID       *uint   `gorm:"index"`                // 店铺ID代理账号必填
    EnterpriseID *uint   `gorm:"index"`                // 企业ID企业账号必填
    Status       int     `gorm:"not null;default:1"`   // 状态 0=禁用 1=启用
    // 移除 ParentID 字段,层级关系由 Shop 表维护
}

Risks / Trade-offs

Risk 1: 现有数据迁移

  • 风险: 现有 tb_account.parent_id 字段被移除,可能影响现有数据
  • 缓解: 当前系统是空架子,无实际数据需要迁移

Risk 2: 数据权限过滤逻辑变更

  • 风险: 从 owner_id 过滤改为 shop_id 过滤,需要调整现有代码
  • 缓解: 现有的数据权限过滤尚未完全实现,可以直接按新设计实现

Risk 3: 店铺层级查询性能

  • 风险: 7 级店铺层级的递归查询可能影响性能
  • 缓解: 继续使用 Redis 缓存店铺的下级 ID 列表30 分钟过期

Migration Plan

  1. 创建新表:tb_shoptb_enterprisetb_personal_customer
  2. 修改 tb_account 表结构:
    • 添加 enterprise_id 字段
    • 移除 parent_id 字段(如果有数据则先迁移)
  3. 更新 GORM 模型定义
  4. 更新 Store 层实现
  5. 更新常量定义

Open Questions

  1. 店铺层级关系是否需要记录完整路径(如 /1/2/3/)以优化查询? - 暂不需要,使用递归查询 + Redis 缓存
  2. 企业账号未来扩展为多账号时,是否需要区分主账号和子账号? - 未来再设计