# 钱包和标签系统多租户改造 - 技术设计文档

## Context

### 业务背景

系统支持三种客户类型，钱包和标签的使用场景各不相同：

1. **企业客户**：无钱包，公对公支付，后台直接为企业的卡购买套餐
2. **个人客户**：通过 ICCID/IMEI 登录，可能购买单卡或设备（含1-4张卡），卡/设备可以转手给其他微信用户
3. **代理商**：预存款到店铺钱包，用于采购套餐，分佣收入进入单独的分佣钱包

### 现有问题

**钱包系统**：
- 当前 `tb_wallet.user_id` 绑定用户，但个人客户场景下卡/设备可能转手，导致钱包归属错误
- 代理商钱包绑定账号，但业务上应该绑定店铺（支持店铺级别管理）

**标签系统**：
- 标签表全局唯一，企业 A 和企业 B 的标签混在一起
- 缺少数据权限过滤，存在数据泄露和权限绕过风险

---

## Goals / Non-Goals

### Goals

1. **钱包归属重构**：钱包绑定到资源（卡/设备/店铺），支持资源转手场景
2. **标签多租户隔离**：企业/店铺/平台三级标签隔离，自动数据权限过滤
3. **数据迁移平滑**：提供完整的数据迁移脚本和回滚方案
4. **保持审计能力**：钱包交易记录保留 `user_id` 字段用于审计追踪

### Non-Goals

1. **不实现钱包 Service 层**：本次变更只修复模型设计，Service 层后续实现
2. **不实现标签 Service 层**：本次变更只修复模型设计，Service 层后续实现
3. **不处理分佣钱包**：分佣钱包设计后续讨论，本次变更仅处理主钱包

---

## Decisions

### 决策 1：钱包归属多态设计

**决策**：使用 `resource_type + resource_id` 的多态设计替代 `user_id`。

**理由**：
- ✅ 支持资源转手场景（钱包跟着资源走）
- ✅ 统一处理卡钱包、设备钱包、店铺钱包
- ✅ 符合系统中其他多态设计（如 `IotCard.owner_type + owner_id`）

**备选方案**：
- ❌ 方案A：保留 `user_id`，添加 `resource_type + resource_id`（冗余字段过多）
- ❌ 方案B：为卡、设备、店铺分别创建钱包表（表结构重复，维护成本高）

**ResourceType 取值**：
```go
const (
    WalletResourceTypeIotCard = "iot_card"  // 个人客户的卡钱包
    WalletResourceTypeDevice  = "device"    // 个人客户的设备钱包（多卡共享）
    WalletResourceTypeShop    = "shop"      // 代理商店铺钱包
)
```

### 决策 2：标签三级隔离模型

**决策**：通过 `enterprise_id` 和 `shop_id` 字段实现三级隔离。

**三级隔离规则**：
```
Level 1: 平台全局标签
- enterprise_id = NULL AND shop_id = NULL
- 所有用户可见

Level 2: 企业标签
- enterprise_id = 企业ID AND shop_id = NULL
- 仅该企业可见

Level 3: 店铺标签
- enterprise_id = NULL AND shop_id = 店铺ID
- 该店铺及下级店铺可见
```

**理由**：
- ✅ 支持企业、店铺、平台三种标签归属
- ✅ 通过 GORM Callback 自动过滤，无需手动添加条件
- ✅ 灵活性高，后续可扩展个人标签

**备选方案**：
- ❌ 方案A：使用 `scope + scope_id`（字段名不够直观，查询复杂）
- ❌ 方案B：为企业、店铺分别创建标签表（表结构重复）

### 决策 3：保留钱包交易记录的 user_id

**决策**：`tb_wallet_transaction` 保留 `user_id` 字段，不做变更。

**理由**：
- ✅ 用于审计追踪（记录操作人）
- ✅ 避免历史数据迁移
- ✅ 交易记录不需要按资源查询，只需要按钱包ID或用户ID查询

**字段含义变更**：
- 旧含义：钱包所有者
- 新含义：交易操作人（充值、扣费、退款等操作的发起人）

### 决策 4：资源标签关联表添加隔离字段

**决策**：`tb_resource_tag` 添加 `enterprise_id` 和 `shop_id` 字段。

**理由**：
- ✅ 防止跨租户打标签（如企业 A 的用户为企业 B 的设备打标签）
- ✅ 支持按租户统计标签使用情况
- ✅ 数据权限过滤更精确

**字段设置规则**：
- 创建资源标签时，从 **资源的所有者** 推断 `enterprise_id` 或 `shop_id`
- 如果资源 `owner_type=user`，查找用户的 `enterprise_id`
- 如果资源 `owner_type=agent`，查找代理的 `shop_id`
- 如果资源 `owner_type=platform`，设置为 NULL（全局）

---

## Technical Design

### 1. 数据库表结构变更

#### tb_wallet (钱包表)

**变更前**：
```sql
CREATE TABLE tb_wallet (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,  -- 删除
    wallet_type VARCHAR(20) NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0,
    frozen_balance BIGINT NOT NULL DEFAULT 0,
    currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
    status INT NOT NULL DEFAULT 1,
    version INT NOT NULL DEFAULT 0,
    creator BIGINT,
    updater BIGINT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP,
    CONSTRAINT idx_wallet_user_type_currency UNIQUE (user_id, wallet_type, currency) WHERE deleted_at IS NULL
);
```

**变更后**：
```sql
CREATE TABLE tb_wallet (
    id BIGSERIAL PRIMARY KEY,
    resource_type VARCHAR(20) NOT NULL,  -- 新增
    resource_id BIGINT NOT NULL,         -- 新增
    wallet_type VARCHAR(20) NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0,
    frozen_balance BIGINT NOT NULL DEFAULT 0,
    currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
    status INT NOT NULL DEFAULT 1,
    version INT NOT NULL DEFAULT 0,
    creator BIGINT,
    updater BIGINT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP,
    CONSTRAINT idx_wallet_resource_type_currency UNIQUE (resource_type, resource_id, wallet_type, currency) WHERE deleted_at IS NULL
);

-- 索引
CREATE INDEX idx_wallet_resource ON tb_wallet (resource_type, resource_id, deleted_at);
CREATE INDEX idx_wallet_status ON tb_wallet (status, deleted_at);
```

#### tb_tag (标签表)

**变更**：
```sql
ALTER TABLE tb_tag 
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;

-- 索引
CREATE INDEX idx_tag_enterprise ON tb_tag (enterprise_id, deleted_at);
CREATE INDEX idx_tag_shop ON tb_tag (shop_id, deleted_at);

-- 删除旧唯一约束
DROP INDEX idx_tag_name;

-- 新唯一约束
CREATE UNIQUE INDEX idx_tag_enterprise_name 
ON tb_tag (enterprise_id, name) 
WHERE deleted_at IS NULL AND enterprise_id IS NOT NULL;

CREATE UNIQUE INDEX idx_tag_shop_name 
ON tb_tag (shop_id, name) 
WHERE deleted_at IS NULL AND shop_id IS NOT NULL;

CREATE UNIQUE INDEX idx_tag_global_name 
ON tb_tag (name) 
WHERE deleted_at IS NULL AND enterprise_id IS NULL AND shop_id IS NULL;
```

#### tb_resource_tag (资源标签关联表)

**变更**：
```sql
ALTER TABLE tb_resource_tag 
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;

-- 索引
CREATE INDEX idx_resource_tag_enterprise ON tb_resource_tag (enterprise_id, deleted_at);
CREATE INDEX idx_resource_tag_shop ON tb_resource_tag (shop_id, deleted_at);
```

---

### 2. Go 模型变更

#### internal/model/wallet.go

```go
// Wallet 钱包模型
// 用户和代理的资金账户，支持充值、消费、提现等操作
// 使用乐观锁（version字段）防止并发余额冲突
type Wallet struct {
	gorm.Model
	BaseModel     `gorm:"embedded"`
	
	// 钱包归属资源（多态设计）
	ResourceType  string `gorm:"column:resource_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:1;comment:资源类型 iot_card-物联网卡 device-设备 shop-店铺"`
	ResourceID    uint   `gorm:"column:resource_id;not null;uniqueIndex:idx_wallet_resource_type_currency,priority:2;index:idx_wallet_resource,priority:2;comment:资源ID"`
	
	WalletType    string `gorm:"column:wallet_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:3;comment:钱包类型 main-主钱包 commission-分佣钱包"`
	Balance       int64  `gorm:"column:balance;type:bigint;not null;default:0;comment:余额（分）"`
	FrozenBalance int64  `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额（分）"`
	Currency      string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';uniqueIndex:idx_wallet_resource_type_currency,priority:4;comment:币种"`
	Status        int    `gorm:"column:status;type:int;not null;default:1;index:idx_wallet_status;comment:钱包状态 1-正常 2-冻结 3-关闭"`
	Version       int    `gorm:"column:version;type:int;not null;default:0;comment:版本号（乐观锁）"`
}
```

#### internal/model/tag.go

```go
// Tag 标签模型
// 用于设备、IoT卡、号卡的分类标记，支持自定义颜色
// 支持企业、店铺、平台三级隔离
type Tag struct {
	gorm.Model
	BaseModel    `gorm:"embedded"`
	Name         string  `gorm:"column:name;type:varchar(100);not null;comment:标签名称"`
	EnterpriseID *uint   `gorm:"column:enterprise_id;index:idx_tag_enterprise;uniqueIndex:idx_tag_enterprise_name,priority:1;comment:归属企业ID（NULL表示非企业标签）"`
	ShopID       *uint   `gorm:"column:shop_id;index:idx_tag_shop;uniqueIndex:idx_tag_shop_name,priority:1;comment:归属店铺ID（NULL表示非店铺标签）"`
	Color        *string `gorm:"column:color;type:varchar(20);comment:标签颜色（十六进制）"`
	UsageCount   int     `gorm:"column:usage_count;type:int;not null;default:0;index:idx_tag_usage;comment:使用次数"`
}

// ResourceTag 资源-标签关联模型
// 统一管理设备、IoT卡、号卡与标签的多对多关系
// 添加 enterprise_id 和 shop_id 用于权限控制
type ResourceTag struct {
	gorm.Model
	BaseModel    `gorm:"embedded"`
	ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;uniqueIndex:idx_resource_tag_unique,priority:1,where:deleted_at IS NULL;comment:资源类型 device-设备 iot_card-IoT卡 number_card-号卡"`
	ResourceID   uint   `gorm:"column:resource_id;not null;uniqueIndex:idx_resource_tag_unique,priority:2,where:deleted_at IS NULL;comment:资源ID"`
	TagID        uint   `gorm:"column:tag_id;not null;uniqueIndex:idx_resource_tag_unique,priority:3,where:deleted_at IS NULL;comment:标签ID"`
	EnterpriseID *uint  `gorm:"column:enterprise_id;index:idx_resource_tag_enterprise;comment:归属企业ID（从资源推断）"`
	ShopID       *uint  `gorm:"column:shop_id;index:idx_resource_tag_shop;comment:归属店铺ID（从资源推断）"`
}
```

#### pkg/constants/wallet.go

```go
// 钱包资源类型
const (
	WalletResourceTypeIotCard = "iot_card" // 物联网卡钱包
	WalletResourceTypeDevice  = "device"   // 设备钱包
	WalletResourceTypeShop    = "shop"     // 店铺钱包
)
```

---

### 3. GORM Callback 扩展

#### pkg/gorm/callback.go

```go
// 在 applyDataPermissionFilter 函数中添加标签表的处理

// 标签表和资源标签表的数据权限过滤
if tableName == "tb_tag" || tableName == "tb_resource_tag" {
	switch userType {
	case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
		// 超级管理员和平台用户可以看到所有标签
		return db
	
	case constants.UserTypeAgent:
		// 代理用户：只能看到自己店铺及下级店铺的标签，以及全局标签
		subordinateShopIDs, err := getSubordinateShopIDs(ctx, shopID, shopStore)
		if err != nil {
			logger.GetAppLogger().Error("获取下级店铺ID失败", zap.Error(err))
			return db.Where("1 = 0") // 失败时返回空结果
		}
		return db.Where(
			"shop_id IN (?) OR (enterprise_id IS NULL AND shop_id IS NULL)",
			subordinateShopIDs,
		)
	
	case constants.UserTypeEnterprise:
		// 企业用户：只能看到自己企业的标签，以及全局标签
		return db.Where(
			"enterprise_id = ? OR (enterprise_id IS NULL AND shop_id IS NULL)",
			enterpriseID,
		)
	
	default:
		// 个人客户：只能看到全局标签
		return db.Where("enterprise_id IS NULL AND shop_id IS NULL")
	}
}
```

---

### 4. 数据迁移脚本

#### migrations/000008_fix_wallet_tag_multi_tenant.up.sql

```sql
-- ========================================
-- 第 1 步：备份数据
-- ========================================

-- 备份钱包表
CREATE TABLE tb_wallet_backup AS SELECT * FROM tb_wallet;

-- 备份标签表
CREATE TABLE tb_tag_backup AS SELECT * FROM tb_tag;

-- 备份资源标签表
CREATE TABLE tb_resource_tag_backup AS SELECT * FROM tb_resource_tag;

-- ========================================
-- 第 2 步：钱包表结构变更
-- ========================================

-- 添加新字段（先添加，允许 NULL）
ALTER TABLE tb_wallet 
ADD COLUMN resource_type VARCHAR(20),
ADD COLUMN resource_id BIGINT;

-- 迁移代理钱包数据
-- 代理钱包从 user_id 迁移到 shop_id
UPDATE tb_wallet w
SET 
    resource_type = 'shop',
    resource_id = a.shop_id
FROM tb_account a
WHERE 
    w.user_id = a.id 
    AND w.wallet_type = 'agent'
    AND a.shop_id IS NOT NULL;

-- 标记无法迁移的代理钱包（shop_id 为 NULL）
UPDATE tb_wallet
SET resource_type = 'INVALID_AGENT'
WHERE wallet_type = 'agent' AND resource_type IS NULL;

-- 标记用户钱包为待处理（需要业务人员确认）
UPDATE tb_wallet
SET resource_type = 'PENDING_USER'
WHERE wallet_type = 'user' AND resource_type IS NULL;

-- 设置字段为 NOT NULL
ALTER TABLE tb_wallet 
ALTER COLUMN resource_type SET NOT NULL,
ALTER COLUMN resource_id SET NOT NULL;

-- 删除旧字段和约束
ALTER TABLE tb_wallet DROP CONSTRAINT IF EXISTS idx_wallet_user_type_currency;
ALTER TABLE tb_wallet DROP COLUMN user_id;

-- 创建新约束
CREATE UNIQUE INDEX idx_wallet_resource_type_currency 
ON tb_wallet (resource_type, resource_id, wallet_type, currency) 
WHERE deleted_at IS NULL;

CREATE INDEX idx_wallet_resource ON tb_wallet (resource_type, resource_id, deleted_at);

-- ========================================
-- 第 3 步：标签表结构变更
-- ========================================

-- 添加新字段
ALTER TABLE tb_tag 
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;

-- 迁移企业标签数据
UPDATE tb_tag t
SET enterprise_id = (
    SELECT a.enterprise_id 
    FROM tb_account a 
    WHERE a.id = t.creator AND a.enterprise_id IS NOT NULL
    LIMIT 1
);

-- 迁移店铺标签数据
UPDATE tb_tag t
SET shop_id = (
    SELECT a.shop_id 
    FROM tb_account a 
    WHERE a.id = t.creator AND a.shop_id IS NOT NULL
    LIMIT 1
)
WHERE enterprise_id IS NULL;

-- 其他标签默认为全局标签（enterprise_id 和 shop_id 都为 NULL）

-- 删除旧约束
DROP INDEX IF EXISTS idx_tag_name;

-- 创建新索引和约束
CREATE INDEX idx_tag_enterprise ON tb_tag (enterprise_id, deleted_at);
CREATE INDEX idx_tag_shop ON tb_tag (shop_id, deleted_at);

CREATE UNIQUE INDEX idx_tag_enterprise_name 
ON tb_tag (enterprise_id, name) 
WHERE deleted_at IS NULL AND enterprise_id IS NOT NULL;

CREATE UNIQUE INDEX idx_tag_shop_name 
ON tb_tag (shop_id, name) 
WHERE deleted_at IS NULL AND shop_id IS NOT NULL;

CREATE UNIQUE INDEX idx_tag_global_name 
ON tb_tag (name) 
WHERE deleted_at IS NULL AND enterprise_id IS NULL AND shop_id IS NULL;

-- ========================================
-- 第 4 步：资源标签表结构变更
-- ========================================

-- 添加新字段
ALTER TABLE tb_resource_tag 
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;

-- 从 creator 推断归属
UPDATE tb_resource_tag rt
SET enterprise_id = (
    SELECT a.enterprise_id 
    FROM tb_account a 
    WHERE a.id = rt.creator AND a.enterprise_id IS NOT NULL
    LIMIT 1
);

UPDATE tb_resource_tag rt
SET shop_id = (
    SELECT a.shop_id 
    FROM tb_account a 
    WHERE a.id = rt.creator AND a.shop_id IS NOT NULL
    LIMIT 1
)
WHERE enterprise_id IS NULL;

-- 创建索引
CREATE INDEX idx_resource_tag_enterprise ON tb_resource_tag (enterprise_id, deleted_at);
CREATE INDEX idx_resource_tag_shop ON tb_resource_tag (shop_id, deleted_at);

-- ========================================
-- 第 5 步：验证数据一致性
-- ========================================

-- 检查无法迁移的钱包
SELECT COUNT(*) AS invalid_agent_wallets 
FROM tb_wallet 
WHERE resource_type = 'INVALID_AGENT';

SELECT COUNT(*) AS pending_user_wallets 
FROM tb_wallet 
WHERE resource_type = 'PENDING_USER';

-- 如果有无法迁移的数据，停止迁移并输出错误信息
DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM tb_wallet WHERE resource_type IN ('INVALID_AGENT', 'PENDING_USER')) THEN
        RAISE EXCEPTION '存在无法自动迁移的钱包数据，请手动处理后再执行迁移';
    END IF;
END $$;
```

#### migrations/000008_fix_wallet_tag_multi_tenant.down.sql

```sql
-- ========================================
-- 回滚脚本
-- ========================================

-- 恢复钱包表
DROP TABLE IF EXISTS tb_wallet;
CREATE TABLE tb_wallet AS SELECT * FROM tb_wallet_backup;

-- 恢复标签表
ALTER TABLE tb_tag DROP COLUMN IF EXISTS enterprise_id;
ALTER TABLE tb_tag DROP COLUMN IF EXISTS shop_id;

-- 恢复资源标签表
ALTER TABLE tb_resource_tag DROP COLUMN IF EXISTS enterprise_id;
ALTER TABLE tb_resource_tag DROP COLUMN IF EXISTS shop_id;

-- 重建旧约束
CREATE UNIQUE INDEX idx_tag_name ON tb_tag (name) WHERE deleted_at IS NULL;

-- 删除备份表（可选，建议手动删除）
-- DROP TABLE tb_wallet_backup;
-- DROP TABLE tb_tag_backup;
-- DROP TABLE tb_resource_tag_backup;
```

---

## Risks / Trade-offs

### 风险

| 风险 | 严重程度 | 缓解措施 |
|------|---------|---------|
| 钱包数据迁移失败 | 🔴 高 | 1. 完整备份数据<br>2. 在测试环境完整演练<br>3. 提供回滚脚本<br>4. 停服维护期间操作 |
| 用户钱包无法自动迁移 | 🟡 中 | 1. 标记为待处理<br>2. 业务人员手动确认<br>3. 提供管理后台工具 |
| 标签名称冲突 | 🟡 中 | 1. 迁移后检查重名标签<br>2. 提供工具批量重命名<br>3. 通知用户手动处理 |
| GORM Callback 性能影响 | 🟢 低 | 1. 下级店铺ID缓存（已有）<br>2. 索引优化<br>3. 监控慢查询 |

### Trade-offs

| 决策 | 优点 | 缺点 | 权衡理由 |
|------|------|------|---------|
| 删除 wallet.user_id | 符合业务逻辑，支持资源转手 | 破坏性变更，需要数据迁移 | 业务正确性优先 |
| 保留 wallet_transaction.user_id | 保持审计能力，无需迁移历史数据 | 字段含义变更 | 历史数据保留优先 |
| 标签三级隔离 | 灵活性高，支持多种场景 | 查询条件复杂（三个字段组合） | 通过索引和 GORM Callback 优化，影响可控 |
| 资源标签表添加隔离字段 | 权限控制更精确 | 数据冗余 | 安全性优先，性能影响可控 |

---

## Migration Plan

### 前置准备

1. **数据备份**（必须）
   ```bash
   pg_dump -h localhost -U postgres -d junhong_cmp -t tb_wallet > tb_wallet_backup.sql
   pg_dump -h localhost -U postgres -d junhong_cmp -t tb_tag > tb_tag_backup.sql
   pg_dump -h localhost -U postgres -d junhong_cmp -t tb_resource_tag > tb_resource_tag_backup.sql
   ```

2. **测试环境验证**（必须）
   - 在测试环境完整执行迁移流程
   - 验证代理钱包迁移正确性
   - 验证标签隔离功能
   - 验证回滚脚本

3. **业务人员确认**（必须）
   - 确认所有 `wallet_type=user` 的钱包归属
   - 提供待迁移钱包列表给业务人员
   - 确认迁移窗口时间

### 迁移步骤

**时间窗口**：预计 2 小时（包含验证和应急处理）

1. **停止服务**（0:00）
   ```bash
   systemctl stop junhong-api
   systemctl stop junhong-worker
   ```

2. **执行迁移 SQL**（0:05）
   ```bash
   psql -h localhost -U postgres -d junhong_cmp -f migrations/000008_fix_wallet_tag_multi_tenant.up.sql
   ```

3. **检查迁移结果**（0:10）
   ```sql
   -- 检查无效数据
   SELECT COUNT(*) FROM tb_wallet WHERE resource_type IN ('INVALID_AGENT', 'PENDING_USER');
   
   -- 检查标签重名
   SELECT enterprise_id, shop_id, name, COUNT(*) 
   FROM tb_tag 
   GROUP BY enterprise_id, shop_id, name 
   HAVING COUNT(*) > 1;
   ```

4. **部署新代码**（0:20）
   ```bash
   git pull origin main
   go build -o junhong-api cmd/api/main.go
   go build -o junhong-worker cmd/worker/main.go
   ```

5. **启动服务**（0:30）
   ```bash
   systemctl start junhong-api
   systemctl start junhong-worker
   ```

6. **验证核心功能**（0:35）
   - 代理钱包查询：`GET /api/v1/wallet`
   - 企业标签创建：`POST /api/v1/tags`
   - 企业标签查询：`GET /api/v1/tags`
   - 个人客户标签查询：`GET /api/c/v1/tags`

7. **监控错误日志**（0:45 - 1:00）
   ```bash
   tail -f logs/app.log | grep -i error
   ```

8. **如果出现问题，执行回滚**（1:00 - 1:30）
   ```bash
   systemctl stop junhong-api
   systemctl stop junhong-worker
   psql -h localhost -U postgres -d junhong_cmp -f migrations/000008_fix_wallet_tag_multi_tenant.down.sql
   git checkout <previous_commit>
   go build -o junhong-api cmd/api/main.go
   go build -o junhong-worker cmd/worker/main.go
   systemctl start junhong-api
   systemctl start junhong-worker
   ```

9. **完成迁移**（1:30 - 2:00）
   - 清理备份表（可选，建议保留 7 天）
   - 更新文档
   - 通知团队

---

## Open Questions

1. **用户钱包迁移**：当前系统中是否存在 `wallet_type=user` 的钱包？如果有，应该如何迁移？
   - 建议：业务人员提供 user_id → resource_type + resource_id 的映射表

2. **标签重名处理**：迁移后如果出现企业内标签重名，如何处理？
   - 建议：提供管理后台工具，支持批量重命名

3. **分佣钱包**：代理商的分佣钱包是否也需要改为 `resource_type=shop`？
   - 建议：分佣钱包后续单独讨论，本次变更暂不处理

4. **历史订单**：订单表是否需要添加 `wallet_id` 字段关联钱包？
   - 建议：后续讨论，本次变更不涉及

---

## Testing Strategy

### 单元测试

1. **钱包 Store 层测试**
   ```go
   // TestFindWalletByResource - 按资源查询钱包
   // TestCreateIotCardWallet - 创建卡钱包
   // TestCreateDeviceWallet - 创建设备钱包
   // TestCreateShopWallet - 创建店铺钱包
   ```

2. **标签 Store 层测试**
   ```go
   // TestCreateEnterpriseTag - 创建企业标签
   // TestCreateShopTag - 创建店铺标签
   // TestCreateGlobalTag - 创建全局标签
   // TestQueryEnterpriseTagsIsolation - 企业标签隔离验证
   ```

### 集成测试

1. **钱包业务测试**
   ```
   - 个人客户为卡充值
   - 个人客户为设备充值（3张卡共享）
   - 卡转手后新用户查询余额
   - 代理商店铺钱包充值和扣费
   ```

2. **标签业务测试**
   ```
   - 企业 A 创建标签
   - 企业 B 查询标签（不应看到企业 A 的标签）
   - 企业 A 为自己的设备打标签
   - 企业 A 尝试为企业 B 的设备打标签（应被拒绝）
   ```

### 数据迁移测试

1. **测试环境完整迁移**
   ```
   - 准备测试数据（代理钱包、用户钱包、标签）
   - 执行迁移 SQL
   - 验证数据一致性
   - 执行回滚 SQL
   - 验证回滚后数据恢复
   ```

2. **边界情况测试**
   ```
   - 钱包表为空
   - 标签表为空
   - 存在大量重名标签
   - 存在无法迁移的钱包
   ```

---

## Monitoring

### 迁移过程监控

```sql
-- 实时监控迁移进度
SELECT 
    'tb_wallet' AS table_name,
    COUNT(*) AS total,
    COUNT(*) FILTER (WHERE resource_type NOT IN ('INVALID_AGENT', 'PENDING_USER')) AS migrated,
    COUNT(*) FILTER (WHERE resource_type IN ('INVALID_AGENT', 'PENDING_USER')) AS pending
FROM tb_wallet;

-- 监控标签迁移
SELECT 
    'tb_tag' AS table_name,
    COUNT(*) AS total,
    COUNT(*) FILTER (WHERE enterprise_id IS NOT NULL) AS enterprise_tags,
    COUNT(*) FILTER (WHERE shop_id IS NOT NULL) AS shop_tags,
    COUNT(*) FILTER (WHERE enterprise_id IS NULL AND shop_id IS NULL) AS global_tags
FROM tb_tag;
```

### 运行时监控

```
- API 错误率（Grafana）
- 钱包查询响应时间（Grafana）
- 标签查询响应时间（Grafana）
- 错误日志关键词：wallet, tag, resource_type, enterprise_id, shop_id
```

---

## Documentation

迁移完成后需要更新的文档：

1. **README.md** - 添加钱包和标签系统的业务说明
2. **openspec/specs/wallet/spec.md** - 更新钱包归属规则
3. **openspec/specs/tag/spec.md** - 更新标签多租户隔离规则
4. **API 文档** - 更新钱包和标签相关接口（如果已实现）
5. **运维文档** - 添加数据迁移操作手册
