feat: complete unified plugin admin v1.1.0
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit ada4ab3c21
69 changed files with 6681 additions and 901 deletions
+3
View File
@@ -7,6 +7,8 @@
bin/
dist/
build/
ui-build/
node_modules/
data/
.cache/
*.log
@@ -22,3 +24,4 @@ coverage.out
# Browser test output
playwright-report/
test-results/
.screenshots/
+26 -8
View File
@@ -9,11 +9,17 @@ HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、
下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。
- `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理
控制面,只有安装、启用并应用菜单后才会出现。
- `plugins/subscription-admin`:可选的订阅管理业务模块后端。它不是插件管理
控制面;只有安装并启用后才会挂载到 Plugin Admin 的统一导航中。
- `docs/`:插件框架、清单、边界、架构、开发和验收契约。
两个插件都是独立 Go module,可以分别构建和发布。生产环境应使用独立的
Plugin Admin 的浏览器控制面位于 `plugins/plugin-admin/ui-vue`,基于腾讯
TDesign Vue Next starter。运行 `plugins/plugin-admin/build-ui.sh` 会完成
TypeScript/Vite 构建,并把本地打包的 TDesign、线性图标和 ECharts 嵌入
Go 服务;官方 Core 的 `frontend/` 不参与构建。
控制面和业务模块后端可以独立构建和发布,但浏览器端只有一个 Plugin Admin
登录入口。生产环境应使用独立的
低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。
## 推荐部署顺序
@@ -48,12 +54,16 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。
## 接入顺序
1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。
2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。
1. 启动 `plugin-admin` 和需要的业务模块后端,各自监听独立端口。
2. 使用 Core 管理员账号登录 Plugin Admin 一次;普通账号被拒绝,业务模块不再单独登录。
3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。
4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。
5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。
订阅模块进入统一控制面后使用 `/modules/subscription/*` 路由。Plugin Admin
通过同源 BFF 代理固定的套餐、订阅和用户只读接口,浏览器不再访问订阅
服务的登录页,也不会创建第二个管理员会话。
Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端
不持有 Core JWT、Admin Key 或其他服务密钥。
@@ -62,14 +72,22 @@ Core 继续作为用户、余额、订阅、计费和用量账本的权威来源
Linux + systemd 环境可直接使用仓库内的安装脚本:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
| sudo bash -s -- --plugin all
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
```
生产安装必须提供发布提交的 `PLUGIN_COMMIT_SHA`;上面的 `RELEASE_SHA`
应从受信任的发布记录中复制,并与 `PLUGIN_REF` 对应。脚本默认使用
`v1.1.0` tag,但 tag 本身不作为完整性证明。可变分支和未 pin 的 tag
仅能在开发环境分别显式开启 `PLUGIN_ALLOW_MUTABLE_REF=true` 或
`PLUGIN_ALLOW_UNPINNED_TAG=true`。
默认卸载并保留配置/数据:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
| sudo bash -s -- --plugin all
```
+24 -8
View File
@@ -22,22 +22,35 @@ docker compose -f docker-compose.yml up -d
在一台已安装 Go 1.23+、Git 和 systemd 的 Linux 主机上:
```sh
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
```
生产安装必须固定 `PLUGIN_COMMIT_SHA`,并从受信任的发布记录复制
`RELEASE_SHA`。tag 只是选择版本,不是完整性证明。开发环境如需使用
可变分支或未 pin 的 tag,必须显式设置对应的开发开关。
```sh
export PLUGIN_ENV=development PLUGIN_REF=main PLUGIN_ALLOW_MUTABLE_REF=true
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
| sudo bash -s -- --plugin all
| sudo -E bash -s -- --plugin all
```
只安装控制面:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
| sudo bash -s -- --plugin plugin-admin
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin plugin-admin
```
安装订阅插件:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
| sudo bash -s -- --plugin subscription-admin
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin subscription-admin
```
默认安装位置:
@@ -63,7 +76,8 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin
`PLUGIN_CONFIG_KEY` 和受信发布者公钥。插件市场默认读取
`/var/lib/sub2api-add/plugin-admin/marketplace/index.json`;可通过
`PLUGIN_MARKETPLACE_INDEX` 指向受控本地索引或 HTTPS 索引,并用
`PLUGIN_MARKETPLACE_ALLOWED_HOSTS` 限定远程索引和包的精确主机。
`PLUGIN_MARKETPLACE_ALLOWED_HOSTS` 限定远程索引和包的精确主机。生产远程索引
还必须设置 `PLUGIN_MARKETPLACE_INDEX_SHA256`,把目录内容固定到已审核的摘要。
## 菜单接入
@@ -81,14 +95,16 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin
默认卸载服务和二进制,但保留配置与插件数据,便于重新安装:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
| sudo bash -s -- --plugin all
```
确认同时删除插件配置、插件数据和源码:
```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
RELEASE_SHA=COMMIT_SHA_40_HEX
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
| sudo bash -s -- --plugin all --purge --yes
```
+63 -3
View File
@@ -3,12 +3,56 @@ set -Eeuo pipefail
REPO_URL=${PLUGIN_REPO_URL:-https://git.awaioi.com/awaioi/sub2api-add.git}
SOURCE_DIR=${PLUGIN_SOURCE_DIR:-/opt/sub2api-add}
REF=${PLUGIN_REF:-main}
REF=${PLUGIN_REF:-v1.1.0}
EXPECTED_SHA=${PLUGIN_COMMIT_SHA:-}
ALLOW_MUTABLE_REF=${PLUGIN_ALLOW_MUTABLE_REF:-false}
DEPLOY_ENV=${PLUGIN_ENV:-production}
ALLOW_UNPINNED_TAG=${PLUGIN_ALLOW_UNPINNED_TAG:-false}
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
validate_path_components() {
local value=$1 label=$2 component current=""
[[ -n "$value" && "$value" = /* && "$value" != "/" ]] || die "$label 必须是非根绝对路径"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
local parts=()
IFS='/' read -r -a parts <<< "${value#/}"
for component in "${parts[@]}"; do
[[ -z "$component" ]] && continue
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
current="$current/$component"
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
done
}
validate_path_components "$SOURCE_DIR" PLUGIN_SOURCE_DIR
if [[ "$SOURCE_DIR" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
die "PLUGIN_SOURCE_DIR 必须以 sub2api-add 结尾;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
fi
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
command -v git >/dev/null 2>&1 || die "缺少 git"
is_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; }
is_release_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; }
[[ "$DEPLOY_ENV" == "development" || "$DEPLOY_ENV" == "production" ]] || die "PLUGIN_ENV 必须是 development 或 production"
if [[ -n "$EXPECTED_SHA" ]] && ! is_sha "$EXPECTED_SHA"; then
die "PLUGIN_COMMIT_SHA 必须是 40 位 commit SHA"
fi
if [[ "$ALLOW_MUTABLE_REF" == "true" && "$DEPLOY_ENV" != "development" ]]; then
die "PLUGIN_ALLOW_MUTABLE_REF=true 仅允许在 PLUGIN_ENV=development 使用"
fi
if ! is_sha "$REF" && ! is_release_tag "$REF" && [[ "$ALLOW_MUTABLE_REF" != "true" ]]; then
die "默认只接受不可变 commit SHA 或版本 tag;开发环境显式设置 PLUGIN_ALLOW_MUTABLE_REF=true 才能使用分支"
fi
if [[ -z "$EXPECTED_SHA" ]] && is_release_tag "$REF"; then
if [[ "$DEPLOY_ENV" != "development" || "$ALLOW_UNPINNED_TAG" != "true" ]]; then
die "使用版本 tag 时必须设置 PLUGIN_COMMIT_SHA;仅开发环境显式设置 PLUGIN_ALLOW_UNPINNED_TAG=true 才能跳过 pin"
fi
fi
if [[ "$ALLOW_UNPINNED_TAG" == "true" && "$DEPLOY_ENV" != "development" ]]; then
die "PLUGIN_ALLOW_UNPINNED_TAG=true 仅允许在 PLUGIN_ENV=development 使用"
fi
if [[ -e "$SOURCE_DIR" && ! -d "$SOURCE_DIR/.git" ]]; then
die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR"
fi
@@ -17,10 +61,26 @@ if [[ -d "$SOURCE_DIR/.git" ]]; then
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then
die "$SOURCE_DIR 有未提交修改,先清理后再升级"
fi
git -C "$SOURCE_DIR" fetch --depth 1 origin "$REF"
git -C "$SOURCE_DIR" fetch --depth 1 --no-tags origin "$REF"
git -C "$SOURCE_DIR" checkout --force FETCH_HEAD
else
git clone --depth 1 --branch "$REF" "$REPO_URL" "$SOURCE_DIR"
if is_sha "$REF"; then
git clone --depth 1 --no-tags "$REPO_URL" "$SOURCE_DIR"
git -C "$SOURCE_DIR" fetch --depth 1 --no-tags origin "$REF"
git -C "$SOURCE_DIR" checkout --force FETCH_HEAD
else
git clone --depth 1 --no-tags --branch "$REF" "$REPO_URL" "$SOURCE_DIR"
fi
fi
RESOLVED_SHA=$(git -C "$SOURCE_DIR" rev-parse HEAD)
NORMALIZED_RESOLVED_SHA=$(printf '%s' "$RESOLVED_SHA" | tr '[:upper:]' '[:lower:]')
NORMALIZED_EXPECTED_SHA=$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')
if [[ -n "$EXPECTED_SHA" && "$NORMALIZED_RESOLVED_SHA" != "$NORMALIZED_EXPECTED_SHA" ]]; then
die "检出的 commit 与 PLUGIN_COMMIT_SHA 不一致:$RESOLVED_SHA"
fi
if ! is_sha "$REF" && ! is_release_tag "$REF" && [[ "$ALLOW_MUTABLE_REF" == "true" ]]; then
printf '%s\n' '警告:当前使用可变 Git ref,仅限开发环境。' >&2
fi
exec "$SOURCE_DIR/scripts/install-local.sh" "$@"
+39 -6
View File
@@ -10,8 +10,27 @@ SELECTION=all
PURGE=false
YES=false
validate_managed_root() {
local value=$1 label=$2 component current=""
[[ -n "$value" && "$value" = /* ]] || die "$label 必须是绝对路径"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
[[ "$value" != "/" ]] || die "$label 不允许指向根目录"
local parts=()
IFS='/' read -r -a parts <<< "${value#/}"
for component in "${parts[@]}"; do
[[ -z "$component" ]] && continue
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
current="$current/$component"
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
done
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
fi
}
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
while (($#)); do
case "$1" in
@@ -29,6 +48,14 @@ case "$SELECTION" in
*) die "插件必须是 all、plugin-admin 或 subscription-admin" ;;
esac
validate_managed_root "$ETC_DIR" PLUGIN_ETC_DIR
validate_managed_root "$VAR_DIR" PLUGIN_VAR_DIR
validate_managed_root "$PREFIX" PLUGIN_INSTALL_PREFIX
validate_managed_root "$SOURCE_DIR" PLUGIN_SOURCE_DIR
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" != "$PREFIX" && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
die "为避免误删,--purge 默认要求 PLUGIN_SOURCE_DIR 与 PLUGIN_INSTALL_PREFIX 相同"
fi
if $PURGE && ! $YES; then
[[ -t 0 ]] || die "非交互环境执行 --purge 时必须同时提供 --yes"
read -r -p "将删除插件配置和数据,继续?[y/N] " answer
@@ -38,21 +65,27 @@ fi
for plugin in "${PLUGINS[@]}"; do
unit="sub2api-$plugin.service"
systemctl disable --now "$unit" 2>/dev/null || true
rm -f "/etc/systemd/system/$unit"
rm -f -- "/etc/systemd/system/$unit"
# Keep the checked-out source clean so a later install can fast-forward it.
# Only generated binaries are removed unless --purge is explicitly used.
rm -f "$PREFIX/$plugin/bin/$plugin"
rm -f -- "$PREFIX/$plugin/bin/$plugin"
if $PURGE; then
rm -rf "$PREFIX/$plugin"
rm -f "$ETC_DIR/$plugin.env"
rm -rf "$VAR_DIR/$plugin"
plugin_root="$PREFIX/$plugin"
if [[ -d "$plugin_root" && ! -L "$plugin_root" ]]; then
rm -rf -- "${plugin_root:?}"
fi
rm -f -- "$ETC_DIR/$plugin.env"
plugin_data="$VAR_DIR/$plugin"
if [[ -d "$plugin_data" && ! -L "$plugin_data" ]]; then
rm -rf -- "${plugin_data:?}"
fi
fi
done
systemctl daemon-reload
if $PURGE && [[ -d "$ETC_DIR" ]]; then rmdir "$ETC_DIR" 2>/dev/null || true; fi
if $PURGE && [[ -d "$VAR_DIR" ]]; then rmdir "$VAR_DIR" 2>/dev/null || true; fi
if $PURGE && [[ -d "$SOURCE_DIR" ]]; then rm -rf "$SOURCE_DIR"; fi
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" == "$PREFIX" && -d "$SOURCE_DIR" && ! -L "$SOURCE_DIR" ]]; then rm -rf -- "$SOURCE_DIR"; fi
if $PURGE; then
userdel "$RUN_USER" 2>/dev/null || true
+7 -3
View File
@@ -5,12 +5,15 @@
| AUTH-01 | 鉴权 | Core 管理员登录控制面 | `plugins/plugin-admin/main_test.go:TestAdminLoginDoesNotExposeCoreTokens`;本地浏览器登录 | passed |
| AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed |
| AUTH-03 | 鉴权 | 普通用户登录和 API | `plugins/plugin-admin/main_test.go:TestOrdinaryCoreUserIsRejected` | passed |
| AUTH-03A | 鉴权 | Core CAPTCHA 配置与一次性 proof | `TestCaptchaConfigReturnsOnlyPublicFields`、`TestLoginForwardsCaptchaProof`;登录页按 provider 渲染挑战 | passed |
| AUTH-04 | 会话 | 过期、撤销、登出和刷新 | `plugins/plugin-admin/main_test.go:TestRefreshRevalidatesAdminRole` | passed |
| AUTH-05 | CSRF | 所有写请求 | `plugins/plugin-admin/main_test.go:TestMutationRequiresCSRFAndIdempotency` | passed |
| SEC-01 | 秘密 | 浏览器、URL、HTML、JS、LocalStorage、下载、日志 | 登录/配置测试断言 token 和 secret 不回显;浏览器 DOM 未出现 Core token | passed |
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
| SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed |
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed |
| DEPLOY-01 | 发布完整性 | 生产安装提交 pin | `deploy/install.sh` 对 tag 要求 `PLUGIN_COMMIT_SHA`,并校验检出 commit;脚本可从 immutable commit URL 获取 | passed |
| DEPLOY-02 | 路径安全 | 安装/卸载根目录与父路径 | 安装路径拒绝根目录、`.`/`..` 和任一级符号链接父路径 | passed |
| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
| MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed |
@@ -21,7 +24,7 @@
| LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed |
| LIFE-04A | 删除接口 | `DELETE /api/plugins/{id}` 与卸载语义一致 | 市场生命周期测试覆盖标准 DELETE | passed |
| MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | passed |
| MENU-02 | 嵌入 | iframe 和新窗口 | 本地控制面三视口登录/刷新;插件提供独立登录和新窗口入口 | passed |
| MENU-02 | 嵌入 | iframe 和新窗口 | Plugin Admin 统一登录/刷新;订阅模块从统一控制面入口进入,iframe/新窗口均复用同一会话,不提供第二个登录页或 Cookie | passed |
| API-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed |
| API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed |
| UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed |
@@ -40,6 +43,7 @@ go vet ./...
node --check <all-ui-scripts>
production build
manifest verification
bash -n <all-shell-scripts>
git diff --check
```
@@ -49,10 +53,10 @@ git diff --check
- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
- 本地浏览器登录后,控制面首页显示“已登记插件”与订阅插件卡片;425、900、1440 视口均无横向溢出。
- 本地浏览器登录后,控制面首页、插件市场和操作记录可访问;普通登录与 Turnstile 模拟登录在 425、900、1440 视口均通过,无横向溢出或敏感响应字段。
- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。
截图证据保存在 `.playwright-cli/plugin-admin-v1-final/`、`.playwright-cli/plugin-admin-v1-sensitive/`、`.playwright-cli/subscription-admin-v1-final/` 和 `.playwright-cli/subscription-admin-v1-sensitive/`,每组包含 425px、900px、1440px 三种视口。
浏览器脚本通过 `PLUGIN_SCREENSHOT_DIR` 输出 425px、900px、1440px 截图;本轮使用系统 Chrome 运行普通控制面、Turnstile fixture 和订阅模块三套验收,截图保留在本机临时证据目录。
## 本地生命周期硬化证据
+7 -6
View File
@@ -7,9 +7,9 @@ flowchart TD
B[管理员浏览器] --> C[Core custom_menu_items]
C --> I[Core /custom/:id sandbox iframe]
I --> P[反向代理 /extensions/:plugin-id/]
P --> M[Plugin Control Plane]
M --> S[独立业务插件服务]
S --> A[Typed Core API Adapter]
P --> M[Plugin Control Plane / TDesign Shell]
M --> S[订阅业务模块 BFF / 独立服务]
M --> A[Typed Core API Adapter]
A --> K[Core Auth/Admin API]
K --> D[Core 权威账本与审计]
M --> R[Plugin Registry / Revisions / Audit]
@@ -23,15 +23,16 @@ sequenceDiagram
participant B as Browser
participant P as Plugin BFF
participant C as Core
B->>P: POST /login
B->>P: POST /login(唯一登录入口)
P->>C: POST /api/v1/auth/login
C-->>P: access/refresh 或 2FA challenge
P->>C: POST /api/v1/auth/login/2fa (按需)
P->>C: GET /api/v1/auth/me
C-->>P: role=admin
P-->>B: HttpOnly plugin session + CSRF token
B->>P: GET /api/plugins
P-->>B: 唯一 HttpOnly plugin session + CSRF token
B->>P: GET /api/plugins 或 /modules/subscription/*
P->>C: Bearer Core JWT + X-Request-ID
P->>S: 复用同一管理员会话调用订阅模块
P-->>B: 脱敏业务数据
```
+2
View File
@@ -1,5 +1,7 @@
# Business Plugin V1 边界
> **前端架构修订(2026-08-30)**:Plugin Admin 是唯一的插件管理控制面和登录入口。订阅属于已安装业务模块,复用控制面会话、导航和 CSRF;订阅后端可以独立进程运行,但订阅前端不得再提供独立登录页、Cookie 或管理员身份。
## Core 负责
- 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色;
+17 -8
View File
@@ -2,7 +2,9 @@
状态:Accepted Contract / V1 参考实现已完成本地验收
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件是独立服务、独立端口、独立版本和独立 UI;它可以通过现有管理员自定义菜单嵌入 Core,也可以在新窗口运行。订阅管理只是一个可选业务插件,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件的后端服务、端口和版本独立;浏览器端由统一的 Plugin Admin 控制面承载,业务插件 UI 以模块方式挂载到同一个管理员 Shell 中。订阅管理只是一个可选业务模块,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
> **前端架构修订(2026-08-30)**:此前“独立 UI/独立管理员后台”的表述仅指后端服务可以独立部署,不表示业务模块要再次登录。Plugin Admin 负责唯一的管理员登录、会话、导航和 CSRF;订阅模块安装并启用后才出现在控制面导航中,继承同一会话,不提供第二个登录页或第二套 Cookie。
## 1. 目标
@@ -14,7 +16,7 @@ V1 需要提供一个独立的插件控制面,负责:
- 保存插件版本、服务地址、运行状态、菜单声明和操作审计;
- 通过 Core 现有管理员鉴权复用操作者身份;
- 将已启用插件的管理员菜单注入 `custom_menu_items`;
- 让每个业务插件仅通过自己的 BFF 调用 Core 明确允许的 API。
- 让每个业务插件仅通过控制面或其受控模块 BFF 调用 Core 明确允许的 API。
V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。
@@ -25,7 +27,7 @@ V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2
| 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF |
| 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 |
| 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane |
| UI | 配置 iframe + UI Bridge | 业务后台/用户工具页面 |
| UI | 配置 iframe + UI Bridge | 统一控制面中的业务模块页面 |
| 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 |
| 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 |
@@ -60,7 +62,7 @@ Sub2API Core 现有鉴权、Admin API 和领域账本
- `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。
- `plugin_readonly`:只读查看已登记插件。
V1 的控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;插件会话只保存 `plugin_id`、`admin_user_id`、角色、会话版本和过期时间。UI 隐藏按钮不等于授权,控制面和 Core API 均须重新校验权限。
V1 的 Plugin Admin 控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;控制面会话保存 `admin_user_id`、角色、会话版本和过期时间,订阅等业务模块直接继承该会话。模块不得创建自己的登录页、Cookie 或独立权限入口。UI 隐藏按钮不等于授权,控制面、模块后端和 Core API 均须重新校验权限。
## 5. 管理面契约
@@ -157,9 +159,16 @@ Core 返回 `401` 时,一次用户请求最多 refresh 一次;并发 refresh
## 9. 会话和嵌入
插件登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存插件服务端会话,浏览器只持有 HttpOnly、Secure、SameSite Cookie 和插件 CSRF token。
Plugin Admin 登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存控制面服务端会话,浏览器只持有控制面的 HttpOnly、Secure、SameSite Cookie 和 CSRF token;订阅模块请求沿用这套会话。
Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 V1 必须同时提供新窗口入口;iframe 首屏显示插件登录页是已知行为。真正无感 SSO 需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。
会话和进程是两个独立的生命周期:插件作为常驻服务运行,Core access token
到期时由后端按需 refresh,不要求重启插件。默认会话空闲 30 分钟、绝对上限
8 小时;refresh 失败或 Core 撤销管理员后清除会话并要求重新登录。V1 会话
默认只在插件进程内存中保存,所以控制面重启后需要重新登录一次,但已启用
插件会按 registry 恢复。跨实例或跨重启免登录必须接入插件自有加密共享会话
存储,不能把 Core token 放入浏览器或 Core 数据库。
Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 iframe 首屏由 Plugin Admin 显示一次登录页;进入订阅模块时不再追加登录。真正无感 Core SSO 仍需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。
菜单注入使用 Core 现有 `custom_menu_items`:
@@ -167,7 +176,7 @@ Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core
{
"id": "DOMAIN_PLUGIN_ID",
"label": "DOMAIN_PLUGIN_LABEL",
"url": "https://CORE_ORIGIN/extensions/DOMAIN_PLUGIN_ID/",
"url": "https://PLUGIN_PUBLIC_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/DOMAIN_MODULE/overview",
"visibility": "admin",
"sort_order": 200
}
@@ -200,7 +209,7 @@ Core 始终是用户身份、余额、订阅、订单、用量、权限、计费
### Phase 2:业务插件适配
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个独立业务插件接入,只实现自身领域页面和 Core 只读 API,不改变控制面。
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个业务模块接入统一控制面,只实现自身领域页面和 Core 只读 API,不复制控制面的登录、导航和会话。
### Phase 3:生产增强
@@ -0,0 +1,106 @@
# 插件控制面 UI 信息架构
## 目标
插件控制面是统一的插件运维后台,不是 Core 主站的复制品。订阅是安装后的业务模块,不是第二个后台系统。界面按任务拆分页面,避免把安装、版本、配置、菜单、订阅和审计动作堆在同一张卡片或同一个长页面中。
## 页面层级
```text
插件管理
├── 概览 # 控制面总览,不执行高风险操作
├── 已安装插件 # 插件摘要列表,只保留常用主操作
│ └── 插件详情/:id
│ ├── 运行概况 # 健康、状态、端点、兼容性
│ ├── 版本与升级 # revision、升级、回滚
│ ├── 配置 # 服务地址、菜单地址、密钥提示
│ ├── 菜单接入 # 预览和应用管理员菜单
│ └── 操作历史 # 当前插件的审计记录
├── 插件市场 # 受控目录元数据和入库入口
└── 操作记录 # 全局操作审计和操作详情
已安装并启用的业务模块
└── 订阅管理 # 复用控制面会话,不提供第二个登录页
├── 概览
├── 套餐
├── 用户订阅
└── 操作记录
```
页面使用 hash 路由,便于刷新、复制链接和从 Core 自定义菜单直接打开:
```text
#/overview
#/plugins
#/plugins/{plugin_id}/overview
#/plugins/{plugin_id}/revisions
#/plugins/{plugin_id}/config
#/plugins/{plugin_id}/menu
#/plugins/{plugin_id}/operations
#/marketplace
#/operations
#/modules/subscription/overview
#/modules/subscription/plans
#/modules/subscription/subscriptions
#/modules/subscription/audit
```
hash 只表达页面位置,不承载凭据、Core JWT、Admin Key 或服务密钥。
## 一级页面职责
### 概览
只展示已登记、运行中、待启用和需要关注的数量,以及最近操作和生命周期提示。概览不直接承载上传、启停、回滚或卸载按钮,避免误操作;通过“管理插件”和“查看全部”进入专门页面。
### 已安装插件
每个插件条目只显示名称、版本、状态、Core 兼容性、活动 revision 和更新时间。条目保留一个生命周期主操作(启用或停用)和“查看详情”;升级、回滚、菜单和卸载进入“更多操作”菜单,防止按钮挤压或误触。
### 插件详情
详情页的每个二级页签只有一个任务:
- **运行概况**:判断当前是否健康、是否已配置、是否可以启用;错误仅展示脱敏后的最近错误。
- **版本与升级**:查看 revision、活动版本和校验时间;上传升级包、选择回滚版本。
- **配置**:读取和保存服务/菜单地址;敏感配置仍由服务端加密,页面不回显原值。
- **菜单接入**:查看声明的菜单元数据,先预览再应用,不把菜单操作混在生命周期按钮中。
- **操作历史**:只看当前插件关联的操作,操作 ID 可以打开详情。
### 插件市场
市场只显示受控索引提供的名称、版本、发布者、兼容性、发布时间和归档哈希。安装动作的语义是“下载并校验后入库”,完成后插件处于待启用状态;已经登记的插件跳转到版本页,不在市场卡片上直接覆盖现有版本。
### 操作记录
提供按插件和操作类型筛选的全局审计视图。列表展示操作、插件、结果和时间;操作 ID 进入详情弹窗,详情中包含请求 ID、状态和脱敏错误。
### 订阅业务模块
订阅模块只有在 `qiu.subscription-admin` 安装、启用并通过健康检查后才出现在导航中。它沿用 Plugin Admin 的管理员会话、CSRF、主题和响应式 Shell;模块页面不显示登录表单、不创建 Cookie,也不重复调用 Core 登录。套餐、用户订阅和余额数据仍从模块 BFF 读取,Core 继续是权威来源。
## 导航与响应式规则
- 桌面端使用左侧一级导航,内容区只显示当前路由页面;导航项同时显示页面名称和一句职责提示。
- 移动端(宽度不超过 760px)将一级导航变为抽屉,通过“菜单”按钮打开;打开导航不会改变页面宽度,也不会让整个页面依赖横向滚动。
- 详情页签只允许页签条自身横向滚动,内容区和页面主体始终保持 `scrollWidth <= innerWidth`。
- 桌面端统计卡片四列,中等宽度两列,手机端仍使用两列但缩小内边距;内容面板在中等宽度以下改为单列。
- 列表卡片的次要动作进入操作菜单;移动端按钮按可用宽度换行,不使用固定宽度挤压文字。
- 审计表格在手机端保留最小可读列宽,仅表格容器横向滚动,页面本身不横向溢出。
- 页面、卡片和表单使用统一 4px/6px 圆角、36px 控件高度和统一边距;页面不会用额外的局部颜色覆盖 Core 主题。
## 数据加载与权限
- 页面数据按路由按需加载:概览加载插件和审计,市场加载插件和市场索引,操作记录加载插件和审计,详情加载指定插件和审计;订阅模块只在启用后加载自己的数据。
- 所有请求继续通过插件后端会话和 CSRF;浏览器不会直接调用 Core,也不会收到 Core token 或 Admin Key。
- 订阅模块请求只能沿用控制面会话,不允许出现第二个登录 endpoint 或模块级会话。
- 页面上的按钮隐藏只改善交互,真正的管理员权限、生命周期状态和幂等校验仍由插件后端负责。
- mutation 返回 operation ID 后,前端短暂读取操作详情,再刷新当前路由;操作记录页面始终保留最终审计结果。
## 验收标准
1. 登录后默认进入概览,导航可进入控制面页面和已启用的业务模块,浏览器刷新后 hash 路由不丢失。
2. 已安装列表不再展示所有生命周期按钮;插件详情的五个二级页签分别承担单一职责。
3. 市场入库、配置、启停、升级、回滚、菜单和卸载接口行为与现有后端契约一致。
4. 425px、900px、1440px 视口没有页面级横向溢出;移动端导航、详情页签和操作菜单可触达。
5. 页面 DOM、JSON 响应、URL、错误提示和操作详情不出现 Core JWT、Admin Key、密码或服务密钥。
+43 -32
View File
@@ -2,7 +2,9 @@
状态:V1 通用插件控制面参考实现已落库;订阅业务插件只读适配与 Core Host Adapter/写操作仍为 Draft
本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、插件市场、下载入库、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。
本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、插件市场、下载入库、启停、升级、回滚、卸载、配置、审计和菜单注入;订阅不是第二个后台,而是安装到控制面后的业务模块。订阅后端可以作为独立服务运行在自己的端口,但浏览器端统一由 Plugin Admin Shell 承载,所有业务模块共享一次管理员登录、会话、导航和 CSRF,不创建第二个登录页或 Cookie。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。
> **前端架构修订(2026-08-30)**:文档中“独立服务”表示部署和进程边界,不表示每个业务模块都是独立产品。订阅模块只能在插件控制面会话内访问;模块入口由插件清单的 capability/menu 声明决定,未安装或未启用时不显示。
V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现范围”章节为准;本文中的订阅业务插件只是首个适配样例。Core Host Adapter、短时 Plugin Access Token、无感 SSO 和余额写操作仍是后续版本设计,不代表当前 Core 已提供这些接口。
@@ -19,7 +21,7 @@ V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现
- 插件只在服务端调用 Core 现有 API,浏览器不持有 `x-api-key` 或 Core JWT;
- 第一阶段只搭框架、登录、权限、健康检查、嵌入和只读联调,不实现订阅购买写操作。
现有自定义菜单可以完成“把插件页面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:插件登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时插件会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表,但不是无感知的当前页面会话共享。
现有自定义菜单可以完成“把插件控制面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:Plugin Admin 登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时控制面会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表。订阅模块继承控制面会话,不得再次调用 Core 登录。
如果以后要求“已登录 Core 后打开 iframe 立即无感登录”,需要一个很小的 Core 一次性登录交接接口或前端 `postMessage` 适配;这属于 V1.1,不应通过 URL 明文传递 JWT。反向代理只改变网络路径,不改变这一认证边界。
@@ -37,12 +39,13 @@ Core `/custom/PLUGIN_ID`
反向代理 `/extensions/PLUGIN_ID/*`
│ 转发到独立服务 `127.0.0.1:PLUGIN_PORT`
▼
业务插件后台
├─ `/login` 接收管理员登录请求
Plugin Admin 控制面(统一 TDesign Shell)
├─ `/login` 唯一登录入口
├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`)
├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin`
├─ 建立插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
└─ 通过 Bearer Core JWT 调用现有 Core Admin API
├─ 建立唯一的插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
├─ 挂载已启用业务模块(订阅等),模块不再登录
└─ 通过受控 BFF/内部服务调用业务模块和 Core Admin API
▼
Sub2API Core 现有认证、Admin API 和订阅/余额账本
```
@@ -53,7 +56,7 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
### 1.2 V1 的默认范围
- 插件拥有独立后台和独立发布版本。
- 插件后端拥有独立服务和独立发布版本;前端由统一控制面承载。
- 只允许管理员登录;普通用户访问插件后台一律拒绝。
- 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。
- 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。
@@ -78,8 +81,8 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
| 术语 | 定义 |
|---|---|
| Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 |
| Business Plugin | 独立部署的后台服务,提供一个业务域的管理 UI 和 BFF。 |
| Plugin UI | 由 Business Plugin 提供的页面,只调用自己的后端。 |
| Business Plugin | 独立部署的后台服务或业务模块,提供一个业务域的 UI 和 BFF。 |
| Plugin UI | 由统一控制面挂载的业务模块页面,只调用模块 BFF。 |
| Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 |
| Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 |
| Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 |
@@ -91,27 +94,27 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
### 4.1 管理员登录
V1 采用“插件会话 + Core 现有登录”的两层模型,不创建插件用户表:
V1 采用“控制面会话 + Core 现有登录”的两层模型,不创建插件用户表;所有业务模块继承控制面会话:
1. 浏览器打开插件 `/login`,只向插件后端提交 Core 管理员凭据和必要的 2FA 信息。
1. 浏览器打开 Plugin Admin `/login`,只向控制面后端提交 Core 管理员凭据和必要的 2FA 信息。
2. 插件后端服务端调用 Core `POST /api/v1/auth/login`;需要二次验证时继续调用 `POST /api/v1/auth/login/2fa`。
3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。
4. 插件后端建立自己的短时 HttpOnly 会话;Core access/refresh token 只保存在插件服务端的会话存储中,不回传浏览器。
5. 插件业务请求只携带插件会话 Cookie,插件后端再用对应管理员的 Core Bearer token 调用允许的 Core API。
4. 控制面建立唯一的短时 HttpOnly 会话;Core access/refresh token 只保存在控制面服务端的会话存储中,不回传浏览器。
5. 订阅等业务模块请求只携带控制面会话 Cookie,由控制面 BFF 或受控内部转发调用模块和允许的 Core API。
```text
浏览器 -> Plugin /login(插件会话 Cookie 尚未建立)
Plugin -> Core /api/v1/auth/login
Plugin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
Plugin -> Core /api/v1/auth/me(确认 role=admin)
Plugin <- 建立 HttpOnly 插件会话
浏览器 -> Plugin /admin/*(只带插件会话 Cookie)
Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
浏览器 -> Plugin Admin /login(控制面会话 Cookie 尚未建立)
Plugin Admin -> Core /api/v1/auth/login
Plugin Admin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
Plugin Admin -> Core /api/v1/auth/me(确认 role=admin)
Plugin Admin <- 建立唯一 HttpOnly 控制面会话
浏览器 -> Plugin Admin /admin/* 与 /modules/*(只带同一会话 Cookie)
Plugin Admin -> 订阅模块 BFF / Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
```
这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,插件不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。
这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,控制面不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。订阅等业务模块直接复用控制面会话,不创建模块级会话。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。
当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示插件登录页;由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,插件必须提供“新窗口打开插件”入口作为稳定登录路径。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。
当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示一次 Plugin Admin 登录页;进入订阅模块时不再追加登录。由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,控制面必须提供“新窗口打开”入口。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。
### 4.2 权限判定
@@ -131,6 +134,14 @@ Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
- 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。
- 插件 UI 不把 Core JWT、Admin Key 或服务凭据写入 LocalStorage、URL、HTML、日志或错误提示。
会话有效期与插件进程生命周期相互独立。插件作为常驻 HTTP 服务运行,Core
access token 过期时由后端按需调用 `/auth/refresh` 并更新服务端会话,不需要
重启插件。只有 refresh 失败、管理员被 Core 撤销、插件会话达到空闲/绝对
TTL,或管理员主动退出时,浏览器才需要重新登录。V1 默认使用内存会话,因
此控制面进程重启会使现有插件会话失效一次;这不影响 registry 中已登记插件
的恢复。需要跨重启免登录时,使用插件自有的加密共享会话存储,不改变 Core
身份权威,也不把 refresh token 下发给浏览器。
## 5. Admin Key 与服务凭据安全
### 5.1 绝对禁止的做法
@@ -145,7 +156,7 @@ Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
| 环境 | 凭据 | 用途 | 约束 |
|---|---|---|---|
| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到插件登录页,不写入代码、配置和日志。 |
| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到 Plugin Admin 登录页,不写入代码、配置和日志。 |
| 测试 | Core 返回的 access/refresh token | 建立插件服务端会话 | 只存插件服务端会话存储,短 TTL,测试 Core 与测试管理员专用。 |
| 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 |
@@ -155,8 +166,8 @@ V1.1 如需在不保存 Core refresh token 的情况下运行,再设计 Core
### 5.3 凭据生命周期
1. 插件后端接收管理员登录请求,但不保存密码。
2. 插件后端调用 Core 登录/2FA,保存返回 token 到服务端会话,并绑定 `admin_user_id`。
1. Plugin Admin 后端接收管理员登录请求,但不保存密码。
2. Plugin Admin 后端调用 Core 登录/2FA,保存返回 token 到唯一的服务端会话,并绑定 `admin_user_id`。
3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。
4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。
5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。
@@ -302,11 +313,11 @@ Core `/custom/example.subscription`
└── sandbox iframe -> 反向代理 -> `127.0.0.1:PLUGIN_PORT`
```
`visibility=admin` 只负责隐藏普通账号的菜单入口,插件后端仍必须独立鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示插件登录页是 V1 的预期行为。插件也应提供“新窗口打开”,便于登录后保持自身会话。
`visibility=admin` 只负责隐藏普通账号的菜单入口,Plugin Admin 后端仍必须鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示一次控制面登录页是 V1 的预期行为。订阅模块显示在控制面内部,不再出现独立登录页。控制面也应提供“新窗口打开”,便于登录后保持自身会话。
生产部署建议把插件外部地址挂在与 Core 相同的 HTTPS 站点下,由 Nginx/Caddy 按路径反代到独立端口;这只减少浏览器跨域问题,不改变插件必须登录和服务端调用 Core 的事实。若使用独立 origin,必须在 Core CORS 中精确加入该 origin,禁止 `*`,并仅允许必要的 `Authorization` 请求头。
插件页面只调用自己的 `/plugin-api/*`,由插件后端调用 Core 现有认证和管理员 API。V1 不允许插件动态注入主应用 Vue 路由、修改核心菜单组件或覆盖全局 CSS;主应用只提供一个受权限控制的“业务插件”入口,插件内部菜单由插件自己管理。
插件模块页面只调用控制面提供的 `/plugin-api/*` 或受控模块 BFF,由控制面调用 Core 现有认证和管理员 API。V1 不允许插件动态注入 Core Vue 路由、修改 Core 菜单组件或覆盖全局 CSS;Plugin Admin 只挂载已安装、已启用且管理员可见的业务模块。
## 10. 威胁模型与处置
@@ -345,7 +356,7 @@ Core `/custom/example.subscription`
### 11.3 浏览器和部署测试
- 管理员可登录,普通用户无法登录或访问任何后台 API;
- 管理员在 Plugin Admin 完成一次登录后可进入订阅模块,普通用户无法登录或访问任何后台 API;
- 不同屏幕下页面无横向泄露和敏感字段;
- 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key;
- HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏;
@@ -355,7 +366,7 @@ Core `/custom/example.subscription`
### Phase 0:冻结部署契约
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务);
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务),并冻结统一控制面模块挂载方式;
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
- 冻结允许调用的现有 Core API 路径、字段、分页和错误处理;
- 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表;
@@ -369,10 +380,10 @@ Core `/custom/example.subscription`
- 插件健康检查、启停、操作审计和同源入口;
- Core API allowlist、contract test 与本地示例插件。
### Phase 2:订阅插件试验
### Phase 2:订阅业务模块试验
- 只读套餐、用户余额和订阅列表;
- 管理员操作页面、查询缓存和审计;
- 在统一控制面内挂载只读套餐、用户余额和订阅列表模块;
- 复用控制面管理员会话、查询缓存和审计;
- 使用测试 Core 和测试账户,不连接生产余额;
- 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。
+3 -1
View File
@@ -8,12 +8,14 @@
- [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md)
- [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md)
- [Architecture](BUSINESS_PLUGIN_ARCHITECTURE.md)
- [Plugin Admin UI Information Architecture](PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md)
- [TDesign Frontend Migration Assessment](TDESIGN_FRONTEND_MIGRATION_ASSESSMENT.md)
- [Acceptance](BUSINESS_PLUGIN_ACCEPTANCE.md)
- [Development](BUSINESS_PLUGIN_DEVELOPMENT.md)
## 领域插件
- `subscription-admin`:独立管理员只读订阅插件。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。
- `subscription-admin`:订阅业务模块后端。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件;前端由 Plugin Admin 统一挂载并共享一次登录。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。
## 现有 `.s2plugin`
+14 -11
View File
@@ -2,14 +2,16 @@
状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft
本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务插件试验,对应实现为 `plugins/subscription-admin`。插件是独立端口的管理员后台,复用 Sub2API Core 的管理员鉴权,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。
本文定义基于 [`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 目标
- 提供独立的管理员订阅后台,普通账号拒绝登录和访问;
- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码;
- 提供挂载在统一 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 网关、余额账本和已有订阅;
@@ -42,14 +44,15 @@
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 用户表)
└─ 反向代理 -> 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 首屏显示插件登录页属于预期行为。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,插件必须提供新窗口登录路径;登录密码只在一次转发请求中经过插件后端,不落库、不写日志。
订阅模块 UI 只访问控制面的模块 BFF;Core JWT、refresh token 和 Admin API Key 只在控制面服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示一次 Plugin Admin 登录页属于预期行为;进入订阅模块时不再登录。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,控制面必须提供新窗口入口;登录密码只在一次转发请求中经过控制面后端,不落库、不写日志。
## 4. 权威边界
@@ -65,7 +68,7 @@ Core 管理后台
### 4.2 插件可以负责
- 管理员登录代理、插件会话和插件内角色;
- 控制面负责管理员登录代理、统一插件会话和插件内角色;订阅模块只消费经过授权的模块会话;
- 套餐、用户和订阅的分页筛选与展示;
- 只读缓存、操作结果页和管理员审计视图;
- 未来写操作的确认表单,但提交必须调用 Core 原子命令;
@@ -210,7 +213,7 @@ accepted -> processing -> completed
### 11.3 浏览器和部署
- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝;
- 管理员在 Plugin Admin 的 iframe 或新窗口完成一次登录即可进入订阅模块;普通用户菜单不可见且 API 拒绝;
- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露;
- DevTools 请求、下载、页面源和日志中没有 secret;
- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。
@@ -247,7 +250,7 @@ accepted -> processing -> completed
- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。
- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。
- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。
- [ ] Plugin Admin 统一登录入口在 iframe 和新窗口场景均可用;订阅模块不提供独立登录,V1 不提供无感 SSO。
- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。
- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。
- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。
@@ -0,0 +1,212 @@
# TDesign 插件前端重构评估
## 1. 范围与结论
本次只重构独立插件仓库的前端,不修改官方 Sub2API Core 的 Go、Vue、迁移、鉴权或构建配置。
独立插件仓库当前工作目录:`/tmp/sub2api-add-repo.pzrg17`
官方 Core 当前工作目录:`/Users/qiu/Desktop/Sub2API`
建议把 TDesign starter 作为统一插件控制面的 UI 基座,而不是把 starter 合并到 Core 的 `frontend/`。插件控制面只登录一次;订阅是安装后挂载到控制面里的业务模块,不是第二个后台系统。
## 2. Starter 基线
已拉取:`/Users/qiu/Desktop/tdesign-vue-next-starter-v1`
```text
仓库: https://github.com/Tencent/tdesign-vue-next-starter
分支: develop
提交: d6f8fafad9c1596cac8dfac8d52a88d0ca692acc
版本: package.json 0.14.0
```
已确认的技术栈:
- Vue 3.5、TypeScript、Vite 8、Pinia 3、Vue Router 5
- `tdesign-vue-next` `^1.20.2`
- `tdesign-icons-vue-next` `^0.4.4`
- ECharts `^6.1.0`
- Node.js `>=22.12.0`
Starter 的基线构建已通过:`npm ci --ignore-scripts`、`npm run build`。
ECharts 已在 starter 的 `src/hooks/index.ts` 中通过 `echarts/core` 初始化。starter 没有 `line-icons` 或 `lineicon` 依赖;TDesign Icons 本身是线性图标风格,后续统一使用 `tdesign-icons-vue-next`,避免混用多个图标系统。
## 3. 当前插件页面清单
### 3.1 Plugin Admin
入口:`plugins/plugin-admin/ui/index.html`、`plugins/plugin-admin/ui/app.js`
这是通用插件控制面,不是订阅业务页面。现有职责应拆成以下路由:
```text
#/overview
#/plugins
#/plugins/:plugin_id/overview
#/plugins/:plugin_id/revisions
#/plugins/:plugin_id/config
#/plugins/:plugin_id/menu
#/plugins/:plugin_id/operations
#/marketplace
#/operations
```
页面职责:
| 页面 | 主要内容 | TDesign 组件方向 |
| --- | --- | --- |
| 登录 | Core 管理员账号登录、2FA、会话过期 | `TForm`、`TInput`、`TButton`、`TAlert` |
| 概览 | 已登记、运行中、待启用、需关注、最近操作 | `TCard`、`TStatistic`、`TTag`、`TTimeline` |
| 已安装插件 | 摘要列表、生命周期主操作、查看详情 | `TTable` 或响应式 `TCard`、`TDropdown` |
| 运行概况 | 健康、端点、兼容性、活动 revision | `TDescriptions`、`TProgress`、`TTag` |
| 版本与升级 | revision、校验、升级包、回滚 | `TTable`、`TUpload`、`TDialog` |
| 配置 | 服务地址、菜单地址、敏感配置提示 | `TForm`、`TInput`、`TAlert` |
| 菜单接入 | 菜单声明、预览、应用 | `TDescriptions`、`TDialog`、`TButton` |
| 操作历史 | 当前插件或全局审计记录、详情 | `TTable`、`TDrawer`、`TTag` |
| 插件市场 | 受控索引、版本、兼容性、哈希、入库 | `TCard`、`TTag`、`TButton` |
现有接口保持不变:
```text
GET /api/plugins
POST /api/plugins/upload
POST /api/plugins/:id/enable
POST /api/plugins/:id/disable
POST /api/plugins/:id/test
POST /api/plugins/:id/ui-session
DELETE /api/plugins/:id
GET/PUT /api/plugins/:id/config
GET /api/marketplace
GET /api/audit
```
### 3.2 订阅业务模块
入口:`plugins/subscription-admin/ui/index.html`、`plugins/subscription-admin/ui/app.js`
订阅管理是可选业务模块,由 Plugin Admin 安装、启用和卸载。它复用控制面的登录态、导航、CSRF 和管理员权限,不再出现第二个登录页。模块后端可以继续作为独立进程运行,但浏览器只访问控制面提供的同源模块路由。
```text
#/modules/subscription/overview
#/modules/subscription/plans
#/modules/subscription/subscriptions
#/modules/subscription/audit
```
页面职责:
| 页面 | 主要内容 | TDesign 组件方向 |
| --- | --- | --- |
| 概览 | Core 连接状态、套餐数量、订阅数量、余额查询 | `TStatistic`、`TCard`、`TAlert` |
| 套餐 | Core 返回的套餐目录和覆盖分组 | `TTable`、`TTag`、`TEmpty` |
| 用户订阅 | 用户、状态、分组、服务端分页和筛选 | `TForm`、`TSelect`、`TTable`、`TPagination` |
| 操作记录 | 插件会话和只读查询审计 | `TTable`、`TDrawer` |
| 模块设置 | 运行模式、allowlist、凭据状态 | 插件详情的“配置/运行概况”页,不重复做模块登录 |
订阅模块现有 Core 代理接口、分页参数、状态枚举和只读边界不变。余额购买、续费、撤销等后续能力仍应通过模块后端 API 增量加入,不能在此次 UI 换肤时偷偷改变业务语义。
## 4. 推荐前端目录
不要复用官方 Core 的 `frontend/` 目录。建议在独立插件仓库新增一个统一 TDesign 控制面,并把订阅前端作为可挂载模块构建:
```text
plugins/
├── plugin-admin/
│ ├── ui/ # 统一 TDesign 应用、登录、导航和模块路由
│ └── ui-modules/ # 订阅等业务模块的 Vue/TS 源码
└── subscription-admin/
├── service/ # 可继续独立运行的业务后端
└── ui-module/ # 被 plugin-admin 挂载的订阅模块,不包含登录页
```
订阅模块可以独立打包和回滚,但它的浏览器入口由 Plugin Admin 统一托管。Go 的 `embed`、静态文件路径和部署脚本必须在插件仓库内同步,不与 Core 构建耦合。
共享但不跨 Core 的内容:
- TDesign 主题 token、字体、间距、移动端断点
- 请求封装、插件会话/CSRF、错误提示
- ECharts 按需注册和 resize composable
- TDesign Icons 的图标命名约定
## 5. 不可改变的系统边界
```text
浏览器
↓ 一次登录:Plugin Admin Session Cookie + CSRF
统一 Plugin Admin TDesign Shell
├─ 插件管理页面
└─ 订阅业务模块路由
↓ 控制面同源 BFF / 内部服务调用
独立插件 Go 服务(订阅模块)
↓ 服务端 allowlist + Bearer Core token
官方 Sub2API Core REST API
```
- 浏览器不能拿到 Core access token、refresh token、Admin Key 或服务密钥。
- 所有模块前端只能使用统一控制面的会话和模块 API,不能直连 Core API、PostgreSQL 或 Redis。
- 订阅模块不得再实现 `/login`、独立 Cookie 或第二套管理员会话。
- `custom_menu_items` 和 Core `/custom/:id` iframe 注入方式保持不变。
- Plugin Admin 的配置 iframe `postMessage` bridge、`ui-session`、step-up 和来源校验保持不变。
- Core 的源码仓库、版本文件、数据库迁移和前端页面不在本次改动范围内。
## 6. 分阶段实施
### 阶段 A:基座复制与边界固定
从 starter 复制应用骨架,替换 demo 路由、mock 数据、示例登录和品牌资源;保留 TDesign Layout、主题切换、Pinia、Vue Router、ECharts 基础能力。先让统一控制面独立构建,并定义模块注册契约。
### 阶段 B:Plugin Admin
先完成登录和应用壳,再按“概览 → 已安装 → 详情页签 → 市场 → 审计”迁移。所有 mutation 仍由现有 Go endpoint 执行,前端只负责表单、状态和操作确认。
### 阶段 C:订阅业务模块
订阅 UI 作为控制面的一个模块挂载,复用同一套会话、导航、视觉 token 和错误处理。先迁移概览、套餐、订阅列表和操作记录,服务端分页/筛选参数保持原样。订阅后端是否独立进程不影响前端只有一次登录。
### 阶段 D:图表与响应式
只在概览和需要趋势的页面引入 ECharts;图表容器使用固定最小高度、`resize` observer 和按需导入,避免页面被超长 canvas 撑开。列表在移动端只允许表格容器横向滚动,页面主体不得横向溢出。
### 阶段 E:接入验收
验证统一控制面一次登录后进入插件管理和订阅模块、Core 菜单 iframe、会话过期、CSRF、管理员权限、插件启停、配置 bridge、市场入库和审计链路。确认官方 Core 工作区没有任何变更。
## 7. 主要风险与处理
1. **starter 自带 mock/示例权限**:全部删除,改为 Plugin Admin 会话和管理员权限;不把 starter 的演示用户带入生产。
2. **旧 UI 是原生 HTML/JS**:不要强行在同一页面混用原生模板和 TDesign;按应用整体迁移,减少样式优先级冲突。
3. **配置 iframe bridge**:只能替换外层视觉,消息名称、token、来源校验、超时和 step-up 语义不变。
4. **表格移动端**:使用固定列/可滚动列的明确容器,禁止给 `body` 或整个页面设置横向滚动。
5. **图标包选择**:统一 `tdesign-icons-vue-next`;它提供线性图标,不再额外引入未知的 `line-icons` 包。
6. **模块登录分裂**:订阅模块不得复制登录页、Cookie 或权限判断;模块调用统一控制面 BFF,由控制面把管理员身份传给订阅服务。
7. **Core 更新兼容性**:插件只依赖已声明的 HTTP allowlist 和响应 DTO,Core 更新时只做 API 契约兼容检查。
## 8. 第一版验收标准
- 官方 Core `/Users/qiu/Desktop/Sub2API` 保持干净,版本和源码不被修改。
- 统一 Plugin Admin 前端能独立 `npm run build`,不依赖 Core 的 Vite 配置;订阅模块以模块产物或受控动态入口挂载。
- 管理员只登录一次即可访问插件控制面和已启用订阅模块;直接刷新模块路由仍保持同一插件会话,不把凭据写入 URL。
- 425px、768px、1440px 下无页面级横向溢出;表格需要横向查看时只滚动表格容器。
- 所有管理员 mutation 仍经过插件后端的会话、CSRF、step-up 和幂等校验。
- DOM、网络响应、日志和 URL 不出现 Core token、Admin Key、密码或服务密钥。
- ECharts 图表只展示有数据的系列,容器尺寸稳定,窗口变化后可重绘。
## 9. 当前状态
已完成:
- 拉取官方 TDesign Vue Next starter。
- 固定 starter 基线和依赖版本。
- 完成 starter 基线构建检查。
- 完成独立插件页面、API、组件和迁移边界评估。
- 根据反馈修正页面边界:订阅从“独立后台”改为统一插件控制面内的业务模块,不再单独登录。
已完成第一版落地:
- `plugins/plugin-admin/ui-vue` 基于 TDesign Vue Next starter 建立独立 Vue 3/Vite 应用。
- Plugin Admin 登录、统一 Shell、概览、插件列表/详情、市场和审计页面已迁移,图标统一使用 `tdesign-icons-vue-next`,概览趋势图使用 ECharts。
- 订阅作为 Shell 内的 `/modules/subscription/*` 业务模块挂载,不再渲染第二个登录页;控制面新增同源、allowlist 约束的订阅只读 BFF。
- Go 静态资源支持挂载路径、SPA 回退和路径穿越拒绝;`build-ui.sh` 负责可重复构建并复制嵌入产物。
- 已完成 TypeScript/Vite、Go 单元、API 契约和 425/900/1440 多视口浏览器验收;下一轮可继续补充真实业务写操作,但不改变 Core 边界。
+8 -1
View File
@@ -2,12 +2,17 @@ CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_ENV=production
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8090
PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin
PLUGIN_REGISTRY_DIR=/var/lib/sub2api-add/plugin-admin
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin
# Externally reachable Plugin Admin base URL (without /admin). Required when
# applying the subscription module menu so Core always opens the shared Shell.
PLUGIN_PUBLIC_URL=https://CORE_ORIGIN/extensions/qiu.plugin-admin
PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/
PLUGIN_COOKIE_SECURE=false
PLUGIN_COOKIE_SAMESITE=lax
PLUGIN_FRAME_ANCESTORS='self'
# Accept X-Forwarded-For only when the immediate proxy is loopback/trusted.
PLUGIN_TRUST_PROXY=false
PLUGIN_ALLOW_UNSIGNED=false
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
@@ -16,3 +21,5 @@ PLUGIN_TRUSTED_PUBLISHERS={}
# and list its exact host (including port when non-standard) below.
PLUGIN_MARKETPLACE_INDEX=/var/lib/sub2api-add/plugin-admin/marketplace/index.json
PLUGIN_MARKETPLACE_ALLOWED_HOSTS=
# Required for a remote production catalog; pin the exact catalog bytes.
PLUGIN_MARKETPLACE_INDEX_SHA256=
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: test build check browser-check
.PHONY: test build ui-build check browser-check
test:
go test ./... -count=1
@@ -7,7 +7,10 @@ build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin .
check: test
ui-build:
./build-ui.sh
check: test ui-build
node --check ui/app.js
git diff --check
+64
View File
@@ -31,17 +31,69 @@ key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
generated secret in production and keep it stable across restarts so encrypted
plugin configuration remains decryptable.
When the optional subscription module is enabled, set `PLUGIN_PUBLIC_URL` to
the externally reachable Plugin Admin base URL (without `/admin`), for example
`https://CORE_ORIGIN/extensions/qiu.plugin-admin`. Menu apply then always emits
`/admin/#/modules/subscription/overview`; a subscription service URL is never
used as a browser entrypoint.
Open `/admin/` directly or expose the service through the reverse proxy in
`deploy/`. The first login is the existing Core administrator login; no plugin
user table is created. The control plane stores only a short-lived server-side
session and encrypted plugin configuration.
The administrator UI is organized as separate hash-routed pages: overview,
installed plugins, marketplace, and operations. Each plugin has its own detail
route with secondary tabs for health, revisions, configuration, menu
integration, and history. See
[`../../docs/PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md`](../../docs/PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md)
for the page responsibilities and responsive layout contract.
### TDesign UI build
The browser shell is a standalone Vue 3 application based on the Tencent
TDesign Vue Next starter. It is built outside the Core repository and then
embedded by this Go service:
```sh
./build-ui.sh
```
The command runs `npm ci`, type-checks the application, bundles TDesign Icons
and ECharts locally (the service CSP does not allow a CDN), and copies the
three runtime files into `ui/`. The generated shell keeps the existing
`/login`, `/api/me`, CSRF and lifecycle contracts; no Core source or frontend
build configuration is involved.
### 会话与令牌生命周期
插件进程是常驻服务,不会因为一次登录、一次请求或一次令牌刷新而重启。
Core 的 access token 过期时,插件后端在当前请求链路中使用对应的 refresh
token 刷新一次,并继续完成请求;刷新后的 token 仍只保存在插件服务端。
浏览器始终只持有插件的 HttpOnly 会话 Cookie 和 CSRF token。
默认会话策略如下:
| 情况 | 行为 |
| --- | --- |
| 持续使用 | 每次请求滑动续期,通常无需重新登录 |
| 空闲超过 30 分钟 | 插件会话失效,下一次访问回到登录页 |
| 会话达到 8 小时 | 绝对过期,需要重新登录 |
| Core access token 过期 | 后端透明 refresh,不重启插件 |
| refresh token 被撤销/失效 | 清除插件会话并要求重新登录 |
| 插件进程重启 | V1 内存会话清空,需要重新登录一次;已登记的插件由控制面按 registry 恢复 |
因此日常使用不需要“用完就重启”。生产多实例若需要跨实例或跨重启保留
会话,应接入插件自己的加密 Redis/会话存储,并使用稳定的密钥;不要把
Core token 写入浏览器、Core 数据库或 URL。
## Control-plane endpoints
```text
GET /healthz
GET /readyz
POST /login POST /login/2fa POST /logout
GET /api/captcha-config
GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version)
GET /api/me GET /api/plugins GET /api/plugins/{id}
POST /api/plugins/install (multipart field: package)
@@ -53,6 +105,11 @@ GET|PUT /api/plugins/{id}/config
POST /api/plugins/{id}/menu-preview|menu-apply
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
GET /api/audit
GET /api/subscription/status
GET /api/subscription/audit
GET /api/subscription/plans
GET /api/subscription/subscriptions[/{id}]
GET /api/subscription/users/{id}[/subscriptions]
```
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
@@ -65,6 +122,13 @@ installs its runtime/menu. Failed installation and upgrade never replace the
active revision. Delete/uninstall is allowed only after disable and removes
plugin files, not Core data.
The login page reads only public CAPTCHA fields from `/api/captcha-config`.
When Core enables Turnstile, Tencent Captcha, or Aliyun Captcha, the matching
challenge is rendered in this page and its one-time proof is forwarded
server-side to Core. Provider secrets are never returned. Keep the provider
origins in the example CSP when CAPTCHA is enabled; changing the Core setting
is picked up on the next login-page load and does not require a plugin restart.
## Marketplace catalog
The marketplace is server-side only. The browser receives metadata and sends a
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$ROOT/ui-vue"
# Keep the lockfile authoritative in CI and never install dependencies into
# the embedded Go source tree.
npm ci --ignore-scripts
npm run build
cp "$ROOT/ui-build/index.html" "$ROOT/ui-build/app.js" "$ROOT/ui-build/styles.css" "$ROOT/ui/"
printf '%s\n' "TDesign UI built into $ROOT/ui"
@@ -2,7 +2,7 @@
"schema_version": 1,
"plugin_id": "qiu.plugin-admin",
"name": "Business Plugin Control Plane",
"version": "1.0.0",
"version": "1.1.0",
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"],
"capabilities": ["plugin.admin.v1"],
@@ -28,6 +28,11 @@
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/settings"
"GET /api/v1/admin/settings",
"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"
]
}
@@ -7,16 +7,29 @@ Wants=network-online.target
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/plugin-admin
EnvironmentFile=/etc/sub2api/plugin-admin.env
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin
WorkingDirectory=/var/lib/sub2api-add/plugin-admin
EnvironmentFile=/etc/sub2api-add/plugin-admin.env
ExecStart=/opt/sub2api-add/plugin-admin/bin/plugin-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/plugin-admin
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
TasksMax=128
MemoryMax=512M
CPUQuota=200%
ReadWritePaths=/var/lib/sub2api-add/plugin-admin
[Install]
WantedBy=multi-user.target
File diff suppressed because it is too large Load Diff
+437 -4
View File
@@ -195,6 +195,123 @@ func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) {
}
}
func TestCaptchaConfigReturnsOnlyPublicFields(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/settings/public" {
t.Fatalf("unexpected Core path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"turnstile_enabled":true,"turnstile_site_key":"site-key","tencent_captcha_enabled":false,"aliyun_captcha_enabled":false,"turnstile_secret_key":"must-not-leak","admin_password":"must-not-leak"}}`)
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
recorder := httptest.NewRecorder()
a.routes().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/captcha-config", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
if !strings.Contains(body, `"turnstile_enabled":true`) || !strings.Contains(body, `"turnstile_site_key":"site-key"`) {
t.Fatalf("public captcha fields missing: %s", body)
}
if strings.Contains(body, "must-not-leak") || strings.Contains(body, "secret_key") || strings.Contains(body, "admin_password") {
t.Fatalf("sensitive Core settings leaked: %s", body)
}
}
func TestLoginForwardsCaptchaProof(t *testing.T) {
var received struct {
TurnstileToken string `json:"turnstile_token"`
}
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode login body: %v", err)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password","turnstile_token":"proof-token"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if rec.Code != http.StatusOK || received.TurnstileToken != "proof-token" {
t.Fatalf("proof was not forwarded: status=%d body=%s received=%#v", rec.Code, rec.Body.String(), received)
}
}
func TestLoginRateLimitBoundsCoreAttempts(t *testing.T) {
var loginCalls int
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
loginCalls++
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
for attempt := 0; attempt < loginLimit+1; attempt++ {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if attempt < loginLimit && rec.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
}
if attempt == loginLimit && rec.Code != http.StatusTooManyRequests {
t.Fatalf("limit status=%d body=%s", rec.Code, rec.Body.String())
}
}
if loginCalls != loginLimit {
t.Fatalf("Core received %d login calls, want %d", loginCalls, loginLimit)
}
}
func TestPendingLoginCapAndExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, nil, t.TempDir())
a.clock = func() time.Time { return now }
for i := 0; i < maxPendingLogins; i++ {
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "TEMP", Expires: now.Add(time.Minute)}); !ok {
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
}
}
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "OVERFLOW", Expires: now.Add(time.Minute)}); ok {
t.Fatal("pending challenge cap was not enforced")
}
if got := len(a.pending); got != maxPendingLogins {
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
}
a.clock = func() time.Time { return now.Add(pendingLoginTTL + time.Second) }
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "AFTER-EXPIRY", Expires: now.Add(2 * pendingLoginTTL)}); !ok {
t.Fatal("expired pending challenges were not evicted")
}
if got := len(a.pending); got != 1 {
t.Fatalf("pending map size after expiry=%d want 1", got)
}
}
func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
var input struct {
Name string `json:"name"`
@@ -420,6 +537,43 @@ func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) {
}
}
func TestMarketplaceVersionComparisonRejectsDowngrades(t *testing.T) {
for _, test := range []struct {
left, right string
want int
}{
{"1.2.0", "1.1.9", 1},
{"1.0.0", "1.0.0", 0},
{"1.0.0-beta.2", "1.0.0-beta.10", -1},
{"1.0.0", "1.0.0-rc.1", 1},
} {
if got := compareMarketplaceVersions(test.left, test.right); got != test.want {
t.Fatalf("compare(%q,%q)=%d want %d", test.left, test.right, got, test.want)
}
}
}
func TestMarketplaceIndexDigestPin(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "index.json")
raw := []byte(`{"schema_version":1,"entries":[]}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
service, err := newMarketplaceService(path, "", true)
if err != nil {
t.Fatal(err)
}
service.indexSHA256 = sha256Hex(raw)
if _, _, err := service.loadIndex(context.Background()); err != nil {
t.Fatalf("matching digest rejected: %v", err)
}
service.indexSHA256 = strings.Repeat("0", 64)
if _, _, err := service.loadIndex(context.Background()); err == nil {
t.Fatal("mismatched digest was accepted")
}
}
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
@@ -587,6 +741,7 @@ func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
func TestRefreshRevalidatesAdminRole(t *testing.T) {
var meCalls int
var logoutTokens []string
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
@@ -601,6 +756,9 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
case "/api/v1/auth/refresh":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
case "/api/v1/auth/logout":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
logoutTokens = append(logoutTokens, body["refresh_token"])
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
@@ -619,6 +777,16 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
if rec.Code != http.StatusForbidden {
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
foundRotated := false
for _, value := range logoutTokens {
if value == "NEW-R" {
foundRotated = true
break
}
}
if !foundRotated {
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
}
}
func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
@@ -647,9 +815,209 @@ func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
csp := response.Header.Get("Content-Security-Policy")
if response.StatusCode != http.StatusUnauthorized || csp == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header)
}
for _, origin := range []string{"https://challenges.cloudflare.com", "https://turing.captcha.qcloud.com", "https://o.alicdn.com"} {
if !strings.Contains(csp, origin) {
t.Fatalf("captcha origin %q missing from CSP: %s", origin, csp)
}
}
}
func staticTestApp(t *testing.T) *app {
t.Helper()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
return newApp(nil, reg, t.TempDir())
}
func TestStaticServesAssetsWithMIME(t *testing.T) {
a := staticTestApp(t)
for path, wantType := range map[string]string{"/admin/app.js": "text/javascript", "/admin/styles.css": "text/css"} {
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), wantType) {
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
}
}
}
func TestStaticRejectsPathTraversal(t *testing.T) {
a := staticTestApp(t)
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, "/admin/%2e%2e/main.go", nil))
if recorder.Code != http.StatusNotFound || strings.Contains(recorder.Body.String(), "package main") {
t.Fatalf("expected traversal to be rejected: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestStaticReplacesHTMLBasePlaceholderAndSupportsMountedSPA(t *testing.T) {
a := staticTestApp(t)
a.publicBasePath = "/console"
for _, path := range []string{"/console/admin/", "/console/admin/plugins/example.plugin/config"} {
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/html") {
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
}
body := recorder.Body.String()
if strings.Contains(body, "__PLUGIN_BASE_PATH_JSON__") || !strings.Contains(body, `meta name="plugin-base-path" content="/console"`) || strings.Contains(body, "window.__PLUGIN_BASE_PATH__ =") {
t.Fatalf("path=%s base placeholder was not replaced: %q", path, body)
}
}
}
func TestSubscriptionProxySharesControlPlaneSessionAndRedactsCoreSecrets(t *testing.T) {
var gotAuthorization, gotPath string
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`)
case "/api/v1/admin/subscriptions":
gotAuthorization = r.Header.Get("Authorization")
gotPath = r.URL.RequestURI()
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_LEAK","items":[{"id":1,"refresh_token":"REFRESH_LEAK","status":"active"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0"}, State: "healthy", ActiveRevision: "rev-1"}
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&evil=ignored", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
a.subscriptionProxy(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected unknown query to be rejected, status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&page_size=20", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
a.subscriptionProxy(rec, req)
if rec.Code != http.StatusOK || gotAuthorization != "Bearer ACCESS" || gotPath != "/api/v1/admin/subscriptions?page=1&page_size=20" {
t.Fatalf("status=%d auth=%q path=%q body=%s", rec.Code, gotAuthorization, gotPath, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "CORE_LEAK") || strings.Contains(rec.Body.String(), "REFRESH_LEAK") {
t.Fatalf("Core secret leaked in module response: %s", rec.Body.String())
}
}
func TestSubscriptionStatusDeclaresSharedSession(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
req := httptest.NewRequest(http.MethodGet, "/api/subscription/status", nil)
req.AddCookie(adminSession(a))
rec := httptest.NewRecorder()
a.subscriptionStatus(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"session_mode":"shared"`) || strings.Contains(rec.Body.String(), "access_token") {
t.Fatalf("unexpected status response: %d %s", rec.Code, rec.Body.String())
}
}
func TestSubscriptionProxyRequiresHealthyModule(t *testing.T) {
var coreReads int
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/me" {
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
return
}
if r.URL.Path == "/api/v1/admin/subscriptions" {
coreReads++
}
_, _ = io.WriteString(w, `{"code":0,"data":{"items":[]}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
cookie := adminSession(a)
request := func(endpoint string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, endpoint, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
if endpoint == "/api/subscription/audit" {
a.subscriptionAudit(rec, req)
} else {
a.subscriptionProxy(rec, req)
}
return rec
}
endpoints := []string{
"/api/subscription/plans",
"/api/subscription/subscriptions",
"/api/subscription/subscriptions/1",
"/api/subscription/users/1",
"/api/subscription/users/1/subscriptions",
"/api/subscription/audit",
}
for _, endpoint := range endpoints {
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
t.Fatalf("uninstalled module endpoint=%s leaked data: status=%d reads=%d body=%s", endpoint, rec.Code, coreReads, rec.Body.String())
}
}
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "disabled"}
for _, endpoint := range endpoints {
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
t.Fatalf("stopped module endpoint=%s leaked data: status=%d reads=%d", endpoint, rec.Code, coreReads)
}
}
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "healthy"}
if rec := request("/api/subscription/subscriptions"); rec.Code != http.StatusOK || coreReads != 1 {
t.Fatalf("healthy module was not proxied: status=%d reads=%d body=%s", rec.Code, coreReads, rec.Body.String())
}
}
func TestSubscriptionMenuAlwaysTargetsUnifiedShell(t *testing.T) {
var applied []byte
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.publicURL = "https://console.example.test/extensions/qiu.plugin-admin"
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0", UI: manifest.UI{Menu: manifest.Menu{ID: subscriptionPluginID, Label: "订阅管理", Visibility: "admin", SortOrder: 200, URL: "https://wrong.example.test/login"}}}, State: "healthy", ActiveRevision: "rev-1"}
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+subscriptionPluginID+"/menu-apply", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "subscription-menu-unified")
rec := httptest.NewRecorder()
a.menu(rec, req, true)
want := "https://console.example.test/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview"
if rec.Code != http.StatusAccepted || !strings.Contains(string(applied), want) || strings.Contains(string(applied), "wrong.example.test") {
t.Fatalf("subscription menu was not normalized: status=%d body=%s applied=%s", rec.Code, rec.Body.String(), applied)
}
}
func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
@@ -682,7 +1050,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
preview.Header.Set("Idempotency-Key", "menu-preview-1")
previewRec := httptest.NewRecorder()
a.menu(previewRec, preview, false)
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") {
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example-plugin") {
t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String())
}
global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`))
@@ -691,7 +1059,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
global.Header.Set("Idempotency-Key", "global-menu-preview-1")
globalRec := httptest.NewRecorder()
a.menuGlobal(globalRec, global, false)
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") {
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example-plugin") {
t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String())
}
apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil)
@@ -700,11 +1068,28 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
apply.Header.Set("Idempotency-Key", "menu-apply-1")
applyRec := httptest.NewRecorder()
a.menu(applyRec, apply, true)
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") {
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example-plugin") || strings.Contains(string(applied), "example.plugin") {
t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied)
}
}
func TestCoreMenuIDNormalizesPluginNamespaceAndBoundsLength(t *testing.T) {
if got, err := coreMenuID("qiu.subscription-admin"); err != nil || got != "qiu-subscription-admin" {
t.Fatalf("namespace ID normalization: got=%q err=%v", got, err)
}
if got, err := coreMenuID("already_valid"); err != nil || got != "already_valid" {
t.Fatalf("valid ID changed: got=%q err=%v", got, err)
}
long := strings.Repeat("plugin.", 12)
got, err := coreMenuID(long)
if err != nil || len(got) > 32 || !coreMenuIDPattern.MatchString(got) {
t.Fatalf("long ID normalization: got=%q len=%d err=%v", got, len(got), err)
}
if _, err := coreMenuID("..."); err == nil {
t.Fatal("expected an all-separator ID to be rejected")
}
}
func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -790,6 +1175,51 @@ func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
}
}
func TestRollbackHonorsExplicitRevisionSelection(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","version":"0.8.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
base := manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}
oldManifest, middleManifest := base, base
oldManifest.Version, middleManifest.Version = "0.8.0", "0.9.0"
p := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "rev-new", Endpoint: pluginServer.URL, Revisions: []revision{
{ID: "rev-old", Version: "0.8.0", Manifest: oldManifest},
{ID: "rev-middle", Version: "0.9.0", Manifest: middleManifest},
{ID: "rev-new", Version: "1.0.0", Manifest: base},
}}
reg.data.Plugins[p.Manifest.PluginID] = p
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", strings.NewReader(`{"revision":"rev-old"}`))
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "rollback-explicit-old")
rec := httptest.NewRecorder()
a.rollback(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("rollback status=%d body=%s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
got := reg.data.Plugins[p.Manifest.PluginID]
reg.mu.Unlock()
if got.ActiveRevision != "rev-old" || got.Manifest.Version != "0.8.0" {
t.Fatalf("explicit rollback selected wrong revision: active=%q manifest=%q", got.ActiveRevision, got.Manifest.Version)
}
}
func TestLifecycleEnableDisableUninstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
var applied []byte
@@ -1024,6 +1454,9 @@ func TestRecoverCommandPluginAfterRestart(t *testing.T) {
t.Skip("python3 is unavailable")
}
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1)
if err := os.WriteFile(command, []byte(source), 0o700); err != nil {
t.Fatal(err)
}
reg, _ := openRegistry(filepath.Join(root, "registry"))
pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}}
reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}}
+77
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@@ -31,6 +32,75 @@ var (
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
// compareMarketplaceVersions implements the semver subset accepted by the
// manifest validator. It is used only for marketplace installs, where a
// catalog entry must never silently downgrade an already registered plugin.
func compareMarketplaceVersions(left, right string) int {
parse := func(value string) ([3]int, []string) {
value = strings.TrimPrefix(strings.TrimSpace(value), "v")
parts := strings.SplitN(value, "-", 2)
core := strings.Split(parts[0], ".")
var numbers [3]int
for i := 0; i < len(numbers) && i < len(core); i++ {
numbers[i], _ = strconv.Atoi(core[i])
}
if len(parts) == 1 {
return numbers, nil
}
return numbers, strings.Split(parts[1], ".")
}
leftCore, leftPre := parse(left)
rightCore, rightPre := parse(right)
for i := range leftCore {
if leftCore[i] < rightCore[i] {
return -1
}
if leftCore[i] > rightCore[i] {
return 1
}
}
if len(leftPre) == 0 && len(rightPre) == 0 {
return 0
}
if len(leftPre) == 0 {
return 1
}
if len(rightPre) == 0 {
return -1
}
for i := 0; i < len(leftPre) && i < len(rightPre); i++ {
l, r := leftPre[i], rightPre[i]
if l == r {
continue
}
ln, le := strconv.Atoi(l)
rn, re := strconv.Atoi(r)
if le == nil && re == nil {
if ln < rn {
return -1
}
return 1
}
if le == nil {
return -1
}
if re == nil {
return 1
}
if l < r {
return -1
}
return 1
}
if len(leftPre) < len(rightPre) {
return -1
}
if len(leftPre) > len(rightPre) {
return 1
}
return 0
}
// marketplaceEntry is deliberately metadata-only. The browser never receives
// the archive URL; downloads are performed by this server after catalog and
// transport policy validation.
@@ -63,6 +133,7 @@ type marketplaceService struct {
remoteURL *url.URL
allowedHosts map[string]struct{}
allowLoopback bool
indexSHA256 string
client *http.Client
}
@@ -238,6 +309,12 @@ func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, ma
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
}
if expected := strings.TrimSpace(m.indexSHA256); expected != "" {
sum := sha256.Sum256(raw)
if !strings.EqualFold(hex.EncodeToString(sum[:]), expected) {
return marketplaceIndex{}, origin, errors.New("marketplace index sha256 does not match the configured pin")
}
}
var index marketplaceIndex
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
+67 -8
View File
@@ -9,7 +9,9 @@ const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com'
const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
await fs.mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({ headless: true })
const launchOptions = { headless: true }
if (process.env.PLAYWRIGHT_EXECUTABLE_PATH) launchOptions.executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
const browser = await chromium.launch(launchOptions)
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
@@ -21,15 +23,72 @@ try {
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {}
})
if (process.env.PLUGIN_EXPECT_CAPTCHA === 'turnstile') {
// The fixture uses a deterministic SDK shim; production pages load the
// provider SDK from the CSP allowlist. This keeps the browser contract
// test network-independent while exercising the proof handoff.
await page.addInitScript(() => {
window.turnstile = {
render(container, options) {
const marker = document.createElement('span')
marker.dataset.testid = 'turnstile-fixture'
marker.textContent = 'fixture captcha'
container.appendChild(marker)
queueMicrotask(() => options.callback('MOCK-TURNSTILE-TOKEN'))
return 'fixture-widget'
},
reset() {},
remove() {},
}
})
}
await page.goto(entry, { waitUntil: 'networkidle' })
await page.getByLabel('邮箱').fill(email)
await page.getByLabel('密码').fill(password)
await page.locator('input[autocomplete="username"]').fill(email)
await page.locator('input[autocomplete="current-password"]').fill(password)
if (process.env.PLUGIN_EXPECT_CAPTCHA === 'turnstile') {
await page.locator('[data-testid="turnstile-fixture"]').waitFor({ state: 'visible' })
}
await page.getByRole('button', { name: '登录' }).click()
await page.getByRole('heading', { name: '已登记插件' }).waitFor()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
if (!buttons) throw new Error(`control collapsed at ${width}px`)
await page.getByRole('heading', { name: '概览' }).waitFor()
const assertViewport = async (label) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px (${label})`)
const controls = await page.locator('button, .file-button, input[type="file"]').evaluateAll((items) => items.filter((item) => !item.classList.contains('visually-hidden') && item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
if (!controls) throw new Error(`control collapsed at ${width}px (${label})`)
}
const visitView = async (view, heading) => {
await page.evaluate((hash) => { window.location.hash = hash }, `#/${view}`)
await page.getByRole('heading', { name: heading }).waitFor()
if (!page.url().includes(`#/${view}`)) throw new Error(`hash route missing for ${view}: ${page.url()}`)
await assertViewport(view)
}
await visitView('plugins', '已安装插件')
await visitView('marketplace', '插件市场')
await visitView('operations', '操作记录')
// The optional subscription module is served by the same Shell. When the
// fixture has it enabled, verify the deep link remains inside the unified
// hash router instead of opening a second service/login surface.
if (process.env.PLUGIN_EXPECT_SUBSCRIPTION === 'true') {
await visitView('modules/subscription/overview', '订阅管理')
if (!page.url().includes('#/modules/subscription/overview')) throw new Error(`subscription route escaped unified shell: ${page.url()}`)
}
// When the backend has installed plugins, exercise a detail route and its secondary tab.
await visitView('plugins', '已安装插件')
const detailTrigger = page.getByRole('button', { name: '查看详情' }).first()
if (await detailTrigger.count()) {
await detailTrigger.click()
await page.getByRole('heading', { name: '插件详情' }).waitFor().catch(() => {})
const secondary = page.getByRole('tab', { name: '版本与升级' })
if (await secondary.count()) await secondary.click()
await assertViewport('detail')
}
await page.reload({ waitUntil: 'networkidle' })
if (!page.url().includes('#')) throw new Error(`hash route lost after refresh: ${page.url()}`)
await assertViewport('refresh')
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true })
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="plugin-base-path" content="__PLUGIN_BASE_PATH__" />
<title>插件管理控制面</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@awaioi/plugin-admin-ui",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 3002",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"echarts": "^6.1.0",
"pinia": "^3.0.4",
"tdesign-icons-vue-next": "^0.4.4",
"tdesign-vue-next": "^1.20.2",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"vue-tsc": "^3.3.6"
},
"engines": {
"node": ">=22.12.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
<template>
<t-config-provider>
<div v-if="!authState.ready" class="boot-screen"><t-loading size="32px" text="正在连接控制面" /></div>
<router-view v-else-if="authState.user" />
<Login v-else />
</t-config-provider>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import router from './router'
import { authState, hydrateSession, loadSubscriptionState } from './services/api'
import Login from './views/Login.vue'
onMounted(async () => {
await hydrateSession()
// The first router pass happens while authState.ready is false. Re-check a
// deep link after hydration so a direct subscription URL cannot bypass the
// route-level module gate.
const current = router.currentRoute.value
if (!authState.user && current.path !== '/login') {
await router.replace('/login')
} else if (authState.user && current.path.startsWith('/modules/subscription')) {
if (!(await loadSubscriptionState(true))) await router.replace('/overview')
}
})
</script>
@@ -0,0 +1,194 @@
<template>
<div v-if="sceneId && prefix" class="captcha-action">
<button
:id="buttonId"
type="button"
class="captcha-action__trigger"
:class="{ 'is-verified': state === 'verified' }"
:disabled="state === 'verified' || state === 'verifying'"
@click="handleTrigger"
>
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
</button>
<div :id="elementId" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
interface AliyunInitOptions {
SceneId: string
prefix: string
mode: 'popup' | 'embed'
element: string
button: string
captchaVerifyCallback: (param: string) => { captchaResult: boolean; bizResult?: boolean }
onBizResultCallback: (result: boolean) => void
getInstance: (instance: unknown) => void
slideStyle?: { width: number; height: number }
language?: string
}
declare global {
interface Window {
initAliyunCaptcha?: (options: AliyunInitOptions) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
const props = withDefaults(defineProps<{ sceneId: string; prefix: string; region?: string }>(), { region: 'cn' })
const emit = defineEmits<{ verify: [param: string]; expire: []; error: [] }>()
const uid = Math.random().toString(36).slice(2, 10)
const buttonId = `plugin-aliyun-captcha-button-${uid}`
const elementId = `plugin-aliyun-captcha-element-${uid}`
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
const scriptSrc = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
let readyPromise: Promise<void> | null = null
let cachedParam: string | null = null
let pending: { resolve: (value: string | null) => void } | null = null
let pendingTimer: number | null = null
let initialized = false
function loadScript(): Promise<void> {
if (window.initAliyunCaptcha) return Promise.resolve()
if (readyPromise) return readyPromise
window.AliyunCaptchaConfig = { region: props.region || 'cn', prefix: props.prefix }
readyPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src*="aliyunCaptcha/AliyunCaptcha"]')
if (existing) {
existing.addEventListener('load', () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable')), { once: true })
existing.addEventListener('error', () => reject(new Error('Failed to load Aliyun Captcha SDK')), { once: true })
return
}
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable'))
script.onerror = () => reject(new Error('Failed to load Aliyun Captcha SDK'))
document.head.appendChild(script)
}).catch((error) => {
readyPromise = null
throw error
})
return readyPromise
}
function resolveParam(param: string) {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
const value = param.trim()
if (!value) {
state.value = 'idle'
pending?.resolve(null)
pending = null
emit('error')
return
}
cachedParam = value
state.value = 'verified'
emit('verify', value)
pending?.resolve(value)
pending = null
}
function initCaptcha() {
if (initialized) return
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
window.initAliyunCaptcha({
SceneId: props.sceneId,
prefix: props.prefix,
mode: 'popup',
element: `#${elementId}`,
button: `#${buttonId}`,
captchaVerifyCallback: (param) => {
resolveParam(param)
return { captchaResult: true }
},
onBizResultCallback: () => undefined,
getInstance: () => undefined,
slideStyle: { width: 360, height: 40 },
language: 'cn',
})
initialized = true
}
function handleTrigger() {
if (state.value === 'verified' || state.value === 'verifying') return
state.value = 'verifying'
void verify().catch(() => {
state.value = 'idle'
emit('error')
})
}
async function verify(): Promise<string | null> {
if (cachedParam) {
const value = cachedParam
cachedParam = null
return value
}
await loadScript()
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
initCaptcha()
return new Promise<string | null>((resolve) => {
pending = { resolve }
if (pendingTimer !== null) window.clearTimeout(pendingTimer)
pendingTimer = window.setTimeout(() => {
pendingTimer = null
state.value = 'idle'
pending?.resolve(null)
pending = null
}, 15000)
document.getElementById(buttonId)?.click()
})
}
function reset() {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
cachedParam = null
state.value = 'idle'
pending?.resolve(null)
pending = null
document.getElementById('aliyunCaptcha-mask')?.remove()
document.getElementById('aliyunCaptcha-window-popup')?.remove()
emit('expire')
}
defineExpose({ verify, reset })
onMounted(() => {
if (!props.sceneId || !props.prefix) return
void loadScript().then(initCaptcha).catch(() => emit('error'))
})
onUnmounted(() => {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer)
pendingTimer = null
}
pending?.resolve(null)
pending = null
document.getElementById('aliyunCaptcha-mask')?.remove()
document.getElementById('aliyunCaptcha-window-popup')?.remove()
})
</script>
<style scoped>
.captcha-action { width: 100%; }
.captcha-action__trigger {
display: inline-flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
}
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
.captcha-action__trigger.is-verified { border-color: #00a870; background: #f0fff8; color: #087f5b; }
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
.captcha-action__dot.is-verified { background: #00a870; }
</style>
@@ -0,0 +1,117 @@
<template>
<div v-if="enabled" class="captcha-challenge" aria-live="polite">
<TurnstileWidget
v-if="provider === 'turnstile' && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onExpire"
@error="onError"
/>
<TencentCaptchaGate
v-else-if="provider === 'tencent' && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
@verify="onTencentVerify"
@expire="onExpire"
@error="onError"
/>
<AliyunCaptchaWidget
v-else-if="provider === 'aliyun' && aliyunSceneId && aliyunPrefix"
ref="aliyunRef"
:scene-id="aliyunSceneId"
:prefix="aliyunPrefix"
:region="aliyunRegion"
@verify="onAliyunVerify"
@expire="onExpire"
@error="onError"
/>
<div v-else class="captcha-challenge__missing">验证码配置不完整,请联系管理员</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import TurnstileWidget from './TurnstileWidget.vue'
import TencentCaptchaGate from './TencentCaptchaGate.vue'
import AliyunCaptchaWidget from './AliyunCaptchaWidget.vue'
import type { TencentCaptchaProof } from '../services/tencentCaptcha'
export interface CaptchaProof { token: string; randstr: string; provider: 'turnstile' | 'tencent' | 'aliyun' }
const props = defineProps<{
enabled: boolean
provider: string
turnstileSiteKey?: string
tencentAppId?: string
tencentRegion?: string
aliyunSceneId?: string
aliyunPrefix?: string
aliyunRegion?: string
}>()
const emit = defineEmits<{ verify: [proof: CaptchaProof]; expire: []; error: [] }>()
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
const turnstileToken = ref('')
const tencentProof = ref<TencentCaptchaProof | null>(null)
const aliyunToken = ref('')
const provider = computed(() => props.provider)
function onTurnstileVerify(token: string) {
turnstileToken.value = token
emit('verify', { token, randstr: '', provider: 'turnstile' })
}
function onTencentVerify(proof: TencentCaptchaProof) {
tencentProof.value = proof
emit('verify', { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' })
}
function onAliyunVerify(token: string) {
aliyunToken.value = token
emit('verify', { token, randstr: '', provider: 'aliyun' })
}
function onExpire() {
turnstileToken.value = ''
tencentProof.value = null
aliyunToken.value = ''
emit('expire')
}
function onError() {
onExpire()
emit('error')
}
async function verifyAction(): Promise<CaptchaProof | null> {
if (!props.enabled) return null
if (provider.value === 'turnstile') {
return turnstileToken.value ? { token: turnstileToken.value, randstr: '', provider: 'turnstile' } : null
}
if (provider.value === 'tencent') {
const proof = tencentProof.value || await tencentRef.value?.verify() || null
return proof ? { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' } : null
}
if (provider.value === 'aliyun') {
const token = aliyunToken.value || await aliyunRef.value?.verify() || ''
return token ? { token, randstr: '', provider: 'aliyun' } : null
}
return null
}
function reset() {
turnstileToken.value = ''
tencentProof.value = null
aliyunToken.value = ''
turnstileRef.value?.reset()
tencentRef.value?.reset()
aliyunRef.value?.reset()
}
defineExpose({ verifyAction, reset })
</script>
<style scoped>
.captcha-challenge { width: 100%; margin: 2px 0 10px; }
.captcha-challenge__missing { padding: 10px 12px; border: 1px solid #f3c7c4; border-radius: 6px; color: #a61b1b; background: #fff7f7; font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,6 @@
<template>
<header class="page-header"><div><p class="eyebrow">{{ eyebrow }}</p><h1>{{ title }}</h1><p class="page-subtitle">{{ subtitle }}</p></div><div class="page-actions"><slot name="actions" /></div></header>
</template>
<script setup lang="ts">
defineProps<{ eyebrow: string; title: string; subtitle: string }>()
</script>
@@ -0,0 +1,134 @@
<template>
<div class="captcha-action">
<div v-if="isInternational" ref="internationalContainerRef" class="captcha-action__international" />
<button
v-else
type="button"
class="captcha-action__trigger"
:disabled="state === 'verified' || state === 'verifying'"
@click="handleTrigger"
>
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { loadTencentCaptcha, normalizeTencentCaptchaRegion, type TencentCaptchaProof } from '../services/tencentCaptcha'
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const emit = defineEmits<{ verify: [proof: TencentCaptchaProof]; expire: []; error: [] }>()
const isInternational = computed(() => normalizeTencentCaptchaRegion(props.region) === 'intl')
const internationalContainerRef = ref<HTMLElement | null>(null)
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let resolvePending: ((proof: TencentCaptchaProof | null) => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
function handleResult(result: { ret: number; ticket?: string | null; randstr?: string | null; errorCode?: number }) {
if (result.ret === 2) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
return
}
const ticket = result.ticket?.trim() || ''
const randstr = result.randstr?.trim() || ''
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('error')
return
}
const proof = { ticket, randstr }
cachedProof = proof
state.value = 'verified'
emit('verify', proof)
resolvePending?.(proof)
resolvePending = null
pending = null
}
async function ensureInstance() {
const Constructor = await loadTencentCaptcha(normalizeTencentCaptchaRegion(props.region))
if (instance) return
if (isInternational.value) {
if (!internationalContainerRef.value) throw new Error('Tencent Captcha container unavailable')
instance = new Constructor(internationalContainerRef.value, props.appId, handleResult, { enableAutoCheck: false, type: 'popup' })
} else {
instance = new Constructor(props.appId, handleResult, { userLanguage: 'zh-cn' })
}
}
function handleTrigger() {
if (state.value === 'verified' || state.value === 'verifying') return
state.value = 'verifying'
void verify()
}
async function verify(): Promise<TencentCaptchaProof | null> {
if (cachedProof) {
const proof = cachedProof
cachedProof = null
return proof
}
if (pending) return pending
const verification = new Promise<TencentCaptchaProof | null>((resolve) => { resolvePending = resolve })
pending = verification
try {
await ensureInstance()
instance?.show()
} catch (error) {
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('error')
throw error
}
return verification
}
function reset() {
instance?.destroy()
instance = null
cachedProof = null
state.value = 'idle'
resolvePending?.(null)
resolvePending = null
pending = null
emit('expire')
}
defineExpose({ verify, reset })
onMounted(() => {
if (!props.appId) return
// Preload the international checkbox so the first click is visible and
// never gets swallowed by the asynchronous SDK initialization.
if (isInternational.value) void ensureInstance().catch(() => emit('error'))
})
onBeforeUnmount(reset)
</script>
<style scoped>
.captcha-action { width: 100%; }
.captcha-action__international { display: flex; width: 100%; min-height: 60px; align-items: center; justify-content: center; }
.captcha-action__trigger {
display: inline-flex;
width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
}
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
.captcha-action__dot.is-verified { background: #00a870; }
</style>
@@ -0,0 +1,32 @@
<template><div ref="element" class="trend-chart" aria-label="近期操作趋势" /></template>
<script setup lang="ts">
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
echarts.use([BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
const props = defineProps<{ labels: string[]; values: number[] }>()
const element = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let observer: ResizeObserver | null = null
function render() {
if (!element.value) return
chart ||= echarts.init(element.value)
chart.setOption({
animationDuration: 350,
grid: { left: 36, right: 16, top: 20, bottom: 24 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', boundaryGap: false, data: props.labels, axisLine: { lineStyle: { color: '#d7dce3' } }, axisLabel: { color: '#718096', fontSize: 11 } },
yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#edf0f4' } }, axisLabel: { color: '#718096', fontSize: 11 } },
series: [{ name: '操作数', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: props.values, lineStyle: { color: '#0052d9', width: 2 }, itemStyle: { color: '#0052d9' }, areaStyle: { color: 'rgba(0,82,217,.10)' } }],
})
}
onMounted(() => { render(); if (element.value) { observer = new ResizeObserver(() => chart?.resize()); observer.observe(element.value) } })
watch(() => [props.labels, props.values], render, { deep: true })
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose(); chart = null })
</script>
@@ -0,0 +1,119 @@
<template>
<div v-if="siteKey" class="captcha-turnstile">
<div ref="containerRef" class="captcha-turnstile__container" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
interface TurnstileRenderOptions {
sitekey: string
callback: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
}
interface TurnstileAPI {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileAPI
__pluginTurnstileReady?: () => void
}
}
const props = withDefaults(
defineProps<{ siteKey: string; theme?: 'light' | 'dark' | 'auto'; size?: 'normal' | 'compact' | 'flexible' }>(),
{ theme: 'auto', size: 'flexible' }
)
const emit = defineEmits<{ verify: [token: string]; expire: []; error: [] }>()
const containerRef = ref<HTMLElement | null>(null)
let widgetId: string | null = null
let scriptPromise: Promise<void> | null = null
function loadScript(): Promise<void> {
if (window.turnstile) return Promise.resolve()
if (scriptPromise) return scriptPromise
scriptPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src*="challenges.cloudflare.com/turnstile"]')
if (existing) {
const onLoad = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
existing.addEventListener('load', onLoad, { once: true })
if (window.turnstile) resolve()
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
script.async = true
script.defer = true
script.onload = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
script.onerror = () => reject(new Error('Failed to load Turnstile SDK'))
document.head.appendChild(script)
}).catch((error) => {
scriptPromise = null
throw error
})
return scriptPromise
}
function renderWidget() {
if (!window.turnstile || !containerRef.value || !props.siteKey) return
if (widgetId) {
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
widgetId = null
}
containerRef.value.innerHTML = ''
widgetId = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey,
theme: props.theme,
size: props.size,
callback: (token) => emit('verify', token),
'expired-callback': () => emit('expire'),
'error-callback': () => emit('error'),
})
}
function reset() {
if (window.turnstile && widgetId) {
try { window.turnstile.reset(widgetId) } catch { /* SDK cleanup is best effort. */ }
}
}
defineExpose({ reset })
onMounted(async () => {
if (!props.siteKey) return
try {
await loadScript()
renderWidget()
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error')
}
})
watch(() => props.siteKey, (value) => {
if (value && window.turnstile) renderWidget()
})
onUnmounted(() => {
if (window.turnstile && widgetId) {
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
}
widgetId = null
})
</script>
<style scoped>
.captcha-turnstile { width: 100%; min-height: 66px; }
.captcha-turnstile__container { width: 100%; min-height: 66px; }
.captcha-turnstile__container :deep(iframe) { display: block; max-width: 100%; }
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,94 @@
<template>
<t-layout class="app-shell">
<div v-if="mobileOpen" class="mobile-scrim" @click="mobileOpen = false" />
<t-aside :class="['app-aside', { 'app-aside-mobile-open': mobileOpen }]" width="252px">
<div class="brand">
<div class="brand-mark"><ControlPlatformIcon size="22px" /></div>
<div>
<div class="brand-title">Sub2API</div>
<div class="brand-subtitle">插件控制面</div>
</div>
</div>
<div class="nav-caption">控制台</div>
<t-menu :value="activeMenu" class="main-menu" @change="onMenuChange">
<t-menu-item value="/overview"><template #icon><DashboardIcon /></template>概览</t-menu-item>
<t-menu-item value="/plugins"><template #icon><AppIcon /></template>已安装插件</t-menu-item>
<t-menu-item value="/marketplace"><template #icon><ShopIcon /></template>插件市场</t-menu-item>
<t-menu-item value="/operations"><template #icon><HistoryIcon /></template>操作记录</t-menu-item>
<template v-if="subscriptionEnabled">
<t-divider class="nav-divider" />
<div class="nav-caption nav-caption-module">业务模块</div>
</template>
<t-menu-item v-if="subscriptionEnabled" value="/modules/subscription/overview">
<template #icon><ChartLineIcon /></template>订阅管理
</t-menu-item>
</t-menu>
<div class="aside-footer">
<t-tag theme="success" variant="light" class="status-tag"><CheckCircleIcon size="14px" /> Core 会话正常</t-tag>
<span class="version-text">Plugin Admin v1.1.0</span>
</div>
</t-aside>
<t-layout class="main-layout">
<t-header class="app-header">
<div class="header-left">
<t-button class="mobile-menu-button" variant="text" shape="square" aria-label="打开导航" @click="mobileOpen = true">
<template #icon><MenuIcon /></template>
</t-button>
<div class="breadcrumbs"><span>插件管理</span><ChevronRightIcon size="15px" /><strong>{{ pageTitle }}</strong></div>
</div>
<div class="header-right">
<t-tag theme="success" variant="light" class="secure-tag"><CheckCircleIcon size="14px" /> 安全会话</t-tag>
<t-dropdown :options="userOptions" trigger="click" @click="onUserAction">
<t-button variant="text" class="user-button">
<template #icon><UserCircleIcon /></template>
<span class="user-name">{{ displayName }}</span><ChevronDownIcon size="15px" />
</t-button>
</t-dropdown>
</div>
</t-header>
<t-content class="app-content"><router-view /></t-content>
</t-layout>
</t-layout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
AppIcon, ChartLineIcon, CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, ControlPlatformIcon,
DashboardIcon, HistoryIcon, MenuIcon, ShopIcon, UserCircleIcon,
} from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import { authState, loadSubscriptionState, logout, moduleState } from '../services/api'
const route = useRoute()
const router = useRouter()
const mobileOpen = ref(false)
const subscriptionEnabled = computed(() => moduleState.subscriptionEnabled)
const pageTitle = computed(() => String(route.meta.title || (route.path.startsWith('/modules/subscription') ? '订阅管理' : '概览')))
const activeMenu = computed(() => route.path.startsWith('/plugins') ? '/plugins' : route.path.startsWith('/modules/subscription') ? '/modules/subscription/overview' : route.path)
const displayName = computed(() => String(authState.user?.email || authState.user?.username || '管理员'))
const userOptions = [{ content: '退出登录', value: 'logout' }]
async function loadModules() {
await loadSubscriptionState(true)
}
function onMenuChange(value: string | number) {
const target = String(value)
if (target === '/modules/subscription/overview' && !subscriptionEnabled.value) {
MessagePlugin.warning('请先安装并启用订阅模块')
return
}
router.push(target)
mobileOpen.value = false
}
async function onUserAction(data: any) {
if (String(data?.value || '') !== 'logout') return
await logout()
router.push('/login')
}
onMounted(loadModules)
</script>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
import './styles.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(TDesign)
app.use(createPinia())
app.use(router)
app.mount('#app')
+43
View File
@@ -0,0 +1,43 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Shell from './layouts/Shell.vue'
import Login from './views/Login.vue'
import Overview from './views/Overview.vue'
import Plugins from './views/Plugins.vue'
import PluginDetail from './views/PluginDetail.vue'
import Marketplace from './views/Marketplace.vue'
import Operations from './views/Operations.vue'
import Subscription from './views/Subscription.vue'
import { authState, loadSubscriptionState } from './services/api'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login, meta: { public: true } },
{
path: '/', component: Shell,
children: [
{ path: '', redirect: '/overview' },
{ path: 'overview', component: Overview, meta: { title: '概览' } },
{ path: 'plugins', component: Plugins, meta: { title: '已安装插件' } },
{ path: 'plugins/:id/:tab?', component: PluginDetail, meta: { title: '插件详情' } },
{ path: 'marketplace', component: Marketplace, meta: { title: '插件市场' } },
{ path: 'operations', component: Operations, meta: { title: '操作记录' } },
{ path: 'modules/subscription/:tab?', component: Subscription, meta: { title: '订阅管理' } },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/overview' },
],
})
router.beforeEach(async (to) => {
if (!authState.ready) return true
if (!authState.user && !to.meta.public) return '/login'
if (authState.user && to.path === '/login') return '/overview'
if (authState.user && to.path.startsWith('/modules/subscription')) {
const enabled = await loadSubscriptionState()
if (!enabled) return '/overview'
}
return true
})
export default router
@@ -0,0 +1,212 @@
import { reactive } from 'vue'
export interface User {
id?: string | number
email?: string
username?: string
role?: string
status?: string
[key: string]: unknown
}
export interface CaptchaConfig {
enabled: boolean
provider: '' | 'turnstile' | 'tencent' | 'aliyun' | string
turnstile_enabled: boolean
turnstile_site_key: string
tencent_captcha_enabled: boolean
tencent_captcha_app_id: string
tencent_captcha_region: string
aliyun_captcha_enabled: boolean
aliyun_captcha_scene_id: string
aliyun_captcha_prefix: string
aliyun_captcha_region: string
}
export const authState = reactive<{
ready: boolean
user: User | null
csrf: string
pendingToken: string
}>({ ready: false, user: null, csrf: '', pendingToken: '' })
export const moduleState = reactive<{
subscriptionEnabled: boolean
loaded: boolean
loading: boolean
}>({ subscriptionEnabled: false, loaded: false, loading: false })
const basePath = () => {
const meta = document.querySelector('meta[name="plugin-base-path"]')
return (meta?.getAttribute('content') || '').replace(/\/$/, '')
}
export class ApiError extends Error {
status: number
constructor(message: string, status = 0) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
const method = (options.method || 'GET').toUpperCase()
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
if (authState.csrf && method !== 'GET') headers.set('X-CSRF-Token', authState.csrf)
const response = await fetch(`${basePath()}${path}`, { ...options, method, headers, credentials: 'same-origin' })
const data = await response.json().catch(() => ({}))
if (!response.ok) {
if (response.status === 401 && !path.startsWith('/login')) {
authState.user = null
authState.csrf = ''
}
throw new ApiError(data?.error || `请求失败 (${response.status})`, response.status)
}
return data as T
}
export async function hydrateSession() {
try {
const data = await request<{ user: User; csrf_token: string }>('/api/me')
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
} catch (_) {
authState.user = null
authState.csrf = ''
moduleState.loaded = false
moduleState.subscriptionEnabled = false
} finally {
authState.ready = true
}
}
export async function getCaptchaConfig(): Promise<CaptchaConfig> {
const data = await request<Partial<CaptchaConfig>>('/api/captcha-config')
return {
enabled: data.enabled === true,
provider: data.provider || '',
turnstile_enabled: data.turnstile_enabled === true,
turnstile_site_key: String(data.turnstile_site_key || ''),
tencent_captcha_enabled: data.tencent_captcha_enabled === true,
tencent_captcha_app_id: String(data.tencent_captcha_app_id || ''),
tencent_captcha_region: String(data.tencent_captcha_region || 'cn'),
aliyun_captcha_enabled: data.aliyun_captcha_enabled === true,
aliyun_captcha_scene_id: String(data.aliyun_captcha_scene_id || ''),
aliyun_captcha_prefix: String(data.aliyun_captcha_prefix || ''),
aliyun_captcha_region: String(data.aliyun_captcha_region || 'cn'),
}
}
export async function loadSubscriptionState(force = false): Promise<boolean> {
if (moduleState.loaded && !force) return moduleState.subscriptionEnabled
if (moduleState.loading) {
// A concurrent router/Shell request should observe the same eventual
// server decision instead of issuing an unbounded request fan-out.
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (!moduleState.loading) {
window.clearInterval(timer)
resolve()
}
}, 20)
})
return moduleState.subscriptionEnabled
}
moduleState.loading = true
try {
const data = await request<any>('/api/subscription/status')
const payload = unwrap<any>(data) || {}
moduleState.subscriptionEnabled = payload.enabled === true
} catch (_) {
moduleState.subscriptionEnabled = false
} finally {
moduleState.loaded = true
moduleState.loading = false
}
return moduleState.subscriptionEnabled
}
export async function login(email: string, password: string, extra: Record<string, unknown> = {}) {
const data = await request<{ requires_2fa?: boolean; pending_token?: string; user?: User; csrf_token?: string }>('/login', {
method: 'POST',
body: JSON.stringify({ email, password, ...extra }),
})
if (data.requires_2fa) {
authState.pendingToken = data.pending_token || ''
return { requires2fa: true }
}
authState.pendingToken = ''
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
moduleState.loaded = false
return { requires2fa: false }
}
export async function verify2fa(code: string) {
const data = await request<{ user?: User; csrf_token?: string }>('/login/2fa', {
method: 'POST',
body: JSON.stringify({ pending_token: authState.pendingToken, totp_code: code }),
})
authState.pendingToken = ''
authState.user = data.user || null
authState.csrf = data.csrf_token || ''
moduleState.loaded = false
}
export async function logout() {
try {
await request('/logout', { method: 'POST' })
} finally {
authState.user = null
authState.csrf = ''
authState.pendingToken = ''
moduleState.loaded = false
moduleState.subscriptionEnabled = false
}
}
export function unwrap<T = any>(response: any): T {
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && ('code' in response || 'message' in response)) return response.data as T
return response as T
}
export function listFrom(value: any): any[] {
const data = unwrap<any>(value)
if (Array.isArray(data)) return data
if (Array.isArray(data?.items)) return data.items
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.rows)) return data.rows
return []
}
export function formatDate(value: unknown) {
if (!value) return '-'
const date = new Date(String(value))
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
export function stateLabel(value: unknown) {
const labels: Record<string, string> = {
healthy: '运行中', enabled: '运行中', disabled: '已停用', incompatible: 'Core 不兼容', error: '启用失败',
starting: '启动中', draining: '停用中', upgrading: '升级中', rollback_pending: '等待回滚',
}
return labels[String(value)] || String(value || '未知')
}
export function operationSucceeded(value: unknown) {
return ['completed', 'success', 'succeeded'].includes(String(value || '').toLowerCase())
}
export function operationTheme(value: unknown) {
const state = String(value || '').toLowerCase()
if (operationSucceeded(state)) return 'success'
if (state === 'failed' || state === 'error') return 'danger'
return 'warning'
}
export function operationKey(kind: string, id = '') {
return `${kind}-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
@@ -0,0 +1,8 @@
import { AppIcon, CheckCircleIcon, CloudDownloadIcon, DeleteIcon, HistoryIcon, RollbackIcon, SettingIcon, UploadIcon } from 'tdesign-icons-vue-next'
export const actionLabels: Record<string, string> = { install: '入库', marketplace_install: '市场入库', enable: '启用', disable: '停用', upgrade: '升级', rollback: '回滚', uninstall: '卸载', menu_preview: '菜单预览', menu_apply: '应用菜单', config: '保存配置' }
export function actionLabel(action: unknown) { return actionLabels[String(action)] || String(action || '操作') }
export function actionIcon(action: unknown) {
const icons: Record<string, any> = { install: CloudDownloadIcon, marketplace_install: CloudDownloadIcon, enable: CheckCircleIcon, disable: SettingIcon, upgrade: UploadIcon, rollback: RollbackIcon, uninstall: DeleteIcon, menu_preview: AppIcon, menu_apply: AppIcon, config: SettingIcon }
return icons[String(action)] || HistoryIcon
}
@@ -0,0 +1,89 @@
export type TencentCaptchaRegion = 'cn' | 'intl'
export interface TencentCaptchaProof {
ticket: string
randstr: string
}
interface TencentCaptchaResult {
ret: number
ticket?: string | null
randstr?: string | null
errorCode?: number
}
interface TencentCaptchaInstance {
show(): void
destroy(): void
}
interface TencentCaptchaConstructor {
new (appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
new (element: HTMLElement, appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
}
declare global {
interface Window {
TencentCaptcha?: TencentCaptchaConstructor
TCaptchaGlobal?: boolean
}
}
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js',
}
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
let loadedRegion: TencentCaptchaRegion | null = null
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
return value === 'intl' ? 'intl' : 'cn'
}
function existingRegion(): TencentCaptchaRegion {
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
}
export function loadTencentCaptcha(region: TencentCaptchaRegion = 'cn'): Promise<TencentCaptchaConstructor> {
if (window.TencentCaptcha && (loadedRegion === region || existingRegion() === region)) {
return Promise.resolve(window.TencentCaptcha)
}
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
}
if (scriptPromise && loadedRegion === region) return scriptPromise
loadedRegion = region
scriptPromise = new Promise<TencentCaptchaConstructor>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC[region]}"]`)
if (existing) {
const finish = () => window.TencentCaptcha && existingRegion() === region
? resolve(window.TencentCaptcha)
: reject(new Error('Tencent Captcha SDK unavailable'))
if (window.TencentCaptcha) finish()
else existing.addEventListener('load', finish, { once: true })
existing.addEventListener('error', () => reject(new Error('Failed to load Tencent Captcha SDK')), { once: true })
return
}
const script = document.createElement('script')
script.src = SCRIPT_SRC[region]
script.async = true
script.onload = () => {
if (window.TencentCaptcha && existingRegion() === region) resolve(window.TencentCaptcha)
else reject(new Error('Tencent Captcha SDK unavailable'))
}
script.onerror = () => reject(new Error('Failed to load Tencent Captcha SDK'))
document.head.appendChild(script)
}).catch((error) => {
scriptPromise = null
loadedRegion = null
throw error
})
return scriptPromise
}
export function resetTencentCaptchaLoaderForTest() {
scriptPromise = null
loadedRegion = null
}
+240
View File
@@ -0,0 +1,240 @@
:root {
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #1f2937;
background: #f3f6fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
--app-blue: #0052d9;
--app-ink: #1f2937;
--app-muted: #718096;
--app-border: #e5e7eb;
--app-panel: #fff;
}
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { overflow-x: hidden; background: #f3f6fa; }
button, input, textarea, select { font: inherit; }
a, button { -webkit-tap-highlight-color: transparent; }
.mono, code, .numeric, .stat-card strong { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-variant-numeric: tabular-nums; }
.wrap-anywhere { overflow-wrap: anywhere; word-break: break-word; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.boot-screen { min-height: 100vh; display: grid; place-items: center; color: var(--app-muted); }
/* Login */
.login-page { position: relative; min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; background: radial-gradient(circle at 12% 10%, #e8f1ff 0, transparent 34%), #f5f7fb; }
.login-accent { position: absolute; inset: 0 0 auto; height: 4px; background: var(--app-blue); }
.login-layout { width: min(1060px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 80px; align-items: center; }
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 auto; border-radius: 10px; color: #fff; background: var(--app-blue); box-shadow: 0 8px 20px rgb(0 82 217 / 20%); }
.brand-title { color: #111827; font-size: 18px; font-weight: 700; letter-spacing: .01em; }
.brand-subtitle { margin-top: 2px; color: var(--app-muted); font-size: 12px; }
.brand-login { margin-bottom: 72px; }
.login-intro .eyebrow { margin-bottom: 14px; }
.eyebrow { margin: 0 0 7px; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; line-height: 1.3; }
.login-intro h1 { max-width: 560px; margin: 0 0 18px; color: #111827; font-size: clamp(32px, 4vw, 48px); line-height: 1.12; letter-spacing: 0; }
.login-lead { max-width: 560px; margin: 0; color: #5f6b7a; font-size: 16px; line-height: 1.75; }
.login-points { display: grid; gap: 13px; margin-top: 34px; }
.login-points div { display: flex; align-items: center; gap: 10px; color: #4b5563; font-size: 13px; }
.login-points svg { color: #00a870; flex: 0 0 auto; }
.login-card { border-radius: 10px !important; box-shadow: 0 18px 50px rgb(15 23 42 / 9%); }
.login-card .t-card__body { padding: 30px; }
.login-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.login-card h2 { margin: 0; color: #111827; font-size: 25px; line-height: 1.25; }
.login-card > .t-card__body > .muted { margin: 0 0 24px; }
.muted, .secondary-text { color: var(--app-muted); font-size: 13px; line-height: 1.55; }
.login-form { display: grid; gap: 4px; }
.login-form .t-form__item { margin-bottom: 14px; }
.login-form .t-button { height: 42px; margin-top: 5px; }
.login-form .captcha-challenge + .t-button { margin-top: 2px; }
.login-error { margin-top: 18px; }
.login-security { display: flex; align-items: center; gap: 7px; justify-content: center; margin-top: 24px; padding-top: 18px; border-top: 1px solid #edf0f4; color: #8792a2; font-size: 11px; }
/* App shell */
.app-shell { min-height: 100vh; }
.app-aside { position: relative; z-index: 30; display: flex; flex-direction: column; min-height: 100vh; border-right: 1px solid #e6eaf0; background: #fff; }
.brand { padding: 23px 22px 20px; }
.app-aside .brand-mark { width: 36px; height: 36px; border-radius: 8px; box-shadow: none; }
.nav-caption { padding: 17px 22px 8px; color: #9aa5b4; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.nav-caption-module { padding-top: 5px; }
.main-menu { border-right: 0 !important; }
.main-menu .t-menu__item { min-height: 44px; margin: 2px 10px; border-radius: 5px; color: #4b5563; font-size: 14px; }
.main-menu .t-menu__item.t-is-active { color: var(--app-blue); background: #e8f1ff; }
.main-menu .t-menu__item:hover { background: #f4f7fb; }
.main-menu .t-menu__item.t-is-disabled { opacity: .55; }
.nav-divider { margin: 10px 22px 0 !important; }
.aside-footer { display: grid; gap: 10px; margin-top: auto; padding: 18px 22px 22px; border-top: 1px solid #edf0f4; }
.status-tag { justify-self: start; display: inline-flex; align-items: center; gap: 6px; }
.version-text { color: #9aa5b4; font-size: 11px; }
.main-layout { min-width: 0; min-height: 100vh; }
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 64px; padding: 0 34px; border-bottom: 1px solid #e6eaf0; background: #fff; }
.header-left, .header-right, .breadcrumbs, .user-button { display: flex; align-items: center; min-width: 0; }
.header-left, .header-right { gap: 13px; }
.breadcrumbs { gap: 9px; color: #8a96a5; font-size: 13px; }
.breadcrumbs strong { overflow: hidden; color: #1f2937; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.secure-tag { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.user-button { gap: 6px; color: #374151; }
.user-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-menu-button { display: none; }
.app-content { min-width: 0; padding: 32px 34px 56px; }
.page-view { width: min(1400px, 100%); min-width: 0; margin: 0 auto; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; padding-bottom: 21px; border-bottom: 0; }
.page-header h1 { margin: 0 0 5px; color: #111827; font-size: 28px; font-weight: 700; line-height: 1.25; letter-spacing: 0; }
.page-subtitle { max-width: 720px; margin: 0; color: #718096; font-size: 13px; line-height: 1.6; }
.page-actions, .toolbar-actions, .table-actions, .detail-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.page-actions { flex: 0 0 auto; }
.page-alert { margin-bottom: 16px; }
.page-toolbar, .section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-toolbar { align-items: flex-end; }
.section-toolbar h2 { margin: 0 0 4px; color: #1f2937; font-size: 20px; }
.toolbar-hint { color: #9aa5b4; font-size: 12px; }
/* Cards and statistics */
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.stat-card { min-width: 0; border-top: 3px solid #94a3b8 !important; border-radius: 7px !important; }
.stat-card .t-card__body { padding: 18px; }
.stat-card.stat-success { border-top-color: #00a870 !important; }
.stat-card.stat-warning { border-top-color: #ed7b2f !important; }
.stat-card.stat-danger { border-top-color: #d54941 !important; }
.stat-card.stat-primary { border-top-color: var(--app-blue) !important; }
.stat-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 15px; color: #637083; font-size: 12px; }
.stat-card-top svg { color: var(--app-blue); }
.stat-card strong { display: block; margin-bottom: 7px; color: #111827; font-size: 29px; font-weight: 700; line-height: 1; }
.stat-card small { color: #96a0ae; font-size: 11px; }
.stat-success .stat-card-top svg { color: #00a870; }
.stat-warning .stat-card-top svg { color: #ed7b2f; }
.stat-danger .stat-card-top svg { color: #d54941; }
.overview-grid, .detail-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(340px, .8fr); gap: 16px; margin-bottom: 16px; }
.panel-card, .table-card, .filter-card, .empty-card { min-width: 0; border-radius: 7px !important; }
.panel-card .t-card__header, .table-card .t-card__header { padding: 18px 20px 0; }
.panel-card .t-card__title, .table-card .t-card__title { color: #1f2937; font-size: 16px; font-weight: 600; }
.panel-card .t-card__body { padding: 18px 20px 20px; }
.panel-card .t-card__actions { color: var(--app-blue); }
.trend-chart { width: 100%; height: 255px; min-height: 220px; }
.guidance-card { margin-bottom: 0; }
.guidance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; }
.guidance-item { display: grid; grid-template-columns: 31px minmax(0, 1fr); gap: 10px; }
.guidance-number { color: var(--app-blue); font-size: 12px; font-weight: 700; line-height: 1.7; }
.guidance-item strong { color: #344054; font-size: 13px; }
.guidance-item p { margin: 5px 0 0; color: #7c8795; font-size: 12px; line-height: 1.55; }
.table-action { display: inline-flex; align-items: center; gap: 7px; color: #344054; }
.table-actions { justify-content: flex-start; flex-wrap: nowrap; }
.table-actions .t-button { flex: 0 0 auto; }
.cell-note { display: block; margin-top: 3px; color: #9aa5b4; font-size: 11px; }
.plugin-cell { display: flex; align-items: center; gap: 11px; min-width: 0; }
.plugin-cell > div:last-child { display: grid; gap: 3px; min-width: 0; }
.plugin-cell strong { overflow: hidden; color: #263244; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.plugin-avatar, .market-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 7px; color: var(--app-blue); background: #e8f1ff; }
.empty-card { padding: 34px; }
.filter-card { margin-bottom: 16px; }
.filter-card .t-card__body { padding: 17px 20px; }
.filter-grid { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 12px; }
.filter-grid .t-form__item { margin-bottom: 0; }
/* Detail */
.detail-back { margin-bottom: 14px; }
.detail-back .t-link { font-size: 13px; }
.detail-tabs { margin-bottom: 22px; }
.detail-tabs .t-tabs__nav { overflow-x: auto; }
.detail-tabs .t-tabs__nav-container { min-width: max-content; }
.detail-tabs .t-tabs__content { display: none; }
.detail-content { min-width: 0; }
.detail-actions { margin-bottom: 16px; }
.description-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; margin: 0; }
.description-grid > div { min-width: 0; }
.description-grid dt { margin-bottom: 5px; color: #8a96a5; font-size: 12px; }
.description-grid dd { min-width: 0; color: #344054; font-size: 13px; line-height: 1.5; }
.inline-alert { margin-top: 20px; }
.narrow-content { max-width: 900px; }
.tag-gap { margin-left: 8px; }
.preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.preview-grid h3 { margin: 0 0 8px; font-size: 13px; }
.preview-grid pre { min-height: 210px; max-height: 420px; margin: 0; padding: 13px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 5px; color: #344054; background: #f7f9fc; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.loading-panel { display: grid; place-items: center; min-height: 260px; }
.pagination-row { display: flex; justify-content: flex-end; padding-top: 17px; }
.drawer-description { margin-bottom: 20px; }
/* Marketplace */
.market-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.market-card { min-width: 0; border-radius: 7px !important; }
.market-card .t-card__header { padding: 20px 20px 0; }
.market-card .t-card__body { padding: 16px 20px; }
.market-card .t-card__footer { padding: 0 20px 20px; }
.market-heading { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.market-title { min-width: 0; flex: 1 1 auto; }
.market-title h2 { margin: 0 0 4px; overflow: hidden; color: #263244; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.market-heading > .t-tag { flex: 0 0 auto; }
.market-description { min-height: 42px; margin: 0 0 18px; color: #687585; font-size: 13px; line-height: 1.6; }
.market-meta { gap: 13px 20px; margin-bottom: 17px; }
.market-hash { display: grid; gap: 5px; padding-top: 13px; border-top: 1px solid #edf0f4; }
.market-hash span { color: #9aa5b4; font-size: 11px; }
.market-hash code { color: #556274; font-size: 11px; overflow-wrap: anywhere; }
/* TDesign table containment */
.table-card { overflow: hidden; }
.table-card .t-card__body { min-width: 0; padding: 0 0 18px; }
.table-card .t-table { min-width: 680px; }
.table-card .t-table__content { overflow-x: auto; }
.table-card .t-table__header th { background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 600; }
.table-card .t-table__body td { color: #344054; font-size: 13px; }
.table-card .t-table__pagination { padding: 15px 20px 0; }
@media (max-width: 1100px) {
.login-layout { gap: 44px; }
.app-header { padding: 0 24px; }
.app-content { padding: 28px 24px 44px; }
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-grid, .detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.login-page { padding: 26px 16px; place-items: start center; }
.login-layout { grid-template-columns: 1fr; gap: 28px; margin-top: 16px; }
.brand-login { margin-bottom: 34px; }
.login-intro h1 { font-size: 33px; }
.login-lead { font-size: 14px; }
.login-points { display: none; }
.login-card .t-card__body { padding: 22px 18px; }
.app-aside { position: fixed; inset: 0 auto 0 0; width: min(280px, calc(100vw - 48px)); min-height: 100vh; transform: translateX(-105%); transition: transform .2s ease; box-shadow: 12px 0 32px rgb(15 23 42 / 16%); }
.app-aside.app-aside-mobile-open { transform: translateX(0); }
.mobile-scrim { position: fixed; z-index: 20; inset: 0; background: rgb(15 23 42 / 32%); }
.mobile-menu-button { display: inline-flex; }
.app-header { height: 58px; padding: 0 14px; }
.header-left, .header-right { gap: 8px; }
.secure-tag { display: none; }
.user-name { max-width: 108px; }
.app-content { padding: 22px 14px 36px; }
.page-header { align-items: flex-start; flex-direction: column; gap: 14px; margin-bottom: 18px; }
.page-header h1 { font-size: 23px; }
.page-actions { width: 100%; justify-content: flex-start; }
.page-actions .t-button { flex: 1 1 auto; min-width: 0; }
.page-toolbar, .section-toolbar { align-items: flex-start; flex-direction: column; gap: 8px; }
.section-toolbar .t-button, .toolbar-actions { width: 100%; justify-content: flex-start; }
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.stat-card .t-card__body { padding: 13px; }
.stat-card strong { font-size: 23px; }
.stat-card-top { margin-bottom: 11px; }
.overview-grid, .detail-grid, .market-grid { grid-template-columns: 1fr; gap: 12px; }
.guidance-grid { grid-template-columns: 1fr; gap: 15px; }
.panel-card .t-card__header, .table-card .t-card__header { padding: 15px 15px 0; }
.panel-card .t-card__body { padding: 15px; }
.filter-card .t-card__body { padding: 14px; }
.filter-grid { grid-template-columns: 1fr; gap: 10px; }
.filter-grid .t-button { justify-self: start; }
.description-grid { grid-template-columns: 1fr; gap: 13px; }
.detail-tabs { margin-bottom: 17px; }
.detail-tabs .t-tabs__nav-item { padding: 0 12px; }
.preview-grid { grid-template-columns: 1fr; }
.market-card .t-card__header { padding: 16px 15px 0; }
.market-card .t-card__body { padding: 14px 15px; }
.market-card .t-card__footer { padding: 0 15px 15px; }
.table-actions { flex-wrap: wrap; }
.table-card .t-table { min-width: 720px; }
.pagination-row { justify-content: flex-start; overflow-x: auto; }
}
@media (max-width: 430px) {
.app-content { padding-left: 10px; padding-right: 10px; }
.page-actions .t-button { padding-left: 10px; padding-right: 10px; font-size: 12px; }
.breadcrumbs span { display: none; }
.breadcrumbs strong { max-width: 150px; }
.user-name { max-width: 90px; }
.stat-card small { font-size: 10px; }
}
@@ -0,0 +1,181 @@
<template>
<main class="login-page">
<div class="login-accent" />
<section class="login-layout">
<div class="login-intro">
<div class="brand brand-login"><div class="brand-mark"><ControlPlatformIcon size="24px" /></div><div><div class="brand-title">Sub2API</div><div class="brand-subtitle">插件控制面</div></div></div>
<p class="eyebrow">BUSINESS PLUGIN CONTROL PLANE</p>
<h1>统一管理你的业务插件</h1>
<p class="login-lead">使用 Core 管理员会话进入插件市场、生命周期和业务模块。所有插件操作都在同一安全控制面完成。</p>
<div class="login-points">
<div><CheckCircleIcon size="18px" /><span>一次登录,访问已启用业务模块</span></div>
<div><CheckCircleIcon size="18px" /><span>凭据仅保存在服务端会话</span></div>
<div><CheckCircleIcon size="18px" /><span>每次变更都有可追溯操作记录</span></div>
</div>
</div>
<t-card class="login-card" :bordered="true">
<div class="login-card-heading"><div><p class="eyebrow">ADMIN ACCESS</p><h2>管理员登录</h2></div><t-tag theme="primary" variant="light">仅管理员</t-tag></div>
<p class="muted">使用 Sub2API Core 的管理员账号登录,普通账号没有访问权限。</p>
<t-alert v-if="captchaConfigError" class="login-error" theme="warning" :message="captchaConfigError" />
<t-form v-if="!twoFactor" ref="loginForm" class="login-form" :data="credentials" :rules="rules" @submit="submitLogin">
<t-form-item label="邮箱" name="email"><t-input v-model="credentials.email" type="text" aria-label="邮箱" autocomplete="username" placeholder="admin@example.com" /></t-form-item>
<t-form-item label="密码" name="password"><t-input v-model="credentials.password" type="password" aria-label="密码" autocomplete="current-password" placeholder="输入管理员密码" /></t-form-item>
<CaptchaChallenge
v-if="captchaLoaded && captchaConfig.enabled"
ref="captchaRef"
:enabled="captchaConfig.enabled"
:provider="captchaConfig.provider"
:turnstile-site-key="captchaConfig.turnstile_site_key"
:tencent-app-id="captchaConfig.tencent_captcha_app_id"
:tencent-region="captchaConfig.tencent_captcha_region"
:aliyun-scene-id="captchaConfig.aliyun_captcha_scene_id"
:aliyun-prefix="captchaConfig.aliyun_captcha_prefix"
:aliyun-region="captchaConfig.aliyun_captcha_region"
@verify="onCaptchaVerify"
@expire="onCaptchaExpire"
@error="onCaptchaError"
/>
<t-button theme="primary" type="submit" block :loading="loading" :disabled="!captchaLoaded || Boolean(captchaConfigError)"><template #icon><UserCircleIcon /></template>登录</t-button>
</t-form>
<t-form v-else class="login-form" :data="twoFactorData" @submit="submit2fa">
<t-alert theme="info" title="需要二次验证">请输入 Core 返回的 6 位验证码。</t-alert>
<t-form-item label="验证码" name="totp_code"><t-input v-model="twoFactorData.totp_code" inputmode="numeric" maxlength="6" placeholder="000000" /></t-form-item>
<t-button theme="primary" type="submit" block :loading="loading"><template #icon><CheckCircleIcon /></template>验证并继续</t-button>
<t-button variant="text" block @click="resetTwoFactor">返回账号登录</t-button>
</t-form>
<t-alert v-if="errorMessage" class="login-error" theme="error" :message="errorMessage" />
<div class="login-security"><LockOnIcon size="15px" /> HttpOnly 会话 · CSRF 保护 · 8 小时绝对有效期</div>
</t-card>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircleIcon, ControlPlatformIcon, LockOnIcon, UserCircleIcon } from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import CaptchaChallenge, { type CaptchaProof } from '../components/CaptchaChallenge.vue'
import { getCaptchaConfig, login, verify2fa, type CaptchaConfig } from '../services/api'
const router = useRouter()
const loading = ref(false)
const twoFactor = ref(false)
const errorMessage = ref('')
const captchaLoaded = ref(false)
const captchaConfigError = ref('')
const captchaRef = ref<InstanceType<typeof CaptchaChallenge> | null>(null)
const captchaProof = ref<CaptchaProof | null>(null)
const captchaConfig = reactive<CaptchaConfig>({
enabled: false,
provider: '',
turnstile_enabled: false,
turnstile_site_key: '',
tencent_captcha_enabled: false,
tencent_captcha_app_id: '',
tencent_captcha_region: 'cn',
aliyun_captcha_enabled: false,
aliyun_captcha_scene_id: '',
aliyun_captcha_prefix: '',
aliyun_captcha_region: 'cn',
})
const credentials = reactive({ email: '', password: '' })
const twoFactorData = reactive({ totp_code: '' })
const rules = {
email: [{ required: true, message: '请输入管理员邮箱', type: 'error' as const }],
password: [{ required: true, message: '请输入密码', type: 'error' as const }],
}
async function submitLogin() {
if (!credentials.email.trim() || !credentials.password) { errorMessage.value = '请输入邮箱和密码'; return }
loading.value = true
errorMessage.value = ''
try {
const proof = captchaConfig.enabled ? (await captchaRef.value?.verifyAction()) || null : null
if (captchaConfig.enabled && !proof) {
errorMessage.value = '请先完成安全验证'
return
}
captchaProof.value = proof
const extra = proof?.provider === 'tencent'
? { tencent_captcha_ticket: proof.token, tencent_captcha_randstr: proof.randstr }
: proof?.provider === 'aliyun'
? { turnstile_token: proof.token }
: proof?.provider === 'turnstile'
? { turnstile_token: proof.token }
: {}
const result = await login(credentials.email.trim(), credentials.password, extra)
if (result.requires2fa) twoFactor.value = true
else {
MessagePlugin.success('登录成功')
router.push('/overview')
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
} finally {
captchaRef.value?.reset()
captchaProof.value = null
loading.value = false
}
}
async function submit2fa() {
if (!/^\d{6}$/.test(twoFactorData.totp_code)) {
errorMessage.value = '请输入 6 位数字验证码'
return
}
loading.value = true
errorMessage.value = ''
try {
await verify2fa(twoFactorData.totp_code)
MessagePlugin.success('登录成功')
router.push('/overview')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '验证码校验失败'
} finally {
loading.value = false
}
}
function resetTwoFactor() {
twoFactor.value = false
twoFactorData.totp_code = ''
errorMessage.value = ''
}
function onCaptchaVerify(proof: CaptchaProof) {
captchaProof.value = proof
errorMessage.value = ''
}
function onCaptchaExpire() {
captchaProof.value = null
}
function onCaptchaError() {
captchaProof.value = null
errorMessage.value = '安全验证加载失败,请刷新页面重试'
}
onMounted(async () => {
captchaConfigError.value = ''
try {
const loaded = await getCaptchaConfig()
Object.assign(captchaConfig, loaded)
const enabledProviders = [
loaded.turnstile_enabled,
loaded.tencent_captcha_enabled,
loaded.aliyun_captcha_enabled,
].filter(Boolean).length
if (enabledProviders > 1) {
captchaConfigError.value = 'Core 同时启用了多个验证码服务,请保留一个服务商后重试'
} else if (loaded.enabled && !loaded.provider) {
captchaConfigError.value = '验证码配置不完整,请联系管理员'
}
} catch (error) {
captchaConfigError.value = error instanceof Error ? `验证码配置加载失败:${error.message}` : '验证码配置加载失败,请刷新页面重试'
} finally {
captchaLoaded.value = true
}
})
</script>
@@ -0,0 +1,35 @@
<template>
<div class="page-view">
<PageHeader eyebrow="MARKETPLACE" title="插件市场" subtitle="从受控目录查看可用版本,下载后先入库,再由管理员手动启用。">
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新市场</t-button></template>
</PageHeader>
<t-alert theme="info" class="page-alert" message="归档地址只在控制面服务端访问,浏览器不会接触下载 URL。" />
<t-alert v-if="error" theme="error" class="page-alert" :message="error" />
<section v-if="items.length" class="market-grid">
<t-card v-for="item in items" :key="`${item.plugin_id}-${item.version}`" class="market-card" :bordered="true">
<template #header><div class="market-heading"><div class="market-icon"><CloudDownloadIcon size="20px" /></div><div class="market-title"><h2>{{ item.name || item.plugin_id }}</h2><span class="secondary-text mono">{{ item.plugin_id }} · v{{ item.version || '-' }}</span></div><t-tag :theme="marketState(item).theme as any" variant="light">{{ marketState(item).label }}</t-tag></div></template>
<p class="market-description">{{ item.description || '由受控插件目录提供的 Business Plugin' }}</p>
<dl class="description-grid market-meta"><div><dt>发布者</dt><dd>{{ item.publisher || item.publisher_key_id || '-' }}</dd></div><div><dt>兼容性</dt><dd>{{ item.compatibility?.status || 'unknown' }}</dd></div><div><dt>发布时间</dt><dd>{{ formatDate(item.published_at) }}</dd></div><div><dt>包大小</dt><dd>{{ formatSize(item.archive_size) }}</dd></div></dl>
<div class="market-hash"><span>SHA-256</span><code>{{ item.archive_sha256 || '-' }}</code></div>
<template #footer><t-button v-if="!item.installed" theme="primary" block :loading="busy === item.plugin_id" @click="install(item)"><template #icon><CloudDownloadIcon /></template>下载并入库</t-button><t-button v-else variant="outline" block @click="router.push(`/plugins/${encodeURIComponent(item.plugin_id)}/revisions`)">查看已安装版本</t-button></template>
</t-card>
</section>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="暂无可用插件" /></t-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { CloudDownloadIcon, RefreshIcon } from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, operationKey, request } from '../services/api'
const router = useRouter(); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
function marketState(item: any) { if (!item.installed) return { label: '可入库', theme: 'primary' }; return String(item.installed_version).replace(/^v/, '') === String(item.version).replace(/^v/, '') ? { label: '已入库', theme: 'success' } : { label: '已有版本', theme: 'warning' } }
function formatSize(value: unknown) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return '-'; if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${Math.ceil(n / 1024)} KB` }
async function load() { loading.value = true; error.value = ''; try { const [market, installed] = await Promise.all([request<any>('/api/marketplace'), request<any>('/api/plugins')]); const map = new Map(listFrom(installed).map((p) => [p.plugin_id, p])); items.value = listFrom(market).map((item) => ({ ...item, installed: item.installed || map.has(item.plugin_id), installed_version: item.installed_version || map.get(item.plugin_id)?.version })) } catch (e) { error.value = e instanceof Error ? e.message : '市场加载失败' } finally { loading.value = false } }
async function install(item: any) { busy.value = item.plugin_id; try { const result = await request<any>('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('marketplace-install', `${item.plugin_id}-${item.version}`) }, body: JSON.stringify({ plugin_id: item.plugin_id, version: item.version }) }); MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`); await load() } catch (e) { error.value = e instanceof Error ? e.message : '插件入库失败' } finally { busy.value = '' } }
onMounted(load)
</script>
@@ -0,0 +1,30 @@
<template>
<div class="page-view">
<PageHeader eyebrow="OPERATIONS" title="操作记录" subtitle="按插件和操作类型筛选控制面审计结果。">
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></template>
</PageHeader>
<t-card class="filter-card" :bordered="true"><div class="filter-grid"><t-select v-model="filters.plugin" label="插件" placeholder="全部插件" clearable><t-option value="" label="全部插件" /> <t-option v-for="plugin in plugins" :key="plugin.plugin_id" :value="plugin.plugin_id" :label="plugin.name || plugin.plugin_id" /></t-select><t-select v-model="filters.action" label="操作类型" placeholder="全部操作" clearable><t-option value="" label="全部操作" /><t-option v-for="action in actionSet" :key="action" :value="action" :label="actionLabel(action)" /></t-select><t-button variant="text" @click="clearFilters">清除筛选</t-button></div></t-card>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<t-card class="table-card" :bordered="true"><t-table :data="filteredItems" :columns="columns" row-key="operation_id" :loading="loading" :hover="true"><template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template><template #plugin_id="{ row }"><span class="mono wrap-anywhere">{{ row.plugin_id || '-' }}</span></template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #request_id="{ row }"><span class="secondary-text mono wrap-anywhere">{{ row.request_id || '-' }}</span></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template><template #operation_id="{ row }"><t-link v-if="row.operation_id" theme="primary" @click="openDetail(row.operation_id)">查看详情</t-link></template></t-table><t-empty v-if="!filteredItems.length" description="还没有操作记录" /></t-card>
<t-drawer v-model:visible="detailVisible" header="操作详情" size="min(520px, 100vw)"><dl v-if="detail" class="description-grid drawer-description"><div><dt>操作 ID</dt><dd class="mono wrap-anywhere">{{ detail.id }}</dd></div><div><dt>操作类型</dt><dd>{{ actionLabel(detail.kind) }}</dd></div><div><dt>插件</dt><dd class="mono wrap-anywhere">{{ detail.plugin_id || '-' }}</dd></div><div><dt>状态</dt><dd>{{ detail.state || '-' }}</dd></div><div><dt>请求 ID</dt><dd class="mono wrap-anywhere">{{ detail.request_id || '-' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(detail.updated_at) }}</dd></div></dl><t-alert v-if="detail?.error" theme="error" :message="detail.error" /><t-alert v-if="detail?.warning" theme="warning" :message="detail.warning" /></t-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshIcon } from 'tdesign-icons-vue-next'
import { useRouter } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import { actionIcon, actionLabel } from '../services/labels'
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
const router = useRouter(); const plugins = ref<any[]>([]); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const filters = ref({ plugin: '', action: '' }); const detailVisible = ref(false); const detail = ref<any>(null)
const columns = [{ colKey: 'action', title: '操作', width: 170 }, { colKey: 'plugin_id', title: '插件', minWidth: 220 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }, { colKey: 'operation_id', title: '', width: 100, fixed: 'right' as const }]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const actionSet = computed(() => [...new Set(items.value.map((item) => item.action).filter(Boolean))].sort())
const filteredItems = computed(() => items.value.filter((item) => (!filters.value.plugin || item.plugin_id === filters.value.plugin) && (!filters.value.action || item.action === filters.value.action)).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()))
function clearFilters() { filters.value = { plugin: '', action: '' } }
async function load() { loading.value = true; error.value = ''; try { const [audit, pluginData] = await Promise.all([request<any>('/api/audit'), request<any>('/api/plugins')]); items.value = listFrom(audit); plugins.value = listFrom(pluginData) } catch (e) { error.value = e instanceof Error ? e.message : '操作记录加载失败' } finally { loading.value = false } }
async function openDetail(id: string) { detailVisible.value = true; detail.value = await request(`/api/operations/${encodeURIComponent(id)}`).catch(() => null) }
onMounted(load)
</script>
@@ -0,0 +1,86 @@
<template>
<div class="page-view">
<PageHeader eyebrow="OVERVIEW" title="概览" subtitle="查看插件控制面的总体状态、运行健康和最近活动。">
<template #actions><t-button theme="primary" @click="router.push('/plugins')"><template #icon><AppIcon /></template>管理插件</t-button></template>
</PageHeader>
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
<section class="stat-grid" aria-label="插件统计">
<t-card v-for="stat in stats" :key="stat.label" class="stat-card" :class="`stat-${stat.theme}`" :bordered="true">
<div class="stat-card-top"><span>{{ stat.label }}</span><component :is="stat.icon" size="18px" /></div>
<strong>{{ stat.value }}</strong><small>{{ stat.caption }}</small>
</t-card>
</section>
<section class="overview-grid">
<t-card class="panel-card" title="近期操作" :bordered="true">
<template #actions><t-link theme="primary" @click="router.push('/operations')">查看全部</t-link></template>
<t-table v-if="recent.length" :data="recent" :columns="recentColumns" row-key="id" size="small" :hover="true">
<template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template>
<template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template>
<template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template>
</t-table>
<t-empty v-else description="还没有操作记录" />
</t-card>
<t-card class="panel-card" title="操作趋势" :bordered="true">
<template #actions><t-tag theme="primary" variant="light">最近 7 天</t-tag></template>
<TrendChart :labels="trendLabels" :values="trendValues" />
</t-card>
</section>
<t-card class="panel-card guidance-card" title="生命周期提示" :bordered="true">
<div class="guidance-grid">
<div v-for="(item, index) in guidance" :key="item.title" class="guidance-item"><span class="guidance-number">{{ String(index + 1).padStart(2, '0') }}</span><div><strong>{{ item.title }}</strong><p>{{ item.body }}</p></div></div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { AppIcon, CheckCircleIcon, ErrorCircleIcon, TimeIcon, HistoryIcon } from 'tdesign-icons-vue-next'
import { useRouter } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import TrendChart from '../components/TrendChart.vue'
import { actionIcon, actionLabel } from '../services/labels'
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
const router = useRouter()
const plugins = ref<any[]>([])
const recent = ref<any[]>([])
const error = ref('')
const recentColumns = [
{ colKey: 'action', title: '操作', width: 150 },
{ colKey: 'plugin_id', title: '插件', ellipsis: true },
{ colKey: 'result', title: '结果', width: 90 },
{ colKey: 'time', title: '时间', width: 170 },
]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const stats = computed(() => {
const healthy = plugins.value.filter((p) => ['healthy', 'enabled'].includes(p.state)).length
const staged = plugins.value.filter((p) => ['disabled', 'incompatible'].includes(p.state) || p.installation_status === 'staged').length
const attention = plugins.value.filter((p) => ['error', 'starting', 'upgrading', 'rollback_pending'].includes(p.state)).length
return [
{ label: '已登记插件', value: plugins.value.length, caption: '全部已校验版本', theme: 'neutral', icon: AppIcon },
{ label: '运行中', value: healthy, caption: '健康检查通过', theme: 'success', icon: CheckCircleIcon },
{ label: '待启用', value: staged, caption: '已入库,等待启动', theme: 'warning', icon: TimeIcon },
{ label: '需要关注', value: attention, caption: '失败或迁移中', theme: 'danger', icon: ErrorCircleIcon },
]
})
const guidance = [
{ title: '入库不等于启用', body: '上传或市场下载只完成验签、哈希和兼容性校验。' },
{ title: '启用前先配置', body: '服务地址和菜单地址在插件详情的配置页维护。' },
{ title: '高风险操作可追溯', body: '升级、回滚和卸载均保留操作 ID 与审计结果。' },
]
const trendLabels = computed(() => ['-6 天', '-5 天', '-4 天', '-3 天', '-2 天', '昨天', '今天'])
const trendValues = computed(() => {
const values = new Array(7).fill(0)
recent.value.forEach((item) => { const date = new Date(item.time); if (!Number.isNaN(date.getTime())) { const days = Math.floor((Date.now() - date.getTime()) / 86400000); if (days >= 0 && days < 7) values[6 - days] += 1 } })
return values
})
async function load() {
try {
const [pluginData, auditData] = await Promise.all([request('/api/plugins'), request('/api/audit')])
plugins.value = listFrom(pluginData)
recent.value = listFrom(auditData).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()).slice(0, 8)
} catch (e) { error.value = e instanceof Error ? e.message : '概览数据加载失败' }
}
onMounted(load)
</script>
@@ -0,0 +1,96 @@
<template>
<div class="page-view">
<div class="detail-back"><t-link theme="primary" @click="router.push('/plugins')"><template #prefix-icon><ChevronLeftIcon /></template>返回已安装插件</t-link></div>
<PageHeader eyebrow="PLUGIN DETAIL" :title="plugin?.name || String(route.params.id)" :subtitle="`${String(route.params.id)} · v${plugin?.version || '-'}`">
<template #actions><t-tag v-if="plugin" :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template>
</PageHeader>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<t-tabs v-model="activeTab" class="detail-tabs" @change="onTabChange">
<t-tab-panel v-for="tab in tabs" :key="tab.value" :value="tab.value" :label="tab.label" />
</t-tabs>
<div v-if="loading" class="loading-panel"><t-loading text="加载中" /></div>
<template v-else-if="plugin">
<section v-if="activeTab === 'overview'" class="detail-content">
<div class="detail-actions"><t-button v-if="isHealthy(plugin)" variant="outline" :loading="busy === 'disable'" @click="mutate('disable')"><template #icon><PauseIcon /></template>停用插件</t-button><t-button v-else theme="primary" :disabled="plugin.state === 'incompatible'" :loading="busy === 'enable'" @click="mutate('enable')"><template #icon><PlayCircleIcon /></template>启用插件</t-button><t-button theme="danger" variant="outline" :disabled="isHealthy(plugin)" :loading="busy === 'uninstall'" @click="mutate('uninstall')"><template #icon><DeleteIcon /></template>卸载插件</t-button></div>
<div class="detail-grid">
<t-card title="健康状态" class="panel-card" :bordered="true"><template #actions><t-tag :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template><dl class="description-grid"><div><dt>安装状态</dt><dd>{{ plugin.installation_status || '-' }}</dd></div><div><dt>服务地址</dt><dd class="mono wrap-anywhere">{{ plugin.endpoint || '未配置' }}</dd></div><div><dt>活动 revision</dt><dd class="mono">{{ plugin.active_revision || '-' }}</dd></div><div><dt>待切换 revision</dt><dd class="mono">{{ plugin.pending_revision || '-' }}</dd></div></dl><t-alert v-if="plugin.last_error" theme="error" :message="plugin.last_error" class="inline-alert" /><t-alert v-else theme="success" message="最近一次检查未发现错误。" class="inline-alert" /></t-card>
<t-card title="插件信息" class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>能力</dt><dd class="wrap-anywhere">{{ (plugin.capabilities || []).join(', ') || '未声明' }}</dd></div><div><dt>Core 兼容性</dt><dd>{{ plugin.compatibility?.status || 'unknown' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(plugin.updated_at) }}</dd></div><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '未声明' }}</dd></div></dl></t-card>
</div>
</section>
<section v-else-if="activeTab === 'revisions'" class="detail-content">
<div class="section-toolbar"><div><h2>版本与升级</h2><p class="secondary-text">新版本先校验并进入候选 revision,健康检查通过后才会切换。</p></div><input ref="upgradeInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onUpgradeSelected" /><t-button theme="primary" :loading="busy === 'upgrade'" @click="upgradeInput?.click()"><template #icon><UploadIcon /></template>上传升级</t-button></div>
<t-card class="table-card" :bordered="true"><t-table v-if="visibleRevisions.length" :data="visibleRevisions" :columns="revisionColumns" row-key="id" :hover="true"><template #version="{ row }"><div><strong>v{{ row.version || '-' }}</strong><t-tag v-if="row.id === plugin.active_revision" theme="success" variant="light" class="tag-gap">当前活动</t-tag><div class="secondary-text mono wrap-anywhere">{{ row.id }}</div></div></template><template #hash="{ row }"><span class="mono wrap-anywhere">{{ row.archive_sha256 || '-' }}</span></template><template #verified="{ row }"><span class="secondary-text">{{ formatDate(row.verified_at) }}</span></template><template #actions="{ row }"><t-button v-if="row.id !== plugin.active_revision && !row.retired" size="medium" variant="outline" :loading="busy === row.id" @click="mutate('rollback', row.id)"><template #icon><RollbackIcon /></template>回滚到此版本</t-button></template></t-table><t-empty v-else description="暂无可用版本记录" /></t-card>
</section>
<section v-else-if="activeTab === 'config'" class="detail-content narrow-content">
<div class="section-toolbar"><div><h2>配置</h2><p class="secondary-text">服务地址和菜单地址由控制面保存,密钥不会回显到浏览器。</p></div></div>
<t-card class="panel-card" :bordered="true"><t-form :data="configForm" :rules="configRules" @submit="saveConfig"><t-form-item label="服务地址" name="service_url"><t-input v-model="configForm.service_url" placeholder="http://127.0.0.1:18090" /></t-form-item><t-form-item v-if="!isSubscription" label="菜单地址" name="public_url"><t-input v-model="configForm.public_url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/" /></t-form-item><t-alert v-if="isSubscription" theme="info" message="订阅模块的菜单固定进入 Plugin Admin 统一 Shell;这里只需配置服务地址用于健康检查。" class="inline-alert" /><t-alert v-else theme="info" message="敏感配置只在服务端加密保存,页面不会回显原值。" class="inline-alert" /><t-button theme="primary" type="submit" :loading="busy === 'config'"><template #icon><SaveIcon /></template>保存配置</t-button></t-form></t-card>
</section>
<section v-else-if="activeTab === 'menu'" class="detail-content narrow-content">
<div class="section-toolbar"><div><h2>菜单接入</h2><p class="secondary-text">先预览菜单变更,再应用到 Core 管理员导航。</p></div><div class="toolbar-actions"><t-button variant="outline" :loading="busy === 'menu-preview'" @click="menuPreview"><template #icon><ViewListIcon /></template>预览菜单</t-button><t-button theme="primary" :loading="busy === 'menu-apply'" @click="mutate('menu-apply')"><template #icon><CheckIcon /></template>应用菜单</t-button></div></div>
<t-card class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '-' }}</dd></div><div><dt>菜单名称</dt><dd>{{ plugin.menu?.label || '-' }}</dd></div><div><dt>可见性</dt><dd>{{ plugin.menu?.visibility || 'admin' }}</dd></div><div><dt>排序</dt><dd>{{ plugin.menu?.sort_order ?? '-' }}</dd></div></dl><t-alert theme="info" message="应用菜单只会更新 Core 的 custom_menu_items,不会修改 Core 源码。" class="inline-alert" /></t-card>
</section>
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>操作历史</h2><p class="secondary-text">只显示当前插件的生命周期和菜单操作。</p></div><t-button variant="outline" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="pluginAudit" :columns="auditColumns" row-key="operation_id" :hover="true"><template #action="{ row }">{{ actionLabel(row.action) }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!pluginAudit.length" description="还没有操作记录" /></t-card></section>
</template>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="插件不存在或已卸载" /></t-card>
<t-dialog v-model:visible="previewVisible" header="菜单变更预览" width="min(880px, calc(100vw - 32px))" :footer="false"><div class="preview-grid"><div><h3>当前菜单</h3><pre>{{ JSON.stringify(menuCurrent, null, 2) }}</pre></div><div><h3>应用后菜单</h3><pre>{{ JSON.stringify(menuNext, null, 2) }}</pre></div></div></t-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CheckIcon, ChevronLeftIcon, DeleteIcon, PauseIcon, PlayCircleIcon, RefreshIcon, RollbackIcon, SaveIcon, UploadIcon, ViewListIcon } from 'tdesign-icons-vue-next'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { actionLabel } from '../services/labels'
import { formatDate, operationKey, operationSucceeded, operationTheme, request, stateLabel } from '../services/api'
const route = useRoute(); const router = useRouter()
const plugin = ref<any | null>(null); const pluginAudit = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
const tabs = [{ value: 'overview', label: '运行概况' }, { value: 'revisions', label: '版本与升级' }, { value: 'config', label: '配置' }, { value: 'menu', label: '菜单接入' }, { value: 'operations', label: '操作历史' }]
const activeTab = ref(String(route.params.tab || 'overview')); const upgradeInput = ref<HTMLInputElement | null>(null)
const configForm = reactive({ service_url: '', public_url: '' }); const configRules = { service_url: [{ required: true, message: '请输入服务地址', type: 'error' as const }] }
const previewVisible = ref(false); const menuCurrent = ref<any[]>([]); const menuNext = ref<any[]>([])
const revisionColumns = [{ colKey: 'version', title: '版本', minWidth: 250 }, { colKey: 'hash', title: 'SHA-256', minWidth: 300 }, { colKey: 'verified', title: '验证时间', width: 180 }, { colKey: 'actions', title: '操作', width: 160 }]
const auditColumns = [{ colKey: 'action', title: '操作', width: 180 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 250 }, { colKey: 'time', title: '时间', width: 180 }]
const isHealthy = (item: any) => ['healthy', 'enabled'].includes(item.state)
const tagTheme = (state: string) => isHealthy({ state }) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
const isSubscription = computed(() => plugin.value?.plugin_id === 'qiu.subscription-admin')
const visibleRevisions = computed(() => (plugin.value?.revisions || []).filter((revision: any) => !revision.retired))
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
function onTabChange(value: string | number) { activeTab.value = String(value); router.replace(`/plugins/${encodeURIComponent(String(route.params.id))}/${String(value)}`) }
async function load() {
loading.value = true; error.value = ''
try {
const id = encodeURIComponent(String(route.params.id)); plugin.value = await request(`/api/plugins/${id}`)
const audits = await request<any>('/api/audit'); pluginAudit.value = (Array.isArray(audits.items) ? audits.items : []).filter((item: any) => item.plugin_id === plugin.value.plugin_id).sort((a: any, b: any) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime())
if (['config', 'menu'].includes(activeTab.value)) { const cfg = await request<any>(`/api/plugins/${id}/config`); configForm.service_url = cfg.endpoint || ''; configForm.public_url = typeof cfg.config?.public_url === 'string' ? cfg.config.public_url : '' }
} catch (e) { error.value = e instanceof Error ? e.message : '插件详情加载失败' }
finally { loading.value = false }
}
async function waitOperation(id: string) { for (let i = 0; i < 20 && id; i += 1) { const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null); if (op?.state && op.state !== 'running') return op; await new Promise((resolve) => window.setTimeout(resolve, 200)) } return null }
async function mutate(action: string, revision = '') {
const id = encodeURIComponent(String(route.params.id)); const endpoint = action === 'rollback' ? `/api/plugins/${id}/rollback` : `/api/plugins/${id}/${action}`
if (['disable', 'uninstall'].includes(action)) { const dialog = DialogPlugin.confirm({ header: action === 'uninstall' ? '确认卸载插件' : '确认停用插件', body: action === 'uninstall' ? '卸载会移除插件文件,但不会删除 Core 数据。' : '停用后 Core 管理员菜单会被移除。', confirmBtn: action === 'uninstall' ? '卸载' : '停用', cancelBtn: '取消', onConfirm: () => { dialog.hide(); performMutation(endpoint, action, revision) } }); return }
await performMutation(endpoint, action, revision)
}
async function performMutation(endpoint: string, action: string, revision = '') {
busy.value = action === 'rollback' ? revision : action
try {
const body = action === 'rollback' && revision ? JSON.stringify({ revision }) : undefined
const result = await request<any>(endpoint, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, revision) }, body })
const final = await waitOperation(result.operation_id)
const state = final?.state || result.state
if (state === 'failed') throw new Error(final?.error || result.error || `${actionLabel(action)}失败`)
if (state && !operationSucceeded(state)) MessagePlugin.warning(`${actionLabel(action)}操作仍在处理中`)
else MessagePlugin.success(`${actionLabel(action)}已完成`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' } finally { busy.value = '' }
}
async function saveConfig() { if (!configForm.service_url.trim()) { error.value = '请输入服务地址'; return }; busy.value = 'config'; try { const payload = isSubscription.value ? { service_url: configForm.service_url } : configForm; const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/config`, { method: 'PUT', headers: { 'Idempotency-Key': operationKey('config', String(route.params.id)) }, body: JSON.stringify(payload) }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '配置保存失败'); MessagePlugin.success('配置已保存'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '配置保存失败' } finally { busy.value = '' } }
async function menuPreview() { busy.value = 'menu-preview'; try { const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/menu-preview`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('menu-preview', String(route.params.id)) } }); menuCurrent.value = result.current || []; menuNext.value = result.next || []; previewVisible.value = true } catch (e) { error.value = e instanceof Error ? e.message : '菜单预览失败' } finally { busy.value = '' } }
async function onUpgradeSelected(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; busy.value = 'upgrade'; try { const body = new FormData(); body.append('package', file); const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('upgrade', String(route.params.id)) }, body }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '升级失败'); MessagePlugin.success('升级已完成'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '升级上传失败' } finally { busy.value = '' } }
watch(() => route.params.tab, (value) => { activeTab.value = String(value || 'overview'); load() })
onMounted(load)
</script>
@@ -0,0 +1,110 @@
<template>
<div class="page-view">
<PageHeader eyebrow="INSTALLED PLUGINS" title="已安装插件" subtitle="查看插件状态、版本和生命周期。高风险操作进入插件详情页。">
<template #actions>
<input ref="packageInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onPackageSelected" />
<t-button theme="primary" :loading="busy === 'upload'" @click="packageInput?.click()"><template #icon><UploadIcon /></template>上传并入库</t-button>
<t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button>
</template>
</PageHeader>
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
<div class="page-toolbar"><span class="secondary-text">共 {{ plugins.length }} 个插件</span><span class="toolbar-hint">下载包仅入库,不会自动启动</span></div>
<t-card v-if="plugins.length" class="table-card" :bordered="true">
<t-table :data="plugins" :columns="columns" row-key="plugin_id" :loading="loading" :hover="true" stripe>
<template #plugin="{ row }"><div class="plugin-cell"><div class="plugin-avatar"><AppIcon size="19px" /></div><div><strong>{{ row.name || row.plugin_id }}</strong><span class="secondary-text mono">{{ row.plugin_id }}</span></div></div></template>
<template #version="{ row }"><span class="mono">v{{ row.version || '-' }}</span></template>
<template #state="{ row }"><t-tag :theme="tagTheme(row.state)" variant="light">{{ stateLabel(row.state) }}</t-tag><span class="cell-note">{{ row.installation_status === 'staged' ? '待启用' : '' }}</span></template>
<template #compatibility="{ row }"><t-tag :theme="row.compatibility?.compatible ? 'success' : 'warning'" variant="light">{{ row.compatibility?.status || 'unknown' }}</t-tag></template>
<template #updated="{ row }"><span class="secondary-text">{{ formatDate(row.updated_at) }}</span></template>
<template #actions="{ row }"><div class="table-actions"><t-button size="medium" variant="outline" @click="router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/overview`)">查看详情</t-button><t-button size="medium" :theme="isHealthy(row) ? 'default' : 'primary'" :variant="isHealthy(row) ? 'outline' : 'base'" :loading="busy === row.plugin_id" :disabled="isTransitioning(row) || row.state === 'incompatible'" @click="mutate(isHealthy(row) ? 'disable' : 'enable', row.plugin_id)">{{ isHealthy(row) ? '停用' : '启用' }}</t-button><t-dropdown :options="moreOptions(row)" trigger="click" @click="(data: any) => onMore(String(data?.value || ''), row)"><t-button size="medium" variant="text" shape="square" aria-label="更多操作"><template #icon><MoreIcon /></template></t-button></t-dropdown></div></template>
</t-table>
</t-card>
<t-card v-else class="empty-card" :bordered="true"><t-empty description="还没有登记业务插件" /></t-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AppIcon, MoreIcon, RefreshIcon, UploadIcon } from 'tdesign-icons-vue-next'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, operationKey, request, stateLabel } from '../services/api'
const router = useRouter()
const plugins = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const busy = ref('')
const packageInput = ref<HTMLInputElement | null>(null)
const columns = [
{ colKey: 'plugin', title: '插件', minWidth: 230 },
{ colKey: 'version', title: '版本', width: 110 },
{ colKey: 'state', title: '状态', width: 150 },
{ colKey: 'compatibility', title: 'Core 兼容性', width: 145 },
{ colKey: 'updated', title: '最近更新', width: 180 },
{ colKey: 'actions', title: '操作', width: 250, fixed: 'right' as const },
]
const healthyStates = ['healthy', 'enabled']
const isHealthy = (row: any) => healthyStates.includes(row.state)
const isTransitioning = (row: any) => ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(row.state)
const tagTheme = (state: string) => healthyStates.includes(state) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
async function load() {
loading.value = true; error.value = ''
try { plugins.value = listFrom(await request('/api/plugins')).sort((a, b) => String(a.name || a.plugin_id).localeCompare(String(b.name || b.plugin_id))) }
catch (e) { error.value = e instanceof Error ? e.message : '插件列表加载失败' }
finally { loading.value = false }
}
async function waitOperation(id: string) {
if (!id) return null
for (let i = 0; i < 8; i += 1) {
const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null)
if (op && op.state && op.state !== 'running') return op
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
return null
}
async function mutate(action: string, id: string) {
busy.value = id; error.value = ''
try {
const result = await request<any>(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, id) } })
const final = await waitOperation(result.operation_id)
MessagePlugin.success(`${action === 'enable' ? '启用' : action === 'disable' ? '停用' : action}操作已完成${final?.state ? `:${final.state}` : ''}`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' }
finally { busy.value = '' }
}
function moreOptions(row: any) {
return [
{ content: '配置', value: 'config' },
{ content: '版本与升级', value: 'revisions' },
{ content: '菜单接入', value: 'menu' },
{ content: '卸载插件', value: 'uninstall', disabled: isHealthy(row) },
]
}
function onMore(value: string, row: any) {
if (value === 'config' || value === 'revisions' || value === 'menu') router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/${value}`)
if (value === 'uninstall') {
const dialog = DialogPlugin.confirm({ header: '确认卸载插件', body: '卸载会移除插件文件,但不会删除 Core 数据。', confirmBtn: '卸载', cancelBtn: '取消', onConfirm: () => { dialog.hide(); mutate('uninstall', row.plugin_id) } })
}
}
async function onPackageSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
busy.value = 'upload'; error.value = ''
try {
const body = new FormData(); body.append('package', file)
const result = await request<any>('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('install') }, body })
MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`)
await load()
} catch (e) { error.value = e instanceof Error ? e.message : '上传失败' }
finally { busy.value = '' }
}
onMounted(load)
</script>
@@ -0,0 +1,55 @@
<template>
<div class="page-view">
<PageHeader eyebrow="BUSINESS MODULE · SUBSCRIPTION" title="订阅管理" subtitle="订阅是插件控制面中的业务模块,沿用当前管理员会话,不需要再次登录。">
<template #actions><t-tag theme="success" variant="light"><CheckCircleIcon size="14px" /> 共享控制面会话</t-tag></template>
</PageHeader>
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
<t-tabs v-model="activeTab" class="detail-tabs" @change="changeTab"><t-tab-panel value="overview" label="概览" /><t-tab-panel value="plans" label="套餐" /><t-tab-panel value="subscriptions" label="用户订阅" /><t-tab-panel value="audit" label="操作记录" /></t-tabs>
<section v-if="activeTab === 'overview'" class="detail-content">
<div class="stat-grid module-stats"><t-card class="stat-card stat-neutral" :bordered="true"><div class="stat-card-top"><span>Core 连接</span><LinkIcon size="18px" /></div><strong>{{ status?.core_base_configured ? '正常' : '未配置' }}</strong><small>服务端 allowlist</small></t-card><t-card class="stat-card stat-primary" :bordered="true"><div class="stat-card-top"><span>套餐数量</span><CatalogIcon size="18px" /></div><strong>{{ plans.length }}</strong><small>来自 Core 套餐目录</small></t-card><t-card class="stat-card stat-success" :bordered="true"><div class="stat-card-top"><span>订阅记录</span><ChartLineIcon size="18px" /></div><strong>{{ subscriptionTotal }}</strong><small>当前筛选范围</small></t-card><t-card class="stat-card stat-warning" :bordered="true"><div class="stat-card-top"><span>会话模式</span><LockOnIcon size="18px" /></div><strong>共享</strong><small>不创建模块 Cookie</small></t-card></div>
<t-card class="panel-card module-note" title="模块边界" :bordered="true"><t-alert theme="info" message="浏览器只请求 Plugin Admin 的同源 BFF。Core access token、refresh token 和服务密钥始终保存在服务端会话。" /><div class="module-flow"><span>统一登录</span><ChevronRightIcon /><span>订阅模块</span><ChevronRightIcon /><span>Core 只读接口</span></div></t-card>
</section>
<section v-else-if="activeTab === 'plans'" class="detail-content"><div class="section-toolbar"><div><h2>套餐目录</h2><p class="secondary-text">套餐数据由 Core 返回,订阅模块只负责展示和查询。</p></div><t-button variant="outline" :loading="loading" @click="loadPlans"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="plans" :columns="planColumns" row-key="id" :loading="loading" :hover="true"><template #name="{ row }"><div><strong>{{ row.name || row.title || `套餐 ${row.id || '-'}` }}</strong><span class="secondary-text">{{ row.description || 'Core 套餐' }}</span></div></template><template #price="{ row }"><span class="numeric">{{ formatMoney(row.price ?? row.amount) }}</span></template><template #status="{ row }"><t-tag :theme="row.enabled === false ? 'default' : 'success'" variant="light">{{ row.enabled === false ? '已停用' : '可用' }}</t-tag></template></t-table><t-empty v-if="!plans.length" description="暂无套餐数据" /></t-card></section>
<section v-else-if="activeTab === 'subscriptions'" class="detail-content"><div class="section-toolbar"><div><h2>用户订阅</h2><p class="secondary-text">服务端分页和筛选参数沿用订阅模块 API。</p></div><t-button variant="outline" :loading="loading" @click="loadSubscriptions"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="filter-card" :bordered="true"><div class="filter-grid subscription-filter"><t-input v-model="filters.user_id" label="用户 ID" placeholder="输入用户 ID" clearable /><t-input v-model="filters.group_id" label="分组 ID" placeholder="输入分组 ID" clearable /><t-select v-model="filters.status" label="状态" placeholder="全部状态" clearable><t-option value="active" label="有效" /><t-option value="expired" label="已过期" /><t-option value="revoked" label="已撤销" /></t-select><t-button theme="primary" @click="applyFilters"><template #icon><SearchIcon /></template>查询</t-button></div></t-card><t-card class="table-card" :bordered="true"><t-table :data="subscriptions" :columns="subscriptionColumns" row-key="id" :loading="loading" :hover="true"><template #user="{ row }"><div><strong>{{ row.user?.username || row.user?.email || row.user_id || '-' }}</strong><span class="secondary-text">用户 ID {{ row.user_id || '-' }}</span></div></template><template #plan="{ row }">{{ row.plan?.name || row.plan_name || row.group?.name || row.group_id || '-' }}</template><template #status="{ row }"><t-tag :theme="subscriptionTheme(row.status)" variant="light">{{ subscriptionLabel(row.status) }}</t-tag></template><template #period="{ row }"><span class="secondary-text">{{ formatDate(row.start_at || row.started_at) }} 至 {{ formatDate(row.expire_at || row.expires_at) }}</span></template></t-table><t-empty v-if="!subscriptions.length" description="暂无订阅记录" /><div class="pagination-row"><t-pagination v-model="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :show-jumper="true" @change="onPageChange" /></div></t-card></section>
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>模块操作记录</h2><p class="secondary-text">只记录通过统一控制面发起的订阅查询。</p></div><t-button variant="outline" :loading="loading" @click="loadAudit"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="audit" :columns="auditColumns" row-key="request"><template #action="{ row }">{{ row.action || '-' }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!audit.length" description="还没有模块操作记录" /></t-card></section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CatalogIcon, ChartLineIcon, CheckCircleIcon, ChevronRightIcon, LinkIcon, LockOnIcon, RefreshIcon, SearchIcon } from 'tdesign-icons-vue-next'
import PageHeader from '../components/PageHeader.vue'
import { formatDate, listFrom, loadSubscriptionState, operationSucceeded, operationTheme, request, unwrap } from '../services/api'
const route = useRoute(); const router = useRouter(); const activeTab = ref(String(route.params.tab || 'overview')); const loading = ref(false); const error = ref(''); const status = ref<any>(null); const plans = ref<any[]>([]); const subscriptions = ref<any[]>([]); const audit = ref<any[]>([]); const pagination = reactive({ current: 1, pageSize: 20, total: 0 }); const filters = reactive({ user_id: '', group_id: '', status: '' })
const planColumns = [{ colKey: 'name', title: '套餐', minWidth: 260 }, { colKey: 'price', title: '价格', width: 150 }, { colKey: 'status', title: '状态', width: 120 }]
const subscriptionColumns = [{ colKey: 'user', title: '用户', minWidth: 220 }, { colKey: 'plan', title: '套餐/分组', minWidth: 200 }, { colKey: 'status', title: '状态', width: 120 }, { colKey: 'period', title: '有效期', minWidth: 270 }]
const auditColumns = [{ colKey: 'action', title: '操作', minWidth: 240 }, { colKey: 'result', title: '结果', width: 110 }, { colKey: 'request', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }]
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
const subscriptionTotal = computed(() => pagination.total || subscriptions.value.length)
function formatMoney(value: unknown) { const n = Number(value); return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '-' }
function subscriptionTheme(statusValue: unknown) { return statusValue === 'active' || statusValue === 'enabled' ? 'success' : statusValue === 'revoked' ? 'danger' : 'warning' }
function subscriptionLabel(statusValue: unknown) { return ({ active: '有效', enabled: '有效', expired: '已过期', revoked: '已撤销' } as Record<string, string>)[String(statusValue)] || String(statusValue || '未知') }
function changeTab(value: string | number) { activeTab.value = String(value); router.replace(`/modules/subscription/${String(value)}`); loadForTab() }
function extractPage(value: any) { const data = unwrap<any>(value) || {}; const rows = listFrom(value); return { rows, total: Number(data.total ?? data.pagination?.total ?? rows.length) || rows.length } }
async function loadStatus() { status.value = await request('/api/subscription/status').then(unwrap).catch(() => null) }
async function loadPlans() { loading.value = true; error.value = ''; try { plans.value = listFrom(await request('/api/subscription/plans')) } catch (e) { error.value = e instanceof Error ? e.message : '套餐加载失败' } finally { loading.value = false } }
async function loadSubscriptions() { loading.value = true; error.value = ''; try { const query = new URLSearchParams({ page: String(pagination.current), page_size: String(pagination.pageSize) }); Object.entries(filters).forEach(([key, value]) => { if (value) query.set(key, value) }); const result = extractPage(await request(`/api/subscription/subscriptions?${query.toString()}`)); subscriptions.value = result.rows; pagination.total = result.total } catch (e) { error.value = e instanceof Error ? e.message : '订阅加载失败' } finally { loading.value = false } }
async function loadAudit() { loading.value = true; error.value = ''; try { audit.value = listFrom(await request('/api/subscription/audit')) } catch (e) { error.value = e instanceof Error ? e.message : '审计加载失败' } finally { loading.value = false } }
function applyFilters() { pagination.current = 1; loadSubscriptions() }
function onPageChange(info: any) { pagination.current = Number(info?.current || info?.page || info || 1); pagination.pageSize = Number(info?.pageSize || pagination.pageSize); loadSubscriptions() }
function loadForTab() { if (activeTab.value === 'plans') loadPlans(); else if (activeTab.value === 'subscriptions') loadSubscriptions(); else if (activeTab.value === 'audit') loadAudit(); else loadStatus() }
watch(() => route.params.tab, async (value) => {
activeTab.value = String(value || 'overview')
if (await loadSubscriptionState()) loadForTab()
})
onMounted(async () => {
if (!(await loadSubscriptionState())) {
await router.replace('/overview')
return
}
await loadStatus()
loadForTab()
})
</script>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"ignoreDeprecations": "6.0",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "tdesign-vue-next/global"],
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
}
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: './',
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
outDir: '../ui-build',
emptyOutDir: true,
cssCodeSplit: false,
assetsInlineLimit: 1024 * 1024 * 8,
rollupOptions: {
output: {
entryFileNames: 'app.js',
chunkFileNames: 'chunk-[name].js',
assetFileNames: (asset) => asset.name?.endsWith('.css') ? 'styles.css' : 'assets/[name][extname]',
},
},
},
})
File diff suppressed because one or more lines are too long
+11 -86
View File
@@ -1,89 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>插件管理</title>
<link rel="stylesheet" href="../styles.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">SUB2API EXTENSIONS</p>
<h1>插件管理</h1>
<p class="muted">独立业务插件控制面</p>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="button button-quiet" hidden>退出</button>
</div>
</header>
<section id="login-panel" class="panel auth-panel">
<h2>管理员登录</h2>
<p class="muted">使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。</p>
<form id="login-form" class="form-grid">
<label>邮箱<input name="email" type="email" autocomplete="username" required></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button" type="submit">登录</button>
</form>
<form id="twofa-form" class="form-grid" hidden>
<label>验证码<input name="totp_code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required></label>
<button class="button" type="submit">验证并继续</button>
</form>
<p id="login-error" class="error" role="alert"></p>
</section>
<section id="app-panel" hidden>
<div class="toolbar">
<div>
<h2>已登记插件</h2>
<p class="muted">上传后只完成验签、哈希和 Core 兼容性校验并入库;点击启用后才会启动插件。</p>
</div>
<div class="toolbar-actions">
<label class="file-button">上传并入库<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
<button id="refresh" class="button button-secondary" type="button">刷新</button>
</div>
</div>
<p id="notice" class="notice" role="status"></p>
<section class="marketplace-section" aria-labelledby="marketplace-heading">
<div class="section-heading">
<div>
<h2 id="marketplace-heading">插件市场</h2>
<p class="muted">从受控目录查看可安装版本。安装后仅入库,需在下方手动启用。</p>
</div>
<button id="marketplace-refresh" class="button button-secondary" type="button">刷新市场</button>
</div>
<div id="marketplace" class="marketplace-list" aria-live="polite"></div>
</section>
<section aria-labelledby="installed-heading">
<div class="section-heading installed-heading">
<div>
<h2 id="installed-heading">已入库插件</h2>
<p class="muted">启用、停用、升级、回滚和卸载均需在插件卡片中手动操作。</p>
</div>
</div>
<div id="plugins" class="plugin-list"></div>
</section>
<section class="panel audit-panel">
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
<div id="audit" class="audit-list"></div>
</section>
</section>
</main>
<dialog id="config-dialog" class="config-dialog">
<form id="config-form" method="dialog" class="config-form">
<div class="section-heading"><h2>插件配置</h2><button id="config-close" class="button button-quiet" type="button">关闭</button></div>
<p id="config-plugin" class="muted mono"></p>
<label>服务地址<input name="service_url" type="url" placeholder="http://127.0.0.1:18090" required></label>
<label>菜单地址<input name="public_url" type="url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/"></label>
<p class="muted">密钥只在服务端加密保存,页面不会回显原值。</p>
<div class="dialog-actions"><button id="config-cancel" class="button button-secondary" type="button">取消</button><button class="button" type="submit">保存配置</button></div>
<p id="config-error" class="error" role="alert"></p>
</form>
</dialog>
<script>window.__PLUGIN_BASE_PATH__ = __PLUGIN_BASE_PATH_JSON__;</script>
<script src="../app.js" defer></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="plugin-base-path" content="__PLUGIN_BASE_PATH__" />
<title>插件管理控制面</title>
<script type="module" crossorigin src="./app.js"></script>
<link rel="stylesheet" crossorigin href="./styles.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
+5
View File
@@ -14,3 +14,8 @@ PLUGIN_COOKIE_SAMESITE=lax
PLUGIN_FRAME_ANCESTORS='self'
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
PLUGIN_TRUST_PROXY=false
# Development-only compatibility endpoints. Keep false in production; the
# unified Plugin Admin owns login, sessions and subscription BFF routes.
# Compatibility login/API is accepted only with PLUGIN_ENV=development and a
# loopback PLUGIN_HOST. Production should leave this false.
PLUGIN_STANDALONE_AUTH=false
+28 -11
View File
@@ -1,6 +1,6 @@
# Sub2API Subscription Admin Business Plugin V1
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
这是一个可独立运行后端的管理员只读订阅业务模块,不是现有 `.s2plugin` transport 插件,也不是插件管理控制面。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。浏览器前端由 `plugin-admin` 统一 TDesign 控制面承载,订阅模块不提供第二个登录页或 Cookie。
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
@@ -13,12 +13,12 @@ PLUGIN_PORT=8091 \
go run .
```
打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。
本地后端可打开 `http://127.0.0.1:8091/healthz` 进行服务契约调试;生产浏览器入口应从 Plugin Admin 的订阅模块路由进入。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。默认情况下本服务只暴露健康检查、就绪检查和交接页,不暴露第二套登录、Cookie 或 Core 数据 API。`PLUGIN_STANDALONE_AUTH=true` 仅在 `PLUGIN_ENV=development` 且监听地址为 loopback 时生效,生产必须保持关闭。
## V1 范围
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
- 由 Plugin Admin 统一完成 Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 订阅模块复用控制面的 HttpOnly、SameSite 会话和写请求 CSRF 校验,不创建模块级登录会话。
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
- 只读套餐、订阅列表、订阅详情和插件操作记录。
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
@@ -28,7 +28,7 @@ go run .
## Core API allowlist
插件服务端仅调用这些明确路径:
插件服务端在 `PLUGIN_STANDALONE_AUTH=true`、`PLUGIN_ENV=development` 且 loopback 的本地兼容模式下仅调用这些明确路径;生产数据访问由 Plugin Admin 同源 BFF 完成:
```text
POST /api/v1/auth/login
@@ -54,13 +54,13 @@ GET /api/v1/admin/users/{id}/subscriptions
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
"visibility": "admin",
"sort_order": 200
}
```
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此菜单应指向 Plugin Admin 的统一控制面入口;订阅模块本身不显示登录页、不创建 Cookie。不要把 JWT 放进 URL。
## 测试
@@ -78,10 +78,13 @@ node --check ui/app.js
```
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
`manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式:
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
启用、健康检查和菜单应用。生产环境必须把签名文件通过
`service_url`(插件 loopback 地址),并为 Plugin Admin 设置
`PLUGIN_PUBLIC_URL`(例如 `https://CORE_ORIGIN/extensions/qiu.plugin-admin`)。
然后执行启用、健康检查和菜单应用。浏览器前端由 `plugin-admin` 统一挂载并共享
控制面会话,菜单固定跳转到 `#/modules/subscription/overview`,不会跳转到本服务
的登录页。生产环境必须把签名文件通过
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
加入控制面受信发布者配置;未签名包仅限 development + loopback。
@@ -89,9 +92,23 @@ node --check ui/app.js
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
发布构建必须显式启用签名门禁:
```sh
RELEASE_BUILD=true SIGNATURE_FILE=/secure/signature.json ./package.sh
```
脚本会把 `signature.json` 放入归档并生成同名 `.sha256` 校验文件;未签名包
仅用于 development + loopback。
## 已知限制
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
- 模块后端是常驻服务,不需要每次使用后重启。Core access token 过期时,后端会
按需 refresh 并继续当前请求;只有 refresh 失效、Core 撤销管理员、会话
空闲超过 30 分钟或达到 8 小时绝对上限时才需要重新登录。
- V1 控制面使用内存会话,控制面服务重启会要求重新登录一次;订阅模块不会
单独要求登录。多实例或跨重启免登录需将控制面会话存储替换为插件自有的
加密 Redis/共享会话服务。
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
@@ -2,7 +2,7 @@
"schema_version": 1,
"plugin_id": "qiu.subscription-admin",
"name": "Subscription Admin",
"version": "0.1.1",
"version": "0.2.0",
"core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"],
@@ -37,8 +37,8 @@
"GET /api/v1/admin/users/{id}/subscriptions"
],
"files": {
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
"ui/index.html": "1c863feae45d0c2b17e7c3f267f0360b5998b796961eb191deb783ea5cc5ebf7",
"ui/app.js": "c6b5742f7926a89df8ddf7a18b60903f235b80ee39d697192a7b57b145f5c126",
"ui/styles.css": "2f0a1848586f66c3df4a54e7b674618788c2309dc8e3068e88a2676a6dcb09e9"
}
}
@@ -1,7 +1,7 @@
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
"visibility": "admin",
"sort_order": 200
}
@@ -7,16 +7,29 @@ Wants=network-online.target
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/subscription-admin
EnvironmentFile=/etc/sub2api/subscription-admin.env
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin
WorkingDirectory=/var/lib/sub2api-add/subscription-admin
EnvironmentFile=/etc/sub2api-add/subscription-admin.env
ExecStart=/opt/sub2api-add/subscription-admin/bin/subscription-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/subscription-admin
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
TasksMax=128
MemoryMax=512M
CPUQuota=200%
ReadWritePaths=/var/lib/sub2api-add/subscription-admin
[Install]
WantedBy=multi-user.target
+137 -40
View File
@@ -4,6 +4,7 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"embed"
@@ -32,8 +33,9 @@ const (
sessionTTL = 30 * time.Minute
sessionMaxTTL = 8 * time.Hour
pendingTTL = 5 * time.Minute
maxPendingLogins = 1024
pluginID = "qiu.subscription-admin"
pluginVersion = "0.1.1"
pluginVersion = "0.2.0"
requestIDHeader = "X-Request-ID"
maxRequestIDBytes = 64
)
@@ -436,10 +438,33 @@ func token(n int) string {
}
func decodeJSON(r *http.Request, out any) error {
if r == nil || r.Body == nil {
return errors.New("request body is required")
}
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes))
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
return err
}
if int64(len(raw)) > maxBodyBytes {
return errors.New("request body exceeds size limit")
}
if len(bytes.TrimSpace(raw)) == 0 {
return errors.New("request body is required")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
return dec.Decode(out)
if err := dec.Decode(out); err != nil {
return err
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("request body must contain exactly one JSON value")
}
return err
}
return nil
}
func (a *app) writeJSON(w http.ResponseWriter, status int, value any) {
@@ -480,6 +505,13 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
now := a.clock()
a.mu.Lock()
defer a.mu.Unlock()
// Expire buckets opportunistically so a long-lived development compatibility
// process cannot retain one map entry for every attempted identity forever.
for candidate, attempt := range a.loginAttempts {
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
delete(a.loginAttempts, candidate)
}
}
attempt := a.loginAttempts[key]
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
attempt = loginAttempt{started: now}
@@ -493,6 +525,32 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
return true
}
// addPendingLogin keeps the optional standalone 2FA compatibility mode
// bounded. A client can create a challenge without completing it, so expired
// entries are removed before enforcing the hard cap.
func (a *app) addPendingLogin(value pendingLogin) (string, bool) {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if a.clock != nil {
now = a.clock()
}
for id, pending := range a.pending {
if !pending.expires.After(now) {
delete(a.pending, id)
}
}
if len(a.pending) >= maxPendingLogins {
return "", false
}
if a.pending == nil {
a.pending = make(map[string]pendingLogin)
}
id := token(24)
a.pending[id] = value
return id, true
}
func (a *app) clientIP(r *http.Request) string {
return trustedClientIPWithConfig(r, a.trustProxy)
}
@@ -526,10 +584,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
return
}
pendingID := token(24)
a.mu.Lock()
a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)}
a.mu.Unlock()
pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
if !accepted {
w.Header().Set("Retry-After", "60")
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many pending login challenges"})
return
}
a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
return
}
@@ -760,6 +820,13 @@ func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
}
}
if err != nil {
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized {
a.removeSessionIfCurrent(sessionID, s.accessToken)
a.core.logout(r.Context(), s.refreshToken, correlationID)
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
return
}
a.coreError(w, err, "user lookup failed")
return
}
@@ -853,18 +920,25 @@ func (a *app) refreshSession(ctx context.Context, id string, stale session) (ses
return session{}, false
}
data := envelopeData(refreshed)
candidateRefresh := current.refreshToken
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
candidateRefresh = nextRefresh
}
access, _ := data["access_token"].(string)
if access == "" {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
// Refresh-token rotation is common. Do not commit the replacement until the
// new access token has passed the Core identity and admin-role checks; if a
// check fails, revoke the replacement instead of leaving it live.
nextMe, err := a.core.me(ctx, access, correlationID)
if err != nil || !isAdmin(envelopeData(nextMe)) {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
current.accessToken = access
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
current.refreshToken = nextRefresh
}
current.refreshToken = candidateRefresh
current.user = publicUser(envelopeData(nextMe))
current.lastSeen = a.clock()
a.mu.Lock()
@@ -995,40 +1069,63 @@ func (a *app) static(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}
// standaloneAuthEnabled is intentionally fail-closed. The module's own
// login/API compatibility surface is useful for local contract tests only;
// production deployments must expose the shared Plugin Admin Shell instead.
func standaloneAuthEnabled() bool {
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_STANDALONE_AUTH")), "true") {
return false
}
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_ENV")), "development") {
return false
}
host := strings.Trim(strings.TrimSpace(os.Getenv("PLUGIN_HOST")), "[]")
if host == "" {
host = "127.0.0.1"
}
return isLoopbackHost(host)
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.health)
mux.HandleFunc("/readyz", a.ready)
mux.HandleFunc("/login", a.login)
mux.HandleFunc("/login/2fa", a.login2FA)
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/status", a.status)
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
mux.HandleFunc("/api/audit", a.auditLog)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
parts := strings.Split(rest, "/")
if len(parts) == 1 {
a.userProxy(w, r)
return
}
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
})
// The business module is mounted by Plugin Admin. A standalone compatibility
// API is available only when explicitly opted into for local contract tests;
// production deployments leave it disabled so there is no second login,
// session cookie, or Core-data BFF on the module's own port.
if standaloneAuthEnabled() {
mux.HandleFunc("/login", a.login)
mux.HandleFunc("/login/2fa", a.login2FA)
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/status", a.status)
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
mux.HandleFunc("/api/audit", a.auditLog)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
parts := strings.Split(rest, "/")
if len(parts) == 1 {
a.userProxy(w, r)
return
}
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
})
}
mux.HandleFunc("/", a.static)
return requestIDMiddleware(a.securityHeaders(mux))
}
+192 -9
View File
@@ -259,6 +259,97 @@ func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
}
}
func TestRefreshRotationRevokesCandidateWhenAdminCheckFails(t *testing.T) {
var meCalls int32
var logoutTokens []string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
if atomic.AddInt32(&meCalls, 1) == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":2,"role":"user"}}`))
case "/api/v1/auth/refresh":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
case "/api/v1/auth/logout":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
logoutTokens = append(logoutTokens, body["refresh_token"])
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "OLD", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected refreshed session rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
for _, value := range logoutTokens {
if value == "NEW-REFRESH" {
return
}
}
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
}
func TestUserProxyClearsSessionWhenReadTokenIsRevoked(t *testing.T) {
var logoutCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
case "/api/v1/admin/users/1":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
case "/api/v1/auth/refresh":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"refresh revoked"}`))
case "/api/v1/auth/logout":
atomic.AddInt32(&logoutCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.userProxy(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.sessions["sid"]; ok {
t.Fatal("revoked session remained in plugin store")
}
if atomic.LoadInt32(&logoutCalls) != 1 {
t.Fatalf("logout calls=%d", logoutCalls)
}
cleared := false
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == sessionCookieName && cookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("session cookie was not cleared: %#v", rec.Result().Cookies())
}
}
func TestSessionExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
@@ -285,6 +376,61 @@ func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
}
}
func TestPendingLoginCapAndExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
for i := 0; i < maxPendingLogins; i++ {
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "TEMP", expires: now.Add(time.Minute)}); !ok {
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
}
}
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "OVERFLOW", expires: now.Add(time.Minute)}); ok {
t.Fatal("pending challenge cap was not enforced")
}
if got := len(a.pending); got != maxPendingLogins {
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
}
a.clock = func() time.Time { return now.Add(pendingTTL + time.Second) }
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "AFTER-EXPIRY", expires: now.Add(2 * pendingTTL)}); !ok {
t.Fatal("expired pending challenges were not evicted")
}
if got := len(a.pending); got != 1 {
t.Fatalf("pending map size after expiry=%d want 1", got)
}
}
func TestStandaloneLoginRateLimitBoundsCoreAttempts(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", "development")
var loginCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/login" {
atomic.AddInt32(&loginCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"requires_2fa":true,"temp_token":"TEMP"}}`))
return
}
t.Errorf("unexpected Core path %s", r.URL.Path)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newAppWithConfig(c, appConfig{LoginLimit: 2, LoginWindow: time.Hour})
for attempt := 0; attempt < 3; attempt++ {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if attempt < 2 && rec.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
}
if attempt == 2 && (rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "") {
t.Fatalf("limit response status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String())
}
}
if got := atomic.LoadInt32(&loginCalls); got != 2 {
t.Fatalf("Core received %d login calls want 2", got)
}
}
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress",
@@ -299,22 +445,39 @@ func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
}
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
a := newApp(nil, false)
t.Setenv("PLUGIN_STANDALONE_AUTH", "false")
t.Setenv("PLUGIN_ENV", "production")
var coreCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&coreCalls, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
a := newApp(c, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
response, err := server.Client().Get(server.URL + "/api/plans")
if err != nil {
t.Fatal(err)
for _, endpoint := range []string{"/login", "/login/2fa", "/logout", "/api/me", "/api/status", "/api/plans", "/api/subscriptions", "/api/users/1"} {
response, err := server.Client().Get(server.URL + endpoint)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusNotFound {
t.Fatalf("endpoint=%s status=%d", endpoint, response.StatusCode)
}
if len(response.Cookies()) != 0 {
t.Fatalf("endpoint=%s unexpectedly set cookies: %#v", endpoint, response.Cookies())
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("endpoint=%s security headers missing: %#v", endpoint, response.Header)
}
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d", response.StatusCode)
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("security headers missing: %#v", response.Header)
if atomic.LoadInt32(&coreCalls) != 0 {
t.Fatalf("disabled standalone routes called Core %d times", coreCalls)
}
}
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", "development")
var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader)
@@ -358,6 +521,26 @@ func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
}
}
func TestStandaloneAuthFailsClosedOutsideDevelopmentLoopback(t *testing.T) {
for _, test := range []struct {
name string
env string
host string
}{
{name: "production", env: "production", host: "127.0.0.1"},
{name: "public-development", env: "development", host: "0.0.0.0"},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", test.env)
t.Setenv("PLUGIN_HOST", test.host)
if standaloneAuthEnabled() {
t.Fatal("standalone auth unexpectedly enabled")
}
})
}
}
func readBody(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
+23 -2
View File
@@ -3,6 +3,7 @@ set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
RELEASE_BUILD=${RELEASE_BUILD:-false}
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
TMP=$(mktemp -d)
cleanup() {
@@ -24,10 +25,30 @@ jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
if [ -n "${SIGNATURE_FILE:-}" ]; then
cp "$SIGNATURE_FILE" "$TMP/signature.json"
[ -f "$SIGNATURE_FILE" ] || { printf '%s\n' 'SIGNATURE_FILE does not exist' >&2; exit 2; }
SIGNATURE_KEY=$(jq -r '.publisher.key_id // empty' "$TMP/manifest.json")
SIGNATURE_ALGORITHM=$(jq -r '.algorithm // empty' "$SIGNATURE_FILE")
SIGNATURE_FILE_KEY=$(jq -r '.key_id // empty' "$SIGNATURE_FILE")
SIGNATURE_VALUE=$(jq -r '.signature // empty' "$SIGNATURE_FILE")
[ "$SIGNATURE_ALGORITHM" = "ed25519" ] || { printf '%s\n' 'signature.json must use ed25519' >&2; exit 2; }
[ -n "$SIGNATURE_KEY" ] && [ "$SIGNATURE_FILE_KEY" = "$SIGNATURE_KEY" ] || { printf '%s\n' 'signature key_id does not match manifest publisher' >&2; exit 2; }
[ -n "$SIGNATURE_VALUE" ] || { printf '%s\n' 'signature value is required' >&2; exit 2; }
cp "$SIGNATURE_FILE" "$TMP/signature.json"
elif [ "$RELEASE_BUILD" = "true" ]; then
printf '%s\n' 'RELEASE_BUILD=true requires SIGNATURE_FILE' >&2
exit 2
else
printf '%s\n' 'warning: building an unsigned development package' >&2
fi
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
find "$TMP" -type f -exec touch -t 198001010000 {} +
if [ -f "$TMP/signature.json" ]; then
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json signature.json ui)
else
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json ui)
fi
ARCHIVE_HASH=$(shasum -a 256 "$OUT_PATH" | awk '{print $1}')
printf '%s %s\n' "$ARCHIVE_HASH" "$(basename -- "$OUT_PATH")" > "$OUT_PATH.sha256"
printf '%s\n' "$OUT_PATH"
@@ -8,7 +8,9 @@ const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscrip
await fs.mkdir(path.resolve(outputDir), { recursive: true })
const browser = await chromium.launch({ headless: true })
const launchOptions = { headless: true }
if (process.env.PLAYWRIGHT_EXECUTABLE_PATH) launchOptions.executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
const browser = await chromium.launch(launchOptions)
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
@@ -21,18 +23,12 @@ try {
} catch (_) {}
})
await page.goto(entry, { waitUntil: 'domcontentloaded' })
await page.getByLabel('管理员邮箱').fill('admin@example.com')
await page.getByLabel('密码').fill('password')
await page.getByRole('button', { name: '登录' }).click()
await page.locator('#app-view').waitFor({ state: 'visible' })
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
if (await page.locator('input[type="password"], form').count()) throw new Error('subscription module exposed an independent login form')
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.getByRole('button', { name: '用户订阅' }).click()
await page.locator('#subscriptions-list table').waitFor()
await page.getByRole('button', { name: '下一页' }).click()
await page.getByRole('button', { name: '概览' }).click()
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('#app-view').waitFor({ state: 'visible' })
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
+10 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http'
const port = Number(process.env.MOCK_CORE_PORT || 18080)
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
const captchaProvider = String(process.env.MOCK_CAPTCHA_PROVIDER || '').toLowerCase()
const users = new Map([
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
@@ -47,6 +48,9 @@ const server = http.createServer(async (req, res) => {
const input = await body(req)
const user = users.get(input.email)
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
if (captchaProvider === 'turnstile' && input.turnstile_token !== 'MOCK-TURNSTILE-TOKEN') return json(res, 400, { code: 400, message: 'captcha required' })
if (captchaProvider === 'tencent' && (!input.tencent_captcha_ticket || !input.tencent_captcha_randstr)) return json(res, 400, { code: 400, message: 'captcha required' })
if (captchaProvider === 'aliyun' && !input.turnstile_token) return json(res, 400, { code: 400, message: 'captcha required' })
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
}
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
@@ -55,7 +59,12 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
}
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: {
turnstile_enabled: captchaProvider === 'turnstile', turnstile_site_key: captchaProvider === 'turnstile' ? 'mock-site-key' : '',
geetest_captcha_enabled: false,
tencent_captcha_enabled: captchaProvider === 'tencent', tencent_captcha_app_id: captchaProvider === 'tencent' ? '123456' : '', tencent_captcha_region: 'cn',
aliyun_captcha_enabled: captchaProvider === 'aliyun', aliyun_captcha_scene_id: captchaProvider === 'aliyun' ? 'mock-scene' : '', aliyun_captcha_prefix: captchaProvider === 'aliyun' ? 'mock-prefix' : '', aliyun_captcha_region: 'cn'
} })
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
@@ -8,14 +8,22 @@ cleanup() {
trap cleanup EXIT INT TERM
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
CORE_BASE_URL=http://127.0.0.1:18080 \
PLUGIN_HOST=127.0.0.1 \
PLUGIN_PORT=18082 \
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
PLUGIN_COOKIE_SECURE=false \
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
(
export CORE_BASE_URL=http://127.0.0.1:18080
export PLUGIN_HOST=127.0.0.1
export PLUGIN_PORT=18082
export PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
export PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
export PLUGIN_COOKIE_SECURE=false
cd "$ROOT"
go run .
) >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
sleep 2
for attempt in $(seq 1 30); do
if curl -fsS http://127.0.0.1:18081/extensions/qiu.subscription-admin/healthz >/dev/null 2>&1; then
break
fi
sleep 0.2
done
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
+2 -167
View File
@@ -1,170 +1,5 @@
// The subscription browser UI is mounted by Plugin Admin. This compatibility
// entry intentionally contains no login form, cookie, or token handling.
(() => {
'use strict'
const state = {
csrf: '',
plans: [],
subscriptions: null,
subscriptionPage: 1,
subscriptionPageSize: 50,
captcha: { enabled: false, provider: '' }
}
const $ = (selector) => document.querySelector(selector)
const loginView = $('#login-view')
const appView = $('#app-view')
const loginForm = $('#login-form')
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
const route = (path) => `${basePath}${path}`
function showError(message, target = $('#app-error')) {
target.textContent = message || '请求失败'
target.hidden = !message
}
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
async function request(path, options = {}) {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
throw new Error(payload.error || '请求失败')
}
return payload
}
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
function formatNumber(value) {
if (value === null || value === undefined || value === '') return '-'
const number = Number(value)
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
}
function formatDate(value) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character])) }
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
function setView(name) {
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
if (name === 'plans' && !state.plans.length) loadPlans()
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
if (name === 'audit') loadAudit()
}
async function login(event) {
event.preventDefault(); clearError($('#login-error'))
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
try {
const captchaToken = String(form.get('captcha_token') || '').trim()
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
email: form.get('email'),
password: form.get('password'),
turnstile_token: captchaToken || undefined,
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
}) })
if (payload.requires_2fa) {
const code = window.prompt('请输入管理员 2FA 验证码')
if (!code) throw new Error('需要 2FA 验证码')
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
}
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadStatus(), loadOverview()])
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
}
async function loadPlans() {
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
catch (error) { showError(error.message); return false }
}
async function loadSubscriptions() {
try {
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
let endpoint = '/api/subscriptions'
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
} catch (error) { showError(error.message); return false }
}
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
async function loadOverview() {
clearError(); $('#sync-time').textContent = '同步中'
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
}
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
async function loadStatus() {
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
catch { $('#credential-status').textContent = '不可用' }
}
async function loadCaptchaConfig() {
try {
const payload = await request('/api/captcha-config'); state.captcha = payload
const panel = $('#captcha-panel')
if (!payload.enabled) { panel.hidden = true; return }
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
} catch { $('#captcha-panel').hidden = true }
}
async function loadBalance(userID) {
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
}
async function bootstrap() {
try {
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
if (!state.csrf || !payload.user) throw new Error('session unavailable')
loginView.hidden = true; appView.hidden = false
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
}
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
function renderPlans() {
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
container.innerHTML = state.plans.map((plan) => {
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
const features = parseFeatures(plan.features)
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
}).join('')
}
function renderSubscriptions(payload) {
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
}
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
loginForm.addEventListener('submit', login)
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
$('#refresh-all').addEventListener('click', loadOverview)
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
void bootstrap()
})()
+9 -111
View File
@@ -3,120 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>订阅管理插件</title>
<meta name="color-scheme" content="light">
<title>订阅管理模块</title>
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
</head>
<body data-base-path="__PLUGIN_BASE_PATH__">
<main class="shell">
<section id="login-view" class="auth-panel" aria-labelledby="login-title">
<div class="eyebrow">SUB2API EXTENSION</div>
<h1 id="login-title">订阅管理</h1>
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p>
<form id="login-form" class="stack" autocomplete="on">
<label>
<span>管理员邮箱</span>
<input name="email" type="email" autocomplete="username" required maxlength="254">
</label>
<label>
<span>密码</span>
<input name="password" type="password" autocomplete="current-password" required maxlength="200">
</label>
<fieldset id="captcha-panel" class="captcha-panel" hidden>
<legend id="captcha-label">安全验证</legend>
<p id="captcha-help" class="muted"></p>
<label>
<span>验证结果</span>
<textarea name="captcha_token" rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea>
</label>
<label id="captcha-randstr-row" hidden>
<span>验证随机串</span>
<input name="captcha_randstr" maxlength="512" autocomplete="off">
</label>
</fieldset>
<button class="primary" type="submit">登录</button>
<p id="login-error" class="error" role="alert" hidden></p>
</form>
</section>
<section id="app-view" hidden>
<header class="topbar">
<div>
<div class="eyebrow">SUB2API EXTENSION</div>
<h1>订阅管理</h1>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="secondary" type="button">退出</button>
</div>
</header>
<nav class="tabs" aria-label="插件页面">
<button class="tab active" data-view="overview" type="button">概览</button>
<button class="tab" data-view="plans" type="button">套餐</button>
<button class="tab" data-view="subscriptions" type="button">用户订阅</button>
<button class="tab" data-view="audit" type="button">操作记录</button>
<button class="tab" data-view="settings" type="button">设置</button>
</nav>
<p id="app-error" class="error" role="alert" hidden></p>
<section id="view-overview" class="view stack">
<div class="section-heading"><div><h2>连接概览</h2><p class="muted">Core 是套餐、余额和订阅账本的唯一来源。</p></div><button id="refresh-all" class="secondary" type="button">刷新数据</button></div>
<div class="metric-grid">
<article class="metric"><span>插件状态</span><strong class="status ok">运行中</strong><small>独立服务 · 只读模式</small></article>
<article class="metric"><span>Core 连接</span><strong id="core-status" class="status">检查中</strong><small id="sync-time">尚未同步</small></article>
<article class="metric"><span>可售套餐</span><strong id="plan-count">-</strong><small>来自 Core 套餐目录</small></article>
<article class="metric"><span>订阅实例</span><strong id="subscription-count">-</strong><small>当前分页结果</small></article>
<article class="metric"><span>用户余额</span><strong id="user-balance">-</strong><small>选择用户后显示 Core 余额</small></article>
</div>
<form id="balance-form" class="filter-row balance-form">
<label><span>查询用户余额</span><input name="user_id" inputmode="numeric" pattern="[1-9][0-9]*" placeholder="用户 ID" required></label>
<button class="secondary" type="submit">查询余额</button>
</form>
<div class="notice"><strong>只读试验版</strong><span>余额购买、续费、撤销和外部支付尚未启用。页面不会提交任何写操作。</span></div>
</section>
<section id="view-plans" class="view" hidden>
<div class="section-heading"><div><h2>套餐目录</h2><p class="muted">展示 Core 当前返回的价格、额度和覆盖分组。</p></div><button class="secondary reload" data-target="plans" type="button">刷新</button></div>
<div id="plans-list" class="card-list"></div>
</section>
<section id="view-subscriptions" class="view" hidden>
<div class="section-heading"><div><h2>用户订阅</h2><p class="muted">每个订阅实例独立展示,支持同档位多实例。</p></div><button class="secondary reload" data-target="subscriptions" type="button">刷新</button></div>
<form id="subscription-filter" class="filter-row">
<label><span>用户 ID</span><input name="user_id" inputmode="numeric" pattern="[0-9]*"></label>
<label><span>状态</span><select name="status"><option value="">全部</option><option value="active">active</option><option value="expired">expired</option><option value="revoked">revoked</option></select></label>
<button class="secondary" type="submit">筛选</button>
</form>
<div id="subscriptions-list" class="table-wrap"></div>
<div id="subscription-pagination" class="pagination" hidden>
<span id="subscription-page-info" class="muted"></span>
<div><button id="subscription-prev" class="secondary" type="button">上一页</button><button id="subscription-next" class="secondary" type="button">下一页</button></div>
</div>
</section>
<section id="view-audit" class="view" hidden>
<div class="section-heading"><div><h2>操作记录</h2><p class="muted">仅记录插件会话和只读查询结果,不记录 token 或密码。</p></div><button class="secondary reload" data-target="audit" type="button">刷新</button></div>
<div id="audit-list" class="table-wrap"></div>
</section>
<section id="view-settings" class="view stack" hidden>
<div class="section-heading"><div><h2>插件设置</h2><p class="muted">部署参数由服务端环境变量管理,页面不提供 secret 编辑入口。</p></div></div>
<div class="settings-list">
<div><span>运行模式</span><strong>只读 V1</strong></div>
<div><span>Core API allowlist</span><strong>10 个固定端点</strong></div>
<div><span>会话存储</span><strong>进程内存(重启后需重新登录)</strong></div>
<div><span>Core 凭据</span><strong id="credential-status">检查中</strong></div>
<div><span>余额购买 / 续费 / 撤销</span><strong class="status">未启用</strong></div>
</div>
</section>
</section>
<main class="handoff-page">
<div class="handoff-mark" aria-hidden="true">S</div>
<p class="eyebrow">SUB2API BUSINESS MODULE</p>
<h1>订阅管理已纳入统一控制面</h1>
<p class="muted">此服务只提供订阅模块的后端能力。管理员请从 Plugin Admin 进入,订阅页面会复用同一个会话,不需要再次登录。</p>
<a class="primary" href="__PLUGIN_BASE_PATH__/admin/">打开统一控制面</a>
<p class="security-note">Core 凭据由服务端托管,浏览器不会接触访问令牌。</p>
</main>
<dialog id="detail-dialog" class="detail-dialog">
<div class="dialog-heading"><h2>订阅详情</h2><button id="close-detail" class="icon-button" type="button" aria-label="关闭">×</button></div>
<pre id="detail-content"></pre>
</dialog>
<script src="__PLUGIN_BASE_PATH__/app.js" defer></script>
</body>
</html>
+11 -102
View File
@@ -1,103 +1,12 @@
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; }
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
:root { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color: #1f2937; background: #f3f6fa; font-synthesis: none; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); }
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } }
h1, h2, h3, p { margin: 0; }
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; }
.muted { color: #647384; font-size: .86rem; line-height: 1.5; }
@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } }
.stack { display: grid; gap: 18px; }
form.stack { margin-top: 26px; }
label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; }
input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; }
textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); }
button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; }
.primary { color: white; background: #3977a9; }
.primary:hover { background: #2e628e; }
.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; }
.secondary:hover { background: rgb(57 119 169 / 8%); }
.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; }
.top-actions { flex-wrap: wrap; justify-content: flex-end; }
.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; }
.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; }
.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; }
.view { padding-top: 28px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.metric > span { color: #647384; font-size: .82rem; }
.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; }
.metric small { color: #7b8996; font-size: .75rem; }
.status { color: #647384; }
.status.ok { color: #2f8b5c; }
.status.bad { color: #c14f4f; }
.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; }
.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; }
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); }
.facts dt { color: #647384; font-size: .72rem; }
.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; }
.tag { color: #3977a9; background: rgb(57 119 169 / 11%); }
.pill { color: #647384; background: rgb(100 115 132 / 12%); }
.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); }
.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); }
.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; }
.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; }
.filter-row label { width: min(220px, 100%); }
.balance-form { margin-top: 16px; }
.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; }
.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
.pagination > div { display: flex; gap: 8px; }
.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; }
.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; }
.settings-list strong { font-weight: 600; text-align: right; }
@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } }
.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; }
table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; }
th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; }
th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); }
tr:last-child td { border-bottom: 0; }
code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; }
.empty { padding: 32px; color: #647384; text-align: center; }
.error { color: #b84d4d; font-size: .84rem; }
.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; }
.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); }
.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; }
.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; }
pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.shell { width: min(100% - 20px, 600px); padding-top: 18px; }
.auth-panel { margin-top: 4vh; padding: 22px; }
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; }
.metric-grid, .card-list { grid-template-columns: 1fr; }
.metric { min-height: 104px; }
.notice { align-items: flex-start; flex-direction: column; gap: 5px; }
.filter-row { align-items: stretch; flex-direction: column; }
.filter-row label { width: 100%; }
.filter-row button { width: 100%; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { width: 100%; }
.pagination button { flex: 1; }
.settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; }
.settings-list strong { text-align: left; }
}
body { min-width: 320px; min-height: 100vh; margin: 0; }
.handoff-page { width: min(560px, calc(100% - 32px)); min-height: 100vh; margin: 0 auto; display: grid; align-content: center; justify-items: start; gap: 16px; }
.handoff-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 9px; color: #fff; background: #0052d9; font-size: 22px; font-weight: 700; }
.eyebrow { margin: 10px 0 0; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
h1 { max-width: 500px; margin: 0; color: #111827; font-size: clamp(26px, 5vw, 38px); line-height: 1.2; }
.muted { max-width: 510px; margin: 0; color: #687585; font-size: 14px; line-height: 1.75; }
.primary { display: inline-flex; align-items: center; min-height: 40px; padding: 0 18px; border-radius: 5px; color: #fff; background: #0052d9; text-decoration: none; font-size: 13px; }
.primary:hover { background: #003cab; }
.security-note { margin: 8px 0 0; color: #9aa5b4; font-size: 11px; }
@media (max-width: 480px) { .handoff-page { width: calc(100% - 24px); } h1 { font-size: 28px; } }
+39
View File
@@ -46,6 +46,11 @@ done
command -v go >/dev/null 2>&1 || die "缺少 Go;请安装 Go 1.23 或更高版本"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
command -v install >/dev/null 2>&1 || die "缺少 install 命令"
GO_MAJOR=$(go version | sed -nE 's/.* go([0-9]+)\.([0-9]+).*/\1/p')
GO_MINOR=$(go version | sed -nE 's/.* go([0-9]+)\.([0-9]+).*/\2/p')
if [[ -z "$GO_MAJOR" || -z "$GO_MINOR" ]] || (( GO_MAJOR < 1 || (GO_MAJOR == 1 && GO_MINOR < 23) )); then
die "需要 Go 1.23 或更高版本"
fi
case "$SELECTION" in
all) PLUGINS=(plugin-admin subscription-admin) ;;
@@ -78,6 +83,27 @@ random_secret() {
fi
}
validate_install_root() {
local value=$1 label=$2 component current=""
[[ -n "$value" && "$value" = /* && "$value" != "/" ]] || die "$label 必须是非根绝对路径"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
local parts=()
IFS='/' read -r -a parts <<< "${value#/}"
for component in "${parts[@]}"; do
[[ -z "$component" ]] && continue
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
current="$current/$component"
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
done
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
fi
}
validate_install_root "$PREFIX" PLUGIN_INSTALL_PREFIX
validate_install_root "$ETC_DIR" PLUGIN_ETC_DIR
validate_install_root "$VAR_DIR" PLUGIN_VAR_DIR
install_one() {
local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env"
local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1"
@@ -125,8 +151,21 @@ Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
TasksMax=128
MemoryMax=512M
CPUQuota=200%
ReadWritePaths=${data_dir}
[Install]