feat(wallet,tag): 钱包和标签系统多租户改造
核心变更: - 钱包表:删除 user_id,添加 resource_type/resource_id(绑定资源而非用户) - 标签表:添加 enterprise_id/shop_id(实现三级隔离:全局/企业/店铺) - GORM Callback:自动数据权限过滤 - 迁移脚本:可重复执行,已验证回滚功能 钱包归属重构原因: - 旧设计:钱包绑定用户账号,个人客户卡/设备转手后新用户无法使用余额 - 新设计:钱包绑定资源(卡/设备/店铺),余额随资源流转 标签三级隔离: - 平台全局标签:所有用户可见 - 企业标签:仅该企业可见(企业内唯一) - 店铺标签:该店铺及下级可见(店铺内唯一) 测试覆盖: - 9 个单元测试验证标签多租户过滤(全部通过) - 迁移和回滚功能测试通过(测试环境) - OpenSpec 验证通过 变更 ID: fix-wallet-tag-multi-tenant 迁移版本: 000008 参考: openspec/changes/archive/2026-01-13-fix-wallet-tag-multi-tenant/
This commit is contained in:
@@ -5,40 +5,42 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
|
||||
## Requirements
|
||||
### Requirement: 标签实体定义
|
||||
|
||||
系统 SHALL 定义标签(Tag)实体,用于为资源(设备、IoT卡、号卡)提供自定义标签分类功能。
|
||||
系统 SHALL 定义标签(Tag)实体,用于设备、IoT卡、号卡的分类标记,支持自定义颜色。
|
||||
|
||||
**核心概念**:
|
||||
- 企业用户可以为自己的设备/卡片创建和管理标签
|
||||
- 标签可以跨资源类型使用(一个标签可以同时用于设备和卡片)
|
||||
- 支持按标签查询和筛选资源
|
||||
|
||||
**实体字段**:
|
||||
**实体字段变更**:
|
||||
- `id`:标签 ID(主键,BIGINT)
|
||||
- `name`:标签名称(VARCHAR(100),唯一)
|
||||
- `color`:标签颜色(VARCHAR(20),可选,用于前端显示,如 "#FF5733")
|
||||
- `usage_count`:使用次数(INT,默认 0,记录有多少资源使用了该标签)
|
||||
- `name`:标签名称(VARCHAR(100),非全局唯一,按租户隔离)
|
||||
- `enterprise_id`:归属企业 ID(BIGINT,可空,NULL 表示非企业标签)(**新增**)
|
||||
- `shop_id`:归属店铺 ID(BIGINT,可空,NULL 表示非店铺标签)(**新增**)
|
||||
- `color`:标签颜色(VARCHAR(20),十六进制,可选)
|
||||
- `usage_count`:使用次数(INT,默认 0)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `updater`:更新人 ID(BIGINT)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**唯一约束**:`name` 在 `deleted_at IS NULL` 条件下唯一
|
||||
**唯一约束变更**:
|
||||
- 旧约束:`(name) WHERE deleted_at IS NULL`(**已删除**)
|
||||
- 新约束(三个独立约束):
|
||||
1. 企业标签:`(enterprise_id, name) WHERE deleted_at IS NULL AND enterprise_id IS NOT NULL`(**新增**)
|
||||
2. 店铺标签:`(shop_id, name) WHERE deleted_at IS NULL AND shop_id IS NOT NULL`(**新增**)
|
||||
3. 全局标签:`(name) WHERE deleted_at IS NULL AND enterprise_id IS NULL AND shop_id IS NULL`(**新增**)
|
||||
|
||||
#### Scenario: 创建标签
|
||||
#### Scenario: 创建企业标签
|
||||
|
||||
- **WHEN** 用户创建标签,名称为"生产设备",颜色为"#FF5733"
|
||||
- **THEN** 系统创建标签记录,`name` 为 "生产设备",`color` 为 "#FF5733",`usage_count` 为 0
|
||||
- **WHEN** 企业用户(企业 ID 为 5)创建标签"重要客户",颜色为 "#FF0000"
|
||||
- **THEN** 系统创建标签记录,`enterprise_id` 为 5,`shop_id` 为 NULL,`name` 为 "重要客户",`color` 为 "#FF0000",`usage_count` 为 0
|
||||
|
||||
#### Scenario: 标签名称重复
|
||||
#### Scenario: 创建店铺标签
|
||||
|
||||
- **WHEN** 用户创建标签,名称为已存在的"生产设备"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"标签名称已存在"
|
||||
- **WHEN** 代理用户(店铺 ID 为 10)创建标签"华东区",颜色为 "#00FF00"
|
||||
- **THEN** 系统创建标签记录,`enterprise_id` 为 NULL,`shop_id` 为 10,`name` 为 "华东区",`color` 为 "#00FF00",`usage_count` 为 0
|
||||
|
||||
#### Scenario: 更新标签
|
||||
#### Scenario: 创建全局标签
|
||||
|
||||
- **WHEN** 用户更新标签(ID 为 101),将颜色从"#FF5733"改为"#33FF57"
|
||||
- **THEN** 系统更新标签记录,`color` 为 "#33FF57",`updated_at` 为当前时间
|
||||
- **WHEN** 平台管理员创建标签"VIP",颜色为 "#FFD700"
|
||||
- **THEN** 系统创建标签记录,`enterprise_id` 为 NULL,`shop_id` 为 NULL,`name` 为 "VIP",`color` 为 "#FFD700",`usage_count` 为 0
|
||||
|
||||
---
|
||||
|
||||
@@ -220,3 +222,55 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
|
||||
- **WHEN** 用户为设备添加标签,`tag_id` 为 99999(不存在的标签)
|
||||
- **THEN** 系统拒绝操作,返回错误信息"标签不存在"
|
||||
|
||||
### Requirement: 资源标签关联隔离
|
||||
|
||||
系统 SHALL 在资源-标签关联表中添加隔离字段,防止跨租户打标签操作。
|
||||
|
||||
**ResourceTag 实体字段变更**:
|
||||
- `id`:关联记录 ID(主键,BIGINT)
|
||||
- `resource_type`:资源类型(VARCHAR(20),"device" | "iot_card" | "number_card")
|
||||
- `resource_id`:资源 ID(BIGINT)
|
||||
- `tag_id`:标签 ID(BIGINT)
|
||||
- `enterprise_id`:归属企业 ID(BIGINT,可空,从资源所有者推断)(**新增**)
|
||||
- `shop_id`:归属店铺 ID(BIGINT,可空,从资源所有者推断)(**新增**)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `updater`:更新人 ID(BIGINT)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**隔离字段推断规则**:
|
||||
- 如果资源 `owner_type` = "user",查找用户的 `enterprise_id`,设置 `enterprise_id`
|
||||
- 如果资源 `owner_type` = "agent",查找代理的 `shop_id`,设置 `shop_id`
|
||||
- 如果资源 `owner_type` = "platform",设置 `enterprise_id` 和 `shop_id` 都为 NULL
|
||||
|
||||
**权限控制规则**:
|
||||
- 企业用户只能为自己企业的资源打标签
|
||||
- 代理用户只能为自己店铺及下级店铺的资源打标签
|
||||
- 平台用户可以为所有资源打标签
|
||||
|
||||
#### Scenario: 企业用户为自己的设备打标签
|
||||
|
||||
- **WHEN** 企业 A(企业 ID 为 5)的用户为企业 A 的设备(设备 ID 为 101)打标签"重要设备"
|
||||
- **THEN** 系统创建资源标签关联记录,`resource_type` 为 "device",`resource_id` 为 101,`tag_id` 为标签 ID,`enterprise_id` 为 5,`shop_id` 为 NULL
|
||||
|
||||
#### Scenario: 企业用户尝试为其他企业的设备打标签
|
||||
|
||||
- **WHEN** 企业 A(企业 ID 为 5)的用户尝试为企业 B(企业 ID 为 8)的设备(设备 ID 为 201)打标签
|
||||
- **THEN** 系统检测到权限不足,拒绝操作,返回错误信息"无权为该资源打标签"
|
||||
|
||||
#### Scenario: 代理用户为自己店铺的设备打标签
|
||||
|
||||
- **WHEN** 代理商(店铺 ID 为 10)的用户为店铺 10 的设备(设备 ID 为 301)打标签"华东区设备"
|
||||
- **THEN** 系统创建资源标签关联记录,`resource_type` 为 "device",`resource_id` 为 301,`tag_id` 为标签 ID,`enterprise_id` 为 NULL,`shop_id` 为 10
|
||||
|
||||
#### Scenario: 代理用户尝试为其他店铺的设备打标签
|
||||
|
||||
- **WHEN** 代理商(店铺 ID 为 10)的用户尝试为店铺 20 的设备(设备 ID 为 401)打标签
|
||||
- **THEN** 系统检测到权限不足,拒绝操作,返回错误信息"无权为该资源打标签"
|
||||
|
||||
#### Scenario: 平台用户为任意资源打标签
|
||||
|
||||
- **WHEN** 平台管理员为任意资源(设备 ID 为 501)打标签"VIP"
|
||||
- **THEN** 系统创建资源标签关联记录,`resource_type` 为 "device",`resource_id` 为 501,`tag_id` 为标签 ID,`enterprise_id` 和 `shop_id` 根据资源所有者推断
|
||||
|
||||
|
||||
@@ -177,27 +177,78 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
|
||||
|
||||
系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- `user_id`:必填,≥ 1
|
||||
- `wallet_type`:必填,枚举值 "user" | "agent"
|
||||
**校验规则变更**:
|
||||
- ~~`user_id`~~:~~必填,≥ 1~~(**已删除**)
|
||||
- `resource_type`:必填,枚举值 "iot_card" | "device" | "shop"(**新增**)
|
||||
- `resource_id`:必填,≥ 1,必须是有效的资源 ID(**新增**)
|
||||
- `wallet_type`:必填,枚举值 "main" | "commission"
|
||||
- `balance`:必填,≥ 0
|
||||
- `frozen_balance`:必填,≥ 0,≤ balance
|
||||
- `currency`:必填,长度 1-10 字符
|
||||
- `status`:必填,枚举值 1-3
|
||||
- `version`:必填,≥ 0
|
||||
|
||||
#### Scenario: 创建钱包时 user_id 无效
|
||||
#### Scenario: 创建钱包时 resource_type 无效
|
||||
|
||||
- **WHEN** 创建钱包,`user_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"用户 ID 无效"
|
||||
- **WHEN** 创建钱包,`resource_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card、device 或 shop"
|
||||
|
||||
#### Scenario: 创建钱包时 wallet_type 无效
|
||||
#### Scenario: 创建钱包时 resource_id 无效
|
||||
|
||||
- **WHEN** 创建钱包,`wallet_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效"
|
||||
- **WHEN** 创建钱包,`resource_type` 为 "iot_card",`resource_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
|
||||
|
||||
#### Scenario: 冻结余额超过总余额
|
||||
|
||||
- **WHEN** 钱包余额为 10000 分,尝试冻结 15000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||
|
||||
### Requirement: 钱包归属资源规则
|
||||
|
||||
系统 SHALL 根据资源类型管理钱包归属,支持个人客户卡/设备转手和代理商店铺级别管理。
|
||||
|
||||
**归属规则**:
|
||||
|
||||
| 资源类型 | ResourceType | 适用场景 | 说明 |
|
||||
|---------|-------------|---------|------|
|
||||
| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 |
|
||||
| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包 |
|
||||
| 店铺 | shop | 代理商预存款 | 钱包归属店铺,店铺的多个员工账号共享钱包 |
|
||||
|
||||
**资源转手规则**:
|
||||
- 物联网卡转手:新用户登录后可以看到卡的钱包余额
|
||||
- 设备转手:新用户登录后可以看到设备的钱包余额(包含绑定的所有卡)
|
||||
- 店铺钱包:不支持转手,归属店铺不变
|
||||
|
||||
#### Scenario: 个人客户购买单卡并充值
|
||||
|
||||
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
|
||||
- **THEN** 系统创建钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000
|
||||
|
||||
#### Scenario: 个人客户购买设备并充值
|
||||
|
||||
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
|
||||
- **THEN** 系统创建钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包
|
||||
|
||||
#### Scenario: 卡转手后新用户查询余额
|
||||
|
||||
- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),卡钱包余额为 5000 分
|
||||
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
|
||||
|
||||
#### Scenario: 设备转手后新用户查询余额
|
||||
|
||||
- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B,设备钱包余额为 15000 分
|
||||
- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分,3 张卡共享该余额
|
||||
|
||||
#### Scenario: 代理商店铺钱包充值
|
||||
|
||||
- **WHEN** 代理商(店铺 ID 为 10)充值 50000 分
|
||||
- **THEN** 系统创建或更新钱包记录,`resource_type` 为 "shop",`resource_id` 为 10,`balance` 增加 50000 分
|
||||
|
||||
#### Scenario: 代理商店铺的多个员工账号共享钱包
|
||||
|
||||
- **WHEN** 代理商店铺(店铺 ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺钱包余额为 50000 分
|
||||
- **THEN** 3 个员工账号登录后查询店铺钱包,余额都是 50000 分,可以共享使用
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user