Files
sub2api-add/docs/SUBSCRIPTION_PLUGIN_V1_RFC.md
T
Qiufeng 5feae3ad41
Business Plugins CI / check (plugin-admin) (push) Successful in 3m13s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m41s
chore: initialize standalone business plugin repository
2026-08-27 23:36:08 +08:00

255 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 余额订阅业务插件 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、测试管理员和测试订阅数据已准备,生产余额尚未接入。