feat: complete unified plugin admin v1.1.0

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit 8aed5b75d9
69 changed files with 6681 additions and 901 deletions
+3
View File
@@ -7,6 +7,8 @@
bin/ bin/
dist/ dist/
build/ build/
ui-build/
node_modules/
data/ data/
.cache/ .cache/
*.log *.log
@@ -22,3 +24,4 @@ coverage.out
# Browser test output # Browser test output
playwright-report/ playwright-report/
test-results/ test-results/
.screenshots/
+26 -8
View File
@@ -9,11 +9,17 @@ HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、 - `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、
下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。 下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。
- `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理 - `plugins/subscription-admin`:可选的订阅管理业务模块后端。它不是插件管理
控制面,只有安装、启用并应用菜单后才会出现。 控制面;只有安装并启用后才会挂载到 Plugin Admin 的统一导航中。
- `docs/`:插件框架、清单、边界、架构、开发和验收契约。 - `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 兼容基线。 低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。
## 推荐部署顺序 ## 推荐部署顺序
@@ -48,12 +54,16 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。
## 接入顺序 ## 接入顺序
1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。 1. 启动 `plugin-admin` 和需要的业务模块后端,各自监听独立端口。
2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。 2. 使用 Core 管理员账号登录 Plugin Admin 一次;普通账号被拒绝,业务模块不再单独登录。
3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。 3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。
4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。 4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。
5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。 5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。
订阅模块进入统一控制面后使用 `/modules/subscription/*` 路由。Plugin Admin
通过同源 BFF 代理固定的套餐、订阅和用户只读接口,浏览器不再访问订阅
服务的登录页,也不会创建第二个管理员会话。
Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端 Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端
不持有 Core JWT、Admin Key 或其他服务密钥。 不持有 Core JWT、Admin Key 或其他服务密钥。
@@ -62,14 +72,22 @@ Core 继续作为用户、余额、订阅、计费和用量账本的权威来源
Linux + systemd 环境可直接使用仓库内的安装脚本: Linux + systemd 环境可直接使用仓库内的安装脚本:
```sh ```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \ RELEASE_SHA=COMMIT_SHA_40_HEX
| sudo bash -s -- --plugin all 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 ```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 | 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 主机上: 在一台已安装 Go 1.23+、Git 和 systemd 的 Linux 主机上:
```sh ```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 \ 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 ```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \ RELEASE_SHA=COMMIT_SHA_40_HEX
| sudo bash -s -- --plugin plugin-admin 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 ```sh
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \ RELEASE_SHA=COMMIT_SHA_40_HEX
| sudo bash -s -- --plugin subscription-admin 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` 和受信发布者公钥。插件市场默认读取 `PLUGIN_CONFIG_KEY` 和受信发布者公钥。插件市场默认读取
`/var/lib/sub2api-add/plugin-admin/marketplace/index.json`;可通过 `/var/lib/sub2api-add/plugin-admin/marketplace/index.json`;可通过
`PLUGIN_MARKETPLACE_INDEX` 指向受控本地索引或 HTTPS 索引,并用 `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 ```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 | sudo bash -s -- --plugin all
``` ```
确认同时删除插件配置、插件数据和源码: 确认同时删除插件配置、插件数据和源码:
```sh ```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 | 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} REPO_URL=${PLUGIN_REPO_URL:-https://git.awaioi.com/awaioi/sub2api-add.git}
SOURCE_DIR=${PLUGIN_SOURCE_DIR:-/opt/sub2api-add} 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; } 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 运行" [[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
command -v git >/dev/null 2>&1 || die "缺少 git" 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 if [[ -e "$SOURCE_DIR" && ! -d "$SOURCE_DIR/.git" ]]; then
die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR" die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR"
fi fi
@@ -17,10 +61,26 @@ if [[ -d "$SOURCE_DIR/.git" ]]; then
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then
die "$SOURCE_DIR 有未提交修改,先清理后再升级" die "$SOURCE_DIR 有未提交修改,先清理后再升级"
fi 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 git -C "$SOURCE_DIR" checkout --force FETCH_HEAD
else 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 fi
exec "$SOURCE_DIR/scripts/install-local.sh" "$@" exec "$SOURCE_DIR/scripts/install-local.sh" "$@"
+39 -6
View File
@@ -10,8 +10,27 @@ SELECTION=all
PURGE=false PURGE=false
YES=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; } die() { printf '错误:%s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行" [[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
while (($#)); do while (($#)); do
case "$1" in case "$1" in
@@ -29,6 +48,14 @@ case "$SELECTION" in
*) die "插件必须是 all、plugin-admin 或 subscription-admin" ;; *) die "插件必须是 all、plugin-admin 或 subscription-admin" ;;
esac 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 if $PURGE && ! $YES; then
[[ -t 0 ]] || die "非交互环境执行 --purge 时必须同时提供 --yes" [[ -t 0 ]] || die "非交互环境执行 --purge 时必须同时提供 --yes"
read -r -p "将删除插件配置和数据,继续?[y/N] " answer read -r -p "将删除插件配置和数据,继续?[y/N] " answer
@@ -38,21 +65,27 @@ fi
for plugin in "${PLUGINS[@]}"; do for plugin in "${PLUGINS[@]}"; do
unit="sub2api-$plugin.service" unit="sub2api-$plugin.service"
systemctl disable --now "$unit" 2>/dev/null || true 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. # Keep the checked-out source clean so a later install can fast-forward it.
# Only generated binaries are removed unless --purge is explicitly used. # 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 if $PURGE; then
rm -rf "$PREFIX/$plugin" plugin_root="$PREFIX/$plugin"
rm -f "$ETC_DIR/$plugin.env" if [[ -d "$plugin_root" && ! -L "$plugin_root" ]]; then
rm -rf "$VAR_DIR/$plugin" 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 fi
done done
systemctl daemon-reload systemctl daemon-reload
if $PURGE && [[ -d "$ETC_DIR" ]]; then rmdir "$ETC_DIR" 2>/dev/null || true; fi 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 "$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 if $PURGE; then
userdel "$RUN_USER" 2>/dev/null || true 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-01 | 鉴权 | Core 管理员登录控制面 | `plugins/plugin-admin/main_test.go:TestAdminLoginDoesNotExposeCoreTokens`;本地浏览器登录 | passed |
| AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed | | AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed |
| AUTH-03 | 鉴权 | 普通用户登录和 API | `plugins/plugin-admin/main_test.go:TestOrdinaryCoreUserIsRejected` | 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-04 | 会话 | 过期、撤销、登出和刷新 | `plugins/plugin-admin/main_test.go:TestRefreshRevalidatesAdminRole` | passed |
| AUTH-05 | CSRF | 所有写请求 | `plugins/plugin-admin/main_test.go:TestMutationRequiresCSRFAndIdempotency` | 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-01 | 秘密 | 浏览器、URL、HTML、JS、LocalStorage、下载、日志 | 登录/配置测试断言 token 和 secret 不回显;浏览器 DOM 未出现 Core token | passed |
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed | | SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
| SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed | | SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed |
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | 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-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed | | MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
| MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed | | MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed |
@@ -21,7 +24,7 @@
| LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed | | LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed |
| LIFE-04A | 删除接口 | `DELETE /api/plugins/{id}` 与卸载语义一致 | 市场生命周期测试覆盖标准 DELETE | passed | | LIFE-04A | 删除接口 | `DELETE /api/plugins/{id}` 与卸载语义一致 | 市场生命周期测试覆盖标准 DELETE | passed |
| MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | 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-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed |
| API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed | | API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed |
| UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed | | UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed |
@@ -40,6 +43,7 @@ go vet ./...
node --check <all-ui-scripts> node --check <all-ui-scripts>
production build production build
manifest verification manifest verification
bash -n <all-shell-scripts>
git diff --check 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/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。 - `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
- 本地浏览器登录后,控制面首页显示“已登记插件”与订阅插件卡片;425、900、1440 视口均无横向溢出。 - 本地浏览器登录后,控制面首页、插件市场和操作记录可访问;普通登录与 Turnstile 模拟登录在 425、900、1440 视口均通过,无横向溢出或敏感响应字段。
- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。 - 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 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] B[管理员浏览器] --> C[Core custom_menu_items]
C --> I[Core /custom/:id sandbox iframe] C --> I[Core /custom/:id sandbox iframe]
I --> P[反向代理 /extensions/:plugin-id/] I --> P[反向代理 /extensions/:plugin-id/]
P --> M[Plugin Control Plane] P --> M[Plugin Control Plane / TDesign Shell]
M --> S[独立业务插件服务] M --> S[订阅业务模块 BFF / 独立服务]
S --> A[Typed Core API Adapter] M --> A[Typed Core API Adapter]
A --> K[Core Auth/Admin API] A --> K[Core Auth/Admin API]
K --> D[Core 权威账本与审计] K --> D[Core 权威账本与审计]
M --> R[Plugin Registry / Revisions / Audit] M --> R[Plugin Registry / Revisions / Audit]
@@ -23,15 +23,16 @@ sequenceDiagram
participant B as Browser participant B as Browser
participant P as Plugin BFF participant P as Plugin BFF
participant C as Core participant C as Core
B->>P: POST /login B->>P: POST /login(唯一登录入口)
P->>C: POST /api/v1/auth/login P->>C: POST /api/v1/auth/login
C-->>P: access/refresh 或 2FA challenge C-->>P: access/refresh 或 2FA challenge
P->>C: POST /api/v1/auth/login/2fa (按需) P->>C: POST /api/v1/auth/login/2fa (按需)
P->>C: GET /api/v1/auth/me P->>C: GET /api/v1/auth/me
C-->>P: role=admin C-->>P: role=admin
P-->>B: HttpOnly plugin session + CSRF token P-->>B: 唯一 HttpOnly plugin session + CSRF token
B->>P: GET /api/plugins B->>P: GET /api/plugins 或 /modules/subscription/*
P->>C: Bearer Core JWT + X-Request-ID P->>C: Bearer Core JWT + X-Request-ID
P->>S: 复用同一管理员会话调用订阅模块
P-->>B: 脱敏业务数据 P-->>B: 脱敏业务数据
``` ```
+2
View File
@@ -1,5 +1,7 @@
# Business Plugin V1 边界 # Business Plugin V1 边界
> **前端架构修订(2026-08-30)**:Plugin Admin 是唯一的插件管理控制面和登录入口。订阅属于已安装业务模块,复用控制面会话、导航和 CSRF;订阅后端可以独立进程运行,但订阅前端不得再提供独立登录页、Cookie 或管理员身份。
## Core 负责 ## Core 负责
- 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色; - 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色;
+17 -8
View File
@@ -2,7 +2,9 @@
状态:Accepted Contract / V1 参考实现已完成本地验收 状态:Accepted Contract / V1 参考实现已完成本地验收
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件是独立服务、独立端口、独立版本和独立 UI;它可以通过现有管理员自定义菜单嵌入 Core,也可以在新窗口运行。订阅管理只是一个可选业务插件,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。 本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件的后端服务、端口和版本独立;浏览器端由统一的 Plugin Admin 控制面承载,业务插件 UI 以模块方式挂载到同一个管理员 Shell 中。订阅管理只是一个可选业务模块,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
> **前端架构修订(2026-08-30)**:此前“独立 UI/独立管理员后台”的表述仅指后端服务可以独立部署,不表示业务模块要再次登录。Plugin Admin 负责唯一的管理员登录、会话、导航和 CSRF;订阅模块安装并启用后才出现在控制面导航中,继承同一会话,不提供第二个登录页或第二套 Cookie。
## 1. 目标 ## 1. 目标
@@ -14,7 +16,7 @@ V1 需要提供一个独立的插件控制面,负责:
- 保存插件版本、服务地址、运行状态、菜单声明和操作审计; - 保存插件版本、服务地址、运行状态、菜单声明和操作审计;
- 通过 Core 现有管理员鉴权复用操作者身份; - 通过 Core 现有管理员鉴权复用操作者身份;
- 将已启用插件的管理员菜单注入 `custom_menu_items`; - 将已启用插件的管理员菜单注入 `custom_menu_items`;
- 让每个业务插件仅通过自己的 BFF 调用 Core 明确允许的 API。 - 让每个业务插件仅通过控制面或其受控模块 BFF 调用 Core 明确允许的 API。
V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。 V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。
@@ -25,7 +27,7 @@ V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2
| 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF | | 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF |
| 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 | | 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 |
| 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane | | 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane |
| UI | 配置 iframe + UI Bridge | 业务后台/用户工具页面 | | UI | 配置 iframe + UI Bridge | 统一控制面中的业务模块页面 |
| 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 | | 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 |
| 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 | | 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 |
@@ -60,7 +62,7 @@ Sub2API Core 现有鉴权、Admin API 和领域账本
- `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。 - `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。
- `plugin_readonly`:只读查看已登记插件。 - `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. 管理面契约 ## 5. 管理面契约
@@ -157,9 +159,16 @@ Core 返回 `401` 时,一次用户请求最多 refresh 一次;并发 refresh
## 9. 会话和嵌入 ## 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`: 菜单注入使用 Core 现有 `custom_menu_items`:
@@ -167,7 +176,7 @@ Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core
{ {
"id": "DOMAIN_PLUGIN_ID", "id": "DOMAIN_PLUGIN_ID",
"label": "DOMAIN_PLUGIN_LABEL", "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", "visibility": "admin",
"sort_order": 200 "sort_order": 200
} }
@@ -200,7 +209,7 @@ Core 始终是用户身份、余额、订阅、订单、用量、权限、计费
### Phase 2:业务插件适配 ### Phase 2:业务插件适配
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个独立业务插件接入,只实现自身领域页面和 Core 只读 API,不改变控制面。 提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个业务模块接入统一控制面,只实现自身领域页面和 Core 只读 API,不复制控制面的登录、导航和会话。
### Phase 3:生产增强 ### 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 状态: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 已提供这些接口。 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; - 插件只在服务端调用 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。反向代理只改变网络路径,不改变这一认证边界。 如果以后要求“已登录 Core 后打开 iframe 立即无感登录”,需要一个很小的 Core 一次性登录交接接口或前端 `postMessage` 适配;这属于 V1.1,不应通过 URL 明文传递 JWT。反向代理只改变网络路径,不改变这一认证边界。
@@ -37,12 +39,13 @@ Core `/custom/PLUGIN_ID`
反向代理 `/extensions/PLUGIN_ID/*` 反向代理 `/extensions/PLUGIN_ID/*`
│ 转发到独立服务 `127.0.0.1:PLUGIN_PORT` │ 转发到独立服务 `127.0.0.1:PLUGIN_PORT`
▼ ▼
业务插件后台 Plugin Admin 控制面(统一 TDesign Shell)
├─ `/login` 接收管理员登录请求 ├─ `/login` 唯一登录入口
├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`) ├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`)
├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin` ├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin`
├─ 建立插件 HttpOnly 会话,Core access/refresh token 只在服务端保存 ├─ 建立唯一的插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
└─ 通过 Bearer Core JWT 调用现有 Core Admin API ├─ 挂载已启用业务模块(订阅等),模块不再登录
└─ 通过受控 BFF/内部服务调用业务模块和 Core Admin API
▼ ▼
Sub2API Core 现有认证、Admin API 和订阅/余额账本 Sub2API Core 现有认证、Admin API 和订阅/余额账本
``` ```
@@ -53,7 +56,7 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
### 1.2 V1 的默认范围 ### 1.2 V1 的默认范围
- 插件拥有独立后台和独立发布版本。 - 插件后端拥有独立服务和独立发布版本;前端由统一控制面承载。
- 只允许管理员登录;普通用户访问插件后台一律拒绝。 - 只允许管理员登录;普通用户访问插件后台一律拒绝。
- 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。 - 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。
- 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。 - 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。
@@ -78,8 +81,8 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
| 术语 | 定义 | | 术语 | 定义 |
|---|---| |---|---|
| Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 | | Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 |
| Business Plugin | 独立部署的后台服务,提供一个业务域的管理 UI 和 BFF。 | | Business Plugin | 独立部署的后台服务或业务模块,提供一个业务域的 UI 和 BFF。 |
| Plugin UI | 由 Business Plugin 提供的页面,只调用自己的后端。 | | Plugin UI | 由统一控制面挂载的业务模块页面,只调用模块 BFF。 |
| Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 | | Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 |
| Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 | | Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 |
| Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 | | Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 |
@@ -91,27 +94,27 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
### 4.1 管理员登录 ### 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`。 2. 插件后端服务端调用 Core `POST /api/v1/auth/login`;需要二次验证时继续调用 `POST /api/v1/auth/login/2fa`。
3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。 3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。
4. 插件后端建立自己的短时 HttpOnly 会话;Core access/refresh token 只保存在插件服务端的会话存储中,不回传浏览器。 4. 控制面建立唯一的短时 HttpOnly 会话;Core access/refresh token 只保存在控制面服务端的会话存储中,不回传浏览器。
5. 插件业务请求只携带插件会话 Cookie,插件后端再用对应管理员的 Core Bearer token 调用允许的 Core API。 5. 订阅等业务模块请求只携带控制面会话 Cookie,由控制面 BFF 或受控内部转发调用模块和允许的 Core API。
```text ```text
浏览器 -> Plugin /login(插件会话 Cookie 尚未建立) 浏览器 -> Plugin Admin /login(控制面会话 Cookie 尚未建立)
Plugin -> Core /api/v1/auth/login Plugin Admin -> Core /api/v1/auth/login
Plugin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求) Plugin Admin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
Plugin -> Core /api/v1/auth/me(确认 role=admin) Plugin Admin -> Core /api/v1/auth/me(确认 role=admin)
Plugin <- 建立 HttpOnly 插件会话 Plugin Admin <- 建立唯一 HttpOnly 控制面会话
浏览器 -> Plugin /admin/*(只带插件会话 Cookie) 浏览器 -> Plugin Admin /admin/* 与 /modules/*(只带同一会话 Cookie)
Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT) 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 权限判定 ### 4.2 权限判定
@@ -131,6 +134,14 @@ Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
- 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。 - 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。
- 插件 UI 不把 Core JWT、Admin Key 或服务凭据写入 LocalStorage、URL、HTML、日志或错误提示。 - 插件 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. Admin Key 与服务凭据安全
### 5.1 绝对禁止的做法 ### 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 | 建立插件服务端会话 | 只存插件服务端会话存储,短 TTL,测试 Core 与测试管理员专用。 |
| 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 | | 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 |
@@ -155,8 +166,8 @@ V1.1 如需在不保存 Core refresh token 的情况下运行,再设计 Core
### 5.3 凭据生命周期 ### 5.3 凭据生命周期
1. 插件后端接收管理员登录请求,但不保存密码。 1. Plugin Admin 后端接收管理员登录请求,但不保存密码。
2. 插件后端调用 Core 登录/2FA,保存返回 token 到服务端会话,并绑定 `admin_user_id`。 2. Plugin Admin 后端调用 Core 登录/2FA,保存返回 token 到唯一的服务端会话,并绑定 `admin_user_id`。
3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。 3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。
4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。 4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。
5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。 5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。
@@ -302,11 +313,11 @@ Core `/custom/example.subscription`
└── sandbox iframe -> 反向代理 -> `127.0.0.1:PLUGIN_PORT` └── 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` 请求头。 生产部署建议把插件外部地址挂在与 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. 威胁模型与处置 ## 10. 威胁模型与处置
@@ -345,7 +356,7 @@ Core `/custom/example.subscription`
### 11.3 浏览器和部署测试 ### 11.3 浏览器和部署测试
- 管理员可登录,普通用户无法登录或访问任何后台 API; - 管理员在 Plugin Admin 完成一次登录后可进入订阅模块,普通用户无法登录或访问任何后台 API;
- 不同屏幕下页面无横向泄露和敏感字段; - 不同屏幕下页面无横向泄露和敏感字段;
- 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key; - 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key;
- HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏; - HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏;
@@ -355,7 +366,7 @@ Core `/custom/example.subscription`
### Phase 0:冻结部署契约 ### Phase 0:冻结部署契约
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务); - 选择外部服务部署方式(推荐同源反向代理 + 独立服务),并冻结统一控制面模块挂载方式;
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查; - 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
- 冻结允许调用的现有 Core API 路径、字段、分页和错误处理; - 冻结允许调用的现有 Core API 路径、字段、分页和错误处理;
- 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表; - 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表;
@@ -369,10 +380,10 @@ Core `/custom/example.subscription`
- 插件健康检查、启停、操作审计和同源入口; - 插件健康检查、启停、操作审计和同源入口;
- Core API allowlist、contract test 与本地示例插件。 - Core API allowlist、contract test 与本地示例插件。
### Phase 2:订阅插件试验 ### Phase 2:订阅业务模块试验
- 只读套餐、用户余额和订阅列表; - 在统一控制面内挂载只读套餐、用户余额和订阅列表模块;
- 管理员操作页面、查询缓存和审计; - 复用控制面管理员会话、查询缓存和审计;
- 使用测试 Core 和测试账户,不连接生产余额; - 使用测试 Core 和测试账户,不连接生产余额;
- 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。 - 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。
+3 -1
View File
@@ -8,12 +8,14 @@
- [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md) - [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md)
- [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md) - [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md)
- [Architecture](BUSINESS_PLUGIN_ARCHITECTURE.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) - [Acceptance](BUSINESS_PLUGIN_ACCEPTANCE.md)
- [Development](BUSINESS_PLUGIN_DEVELOPMENT.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` ## 现有 `.s2plugin`
+14 -11
View File
@@ -2,14 +2,16 @@
状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft 状态: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.1 V1 目标 ### 1.1 V1 目标
- 提供独立的管理员订阅后台,普通账号拒绝登录和访问; - 提供挂载在统一 Plugin Admin 控制面内的管理员订阅模块,普通账号拒绝登录和访问;
- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码; - 由 Plugin Admin 统一调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`;订阅模块不维护第二套用户密码或登录态;
- 展示 Core 中的套餐、用户余额和订阅实例状态; - 展示 Core 中的套餐、用户余额和订阅实例状态;
- 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开; - 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开;
- 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅; - 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅;
@@ -42,14 +44,15 @@
Core 管理后台 Core 管理后台
└─ custom_menu_items (visibility=admin) └─ custom_menu_items (visibility=admin)
└─ sandbox iframe / 新窗口 └─ sandbox iframe / 新窗口
└─ 反向代理 -> Plugin `127.0.0.1:PLUGIN_PORT` └─ 反向代理 -> Plugin Admin `127.0.0.1:PLUGIN_PORT`
├─ Plugin /login -> Core /api/v1/auth/login (+ /login/2fa) ├─ Plugin Admin /login -> Core /api/v1/auth/login (+ /login/2fa)
├─ Plugin /auth/me -> 只允许 role=admin ├─ Plugin Admin /auth/me -> 只允许 role=admin
├─ HttpOnly 插件会话(不建 Core 用户表) ├─ 唯一 HttpOnly 控制面会话(不建 Core 用户表)
├─ 挂载订阅模块 /modules/subscription/*(不重复登录)
└─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读) └─ 服务端 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. 权威边界 ## 4. 权威边界
@@ -65,7 +68,7 @@ Core 管理后台
### 4.2 插件可以负责 ### 4.2 插件可以负责
- 管理员登录代理、插件会话和插件内角色; - 控制面负责管理员登录代理、统一插件会话和插件内角色;订阅模块只消费经过授权的模块会话;
- 套餐、用户和订阅的分页筛选与展示; - 套餐、用户和订阅的分页筛选与展示;
- 只读缓存、操作结果页和管理员审计视图; - 只读缓存、操作结果页和管理员审计视图;
- 未来写操作的确认表单,但提交必须调用 Core 原子命令; - 未来写操作的确认表单,但提交必须调用 Core 原子命令;
@@ -210,7 +213,7 @@ accepted -> processing -> completed
### 11.3 浏览器和部署 ### 11.3 浏览器和部署
- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝; - 管理员在 Plugin Admin 的 iframe 或新窗口完成一次登录即可进入订阅模块;普通用户菜单不可见且 API 拒绝;
- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露; - 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露;
- DevTools 请求、下载、页面源和日志中没有 secret; - DevTools 请求、下载、页面源和日志中没有 secret;
- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。 - 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。
@@ -247,7 +250,7 @@ accepted -> processing -> completed
- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。 - [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。
- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。 - [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。
- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。 - [ ] Plugin Admin 统一登录入口在 iframe 和新窗口场景均可用;订阅模块不提供独立登录,V1 不提供无感 SSO。
- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。 - [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。
- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。 - [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。
- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。 - [ ] 余额购买写操作明确等待 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_ENV=production
PLUGIN_HOST=127.0.0.1 PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8090 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 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_PATH=/extensions/qiu.plugin-admin/
PLUGIN_COOKIE_SECURE=false PLUGIN_COOKIE_SECURE=false
PLUGIN_COOKIE_SAMESITE=lax PLUGIN_COOKIE_SAMESITE=lax
PLUGIN_FRAME_ANCESTORS='self' 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_ALLOW_UNSIGNED=false
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"} # 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. # 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_INDEX=/var/lib/sub2api-add/plugin-admin/marketplace/index.json
PLUGIN_MARKETPLACE_ALLOWED_HOSTS= 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: test:
go test ./... -count=1 go test ./... -count=1
@@ -7,7 +7,10 @@ build:
mkdir -p bin mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin . 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 node --check ui/app.js
git diff --check 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 generated secret in production and keep it stable across restarts so encrypted
plugin configuration remains decryptable. 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 Open `/admin/` directly or expose the service through the reverse proxy in
`deploy/`. The first login is the existing Core administrator login; no plugin `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 user table is created. The control plane stores only a short-lived server-side
session and encrypted plugin configuration. 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 ## Control-plane endpoints
```text ```text
GET /healthz GET /healthz
GET /readyz GET /readyz
POST /login POST /login/2fa POST /logout POST /login POST /login/2fa POST /logout
GET /api/captcha-config
GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version) GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version)
GET /api/me GET /api/plugins GET /api/plugins/{id} GET /api/me GET /api/plugins GET /api/plugins/{id}
POST /api/plugins/install (multipart field: package) 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/plugins/{id}/menu-preview|menu-apply
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."}) POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
GET /api/audit 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 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 active revision. Delete/uninstall is allowed only after disable and removes
plugin files, not Core data. 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 ## Marketplace catalog
The marketplace is server-side only. The browser receives metadata and sends a 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, "schema_version": 1,
"plugin_id": "qiu.plugin-admin", "plugin_id": "qiu.plugin-admin",
"name": "Business Plugin Control Plane", "name": "Business Plugin Control Plane",
"version": "1.0.0", "version": "1.1.0",
"core_api_baseline": "sub2api-0.1.183", "core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"], "tested_core_versions": ["0.1.183"],
"capabilities": ["plugin.admin.v1"], "capabilities": ["plugin.admin.v1"],
@@ -28,6 +28,11 @@
"POST /api/v1/auth/logout", "POST /api/v1/auth/logout",
"GET /api/v1/auth/me", "GET /api/v1/auth/me",
"GET /api/v1/settings/public", "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 Type=simple
User=sub2api-plugin User=sub2api-plugin
Group=sub2api-plugin Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/plugin-admin WorkingDirectory=/var/lib/sub2api-add/plugin-admin
EnvironmentFile=/etc/sub2api/plugin-admin.env EnvironmentFile=/etc/sub2api-add/plugin-admin.env
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin ExecStart=/opt/sub2api-add/plugin-admin/bin/plugin-admin
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true 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] [Install]
WantedBy=multi-user.target 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) { func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
var input struct { var input struct {
Name string `json:"name"` 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) { func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183") t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir()) reg, _ := openRegistry(t.TempDir())
@@ -587,6 +741,7 @@ func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
func TestRefreshRevalidatesAdminRole(t *testing.T) { func TestRefreshRevalidatesAdminRole(t *testing.T) {
var meCalls int var meCalls int
var logoutTokens []string
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.URL.Path { switch r.URL.Path {
@@ -601,6 +756,9 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
case "/api/v1/auth/refresh": case "/api/v1/auth/refresh":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`) _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
case "/api/v1/auth/logout": 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":{}}`) _, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default: default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`) _, _ = io.WriteString(w, `{"code":0,"data":{}}`)
@@ -619,6 +777,16 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String()) 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) { func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
@@ -647,9 +815,209 @@ func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) 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) { func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
@@ -682,7 +1050,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
preview.Header.Set("Idempotency-Key", "menu-preview-1") preview.Header.Set("Idempotency-Key", "menu-preview-1")
previewRec := httptest.NewRecorder() previewRec := httptest.NewRecorder()
a.menu(previewRec, preview, false) 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()) 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"}`)) 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") global.Header.Set("Idempotency-Key", "global-menu-preview-1")
globalRec := httptest.NewRecorder() globalRec := httptest.NewRecorder()
a.menuGlobal(globalRec, global, false) 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()) 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) 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") apply.Header.Set("Idempotency-Key", "menu-apply-1")
applyRec := httptest.NewRecorder() applyRec := httptest.NewRecorder()
a.menu(applyRec, apply, true) 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) 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) { func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183") t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) { func TestLifecycleEnableDisableUninstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183") t.Setenv("CORE_VERSION", "0.1.183")
var applied []byte var applied []byte
@@ -1024,6 +1454,9 @@ func TestRecoverCommandPluginAfterRestart(t *testing.T) {
t.Skip("python3 is unavailable") t.Skip("python3 is unavailable")
} }
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1) 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")) 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"}} 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}}} 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" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -31,6 +32,75 @@ var (
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`) 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 // marketplaceEntry is deliberately metadata-only. The browser never receives
// the archive URL; downloads are performed by this server after catalog and // the archive URL; downloads are performed by this server after catalog and
// transport policy validation. // transport policy validation.
@@ -63,6 +133,7 @@ type marketplaceService struct {
remoteURL *url.URL remoteURL *url.URL
allowedHosts map[string]struct{} allowedHosts map[string]struct{}
allowLoopback bool allowLoopback bool
indexSHA256 string
client *http.Client 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") 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 var index marketplaceIndex
decoder := json.NewDecoder(bytes.NewReader(raw)) decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields() 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' const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
await fs.mkdir(outputDir, { recursive: true }) 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 { try {
for (const width of [425, 900, 1440]) { for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 }) 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()) if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {} } 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.goto(entry, { waitUntil: 'networkidle' })
await page.getByLabel('邮箱').fill(email) await page.locator('input[autocomplete="username"]').fill(email)
await page.getByLabel('密码').fill(password) 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('button', { name: '登录' }).click()
await page.getByRole('heading', { name: '已登记插件' }).waitFor() 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 assertViewport = async (label) => {
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)) const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (!buttons) throw new Error(`control collapsed at ${width}px`) 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) await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`) 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 }) 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> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>插件管理</title> <meta name="plugin-base-path" content="__PLUGIN_BASE_PATH__" />
<link rel="stylesheet" href="../styles.css"> <title>插件管理控制面</title>
</head> <script type="module" crossorigin src="./app.js"></script>
<body> <link rel="stylesheet" crossorigin href="./styles.css">
<main class="shell"> </head>
<header class="topbar"> <body>
<div> <div id="app"></div>
<p class="eyebrow">SUB2API EXTENSIONS</p> </body>
<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>
</html> </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' PLUGIN_FRAME_ANCESTORS='self'
# Set true only when the immediate reverse proxy is trusted and supplies XFF. # Set true only when the immediate reverse proxy is trusted and supplies XFF.
PLUGIN_TRUST_PROXY=false 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 # 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 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。 生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
@@ -13,12 +13,12 @@ PLUGIN_PORT=8091 \
go run . 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 范围 ## V1 范围
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。 - 由 Plugin Admin 统一完成 Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。 - 订阅模块复用控制面的 HttpOnly、SameSite 会话和写请求 CSRF 校验,不创建模块级登录会话。
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。 - Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
- 只读套餐、订阅列表、订阅详情和插件操作记录。 - 只读套餐、订阅列表、订阅详情和插件操作记录。
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。 - Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
@@ -28,7 +28,7 @@ go run .
## Core API allowlist ## Core API allowlist
插件服务端仅调用这些明确路径: 插件服务端在 `PLUGIN_STANDALONE_AUTH=true`、`PLUGIN_ENV=development` 且 loopback 的本地兼容模式下仅调用这些明确路径;生产数据访问由 Plugin Admin 同源 BFF 完成:
```text ```text
POST /api/v1/auth/login POST /api/v1/auth/login
@@ -54,13 +54,13 @@ GET /api/v1/admin/users/{id}/subscriptions
{ {
"id": "qiu.subscription-admin", "id": "qiu.subscription-admin",
"label": "订阅管理", "label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/", "url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
"visibility": "admin", "visibility": "admin",
"sort_order": 200 "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`,包内根文件名为 脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式: `manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式:
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写 安装后先独立启动 `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` 放入包内,并将对应公钥 `SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
加入控制面受信发布者配置;未签名包仅限 development + loopback。 加入控制面受信发布者配置;未签名包仅限 development + loopback。
@@ -89,9 +92,23 @@ node --check ui/app.js
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。 `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 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。 - Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。 - Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
@@ -2,7 +2,7 @@
"schema_version": 1, "schema_version": 1,
"plugin_id": "qiu.subscription-admin", "plugin_id": "qiu.subscription-admin",
"name": "Subscription Admin", "name": "Subscription Admin",
"version": "0.1.1", "version": "0.2.0",
"core_api_baseline": "sub2api-0.1.183", "core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"], "capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"], "tested_core_versions": ["0.1.183"],
@@ -37,8 +37,8 @@
"GET /api/v1/admin/users/{id}/subscriptions" "GET /api/v1/admin/users/{id}/subscriptions"
], ],
"files": { "files": {
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697", "ui/index.html": "1c863feae45d0c2b17e7c3f267f0360b5998b796961eb191deb783ea5cc5ebf7",
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca", "ui/app.js": "c6b5742f7926a89df8ddf7a18b60903f235b80ee39d697192a7b57b145f5c126",
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54" "ui/styles.css": "2f0a1848586f66c3df4a54e7b674618788c2309dc8e3068e88a2676a6dcb09e9"
} }
} }
@@ -1,7 +1,7 @@
{ {
"id": "qiu.subscription-admin", "id": "qiu.subscription-admin",
"label": "订阅管理", "label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/", "url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
"visibility": "admin", "visibility": "admin",
"sort_order": 200 "sort_order": 200
} }
@@ -7,16 +7,29 @@ Wants=network-online.target
Type=simple Type=simple
User=sub2api-plugin User=sub2api-plugin
Group=sub2api-plugin Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/subscription-admin WorkingDirectory=/var/lib/sub2api-add/subscription-admin
EnvironmentFile=/etc/sub2api/subscription-admin.env EnvironmentFile=/etc/sub2api-add/subscription-admin.env
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin ExecStart=/opt/sub2api-add/subscription-admin/bin/subscription-admin
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+137 -40
View File
@@ -4,6 +4,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"embed" "embed"
@@ -32,8 +33,9 @@ const (
sessionTTL = 30 * time.Minute sessionTTL = 30 * time.Minute
sessionMaxTTL = 8 * time.Hour sessionMaxTTL = 8 * time.Hour
pendingTTL = 5 * time.Minute pendingTTL = 5 * time.Minute
maxPendingLogins = 1024
pluginID = "qiu.subscription-admin" pluginID = "qiu.subscription-admin"
pluginVersion = "0.1.1" pluginVersion = "0.2.0"
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
maxRequestIDBytes = 64 maxRequestIDBytes = 64
) )
@@ -436,10 +438,33 @@ func token(n int) string {
} }
func decodeJSON(r *http.Request, out any) error { 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() 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() 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) { 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() now := a.clock()
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() 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] attempt := a.loginAttempts[key]
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow { if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
attempt = loginAttempt{started: now} attempt = loginAttempt{started: now}
@@ -493,6 +525,32 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
return true 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 { func (a *app) clientIP(r *http.Request) string {
return trustedClientIPWithConfig(r, a.trustProxy) 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"}) a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
return return
} }
pendingID := token(24) pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
a.mu.Lock() if !accepted {
a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)} w.Header().Set("Retry-After", "60")
a.mu.Unlock() 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}) a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
return return
} }
@@ -760,6 +820,13 @@ func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
} }
} }
if err != nil { 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") a.coreError(w, err, "user lookup failed")
return return
} }
@@ -853,18 +920,25 @@ func (a *app) refreshSession(ctx context.Context, id string, stale session) (ses
return session{}, false return session{}, false
} }
data := envelopeData(refreshed) data := envelopeData(refreshed)
candidateRefresh := current.refreshToken
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
candidateRefresh = nextRefresh
}
access, _ := data["access_token"].(string) access, _ := data["access_token"].(string)
if access == "" { if access == "" {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false 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) nextMe, err := a.core.me(ctx, access, correlationID)
if err != nil || !isAdmin(envelopeData(nextMe)) { if err != nil || !isAdmin(envelopeData(nextMe)) {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false return session{}, false
} }
current.accessToken = access current.accessToken = access
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" { current.refreshToken = candidateRefresh
current.refreshToken = nextRefresh
}
current.user = publicUser(envelopeData(nextMe)) current.user = publicUser(envelopeData(nextMe))
current.lastSeen = a.clock() current.lastSeen = a.clock()
a.mu.Lock() a.mu.Lock()
@@ -995,40 +1069,63 @@ func (a *app) static(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) 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 { func (a *app) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.health) mux.HandleFunc("/healthz", a.health)
mux.HandleFunc("/readyz", a.ready) mux.HandleFunc("/readyz", a.ready)
mux.HandleFunc("/login", a.login) // The business module is mounted by Plugin Admin. A standalone compatibility
mux.HandleFunc("/login/2fa", a.login2FA) // API is available only when explicitly opted into for local contract tests;
mux.HandleFunc("/logout", a.logout) // production deployments leave it disabled so there is no second login,
mux.HandleFunc("/api/me", a.me) // session cookie, or Core-data BFF on the module's own port.
mux.HandleFunc("/api/status", a.status) if standaloneAuthEnabled() {
mux.HandleFunc("/api/captcha-config", a.captchaConfig) mux.HandleFunc("/login", a.login)
mux.HandleFunc("/api/audit", a.auditLog) mux.HandleFunc("/login/2fa", a.login2FA)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans")) mux.HandleFunc("/logout", a.logout)
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions")) mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/status", a.status)
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/") mux.HandleFunc("/api/captcha-config", a.captchaConfig)
if !positiveID(id) || strings.Contains(id, "/") { mux.HandleFunc("/api/audit", a.auditLog)
http.NotFound(w, r) mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
return mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
} mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r) id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
}) if !positiveID(id) || strings.Contains(id, "/") {
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r)
rest := strings.TrimPrefix(r.URL.Path, "/api/users/") return
parts := strings.Split(rest, "/") }
if len(parts) == 1 { a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
a.userProxy(w, r) })
return mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
} rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" { parts := strings.Split(rest, "/")
http.NotFound(w, r) if len(parts) == 1 {
return a.userProxy(w, r)
} return
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r) }
}) 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) mux.HandleFunc("/", a.static)
return requestIDMiddleware(a.securityHeaders(mux)) 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) { func TestSessionExpiry(t *testing.T) {
now := time.Now() now := time.Now()
a := newApp(nil, false) 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) { func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{ for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress", "/api/v1/admin/subscriptions/1/progress",
@@ -299,22 +445,39 @@ func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
} }
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(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()) server := httptest.NewServer(a.routes())
t.Cleanup(server.Close) t.Cleanup(server.Close)
response, err := server.Client().Get(server.URL + "/api/plans") for _, endpoint := range []string{"/login", "/login/2fa", "/logout", "/api/me", "/api/status", "/api/plans", "/api/subscriptions", "/api/users/1"} {
if err != nil { response, err := server.Client().Get(server.URL + endpoint)
t.Fatal(err) 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 { if atomic.LoadInt32(&coreCalls) != 0 {
t.Fatalf("status=%d", response.StatusCode) t.Fatalf("disabled standalone routes called Core %d times", coreCalls)
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("security headers missing: %#v", response.Header)
} }
} }
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) { func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", "development")
var coreRequestID string var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader) 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 { func readBody(t *testing.T, response *http.Response) string {
t.Helper() t.Helper()
defer response.Body.Close() defer response.Body.Close()
+23 -2
View File
@@ -3,6 +3,7 @@ set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"} 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; } command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
TMP=$(mktemp -d) TMP=$(mktemp -d)
cleanup() { 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" "$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
if [ -n "${SIGNATURE_FILE:-}" ]; then 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 fi
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd) OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")" 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" 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 }) 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 { try {
for (const width of [425, 900, 1440]) { for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 }) const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
@@ -21,18 +23,12 @@ try {
} catch (_) {} } catch (_) {}
}) })
await page.goto(entry, { waitUntil: 'domcontentloaded' }) await page.goto(entry, { waitUntil: 'domcontentloaded' })
await page.getByLabel('管理员邮箱').fill('admin@example.com') await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
await page.getByLabel('密码').fill('password') if (await page.locator('input[type="password"], form').count()) throw new Error('subscription module exposed an independent login form')
await page.getByRole('button', { name: '登录' }).click()
await page.locator('#app-view').waitFor({ state: 'visible' })
await page.waitForTimeout(50) await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`) 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.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) const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`) if (overflow) throw new Error(`horizontal overflow at ${width}px`)
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true }) 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 port = Number(process.env.MOCK_CORE_PORT || 18080)
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS' const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH' const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
const captchaProvider = String(process.env.MOCK_CAPTCHA_PROVIDER || '').toLowerCase()
const users = new Map([ const users = new Map([
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }], ['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' }] ['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 input = await body(req)
const user = users.get(input.email) const user = users.get(input.email)
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' }) 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 } }) 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') { 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' } }) 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 === '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 (!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/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') { if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
@@ -8,14 +8,22 @@ cleanup() {
trap cleanup EXIT INT TERM 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=$! 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 \ export CORE_BASE_URL=http://127.0.0.1:18080
PLUGIN_PORT=18082 \ export PLUGIN_HOST=127.0.0.1
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \ export PLUGIN_PORT=18082
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \ export PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
PLUGIN_COOKIE_SECURE=false \ export PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$! 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=$! 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" 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' '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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light">
<title>订阅管理插件</title> <title>订阅管理模块</title>
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css"> <link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
</head> </head>
<body data-base-path="__PLUGIN_BASE_PATH__"> <body data-base-path="__PLUGIN_BASE_PATH__">
<main class="shell"> <main class="handoff-page">
<section id="login-view" class="auth-panel" aria-labelledby="login-title"> <div class="handoff-mark" aria-hidden="true">S</div>
<div class="eyebrow">SUB2API EXTENSION</div> <p class="eyebrow">SUB2API BUSINESS MODULE</p>
<h1 id="login-title">订阅管理</h1> <h1>订阅管理已纳入统一控制面</h1>
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p> <p class="muted">此服务只提供订阅模块的后端能力。管理员请从 Plugin Admin 进入,订阅页面会复用同一个会话,不需要再次登录。</p>
<form id="login-form" class="stack" autocomplete="on"> <a class="primary" href="__PLUGIN_BASE_PATH__/admin/">打开统一控制面</a>
<label> <p class="security-note">Core 凭据由服务端托管,浏览器不会接触访问令牌。</p>
<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> </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> </body>
</html> </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; } :root { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color: #1f2937; background: #f3f6fa; font-synthesis: none; }
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; } body { min-width: 320px; min-height: 100vh; margin: 0; }
body { margin: 0; min-width: 320px; } .handoff-page { width: min(560px, calc(100% - 32px)); min-height: 100vh; margin: 0 auto; display: grid; align-content: center; justify-items: start; gap: 16px; }
button, input, select { font: inherit; } .handoff-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 9px; color: #fff; background: #0052d9; font-size: 22px; font-weight: 700; }
button { cursor: pointer; } .eyebrow { margin: 10px 0 0; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
button:disabled { cursor: wait; opacity: .6; } h1 { max-width: 500px; margin: 0; color: #111827; font-size: clamp(26px, 5vw, 38px); line-height: 1.2; }
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; } .muted { max-width: 510px; margin: 0; color: #687585; font-size: 14px; line-height: 1.75; }
.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%); } .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; }
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } } .primary:hover { background: #003cab; }
h1, h2, h3, p { margin: 0; } .security-note { margin: 8px 0 0; color: #9aa5b4; font-size: 11px; }
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; } @media (max-width: 480px) { .handoff-page { width: calc(100% - 24px); } h1 { font-size: 28px; } }
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; }
}
+39
View File
@@ -46,6 +46,11 @@ done
command -v go >/dev/null 2>&1 || die "缺少 Go;请安装 Go 1.23 或更高版本" command -v go >/dev/null 2>&1 || die "缺少 Go;请安装 Go 1.23 或更高版本"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl" command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
command -v install >/dev/null 2>&1 || die "缺少 install 命令" 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 case "$SELECTION" in
all) PLUGINS=(plugin-admin subscription-admin) ;; all) PLUGINS=(plugin-admin subscription-admin) ;;
@@ -78,6 +83,27 @@ random_secret() {
fi 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() { install_one() {
local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env" local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env"
local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1" local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1"
@@ -125,8 +151,21 @@ Restart=on-failure
RestartSec=3 RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true 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} ReadWritePaths=${data_dir}
[Install] [Install]