# 17 · 平台运营后台设计 (M14)

> 生成时间: 2026-04-25
> 范围: yaoai-comic-studio 配套的"平台运营后台"，供平台方运营/财务团队跨租户管理客户公司、积分充值、计费对账、运营人员与权限管理。
> 技术栈: 后端 Spring Boot 3.x + Sa-Token 双账号 + MyBatis-Plus；前端 Vue3 + vue-vben-admin v5 + Element Plus

---

## 1. 范围与边界

### 1.1 做

- 跨租户管理（看所有公司、暂停/恢复、变更套餐、配额覆盖）
- **手动给租户充值积分**（核心需求）
- 跨租户用户管理（禁用、强制下线、重置密码）
- 全平台计费记录查询与汇总（充值/消费/退款）
- 套餐定义与配额维护
- 运营人员账号管理（多角色 RBAC）
- 审计日志（运营操作可追溯）
- 运营人员模拟登录租户（impersonate，全程审计）

### 1.2 暂不做

- 支付集成（按用户决策）
- 订阅自动续费（无支付即无意义）
- 运营人员邮件/短信通知
- GDPR 数据导出（设计文档 09 §7，单独里程碑）
- 2FA / SSO（运营账号数量少，先用强密码）

---

## 2. 关键架构决策

### 2.1 模块归属

将现有 `yaoai-api/.../admin/AdminController` **迁移到 `yaoai-admin` 模块**。

**理由：**
- 设计文档 05 §2 已规划好 `yaoai-admin` 独立模块，目前是空壳
- 继续放在 `yaoai-api` 会让客户端 jar 包含运营代码，部署边界不清
- 未来 admin 服务独立部署时，模块边界已经划好

### 2.2 认证体系：Sa-Token 双账号 (StpAdminUtil)

设计文档 05 §5.1 已经明确"`StpUtil`（用户端）/ `StpAdminUtil`（运营后台），互不干扰"。当前 `AdminController.requireAdmin()` 用的是 `StpUtil` + `users.role`，等于运营和客户共用一套 token，越权风险大。

新方案：

| 维度 | 客户端 | 运营端 |
|---|---|---|
| Stp 实例 | `StpUtil` (默认) | `StpAdminUtil` (`StpLogic("admin")`) |
| Redis Key | `sa-token:login:token:*` | `sa-token:admin:login:token:*` |
| 用户表 | `users` | `admin_users` (新增) |
| URL 前缀 | `/api/**` | `/admin-api/**` |
| Header | `satoken` | `satoken-admin` |

部署上 admin 服务建议独立端口（8081）+ 内部 ingress + IP 白名单。

### 2.3 跨租户数据访问

admin 模块所有 Mapper 方法显式标注 `@InterceptorIgnore(tenantLine = "true")`，绕过 `TenantLineInnerInterceptor`。比"清理 TenantContext"更显式可读。

### 2.4 充值与暂停的业务规则

- **充值不允许给 SUSPENDED 状态的租户**：避免账务混乱
- **变更套餐**会同步修改 `tenant_quotas`：套餐定义里的字段（max_members 等）覆盖到租户配额表
- **暂停租户**会调用 `StpUtil.kickout(userId)` 把该租户所有用户踢下线

---

## 3. 数据模型

### 3.1 新增 4 张表

```sql
-- 运营人员（独立于 users，避免 role 字段混乱）
CREATE TABLE admin_users (
  id BIGINT PK AUTO_INCREMENT,
  username VARCHAR(64) UNIQUE NOT NULL,
  password_hash VARCHAR(100) NOT NULL,
  display_name VARCHAR(128),
  email VARCHAR(255),
  role ENUM('SUPER_ADMIN','OPERATOR','FINANCE','READONLY') NOT NULL,
  status ENUM('ACTIVE','DISABLED') DEFAULT 'ACTIVE',
  last_login_at DATETIME,
  last_login_ip VARCHAR(45),
  created_by BIGINT,
  created_at DATETIME, updated_at DATETIME
);

-- 运营操作审计（与设计文档 09 §6 的 audit_logs 区分：那张表是租户内业务审计）
CREATE TABLE admin_audit_logs (
  id BIGINT PK AUTO_INCREMENT,
  admin_user_id BIGINT NOT NULL,
  admin_username VARCHAR(64),       -- 冗余，账号删除后仍可追溯
  action VARCHAR(64),                -- TENANT_RECHARGE / TENANT_SUSPEND / PLAN_CHANGE / USER_DISABLE / IMPERSONATE_START / ...
  resource_type VARCHAR(32),
  resource_id VARCHAR(64),
  before_value JSON,
  after_value JSON,
  ip VARCHAR(45), user_agent VARCHAR(255),
  request_id VARCHAR(64),
  status ENUM('SUCCESS','FAILED') DEFAULT 'SUCCESS',
  error_message TEXT,
  created_at DATETIME,
  INDEX idx_admin_time (admin_user_id, created_at),
  INDEX idx_resource (resource_type, resource_id),
  INDEX idx_action_time (action, created_at)
);

-- 套餐定义（设计文档 01 §4.1 是硬编码，现持久化）
CREATE TABLE plans (
  id BIGINT PK AUTO_INCREMENT,
  code ENUM('FREE','PRO','TEAM','ENTERPRISE') UNIQUE NOT NULL,
  name VARCHAR(64),
  price_monthly DECIMAL(10,2),
  monthly_credits INT,
  max_members INT,
  max_projects INT,
  max_concurrent_tasks INT,
  features JSON,
  is_active BOOLEAN DEFAULT TRUE,
  updated_at DATETIME, updated_by BIGINT
);

-- 租户配额覆盖（设计文档 09 §5.1 规划过，现落地）
CREATE TABLE tenant_quotas (
  tenant_id BIGINT PK,
  max_members INT,
  max_projects INT,
  max_monthly_credits INT,
  max_concurrent_tasks INT,
  monthly_budget_limit DECIMAL(18,4),
  notes TEXT,
  updated_at DATETIME,
  updated_by BIGINT
);
```

### 3.2 现有表的微改动

```sql
-- tenants 加暂停元信息
ALTER TABLE tenants
  ADD COLUMN suspended_at DATETIME NULL,
  ADD COLUMN suspended_by BIGINT NULL,
  ADD COLUMN suspended_reason VARCHAR(255) NULL,
  ADD COLUMN notes TEXT NULL;
```

### 3.3 ⭐ billing_records 模型粒度升级（核心需求）

**问题：** 现有 `billing_records` 只有 `operation`（6 个枚举：outline_generate / episode_generate / image_generate / video_generate / assembly / recharge），完全无法回答运营/客户的真实问题：

> "这个月用 Seedream 生了多少图、花了多少积分？为什么 Wanx 比 Seedance 贵？"
> "张三这周生成视频 30 次有没有滥用？"

**升级方案：** 给 `billing_records` 加 8 列，把"模型 + 用户 + 数量 + 元信息"全部记下来。

```sql
ALTER TABLE billing_records
  -- 充值发起人（运营手动充值时记录）
  ADD COLUMN created_by_admin_id BIGINT NULL COMMENT '运营人员手动充值时记录',
  -- 谁发起的（租户内用户）
  ADD COLUMN user_id BIGINT NULL COMMENT '租户内发起操作的用户',
  -- 模态：TEXT / IMAGE / VIDEO / AUDIO / COMPOSE / RECHARGE
  ADD COLUMN modality VARCHAR(16) NULL COMMENT '生成模态',
  -- 模型三元组
  ADD COLUMN model_provider VARCHAR(32) NULL COMMENT 'volcengine/qwen/openai',
  ADD COLUMN model_id VARCHAR(64) NULL COMMENT 'seedream-3-0-t2i / seedance-1-0 / qwen-max',
  ADD COLUMN model_name VARCHAR(128) NULL COMMENT '展示名,如"Seedream 3.0 文生图"',
  -- 数量与状态
  ADD COLUMN unit_count INT NULL DEFAULT 1 COMMENT '本次生成数量(图片张数/视频段数/字符数)',
  ADD COLUMN success TINYINT(1) NULL DEFAULT 1 COMMENT '成功失败',
  -- 元信息：分辨率、时长、token、画幅比等
  ADD COLUMN billing_meta JSON NULL,
  ADD INDEX idx_modality_model (modality, model_provider, model_id),
  ADD INDEX idx_user_created (user_id, created_at);
```

**配套：BillingService.charge() 签名升级**

```java
// 旧
void charge(Long tenantId, Long projectId, String operation, String refId);

// 新（保留旧的做兜底，新签名优先）
void charge(BillingChargeRequest req);

@Data @Builder
public class BillingChargeRequest {
    Long tenantId;
    Long userId;            // ⭐ 新
    Long projectId;
    String operation;       // outline_generate / image_generate / ...
    String modality;        // TEXT / IMAGE / VIDEO / AUDIO / COMPOSE
    String modelProvider;   // volcengine / qwen / openai
    String modelId;         // seedream-3-0-t2i
    String modelName;       // Seedream 3.0 文生图
    Integer unitCount;      // 默认 1
    Boolean success;        // 默认 true
    BigDecimal credits;     // 可选,如不传按 BillingConfig.getCost(operation)
    Map<String,Object> meta;// 分辨率/时长/token...
    String refId;
}
```

**5 个现有调用点全部改造**（详见 §7.7）：

| 文件 | 模型来源 |
|---|---|
| `OutlinePipelineServiceImpl.java:92` (outline) | `ArkLlmService` 当前用的 model（qwen-max 之类） |
| `OutlinePipelineServiceImpl.java:143` (episode) | 同上 |
| `ImageGenPipelineServiceImpl.java:42` (角色/场景图) | `SeedreamService` 用的 model_id |
| `VideoTaskPipelineServiceImpl.java:51` 和 `VideoTaskAsyncProcessor.java:75` (视频) | `SeedanceService` 用的 model_id |
| `AssemblyPipelineServiceImpl.java:112` (合成) | 没有外部模型，modality=COMPOSE，model_provider=ffmpeg |

### 3.4 初始化数据

Flyway V13 中 INSERT：
- 4 个套餐 (FREE/PRO/TEAM/ENTERPRISE)，参数对齐设计文档 01 §4.1
- 1 个 SUPER_ADMIN 账号 `admin / admin@123!`，登录后强制改密
- （可选）`models` 字典表，把 `model_id → 展示名` 维护一份（也可以不建表，直接由租户的 `tenant_model_providers` 表反查）

---

## 4. API 设计

URL 统一前缀 `/admin-api`，所有端点要求 `@SaCheckLogin(type="admin")`。

### 4.1 认证
```
POST /admin-api/auth/login              # 用户名+密码 → { token, userInfo }
POST /admin-api/auth/logout
GET  /admin-api/auth/me
POST /admin-api/auth/change-password
```

### 4.2 仪表盘
```
GET  /admin-api/dashboard/overview      # 总租户/活跃租户/总用户/今日新增/今日消费/今日充值
GET  /admin-api/dashboard/trends?days=30   # 按天的新增/消费/充值趋势
GET  /admin-api/dashboard/tasks         # 任务统计：成功率、平均耗时、按 type 分组
GET  /admin-api/dashboard/providers     # AI 调用量与失败率
```

### 4.3 租户管理
```
GET    /admin-api/tenants                       # 分页+plan/status筛选+搜索
GET    /admin-api/tenants/{id}                  # 详情
PATCH  /admin-api/tenants/{id}                  # 改 name/notes
PUT    /admin-api/tenants/{id}/plan             # 变更套餐 (body: planCode)
POST   /admin-api/tenants/{id}/suspend          # body: reason
POST   /admin-api/tenants/{id}/resume
GET    /admin-api/tenants/{id}/balance          # 当前余额 + 最近 30 天交易
POST   /admin-api/tenants/{id}/recharge         # body: amount, note, externalRefId
GET    /admin-api/tenants/{id}/members
GET    /admin-api/tenants/{id}/projects
PUT    /admin-api/tenants/{id}/quota            # 配额覆盖
POST   /admin-api/tenants/{id}/impersonate      # 模拟登录该租户某用户 → 返回客户端 token
```

### 4.4 用户管理
```
GET    /admin-api/users                          # 跨租户列表（分页+搜索 email/username）
GET    /admin-api/users/{id}
PATCH  /admin-api/users/{id}/status              # ACTIVE/DISABLED
POST   /admin-api/users/{id}/kickout             # StpUtil.kickout()
POST   /admin-api/users/{id}/reset-password      # 生成随机密码，返回一次
```

### 4.5 计费与积分明细 ⭐
```
# 原始记录
GET  /admin-api/billing/records?modality=&modelId=&tenantId=&userId=&from=&to=&page=&size=
GET  /admin-api/billing/recharges                # 充值记录专用视图
GET  /admin-api/billing/consumes                 # 消费记录专用视图

# 多维度汇总（核心：回答"哪个模型/谁/哪天 花了多少积分"）
GET  /admin-api/billing/summary?dim=modality                # 按模态汇总（TEXT/IMAGE/VIDEO 各多少）
GET  /admin-api/billing/summary?dim=model                   # 按模型汇总（Seedream/Seedance/Qwen 各多少）
GET  /admin-api/billing/summary?dim=tenant                  # 按租户汇总
GET  /admin-api/billing/summary?dim=user&tenantId=xxx       # 某租户内按用户汇总
GET  /admin-api/billing/summary?dim=day                     # 按天汇总（趋势图）
GET  /admin-api/billing/summary?dim=project                 # 按项目汇总

# 单租户的积分明细页（运营查客户问题用）
GET  /admin-api/tenants/{id}/usage-detail
  ?from=&to=&groupBy=modality|model|user|day
  → {
      total: 1234,
      breakdown: [
        { key: "Seedream 3.0 文生图", credits: 540, count: 36, percent: 43.7 },
        { key: "Seedance 1.0 视频",   credits: 480, count: 8,  percent: 38.9 },
        { key: "Qwen Max 文本",       credits: 214, count: 142,percent: 17.4 }
      ]
    }
```

### 4.6 套餐与配额
```
GET    /admin-api/plans                          # 套餐列表
PATCH  /admin-api/plans/{id}                     # 改月度积分/上限/价格
```

### 4.7 运营人员（仅 SUPER_ADMIN）
```
GET    /admin-api/admin-users
POST   /admin-api/admin-users
PATCH  /admin-api/admin-users/{id}               # 改 role/status/email
POST   /admin-api/admin-users/{id}/reset-password
```

### 4.8 审计日志
```
GET  /admin-api/audit-logs?adminId=&action=&resourceType=&from=&to=
```

---

## 5. 角色权限矩阵

| 端点类别 | SUPER_ADMIN | OPERATOR | FINANCE | READONLY |
|---|:-:|:-:|:-:|:-:|
| 仪表盘 | ✓ | ✓ | ✓ | ✓ |
| 租户列表/详情/成员/项目 | ✓ | ✓ | ✓ | ✓ |
| 租户暂停/恢复/套餐变更 | ✓ | ✓ | | |
| **租户充值** | ✓ | | ✓ | |
| 租户配额修改 | ✓ | | | |
| 租户 impersonate | ✓ | ✓ | | |
| 用户禁用/强制下线/重置密码 | ✓ | ✓ | | |
| 计费记录查询/汇总 | ✓ | ✓ | ✓ | ✓ |
| 套餐参数修改 | ✓ | | | |
| 运营人员管理 | ✓ | | | |
| 审计日志查询 | ✓（全量） | ✓（自己） | ✓（自己） | ✓（自己） |

---

## 6. 模块代码组织

```
yaoai-admin/
├── pom.xml                                       # 依赖 yaoai-domain / yaoai-billing / yaoai-security / yaoai-common
└── src/main/java/com/yaoai/admin/
    ├── AdminModuleConfig.java
    ├── auth/
    │   ├── entity/AdminUser.java
    │   ├── mapper/AdminUserMapper.java           # @InterceptorIgnore(tenantLine="true")
    │   ├── AdminAuthController.java
    │   ├── AdminAuthService.java
    │   ├── StpAdminUtil.java                     # StpLogic("admin")
    │   └── AdminPermissionInterface.java         # 实现 StpInterface 注入运营角色
    ├── dashboard/
    ├── tenant/
    │   ├── TenantAdminController.java
    │   ├── TenantAdminService.java
    │   ├── ImpersonateService.java
    │   └── dto/...
    ├── user/
    ├── billing/
    ├── plan/
    ├── audit/
    │   ├── entity/AdminAuditLog.java
    │   ├── annotation/AdminAudited.java
    │   ├── aspect/AdminAuditAspect.java
    │   └── AdminAuditService.java
    └── config/
        ├── SaTokenAdminConfig.java               # 注册 StpLogic + 路由拦截
        ├── AdminGlobalExceptionHandler.java
        └── AdminWebConfig.java                   # /admin-api/** 路由
```

---

## 7. 关键实现要点

### 7.1 Sa-Token 双账号注册

```java
@Component
public class StpAdminUtil {
    public static final StpLogic INSTANCE = new StpLogic("admin");
    static { SaManager.putStpLogic(INSTANCE); }

    public static void login(Long adminId) { INSTANCE.login(adminId); }
    public static long getLoginIdAsLong() { return INSTANCE.getLoginIdAsLong(); }
    // ...
}

// Controller 用法
@SaCheckLogin(type = "admin")
@SaCheckRole(value = "SUPER_ADMIN", type = "admin")
@PostMapping("/admin-api/admin-users")
```

### 7.2 审计切面（@AdminAudited）

```java
@AdminAudited(action = "TENANT_RECHARGE", resource = "TENANT", resourceIdParam = "tenantId")
public BalanceDTO recharge(Long tenantId, BigDecimal amount, String note) { ... }
```

切面在方法前后抓取实体快照，写入 `admin_audit_logs` 的 `before_value` / `after_value`。失败也记录（status=FAILED）。

### 7.3 充值流程（无支付的极简版）

```
1. 校验 amount > 0 且 <= 上限（防误操作，例如单笔 ≤ 1000 万积分）
2. 校验 tenant 存在且 status != SUSPENDED
3. 调用 BillingService.recharge(tenantId, amount, "ADMIN:" + adminId)
4. billing_records 写一条 RECHARGE，created_by_admin_id = currentAdminId
5. 切面写 admin_audit_logs（before/after 余额）
```

### 7.4 Impersonate 模拟登录

```
1. 校验运营有 IMPERSONATE 权限
2. 选择目标租户的 owner 用户（或指定 userId）
3. 用客户端 StpUtil 颁发临时 token，TTL = 30 分钟
4. token 的 session 写入 impersonatedBy = adminId（用于服务端追溯）
5. 审计日志记录 IMPERSONATE_START + 租户 ID + 目标 userId
6. 客户端用此 token 访问 /api/**，所有操作都额外带 X-Impersonated-By header
```

### 7.5 套餐变更同步配额

变更套餐时事务内同步执行：
```
UPDATE tenants SET plan = ? WHERE id = ?;
INSERT INTO tenant_quotas (...) ON DUPLICATE KEY UPDATE max_members = ?, ...;
```
配额来自 `plans` 表对应行；如果租户已有 `tenant_quotas` 行（企业版定制），保留覆盖项。

### 7.6 首个 SUPER_ADMIN 怎么来

Flyway V13 INSERT 一条 `admin / admin@123!`（BCrypt salt=12）。系统检测到是默认密码时，登录后强制弹"必须修改密码"对话框。

### 7.7 ⭐ charge() 签名升级与 5 个调用点改造

**Step 1: BillingService 接口加新签名（保留旧的做兜底）**

```java
public interface BillingService {
    /** @deprecated 用 charge(BillingChargeRequest) */
    @Deprecated
    void charge(Long tenantId, Long projectId, String operation, String refId);

    void charge(BillingChargeRequest req);
}
```

**Step 2: BillingServiceImpl.charge(req) 实现**

```java
@Override
@Transactional
@CacheEvict(value = "balance", key = "#req.tenantId")
public void charge(BillingChargeRequest req) {
    if (billingConfig.isOff()) return;

    BigDecimal cost = req.getCredits() != null
        ? req.getCredits()
        : billingConfig.getCost(req.getOperation());
    if (cost.compareTo(BigDecimal.ZERO) <= 0) return;

    if (billingConfig.isStrict() && Boolean.TRUE.equals(req.getSuccess())) {
        int rows = tenantBalanceMapper.tryDeduct(req.getTenantId(), cost);
        if (rows == 0) throw new BizException(ErrorCode.INVALID_PARAM, "积分余额不足");
    }

    BillingRecord r = new BillingRecord();
    r.setTenantId(req.getTenantId());
    r.setUserId(req.getUserId());
    r.setProjectId(req.getProjectId());
    r.setOperation(req.getOperation());
    r.setModality(req.getModality());
    r.setModelProvider(req.getModelProvider());
    r.setModelId(req.getModelId());
    r.setModelName(req.getModelName());
    r.setUnitCount(req.getUnitCount() == null ? 1 : req.getUnitCount());
    r.setSuccess(req.getSuccess() == null || req.getSuccess());
    r.setCredits(cost);
    r.setBillingMode(billingConfig.getMode());
    r.setRefId(req.getRefId());
    if (req.getMeta() != null) r.setBillingMeta(JSON.toJSONString(req.getMeta()));
    billingRecordMapper.insert(r);
}
```

**Step 3: 5 个调用点改造示例**

OutlinePipelineServiceImpl.java:92 改造前：
```java
billingService.charge(tenantId, projectId, "outline_generate", String.valueOf(projectId));
```

改造后：
```java
billingService.charge(BillingChargeRequest.builder()
    .tenantId(tenantId).userId(StpUtil.getLoginIdAsLong())
    .projectId(projectId)
    .operation("outline_generate")
    .modality("TEXT")
    .modelProvider("volcengine")
    .modelId(arkLlmService.currentModel())   // 从 ArkLlmService 暴露
    .modelName("豆包 Pro / Qwen Max")
    .unitCount(1)
    .meta(Map.of("tokenCount", outline.getTokenCount()))
    .refId(String.valueOf(projectId))
    .build());
```

ImageGenPipelineServiceImpl.java:42 改造：
```java
billingService.charge(BillingChargeRequest.builder()
    .tenantId(tenantId).userId(currentUserId).projectId(projectId)
    .operation("image_generate")
    .modality("IMAGE")
    .modelProvider("volcengine")
    .modelId(seedreamService.modelId())      // "doubao-seedream-3-0-t2i-250415"
    .modelName("Seedream 3.0 文生图")
    .unitCount(1)
    .meta(Map.of("size", "2048x2048", "purpose", "scene"))
    .refId(key)
    .build());
```

视频/合成同理。**所有 AI Provider Service 必须暴露 `modelId()` 方法**（`SeedreamService.modelId()` / `SeedanceService.modelId()` / `ArkLlmService.currentModel()`），让 charge 调用方拿到。

**Step 4: 失败也要记一笔（success=false, credits=0）**

任务失败时 charge 一条 `success=false, credits=0` 的记录，便于运营回答"为什么生成 10 次扣了 30 积分但只看到 5 张图"——另外 5 次失败也有迹可循。

### 7.8 积分明细的 SQL 模板（运营最常问）

按模型汇总：
```sql
SELECT model_provider, model_id, model_name,
       SUM(credits) AS total_credits,
       SUM(unit_count) AS total_units,
       SUM(IF(success, 1, 0)) AS success_count,
       SUM(IF(success, 0, 1)) AS fail_count
FROM billing_records
WHERE tenant_id = ?
  AND created_at BETWEEN ? AND ?
  AND modality != 'RECHARGE'
GROUP BY model_provider, model_id, model_name
ORDER BY total_credits DESC;
```

按用户汇总：
```sql
SELECT br.user_id, u.email,
       SUM(br.credits) AS spent,
       COUNT(*) AS ops
FROM billing_records br
LEFT JOIN users u ON u.id = br.user_id
WHERE br.tenant_id = ?
  AND br.created_at BETWEEN ? AND ?
  AND br.modality != 'RECHARGE'
GROUP BY br.user_id, u.email
ORDER BY spent DESC;
```

按天趋势：
```sql
SELECT DATE(created_at) AS day,
       SUM(IF(modality='TEXT',  credits, 0)) AS text_credits,
       SUM(IF(modality='IMAGE', credits, 0)) AS image_credits,
       SUM(IF(modality='VIDEO', credits, 0)) AS video_credits,
       SUM(IF(modality='RECHARGE', credits, 0)) AS recharge
FROM billing_records
WHERE tenant_id = ?
  AND created_at BETWEEN ? AND ?
GROUP BY DATE(created_at)
ORDER BY day;
```

---

## 8. 前端项目: yaoai-admin-web

### 8.1 技术栈

| 项 | 选择 |
|---|---|
| 脚手架 | **vue-vben-admin v5** ([github.com/vbenjs/vue-vben-admin](https://github.com/vbenjs/vue-vben-admin)) |
| Vue | 3.5+ |
| 构建 | Vite 6 |
| 语言 | TypeScript 5 |
| UI | Element Plus（vben 已封装） |
| 状态 | Pinia |
| 表单/表格 | VbenForm / VbenVxeTable（vben 自带，基于 vxe-table） |
| HTTP | Axios（vben 封装） |
| 包管理 | pnpm |

### 8.2 初始化策略

vben v5 是 monorepo 结构，含多个 apps（web-ele / web-antd / web-naive）。我们的策略：

```bash
# 项目根目录
cd F:/yaoaiwork/yaoaivideo
git clone https://github.com/vbenjs/vue-vben-admin.git yaoai-admin-web
cd yaoai-admin-web

# 只保留 apps/web-ele（Element Plus 版）和必要的 packages/*
# 删除 apps/web-antd / apps/web-naive / docs / playground
# 把 apps/web-ele 重命名为 apps/admin

pnpm install
pnpm dev:admin
```

> 也可以用 vben "极简版"（standalone），但 monorepo 结构对未来扩展（比如再做一个客户公司的二级管理后台）更友好。

### 8.3 关键集成改造

#### (1) HTTP 客户端对接 Sa-Token

vben 默认用 Authorization Bearer，改为 Sa-Token 习惯的 `satoken-admin` header：

```typescript
// apps/admin/src/api/request.ts
import { useAccessStore } from '@vben/stores';

export const adminRequest = createAxiosInstance({
  baseURL: import.meta.env.VITE_GLOB_API_URL, // /admin-api
});

adminRequest.addRequestInterceptor({
  fulfilled: (config) => {
    const token = useAccessStore().accessToken;
    if (token) config.headers['satoken-admin'] = token;
    return config;
  },
});

adminRequest.addResponseInterceptor({
  rejected: (err) => {
    if (err.response?.status === 401) {
      useAccessStore().setAccessToken(null);
      router.replace('/auth/login');
    }
    return Promise.reject(err);
  },
});
```

#### (2) 登录/用户信息接口

```typescript
// apps/admin/src/api/core/auth.ts
export const loginApi = (data: { username: string; password: string }) =>
  adminRequest.post<{ token: string }>('/auth/login', data);

export const getUserInfoApi = () =>
  adminRequest.get<AdminUserInfo>('/auth/me');

export const logoutApi = () => adminRequest.post('/auth/logout');
```

#### (3) 路由与菜单（静态 + 角色过滤）

不走 vben 默认的"完全动态路由从后端拉"，MVP 阶段用静态路由 + `meta.authority` 按角色过滤：

```typescript
// apps/admin/src/router/routes/modules/admin.ts
const routes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    component: BasicLayout,
    meta: { title: '仪表盘', icon: 'lucide:layout-dashboard' },
    children: [{ path: '', component: () => import('#/views/dashboard/index.vue') }],
  },
  { path: '/tenants', meta: { title: '租户管理', icon: 'lucide:building-2' }, children: [...] },
  { path: '/users', meta: { title: '用户管理', icon: 'lucide:users' }, children: [...] },
  { path: '/billing', meta: { title: '计费记录', icon: 'lucide:receipt' }, children: [...] },
  { path: '/plans', meta: { title: '套餐管理', icon: 'lucide:package', authority: ['SUPER_ADMIN'] }, children: [...] },
  { path: '/admin-users', meta: { title: '运营人员', icon: 'lucide:shield', authority: ['SUPER_ADMIN'] }, children: [...] },
  { path: '/audit-logs', meta: { title: '审计日志', icon: 'lucide:scroll-text' }, children: [...] },
];
```

#### (4) 按钮级权限

vben 提供 `<AccessControl>` 组件 + `hasAccessByRoles()` hook：

```vue
<AccessControl :codes="['SUPER_ADMIN', 'FINANCE']">
  <Button type="primary" @click="openRechargeModal">充值</Button>
</AccessControl>
```

### 8.4 页面实现要点

| 页面 | 主要 vben 组件 | 关键点 |
|---|---|---|
| AdminLogin | 自定义（vben 默认登录页改造） | 登录后存 token + 用户信息到 Pinia |
| Dashboard | VbenCountUp + ECharts（vben 内置） | 4 个总览卡 + 30 天趋势折线图（按模态分色） |
| TenantList | VbenVxeTable + VbenForm | 表格分页/搜索/批量操作 |
| TenantDetail | Tabs + Descriptions | **5 个 Tab：基本/余额/成员/项目/⭐积分明细** |
| 充值弹窗 | VbenModal + VbenForm | 二次确认 + 金额上限校验 + 备注必填 |
| **⭐ 积分明细 Tab** | VbenVxeTable + ECharts(饼图+柱图) + VbenForm 筛选 | 4 种 groupBy 视图切换：模态/模型/用户/天 |
| BillingRecords | VbenVxeTable + 模型筛选下拉 | 全平台明细表，列：时间/租户/用户/模态/模型/数量/积分/成功 |
| AuditLogs | VbenVxeTable + VbenDrawer | 详情抽屉展开 before/after JSON 对比 |
| AdminUserManagement | VbenVxeTable + 表单弹窗 | 仅 SUPER_ADMIN 可见 |

**积分明细 Tab 布局（核心新增）**：

```
┌─ 时间范围筛选: [近7天] [近30天] [本月] [自定义] ─┐
│                                                    │
├─ Tabs: [按模型] [按模态] [按用户] [按天]           │
│                                                    │
│   按模型视图：                                      │
│   ┌─ 饼图 ──────────┬─ 表格 ──────────────────┐  │
│   │ Seedream  43.7% │ 模型 │ 调用 │ 积分 │ 失败 │  │
│   │ Seedance  38.9% │ ...                      │  │
│   │ Qwen Max  17.4% │                          │  │
│   └─────────────────┴──────────────────────────┘  │
│                                                    │
│   按天视图：                                        │
│   ┌─ 堆叠柱图（文本/图/视频/合成 4 色）────────┐  │
│   │  ▮▮▮▮▮▮▮▮▮▮ 30 天趋势                    │  │
│   └────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘
```

### 8.5 部署

```
yaoai-admin-web/apps/admin/dist/  (Vite build 产物)
       ↓
   nginx
       ├─ /  → 静态文件
       └─ /admin-api/* → 反代到 admin server :8081
```

```dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build:admin

FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```

### 8.6 安全部署

- 独立域名 `admin.yaoai.com` 或子路径 `yaoai.com/admin/`
- nginx 配置 IP 白名单（公司公网出口 / VPN 网段）
- 强制 HTTPS
- CSP header：`default-src 'self'`，禁第三方资源
- 浏览器关闭后自动登出（Sa-Token 设置 token-style=`tik`，前端不持久化 token 到 localStorage，改用 sessionStorage）

---

## 9. 实施路线图（M14）

> ⚠️ Flyway 当前最新版本 V12（V10/V11/V12 已被占用），M14 从 **V13** 开始。

| Step | 内容 | 工时 |
|:-:|---|:-:|
| 1 | Flyway V13：4 张新表 + tenants ALTER + ⭐billing_records 8 列升级 + 套餐/初始 admin 数据 | 0.5d |
| 2 | ⭐ BillingService.charge(req) 新签名 + 5 个调用点改造 + Provider 暴露 modelId() | **1d** |
| 3 | `yaoai-admin` 模块骨架 + Sa-Token 双账号 + 登录/me API | 1d |
| 4 | `@AdminAudited` 切面 + admin_audit_logs 写入 + 异常处理 | 0.5d |
| 5 | 仪表盘 + 租户管理（列表/详情/暂停/恢复/套餐变更） | 1.5d |
| 6 | 租户充值 + impersonate + 配额管理 | 0.5d |
| 7 | ⭐ 积分明细 API（按模态/模型/用户/天的 4 种汇总）+ 单租户 usage-detail | 1d |
| 8 | 用户管理 + 计费记录 + 套餐管理 + 运营人员管理 + 审计查询 | 1.5d |
| 9 | yaoai-admin-web 脚手架初始化 + Sa-Token 集成 + 9 页落地（含积分明细 Tab + ECharts 饼/柱图） | **2d** |
| 10 | 联调 + Helm chart 增加 admin-server Deployment + nginx 反代 | 1d |
| **合计** | | **~10d** |

**新增核心交付物：**
- ⭐ 升级后的 `billing_records`：每条都能回答"用谁/哪个模型/做什么/几次/成不成"
- ⭐ TenantDetail 的"积分明细" Tab：饼图按模型分布、柱图按天分模态、表格可下钻到用户
- 全平台 BillingRecords 页：跨租户多维度筛选

---

## 10. 安全与运维

### 10.1 网络
- admin server 不暴露公网，走内部 ingress
- 前端独立域名 + IP 白名单

### 10.2 账号
- 强密码策略（≥10 位，大小写+数字+特殊字符）
- 默认账号首次登录强制改密
- 登录失败 5 次锁定 30 分钟（Sa-Token 内置）
- Token TTL 2 小时 + 滑动续期 30 分钟

### 10.3 审计
- 所有"写"操作必须有 `@AdminAudited`
- impersonate 全程留痕（开始/结束 + 期间客户端 token 记 `impersonatedBy`）
- audit_logs 保留 365 天，超期归档到冷存储

### 10.4 监控
- 复用现有 Prometheus + Grafana（设计文档 10）
- 新增面板：admin 登录次数、充值笔数/金额、impersonate 次数、失败操作率

---

## 11. 与现有代码的迁移

### 11.1 删除/迁移
- `yaoai-api/.../admin/AdminController.java` → 删除
- 业务逻辑迁移到 `yaoai-admin/.../tenant/TenantAdminController.java` 等
- 现有 `users.role` 字段保留（客户端用 `OWNER/ADMIN/MEMBER` 这种租户内角色），不再混用 admin

### 11.2 兼容性
- 前端 `doc/html` 中没有调用 `/admin/**`，迁移无影响
- 部署配置：admin 端点的 Spring profile 拆开，未启用 admin profile 时 `yaoai-admin` 模块不加载

---

## 12. 验收标准

- [ ] super_admin 登录后能看到 9 个菜单项
- [ ] finance 登录后看不到运营人员管理菜单
- [ ] readonly 登录后所有"操作"按钮置灰
- [ ] 给 SUSPENDED 租户充值返回 400
- [ ] 充值 1000 积分后，租户客户端余额刷新即时同步
- [ ] 操作日志能查到充值前后的余额对比
- [ ] impersonate 进入客户端后，关闭浏览器再回来要求重新登录（不污染原 admin token）
- [ ] 套餐从 PRO 改为 TEAM 后，租户配额 max_members 同步更新
- [ ] ⭐ 在租户详情"积分明细"Tab 切换到"按模型"，能看到 Seedream/Seedance/Qwen 三种模型各自的积分占比饼图
- [ ] ⭐ 切换到"按天"，能看到 30 天趋势的堆叠柱图（文本/图/视频/合成 4 色分层）
- [ ] ⭐ 切换到"按用户"，能看到该租户内消费排行（用户邮箱 + 积分 + 操作次数）
- [ ] ⭐ 任务失败时也写一条 success=false / credits=0 的 billing_record，运营能在明细里看到失败次数
- [ ] ⭐ 全平台 BillingRecords 页，按"模型 = Seedream"过滤后只剩图像生成记录
