255 lines
13 KiB
Markdown
255 lines
13 KiB
Markdown
# 余额订阅业务插件 V1 RFC
|
||
|
||
状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft
|
||
|
||
本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务插件试验,对应实现为 `plugins/subscription-admin`。插件是独立端口的管理员后台,复用 Sub2API Core 的管理员鉴权,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。
|
||
|
||
## 1. 目标和范围
|
||
|
||
### 1.1 V1 目标
|
||
|
||
- 提供独立的管理员订阅后台,普通账号拒绝登录和访问;
|
||
- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码;
|
||
- 展示 Core 中的套餐、用户余额和订阅实例状态;
|
||
- 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开;
|
||
- 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅;
|
||
- 记录未来订阅写操作所需的业务语义、幂等和审计契约,但本阶段不执行扣款。
|
||
|
||
### 1.2 V1 非目标
|
||
|
||
- 不接入支付宝、微信、Stripe 或其他外部支付渠道;
|
||
- 不允许普通用户登录插件后台或在插件内自助购买;
|
||
- 不把余额、套餐、订阅额度复制成插件自己的权威账本;
|
||
- 不把每次网关请求改成调用插件 RPC;
|
||
- 不通过多个现有 Admin API 调用拼接一次购买;
|
||
- 不在本次设计阶段修改 Core 代码、数据库迁移、现有前端路由或 `.s2plugin` ABI。
|
||
|
||
## 2. 业务语义(供后续 Core 原子接口使用)
|
||
|
||
订阅插件以后需要保持当前业务语义,写操作必须由 Core 在单事务内完成:
|
||
|
||
1. **支持多个同档位**:同一用户可以拥有多个相同套餐/档位的订阅实例。每个实例有独立的 `subscription_id`、期限、配额、用量和审计记录;实例数量受套餐的上限字段约束。
|
||
2. **支持单独续费**:续费请求必须指定 `subscription_id`,只延长目标实例的期限或按 Core 规则生成续费记录,不影响同用户的其他同档位实例。
|
||
3. **不可由用户取消**:插件和用户端不提供“取消订阅”入口。管理员撤销属于单独的受控操作,需 Core 权限、原因、二次确认和审计;它不等同于用户取消。
|
||
4. **购买时快照**:价格、货币、有效期、额度、Group 覆盖和实例规则以购买时版本写入 Core 快照,后续编辑套餐不静默改写已购买实例。
|
||
5. **余额付款**:余额扣减、订阅创建/续费、余额流水、幂等记录和审计必须在 Core 同一个事务边界内完成。
|
||
|
||
这些语义是插件的业务约束,不代表当前 Core 已经提供了对应的业务插件 API。第一阶段只验证读取现有订阅数据,写入契约单独评审。
|
||
|
||
## 3. 推荐拓扑
|
||
|
||
```text
|
||
Core 管理后台
|
||
└─ custom_menu_items (visibility=admin)
|
||
└─ sandbox iframe / 新窗口
|
||
└─ 反向代理 -> Plugin `127.0.0.1:PLUGIN_PORT`
|
||
├─ Plugin /login -> Core /api/v1/auth/login (+ /login/2fa)
|
||
├─ Plugin /auth/me -> 只允许 role=admin
|
||
├─ HttpOnly 插件会话(不建 Core 用户表)
|
||
└─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读)
|
||
```
|
||
|
||
插件 UI 只访问自己的 BFF;Core JWT、refresh token 和 Admin API Key 只在插件服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页属于预期行为。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,插件必须提供新窗口登录路径;登录密码只在一次转发请求中经过插件后端,不落库、不写日志。
|
||
|
||
## 4. 权威边界
|
||
|
||
### 4.1 必须留在 Core
|
||
|
||
- 用户身份、管理员权限和资源授权;
|
||
- 用户余额和余额流水;
|
||
- 套餐价格、货币、有效期、额度、包含的 Group、实例模式和上限;
|
||
- 购买时的套餐快照;
|
||
- 订阅实例状态、期限、配额、reserved 和用量;
|
||
- 余额扣减、订阅创建/续费、撤销、退款/补偿;
|
||
- 额度预留、实际结算、幂等、防超卖、缓存失效和网关放行判定。
|
||
|
||
### 4.2 插件可以负责
|
||
|
||
- 管理员登录代理、插件会话和插件内角色;
|
||
- 套餐、用户和订阅的分页筛选与展示;
|
||
- 只读缓存、操作结果页和管理员审计视图;
|
||
- 未来写操作的确认表单,但提交必须调用 Core 原子命令;
|
||
- 插件 UI 的版本和发布。
|
||
|
||
插件的本地副本可删除、可重建、带来源版本和更新时间,不能作为网关授权或扣费依据。
|
||
|
||
## 5. V1 现有 Core API 适配
|
||
|
||
V1 不调用尚不存在的 `/api/v1/plugin-host/*`。插件后端通过严格 allowlist 调用 Core 已有接口,具体路径按部署的 Core 版本冻结:
|
||
|
||
```http
|
||
POST /api/v1/auth/login
|
||
POST /api/v1/auth/login/2fa
|
||
POST /api/v1/auth/refresh
|
||
POST /api/v1/auth/logout
|
||
GET /api/v1/auth/me
|
||
GET /api/v1/settings/public
|
||
GET /api/v1/admin/payment/plans
|
||
GET /api/v1/admin/subscriptions
|
||
GET /api/v1/admin/subscriptions/{id}
|
||
GET /api/v1/admin/users/{id}
|
||
GET /api/v1/admin/users/{id}/subscriptions
|
||
```
|
||
|
||
请求头只由插件后端添加:
|
||
|
||
```http
|
||
Authorization: Bearer CORE_ACCESS_TOKEN
|
||
X-Request-Id: UNIQUE_REQUEST_ID
|
||
```
|
||
|
||
列表接口必须设置分页、字段最小化、超时和审计。插件配置列出具体方法和路径,不能使用任意 URL 或宽泛通配符。Core 返回 `401` 时,插件先尝试一次 refresh;refresh 失败就销毁插件会话并要求重新登录。
|
||
|
||
## 6. 未来写操作契约(V1.1,当前不实现)
|
||
|
||
现有 Core Admin API 的 `assign`、`extend`、`revoke` 等操作属于 Core 管理面,当前不把它们组合成余额购买流程。要在插件内支持余额购买,Core 需要增加版本化原子命令或等价 Host Adapter,并在 Core 内完成余额、订单/购买记录、订阅和审计的一致性事务。
|
||
|
||
### 6.1 余额购买请求示例
|
||
|
||
```http
|
||
POST /api/v1/plugin-host/v1/subscription-purchases/balance
|
||
Authorization: Bearer PLUGIN_ACCESS_TOKEN
|
||
Idempotency-Key: subscription-purchase-UNIQUE_ID
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"user_id": 123,
|
||
"plan_id": 12,
|
||
"expected_plan_version": 4,
|
||
"expected_price": "19.00",
|
||
"currency": "USD",
|
||
"request_id": "UNIQUE_REQUEST_ID"
|
||
}
|
||
```
|
||
|
||
该路径是未来接口示例,当前 Core 没有实现。Core 必须重新读取当前套餐和余额,`expected_*` 只用于发现界面陈旧;价格以字符串 decimal 处理,不使用二进制浮点数。
|
||
|
||
### 6.2 Core 事务要求
|
||
|
||
未来 Core 原子命令至少需要:
|
||
|
||
1. 校验操作者为管理员并通过资源级授权;
|
||
2. 锁定用户余额、套餐购买上限和目标订阅资源;
|
||
3. 校验套餐可售、Group 覆盖、余额和实例上限;
|
||
4. 写入购买快照;
|
||
5. 扣余额并写余额流水;
|
||
6. 创建新实例或仅续费指定 `subscription_id`;
|
||
7. 写唯一 operation、幂等记录和管理员审计;
|
||
8. 事务提交后失效相关缓存。
|
||
|
||
任一步失败都回滚整笔命令。插件不接触 SQL transaction,也不执行失败后的自行补偿。
|
||
|
||
### 6.3 操作状态和重试
|
||
|
||
```text
|
||
accepted -> processing -> completed
|
||
└── failed
|
||
```
|
||
|
||
同一 `Idempotency-Key` 重试返回第一次操作结果,不重复扣款。网络超时后插件查询 operation 状态,不再次创建购买。`401/403` 不重试;`429` 按 `Retry-After` 有上限退避;只有明确幂等命令才允许对 `5xx` 重试。
|
||
|
||
## 7. 同档位实例和续费规则
|
||
|
||
未来实现必须覆盖以下测试矩阵:
|
||
|
||
| 场景 | 预期结果 |
|
||
|---|---|
|
||
| 同一用户购买两个相同档位 | 产生两个独立实例,各自有期限、配额和 `subscription_id`。 |
|
||
| 续费实例 A | 只改变实例 A;实例 B 的期限和配额保持不变。 |
|
||
| 达到实例上限 | 返回稳定业务错误,不扣余额、不创建半成品订阅。 |
|
||
| 用户尝试取消 | 插件没有取消入口;Core 用户接口也不提供用户自助取消语义。 |
|
||
| 管理员撤销 | 走单独的受控 Core 操作,要求原因、审计和明确的退款/补偿规则。 |
|
||
| 两个管理员并发购买 | 由 Core 锁和幂等保证不超卖;失败方查询最终状态。 |
|
||
|
||
## 8. 套餐快照和变更
|
||
|
||
推荐后续 Core 设计提供不可变套餐版本或购买快照,至少包含价格、货币、有效期、三类额度、覆盖 Group、实例模式和上限。当前 schema 的运行时 Group 覆盖和套餐字段存在可变性,不能在本 RFC 中宣称已经提供完整快照保证。
|
||
|
||
套餐下架只影响新购买;已有订阅如何处理由 Core 现有授权和计费规则决定。若要迁移已有实例,需要单独的预览、影响数量、确认 token、幂等键和审计命令。
|
||
|
||
## 9. 管理员 UI V1
|
||
|
||
首版只读页面建议顺序:
|
||
|
||
1. 概览:Core 连接状态、插件版本、最后同步时间和健康状态;
|
||
2. 套餐:价格、货币、有效期、额度、覆盖 Group 和可售状态;
|
||
3. 用户订阅:按用户 ID、脱敏名称、订阅状态、实例档位和到期时间查询;
|
||
4. 订阅详情:展示单个 `subscription_id` 的期限、窗口额度、用量和来源版本;
|
||
5. 操作记录:展示操作者、Core request ID、结果和时间;
|
||
6. 设置:Core 地址、allowlist、会话/凭据状态和健康检查;secret 只允许轮换,不允许回显。
|
||
|
||
余额购买、单独续费和管理员撤销在 V1 只显示“未启用”状态,不渲染可提交按钮,避免误触发现有非原子接口。
|
||
|
||
## 10. 安全验收
|
||
|
||
- 页面源码、网络请求、下载文件和浏览器存储中没有 Admin Key 或 Core JWT;
|
||
- 普通用户使用 Core JWT 登录插件返回 `403`,直接访问插件 API 也返回 `403`;
|
||
- Core access/refresh token 不进入插件响应、页面、日志和异常堆栈;
|
||
- 插件后端只访问配置中的 Core API 路径,禁止 SSRF 和任意 URL 代理;
|
||
- 插件服务停用后,已有订阅仍按 Core 原有网关鉴权和计费逻辑运行;
|
||
- 清空插件本地缓存后,可从 Core 重建只读列表,不影响核心余额和订阅;
|
||
- 用户端没有取消订阅入口,管理员撤销(未来启用时)必须有原因、审计和幂等键。
|
||
|
||
## 11. 测试计划
|
||
|
||
### 11.1 插件后端
|
||
|
||
- 管理员登录、2FA、普通用户拒绝、会话过期、登出和 CSRF;
|
||
- Core token refresh、撤销和 Core `401/403/429/5xx` 处理;
|
||
- 套餐/余额/订阅查询的分页、字段脱敏、超时和缓存重建;
|
||
- allowlist 拒绝未声明路径、任意 URL 和跨插件会话;
|
||
- 多实例会话存储和服务重启后的会话策略。
|
||
|
||
### 11.2 未来 Core 写操作
|
||
|
||
- 两个同档位实例、目标实例单独续费和实例上限;
|
||
- 用户取消入口不存在,管理员撤销的审计和补偿规则;
|
||
- 余额不足、套餐下架、价格版本冲突、并发购买和幂等重试;
|
||
- 事务失败时余额、订单、订阅和审计均保持原状;
|
||
- 购买快照内容不可变、缓存只在提交后失效。
|
||
|
||
### 11.3 浏览器和部署
|
||
|
||
- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝;
|
||
- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露;
|
||
- DevTools 请求、下载、页面源和日志中没有 secret;
|
||
- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。
|
||
|
||
## 12. 分阶段实施计划
|
||
|
||
### Phase 0:框架契约
|
||
|
||
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
|
||
- 冻结 Core 登录/2FA、管理员角色判断和现有只读 API allowlist;
|
||
- 确认会话存储、Core token 生命周期、日志脱敏和 secret 轮换;
|
||
- 不改 Core 代码、迁移、现有插件 ABI 或前端路由。
|
||
|
||
### Phase 1:插件框架最小实现
|
||
|
||
- 独立服务目录、清单、签名和部署兼容性检查;
|
||
- Core 登录代理、管理员角色校验、HttpOnly 插件会话和 CSRF;
|
||
- Core API BFF、健康检查、审计和只读示例页;
|
||
- 本地 contract test、浏览器验收和反向代理样例。
|
||
|
||
### Phase 2:订阅只读插件
|
||
|
||
- 套餐、用户余额、订阅实例和操作记录只读查询;
|
||
- 同档位实例/单独续费/不可取消语义的 UI 展示与测试数据;
|
||
- 只接入测试 Core,不连接生产余额。
|
||
|
||
### Phase 3:单独立项的 Core 写能力
|
||
|
||
- 评审并实现 Core 原子余额购买/续费/撤销接口;
|
||
- 增加 Core 幂等存储、审计和购买快照后,再接入插件写操作;
|
||
- 通过并发、事务、浏览器和回滚门禁后才启用生产 scope。
|
||
|
||
## 13. 实施前检查清单
|
||
|
||
- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。
|
||
- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。
|
||
- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。
|
||
- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。
|
||
- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。
|
||
- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。
|
||
- [ ] 测试 Core、测试管理员和测试订阅数据已准备,生产余额尚未接入。
|