# 余额订阅业务插件 V1 RFC 状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft 本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务模块试验,对应后端实现为 `plugins/subscription-admin`。订阅后端可以独立端口运行,但它不是第二个管理员后台:浏览器入口由 `plugins/plugin-admin` 统一控制面承载,复用同一套管理员会话、导航、CSRF 和权限,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。 > **前端架构修订(2026-08-30)**:本 RFC 中原有“订阅插件登录页/独立后台”描述由本条覆盖。订阅模块不得提供第二个 `/login`、独立 Cookie、独立管理员身份或重复的 Core 登录;安装并启用后才在 Plugin Admin 导航中出现。 ## 1. 目标和范围 ### 1.1 V1 目标 - 提供挂载在统一 Plugin Admin 控制面内的管理员订阅模块,普通账号拒绝登录和访问; - 由 Plugin Admin 统一调用 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 Admin `127.0.0.1:PLUGIN_PORT` ├─ Plugin Admin /login -> Core /api/v1/auth/login (+ /login/2fa) ├─ Plugin Admin /auth/me -> 只允许 role=admin ├─ 唯一 HttpOnly 控制面会话(不建 Core 用户表) ├─ 挂载订阅模块 /modules/subscription/*(不重复登录) └─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读) ``` 订阅模块 UI 只访问控制面的模块 BFF;Core JWT、refresh token 和 Admin API Key 只在控制面服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示一次 Plugin Admin 登录页属于预期行为;进入订阅模块时不再登录。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 浏览器和部署 - 管理员在 Plugin Admin 的 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` 菜单入口已确定。 - [ ] Plugin Admin 统一登录入口在 iframe 和新窗口场景均可用;订阅模块不提供独立登录,V1 不提供无感 SSO。 - [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。 - [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。 - [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。 - [ ] 测试 Core、测试管理员和测试订阅数据已准备,生产余额尚未接入。