# 09 · 计费与多租户 SaaS 基础设施

## 1. 计费系统(借鉴 waoowaoo 余额冻结机制)

### 1.1 三表核心

```
user_balances        (账户快照:可用 + 冻结 + 已花)
balance_freezes      (操作原子性:pending → confirmed/rolled_back)
balance_transactions (审计流水:不可变)
```

### 1.2 状态机

```
操作开始
   │
   ▼
[prepare]        估算成本 → freeze 金额
   │             (balance -= amount, frozen += amount)
   ▼
[execute]        AI 调用(异步)
   │
   ├ 成功 → [settle]
   │         actualCost 可能 ≠ quotedCost
   │         frozen -= quotedCost
   │         balance += (quotedCost - actualCost)  // 退回差额
   │         totalSpent += actualCost
   │         写 balance_transactions (CONSUME)
   │         freeze.status = CONFIRMED
   │
   ├ 失败 → [rollback]
   │         balance += quotedCost, frozen -= quotedCost
   │         freeze.status = ROLLED_BACK
   │
   └ 超时 → [expire](scheduler)
             同 rollback
```

### 1.3 核心代码

```java
@Service
@Transactional
public class BillingService {

    public BalanceFreeze freeze(Long userId, BigDecimal amount, String idempotencyKey, String taskId) {
        // 幂等
        var existing = freezeRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        // 乐观锁扣减 + 冻结
        int rows = balanceRepo.tryFreeze(userId, amount);
        if (rows == 0) throw new InsufficientBalanceException();

        BalanceFreeze f = BalanceFreeze.builder()
            .userId(userId).amount(amount)
            .status(PENDING).idempotencyKey(idempotencyKey)
            .taskId(taskId).expiresAt(now().plusHours(2))
            .build();
        return freezeRepo.save(f);
    }

    public void settle(Long freezeId, BigDecimal actualCost) {
        BalanceFreeze f = freezeRepo.findByIdForUpdate(freezeId);
        if (f.getStatus() != PENDING) return;

        BigDecimal refund = f.getAmount().subtract(actualCost).max(ZERO);
        balanceRepo.confirmConsume(f.getUserId(), f.getAmount(), actualCost, refund);

        transactionRepo.save(BalanceTransaction.builder()
            .userId(f.getUserId()).type(CONSUME)
            .amount(actualCost.negate())
            .balanceAfter(balanceRepo.getBalance(f.getUserId()))
            .taskId(f.getTaskId()).billingMeta(...)
            .build());

        f.setStatus(CONFIRMED);
        freezeRepo.save(f);
    }

    public void rollback(Long freezeId) {
        BalanceFreeze f = freezeRepo.findByIdForUpdate(freezeId);
        if (f.getStatus() != PENDING) return;
        balanceRepo.releaseFreeze(f.getUserId(), f.getAmount());
        f.setStatus(ROLLED_BACK);
        freezeRepo.save(f);
    }
}
```

### 1.4 SQL(乐观锁扣减)

```sql
-- 预冻结
UPDATE user_balances
SET balance = balance - :amount,
    frozen_amount = frozen_amount + :amount,
    version = version + 1
WHERE user_id = :uid AND balance >= :amount;
-- rows=0 即余额不足

-- 确认消费(结算)
UPDATE user_balances
SET frozen_amount = frozen_amount - :frozen,
    balance = balance + :refund,
    total_spent = total_spent + :actual
WHERE user_id = :uid;

-- 回滚冻结
UPDATE user_balances
SET frozen_amount = frozen_amount - :amount,
    balance = balance + :amount
WHERE user_id = :uid;
```

### 1.5 BillingMode 开关

| 模式 | 行为 |
|------|------|
| `OFF` | 完全跳过计费(开发/开源) |
| `SOFT` | 只记录不拦截(观察期) |
| `STRICT` | 余额不足拒绝任务(生产) |

## 2. 订阅与支付

### 2.1 订阅模型

```
subscriptions
  - plan: FREE / PRO / TEAM / ENTERPRISE
  - status: ACTIVE / TRIALING / PAST_DUE / CANCELED
  - monthly_credits: 每月赠送积分
  - started_at / renewed_at / expires_at
  - auto_renew: BOOLEAN
  - external_subscription_id: 支付网关的订阅 ID
```

### 2.2 支付集成

| 网关 | 场景 |
|------|------|
| 支付宝 | 国内 C 端 |
| 微信支付 | 国内 C 端 |
| Stripe | 海外 + 订阅 |
| 对公转账 | 企业版 |

**集成方式**: 抽象 `PaymentProvider` 接口,各实现适配异步回调 Webhook,回调更新 `balance_transactions(type=RECHARGE)` + `subscriptions.status`。

### 2.3 续费与失败处理

- `auto_renew=true`: 每月到期前 3 天尝试扣款,失败重试 3 次,最后降级到 FREE
- `auto_renew=false`: 到期直接降级
- 降级后保留数据 30 天,超期归档

## 3. 多租户隔离

### 3.1 三种隔离级别

| 级别 | 对应套餐 | 实现 |
|------|----------|------|
| L1: 共享表 + tenant_id 列 | Free/Pro/Team | MyBatis-Plus `TenantLineInnerInterceptor` 自动注入 `tenant_id=?` |
| L2: 独立 schema | Enterprise | MyBatis-Plus `DynamicTableNameInnerInterceptor` + 动态数据源(`baomidou/dynamic-datasource`) |
| L3: 独立数据库 | 私有部署 | 每个客户一个独立实例 |

### 3.2 TenantContext

```java
public final class TenantContext {
    private static final ThreadLocal<Long> CURRENT = new ThreadLocal<>();
    public static void set(Long tenantId) { CURRENT.set(tenantId); }
    public static Long get() { return CURRENT.get(); }
    public static void clear() { CURRENT.remove(); }
}

@Component
public class TenantFilter extends OncePerRequestFilter {
    protected void doFilterInternal(...) {
        if (StpUtil.isLogin()) {
            Long tenantId = (Long) StpUtil.getSession().get("tenantId");
            TenantContext.set(tenantId);
        }
        try { chain.doFilter(request, response); }
        finally { TenantContext.clear(); }
    }
}
```

### 3.3 MyBatis-Plus 多租户插件自动注入

```java
// MybatisPlusConfig 中注册 TenantLineInnerInterceptor(详见 doc 05 §5.2)
// 所有 SQL 自动追加 WHERE tenant_id = ?,无需在 Entity 上加任何注解

// Entity 只需声明 tenantId 字段即可
@Data
@TableName("projects")
public class Project {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private Long tenantId;   // 插件自动处理
    // ...
}

// 跨租户操作(白名单): 在 Mapper 方法上标注忽略
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM global_assets WHERE library = 'MARKET'")
List<GlobalAsset> selectPublicAssets();
```

**Worker 环境**: 从 Task 的 `tenantId` 字段恢复 TenantContext，插件自动生效。

### 3.4 跨租户操作(白名单)

- 系统管理员后台(只读审计)
- 资产市场(公共库,`tenant_id IS NULL`)
- 平台通知

这些场景显式关闭 Filter: `session.disableFilter("tenantFilter")`。

## 4. 权限模型 (RBAC + 资源级)

### 4.1 层次

```
租户级角色: OWNER / ADMIN / MEMBER
  ↓
项目级角色: owner / admin / editor / viewer
  ↓
资源级权限: 分镜审批、成员邀请、计费查看...
```

### 4.2 权限注解

```java
@PreAuthorize("@projectPermission.check(#projectId, 'STORYBOARD:EDIT')")
@PatchMapping("/projects/{projectId}/storyboards/{sid}")
public Storyboard update(@PathVariable Long projectId, ...) { }
```

```java
@Component("projectPermission")
public class ProjectPermissionEvaluator {
    @Autowired ProjectMemberMapper memberMapper;

    public boolean check(Long projectId, String perm) {
        Long uid = currentUserId();
        ProjectMember member = memberMapper.selectOne(
            new LambdaQueryWrapper<ProjectMember>()
                .eq(ProjectMember::getProjectId, projectId)
                .eq(ProjectMember::getUserId, uid));
        if (member == null) throw new ForbiddenException();
        return ROLE_PERMS.get(member.getRole()).contains(perm);
    }
}
```

### 4.3 角色权限矩阵

| 权限 | owner | admin | editor | viewer |
|------|:-----:|:-----:|:------:|:------:|
| PROJECT:READ | ✓ | ✓ | ✓ | ✓ |
| PROJECT:WRITE | ✓ | ✓ | ✓ | |
| PROJECT:DELETE | ✓ | | | |
| STORYBOARD:EDIT | ✓ | ✓ | ✓ | |
| STORYBOARD:APPROVE | ✓ | ✓ | | |
| STORYBOARD:GENERATE | ✓ | ✓ | ✓ | |
| TEAM:INVITE | ✓ | ✓ | | |
| TEAM:REMOVE | ✓ | ✓ | | |
| BILLING:VIEW | ✓ | ✓ | | |
| BILLING:RECHARGE | ✓ | | | |

## 5. 配额与速率限制

### 5.1 租户级配额

```java
@Data
@TableName("tenant_quotas")
public class TenantQuota {
    @TableId
    Long tenantId;
    Integer maxProjects;
    Integer maxMembers;
    Integer maxMonthlyCredits;
    Integer maxConcurrentTasks;       // 同时进行中的任务数
    BigDecimal monthlyBudgetLimit;    // 可选:硬性月消费上限
}
```

### 5.2 限流(Bucket4j + Redis)

```java
@Component
public class RateLimitFilter extends OncePerRequestFilter {
    // 路径级策略
    Map<String, Bandwidth> rules = Map.of(
        "/auth/login",       Bandwidth.simple(5, Duration.ofMinutes(1)),
        "/auth/register",    Bandwidth.simple(3, Duration.ofHours(1)),
        "/projects",         Bandwidth.simple(60, Duration.ofMinutes(1)),
        "/api/default",      Bandwidth.simple(300, Duration.ofMinutes(1))
    );
}
```

## 6. 审计日志

```java
@Aspect @Component
public class AuditAspect {
    @AfterReturning("@annotation(audit)")
    public void log(JoinPoint jp, Audited audit) {
        auditLogRepo.save(AuditLog.builder()
            .tenantId(TenantContext.get())
            .userId(currentUserId())
            .action(audit.action())
            .resourceType(audit.resource())
            .resourceId(extractResourceId(jp))
            .ip(request.getRemoteAddr())
            .userAgent(request.getHeader("User-Agent"))
            .requestId(MDC.get("requestId"))
            .build());
    }
}

@Audited(action = "PROJECT_DELETE", resource = "PROJECT")
@DeleteMapping("/projects/{id}")
public void delete(@PathVariable Long id) { ... }
```

## 7. GDPR / 数据合规

- `POST /user/data-export` 异步导出全部个人数据到 ZIP(含项目、作品、消费记录)
- `POST /user/delete-account` 7 天延迟删除(可撤回)→ 硬删除
- 日志自动脱敏(`passwords/tokens/api_keys/email` 用 `****` 替换)
- 敏感字段加密存储(`api_keys`、`payment_info` 用 Jasypt)

## 8. 可观测性

- **Metrics**: Micrometer → Prometheus → Grafana 面板
  - 任务队列深度、耗时 p99、失败率
  - 余额扣减 QPS、账单异常
  - AI Provider 响应时间、错误码分布
- **Logs**: Logback JSON + MDC(requestId/userId/tenantId/taskId)→ ELK
- **Traces**: SkyWalking 或 OTel,跨 API→MQ→Worker 串联
- **Health**: `/actuator/health` 包含 MySQL / Redis Stack(RediSearch)/ MQ / OSS 依赖检查
