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:
2026-01-13 16:52:37 +08:00
parent 6e2dc325d7
commit 2570269c8d
18 changed files with 3145 additions and 41 deletions

173
README.md
View File

@@ -8,6 +8,179 @@
**技术栈**Fiber + GORM + Viper + Zap + Lumberjack.v2 + Validator + sonic JSON + Asynq + PostgreSQL
---
## 核心业务说明
### 业务模式概览
君鸿卡管系统是一个物联网卡和号卡的全生命周期管理平台,支持三种客户类型和两种组织实体的多租户管理。
### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 |
|---------|---------|---------|---------|
| **企业客户** | B端大客户公对公支付 | 企业购买大量卡/设备用于业务运营 | ❌ 无钱包(后台直接分配套餐) |
| **个人客户** | C端用户微信登录 | 个人购买单卡或设备含1-4张卡 | ✅ 钱包归属**卡/设备**(支持转手) |
| **代理商** | 渠道分销商,层级管理 | 预存款采购套餐,按成本价+加价销售 | ✅ 钱包归属**店铺**(多账号共享) |
### 个人客户业务流程
```
┌─────────────────────────────────────────────────────────────────┐
│ 个人客户使用流程 │
└──────────────────────────┬──────────────────────────────────────┘
┌──────────▼──────────┐
│ 1. 获得卡/设备 │
│ - 单卡ICCID │
│ - 设备:设备号/IMEI │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 2. 微信扫码登录 │
│ - 输入ICCID/IMEI │
│ - 首次需绑定手机号 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 3. 查看卡/设备信息 │
│ - 流量使用情况 │
│ - 套餐有效期 │
│ - 钱包余额 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 4. 钱包充值 │
│ - 微信支付 │
│ - 支付宝支付 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 5. 购买套餐 │
│ - 单卡套餐 │
│ - 设备套餐(共享) │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 6. 卡/设备转手 │
│ - 新用户扫码登录 │
│ - 钱包余额跟着走 │
└─────────────────────┘
```
### 钱包归属设计
#### 为什么钱包绑定资源(卡/设备)而非用户?
**问题场景**
```
个人客户 A 购买单卡 → 充值 100 元 → 使用 50 元 → 转手给个人客户 B
```
**如果钱包绑定用户**
- ❌ 个人客户 B 登录后看不到余额(钱包还在 A 账号下)
- ❌ 需要手动转账或退款,体验极差
**钱包绑定资源(当前设计)**
- ✅ 个人客户 B 登录后看到剩余 50 元(钱包跟着卡走)
- ✅ 无需任何额外操作,自然流转
#### 钱包归属规则
```go
// 钱包模型
type Wallet struct {
ResourceType string // iot_card | device | shop
ResourceID uint // 资源ID
Balance int64 // 余额(分)
// ...
}
// 场景1个人客户的单卡钱包
resource_type = "iot_card"
resource_id = 101 // 卡ID
// 场景2个人客户的设备钱包3张卡共享
resource_type = "device"
resource_id = 1001 // 设备ID
// 场景3代理商店铺钱包多账号共享
resource_type = "shop"
resource_id = 10 // 店铺ID
```
### 设备套餐业务规则
#### 设备级套餐购买
```
设备绑定 3 张 IoT 卡
├── 卡1ICCID-001
├── 卡2ICCID-002
└── 卡3ICCID-003
用户购买套餐399 元/年,每月 3000G 流量
├── 套餐分配3 张卡都获得该套餐
├── 流量共享3000G/月 在 3 张卡之间共享(总共 3000G
├── 用户支付399 元(一次性)
└── 代理分佣100 元(只分一次,不按卡数倍增)
```
**关键点**
- ✅ 套餐自动分配到设备的所有卡
- ✅ 流量是**设备级别共享**(非每卡独立)
- ✅ 分佣**只计算一次**(防止重复分佣)
### 标签系统多租户隔离
#### 三级隔离模型
| 标签类型 | 创建者 | 可见范围 | 名称唯一性 | 示例 |
|---------|-------|---------|-----------|------|
| 平台全局标签 | 平台管理员 | 所有用户 | 全局唯一 | "VIP"、"重要客户" |
| 企业标签 | 企业用户 | 仅该企业 | 企业内唯一 | 企业A的"测试标签" |
| 店铺标签 | 代理商 | 该店铺及下级 | 店铺内唯一 | 店铺10的"华东区" |
#### 隔离规则
```
企业 A 创建标签 "测试标签"
├── enterprise_id = 5, shop_id = NULL
├── 企业 A 的用户可见
└── 企业 B 的用户不可见
企业 B 创建标签 "测试标签"(允许)
├── enterprise_id = 8, shop_id = NULL
├── 企业 B 的用户可见
└── 与企业 A 的 "测试标签" 相互隔离
平台创建标签 "VIP"
├── enterprise_id = NULL, shop_id = NULL
└── 所有用户可见
```
#### 数据权限自动过滤
```go
// GORM Callback 自动注入过滤条件
switch userType {
case UserTypeAgent:
// 代理用户:只看到自己店铺及下级店铺的标签
db.Where("shop_id IN (?) OR (enterprise_id IS NULL AND shop_id IS NULL)", subordinateShopIDs)
case UserTypeEnterprise:
// 企业用户:只看到自己企业的标签
db.Where("enterprise_id = ? OR (enterprise_id IS NULL AND shop_id IS NULL)", enterpriseID)
default:
// 个人客户:只看到全局标签
db.Where("enterprise_id IS NULL AND shop_id IS NULL")
}
```
---
## 核心功能
- **认证中间件**:基于 Redis 的 Token 认证