chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
Browse files代码追上上游 1.12.0(基线 1b6abaa0 -> 47613d8),其中包含与定时任务直接相关的修复:
fix(scheduler): 槽位等待改为墙钟校验的分段睡眠——修复睡眠冻结 timer 导致的时点顺延
fix(scheduler): 暂停号口径修正
feat(scheduler): 保号任务可覆盖已禁用账号 include_disabled_in_tasks
其余:模型锁池视图、暂停选号 paused、模型级阻塞不再伪装成「没有可用账号」、
上游超时识别止损(不换号/不罚号)、sanitizeMessages 补洗 reasoning 等。
同时带上新增的凭证同步(internal/syncer / cmd/sync)、独立启动器(cmd/launcher)
与 Space 保活工作流。持久化仍走私有 Storage Bucket(/app/storage),不受影响。
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +28 -24
- .github/workflows/go-binaries.yml +156 -0
- .github/workflows/space-keepalive.yml +78 -0
- .gitignore +58 -47
- Dockerfile +42 -39
- LICENSE +22 -22
- README.md +160 -67
- cmd/credit/main.go +294 -294
- cmd/launcher/flags.go +119 -0
- cmd/launcher/main.go +484 -0
- cmd/login/main.go +336 -336
- cmd/login/realm_test.go +169 -169
- cmd/server/config.go +658 -616
- cmd/server/config_test.go +861 -816
- cmd/server/credit_floor_config_test.go +48 -48
- cmd/server/main.go +563 -550
- cmd/server/wiring.go +24 -24
- cmd/signin/main.go +140 -140
- cmd/sync/main.go +438 -0
- cmd/trial/main.go +133 -133
- config.default.json +102 -98
- config.example.json +105 -84
- credit.sh +14 -14
- deploy/cloudflare-worker/.dev.vars.example +24 -0
- deploy/cloudflare-worker/README.md +350 -0
- deploy/cloudflare-worker/dev-server.mjs +113 -0
- deploy/cloudflare-worker/worker.js +347 -0
- deploy/cloudflare-worker/wrangler.toml +81 -0
- docker-compose.yml +15 -15
- docker-entrypoint.sh +69 -69
- go.mod +11 -11
- go.sum +12 -12
- internal/auth/auth.go +378 -378
- internal/auth/auth_test.go +266 -266
- internal/auth/permhint_test.go +29 -29
- internal/auth/realm_test.go +275 -275
- internal/httpauth/httpauth.go +43 -43
- internal/httpauth/httpauth_test.go +61 -61
- internal/livecfg/livecfg.go +48 -48
- internal/logfmt/logfmt.go +166 -166
- internal/logfmt/shortua_test.go +50 -50
- internal/panel/app.js +0 -0
- internal/panel/autotask.go +0 -0
- internal/panel/autotask_lock_test.go +52 -52
- internal/panel/config.go +57 -57
- internal/panel/frontend_test.go +769 -647
- internal/panel/import.go +181 -181
- internal/panel/index.go +61 -61
- internal/panel/index.html +0 -0
- internal/panel/login.go +322 -322
.dockerignore
CHANGED
|
@@ -1,24 +1,28 @@
|
|
| 1 |
-
auths/
|
| 2 |
-
data/
|
| 3 |
-
storage/
|
| 4 |
-
*.tmp
|
| 5 |
-
.git/
|
| 6 |
-
.github/
|
| 7 |
-
.gitignore
|
| 8 |
-
docs/
|
| 9 |
-
scripts/__pycache__/
|
| 10 |
-
# 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐)
|
| 11 |
-
/login
|
| 12 |
-
/credit
|
| 13 |
-
/wb2api
|
| 14 |
-
/
|
| 15 |
-
/wb2api
|
| 16 |
-
/
|
| 17 |
-
/
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
auths/
|
| 2 |
+
data/
|
| 3 |
+
storage/
|
| 4 |
+
*.tmp
|
| 5 |
+
.git/
|
| 6 |
+
.github/
|
| 7 |
+
.gitignore
|
| 8 |
+
docs/
|
| 9 |
+
scripts/__pycache__/
|
| 10 |
+
# 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐)
|
| 11 |
+
/login
|
| 12 |
+
/credit
|
| 13 |
+
/wb2api
|
| 14 |
+
/wb2api-launcher
|
| 15 |
+
/wb2api-sync
|
| 16 |
+
/signin_bin
|
| 17 |
+
/wb2api.exe
|
| 18 |
+
/wb2api-launcher.exe
|
| 19 |
+
/wb2api-sync.exe
|
| 20 |
+
/login.exe
|
| 21 |
+
/credit.exe
|
| 22 |
+
# 真实配置不进镜像;镜像内模板是 config.default.json
|
| 23 |
+
config.json
|
| 24 |
+
PLAN.md
|
| 25 |
+
SPEC.md
|
| 26 |
+
LOOP.md
|
| 27 |
+
README.md
|
| 28 |
+
README.upstream.md
|
.github/workflows/go-binaries.yml
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: go-binaries
|
| 2 |
+
|
| 3 |
+
# 可运行二进制构建/发布:
|
| 4 |
+
# - PR / push main → 测试 + 五平台编译验证(产物仅存 artifact,不发布)
|
| 5 |
+
# - push v* tag → 同上 + 打包上传到 GitHub Release(zip/tar.gz + checksums)
|
| 6 |
+
# 固化的发布纪律(曾两次人祸,见仓库 HANDOFF):-s -w 剥离、二进制版本串 == 源码
|
| 7 |
+
# appVersion、tag 版本 == 源码版本(-ci 后缀的测试 tag 豁免,用于演练发布线)。
|
| 8 |
+
on:
|
| 9 |
+
push:
|
| 10 |
+
branches: [main]
|
| 11 |
+
tags: ["v*"]
|
| 12 |
+
pull_request:
|
| 13 |
+
paths:
|
| 14 |
+
- "go.mod"
|
| 15 |
+
- "go.sum"
|
| 16 |
+
- "cmd/**"
|
| 17 |
+
- "internal/**"
|
| 18 |
+
- "config.example.json"
|
| 19 |
+
- ".github/workflows/go-binaries.yml"
|
| 20 |
+
workflow_dispatch:
|
| 21 |
+
|
| 22 |
+
concurrency:
|
| 23 |
+
group: ${{ github.workflow }}-${{ github.ref }}
|
| 24 |
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
| 25 |
+
|
| 26 |
+
permissions:
|
| 27 |
+
contents: read
|
| 28 |
+
|
| 29 |
+
jobs:
|
| 30 |
+
test:
|
| 31 |
+
runs-on: ubuntu-latest
|
| 32 |
+
steps:
|
| 33 |
+
- uses: actions/checkout@v4
|
| 34 |
+
- uses: actions/setup-go@v5
|
| 35 |
+
with:
|
| 36 |
+
go-version-file: go.mod
|
| 37 |
+
# ubuntu runner 自带 node → panel 包的 JS 运行时冒烟测试(TestAppJSTopLevelSmoke)会真实执行
|
| 38 |
+
- run: go test ./...
|
| 39 |
+
|
| 40 |
+
build:
|
| 41 |
+
needs: test
|
| 42 |
+
runs-on: ubuntu-latest
|
| 43 |
+
strategy:
|
| 44 |
+
fail-fast: false
|
| 45 |
+
matrix:
|
| 46 |
+
include:
|
| 47 |
+
- { goos: windows, goarch: amd64, ext: .zip }
|
| 48 |
+
- { goos: linux, goarch: amd64, ext: .tar.gz }
|
| 49 |
+
- { goos: linux, goarch: arm64, ext: .tar.gz }
|
| 50 |
+
- { goos: darwin, goarch: amd64, ext: .tar.gz }
|
| 51 |
+
- { goos: darwin, goarch: arm64, ext: .tar.gz }
|
| 52 |
+
steps:
|
| 53 |
+
- uses: actions/checkout@v4
|
| 54 |
+
- uses: actions/setup-go@v5
|
| 55 |
+
with:
|
| 56 |
+
go-version-file: go.mod
|
| 57 |
+
|
| 58 |
+
- name: 取源码版本号
|
| 59 |
+
id: ver
|
| 60 |
+
run: echo "v=$(grep -oP 'appVersion = "\K[^"]+' cmd/server/main.go)" >> "$GITHUB_OUTPUT"
|
| 61 |
+
|
| 62 |
+
- name: 构建(剥离符号,对齐本地发布口径)
|
| 63 |
+
env:
|
| 64 |
+
GOOS: ${{ matrix.goos }}
|
| 65 |
+
GOARCH: ${{ matrix.goarch }}
|
| 66 |
+
CGO_ENABLED: 0
|
| 67 |
+
run: go build -ldflags "-s -w" -o "wb2api${{ matrix.goos == 'windows' && '.exe' || '' }}" ./cmd/server
|
| 68 |
+
|
| 69 |
+
# 启动器单独构建:它是**独立程序**,作用是把新版的 wb2api 二进制换进去就能升级,
|
| 70 |
+
# 启动器本身不用动。所以刻意不参与上面的版本串断言(它有自己独立的版本号)。
|
| 71 |
+
- name: 构建启动器(独立,不参与网关版本串断言)
|
| 72 |
+
env:
|
| 73 |
+
GOOS: ${{ matrix.goos }}
|
| 74 |
+
GOARCH: ${{ matrix.goarch }}
|
| 75 |
+
CGO_ENABLED: 0
|
| 76 |
+
run: |
|
| 77 |
+
LEXT="${{ matrix.goos == 'windows' && '.exe' || '' }}"
|
| 78 |
+
go build -ldflags "-s -w" -o "wb2api-launcher${LEXT}" ./cmd/launcher
|
| 79 |
+
ls -la "wb2api-launcher${LEXT}"
|
| 80 |
+
|
| 81 |
+
# 版本串断言:二进制必须含源码 appVersion(防交叉编译矩阵里编错树/漏 ldflags)
|
| 82 |
+
- name: 断言二进制版本串
|
| 83 |
+
run: |
|
| 84 |
+
BIN="wb2api${{ matrix.goos == 'windows' && '.exe' || '' }}"
|
| 85 |
+
if ! grep -qa "${{ steps.ver.outputs.v }}" "$BIN"; then
|
| 86 |
+
echo "::error::binary missing version string ${{ steps.ver.outputs.v }}"
|
| 87 |
+
exit 1
|
| 88 |
+
fi
|
| 89 |
+
echo "version assert OK: ${{ steps.ver.outputs.v }}"
|
| 90 |
+
|
| 91 |
+
- name: 打包(windows=zip 三件套,其余=tar.gz)
|
| 92 |
+
run: |
|
| 93 |
+
V="${{ steps.ver.outputs.v }}"
|
| 94 |
+
V="${V%-panel}" # 文件名不带 -panel 后缀(wb2api-panel-v1.2.3-... 既有惯例)
|
| 95 |
+
case "${{ matrix.goos }}" in
|
| 96 |
+
windows)
|
| 97 |
+
NAME="wb2api-panel-v${V}-windows-amd64.zip"
|
| 98 |
+
zip -q "$NAME" wb2api.exe wb2api-launcher.exe config.example.json README.md
|
| 99 |
+
;;
|
| 100 |
+
*)
|
| 101 |
+
NAME="wb2api-panel-v${V}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
|
| 102 |
+
tar czf "$NAME" wb2api wb2api-launcher config.example.json README.md
|
| 103 |
+
;;
|
| 104 |
+
esac
|
| 105 |
+
echo "name=$NAME" >> "$GITHUB_ENV"
|
| 106 |
+
|
| 107 |
+
- uses: actions/upload-artifact@v4
|
| 108 |
+
with:
|
| 109 |
+
name: ${{ env.name }}
|
| 110 |
+
path: ${{ env.name }}
|
| 111 |
+
retention-days: 7
|
| 112 |
+
|
| 113 |
+
# 仅 tag 触发:校验 tag == 源码版本(-ci 演练 tag 豁免)→ 汇总资产 → 建 Release
|
| 114 |
+
publish:
|
| 115 |
+
needs: build
|
| 116 |
+
if: startsWith(github.ref, 'refs/tags/v')
|
| 117 |
+
runs-on: ubuntu-latest
|
| 118 |
+
permissions:
|
| 119 |
+
contents: write
|
| 120 |
+
steps:
|
| 121 |
+
- uses: actions/checkout@v4
|
| 122 |
+
with:
|
| 123 |
+
fetch-depth: 0 # 取历史 tag 算 compare 链接
|
| 124 |
+
- name: tag 与源码版本一致性断言
|
| 125 |
+
run: |
|
| 126 |
+
SRC=$(grep -oP 'appVersion = "\K[^"]+' cmd/server/main.go)
|
| 127 |
+
SRC="${SRC%-panel}" # appVersion 带 -panel 后缀,比对前剥掉
|
| 128 |
+
TAG="${GITHUB_REF_NAME#v}"
|
| 129 |
+
if [[ "$GITHUB_REF_NAME" != *-ci ]]; then
|
| 130 |
+
if [[ "$TAG" != "$SRC" ]]; then
|
| 131 |
+
echo "::error::tag $GITHUB_REF_NAME 与源码 appVersion=$SRC 不一致(先提交版本号再打 tag)"
|
| 132 |
+
exit 1
|
| 133 |
+
fi
|
| 134 |
+
else
|
| 135 |
+
echo "演练 tag(-ci 后缀)跳过一致性断言:tag=$TAG src=$SRC"
|
| 136 |
+
fi
|
| 137 |
+
- uses: actions/download-artifact@v4
|
| 138 |
+
with:
|
| 139 |
+
path: dist
|
| 140 |
+
merge-multiple: true
|
| 141 |
+
- name: checksums
|
| 142 |
+
run: cd dist && sha256sum *.zip *.tar.gz > checksums.txt && ls -la
|
| 143 |
+
- name: 上一版 tag(compare 链接用)
|
| 144 |
+
id: prev
|
| 145 |
+
run: |
|
| 146 |
+
PREV=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)
|
| 147 |
+
echo "prev=${PREV}" >> "$GITHUB_OUTPUT"
|
| 148 |
+
- uses: softprops/action-gh-release@v2
|
| 149 |
+
with:
|
| 150 |
+
name: ${{ github.ref_name }}-panel
|
| 151 |
+
prerelease: ${{ contains(github.ref_name, '-') }}
|
| 152 |
+
body: |
|
| 153 |
+
CI 自动构建发布(五平台二进制 + checksums)。
|
| 154 |
+
|
| 155 |
+
完整变更:https://github.com/${{ github.repository }}/compare/${{ steps.prev.outputs.prev }}...${{ github.ref_name }}
|
| 156 |
+
files: dist/*
|
.github/workflows/space-keepalive.yml
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: hf-space-keepalive
|
| 2 |
+
|
| 3 |
+
# 作用:定时给 Hugging Face Space 打一次 /healthz,阻止它因「48 小时无 HTTP 流量」被暂停。
|
| 4 |
+
# 为什么需要它:Space 一被暂停,它里面的定时任务(签到 / 活跃 / 保活)就全部停摆,
|
| 5 |
+
# 而唤醒要等 30~60 秒冷启动。
|
| 6 |
+
#
|
| 7 |
+
# 为什么用 GitHub Actions 而不是 Cloudflare Cron:
|
| 8 |
+
# Cloudflare 免费版**每个账号只有 5 个 cron 触发器**。如果那 5 个已经被别的 Worker 占了,
|
| 9 |
+
# 再给本 Worker 加 cron 会直接报 `code: 10072`(本仓库实测踩过)。GH Actions 没有这个限制。
|
| 10 |
+
#
|
| 11 |
+
# 为什么 6 小时一次就够:HF 的判定阈值是 **48 小时**,所以哪怕 GitHub 的定时任务
|
| 12 |
+
# 延迟几十分钟(高峰期常见)也毫无影响。刻意避开整点(:17)以减少排队。
|
| 13 |
+
#
|
| 14 |
+
# ⚠️⚠️ 必须打 Space 的**直连域名**(*.hf.space),**不要**改成公网入口
|
| 15 |
+
# cli.3216gemini.dpdns.org —— 实测那个入口的 `/healthz` 是 **Cloudflare 自己
|
| 16 |
+
# 应答的 `ok`**(响应带 cf-ray,但既没有网关的 `x-service`,也没有 HF 的
|
| 17 |
+
# `link: …/spaces/a3216/gcli2api` 头),请求根本没到 Space。拿它做保活
|
| 18 |
+
# 等于白 ping:返回 200 看着很健康,Space 却照样被暂停。
|
| 19 |
+
# (该入口只在 `/healthz` 上做了短路,`/status`、`/panel/*` 等仍是透传到 Space 的。)
|
| 20 |
+
#
|
| 21 |
+
# ⚠️ 两个 GitHub 侧的限制,知道一下就好:
|
| 22 |
+
# 1. 定时工作流只在**默认分支**上生效(所以这个文件要合进 main)。
|
| 23 |
+
# 2. 仓库连续 60 天没有任何提交/活动,GitHub 会自动停用定时工作流并给你发邮件;
|
| 24 |
+
# 到时在 Actions 页面点一下 "Enable workflow" 即可(或随便提交一次)。
|
| 25 |
+
|
| 26 |
+
on:
|
| 27 |
+
schedule:
|
| 28 |
+
- cron: "17 */6 * * *"
|
| 29 |
+
workflow_dispatch: # 也支持手动跑一次(Actions 页面 → Run workflow)
|
| 30 |
+
|
| 31 |
+
permissions: {}
|
| 32 |
+
|
| 33 |
+
concurrency:
|
| 34 |
+
group: hf-space-keepalive
|
| 35 |
+
cancel-in-progress: false
|
| 36 |
+
|
| 37 |
+
jobs:
|
| 38 |
+
ping:
|
| 39 |
+
runs-on: ubuntu-latest
|
| 40 |
+
timeout-minutes: 10
|
| 41 |
+
steps:
|
| 42 |
+
- name: 唤醒 / 保活 HF Space
|
| 43 |
+
run: |
|
| 44 |
+
set -uo pipefail
|
| 45 |
+
|
| 46 |
+
# 直连 Space 的域名(*.hf.space)。**不要**改成 cli.3216gemini.dpdns.org:
|
| 47 |
+
# 那个入口的 /healthz 由 Cloudflare 自己回 "ok",请求到不了 Space,
|
| 48 |
+
# 拿它保活是无效的(详见文件头注释)。
|
| 49 |
+
# 想顺带把公网入口也探一遍,可以加进来 —— 但它只证明边缘在,不证明 Space 醒着。
|
| 50 |
+
URLS=(
|
| 51 |
+
"https://a3216-gcli2api.hf.space/healthz"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
fail=0
|
| 55 |
+
for url in "${URLS[@]}"; do
|
| 56 |
+
echo "::group::$url"
|
| 57 |
+
ok=0
|
| 58 |
+
# 冷启动可能要 30~60s,给足超时并重试三次。
|
| 59 |
+
for attempt in 1 2 3; do
|
| 60 |
+
code=$(curl -s -o /dev/null -w '%{http_code}' -m 90 "$url" || echo "000")
|
| 61 |
+
echo "第 ${attempt} 次: HTTP ${code}"
|
| 62 |
+
# 关键判据:只要拿到了**任何** HTTP 状态码,就说明 Space 是醒着的。
|
| 63 |
+
# 503 是网关「当前没有可用账号可服务」的业务语义,对保活而言完全正常。
|
| 64 |
+
# 只有 000(连不上/超时)才算这次没打到。
|
| 65 |
+
if [ "$code" != "000" ]; then
|
| 66 |
+
ok=1
|
| 67 |
+
break
|
| 68 |
+
fi
|
| 69 |
+
sleep 20
|
| 70 |
+
done
|
| 71 |
+
echo "::endgroup::"
|
| 72 |
+
if [ "$ok" -ne 1 ]; then
|
| 73 |
+
echo "::error::$url 三次都拿不到响应(Space 可能真的挂了,或正在长时间冷启动)"
|
| 74 |
+
fail=1
|
| 75 |
+
fi
|
| 76 |
+
done
|
| 77 |
+
|
| 78 |
+
exit "$fail"
|
.gitignore
CHANGED
|
@@ -1,47 +1,58 @@
|
|
| 1 |
-
# Auth & secrets
|
| 2 |
-
auths/
|
| 3 |
-
data/
|
| 4 |
-
*.key
|
| 5 |
-
*.pem
|
| 6 |
-
*.env
|
| 7 |
-
.env
|
| 8 |
-
.env.*
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
/
|
| 15 |
-
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
.
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
#
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
/
|
| 41 |
-
|
| 42 |
-
/
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
/
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auth & secrets
|
| 2 |
+
auths/
|
| 3 |
+
data/
|
| 4 |
+
*.key
|
| 5 |
+
*.pem
|
| 6 |
+
*.env
|
| 7 |
+
.env
|
| 8 |
+
.env.*
|
| 9 |
+
# wrangler 本地变量(Cloudflare Worker 的 api_key 等,绝不进版本库)
|
| 10 |
+
.dev.vars
|
| 11 |
+
.dev.vars.*
|
| 12 |
+
# ……但模板必须能进版本库(里面只有占位符,没有真密钥)
|
| 13 |
+
!.dev.vars.example
|
| 14 |
+
.wrangler/
|
| 15 |
+
|
| 16 |
+
# Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/)
|
| 17 |
+
/login
|
| 18 |
+
/credit
|
| 19 |
+
/wb2api
|
| 20 |
+
/wb2api-launcher
|
| 21 |
+
/wb2api-sync
|
| 22 |
+
/signin_bin
|
| 23 |
+
|
| 24 |
+
# Config with secrets (use config.example.json)
|
| 25 |
+
config.json
|
| 26 |
+
|
| 27 |
+
# Build artifacts
|
| 28 |
+
*.tmp
|
| 29 |
+
__pycache__/
|
| 30 |
+
|
| 31 |
+
# Backups (contains auth tokens)
|
| 32 |
+
backups/
|
| 33 |
+
|
| 34 |
+
# IDE
|
| 35 |
+
.idea/
|
| 36 |
+
.vscode/
|
| 37 |
+
LOOP.md
|
| 38 |
+
PLAN.md
|
| 39 |
+
SPEC.md
|
| 40 |
+
docs/
|
| 41 |
+
|
| 42 |
+
# 设计/计划文档(但 README.md 与 go:embed 的内置提示词必须进版本库)
|
| 43 |
+
*.md
|
| 44 |
+
!README.md
|
| 45 |
+
!internal/prompt/defaultprompt.md
|
| 46 |
+
|
| 47 |
+
# Windows binaries
|
| 48 |
+
/wb2api.exe
|
| 49 |
+
/wb2api-launcher.exe
|
| 50 |
+
/wb2api-sync.exe
|
| 51 |
+
/login.exe
|
| 52 |
+
/credit.exe
|
| 53 |
+
|
| 54 |
+
# 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md)
|
| 55 |
+
/cmd/desktopverify*/
|
| 56 |
+
*.zip
|
| 57 |
+
server.exe
|
| 58 |
+
.DS_Store
|
Dockerfile
CHANGED
|
@@ -1,39 +1,42 @@
|
|
| 1 |
-
# syntax=docker/dockerfile:1
|
| 2 |
-
# ---------------------------------------------------------------- build stage
|
| 3 |
-
FROM golang:1.23-alpine AS build
|
| 4 |
-
WORKDIR /src
|
| 5 |
-
COPY go.mod go.sum ./
|
| 6 |
-
RUN go mod download
|
| 7 |
-
COPY . .
|
| 8 |
-
# 全静态二进制:镜像里直接跑脚本,无需 libc。
|
| 9 |
-
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \
|
| 10 |
-
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/
|
| 11 |
-
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/
|
| 12 |
-
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
COPY --from=build /out/
|
| 24 |
-
|
| 25 |
-
COPY --from=build /out/
|
| 26 |
-
COPY
|
| 27 |
-
COPY
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
COPY
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# ---------------------------------------------------------------- build stage
|
| 3 |
+
FROM golang:1.23-alpine AS build
|
| 4 |
+
WORKDIR /src
|
| 5 |
+
COPY go.mod go.sum ./
|
| 6 |
+
RUN go mod download
|
| 7 |
+
COPY . .
|
| 8 |
+
# 全静态二进制:镜像里直接跑脚本,无需 libc。
|
| 9 |
+
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \
|
| 10 |
+
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api-launcher ./cmd/launcher \
|
| 11 |
+
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/signin_bin ./cmd/signin \
|
| 12 |
+
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \
|
| 13 |
+
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit
|
| 14 |
+
|
| 15 |
+
# -------------------------------------------------------------- runtime stage
|
| 16 |
+
FROM alpine:3.20
|
| 17 |
+
# python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。
|
| 18 |
+
RUN apk add --no-cache wget ca-certificates tzdata python3 bash \
|
| 19 |
+
&& mkdir -p /app/auths /app/data
|
| 20 |
+
ENV TZ=Asia/Shanghai
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
COPY --from=build /out/wb2api /app/wb2api
|
| 24 |
+
# 独立启动器:本地/容器里都能「先同步凭证、再拉起网关」,且换 wb2api 版本时它不用重编。
|
| 25 |
+
COPY --from=build /out/wb2api-launcher /app/wb2api-launcher
|
| 26 |
+
COPY --from=build /out/signin_bin /app/signin_bin
|
| 27 |
+
COPY --from=build /out/login /app/login
|
| 28 |
+
COPY --from=build /out/credit /app/credit
|
| 29 |
+
COPY login.sh signin.sh credit.sh /app/
|
| 30 |
+
COPY scripts/probe_active.py /app/scripts/probe_active.py
|
| 31 |
+
# config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。
|
| 32 |
+
# 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。
|
| 33 |
+
COPY config.default.json /app/config.json
|
| 34 |
+
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
| 35 |
+
RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \
|
| 36 |
+
&& chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh
|
| 37 |
+
|
| 38 |
+
# 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。
|
| 39 |
+
EXPOSE 7861
|
| 40 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
| 41 |
+
CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1
|
| 42 |
+
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
LICENSE
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
-
MIT License
|
| 2 |
-
|
| 3 |
-
Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api)
|
| 4 |
-
Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel)
|
| 5 |
-
|
| 6 |
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
-
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
-
in the Software without restriction, including without limitation the rights
|
| 9 |
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
-
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
-
furnished to do so, subject to the following conditions:
|
| 12 |
-
|
| 13 |
-
The above copyright notice and this permission notice shall be included in all
|
| 14 |
-
copies or substantial portions of the Software.
|
| 15 |
-
|
| 16 |
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
-
SOFTWARE.
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api)
|
| 4 |
+
Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel)
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,67 +1,160 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: WorkBuddy2API Panel
|
| 3 |
-
emoji: 🧩
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: blue
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7861
|
| 8 |
-
pinned: false
|
| 9 |
-
short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶
|
| 10 |
-
---
|
| 11 |
-
|
| 12 |
-
# WorkBuddy2API Panel — Hugging Face Spaces 部署
|
| 13 |
-
|
| 14 |
-
把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游:
|
| 15 |
-
[linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。
|
| 16 |
-
本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。
|
| 17 |
-
|
| 18 |
-
## 这个分支改了什么
|
| 19 |
-
|
| 20 |
-
Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。
|
| 21 |
-
原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。
|
| 22 |
-
|
| 23 |
-
本分支的做法:
|
| 24 |
-
|
| 25 |
-
- 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写);
|
| 26 |
-
- `docker-entrypoint.sh` 把运行配置指到卷上:
|
| 27 |
-
- `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有)
|
| 28 |
-
- `/app/storage/auths/*.json` — 账号凭证
|
| 29 |
-
- `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档
|
| 30 |
-
- 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的);
|
| 31 |
-
- **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。
|
| 32 |
-
|
| 33 |
-
## 一次性配置(已经做完,此处仅为记录)
|
| 34 |
-
|
| 35 |
-
```python
|
| 36 |
-
from huggingface_hub import HfApi, Volume
|
| 37 |
-
api = HfApi()
|
| 38 |
-
api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True)
|
| 39 |
-
api.set_space_volumes(
|
| 40 |
-
"a3216/gcli2api",
|
| 41 |
-
volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")],
|
| 42 |
-
)
|
| 43 |
-
```
|
| 44 |
-
|
| 45 |
-
`set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。
|
| 46 |
-
|
| 47 |
-
##
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
-
|
| 59 |
-
-
|
| 60 |
-
|
| 61 |
-
-
|
| 62 |
-
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: WorkBuddy2API Panel
|
| 3 |
+
emoji: 🧩
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7861
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# WorkBuddy2API Panel — Hugging Face Spaces 部署
|
| 13 |
+
|
| 14 |
+
把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游:
|
| 15 |
+
[linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。
|
| 16 |
+
本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。
|
| 17 |
+
|
| 18 |
+
## 这个分支改了什么
|
| 19 |
+
|
| 20 |
+
Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。
|
| 21 |
+
原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。
|
| 22 |
+
|
| 23 |
+
本分支的做法:
|
| 24 |
+
|
| 25 |
+
- 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写);
|
| 26 |
+
- `docker-entrypoint.sh` 把运行配置指到卷上:
|
| 27 |
+
- `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有)
|
| 28 |
+
- `/app/storage/auths/*.json` — 账号凭证
|
| 29 |
+
- `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档
|
| 30 |
+
- 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的);
|
| 31 |
+
- **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。
|
| 32 |
+
|
| 33 |
+
## 一次性配置(已经做完,此处仅为记录)
|
| 34 |
+
|
| 35 |
+
```python
|
| 36 |
+
from huggingface_hub import HfApi, Volume
|
| 37 |
+
api = HfApi()
|
| 38 |
+
api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True)
|
| 39 |
+
api.set_space_volumes(
|
| 40 |
+
"a3216/gcli2api",
|
| 41 |
+
volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")],
|
| 42 |
+
)
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
`set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。
|
| 46 |
+
|
| 47 |
+
## 本地 ↔ 桶:凭证同步与独立启动器
|
| 48 |
+
|
| 49 |
+
**要解决的问题**:同一批账号常常要两处跑 —— 本地机器给工具提供低延迟 API,Space 负责定时签到。
|
| 50 |
+
两边各自维护 `auths/` 就会不同步:本地新加的号 Space 不知道(签到漏掉),
|
| 51 |
+
Space 刷新过的 token 本地还在用旧的;最糟的是两边**同时** refresh 同一个账号,
|
| 52 |
+
refreshToken 轮换后只有一方拿到新的,另一方直接掉线。
|
| 53 |
+
|
| 54 |
+
`wb2api-launcher` 就是为此而生:**先同步、后启动**。
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
# 独立 exe:不需要 Go / Python / PowerShell
|
| 58 |
+
wb2api-launcher # 同步 → 拉起网关 → 后台每 5 分钟对账
|
| 59 |
+
wb2api-launcher -status # 只看两边差异,不启动网关(有差异退出码 2)
|
| 60 |
+
wb2api-launcher -sync-only # 同步一次就退出
|
| 61 |
+
wb2api-launcher -no-sync # 离线启动(用本地已有凭证)
|
| 62 |
+
wb2api-launcher -h # 全部选项
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
它是个**独立程序**:与网关之间只有两个契约 ——「`wb2api -config <路径>` 能被拉起来」
|
| 66 |
+
和 `config.json` 里那几个键,不引用网关内部实现。所以**上游更新时你只需要换 `wb2api`,
|
| 67 |
+
启动器不用动**;同步引擎直接编在里面,也不需要第二个二进制。
|
| 68 |
+
`start.ps1` / `start.sh` 只是「没有二进制就 `go build`,然后 exec 启动器」的引导壳。
|
| 69 |
+
|
| 70 |
+
配置见 `config.example.json` 的 `sync` 段:
|
| 71 |
+
|
| 72 |
+
```json
|
| 73 |
+
"sync": {
|
| 74 |
+
"bucket": "a3216/wb2api-storage",
|
| 75 |
+
"token_file": "./data/hf_token.txt",
|
| 76 |
+
"writer": "auto"
|
| 77 |
+
}
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
两个方向的通道**刻意不同**(实测结论,不是设计偏好):
|
| 81 |
+
|
| 82 |
+
| 方向 | 通道 | 为什么 |
|
| 83 |
+
| --- | --- | --- |
|
| 84 |
+
| **读** | HF Hub REST(纯 HTTPS) | 不触发 Space 冷启动,它休眠/暂停时照样能拉 —— 启动那一次同步必须快 |
|
| 85 |
+
| **写** | 官方 `hf` CLI | Bucket 上传必须先做 **Xet 内容寻址上传**,拿文件 SHA-256 当 `xetHash` 会被服务端拒绝(`File not found in Xet storage`)。该协议不适合在 Go 里重造 |
|
| 86 |
+
|
| 87 |
+
冲突裁决(两端都有且内容不同时,按优先级):能被网关解析的一方赢 → **`expiresAt` 更晚的一方赢**
|
| 88 |
+
("谁刷新过用谁的",这条直接治凭证不同步)→ refreshToken 非空的一方赢 → mtime 更晚的一方赢 →
|
| 89 |
+
全平取本端(保证裁决确定性,否则两端会互相覆盖成无限拉锯)。
|
| 90 |
+
刻意**不做字段级合并**:accessToken 与 refreshToken 必须成对来自同一次刷新,拼起来只会造出永远刷不动的凭证。
|
| 91 |
+
也**不会删除账号** —— 删除只能在面板显式操作。
|
| 92 |
+
|
| 93 |
+
> ⚠️ **定时任务只能由一边跑。** 两边同时开签到/保活会互抢 refreshToken(轮换后只有一方拿到新的,
|
| 94 |
+
> 另一方直接掉线),这是同步工具救不了的**并发写冲突**。推荐分工:**Space 跑定时任务,本地只做 API** ——
|
| 95 |
+
> 把本地 `schedule.*_enabled` 全设 `false` 即可,网关处理请求时本来就会按需刷新 token,不影响可用性。
|
| 96 |
+
>
|
| 97 |
+
> ⚠️ **手动禁用 / 暂停不会被同步。** `disabled` / `paused` 是每端各自的运维意图,不属于凭证。
|
| 98 |
+
> 要两边一致请分别在两端面板里设置;也不要靠 `-include-data state.json` 去"抹平"它
|
| 99 |
+
> (那会把对端的冷却/熔断状态一起搬过来)。
|
| 100 |
+
|
| 101 |
+
## 保活:别让 Space 睡过去
|
| 102 |
+
|
| 103 |
+
cpu-basic 免费硬件**连续 48 小时没有任何 HTTP 流量就被暂停**,暂停期间网关的定时任务
|
| 104 |
+
(签到 / 领积分)不会执行。给它一个稳定的定时流量即可。
|
| 105 |
+
|
| 106 |
+
### 方案 A:GitHub Actions(推荐,不占 CF 配额)
|
| 107 |
+
|
| 108 |
+
仓库自带 `.github/workflows/space-keepalive.yml`:每 6 小时 ping 一次 Space 的 `/healthz`。
|
| 109 |
+
|
| 110 |
+
**为什么 6 小时就够**:HF 的判定阈值是 **48 小时**,所以有 8 倍余量;GitHub 定时任务
|
| 111 |
+
偶发延迟几十分钟毫无影响。它把**任何 HTTP 状态码都算成功**——`503` 是"当前没有可用账号"
|
| 112 |
+
的业务语义,但 Space 是醒着的,保活目的已经达到;只有连不上(`000`)才需要关注。
|
| 113 |
+
|
| 114 |
+
定时工作流只在**默认分支**生效;仓库连续 60 天无活动时 GitHub 会自动停用它
|
| 115 |
+
(Actions 页面点一下重新启用,或随便提交一次)。
|
| 116 |
+
|
| 117 |
+
> ⚠️ **保活必须打 Space 的直连域名(`*.hf.space`),不能打公网入口 `cli.3216gemini.dpdns.org`。**
|
| 118 |
+
> 实测那个入口的 `/healthz` 是 **Cloudflare 自己应答的 `ok`**
|
| 119 |
+
> (响应带 `cf-ray`,但既没有网关的 `x-service`,也没有 HF 的 `link: …/spaces/a3216/gcli2api` 头),
|
| 120 |
+
> **请求根本没到 Space** —— 拿它做保活会稳定返回 200、看着很健康,而 Space 照样被暂停。
|
| 121 |
+
> 该入口只在 `/healthz` 上短路,`/status`、`/panel/*` 仍是透传的。
|
| 122 |
+
> 工作流里因此写死直连域名并注明了���因,**别"顺手优化"掉**。
|
| 123 |
+
|
| 124 |
+
### 方案 B:Cloudflare Worker + Cron
|
| 125 |
+
|
| 126 |
+
`deploy/cloudflare-worker/` 是一份单文件 Worker:反代 `cli.3216gemini.dpdns.org/*`
|
| 127 |
+
(响应体流式回传,不破坏 SSE)+ `scheduled()` 保活。部署前可以先用
|
| 128 |
+
`node dev-server.mjs` 在本地把它验一遍(该脚本原样 import 同一个 `worker.js`,转发到真实 Space)。
|
| 129 |
+
|
| 130 |
+
> ⚠️ **Cloudflare 免费版的 cron 限制是「每个账号 5 个」,不是每个 Worker 5 个。**
|
| 131 |
+
> 名额满了 `wrangler deploy` 会给出**半成功**结果:`Uploaded` / `Deployed` 都成功,但紧接着
|
| 132 |
+
> `✘ [ERROR] ... reached the Workers Free limit of 5 cron triggers per account [code: 10072]`
|
| 133 |
+
> —— Worker 代码上线了、cron 没加上,于是它**空转,谁也唤不醒**。要么腾一个名额,要么用方案 A。
|
| 134 |
+
>
|
| 135 |
+
> 另外:`wrangler deploy` 打印的 `*.workers.dev` 地址在国内常因 DNS 污染而连不上,
|
| 136 |
+
> 这不代表部署失败;`wrangler tail` 若报 400,是它走的 WebSocket 被本地抓包代理挡了,
|
| 137 |
+
> 换 Dashboard → Worker → Logs 看即可。
|
| 138 |
+
|
| 139 |
+
## 环境变量
|
| 140 |
+
|
| 141 |
+
| 变量 | 作用 |
|
| 142 |
+
| --- | --- |
|
| 143 |
+
| `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) |
|
| 144 |
+
| `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` |
|
| 145 |
+
| `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README |
|
| 146 |
+
|
| 147 |
+
## 运维
|
| 148 |
+
|
| 149 |
+
- **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。
|
| 150 |
+
- **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。
|
| 151 |
+
- **备份**:桶本身就是备份。本地拉一份:
|
| 152 |
+
`hf buckets sync hf://buckets/a3216/wb2api-storage ./backup`
|
| 153 |
+
- **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。
|
| 154 |
+
- **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。
|
| 155 |
+
唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。
|
| 156 |
+
→ **保活方案见 [保活:别让 Space 睡过去](#保活别让-space-睡过去)。**
|
| 157 |
+
|
| 158 |
+
## 说明
|
| 159 |
+
|
| 160 |
+
上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。
|
cmd/credit/main.go
CHANGED
|
@@ -1,294 +1,294 @@
|
|
| 1 |
-
// credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。
|
| 2 |
-
//
|
| 3 |
-
// 用法:
|
| 4 |
-
//
|
| 5 |
-
// go run ./cmd/credit # 或编译后 ./credit
|
| 6 |
-
//
|
| 7 |
-
// 输出结构:
|
| 8 |
-
//
|
| 9 |
-
// {"service":"workbuddy","ts":N,
|
| 10 |
-
// "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N},
|
| 11 |
-
// "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]}
|
| 12 |
-
//
|
| 13 |
-
// 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的
|
| 14 |
-
// Cycle* 字段,TotalDosage 作 size 下限。
|
| 15 |
-
package main
|
| 16 |
-
|
| 17 |
-
import (
|
| 18 |
-
"bytes"
|
| 19 |
-
"encoding/json"
|
| 20 |
-
"fmt"
|
| 21 |
-
"net/http"
|
| 22 |
-
"os"
|
| 23 |
-
"path/filepath"
|
| 24 |
-
"sort"
|
| 25 |
-
"strings"
|
| 26 |
-
"time"
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
const billingBaseCN = "https://www.codebuddy.cn"
|
| 30 |
-
|
| 31 |
-
// billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401:
|
| 32 |
-
// www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。
|
| 33 |
-
const billingBaseGlobal = "https://www.workbuddy.ai"
|
| 34 |
-
|
| 35 |
-
// billingBaseFor 按账号 realm 选择计费域。
|
| 36 |
-
//
|
| 37 |
-
// 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在
|
| 38 |
-
// workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域,
|
| 39 |
-
// 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。
|
| 40 |
-
func billingBaseFor(af *authFile) string {
|
| 41 |
-
if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") {
|
| 42 |
-
return billingBaseGlobal
|
| 43 |
-
}
|
| 44 |
-
d := strings.ToLower(strings.TrimSpace(af.Auth.Domain))
|
| 45 |
-
if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") {
|
| 46 |
-
return billingBaseGlobal
|
| 47 |
-
}
|
| 48 |
-
return billingBaseCN
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
type authFile struct {
|
| 52 |
-
Auth struct {
|
| 53 |
-
AccessToken string `json:"accessToken"`
|
| 54 |
-
Domain string `json:"domain"`
|
| 55 |
-
Realm string `json:"realm"`
|
| 56 |
-
} `json:"auth"`
|
| 57 |
-
Account struct {
|
| 58 |
-
UID string `json:"uid"`
|
| 59 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 60 |
-
Nickname string `json:"nickname"`
|
| 61 |
-
} `json:"account"`
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
type accountResult struct {
|
| 65 |
-
UID string `json:"uid"`
|
| 66 |
-
Nickname string `json:"nickname"`
|
| 67 |
-
Remain *int64 `json:"remain"`
|
| 68 |
-
Used *int64 `json:"used"`
|
| 69 |
-
Size *int64 `json:"size"`
|
| 70 |
-
Packages int `json:"packages,omitempty"`
|
| 71 |
-
OK bool `json:"ok"`
|
| 72 |
-
Error string `json:"error,omitempty"`
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
type resourcePackage struct {
|
| 76 |
-
CapacityRemain int64 `json:"CapacityRemain"`
|
| 77 |
-
CapacityUsed int64 `json:"CapacityUsed"`
|
| 78 |
-
CapacitySize int64 `json:"CapacitySize"`
|
| 79 |
-
CycleCapacityRemain int64 `json:"CycleCapacityRemain"`
|
| 80 |
-
CycleCapacityUsed int64 `json:"CycleCapacityUsed"`
|
| 81 |
-
CycleCapacitySize int64 `json:"CycleCapacitySize"`
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
// packageRemainUsed 与 billing.go:203-258 一致
|
| 85 |
-
func packageRemainUsed(a resourcePackage) (remain, used, size int64) {
|
| 86 |
-
if a.CycleCapacitySize > 0 {
|
| 87 |
-
remain = a.CycleCapacityRemain
|
| 88 |
-
size = a.CycleCapacitySize
|
| 89 |
-
if remain < 0 {
|
| 90 |
-
remain = 0
|
| 91 |
-
}
|
| 92 |
-
if remain > size {
|
| 93 |
-
remain = size
|
| 94 |
-
}
|
| 95 |
-
used = size - remain
|
| 96 |
-
if a.CycleCapacityUsed > used {
|
| 97 |
-
used = a.CycleCapacityUsed
|
| 98 |
-
if size >= used {
|
| 99 |
-
remain = size - used
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
return remain, used, size
|
| 103 |
-
}
|
| 104 |
-
remain = a.CapacityRemain
|
| 105 |
-
used = a.CapacityUsed
|
| 106 |
-
size = a.CapacitySize
|
| 107 |
-
if used == 0 && size > remain {
|
| 108 |
-
used = size - remain
|
| 109 |
-
}
|
| 110 |
-
return remain, used, size
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) {
|
| 114 |
-
now := time.Now()
|
| 115 |
-
body, _ := json.Marshal(map[string]any{
|
| 116 |
-
"PageNumber": 1,
|
| 117 |
-
"PageSize": 100,
|
| 118 |
-
"ProductCode": "p_tcaca",
|
| 119 |
-
"Status": []int{0, 3},
|
| 120 |
-
"PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"),
|
| 121 |
-
"PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"),
|
| 122 |
-
})
|
| 123 |
-
base := billingBaseFor(af)
|
| 124 |
-
// global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。
|
| 125 |
-
path := "/v2/billing/meter/get-user-resource"
|
| 126 |
-
if base == billingBaseGlobal {
|
| 127 |
-
path = "/billing/meter/get-user-resource"
|
| 128 |
-
}
|
| 129 |
-
req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body))
|
| 130 |
-
if err != nil {
|
| 131 |
-
return 0, 0, 0, 0, err
|
| 132 |
-
}
|
| 133 |
-
req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken)
|
| 134 |
-
req.Header.Set("Accept", "application/json")
|
| 135 |
-
req.Header.Set("Content-Type", "application/json")
|
| 136 |
-
if af.Account.UID != "" {
|
| 137 |
-
req.Header.Set("X-User-Id", af.Account.UID)
|
| 138 |
-
}
|
| 139 |
-
if af.Account.EnterpriseID != "" {
|
| 140 |
-
req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID)
|
| 141 |
-
req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID)
|
| 142 |
-
}
|
| 143 |
-
if af.Auth.Domain != "" {
|
| 144 |
-
req.Header.Set("X-Domain", af.Auth.Domain)
|
| 145 |
-
}
|
| 146 |
-
client := &http.Client{Timeout: 20 * time.Second}
|
| 147 |
-
resp, err := client.Do(req)
|
| 148 |
-
if err != nil {
|
| 149 |
-
return 0, 0, 0, 0, err
|
| 150 |
-
}
|
| 151 |
-
defer resp.Body.Close()
|
| 152 |
-
if resp.StatusCode >= 400 {
|
| 153 |
-
return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode)
|
| 154 |
-
}
|
| 155 |
-
var env struct {
|
| 156 |
-
Code int `json:"code"`
|
| 157 |
-
Msg string `json:"msg"`
|
| 158 |
-
Data struct {
|
| 159 |
-
Response struct {
|
| 160 |
-
Data struct {
|
| 161 |
-
TotalDosage int64 `json:"TotalDosage"`
|
| 162 |
-
Accounts []resourcePackage `json:"Accounts"`
|
| 163 |
-
} `json:"Data"`
|
| 164 |
-
} `json:"Response"`
|
| 165 |
-
} `json:"data"`
|
| 166 |
-
}
|
| 167 |
-
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
| 168 |
-
return 0, 0, 0, 0, err
|
| 169 |
-
}
|
| 170 |
-
if env.Code != 0 {
|
| 171 |
-
return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg)
|
| 172 |
-
}
|
| 173 |
-
for _, a := range env.Data.Response.Data.Accounts {
|
| 174 |
-
r, u, s := packageRemainUsed(a)
|
| 175 |
-
remain += r
|
| 176 |
-
used += u
|
| 177 |
-
size += s
|
| 178 |
-
}
|
| 179 |
-
packs = len(env.Data.Response.Data.Accounts)
|
| 180 |
-
if size > 0 {
|
| 181 |
-
if derived := size - remain; derived > used {
|
| 182 |
-
used = derived
|
| 183 |
-
}
|
| 184 |
-
}
|
| 185 |
-
if dosage := env.Data.Response.Data.TotalDosage; dosage > size {
|
| 186 |
-
size = dosage
|
| 187 |
-
if derived := size - remain; derived > used {
|
| 188 |
-
used = derived
|
| 189 |
-
}
|
| 190 |
-
}
|
| 191 |
-
return remain, used, size, packs, nil
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
func main() {
|
| 195 |
-
pretty := len(os.Args) > 1 && os.Args[1] == "-pretty"
|
| 196 |
-
authDir := "./auths"
|
| 197 |
-
if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
|
| 198 |
-
authDir = v
|
| 199 |
-
}
|
| 200 |
-
files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
|
| 201 |
-
sort.Strings(files)
|
| 202 |
-
|
| 203 |
-
accounts := make([]accountResult, 0, len(files))
|
| 204 |
-
for _, f := range files {
|
| 205 |
-
var af authFile
|
| 206 |
-
raw, err := os.ReadFile(f)
|
| 207 |
-
if err != nil || json.Unmarshal(raw, &af) != nil {
|
| 208 |
-
continue
|
| 209 |
-
}
|
| 210 |
-
res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname}
|
| 211 |
-
if af.Auth.AccessToken == "" {
|
| 212 |
-
res.Error = "no accessToken"
|
| 213 |
-
accounts = append(accounts, res)
|
| 214 |
-
continue
|
| 215 |
-
}
|
| 216 |
-
remain, used, size, packs, err := fetchUserResource(&af)
|
| 217 |
-
if err != nil {
|
| 218 |
-
res.Error = err.Error()
|
| 219 |
-
} else {
|
| 220 |
-
res.Remain = &remain
|
| 221 |
-
res.Used = &used
|
| 222 |
-
res.Size = &size
|
| 223 |
-
res.Packages = packs
|
| 224 |
-
res.OK = true
|
| 225 |
-
}
|
| 226 |
-
accounts = append(accounts, res)
|
| 227 |
-
time.Sleep(200 * time.Millisecond)
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
var totalRemain, totalUsed, totalSize int64
|
| 231 |
-
okCount := 0
|
| 232 |
-
for _, a := range accounts {
|
| 233 |
-
if a.OK {
|
| 234 |
-
okCount++
|
| 235 |
-
if a.Remain != nil {
|
| 236 |
-
totalRemain += *a.Remain
|
| 237 |
-
}
|
| 238 |
-
if a.Used != nil {
|
| 239 |
-
totalUsed += *a.Used
|
| 240 |
-
}
|
| 241 |
-
if a.Size != nil {
|
| 242 |
-
totalSize += *a.Size
|
| 243 |
-
}
|
| 244 |
-
}
|
| 245 |
-
}
|
| 246 |
-
out := map[string]any{
|
| 247 |
-
"service": "workbuddy",
|
| 248 |
-
"ts": time.Now().Unix(),
|
| 249 |
-
"total": map[string]any{
|
| 250 |
-
"remain": totalRemain,
|
| 251 |
-
"used": totalUsed,
|
| 252 |
-
"size": totalSize,
|
| 253 |
-
"accounts": len(accounts),
|
| 254 |
-
"ok": okCount,
|
| 255 |
-
"failed": len(accounts) - okCount,
|
| 256 |
-
},
|
| 257 |
-
"accounts": accounts,
|
| 258 |
-
}
|
| 259 |
-
if pretty {
|
| 260 |
-
printPretty(accounts, totalRemain, totalUsed, totalSize, okCount)
|
| 261 |
-
return
|
| 262 |
-
}
|
| 263 |
-
raw, _ := json.Marshal(out)
|
| 264 |
-
fmt.Println(string(raw))
|
| 265 |
-
}
|
| 266 |
-
|
| 267 |
-
// printPretty 人类可读日报:四行汇总,无账号明细。
|
| 268 |
-
func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) {
|
| 269 |
-
withBalance := 0
|
| 270 |
-
var failed []string
|
| 271 |
-
for _, a := range accounts {
|
| 272 |
-
if a.OK && a.Remain != nil && *a.Remain > 0 {
|
| 273 |
-
withBalance++
|
| 274 |
-
}
|
| 275 |
-
if !a.OK {
|
| 276 |
-
name := a.Nickname
|
| 277 |
-
if name == "" && len(a.UID) >= 8 {
|
| 278 |
-
name = a.UID[:8]
|
| 279 |
-
}
|
| 280 |
-
failed = append(failed, name+" "+a.Error)
|
| 281 |
-
}
|
| 282 |
-
}
|
| 283 |
-
pct := int64(0)
|
| 284 |
-
if totalSize > 0 {
|
| 285 |
-
pct = totalRemain * 100 / totalSize
|
| 286 |
-
}
|
| 287 |
-
fmt.Printf("📊 WorkBuddy 积分日报\n")
|
| 288 |
-
fmt.Printf("账号: %d/%d\n", withBalance, len(accounts))
|
| 289 |
-
fmt.Printf("总计: %d/%d\n", totalRemain, totalSize)
|
| 290 |
-
fmt.Printf("剩余: %d%%\n", pct)
|
| 291 |
-
for _, f := range failed {
|
| 292 |
-
fmt.Printf("⚠️ %s\n", f)
|
| 293 |
-
}
|
| 294 |
-
}
|
|
|
|
| 1 |
+
// credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。
|
| 2 |
+
//
|
| 3 |
+
// 用法:
|
| 4 |
+
//
|
| 5 |
+
// go run ./cmd/credit # 或编译后 ./credit
|
| 6 |
+
//
|
| 7 |
+
// 输出结构:
|
| 8 |
+
//
|
| 9 |
+
// {"service":"workbuddy","ts":N,
|
| 10 |
+
// "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N},
|
| 11 |
+
// "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]}
|
| 12 |
+
//
|
| 13 |
+
// 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的
|
| 14 |
+
// Cycle* 字段,TotalDosage 作 size 下限。
|
| 15 |
+
package main
|
| 16 |
+
|
| 17 |
+
import (
|
| 18 |
+
"bytes"
|
| 19 |
+
"encoding/json"
|
| 20 |
+
"fmt"
|
| 21 |
+
"net/http"
|
| 22 |
+
"os"
|
| 23 |
+
"path/filepath"
|
| 24 |
+
"sort"
|
| 25 |
+
"strings"
|
| 26 |
+
"time"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
const billingBaseCN = "https://www.codebuddy.cn"
|
| 30 |
+
|
| 31 |
+
// billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401:
|
| 32 |
+
// www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。
|
| 33 |
+
const billingBaseGlobal = "https://www.workbuddy.ai"
|
| 34 |
+
|
| 35 |
+
// billingBaseFor 按账号 realm 选择计费域。
|
| 36 |
+
//
|
| 37 |
+
// 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在
|
| 38 |
+
// workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域,
|
| 39 |
+
// 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。
|
| 40 |
+
func billingBaseFor(af *authFile) string {
|
| 41 |
+
if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") {
|
| 42 |
+
return billingBaseGlobal
|
| 43 |
+
}
|
| 44 |
+
d := strings.ToLower(strings.TrimSpace(af.Auth.Domain))
|
| 45 |
+
if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") {
|
| 46 |
+
return billingBaseGlobal
|
| 47 |
+
}
|
| 48 |
+
return billingBaseCN
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
type authFile struct {
|
| 52 |
+
Auth struct {
|
| 53 |
+
AccessToken string `json:"accessToken"`
|
| 54 |
+
Domain string `json:"domain"`
|
| 55 |
+
Realm string `json:"realm"`
|
| 56 |
+
} `json:"auth"`
|
| 57 |
+
Account struct {
|
| 58 |
+
UID string `json:"uid"`
|
| 59 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 60 |
+
Nickname string `json:"nickname"`
|
| 61 |
+
} `json:"account"`
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
type accountResult struct {
|
| 65 |
+
UID string `json:"uid"`
|
| 66 |
+
Nickname string `json:"nickname"`
|
| 67 |
+
Remain *int64 `json:"remain"`
|
| 68 |
+
Used *int64 `json:"used"`
|
| 69 |
+
Size *int64 `json:"size"`
|
| 70 |
+
Packages int `json:"packages,omitempty"`
|
| 71 |
+
OK bool `json:"ok"`
|
| 72 |
+
Error string `json:"error,omitempty"`
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
type resourcePackage struct {
|
| 76 |
+
CapacityRemain int64 `json:"CapacityRemain"`
|
| 77 |
+
CapacityUsed int64 `json:"CapacityUsed"`
|
| 78 |
+
CapacitySize int64 `json:"CapacitySize"`
|
| 79 |
+
CycleCapacityRemain int64 `json:"CycleCapacityRemain"`
|
| 80 |
+
CycleCapacityUsed int64 `json:"CycleCapacityUsed"`
|
| 81 |
+
CycleCapacitySize int64 `json:"CycleCapacitySize"`
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// packageRemainUsed 与 billing.go:203-258 一致
|
| 85 |
+
func packageRemainUsed(a resourcePackage) (remain, used, size int64) {
|
| 86 |
+
if a.CycleCapacitySize > 0 {
|
| 87 |
+
remain = a.CycleCapacityRemain
|
| 88 |
+
size = a.CycleCapacitySize
|
| 89 |
+
if remain < 0 {
|
| 90 |
+
remain = 0
|
| 91 |
+
}
|
| 92 |
+
if remain > size {
|
| 93 |
+
remain = size
|
| 94 |
+
}
|
| 95 |
+
used = size - remain
|
| 96 |
+
if a.CycleCapacityUsed > used {
|
| 97 |
+
used = a.CycleCapacityUsed
|
| 98 |
+
if size >= used {
|
| 99 |
+
remain = size - used
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
return remain, used, size
|
| 103 |
+
}
|
| 104 |
+
remain = a.CapacityRemain
|
| 105 |
+
used = a.CapacityUsed
|
| 106 |
+
size = a.CapacitySize
|
| 107 |
+
if used == 0 && size > remain {
|
| 108 |
+
used = size - remain
|
| 109 |
+
}
|
| 110 |
+
return remain, used, size
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) {
|
| 114 |
+
now := time.Now()
|
| 115 |
+
body, _ := json.Marshal(map[string]any{
|
| 116 |
+
"PageNumber": 1,
|
| 117 |
+
"PageSize": 100,
|
| 118 |
+
"ProductCode": "p_tcaca",
|
| 119 |
+
"Status": []int{0, 3},
|
| 120 |
+
"PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"),
|
| 121 |
+
"PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"),
|
| 122 |
+
})
|
| 123 |
+
base := billingBaseFor(af)
|
| 124 |
+
// global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。
|
| 125 |
+
path := "/v2/billing/meter/get-user-resource"
|
| 126 |
+
if base == billingBaseGlobal {
|
| 127 |
+
path = "/billing/meter/get-user-resource"
|
| 128 |
+
}
|
| 129 |
+
req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body))
|
| 130 |
+
if err != nil {
|
| 131 |
+
return 0, 0, 0, 0, err
|
| 132 |
+
}
|
| 133 |
+
req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken)
|
| 134 |
+
req.Header.Set("Accept", "application/json")
|
| 135 |
+
req.Header.Set("Content-Type", "application/json")
|
| 136 |
+
if af.Account.UID != "" {
|
| 137 |
+
req.Header.Set("X-User-Id", af.Account.UID)
|
| 138 |
+
}
|
| 139 |
+
if af.Account.EnterpriseID != "" {
|
| 140 |
+
req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID)
|
| 141 |
+
req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID)
|
| 142 |
+
}
|
| 143 |
+
if af.Auth.Domain != "" {
|
| 144 |
+
req.Header.Set("X-Domain", af.Auth.Domain)
|
| 145 |
+
}
|
| 146 |
+
client := &http.Client{Timeout: 20 * time.Second}
|
| 147 |
+
resp, err := client.Do(req)
|
| 148 |
+
if err != nil {
|
| 149 |
+
return 0, 0, 0, 0, err
|
| 150 |
+
}
|
| 151 |
+
defer resp.Body.Close()
|
| 152 |
+
if resp.StatusCode >= 400 {
|
| 153 |
+
return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode)
|
| 154 |
+
}
|
| 155 |
+
var env struct {
|
| 156 |
+
Code int `json:"code"`
|
| 157 |
+
Msg string `json:"msg"`
|
| 158 |
+
Data struct {
|
| 159 |
+
Response struct {
|
| 160 |
+
Data struct {
|
| 161 |
+
TotalDosage int64 `json:"TotalDosage"`
|
| 162 |
+
Accounts []resourcePackage `json:"Accounts"`
|
| 163 |
+
} `json:"Data"`
|
| 164 |
+
} `json:"Response"`
|
| 165 |
+
} `json:"data"`
|
| 166 |
+
}
|
| 167 |
+
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
| 168 |
+
return 0, 0, 0, 0, err
|
| 169 |
+
}
|
| 170 |
+
if env.Code != 0 {
|
| 171 |
+
return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg)
|
| 172 |
+
}
|
| 173 |
+
for _, a := range env.Data.Response.Data.Accounts {
|
| 174 |
+
r, u, s := packageRemainUsed(a)
|
| 175 |
+
remain += r
|
| 176 |
+
used += u
|
| 177 |
+
size += s
|
| 178 |
+
}
|
| 179 |
+
packs = len(env.Data.Response.Data.Accounts)
|
| 180 |
+
if size > 0 {
|
| 181 |
+
if derived := size - remain; derived > used {
|
| 182 |
+
used = derived
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
if dosage := env.Data.Response.Data.TotalDosage; dosage > size {
|
| 186 |
+
size = dosage
|
| 187 |
+
if derived := size - remain; derived > used {
|
| 188 |
+
used = derived
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
return remain, used, size, packs, nil
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
func main() {
|
| 195 |
+
pretty := len(os.Args) > 1 && os.Args[1] == "-pretty"
|
| 196 |
+
authDir := "./auths"
|
| 197 |
+
if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
|
| 198 |
+
authDir = v
|
| 199 |
+
}
|
| 200 |
+
files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
|
| 201 |
+
sort.Strings(files)
|
| 202 |
+
|
| 203 |
+
accounts := make([]accountResult, 0, len(files))
|
| 204 |
+
for _, f := range files {
|
| 205 |
+
var af authFile
|
| 206 |
+
raw, err := os.ReadFile(f)
|
| 207 |
+
if err != nil || json.Unmarshal(raw, &af) != nil {
|
| 208 |
+
continue
|
| 209 |
+
}
|
| 210 |
+
res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname}
|
| 211 |
+
if af.Auth.AccessToken == "" {
|
| 212 |
+
res.Error = "no accessToken"
|
| 213 |
+
accounts = append(accounts, res)
|
| 214 |
+
continue
|
| 215 |
+
}
|
| 216 |
+
remain, used, size, packs, err := fetchUserResource(&af)
|
| 217 |
+
if err != nil {
|
| 218 |
+
res.Error = err.Error()
|
| 219 |
+
} else {
|
| 220 |
+
res.Remain = &remain
|
| 221 |
+
res.Used = &used
|
| 222 |
+
res.Size = &size
|
| 223 |
+
res.Packages = packs
|
| 224 |
+
res.OK = true
|
| 225 |
+
}
|
| 226 |
+
accounts = append(accounts, res)
|
| 227 |
+
time.Sleep(200 * time.Millisecond)
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
var totalRemain, totalUsed, totalSize int64
|
| 231 |
+
okCount := 0
|
| 232 |
+
for _, a := range accounts {
|
| 233 |
+
if a.OK {
|
| 234 |
+
okCount++
|
| 235 |
+
if a.Remain != nil {
|
| 236 |
+
totalRemain += *a.Remain
|
| 237 |
+
}
|
| 238 |
+
if a.Used != nil {
|
| 239 |
+
totalUsed += *a.Used
|
| 240 |
+
}
|
| 241 |
+
if a.Size != nil {
|
| 242 |
+
totalSize += *a.Size
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
out := map[string]any{
|
| 247 |
+
"service": "workbuddy",
|
| 248 |
+
"ts": time.Now().Unix(),
|
| 249 |
+
"total": map[string]any{
|
| 250 |
+
"remain": totalRemain,
|
| 251 |
+
"used": totalUsed,
|
| 252 |
+
"size": totalSize,
|
| 253 |
+
"accounts": len(accounts),
|
| 254 |
+
"ok": okCount,
|
| 255 |
+
"failed": len(accounts) - okCount,
|
| 256 |
+
},
|
| 257 |
+
"accounts": accounts,
|
| 258 |
+
}
|
| 259 |
+
if pretty {
|
| 260 |
+
printPretty(accounts, totalRemain, totalUsed, totalSize, okCount)
|
| 261 |
+
return
|
| 262 |
+
}
|
| 263 |
+
raw, _ := json.Marshal(out)
|
| 264 |
+
fmt.Println(string(raw))
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// printPretty 人类可读日报:四行汇总,无账号明细。
|
| 268 |
+
func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) {
|
| 269 |
+
withBalance := 0
|
| 270 |
+
var failed []string
|
| 271 |
+
for _, a := range accounts {
|
| 272 |
+
if a.OK && a.Remain != nil && *a.Remain > 0 {
|
| 273 |
+
withBalance++
|
| 274 |
+
}
|
| 275 |
+
if !a.OK {
|
| 276 |
+
name := a.Nickname
|
| 277 |
+
if name == "" && len(a.UID) >= 8 {
|
| 278 |
+
name = a.UID[:8]
|
| 279 |
+
}
|
| 280 |
+
failed = append(failed, name+" "+a.Error)
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
pct := int64(0)
|
| 284 |
+
if totalSize > 0 {
|
| 285 |
+
pct = totalRemain * 100 / totalSize
|
| 286 |
+
}
|
| 287 |
+
fmt.Printf("📊 WorkBuddy 积分日报\n")
|
| 288 |
+
fmt.Printf("账号: %d/%d\n", withBalance, len(accounts))
|
| 289 |
+
fmt.Printf("总计: %d/%d\n", totalRemain, totalSize)
|
| 290 |
+
fmt.Printf("剩余: %d%%\n", pct)
|
| 291 |
+
for _, f := range failed {
|
| 292 |
+
fmt.Printf("⚠️ %s\n", f)
|
| 293 |
+
}
|
| 294 |
+
}
|
cmd/launcher/flags.go
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"flag"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/syncer"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
// usageHead 帮助文本前半段。
|
| 14 |
+
const usageHead = `wb2api-launcher —— WorkBuddy2API 独立启动器(同步凭证 + 拉起网关 + 后台对账)
|
| 15 |
+
|
| 16 |
+
用法:
|
| 17 |
+
wb2api-launcher [标志]
|
| 18 |
+
|
| 19 |
+
常用:
|
| 20 |
+
wb2api-launcher 同步一次 → 启动网关 → 后台每 5 分钟对账
|
| 21 |
+
wb2api-launcher -status 只看两边差异,不启动网关(有差异退出码 2)
|
| 22 |
+
wb2api-launcher -sync-only 同步一次就退出
|
| 23 |
+
wb2api-launcher -no-sync 离线启动(跳过同步,用本地已有凭证)
|
| 24 |
+
wb2api-launcher -no-watch 不起后台对账
|
| 25 |
+
wb2api-launcher -mode pull 只拉不推
|
| 26 |
+
wb2api-launcher -version 打印版本
|
| 27 |
+
|
| 28 |
+
标志:
|
| 29 |
+
`
|
| 30 |
+
|
| 31 |
+
// usageTail 帮助文本后半段。
|
| 32 |
+
const usageTail = `
|
| 33 |
+
退出码:
|
| 34 |
+
0 正常(网关退出时原样透出它的退出码)
|
| 35 |
+
1 启动阶段失败
|
| 36 |
+
2 -status 模式下存在未同步的差异
|
| 37 |
+
|
| 38 |
+
配置查找:
|
| 39 |
+
config.json 默认取启动器同目录;相对路径一律以 config.json 所在目录为基准,
|
| 40 |
+
网关子进程的工作目录也会被切到那里 —— 保证"同步写的目录"就是"网关读的目录"。
|
| 41 |
+
`
|
| 42 |
+
|
| 43 |
+
// parseFlags 解析命令行。返回 (选项, 是否只打印版本, error)。
|
| 44 |
+
func parseFlags(args []string) (options, bool, error) {
|
| 45 |
+
var (
|
| 46 |
+
opt options
|
| 47 |
+
showVersion bool
|
| 48 |
+
modeStr string
|
| 49 |
+
statusOnly bool
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
fs := flag.NewFlagSet("wb2api-launcher", flag.ContinueOnError)
|
| 53 |
+
fs.SetOutput(os.Stderr)
|
| 54 |
+
fs.Usage = func() {
|
| 55 |
+
fmt.Fprint(os.Stderr, usageHead)
|
| 56 |
+
fs.PrintDefaults()
|
| 57 |
+
fmt.Fprint(os.Stderr, usageTail)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
fs.StringVar(&opt.configPath, "config", "", "配置文件路径(默认:启动器同目录的 config.json)")
|
| 61 |
+
fs.StringVar(&opt.serverBin, "server", "", "网关二进制路径(默认:同目录的 wb2api(.exe),再找 PATH)")
|
| 62 |
+
|
| 63 |
+
fs.StringVar(&modeStr, "mode", "sync", "同步方向:sync / pull / push")
|
| 64 |
+
fs.BoolVar(&statusOnly, "status", false, "只看差异不写入,也不启动网关(等价 -mode sync -dry-run -sync-only)")
|
| 65 |
+
fs.BoolVar(&opt.syncOnly, "sync-only", false, "同步一次后退出,不启动网关")
|
| 66 |
+
|
| 67 |
+
fs.BoolVar(&opt.noSync, "no-sync", false, "跳过同步,直接启动网关")
|
| 68 |
+
fs.BoolVar(&opt.noWatch, "no-watch", false, "不起后台定时对账")
|
| 69 |
+
fs.DurationVar(&opt.interval, "interval", 5*time.Minute, "后台对账间隔")
|
| 70 |
+
fs.DurationVar(&opt.shutdownGrace, "shutdown-grace", 10*time.Second, "退出时等待网关优雅收尾的宽限期")
|
| 71 |
+
fs.BoolVar(&opt.quiet, "quiet", false, "减少输出(仍会打印同步结果与告警)")
|
| 72 |
+
|
| 73 |
+
// 同步参数覆盖(不写 config.json 也能跑)
|
| 74 |
+
fs.StringVar(&opt.bucket, "bucket", "", "HF 桶 ID,如 a3216/wb2api-storage")
|
| 75 |
+
fs.StringVar(&opt.token, "token", "", "HF token(更推荐写进 config.json 的 sync.token_file)")
|
| 76 |
+
fs.StringVar(&opt.endpoint, "endpoint", "", "HF 端点(默认 https://huggingface.co)")
|
| 77 |
+
fs.StringVar(&opt.include, "include-data", "", "额外同步的 data/ 文件名,逗号分隔(默认只同步凭证)")
|
| 78 |
+
fs.StringVar(&opt.writer, "writer", "", "写入通道:auto / hf / relay")
|
| 79 |
+
fs.StringVar(&opt.relayURL, "relay-url", "", "中转网关地址(relay 写入通道用)")
|
| 80 |
+
fs.StringVar(&opt.restart, "restart-space", "", "推了新凭证后重启该 Space,如 a3216/gcli2api")
|
| 81 |
+
fs.StringVar(&opt.caBundle, "ca-bundle", "", "额外 CA 证书包(PEM);本机跑抓包代理时给 hf CLI 用")
|
| 82 |
+
fs.BoolVar(&opt.dryRun, "dry-run", false, "只展示将要做什么,不写任何东西")
|
| 83 |
+
|
| 84 |
+
fs.BoolVar(&showVersion, "version", false, "打印版本后退出")
|
| 85 |
+
|
| 86 |
+
if err := fs.Parse(args); err != nil {
|
| 87 |
+
if err == flag.ErrHelp {
|
| 88 |
+
// -h 走 Usage(已经打印过了),按成功退出。
|
| 89 |
+
return opt, true, nil
|
| 90 |
+
}
|
| 91 |
+
return opt, false, err
|
| 92 |
+
}
|
| 93 |
+
if rest := fs.Args(); len(rest) > 0 {
|
| 94 |
+
return opt, false, fmt.Errorf("未知参数: %s(用 -h 看帮助)", strings.Join(rest, " "))
|
| 95 |
+
}
|
| 96 |
+
if showVersion {
|
| 97 |
+
return opt, true, nil
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
mode, reportOnly, err := syncer.ParseCommand(modeStr)
|
| 101 |
+
if err != nil {
|
| 102 |
+
return opt, false, err
|
| 103 |
+
}
|
| 104 |
+
opt.mode = mode
|
| 105 |
+
// modeStr 写 "status" 时 ParseCommand 已告知只报告。
|
| 106 |
+
opt.reportOnly = reportOnly
|
| 107 |
+
|
| 108 |
+
// -status 等价于「按 sync 算一遍,但不落地、不启动网关」。
|
| 109 |
+
if statusOnly {
|
| 110 |
+
opt.mode = syncer.ModeSync
|
| 111 |
+
opt.reportOnly = true
|
| 112 |
+
opt.syncOnly = true
|
| 113 |
+
}
|
| 114 |
+
// 只报告就不可能写入:把 dry-run 也定死,避免出现自相矛盾的组合。
|
| 115 |
+
if opt.reportOnly {
|
| 116 |
+
opt.dryRun = true
|
| 117 |
+
}
|
| 118 |
+
return opt, false, nil
|
| 119 |
+
}
|
cmd/launcher/main.go
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// wb2api-launcher —— WorkBuddy2API 的**独立启动器**。
|
| 2 |
+
//
|
| 3 |
+
// 为什么是一个独立 exe 而不是 shell 脚本:
|
| 4 |
+
//
|
| 5 |
+
// - 上游作者更新时,你只需要替换 wb2api.exe(网关本体),启动器完全不动。
|
| 6 |
+
// 启动器只做三件事——同步凭证、拉起网关、转发信号——不依赖网关的任何内部实现,
|
| 7 |
+
// 也不依赖 go / python / bash / PowerShell 环境。
|
| 8 |
+
// - 同步引擎(internal/syncer)直接**编进这个 exe**,不再需要第二个 wb2api-sync
|
| 9 |
+
// 二进制;后台定时对账也在同一个进程里跑,不会留下孤儿进程。
|
| 10 |
+
//
|
| 11 |
+
// 启动顺序刻意是「先同步、后启动」:网关只在启动时扫一遍 auths/(没有文件监听),
|
| 12 |
+
// 在它扫描之前把远端凭证落下来,网关启动时就自然加载到最新的一批账号。
|
| 13 |
+
//
|
| 14 |
+
// 用法:
|
| 15 |
+
//
|
| 16 |
+
// wb2api-launcher.exe # 同步 + 启动网关 + 后台对账
|
| 17 |
+
// wb2api-launcher.exe -status # 只看两边差异,不启动网关
|
| 18 |
+
// wb2api-launcher.exe -sync-only # 同步一次就退出
|
| 19 |
+
// wb2api-launcher.exe -no-sync # 离线启动(跳过同步)
|
| 20 |
+
// wb2api-launcher.exe -no-watch # 不起后台对账
|
| 21 |
+
// wb2api-launcher.exe -mode pull # 只拉不推
|
| 22 |
+
//
|
| 23 |
+
// 退出码:网关的退出码原样透出;启动阶段失败返回 1;-status 有差异返回 2。
|
| 24 |
+
package main
|
| 25 |
+
|
| 26 |
+
import (
|
| 27 |
+
"context"
|
| 28 |
+
"errors"
|
| 29 |
+
"fmt"
|
| 30 |
+
"os"
|
| 31 |
+
"os/exec"
|
| 32 |
+
"os/signal"
|
| 33 |
+
"path/filepath"
|
| 34 |
+
"runtime"
|
| 35 |
+
"strings"
|
| 36 |
+
"syscall"
|
| 37 |
+
"time"
|
| 38 |
+
|
| 39 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/syncer"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
// version 版本号(-version)。做成变量便于 -ldflags 注入。
|
| 43 |
+
var version = "1.0.0"
|
| 44 |
+
|
| 45 |
+
// 退出码约定。
|
| 46 |
+
const (
|
| 47 |
+
exitOK = 0
|
| 48 |
+
exitError = 1
|
| 49 |
+
exitPending = 2 // -status 模式下存在未同步的差异
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
func main() {
|
| 53 |
+
if err := run(); err != nil {
|
| 54 |
+
fmt.Fprintf(os.Stderr, "\n[launcher] 失败: %v\n", err)
|
| 55 |
+
os.Exit(exitError)
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// options 是解析后的启动参数。
|
| 60 |
+
type options struct {
|
| 61 |
+
configPath string
|
| 62 |
+
serverBin string
|
| 63 |
+
|
| 64 |
+
mode syncer.Mode
|
| 65 |
+
reportOnly bool
|
| 66 |
+
|
| 67 |
+
noSync bool
|
| 68 |
+
noWatch bool
|
| 69 |
+
syncOnly bool
|
| 70 |
+
quiet bool
|
| 71 |
+
|
| 72 |
+
interval time.Duration
|
| 73 |
+
shutdownGrace time.Duration
|
| 74 |
+
|
| 75 |
+
// sync 覆盖项(不写 config.json 也能用)
|
| 76 |
+
bucket string
|
| 77 |
+
token string
|
| 78 |
+
endpoint string
|
| 79 |
+
include string
|
| 80 |
+
writer string
|
| 81 |
+
restart string
|
| 82 |
+
caBundle string
|
| 83 |
+
relayURL string
|
| 84 |
+
dryRun bool
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func run() error {
|
| 88 |
+
opt, showVersion, err := parseFlags(os.Args[1:])
|
| 89 |
+
if err != nil {
|
| 90 |
+
return err
|
| 91 |
+
}
|
| 92 |
+
if showVersion {
|
| 93 |
+
fmt.Printf("wb2api-launcher %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
|
| 94 |
+
return nil
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
exePath, err := os.Executable()
|
| 98 |
+
if err != nil {
|
| 99 |
+
return fmt.Errorf("无法定位启动器自身路径: %w", err)
|
| 100 |
+
}
|
| 101 |
+
exeDir := filepath.Dir(exePath)
|
| 102 |
+
|
| 103 |
+
// 配置文件默认放在启动器旁边——"解压到目录、双击"即可用。
|
| 104 |
+
if opt.configPath == "" {
|
| 105 |
+
opt.configPath = filepath.Join(exeDir, "config.json")
|
| 106 |
+
} else if !filepath.IsAbs(opt.configPath) {
|
| 107 |
+
opt.configPath = filepath.Join(exeDir, opt.configPath)
|
| 108 |
+
}
|
| 109 |
+
absCfg, err := filepath.Abs(opt.configPath)
|
| 110 |
+
if err != nil {
|
| 111 |
+
return err
|
| 112 |
+
}
|
| 113 |
+
cfgDir := filepath.Dir(absCfg)
|
| 114 |
+
|
| 115 |
+
ensureConfig(absCfg, exeDir, cfgDir)
|
| 116 |
+
|
| 117 |
+
// 关键:把工作目录切到 config.json 所在目录,再启动网关。
|
| 118 |
+
// 这样配置里的相对路径(./auths、./data/state.json)对"启动器的同步"和
|
| 119 |
+
// "网关子进程"是**同一个**目录,不会出现同步写 A、网关读 B 的错位。
|
| 120 |
+
if err := os.Chdir(cfgDir); err != nil {
|
| 121 |
+
return fmt.Errorf("切换工作目录到 %s 失败: %w", cfgDir, err)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
syncCfg, err := loadSyncConfig(absCfg, opt)
|
| 125 |
+
if err != nil {
|
| 126 |
+
return err
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
| 130 |
+
defer stop()
|
| 131 |
+
|
| 132 |
+
var engine *syncer.Engine
|
| 133 |
+
if !opt.noSync {
|
| 134 |
+
engine, err = newEngine(ctx, syncCfg, opt)
|
| 135 |
+
if err != nil {
|
| 136 |
+
// 同步配置不全不是致命错误:照样能把网关拉起来(离线自用)。
|
| 137 |
+
info("同步未启用:%v", err)
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if engine != nil {
|
| 142 |
+
if code := preflightSync(ctx, engine, opt); code != exitOK {
|
| 143 |
+
if opt.syncOnly || opt.reportOnly {
|
| 144 |
+
os.Exit(code)
|
| 145 |
+
}
|
| 146 |
+
// 启动阶段同步失败不阻断启动:断网/中转挂了不该让你连本地网关都用不了。
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if opt.syncOnly || opt.reportOnly {
|
| 151 |
+
return nil
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
serverBin, err := findServer(opt.serverBin, exeDir)
|
| 155 |
+
if err != nil {
|
| 156 |
+
return err
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
info("网关二进制:%s", serverBin)
|
| 160 |
+
info("配置:%s", absCfg)
|
| 161 |
+
info("运行目录:%s", cfgDir)
|
| 162 |
+
|
| 163 |
+
child := exec.Command(serverBin, "-config", absCfg)
|
| 164 |
+
child.Stdin = os.Stdin
|
| 165 |
+
child.Stdout = os.Stdout
|
| 166 |
+
child.Stderr = os.Stderr
|
| 167 |
+
child.Dir = cfgDir
|
| 168 |
+
if err := child.Start(); err != nil {
|
| 169 |
+
return fmt.Errorf("启动网关失败 (%s): %w", serverBin, err)
|
| 170 |
+
}
|
| 171 |
+
info("网关已启动 (PID %d)", child.Process.Pid)
|
| 172 |
+
|
| 173 |
+
// 后台对账:跑在同一个进程里��不会留下孤儿进程。
|
| 174 |
+
if engine != nil && !opt.noWatch {
|
| 175 |
+
go watchLoop(ctx, engine, opt)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 等子进程退出。Ctrl-C 在这个控制台里会同时送到启动器与网关,
|
| 179 |
+
// 网关收到后会 Flush() 落盘再做优雅停机——所以这里给一段宽限期让它自己收尾。
|
| 180 |
+
done := make(chan error, 1)
|
| 181 |
+
go func() { done <- child.Wait() }()
|
| 182 |
+
|
| 183 |
+
select {
|
| 184 |
+
case werr := <-done:
|
| 185 |
+
return exitCodeOf(werr)
|
| 186 |
+
case <-ctx.Done():
|
| 187 |
+
info("收到中断信号,等待网关优雅退出(最多 %s)...", opt.shutdownGrace)
|
| 188 |
+
if err := waitOrKill(child, done, opt.shutdownGrace); err != nil {
|
| 189 |
+
return err
|
| 190 |
+
}
|
| 191 |
+
return nil
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// waitOrKill 给子进程一段宽限期自行退出,超时才强杀。
|
| 196 |
+
//
|
| 197 |
+
// 为什么优先"等"而不是直接 Kill:网关在收到 SIGINT/SIGTERM 时会先
|
| 198 |
+
// Pool.Flush() 把内存状态落盘再 Shutdown;直接杀掉会丢掉最后一次状态落盘
|
| 199 |
+
// (积分/冷却/连登计数都是攒在内存里定时刷的)。
|
| 200 |
+
func waitOrKill(child *exec.Cmd, done <-chan error, grace time.Duration) error {
|
| 201 |
+
select {
|
| 202 |
+
case <-done:
|
| 203 |
+
info("网关已优雅退出")
|
| 204 |
+
case <-time.After(grace):
|
| 205 |
+
info("宽限期已到,强制结束网关进程")
|
| 206 |
+
if child.Process != nil {
|
| 207 |
+
_ = child.Process.Kill()
|
| 208 |
+
}
|
| 209 |
+
<-done
|
| 210 |
+
}
|
| 211 |
+
return nil
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// exitCodeOf 把 Wait 的错误翻译成退出码。
|
| 215 |
+
func exitCodeOf(err error) error {
|
| 216 |
+
if err == nil {
|
| 217 |
+
return nil
|
| 218 |
+
}
|
| 219 |
+
var ee *exec.ExitError
|
| 220 |
+
if errors.As(err, &ee) {
|
| 221 |
+
// 原样透出子进程退出码,便于外层脚本判断。
|
| 222 |
+
os.Exit(ee.ExitCode())
|
| 223 |
+
}
|
| 224 |
+
return err
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// preflightSync 启动前同步一次。返回退出码。
|
| 228 |
+
func preflightSync(ctx context.Context, engine *syncer.Engine, opt options) int {
|
| 229 |
+
fmt.Println()
|
| 230 |
+
info("━━ 启动前同步(模式 %s)━━", opt.mode)
|
| 231 |
+
plan, res, err := engine.Sync(ctx, opt.mode, opt.dryRun)
|
| 232 |
+
if err != nil {
|
| 233 |
+
warn("同步失败:%v", err)
|
| 234 |
+
warn("按本地已有凭证继续启动,本次不做远端对账。")
|
| 235 |
+
return exitError
|
| 236 |
+
}
|
| 237 |
+
printPlan(plan, res, opt)
|
| 238 |
+
if res.Failed > 0 {
|
| 239 |
+
warn("%d 个文件同步失败,按本地已有凭证继续启动。", res.Failed)
|
| 240 |
+
return exitError
|
| 241 |
+
}
|
| 242 |
+
if opt.reportOnly && plan.Changes() > 0 {
|
| 243 |
+
return exitPending
|
| 244 |
+
}
|
| 245 |
+
if plan.Changes() == 0 {
|
| 246 |
+
info("已与中转一致(无改动)")
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// 本地一个凭证都没有时,网关起来是空池(/healthz 503),把原因说清楚。
|
| 250 |
+
if n := countAuthFiles(engine.Config().AuthDir); n == 0 {
|
| 251 |
+
warn("auths/ 里没有任何账号 —— 网关会以空池启动(/healthz 会返回 503)。")
|
| 252 |
+
warn(" 检查 config.json 的 sync.bucket / sync.token。")
|
| 253 |
+
}
|
| 254 |
+
return exitOK
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// watchLoop 常驻后台对账:把本地运行期刷新出来的新 token 持续推回中转。
|
| 258 |
+
func watchLoop(ctx context.Context, engine *syncer.Engine, opt options) {
|
| 259 |
+
t := time.NewTicker(opt.interval)
|
| 260 |
+
defer t.Stop()
|
| 261 |
+
for {
|
| 262 |
+
select {
|
| 263 |
+
case <-ctx.Done():
|
| 264 |
+
return
|
| 265 |
+
case <-t.C:
|
| 266 |
+
plan, res, err := engine.Sync(ctx, syncer.ModeSync, false)
|
| 267 |
+
switch {
|
| 268 |
+
case err != nil:
|
| 269 |
+
warn("[后台对账] 出错:%v", err)
|
| 270 |
+
case res.Failed > 0:
|
| 271 |
+
warn("[后台对账] 上传 %d / 下载 %d / 失败 %d", res.Uploaded, res.Downloaded, res.Failed)
|
| 272 |
+
case plan.Changes() > 0:
|
| 273 |
+
info("[后台对账] 上传 %d / 下载 %d", res.Uploaded, res.Downloaded)
|
| 274 |
+
for _, a := range plan.Actions {
|
| 275 |
+
if a.Op == syncer.OpNoop {
|
| 276 |
+
continue
|
| 277 |
+
}
|
| 278 |
+
info(" %s %s (%s)", opIcon(a.Op), a.Path, a.Reason)
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// ---------------------------------------------------------------------------
|
| 286 |
+
// 配置
|
| 287 |
+
// ---------------------------------------------------------------------------
|
| 288 |
+
|
| 289 |
+
// loadSyncConfig 读取 config.json 的 sync 段并叠加命令行覆盖。
|
| 290 |
+
func loadSyncConfig(cfgPath string, opt options) (syncer.Config, error) {
|
| 291 |
+
cfg, err := syncer.LoadConfigFile(cfgPath)
|
| 292 |
+
if err != nil && !os.IsNotExist(err) {
|
| 293 |
+
return syncer.Config{}, fmt.Errorf("读取 %s 失败: %w", cfgPath, err)
|
| 294 |
+
}
|
| 295 |
+
if opt.bucket != "" {
|
| 296 |
+
cfg.Bucket = opt.bucket
|
| 297 |
+
}
|
| 298 |
+
if opt.token != "" {
|
| 299 |
+
cfg.Token = opt.token
|
| 300 |
+
}
|
| 301 |
+
if opt.endpoint != "" {
|
| 302 |
+
cfg.Endpoint = opt.endpoint
|
| 303 |
+
}
|
| 304 |
+
if opt.writer != "" {
|
| 305 |
+
cfg.Writer = opt.writer
|
| 306 |
+
}
|
| 307 |
+
if opt.relayURL != "" {
|
| 308 |
+
cfg.RelayURL = opt.relayURL
|
| 309 |
+
}
|
| 310 |
+
if opt.restart != "" {
|
| 311 |
+
cfg.RestartSpace = opt.restart
|
| 312 |
+
}
|
| 313 |
+
if opt.caBundle != "" {
|
| 314 |
+
cfg.CABundle = opt.caBundle
|
| 315 |
+
}
|
| 316 |
+
if opt.include != "" {
|
| 317 |
+
var list []string
|
| 318 |
+
for _, p := range strings.Split(opt.include, ",") {
|
| 319 |
+
if p = strings.TrimSpace(p); p != "" {
|
| 320 |
+
list = append(list, p)
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
cfg.IncludeData = list
|
| 324 |
+
}
|
| 325 |
+
if cfg.Token == "" && cfg.TokenFile == "" {
|
| 326 |
+
if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" {
|
| 327 |
+
cfg.Token = t
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
cfg.Normalize()
|
| 331 |
+
if cfg.AuthDir == "" {
|
| 332 |
+
cfg.AuthDir = "auths"
|
| 333 |
+
}
|
| 334 |
+
if cfg.DataDir == "" {
|
| 335 |
+
cfg.DataDir = "data"
|
| 336 |
+
}
|
| 337 |
+
return cfg, nil
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// newEngine 构造同步引擎;必要时先从桶里取中转的 api_key(relay 写入通道用)。
|
| 341 |
+
func newEngine(ctx context.Context, cfg syncer.Config, opt options) (*syncer.Engine, error) {
|
| 342 |
+
if cfg.Bucket == "" {
|
| 343 |
+
return nil, fmt.Errorf("config.json 的 sync.bucket 为空(未配置中转桶)")
|
| 344 |
+
}
|
| 345 |
+
eng, err := syncer.New(cfg)
|
| 346 |
+
if err != nil {
|
| 347 |
+
return nil, err
|
| 348 |
+
}
|
| 349 |
+
if eng.WriterName() == "relay" && eng.Config().RelayKey == "" {
|
| 350 |
+
if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil {
|
| 351 |
+
cfg.RelayKey = key
|
| 352 |
+
if eng, err = syncer.New(cfg); err != nil {
|
| 353 |
+
return nil, err
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
return eng, nil
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// ensureConfig 首启兜底:没有 config.json 时从 config.example.json 复制一份。
|
| 361 |
+
//
|
| 362 |
+
// 刻意不在这里生成带随机 api_key 的最小配置:网关自己首启就会生成推荐配置
|
| 363 |
+
// (含 crypto/rand 的 api_key),启动器不该抢着替它写一份半成品。
|
| 364 |
+
func ensureConfig(cfgPath, exeDir, cfgDir string) {
|
| 365 |
+
if _, err := os.Stat(cfgPath); err == nil {
|
| 366 |
+
return
|
| 367 |
+
}
|
| 368 |
+
for _, cand := range []string{
|
| 369 |
+
filepath.Join(cfgDir, "config.example.json"),
|
| 370 |
+
filepath.Join(exeDir, "config.example.json"),
|
| 371 |
+
} {
|
| 372 |
+
if raw, err := os.ReadFile(cand); err == nil {
|
| 373 |
+
if werr := os.WriteFile(cfgPath, raw, 0o600); werr == nil {
|
| 374 |
+
warn("未找到 config.json,已从 %s 复制一份。请按需修改(尤其是 api_key 与 sync 段)。", filepath.Base(cand))
|
| 375 |
+
return
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
info("未找到 config.json,也没有 config.example.json —— 交给网关自行生成默认配置。")
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// findServer 定位网关二进制。
|
| 383 |
+
//
|
| 384 |
+
// 查找顺序:-server 显式指定 → 启动器同目录(Windows 下 wb2api.exe)→ PATH。
|
| 385 |
+
// 这样"把新下载的 wb2api.exe 丢到启动器旁边"就是完整的升级动作。
|
| 386 |
+
func findServer(explicit, exeDir string) (string, error) {
|
| 387 |
+
names := []string{"wb2api"}
|
| 388 |
+
if runtime.GOOS == "windows" {
|
| 389 |
+
names = []string{"wb2api.exe", "wb2api"}
|
| 390 |
+
}
|
| 391 |
+
if explicit != "" {
|
| 392 |
+
p := explicit
|
| 393 |
+
if !filepath.IsAbs(p) {
|
| 394 |
+
p = filepath.Join(exeDir, explicit)
|
| 395 |
+
}
|
| 396 |
+
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
| 397 |
+
return p, nil
|
| 398 |
+
}
|
| 399 |
+
return "", fmt.Errorf("-server 指定的文件不存在: %s", p)
|
| 400 |
+
}
|
| 401 |
+
for _, n := range names {
|
| 402 |
+
p := filepath.Join(exeDir, n)
|
| 403 |
+
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
| 404 |
+
return p, nil
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
for _, n := range names {
|
| 408 |
+
if p, err := exec.LookPath(n); err == nil {
|
| 409 |
+
return p, nil
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
return "", fmt.Errorf("找不到网关二进制 wb2api(找了 %s 与 PATH)。\n"+
|
| 413 |
+
" 把 wb2api.exe 放在启动器同目录,或用 -server 指定路径", strings.Join(names, " / "))
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
// countAuthFiles 数本地凭证文件个数。
|
| 417 |
+
func countAuthFiles(dir string) int {
|
| 418 |
+
entries, err := os.ReadDir(dir)
|
| 419 |
+
if err != nil {
|
| 420 |
+
return 0
|
| 421 |
+
}
|
| 422 |
+
n := 0
|
| 423 |
+
for _, e := range entries {
|
| 424 |
+
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
|
| 425 |
+
n++
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
return n
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// ---------------------------------------------------------------------------
|
| 432 |
+
// 输出
|
| 433 |
+
// ---------------------------------------------------------------------------
|
| 434 |
+
|
| 435 |
+
func info(format string, a ...any) {
|
| 436 |
+
fmt.Printf("[launcher] "+format+"\n", a...)
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
func warn(format string, a ...any) {
|
| 440 |
+
fmt.Printf("[launcher] ⚠ "+format+"\n", a...)
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
func opIcon(op syncer.Op) string {
|
| 444 |
+
switch op {
|
| 445 |
+
case syncer.OpUpload:
|
| 446 |
+
return "↑"
|
| 447 |
+
case syncer.OpDownload:
|
| 448 |
+
return "↓"
|
| 449 |
+
default:
|
| 450 |
+
return "="
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
// printPlan 打印对账结果(只列有动作的行,一致的不刷屏)。
|
| 455 |
+
func printPlan(plan *syncer.Plan, res *syncer.Result, opt options) {
|
| 456 |
+
cfgWriter := ""
|
| 457 |
+
if plan.Writer != "" {
|
| 458 |
+
cfgWriter = plan.Writer
|
| 459 |
+
} else {
|
| 460 |
+
cfgWriter = "(只读)"
|
| 461 |
+
}
|
| 462 |
+
info("桶 %s · 写入通道 %s · 本地 %d / 远端 %d 个文件",
|
| 463 |
+
plan.Bucket, cfgWriter, plan.LocalFiles, plan.RemoteFiles)
|
| 464 |
+
|
| 465 |
+
for _, a := range plan.Actions {
|
| 466 |
+
if a.Op == syncer.OpNoop && !a.Conflict {
|
| 467 |
+
continue
|
| 468 |
+
}
|
| 469 |
+
line := fmt.Sprintf(" %s %-8s %s", opIcon(a.Op), a.Op, a.Path)
|
| 470 |
+
if a.Conflict {
|
| 471 |
+
line += " [冲突]"
|
| 472 |
+
}
|
| 473 |
+
fmt.Println(line)
|
| 474 |
+
fmt.Printf(" %s\n", a.Reason)
|
| 475 |
+
if a.Err != "" {
|
| 476 |
+
fmt.Printf(" ✗ %s\n", a.Err)
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
info("汇总:上传 %d / 下载 %d / 跳过 %d / 失败 %d",
|
| 480 |
+
res.Uploaded, res.Downloaded, res.Skipped, res.Failed)
|
| 481 |
+
for _, e := range res.Errors {
|
| 482 |
+
warn("%s", e)
|
| 483 |
+
}
|
| 484 |
+
}
|
cmd/login/main.go
CHANGED
|
@@ -1,336 +1,336 @@
|
|
| 1 |
-
// login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。
|
| 2 |
-
//
|
| 3 |
-
// 两个子命令,由 login.sh 顺序驱动:
|
| 4 |
-
//
|
| 5 |
-
// login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl,
|
| 6 |
-
// state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL
|
| 7 |
-
// login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次,
|
| 8 |
-
// 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname,
|
| 9 |
-
// stdout 打印完整 token+account JSON(含 realm 键)
|
| 10 |
-
//
|
| 11 |
-
// --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer:
|
| 12 |
-
//
|
| 13 |
-
// cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn)
|
| 14 |
-
// global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai)
|
| 15 |
-
//
|
| 16 |
-
// state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。
|
| 17 |
-
// 无 PKCE(workbuddy 设备流由服务端签发 state)。
|
| 18 |
-
package main
|
| 19 |
-
|
| 20 |
-
import (
|
| 21 |
-
"bufio"
|
| 22 |
-
"bytes"
|
| 23 |
-
"encoding/json"
|
| 24 |
-
"fmt"
|
| 25 |
-
"io"
|
| 26 |
-
"net/http"
|
| 27 |
-
"net/http/cookiejar"
|
| 28 |
-
"os"
|
| 29 |
-
"path/filepath"
|
| 30 |
-
"strings"
|
| 31 |
-
"time"
|
| 32 |
-
|
| 33 |
-
auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
// 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai
|
| 37 |
-
// (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。
|
| 38 |
-
const (
|
| 39 |
-
upstreamBaseCN = "https://copilot.tencent.com"
|
| 40 |
-
upstreamBaseGlobal = "https://www.workbuddy.ai"
|
| 41 |
-
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
|
| 42 |
-
originRefererCN = "https://www.codebuddy.cn"
|
| 43 |
-
originRefererGlobal = "https://www.workbuddy.ai"
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
// 登录 state 落盘路径(var 便于测试替换临时文件)
|
| 47 |
-
// Portable across OSes: the upstream hardcoded "/tmp/...", which on
|
| 48 |
-
// Windows resolves to <drive>:\tmp\... and aborts the OAuth flow with
|
| 49 |
-
// "The system cannot find the path specified". os.TempDir() is /tmp on Linux.
|
| 50 |
-
var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json")
|
| 51 |
-
|
| 52 |
-
// exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。
|
| 53 |
-
var exitFunc = os.Exit
|
| 54 |
-
|
| 55 |
-
// realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global →
|
| 56 |
-
// (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。
|
| 57 |
-
func realmConfig(realm string) (base, origin string) {
|
| 58 |
-
if realm == realmGlobal {
|
| 59 |
-
return upstreamBaseGlobal, originRefererGlobal
|
| 60 |
-
}
|
| 61 |
-
return upstreamBaseCN, originRefererCN
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
// commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。
|
| 65 |
-
// 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。
|
| 66 |
-
func commonHeaders(origin string) func(*http.Request) {
|
| 67 |
-
return func(req *http.Request) {
|
| 68 |
-
req.Header.Set("Content-Type", "application/json")
|
| 69 |
-
req.Header.Set("Accept", "application/json, text/plain, */*")
|
| 70 |
-
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
| 71 |
-
req.Header.Set("Origin", origin)
|
| 72 |
-
req.Header.Set("Referer", origin+"/")
|
| 73 |
-
req.Header.Set("User-Agent", clientUA)
|
| 74 |
-
}
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
// apiEnvelope 与 main.go:429-433 一致
|
| 78 |
-
type apiEnvelope struct {
|
| 79 |
-
Code int `json:"code"`
|
| 80 |
-
Msg string `json:"msg"`
|
| 81 |
-
Data json.RawMessage `json:"data"`
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
// doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error
|
| 85 |
-
func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
|
| 86 |
-
req, err := http.NewRequest(method, fullURL, body)
|
| 87 |
-
if err != nil {
|
| 88 |
-
return nil, 0, err
|
| 89 |
-
}
|
| 90 |
-
if headers != nil {
|
| 91 |
-
headers(req)
|
| 92 |
-
} else {
|
| 93 |
-
// 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归)
|
| 94 |
-
commonHeaders(originRefererCN)(req)
|
| 95 |
-
}
|
| 96 |
-
resp, err := client.Do(req)
|
| 97 |
-
if err != nil {
|
| 98 |
-
return nil, 0, err
|
| 99 |
-
}
|
| 100 |
-
defer resp.Body.Close()
|
| 101 |
-
raw, _ := io.ReadAll(resp.Body)
|
| 102 |
-
if resp.StatusCode >= 400 {
|
| 103 |
-
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
|
| 104 |
-
}
|
| 105 |
-
if resp.StatusCode >= 300 {
|
| 106 |
-
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode)
|
| 107 |
-
}
|
| 108 |
-
var env apiEnvelope
|
| 109 |
-
if err := json.Unmarshal(raw, &env); err != nil {
|
| 110 |
-
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
|
| 111 |
-
}
|
| 112 |
-
if env.Code != 0 {
|
| 113 |
-
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
|
| 114 |
-
}
|
| 115 |
-
return env.Data, resp.StatusCode, nil
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
func fatal(format string, args ...any) {
|
| 119 |
-
fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...)
|
| 120 |
-
exitFunc(1)
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
type loginState struct {
|
| 124 |
-
State string `json:"state"`
|
| 125 |
-
Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
// realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。
|
| 129 |
-
const (
|
| 130 |
-
realmCN = "cn"
|
| 131 |
-
realmGlobal = "global"
|
| 132 |
-
)
|
| 133 |
-
|
| 134 |
-
// parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm <v>)flag,缺省 cn。
|
| 135 |
-
// 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。
|
| 136 |
-
func parseRealmArgs(args []string) (realm string, rest []string, err error) {
|
| 137 |
-
realm = realmCN
|
| 138 |
-
for i := 0; i < len(args); i++ {
|
| 139 |
-
a := args[i]
|
| 140 |
-
switch {
|
| 141 |
-
case a == "--realm":
|
| 142 |
-
if i+1 >= len(args) {
|
| 143 |
-
return "", nil, fmt.Errorf("--realm requires a value")
|
| 144 |
-
}
|
| 145 |
-
v := strings.ToLower(strings.TrimSpace(args[i+1]))
|
| 146 |
-
if v != realmCN && v != realmGlobal {
|
| 147 |
-
return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1])
|
| 148 |
-
}
|
| 149 |
-
realm = v
|
| 150 |
-
i++
|
| 151 |
-
case strings.HasPrefix(a, "--realm="):
|
| 152 |
-
v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm=")))
|
| 153 |
-
if v != realmCN && v != realmGlobal {
|
| 154 |
-
return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v)
|
| 155 |
-
}
|
| 156 |
-
realm = v
|
| 157 |
-
default:
|
| 158 |
-
rest = append(rest, a)
|
| 159 |
-
}
|
| 160 |
-
}
|
| 161 |
-
return realm, rest, nil
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
// resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支
|
| 165 |
-
// 的核心决策,可测)。规则:
|
| 166 |
-
//
|
| 167 |
-
// "1"/"cn"(大小写不敏感)/""(回车默认)→ cn
|
| 168 |
-
// "2"/"global" → global
|
| 169 |
-
// 其他 → ("", false)(调用方回默认 cn)
|
| 170 |
-
func resolveRealmInput(input string) (string, bool) {
|
| 171 |
-
switch strings.ToLower(strings.TrimSpace(input)) {
|
| 172 |
-
case "", "1", "cn":
|
| 173 |
-
return realmCN, true
|
| 174 |
-
case "2", "global":
|
| 175 |
-
return realmGlobal, true
|
| 176 |
-
}
|
| 177 |
-
return "", false
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
// promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身),
|
| 181 |
-
// 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。
|
| 182 |
-
func promptRealm(in io.Reader, out io.Writer) string {
|
| 183 |
-
fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ")
|
| 184 |
-
line, err := bufio.NewReader(in).ReadString('\n')
|
| 185 |
-
if err != nil && line == "" {
|
| 186 |
-
// EOF/非交互 → 回落默认 cn
|
| 187 |
-
return realmCN
|
| 188 |
-
}
|
| 189 |
-
if realm, ok := resolveRealmInput(line); ok {
|
| 190 |
-
return realm
|
| 191 |
-
}
|
| 192 |
-
fmt.Fprintln(out, "无效选择,默认国内版 cn")
|
| 193 |
-
return realmCN
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
// validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域):
|
| 197 |
-
// state 无 realm(旧文件)放行;非空且不一致 → error。
|
| 198 |
-
func validateRealmMatch(stateRealm, cliRealm string) error {
|
| 199 |
-
if stateRealm != "" && stateRealm != cliRealm {
|
| 200 |
-
return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm)
|
| 201 |
-
}
|
| 202 |
-
return nil
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
// runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL,
|
| 206 |
-
// state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径
|
| 207 |
-
// (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。
|
| 208 |
-
func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
|
| 209 |
-
headers := commonHeaders(origin)
|
| 210 |
-
data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}")))
|
| 211 |
-
if err != nil {
|
| 212 |
-
fatal("auth state failed: %v", err)
|
| 213 |
-
}
|
| 214 |
-
var st struct {
|
| 215 |
-
State string `json:"state"`
|
| 216 |
-
AuthURL string `json:"authUrl"`
|
| 217 |
-
}
|
| 218 |
-
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
|
| 219 |
-
fatal("auth state: missing state or authUrl")
|
| 220 |
-
}
|
| 221 |
-
raw, _ := json.Marshal(loginState{State: st.State, Realm: realm})
|
| 222 |
-
if err := os.WriteFile(statePath, raw, 0o600); err != nil {
|
| 223 |
-
fatal("write state: %v", err)
|
| 224 |
-
}
|
| 225 |
-
fmt.Fprintln(out, st.AuthURL)
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
// runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点
|
| 229 |
-
// GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。
|
| 230 |
-
// statePath 可注入临时文件便于测试。
|
| 231 |
-
func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
|
| 232 |
-
raw, err := os.ReadFile(statePath)
|
| 233 |
-
if err != nil {
|
| 234 |
-
fatal("read state: %v (先跑 login url)", err)
|
| 235 |
-
}
|
| 236 |
-
var ls loginState
|
| 237 |
-
if err := json.Unmarshal(raw, &ls); err != nil {
|
| 238 |
-
fatal("parse state: %v", err)
|
| 239 |
-
}
|
| 240 |
-
// 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域)
|
| 241 |
-
if err := validateRealmMatch(ls.Realm, realm); err != nil {
|
| 242 |
-
fatal("%v", err)
|
| 243 |
-
}
|
| 244 |
-
headers := commonHeaders(origin)
|
| 245 |
-
// handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点,
|
| 246 |
-
// pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle
|
| 247 |
-
tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil)
|
| 248 |
-
if errTok != nil {
|
| 249 |
-
if status == 0 || status >= 500 {
|
| 250 |
-
fatal("token endpoint error: %v", errTok)
|
| 251 |
-
}
|
| 252 |
-
fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
|
| 253 |
-
}
|
| 254 |
-
var tok struct {
|
| 255 |
-
AccessToken string `json:"accessToken"`
|
| 256 |
-
RefreshToken string `json:"refreshToken"`
|
| 257 |
-
ExpiresIn int64 `json:"expiresIn"`
|
| 258 |
-
Domain string `json:"domain"`
|
| 259 |
-
}
|
| 260 |
-
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
|
| 261 |
-
fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
|
| 262 |
-
}
|
| 263 |
-
// login/account 拿 uid/nickname(带 Bearer)
|
| 264 |
-
var acct struct {
|
| 265 |
-
UID string `json:"uid"`
|
| 266 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 267 |
-
Nickname string `json:"nickname"`
|
| 268 |
-
}
|
| 269 |
-
acctHeaders := func(r *http.Request) {
|
| 270 |
-
headers(r)
|
| 271 |
-
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
| 272 |
-
}
|
| 273 |
-
if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil {
|
| 274 |
-
_ = json.Unmarshal(acctRaw, &acct)
|
| 275 |
-
}
|
| 276 |
-
oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct))
|
| 277 |
-
fmt.Fprintln(out, string(oraw))
|
| 278 |
-
os.Remove(statePath)
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
// buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。
|
| 282 |
-
// realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断——
|
| 283 |
-
// 保证登录落盘的 auth 文件恒带 realm 键。
|
| 284 |
-
func buildLoginOutput(tok struct {
|
| 285 |
-
AccessToken string `json:"accessToken"`
|
| 286 |
-
RefreshToken string `json:"refreshToken"`
|
| 287 |
-
ExpiresIn int64 `json:"expiresIn"`
|
| 288 |
-
Domain string `json:"domain"`
|
| 289 |
-
}, realm string, acct struct {
|
| 290 |
-
UID string `json:"uid"`
|
| 291 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 292 |
-
Nickname string `json:"nickname"`
|
| 293 |
-
}) map[string]any {
|
| 294 |
-
return map[string]any{
|
| 295 |
-
"access_token": tok.AccessToken,
|
| 296 |
-
"refresh_token": tok.RefreshToken,
|
| 297 |
-
"expires_in": tok.ExpiresIn,
|
| 298 |
-
"domain": tok.Domain,
|
| 299 |
-
"realm": auth2.ResolveRealm(realm, tok.Domain),
|
| 300 |
-
"uid": acct.UID,
|
| 301 |
-
"enterprise_id": acct.EnterpriseID,
|
| 302 |
-
"nickname": acct.Nickname,
|
| 303 |
-
}
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
func main() {
|
| 307 |
-
realm, rest, err := parseRealmArgs(os.Args[1:])
|
| 308 |
-
if err != nil {
|
| 309 |
-
fatal("%v (usage: login [--realm=cn|global] <url|poll|realm>)", err)
|
| 310 |
-
}
|
| 311 |
-
if len(rest) < 1 {
|
| 312 |
-
fatal("usage: login [--realm=cn|global] <url|poll>")
|
| 313 |
-
}
|
| 314 |
-
// 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话)
|
| 315 |
-
jar, _ := cookiejar.New(nil)
|
| 316 |
-
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
|
| 317 |
-
|
| 318 |
-
base, origin := realmConfig(realm)
|
| 319 |
-
|
| 320 |
-
switch rest[0] {
|
| 321 |
-
case "url":
|
| 322 |
-
runURL(base, origin, realm, stateFile, client, os.Stdout)
|
| 323 |
-
|
| 324 |
-
case "poll":
|
| 325 |
-
runPoll(base, origin, realm, stateFile, client, os.Stdout)
|
| 326 |
-
|
| 327 |
-
case "realm":
|
| 328 |
-
// 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。
|
| 329 |
-
// 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。
|
| 330 |
-
realm := promptRealm(os.Stdin, os.Stderr)
|
| 331 |
-
fmt.Println(realm)
|
| 332 |
-
|
| 333 |
-
default:
|
| 334 |
-
fatal("unknown subcommand %q (want url|poll|realm)", rest[0])
|
| 335 |
-
}
|
| 336 |
-
}
|
|
|
|
| 1 |
+
// login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。
|
| 2 |
+
//
|
| 3 |
+
// 两个子命令,由 login.sh 顺序驱动:
|
| 4 |
+
//
|
| 5 |
+
// login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl,
|
| 6 |
+
// state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL
|
| 7 |
+
// login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次,
|
| 8 |
+
// 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname,
|
| 9 |
+
// stdout 打印完整 token+account JSON(含 realm 键)
|
| 10 |
+
//
|
| 11 |
+
// --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer:
|
| 12 |
+
//
|
| 13 |
+
// cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn)
|
| 14 |
+
// global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai)
|
| 15 |
+
//
|
| 16 |
+
// state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。
|
| 17 |
+
// 无 PKCE(workbuddy 设备流由服务端签发 state)。
|
| 18 |
+
package main
|
| 19 |
+
|
| 20 |
+
import (
|
| 21 |
+
"bufio"
|
| 22 |
+
"bytes"
|
| 23 |
+
"encoding/json"
|
| 24 |
+
"fmt"
|
| 25 |
+
"io"
|
| 26 |
+
"net/http"
|
| 27 |
+
"net/http/cookiejar"
|
| 28 |
+
"os"
|
| 29 |
+
"path/filepath"
|
| 30 |
+
"strings"
|
| 31 |
+
"time"
|
| 32 |
+
|
| 33 |
+
auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
// 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai
|
| 37 |
+
// (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。
|
| 38 |
+
const (
|
| 39 |
+
upstreamBaseCN = "https://copilot.tencent.com"
|
| 40 |
+
upstreamBaseGlobal = "https://www.workbuddy.ai"
|
| 41 |
+
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
|
| 42 |
+
originRefererCN = "https://www.codebuddy.cn"
|
| 43 |
+
originRefererGlobal = "https://www.workbuddy.ai"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
// 登录 state 落盘路径(var 便于测试替换临时文件)
|
| 47 |
+
// Portable across OSes: the upstream hardcoded "/tmp/...", which on
|
| 48 |
+
// Windows resolves to <drive>:\tmp\... and aborts the OAuth flow with
|
| 49 |
+
// "The system cannot find the path specified". os.TempDir() is /tmp on Linux.
|
| 50 |
+
var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json")
|
| 51 |
+
|
| 52 |
+
// exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。
|
| 53 |
+
var exitFunc = os.Exit
|
| 54 |
+
|
| 55 |
+
// realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global →
|
| 56 |
+
// (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。
|
| 57 |
+
func realmConfig(realm string) (base, origin string) {
|
| 58 |
+
if realm == realmGlobal {
|
| 59 |
+
return upstreamBaseGlobal, originRefererGlobal
|
| 60 |
+
}
|
| 61 |
+
return upstreamBaseCN, originRefererCN
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。
|
| 65 |
+
// 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。
|
| 66 |
+
func commonHeaders(origin string) func(*http.Request) {
|
| 67 |
+
return func(req *http.Request) {
|
| 68 |
+
req.Header.Set("Content-Type", "application/json")
|
| 69 |
+
req.Header.Set("Accept", "application/json, text/plain, */*")
|
| 70 |
+
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
| 71 |
+
req.Header.Set("Origin", origin)
|
| 72 |
+
req.Header.Set("Referer", origin+"/")
|
| 73 |
+
req.Header.Set("User-Agent", clientUA)
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// apiEnvelope 与 main.go:429-433 一致
|
| 78 |
+
type apiEnvelope struct {
|
| 79 |
+
Code int `json:"code"`
|
| 80 |
+
Msg string `json:"msg"`
|
| 81 |
+
Data json.RawMessage `json:"data"`
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error
|
| 85 |
+
func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
|
| 86 |
+
req, err := http.NewRequest(method, fullURL, body)
|
| 87 |
+
if err != nil {
|
| 88 |
+
return nil, 0, err
|
| 89 |
+
}
|
| 90 |
+
if headers != nil {
|
| 91 |
+
headers(req)
|
| 92 |
+
} else {
|
| 93 |
+
// 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归)
|
| 94 |
+
commonHeaders(originRefererCN)(req)
|
| 95 |
+
}
|
| 96 |
+
resp, err := client.Do(req)
|
| 97 |
+
if err != nil {
|
| 98 |
+
return nil, 0, err
|
| 99 |
+
}
|
| 100 |
+
defer resp.Body.Close()
|
| 101 |
+
raw, _ := io.ReadAll(resp.Body)
|
| 102 |
+
if resp.StatusCode >= 400 {
|
| 103 |
+
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
|
| 104 |
+
}
|
| 105 |
+
if resp.StatusCode >= 300 {
|
| 106 |
+
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode)
|
| 107 |
+
}
|
| 108 |
+
var env apiEnvelope
|
| 109 |
+
if err := json.Unmarshal(raw, &env); err != nil {
|
| 110 |
+
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
|
| 111 |
+
}
|
| 112 |
+
if env.Code != 0 {
|
| 113 |
+
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
|
| 114 |
+
}
|
| 115 |
+
return env.Data, resp.StatusCode, nil
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func fatal(format string, args ...any) {
|
| 119 |
+
fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...)
|
| 120 |
+
exitFunc(1)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
type loginState struct {
|
| 124 |
+
State string `json:"state"`
|
| 125 |
+
Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。
|
| 129 |
+
const (
|
| 130 |
+
realmCN = "cn"
|
| 131 |
+
realmGlobal = "global"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
// parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm <v>)flag,缺省 cn。
|
| 135 |
+
// 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。
|
| 136 |
+
func parseRealmArgs(args []string) (realm string, rest []string, err error) {
|
| 137 |
+
realm = realmCN
|
| 138 |
+
for i := 0; i < len(args); i++ {
|
| 139 |
+
a := args[i]
|
| 140 |
+
switch {
|
| 141 |
+
case a == "--realm":
|
| 142 |
+
if i+1 >= len(args) {
|
| 143 |
+
return "", nil, fmt.Errorf("--realm requires a value")
|
| 144 |
+
}
|
| 145 |
+
v := strings.ToLower(strings.TrimSpace(args[i+1]))
|
| 146 |
+
if v != realmCN && v != realmGlobal {
|
| 147 |
+
return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1])
|
| 148 |
+
}
|
| 149 |
+
realm = v
|
| 150 |
+
i++
|
| 151 |
+
case strings.HasPrefix(a, "--realm="):
|
| 152 |
+
v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm=")))
|
| 153 |
+
if v != realmCN && v != realmGlobal {
|
| 154 |
+
return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v)
|
| 155 |
+
}
|
| 156 |
+
realm = v
|
| 157 |
+
default:
|
| 158 |
+
rest = append(rest, a)
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
return realm, rest, nil
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支
|
| 165 |
+
// 的核心决策,可测)。规则:
|
| 166 |
+
//
|
| 167 |
+
// "1"/"cn"(大小写不敏感)/""(回车默认)→ cn
|
| 168 |
+
// "2"/"global" → global
|
| 169 |
+
// 其他 → ("", false)(调用方回默认 cn)
|
| 170 |
+
func resolveRealmInput(input string) (string, bool) {
|
| 171 |
+
switch strings.ToLower(strings.TrimSpace(input)) {
|
| 172 |
+
case "", "1", "cn":
|
| 173 |
+
return realmCN, true
|
| 174 |
+
case "2", "global":
|
| 175 |
+
return realmGlobal, true
|
| 176 |
+
}
|
| 177 |
+
return "", false
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身),
|
| 181 |
+
// 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。
|
| 182 |
+
func promptRealm(in io.Reader, out io.Writer) string {
|
| 183 |
+
fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ")
|
| 184 |
+
line, err := bufio.NewReader(in).ReadString('\n')
|
| 185 |
+
if err != nil && line == "" {
|
| 186 |
+
// EOF/非交互 → 回落默认 cn
|
| 187 |
+
return realmCN
|
| 188 |
+
}
|
| 189 |
+
if realm, ok := resolveRealmInput(line); ok {
|
| 190 |
+
return realm
|
| 191 |
+
}
|
| 192 |
+
fmt.Fprintln(out, "无效选择,默认国内版 cn")
|
| 193 |
+
return realmCN
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域):
|
| 197 |
+
// state 无 realm(旧文件)放行;非空且不一致 → error。
|
| 198 |
+
func validateRealmMatch(stateRealm, cliRealm string) error {
|
| 199 |
+
if stateRealm != "" && stateRealm != cliRealm {
|
| 200 |
+
return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm)
|
| 201 |
+
}
|
| 202 |
+
return nil
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL,
|
| 206 |
+
// state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径
|
| 207 |
+
// (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。
|
| 208 |
+
func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
|
| 209 |
+
headers := commonHeaders(origin)
|
| 210 |
+
data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}")))
|
| 211 |
+
if err != nil {
|
| 212 |
+
fatal("auth state failed: %v", err)
|
| 213 |
+
}
|
| 214 |
+
var st struct {
|
| 215 |
+
State string `json:"state"`
|
| 216 |
+
AuthURL string `json:"authUrl"`
|
| 217 |
+
}
|
| 218 |
+
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
|
| 219 |
+
fatal("auth state: missing state or authUrl")
|
| 220 |
+
}
|
| 221 |
+
raw, _ := json.Marshal(loginState{State: st.State, Realm: realm})
|
| 222 |
+
if err := os.WriteFile(statePath, raw, 0o600); err != nil {
|
| 223 |
+
fatal("write state: %v", err)
|
| 224 |
+
}
|
| 225 |
+
fmt.Fprintln(out, st.AuthURL)
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点
|
| 229 |
+
// GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。
|
| 230 |
+
// statePath 可注入临时文件便于测试。
|
| 231 |
+
func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
|
| 232 |
+
raw, err := os.ReadFile(statePath)
|
| 233 |
+
if err != nil {
|
| 234 |
+
fatal("read state: %v (先跑 login url)", err)
|
| 235 |
+
}
|
| 236 |
+
var ls loginState
|
| 237 |
+
if err := json.Unmarshal(raw, &ls); err != nil {
|
| 238 |
+
fatal("parse state: %v", err)
|
| 239 |
+
}
|
| 240 |
+
// 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域)
|
| 241 |
+
if err := validateRealmMatch(ls.Realm, realm); err != nil {
|
| 242 |
+
fatal("%v", err)
|
| 243 |
+
}
|
| 244 |
+
headers := commonHeaders(origin)
|
| 245 |
+
// handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点,
|
| 246 |
+
// pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle
|
| 247 |
+
tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil)
|
| 248 |
+
if errTok != nil {
|
| 249 |
+
if status == 0 || status >= 500 {
|
| 250 |
+
fatal("token endpoint error: %v", errTok)
|
| 251 |
+
}
|
| 252 |
+
fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
|
| 253 |
+
}
|
| 254 |
+
var tok struct {
|
| 255 |
+
AccessToken string `json:"accessToken"`
|
| 256 |
+
RefreshToken string `json:"refreshToken"`
|
| 257 |
+
ExpiresIn int64 `json:"expiresIn"`
|
| 258 |
+
Domain string `json:"domain"`
|
| 259 |
+
}
|
| 260 |
+
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
|
| 261 |
+
fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
|
| 262 |
+
}
|
| 263 |
+
// login/account 拿 uid/nickname(带 Bearer)
|
| 264 |
+
var acct struct {
|
| 265 |
+
UID string `json:"uid"`
|
| 266 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 267 |
+
Nickname string `json:"nickname"`
|
| 268 |
+
}
|
| 269 |
+
acctHeaders := func(r *http.Request) {
|
| 270 |
+
headers(r)
|
| 271 |
+
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
| 272 |
+
}
|
| 273 |
+
if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil {
|
| 274 |
+
_ = json.Unmarshal(acctRaw, &acct)
|
| 275 |
+
}
|
| 276 |
+
oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct))
|
| 277 |
+
fmt.Fprintln(out, string(oraw))
|
| 278 |
+
os.Remove(statePath)
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。
|
| 282 |
+
// realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断——
|
| 283 |
+
// 保证登录落盘的 auth 文件恒带 realm 键。
|
| 284 |
+
func buildLoginOutput(tok struct {
|
| 285 |
+
AccessToken string `json:"accessToken"`
|
| 286 |
+
RefreshToken string `json:"refreshToken"`
|
| 287 |
+
ExpiresIn int64 `json:"expiresIn"`
|
| 288 |
+
Domain string `json:"domain"`
|
| 289 |
+
}, realm string, acct struct {
|
| 290 |
+
UID string `json:"uid"`
|
| 291 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 292 |
+
Nickname string `json:"nickname"`
|
| 293 |
+
}) map[string]any {
|
| 294 |
+
return map[string]any{
|
| 295 |
+
"access_token": tok.AccessToken,
|
| 296 |
+
"refresh_token": tok.RefreshToken,
|
| 297 |
+
"expires_in": tok.ExpiresIn,
|
| 298 |
+
"domain": tok.Domain,
|
| 299 |
+
"realm": auth2.ResolveRealm(realm, tok.Domain),
|
| 300 |
+
"uid": acct.UID,
|
| 301 |
+
"enterprise_id": acct.EnterpriseID,
|
| 302 |
+
"nickname": acct.Nickname,
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
func main() {
|
| 307 |
+
realm, rest, err := parseRealmArgs(os.Args[1:])
|
| 308 |
+
if err != nil {
|
| 309 |
+
fatal("%v (usage: login [--realm=cn|global] <url|poll|realm>)", err)
|
| 310 |
+
}
|
| 311 |
+
if len(rest) < 1 {
|
| 312 |
+
fatal("usage: login [--realm=cn|global] <url|poll>")
|
| 313 |
+
}
|
| 314 |
+
// 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话)
|
| 315 |
+
jar, _ := cookiejar.New(nil)
|
| 316 |
+
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
|
| 317 |
+
|
| 318 |
+
base, origin := realmConfig(realm)
|
| 319 |
+
|
| 320 |
+
switch rest[0] {
|
| 321 |
+
case "url":
|
| 322 |
+
runURL(base, origin, realm, stateFile, client, os.Stdout)
|
| 323 |
+
|
| 324 |
+
case "poll":
|
| 325 |
+
runPoll(base, origin, realm, stateFile, client, os.Stdout)
|
| 326 |
+
|
| 327 |
+
case "realm":
|
| 328 |
+
// 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。
|
| 329 |
+
// 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。
|
| 330 |
+
realm := promptRealm(os.Stdin, os.Stderr)
|
| 331 |
+
fmt.Println(realm)
|
| 332 |
+
|
| 333 |
+
default:
|
| 334 |
+
fatal("unknown subcommand %q (want url|poll|realm)", rest[0])
|
| 335 |
+
}
|
| 336 |
+
}
|
cmd/login/realm_test.go
CHANGED
|
@@ -1,169 +1,169 @@
|
|
| 1 |
-
package main
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"bytes"
|
| 5 |
-
"reflect"
|
| 6 |
-
"strings"
|
| 7 |
-
"testing"
|
| 8 |
-
)
|
| 9 |
-
|
| 10 |
-
// TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、
|
| 11 |
-
// 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。
|
| 12 |
-
func TestParseRealmArgs(t *testing.T) {
|
| 13 |
-
cases := []struct {
|
| 14 |
-
name string
|
| 15 |
-
args []string
|
| 16 |
-
wantRealm string
|
| 17 |
-
wantRest []string
|
| 18 |
-
wantErr bool
|
| 19 |
-
}{
|
| 20 |
-
{name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}},
|
| 21 |
-
{name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 22 |
-
{name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}},
|
| 23 |
-
{name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 24 |
-
{name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true},
|
| 25 |
-
{name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true},
|
| 26 |
-
{name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 27 |
-
}
|
| 28 |
-
for _, c := range cases {
|
| 29 |
-
t.Run(c.name, func(t *testing.T) {
|
| 30 |
-
realm, rest, err := parseRealmArgs(c.args)
|
| 31 |
-
if c.wantErr {
|
| 32 |
-
if err == nil {
|
| 33 |
-
t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args)
|
| 34 |
-
}
|
| 35 |
-
return
|
| 36 |
-
}
|
| 37 |
-
if err != nil {
|
| 38 |
-
t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err)
|
| 39 |
-
}
|
| 40 |
-
if realm != c.wantRealm {
|
| 41 |
-
t.Errorf("realm=%q want %q", realm, c.wantRealm)
|
| 42 |
-
}
|
| 43 |
-
if !reflect.DeepEqual(rest, c.wantRest) {
|
| 44 |
-
t.Errorf("rest=%v want %v", rest, c.wantRest)
|
| 45 |
-
}
|
| 46 |
-
})
|
| 47 |
-
}
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
// TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策):
|
| 51 |
-
// "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。
|
| 52 |
-
func TestResolveRealmInput(t *testing.T) {
|
| 53 |
-
cases := []struct {
|
| 54 |
-
input string
|
| 55 |
-
want string
|
| 56 |
-
}{
|
| 57 |
-
{input: "1", want: "cn"},
|
| 58 |
-
{input: "cn", want: "cn"},
|
| 59 |
-
{input: "CN", want: "cn"},
|
| 60 |
-
{input: "", want: "cn"}, // 回车默认
|
| 61 |
-
{input: "2", want: "global"},
|
| 62 |
-
{input: "global", want: "global"},
|
| 63 |
-
{input: "GLOBAL", want: "global"},
|
| 64 |
-
}
|
| 65 |
-
for _, c := range cases {
|
| 66 |
-
got, ok := resolveRealmInput(c.input)
|
| 67 |
-
if !ok {
|
| 68 |
-
t.Errorf("resolveRealmInput(%q) ok=false want true", c.input)
|
| 69 |
-
continue
|
| 70 |
-
}
|
| 71 |
-
if got != c.want {
|
| 72 |
-
t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want)
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
// 非法输入 → (false)。
|
| 76 |
-
for _, bad := range []string{"3", "cnn", "globalx", "foo"} {
|
| 77 |
-
if got, ok := resolveRealmInput(bad); ok {
|
| 78 |
-
t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got)
|
| 79 |
-
}
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
// TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm):
|
| 84 |
-
//
|
| 85 |
-
// 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示;
|
| 86 |
-
// 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。
|
| 87 |
-
func TestPromptRealm(t *testing.T) {
|
| 88 |
-
cases := []struct {
|
| 89 |
-
name string
|
| 90 |
-
input string
|
| 91 |
-
want string
|
| 92 |
-
wantHint bool // 输出中出现"选择登录版本"提示
|
| 93 |
-
}{
|
| 94 |
-
{name: "选 cn", input: "1\n", want: "cn", wantHint: true},
|
| 95 |
-
{name: "选 global", input: "2\n", want: "global", wantHint: true},
|
| 96 |
-
{name: "回车默认 cn", input: "\n", want: "cn", wantHint: true},
|
| 97 |
-
{name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true},
|
| 98 |
-
{name: "EOF 回落 cn", input: "", want: "cn", wantHint: true},
|
| 99 |
-
}
|
| 100 |
-
for _, c := range cases {
|
| 101 |
-
t.Run(c.name, func(t *testing.T) {
|
| 102 |
-
var out bytes.Buffer
|
| 103 |
-
got := promptRealm(strings.NewReader(c.input), &out)
|
| 104 |
-
if got != c.want {
|
| 105 |
-
t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want)
|
| 106 |
-
}
|
| 107 |
-
if c.wantHint && !strings.Contains(out.String(), "选择登录版本") {
|
| 108 |
-
t.Errorf("prompt should contain '选择登录版本', got %q", out.String())
|
| 109 |
-
}
|
| 110 |
-
})
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
// TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用):
|
| 115 |
-
// 子命令剥离 --realm,剩余参数为首个 "realm"。
|
| 116 |
-
func TestRealmSubcommandSelection(t *testing.T) {
|
| 117 |
-
realm, rest, err := parseRealmArgs([]string{"realm"})
|
| 118 |
-
if err != nil {
|
| 119 |
-
t.Fatalf("parseRealmArgs(realm) err=%v", err)
|
| 120 |
-
}
|
| 121 |
-
if realm != "cn" {
|
| 122 |
-
t.Errorf("realm default=%q want cn", realm)
|
| 123 |
-
}
|
| 124 |
-
if len(rest) != 1 || rest[0] != "realm" {
|
| 125 |
-
t.Errorf("rest=%v want [realm]", rest)
|
| 126 |
-
}
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
// TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键:
|
| 130 |
-
// 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。
|
| 131 |
-
// 这是「登录落盘永远带 realm 标识」契约的测试载体。
|
| 132 |
-
func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) {
|
| 133 |
-
tok := struct {
|
| 134 |
-
AccessToken string `json:"accessToken"`
|
| 135 |
-
RefreshToken string `json:"refreshToken"`
|
| 136 |
-
ExpiresIn int64 `json:"expiresIn"`
|
| 137 |
-
Domain string `json:"domain"`
|
| 138 |
-
}{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"}
|
| 139 |
-
acct := struct {
|
| 140 |
-
UID string `json:"uid"`
|
| 141 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 142 |
-
Nickname string `json:"nickname"`
|
| 143 |
-
}{UID: "u1", Nickname: "n1"}
|
| 144 |
-
|
| 145 |
-
cases := []struct {
|
| 146 |
-
name string
|
| 147 |
-
realm string
|
| 148 |
-
domain string
|
| 149 |
-
wantRealm string
|
| 150 |
-
}{
|
| 151 |
-
{name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"},
|
| 152 |
-
{name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"},
|
| 153 |
-
{name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"},
|
| 154 |
-
{name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"},
|
| 155 |
-
}
|
| 156 |
-
for _, c := range cases {
|
| 157 |
-
t.Run(c.name, func(t *testing.T) {
|
| 158 |
-
tok.Domain = c.domain
|
| 159 |
-
out := buildLoginOutput(tok, c.realm, acct)
|
| 160 |
-
m, ok := out["realm"].(string)
|
| 161 |
-
if !ok {
|
| 162 |
-
t.Fatalf("missing realm key in login output: %v", out)
|
| 163 |
-
}
|
| 164 |
-
if m != c.wantRealm {
|
| 165 |
-
t.Errorf("realm=%q want %q", m, c.wantRealm)
|
| 166 |
-
}
|
| 167 |
-
})
|
| 168 |
-
}
|
| 169 |
-
}
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"reflect"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、
|
| 11 |
+
// 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。
|
| 12 |
+
func TestParseRealmArgs(t *testing.T) {
|
| 13 |
+
cases := []struct {
|
| 14 |
+
name string
|
| 15 |
+
args []string
|
| 16 |
+
wantRealm string
|
| 17 |
+
wantRest []string
|
| 18 |
+
wantErr bool
|
| 19 |
+
}{
|
| 20 |
+
{name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}},
|
| 21 |
+
{name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 22 |
+
{name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}},
|
| 23 |
+
{name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 24 |
+
{name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true},
|
| 25 |
+
{name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true},
|
| 26 |
+
{name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}},
|
| 27 |
+
}
|
| 28 |
+
for _, c := range cases {
|
| 29 |
+
t.Run(c.name, func(t *testing.T) {
|
| 30 |
+
realm, rest, err := parseRealmArgs(c.args)
|
| 31 |
+
if c.wantErr {
|
| 32 |
+
if err == nil {
|
| 33 |
+
t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args)
|
| 34 |
+
}
|
| 35 |
+
return
|
| 36 |
+
}
|
| 37 |
+
if err != nil {
|
| 38 |
+
t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err)
|
| 39 |
+
}
|
| 40 |
+
if realm != c.wantRealm {
|
| 41 |
+
t.Errorf("realm=%q want %q", realm, c.wantRealm)
|
| 42 |
+
}
|
| 43 |
+
if !reflect.DeepEqual(rest, c.wantRest) {
|
| 44 |
+
t.Errorf("rest=%v want %v", rest, c.wantRest)
|
| 45 |
+
}
|
| 46 |
+
})
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策):
|
| 51 |
+
// "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。
|
| 52 |
+
func TestResolveRealmInput(t *testing.T) {
|
| 53 |
+
cases := []struct {
|
| 54 |
+
input string
|
| 55 |
+
want string
|
| 56 |
+
}{
|
| 57 |
+
{input: "1", want: "cn"},
|
| 58 |
+
{input: "cn", want: "cn"},
|
| 59 |
+
{input: "CN", want: "cn"},
|
| 60 |
+
{input: "", want: "cn"}, // 回车默认
|
| 61 |
+
{input: "2", want: "global"},
|
| 62 |
+
{input: "global", want: "global"},
|
| 63 |
+
{input: "GLOBAL", want: "global"},
|
| 64 |
+
}
|
| 65 |
+
for _, c := range cases {
|
| 66 |
+
got, ok := resolveRealmInput(c.input)
|
| 67 |
+
if !ok {
|
| 68 |
+
t.Errorf("resolveRealmInput(%q) ok=false want true", c.input)
|
| 69 |
+
continue
|
| 70 |
+
}
|
| 71 |
+
if got != c.want {
|
| 72 |
+
t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want)
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
// 非法输入 → (false)。
|
| 76 |
+
for _, bad := range []string{"3", "cnn", "globalx", "foo"} {
|
| 77 |
+
if got, ok := resolveRealmInput(bad); ok {
|
| 78 |
+
t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got)
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm):
|
| 84 |
+
//
|
| 85 |
+
// 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示;
|
| 86 |
+
// 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。
|
| 87 |
+
func TestPromptRealm(t *testing.T) {
|
| 88 |
+
cases := []struct {
|
| 89 |
+
name string
|
| 90 |
+
input string
|
| 91 |
+
want string
|
| 92 |
+
wantHint bool // 输出中出现"选择登录版本"提示
|
| 93 |
+
}{
|
| 94 |
+
{name: "选 cn", input: "1\n", want: "cn", wantHint: true},
|
| 95 |
+
{name: "选 global", input: "2\n", want: "global", wantHint: true},
|
| 96 |
+
{name: "回车默认 cn", input: "\n", want: "cn", wantHint: true},
|
| 97 |
+
{name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true},
|
| 98 |
+
{name: "EOF 回落 cn", input: "", want: "cn", wantHint: true},
|
| 99 |
+
}
|
| 100 |
+
for _, c := range cases {
|
| 101 |
+
t.Run(c.name, func(t *testing.T) {
|
| 102 |
+
var out bytes.Buffer
|
| 103 |
+
got := promptRealm(strings.NewReader(c.input), &out)
|
| 104 |
+
if got != c.want {
|
| 105 |
+
t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want)
|
| 106 |
+
}
|
| 107 |
+
if c.wantHint && !strings.Contains(out.String(), "选择登录版本") {
|
| 108 |
+
t.Errorf("prompt should contain '选择登录版本', got %q", out.String())
|
| 109 |
+
}
|
| 110 |
+
})
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用):
|
| 115 |
+
// 子命令剥离 --realm,剩余参数为首个 "realm"。
|
| 116 |
+
func TestRealmSubcommandSelection(t *testing.T) {
|
| 117 |
+
realm, rest, err := parseRealmArgs([]string{"realm"})
|
| 118 |
+
if err != nil {
|
| 119 |
+
t.Fatalf("parseRealmArgs(realm) err=%v", err)
|
| 120 |
+
}
|
| 121 |
+
if realm != "cn" {
|
| 122 |
+
t.Errorf("realm default=%q want cn", realm)
|
| 123 |
+
}
|
| 124 |
+
if len(rest) != 1 || rest[0] != "realm" {
|
| 125 |
+
t.Errorf("rest=%v want [realm]", rest)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键:
|
| 130 |
+
// 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。
|
| 131 |
+
// 这是「登录落盘永远带 realm 标识」契约的测试载体。
|
| 132 |
+
func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) {
|
| 133 |
+
tok := struct {
|
| 134 |
+
AccessToken string `json:"accessToken"`
|
| 135 |
+
RefreshToken string `json:"refreshToken"`
|
| 136 |
+
ExpiresIn int64 `json:"expiresIn"`
|
| 137 |
+
Domain string `json:"domain"`
|
| 138 |
+
}{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"}
|
| 139 |
+
acct := struct {
|
| 140 |
+
UID string `json:"uid"`
|
| 141 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 142 |
+
Nickname string `json:"nickname"`
|
| 143 |
+
}{UID: "u1", Nickname: "n1"}
|
| 144 |
+
|
| 145 |
+
cases := []struct {
|
| 146 |
+
name string
|
| 147 |
+
realm string
|
| 148 |
+
domain string
|
| 149 |
+
wantRealm string
|
| 150 |
+
}{
|
| 151 |
+
{name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"},
|
| 152 |
+
{name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"},
|
| 153 |
+
{name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"},
|
| 154 |
+
{name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"},
|
| 155 |
+
}
|
| 156 |
+
for _, c := range cases {
|
| 157 |
+
t.Run(c.name, func(t *testing.T) {
|
| 158 |
+
tok.Domain = c.domain
|
| 159 |
+
out := buildLoginOutput(tok, c.realm, acct)
|
| 160 |
+
m, ok := out["realm"].(string)
|
| 161 |
+
if !ok {
|
| 162 |
+
t.Fatalf("missing realm key in login output: %v", out)
|
| 163 |
+
}
|
| 164 |
+
if m != c.wantRealm {
|
| 165 |
+
t.Errorf("realm=%q want %q", m, c.wantRealm)
|
| 166 |
+
}
|
| 167 |
+
})
|
| 168 |
+
}
|
| 169 |
+
}
|
cmd/server/config.go
CHANGED
|
@@ -1,616 +1,658 @@
|
|
| 1 |
-
// config.go 加载 JSON 配置 + 环境变量覆盖。
|
| 2 |
-
package main
|
| 3 |
-
|
| 4 |
-
import (
|
| 5 |
-
"crypto/rand"
|
| 6 |
-
"encoding/base64"
|
| 7 |
-
"encoding/json"
|
| 8 |
-
"fmt"
|
| 9 |
-
"os"
|
| 10 |
-
"path/filepath"
|
| 11 |
-
"strconv"
|
| 12 |
-
"strings"
|
| 13 |
-
"time"
|
| 14 |
-
|
| 15 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/prompt"
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
// Config 顶层配置。
|
| 19 |
-
type Config struct {
|
| 20 |
-
Listen string `json:"listen"` // ":7863"
|
| 21 |
-
APIKey string `json:"api_key"` // 空 = 不鉴权
|
| 22 |
-
AuthDir string `json:"auth_dir"` // ./auths
|
| 23 |
-
StateFile string `json:"state_file"` // ./data/state.json
|
| 24 |
-
|
| 25 |
-
Panel struct {
|
| 26 |
-
// PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。
|
| 27 |
-
PackageDetailLimit int `json:"package_detail_limit"`
|
| 28 |
-
} `json:"panel"`
|
| 29 |
-
|
| 30 |
-
Logging struct {
|
| 31 |
-
// RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。
|
| 32 |
-
RequestArchiveEnabled bool `json:"request_archive_enabled"`
|
| 33 |
-
// RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。
|
| 34 |
-
RequestRetentionDays int `json:"request_retention_days"`
|
| 35 |
-
// RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。
|
| 36 |
-
RequestArchiveMaxMB int `json:"request_archive_max_mb"`
|
| 37 |
-
// RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行
|
| 38 |
-
// 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。
|
| 39 |
-
//
|
| 40 |
-
// 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索,
|
| 41 |
-
// 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要
|
| 42 |
-
// 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。
|
| 43 |
-
// 热生效(经 livecfg 快照),无需重启。
|
| 44 |
-
RequestClientInfo bool `json:"request_client_info"`
|
| 45 |
-
} `json:"logging"`
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
//
|
| 49 |
-
//
|
| 50 |
-
// 旧
|
| 51 |
-
|
| 52 |
-
//
|
| 53 |
-
//
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
//
|
| 69 |
-
//
|
| 70 |
-
//
|
| 71 |
-
//
|
| 72 |
-
//
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
//
|
| 81 |
-
//
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
//
|
| 88 |
-
|
| 89 |
-
//
|
| 90 |
-
//
|
| 91 |
-
//
|
| 92 |
-
|
| 93 |
-
//
|
| 94 |
-
//
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
//
|
| 101 |
-
|
| 102 |
-
//
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
//
|
| 107 |
-
|
| 108 |
-
//
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
//
|
| 114 |
-
|
| 115 |
-
//
|
| 116 |
-
//
|
| 117 |
-
|
| 118 |
-
//
|
| 119 |
-
//
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
//
|
| 134 |
-
|
| 135 |
-
//
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
//
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
//
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
//
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
//
|
| 177 |
-
//
|
| 178 |
-
// 默认
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
//
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
//
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
c
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
c.
|
| 243 |
-
c.
|
| 244 |
-
c.
|
| 245 |
-
c.
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
//
|
| 249 |
-
|
| 250 |
-
c.
|
| 251 |
-
c.
|
| 252 |
-
c.
|
| 253 |
-
c.
|
| 254 |
-
c.
|
| 255 |
-
c.
|
| 256 |
-
c.
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
c.
|
| 260 |
-
|
| 261 |
-
c.
|
| 262 |
-
c.
|
| 263 |
-
c.
|
| 264 |
-
c.
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
//
|
| 269 |
-
|
| 270 |
-
c
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
c
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
if
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
}
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
}
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
}
|
| 410 |
-
if v := os.Getenv("
|
| 411 |
-
c.
|
| 412 |
-
}
|
| 413 |
-
if v := os.Getenv("
|
| 414 |
-
c.
|
| 415 |
-
}
|
| 416 |
-
if v := os.Getenv("
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
}
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
if
|
| 426 |
-
c.
|
| 427 |
-
}
|
| 428 |
-
if
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
if
|
| 439 |
-
c.
|
| 440 |
-
}
|
| 441 |
-
if
|
| 442 |
-
|
| 443 |
-
}
|
| 444 |
-
if
|
| 445 |
-
|
| 446 |
-
}
|
| 447 |
-
if
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
if c.
|
| 457 |
-
|
| 458 |
-
}
|
| 459 |
-
if c.
|
| 460 |
-
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
if c.Pool.
|
| 493 |
-
|
| 494 |
-
}
|
| 495 |
-
if c.Pool.
|
| 496 |
-
|
| 497 |
-
}
|
| 498 |
-
if c.
|
| 499 |
-
|
| 500 |
-
}
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
if c.Pool.
|
| 506 |
-
c.Pool.
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
c.
|
| 513 |
-
}
|
| 514 |
-
//
|
| 515 |
-
//
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
//
|
| 527 |
-
if
|
| 528 |
-
c.
|
| 529 |
-
}
|
| 530 |
-
if
|
| 531 |
-
c.
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
}
|
| 575 |
-
if c.
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
}
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
//
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
}
|
| 594 |
-
if err :=
|
| 595 |
-
return err
|
| 596 |
-
}
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// config.go 加载 JSON 配置 + 环境变量覆盖。
|
| 2 |
+
package main
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"crypto/rand"
|
| 6 |
+
"encoding/base64"
|
| 7 |
+
"encoding/json"
|
| 8 |
+
"fmt"
|
| 9 |
+
"os"
|
| 10 |
+
"path/filepath"
|
| 11 |
+
"strconv"
|
| 12 |
+
"strings"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/prompt"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// Config 顶层配置。
|
| 19 |
+
type Config struct {
|
| 20 |
+
Listen string `json:"listen"` // ":7863"
|
| 21 |
+
APIKey string `json:"api_key"` // 空 = 不鉴权
|
| 22 |
+
AuthDir string `json:"auth_dir"` // ./auths
|
| 23 |
+
StateFile string `json:"state_file"` // ./data/state.json
|
| 24 |
+
|
| 25 |
+
Panel struct {
|
| 26 |
+
// PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。
|
| 27 |
+
PackageDetailLimit int `json:"package_detail_limit"`
|
| 28 |
+
} `json:"panel"`
|
| 29 |
+
|
| 30 |
+
Logging struct {
|
| 31 |
+
// RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。
|
| 32 |
+
RequestArchiveEnabled bool `json:"request_archive_enabled"`
|
| 33 |
+
// RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。
|
| 34 |
+
RequestRetentionDays int `json:"request_retention_days"`
|
| 35 |
+
// RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。
|
| 36 |
+
RequestArchiveMaxMB int `json:"request_archive_max_mb"`
|
| 37 |
+
// RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行
|
| 38 |
+
// 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。
|
| 39 |
+
//
|
| 40 |
+
// 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索,
|
| 41 |
+
// 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要
|
| 42 |
+
// 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。
|
| 43 |
+
// 热生效(经 livecfg 快照),无需重启。
|
| 44 |
+
RequestClientInfo bool `json:"request_client_info"`
|
| 45 |
+
} `json:"logging"`
|
| 46 |
+
|
| 47 |
+
Server struct {
|
| 48 |
+
// ReadTimeout 入站请求读取(含 body 上传)总时长上限(issue #100)。
|
| 49 |
+
// http.Server 的 ReadTimeout 覆盖整个请求读取:大上下文/文件块请求经
|
| 50 |
+
// 反代链转发时上传可超过旧固定值 60s,被掐后客户端拿到
|
| 51 |
+
// 400 "read body: ... i/o timeout"。缺省 "300s";"0" = 不限制
|
| 52 |
+
//(慢速 body 可无限占用连接,自担风险);改动需重启进程。
|
| 53 |
+
ReadTimeout string `json:"read_timeout"` // "300s";"0" = 不限制
|
| 54 |
+
} `json:"server"`
|
| 55 |
+
|
| 56 |
+
Cooldown struct {
|
| 57 |
+
// hard_credit / err_threshold / err_cooldown 三个历史键已退役:
|
| 58 |
+
// 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。
|
| 59 |
+
// 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。
|
| 60 |
+
SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数
|
| 61 |
+
// SoftRateMax 软冷却指数退避的封顶,默认 "2h"。
|
| 62 |
+
// 空值回落默认,非法值报错(处理风格同 soft_rate)。
|
| 63 |
+
SoftRateMax string `json:"soft_rate_max"` // "2h"
|
| 64 |
+
} `json:"cooldown"`
|
| 65 |
+
|
| 66 |
+
Schedule struct {
|
| 67 |
+
CheckinHours []int `json:"checkin_hours"` // [9,21]
|
| 68 |
+
TravelHours []int `json:"travel_hours"` // [9,21]
|
| 69 |
+
ActivityHours []int `json:"activity_hours"` // [10]
|
| 70 |
+
KeepaliveHours []int `json:"keepalive_hours"` // [22]
|
| 71 |
+
BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数)
|
| 72 |
+
GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行)
|
| 73 |
+
// CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。
|
| 74 |
+
//
|
| 75 |
+
// 为什么用独立 bool 而不是空数组/哨兵值表意"禁用":
|
| 76 |
+
// - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转
|
| 77 |
+
// 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true
|
| 78 |
+
// 则对老配置零影响,向后完全兼容。
|
| 79 |
+
// - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。
|
| 80 |
+
// - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。
|
| 81 |
+
// 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。
|
| 82 |
+
CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到
|
| 83 |
+
TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行
|
| 84 |
+
ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报
|
| 85 |
+
KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活
|
| 86 |
+
BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子
|
| 87 |
+
GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程
|
| 88 |
+
|
| 89 |
+
// IncludeDisabledInTasks 让「保号类」定时任务(签到 / 活跃上报 / token 保活 /
|
| 90 |
+
// 余额刷新)对**已禁用(disabled)**的账号也执行。
|
| 91 |
+
//
|
| 92 |
+
// 为什么需要它:面板「禁用」的语义是「不再参与选号」(见面板确认文案),但这四类
|
| 93 |
+
// 任务此前一律 `if st.Disabled { continue }`,等于把「停用流量」放大成「停止一切
|
| 94 |
+
// 上游保号行为」——被禁用的号拿不到签到积分、不续 token、余额也不再刷新;而
|
| 95 |
+
// ReenableIfCredits 明确不复活 disabled 账号(见 pool.state.go),于是签到这条唯一
|
| 96 |
+
// 的自动回血路径也断了,账号只能靠人工「解冻」回来。
|
| 97 |
+
//
|
| 98 |
+
// 对「一次只放开一个号、用禁用做流量开关」的轮换用法(同 IP 多号防风控),闲置
|
| 99 |
+
// 待命的号恰恰是最需要签到的那批——本开关即为该用法提供出口。
|
| 100 |
+
//
|
| 101 |
+
// 缺省 false = 保持既有行为,对老配置零影响。打开后禁用号仍会签到 / 保活,但
|
| 102 |
+
// **依旧不参与选号**:pool 选号侧的 disabled 过滤不受本开关影响。
|
| 103 |
+
IncludeDisabledInTasks bool `json:"include_disabled_in_tasks"`
|
| 104 |
+
|
| 105 |
+
// 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。
|
| 106 |
+
// 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。
|
| 107 |
+
BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭
|
| 108 |
+
BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5
|
| 109 |
+
} `json:"schedule"`
|
| 110 |
+
|
| 111 |
+
Global struct {
|
| 112 |
+
// Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/
|
| 113 |
+
// domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。
|
| 114 |
+
// 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global
|
| 115 |
+
// 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号
|
| 116 |
+
// 恒判 cn,global base 只在 realm=global 的账号上被使用。
|
| 117 |
+
Enabled bool `json:"enabled"`
|
| 118 |
+
// ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认
|
| 119 |
+
// https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。
|
| 120 |
+
ChatBase string `json:"chat_base"`
|
| 121 |
+
BillingBase string `json:"billing_base"`
|
| 122 |
+
} `json:"global"`
|
| 123 |
+
|
| 124 |
+
Upstream struct {
|
| 125 |
+
// TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。
|
| 126 |
+
TimeoutSeconds int `json:"timeout_seconds"`
|
| 127 |
+
// HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。
|
| 128 |
+
HeaderTimeoutSeconds int `json:"header_timeout_seconds"`
|
| 129 |
+
// IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。
|
| 130 |
+
IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
|
| 131 |
+
// UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。
|
| 132 |
+
// 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。
|
| 133 |
+
// 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。
|
| 134 |
+
UserAgent string `json:"user_agent"`
|
| 135 |
+
// ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` 与归属头
|
| 136 |
+
// X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。
|
| 137 |
+
ClientVersion string `json:"client_version"`
|
| 138 |
+
// CliVersion 出站 UA 中 `CLI/<ver>` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。
|
| 139 |
+
CliVersion string `json:"cli_version"`
|
| 140 |
+
// ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。
|
| 141 |
+
// 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。
|
| 142 |
+
ClientName string `json:"client_name"`
|
| 143 |
+
// DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。
|
| 144 |
+
// 每号 auth 文件的 device_token 键优先于本项。
|
| 145 |
+
DeviceToken string `json:"device_token"`
|
| 146 |
+
// DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。
|
| 147 |
+
DeviceTokenFile string `json:"device_token_file"`
|
| 148 |
+
// PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。
|
| 149 |
+
PassthroughIP bool `json:"passthrough_ip"`
|
| 150 |
+
} `json:"upstream"`
|
| 151 |
+
|
| 152 |
+
Features struct {
|
| 153 |
+
// SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。
|
| 154 |
+
SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"`
|
| 155 |
+
} `json:"features"`
|
| 156 |
+
|
| 157 |
+
Prompt struct {
|
| 158 |
+
// Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded);
|
| 159 |
+
// custom = 网关用自有系统提示词替换客户端 system/developer;
|
| 160 |
+
// append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。
|
| 161 |
+
Mode string `json:"mode"` // "passthrough" / "custom" / "append"
|
| 162 |
+
// File 提示词文件路径;空 = 内置默认 defaultprompt.md;
|
| 163 |
+
// 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。
|
| 164 |
+
File string `json:"file"`
|
| 165 |
+
} `json:"prompt"`
|
| 166 |
+
|
| 167 |
+
// PromptText 解析后的系统提示词文本(custom/append 模式使用)。
|
| 168 |
+
PromptText string `json:"-"`
|
| 169 |
+
|
| 170 |
+
Upstash struct {
|
| 171 |
+
URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host
|
| 172 |
+
Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:<token>@<host>:6379
|
| 173 |
+
} `json:"upstash"`
|
| 174 |
+
|
| 175 |
+
Pool struct {
|
| 176 |
+
MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限
|
| 177 |
+
MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2
|
| 178 |
+
BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3
|
| 179 |
+
BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m"
|
| 180 |
+
BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h"
|
| 181 |
+
// 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈
|
| 182 |
+
// 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。
|
| 183 |
+
DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5
|
| 184 |
+
DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m"
|
| 185 |
+
DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制)
|
| 186 |
+
IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重
|
| 187 |
+
IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0
|
| 188 |
+
// PreferExpiring 快过期积分加权开关,默认 true。开启且 expiring_soon 窗口内
|
| 189 |
+
// 存在有效批次时,该账号选号权重 ×3(虚拟实例,见 pool 路由加权);
|
| 190 |
+
// 不按到期时间排序、与批次金额无关(issue #101 对齐实现口径)。
|
| 191 |
+
// 关闭后完全不使用到期信息选号。
|
| 192 |
+
PreferExpiring bool `json:"prefer_expiring"`
|
| 193 |
+
// ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在
|
| 194 |
+
// 此窗口内的批次令账号命中上述 ×3 加权;窗口开大 → 命中账号变多、
|
| 195 |
+
// 偏好被稀释。空/0 = 禁用该加权门槛。
|
| 196 |
+
ExpiringSoon string `json:"expiring_soon"`
|
| 197 |
+
// CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0
|
| 198 |
+
// 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切
|
| 199 |
+
// tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有
|
| 200 |
+
// 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为);
|
| 201 |
+
// 空值回落默认。
|
| 202 |
+
CostExploreInterval string `json:"cost_explore_interval"`
|
| 203 |
+
// CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再
|
| 204 |
+
// 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。
|
| 205 |
+
// tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。
|
| 206 |
+
// 默认 0 = 关闭;负值钳 0。
|
| 207 |
+
CreditFloor int64 `json:"credit_floor"`
|
| 208 |
+
} `json:"pool"`
|
| 209 |
+
|
| 210 |
+
SessionSticky struct {
|
| 211 |
+
Enabled bool `json:"enabled"` // 默认 true
|
| 212 |
+
TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m"
|
| 213 |
+
GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m"
|
| 214 |
+
} `json:"session_sticky"`
|
| 215 |
+
|
| 216 |
+
// 解析后
|
| 217 |
+
SoftRateDur time.Duration `json:"-"`
|
| 218 |
+
SoftRateMaxDur time.Duration `json:"-"`
|
| 219 |
+
BreakerCooldownDur time.Duration `json:"-"`
|
| 220 |
+
BreakerCooldownMaxD time.Duration `json:"-"`
|
| 221 |
+
DegradeCooldownDur time.Duration `json:"-"`
|
| 222 |
+
DegradeCooldownMaxD time.Duration `json:"-"`
|
| 223 |
+
SessionTTL time.Duration `json:"-"`
|
| 224 |
+
SessionGCInterval time.Duration `json:"-"`
|
| 225 |
+
BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false)
|
| 226 |
+
ExpiringSoonDur time.Duration `json:"-"`
|
| 227 |
+
// CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。
|
| 228 |
+
CostExploreIntervalDur time.Duration `json:"-"`
|
| 229 |
+
// ServerReadTimeoutDur 解析后的入站请求读取上限(issue #100);0 = 不限制。
|
| 230 |
+
ServerReadTimeoutDur time.Duration `json:"-"`
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// Default 默认配置。
|
| 234 |
+
func Default() *Config {
|
| 235 |
+
c := &Config{
|
| 236 |
+
Listen: ":7863",
|
| 237 |
+
APIKey: "",
|
| 238 |
+
AuthDir: "./auths",
|
| 239 |
+
StateFile: "./data/state.json",
|
| 240 |
+
}
|
| 241 |
+
c.Cooldown.SoftRate = "600s"
|
| 242 |
+
c.Cooldown.SoftRateMax = "2h"
|
| 243 |
+
c.Server.ReadTimeout = "300s"
|
| 244 |
+
c.Panel.PackageDetailLimit = 5
|
| 245 |
+
c.Logging.RequestArchiveEnabled = true
|
| 246 |
+
c.Logging.RequestRetentionDays = 7
|
| 247 |
+
c.Logging.RequestArchiveMaxMB = 100
|
| 248 |
+
// 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值,
|
| 249 |
+
// 只有显式 false 才关闭来源记录。
|
| 250 |
+
c.Logging.RequestClientInfo = true
|
| 251 |
+
c.Schedule.CheckinHours = []int{9, 21}
|
| 252 |
+
c.Schedule.TravelHours = []int{9, 21}
|
| 253 |
+
c.Schedule.ActivityHours = []int{10}
|
| 254 |
+
c.Schedule.KeepaliveHours = []int{22}
|
| 255 |
+
c.Schedule.BlackcatHours = []int{23}
|
| 256 |
+
c.Schedule.GrowthHours = []int{1}
|
| 257 |
+
// 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖,
|
| 258 |
+
// 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。
|
| 259 |
+
c.Schedule.CheckinEnabled = true
|
| 260 |
+
c.Schedule.GrowthEnabled = true
|
| 261 |
+
c.Schedule.TravelEnabled = true
|
| 262 |
+
c.Schedule.ActivityEnabled = true
|
| 263 |
+
c.Schedule.KeepaliveEnabled = true
|
| 264 |
+
c.Schedule.BlackcatEnabled = true
|
| 265 |
+
c.Schedule.BalanceRefreshEnabled = true
|
| 266 |
+
c.Schedule.BalanceRefreshMinutes = 5
|
| 267 |
+
c.Upstream.TimeoutSeconds = 120
|
| 268 |
+
// HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。
|
| 269 |
+
c.Upstream.HeaderTimeoutSeconds = 0
|
| 270 |
+
c.Upstream.IdleTimeoutSeconds = 0
|
| 271 |
+
// Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用);
|
| 272 |
+
// ChatBase/BillingBase 缺省空(回落内置默认)。
|
| 273 |
+
c.Global.Enabled = true
|
| 274 |
+
c.Features.SanitizeBlacklistFingerprints = true
|
| 275 |
+
c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择)
|
| 276 |
+
c.Pool.MaxInFlight = 3
|
| 277 |
+
// MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复
|
| 278 |
+
// P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键
|
| 279 |
+
// 的 0 没有合理语义,回退分档默认最稳)。
|
| 280 |
+
c.Pool.MaxInFlightGlobal = 2
|
| 281 |
+
c.Pool.BreakerThreshold = 3
|
| 282 |
+
c.Pool.BreakerCooldown = "30m"
|
| 283 |
+
c.Pool.BreakerCooldownMax = "6h"
|
| 284 |
+
c.Pool.DegradeThreshold = 5
|
| 285 |
+
c.Pool.DegradeCooldown = "10m"
|
| 286 |
+
c.Pool.DegradeCooldownMax = "2h"
|
| 287 |
+
c.Pool.IdleWeightPerHour = 0.5
|
| 288 |
+
c.Pool.IdleWeightMax = 5.0
|
| 289 |
+
c.Pool.PreferExpiring = true
|
| 290 |
+
c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期
|
| 291 |
+
// costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。
|
| 292 |
+
c.Pool.CostExploreInterval = "30m"
|
| 293 |
+
c.SessionSticky.Enabled = true
|
| 294 |
+
c.SessionSticky.TTL = "30m"
|
| 295 |
+
c.SessionSticky.GCInterval = "5m"
|
| 296 |
+
return c
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// Load 从文件读,再用 WB2A_* env 覆盖。
|
| 300 |
+
func Load(path string) (*Config, error) {
|
| 301 |
+
c := Default()
|
| 302 |
+
if path != "" {
|
| 303 |
+
// 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录,
|
| 304 |
+
// 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。
|
| 305 |
+
if st, statErr := os.Stat(path); statErr == nil && st.IsDir() {
|
| 306 |
+
return nil, fmt.Errorf("config %s 是目录而非文件——"+
|
| 307 |
+
"Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+
|
| 308 |
+
"请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path)
|
| 309 |
+
}
|
| 310 |
+
raw, err := os.ReadFile(path)
|
| 311 |
+
if err != nil {
|
| 312 |
+
return nil, fmt.Errorf("read config: %w", err)
|
| 313 |
+
}
|
| 314 |
+
if _, err := ParseConfigInto(raw, c); err != nil {
|
| 315 |
+
return nil, err
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
applyEnv(c)
|
| 319 |
+
if err := c.normalize(); err != nil {
|
| 320 |
+
return nil, err
|
| 321 |
+
}
|
| 322 |
+
return c, nil
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。
|
| 326 |
+
// 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。
|
| 327 |
+
func ParseConfigInto(raw []byte, c *Config) (*Config, error) {
|
| 328 |
+
if err := json.Unmarshal(raw, c); err != nil {
|
| 329 |
+
return nil, fmt.Errorf("parse config: %w", err)
|
| 330 |
+
}
|
| 331 |
+
if err := c.normalize(); err != nil {
|
| 332 |
+
return nil, err
|
| 333 |
+
}
|
| 334 |
+
return c, nil
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。
|
| 338 |
+
func ParseConfig(raw []byte) (*Config, error) {
|
| 339 |
+
return ParseConfigInto(raw, Default())
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。
|
| 343 |
+
// 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成:
|
| 344 |
+
// 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。
|
| 345 |
+
// 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。
|
| 346 |
+
func WriteDefault(path string) (string, error) {
|
| 347 |
+
raw := make([]byte, 18)
|
| 348 |
+
if _, err := rand.Read(raw); err != nil {
|
| 349 |
+
return "", fmt.Errorf("gen api_key: %w", err)
|
| 350 |
+
}
|
| 351 |
+
key := "sk-" + base64.RawURLEncoding.EncodeToString(raw)
|
| 352 |
+
c := Default()
|
| 353 |
+
c.APIKey = key
|
| 354 |
+
_ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值
|
| 355 |
+
out, err := json.MarshalIndent(c, "", " ")
|
| 356 |
+
if err != nil {
|
| 357 |
+
return "", fmt.Errorf("marshal config: %w", err)
|
| 358 |
+
}
|
| 359 |
+
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
| 360 |
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
| 361 |
+
return "", fmt.Errorf("mkdir config dir: %w", err)
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
// O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。
|
| 365 |
+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
| 366 |
+
if err != nil {
|
| 367 |
+
return "", fmt.Errorf("write config: %w", err)
|
| 368 |
+
}
|
| 369 |
+
defer f.Close()
|
| 370 |
+
if _, err := f.Write(out); err != nil {
|
| 371 |
+
return "", fmt.Errorf("write config: %w", err)
|
| 372 |
+
}
|
| 373 |
+
return key, nil
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
func applyEnv(c *Config) {
|
| 377 |
+
if v := os.Getenv("WB2A_LISTEN"); v != "" {
|
| 378 |
+
c.Listen = v
|
| 379 |
+
}
|
| 380 |
+
if v := os.Getenv("WB2A_API_KEY"); v != "" {
|
| 381 |
+
c.APIKey = v
|
| 382 |
+
}
|
| 383 |
+
if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
|
| 384 |
+
c.AuthDir = v
|
| 385 |
+
}
|
| 386 |
+
if v := os.Getenv("WB2A_STATE_FILE"); v != "" {
|
| 387 |
+
c.StateFile = v
|
| 388 |
+
}
|
| 389 |
+
if v := os.Getenv("WB2A_SOFT_RATE"); v != "" {
|
| 390 |
+
c.Cooldown.SoftRate = v
|
| 391 |
+
}
|
| 392 |
+
if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" {
|
| 393 |
+
c.Cooldown.SoftRateMax = v
|
| 394 |
+
}
|
| 395 |
+
if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" {
|
| 396 |
+
if n, err := strconv.Atoi(v); err == nil {
|
| 397 |
+
c.Upstream.TimeoutSeconds = n
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" {
|
| 401 |
+
if n, err := strconv.Atoi(v); err == nil {
|
| 402 |
+
c.Upstream.HeaderTimeoutSeconds = n
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" {
|
| 406 |
+
if n, err := strconv.Atoi(v); err == nil {
|
| 407 |
+
c.Upstream.IdleTimeoutSeconds = n
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
if v := os.Getenv("WB2A_USER_AGENT"); v != "" {
|
| 411 |
+
c.Upstream.UserAgent = v
|
| 412 |
+
}
|
| 413 |
+
if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" {
|
| 414 |
+
c.Upstream.ClientVersion = v
|
| 415 |
+
}
|
| 416 |
+
if v := os.Getenv("WB2A_CLI_VERSION"); v != "" {
|
| 417 |
+
c.Upstream.CliVersion = v
|
| 418 |
+
}
|
| 419 |
+
if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" {
|
| 420 |
+
c.Upstream.ClientName = v
|
| 421 |
+
}
|
| 422 |
+
if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" {
|
| 423 |
+
c.Upstream.DeviceToken = v
|
| 424 |
+
}
|
| 425 |
+
if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" {
|
| 426 |
+
c.Upstream.DeviceTokenFile = v
|
| 427 |
+
}
|
| 428 |
+
if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" {
|
| 429 |
+
if b, err := strconv.ParseBool(v); err == nil {
|
| 430 |
+
c.Upstream.PassthroughIP = b
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" {
|
| 434 |
+
if b, err := strconv.ParseBool(v); err == nil {
|
| 435 |
+
c.Features.SanitizeBlacklistFingerprints = b
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" {
|
| 439 |
+
c.Prompt.Mode = v
|
| 440 |
+
}
|
| 441 |
+
if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" {
|
| 442 |
+
c.Prompt.File = v
|
| 443 |
+
}
|
| 444 |
+
if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" {
|
| 445 |
+
c.Pool.ExpiringSoon = v
|
| 446 |
+
}
|
| 447 |
+
if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" {
|
| 448 |
+
if b, err := strconv.ParseBool(v); err == nil {
|
| 449 |
+
c.Pool.PreferExpiring = b
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
func (c *Config) normalize() error {
|
| 455 |
+
var err error
|
| 456 |
+
if c.Panel.PackageDetailLimit <= 0 {
|
| 457 |
+
c.Panel.PackageDetailLimit = 5
|
| 458 |
+
}
|
| 459 |
+
if c.Logging.RequestRetentionDays <= 0 {
|
| 460 |
+
c.Logging.RequestRetentionDays = 7
|
| 461 |
+
}
|
| 462 |
+
if c.Logging.RequestArchiveMaxMB <= 0 {
|
| 463 |
+
c.Logging.RequestArchiveMaxMB = 100
|
| 464 |
+
}
|
| 465 |
+
// 入站读取上限(issue #100):空值回落默认 300s;"0" 合法(不限制);
|
| 466 |
+
// 负值无语义,fail fast(静默钳 0 会把保护悄悄关掉)。
|
| 467 |
+
if c.Server.ReadTimeout == "" {
|
| 468 |
+
c.Server.ReadTimeout = "300s"
|
| 469 |
+
}
|
| 470 |
+
if c.ServerReadTimeoutDur, err = time.ParseDuration(c.Server.ReadTimeout); err != nil {
|
| 471 |
+
return fmt.Errorf("server.read_timeout: %w", err)
|
| 472 |
+
}
|
| 473 |
+
if c.ServerReadTimeoutDur < 0 {
|
| 474 |
+
return fmt.Errorf("server.read_timeout: 负时长 %q 无意义", c.Server.ReadTimeout)
|
| 475 |
+
}
|
| 476 |
+
if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil {
|
| 477 |
+
return fmt.Errorf("cooldown.soft_rate: %w", err)
|
| 478 |
+
}
|
| 479 |
+
// 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。
|
| 480 |
+
if c.Cooldown.SoftRateMax == "" {
|
| 481 |
+
c.Cooldown.SoftRateMax = "2h"
|
| 482 |
+
}
|
| 483 |
+
if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil {
|
| 484 |
+
return fmt.Errorf("cooldown.soft_rate_max: %w", err)
|
| 485 |
+
}
|
| 486 |
+
if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil {
|
| 487 |
+
return fmt.Errorf("pool.breaker_cooldown: %w", err)
|
| 488 |
+
}
|
| 489 |
+
if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil {
|
| 490 |
+
return fmt.Errorf("pool.breaker_cooldown_max: %w", err)
|
| 491 |
+
}
|
| 492 |
+
if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil {
|
| 493 |
+
return fmt.Errorf("pool.degrade_cooldown: %w", err)
|
| 494 |
+
}
|
| 495 |
+
if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil {
|
| 496 |
+
return fmt.Errorf("pool.degrade_cooldown_max: %w", err)
|
| 497 |
+
}
|
| 498 |
+
if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil {
|
| 499 |
+
return fmt.Errorf("session_sticky.ttl: %w", err)
|
| 500 |
+
}
|
| 501 |
+
if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil {
|
| 502 |
+
return fmt.Errorf("session_sticky.gc_interval: %w", err)
|
| 503 |
+
}
|
| 504 |
+
// 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。
|
| 505 |
+
if c.Pool.ExpiringSoon != "" {
|
| 506 |
+
if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil {
|
| 507 |
+
return fmt.Errorf("pool.expiring_soon: %w", err)
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
if c.ExpiringSoonDur < 0 {
|
| 511 |
+
c.ExpiringSoonDur = 0
|
| 512 |
+
c.Pool.ExpiringSoon = "0"
|
| 513 |
+
}
|
| 514 |
+
// costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖
|
| 515 |
+
// 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停
|
| 516 |
+
//("−5m" 无合理语义)。
|
| 517 |
+
if c.Pool.CostExploreInterval == "" {
|
| 518 |
+
c.Pool.CostExploreInterval = "30m"
|
| 519 |
+
}
|
| 520 |
+
if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil {
|
| 521 |
+
return fmt.Errorf("pool.cost_explore_interval: %w", err)
|
| 522 |
+
}
|
| 523 |
+
if c.CostExploreIntervalDur < 0 {
|
| 524 |
+
c.CostExploreIntervalDur = 0
|
| 525 |
+
}
|
| 526 |
+
// 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。
|
| 527 |
+
if c.Pool.CreditFloor < 0 {
|
| 528 |
+
c.Pool.CreditFloor = 0
|
| 529 |
+
}
|
| 530 |
+
if c.Pool.BreakerThreshold <= 0 {
|
| 531 |
+
c.Pool.BreakerThreshold = 3
|
| 532 |
+
}
|
| 533 |
+
// 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。
|
| 534 |
+
if c.Pool.DegradeThreshold <= 0 {
|
| 535 |
+
c.Pool.DegradeThreshold = 5
|
| 536 |
+
}
|
| 537 |
+
if c.Pool.DegradeCooldown == "" {
|
| 538 |
+
c.Pool.DegradeCooldown = "10m"
|
| 539 |
+
}
|
| 540 |
+
if c.Pool.DegradeCooldownMax == "" {
|
| 541 |
+
c.Pool.DegradeCooldownMax = "2h"
|
| 542 |
+
}
|
| 543 |
+
// global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。
|
| 544 |
+
if c.Pool.MaxInFlightGlobal <= 0 {
|
| 545 |
+
c.Pool.MaxInFlightGlobal = 2
|
| 546 |
+
}
|
| 547 |
+
if c.Pool.IdleWeightPerHour <= 0 {
|
| 548 |
+
c.Pool.IdleWeightPerHour = 0.5
|
| 549 |
+
}
|
| 550 |
+
if c.Pool.IdleWeightMax <= 0 {
|
| 551 |
+
c.Pool.IdleWeightMax = 5.0
|
| 552 |
+
}
|
| 553 |
+
if c.Upstream.TimeoutSeconds <= 0 {
|
| 554 |
+
c.Upstream.TimeoutSeconds = 120
|
| 555 |
+
}
|
| 556 |
+
// header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。
|
| 557 |
+
// 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。
|
| 558 |
+
if c.Upstream.HeaderTimeoutSeconds <= 0 {
|
| 559 |
+
c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds
|
| 560 |
+
}
|
| 561 |
+
if c.Upstream.IdleTimeoutSeconds <= 0 {
|
| 562 |
+
c.Upstream.IdleTimeoutSeconds = 300
|
| 563 |
+
}
|
| 564 |
+
if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") {
|
| 565 |
+
c.Listen = ":" + c.Listen
|
| 566 |
+
}
|
| 567 |
+
// 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。
|
| 568 |
+
// 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。
|
| 569 |
+
if len(c.Schedule.CheckinHours) == 0 {
|
| 570 |
+
c.Schedule.CheckinHours = []int{9, 21}
|
| 571 |
+
}
|
| 572 |
+
if len(c.Schedule.TravelHours) == 0 {
|
| 573 |
+
c.Schedule.TravelHours = []int{9, 21}
|
| 574 |
+
}
|
| 575 |
+
if len(c.Schedule.ActivityHours) == 0 {
|
| 576 |
+
c.Schedule.ActivityHours = []int{10}
|
| 577 |
+
}
|
| 578 |
+
if len(c.Schedule.KeepaliveHours) == 0 {
|
| 579 |
+
c.Schedule.KeepaliveHours = []int{22}
|
| 580 |
+
}
|
| 581 |
+
if len(c.Schedule.BlackcatHours) == 0 {
|
| 582 |
+
c.Schedule.BlackcatHours = []int{23}
|
| 583 |
+
}
|
| 584 |
+
if len(c.Schedule.GrowthHours) == 0 {
|
| 585 |
+
c.Schedule.GrowthHours = []int{1}
|
| 586 |
+
}
|
| 587 |
+
// 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。
|
| 588 |
+
if c.Schedule.BalanceRefreshEnabled {
|
| 589 |
+
if c.Schedule.BalanceRefreshMinutes <= 0 {
|
| 590 |
+
c.Schedule.BalanceRefreshMinutes = 5
|
| 591 |
+
}
|
| 592 |
+
c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute
|
| 593 |
+
}
|
| 594 |
+
if err := c.validateScheduleHours(); err != nil {
|
| 595 |
+
return err
|
| 596 |
+
}
|
| 597 |
+
return c.normalizePrompt()
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
// normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。
|
| 601 |
+
//
|
| 602 |
+
// mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支;
|
| 603 |
+
// custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认
|
| 604 |
+
// (两模式共用同一加载路径,PromptText 均非空)。
|
| 605 |
+
// passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。
|
| 606 |
+
func (c *Config) normalizePrompt() error {
|
| 607 |
+
switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m {
|
| 608 |
+
case "", "passthrough":
|
| 609 |
+
c.Prompt.Mode = "passthrough"
|
| 610 |
+
case "custom":
|
| 611 |
+
c.Prompt.Mode = "custom"
|
| 612 |
+
case "append":
|
| 613 |
+
c.Prompt.Mode = "append"
|
| 614 |
+
default:
|
| 615 |
+
return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode)
|
| 616 |
+
}
|
| 617 |
+
if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" {
|
| 618 |
+
text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File)
|
| 619 |
+
if err != nil {
|
| 620 |
+
return err
|
| 621 |
+
}
|
| 622 |
+
c.PromptText = text
|
| 623 |
+
}
|
| 624 |
+
return nil
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
// validateScheduleHours 校验排程小时落在 0-23。
|
| 628 |
+
//
|
| 629 |
+
// 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到,
|
| 630 |
+
// 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关
|
| 631 |
+
// (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。
|
| 632 |
+
func (c *Config) validateScheduleHours() error {
|
| 633 |
+
if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil {
|
| 634 |
+
return err
|
| 635 |
+
}
|
| 636 |
+
if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil {
|
| 637 |
+
return err
|
| 638 |
+
}
|
| 639 |
+
if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil {
|
| 640 |
+
return err
|
| 641 |
+
}
|
| 642 |
+
if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil {
|
| 643 |
+
return err
|
| 644 |
+
}
|
| 645 |
+
if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil {
|
| 646 |
+
return err
|
| 647 |
+
}
|
| 648 |
+
return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours)
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
func checkHourRange(field, switchKey string, hours []int) error {
|
| 652 |
+
for _, h := range hours {
|
| 653 |
+
if h < 0 || h > 23 {
|
| 654 |
+
return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey)
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
return nil
|
| 658 |
+
}
|
cmd/server/config_test.go
CHANGED
|
@@ -1,816 +1,861 @@
|
|
| 1 |
-
package main
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"encoding/json"
|
| 5 |
-
"os"
|
| 6 |
-
"path/filepath"
|
| 7 |
-
"strings"
|
| 8 |
-
"testing"
|
| 9 |
-
"time"
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
func TestDefault(t *testing.T) {
|
| 13 |
-
c := Default()
|
| 14 |
-
if c.Listen != ":7863" {
|
| 15 |
-
t.Errorf("listen=%s", c.Listen)
|
| 16 |
-
}
|
| 17 |
-
if err := c.normalize(); err != nil {
|
| 18 |
-
t.Fatalf("normalize: %v", err)
|
| 19 |
-
}
|
| 20 |
-
if c.SoftRateDur.Seconds() != 600 {
|
| 21 |
-
t.Errorf("soft=%v want 600s", c.SoftRateDur)
|
| 22 |
-
}
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
func TestPanelPackageDetailLimit(t *testing.T) {
|
| 26 |
-
c := Default()
|
| 27 |
-
if err := c.normalize(); err != nil {
|
| 28 |
-
t.Fatalf("normalize: %v", err)
|
| 29 |
-
}
|
| 30 |
-
if c.Panel.PackageDetailLimit != 5 {
|
| 31 |
-
t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit)
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`))
|
| 35 |
-
if err != nil {
|
| 36 |
-
t.Fatalf("parse configured limit: %v", err)
|
| 37 |
-
}
|
| 38 |
-
if configured.Panel.PackageDetailLimit != 8 {
|
| 39 |
-
t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`))
|
| 43 |
-
if err != nil {
|
| 44 |
-
t.Fatalf("parse fallback limit: %v", err)
|
| 45 |
-
}
|
| 46 |
-
if fallback.Panel.PackageDetailLimit != 5 {
|
| 47 |
-
t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit)
|
| 48 |
-
}
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
func TestLoggingDefaults(t *testing.T) {
|
| 52 |
-
c := Default()
|
| 53 |
-
if err := c.normalize(); err != nil {
|
| 54 |
-
t.Fatal(err)
|
| 55 |
-
}
|
| 56 |
-
if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 {
|
| 57 |
-
t.Fatalf("logging defaults = %+v", c.Logging)
|
| 58 |
-
}
|
| 59 |
-
// 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。
|
| 60 |
-
if !c.Logging.RequestClientInfo {
|
| 61 |
-
t.Fatalf("request_client_info default = false, want true: %+v", c.Logging)
|
| 62 |
-
}
|
| 63 |
-
configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`))
|
| 64 |
-
if err != nil {
|
| 65 |
-
t.Fatal(err)
|
| 66 |
-
}
|
| 67 |
-
if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 {
|
| 68 |
-
t.Fatalf("configured logging = %+v", configured.Logging)
|
| 69 |
-
}
|
| 70 |
-
off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`))
|
| 71 |
-
if err != nil {
|
| 72 |
-
t.Fatal(err)
|
| 73 |
-
}
|
| 74 |
-
if off.Logging.RequestClientInfo {
|
| 75 |
-
t.Fatalf("explicit false ignored: %+v", off.Logging)
|
| 76 |
-
}
|
| 77 |
-
fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`))
|
| 78 |
-
if err != nil {
|
| 79 |
-
t.Fatal(err)
|
| 80 |
-
}
|
| 81 |
-
if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 {
|
| 82 |
-
t.Fatalf("logging fallback = %+v", fallback.Logging)
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
func TestLoadFile(t *testing.T) {
|
| 87 |
-
dir := t.TempDir()
|
| 88 |
-
fp := filepath.Join(dir, "c.json")
|
| 89 |
-
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
|
| 90 |
-
c, err := Load(fp)
|
| 91 |
-
if err != nil {
|
| 92 |
-
t.Fatal(err)
|
| 93 |
-
}
|
| 94 |
-
if c.Listen != ":9999" || c.APIKey != "k" {
|
| 95 |
-
t.Errorf("c=%+v", c)
|
| 96 |
-
}
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
func TestEnvOverride(t *testing.T) {
|
| 100 |
-
t.Setenv("WB2A_LISTEN", ":7777")
|
| 101 |
-
t.Setenv("WB2A_API_KEY", "envkey")
|
| 102 |
-
c, err := Load("")
|
| 103 |
-
if err != nil {
|
| 104 |
-
t.Fatal(err)
|
| 105 |
-
}
|
| 106 |
-
if c.Listen != ":7777" || c.APIKey != "envkey" {
|
| 107 |
-
t.Errorf("c=%+v", c)
|
| 108 |
-
}
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
func TestBadDuration(t *testing.T) {
|
| 112 |
-
dir := t.TempDir()
|
| 113 |
-
fp := filepath.Join(dir, "c.json")
|
| 114 |
-
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600)
|
| 115 |
-
if _, err := Load(fp); err == nil {
|
| 116 |
-
t.Fatal("want error for bad duration")
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
func TestHardCreditKeyIgnored(t *testing.T) {
|
| 121 |
-
// 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。
|
| 122 |
-
dir := t.TempDir()
|
| 123 |
-
fp := filepath.Join(dir, "c.json")
|
| 124 |
-
os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600)
|
| 125 |
-
c, err := Load(fp)
|
| 126 |
-
if err != nil {
|
| 127 |
-
t.Fatalf("hard_credit must be ignored (not validated): %v", err)
|
| 128 |
-
}
|
| 129 |
-
if c.SoftRateDur.Seconds() != 30 {
|
| 130 |
-
t.Errorf("soft_rate=%v want 30s", c.SoftRateDur)
|
| 131 |
-
}
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
func TestNewPoolConfigDefaults(t *testing.T) {
|
| 135 |
-
c := Default()
|
| 136 |
-
if err := c.normalize(); err != nil {
|
| 137 |
-
t.Fatalf("normalize: %v", err)
|
| 138 |
-
}
|
| 139 |
-
if c.Pool.MaxInFlight != 3 {
|
| 140 |
-
t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight)
|
| 141 |
-
}
|
| 142 |
-
if c.Pool.BreakerThreshold != 3 {
|
| 143 |
-
t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold)
|
| 144 |
-
}
|
| 145 |
-
if c.BreakerCooldownDur.Minutes() != 30 {
|
| 146 |
-
t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur)
|
| 147 |
-
}
|
| 148 |
-
if c.BreakerCooldownMaxD.Hours() != 6 {
|
| 149 |
-
t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD)
|
| 150 |
-
}
|
| 151 |
-
if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 {
|
| 152 |
-
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
|
| 153 |
-
}
|
| 154 |
-
if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour {
|
| 155 |
-
t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
|
| 156 |
-
}
|
| 157 |
-
if c.SoftRateMaxDur.Hours() != 2 {
|
| 158 |
-
t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur)
|
| 159 |
-
}
|
| 160 |
-
if !c.SessionSticky.Enabled {
|
| 161 |
-
t.Error("session_sticky.enabled want true")
|
| 162 |
-
}
|
| 163 |
-
if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 {
|
| 164 |
-
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
|
| 165 |
-
}
|
| 166 |
-
if c.Upstash.URL != "" || c.Upstash.Token != "" {
|
| 167 |
-
t.Errorf("upstash default should be empty: %+v", c.Upstash)
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
func TestPoolConfigParsedFromFile(t *testing.T) {
|
| 172 |
-
dir := t.TempDir()
|
| 173 |
-
fp := filepath.Join(dir, "c.json")
|
| 174 |
-
os.WriteFile(fp, []byte(`{
|
| 175 |
-
"upstash":{"url":"https://foo.upstash.io","token":"tok"},
|
| 176 |
-
"pool":{
|
| 177 |
-
"max_in_flight":5,
|
| 178 |
-
"breaker_threshold":4,
|
| 179 |
-
"breaker_cooldown":"10m",
|
| 180 |
-
"breaker_cooldown_max":"2h",
|
| 181 |
-
"idle_weight_per_hour":0.7,
|
| 182 |
-
"idle_weight_max":8.0,
|
| 183 |
-
"prefer_expiring":false,
|
| 184 |
-
"expiring_soon":"72h"
|
| 185 |
-
},
|
| 186 |
-
"session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"}
|
| 187 |
-
}`), 0o600)
|
| 188 |
-
c, err := Load(fp)
|
| 189 |
-
if err != nil {
|
| 190 |
-
t.Fatal(err)
|
| 191 |
-
}
|
| 192 |
-
if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" {
|
| 193 |
-
t.Errorf("upstash=%+v", c.Upstash)
|
| 194 |
-
}
|
| 195 |
-
if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 {
|
| 196 |
-
t.Errorf("pool=%+v", c.Pool)
|
| 197 |
-
}
|
| 198 |
-
if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 {
|
| 199 |
-
t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD)
|
| 200 |
-
}
|
| 201 |
-
if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 {
|
| 202 |
-
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
|
| 203 |
-
}
|
| 204 |
-
if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour {
|
| 205 |
-
t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
|
| 206 |
-
}
|
| 207 |
-
if c.SessionSticky.Enabled {
|
| 208 |
-
t.Error("session_sticky.enabled want false from file")
|
| 209 |
-
}
|
| 210 |
-
if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 {
|
| 211 |
-
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
|
| 212 |
-
}
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
func TestSoftRateMaxParsedFromFile(t *testing.T) {
|
| 216 |
-
dir := t.TempDir()
|
| 217 |
-
fp := filepath.Join(dir, "c.json")
|
| 218 |
-
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600)
|
| 219 |
-
c, err := Load(fp)
|
| 220 |
-
if err != nil {
|
| 221 |
-
t.Fatal(err)
|
| 222 |
-
}
|
| 223 |
-
if c.SoftRateDur.Minutes() != 5 {
|
| 224 |
-
t.Errorf("soft_rate=%v want 5m", c.SoftRateDur)
|
| 225 |
-
}
|
| 226 |
-
if c.SoftRateMaxDur.Minutes() != 45 {
|
| 227 |
-
t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur)
|
| 228 |
-
}
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) {
|
| 232 |
-
dir := t.TempDir()
|
| 233 |
-
fp := filepath.Join(dir, "c.json")
|
| 234 |
-
os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600)
|
| 235 |
-
c, err := Load(fp)
|
| 236 |
-
if err != nil {
|
| 237 |
-
t.Fatal(err)
|
| 238 |
-
}
|
| 239 |
-
if !c.Pool.PreferExpiring {
|
| 240 |
-
t.Fatal("missing prefer_expiring must preserve default true")
|
| 241 |
-
}
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) {
|
| 245 |
-
dir := t.TempDir()
|
| 246 |
-
fp := filepath.Join(dir, "c.json")
|
| 247 |
-
os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600)
|
| 248 |
-
c, err := Load(fp)
|
| 249 |
-
if err != nil {
|
| 250 |
-
t.Fatal(err)
|
| 251 |
-
}
|
| 252 |
-
if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" {
|
| 253 |
-
t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon)
|
| 254 |
-
}
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) {
|
| 258 |
-
// 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。
|
| 259 |
-
dir := t.TempDir()
|
| 260 |
-
fp := filepath.Join(dir, "c.json")
|
| 261 |
-
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600)
|
| 262 |
-
c, err := Load(fp)
|
| 263 |
-
if err != nil {
|
| 264 |
-
t.Fatal(err)
|
| 265 |
-
}
|
| 266 |
-
if c.SoftRateMaxDur.Hours() != 2 {
|
| 267 |
-
t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur)
|
| 268 |
-
}
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
func TestBadSoftRateMax(t *testing.T) {
|
| 272 |
-
dir := t.TempDir()
|
| 273 |
-
fp := filepath.Join(dir, "c.json")
|
| 274 |
-
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600)
|
| 275 |
-
if _, err := Load(fp); err == nil {
|
| 276 |
-
t.Fatal("want error for bad soft_rate_max")
|
| 277 |
-
}
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
func TestBadBreakerCooldown(t *testing.T) {
|
| 281 |
-
dir := t.TempDir()
|
| 282 |
-
fp := filepath.Join(dir, "c.json")
|
| 283 |
-
os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600)
|
| 284 |
-
if _, err := Load(fp); err == nil {
|
| 285 |
-
t.Fatal("want error for bad breaker_cooldown")
|
| 286 |
-
}
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
func TestUpstreamTimeoutDefaults(t *testing.T) {
|
| 290 |
-
// 默认:header 回落 timeout,idle 回落 300。
|
| 291 |
-
c := Default()
|
| 292 |
-
if err := c.normalize(); err != nil {
|
| 293 |
-
t.Fatalf("normalize: %v", err)
|
| 294 |
-
}
|
| 295 |
-
if c.Upstream.TimeoutSeconds != 120 {
|
| 296 |
-
t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds)
|
| 297 |
-
}
|
| 298 |
-
if c.Upstream.HeaderTimeoutSeconds != 120 {
|
| 299 |
-
t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds)
|
| 300 |
-
}
|
| 301 |
-
if c.Upstream.IdleTimeoutSeconds != 300 {
|
| 302 |
-
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
|
| 303 |
-
}
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) {
|
| 307 |
-
// 只设 timeout_seconds:header 回落同值,idle 回落 300。
|
| 308 |
-
dir := t.TempDir()
|
| 309 |
-
fp := filepath.Join(dir, "c.json")
|
| 310 |
-
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600)
|
| 311 |
-
c, err := Load(fp)
|
| 312 |
-
if err != nil {
|
| 313 |
-
t.Fatal(err)
|
| 314 |
-
}
|
| 315 |
-
if c.Upstream.HeaderTimeoutSeconds != 60 {
|
| 316 |
-
t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds)
|
| 317 |
-
}
|
| 318 |
-
if c.Upstream.IdleTimeoutSeconds != 300 {
|
| 319 |
-
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
|
| 320 |
-
}
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
func TestUpstreamExplicitHeaderIdle(t *testing.T) {
|
| 324 |
-
dir := t.TempDir()
|
| 325 |
-
fp := filepath.Join(dir, "c.json")
|
| 326 |
-
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600)
|
| 327 |
-
c, err := Load(fp)
|
| 328 |
-
if err != nil {
|
| 329 |
-
t.Fatal(err)
|
| 330 |
-
}
|
| 331 |
-
if c.Upstream.HeaderTimeoutSeconds != 30 {
|
| 332 |
-
t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds)
|
| 333 |
-
}
|
| 334 |
-
if c.Upstream.IdleTimeoutSeconds != 600 {
|
| 335 |
-
t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds)
|
| 336 |
-
}
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
func TestUpstreamEnvOverride(t *testing.T) {
|
| 340 |
-
t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45")
|
| 341 |
-
t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900")
|
| 342 |
-
c, err := Load("")
|
| 343 |
-
if err != nil {
|
| 344 |
-
t.Fatal(err)
|
| 345 |
-
}
|
| 346 |
-
if c.Upstream.HeaderTimeoutSeconds != 45 {
|
| 347 |
-
t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds)
|
| 348 |
-
}
|
| 349 |
-
if c.Upstream.IdleTimeoutSeconds != 900 {
|
| 350 |
-
t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds)
|
| 351 |
-
}
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。
|
| 355 |
-
func TestRetiredTravelIntervalKeyIgnored(t *testing.T) {
|
| 356 |
-
dir := t.TempDir()
|
| 357 |
-
fp := filepath.Join(dir, "c.json")
|
| 358 |
-
os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600)
|
| 359 |
-
c, err := Load(fp)
|
| 360 |
-
if err != nil {
|
| 361 |
-
t.Fatalf("retired key should not fail load: %v", err)
|
| 362 |
-
}
|
| 363 |
-
if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 {
|
| 364 |
-
t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours)
|
| 365 |
-
}
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true:
|
| 369 |
-
// 老 config 不写这些键,行为必须与从前完全一致。
|
| 370 |
-
func TestScheduleEnabledByDefault(t *testing.T) {
|
| 371 |
-
c := Default()
|
| 372 |
-
if err := c.normalize(); err != nil {
|
| 373 |
-
t.Fatalf("normalize: %v", err)
|
| 374 |
-
}
|
| 375 |
-
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 376 |
-
t.Errorf("enabled defaults want true/true, got %v/%v",
|
| 377 |
-
c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled)
|
| 378 |
-
}
|
| 379 |
-
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 380 |
-
t.Errorf("travel/activity enabled defaults want true/true, got %v/%v",
|
| 381 |
-
c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled)
|
| 382 |
-
}
|
| 383 |
-
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 384 |
-
t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours)
|
| 385 |
-
}
|
| 386 |
-
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 387 |
-
t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours)
|
| 388 |
-
}
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
-
// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态,
|
| 392 |
-
// 新开关缺省 true、新 hours 回落默认——对老配置零影响。
|
| 393 |
-
func TestScheduleLegacyConfigKeepsRunning(t *testing.T) {
|
| 394 |
-
dir := t.TempDir()
|
| 395 |
-
fp := filepath.Join(dir, "c.json")
|
| 396 |
-
os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600)
|
| 397 |
-
c, err := Load(fp)
|
| 398 |
-
if err != nil {
|
| 399 |
-
t.Fatal(err)
|
| 400 |
-
}
|
| 401 |
-
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 402 |
-
t.Errorf("legacy config must stay enabled: %+v", c.Schedule)
|
| 403 |
-
}
|
| 404 |
-
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 405 |
-
t.Errorf("new switches must default true on legacy config: %+v", c.Schedule)
|
| 406 |
-
}
|
| 407 |
-
if len(c.Schedule.CheckinHours) != 2 {
|
| 408 |
-
t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours)
|
| 409 |
-
}
|
| 410 |
-
// 新 hours 缺省 → 回落默认(非空)。
|
| 411 |
-
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 412 |
-
t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours)
|
| 413 |
-
}
|
| 414 |
-
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 415 |
-
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
|
| 416 |
-
}
|
| 417 |
-
}
|
| 418 |
-
|
| 419 |
-
// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到
|
| 420 |
-
// (issue #27 边界:此前无论怎么配小时都关不掉)。
|
| 421 |
-
func TestScheduleExplicitDisable(t *testing.T) {
|
| 422 |
-
dir := t.TempDir()
|
| 423 |
-
fp := filepath.Join(dir, "c.json")
|
| 424 |
-
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600)
|
| 425 |
-
c, err := Load(fp)
|
| 426 |
-
if err != nil {
|
| 427 |
-
t.Fatal(err)
|
| 428 |
-
}
|
| 429 |
-
if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled {
|
| 430 |
-
t.Errorf("want both disabled: %+v", c.Schedule)
|
| 431 |
-
}
|
| 432 |
-
// 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。
|
| 433 |
-
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
|
| 434 |
-
t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours)
|
| 435 |
-
}
|
| 436 |
-
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
|
| 437 |
-
t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours)
|
| 438 |
-
}
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。
|
| 442 |
-
func TestScheduleTravelActivityExplicitDisable(t *testing.T) {
|
| 443 |
-
dir := t.TempDir()
|
| 444 |
-
fp := filepath.Join(dir, "c.json")
|
| 445 |
-
os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600)
|
| 446 |
-
c, err := Load(fp)
|
| 447 |
-
if err != nil {
|
| 448 |
-
t.Fatal(err)
|
| 449 |
-
}
|
| 450 |
-
if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled {
|
| 451 |
-
t.Errorf("want travel/activity disabled: %+v", c.Schedule)
|
| 452 |
-
}
|
| 453 |
-
// 签到/保活开关缺省 true(互不干扰)。
|
| 454 |
-
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 455 |
-
t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule)
|
| 456 |
-
}
|
| 457 |
-
// hours 仍回落默认。
|
| 458 |
-
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 459 |
-
t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours)
|
| 460 |
-
}
|
| 461 |
-
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 462 |
-
t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours)
|
| 463 |
-
}
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。
|
| 467 |
-
func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) {
|
| 468 |
-
cases := []struct{ body, wantSwitch string }{
|
| 469 |
-
{`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"},
|
| 470 |
-
{`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"},
|
| 471 |
-
{`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"},
|
| 472 |
-
{`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"},
|
| 473 |
-
}
|
| 474 |
-
for _, tc := range cases {
|
| 475 |
-
dir := t.TempDir()
|
| 476 |
-
fp := filepath.Join(dir, "c.json")
|
| 477 |
-
os.WriteFile(fp, []byte(tc.body), 0o600)
|
| 478 |
-
_, err := Load(fp)
|
| 479 |
-
if err == nil {
|
| 480 |
-
t.Fatalf("want error for %s", tc.body)
|
| 481 |
-
}
|
| 482 |
-
if !strings.Contains(err.Error(), tc.wantSwitch) {
|
| 483 |
-
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
|
| 484 |
-
}
|
| 485 |
-
}
|
| 486 |
-
}
|
| 487 |
-
|
| 488 |
-
// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。
|
| 489 |
-
func TestScheduleTravelActivityExplicitHours(t *testing.T) {
|
| 490 |
-
dir := t.TempDir()
|
| 491 |
-
fp := filepath.Join(dir, "c.json")
|
| 492 |
-
os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600)
|
| 493 |
-
c, err := Load(fp)
|
| 494 |
-
if err != nil {
|
| 495 |
-
t.Fatal(err)
|
| 496 |
-
}
|
| 497 |
-
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 498 |
-
t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours)
|
| 499 |
-
}
|
| 500 |
-
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 {
|
| 501 |
-
t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours)
|
| 502 |
-
}
|
| 503 |
-
}
|
| 504 |
-
|
| 505 |
-
// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。
|
| 506 |
-
func TestScheduleDisableKeepsExplicitHours(t *testing.T) {
|
| 507 |
-
dir := t.TempDir()
|
| 508 |
-
fp := filepath.Join(dir, "c.json")
|
| 509 |
-
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600)
|
| 510 |
-
c, err := Load(fp)
|
| 511 |
-
if err != nil {
|
| 512 |
-
t.Fatal(err)
|
| 513 |
-
}
|
| 514 |
-
if c.Schedule.CheckinEnabled {
|
| 515 |
-
t.Error("checkin should be disabled")
|
| 516 |
-
}
|
| 517 |
-
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 {
|
| 518 |
-
t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours)
|
| 519 |
-
}
|
| 520 |
-
}
|
| 521 |
-
|
| 522 |
-
// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。
|
| 523 |
-
func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) {
|
| 524 |
-
cases := map[string]string{
|
| 525 |
-
"absent": `{}`,
|
| 526 |
-
"empty": `{"schedule":{}}`,
|
| 527 |
-
"null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`,
|
| 528 |
-
"emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`,
|
| 529 |
-
}
|
| 530 |
-
for name, body := range cases {
|
| 531 |
-
t.Run(name, func(t *testing.T) {
|
| 532 |
-
dir := t.TempDir()
|
| 533 |
-
fp := filepath.Join(dir, "c.json")
|
| 534 |
-
os.WriteFile(fp, []byte(body), 0o600)
|
| 535 |
-
c, err := Load(fp)
|
| 536 |
-
if err != nil {
|
| 537 |
-
t.Fatal(err)
|
| 538 |
-
}
|
| 539 |
-
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
|
| 540 |
-
t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours)
|
| 541 |
-
}
|
| 542 |
-
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
|
| 543 |
-
t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours)
|
| 544 |
-
}
|
| 545 |
-
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 546 |
-
t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours)
|
| 547 |
-
}
|
| 548 |
-
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 549 |
-
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
|
| 550 |
-
}
|
| 551 |
-
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 552 |
-
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
|
| 553 |
-
}
|
| 554 |
-
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 555 |
-
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
|
| 556 |
-
}
|
| 557 |
-
})
|
| 558 |
-
}
|
| 559 |
-
}
|
| 560 |
-
|
| 561 |
-
// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户
|
| 562 |
-
// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。
|
| 563 |
-
func TestScheduleInvalidHourRejected(t *testing.T) {
|
| 564 |
-
cases := []struct{ body, wantSwitch string }{
|
| 565 |
-
{`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"},
|
| 566 |
-
{`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"},
|
| 567 |
-
{`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"},
|
| 568 |
-
}
|
| 569 |
-
for _, tc := range cases {
|
| 570 |
-
dir := t.TempDir()
|
| 571 |
-
fp := filepath.Join(dir, "c.json")
|
| 572 |
-
os.WriteFile(fp, []byte(tc.body), 0o600)
|
| 573 |
-
_, err := Load(fp)
|
| 574 |
-
if err == nil {
|
| 575 |
-
t.Fatalf("want error for %s", tc.body)
|
| 576 |
-
}
|
| 577 |
-
if !strings.Contains(err.Error(), tc.wantSwitch) {
|
| 578 |
-
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
|
| 579 |
-
}
|
| 580 |
-
}
|
| 581 |
-
}
|
| 582 |
-
|
| 583 |
-
func TestBadSessionTTL(t *testing.T) {
|
| 584 |
-
dir := t.TempDir()
|
| 585 |
-
fp := filepath.Join(dir, "c.json")
|
| 586 |
-
os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600)
|
| 587 |
-
if _, err := Load(fp); err == nil {
|
| 588 |
-
t.Fatal("want error for bad session_sticky.ttl")
|
| 589 |
-
}
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
-
func TestWriteDefault(t *testing.T) {
|
| 593 |
-
dir := t.TempDir()
|
| 594 |
-
fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建
|
| 595 |
-
key, err := WriteDefault(fp)
|
| 596 |
-
if err != nil {
|
| 597 |
-
t.Fatal(err)
|
| 598 |
-
}
|
| 599 |
-
// key 形如 sk-<24字符随机串>,两次生成不重复
|
| 600 |
-
if !strings.HasPrefix(key, "sk-") || len(key) < 20 {
|
| 601 |
-
t.Errorf("key=%q want sk-<random>", key)
|
| 602 |
-
}
|
| 603 |
-
if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key {
|
| 604 |
-
t.Errorf("two generated keys identical: %q", key)
|
| 605 |
-
}
|
| 606 |
-
// 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效
|
| 607 |
-
c, err := Load(fp)
|
| 608 |
-
if err != nil {
|
| 609 |
-
t.Fatalf("load generated config: %v", err)
|
| 610 |
-
}
|
| 611 |
-
if c.APIKey != key {
|
| 612 |
-
t.Errorf("api_key=%q want %q", c.APIKey, key)
|
| 613 |
-
}
|
| 614 |
-
if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" {
|
| 615 |
-
t.Errorf("generated defaults off: %+v", c)
|
| 616 |
-
}
|
| 617 |
-
if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled {
|
| 618 |
-
t.Errorf("generated schedule off: %+v", c.Schedule)
|
| 619 |
-
}
|
| 620 |
-
// 已存在的文件不覆盖:二次写入同一路径必须报错
|
| 621 |
-
if _, err := WriteDefault(fp); err == nil {
|
| 622 |
-
t.Error("WriteDefault must refuse to overwrite existing file")
|
| 623 |
-
}
|
| 624 |
-
}
|
| 625 |
-
|
| 626 |
-
func TestBalanceRefreshDefaults(t *testing.T) {
|
| 627 |
-
// 缺省:启用 + 30 分钟
|
| 628 |
-
c := Default()
|
| 629 |
-
if err := c.normalize(); err != nil {
|
| 630 |
-
t.Fatal(err)
|
| 631 |
-
}
|
| 632 |
-
if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute {
|
| 633 |
-
t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval)
|
| 634 |
-
}
|
| 635 |
-
// 显式配置 10 分钟
|
| 636 |
-
dir := t.TempDir()
|
| 637 |
-
fp := filepath.Join(dir, "c.json")
|
| 638 |
-
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600)
|
| 639 |
-
c2, err := Load(fp)
|
| 640 |
-
if err != nil {
|
| 641 |
-
t.Fatal(err)
|
| 642 |
-
}
|
| 643 |
-
if c2.BalanceRefreshInterval != 10*time.Minute {
|
| 644 |
-
t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval)
|
| 645 |
-
}
|
| 646 |
-
// 显式关闭:interval 归零(不启动)
|
| 647 |
-
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600)
|
| 648 |
-
c3, err := Load(fp)
|
| 649 |
-
if err != nil {
|
| 650 |
-
t.Fatal(err)
|
| 651 |
-
}
|
| 652 |
-
if c3.BalanceRefreshInterval != 0 {
|
| 653 |
-
t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval)
|
| 654 |
-
}
|
| 655 |
-
// 启用但 minutes<=0 → 回落默认 30
|
| 656 |
-
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600)
|
| 657 |
-
c4, err := Load(fp)
|
| 658 |
-
if err != nil {
|
| 659 |
-
t.Fatal(err)
|
| 660 |
-
}
|
| 661 |
-
if c4.BalanceRefreshInterval != 5*time.Minute {
|
| 662 |
-
t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval)
|
| 663 |
-
}
|
| 664 |
-
}
|
| 665 |
-
|
| 666 |
-
// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端
|
| 667 |
-
// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。
|
| 668 |
-
func TestPromptDefaultPassthrough(t *testing.T) {
|
| 669 |
-
c, err := Load("")
|
| 670 |
-
if err != nil {
|
| 671 |
-
t.Fatal(err)
|
| 672 |
-
}
|
| 673 |
-
if c.Prompt.Mode != "passthrough" {
|
| 674 |
-
t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode)
|
| 675 |
-
}
|
| 676 |
-
// passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。
|
| 677 |
-
}
|
| 678 |
-
|
| 679 |
-
// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。
|
| 680 |
-
func TestPromptExplicitPassthrough(t *testing.T) {
|
| 681 |
-
dir := t.TempDir()
|
| 682 |
-
fp := filepath.Join(dir, "c.json")
|
| 683 |
-
os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600)
|
| 684 |
-
c, err := Load(fp)
|
| 685 |
-
if err != nil {
|
| 686 |
-
t.Fatal(err)
|
| 687 |
-
}
|
| 688 |
-
if c.Prompt.Mode != "passthrough" {
|
| 689 |
-
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
|
| 690 |
-
}
|
| 691 |
-
if c.PromptText != "" {
|
| 692 |
-
t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText))
|
| 693 |
-
}
|
| 694 |
-
}
|
| 695 |
-
|
| 696 |
-
// TestPromptInvalidMode 非法 mode 启动报错。
|
| 697 |
-
func TestPromptInvalidMode(t *testing.T) {
|
| 698 |
-
dir := t.TempDir()
|
| 699 |
-
fp := filepath.Join(dir, "c.json")
|
| 700 |
-
os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600)
|
| 701 |
-
if _, err := Load(fp); err == nil {
|
| 702 |
-
t.Fatal("want error for invalid prompt.mode")
|
| 703 |
-
}
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
// TestPromptFileMissing 文件路径非空但
|
| 707 |
-
func TestPromptFileMissing(t *testing.T) {
|
| 708 |
-
dir := t.TempDir()
|
| 709 |
-
fp := filepath.Join(dir, "c.json")
|
| 710 |
-
os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600)
|
| 711 |
-
if _, err := Load(fp); err == nil {
|
| 712 |
-
t.Fatal("want error for missing prompt file")
|
| 713 |
-
}
|
| 714 |
-
}
|
| 715 |
-
|
| 716 |
-
// TestPromptFileOverride 自定义 file 覆盖内置默认。
|
| 717 |
-
func TestPromptFileOverride(t *testing.T) {
|
| 718 |
-
dir := t.TempDir()
|
| 719 |
-
pf := filepath.Join(dir, "my.md")
|
| 720 |
-
want := "我的自定义人格入口"
|
| 721 |
-
os.WriteFile(pf, []byte(want), 0o600)
|
| 722 |
-
cf := filepath.Join(dir, "c.json")
|
| 723 |
-
// 用 json.Marshal
|
| 724 |
-
cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}})
|
| 725 |
-
if err != nil {
|
| 726 |
-
t.Fatal(err)
|
| 727 |
-
}
|
| 728 |
-
os.WriteFile(cf, cfgJSON, 0o600)
|
| 729 |
-
c, err := Load(cf)
|
| 730 |
-
if err != nil {
|
| 731 |
-
t.Fatal(err)
|
| 732 |
-
}
|
| 733 |
-
if c.PromptText != want {
|
| 734 |
-
t.Errorf("PromptText=%q want %q", c.PromptText, want)
|
| 735 |
-
}
|
| 736 |
-
}
|
| 737 |
-
|
| 738 |
-
// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。
|
| 739 |
-
func TestPromptEnvOverride(t *testing.T) {
|
| 740 |
-
t.Setenv("WB2A_PROMPT_MODE", "passthrough")
|
| 741 |
-
c, err := Load("")
|
| 742 |
-
if err != nil {
|
| 743 |
-
t.Fatal(err)
|
| 744 |
-
}
|
| 745 |
-
if c.Prompt.Mode != "passthrough" {
|
| 746 |
-
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
|
| 747 |
-
}
|
| 748 |
-
}
|
| 749 |
-
|
| 750 |
-
// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。
|
| 751 |
-
func TestPromptLegacyConfigNoImpact(t *testing.T) {
|
| 752 |
-
dir := t.TempDir()
|
| 753 |
-
fp := filepath.Join(dir, "c.json")
|
| 754 |
-
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
|
| 755 |
-
c, err := Load(fp)
|
| 756 |
-
if err != nil {
|
| 757 |
-
t.Fatal(err)
|
| 758 |
-
}
|
| 759 |
-
if c.Prompt.Mode != "passthrough" {
|
| 760 |
-
t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode)
|
| 761 |
-
}
|
| 762 |
-
if c.Listen != ":9999" {
|
| 763 |
-
t.Errorf("listen=%q", c.Listen)
|
| 764 |
-
}
|
| 765 |
-
}
|
| 766 |
-
|
| 767 |
-
// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效,
|
| 768 |
-
// 缺省空串保持现状(headers 层回落到 clientUA)。
|
| 769 |
-
func TestUpstreamUserAgentConfig(t *testing.T) {
|
| 770 |
-
// JSON 配置
|
| 771 |
-
dir := t.TempDir()
|
| 772 |
-
fp := filepath.Join(dir, "c.json")
|
| 773 |
-
os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600)
|
| 774 |
-
c, err := Load(fp)
|
| 775 |
-
if err != nil {
|
| 776 |
-
t.Fatal(err)
|
| 777 |
-
}
|
| 778 |
-
if c.Upstream.UserAgent != "WorkBuddy/1.2.3" {
|
| 779 |
-
t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent)
|
| 780 |
-
}
|
| 781 |
-
// 缺省为空
|
| 782 |
-
if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" {
|
| 783 |
-
t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err)
|
| 784 |
-
}
|
| 785 |
-
// env 覆盖
|
| 786 |
-
t.Setenv("WB2A_USER_AGENT", "EnvAgent/9")
|
| 787 |
-
c3, err := Load("")
|
| 788 |
-
if err != nil {
|
| 789 |
-
t.Fatal(err)
|
| 790 |
-
}
|
| 791 |
-
if c3.Upstream.UserAgent != "EnvAgent/9" {
|
| 792 |
-
t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent)
|
| 793 |
-
}
|
| 794 |
-
}
|
| 795 |
-
|
| 796 |
-
// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。
|
| 797 |
-
// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。
|
| 798 |
-
// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。
|
| 799 |
-
func TestLoadConfigPathIsDirectory(t *testing.T) {
|
| 800 |
-
dir := t.TempDir()
|
| 801 |
-
asDir := filepath.Join(dir, "config.json")
|
| 802 |
-
if err := os.Mkdir(asDir, 0o755); err != nil {
|
| 803 |
-
t.Fatal(err)
|
| 804 |
-
}
|
| 805 |
-
_, err := Load(asDir)
|
| 806 |
-
if err == nil {
|
| 807 |
-
t.Fatal("want error when config path is a directory")
|
| 808 |
-
}
|
| 809 |
-
msg := err.Error()
|
| 810 |
-
if !strings.Contains(msg, "是目录") {
|
| 811 |
-
t.Errorf("error should explain it is a directory: %v", err)
|
| 812 |
-
}
|
| 813 |
-
if !strings.Contains(msg, "config.example.json") {
|
| 814 |
-
t.Errorf("error should suggest the fix (cp config.example.json): %v", err)
|
| 815 |
-
}
|
| 816 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"strings"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
func TestDefault(t *testing.T) {
|
| 13 |
+
c := Default()
|
| 14 |
+
if c.Listen != ":7863" {
|
| 15 |
+
t.Errorf("listen=%s", c.Listen)
|
| 16 |
+
}
|
| 17 |
+
if err := c.normalize(); err != nil {
|
| 18 |
+
t.Fatalf("normalize: %v", err)
|
| 19 |
+
}
|
| 20 |
+
if c.SoftRateDur.Seconds() != 600 {
|
| 21 |
+
t.Errorf("soft=%v want 600s", c.SoftRateDur)
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func TestPanelPackageDetailLimit(t *testing.T) {
|
| 26 |
+
c := Default()
|
| 27 |
+
if err := c.normalize(); err != nil {
|
| 28 |
+
t.Fatalf("normalize: %v", err)
|
| 29 |
+
}
|
| 30 |
+
if c.Panel.PackageDetailLimit != 5 {
|
| 31 |
+
t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`))
|
| 35 |
+
if err != nil {
|
| 36 |
+
t.Fatalf("parse configured limit: %v", err)
|
| 37 |
+
}
|
| 38 |
+
if configured.Panel.PackageDetailLimit != 8 {
|
| 39 |
+
t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`))
|
| 43 |
+
if err != nil {
|
| 44 |
+
t.Fatalf("parse fallback limit: %v", err)
|
| 45 |
+
}
|
| 46 |
+
if fallback.Panel.PackageDetailLimit != 5 {
|
| 47 |
+
t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
func TestLoggingDefaults(t *testing.T) {
|
| 52 |
+
c := Default()
|
| 53 |
+
if err := c.normalize(); err != nil {
|
| 54 |
+
t.Fatal(err)
|
| 55 |
+
}
|
| 56 |
+
if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 {
|
| 57 |
+
t.Fatalf("logging defaults = %+v", c.Logging)
|
| 58 |
+
}
|
| 59 |
+
// 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。
|
| 60 |
+
if !c.Logging.RequestClientInfo {
|
| 61 |
+
t.Fatalf("request_client_info default = false, want true: %+v", c.Logging)
|
| 62 |
+
}
|
| 63 |
+
configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`))
|
| 64 |
+
if err != nil {
|
| 65 |
+
t.Fatal(err)
|
| 66 |
+
}
|
| 67 |
+
if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 {
|
| 68 |
+
t.Fatalf("configured logging = %+v", configured.Logging)
|
| 69 |
+
}
|
| 70 |
+
off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`))
|
| 71 |
+
if err != nil {
|
| 72 |
+
t.Fatal(err)
|
| 73 |
+
}
|
| 74 |
+
if off.Logging.RequestClientInfo {
|
| 75 |
+
t.Fatalf("explicit false ignored: %+v", off.Logging)
|
| 76 |
+
}
|
| 77 |
+
fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`))
|
| 78 |
+
if err != nil {
|
| 79 |
+
t.Fatal(err)
|
| 80 |
+
}
|
| 81 |
+
if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 {
|
| 82 |
+
t.Fatalf("logging fallback = %+v", fallback.Logging)
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
func TestLoadFile(t *testing.T) {
|
| 87 |
+
dir := t.TempDir()
|
| 88 |
+
fp := filepath.Join(dir, "c.json")
|
| 89 |
+
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
|
| 90 |
+
c, err := Load(fp)
|
| 91 |
+
if err != nil {
|
| 92 |
+
t.Fatal(err)
|
| 93 |
+
}
|
| 94 |
+
if c.Listen != ":9999" || c.APIKey != "k" {
|
| 95 |
+
t.Errorf("c=%+v", c)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
func TestEnvOverride(t *testing.T) {
|
| 100 |
+
t.Setenv("WB2A_LISTEN", ":7777")
|
| 101 |
+
t.Setenv("WB2A_API_KEY", "envkey")
|
| 102 |
+
c, err := Load("")
|
| 103 |
+
if err != nil {
|
| 104 |
+
t.Fatal(err)
|
| 105 |
+
}
|
| 106 |
+
if c.Listen != ":7777" || c.APIKey != "envkey" {
|
| 107 |
+
t.Errorf("c=%+v", c)
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
func TestBadDuration(t *testing.T) {
|
| 112 |
+
dir := t.TempDir()
|
| 113 |
+
fp := filepath.Join(dir, "c.json")
|
| 114 |
+
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600)
|
| 115 |
+
if _, err := Load(fp); err == nil {
|
| 116 |
+
t.Fatal("want error for bad duration")
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
func TestHardCreditKeyIgnored(t *testing.T) {
|
| 121 |
+
// 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。
|
| 122 |
+
dir := t.TempDir()
|
| 123 |
+
fp := filepath.Join(dir, "c.json")
|
| 124 |
+
os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600)
|
| 125 |
+
c, err := Load(fp)
|
| 126 |
+
if err != nil {
|
| 127 |
+
t.Fatalf("hard_credit must be ignored (not validated): %v", err)
|
| 128 |
+
}
|
| 129 |
+
if c.SoftRateDur.Seconds() != 30 {
|
| 130 |
+
t.Errorf("soft_rate=%v want 30s", c.SoftRateDur)
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
func TestNewPoolConfigDefaults(t *testing.T) {
|
| 135 |
+
c := Default()
|
| 136 |
+
if err := c.normalize(); err != nil {
|
| 137 |
+
t.Fatalf("normalize: %v", err)
|
| 138 |
+
}
|
| 139 |
+
if c.Pool.MaxInFlight != 3 {
|
| 140 |
+
t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight)
|
| 141 |
+
}
|
| 142 |
+
if c.Pool.BreakerThreshold != 3 {
|
| 143 |
+
t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold)
|
| 144 |
+
}
|
| 145 |
+
if c.BreakerCooldownDur.Minutes() != 30 {
|
| 146 |
+
t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur)
|
| 147 |
+
}
|
| 148 |
+
if c.BreakerCooldownMaxD.Hours() != 6 {
|
| 149 |
+
t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD)
|
| 150 |
+
}
|
| 151 |
+
if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 {
|
| 152 |
+
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
|
| 153 |
+
}
|
| 154 |
+
if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour {
|
| 155 |
+
t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
|
| 156 |
+
}
|
| 157 |
+
if c.SoftRateMaxDur.Hours() != 2 {
|
| 158 |
+
t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur)
|
| 159 |
+
}
|
| 160 |
+
if !c.SessionSticky.Enabled {
|
| 161 |
+
t.Error("session_sticky.enabled want true")
|
| 162 |
+
}
|
| 163 |
+
if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 {
|
| 164 |
+
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
|
| 165 |
+
}
|
| 166 |
+
if c.Upstash.URL != "" || c.Upstash.Token != "" {
|
| 167 |
+
t.Errorf("upstash default should be empty: %+v", c.Upstash)
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func TestPoolConfigParsedFromFile(t *testing.T) {
|
| 172 |
+
dir := t.TempDir()
|
| 173 |
+
fp := filepath.Join(dir, "c.json")
|
| 174 |
+
os.WriteFile(fp, []byte(`{
|
| 175 |
+
"upstash":{"url":"https://foo.upstash.io","token":"tok"},
|
| 176 |
+
"pool":{
|
| 177 |
+
"max_in_flight":5,
|
| 178 |
+
"breaker_threshold":4,
|
| 179 |
+
"breaker_cooldown":"10m",
|
| 180 |
+
"breaker_cooldown_max":"2h",
|
| 181 |
+
"idle_weight_per_hour":0.7,
|
| 182 |
+
"idle_weight_max":8.0,
|
| 183 |
+
"prefer_expiring":false,
|
| 184 |
+
"expiring_soon":"72h"
|
| 185 |
+
},
|
| 186 |
+
"session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"}
|
| 187 |
+
}`), 0o600)
|
| 188 |
+
c, err := Load(fp)
|
| 189 |
+
if err != nil {
|
| 190 |
+
t.Fatal(err)
|
| 191 |
+
}
|
| 192 |
+
if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" {
|
| 193 |
+
t.Errorf("upstash=%+v", c.Upstash)
|
| 194 |
+
}
|
| 195 |
+
if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 {
|
| 196 |
+
t.Errorf("pool=%+v", c.Pool)
|
| 197 |
+
}
|
| 198 |
+
if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 {
|
| 199 |
+
t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD)
|
| 200 |
+
}
|
| 201 |
+
if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 {
|
| 202 |
+
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
|
| 203 |
+
}
|
| 204 |
+
if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour {
|
| 205 |
+
t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
|
| 206 |
+
}
|
| 207 |
+
if c.SessionSticky.Enabled {
|
| 208 |
+
t.Error("session_sticky.enabled want false from file")
|
| 209 |
+
}
|
| 210 |
+
if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 {
|
| 211 |
+
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
func TestSoftRateMaxParsedFromFile(t *testing.T) {
|
| 216 |
+
dir := t.TempDir()
|
| 217 |
+
fp := filepath.Join(dir, "c.json")
|
| 218 |
+
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600)
|
| 219 |
+
c, err := Load(fp)
|
| 220 |
+
if err != nil {
|
| 221 |
+
t.Fatal(err)
|
| 222 |
+
}
|
| 223 |
+
if c.SoftRateDur.Minutes() != 5 {
|
| 224 |
+
t.Errorf("soft_rate=%v want 5m", c.SoftRateDur)
|
| 225 |
+
}
|
| 226 |
+
if c.SoftRateMaxDur.Minutes() != 45 {
|
| 227 |
+
t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur)
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) {
|
| 232 |
+
dir := t.TempDir()
|
| 233 |
+
fp := filepath.Join(dir, "c.json")
|
| 234 |
+
os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600)
|
| 235 |
+
c, err := Load(fp)
|
| 236 |
+
if err != nil {
|
| 237 |
+
t.Fatal(err)
|
| 238 |
+
}
|
| 239 |
+
if !c.Pool.PreferExpiring {
|
| 240 |
+
t.Fatal("missing prefer_expiring must preserve default true")
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) {
|
| 245 |
+
dir := t.TempDir()
|
| 246 |
+
fp := filepath.Join(dir, "c.json")
|
| 247 |
+
os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600)
|
| 248 |
+
c, err := Load(fp)
|
| 249 |
+
if err != nil {
|
| 250 |
+
t.Fatal(err)
|
| 251 |
+
}
|
| 252 |
+
if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" {
|
| 253 |
+
t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon)
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) {
|
| 258 |
+
// 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。
|
| 259 |
+
dir := t.TempDir()
|
| 260 |
+
fp := filepath.Join(dir, "c.json")
|
| 261 |
+
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600)
|
| 262 |
+
c, err := Load(fp)
|
| 263 |
+
if err != nil {
|
| 264 |
+
t.Fatal(err)
|
| 265 |
+
}
|
| 266 |
+
if c.SoftRateMaxDur.Hours() != 2 {
|
| 267 |
+
t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur)
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
func TestBadSoftRateMax(t *testing.T) {
|
| 272 |
+
dir := t.TempDir()
|
| 273 |
+
fp := filepath.Join(dir, "c.json")
|
| 274 |
+
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600)
|
| 275 |
+
if _, err := Load(fp); err == nil {
|
| 276 |
+
t.Fatal("want error for bad soft_rate_max")
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
func TestBadBreakerCooldown(t *testing.T) {
|
| 281 |
+
dir := t.TempDir()
|
| 282 |
+
fp := filepath.Join(dir, "c.json")
|
| 283 |
+
os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600)
|
| 284 |
+
if _, err := Load(fp); err == nil {
|
| 285 |
+
t.Fatal("want error for bad breaker_cooldown")
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
func TestUpstreamTimeoutDefaults(t *testing.T) {
|
| 290 |
+
// 默认:header 回落 timeout,idle 回落 300。
|
| 291 |
+
c := Default()
|
| 292 |
+
if err := c.normalize(); err != nil {
|
| 293 |
+
t.Fatalf("normalize: %v", err)
|
| 294 |
+
}
|
| 295 |
+
if c.Upstream.TimeoutSeconds != 120 {
|
| 296 |
+
t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds)
|
| 297 |
+
}
|
| 298 |
+
if c.Upstream.HeaderTimeoutSeconds != 120 {
|
| 299 |
+
t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds)
|
| 300 |
+
}
|
| 301 |
+
if c.Upstream.IdleTimeoutSeconds != 300 {
|
| 302 |
+
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) {
|
| 307 |
+
// 只设 timeout_seconds:header 回落同值,idle 回落 300。
|
| 308 |
+
dir := t.TempDir()
|
| 309 |
+
fp := filepath.Join(dir, "c.json")
|
| 310 |
+
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600)
|
| 311 |
+
c, err := Load(fp)
|
| 312 |
+
if err != nil {
|
| 313 |
+
t.Fatal(err)
|
| 314 |
+
}
|
| 315 |
+
if c.Upstream.HeaderTimeoutSeconds != 60 {
|
| 316 |
+
t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds)
|
| 317 |
+
}
|
| 318 |
+
if c.Upstream.IdleTimeoutSeconds != 300 {
|
| 319 |
+
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
func TestUpstreamExplicitHeaderIdle(t *testing.T) {
|
| 324 |
+
dir := t.TempDir()
|
| 325 |
+
fp := filepath.Join(dir, "c.json")
|
| 326 |
+
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600)
|
| 327 |
+
c, err := Load(fp)
|
| 328 |
+
if err != nil {
|
| 329 |
+
t.Fatal(err)
|
| 330 |
+
}
|
| 331 |
+
if c.Upstream.HeaderTimeoutSeconds != 30 {
|
| 332 |
+
t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds)
|
| 333 |
+
}
|
| 334 |
+
if c.Upstream.IdleTimeoutSeconds != 600 {
|
| 335 |
+
t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds)
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
func TestUpstreamEnvOverride(t *testing.T) {
|
| 340 |
+
t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45")
|
| 341 |
+
t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900")
|
| 342 |
+
c, err := Load("")
|
| 343 |
+
if err != nil {
|
| 344 |
+
t.Fatal(err)
|
| 345 |
+
}
|
| 346 |
+
if c.Upstream.HeaderTimeoutSeconds != 45 {
|
| 347 |
+
t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds)
|
| 348 |
+
}
|
| 349 |
+
if c.Upstream.IdleTimeoutSeconds != 900 {
|
| 350 |
+
t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds)
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。
|
| 355 |
+
func TestRetiredTravelIntervalKeyIgnored(t *testing.T) {
|
| 356 |
+
dir := t.TempDir()
|
| 357 |
+
fp := filepath.Join(dir, "c.json")
|
| 358 |
+
os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600)
|
| 359 |
+
c, err := Load(fp)
|
| 360 |
+
if err != nil {
|
| 361 |
+
t.Fatalf("retired key should not fail load: %v", err)
|
| 362 |
+
}
|
| 363 |
+
if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 {
|
| 364 |
+
t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours)
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true:
|
| 369 |
+
// 老 config 不写这些键,行为必须与从前完全一致。
|
| 370 |
+
func TestScheduleEnabledByDefault(t *testing.T) {
|
| 371 |
+
c := Default()
|
| 372 |
+
if err := c.normalize(); err != nil {
|
| 373 |
+
t.Fatalf("normalize: %v", err)
|
| 374 |
+
}
|
| 375 |
+
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 376 |
+
t.Errorf("enabled defaults want true/true, got %v/%v",
|
| 377 |
+
c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled)
|
| 378 |
+
}
|
| 379 |
+
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 380 |
+
t.Errorf("travel/activity enabled defaults want true/true, got %v/%v",
|
| 381 |
+
c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled)
|
| 382 |
+
}
|
| 383 |
+
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 384 |
+
t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours)
|
| 385 |
+
}
|
| 386 |
+
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 387 |
+
t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours)
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态,
|
| 392 |
+
// 新开关缺省 true、新 hours 回落默认——对老配置零影响。
|
| 393 |
+
func TestScheduleLegacyConfigKeepsRunning(t *testing.T) {
|
| 394 |
+
dir := t.TempDir()
|
| 395 |
+
fp := filepath.Join(dir, "c.json")
|
| 396 |
+
os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600)
|
| 397 |
+
c, err := Load(fp)
|
| 398 |
+
if err != nil {
|
| 399 |
+
t.Fatal(err)
|
| 400 |
+
}
|
| 401 |
+
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 402 |
+
t.Errorf("legacy config must stay enabled: %+v", c.Schedule)
|
| 403 |
+
}
|
| 404 |
+
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 405 |
+
t.Errorf("new switches must default true on legacy config: %+v", c.Schedule)
|
| 406 |
+
}
|
| 407 |
+
if len(c.Schedule.CheckinHours) != 2 {
|
| 408 |
+
t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours)
|
| 409 |
+
}
|
| 410 |
+
// 新 hours 缺省 → 回落默认(非空)。
|
| 411 |
+
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 412 |
+
t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours)
|
| 413 |
+
}
|
| 414 |
+
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 415 |
+
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到
|
| 420 |
+
// (issue #27 边界:此前无论怎么配小时都关不掉)。
|
| 421 |
+
func TestScheduleExplicitDisable(t *testing.T) {
|
| 422 |
+
dir := t.TempDir()
|
| 423 |
+
fp := filepath.Join(dir, "c.json")
|
| 424 |
+
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600)
|
| 425 |
+
c, err := Load(fp)
|
| 426 |
+
if err != nil {
|
| 427 |
+
t.Fatal(err)
|
| 428 |
+
}
|
| 429 |
+
if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled {
|
| 430 |
+
t.Errorf("want both disabled: %+v", c.Schedule)
|
| 431 |
+
}
|
| 432 |
+
// 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。
|
| 433 |
+
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
|
| 434 |
+
t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours)
|
| 435 |
+
}
|
| 436 |
+
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
|
| 437 |
+
t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours)
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。
|
| 442 |
+
func TestScheduleTravelActivityExplicitDisable(t *testing.T) {
|
| 443 |
+
dir := t.TempDir()
|
| 444 |
+
fp := filepath.Join(dir, "c.json")
|
| 445 |
+
os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600)
|
| 446 |
+
c, err := Load(fp)
|
| 447 |
+
if err != nil {
|
| 448 |
+
t.Fatal(err)
|
| 449 |
+
}
|
| 450 |
+
if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled {
|
| 451 |
+
t.Errorf("want travel/activity disabled: %+v", c.Schedule)
|
| 452 |
+
}
|
| 453 |
+
// 签到/保活开关缺省 true(互不干扰)。
|
| 454 |
+
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 455 |
+
t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule)
|
| 456 |
+
}
|
| 457 |
+
// hours 仍回落默认。
|
| 458 |
+
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 459 |
+
t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours)
|
| 460 |
+
}
|
| 461 |
+
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 462 |
+
t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours)
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。
|
| 467 |
+
func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) {
|
| 468 |
+
cases := []struct{ body, wantSwitch string }{
|
| 469 |
+
{`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"},
|
| 470 |
+
{`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"},
|
| 471 |
+
{`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"},
|
| 472 |
+
{`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"},
|
| 473 |
+
}
|
| 474 |
+
for _, tc := range cases {
|
| 475 |
+
dir := t.TempDir()
|
| 476 |
+
fp := filepath.Join(dir, "c.json")
|
| 477 |
+
os.WriteFile(fp, []byte(tc.body), 0o600)
|
| 478 |
+
_, err := Load(fp)
|
| 479 |
+
if err == nil {
|
| 480 |
+
t.Fatalf("want error for %s", tc.body)
|
| 481 |
+
}
|
| 482 |
+
if !strings.Contains(err.Error(), tc.wantSwitch) {
|
| 483 |
+
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。
|
| 489 |
+
func TestScheduleTravelActivityExplicitHours(t *testing.T) {
|
| 490 |
+
dir := t.TempDir()
|
| 491 |
+
fp := filepath.Join(dir, "c.json")
|
| 492 |
+
os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600)
|
| 493 |
+
c, err := Load(fp)
|
| 494 |
+
if err != nil {
|
| 495 |
+
t.Fatal(err)
|
| 496 |
+
}
|
| 497 |
+
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 498 |
+
t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours)
|
| 499 |
+
}
|
| 500 |
+
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 {
|
| 501 |
+
t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours)
|
| 502 |
+
}
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。
|
| 506 |
+
func TestScheduleDisableKeepsExplicitHours(t *testing.T) {
|
| 507 |
+
dir := t.TempDir()
|
| 508 |
+
fp := filepath.Join(dir, "c.json")
|
| 509 |
+
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600)
|
| 510 |
+
c, err := Load(fp)
|
| 511 |
+
if err != nil {
|
| 512 |
+
t.Fatal(err)
|
| 513 |
+
}
|
| 514 |
+
if c.Schedule.CheckinEnabled {
|
| 515 |
+
t.Error("checkin should be disabled")
|
| 516 |
+
}
|
| 517 |
+
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 {
|
| 518 |
+
t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours)
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。
|
| 523 |
+
func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) {
|
| 524 |
+
cases := map[string]string{
|
| 525 |
+
"absent": `{}`,
|
| 526 |
+
"empty": `{"schedule":{}}`,
|
| 527 |
+
"null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`,
|
| 528 |
+
"emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`,
|
| 529 |
+
}
|
| 530 |
+
for name, body := range cases {
|
| 531 |
+
t.Run(name, func(t *testing.T) {
|
| 532 |
+
dir := t.TempDir()
|
| 533 |
+
fp := filepath.Join(dir, "c.json")
|
| 534 |
+
os.WriteFile(fp, []byte(body), 0o600)
|
| 535 |
+
c, err := Load(fp)
|
| 536 |
+
if err != nil {
|
| 537 |
+
t.Fatal(err)
|
| 538 |
+
}
|
| 539 |
+
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
|
| 540 |
+
t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours)
|
| 541 |
+
}
|
| 542 |
+
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
|
| 543 |
+
t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours)
|
| 544 |
+
}
|
| 545 |
+
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
|
| 546 |
+
t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours)
|
| 547 |
+
}
|
| 548 |
+
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
|
| 549 |
+
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
|
| 550 |
+
}
|
| 551 |
+
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
|
| 552 |
+
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
|
| 553 |
+
}
|
| 554 |
+
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
|
| 555 |
+
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
|
| 556 |
+
}
|
| 557 |
+
})
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户
|
| 562 |
+
// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。
|
| 563 |
+
func TestScheduleInvalidHourRejected(t *testing.T) {
|
| 564 |
+
cases := []struct{ body, wantSwitch string }{
|
| 565 |
+
{`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"},
|
| 566 |
+
{`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"},
|
| 567 |
+
{`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"},
|
| 568 |
+
}
|
| 569 |
+
for _, tc := range cases {
|
| 570 |
+
dir := t.TempDir()
|
| 571 |
+
fp := filepath.Join(dir, "c.json")
|
| 572 |
+
os.WriteFile(fp, []byte(tc.body), 0o600)
|
| 573 |
+
_, err := Load(fp)
|
| 574 |
+
if err == nil {
|
| 575 |
+
t.Fatalf("want error for %s", tc.body)
|
| 576 |
+
}
|
| 577 |
+
if !strings.Contains(err.Error(), tc.wantSwitch) {
|
| 578 |
+
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
|
| 579 |
+
}
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
func TestBadSessionTTL(t *testing.T) {
|
| 584 |
+
dir := t.TempDir()
|
| 585 |
+
fp := filepath.Join(dir, "c.json")
|
| 586 |
+
os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600)
|
| 587 |
+
if _, err := Load(fp); err == nil {
|
| 588 |
+
t.Fatal("want error for bad session_sticky.ttl")
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
func TestWriteDefault(t *testing.T) {
|
| 593 |
+
dir := t.TempDir()
|
| 594 |
+
fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建
|
| 595 |
+
key, err := WriteDefault(fp)
|
| 596 |
+
if err != nil {
|
| 597 |
+
t.Fatal(err)
|
| 598 |
+
}
|
| 599 |
+
// key 形如 sk-<24字符随机串>,两次生成不重复
|
| 600 |
+
if !strings.HasPrefix(key, "sk-") || len(key) < 20 {
|
| 601 |
+
t.Errorf("key=%q want sk-<random>", key)
|
| 602 |
+
}
|
| 603 |
+
if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key {
|
| 604 |
+
t.Errorf("two generated keys identical: %q", key)
|
| 605 |
+
}
|
| 606 |
+
// 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效
|
| 607 |
+
c, err := Load(fp)
|
| 608 |
+
if err != nil {
|
| 609 |
+
t.Fatalf("load generated config: %v", err)
|
| 610 |
+
}
|
| 611 |
+
if c.APIKey != key {
|
| 612 |
+
t.Errorf("api_key=%q want %q", c.APIKey, key)
|
| 613 |
+
}
|
| 614 |
+
if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" {
|
| 615 |
+
t.Errorf("generated defaults off: %+v", c)
|
| 616 |
+
}
|
| 617 |
+
if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled {
|
| 618 |
+
t.Errorf("generated schedule off: %+v", c.Schedule)
|
| 619 |
+
}
|
| 620 |
+
// 已存在的文件不覆盖:二次写入同一路径必须报错
|
| 621 |
+
if _, err := WriteDefault(fp); err == nil {
|
| 622 |
+
t.Error("WriteDefault must refuse to overwrite existing file")
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
func TestBalanceRefreshDefaults(t *testing.T) {
|
| 627 |
+
// 缺省:启用 + 30 分钟
|
| 628 |
+
c := Default()
|
| 629 |
+
if err := c.normalize(); err != nil {
|
| 630 |
+
t.Fatal(err)
|
| 631 |
+
}
|
| 632 |
+
if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute {
|
| 633 |
+
t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval)
|
| 634 |
+
}
|
| 635 |
+
// 显式配置 10 分钟
|
| 636 |
+
dir := t.TempDir()
|
| 637 |
+
fp := filepath.Join(dir, "c.json")
|
| 638 |
+
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600)
|
| 639 |
+
c2, err := Load(fp)
|
| 640 |
+
if err != nil {
|
| 641 |
+
t.Fatal(err)
|
| 642 |
+
}
|
| 643 |
+
if c2.BalanceRefreshInterval != 10*time.Minute {
|
| 644 |
+
t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval)
|
| 645 |
+
}
|
| 646 |
+
// 显式关闭:interval 归零(不启动)
|
| 647 |
+
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600)
|
| 648 |
+
c3, err := Load(fp)
|
| 649 |
+
if err != nil {
|
| 650 |
+
t.Fatal(err)
|
| 651 |
+
}
|
| 652 |
+
if c3.BalanceRefreshInterval != 0 {
|
| 653 |
+
t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval)
|
| 654 |
+
}
|
| 655 |
+
// 启用但 minutes<=0 → 回落默认 30
|
| 656 |
+
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600)
|
| 657 |
+
c4, err := Load(fp)
|
| 658 |
+
if err != nil {
|
| 659 |
+
t.Fatal(err)
|
| 660 |
+
}
|
| 661 |
+
if c4.BalanceRefreshInterval != 5*time.Minute {
|
| 662 |
+
t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval)
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端
|
| 667 |
+
// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。
|
| 668 |
+
func TestPromptDefaultPassthrough(t *testing.T) {
|
| 669 |
+
c, err := Load("")
|
| 670 |
+
if err != nil {
|
| 671 |
+
t.Fatal(err)
|
| 672 |
+
}
|
| 673 |
+
if c.Prompt.Mode != "passthrough" {
|
| 674 |
+
t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode)
|
| 675 |
+
}
|
| 676 |
+
// passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。
|
| 680 |
+
func TestPromptExplicitPassthrough(t *testing.T) {
|
| 681 |
+
dir := t.TempDir()
|
| 682 |
+
fp := filepath.Join(dir, "c.json")
|
| 683 |
+
os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600)
|
| 684 |
+
c, err := Load(fp)
|
| 685 |
+
if err != nil {
|
| 686 |
+
t.Fatal(err)
|
| 687 |
+
}
|
| 688 |
+
if c.Prompt.Mode != "passthrough" {
|
| 689 |
+
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
|
| 690 |
+
}
|
| 691 |
+
if c.PromptText != "" {
|
| 692 |
+
t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText))
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
// TestPromptInvalidMode 非法 mode 启动报错。
|
| 697 |
+
func TestPromptInvalidMode(t *testing.T) {
|
| 698 |
+
dir := t.TempDir()
|
| 699 |
+
fp := filepath.Join(dir, "c.json")
|
| 700 |
+
os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600)
|
| 701 |
+
if _, err := Load(fp); err == nil {
|
| 702 |
+
t.Fatal("want error for invalid prompt.mode")
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
// TestPromptFileMissing 文件路径非空但不存在 → 启动报错(fail fast)。
|
| 707 |
+
func TestPromptFileMissing(t *testing.T) {
|
| 708 |
+
dir := t.TempDir()
|
| 709 |
+
fp := filepath.Join(dir, "c.json")
|
| 710 |
+
os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600)
|
| 711 |
+
if _, err := Load(fp); err == nil {
|
| 712 |
+
t.Fatal("want error for missing prompt file")
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
// TestPromptFileOverride 自定义 file 覆盖内置默认。
|
| 717 |
+
func TestPromptFileOverride(t *testing.T) {
|
| 718 |
+
dir := t.TempDir()
|
| 719 |
+
pf := filepath.Join(dir, "my.md")
|
| 720 |
+
want := "我的自定义人格入口"
|
| 721 |
+
os.WriteFile(pf, []byte(want), 0o600)
|
| 722 |
+
cf := filepath.Join(dir, "c.json")
|
| 723 |
+
// 用 json.Marshal ���路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。
|
| 724 |
+
cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}})
|
| 725 |
+
if err != nil {
|
| 726 |
+
t.Fatal(err)
|
| 727 |
+
}
|
| 728 |
+
os.WriteFile(cf, cfgJSON, 0o600)
|
| 729 |
+
c, err := Load(cf)
|
| 730 |
+
if err != nil {
|
| 731 |
+
t.Fatal(err)
|
| 732 |
+
}
|
| 733 |
+
if c.PromptText != want {
|
| 734 |
+
t.Errorf("PromptText=%q want %q", c.PromptText, want)
|
| 735 |
+
}
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。
|
| 739 |
+
func TestPromptEnvOverride(t *testing.T) {
|
| 740 |
+
t.Setenv("WB2A_PROMPT_MODE", "passthrough")
|
| 741 |
+
c, err := Load("")
|
| 742 |
+
if err != nil {
|
| 743 |
+
t.Fatal(err)
|
| 744 |
+
}
|
| 745 |
+
if c.Prompt.Mode != "passthrough" {
|
| 746 |
+
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。
|
| 751 |
+
func TestPromptLegacyConfigNoImpact(t *testing.T) {
|
| 752 |
+
dir := t.TempDir()
|
| 753 |
+
fp := filepath.Join(dir, "c.json")
|
| 754 |
+
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
|
| 755 |
+
c, err := Load(fp)
|
| 756 |
+
if err != nil {
|
| 757 |
+
t.Fatal(err)
|
| 758 |
+
}
|
| 759 |
+
if c.Prompt.Mode != "passthrough" {
|
| 760 |
+
t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode)
|
| 761 |
+
}
|
| 762 |
+
if c.Listen != ":9999" {
|
| 763 |
+
t.Errorf("listen=%q", c.Listen)
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效,
|
| 768 |
+
// 缺省空串保持现状(headers 层回落到 clientUA)。
|
| 769 |
+
func TestUpstreamUserAgentConfig(t *testing.T) {
|
| 770 |
+
// JSON 配置
|
| 771 |
+
dir := t.TempDir()
|
| 772 |
+
fp := filepath.Join(dir, "c.json")
|
| 773 |
+
os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600)
|
| 774 |
+
c, err := Load(fp)
|
| 775 |
+
if err != nil {
|
| 776 |
+
t.Fatal(err)
|
| 777 |
+
}
|
| 778 |
+
if c.Upstream.UserAgent != "WorkBuddy/1.2.3" {
|
| 779 |
+
t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent)
|
| 780 |
+
}
|
| 781 |
+
// 缺省为空
|
| 782 |
+
if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" {
|
| 783 |
+
t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err)
|
| 784 |
+
}
|
| 785 |
+
// env 覆盖
|
| 786 |
+
t.Setenv("WB2A_USER_AGENT", "EnvAgent/9")
|
| 787 |
+
c3, err := Load("")
|
| 788 |
+
if err != nil {
|
| 789 |
+
t.Fatal(err)
|
| 790 |
+
}
|
| 791 |
+
if c3.Upstream.UserAgent != "EnvAgent/9" {
|
| 792 |
+
t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent)
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。
|
| 797 |
+
// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。
|
| 798 |
+
// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。
|
| 799 |
+
func TestLoadConfigPathIsDirectory(t *testing.T) {
|
| 800 |
+
dir := t.TempDir()
|
| 801 |
+
asDir := filepath.Join(dir, "config.json")
|
| 802 |
+
if err := os.Mkdir(asDir, 0o755); err != nil {
|
| 803 |
+
t.Fatal(err)
|
| 804 |
+
}
|
| 805 |
+
_, err := Load(asDir)
|
| 806 |
+
if err == nil {
|
| 807 |
+
t.Fatal("want error when config path is a directory")
|
| 808 |
+
}
|
| 809 |
+
msg := err.Error()
|
| 810 |
+
if !strings.Contains(msg, "是目录") {
|
| 811 |
+
t.Errorf("error should explain it is a directory: %v", err)
|
| 812 |
+
}
|
| 813 |
+
if !strings.Contains(msg, "config.example.json") {
|
| 814 |
+
t.Errorf("error should suggest the fix (cp config.example.json): %v", err)
|
| 815 |
+
}
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
// TestServerReadTimeout 入站读取上限(issue #100):空值回落默认 300s;
|
| 819 |
+
// "0" = 显式不限制(0 是合法值不回落);负值 fail fast(静默钳 0 会把保护悄悄关掉)。
|
| 820 |
+
func TestServerReadTimeout(t *testing.T) {
|
| 821 |
+
c := Default()
|
| 822 |
+
if c.Server.ReadTimeout != "300s" {
|
| 823 |
+
t.Errorf("default read_timeout=%q want 300s", c.Server.ReadTimeout)
|
| 824 |
+
}
|
| 825 |
+
if err := c.normalize(); err != nil {
|
| 826 |
+
t.Fatalf("normalize: %v", err)
|
| 827 |
+
}
|
| 828 |
+
if c.ServerReadTimeoutDur != 300*time.Second {
|
| 829 |
+
t.Errorf("default dur=%v want 300s", c.ServerReadTimeoutDur)
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
c = Default()
|
| 833 |
+
c.Server.ReadTimeout = "" // 显式清空 = 未配置 → 回落默认
|
| 834 |
+
if err := c.normalize(); err != nil {
|
| 835 |
+
t.Fatalf("normalize empty: %v", err)
|
| 836 |
+
}
|
| 837 |
+
if c.ServerReadTimeoutDur != 300*time.Second {
|
| 838 |
+
t.Errorf("empty dur=%v want 300s", c.ServerReadTimeoutDur)
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
c = Default()
|
| 842 |
+
c.Server.ReadTimeout = "0" // 显式 0 = 不限制(http.Server ReadTimeout 0 即无超时)
|
| 843 |
+
if err := c.normalize(); err != nil {
|
| 844 |
+
t.Fatalf("normalize zero: %v", err)
|
| 845 |
+
}
|
| 846 |
+
if c.ServerReadTimeoutDur != 0 {
|
| 847 |
+
t.Errorf("zero dur=%v want 0", c.ServerReadTimeoutDur)
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
c = Default()
|
| 851 |
+
c.Server.ReadTimeout = "-5s"
|
| 852 |
+
if err := c.normalize(); err == nil {
|
| 853 |
+
t.Error("negative read_timeout should fail fast")
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
c = Default()
|
| 857 |
+
c.Server.ReadTimeout = "bogus"
|
| 858 |
+
if err := c.normalize(); err == nil {
|
| 859 |
+
t.Error("unparsable read_timeout should fail fast")
|
| 860 |
+
}
|
| 861 |
+
}
|
cmd/server/credit_floor_config_test.go
CHANGED
|
@@ -1,48 +1,48 @@
|
|
| 1 |
-
// credit_floor_config_test.go pool.credit_floor 配置测试:
|
| 2 |
-
// 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。
|
| 3 |
-
package main
|
| 4 |
-
|
| 5 |
-
import (
|
| 6 |
-
"os"
|
| 7 |
-
"path/filepath"
|
| 8 |
-
"testing"
|
| 9 |
-
)
|
| 10 |
-
|
| 11 |
-
// TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。
|
| 12 |
-
func TestCreditFloorDefault(t *testing.T) {
|
| 13 |
-
c := Default()
|
| 14 |
-
if err := c.normalize(); err != nil {
|
| 15 |
-
t.Fatalf("normalize: %v", err)
|
| 16 |
-
}
|
| 17 |
-
if c.Pool.CreditFloor != 0 {
|
| 18 |
-
t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor)
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
// TestCreditFloorParsedFromFile 显式配置覆盖默认。
|
| 23 |
-
func TestCreditFloorParsedFromFile(t *testing.T) {
|
| 24 |
-
dir := t.TempDir()
|
| 25 |
-
fp := filepath.Join(dir, "c.json")
|
| 26 |
-
os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600)
|
| 27 |
-
c, err := Load(fp)
|
| 28 |
-
if err != nil {
|
| 29 |
-
t.Fatal(err)
|
| 30 |
-
}
|
| 31 |
-
if c.Pool.CreditFloor != 100 {
|
| 32 |
-
t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor)
|
| 33 |
-
}
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
// TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。
|
| 37 |
-
func TestCreditFloorNegativeClamped(t *testing.T) {
|
| 38 |
-
dir := t.TempDir()
|
| 39 |
-
fp := filepath.Join(dir, "c.json")
|
| 40 |
-
os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600)
|
| 41 |
-
c, err := Load(fp)
|
| 42 |
-
if err != nil {
|
| 43 |
-
t.Fatal(err)
|
| 44 |
-
}
|
| 45 |
-
if c.Pool.CreditFloor != 0 {
|
| 46 |
-
t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor)
|
| 47 |
-
}
|
| 48 |
-
}
|
|
|
|
| 1 |
+
// credit_floor_config_test.go pool.credit_floor 配置测试:
|
| 2 |
+
// 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。
|
| 3 |
+
package main
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。
|
| 12 |
+
func TestCreditFloorDefault(t *testing.T) {
|
| 13 |
+
c := Default()
|
| 14 |
+
if err := c.normalize(); err != nil {
|
| 15 |
+
t.Fatalf("normalize: %v", err)
|
| 16 |
+
}
|
| 17 |
+
if c.Pool.CreditFloor != 0 {
|
| 18 |
+
t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor)
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// TestCreditFloorParsedFromFile 显式配置覆盖默认。
|
| 23 |
+
func TestCreditFloorParsedFromFile(t *testing.T) {
|
| 24 |
+
dir := t.TempDir()
|
| 25 |
+
fp := filepath.Join(dir, "c.json")
|
| 26 |
+
os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600)
|
| 27 |
+
c, err := Load(fp)
|
| 28 |
+
if err != nil {
|
| 29 |
+
t.Fatal(err)
|
| 30 |
+
}
|
| 31 |
+
if c.Pool.CreditFloor != 100 {
|
| 32 |
+
t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。
|
| 37 |
+
func TestCreditFloorNegativeClamped(t *testing.T) {
|
| 38 |
+
dir := t.TempDir()
|
| 39 |
+
fp := filepath.Join(dir, "c.json")
|
| 40 |
+
os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600)
|
| 41 |
+
c, err := Load(fp)
|
| 42 |
+
if err != nil {
|
| 43 |
+
t.Fatal(err)
|
| 44 |
+
}
|
| 45 |
+
if c.Pool.CreditFloor != 0 {
|
| 46 |
+
t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor)
|
| 47 |
+
}
|
| 48 |
+
}
|
cmd/server/main.go
CHANGED
|
@@ -1,550 +1,563 @@
|
|
| 1 |
-
// main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。
|
| 2 |
-
package main
|
| 3 |
-
|
| 4 |
-
import (
|
| 5 |
-
"context"
|
| 6 |
-
"encoding/json"
|
| 7 |
-
"errors"
|
| 8 |
-
"flag"
|
| 9 |
-
"fmt"
|
| 10 |
-
"io"
|
| 11 |
-
"io/fs"
|
| 12 |
-
"log"
|
| 13 |
-
"net/http"
|
| 14 |
-
"os"
|
| 15 |
-
"os/signal"
|
| 16 |
-
"path/filepath"
|
| 17 |
-
"syscall"
|
| 18 |
-
"time"
|
| 19 |
-
|
| 20 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 21 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/livecfg"
|
| 22 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/panel"
|
| 23 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
|
| 24 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/redisstore"
|
| 25 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/reqlog"
|
| 26 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/scheduler"
|
| 27 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/server"
|
| 28 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/session"
|
| 29 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 30 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/usage"
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。
|
| 34 |
-
const appVersion = "1.
|
| 35 |
-
|
| 36 |
-
// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。
|
| 37 |
-
// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。
|
| 38 |
-
func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") }
|
| 39 |
-
|
| 40 |
-
// stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。
|
| 41 |
-
// usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。
|
| 42 |
-
func stateSibling(stateFile, name string) string {
|
| 43 |
-
dir := filepath.Dir(stateFile)
|
| 44 |
-
if dir == "" || dir == "." {
|
| 45 |
-
return name
|
| 46 |
-
}
|
| 47 |
-
return filepath.Join(dir, name)
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
func main() {
|
| 51 |
-
cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)")
|
| 52 |
-
flag.Parse()
|
| 53 |
-
|
| 54 |
-
cfg, err := Load(*cfgPath)
|
| 55 |
-
if err != nil {
|
| 56 |
-
// errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。
|
| 57 |
-
if errors.Is(err, fs.ErrNotExist) {
|
| 58 |
-
// 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。
|
| 59 |
-
// 双击 exe / 裸跑 docker 即开,无需先手工复制样例。
|
| 60 |
-
if key, werr := WriteDefault(*cfgPath); werr == nil {
|
| 61 |
-
log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key)
|
| 62 |
-
cfg, err = Load(*cfgPath)
|
| 63 |
-
}
|
| 64 |
-
if err != nil {
|
| 65 |
-
// 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。
|
| 66 |
-
log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err)
|
| 67 |
-
cfg, err = Load("")
|
| 68 |
-
}
|
| 69 |
-
}
|
| 70 |
-
if err != nil {
|
| 71 |
-
log.Fatalf("load config: %v", err)
|
| 72 |
-
}
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
auths, err := auth.LoadDir(cfg.AuthDir)
|
| 76 |
-
if err != nil {
|
| 77 |
-
log.Fatalf("load auths: %v", err)
|
| 78 |
-
}
|
| 79 |
-
log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir)
|
| 80 |
-
|
| 81 |
-
// redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。
|
| 82 |
-
store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token)
|
| 83 |
-
|
| 84 |
-
p := pool.New(cfg.StateFile)
|
| 85 |
-
// 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store),
|
| 86 |
-
// 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。
|
| 87 |
-
defer func() {
|
| 88 |
-
p.Close()
|
| 89 |
-
_ = store.Close()
|
| 90 |
-
}()
|
| 91 |
-
p.SetStore(store)
|
| 92 |
-
p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先
|
| 93 |
-
p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留)
|
| 94 |
-
|
| 95 |
-
// 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入,
|
| 96 |
-
// 非正值回退默认)。
|
| 97 |
-
p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD)
|
| 98 |
-
p.SetMaxInFlight(cfg.Pool.MaxInFlight)
|
| 99 |
-
p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1)
|
| 100 |
-
p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD)
|
| 101 |
-
p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h)
|
| 102 |
-
p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停)
|
| 103 |
-
p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭)
|
| 104 |
-
p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax)
|
| 105 |
-
p.SetPreferExpiring(cfg.Pool.PreferExpiring)
|
| 106 |
-
|
| 107 |
-
// 会话粘性路由(可配关闭)。
|
| 108 |
-
var sessRouter *session.Router
|
| 109 |
-
redisMode := "noop"
|
| 110 |
-
if _, ok := store.(redisstore.Noop); !ok {
|
| 111 |
-
redisMode = "upstash"
|
| 112 |
-
}
|
| 113 |
-
if cfg.SessionSticky.Enabled {
|
| 114 |
-
sessRouter = session.New(session.Config{
|
| 115 |
-
TTL: cfg.SessionTTL,
|
| 116 |
-
GCInterval: cfg.SessionGCInterval,
|
| 117 |
-
Store: store,
|
| 118 |
-
Available: p.AvailableUIDs,
|
| 119 |
-
// realm 感知闭包:带
|
| 120 |
-
// 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。
|
| 121 |
-
AvailableForModel: realmAwareAvailableForModel(p),
|
| 122 |
-
})
|
| 123 |
-
sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处)
|
| 124 |
-
sessRouter.StartGC()
|
| 125 |
-
defer sessRouter.StopGC()
|
| 126 |
-
}
|
| 127 |
-
sessCount := func() int {
|
| 128 |
-
if sessRouter != nil {
|
| 129 |
-
return sessRouter.Count()
|
| 130 |
-
}
|
| 131 |
-
return 0
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
up := upstream.New()
|
| 135 |
-
|
| 136 |
-
// 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测
|
| 137 |
-
// 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿
|
| 138 |
-
// (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。
|
| 139 |
-
// 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。
|
| 140 |
-
p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) })
|
| 141 |
-
|
| 142 |
-
// 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。
|
| 143 |
-
up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second
|
| 144 |
-
// 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。
|
| 145 |
-
up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second
|
| 146 |
-
if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok {
|
| 147 |
-
tr.ResponseHeaderTimeout = up.HeaderTimeout
|
| 148 |
-
}
|
| 149 |
-
// 聊天 SSE 流中空闲上限(S3 空闲监控读取)。
|
| 150 |
-
up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second
|
| 151 |
-
up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints)
|
| 152 |
-
// 出站 UA 与归属头(issue #42 + 上游同步):
|
| 153 |
-
// UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态;
|
| 154 |
-
// ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。
|
| 155 |
-
up.UserAgent = cfg.Upstream.UserAgent
|
| 156 |
-
up.ClientVersion = cfg.Upstream.ClientVersion
|
| 157 |
-
up.CliVersion = cfg.Upstream.CliVersion
|
| 158 |
-
up.ClientName = cfg.Upstream.ClientName
|
| 159 |
-
up.DeviceToken = cfg.Upstream.DeviceToken
|
| 160 |
-
up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile
|
| 161 |
-
up.PassthroughIP = cfg.Upstream.PassthroughIP
|
| 162 |
-
// global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖;
|
| 163 |
-
// auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。
|
| 164 |
-
up.GlobalEnabled = cfg.Global.Enabled
|
| 165 |
-
up.ChatBaseGlobal = cfg.Global.ChatBase
|
| 166 |
-
up.BillingBaseGlobal = cfg.Global.BillingBase
|
| 167 |
-
auth.SetGlobalEnabled(cfg.Global.Enabled)
|
| 168 |
-
// model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级):
|
| 169 |
-
// 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动
|
| 170 |
-
// 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。
|
| 171 |
-
upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json"))
|
| 172 |
-
|
| 173 |
-
sch := scheduler.New(scheduler.Config{
|
| 174 |
-
Pool: p,
|
| 175 |
-
Upstream: up,
|
| 176 |
-
CheckinHours: cfg.Schedule.CheckinHours,
|
| 177 |
-
TravelHours: cfg.Schedule.TravelHours,
|
| 178 |
-
ActivityHours: cfg.Schedule.ActivityHours,
|
| 179 |
-
KeepaliveHours: cfg.Schedule.KeepaliveHours,
|
| 180 |
-
BlackcatHours: cfg.Schedule.BlackcatHours,
|
| 181 |
-
GrowthHours: cfg.Schedule.GrowthHours,
|
| 182 |
-
// 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。
|
| 183 |
-
ExpiringSoonWindow: cfg.ExpiringSoonDur,
|
| 184 |
-
CheckinDisabled: !cfg.Schedule.CheckinEnabled,
|
| 185 |
-
TravelDisabled: !cfg.Schedule.TravelEnabled,
|
| 186 |
-
ActivityDisabled: !cfg.Schedule.ActivityEnabled,
|
| 187 |
-
KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled,
|
| 188 |
-
BlackcatDisabled: !cfg.Schedule.BlackcatEnabled,
|
| 189 |
-
GrowthDisabled: !cfg.Schedule.GrowthEnabled,
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
log.Printf("签到已
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
log.Printf("猫猫旅行已
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
log.Printf("活跃上报已
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
log.Printf("token 保活已
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
log.Printf("夜猫子已
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
case cfg.
|
| 224 |
-
log.Printf("余额后台刷
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
//
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
//
|
| 333 |
-
// (
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
}
|
| 350 |
-
|
| 351 |
-
//
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
//
|
| 359 |
-
//
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
//
|
| 410 |
-
//
|
| 411 |
-
//
|
| 412 |
-
//
|
| 413 |
-
//
|
| 414 |
-
//
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
if err
|
| 426 |
-
return nil, fmt.Errorf("
|
| 427 |
-
}
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
if err != nil {
|
| 433 |
-
return nil, err
|
| 434 |
-
}
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
if err
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
p.
|
| 490 |
-
p.
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
sch.
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
//
|
| 528 |
-
//
|
| 529 |
-
//
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。
|
| 2 |
+
package main
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"encoding/json"
|
| 7 |
+
"errors"
|
| 8 |
+
"flag"
|
| 9 |
+
"fmt"
|
| 10 |
+
"io"
|
| 11 |
+
"io/fs"
|
| 12 |
+
"log"
|
| 13 |
+
"net/http"
|
| 14 |
+
"os"
|
| 15 |
+
"os/signal"
|
| 16 |
+
"path/filepath"
|
| 17 |
+
"syscall"
|
| 18 |
+
"time"
|
| 19 |
+
|
| 20 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 21 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/livecfg"
|
| 22 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/panel"
|
| 23 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
|
| 24 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/redisstore"
|
| 25 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/reqlog"
|
| 26 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/scheduler"
|
| 27 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/server"
|
| 28 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/session"
|
| 29 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 30 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/usage"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。
|
| 34 |
+
const appVersion = "1.12.0-panel"
|
| 35 |
+
|
| 36 |
+
// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。
|
| 37 |
+
// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。
|
| 38 |
+
func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") }
|
| 39 |
+
|
| 40 |
+
// stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。
|
| 41 |
+
// usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。
|
| 42 |
+
func stateSibling(stateFile, name string) string {
|
| 43 |
+
dir := filepath.Dir(stateFile)
|
| 44 |
+
if dir == "" || dir == "." {
|
| 45 |
+
return name
|
| 46 |
+
}
|
| 47 |
+
return filepath.Join(dir, name)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func main() {
|
| 51 |
+
cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)")
|
| 52 |
+
flag.Parse()
|
| 53 |
+
|
| 54 |
+
cfg, err := Load(*cfgPath)
|
| 55 |
+
if err != nil {
|
| 56 |
+
// errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。
|
| 57 |
+
if errors.Is(err, fs.ErrNotExist) {
|
| 58 |
+
// 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。
|
| 59 |
+
// 双击 exe / 裸跑 docker 即开,无需先手工复制样例。
|
| 60 |
+
if key, werr := WriteDefault(*cfgPath); werr == nil {
|
| 61 |
+
log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key)
|
| 62 |
+
cfg, err = Load(*cfgPath)
|
| 63 |
+
}
|
| 64 |
+
if err != nil {
|
| 65 |
+
// 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。
|
| 66 |
+
log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err)
|
| 67 |
+
cfg, err = Load("")
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
if err != nil {
|
| 71 |
+
log.Fatalf("load config: %v", err)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
auths, err := auth.LoadDir(cfg.AuthDir)
|
| 76 |
+
if err != nil {
|
| 77 |
+
log.Fatalf("load auths: %v", err)
|
| 78 |
+
}
|
| 79 |
+
log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir)
|
| 80 |
+
|
| 81 |
+
// redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。
|
| 82 |
+
store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token)
|
| 83 |
+
|
| 84 |
+
p := pool.New(cfg.StateFile)
|
| 85 |
+
// 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store),
|
| 86 |
+
// 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。
|
| 87 |
+
defer func() {
|
| 88 |
+
p.Close()
|
| 89 |
+
_ = store.Close()
|
| 90 |
+
}()
|
| 91 |
+
p.SetStore(store)
|
| 92 |
+
p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先
|
| 93 |
+
p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留)
|
| 94 |
+
|
| 95 |
+
// 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入,
|
| 96 |
+
// 非正值回退默认)。
|
| 97 |
+
p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD)
|
| 98 |
+
p.SetMaxInFlight(cfg.Pool.MaxInFlight)
|
| 99 |
+
p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1)
|
| 100 |
+
p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD)
|
| 101 |
+
p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h)
|
| 102 |
+
p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停)
|
| 103 |
+
p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭)
|
| 104 |
+
p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax)
|
| 105 |
+
p.SetPreferExpiring(cfg.Pool.PreferExpiring)
|
| 106 |
+
|
| 107 |
+
// 会话粘性路由(可配关闭)。
|
| 108 |
+
var sessRouter *session.Router
|
| 109 |
+
redisMode := "noop"
|
| 110 |
+
if _, ok := store.(redisstore.Noop); !ok {
|
| 111 |
+
redisMode = "upstash"
|
| 112 |
+
}
|
| 113 |
+
if cfg.SessionSticky.Enabled {
|
| 114 |
+
sessRouter = session.New(session.Config{
|
| 115 |
+
TTL: cfg.SessionTTL,
|
| 116 |
+
GCInterval: cfg.SessionGCInterval,
|
| 117 |
+
Store: store,
|
| 118 |
+
Available: p.AvailableUIDs,
|
| 119 |
+
// realm 感知闭包:带前缀模型名按 realm 过滤可用账号(跨 realm 不泄漏);
|
| 120 |
+
// 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。
|
| 121 |
+
AvailableForModel: realmAwareAvailableForModel(p),
|
| 122 |
+
})
|
| 123 |
+
sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处)
|
| 124 |
+
sessRouter.StartGC()
|
| 125 |
+
defer sessRouter.StopGC()
|
| 126 |
+
}
|
| 127 |
+
sessCount := func() int {
|
| 128 |
+
if sessRouter != nil {
|
| 129 |
+
return sessRouter.Count()
|
| 130 |
+
}
|
| 131 |
+
return 0
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
up := upstream.New()
|
| 135 |
+
|
| 136 |
+
// 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测
|
| 137 |
+
// 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿
|
| 138 |
+
// (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。
|
| 139 |
+
// 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。
|
| 140 |
+
p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) })
|
| 141 |
+
|
| 142 |
+
// 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。
|
| 143 |
+
up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second
|
| 144 |
+
// 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。
|
| 145 |
+
up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second
|
| 146 |
+
if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok {
|
| 147 |
+
tr.ResponseHeaderTimeout = up.HeaderTimeout
|
| 148 |
+
}
|
| 149 |
+
// 聊天 SSE 流中空闲上限(S3 空闲监控读取)。
|
| 150 |
+
up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second
|
| 151 |
+
up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints)
|
| 152 |
+
// 出站 UA 与归属头(issue #42 + 上游同步):
|
| 153 |
+
// UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态;
|
| 154 |
+
// ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。
|
| 155 |
+
up.UserAgent = cfg.Upstream.UserAgent
|
| 156 |
+
up.ClientVersion = cfg.Upstream.ClientVersion
|
| 157 |
+
up.CliVersion = cfg.Upstream.CliVersion
|
| 158 |
+
up.ClientName = cfg.Upstream.ClientName
|
| 159 |
+
up.DeviceToken = cfg.Upstream.DeviceToken
|
| 160 |
+
up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile
|
| 161 |
+
up.PassthroughIP = cfg.Upstream.PassthroughIP
|
| 162 |
+
// global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖;
|
| 163 |
+
// auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。
|
| 164 |
+
up.GlobalEnabled = cfg.Global.Enabled
|
| 165 |
+
up.ChatBaseGlobal = cfg.Global.ChatBase
|
| 166 |
+
up.BillingBaseGlobal = cfg.Global.BillingBase
|
| 167 |
+
auth.SetGlobalEnabled(cfg.Global.Enabled)
|
| 168 |
+
// model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级):
|
| 169 |
+
// 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动
|
| 170 |
+
// 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。
|
| 171 |
+
upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json"))
|
| 172 |
+
|
| 173 |
+
sch := scheduler.New(scheduler.Config{
|
| 174 |
+
Pool: p,
|
| 175 |
+
Upstream: up,
|
| 176 |
+
CheckinHours: cfg.Schedule.CheckinHours,
|
| 177 |
+
TravelHours: cfg.Schedule.TravelHours,
|
| 178 |
+
ActivityHours: cfg.Schedule.ActivityHours,
|
| 179 |
+
KeepaliveHours: cfg.Schedule.KeepaliveHours,
|
| 180 |
+
BlackcatHours: cfg.Schedule.BlackcatHours,
|
| 181 |
+
GrowthHours: cfg.Schedule.GrowthHours,
|
| 182 |
+
// 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。
|
| 183 |
+
ExpiringSoonWindow: cfg.ExpiringSoonDur,
|
| 184 |
+
CheckinDisabled: !cfg.Schedule.CheckinEnabled,
|
| 185 |
+
TravelDisabled: !cfg.Schedule.TravelEnabled,
|
| 186 |
+
ActivityDisabled: !cfg.Schedule.ActivityEnabled,
|
| 187 |
+
KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled,
|
| 188 |
+
BlackcatDisabled: !cfg.Schedule.BlackcatEnabled,
|
| 189 |
+
GrowthDisabled: !cfg.Schedule.GrowthEnabled,
|
| 190 |
+
// 保号类四任务是否覆盖禁用账号(缺省 false = 禁用即跳过,保持既有行为)。
|
| 191 |
+
IncludeDisabledInTasks: cfg.Schedule.IncludeDisabledInTasks,
|
| 192 |
+
})
|
| 193 |
+
switch {
|
| 194 |
+
case !cfg.Schedule.CheckinEnabled:
|
| 195 |
+
log.Printf("签到已禁用(schedule.checkin_enabled=false)")
|
| 196 |
+
default:
|
| 197 |
+
log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours)
|
| 198 |
+
}
|
| 199 |
+
switch {
|
| 200 |
+
case !cfg.Schedule.TravelEnabled:
|
| 201 |
+
log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)")
|
| 202 |
+
default:
|
| 203 |
+
log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours)
|
| 204 |
+
}
|
| 205 |
+
switch {
|
| 206 |
+
case !cfg.Schedule.ActivityEnabled:
|
| 207 |
+
log.Printf("活跃上报已禁用(schedule.activity_enabled=false)")
|
| 208 |
+
default:
|
| 209 |
+
log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours)
|
| 210 |
+
}
|
| 211 |
+
if !cfg.Schedule.KeepaliveEnabled {
|
| 212 |
+
log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)")
|
| 213 |
+
} else {
|
| 214 |
+
log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours)
|
| 215 |
+
}
|
| 216 |
+
switch {
|
| 217 |
+
case !cfg.Schedule.BlackcatEnabled:
|
| 218 |
+
log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)")
|
| 219 |
+
default:
|
| 220 |
+
log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours)
|
| 221 |
+
}
|
| 222 |
+
switch {
|
| 223 |
+
case !cfg.Schedule.BalanceRefreshEnabled:
|
| 224 |
+
log.Printf("余额后台刷��已禁用(schedule.balance_refresh_enabled=false)")
|
| 225 |
+
case cfg.BalanceRefreshInterval > 0:
|
| 226 |
+
log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval)
|
| 227 |
+
}
|
| 228 |
+
if cfg.Schedule.IncludeDisabledInTasks {
|
| 229 |
+
log.Printf("保号任务覆盖禁用账号(schedule.include_disabled_in_tasks=true):禁用号仍签到 / 活跃 / 保活 / 刷新余额,但不参与选号")
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进
|
| 233 |
+
// 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。
|
| 234 |
+
// live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。
|
| 235 |
+
live := livecfg.New(livecfg.Snapshot{
|
| 236 |
+
APIKey: cfg.APIKey,
|
| 237 |
+
SoftCooldown: cfg.SoftRateDur,
|
| 238 |
+
SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints,
|
| 239 |
+
RecordClientInfo: cfg.Logging.RequestClientInfo,
|
| 240 |
+
})
|
| 241 |
+
// 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。
|
| 242 |
+
// datapath 由 state 文件路径推出,避免再加一个配置项。
|
| 243 |
+
usagePath := usagePathFor(cfg.StateFile)
|
| 244 |
+
rec := usage.New(usagePath)
|
| 245 |
+
rec.Start()
|
| 246 |
+
defer rec.Stop()
|
| 247 |
+
log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe())
|
| 248 |
+
|
| 249 |
+
// 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。
|
| 250 |
+
requestLog := reqlog.New(reqlog.Config{
|
| 251 |
+
Dir: stateSibling(cfg.StateFile, "request-logs"),
|
| 252 |
+
Enabled: cfg.Logging.RequestArchiveEnabled,
|
| 253 |
+
RetentionDays: cfg.Logging.RequestRetentionDays,
|
| 254 |
+
MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20,
|
| 255 |
+
})
|
| 256 |
+
defer requestLog.Close()
|
| 257 |
+
rs := requestLog.Snapshot().Archive
|
| 258 |
+
if rs.Enabled {
|
| 259 |
+
log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)",
|
| 260 |
+
rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB)
|
| 261 |
+
} else {
|
| 262 |
+
log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭")
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
pn := panel.New(panel.Config{
|
| 266 |
+
Pool: p,
|
| 267 |
+
Usage: rec,
|
| 268 |
+
RequestLog: requestLog,
|
| 269 |
+
Upstream: up,
|
| 270 |
+
Scheduler: sch,
|
| 271 |
+
AuthDir: cfg.AuthDir,
|
| 272 |
+
APIKey: cfg.APIKey,
|
| 273 |
+
RedisMode: redisMode,
|
| 274 |
+
StickyCount: sessCount,
|
| 275 |
+
Version: appVersion,
|
| 276 |
+
Live: live,
|
| 277 |
+
// 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入):
|
| 278 |
+
// 与 state 文件同目录,缺省 data/output_probes.json。
|
| 279 |
+
ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"),
|
| 280 |
+
ConfigPath: *cfgPath,
|
| 281 |
+
LoadConfig: func() (any, error) {
|
| 282 |
+
return Load(*cfgPath)
|
| 283 |
+
},
|
| 284 |
+
SaveConfig: func(raw []byte) ([]string, error) {
|
| 285 |
+
return saveConfig(raw, *cfgPath, live, p, up, sch)
|
| 286 |
+
},
|
| 287 |
+
})
|
| 288 |
+
// 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后
|
| 289 |
+
// 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。
|
| 290 |
+
sch.SetGrowthHook(pn.RunGrowthQueueOnce)
|
| 291 |
+
log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs()))
|
| 292 |
+
server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs()))
|
| 293 |
+
|
| 294 |
+
h := server.NewHandler(server.Config{
|
| 295 |
+
Pool: p,
|
| 296 |
+
Upstream: up,
|
| 297 |
+
APIKey: cfg.APIKey,
|
| 298 |
+
Session: sessRouter,
|
| 299 |
+
StickyCount: sessCount,
|
| 300 |
+
RedisMode: redisMode,
|
| 301 |
+
SoftCooldown: cfg.SoftRateDur,
|
| 302 |
+
Panel: pn,
|
| 303 |
+
Live: live,
|
| 304 |
+
Usage: rec,
|
| 305 |
+
RequestLog: requestLog,
|
| 306 |
+
PromptMode: cfg.Prompt.Mode,
|
| 307 |
+
PromptText: cfg.PromptText,
|
| 308 |
+
// 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的
|
| 309 |
+
// 裸用/测试路径拿到同一缺省值。
|
| 310 |
+
RecordClientInfo: cfg.Logging.RequestClientInfo,
|
| 311 |
+
// handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。
|
| 312 |
+
GlobalEnabled: cfg.Global.Enabled,
|
| 313 |
+
})
|
| 314 |
+
|
| 315 |
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
| 316 |
+
defer stop()
|
| 317 |
+
go sch.Run(ctx)
|
| 318 |
+
sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval)
|
| 319 |
+
|
| 320 |
+
// 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时
|
| 321 |
+
// 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前,
|
| 322 |
+
// ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号
|
| 323 |
+
// 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费
|
| 324 |
+
// 模型归零;倍率表当时尚未建立)。
|
| 325 |
+
// 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。
|
| 326 |
+
go warmModelRates(ctx, up, p)
|
| 327 |
+
|
| 328 |
+
srv := &http.Server{
|
| 329 |
+
Addr: cfg.Listen,
|
| 330 |
+
Handler: h,
|
| 331 |
+
ReadHeaderTimeout: 30 * time.Second,
|
| 332 |
+
// ReadTimeout 覆盖整个请求读取(含 body 上传):防慢速 body 拖死连接。
|
| 333 |
+
// 请求体已无网关侧上限(max_body_mb ��除)。缺省 300s(issue #100:旧固定
|
| 334 |
+
// 60s 会掐掉大上下文/文件块经反代链的慢速上传,客户端收到
|
| 335 |
+
// 400 "read body: ... i/o timeout");server.read_timeout="0" 显式关闭。
|
| 336 |
+
// 改动需重启进程。
|
| 337 |
+
ReadTimeout: cfg.ServerReadTimeoutDur,
|
| 338 |
+
// IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。
|
| 339 |
+
// 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout
|
| 340 |
+
// (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。
|
| 341 |
+
IdleTimeout: 120 * time.Second,
|
| 342 |
+
}
|
| 343 |
+
go func() {
|
| 344 |
+
<-ctx.Done()
|
| 345 |
+
p.Flush() // 信号触发:先落盘再做优雅停机
|
| 346 |
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
| 347 |
+
defer cancel()
|
| 348 |
+
_ = srv.Shutdown(shutdownCtx)
|
| 349 |
+
}()
|
| 350 |
+
|
| 351 |
+
log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen))
|
| 352 |
+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
| 353 |
+
log.Fatalf("http: %v", err)
|
| 354 |
+
}
|
| 355 |
+
log.Printf("bye")
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。
|
| 359 |
+
//
|
| 360 |
+
// 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时
|
| 361 |
+
// 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发
|
| 362 |
+
// 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被
|
| 363 |
+
// 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。
|
| 364 |
+
//
|
| 365 |
+
// 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域
|
| 366 |
+
// 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。
|
| 367 |
+
func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) {
|
| 368 |
+
// 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。
|
| 369 |
+
if ctx.Err() != nil {
|
| 370 |
+
return
|
| 371 |
+
}
|
| 372 |
+
// CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。
|
| 373 |
+
if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 {
|
| 374 |
+
if a := p.AuthByUID(uids[0]); a != nil {
|
| 375 |
+
if _, err := up.FetchModels(a); err != nil {
|
| 376 |
+
log.Printf("WARN: [upstream] warm model rates (cn): %v", err)
|
| 377 |
+
} else {
|
| 378 |
+
log.Printf("[upstream] warm model rates: cn ok")
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
// global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。
|
| 383 |
+
if up.GlobalEnabled && ctx.Err() == nil {
|
| 384 |
+
if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 {
|
| 385 |
+
if a := p.AuthByUID(uids[0]); a != nil {
|
| 386 |
+
// FetchGlobalModelInfos 无错误返回(内部负缓存自行节流),
|
| 387 |
+
// 仅按结果条数判断是否拿到目录。
|
| 388 |
+
if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 {
|
| 389 |
+
log.Printf("WARN: [upstream] warm model rates (global): empty model list")
|
| 390 |
+
} else {
|
| 391 |
+
log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos))
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
// panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL
|
| 399 |
+
// (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。
|
| 400 |
+
func panelListenPath(listen string) string {
|
| 401 |
+
for i := len(listen) - 1; i >= 0; i-- {
|
| 402 |
+
if listen[i] == ':' {
|
| 403 |
+
return listen[i:]
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
return listen
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。
|
| 410 |
+
//
|
| 411 |
+
// 热生效范围(设计取舍):
|
| 412 |
+
// - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照
|
| 413 |
+
// - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor
|
| 414 |
+
// - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow
|
| 415 |
+
//
|
| 416 |
+
// 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖):
|
| 417 |
+
// - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类)
|
| 418 |
+
//
|
| 419 |
+
// 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改
|
| 420 |
+
// 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体
|
| 421 |
+
// 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。
|
| 422 |
+
func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) {
|
| 423 |
+
// 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。
|
| 424 |
+
oldRaw, err := os.ReadFile(path)
|
| 425 |
+
if err != nil {
|
| 426 |
+
return nil, fmt.Errorf("read current config: %w", err)
|
| 427 |
+
}
|
| 428 |
+
var cur, incoming map[string]any
|
| 429 |
+
if err := json.Unmarshal(oldRaw, &cur); err != nil {
|
| 430 |
+
cur = map[string]any{}
|
| 431 |
+
}
|
| 432 |
+
if err := json.Unmarshal(raw, &incoming); err != nil {
|
| 433 |
+
return nil, fmt.Errorf("parse submitted config: %w", err)
|
| 434 |
+
}
|
| 435 |
+
merged := mergeConfigMaps(cur, incoming)
|
| 436 |
+
|
| 437 |
+
// 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。
|
| 438 |
+
newCfg, err := ParseConfig(mergedJSON(merged))
|
| 439 |
+
if err != nil {
|
| 440 |
+
return nil, err
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// 3) 落盘(原子替换)。
|
| 444 |
+
out, err := json.MarshalIndent(merged, "", " ")
|
| 445 |
+
if err != nil {
|
| 446 |
+
return nil, fmt.Errorf("marshal config: %w", err)
|
| 447 |
+
}
|
| 448 |
+
tmp := path + ".tmp"
|
| 449 |
+
if err := os.WriteFile(tmp, out, 0o600); err != nil {
|
| 450 |
+
return nil, fmt.Errorf("write config: %w", err)
|
| 451 |
+
}
|
| 452 |
+
if err := os.Rename(tmp, path); err != nil {
|
| 453 |
+
// A single-file Docker bind mount cannot be renamed over its mount
|
| 454 |
+
// target (Linux returns EBUSY / "device or resource busy"). Keep the
|
| 455 |
+
// atomic path for regular files, but update the mounted file in place
|
| 456 |
+
// for this specific deployment shape.
|
| 457 |
+
if !errors.Is(err, syscall.EBUSY) {
|
| 458 |
+
return nil, fmt.Errorf("replace config: %w", err)
|
| 459 |
+
}
|
| 460 |
+
f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
|
| 461 |
+
if openErr != nil {
|
| 462 |
+
_ = os.Remove(tmp)
|
| 463 |
+
return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr)
|
| 464 |
+
}
|
| 465 |
+
_, writeErr := f.Write(out)
|
| 466 |
+
if writeErr == nil {
|
| 467 |
+
writeErr = f.Sync()
|
| 468 |
+
}
|
| 469 |
+
closeErr := f.Close()
|
| 470 |
+
// 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容,
|
| 471 |
+
// 可手工恢复);写成功才清理。
|
| 472 |
+
if writeErr != nil {
|
| 473 |
+
return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr)
|
| 474 |
+
}
|
| 475 |
+
_ = os.Remove(tmp)
|
| 476 |
+
if closeErr != nil {
|
| 477 |
+
return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr)
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。
|
| 482 |
+
live.Store(livecfg.Snapshot{
|
| 483 |
+
APIKey: newCfg.APIKey,
|
| 484 |
+
SoftCooldown: newCfg.SoftRateDur,
|
| 485 |
+
SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints,
|
| 486 |
+
RecordClientInfo: newCfg.Logging.RequestClientInfo,
|
| 487 |
+
})
|
| 488 |
+
up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints)
|
| 489 |
+
p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD)
|
| 490 |
+
p.SetMaxInFlight(newCfg.Pool.MaxInFlight)
|
| 491 |
+
p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal)
|
| 492 |
+
p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD)
|
| 493 |
+
p.SetSoftRateMax(newCfg.SoftRateMaxDur)
|
| 494 |
+
p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停)
|
| 495 |
+
p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭)
|
| 496 |
+
p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax)
|
| 497 |
+
p.SetPreferExpiring(newCfg.Pool.PreferExpiring)
|
| 498 |
+
sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur)
|
| 499 |
+
sch.Reconfigure(
|
| 500 |
+
newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours,
|
| 501 |
+
newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours,
|
| 502 |
+
newCfg.Schedule.GrowthHours,
|
| 503 |
+
!newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled,
|
| 504 |
+
!newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled,
|
| 505 |
+
!newCfg.Schedule.GrowthEnabled)
|
| 506 |
+
sch.SetBalanceInterval(newCfg.BalanceRefreshInterval)
|
| 507 |
+
sch.SetIncludeDisabledInTasks(newCfg.Schedule.IncludeDisabledInTasks)
|
| 508 |
+
|
| 509 |
+
return restartRequiredFields(newCfg), nil
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。
|
| 513 |
+
// 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。
|
| 514 |
+
func restartRequiredFields(c *Config) []string {
|
| 515 |
+
var out []string
|
| 516 |
+
// 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。
|
| 517 |
+
if c.Listen != "" {
|
| 518 |
+
out = append(out, "listen")
|
| 519 |
+
}
|
| 520 |
+
if c.AuthDir != "" {
|
| 521 |
+
out = append(out, "auth_dir")
|
| 522 |
+
}
|
| 523 |
+
if c.StateFile != "" {
|
| 524 |
+
out = append(out, "state_file")
|
| 525 |
+
}
|
| 526 |
+
out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds")
|
| 527 |
+
// upstream.user_agent 在装配期被写进出站 client(main.go 的 up.UserAgent = ...),
|
| 528 |
+
// 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完
|
| 529 |
+
// 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。
|
| 530 |
+
out = append(out, "upstream.user_agent")
|
| 531 |
+
if c.Upstash.URL != "" || c.Upstash.Token != "" {
|
| 532 |
+
out = append(out, "upstash")
|
| 533 |
+
}
|
| 534 |
+
out = append(out, "session_sticky.ttl", "session_sticky.gc_interval")
|
| 535 |
+
out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb")
|
| 536 |
+
out = append(out, "server.read_timeout")
|
| 537 |
+
return out
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
// mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。
|
| 541 |
+
// 对嵌套对象逐键覆盖而不是整体替���:面板表单只提交它管理的键,
|
| 542 |
+
// 未提交的兄弟键(含用户手写的未知键)保持原样。
|
| 543 |
+
func mergeConfigMaps(cur, incoming map[string]any) map[string]any {
|
| 544 |
+
for k, v := range incoming {
|
| 545 |
+
if inMap, ok := v.(map[string]any); ok {
|
| 546 |
+
if curMap, ok := cur[k].(map[string]any); ok {
|
| 547 |
+
cur[k] = mergeConfigMaps(curMap, inMap)
|
| 548 |
+
continue
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
cur[k] = v
|
| 552 |
+
}
|
| 553 |
+
return cur
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。
|
| 557 |
+
func mergedJSON(m map[string]any) []byte {
|
| 558 |
+
b, err := json.Marshal(m)
|
| 559 |
+
if err != nil {
|
| 560 |
+
return []byte("{}")
|
| 561 |
+
}
|
| 562 |
+
return b
|
| 563 |
+
}
|
cmd/server/wiring.go
CHANGED
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
package main
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
|
| 5 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/server"
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
// realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。
|
| 9 |
-
//
|
| 10 |
-
// 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出
|
| 11 |
-
// realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给
|
| 12 |
-
// CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。
|
| 13 |
-
//
|
| 14 |
-
// realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状
|
| 15 |
-
// (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。
|
| 16 |
-
//
|
| 17 |
-
// 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知
|
| 18 |
-
// 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。
|
| 19 |
-
func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string {
|
| 20 |
-
return func(model string) []string {
|
| 21 |
-
realm, bare := server.ResolveModel(model)
|
| 22 |
-
return p.WeightedAvailableUIDsForModelRealm(bare, realm)
|
| 23 |
-
}
|
| 24 |
-
}
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
|
| 5 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/server"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。
|
| 9 |
+
//
|
| 10 |
+
// 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出
|
| 11 |
+
// realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给
|
| 12 |
+
// CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。
|
| 13 |
+
//
|
| 14 |
+
// realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状
|
| 15 |
+
// (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。
|
| 16 |
+
//
|
| 17 |
+
// 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知
|
| 18 |
+
// 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。
|
| 19 |
+
func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string {
|
| 20 |
+
return func(model string) []string {
|
| 21 |
+
realm, bare := server.ResolveModel(model)
|
| 22 |
+
return p.WeightedAvailableUIDsForModelRealm(bare, realm)
|
| 23 |
+
}
|
| 24 |
+
}
|
cmd/signin/main.go
CHANGED
|
@@ -1,140 +1,140 @@
|
|
| 1 |
-
// signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号,
|
| 2 |
-
// 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。
|
| 3 |
-
package main
|
| 4 |
-
|
| 5 |
-
import (
|
| 6 |
-
"fmt"
|
| 7 |
-
"log"
|
| 8 |
-
"os"
|
| 9 |
-
"path/filepath"
|
| 10 |
-
"sort"
|
| 11 |
-
"strings"
|
| 12 |
-
|
| 13 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 14 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
type row struct {
|
| 18 |
-
file string
|
| 19 |
-
uid string
|
| 20 |
-
nick string
|
| 21 |
-
status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR
|
| 22 |
-
detail string
|
| 23 |
-
remain int64
|
| 24 |
-
hasQuota bool
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
func main() {
|
| 28 |
-
dir := "auths"
|
| 29 |
-
if len(os.Args) > 1 {
|
| 30 |
-
dir = os.Args[1]
|
| 31 |
-
}
|
| 32 |
-
files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json"))
|
| 33 |
-
if err != nil || len(files) == 0 {
|
| 34 |
-
fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir)
|
| 35 |
-
os.Exit(1)
|
| 36 |
-
}
|
| 37 |
-
sort.Strings(files)
|
| 38 |
-
up := upstream.New()
|
| 39 |
-
|
| 40 |
-
var rows []row
|
| 41 |
-
okN, alreadyN, failN := 0, 0, 0
|
| 42 |
-
for _, f := range files {
|
| 43 |
-
r := row{file: filepath.Base(f)}
|
| 44 |
-
raw, err := os.ReadFile(f)
|
| 45 |
-
if err != nil {
|
| 46 |
-
r.status, r.detail = "LOAD_ERR", err.Error()
|
| 47 |
-
rows = append(rows, r)
|
| 48 |
-
failN++
|
| 49 |
-
continue
|
| 50 |
-
}
|
| 51 |
-
a, err := auth.Parse(raw)
|
| 52 |
-
if err != nil {
|
| 53 |
-
r.status, r.detail = "LOAD_ERR", err.Error()
|
| 54 |
-
rows = append(rows, r)
|
| 55 |
-
failN++
|
| 56 |
-
continue
|
| 57 |
-
}
|
| 58 |
-
a.FilePath = f
|
| 59 |
-
r.uid, r.nick = a.UID, a.Nickname
|
| 60 |
-
|
| 61 |
-
// refresh 过期 token
|
| 62 |
-
if a.NeedsRefresh(2 * 3600) {
|
| 63 |
-
if err := up.RefreshToken(a); err != nil {
|
| 64 |
-
if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead {
|
| 65 |
-
r.status = "AUTH_INVALID"
|
| 66 |
-
} else {
|
| 67 |
-
r.status = "FAIL"
|
| 68 |
-
}
|
| 69 |
-
r.detail = "refresh: " + short(err.Error())
|
| 70 |
-
rows = append(rows, r)
|
| 71 |
-
failN++
|
| 72 |
-
continue
|
| 73 |
-
}
|
| 74 |
-
// refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token
|
| 75 |
-
if err := a.SaveAtomic(); err != nil {
|
| 76 |
-
log.Printf("signin %s save: %v", a.UID, err)
|
| 77 |
-
}
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
err = up.DailyCheckin(a)
|
| 81 |
-
switch {
|
| 82 |
-
case err == nil:
|
| 83 |
-
r.status = "OK"
|
| 84 |
-
okN++
|
| 85 |
-
default:
|
| 86 |
-
// DailyCheckin 已签到返回 code!=0 错误
|
| 87 |
-
if isAlready(err.Error()) {
|
| 88 |
-
r.status = "ALREADY"
|
| 89 |
-
r.detail = short(err.Error())
|
| 90 |
-
alreadyN++
|
| 91 |
-
} else {
|
| 92 |
-
r.status = "FAIL"
|
| 93 |
-
r.detail = short(err.Error())
|
| 94 |
-
failN++
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
// 顺手查余额
|
| 98 |
-
if remain, _, qerr := up.UserResource(a); qerr == nil {
|
| 99 |
-
r.remain, r.hasQuota = remain, true
|
| 100 |
-
}
|
| 101 |
-
rows = append(rows, r)
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
// 报告
|
| 105 |
-
fmt.Printf("uid | nick | status | remain | detail\n")
|
| 106 |
-
fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n")
|
| 107 |
-
for _, r := range rows {
|
| 108 |
-
remain := "-"
|
| 109 |
-
if r.hasQuota {
|
| 110 |
-
remain = fmt.Sprintf("%d", r.remain)
|
| 111 |
-
}
|
| 112 |
-
fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n",
|
| 113 |
-
trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail)
|
| 114 |
-
}
|
| 115 |
-
fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN)
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
// 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样
|
| 119 |
-
func isAlready(msg string) bool {
|
| 120 |
-
s := strings.ToLower(msg)
|
| 121 |
-
return strings.Contains(s, "已签到") ||
|
| 122 |
-
strings.Contains(s, "already") ||
|
| 123 |
-
strings.Contains(s, "checkin") ||
|
| 124 |
-
strings.Contains(s, "code=400")
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
func trunc(s string, n int) string {
|
| 128 |
-
if len(s) > n {
|
| 129 |
-
return s[:n]
|
| 130 |
-
}
|
| 131 |
-
return s
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
func short(s string) string {
|
| 135 |
-
s = strings.ReplaceAll(s, "\n", " ")
|
| 136 |
-
if len(s) > 60 {
|
| 137 |
-
return s[:60]
|
| 138 |
-
}
|
| 139 |
-
return s
|
| 140 |
-
}
|
|
|
|
| 1 |
+
// signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号,
|
| 2 |
+
// 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。
|
| 3 |
+
package main
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"fmt"
|
| 7 |
+
"log"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"sort"
|
| 11 |
+
"strings"
|
| 12 |
+
|
| 13 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 14 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
type row struct {
|
| 18 |
+
file string
|
| 19 |
+
uid string
|
| 20 |
+
nick string
|
| 21 |
+
status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR
|
| 22 |
+
detail string
|
| 23 |
+
remain int64
|
| 24 |
+
hasQuota bool
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func main() {
|
| 28 |
+
dir := "auths"
|
| 29 |
+
if len(os.Args) > 1 {
|
| 30 |
+
dir = os.Args[1]
|
| 31 |
+
}
|
| 32 |
+
files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json"))
|
| 33 |
+
if err != nil || len(files) == 0 {
|
| 34 |
+
fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir)
|
| 35 |
+
os.Exit(1)
|
| 36 |
+
}
|
| 37 |
+
sort.Strings(files)
|
| 38 |
+
up := upstream.New()
|
| 39 |
+
|
| 40 |
+
var rows []row
|
| 41 |
+
okN, alreadyN, failN := 0, 0, 0
|
| 42 |
+
for _, f := range files {
|
| 43 |
+
r := row{file: filepath.Base(f)}
|
| 44 |
+
raw, err := os.ReadFile(f)
|
| 45 |
+
if err != nil {
|
| 46 |
+
r.status, r.detail = "LOAD_ERR", err.Error()
|
| 47 |
+
rows = append(rows, r)
|
| 48 |
+
failN++
|
| 49 |
+
continue
|
| 50 |
+
}
|
| 51 |
+
a, err := auth.Parse(raw)
|
| 52 |
+
if err != nil {
|
| 53 |
+
r.status, r.detail = "LOAD_ERR", err.Error()
|
| 54 |
+
rows = append(rows, r)
|
| 55 |
+
failN++
|
| 56 |
+
continue
|
| 57 |
+
}
|
| 58 |
+
a.FilePath = f
|
| 59 |
+
r.uid, r.nick = a.UID, a.Nickname
|
| 60 |
+
|
| 61 |
+
// refresh 过期 token
|
| 62 |
+
if a.NeedsRefresh(2 * 3600) {
|
| 63 |
+
if err := up.RefreshToken(a); err != nil {
|
| 64 |
+
if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead {
|
| 65 |
+
r.status = "AUTH_INVALID"
|
| 66 |
+
} else {
|
| 67 |
+
r.status = "FAIL"
|
| 68 |
+
}
|
| 69 |
+
r.detail = "refresh: " + short(err.Error())
|
| 70 |
+
rows = append(rows, r)
|
| 71 |
+
failN++
|
| 72 |
+
continue
|
| 73 |
+
}
|
| 74 |
+
// refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token
|
| 75 |
+
if err := a.SaveAtomic(); err != nil {
|
| 76 |
+
log.Printf("signin %s save: %v", a.UID, err)
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
err = up.DailyCheckin(a)
|
| 81 |
+
switch {
|
| 82 |
+
case err == nil:
|
| 83 |
+
r.status = "OK"
|
| 84 |
+
okN++
|
| 85 |
+
default:
|
| 86 |
+
// DailyCheckin 已签到返回 code!=0 错误
|
| 87 |
+
if isAlready(err.Error()) {
|
| 88 |
+
r.status = "ALREADY"
|
| 89 |
+
r.detail = short(err.Error())
|
| 90 |
+
alreadyN++
|
| 91 |
+
} else {
|
| 92 |
+
r.status = "FAIL"
|
| 93 |
+
r.detail = short(err.Error())
|
| 94 |
+
failN++
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
// 顺手查余额
|
| 98 |
+
if remain, _, qerr := up.UserResource(a); qerr == nil {
|
| 99 |
+
r.remain, r.hasQuota = remain, true
|
| 100 |
+
}
|
| 101 |
+
rows = append(rows, r)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// 报告
|
| 105 |
+
fmt.Printf("uid | nick | status | remain | detail\n")
|
| 106 |
+
fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n")
|
| 107 |
+
for _, r := range rows {
|
| 108 |
+
remain := "-"
|
| 109 |
+
if r.hasQuota {
|
| 110 |
+
remain = fmt.Sprintf("%d", r.remain)
|
| 111 |
+
}
|
| 112 |
+
fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n",
|
| 113 |
+
trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail)
|
| 114 |
+
}
|
| 115 |
+
fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样
|
| 119 |
+
func isAlready(msg string) bool {
|
| 120 |
+
s := strings.ToLower(msg)
|
| 121 |
+
return strings.Contains(s, "已签到") ||
|
| 122 |
+
strings.Contains(s, "already") ||
|
| 123 |
+
strings.Contains(s, "checkin") ||
|
| 124 |
+
strings.Contains(s, "code=400")
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
func trunc(s string, n int) string {
|
| 128 |
+
if len(s) > n {
|
| 129 |
+
return s[:n]
|
| 130 |
+
}
|
| 131 |
+
return s
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
func short(s string) string {
|
| 135 |
+
s = strings.ReplaceAll(s, "\n", " ")
|
| 136 |
+
if len(s) > 60 {
|
| 137 |
+
return s[:60]
|
| 138 |
+
}
|
| 139 |
+
return s
|
| 140 |
+
}
|
cmd/sync/main.go
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// sync 是本地 ↔ Hugging Face 中转之间的凭证/状态同步工具。
|
| 2 |
+
//
|
| 3 |
+
// 用法(典型):
|
| 4 |
+
//
|
| 5 |
+
// wb2api-sync status # 只看差异,不写任何东西(有差异时退出码 2)
|
| 6 |
+
// wb2api-sync pull # 远端 → 本地(本地启动器在拉起网关前先跑这个)
|
| 7 |
+
// wb2api-sync push # 本地 → 远端
|
| 8 |
+
// wb2api-sync sync # 双向对账(缺的补上,冲突按判据取新的一方)
|
| 9 |
+
// wb2api-sync watch -interval 5m # 常驻,周期性 sync
|
| 10 |
+
//
|
| 11 |
+
// 配置来源:默认读 ./config.json 的 auth_dir / state_file 与 sync 段;
|
| 12 |
+
// 命令行标志可逐项覆盖(未给 config.json 时也能用标志 + 环境变量跑起来)。
|
| 13 |
+
//
|
| 14 |
+
// 为什么读取走桶、写入可走网关:见 internal/syncer 包注释(Xet 上传协议)。
|
| 15 |
+
package main
|
| 16 |
+
|
| 17 |
+
import (
|
| 18 |
+
"context"
|
| 19 |
+
"encoding/json"
|
| 20 |
+
"flag"
|
| 21 |
+
"fmt"
|
| 22 |
+
"os"
|
| 23 |
+
"os/signal"
|
| 24 |
+
"strings"
|
| 25 |
+
"syscall"
|
| 26 |
+
"time"
|
| 27 |
+
|
| 28 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/syncer"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
// 退出码:0 一切就绪/已对齐;1 出错;2 有差异但未应用(status 模式,供脚本判分支)。
|
| 32 |
+
const (
|
| 33 |
+
exitOK = 0
|
| 34 |
+
exitError = 1
|
| 35 |
+
exitPending = 2
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
// fileConfig 已移到 internal/syncer.LoadConfigFile:启动器(cmd/launcher)与
|
| 39 |
+
// 本工具必须用完全同一套"从 config.json 取同步配置"的规则,否则两边对
|
| 40 |
+
// auth_dir 的理解一旦漂移,就会出现"同步写 A 目录、网关读 B 目录"的诡异故障。
|
| 41 |
+
|
| 42 |
+
func main() {
|
| 43 |
+
if err := run(os.Args[1:]); err != nil {
|
| 44 |
+
fmt.Fprintf(os.Stderr, "[wb2api-sync] 失败: %v\n", err)
|
| 45 |
+
os.Exit(exitError)
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// options 是解析后的运行参数。
|
| 50 |
+
type options struct {
|
| 51 |
+
mode syncer.Mode
|
| 52 |
+
config syncer.Config
|
| 53 |
+
dryRun bool
|
| 54 |
+
reportOnly bool // status/diff/plan:按双向算一遍但不落地
|
| 55 |
+
jsonOut bool
|
| 56 |
+
quiet bool
|
| 57 |
+
watch bool
|
| 58 |
+
interval time.Duration
|
| 59 |
+
loaded string // 实际读到的 config.json 路径(空 = 没读)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
func run(args []string) error {
|
| 63 |
+
// 子命令可以是第一个位置参数;也允许省略(默认 sync)。
|
| 64 |
+
cmd := "sync"
|
| 65 |
+
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
| 66 |
+
cmd = args[0]
|
| 67 |
+
args = args[1:]
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
fs := flag.NewFlagSet("wb2api-sync", flag.ContinueOnError)
|
| 71 |
+
fs.SetOutput(os.Stderr)
|
| 72 |
+
|
| 73 |
+
var (
|
| 74 |
+
configPath = fs.String("config", "config.json", "配置文件路径(读 auth_dir/state_file 与 sync 段)")
|
| 75 |
+
noConfig = fs.Bool("no-config", false, "不读配置文件,只用标志/环境变量")
|
| 76 |
+
|
| 77 |
+
bucket = fs.String("bucket", "", "HF 桶 ID,如 a3216/wb2api-storage")
|
| 78 |
+
endpoint = fs.String("endpoint", "", "HF 端点(默认 https://huggingface.co)")
|
| 79 |
+
token = fs.String("token", "", "HF token(建议改用 -token-file 或 HF_TOKEN 环境变量)")
|
| 80 |
+
tokenFile = fs.String("token-file", "", "存放 HF token 的文件")
|
| 81 |
+
|
| 82 |
+
authDir = fs.String("auth-dir", "", "本地凭证目录(默认取 config.json 的 auth_dir)")
|
| 83 |
+
dataDir = fs.String("data-dir", "", "本地数据目录(默认取 state_file 所在目录)")
|
| 84 |
+
include = fs.String("include-data", "", "额外同步的 data/ 文件名,逗号分隔(默认只同步凭证)")
|
| 85 |
+
|
| 86 |
+
writer = fs.String("writer", "", "写入通道:auto(默认)/ hf / relay")
|
| 87 |
+
hfBin = fs.String("hf-bin", "", "hf 可执行文件,默认 hf")
|
| 88 |
+
relayURL = fs.String("relay-url", "", "中转网关地址(relay 通道用)")
|
| 89 |
+
relayKey = fs.String("relay-key", "", "中转网关 api_key(留空时自动从桶的 config.json 读)")
|
| 90 |
+
restart = fs.String("restart-space", "", "推了新凭证后重启该 Space(如 a3216/gcli2api),让它加载新账号")
|
| 91 |
+
caBundle = fs.String("ca-bundle", "", "额外 CA 证书包(PEM);本机跑抓包代理时给 hf CLI 用")
|
| 92 |
+
|
| 93 |
+
timeout = fs.String("timeout", "", "单次网络超时,默认 60s")
|
| 94 |
+
insecure = fs.Bool("insecure-skip-verify", false, "跳过 TLS 校验(仅供本地抓包代理排障)")
|
| 95 |
+
|
| 96 |
+
dryRun = fs.Bool("dry-run", false, "只展示将要做什么,不写任何东西")
|
| 97 |
+
jsonOut = fs.Bool("json", false, "以 JSON 输出结果")
|
| 98 |
+
quiet = fs.Bool("quiet", false, "只输出汇总行")
|
| 99 |
+
watch = fs.Bool("watch", false, "常驻循环同步")
|
| 100 |
+
interval = fs.Duration("interval", 5*time.Minute, "watch 模式的同步间隔")
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if cmd == "help" || cmd == "-h" || cmd == "--help" {
|
| 104 |
+
printHelp(fs)
|
| 105 |
+
return nil
|
| 106 |
+
}
|
| 107 |
+
if err := fs.Parse(args); err != nil {
|
| 108 |
+
return err
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
mode, reportOnly, err := syncer.ParseCommand(cmd)
|
| 112 |
+
if err != nil {
|
| 113 |
+
return err
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
opt := options{
|
| 117 |
+
mode: mode, dryRun: *dryRun || reportOnly, jsonOut: *jsonOut, quiet: *quiet,
|
| 118 |
+
watch: *watch || cmd == "watch", interval: *interval, reportOnly: reportOnly,
|
| 119 |
+
}
|
| 120 |
+
if opt.watch {
|
| 121 |
+
// watch 必须是双向的:单方向常驻没有意义。
|
| 122 |
+
opt.mode = syncer.ModeSync
|
| 123 |
+
opt.reportOnly = false
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 1) 读配置文件打底
|
| 127 |
+
if !*noConfig && *configPath != "" {
|
| 128 |
+
fc, err := syncer.LoadConfigFile(*configPath)
|
| 129 |
+
switch {
|
| 130 |
+
case err == nil:
|
| 131 |
+
opt.loaded = *configPath
|
| 132 |
+
opt.config = fc
|
| 133 |
+
case os.IsNotExist(err):
|
| 134 |
+
// 没有 config.json 不是错误:可以用标志 + 环境变量把同步跑起来。
|
| 135 |
+
default:
|
| 136 |
+
return fmt.Errorf("读取 %s 失败: %w", *configPath, err)
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 2) 标志覆盖
|
| 141 |
+
if *bucket != "" {
|
| 142 |
+
opt.config.Bucket = *bucket
|
| 143 |
+
}
|
| 144 |
+
if *endpoint != "" {
|
| 145 |
+
opt.config.Endpoint = *endpoint
|
| 146 |
+
}
|
| 147 |
+
if *token != "" {
|
| 148 |
+
opt.config.Token = *token
|
| 149 |
+
}
|
| 150 |
+
if *tokenFile != "" {
|
| 151 |
+
opt.config.TokenFile = *tokenFile
|
| 152 |
+
}
|
| 153 |
+
if *authDir != "" {
|
| 154 |
+
opt.config.AuthDir = *authDir
|
| 155 |
+
}
|
| 156 |
+
if *dataDir != "" {
|
| 157 |
+
opt.config.DataDir = *dataDir
|
| 158 |
+
}
|
| 159 |
+
if *include != "" {
|
| 160 |
+
opt.config.IncludeData = splitCSV(*include)
|
| 161 |
+
}
|
| 162 |
+
if *writer != "" {
|
| 163 |
+
opt.config.Writer = *writer
|
| 164 |
+
}
|
| 165 |
+
if *hfBin != "" {
|
| 166 |
+
opt.config.HFBin = *hfBin
|
| 167 |
+
}
|
| 168 |
+
if *relayURL != "" {
|
| 169 |
+
opt.config.RelayURL = *relayURL
|
| 170 |
+
}
|
| 171 |
+
if *relayKey != "" {
|
| 172 |
+
opt.config.RelayKey = *relayKey
|
| 173 |
+
}
|
| 174 |
+
if *restart != "" {
|
| 175 |
+
opt.config.RestartSpace = *restart
|
| 176 |
+
}
|
| 177 |
+
if *caBundle != "" {
|
| 178 |
+
opt.config.CABundle = *caBundle
|
| 179 |
+
}
|
| 180 |
+
if *timeout != "" {
|
| 181 |
+
opt.config.Timeout = *timeout
|
| 182 |
+
}
|
| 183 |
+
if *insecure {
|
| 184 |
+
opt.config.InsecureSkipVerify = true
|
| 185 |
+
}
|
| 186 |
+
// 环境变量兜底(方便 CI / 临时用,不必把 token 落盘)
|
| 187 |
+
if opt.config.Token == "" && opt.config.TokenFile == "" {
|
| 188 |
+
if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" {
|
| 189 |
+
opt.config.Token = t
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
opt.config.Normalize()
|
| 193 |
+
|
| 194 |
+
if opt.config.AuthDir == "" {
|
| 195 |
+
opt.config.AuthDir = "auths"
|
| 196 |
+
}
|
| 197 |
+
if opt.config.DataDir == "" {
|
| 198 |
+
opt.config.DataDir = "data"
|
| 199 |
+
}
|
| 200 |
+
if opt.config.Bucket == "" {
|
| 201 |
+
return fmt.Errorf("未配置桶:请在 config.json 的 sync.bucket 填桶 ID(形如 a3216/wb2api-storage),或用 -bucket")
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
| 205 |
+
defer cancel()
|
| 206 |
+
|
| 207 |
+
eng, err := syncer.New(opt.config)
|
| 208 |
+
if err != nil {
|
| 209 |
+
return err
|
| 210 |
+
}
|
| 211 |
+
opt.config = eng.Config() // 拿回归一化后的值(含 token_file 解出来的 token)
|
| 212 |
+
|
| 213 |
+
// relay 通道缺 api_key 时,从桶里的 config.json 自动取——省掉手工抄一遍密钥。
|
| 214 |
+
if needsRelayKey(eng) {
|
| 215 |
+
if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil {
|
| 216 |
+
opt.config.RelayKey = key
|
| 217 |
+
if eng, err = syncer.New(opt.config); err != nil {
|
| 218 |
+
return err
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if opt.watch {
|
| 224 |
+
return runWatch(ctx, eng, opt)
|
| 225 |
+
}
|
| 226 |
+
return runOnce(ctx, eng, opt)
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// needsRelayKey 报告当前是否"选了 relay 通道但还没 key"。
|
| 230 |
+
func needsRelayKey(eng *syncer.Engine) bool {
|
| 231 |
+
return eng.WriterName() == "relay" && eng.Config().RelayKey == ""
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// runOnce 跑一轮并输出结果。
|
| 235 |
+
func runOnce(ctx context.Context, eng *syncer.Engine, opt options) error {
|
| 236 |
+
plan, res, err := eng.Sync(ctx, opt.mode, opt.dryRun)
|
| 237 |
+
if err != nil {
|
| 238 |
+
return err
|
| 239 |
+
}
|
| 240 |
+
if opt.jsonOut {
|
| 241 |
+
printJSON(plan, res, opt)
|
| 242 |
+
} else {
|
| 243 |
+
printHuman(plan, res, opt, eng)
|
| 244 |
+
}
|
| 245 |
+
if res.Failed > 0 {
|
| 246 |
+
return fmt.Errorf("%d 个文件同步失败", res.Failed)
|
| 247 |
+
}
|
| 248 |
+
// 推了新账号 → 让 Space 重启一次,否则它只在启动时扫凭证目录,加载不到新号。
|
| 249 |
+
if !opt.dryRun && opt.config.RestartSpace != "" {
|
| 250 |
+
if n := uploadedAuths(res); n > 0 {
|
| 251 |
+
if err := syncer.RestartSpace(ctx, opt.config.Endpoint, opt.config.RestartSpace,
|
| 252 |
+
opt.config.Token, 90*time.Second, opt.config.InsecureSkipVerify); err != nil {
|
| 253 |
+
return fmt.Errorf("凭证已上传,但重启 Space 失败(新账号要等它下次重启才生效): %w", err)
|
| 254 |
+
}
|
| 255 |
+
if !opt.jsonOut {
|
| 256 |
+
fmt.Printf("\n已请求重启 Space %s(推送了 %d 个凭证)——冷启动约 30~60s 后新账号生效。\n",
|
| 257 |
+
opt.config.RestartSpace, n)
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
// status 模式:有差异时用退出码 2 告诉脚本"尚未对齐"(与 git diff --exit-code 同风格)。
|
| 262 |
+
if opt.reportOnly && plan.Changes() > 0 {
|
| 263 |
+
os.Exit(exitPending)
|
| 264 |
+
}
|
| 265 |
+
return nil
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// uploadedAuths 统计本轮真正写成功的 auths/ 文件数——只有这些才需要触发 Space 重启。
|
| 269 |
+
func uploadedAuths(res *syncer.Result) int {
|
| 270 |
+
n := 0
|
| 271 |
+
for _, a := range res.Actions {
|
| 272 |
+
if a.Op == syncer.OpUpload && a.Applied && a.Err == "" && strings.HasPrefix(a.Path, "auths/") {
|
| 273 |
+
n++
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
return n
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// runWatch 常驻循环。只有发生变化时才打印,避免刷屏。
|
| 280 |
+
func runWatch(ctx context.Context, eng *syncer.Engine, opt options) error {
|
| 281 |
+
if !opt.jsonOut {
|
| 282 |
+
fmt.Printf("[wb2api-sync] watch 启动:每 %s 同步一次(Ctrl-C 退出)\n", opt.interval)
|
| 283 |
+
}
|
| 284 |
+
t := time.NewTicker(opt.interval)
|
| 285 |
+
defer t.Stop()
|
| 286 |
+
for {
|
| 287 |
+
plan, res, err := eng.Sync(ctx, syncer.ModeSync, false)
|
| 288 |
+
switch {
|
| 289 |
+
case err != nil:
|
| 290 |
+
fmt.Fprintf(os.Stderr, "[wb2api-sync] %s 同步出错: %v\n", time.Now().Format("15:04:05"), err)
|
| 291 |
+
case plan.Changes() > 0 || res.Failed > 0:
|
| 292 |
+
if opt.jsonOut {
|
| 293 |
+
printJSON(plan, res, opt)
|
| 294 |
+
} else {
|
| 295 |
+
fmt.Printf("[wb2api-sync] %s 上传 %d / 下载 %d / 失败 %d\n",
|
| 296 |
+
time.Now().Format("15:04:05"), res.Uploaded, res.Downloaded, res.Failed)
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
select {
|
| 300 |
+
case <-ctx.Done():
|
| 301 |
+
return nil
|
| 302 |
+
case <-t.C:
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// ---------------------------------------------------------------------------
|
| 308 |
+
// 输出
|
| 309 |
+
// ---------------------------------------------------------------------------
|
| 310 |
+
|
| 311 |
+
func printHuman(plan *syncer.Plan, res *syncer.Result, opt options, eng *syncer.Engine) {
|
| 312 |
+
cfg := eng.Config()
|
| 313 |
+
mode := string(opt.mode)
|
| 314 |
+
if opt.dryRun {
|
| 315 |
+
mode += "(dry-run,未写入)"
|
| 316 |
+
}
|
| 317 |
+
if !opt.quiet {
|
| 318 |
+
fmt.Printf("[wb2api-sync] 桶 %s 端点 %s\n", cfg.Bucket, cfg.Endpoint)
|
| 319 |
+
fmt.Printf(" 模式 %s 写入通道 %s 凭证目录 %s\n", mode, writerLabel(eng), cfg.AuthDir)
|
| 320 |
+
if len(cfg.IncludeData) > 0 {
|
| 321 |
+
fmt.Printf(" 额外同步 data/: %s\n", strings.Join(cfg.IncludeData, ", "))
|
| 322 |
+
} else {
|
| 323 |
+
fmt.Printf(" 额外同步 data/: 无(只同步 auths/ 凭证;需要时用 -include-data state.json 开启)\n")
|
| 324 |
+
}
|
| 325 |
+
fmt.Printf(" 本地 %d 个文件 / 远端 %d 个文件\n\n", plan.LocalFiles, plan.RemoteFiles)
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
for _, a := range plan.Actions {
|
| 329 |
+
if a.Op == syncer.OpNoop && !a.Conflict {
|
| 330 |
+
continue // 一致的文件不刷屏
|
| 331 |
+
}
|
| 332 |
+
icon := "="
|
| 333 |
+
switch a.Op {
|
| 334 |
+
case syncer.OpUpload:
|
| 335 |
+
icon = "↑"
|
| 336 |
+
case syncer.OpDownload:
|
| 337 |
+
icon = "↓"
|
| 338 |
+
}
|
| 339 |
+
tag := ""
|
| 340 |
+
if a.Conflict {
|
| 341 |
+
tag = " [冲突]"
|
| 342 |
+
}
|
| 343 |
+
status := ""
|
| 344 |
+
if a.Err != "" {
|
| 345 |
+
status = " ✗ " + a.Err
|
| 346 |
+
}
|
| 347 |
+
fmt.Printf(" %s %-8s %s%s\n %s%s\n", icon, a.Op, a.Path, tag, a.Reason, status)
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
if n := len(plan.Conflicts()); n > 0 && !opt.quiet {
|
| 351 |
+
fmt.Printf("\n 本轮有 %d 个真冲突(两端都有且内容不同),已按「accessToken 新鲜度 → refreshToken 有无 → mtime」裁决。\n", n)
|
| 352 |
+
}
|
| 353 |
+
fmt.Printf("\n汇总: 上传 %d / 下载 %d / 跳过 %d / 失败 %d\n",
|
| 354 |
+
res.Uploaded, res.Downloaded, res.Skipped, res.Failed)
|
| 355 |
+
for _, e := range res.Errors {
|
| 356 |
+
fmt.Fprintf(os.Stderr, " ! %s\n", e)
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
func writerLabel(eng *syncer.Engine) string {
|
| 361 |
+
if n := eng.WriterName(); n != "" {
|
| 362 |
+
return n
|
| 363 |
+
}
|
| 364 |
+
return "(只读:未配置写入通道)"
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// jsonReport 是 -json 的输出结构。
|
| 368 |
+
type jsonReport struct {
|
| 369 |
+
OK bool `json:"ok"`
|
| 370 |
+
Mode string `json:"mode"`
|
| 371 |
+
DryRun bool `json:"dry_run"`
|
| 372 |
+
Bucket string `json:"bucket"`
|
| 373 |
+
Writer string `json:"writer"`
|
| 374 |
+
Local int `json:"local_files"`
|
| 375 |
+
Remote int `json:"remote_files"`
|
| 376 |
+
Changes int `json:"changes"`
|
| 377 |
+
Uploaded int `json:"uploaded"`
|
| 378 |
+
Downld int `json:"downloaded"`
|
| 379 |
+
Skipped int `json:"skipped"`
|
| 380 |
+
Failed int `json:"failed"`
|
| 381 |
+
Actions []syncer.Action `json:"actions"`
|
| 382 |
+
Errors []string `json:"errors,omitempty"`
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
func printJSON(plan *syncer.Plan, res *syncer.Result, opt options) {
|
| 386 |
+
rep := jsonReport{
|
| 387 |
+
OK: res.Failed == 0, Mode: string(opt.mode), DryRun: opt.dryRun,
|
| 388 |
+
Bucket: plan.Bucket, Writer: plan.Writer,
|
| 389 |
+
Local: plan.LocalFiles, Remote: plan.RemoteFiles, Changes: plan.Changes(),
|
| 390 |
+
Uploaded: res.Uploaded, Downld: res.Downloaded, Skipped: res.Skipped, Failed: res.Failed,
|
| 391 |
+
Actions: plan.Actions, Errors: res.Errors,
|
| 392 |
+
}
|
| 393 |
+
raw, _ := json.MarshalIndent(rep, "", " ")
|
| 394 |
+
fmt.Println(string(raw))
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
func printHelp(fs *flag.FlagSet) {
|
| 398 |
+
fmt.Print(`wb2api-sync — 本地与 Hugging Face 中转之间的凭证/状态同步
|
| 399 |
+
|
| 400 |
+
用法:
|
| 401 |
+
wb2api-sync status [标志] 只看差异(有差异退出码 2),不写任何东西
|
| 402 |
+
wb2api-sync pull [标志] 远端 → 本地
|
| 403 |
+
wb2api-sync push [标志] 本地 → 远端
|
| 404 |
+
wb2api-sync sync [标志] 双向对账(默认)
|
| 405 |
+
wb2api-sync watch [标志] 常驻,周期性 sync
|
| 406 |
+
|
| 407 |
+
退出码: 0 已对齐 / 1 出错 / 2 status 模式下存在差异
|
| 408 |
+
|
| 409 |
+
标志:
|
| 410 |
+
`)
|
| 411 |
+
fs.PrintDefaults()
|
| 412 |
+
fmt.Print(`
|
| 413 |
+
示例:
|
| 414 |
+
# 本地启动前先拉一次远端凭证
|
| 415 |
+
wb2api-sync pull -config config.json
|
| 416 |
+
|
| 417 |
+
# 看两边差在哪(不写盘)
|
| 418 |
+
wb2api-sync status -bucket a3216/wb2api-storage -token-file ./data/hf_token.txt
|
| 419 |
+
|
| 420 |
+
# 常驻,每 5 分钟对账一次
|
| 421 |
+
wb2api-sync watch -interval 5m
|
| 422 |
+
`)
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ---------------------------------------------------------------------------
|
| 426 |
+
// 小工具
|
| 427 |
+
// ---------------------------------------------------------------------------
|
| 428 |
+
|
| 429 |
+
// splitCSV 切分逗号列表并去掉空项。
|
| 430 |
+
func splitCSV(s string) []string {
|
| 431 |
+
var out []string
|
| 432 |
+
for _, part := range strings.Split(s, ",") {
|
| 433 |
+
if p := strings.TrimSpace(part); p != "" {
|
| 434 |
+
out = append(out, p)
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
return out
|
| 438 |
+
}
|
cmd/trial/main.go
CHANGED
|
@@ -1,133 +1,133 @@
|
|
| 1 |
-
// trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号,
|
| 2 |
-
// 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。
|
| 3 |
-
//
|
| 4 |
-
// 用法:
|
| 5 |
-
//
|
| 6 |
-
// # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run
|
| 7 |
-
// go run ./cmd/trial
|
| 8 |
-
//
|
| 9 |
-
// # 容器内:先 cp 进去再 exec
|
| 10 |
-
// docker cp trial workbuddy2api:/tmp/trial
|
| 11 |
-
// docker exec -w /app workbuddy2api /tmp/trial
|
| 12 |
-
//
|
| 13 |
-
// 结果逐账号输出到 stdout:
|
| 14 |
-
//
|
| 15 |
-
// uid | nick | status | detail
|
| 16 |
-
// ----+------+--------+-------
|
| 17 |
-
// ... | GLOBAL | OK | trial granted
|
| 18 |
-
// ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败)
|
| 19 |
-
// ... | CN | N/A | not applicable
|
| 20 |
-
package main
|
| 21 |
-
|
| 22 |
-
import (
|
| 23 |
-
"fmt"
|
| 24 |
-
"os"
|
| 25 |
-
"path/filepath"
|
| 26 |
-
"sort"
|
| 27 |
-
|
| 28 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 29 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
// classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言):
|
| 33 |
-
// err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。
|
| 34 |
-
func classifyTrial(claimed bool, err error) (trialStatus, string) {
|
| 35 |
-
switch {
|
| 36 |
-
case err != nil:
|
| 37 |
-
return trialFailed, err.Error()
|
| 38 |
-
case claimed:
|
| 39 |
-
return trialOK, "trial granted"
|
| 40 |
-
default:
|
| 41 |
-
return trialAlready, "already claimed (idempotent)"
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
// trialStatus 单账号 trial 领取结果状态。
|
| 46 |
-
type trialStatus string
|
| 47 |
-
|
| 48 |
-
const (
|
| 49 |
-
trialOK trialStatus = "OK"
|
| 50 |
-
trialAlready trialStatus = "ALREADY"
|
| 51 |
-
trialNotApp trialStatus = "N/A" // CN 账号不适用
|
| 52 |
-
trialFailed trialStatus = "FAIL"
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
type trialRow struct {
|
| 56 |
-
uid string
|
| 57 |
-
nick string
|
| 58 |
-
status trialStatus
|
| 59 |
-
detail string
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
func main() {
|
| 63 |
-
authDir := "auths"
|
| 64 |
-
if len(os.Args) > 1 {
|
| 65 |
-
authDir = os.Args[1]
|
| 66 |
-
}
|
| 67 |
-
files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
|
| 68 |
-
if err != nil || len(files) == 0 {
|
| 69 |
-
fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir)
|
| 70 |
-
os.Exit(1)
|
| 71 |
-
}
|
| 72 |
-
sort.Strings(files)
|
| 73 |
-
|
| 74 |
-
up := upstream.New()
|
| 75 |
-
// trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的
|
| 76 |
-
// GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。
|
| 77 |
-
up.GlobalEnabled = true
|
| 78 |
-
var rows []trialRow
|
| 79 |
-
for _, f := range files {
|
| 80 |
-
r := trialRow{uid: filepath.Base(f)}
|
| 81 |
-
raw, err := os.ReadFile(f)
|
| 82 |
-
if err != nil {
|
| 83 |
-
r.status, r.detail = trialFailed, "load: "+err.Error()
|
| 84 |
-
rows = append(rows, r)
|
| 85 |
-
continue
|
| 86 |
-
}
|
| 87 |
-
a, err := auth.Parse(raw)
|
| 88 |
-
if err != nil {
|
| 89 |
-
r.status, r.detail = trialFailed, "parse: "+err.Error()
|
| 90 |
-
rows = append(rows, r)
|
| 91 |
-
continue
|
| 92 |
-
}
|
| 93 |
-
a.FilePath = f
|
| 94 |
-
r.uid, r.nick = a.UID, a.Nickname
|
| 95 |
-
|
| 96 |
-
// 仅 global 账号适用:CN 明确提示不适用,不发任何请求。
|
| 97 |
-
if !a.IsGlobal() {
|
| 98 |
-
r.status, r.detail = trialNotApp, "CN account not applicable"
|
| 99 |
-
rows = append(rows, r)
|
| 100 |
-
continue
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
r.status, r.detail = classifyTrial(up.ClaimTrial(a))
|
| 104 |
-
rows = append(rows, r)
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
var okN, alreadyN, notAppN, failN int
|
| 108 |
-
fmt.Printf("uid | nick | status | detail\n")
|
| 109 |
-
fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n")
|
| 110 |
-
for _, r := range rows {
|
| 111 |
-
fmt.Printf("%-36s | %-11s | %-7s | %s\n",
|
| 112 |
-
trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail)
|
| 113 |
-
switch r.status {
|
| 114 |
-
case trialOK:
|
| 115 |
-
okN++
|
| 116 |
-
case trialAlready:
|
| 117 |
-
alreadyN++
|
| 118 |
-
case trialNotApp:
|
| 119 |
-
notAppN++
|
| 120 |
-
default:
|
| 121 |
-
failN++
|
| 122 |
-
}
|
| 123 |
-
}
|
| 124 |
-
fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n",
|
| 125 |
-
len(rows), okN, alreadyN, notAppN, failN)
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
func trunc(s string, n int) string {
|
| 129 |
-
if len(s) > n {
|
| 130 |
-
return s[:n]
|
| 131 |
-
}
|
| 132 |
-
return s
|
| 133 |
-
}
|
|
|
|
| 1 |
+
// trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号,
|
| 2 |
+
// 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。
|
| 3 |
+
//
|
| 4 |
+
// 用法:
|
| 5 |
+
//
|
| 6 |
+
// # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run
|
| 7 |
+
// go run ./cmd/trial
|
| 8 |
+
//
|
| 9 |
+
// # 容器内:先 cp 进去再 exec
|
| 10 |
+
// docker cp trial workbuddy2api:/tmp/trial
|
| 11 |
+
// docker exec -w /app workbuddy2api /tmp/trial
|
| 12 |
+
//
|
| 13 |
+
// 结果逐账号输出到 stdout:
|
| 14 |
+
//
|
| 15 |
+
// uid | nick | status | detail
|
| 16 |
+
// ----+------+--------+-------
|
| 17 |
+
// ... | GLOBAL | OK | trial granted
|
| 18 |
+
// ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败)
|
| 19 |
+
// ... | CN | N/A | not applicable
|
| 20 |
+
package main
|
| 21 |
+
|
| 22 |
+
import (
|
| 23 |
+
"fmt"
|
| 24 |
+
"os"
|
| 25 |
+
"path/filepath"
|
| 26 |
+
"sort"
|
| 27 |
+
|
| 28 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 29 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
// classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言):
|
| 33 |
+
// err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。
|
| 34 |
+
func classifyTrial(claimed bool, err error) (trialStatus, string) {
|
| 35 |
+
switch {
|
| 36 |
+
case err != nil:
|
| 37 |
+
return trialFailed, err.Error()
|
| 38 |
+
case claimed:
|
| 39 |
+
return trialOK, "trial granted"
|
| 40 |
+
default:
|
| 41 |
+
return trialAlready, "already claimed (idempotent)"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// trialStatus 单账号 trial 领取结果状态。
|
| 46 |
+
type trialStatus string
|
| 47 |
+
|
| 48 |
+
const (
|
| 49 |
+
trialOK trialStatus = "OK"
|
| 50 |
+
trialAlready trialStatus = "ALREADY"
|
| 51 |
+
trialNotApp trialStatus = "N/A" // CN 账号不适用
|
| 52 |
+
trialFailed trialStatus = "FAIL"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
type trialRow struct {
|
| 56 |
+
uid string
|
| 57 |
+
nick string
|
| 58 |
+
status trialStatus
|
| 59 |
+
detail string
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
func main() {
|
| 63 |
+
authDir := "auths"
|
| 64 |
+
if len(os.Args) > 1 {
|
| 65 |
+
authDir = os.Args[1]
|
| 66 |
+
}
|
| 67 |
+
files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
|
| 68 |
+
if err != nil || len(files) == 0 {
|
| 69 |
+
fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir)
|
| 70 |
+
os.Exit(1)
|
| 71 |
+
}
|
| 72 |
+
sort.Strings(files)
|
| 73 |
+
|
| 74 |
+
up := upstream.New()
|
| 75 |
+
// trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的
|
| 76 |
+
// GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。
|
| 77 |
+
up.GlobalEnabled = true
|
| 78 |
+
var rows []trialRow
|
| 79 |
+
for _, f := range files {
|
| 80 |
+
r := trialRow{uid: filepath.Base(f)}
|
| 81 |
+
raw, err := os.ReadFile(f)
|
| 82 |
+
if err != nil {
|
| 83 |
+
r.status, r.detail = trialFailed, "load: "+err.Error()
|
| 84 |
+
rows = append(rows, r)
|
| 85 |
+
continue
|
| 86 |
+
}
|
| 87 |
+
a, err := auth.Parse(raw)
|
| 88 |
+
if err != nil {
|
| 89 |
+
r.status, r.detail = trialFailed, "parse: "+err.Error()
|
| 90 |
+
rows = append(rows, r)
|
| 91 |
+
continue
|
| 92 |
+
}
|
| 93 |
+
a.FilePath = f
|
| 94 |
+
r.uid, r.nick = a.UID, a.Nickname
|
| 95 |
+
|
| 96 |
+
// 仅 global 账号适用:CN 明确提示不适用,不发任何请求。
|
| 97 |
+
if !a.IsGlobal() {
|
| 98 |
+
r.status, r.detail = trialNotApp, "CN account not applicable"
|
| 99 |
+
rows = append(rows, r)
|
| 100 |
+
continue
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
r.status, r.detail = classifyTrial(up.ClaimTrial(a))
|
| 104 |
+
rows = append(rows, r)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
var okN, alreadyN, notAppN, failN int
|
| 108 |
+
fmt.Printf("uid | nick | status | detail\n")
|
| 109 |
+
fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n")
|
| 110 |
+
for _, r := range rows {
|
| 111 |
+
fmt.Printf("%-36s | %-11s | %-7s | %s\n",
|
| 112 |
+
trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail)
|
| 113 |
+
switch r.status {
|
| 114 |
+
case trialOK:
|
| 115 |
+
okN++
|
| 116 |
+
case trialAlready:
|
| 117 |
+
alreadyN++
|
| 118 |
+
case trialNotApp:
|
| 119 |
+
notAppN++
|
| 120 |
+
default:
|
| 121 |
+
failN++
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n",
|
| 125 |
+
len(rows), okN, alreadyN, notAppN, failN)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
func trunc(s string, n int) string {
|
| 129 |
+
if len(s) > n {
|
| 130 |
+
return s[:n]
|
| 131 |
+
}
|
| 132 |
+
return s
|
| 133 |
+
}
|
config.default.json
CHANGED
|
@@ -1,98 +1,102 @@
|
|
| 1 |
-
{
|
| 2 |
-
"listen": ":7861",
|
| 3 |
-
"api_key": "",
|
| 4 |
-
"auth_dir": "/app/storage/auths",
|
| 5 |
-
"state_file": "/app/storage/data/state.json",
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
},
|
| 9 |
-
"
|
| 10 |
-
"
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
],
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
],
|
| 34 |
-
"
|
| 35 |
-
|
| 36 |
-
],
|
| 37 |
-
"
|
| 38 |
-
|
| 39 |
-
],
|
| 40 |
-
"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
"
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
"
|
| 72 |
-
},
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
"
|
| 76 |
-
},
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
"
|
| 87 |
-
"
|
| 88 |
-
"
|
| 89 |
-
"
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"listen": ":7861",
|
| 3 |
+
"api_key": "",
|
| 4 |
+
"auth_dir": "/app/storage/auths",
|
| 5 |
+
"state_file": "/app/storage/data/state.json",
|
| 6 |
+
"server": {
|
| 7 |
+
"read_timeout": "300s"
|
| 8 |
+
},
|
| 9 |
+
"panel": {
|
| 10 |
+
"package_detail_limit": 5
|
| 11 |
+
},
|
| 12 |
+
"logging": {
|
| 13 |
+
"request_archive_enabled": true,
|
| 14 |
+
"request_retention_days": 7,
|
| 15 |
+
"request_archive_max_mb": 100,
|
| 16 |
+
"request_client_info": true
|
| 17 |
+
},
|
| 18 |
+
"cooldown": {
|
| 19 |
+
"soft_rate": "600s",
|
| 20 |
+
"soft_rate_max": "2h"
|
| 21 |
+
},
|
| 22 |
+
"schedule": {
|
| 23 |
+
"checkin_hours": [
|
| 24 |
+
9,
|
| 25 |
+
21
|
| 26 |
+
],
|
| 27 |
+
"travel_hours": [
|
| 28 |
+
9,
|
| 29 |
+
21
|
| 30 |
+
],
|
| 31 |
+
"activity_hours": [
|
| 32 |
+
10
|
| 33 |
+
],
|
| 34 |
+
"keepalive_hours": [
|
| 35 |
+
22
|
| 36 |
+
],
|
| 37 |
+
"blackcat_hours": [
|
| 38 |
+
23
|
| 39 |
+
],
|
| 40 |
+
"growth_hours": [
|
| 41 |
+
1
|
| 42 |
+
],
|
| 43 |
+
"checkin_enabled": true,
|
| 44 |
+
"travel_enabled": true,
|
| 45 |
+
"activity_enabled": true,
|
| 46 |
+
"keepalive_enabled": true,
|
| 47 |
+
"blackcat_enabled": true,
|
| 48 |
+
"growth_enabled": true,
|
| 49 |
+
"balance_refresh_enabled": true,
|
| 50 |
+
"balance_refresh_minutes": 5,
|
| 51 |
+
"include_disabled_in_tasks": false
|
| 52 |
+
},
|
| 53 |
+
"global": {
|
| 54 |
+
"enabled": true,
|
| 55 |
+
"chat_base": "",
|
| 56 |
+
"billing_base": ""
|
| 57 |
+
},
|
| 58 |
+
"upstream": {
|
| 59 |
+
"timeout_seconds": 120,
|
| 60 |
+
"header_timeout_seconds": 120,
|
| 61 |
+
"idle_timeout_seconds": 300,
|
| 62 |
+
"user_agent": "",
|
| 63 |
+
"client_version": "",
|
| 64 |
+
"cli_version": "",
|
| 65 |
+
"client_name": "",
|
| 66 |
+
"device_token": "",
|
| 67 |
+
"device_token_file": "",
|
| 68 |
+
"passthrough_ip": false
|
| 69 |
+
},
|
| 70 |
+
"features": {
|
| 71 |
+
"sanitize_blacklist_fingerprints": true
|
| 72 |
+
},
|
| 73 |
+
"prompt": {
|
| 74 |
+
"mode": "passthrough",
|
| 75 |
+
"file": ""
|
| 76 |
+
},
|
| 77 |
+
"upstash": {
|
| 78 |
+
"url": "",
|
| 79 |
+
"token": ""
|
| 80 |
+
},
|
| 81 |
+
"pool": {
|
| 82 |
+
"max_in_flight": 3,
|
| 83 |
+
"max_in_flight_global": 2,
|
| 84 |
+
"breaker_threshold": 3,
|
| 85 |
+
"breaker_cooldown": "30m",
|
| 86 |
+
"breaker_cooldown_max": "6h",
|
| 87 |
+
"degrade_threshold": 5,
|
| 88 |
+
"degrade_cooldown": "10m",
|
| 89 |
+
"degrade_cooldown_max": "2h",
|
| 90 |
+
"idle_weight_per_hour": 0.5,
|
| 91 |
+
"idle_weight_max": 5,
|
| 92 |
+
"prefer_expiring": true,
|
| 93 |
+
"expiring_soon": "168h",
|
| 94 |
+
"cost_explore_interval": "30m",
|
| 95 |
+
"credit_floor": 0
|
| 96 |
+
},
|
| 97 |
+
"session_sticky": {
|
| 98 |
+
"enabled": true,
|
| 99 |
+
"ttl": "30m",
|
| 100 |
+
"gc_interval": "5m"
|
| 101 |
+
}
|
| 102 |
+
}
|
config.example.json
CHANGED
|
@@ -1,84 +1,105 @@
|
|
| 1 |
-
{
|
| 2 |
-
"listen": ":7863",
|
| 3 |
-
"api_key": "test_key",
|
| 4 |
-
"auth_dir": "./auths",
|
| 5 |
-
"state_file": "./data/state.json",
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
},
|
| 9 |
-
"
|
| 10 |
-
"
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
"
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
"
|
| 58 |
-
},
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
},
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"listen": ":7863",
|
| 3 |
+
"api_key": "test_key",
|
| 4 |
+
"auth_dir": "./auths",
|
| 5 |
+
"state_file": "./data/state.json",
|
| 6 |
+
"server": {
|
| 7 |
+
"read_timeout": "300s"
|
| 8 |
+
},
|
| 9 |
+
"panel": {
|
| 10 |
+
"package_detail_limit": 5
|
| 11 |
+
},
|
| 12 |
+
"logging": {
|
| 13 |
+
"request_archive_enabled": true,
|
| 14 |
+
"request_retention_days": 7,
|
| 15 |
+
"request_archive_max_mb": 100,
|
| 16 |
+
"request_client_info": true
|
| 17 |
+
},
|
| 18 |
+
"cooldown": {
|
| 19 |
+
"soft_rate": "600s",
|
| 20 |
+
"soft_rate_max": "2h"
|
| 21 |
+
},
|
| 22 |
+
"schedule": {
|
| 23 |
+
"checkin_hours": [9, 21],
|
| 24 |
+
"growth_hours": [1],
|
| 25 |
+
"travel_hours": [9, 21],
|
| 26 |
+
"activity_hours": [10],
|
| 27 |
+
"keepalive_hours": [22],
|
| 28 |
+
"blackcat_hours": [23],
|
| 29 |
+
"checkin_enabled": true,
|
| 30 |
+
"growth_enabled": true,
|
| 31 |
+
"travel_enabled": true,
|
| 32 |
+
"activity_enabled": true,
|
| 33 |
+
"keepalive_enabled": true,
|
| 34 |
+
"blackcat_enabled": true,
|
| 35 |
+
"balance_refresh_enabled": true,
|
| 36 |
+
"balance_refresh_minutes": 5,
|
| 37 |
+
"include_disabled_in_tasks": false
|
| 38 |
+
},
|
| 39 |
+
"global": {
|
| 40 |
+
"enabled": true,
|
| 41 |
+
"chat_base": "",
|
| 42 |
+
"billing_base": ""
|
| 43 |
+
},
|
| 44 |
+
"upstream": {
|
| 45 |
+
"timeout_seconds": 120,
|
| 46 |
+
"header_timeout_seconds": 120,
|
| 47 |
+
"idle_timeout_seconds": 300,
|
| 48 |
+
"user_agent": "",
|
| 49 |
+
"client_version": "",
|
| 50 |
+
"cli_version": "",
|
| 51 |
+
"client_name": "",
|
| 52 |
+
"device_token": "",
|
| 53 |
+
"device_token_file": "",
|
| 54 |
+
"passthrough_ip": false
|
| 55 |
+
},
|
| 56 |
+
"features": {
|
| 57 |
+
"sanitize_blacklist_fingerprints": true
|
| 58 |
+
},
|
| 59 |
+
"prompt": {
|
| 60 |
+
"mode": "passthrough",
|
| 61 |
+
"file": ""
|
| 62 |
+
},
|
| 63 |
+
"upstash": {
|
| 64 |
+
"url": "",
|
| 65 |
+
"token": ""
|
| 66 |
+
},
|
| 67 |
+
"pool": {
|
| 68 |
+
"max_in_flight": 3,
|
| 69 |
+
"max_in_flight_global": 2,
|
| 70 |
+
"breaker_threshold": 3,
|
| 71 |
+
"breaker_cooldown": "30m",
|
| 72 |
+
"breaker_cooldown_max": "6h",
|
| 73 |
+
"degrade_threshold": 5,
|
| 74 |
+
"degrade_cooldown": "10m",
|
| 75 |
+
"degrade_cooldown_max": "2h",
|
| 76 |
+
"idle_weight_per_hour": 0.5,
|
| 77 |
+
"idle_weight_max": 5.0,
|
| 78 |
+
"prefer_expiring": true,
|
| 79 |
+
"expiring_soon": "168h",
|
| 80 |
+
"cost_explore_interval": "30m",
|
| 81 |
+
"credit_floor": 100
|
| 82 |
+
},
|
| 83 |
+
"session_sticky": {
|
| 84 |
+
"enabled": true,
|
| 85 |
+
"ttl": "30m",
|
| 86 |
+
"gc_interval": "5m"
|
| 87 |
+
},
|
| 88 |
+
"sync": {
|
| 89 |
+
"bucket": "",
|
| 90 |
+
"token": "",
|
| 91 |
+
"token_file": "",
|
| 92 |
+
"endpoint": "https://huggingface.co",
|
| 93 |
+
"auth_dir": "",
|
| 94 |
+
"data_dir": "",
|
| 95 |
+
"include_data": [],
|
| 96 |
+
"writer": "auto",
|
| 97 |
+
"hf_bin": "hf",
|
| 98 |
+
"relay_url": "",
|
| 99 |
+
"relay_key": "",
|
| 100 |
+
"restart_space": "",
|
| 101 |
+
"ca_bundle": "",
|
| 102 |
+
"timeout": "60s",
|
| 103 |
+
"insecure_skip_verify": false
|
| 104 |
+
}
|
| 105 |
+
}
|
credit.sh
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
-
#!/usr/bin/env bash
|
| 2 |
-
# credit.sh — WorkBuddy 积分日报(默认美化输出)
|
| 3 |
-
#
|
| 4 |
-
# 用法:
|
| 5 |
-
# ./credit.sh # 人类可读日报
|
| 6 |
-
# ./credit.sh -json # 原始 JSON
|
| 7 |
-
#
|
| 8 |
-
# 二进制升级: go build -o credit ./cmd/credit
|
| 9 |
-
set -euo pipefail
|
| 10 |
-
cd "$(dirname "$0")"
|
| 11 |
-
if [[ "${1:-}" == "-json" ]]; then
|
| 12 |
-
exec ./credit
|
| 13 |
-
fi
|
| 14 |
-
exec ./credit -pretty
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# credit.sh — WorkBuddy 积分日报(默认美化输出)
|
| 3 |
+
#
|
| 4 |
+
# 用法:
|
| 5 |
+
# ./credit.sh # 人类可读日报
|
| 6 |
+
# ./credit.sh -json # 原始 JSON
|
| 7 |
+
#
|
| 8 |
+
# 二进制升级: go build -o credit ./cmd/credit
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
cd "$(dirname "$0")"
|
| 11 |
+
if [[ "${1:-}" == "-json" ]]; then
|
| 12 |
+
exec ./credit
|
| 13 |
+
fi
|
| 14 |
+
exec ./credit -pretty
|
deploy/cloudflare-worker/.dev.vars.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 本地开发用的变量样例 —— 复制为 .dev.vars 后填写真实值。
|
| 2 |
+
#
|
| 3 |
+
# cp .dev.vars.example .dev.vars
|
| 4 |
+
#
|
| 5 |
+
# ⚠️ .dev.vars 里放的是真实密钥,**永远不要提交**。
|
| 6 |
+
# `.dev.vars.example`(本文件)只放占位值,可以进版本库。
|
| 7 |
+
#
|
| 8 |
+
# 线上环境不要用这个文件,改用:
|
| 9 |
+
# npx wrangler secret put WB2A_API_KEY
|
| 10 |
+
#
|
| 11 |
+
# 格式:KEY="value",一行一个,不要加 export。
|
| 12 |
+
|
| 13 |
+
# 源站(一般不用改,改 Space 时才动)
|
| 14 |
+
ORIGIN="https://a3216-gcli2api.hf.space"
|
| 15 |
+
|
| 16 |
+
# 保活探测路径
|
| 17 |
+
HEALTH_PATH="/healthz"
|
| 18 |
+
|
| 19 |
+
# 面板 / 网关的 Bearer 密钥。留空时 scheduled() 只做裸 ping,不发送鉴权请求。
|
| 20 |
+
WB2A_API_KEY=""
|
| 21 |
+
|
| 22 |
+
# —— 可选的带鉴权签到(默认关闭)——
|
| 23 |
+
# CHECKIN_CRON="0 * * * *"
|
| 24 |
+
# CHECKIN_PATH="/panel/api/keepalive"
|
deploy/cloudflare-worker/README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WorkBuddy2API — Cloudflare Worker:反代 + 保活
|
| 2 |
+
|
| 3 |
+
把公网入口 `cli.3216gemini.dpdns.org` 反代到 Hugging Face Space
|
| 4 |
+
`a3216/gcli2api`(直连域名 `https://a3216-gcli2api.hf.space`),
|
| 5 |
+
并用 Cron 定时打 `/healthz`,防止 Space 被 HF 暂停。
|
| 6 |
+
|
| 7 |
+
## 这个 Worker 做什么
|
| 8 |
+
| 能力 | 说明 |
|
| 9 |
+
|---|---|
|
| 10 |
+
| 🔁 **全量反向代理** | 方法 / 路径 / query / 请求体 / 端到端请求头原样转发,源站的状态码、响应头、响应体原样回传 |
|
| 11 |
+
| ⚡ **零缓冲流式** | 直接返回 `originResponse.body`,不调用 `.text()` / `.json()`。`POST /v1/chat/completions` 是 SSE 长连接,缓冲会直接毁掉流式体验 |
|
| 12 |
+
| ↩️ **不吞后端 302** | `fetch(..., { redirect: 'manual' })`,所以 `GET /` → `/panel/` 的跳转是**浏览器**看到的,不会被 Worker 内部跟掉 |
|
| 13 |
+
| 🌐 **CORS 全透传** | 后端已经返回宽松的 CORS 头,Worker 一律不自己造 `Access-Control-*`,避免出现重复头 |
|
| 14 |
+
| 🧹 **逐跳头剥离** | 剥掉 `host` / `connection` / `keep-alive` / `transfer-encoding` / `upgrade` / `proxy-*` / `cf-*` / `x-forwarded-*`;`Host` 永远不会用公网域名转发过去 |
|
| 15 |
+
| 🍪 **set-cookie 原样** | 不做 domain / path 改写,多条也完整保留 |
|
| 16 |
+
| ⏰ **Cron 保活** | `scheduled()` 每 5 分钟 ping 一次源站 `/healthz`,25s 硬超时 + 失败重试 1 次 + 每次一行日志 |
|
| 17 |
+
| 🛡️ **不抛异常** | Cron 处理器内部吞掉所有异常,不会污染 Cron 运行记录 |
|
| 18 |
+
|
| 19 |
+
涉及的后端接口(都不需要 Worker 特殊处理,纯透传):
|
| 20 |
+
|
| 21 |
+
- `GET /healthz` → `{"healthy":2,"realm_servable":{...},"service":"workbuddy2api","total":3}`(无可服务账号时 503)
|
| 22 |
+
- `GET /` → 302 → `/panel/`
|
| 23 |
+
- `GET /panel/` → 单页管理面板
|
| 24 |
+
- `/panel/api/*` → 面板 JSON API,需 `Authorization: Bearer <api_key>`
|
| 25 |
+
- `POST /v1/chat/completions` → OpenAI 兼容,**SSE 流式**,需同一个 Bearer 密钥
|
| 26 |
+
- `GET /v1/models`、`GET /status`
|
| 27 |
+
|
| 28 |
+
## 为什么需要这个 Cron
|
| 29 |
+
|
| 30 |
+
Hugging Face 免费 `cpu-basic` Space 有一条硬规则:**连续 48 小时没有任何 HTTP 流量就被暂停**,
|
| 31 |
+
下一次访问要等 **30–60 秒**冷启动。
|
| 32 |
+
|
| 33 |
+
所以「反代」和「保活」其实是同一件事的两半:反代提供入口,Cron 保证入口后面那台机器不会睡着。
|
| 34 |
+
只要有任意 HTTP 请求打到 Space,HF 的闲置计时器就会归零——**响应内容是什么完全不影响判断**,
|
| 35 |
+
这就是为什么一次裸的 `GET /healthz` 就够了。
|
| 36 |
+
|
| 37 |
+
## ⚠️ 接管前必读:现有入口的 `/healthz` 是 Cloudflare 自己应答的
|
| 38 |
+
|
| 39 |
+
同一时刻实测对比:
|
| 40 |
+
|
| 41 |
+
| 目标 | `/healthz` 响应 | 判据 |
|
| 42 |
+
| --- | --- | --- |
|
| 43 |
+
| `https://a3216-gcli2api.hf.space/healthz` | `{"healthy":2,…,"service":"workbuddy2api"}` | 带 `x-service`、`x-proxied-replica`,以及 HF 的 `link: …/spaces/a3216/gcli2api` 头 |
|
| 44 |
+
| `https://cli.3216gemini.dpdns.org/healthz` | **`ok`(2 字节)** | 只有 `cf-ray`;**没有** `x-service`、**没有** `link` |
|
| 45 |
+
|
| 46 |
+
**结论:现有入口把 `/healthz` 短路了,请求根本没到 Space。** 很可能是故意的 ——
|
| 47 |
+
避免探活 / 监控 / 负载均衡把 30~60s 冷启动打起来。它只短路了这一个路径:
|
| 48 |
+
`/status`、`/panel/*` 等仍是透传的(两边都带 HF 的 `link` 头,可自证)。
|
| 49 |
+
|
| 50 |
+
两个直接后果:
|
| 51 |
+
|
| 52 |
+
1. **千万别拿 `cli.3216gemini.dpdns.org/healthz` 做保活。** 它会稳定返回 200、
|
| 53 |
+
看起来一切健康,但 Space 完全没被唤醒 —— 48 小时一到照样被暂停。
|
| 54 |
+
保活必须打 Space 的直连域名(见仓库根的 `.github/workflows/space-keepalive.yml`)。
|
| 55 |
+
2. **本 Worker 接管路由后,`/healthz` 的行为会变**:它会把 `/healthz` 原样透传给 Space,
|
| 56 |
+
于是返回的是网关 JSON 而不是 `ok`。对"真探活"来说这是**更正确**的
|
| 57 |
+
(能反映账号可用性),但如果你有监控依赖那个 `ok`,请先调整它。
|
| 58 |
+
想让本 Worker 也短路 `/healthz`,在 `worker.js` 的 `fetch()` 里加一条早返回即可。
|
| 59 |
+
|
| 60 |
+
## ⚠️ 免费版只有 5 个 Cron(最容易踩的坑)
|
| 61 |
+
|
| 62 |
+
**Cloudflare 免费版的 cron 触发器限制是「每个账号 5 个」,不是每个 Worker 5 个。**
|
| 63 |
+
如果你账号里别的 Worker 已经占了 5 个,再部署本 Worker 就会看到这种**半成功**:
|
| 64 |
+
|
| 65 |
+
```text
|
| 66 |
+
Uploaded workbuddy2api-hf-proxy (1.63 sec)
|
| 67 |
+
Deployed workbuddy2api-hf-proxy triggers (1.62 sec)
|
| 68 |
+
✘ [ERROR] Trigger configuration for "workbuddy2api-hf-proxy" was only partially updated:
|
| 69 |
+
Cron schedules: ... failed.
|
| 70 |
+
- This account has reached the Workers Free limit of 5 cron triggers per account.
|
| 71 |
+
[code: 10072]
|
| 72 |
+
Successful trigger changes were not rolled back.
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
**怎么读这段**:Worker 的**代码确实上传成功了**,但 **cron 没加上**,而且
|
| 76 |
+
"Successful trigger changes were not rolled back" 只是说"已成功的部分不回滚"。
|
| 77 |
+
结果是这个 Worker 处于**空转**状态——没有 cron,它不会去唤醒任何人。
|
| 78 |
+
(`* * * * *` 这种多行 crons 数组里**每一行都算一个**触发器,别写成 5 行。)
|
| 79 |
+
|
| 80 |
+
两条出路:
|
| 81 |
+
|
| 82 |
+
### 出路 A:腾一个 CF cron 名额(保持 CF 原生方案)
|
| 83 |
+
|
| 84 |
+
Dashboard → Workers & Pages → **逐个 Worker** 看 Settings → Triggers → Cron Triggers,
|
| 85 |
+
找出已经不用的那个删掉,然后重新 `npx wrangler deploy`。
|
| 86 |
+
|
| 87 |
+
想用 API 一次性列全账号的 cron(需要 `CLOUDFLARE_API_TOKEN`,权限含 *Workers Scripts:Read*):
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
ACC=a75c5f86fdd1b75eb3e860f5436a7970 # 你的 account id
|
| 91 |
+
for w in $(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts" \
|
| 92 |
+
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[].id'); do
|
| 93 |
+
echo "== $w"
|
| 94 |
+
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts/$w/schedules" \
|
| 95 |
+
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '.result.schedules'
|
| 96 |
+
done
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### 出路 B:改用 GitHub Actions(**推荐,无数量限制**)
|
| 100 |
+
|
| 101 |
+
本仓库自带 `.github/workflows/space-keepalive.yml`,每 6 小时 ping 一次 Space,
|
| 102 |
+
零成本、不占 CF 名额。**而且 6 小时一次完全够用**——HF 的判定阈值是 48 小时,
|
| 103 |
+
所以 GitHub 定时任务偶发延迟几十分钟毫无影响(反过来说,本 Worker 里 `*/5` 的
|
| 104 |
+
5 分钟粒度其实是**严重过量**的,纯粹因为免费额度够用才这么写)。
|
| 105 |
+
|
| 106 |
+
两条路不冲突:Worker 留着做反代,保活交给 Actions 也完全可以。
|
| 107 |
+
|
| 108 |
+
## 部署
|
| 109 |
+
|
| 110 |
+
> **先看上一节**:如果你账号的 5 个 cron 名额已满,`wrangler deploy` 会报 `code: 10072`,
|
| 111 |
+
> 这时按「出路 A/B」处理。
|
| 112 |
+
|
| 113 |
+
### 1. 前置条件
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
node -v # 需要 Node 18+
|
| 117 |
+
npx wrangler -v # 首次会提示安装 wrangler
|
| 118 |
+
npx wrangler login # 登录你的 Cloudflare 账号
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### 2. 部署
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
cd D:\workbuddy2api-panel\deploy\cloudflare-worker
|
| 125 |
+
npx wrangler deploy
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
部署成功后 Worker 名称为 `workbuddy2api-hf-proxy`(见 `wrangler.toml` 的 `name`)。
|
| 129 |
+
|
| 130 |
+
### 3. 接线路由
|
| 131 |
+
|
| 132 |
+
`wrangler.toml` 里的 `routes` **默认是注释掉的**,两种接法任选其一:
|
| 133 |
+
|
| 134 |
+
- **A. 交给 wrangler**:取消 `routes` 注释后重新 `wrangler deploy`,路由自动创建;
|
| 135 |
+
- **B. 在 Dashboard 手动加**:Workers → 你的 Worker → Settings → Domains & Routes →
|
| 136 |
+
添加 `cli.3216gemini.dpdns.org/*`(推荐,避免误删线上路由)。
|
| 137 |
+
|
| 138 |
+
> ⚠️ 启用路由前确认该主机名当前没有指向别处的「已代理」DNS 记录,
|
| 139 |
+
> 否则 Worker 路由和 DNS 记录会互相打架。正确做法是该主机名由 Worker 路由接管
|
| 140 |
+
> (DNS 里留一条指向 `100::` 的 AAAA 占位记录)。
|
| 141 |
+
|
| 142 |
+
### 4. 设置密钥(可选)
|
| 143 |
+
|
| 144 |
+
只有开启「可选唤醒/签到」时才需要。**密钥永远不要写进 `wrangler.toml`,也不要提交。**
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
npx wrangler secret put WB2A_API_KEY
|
| 148 |
+
# 粘贴网关的 api_key(与面板登录用的是同一个),回车即可
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
未设置时 `scheduled()` 只做 `/healthz` 裸 ping,不发送任何鉴权请求。
|
| 152 |
+
|
| 153 |
+
## 验证
|
| 154 |
+
|
| 155 |
+
> **先接路由再验**:下面这些 URL 打的是 `cli.3216gemini.dpdns.org`,只有在该主机名已经
|
| 156 |
+
> 路由到本 Worker 时才是"在验 Worker"。没接路由的话,你验到的仍是原来的链路。
|
| 157 |
+
>
|
| 158 |
+
> **`*.workers.dev` 在部分网络下不可用**(国内常见:DNS 被污染,解析到不相干的 IP、
|
| 159 |
+
> 443 不通)。所以 `wrangler deploy` 打印的 `https://<worker>.<sub>.workers.dev`
|
| 160 |
+
> 在你这里可能**根本连不上**——这**不代表部署失败**,用自定义域名的路由来验即可。
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
# 探活:能看到网关自己的 JSON,且带 total / service 字段
|
| 164 |
+
curl -i https://cli.3216gemini.dpdns.org/healthz
|
| 165 |
+
|
| 166 |
+
# 后端自带的 302 应该原样到达客户端(不要出现 -L 跟随)
|
| 167 |
+
curl -i https://cli.3216gemini.dpdns.org/
|
| 168 |
+
|
| 169 |
+
# 面板
|
| 170 |
+
curl -I https://cli.3216gemini.dpdns.org/panel/
|
| 171 |
+
|
| 172 |
+
# 流式:应逐块吐出,而不是憋到最后一次性返回
|
| 173 |
+
curl -N -H "Authorization: Bearer <api_key>" \
|
| 174 |
+
-H "Content-Type: application/json" \
|
| 175 |
+
-d '{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' \
|
| 176 |
+
https://cli.3216gemini.dpdns.org/v1/chat/completions
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
看到 `server: cloudflare` 与 `cf-ray` 属正常——那是 Cloudflare 边缘加的,不代表后端没响应;
|
| 180 |
+
后端的响应头(如 `service`、`x-*`)应当同时原样出现。
|
| 181 |
+
|
| 182 |
+
## 看日志
|
| 183 |
+
|
| 184 |
+
```bash
|
| 185 |
+
cd D:\workbuddy2api-panel\deploy\cloudflare-worker
|
| 186 |
+
npx wrangler tail # 实时请求日志
|
| 187 |
+
npx wrangler tail --format pretty # 人类可读格式
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
> **`wrangler tail` 报 `Error: Unexpected server response: 400`?**
|
| 191 |
+
> 它走的是 WebSocket,经过本地抓包代理(DevSidecar / Reqable 之类)时经常被挡掉。
|
| 192 |
+
> 换成这几条路即可:
|
| 193 |
+
>
|
| 194 |
+
> - Dashboard → Workers & Pages → 你的 Worker → **Logs** → Live / Cron Events(最省事);
|
| 195 |
+
> - 关掉代理再 `npx wrangler tail`;
|
| 196 |
+
> - 或者干脆不依赖 tail:Cron 有没有在跑��**看 Space 的 `last_checkin_day` 有没有变成当天**
|
| 197 |
+
> 最直接(桶里的 `data/state.json`),或看 `npx wrangler deployments list` 的更新时间。
|
| 198 |
+
|
| 199 |
+
Cron 每次运行会打一行:
|
| 200 |
+
|
| 201 |
+
```
|
| 202 |
+
[keepalive] 2025-01-01T00:05:03.412Z /healthz -> HTTP 200 (183ms)
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
失败时是 `warn` 级别,同时带上首次失败原因,便于区分「偶发抖动」和「真的没醒」:
|
| 206 |
+
|
| 207 |
+
```
|
| 208 |
+
[keepalive] 2025-01-01T00:05:03.412Z /healthz -> FAILED status=0 (25013ms) first=ping timeout detail=ping timeout
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
### Cron 粒度说明
|
| 212 |
+
|
| 213 |
+
- `[triggers] crons = ["*/5 * * * *"]` = 每 5 分钟一次,**这是 Cloudflare Cron Triggers 的最小粒度**(不支持每分钟);
|
| 214 |
+
- 频率换算:每 5 分钟 ≈ 288 次/天,对比 48 小时暂停阈值,余量极大;
|
| 215 |
+
- Cron 触发时间有**秒级抖动**(官方说明可能延迟数秒到数十秒),对保活完全无影响;
|
| 216 |
+
- 如果只是防休眠,**不需要再加密**;调成 `*/30` 之类也够用,但 5 分钟最稳。
|
| 217 |
+
|
| 218 |
+
## 本地调试
|
| 219 |
+
|
| 220 |
+
**推荐:先用 `dev-server.mjs` 把代理逻辑验完,再考虑 deploy。** 它用 Node 起一个本地服务,
|
| 221 |
+
**原样 `import` 这个 worker.js**(不做任何改写),转发到真实的 HF Space——不需要 wrangler、
|
| 222 |
+
不需要登录:
|
| 223 |
+
|
| 224 |
+
```bash
|
| 225 |
+
cd deploy/cloudflare-worker
|
| 226 |
+
node dev-server.mjs # 监听 127.0.0.1:8787
|
| 227 |
+
# PORT=9000 node dev-server.mjs # 换端口
|
| 228 |
+
# ORIGIN=https://other.hf.space node dev-server.mjs
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
另开一个终端逐条验:
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
curl -i http://127.0.0.1:8787/ # 期望 302 → /panel/(不要加 -L)
|
| 235 |
+
curl -i http://127.0.0.1:8787/healthz # 期望网关自己的 JSON(带 total / service)
|
| 236 |
+
curl -i http://127.0.0.1:8787/status # 期望 401 invalid_api_key(说明真的打到了网关)
|
| 237 |
+
curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器,看控制台日志
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
`/__ping` 与 `/__cron` 是**本地专用**路径,永远不会被代理到源站,部署后的 Worker 里没有它们。
|
| 241 |
+
|
| 242 |
+
也可以走 wrangler(需要它自己的本地环境):
|
| 243 |
+
|
| 244 |
+
```bash
|
| 245 |
+
npx wrangler dev
|
| 246 |
+
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # 临时触发 Cron 处理器
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
### `.dev.vars`
|
| 250 |
+
|
| 251 |
+
本地调试需要的变量(尤其是 `WB2A_API_KEY`)放在同目录 `.dev.vars` 里,**不要提交**:
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
cp .dev.vars.example .dev.vars # 然后按需填写
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
格式为 `KEY="value"`,一行一个。`wrangler dev` 会自动读取该文件中的变量与密钥。
|
| 258 |
+
|
| 259 |
+
> ✅ **`.gitignore` 已经覆盖到位**(根目录规则):`.dev.vars` / `.dev.vars.*` / `.wrangler/`
|
| 260 |
+
> 全部被忽略,同时用 `!.dev.vars.example` 把**模板**放回版本库(模板里只有占位符)。
|
| 261 |
+
> 四个可提交文件:`worker.js`、`wrangler.toml`、`.dev.vars.example`、本 `README.md`。
|
| 262 |
+
>
|
| 263 |
+
> **任何 secret 都不得提交**,线上一律走 `wrangler secret put`。
|
| 264 |
+
|
| 265 |
+
## 环境变量一览
|
| 266 |
+
|
| 267 |
+
| 名称 | 类型 | 默认值 | 说明 |
|
| 268 |
+
|---|---|---|---|
|
| 269 |
+
| `ORIGIN` | vars | `https://a3216-gcli2api.hf.space` | 源站地址。换 Space 只改这里 |
|
| 270 |
+
| `ROOT_REDIRECT` | vars(注释中) | `/panel/` | `GET /` 的跳转目标;设成空串则关闭跳转、直接透传后端的 404 |
|
| 271 |
+
| `HEALTH_PATH` | vars(注释中) | `/healthz` | 保活探测路径 |
|
| 272 |
+
| `CHECKIN_CRON` | vars(注释中) | 空 = 关闭 | 只在此 Cron 表达式触发时做签到 |
|
| 273 |
+
| `CHECKIN_PATH` | vars(注释中) | 空 = 关闭 | 带 Bearer 调用的路径,如 `/panel/api/keepalive` |
|
| 274 |
+
| `WB2A_API_KEY` | **secret** | 未设置 | 面板/网关 Bearer 密钥,仅签到功能需要 |
|
| 275 |
+
|
| 276 |
+
> `ORIGIN` 若填成非法 URL,Worker 会**回落到默认源站**而不是整体 500——配置拼错时仍能提供服务。
|
| 277 |
+
|
| 278 |
+
## 如果你已有自己的反代 Worker
|
| 279 |
+
|
| 280 |
+
**不要替换掉你现有的反代逻辑**,只把「保活」那一小块搬过去即可,改动量极小:
|
| 281 |
+
|
| 282 |
+
1. **加 `scheduled()` 处理器**:从 `worker.js` 里复制 `scheduled(event, env, ctx)` 整个函数,
|
| 283 |
+
连同它依赖的 `DEFAULT_ORIGIN` / `HEALTH_PATH` / `PING_TIMEOUT_MS` / `PING_RETRY_DELAY_MS`
|
| 284 |
+
常量、`resolveOrigin()` / `normalizePath()` 两个工具函数一起拿走。
|
| 285 |
+
(可选签到那段如果不需要,连同 `CHECKIN_*` 一起删掉即可。)
|
| 286 |
+
2. **确认你的默认导出里有 `scheduled`**:必须是
|
| 287 |
+
`export default { fetch(request, env, ctx) {...}, scheduled(event, env, ctx) {...} }` 这种形状。
|
| 288 |
+
如果你原来是 `export default { async fetch(...) }`,直接把 `scheduled` 加进同一个对象即可,
|
| 289 |
+
**不要**改成 `export { scheduled }` 之外的写法。
|
| 290 |
+
3. **在 `wrangler.toml` 加上 trigger 块**:
|
| 291 |
+
|
| 292 |
+
```toml
|
| 293 |
+
[triggers]
|
| 294 |
+
crons = ["*/5 * * * *"]
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
4. **重新部署**:`npx wrangler deploy`。改 `[triggers]` 必须重新部署才会生效,
|
| 298 |
+
只改 Dashboard 里的 Cron 是临时的、下次部署会被覆盖。
|
| 299 |
+
|
| 300 |
+
反代部分如果你已经有实现,**保持原样**——本 Worker 的 `fetch()` 只是一个满足
|
| 301 |
+
「不��冲 / 不跟随 302 / 剥离逐跳头 / 透传 CORS」四条硬约束的参考实现,
|
| 302 |
+
你的版本只要满足同样四条,效果完全等价。
|
| 303 |
+
|
| 304 |
+
## 常见问题
|
| 305 |
+
|
| 306 |
+
### 代理后 `/v1/chat/completions` 变成一次性返回,没有流式效果?
|
| 307 |
+
|
| 308 |
+
几乎一定是某处调用了 `await res.text()` / `res.json()` / 中间做了缓冲(包括自己包一层
|
| 309 |
+
`TransformStream` 却忘了 flush)。确认直接 `return new Response(originResponse.body, ...)`。
|
| 310 |
+
|
| 311 |
+
### `GET /` 返回的是 `/panel/` 的内容,看不到 302?
|
| 312 |
+
|
| 313 |
+
说明 `fetch` 的 `redirect: 'manual'` 丢了。默认的 `redirect: 'follow'` 会在 Worker 内部
|
| 314 |
+
把 302 跟掉,客户端就永远看不到那次跳转。
|
| 315 |
+
|
| 316 |
+
### `GET /` 到底该返回 302 还是后端原始的 404?
|
| 317 |
+
|
| 318 |
+
**302 → `/panel/`。** 这是实测出来的既有行为,值得记一笔:网关只注册了 `/panel/`,
|
| 319 |
+
所以**直连 Space 的 `GET /` 是 404**;但公网入口 `https://cli.3216gemini.dpdns.org/`
|
| 320 |
+
返回的却是 302 → `/panel/`。同一路径两种结果,说明这层跳转是 **CF 侧加的**(Worker 或
|
| 321 |
+
Redirect Rule)。`worker.js` 因此也自带了这个跳转(`ROOT_REDIRECT`,默认 `/panel/`),
|
| 322 |
+
换用本 Worker 后直接打开域名首页的体验不变。
|
| 323 |
+
|
| 324 |
+
想对比两种行为:
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
curl -i https://a3216-gcli2api.hf.space/ # 404(后端真实行为)
|
| 328 |
+
curl -i https://cli.3216gemini.dpdns.org/ # 302 → /panel/(入口层加的)
|
| 329 |
+
```
|
| 330 |
+
|
| 331 |
+
想把跳转关掉(严格透传后端):把 `ROOT_REDIRECT` 设成空串。
|
| 332 |
+
|
| 333 |
+
### 浏览器报 CORS 重复头错误?
|
| 334 |
+
|
| 335 |
+
Worker 自己造了 `Access-Control-Allow-Origin`,而后端也返回了同一个头。
|
| 336 |
+
删掉 Worker 里的 CORS 处理,让它纯透传。
|
| 337 |
+
|
| 338 |
+
### Cron 日志里一直是 503,是不是保活失败了?
|
| 339 |
+
|
| 340 |
+
**不是。** 503 是后端「当前没有可用账号可服务」的业务语义,但只要它回了 HTTP,
|
| 341 |
+
Space 就是醒着的,保活目的已经达到。只有 `status=0`(超时/网络失败)才需要关注——
|
| 342 |
+
那通常意味着正在冷启动,或者 Space 真的挂了。
|
| 343 |
+
|
| 344 |
+
### 想确认 Cron 到底有没有在跑?
|
| 345 |
+
|
| 346 |
+
```bash
|
| 347 |
+
npx wrangler tail --format pretty # 等 5 分钟就能看到 [keepalive] 行
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
或者 Dashboard → Workers → 你的 Worker → Logs → 筛选 Cron Events。
|
deploy/cloudflare-worker/dev-server.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* worker.js 本地开发/验证服务器。
|
| 4 |
+
*
|
| 5 |
+
* 为什么需要它:Worker 只有在部署到 Cloudflare 之后才会真正接管
|
| 6 |
+
* cli.3216gemini.dpdns.org —— 万一代理逻辑写错,你的域名当场就废了。
|
| 7 |
+
* 这个脚本用 Node 起一个本地 HTTP 服务,**原样执行同一个 worker.js**
|
| 8 |
+
* (直接 import,不做任何改写),把它转发到真实的 HF Space,
|
| 9 |
+
* 于是你可以在本地先把每一条路径验完,再决定要不要 deploy。
|
| 10 |
+
*
|
| 11 |
+
* 用法:
|
| 12 |
+
* node dev-server.mjs # 监听 127.0.0.1:8787
|
| 13 |
+
* PORT=9000 node dev-server.mjs
|
| 14 |
+
* ORIGIN=https://other-space.hf.space node dev-server.mjs
|
| 15 |
+
*
|
| 16 |
+
* 然后另开一个终端:
|
| 17 |
+
* curl -i http://127.0.0.1:8787/healthz # 应返回网关的 JSON
|
| 18 |
+
* curl -i http://127.0.0.1:8787/ # 应是 302 → /panel/(不被跟随)
|
| 19 |
+
* curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器(看日志)
|
| 20 |
+
*
|
| 21 |
+
* 注意:
|
| 22 |
+
* - 本脚本会真实请求 HF Space。若 Space 正在休眠,第一次 /healthz 会等
|
| 23 |
+
* 30~60s 冷启动,这是正常现象(也正是 cron 保活要解决的问题)。
|
| 24 |
+
* - /__cron 是本地专用路径,永远不会被代理到源站,部署后的 Worker 里没有它。
|
| 25 |
+
* - 绝不把 .dev.vars 提交进版本库(见同目录 README)。
|
| 26 |
+
*/
|
| 27 |
+
|
| 28 |
+
import http from "node:http";
|
| 29 |
+
import { Readable } from "node:stream";
|
| 30 |
+
import worker from "./worker.js";
|
| 31 |
+
|
| 32 |
+
const PORT = Number(process.env.PORT || 8787);
|
| 33 |
+
const HOST = process.env.HOST || "127.0.0.1";
|
| 34 |
+
const CRON = process.env.CRON || "*/5 * * * *";
|
| 35 |
+
|
| 36 |
+
/** 把 Node 的 IncomingMessage 头转成 Web Headers。 */
|
| 37 |
+
function toWebHeaders(nodeHeaders) {
|
| 38 |
+
const h = new Headers();
|
| 39 |
+
for (const [name, value] of Object.entries(nodeHeaders)) {
|
| 40 |
+
if (value === undefined) continue;
|
| 41 |
+
if (Array.isArray(value)) {
|
| 42 |
+
for (const v of value) h.append(name, v);
|
| 43 |
+
} else {
|
| 44 |
+
h.set(name, value);
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
return h;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const server = http.createServer(async (req, res) => {
|
| 51 |
+
try {
|
| 52 |
+
exphost: {
|
| 53 |
+
// 本地专用:手动触发一次 scheduled(),用来验证保活/重试/日志逻辑。
|
| 54 |
+
if (req.url === "/__cron") {
|
| 55 |
+
console.log(`[dev] 手动触发 scheduled() cron="${CRON}"`);
|
| 56 |
+
await worker.scheduled({ cron: CRON }, process.env, {});
|
| 57 |
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
| 58 |
+
res.end(JSON.stringify({ ok: true, triggered: CRON }, null, 2) + "\n");
|
| 59 |
+
break exphost;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// 本地专用:探活,不会打到源站。
|
| 63 |
+
if (req.url === "/__ping") {
|
| 64 |
+
res.writeHead(200, { "content-type": "text/plain" });
|
| 65 |
+
res.end("dev-server alive\n");
|
| 66 |
+
break exphost;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
| 70 |
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
| 71 |
+
|
| 72 |
+
// Node 要求流式请求体显式声明 duplex:"half";Cloudflare Workers 不需要,
|
| 73 |
+
// 所以这个参数只在这里出现,worker.js 本身保持平台无关。
|
| 74 |
+
const request = new Request(url, {
|
| 75 |
+
method: req.method,
|
| 76 |
+
headers: toWebHeaders(req.headers),
|
| 77 |
+
body: hasBody ? req : undefined,
|
| 78 |
+
duplex: hasBody ? "half" : undefined,
|
| 79 |
+
redirect: "manual",
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
const response = await worker.fetch(request, process.env, {});
|
| 83 |
+
|
| 84 |
+
const outHeaders = {};
|
| 85 |
+
for (const [k, v] of response.headers) outHeaders[k] = v;
|
| 86 |
+
res.writeHead(response.status, outHeaders);
|
| 87 |
+
|
| 88 |
+
if (response.body) {
|
| 89 |
+
// 关键:流式回传,不缓冲 —— SSE 必须这样,否则流式体验当场报废。
|
| 90 |
+
await new Promise((resolve, reject) => {
|
| 91 |
+
Readable.fromWeb(response.body).pipe(res).on("finish", resolve).on("error", reject);
|
| 92 |
+
});
|
| 93 |
+
} else {
|
| 94 |
+
res.end();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.error("[dev] 处理请求失败:", err);
|
| 99 |
+
if (!res.headersSent) {
|
| 100 |
+
res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
| 101 |
+
}
|
| 102 |
+
res.end(JSON.stringify({ error: "dev_server_error", detail: String(err && err.message) }) + "\n");
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
server.listen(PORT, HOST, () => {
|
| 107 |
+
console.log(`[dev] worker.js 本地验证服务: http://${HOST}:${PORT}`);
|
| 108 |
+
console.log(`[dev] 源站 ORIGIN = ${process.env.ORIGIN || "(默认 https://a3216-gcli2api.hf.space)"}`);
|
| 109 |
+
console.log("[dev] 试试:");
|
| 110 |
+
console.log(`[dev] curl -i http://${HOST}:${PORT}/healthz`);
|
| 111 |
+
console.log(`[dev] curl -i http://${HOST}:${PORT}/`);
|
| 112 |
+
console.log(`[dev] curl -s http://${HOST}:${PORT}/__cron`);
|
| 113 |
+
});
|
deploy/cloudflare-worker/worker.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WorkBuddy2API — Cloudflare Worker:Hugging Face Space 反向代理 + 定时保活
|
| 3 |
+
*
|
| 4 |
+
* 作用有两个,缺一不可:
|
| 5 |
+
* 1. 反代:把 https://cli.3216gemini.dpdns.org/* 的流量原样转发到 HF Space
|
| 6 |
+
* (默认 https://a3216-gcli2api.hf.space,可用 ORIGIN 变量覆盖),
|
| 7 |
+
* 方法 / 路径 / query / 请求体 / 端到端请求头全部保留,
|
| 8 |
+
* 响应状态码、响应头、响应体原样回传。
|
| 9 |
+
* 2. 保活:HF 免费 cpu-basic Space 连续 48 小时没有 HTTP 流量就会被暂停,
|
| 10 |
+
* 冷启动要 30–60s。scheduled() 定时打一次 /healthz 就能避免被暂停。
|
| 11 |
+
*
|
| 12 |
+
* 关键实现约束(改动前请先读懂):
|
| 13 |
+
* - 响应用 `return originResponse.body` 直接流式回传,绝不对代理响应调用
|
| 14 |
+
* .text() / .json() / .arrayBuffer()——一旦缓冲,/v1/chat/completions 的
|
| 15 |
+
* SSE 流就会退化成"憋完再吐",流式体验当场报废。
|
| 16 |
+
* - fetch 必须带 `redirect: 'manual'`。后端的 `GET /` 返回 302 到 /panel/,
|
| 17 |
+
* 这是给浏览器的,不能在 Worker 内部被跟随掉。
|
| 18 |
+
* - Host 头不能被转发(Workers 里 Host 由 URL 决定);同时去掉逐跳头,
|
| 19 |
+
* 否则 Cloudflare 会拒绝或产生"重复头"类的诡异问题。
|
| 20 |
+
* - CORS 全部透传后端的:后端(ASP.NET/Kestrel)已经返回宽松的 CORS 头,
|
| 21 |
+
* Worker 一律不要自己造 Access-Control-*,否则会出现重复头把浏览器搞挂。
|
| 22 |
+
* - set-cookie 原样透传(浏览器看的是公网域名,后端很少下发 cookie)。
|
| 23 |
+
*
|
| 24 |
+
* 部署:见同目录 README.md 与 wrangler.toml。
|
| 25 |
+
* 作者备注:本文件刻意保持零依赖、单文件,方便直接贴进已有反代 Worker。
|
| 26 |
+
*/
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* 默认源站:HF Space 的直连域名。
|
| 30 |
+
* 与 Space 名 a3216/gcli2api 对应(下划线在 HF 域名里写作连字符)。
|
| 31 |
+
*/
|
| 32 |
+
const DEFAULT_ORIGIN = "https://a3216-gcli2api.hf.space";
|
| 33 |
+
|
| 34 |
+
/** 保活探测路径:/healthz 是网关自带的探活端点,无鉴权、开销最小。 */
|
| 35 |
+
const HEALTH_PATH = "/healthz";
|
| 36 |
+
|
| 37 |
+
/** 单次保活请求的硬超时(ms)。冷启动时 503/超时都属正常,必须能快速失败。 */
|
| 38 |
+
const PING_TIMEOUT_MS = 25_000;
|
| 39 |
+
|
| 40 |
+
/** 首次失败后的重试间隔(ms)。 */
|
| 41 |
+
const PING_RETRY_DELAY_MS = 3_000;
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* 根路径跳转目标。
|
| 45 |
+
*
|
| 46 |
+
* 为什么需要这个:网关只注册了 `/panel/`,`GET /` 在它那里是 404。而现有入口
|
| 47 |
+
* `https://cli.3216gemini.dpdns.org/` 实测返回 **302 → /panel/**(实测对比:
|
| 48 |
+
* 直连 Space 的 `/` 是 404,公网域名却是 302 —— 说明这层跳转是 CF 侧加的)。
|
| 49 |
+
* 换用本 Worker 后如果不复刻,直接打开域名首页就会从"进入面板"变成"404 页"。
|
| 50 |
+
* 设为空字符串即可关掉。
|
| 51 |
+
*/
|
| 52 |
+
const DEFAULT_ROOT_REDIRECT = "/panel/";
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* 逐跳头 + 宿主相关头,禁止转发到源站,也禁止从源站回传给客户端。
|
| 56 |
+
* 取值范围:RFC 7230 的 hop-by-hop 头 + Workers 运行时自带的 cf-* 头 + host。
|
| 57 |
+
* 统一小写比较。
|
| 58 |
+
*/
|
| 59 |
+
const STRIP_HEADERS = new Set([
|
| 60 |
+
"host",
|
| 61 |
+
"connection",
|
| 62 |
+
"keep-alive",
|
| 63 |
+
"transfer-encoding",
|
| 64 |
+
"upgrade",
|
| 65 |
+
"proxy-connection",
|
| 66 |
+
"proxy-authenticate",
|
| 67 |
+
"proxy-authorization",
|
| 68 |
+
"te",
|
| 69 |
+
"trailer",
|
| 70 |
+
"http2-settings",
|
| 71 |
+
"cf-connecting-ip",
|
| 72 |
+
"cf-connecting-ipv6",
|
| 73 |
+
"cf-ipcountry",
|
| 74 |
+
"cf-ray",
|
| 75 |
+
"cf-visitor",
|
| 76 |
+
"cf-worker",
|
| 77 |
+
"cf-ew-via",
|
| 78 |
+
"cf-pseudo-ipv4",
|
| 79 |
+
"cdn-loop",
|
| 80 |
+
"x-forwarded-host",
|
| 81 |
+
"x-forwarded-proto",
|
| 82 |
+
"x-real-ip",
|
| 83 |
+
]);
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* 判断一个头名是否要被剥掉。
|
| 87 |
+
* 除白名单外,再兜掉所有 `proxy-` / `cf-` / `x-forwarded-` 前缀的头——逐跳语义
|
| 88 |
+
* 的扩展头基本都长这样,逐个列举容易漏。
|
| 89 |
+
*/
|
| 90 |
+
function shouldStripHeader(name) {
|
| 91 |
+
const lower = name.toLowerCase();
|
| 92 |
+
if (STRIP_HEADERS.has(lower)) return true;
|
| 93 |
+
if (lower.startsWith("proxy-")) return true;
|
| 94 |
+
if (lower.startsWith("cf-")) return true;
|
| 95 |
+
if (lower.startsWith("x-forwarded-")) return true;
|
| 96 |
+
return false;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/** 过滤一组 Headers,返回新的 Headers。 */
|
| 100 |
+
function sanitizeHeaders(headers) {
|
| 101 |
+
const out = new Headers();
|
| 102 |
+
for (const [name, value] of headers) {
|
| 103 |
+
if (shouldStripHeader(name)) continue;
|
| 104 |
+
// append 而不是 set:set-cookie 可能有多条,set 会互相覆盖。
|
| 105 |
+
out.append(name, value);
|
| 106 |
+
}
|
| 107 |
+
return out;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* 把入站请求的 URL 映射到源站 URL。
|
| 112 |
+
* 只保留 path + search,绝不把公网 Host 带过去——源站只认自己的域名。
|
| 113 |
+
*/
|
| 114 |
+
function buildOriginUrl(origin, requestUrl) {
|
| 115 |
+
const incoming = new URL(requestUrl);
|
| 116 |
+
const base = origin.endsWith("/") ? origin.slice(0, -1) : origin;
|
| 117 |
+
return base + incoming.pathname + incoming.search;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/** 解析 ORIGIN 变量;非法值回落到默认源站,避免整个 Worker 因配置拼错而 500。 */
|
| 121 |
+
function resolveOrigin(env) {
|
| 122 |
+
const raw = env && typeof env.ORIGIN === "string" ? env.ORIGIN.trim() : "";
|
| 123 |
+
if (!raw) return DEFAULT_ORIGIN;
|
| 124 |
+
try {
|
| 125 |
+
const parsed = new URL(raw);
|
| 126 |
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
| 127 |
+
return DEFAULT_ORIGIN;
|
| 128 |
+
}
|
| 129 |
+
return parsed.origin;
|
| 130 |
+
} catch {
|
| 131 |
+
return DEFAULT_ORIGIN;
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/** 归一化可选的唤醒/签到路径:允许 "healthz"、"//healthz"、"/healthz"。 */
|
| 136 |
+
function normalizePath(rawPath) {
|
| 137 |
+
const trimmed = String(rawPath || "").trim();
|
| 138 |
+
if (!trimmed) return "";
|
| 139 |
+
return trimmed.startsWith("/") ? trimmed : "/" + trimmed;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export default {
|
| 143 |
+
/**
|
| 144 |
+
* 全量反向代理。
|
| 145 |
+
*
|
| 146 |
+
* 注意 `fetch(request, { redirect: 'manual' })`:这里直接把 Request 对象交给
|
| 147 |
+
* Cloudflare 的 fetch,但 URL 已经被改写成源站域名。因为 Request 的 URL 是
|
| 148 |
+
* 不可变的,需要先 new Request 出来再传。
|
| 149 |
+
*/
|
| 150 |
+
async fetch(request, env, ctx) {
|
| 151 |
+
const origin = resolveOrigin(env);
|
| 152 |
+
|
| 153 |
+
// 根路径跳转:复刻现有入口的行为(见 DEFAULT_ROOT_REDIRECT 的说明)。
|
| 154 |
+
// env.ROOT_REDIRECT 可以改目标;显式设成空串则关闭,直接透传。
|
| 155 |
+
const rootRedirect =
|
| 156 |
+
env && typeof env.ROOT_REDIRECT === "string"
|
| 157 |
+
? env.ROOT_REDIRECT.trim()
|
| 158 |
+
: DEFAULT_ROOT_REDIRECT;
|
| 159 |
+
if (rootRedirect) {
|
| 160 |
+
const incoming = new URL(request.url);
|
| 161 |
+
if (incoming.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
| 162 |
+
const target = rootRedirect + incoming.search;
|
| 163 |
+
return new Response(null, {
|
| 164 |
+
status: 302,
|
| 165 |
+
headers: { location: target, "cache-control": "no-store" },
|
| 166 |
+
});
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const targetUrl = buildOriginUrl(origin, request.url);
|
| 171 |
+
|
| 172 |
+
// 构造转发请求:保留方法、请求体(stream 形式透传,不读进内存)、
|
| 173 |
+
// 过滤后的端到端请求头。Authorization / Content-Type / Accept 等全部保留。
|
| 174 |
+
const forwardHeaders = sanitizeHeaders(request.headers);
|
| 175 |
+
|
| 176 |
+
// 把访客真实 IP 补回去。上面按逐跳规则剥掉了全部 cf-*,其中就包括
|
| 177 |
+
// CF-Connecting-IP——而网关的「运行日志 / 请求归档」是按
|
| 178 |
+
// X-Forwarded-For 首段 → X-Real-IP → TCP 对端 的顺序取来源 IP 的,
|
| 179 |
+
// 三段全被剥掉后,面板里所有调用来源都会显示成 HF 边缘节点,排障时没法看。
|
| 180 |
+
// 这里在过滤之后显式补成网关认的那两个头(不要补 CF-Connecting-IP 本身,
|
| 181 |
+
// 它会在经过 HF 边缘时被覆盖掉)。
|
| 182 |
+
const visitorIP = request.headers.get("cf-connecting-ip");
|
| 183 |
+
if (visitorIP) {
|
| 184 |
+
forwardHeaders.set("x-forwarded-for", visitorIP);
|
| 185 |
+
forwardHeaders.set("x-real-ip", visitorIP);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const outbound = new Request(targetUrl, {
|
| 189 |
+
method: request.method,
|
| 190 |
+
headers: forwardHeaders,
|
| 191 |
+
// GET / HEAD 不允许带 body,其余一律流式透传。
|
| 192 |
+
body:
|
| 193 |
+
request.method === "GET" || request.method === "HEAD"
|
| 194 |
+
? undefined
|
| 195 |
+
: request.body,
|
| 196 |
+
redirect: "manual",
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
let originResponse;
|
| 200 |
+
try {
|
| 201 |
+
originResponse = await fetch(outbound);
|
| 202 |
+
} catch (err) {
|
| 203 |
+
// 源站不可达 / 握手失败。给一个明确的 502,附带便于排查的提示,
|
| 204 |
+
// 不要把它伪装成后端的错误响应。
|
| 205 |
+
const detail = err && err.message ? err.message : String(err);
|
| 206 |
+
return new Response(
|
| 207 |
+
JSON.stringify({
|
| 208 |
+
error: "bad_gateway",
|
| 209 |
+
message: "上游 Hugging Face Space 不可达(可能正在冷启动,30–60s 后重试)",
|
| 210 |
+
origin,
|
| 211 |
+
detail,
|
| 212 |
+
}) + "\n",
|
| 213 |
+
{
|
| 214 |
+
status: 502,
|
| 215 |
+
headers: { "content-type": "application/json; charset=utf-8" },
|
| 216 |
+
},
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// 响应头同样过滤逐跳头;set-cookie 原样保留(可能多条,append 保证不丢)。
|
| 221 |
+
const responseHeaders = sanitizeHeaders(originResponse.headers);
|
| 222 |
+
|
| 223 |
+
// 关键:body 直接流式回传,不做任何缓冲。SSE 就必须这样。
|
| 224 |
+
return new Response(originResponse.body, {
|
| 225 |
+
status: originResponse.status,
|
| 226 |
+
statusText: originResponse.statusText,
|
| 227 |
+
headers: responseHeaders,
|
| 228 |
+
});
|
| 229 |
+
},
|
| 230 |
+
|
| 231 |
+
/**
|
| 232 |
+
* 定时保活:击败 HF 免费 Space 的 48 小时无流量暂停。
|
| 233 |
+
*
|
| 234 |
+
* 行为:
|
| 235 |
+
* - 打 ORIGIN + /healthz(可用 env.HEALTH_PATH 覆盖);
|
| 236 |
+
* - 25s 硬超时(AbortController)——冷启动期间 503 或超时都算预期;
|
| 237 |
+
* - 失败后等 3s 重试一次;
|
| 238 |
+
* - 每次运行只打一行日志:时间 / 状态 / 耗时;
|
| 239 |
+
* - 任何异常都在内部吞掉:绝不从 scheduled 抛出(抛了也只会污染 Cron 日志)。
|
| 240 |
+
*
|
| 241 |
+
* 可选(默认关闭):当 WB2A_API_KEY 已设置、且本次 Cron 触发带了签到标记时,
|
| 242 |
+
* 额外对 env.CHECKIN_PATH 发一次带 Bearer 的 POST。
|
| 243 |
+
* 一般来说根本不必要——裸 ping /healthz 就足以阻止 Space 休眠,因为 HF 只看
|
| 244 |
+
* "有没有 HTTP 请求",不关心响应内容。只有当 Space 自身存在"需要主动调用才
|
| 245 |
+
* 会真正醒来"的业务逻辑时,才需要打开它。
|
| 246 |
+
*/
|
| 247 |
+
async scheduled(event, env, ctx) {
|
| 248 |
+
const origin = resolveOrigin(env);
|
| 249 |
+
const healthPath = normalizePath(env && env.HEALTH_PATH) || HEALTH_PATH;
|
| 250 |
+
|
| 251 |
+
const attempt = async () => {
|
| 252 |
+
const controller = new AbortController();
|
| 253 |
+
const timer = setTimeout(() => controller.abort("ping timeout"), PING_TIMEOUT_MS);
|
| 254 |
+
const startedAt = Date.now();
|
| 255 |
+
try {
|
| 256 |
+
const res = await fetch(origin + healthPath, {
|
| 257 |
+
method: "GET",
|
| 258 |
+
// 不让 Cloudflare 边缘缓存探活结果,否则 Space 可能返回 200 但其实没人打到它。
|
| 259 |
+
cache: "no-store",
|
| 260 |
+
redirect: "manual",
|
| 261 |
+
signal: controller.signal,
|
| 262 |
+
headers: {
|
| 263 |
+
"user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)",
|
| 264 |
+
accept: "*/*",
|
| 265 |
+
},
|
| 266 |
+
});
|
| 267 |
+
return { ok: true, status: res.status, ms: Date.now() - startedAt };
|
| 268 |
+
} catch (err) {
|
| 269 |
+
const detail = err && err.message ? err.message : String(err);
|
| 270 |
+
return { ok: false, status: 0, ms: Date.now() - startedAt, detail };
|
| 271 |
+
} finally {
|
| 272 |
+
clearTimeout(timer);
|
| 273 |
+
}
|
| 274 |
+
};
|
| 275 |
+
|
| 276 |
+
let result = await attempt();
|
| 277 |
+
if (!result.ok || result.status >= 500) {
|
| 278 |
+
// 503 是网关"没有可用账号可服务"的语义,对保活而言无所谓——
|
| 279 |
+
// 只要它回了 HTTP,Space 就是醒着的。但 5xx / 网络失败仍重试一次,
|
| 280 |
+
// 用来区分"偶发抖动"和"确实没醒"。
|
| 281 |
+
await new Promise((resolve) => setTimeout(resolve, PING_RETRY_DELAY_MS));
|
| 282 |
+
const retry = await attempt();
|
| 283 |
+
result = {
|
| 284 |
+
...retry,
|
| 285 |
+
// 首次失败原因在日志里一并带上,方便判断是不是冷启动。
|
| 286 |
+
firstAttempt: result.detail || `HTTP ${result.status}`,
|
| 287 |
+
};
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const stamp = new Date().toISOString();
|
| 291 |
+
if (result.ok && result.status < 500) {
|
| 292 |
+
console.log(
|
| 293 |
+
`[keepalive] ${stamp} ${healthPath} -> HTTP ${result.status} (${result.ms}ms)`,
|
| 294 |
+
);
|
| 295 |
+
} else {
|
| 296 |
+
console.warn(
|
| 297 |
+
`[keepalive] ${stamp} ${healthPath} -> FAILED status=${result.status} ` +
|
| 298 |
+
`(${result.ms}ms) first=${result.firstAttempt || "-"} detail=${result.detail || "-"}`,
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// —— 可选的带鉴权签到(默认关闭)——
|
| 303 |
+
// 开启条件三选三:设了 WB2A_API_KEY、设了 CHECKIN_PATH、且本次触发带标记。
|
| 304 |
+
// 标记来源是 wrangler.toml 里给 Cron 表达式加的注释式约定(见该文件),
|
| 305 |
+
// 这里用 event.cron 与 env.CHECKIN_CRON 匹配;不配就永远不触发。
|
| 306 |
+
const apiKey = env && env.WB2A_API_KEY;
|
| 307 |
+
const checkinPath = normalizePath(env && env.CHECKIN_PATH);
|
| 308 |
+
const cronExpr = event && typeof event.cron === "string" ? event.cron : "";
|
| 309 |
+
const checkinCron = env && env.CHECKIN_CRON ? String(env.CHECKIN_CRON) : "";
|
| 310 |
+
|
| 311 |
+
if (!apiKey || !checkinPath || !checkinCron || cronExpr !== checkinCron) {
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const controller = new AbortController();
|
| 316 |
+
const timer = setTimeout(() => controller.abort("checkin timeout"), PING_TIMEOUT_MS);
|
| 317 |
+
const startedAt = Date.now();
|
| 318 |
+
try {
|
| 319 |
+
const res = await fetch(origin + checkinPath, {
|
| 320 |
+
method: "POST",
|
| 321 |
+
cache: "no-store",
|
| 322 |
+
redirect: "manual",
|
| 323 |
+
signal: controller.signal,
|
| 324 |
+
headers: {
|
| 325 |
+
authorization: `Bearer ${apiKey}`,
|
| 326 |
+
"content-type": "application/json",
|
| 327 |
+
"user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)",
|
| 328 |
+
accept: "application/json",
|
| 329 |
+
},
|
| 330 |
+
body: "{}",
|
| 331 |
+
});
|
| 332 |
+
console.log(
|
| 333 |
+
`[checkin] ${new Date().toISOString()} ${checkinPath} -> HTTP ${res.status} ` +
|
| 334 |
+
`(${Date.now() - startedAt}ms)`,
|
| 335 |
+
);
|
| 336 |
+
} catch (err) {
|
| 337 |
+
// 同样不抛出:签到失败不该让 Cron 报错,保活本身已经完成了。
|
| 338 |
+
const detail = err && err.message ? err.message : String(err);
|
| 339 |
+
console.warn(
|
| 340 |
+
`[checkin] ${new Date().toISOString()} ${checkinPath} -> FAILED ` +
|
| 341 |
+
`(${Date.now() - startedAt}ms) detail=${detail}`,
|
| 342 |
+
);
|
| 343 |
+
} finally {
|
| 344 |
+
clearTimeout(timer);
|
| 345 |
+
}
|
| 346 |
+
},
|
| 347 |
+
};
|
deploy/cloudflare-worker/wrangler.toml
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WorkBuddy2API — Cloudflare Worker 配置
|
| 2 |
+
# 本地路径:D:\workbuddy2api-panel\deploy\cloudflare-worker\worker.js
|
| 3 |
+
#
|
| 4 |
+
# 用法:
|
| 5 |
+
# cd D:\workbuddy2api-panel\deploy\cloudflare-worker
|
| 6 |
+
# npx wrangler deploy
|
| 7 |
+
#
|
| 8 |
+
# 注意:任何 secret 一律用 `wrangler secret put` 写入,绝对不要落到本文件里。
|
| 9 |
+
# 本目录四个文件均可提交:worker.js / wrangler.toml / .dev.vars.example / README.md。
|
| 10 |
+
# `.dev.vars`、`.dev.vars.*`、`.wrangler/` 已被根目录 .gitignore 忽略
|
| 11 |
+
# (同时用 `!.dev.vars.example` 把模板放回版本库)。
|
| 12 |
+
|
| 13 |
+
name = "workbuddy2api-hf-proxy"
|
| 14 |
+
main = "worker.js"
|
| 15 |
+
|
| 16 |
+
# 兼容性日期:固定在一个已发布版本上,不要写得太新也不要写 today。
|
| 17 |
+
# 2025-01-01 已支持 Request/Response 流式透传、AbortController、
|
| 18 |
+
# `redirect: "manual"` 与 crypto/Headers 全套 API,本 Worker 只用这些。
|
| 19 |
+
compatibility_date = "2025-01-01"
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# 环境变量(非机密)
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
[vars]
|
| 25 |
+
# 源站:HF Space 的直连域名。改 Space 时只改这里,不用动 worker.js。
|
| 26 |
+
ORIGIN = "https://a3216-gcli2api.hf.space"
|
| 27 |
+
|
| 28 |
+
# 保活探测路径,默认 /healthz(网关自带探活端点,无鉴权、开销最小)。
|
| 29 |
+
# HEALTH_PATH = "/healthz"
|
| 30 |
+
|
| 31 |
+
# GET / 的跳转目标,默认 /panel/。
|
| 32 |
+
# 网关只注册了 /panel/,直连 Space 的 GET / 是 404;而公网入口
|
| 33 |
+
# cli.3216gemini.dpdns.org/ 实测返回 302 → /panel/ —— 说明这层跳转是 CF 侧加的。
|
| 34 |
+
# 本 Worker 复刻该行为,换用后首页体验不变。设成空串则关闭跳转、严格透传后端 404。
|
| 35 |
+
# ROOT_REDIRECT = "/panel/"
|
| 36 |
+
|
| 37 |
+
# --- 以下为「可选唤醒/签到」相关,默认全部注释掉 = 功能关闭 ---
|
| 38 |
+
# 只有当你确实需要「主动调用某个接口才算真正唤醒」时才打开;
|
| 39 |
+
# 单纯的防休眠只需要 /healthz 的裸 ping,不需要任何鉴权。
|
| 40 |
+
#
|
| 41 |
+
# CHECKIN_CRON:只在这个 Cron 表达式触发的运行里做签到。
|
| 42 |
+
# CHECKIN_PATH:带 Bearer 调用的路径,例如 "/panel/api/keepalive"。
|
| 43 |
+
# CHECKIN_CRON = "0 * * * *"
|
| 44 |
+
# CHECKIN_PATH = "/panel/api/keepalive"
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# 定时触发:Hugging Face 免费 cpu-basic Space 连续 48 小时无 HTTP 流量即暂停,
|
| 48 |
+
# 冷启动 30–60 秒。每 5 分钟打一次 /healthz 即可彻底避免被暂停。
|
| 49 |
+
# 5 分钟是 Cloudflare Cron Triggers 的最小粒度,取最小值最稳。
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
[triggers]
|
| 52 |
+
crons = ["*/5 * * * *"]
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# 路由:把公网入口 cli.3216gemini.dpdns.org 全部流量交给本 Worker。
|
| 56 |
+
# 该域名必须在本 Cloudflare 账号下(zone: 3216gemini.dpdns.org)。
|
| 57 |
+
#
|
| 58 |
+
# 两种接法,任选其一:
|
| 59 |
+
# A) 用 wrangler 管理路由——取消下面注释后 `wrangler deploy` 会自动建路由;
|
| 60 |
+
# B) 在 Cloudflare Dashboard → Workers → 你的 Worker → Settings → Domains &
|
| 61 |
+
# Routes 里手动添加,本文件保持注释即可(推荐,避免误删线上路由)。
|
| 62 |
+
#
|
| 63 |
+
# ⚠️ 启用路由前请确认:该主机名当前的 DNS 记录不能同时是「已代理指向别处」的
|
| 64 |
+
# A/AAAA/CNAME,否则 Worker 路由与 DNS 记录会打架。正确做法是让该主机名
|
| 65 |
+
# 由 Worker 路由接管(DNS 里通常留一条指向 100:: 的 AAAA 占位记录)。
|
| 66 |
+
#
|
| 67 |
+
# routes = [
|
| 68 |
+
# { pattern = "cli.3216gemini.dpdns.org/*", zone_name = "3216gemini.dpdns.org" }
|
| 69 |
+
# ]
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# 机密(不要写进本文件、不要提交)
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# WB2A_API_KEY —— 面板/网关的 Bearer 密钥。仅「可选签到」功能需要它。
|
| 75 |
+
# npx wrangler secret put WB2A_API_KEY
|
| 76 |
+
# 未设置时,scheduled() 只做 /healthz 裸 ping,不发送任何鉴权请求。
|
| 77 |
+
#
|
| 78 |
+
# 本地调试用 `.dev.vars`(同目录,格式 KEY="value",见 .dev.vars.example)。
|
| 79 |
+
# 该文件与 `.wrangler/` 都已被根目录 .gitignore 忽略,但仍请自行确认不要
|
| 80 |
+
# 把真实密钥提交上去;线上请用 `wrangler secret put`。
|
| 81 |
+
# WB2A_API_KEY="你的密钥"
|
docker-compose.yml
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
-
# 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。
|
| 2 |
-
services:
|
| 3 |
-
wb2api:
|
| 4 |
-
build: .
|
| 5 |
-
container_name: workbuddy2api
|
| 6 |
-
restart: unless-stopped
|
| 7 |
-
environment:
|
| 8 |
-
- TZ=Asia/Shanghai
|
| 9 |
-
ports:
|
| 10 |
-
- "7861:7861"
|
| 11 |
-
volumes:
|
| 12 |
-
# 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致:
|
| 13 |
-
# storage/config.json、storage/auths/、storage/data/
|
| 14 |
-
- ./storage:/app/storage
|
| 15 |
-
- ./storage/config.json:/app/config.json
|
|
|
|
| 1 |
+
# 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。
|
| 2 |
+
services:
|
| 3 |
+
wb2api:
|
| 4 |
+
build: .
|
| 5 |
+
container_name: workbuddy2api
|
| 6 |
+
restart: unless-stopped
|
| 7 |
+
environment:
|
| 8 |
+
- TZ=Asia/Shanghai
|
| 9 |
+
ports:
|
| 10 |
+
- "7861:7861"
|
| 11 |
+
volumes:
|
| 12 |
+
# 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致:
|
| 13 |
+
# storage/config.json、storage/auths/、storage/data/
|
| 14 |
+
- ./storage:/app/storage
|
| 15 |
+
- ./storage/config.json:/app/config.json
|
docker-entrypoint.sh
CHANGED
|
@@ -1,69 +1,69 @@
|
|
| 1 |
-
#!/bin/sh
|
| 2 |
-
# WorkBuddy2API Panel — Hugging Face Spaces 入口脚本
|
| 3 |
-
#
|
| 4 |
-
# 持久化模型:
|
| 5 |
-
# HF Storage Bucket(私有) -> 挂载到容器 /app/storage
|
| 6 |
-
# /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库)
|
| 7 |
-
# /app/storage/auths/*.json 账号凭证
|
| 8 |
-
# /app/storage/data/*.json 水位/用量/模型探测/请求归档
|
| 9 |
-
#
|
| 10 |
-
# 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。
|
| 11 |
-
# 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。
|
| 12 |
-
set -eu
|
| 13 |
-
|
| 14 |
-
PERSIST="${WB2A_PERSIST_DIR:-/app/storage}"
|
| 15 |
-
TEMPLATE=/app/config.json
|
| 16 |
-
CFG="$TEMPLATE"
|
| 17 |
-
|
| 18 |
-
echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
| 19 |
-
|
| 20 |
-
if [ -d "$PERSIST" ]; then
|
| 21 |
-
echo "[entrypoint] persistent volume detected at $PERSIST"
|
| 22 |
-
mkdir -p "$PERSIST/auths" "$PERSIST/data"
|
| 23 |
-
|
| 24 |
-
# 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的
|
| 25 |
-
if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \
|
| 26 |
-
&& mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \
|
| 27 |
-
&& rm -f "$PERSIST/.wb2a-rename-probe"; then
|
| 28 |
-
echo "[entrypoint] storage self-test: write+rename OK"
|
| 29 |
-
else
|
| 30 |
-
echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2
|
| 31 |
-
rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true
|
| 32 |
-
fi
|
| 33 |
-
|
| 34 |
-
if [ ! -f "$PERSIST/config.json" ]; then
|
| 35 |
-
if [ -f "$TEMPLATE" ]; then
|
| 36 |
-
echo "[entrypoint] no config.json on the volume -> seeding from image template"
|
| 37 |
-
cp "$TEMPLATE" "$PERSIST/config.json"
|
| 38 |
-
else
|
| 39 |
-
echo "[entrypoint] no config.json on the volume and no image template" >&2
|
| 40 |
-
fi
|
| 41 |
-
fi
|
| 42 |
-
[ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json"
|
| 43 |
-
else
|
| 44 |
-
echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2
|
| 45 |
-
mkdir -p /app/auths /app/data
|
| 46 |
-
fi
|
| 47 |
-
|
| 48 |
-
# 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。
|
| 49 |
-
# 空间是公网可达的,绝不允许以裸奔状态启动。
|
| 50 |
-
if [ -n "${WB2A_API_KEY:-}" ]; then
|
| 51 |
-
echo "[entrypoint] api_key supplied via WB2A_API_KEY env"
|
| 52 |
-
else
|
| 53 |
-
KEY=$(python3 -c 'import json,sys
|
| 54 |
-
try:
|
| 55 |
-
print(json.load(open(sys.argv[1])).get("api_key") or "")
|
| 56 |
-
except Exception:
|
| 57 |
-
print("")' "$CFG" 2>/dev/null || true)
|
| 58 |
-
if [ -z "$KEY" ]; then
|
| 59 |
-
echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2
|
| 60 |
-
echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2
|
| 61 |
-
echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2
|
| 62 |
-
exit 1
|
| 63 |
-
fi
|
| 64 |
-
echo "[entrypoint] api_key loaded from config file"
|
| 65 |
-
fi
|
| 66 |
-
|
| 67 |
-
echo "[entrypoint] config: $CFG"
|
| 68 |
-
echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)"
|
| 69 |
-
exec /app/wb2api -config "$CFG"
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
# WorkBuddy2API Panel — Hugging Face Spaces 入口脚本
|
| 3 |
+
#
|
| 4 |
+
# 持久化模型:
|
| 5 |
+
# HF Storage Bucket(私有) -> 挂载到容器 /app/storage
|
| 6 |
+
# /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库)
|
| 7 |
+
# /app/storage/auths/*.json 账号凭证
|
| 8 |
+
# /app/storage/data/*.json 水位/用量/模型探测/请求归档
|
| 9 |
+
#
|
| 10 |
+
# 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。
|
| 11 |
+
# 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。
|
| 12 |
+
set -eu
|
| 13 |
+
|
| 14 |
+
PERSIST="${WB2A_PERSIST_DIR:-/app/storage}"
|
| 15 |
+
TEMPLATE=/app/config.json
|
| 16 |
+
CFG="$TEMPLATE"
|
| 17 |
+
|
| 18 |
+
echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
| 19 |
+
|
| 20 |
+
if [ -d "$PERSIST" ]; then
|
| 21 |
+
echo "[entrypoint] persistent volume detected at $PERSIST"
|
| 22 |
+
mkdir -p "$PERSIST/auths" "$PERSIST/data"
|
| 23 |
+
|
| 24 |
+
# 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的
|
| 25 |
+
if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \
|
| 26 |
+
&& mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \
|
| 27 |
+
&& rm -f "$PERSIST/.wb2a-rename-probe"; then
|
| 28 |
+
echo "[entrypoint] storage self-test: write+rename OK"
|
| 29 |
+
else
|
| 30 |
+
echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2
|
| 31 |
+
rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
if [ ! -f "$PERSIST/config.json" ]; then
|
| 35 |
+
if [ -f "$TEMPLATE" ]; then
|
| 36 |
+
echo "[entrypoint] no config.json on the volume -> seeding from image template"
|
| 37 |
+
cp "$TEMPLATE" "$PERSIST/config.json"
|
| 38 |
+
else
|
| 39 |
+
echo "[entrypoint] no config.json on the volume and no image template" >&2
|
| 40 |
+
fi
|
| 41 |
+
fi
|
| 42 |
+
[ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json"
|
| 43 |
+
else
|
| 44 |
+
echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2
|
| 45 |
+
mkdir -p /app/auths /app/data
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
# 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。
|
| 49 |
+
# 空间是公网可达的,绝不允许以裸奔状态启动。
|
| 50 |
+
if [ -n "${WB2A_API_KEY:-}" ]; then
|
| 51 |
+
echo "[entrypoint] api_key supplied via WB2A_API_KEY env"
|
| 52 |
+
else
|
| 53 |
+
KEY=$(python3 -c 'import json,sys
|
| 54 |
+
try:
|
| 55 |
+
print(json.load(open(sys.argv[1])).get("api_key") or "")
|
| 56 |
+
except Exception:
|
| 57 |
+
print("")' "$CFG" 2>/dev/null || true)
|
| 58 |
+
if [ -z "$KEY" ]; then
|
| 59 |
+
echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2
|
| 60 |
+
echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2
|
| 61 |
+
echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2
|
| 62 |
+
exit 1
|
| 63 |
+
fi
|
| 64 |
+
echo "[entrypoint] api_key loaded from config file"
|
| 65 |
+
fi
|
| 66 |
+
|
| 67 |
+
echo "[entrypoint] config: $CFG"
|
| 68 |
+
echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)"
|
| 69 |
+
exec /app/wb2api -config "$CFG"
|
go.mod
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
module github.com/linguo2625469/workbuddy2api-panel
|
| 2 |
-
|
| 3 |
-
go 1.22.5
|
| 4 |
-
|
| 5 |
-
require (
|
| 6 |
-
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
| 7 |
-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
| 8 |
-
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
| 9 |
-
go.uber.org/atomic v1.11.0 // indirect
|
| 10 |
-
golang.org/x/sys v0.30.0 // indirect
|
| 11 |
-
)
|
|
|
|
| 1 |
+
module github.com/linguo2625469/workbuddy2api-panel
|
| 2 |
+
|
| 3 |
+
go 1.22.5
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
| 7 |
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
| 8 |
+
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
| 9 |
+
go.uber.org/atomic v1.11.0 // indirect
|
| 10 |
+
golang.org/x/sys v0.30.0 // indirect
|
| 11 |
+
)
|
go.sum
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
-
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
| 2 |
-
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
| 3 |
-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
| 4 |
-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
| 5 |
-
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
| 6 |
-
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
| 7 |
-
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
| 8 |
-
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
| 9 |
-
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
| 10 |
-
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
| 11 |
-
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
| 12 |
-
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
|
|
| 1 |
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
| 2 |
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
| 3 |
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
| 4 |
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
| 5 |
+
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
| 6 |
+
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
| 7 |
+
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
| 8 |
+
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
| 9 |
+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
| 10 |
+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
| 11 |
+
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
| 12 |
+
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
internal/auth/auth.go
CHANGED
|
@@ -1,378 +1,378 @@
|
|
| 1 |
-
// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态),
|
| 2 |
-
// 提供 refresh 后的原子写回。
|
| 3 |
-
package auth
|
| 4 |
-
|
| 5 |
-
import (
|
| 6 |
-
"encoding/json"
|
| 7 |
-
"errors"
|
| 8 |
-
"fmt"
|
| 9 |
-
"io/fs"
|
| 10 |
-
"log"
|
| 11 |
-
"os"
|
| 12 |
-
"path/filepath"
|
| 13 |
-
"strings"
|
| 14 |
-
"sync"
|
| 15 |
-
"sync/atomic"
|
| 16 |
-
"time"
|
| 17 |
-
|
| 18 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
// Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。
|
| 22 |
-
type Auth struct {
|
| 23 |
-
// mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。
|
| 24 |
-
mu sync.Mutex
|
| 25 |
-
|
| 26 |
-
AccessToken string
|
| 27 |
-
RefreshToken string
|
| 28 |
-
ExpiresAt int64 // Unix 秒
|
| 29 |
-
Domain string
|
| 30 |
-
// realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。
|
| 31 |
-
// 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。
|
| 32 |
-
//
|
| 33 |
-
// 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用
|
| 34 |
-
// 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。
|
| 35 |
-
realm string
|
| 36 |
-
UID string
|
| 37 |
-
EnterpriseID string
|
| 38 |
-
Nickname string
|
| 39 |
-
FilePath string // 来源文件;refresh 后原子写回此处
|
| 40 |
-
|
| 41 |
-
// DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。
|
| 42 |
-
// 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。
|
| 43 |
-
// 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形
|
| 44 |
-
// 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。
|
| 45 |
-
DeviceToken string
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
// Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。
|
| 49 |
-
func (a *Auth) Lock() { a.mu.Lock() }
|
| 50 |
-
|
| 51 |
-
// Unlock 释放 a.Lock 获取的锁。
|
| 52 |
-
func (a *Auth) Unlock() { a.mu.Unlock() }
|
| 53 |
-
|
| 54 |
-
// AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。
|
| 55 |
-
//
|
| 56 |
-
// 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt
|
| 57 |
-
// (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造
|
| 58 |
-
// (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models /
|
| 59 |
-
// global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发:
|
| 60 |
-
// Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关),
|
| 61 |
-
// 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的
|
| 62 |
-
// 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。
|
| 63 |
-
func (a *Auth) AccessTokenValue() string {
|
| 64 |
-
if a == nil {
|
| 65 |
-
return ""
|
| 66 |
-
}
|
| 67 |
-
a.mu.Lock()
|
| 68 |
-
defer a.mu.Unlock()
|
| 69 |
-
return a.AccessToken
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
// DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。
|
| 73 |
-
func (a *Auth) DomainValue() string {
|
| 74 |
-
if a == nil {
|
| 75 |
-
return ""
|
| 76 |
-
}
|
| 77 |
-
a.mu.Lock()
|
| 78 |
-
defer a.mu.Unlock()
|
| 79 |
-
return a.Domain
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
// RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内
|
| 83 |
-
// 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的
|
| 84 |
-
// `a.RefreshToken == ""`)必须经此取值,勿直读字段。
|
| 85 |
-
func (a *Auth) RefreshTokenValue() string {
|
| 86 |
-
if a == nil {
|
| 87 |
-
return ""
|
| 88 |
-
}
|
| 89 |
-
a.mu.Lock()
|
| 90 |
-
defer a.mu.Unlock()
|
| 91 |
-
return a.RefreshToken
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// globalEnabled 全局开关:global realm 是否路由(D5 双保险)。
|
| 95 |
-
// 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/
|
| 96 |
-
// domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭
|
| 97 |
-
// → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai
|
| 98 |
-
// 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。
|
| 99 |
-
var globalEnabled atomic.Bool
|
| 100 |
-
|
| 101 |
-
func init() { globalEnabled.Store(true) }
|
| 102 |
-
|
| 103 |
-
// SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。
|
| 104 |
-
func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) }
|
| 105 |
-
|
| 106 |
-
// GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。
|
| 107 |
-
func GlobalEnabled() bool { return globalEnabled.Load() }
|
| 108 |
-
|
| 109 |
-
// Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global",
|
| 110 |
-
// 否则 "cn"。显式 global 优先于 domain 回落(D1)。
|
| 111 |
-
// 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。
|
| 112 |
-
// 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。
|
| 113 |
-
func (a *Auth) Realm() string {
|
| 114 |
-
a.mu.Lock()
|
| 115 |
-
defer a.mu.Unlock()
|
| 116 |
-
return a.realmLocked()
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
// realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入,
|
| 120 |
-
// 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内
|
| 121 |
-
// 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。
|
| 122 |
-
func (a *Auth) realmLocked() string {
|
| 123 |
-
if !globalEnabled.Load() {
|
| 124 |
-
return "cn"
|
| 125 |
-
}
|
| 126 |
-
if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) {
|
| 127 |
-
return "global"
|
| 128 |
-
}
|
| 129 |
-
return "cn"
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
// ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断
|
| 133 |
-
// (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定);
|
| 134 |
-
// domain 也为空 → "cn"(老 CN 凭证零回归)。
|
| 135 |
-
func ResolveRealm(explicit, domain string) string {
|
| 136 |
-
if r := strings.TrimSpace(explicit); r != "" {
|
| 137 |
-
return r
|
| 138 |
-
}
|
| 139 |
-
if isGlobalDomain(domain) {
|
| 140 |
-
return "global"
|
| 141 |
-
}
|
| 142 |
-
return "cn"
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
// BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」
|
| 146 |
-
// 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。
|
| 147 |
-
//
|
| 148 |
-
// 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门
|
| 149 |
-
// (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证
|
| 150 |
-
// (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。
|
| 151 |
-
func (a *Auth) BackfillRealm() (bool, string) {
|
| 152 |
-
a.mu.Lock()
|
| 153 |
-
defer a.mu.Unlock()
|
| 154 |
-
if strings.TrimSpace(a.realm) != "" {
|
| 155 |
-
return false, a.realm
|
| 156 |
-
}
|
| 157 |
-
r := ResolveRealm("", a.Domain)
|
| 158 |
-
a.realm = r
|
| 159 |
-
return true, r
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
// RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。
|
| 163 |
-
func (a *Auth) RealmStored() string {
|
| 164 |
-
a.mu.Lock()
|
| 165 |
-
defer a.mu.Unlock()
|
| 166 |
-
return a.realm
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
// BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了
|
| 170 |
-
// global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global,
|
| 171 |
-
// 非法值报错(防写脏)。返回是否发生变更。
|
| 172 |
-
func BackfillRealmFor(a *Auth, realm string) (bool, error) {
|
| 173 |
-
if a == nil {
|
| 174 |
-
return false, fmt.Errorf("nil auth")
|
| 175 |
-
}
|
| 176 |
-
switch strings.TrimSpace(realm) {
|
| 177 |
-
case "cn", "global":
|
| 178 |
-
default:
|
| 179 |
-
return false, fmt.Errorf("realm must be cn/global, got %q", realm)
|
| 180 |
-
}
|
| 181 |
-
a.mu.Lock()
|
| 182 |
-
defer a.mu.Unlock()
|
| 183 |
-
if a.realm == realm {
|
| 184 |
-
return false, nil
|
| 185 |
-
}
|
| 186 |
-
a.realm = realm
|
| 187 |
-
return true, nil
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
// IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。
|
| 191 |
-
func (a *Auth) IsGlobal() bool { return a.Realm() == "global" }
|
| 192 |
-
|
| 193 |
-
// isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。
|
| 194 |
-
// 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。
|
| 195 |
-
func isGlobalDomain(d string) bool {
|
| 196 |
-
d = strings.ToLower(strings.TrimSpace(d))
|
| 197 |
-
return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai")
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
// NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。
|
| 201 |
-
func (a *Auth) NeedsRefresh(within time.Duration) bool {
|
| 202 |
-
a.mu.Lock()
|
| 203 |
-
defer a.mu.Unlock()
|
| 204 |
-
if a.ExpiresAt <= 0 {
|
| 205 |
-
return true
|
| 206 |
-
}
|
| 207 |
-
return time.Now().Add(within).Unix() >= a.ExpiresAt
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
// Parse 兼容两种磁盘形态:
|
| 211 |
-
//
|
| 212 |
-
// 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出)
|
| 213 |
-
// 扁平形 {"accessToken":...,"uid":...} (手写/旧版)
|
| 214 |
-
func Parse(raw []byte) (*Auth, error) {
|
| 215 |
-
if len(raw) == 0 {
|
| 216 |
-
return nil, fmt.Errorf("empty auth storage")
|
| 217 |
-
}
|
| 218 |
-
var probe map[string]json.RawMessage
|
| 219 |
-
if err := json.Unmarshal(raw, &probe); err != nil {
|
| 220 |
-
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 221 |
-
}
|
| 222 |
-
var a Auth
|
| 223 |
-
if _, nested := probe["auth"]; nested {
|
| 224 |
-
var n struct {
|
| 225 |
-
Auth struct {
|
| 226 |
-
AccessToken string `json:"accessToken"`
|
| 227 |
-
RefreshToken string `json:"refreshToken"`
|
| 228 |
-
ExpiresAt int64 `json:"expiresAt"`
|
| 229 |
-
Domain string `json:"domain"`
|
| 230 |
-
Realm string `json:"realm"`
|
| 231 |
-
} `json:"auth"`
|
| 232 |
-
Account struct {
|
| 233 |
-
UID string `json:"uid"`
|
| 234 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 235 |
-
Nickname string `json:"nickname"`
|
| 236 |
-
} `json:"account"`
|
| 237 |
-
// DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。
|
| 238 |
-
DeviceToken string `json:"device_token"`
|
| 239 |
-
}
|
| 240 |
-
if err := json.Unmarshal(raw, &n); err != nil {
|
| 241 |
-
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 242 |
-
}
|
| 243 |
-
a = Auth{
|
| 244 |
-
AccessToken: n.Auth.AccessToken,
|
| 245 |
-
RefreshToken: n.Auth.RefreshToken,
|
| 246 |
-
ExpiresAt: n.Auth.ExpiresAt,
|
| 247 |
-
Domain: n.Auth.Domain,
|
| 248 |
-
realm: n.Auth.Realm,
|
| 249 |
-
UID: n.Account.UID,
|
| 250 |
-
EnterpriseID: n.Account.EnterpriseID,
|
| 251 |
-
Nickname: n.Account.Nickname,
|
| 252 |
-
DeviceToken: n.DeviceToken,
|
| 253 |
-
}
|
| 254 |
-
} else {
|
| 255 |
-
var f struct {
|
| 256 |
-
AccessToken string `json:"accessToken"`
|
| 257 |
-
RefreshToken string `json:"refreshToken"`
|
| 258 |
-
ExpiresAt int64 `json:"expiresAt"`
|
| 259 |
-
Domain string `json:"domain"`
|
| 260 |
-
Realm string `json:"realm"`
|
| 261 |
-
UID string `json:"uid"`
|
| 262 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 263 |
-
Nickname string `json:"nickname"`
|
| 264 |
-
DeviceToken string `json:"device_token"`
|
| 265 |
-
}
|
| 266 |
-
if err := json.Unmarshal(raw, &f); err != nil {
|
| 267 |
-
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 268 |
-
}
|
| 269 |
-
a = Auth{
|
| 270 |
-
AccessToken: f.AccessToken,
|
| 271 |
-
RefreshToken: f.RefreshToken,
|
| 272 |
-
ExpiresAt: f.ExpiresAt,
|
| 273 |
-
Domain: f.Domain,
|
| 274 |
-
realm: f.Realm,
|
| 275 |
-
UID: f.UID,
|
| 276 |
-
EnterpriseID: f.EnterpriseID,
|
| 277 |
-
Nickname: f.Nickname,
|
| 278 |
-
DeviceToken: f.DeviceToken,
|
| 279 |
-
}
|
| 280 |
-
}
|
| 281 |
-
if strings.TrimSpace(a.AccessToken) == "" {
|
| 282 |
-
return nil, fmt.Errorf("parse_error: missing accessToken")
|
| 283 |
-
}
|
| 284 |
-
return &a, nil
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
// SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。
|
| 288 |
-
// 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。
|
| 289 |
-
// 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。
|
| 290 |
-
func (a *Auth) SaveAtomic() error {
|
| 291 |
-
a.mu.Lock()
|
| 292 |
-
defer a.mu.Unlock()
|
| 293 |
-
if strings.TrimSpace(a.AccessToken) == "" {
|
| 294 |
-
return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID)
|
| 295 |
-
}
|
| 296 |
-
if a.FilePath == "" {
|
| 297 |
-
return fmt.Errorf("no FilePath set")
|
| 298 |
-
}
|
| 299 |
-
doc := map[string]any{
|
| 300 |
-
"auth": map[string]any{
|
| 301 |
-
"accessToken": a.AccessToken,
|
| 302 |
-
"refreshToken": a.RefreshToken,
|
| 303 |
-
"expiresAt": a.ExpiresAt,
|
| 304 |
-
"domain": a.Domain,
|
| 305 |
-
"realm": a.realm,
|
| 306 |
-
},
|
| 307 |
-
"account": map[string]any{
|
| 308 |
-
"uid": a.UID,
|
| 309 |
-
"enterpriseId": a.EnterpriseID,
|
| 310 |
-
"nickname": a.Nickname,
|
| 311 |
-
},
|
| 312 |
-
}
|
| 313 |
-
// DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键
|
| 314 |
-
// (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。
|
| 315 |
-
if a.DeviceToken != "" {
|
| 316 |
-
doc["device_token"] = a.DeviceToken
|
| 317 |
-
}
|
| 318 |
-
raw, err := json.MarshalIndent(doc, "", " ")
|
| 319 |
-
if err != nil {
|
| 320 |
-
return err
|
| 321 |
-
}
|
| 322 |
-
tmp := a.FilePath + ".tmp"
|
| 323 |
-
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
| 324 |
-
// Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001)
|
| 325 |
-
// 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。
|
| 326 |
-
msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err)
|
| 327 |
-
if errors.Is(err, fs.ErrPermission) {
|
| 328 |
-
msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" +
|
| 329 |
-
"1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" +
|
| 330 |
-
"2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" +
|
| 331 |
-
"3) compose 设 user: \"0:0\" 以 root 运行)"
|
| 332 |
-
}
|
| 333 |
-
return errors.New(msg)
|
| 334 |
-
}
|
| 335 |
-
return os.Rename(tmp, a.FilePath)
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。
|
| 339 |
-
// 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic
|
| 340 |
-
// 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续),
|
| 341 |
-
// 避免历史 auth 目录个别文件不可写时整个服务起不来。
|
| 342 |
-
func LoadDir(dir string) ([]*Auth, error) {
|
| 343 |
-
files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json"))
|
| 344 |
-
if err != nil {
|
| 345 |
-
return nil, err
|
| 346 |
-
}
|
| 347 |
-
// seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零)
|
| 348 |
-
// 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。
|
| 349 |
-
seenUID := make(map[string]string, len(files))
|
| 350 |
-
var out []*Auth
|
| 351 |
-
for _, f := range files {
|
| 352 |
-
raw, err := os.ReadFile(f)
|
| 353 |
-
if err != nil {
|
| 354 |
-
continue
|
| 355 |
-
}
|
| 356 |
-
a, err := Parse(raw)
|
| 357 |
-
if err != nil {
|
| 358 |
-
continue
|
| 359 |
-
}
|
| 360 |
-
a.FilePath = f
|
| 361 |
-
if prev, ok := seenUID[a.UID]; ok {
|
| 362 |
-
log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)",
|
| 363 |
-
logfmt.Label(a.UID, a.Nickname), prev, f)
|
| 364 |
-
}
|
| 365 |
-
seenUID[a.UID] = f
|
| 366 |
-
if a.RealmStored() == "" {
|
| 367 |
-
if changed, r := a.BackfillRealm(); changed {
|
| 368 |
-
if err := a.SaveAtomic(); err != nil {
|
| 369 |
-
log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err)
|
| 370 |
-
} else if r == "global" {
|
| 371 |
-
log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain)
|
| 372 |
-
}
|
| 373 |
-
}
|
| 374 |
-
}
|
| 375 |
-
out = append(out, a)
|
| 376 |
-
}
|
| 377 |
-
return out, nil
|
| 378 |
-
}
|
|
|
|
| 1 |
+
// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态),
|
| 2 |
+
// 提供 refresh 后的原子写回。
|
| 3 |
+
package auth
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"encoding/json"
|
| 7 |
+
"errors"
|
| 8 |
+
"fmt"
|
| 9 |
+
"io/fs"
|
| 10 |
+
"log"
|
| 11 |
+
"os"
|
| 12 |
+
"path/filepath"
|
| 13 |
+
"strings"
|
| 14 |
+
"sync"
|
| 15 |
+
"sync/atomic"
|
| 16 |
+
"time"
|
| 17 |
+
|
| 18 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
// Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。
|
| 22 |
+
type Auth struct {
|
| 23 |
+
// mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。
|
| 24 |
+
mu sync.Mutex
|
| 25 |
+
|
| 26 |
+
AccessToken string
|
| 27 |
+
RefreshToken string
|
| 28 |
+
ExpiresAt int64 // Unix 秒
|
| 29 |
+
Domain string
|
| 30 |
+
// realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。
|
| 31 |
+
// 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。
|
| 32 |
+
//
|
| 33 |
+
// 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用
|
| 34 |
+
// 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。
|
| 35 |
+
realm string
|
| 36 |
+
UID string
|
| 37 |
+
EnterpriseID string
|
| 38 |
+
Nickname string
|
| 39 |
+
FilePath string // 来源文件;refresh 后原子写回此处
|
| 40 |
+
|
| 41 |
+
// DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。
|
| 42 |
+
// 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。
|
| 43 |
+
// 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形
|
| 44 |
+
// 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。
|
| 45 |
+
DeviceToken string
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。
|
| 49 |
+
func (a *Auth) Lock() { a.mu.Lock() }
|
| 50 |
+
|
| 51 |
+
// Unlock 释放 a.Lock 获取的锁。
|
| 52 |
+
func (a *Auth) Unlock() { a.mu.Unlock() }
|
| 53 |
+
|
| 54 |
+
// AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。
|
| 55 |
+
//
|
| 56 |
+
// 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt
|
| 57 |
+
// (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造
|
| 58 |
+
// (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models /
|
| 59 |
+
// global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发:
|
| 60 |
+
// Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关),
|
| 61 |
+
// 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的
|
| 62 |
+
// 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。
|
| 63 |
+
func (a *Auth) AccessTokenValue() string {
|
| 64 |
+
if a == nil {
|
| 65 |
+
return ""
|
| 66 |
+
}
|
| 67 |
+
a.mu.Lock()
|
| 68 |
+
defer a.mu.Unlock()
|
| 69 |
+
return a.AccessToken
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。
|
| 73 |
+
func (a *Auth) DomainValue() string {
|
| 74 |
+
if a == nil {
|
| 75 |
+
return ""
|
| 76 |
+
}
|
| 77 |
+
a.mu.Lock()
|
| 78 |
+
defer a.mu.Unlock()
|
| 79 |
+
return a.Domain
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内
|
| 83 |
+
// 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的
|
| 84 |
+
// `a.RefreshToken == ""`)必须经此取值,勿直读字段。
|
| 85 |
+
func (a *Auth) RefreshTokenValue() string {
|
| 86 |
+
if a == nil {
|
| 87 |
+
return ""
|
| 88 |
+
}
|
| 89 |
+
a.mu.Lock()
|
| 90 |
+
defer a.mu.Unlock()
|
| 91 |
+
return a.RefreshToken
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// globalEnabled 全局开关:global realm 是否路由(D5 双保险)。
|
| 95 |
+
// 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/
|
| 96 |
+
// domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭
|
| 97 |
+
// → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai
|
| 98 |
+
// 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。
|
| 99 |
+
var globalEnabled atomic.Bool
|
| 100 |
+
|
| 101 |
+
func init() { globalEnabled.Store(true) }
|
| 102 |
+
|
| 103 |
+
// SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。
|
| 104 |
+
func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) }
|
| 105 |
+
|
| 106 |
+
// GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。
|
| 107 |
+
func GlobalEnabled() bool { return globalEnabled.Load() }
|
| 108 |
+
|
| 109 |
+
// Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global",
|
| 110 |
+
// 否则 "cn"。显式 global 优先于 domain 回落(D1)。
|
| 111 |
+
// 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。
|
| 112 |
+
// 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。
|
| 113 |
+
func (a *Auth) Realm() string {
|
| 114 |
+
a.mu.Lock()
|
| 115 |
+
defer a.mu.Unlock()
|
| 116 |
+
return a.realmLocked()
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入,
|
| 120 |
+
// 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内
|
| 121 |
+
// 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。
|
| 122 |
+
func (a *Auth) realmLocked() string {
|
| 123 |
+
if !globalEnabled.Load() {
|
| 124 |
+
return "cn"
|
| 125 |
+
}
|
| 126 |
+
if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) {
|
| 127 |
+
return "global"
|
| 128 |
+
}
|
| 129 |
+
return "cn"
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断
|
| 133 |
+
// (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定);
|
| 134 |
+
// domain 也为空 → "cn"(老 CN 凭证零回归)。
|
| 135 |
+
func ResolveRealm(explicit, domain string) string {
|
| 136 |
+
if r := strings.TrimSpace(explicit); r != "" {
|
| 137 |
+
return r
|
| 138 |
+
}
|
| 139 |
+
if isGlobalDomain(domain) {
|
| 140 |
+
return "global"
|
| 141 |
+
}
|
| 142 |
+
return "cn"
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」
|
| 146 |
+
// 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。
|
| 147 |
+
//
|
| 148 |
+
// 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门
|
| 149 |
+
// (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证
|
| 150 |
+
// (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。
|
| 151 |
+
func (a *Auth) BackfillRealm() (bool, string) {
|
| 152 |
+
a.mu.Lock()
|
| 153 |
+
defer a.mu.Unlock()
|
| 154 |
+
if strings.TrimSpace(a.realm) != "" {
|
| 155 |
+
return false, a.realm
|
| 156 |
+
}
|
| 157 |
+
r := ResolveRealm("", a.Domain)
|
| 158 |
+
a.realm = r
|
| 159 |
+
return true, r
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。
|
| 163 |
+
func (a *Auth) RealmStored() string {
|
| 164 |
+
a.mu.Lock()
|
| 165 |
+
defer a.mu.Unlock()
|
| 166 |
+
return a.realm
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了
|
| 170 |
+
// global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global,
|
| 171 |
+
// 非法值报错(防写脏)。返回是否发生变更。
|
| 172 |
+
func BackfillRealmFor(a *Auth, realm string) (bool, error) {
|
| 173 |
+
if a == nil {
|
| 174 |
+
return false, fmt.Errorf("nil auth")
|
| 175 |
+
}
|
| 176 |
+
switch strings.TrimSpace(realm) {
|
| 177 |
+
case "cn", "global":
|
| 178 |
+
default:
|
| 179 |
+
return false, fmt.Errorf("realm must be cn/global, got %q", realm)
|
| 180 |
+
}
|
| 181 |
+
a.mu.Lock()
|
| 182 |
+
defer a.mu.Unlock()
|
| 183 |
+
if a.realm == realm {
|
| 184 |
+
return false, nil
|
| 185 |
+
}
|
| 186 |
+
a.realm = realm
|
| 187 |
+
return true, nil
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。
|
| 191 |
+
func (a *Auth) IsGlobal() bool { return a.Realm() == "global" }
|
| 192 |
+
|
| 193 |
+
// isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。
|
| 194 |
+
// 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。
|
| 195 |
+
func isGlobalDomain(d string) bool {
|
| 196 |
+
d = strings.ToLower(strings.TrimSpace(d))
|
| 197 |
+
return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai")
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。
|
| 201 |
+
func (a *Auth) NeedsRefresh(within time.Duration) bool {
|
| 202 |
+
a.mu.Lock()
|
| 203 |
+
defer a.mu.Unlock()
|
| 204 |
+
if a.ExpiresAt <= 0 {
|
| 205 |
+
return true
|
| 206 |
+
}
|
| 207 |
+
return time.Now().Add(within).Unix() >= a.ExpiresAt
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// Parse 兼容两种磁盘形态:
|
| 211 |
+
//
|
| 212 |
+
// 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出)
|
| 213 |
+
// 扁平形 {"accessToken":...,"uid":...} (手写/旧版)
|
| 214 |
+
func Parse(raw []byte) (*Auth, error) {
|
| 215 |
+
if len(raw) == 0 {
|
| 216 |
+
return nil, fmt.Errorf("empty auth storage")
|
| 217 |
+
}
|
| 218 |
+
var probe map[string]json.RawMessage
|
| 219 |
+
if err := json.Unmarshal(raw, &probe); err != nil {
|
| 220 |
+
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 221 |
+
}
|
| 222 |
+
var a Auth
|
| 223 |
+
if _, nested := probe["auth"]; nested {
|
| 224 |
+
var n struct {
|
| 225 |
+
Auth struct {
|
| 226 |
+
AccessToken string `json:"accessToken"`
|
| 227 |
+
RefreshToken string `json:"refreshToken"`
|
| 228 |
+
ExpiresAt int64 `json:"expiresAt"`
|
| 229 |
+
Domain string `json:"domain"`
|
| 230 |
+
Realm string `json:"realm"`
|
| 231 |
+
} `json:"auth"`
|
| 232 |
+
Account struct {
|
| 233 |
+
UID string `json:"uid"`
|
| 234 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 235 |
+
Nickname string `json:"nickname"`
|
| 236 |
+
} `json:"account"`
|
| 237 |
+
// DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。
|
| 238 |
+
DeviceToken string `json:"device_token"`
|
| 239 |
+
}
|
| 240 |
+
if err := json.Unmarshal(raw, &n); err != nil {
|
| 241 |
+
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 242 |
+
}
|
| 243 |
+
a = Auth{
|
| 244 |
+
AccessToken: n.Auth.AccessToken,
|
| 245 |
+
RefreshToken: n.Auth.RefreshToken,
|
| 246 |
+
ExpiresAt: n.Auth.ExpiresAt,
|
| 247 |
+
Domain: n.Auth.Domain,
|
| 248 |
+
realm: n.Auth.Realm,
|
| 249 |
+
UID: n.Account.UID,
|
| 250 |
+
EnterpriseID: n.Account.EnterpriseID,
|
| 251 |
+
Nickname: n.Account.Nickname,
|
| 252 |
+
DeviceToken: n.DeviceToken,
|
| 253 |
+
}
|
| 254 |
+
} else {
|
| 255 |
+
var f struct {
|
| 256 |
+
AccessToken string `json:"accessToken"`
|
| 257 |
+
RefreshToken string `json:"refreshToken"`
|
| 258 |
+
ExpiresAt int64 `json:"expiresAt"`
|
| 259 |
+
Domain string `json:"domain"`
|
| 260 |
+
Realm string `json:"realm"`
|
| 261 |
+
UID string `json:"uid"`
|
| 262 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 263 |
+
Nickname string `json:"nickname"`
|
| 264 |
+
DeviceToken string `json:"device_token"`
|
| 265 |
+
}
|
| 266 |
+
if err := json.Unmarshal(raw, &f); err != nil {
|
| 267 |
+
return nil, fmt.Errorf("storage_parse_error: %w", err)
|
| 268 |
+
}
|
| 269 |
+
a = Auth{
|
| 270 |
+
AccessToken: f.AccessToken,
|
| 271 |
+
RefreshToken: f.RefreshToken,
|
| 272 |
+
ExpiresAt: f.ExpiresAt,
|
| 273 |
+
Domain: f.Domain,
|
| 274 |
+
realm: f.Realm,
|
| 275 |
+
UID: f.UID,
|
| 276 |
+
EnterpriseID: f.EnterpriseID,
|
| 277 |
+
Nickname: f.Nickname,
|
| 278 |
+
DeviceToken: f.DeviceToken,
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
if strings.TrimSpace(a.AccessToken) == "" {
|
| 282 |
+
return nil, fmt.Errorf("parse_error: missing accessToken")
|
| 283 |
+
}
|
| 284 |
+
return &a, nil
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。
|
| 288 |
+
// 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。
|
| 289 |
+
// 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。
|
| 290 |
+
func (a *Auth) SaveAtomic() error {
|
| 291 |
+
a.mu.Lock()
|
| 292 |
+
defer a.mu.Unlock()
|
| 293 |
+
if strings.TrimSpace(a.AccessToken) == "" {
|
| 294 |
+
return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID)
|
| 295 |
+
}
|
| 296 |
+
if a.FilePath == "" {
|
| 297 |
+
return fmt.Errorf("no FilePath set")
|
| 298 |
+
}
|
| 299 |
+
doc := map[string]any{
|
| 300 |
+
"auth": map[string]any{
|
| 301 |
+
"accessToken": a.AccessToken,
|
| 302 |
+
"refreshToken": a.RefreshToken,
|
| 303 |
+
"expiresAt": a.ExpiresAt,
|
| 304 |
+
"domain": a.Domain,
|
| 305 |
+
"realm": a.realm,
|
| 306 |
+
},
|
| 307 |
+
"account": map[string]any{
|
| 308 |
+
"uid": a.UID,
|
| 309 |
+
"enterpriseId": a.EnterpriseID,
|
| 310 |
+
"nickname": a.Nickname,
|
| 311 |
+
},
|
| 312 |
+
}
|
| 313 |
+
// DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键
|
| 314 |
+
// (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。
|
| 315 |
+
if a.DeviceToken != "" {
|
| 316 |
+
doc["device_token"] = a.DeviceToken
|
| 317 |
+
}
|
| 318 |
+
raw, err := json.MarshalIndent(doc, "", " ")
|
| 319 |
+
if err != nil {
|
| 320 |
+
return err
|
| 321 |
+
}
|
| 322 |
+
tmp := a.FilePath + ".tmp"
|
| 323 |
+
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
| 324 |
+
// Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001)
|
| 325 |
+
// 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。
|
| 326 |
+
msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err)
|
| 327 |
+
if errors.Is(err, fs.ErrPermission) {
|
| 328 |
+
msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" +
|
| 329 |
+
"1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" +
|
| 330 |
+
"2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" +
|
| 331 |
+
"3) compose 设 user: \"0:0\" 以 root 运行)"
|
| 332 |
+
}
|
| 333 |
+
return errors.New(msg)
|
| 334 |
+
}
|
| 335 |
+
return os.Rename(tmp, a.FilePath)
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。
|
| 339 |
+
// 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic
|
| 340 |
+
// 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续),
|
| 341 |
+
// 避免历史 auth 目录个别文件不可写时整个服务起不来。
|
| 342 |
+
func LoadDir(dir string) ([]*Auth, error) {
|
| 343 |
+
files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json"))
|
| 344 |
+
if err != nil {
|
| 345 |
+
return nil, err
|
| 346 |
+
}
|
| 347 |
+
// seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零)
|
| 348 |
+
// 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。
|
| 349 |
+
seenUID := make(map[string]string, len(files))
|
| 350 |
+
var out []*Auth
|
| 351 |
+
for _, f := range files {
|
| 352 |
+
raw, err := os.ReadFile(f)
|
| 353 |
+
if err != nil {
|
| 354 |
+
continue
|
| 355 |
+
}
|
| 356 |
+
a, err := Parse(raw)
|
| 357 |
+
if err != nil {
|
| 358 |
+
continue
|
| 359 |
+
}
|
| 360 |
+
a.FilePath = f
|
| 361 |
+
if prev, ok := seenUID[a.UID]; ok {
|
| 362 |
+
log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)",
|
| 363 |
+
logfmt.Label(a.UID, a.Nickname), prev, f)
|
| 364 |
+
}
|
| 365 |
+
seenUID[a.UID] = f
|
| 366 |
+
if a.RealmStored() == "" {
|
| 367 |
+
if changed, r := a.BackfillRealm(); changed {
|
| 368 |
+
if err := a.SaveAtomic(); err != nil {
|
| 369 |
+
log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err)
|
| 370 |
+
} else if r == "global" {
|
| 371 |
+
log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain)
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
out = append(out, a)
|
| 376 |
+
}
|
| 377 |
+
return out, nil
|
| 378 |
+
}
|
internal/auth/auth_test.go
CHANGED
|
@@ -1,266 +1,266 @@
|
|
| 1 |
-
package auth
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"io"
|
| 5 |
-
"log"
|
| 6 |
-
"os"
|
| 7 |
-
"path/filepath"
|
| 8 |
-
"strings"
|
| 9 |
-
"testing"
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
func TestParseNested(t *testing.T) {
|
| 13 |
-
raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`)
|
| 14 |
-
sa, err := Parse(raw)
|
| 15 |
-
if err != nil {
|
| 16 |
-
t.Fatalf("nested parse err: %v", err)
|
| 17 |
-
}
|
| 18 |
-
if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 {
|
| 19 |
-
t.Errorf("tokens: %+v", sa)
|
| 20 |
-
}
|
| 21 |
-
if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" {
|
| 22 |
-
t.Errorf("account: %+v", sa)
|
| 23 |
-
}
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
func TestParseFlat(t *testing.T) {
|
| 27 |
-
raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`)
|
| 28 |
-
sa, err := Parse(raw)
|
| 29 |
-
if err != nil || sa.UID != "u2" || sa.AccessToken != "at" {
|
| 30 |
-
t.Fatalf("flat: %+v %v", sa, err)
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
func TestParseMissingToken(t *testing.T) {
|
| 35 |
-
if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil {
|
| 36 |
-
t.Fatal("want error for missing accessToken")
|
| 37 |
-
}
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
func TestSaveAtomicRoundtrip(t *testing.T) {
|
| 41 |
-
dir := t.TempDir()
|
| 42 |
-
fp := filepath.Join(dir, "workbuddy-u1.json")
|
| 43 |
-
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000,
|
| 44 |
-
UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp}
|
| 45 |
-
if err := a.SaveAtomic(); err != nil {
|
| 46 |
-
t.Fatalf("save: %v", err)
|
| 47 |
-
}
|
| 48 |
-
if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) {
|
| 49 |
-
t.Error("tmp file should not remain")
|
| 50 |
-
}
|
| 51 |
-
raw, err := os.ReadFile(fp)
|
| 52 |
-
if err != nil {
|
| 53 |
-
t.Fatalf("read: %v", err)
|
| 54 |
-
}
|
| 55 |
-
b, err := Parse(raw)
|
| 56 |
-
if err != nil {
|
| 57 |
-
t.Fatalf("reparse: %v", err)
|
| 58 |
-
}
|
| 59 |
-
if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" {
|
| 60 |
-
t.Errorf("roundtrip: %+v", b)
|
| 61 |
-
}
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
// TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载,
|
| 65 |
-
// 解析失败的文件静默跳过。
|
| 66 |
-
func TestLoadDirLoadsAllValid(t *testing.T) {
|
| 67 |
-
dir := t.TempDir()
|
| 68 |
-
cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}`
|
| 69 |
-
other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}`
|
| 70 |
-
bad := `not json`
|
| 71 |
-
os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600)
|
| 72 |
-
os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600)
|
| 73 |
-
os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600)
|
| 74 |
-
|
| 75 |
-
list, err := LoadDir(dir)
|
| 76 |
-
if err != nil {
|
| 77 |
-
t.Fatalf("load: %v", err)
|
| 78 |
-
}
|
| 79 |
-
if len(list) != 2 {
|
| 80 |
-
t.Fatalf("want 2 valid accounts, got %+v", list)
|
| 81 |
-
}
|
| 82 |
-
for _, a := range list {
|
| 83 |
-
if a.FilePath == "" {
|
| 84 |
-
t.Error("FilePath not set")
|
| 85 |
-
}
|
| 86 |
-
}
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
func TestNeedsRefresh(t *testing.T) {
|
| 90 |
-
a := &Auth{ExpiresAt: 0}
|
| 91 |
-
if !a.NeedsRefresh(0) {
|
| 92 |
-
t.Error("zero expiry should need refresh")
|
| 93 |
-
}
|
| 94 |
-
a.ExpiresAt = 9999999999
|
| 95 |
-
if a.NeedsRefresh(0) {
|
| 96 |
-
t.Error("far future should not need refresh")
|
| 97 |
-
}
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
// TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。
|
| 101 |
-
func TestParseDeviceToken(t *testing.T) {
|
| 102 |
-
nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`)
|
| 103 |
-
sa, err := Parse(nested)
|
| 104 |
-
if err != nil {
|
| 105 |
-
t.Fatalf("nested parse: %v", err)
|
| 106 |
-
}
|
| 107 |
-
if sa.DeviceToken != "dev-tok-nested" {
|
| 108 |
-
t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested")
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`)
|
| 112 |
-
fa, err := Parse(flat)
|
| 113 |
-
if err != nil {
|
| 114 |
-
t.Fatalf("flat parse: %v", err)
|
| 115 |
-
}
|
| 116 |
-
if fa.DeviceToken != "dev-tok-flat" {
|
| 117 |
-
t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat")
|
| 118 |
-
}
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
// TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。
|
| 122 |
-
func TestSaveAtomicPreservesDeviceToken(t *testing.T) {
|
| 123 |
-
dir := t.TempDir()
|
| 124 |
-
fp := filepath.Join(dir, "workbuddy-dt.json")
|
| 125 |
-
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 126 |
-
UID: "u1", DeviceToken: "persisted-tok", FilePath: fp}
|
| 127 |
-
if err := a.SaveAtomic(); err != nil {
|
| 128 |
-
t.Fatalf("save: %v", err)
|
| 129 |
-
}
|
| 130 |
-
raw, err := os.ReadFile(fp)
|
| 131 |
-
if err != nil {
|
| 132 |
-
t.Fatalf("read: %v", err)
|
| 133 |
-
}
|
| 134 |
-
b, err := Parse(raw)
|
| 135 |
-
if err != nil {
|
| 136 |
-
t.Fatalf("reparse: %v", err)
|
| 137 |
-
}
|
| 138 |
-
if b.DeviceToken != "persisted-tok" {
|
| 139 |
-
t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok")
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
// TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动
|
| 144 |
-
// backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。
|
| 145 |
-
func TestLoadDirBackfillsRealm(t *testing.T) {
|
| 146 |
-
t.Parallel()
|
| 147 |
-
dir := t.TempDir()
|
| 148 |
-
fixtures := map[string]string{
|
| 149 |
-
"workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`,
|
| 150 |
-
"workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`,
|
| 151 |
-
// 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn
|
| 152 |
-
"workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`,
|
| 153 |
-
}
|
| 154 |
-
for name, body := range fixtures {
|
| 155 |
-
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
| 156 |
-
t.Fatal(err)
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
list, err := LoadDir(dir)
|
| 161 |
-
if err != nil {
|
| 162 |
-
t.Fatalf("load: %v", err)
|
| 163 |
-
}
|
| 164 |
-
if len(list) != 3 {
|
| 165 |
-
t.Fatalf("want 3 accounts, got %d", len(list))
|
| 166 |
-
}
|
| 167 |
-
want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"}
|
| 168 |
-
for _, a := range list {
|
| 169 |
-
// 内存态已补标识
|
| 170 |
-
if got := a.RealmStored(); got != want[a.UID] {
|
| 171 |
-
t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID])
|
| 172 |
-
}
|
| 173 |
-
// 落盘文件也带 realm 键
|
| 174 |
-
raw, err := os.ReadFile(a.FilePath)
|
| 175 |
-
if err != nil {
|
| 176 |
-
t.Fatalf("read %s: %v", a.FilePath, err)
|
| 177 |
-
}
|
| 178 |
-
b, err := Parse(raw)
|
| 179 |
-
if err != nil {
|
| 180 |
-
t.Fatalf("reparse %s: %v", a.FilePath, err)
|
| 181 |
-
}
|
| 182 |
-
if got := b.RealmStored(); got != want[a.UID] {
|
| 183 |
-
t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID])
|
| 184 |
-
}
|
| 185 |
-
}
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
// TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录
|
| 189 |
-
// 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。
|
| 190 |
-
// (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。)
|
| 191 |
-
func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) {
|
| 192 |
-
t.Parallel()
|
| 193 |
-
dir := t.TempDir()
|
| 194 |
-
good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`
|
| 195 |
-
if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil {
|
| 196 |
-
t.Fatal(err)
|
| 197 |
-
}
|
| 198 |
-
// 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。
|
| 199 |
-
if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil {
|
| 200 |
-
t.Fatal(err)
|
| 201 |
-
}
|
| 202 |
-
bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}`
|
| 203 |
-
if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil {
|
| 204 |
-
t.Fatal(err)
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
list, err := LoadDir(dir)
|
| 208 |
-
if err != nil {
|
| 209 |
-
t.Fatalf("load err=%v want nil (write failure must not block startup)", err)
|
| 210 |
-
}
|
| 211 |
-
if len(list) != 2 {
|
| 212 |
-
t.Fatalf("want 2 accounts loaded, got %d", len(list))
|
| 213 |
-
}
|
| 214 |
-
// 好文件迁移成功
|
| 215 |
-
raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json"))
|
| 216 |
-
b, _ := Parse(raw)
|
| 217 |
-
if b.RealmStored() != "global" {
|
| 218 |
-
t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored())
|
| 219 |
-
}
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
// TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir
|
| 223 |
-
// 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、
|
| 224 |
-
// 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。
|
| 225 |
-
func TestLoadDirDuplicateUIDWarning(t *testing.T) {
|
| 226 |
-
dir := t.TempDir()
|
| 227 |
-
// 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...),
|
| 228 |
-
// global realm 文件在后 → 后载入者(global)胜出。
|
| 229 |
-
cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}`
|
| 230 |
-
gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}`
|
| 231 |
-
if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil {
|
| 232 |
-
t.Fatal(err)
|
| 233 |
-
}
|
| 234 |
-
if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil {
|
| 235 |
-
t.Fatal(err)
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
// 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。
|
| 239 |
-
old := log.Writer()
|
| 240 |
-
r, w, err := os.Pipe()
|
| 241 |
-
if err != nil {
|
| 242 |
-
t.Fatal(err)
|
| 243 |
-
}
|
| 244 |
-
log.SetOutput(w)
|
| 245 |
-
|
| 246 |
-
list, err := LoadDir(dir)
|
| 247 |
-
_ = w.Close()
|
| 248 |
-
raw, _ := io.ReadAll(r)
|
| 249 |
-
log.SetOutput(old)
|
| 250 |
-
|
| 251 |
-
if err != nil {
|
| 252 |
-
t.Fatalf("load err=%v", err)
|
| 253 |
-
}
|
| 254 |
-
// 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir
|
| 255 |
-
// 的 UID 键 upsert),不 panic。
|
| 256 |
-
if len(list) != 2 {
|
| 257 |
-
t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list))
|
| 258 |
-
}
|
| 259 |
-
// WARN 已触发且含两文件路径。
|
| 260 |
-
if !strings.Contains(string(raw), "WARN: uid") ||
|
| 261 |
-
!strings.Contains(string(raw), "duplicated") ||
|
| 262 |
-
!strings.Contains(string(raw), "workbuddy-a-cn.json") ||
|
| 263 |
-
!strings.Contains(string(raw), "workbuddy-z-global.json") {
|
| 264 |
-
t.Errorf("expected WARN with both paths, got output: %s", string(raw))
|
| 265 |
-
}
|
| 266 |
-
}
|
|
|
|
| 1 |
+
package auth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"io"
|
| 5 |
+
"log"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"strings"
|
| 9 |
+
"testing"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
func TestParseNested(t *testing.T) {
|
| 13 |
+
raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`)
|
| 14 |
+
sa, err := Parse(raw)
|
| 15 |
+
if err != nil {
|
| 16 |
+
t.Fatalf("nested parse err: %v", err)
|
| 17 |
+
}
|
| 18 |
+
if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 {
|
| 19 |
+
t.Errorf("tokens: %+v", sa)
|
| 20 |
+
}
|
| 21 |
+
if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" {
|
| 22 |
+
t.Errorf("account: %+v", sa)
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func TestParseFlat(t *testing.T) {
|
| 27 |
+
raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`)
|
| 28 |
+
sa, err := Parse(raw)
|
| 29 |
+
if err != nil || sa.UID != "u2" || sa.AccessToken != "at" {
|
| 30 |
+
t.Fatalf("flat: %+v %v", sa, err)
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func TestParseMissingToken(t *testing.T) {
|
| 35 |
+
if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil {
|
| 36 |
+
t.Fatal("want error for missing accessToken")
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func TestSaveAtomicRoundtrip(t *testing.T) {
|
| 41 |
+
dir := t.TempDir()
|
| 42 |
+
fp := filepath.Join(dir, "workbuddy-u1.json")
|
| 43 |
+
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000,
|
| 44 |
+
UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp}
|
| 45 |
+
if err := a.SaveAtomic(); err != nil {
|
| 46 |
+
t.Fatalf("save: %v", err)
|
| 47 |
+
}
|
| 48 |
+
if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) {
|
| 49 |
+
t.Error("tmp file should not remain")
|
| 50 |
+
}
|
| 51 |
+
raw, err := os.ReadFile(fp)
|
| 52 |
+
if err != nil {
|
| 53 |
+
t.Fatalf("read: %v", err)
|
| 54 |
+
}
|
| 55 |
+
b, err := Parse(raw)
|
| 56 |
+
if err != nil {
|
| 57 |
+
t.Fatalf("reparse: %v", err)
|
| 58 |
+
}
|
| 59 |
+
if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" {
|
| 60 |
+
t.Errorf("roundtrip: %+v", b)
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载,
|
| 65 |
+
// 解析失败的文件静默跳过。
|
| 66 |
+
func TestLoadDirLoadsAllValid(t *testing.T) {
|
| 67 |
+
dir := t.TempDir()
|
| 68 |
+
cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}`
|
| 69 |
+
other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}`
|
| 70 |
+
bad := `not json`
|
| 71 |
+
os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600)
|
| 72 |
+
os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600)
|
| 73 |
+
os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600)
|
| 74 |
+
|
| 75 |
+
list, err := LoadDir(dir)
|
| 76 |
+
if err != nil {
|
| 77 |
+
t.Fatalf("load: %v", err)
|
| 78 |
+
}
|
| 79 |
+
if len(list) != 2 {
|
| 80 |
+
t.Fatalf("want 2 valid accounts, got %+v", list)
|
| 81 |
+
}
|
| 82 |
+
for _, a := range list {
|
| 83 |
+
if a.FilePath == "" {
|
| 84 |
+
t.Error("FilePath not set")
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func TestNeedsRefresh(t *testing.T) {
|
| 90 |
+
a := &Auth{ExpiresAt: 0}
|
| 91 |
+
if !a.NeedsRefresh(0) {
|
| 92 |
+
t.Error("zero expiry should need refresh")
|
| 93 |
+
}
|
| 94 |
+
a.ExpiresAt = 9999999999
|
| 95 |
+
if a.NeedsRefresh(0) {
|
| 96 |
+
t.Error("far future should not need refresh")
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。
|
| 101 |
+
func TestParseDeviceToken(t *testing.T) {
|
| 102 |
+
nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`)
|
| 103 |
+
sa, err := Parse(nested)
|
| 104 |
+
if err != nil {
|
| 105 |
+
t.Fatalf("nested parse: %v", err)
|
| 106 |
+
}
|
| 107 |
+
if sa.DeviceToken != "dev-tok-nested" {
|
| 108 |
+
t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested")
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`)
|
| 112 |
+
fa, err := Parse(flat)
|
| 113 |
+
if err != nil {
|
| 114 |
+
t.Fatalf("flat parse: %v", err)
|
| 115 |
+
}
|
| 116 |
+
if fa.DeviceToken != "dev-tok-flat" {
|
| 117 |
+
t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat")
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。
|
| 122 |
+
func TestSaveAtomicPreservesDeviceToken(t *testing.T) {
|
| 123 |
+
dir := t.TempDir()
|
| 124 |
+
fp := filepath.Join(dir, "workbuddy-dt.json")
|
| 125 |
+
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 126 |
+
UID: "u1", DeviceToken: "persisted-tok", FilePath: fp}
|
| 127 |
+
if err := a.SaveAtomic(); err != nil {
|
| 128 |
+
t.Fatalf("save: %v", err)
|
| 129 |
+
}
|
| 130 |
+
raw, err := os.ReadFile(fp)
|
| 131 |
+
if err != nil {
|
| 132 |
+
t.Fatalf("read: %v", err)
|
| 133 |
+
}
|
| 134 |
+
b, err := Parse(raw)
|
| 135 |
+
if err != nil {
|
| 136 |
+
t.Fatalf("reparse: %v", err)
|
| 137 |
+
}
|
| 138 |
+
if b.DeviceToken != "persisted-tok" {
|
| 139 |
+
t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok")
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动
|
| 144 |
+
// backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。
|
| 145 |
+
func TestLoadDirBackfillsRealm(t *testing.T) {
|
| 146 |
+
t.Parallel()
|
| 147 |
+
dir := t.TempDir()
|
| 148 |
+
fixtures := map[string]string{
|
| 149 |
+
"workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`,
|
| 150 |
+
"workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`,
|
| 151 |
+
// 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn
|
| 152 |
+
"workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`,
|
| 153 |
+
}
|
| 154 |
+
for name, body := range fixtures {
|
| 155 |
+
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
| 156 |
+
t.Fatal(err)
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
list, err := LoadDir(dir)
|
| 161 |
+
if err != nil {
|
| 162 |
+
t.Fatalf("load: %v", err)
|
| 163 |
+
}
|
| 164 |
+
if len(list) != 3 {
|
| 165 |
+
t.Fatalf("want 3 accounts, got %d", len(list))
|
| 166 |
+
}
|
| 167 |
+
want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"}
|
| 168 |
+
for _, a := range list {
|
| 169 |
+
// 内存态已补标识
|
| 170 |
+
if got := a.RealmStored(); got != want[a.UID] {
|
| 171 |
+
t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID])
|
| 172 |
+
}
|
| 173 |
+
// 落盘文件也带 realm 键
|
| 174 |
+
raw, err := os.ReadFile(a.FilePath)
|
| 175 |
+
if err != nil {
|
| 176 |
+
t.Fatalf("read %s: %v", a.FilePath, err)
|
| 177 |
+
}
|
| 178 |
+
b, err := Parse(raw)
|
| 179 |
+
if err != nil {
|
| 180 |
+
t.Fatalf("reparse %s: %v", a.FilePath, err)
|
| 181 |
+
}
|
| 182 |
+
if got := b.RealmStored(); got != want[a.UID] {
|
| 183 |
+
t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID])
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录
|
| 189 |
+
// 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。
|
| 190 |
+
// (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。)
|
| 191 |
+
func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) {
|
| 192 |
+
t.Parallel()
|
| 193 |
+
dir := t.TempDir()
|
| 194 |
+
good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`
|
| 195 |
+
if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil {
|
| 196 |
+
t.Fatal(err)
|
| 197 |
+
}
|
| 198 |
+
// 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。
|
| 199 |
+
if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil {
|
| 200 |
+
t.Fatal(err)
|
| 201 |
+
}
|
| 202 |
+
bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}`
|
| 203 |
+
if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil {
|
| 204 |
+
t.Fatal(err)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
list, err := LoadDir(dir)
|
| 208 |
+
if err != nil {
|
| 209 |
+
t.Fatalf("load err=%v want nil (write failure must not block startup)", err)
|
| 210 |
+
}
|
| 211 |
+
if len(list) != 2 {
|
| 212 |
+
t.Fatalf("want 2 accounts loaded, got %d", len(list))
|
| 213 |
+
}
|
| 214 |
+
// 好文件迁移成功
|
| 215 |
+
raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json"))
|
| 216 |
+
b, _ := Parse(raw)
|
| 217 |
+
if b.RealmStored() != "global" {
|
| 218 |
+
t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored())
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir
|
| 223 |
+
// 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、
|
| 224 |
+
// 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。
|
| 225 |
+
func TestLoadDirDuplicateUIDWarning(t *testing.T) {
|
| 226 |
+
dir := t.TempDir()
|
| 227 |
+
// 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...),
|
| 228 |
+
// global realm 文件在后 → 后载入者(global)胜出。
|
| 229 |
+
cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}`
|
| 230 |
+
gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}`
|
| 231 |
+
if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil {
|
| 232 |
+
t.Fatal(err)
|
| 233 |
+
}
|
| 234 |
+
if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil {
|
| 235 |
+
t.Fatal(err)
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。
|
| 239 |
+
old := log.Writer()
|
| 240 |
+
r, w, err := os.Pipe()
|
| 241 |
+
if err != nil {
|
| 242 |
+
t.Fatal(err)
|
| 243 |
+
}
|
| 244 |
+
log.SetOutput(w)
|
| 245 |
+
|
| 246 |
+
list, err := LoadDir(dir)
|
| 247 |
+
_ = w.Close()
|
| 248 |
+
raw, _ := io.ReadAll(r)
|
| 249 |
+
log.SetOutput(old)
|
| 250 |
+
|
| 251 |
+
if err != nil {
|
| 252 |
+
t.Fatalf("load err=%v", err)
|
| 253 |
+
}
|
| 254 |
+
// 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir
|
| 255 |
+
// 的 UID 键 upsert),不 panic。
|
| 256 |
+
if len(list) != 2 {
|
| 257 |
+
t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list))
|
| 258 |
+
}
|
| 259 |
+
// WARN 已触发且含两文件路径。
|
| 260 |
+
if !strings.Contains(string(raw), "WARN: uid") ||
|
| 261 |
+
!strings.Contains(string(raw), "duplicated") ||
|
| 262 |
+
!strings.Contains(string(raw), "workbuddy-a-cn.json") ||
|
| 263 |
+
!strings.Contains(string(raw), "workbuddy-z-global.json") {
|
| 264 |
+
t.Errorf("expected WARN with both paths, got output: %s", string(raw))
|
| 265 |
+
}
|
| 266 |
+
}
|
internal/auth/permhint_test.go
CHANGED
|
@@ -1,29 +1,29 @@
|
|
| 1 |
-
package auth
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"os"
|
| 5 |
-
"path/filepath"
|
| 6 |
-
"runtime"
|
| 7 |
-
"strings"
|
| 8 |
-
"testing"
|
| 9 |
-
)
|
| 10 |
-
|
| 11 |
-
// TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。
|
| 12 |
-
func TestSaveAtomicPermissionHint(t *testing.T) {
|
| 13 |
-
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
|
| 14 |
-
t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过")
|
| 15 |
-
}
|
| 16 |
-
dir := t.TempDir()
|
| 17 |
-
ro := filepath.Join(dir, "ro")
|
| 18 |
-
os.MkdirAll(ro, 0o555) // 只读目录
|
| 19 |
-
defer os.Chmod(ro, 0o755)
|
| 20 |
-
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 21 |
-
UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")}
|
| 22 |
-
err := a.SaveAtomic()
|
| 23 |
-
if err == nil {
|
| 24 |
-
t.Fatal("只读目录保存应失败")
|
| 25 |
-
}
|
| 26 |
-
if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") {
|
| 27 |
-
t.Errorf("权限错误应包含 Docker 指引,实际: %v", err)
|
| 28 |
-
}
|
| 29 |
-
}
|
|
|
|
| 1 |
+
package auth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"runtime"
|
| 7 |
+
"strings"
|
| 8 |
+
"testing"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。
|
| 12 |
+
func TestSaveAtomicPermissionHint(t *testing.T) {
|
| 13 |
+
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
|
| 14 |
+
t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过")
|
| 15 |
+
}
|
| 16 |
+
dir := t.TempDir()
|
| 17 |
+
ro := filepath.Join(dir, "ro")
|
| 18 |
+
os.MkdirAll(ro, 0o555) // 只读目录
|
| 19 |
+
defer os.Chmod(ro, 0o755)
|
| 20 |
+
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 21 |
+
UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")}
|
| 22 |
+
err := a.SaveAtomic()
|
| 23 |
+
if err == nil {
|
| 24 |
+
t.Fatal("只读目录保存应失败")
|
| 25 |
+
}
|
| 26 |
+
if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") {
|
| 27 |
+
t.Errorf("权限错误应包含 Docker 指引,实际: %v", err)
|
| 28 |
+
}
|
| 29 |
+
}
|
internal/auth/realm_test.go
CHANGED
|
@@ -1,275 +1,275 @@
|
|
| 1 |
-
package auth
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"os"
|
| 5 |
-
"path/filepath"
|
| 6 |
-
"testing"
|
| 7 |
-
)
|
| 8 |
-
|
| 9 |
-
// withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保),
|
| 10 |
-
// 测试结束复位为开启态(缺省)。
|
| 11 |
-
func withGlobalEnabled(t *testing.T) {
|
| 12 |
-
t.Helper()
|
| 13 |
-
globalEnabled.Store(true)
|
| 14 |
-
t.Cleanup(func() { globalEnabled.Store(true) })
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
// withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。
|
| 18 |
-
func withGlobalDisabled(t *testing.T) {
|
| 19 |
-
t.Helper()
|
| 20 |
-
globalEnabled.Store(false)
|
| 21 |
-
t.Cleanup(func() { globalEnabled.Store(true) })
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
func TestRealmExplicitGlobal(t *testing.T) {
|
| 25 |
-
withGlobalEnabled(t)
|
| 26 |
-
a := &Auth{realm: "global"}
|
| 27 |
-
if got := a.Realm(); got != "global" {
|
| 28 |
-
t.Errorf("Realm()=%q want global", got)
|
| 29 |
-
}
|
| 30 |
-
if !a.IsGlobal() {
|
| 31 |
-
t.Error("IsGlobal()=false want true")
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
func TestRealmExplicitCN(t *testing.T) {
|
| 36 |
-
a := &Auth{realm: "cn"}
|
| 37 |
-
if got := a.Realm(); got != "cn" {
|
| 38 |
-
t.Errorf("Realm()=%q want cn", got)
|
| 39 |
-
}
|
| 40 |
-
if a.IsGlobal() {
|
| 41 |
-
t.Error("IsGlobal()=true want false")
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
func TestRealmDomainFallback(t *testing.T) {
|
| 46 |
-
withGlobalEnabled(t)
|
| 47 |
-
cases := []struct{ domain, want string }{
|
| 48 |
-
{"www.workbuddy.ai", "global"},
|
| 49 |
-
{"workbuddy.ai", "global"},
|
| 50 |
-
{"sub.workbuddy.ai", "global"},
|
| 51 |
-
{"www.codebuddy.cn", "cn"},
|
| 52 |
-
{"", "cn"},
|
| 53 |
-
}
|
| 54 |
-
for _, c := range cases {
|
| 55 |
-
a := &Auth{Domain: c.domain}
|
| 56 |
-
if got := a.Realm(); got != c.want {
|
| 57 |
-
t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want)
|
| 58 |
-
}
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
func TestRealmEmptyFallsBackToCN(t *testing.T) {
|
| 63 |
-
// 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。
|
| 64 |
-
a := &Auth{}
|
| 65 |
-
if got := a.Realm(); got != "cn" {
|
| 66 |
-
t.Errorf("Realm()=%q want cn", got)
|
| 67 |
-
}
|
| 68 |
-
// 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。
|
| 69 |
-
ag := &Auth{realm: "global"}
|
| 70 |
-
if got := ag.Realm(); got != "global" {
|
| 71 |
-
t.Errorf("Realm()=%q want global", got)
|
| 72 |
-
}
|
| 73 |
-
// domain 回落照常(缺省开启识别 workbuddy.ai)。
|
| 74 |
-
ad := &Auth{Domain: "www.workbuddy.ai"}
|
| 75 |
-
if got := ad.Realm(); got != "global" {
|
| 76 |
-
t.Errorf("Realm()=%q want global", got)
|
| 77 |
-
}
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
// TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain)
|
| 81 |
-
// Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。
|
| 82 |
-
func TestRealmDefaultOnForCNZeroRegression(t *testing.T) {
|
| 83 |
-
withGlobalEnabled(t)
|
| 84 |
-
cases := []*Auth{
|
| 85 |
-
{},
|
| 86 |
-
{Domain: "www.codebuddy.cn"},
|
| 87 |
-
{Domain: "codebuddy.cn"},
|
| 88 |
-
{realm: "cn"},
|
| 89 |
-
{realm: "cn", Domain: "www.codebuddy.cn"},
|
| 90 |
-
}
|
| 91 |
-
for _, a := range cases {
|
| 92 |
-
if got := a.Realm(); got != "cn" {
|
| 93 |
-
t.Errorf("%+v Realm()=%q want cn", a, got)
|
| 94 |
-
}
|
| 95 |
-
if a.IsGlobal() {
|
| 96 |
-
t.Errorf("%+v IsGlobal()=true want false", a)
|
| 97 |
-
}
|
| 98 |
-
}
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
// TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn,
|
| 102 |
-
// 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。
|
| 103 |
-
func TestRealmExplicitOffEscapeHatch(t *testing.T) {
|
| 104 |
-
withGlobalDisabled(t)
|
| 105 |
-
cases := []struct {
|
| 106 |
-
auth *Auth
|
| 107 |
-
}{
|
| 108 |
-
{&Auth{realm: "global"}},
|
| 109 |
-
{&Auth{realm: "global", Domain: "www.workbuddy.ai"}},
|
| 110 |
-
{&Auth{Domain: "www.workbuddy.ai"}},
|
| 111 |
-
}
|
| 112 |
-
for _, tc := range cases {
|
| 113 |
-
if got := tc.auth.Realm(); got != "cn" {
|
| 114 |
-
t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got)
|
| 115 |
-
}
|
| 116 |
-
if tc.auth.IsGlobal() {
|
| 117 |
-
t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth)
|
| 118 |
-
}
|
| 119 |
-
}
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
func TestParseNestedRealm(t *testing.T) {
|
| 123 |
-
raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`)
|
| 124 |
-
sa, err := Parse(raw)
|
| 125 |
-
if err != nil {
|
| 126 |
-
t.Fatalf("nested parse err: %v", err)
|
| 127 |
-
}
|
| 128 |
-
if sa.realm != "global" {
|
| 129 |
-
t.Errorf("nested realm=%q want global", sa.realm)
|
| 130 |
-
}
|
| 131 |
-
// 嵌套形显式 realm 空 → 读不到,靠 domain 回落。
|
| 132 |
-
raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`)
|
| 133 |
-
sa2, err := Parse(raw2)
|
| 134 |
-
if err != nil {
|
| 135 |
-
t.Fatalf("nested no-realm parse err: %v", err)
|
| 136 |
-
}
|
| 137 |
-
if sa2.realm != "" {
|
| 138 |
-
t.Errorf("nested missing realm key should be zero, got %q", sa2.realm)
|
| 139 |
-
}
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
func TestParseFlatRealm(t *testing.T) {
|
| 143 |
-
withGlobalEnabled(t)
|
| 144 |
-
raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`)
|
| 145 |
-
fa, err := Parse(raw)
|
| 146 |
-
if err != nil {
|
| 147 |
-
t.Fatalf("flat parse err: %v", err)
|
| 148 |
-
}
|
| 149 |
-
if fa.realm != "global" {
|
| 150 |
-
t.Errorf("flat realm=%q want global", fa.realm)
|
| 151 |
-
}
|
| 152 |
-
if !fa.IsGlobal() {
|
| 153 |
-
t.Error("flat global account IsGlobal()=false want true")
|
| 154 |
-
}
|
| 155 |
-
// 扁平形缺 realm 键 → 零值 → CN。
|
| 156 |
-
flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`)
|
| 157 |
-
fc, err := Parse(flatCN)
|
| 158 |
-
if err != nil {
|
| 159 |
-
t.Fatalf("flat cn parse err: %v", err)
|
| 160 |
-
}
|
| 161 |
-
if fc.realm != "" {
|
| 162 |
-
t.Errorf("flat missing realm should be zero, got %q", fc.realm)
|
| 163 |
-
}
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
func TestSaveAtomicWritesRealm(t *testing.T) {
|
| 167 |
-
withGlobalEnabled(t)
|
| 168 |
-
dir := t.TempDir()
|
| 169 |
-
fp := filepath.Join(dir, "workbuddy-global.json")
|
| 170 |
-
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 171 |
-
UID: "g1", realm: "global", FilePath: fp}
|
| 172 |
-
if err := a.SaveAtomic(); err != nil {
|
| 173 |
-
t.Fatalf("save: %v", err)
|
| 174 |
-
}
|
| 175 |
-
raw, err := os.ReadFile(fp)
|
| 176 |
-
if err != nil {
|
| 177 |
-
t.Fatalf("read: %v", err)
|
| 178 |
-
}
|
| 179 |
-
b, err := Parse(raw)
|
| 180 |
-
if err != nil {
|
| 181 |
-
t.Fatalf("reparse: %v", err)
|
| 182 |
-
}
|
| 183 |
-
if b.realm != "global" {
|
| 184 |
-
t.Errorf("roundtrip realm=%q want global", b.realm)
|
| 185 |
-
}
|
| 186 |
-
if !b.IsGlobal() {
|
| 187 |
-
t.Error("roundtrip global account IsGlobal()=false")
|
| 188 |
-
}
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
// TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。
|
| 192 |
-
func TestBackfillRealmDomain(t *testing.T) {
|
| 193 |
-
t.Parallel() // 不触碰全局开关
|
| 194 |
-
cases := []struct {
|
| 195 |
-
domain string
|
| 196 |
-
want string
|
| 197 |
-
}{
|
| 198 |
-
{"www.workbuddy.ai", "global"},
|
| 199 |
-
{"workbuddy.ai", "global"},
|
| 200 |
-
{"sub.workbuddy.ai", "global"},
|
| 201 |
-
{"www.codebuddy.cn", "cn"},
|
| 202 |
-
{"codebuddy.cn", "cn"},
|
| 203 |
-
{"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心)
|
| 204 |
-
}
|
| 205 |
-
for _, c := range cases {
|
| 206 |
-
a := &Auth{Domain: c.domain}
|
| 207 |
-
changed, got := a.BackfillRealm()
|
| 208 |
-
if !changed {
|
| 209 |
-
t.Errorf("Domain=%q backfill changed=false want true", c.domain)
|
| 210 |
-
}
|
| 211 |
-
assertRealmStored(a, c.want, t)
|
| 212 |
-
if got != c.want {
|
| 213 |
-
t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want)
|
| 214 |
-
}
|
| 215 |
-
}
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
// TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。
|
| 219 |
-
func TestBackfillRealmEscapeHatchFree(t *testing.T) {
|
| 220 |
-
withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染
|
| 221 |
-
a := &Auth{Domain: "www.workbuddy.ai"}
|
| 222 |
-
if g := a.Realm(); g != "cn" {
|
| 223 |
-
t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g)
|
| 224 |
-
}
|
| 225 |
-
changed, got := a.BackfillRealm()
|
| 226 |
-
if !changed {
|
| 227 |
-
t.Fatal("backfill changed=false want true (escape hatch free)")
|
| 228 |
-
}
|
| 229 |
-
assertRealmStored(a, "global", t)
|
| 230 |
-
if got != "global" {
|
| 231 |
-
t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got)
|
| 232 |
-
}
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
// TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。
|
| 236 |
-
func TestBackfillRealmIdempotent(t *testing.T) {
|
| 237 |
-
t.Parallel()
|
| 238 |
-
a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖
|
| 239 |
-
changed, got := a.BackfillRealm()
|
| 240 |
-
if changed {
|
| 241 |
-
t.Errorf("backfill changed=true want false (existing realm must win)")
|
| 242 |
-
}
|
| 243 |
-
if got != "cn" {
|
| 244 |
-
t.Errorf("backfill got=%q want cn (existing realm preserved)", got)
|
| 245 |
-
}
|
| 246 |
-
assertRealmStored(a, "cn", t)
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
func assertRealmStored(a *Auth, want string, t *testing.T) {
|
| 250 |
-
t.Helper()
|
| 251 |
-
if a.realm != want {
|
| 252 |
-
t.Errorf("stored realm field=%q want %q", a.realm, want)
|
| 253 |
-
}
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
// TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm
|
| 257 |
-
// 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。
|
| 258 |
-
func TestResolveRealm(t *testing.T) {
|
| 259 |
-
t.Parallel() // 纯函数:不触碰全局开关
|
| 260 |
-
cases := []struct {
|
| 261 |
-
explicit, domain, want string
|
| 262 |
-
}{
|
| 263 |
-
{"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global
|
| 264 |
-
{"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn
|
| 265 |
-
{"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断
|
| 266 |
-
{"", "workbuddy.ai", "global"},
|
| 267 |
-
{"", "codebuddy.cn", "cn"},
|
| 268 |
-
{"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归)
|
| 269 |
-
}
|
| 270 |
-
for _, c := range cases {
|
| 271 |
-
if got := ResolveRealm(c.explicit, c.domain); got != c.want {
|
| 272 |
-
t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want)
|
| 273 |
-
}
|
| 274 |
-
}
|
| 275 |
-
}
|
|
|
|
| 1 |
+
package auth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"testing"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保),
|
| 10 |
+
// 测试结束复位为开启态(缺省)。
|
| 11 |
+
func withGlobalEnabled(t *testing.T) {
|
| 12 |
+
t.Helper()
|
| 13 |
+
globalEnabled.Store(true)
|
| 14 |
+
t.Cleanup(func() { globalEnabled.Store(true) })
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。
|
| 18 |
+
func withGlobalDisabled(t *testing.T) {
|
| 19 |
+
t.Helper()
|
| 20 |
+
globalEnabled.Store(false)
|
| 21 |
+
t.Cleanup(func() { globalEnabled.Store(true) })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func TestRealmExplicitGlobal(t *testing.T) {
|
| 25 |
+
withGlobalEnabled(t)
|
| 26 |
+
a := &Auth{realm: "global"}
|
| 27 |
+
if got := a.Realm(); got != "global" {
|
| 28 |
+
t.Errorf("Realm()=%q want global", got)
|
| 29 |
+
}
|
| 30 |
+
if !a.IsGlobal() {
|
| 31 |
+
t.Error("IsGlobal()=false want true")
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func TestRealmExplicitCN(t *testing.T) {
|
| 36 |
+
a := &Auth{realm: "cn"}
|
| 37 |
+
if got := a.Realm(); got != "cn" {
|
| 38 |
+
t.Errorf("Realm()=%q want cn", got)
|
| 39 |
+
}
|
| 40 |
+
if a.IsGlobal() {
|
| 41 |
+
t.Error("IsGlobal()=true want false")
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func TestRealmDomainFallback(t *testing.T) {
|
| 46 |
+
withGlobalEnabled(t)
|
| 47 |
+
cases := []struct{ domain, want string }{
|
| 48 |
+
{"www.workbuddy.ai", "global"},
|
| 49 |
+
{"workbuddy.ai", "global"},
|
| 50 |
+
{"sub.workbuddy.ai", "global"},
|
| 51 |
+
{"www.codebuddy.cn", "cn"},
|
| 52 |
+
{"", "cn"},
|
| 53 |
+
}
|
| 54 |
+
for _, c := range cases {
|
| 55 |
+
a := &Auth{Domain: c.domain}
|
| 56 |
+
if got := a.Realm(); got != c.want {
|
| 57 |
+
t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
func TestRealmEmptyFallsBackToCN(t *testing.T) {
|
| 63 |
+
// 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。
|
| 64 |
+
a := &Auth{}
|
| 65 |
+
if got := a.Realm(); got != "cn" {
|
| 66 |
+
t.Errorf("Realm()=%q want cn", got)
|
| 67 |
+
}
|
| 68 |
+
// 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。
|
| 69 |
+
ag := &Auth{realm: "global"}
|
| 70 |
+
if got := ag.Realm(); got != "global" {
|
| 71 |
+
t.Errorf("Realm()=%q want global", got)
|
| 72 |
+
}
|
| 73 |
+
// domain 回落照常(缺省开启识别 workbuddy.ai)。
|
| 74 |
+
ad := &Auth{Domain: "www.workbuddy.ai"}
|
| 75 |
+
if got := ad.Realm(); got != "global" {
|
| 76 |
+
t.Errorf("Realm()=%q want global", got)
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain)
|
| 81 |
+
// Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。
|
| 82 |
+
func TestRealmDefaultOnForCNZeroRegression(t *testing.T) {
|
| 83 |
+
withGlobalEnabled(t)
|
| 84 |
+
cases := []*Auth{
|
| 85 |
+
{},
|
| 86 |
+
{Domain: "www.codebuddy.cn"},
|
| 87 |
+
{Domain: "codebuddy.cn"},
|
| 88 |
+
{realm: "cn"},
|
| 89 |
+
{realm: "cn", Domain: "www.codebuddy.cn"},
|
| 90 |
+
}
|
| 91 |
+
for _, a := range cases {
|
| 92 |
+
if got := a.Realm(); got != "cn" {
|
| 93 |
+
t.Errorf("%+v Realm()=%q want cn", a, got)
|
| 94 |
+
}
|
| 95 |
+
if a.IsGlobal() {
|
| 96 |
+
t.Errorf("%+v IsGlobal()=true want false", a)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn,
|
| 102 |
+
// 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。
|
| 103 |
+
func TestRealmExplicitOffEscapeHatch(t *testing.T) {
|
| 104 |
+
withGlobalDisabled(t)
|
| 105 |
+
cases := []struct {
|
| 106 |
+
auth *Auth
|
| 107 |
+
}{
|
| 108 |
+
{&Auth{realm: "global"}},
|
| 109 |
+
{&Auth{realm: "global", Domain: "www.workbuddy.ai"}},
|
| 110 |
+
{&Auth{Domain: "www.workbuddy.ai"}},
|
| 111 |
+
}
|
| 112 |
+
for _, tc := range cases {
|
| 113 |
+
if got := tc.auth.Realm(); got != "cn" {
|
| 114 |
+
t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got)
|
| 115 |
+
}
|
| 116 |
+
if tc.auth.IsGlobal() {
|
| 117 |
+
t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth)
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
func TestParseNestedRealm(t *testing.T) {
|
| 123 |
+
raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`)
|
| 124 |
+
sa, err := Parse(raw)
|
| 125 |
+
if err != nil {
|
| 126 |
+
t.Fatalf("nested parse err: %v", err)
|
| 127 |
+
}
|
| 128 |
+
if sa.realm != "global" {
|
| 129 |
+
t.Errorf("nested realm=%q want global", sa.realm)
|
| 130 |
+
}
|
| 131 |
+
// 嵌套形显式 realm 空 → 读不到,靠 domain 回落。
|
| 132 |
+
raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`)
|
| 133 |
+
sa2, err := Parse(raw2)
|
| 134 |
+
if err != nil {
|
| 135 |
+
t.Fatalf("nested no-realm parse err: %v", err)
|
| 136 |
+
}
|
| 137 |
+
if sa2.realm != "" {
|
| 138 |
+
t.Errorf("nested missing realm key should be zero, got %q", sa2.realm)
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
func TestParseFlatRealm(t *testing.T) {
|
| 143 |
+
withGlobalEnabled(t)
|
| 144 |
+
raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`)
|
| 145 |
+
fa, err := Parse(raw)
|
| 146 |
+
if err != nil {
|
| 147 |
+
t.Fatalf("flat parse err: %v", err)
|
| 148 |
+
}
|
| 149 |
+
if fa.realm != "global" {
|
| 150 |
+
t.Errorf("flat realm=%q want global", fa.realm)
|
| 151 |
+
}
|
| 152 |
+
if !fa.IsGlobal() {
|
| 153 |
+
t.Error("flat global account IsGlobal()=false want true")
|
| 154 |
+
}
|
| 155 |
+
// 扁平形缺 realm 键 → 零值 → CN。
|
| 156 |
+
flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`)
|
| 157 |
+
fc, err := Parse(flatCN)
|
| 158 |
+
if err != nil {
|
| 159 |
+
t.Fatalf("flat cn parse err: %v", err)
|
| 160 |
+
}
|
| 161 |
+
if fc.realm != "" {
|
| 162 |
+
t.Errorf("flat missing realm should be zero, got %q", fc.realm)
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
func TestSaveAtomicWritesRealm(t *testing.T) {
|
| 167 |
+
withGlobalEnabled(t)
|
| 168 |
+
dir := t.TempDir()
|
| 169 |
+
fp := filepath.Join(dir, "workbuddy-global.json")
|
| 170 |
+
a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
|
| 171 |
+
UID: "g1", realm: "global", FilePath: fp}
|
| 172 |
+
if err := a.SaveAtomic(); err != nil {
|
| 173 |
+
t.Fatalf("save: %v", err)
|
| 174 |
+
}
|
| 175 |
+
raw, err := os.ReadFile(fp)
|
| 176 |
+
if err != nil {
|
| 177 |
+
t.Fatalf("read: %v", err)
|
| 178 |
+
}
|
| 179 |
+
b, err := Parse(raw)
|
| 180 |
+
if err != nil {
|
| 181 |
+
t.Fatalf("reparse: %v", err)
|
| 182 |
+
}
|
| 183 |
+
if b.realm != "global" {
|
| 184 |
+
t.Errorf("roundtrip realm=%q want global", b.realm)
|
| 185 |
+
}
|
| 186 |
+
if !b.IsGlobal() {
|
| 187 |
+
t.Error("roundtrip global account IsGlobal()=false")
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。
|
| 192 |
+
func TestBackfillRealmDomain(t *testing.T) {
|
| 193 |
+
t.Parallel() // 不触碰全局开关
|
| 194 |
+
cases := []struct {
|
| 195 |
+
domain string
|
| 196 |
+
want string
|
| 197 |
+
}{
|
| 198 |
+
{"www.workbuddy.ai", "global"},
|
| 199 |
+
{"workbuddy.ai", "global"},
|
| 200 |
+
{"sub.workbuddy.ai", "global"},
|
| 201 |
+
{"www.codebuddy.cn", "cn"},
|
| 202 |
+
{"codebuddy.cn", "cn"},
|
| 203 |
+
{"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心)
|
| 204 |
+
}
|
| 205 |
+
for _, c := range cases {
|
| 206 |
+
a := &Auth{Domain: c.domain}
|
| 207 |
+
changed, got := a.BackfillRealm()
|
| 208 |
+
if !changed {
|
| 209 |
+
t.Errorf("Domain=%q backfill changed=false want true", c.domain)
|
| 210 |
+
}
|
| 211 |
+
assertRealmStored(a, c.want, t)
|
| 212 |
+
if got != c.want {
|
| 213 |
+
t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want)
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。
|
| 219 |
+
func TestBackfillRealmEscapeHatchFree(t *testing.T) {
|
| 220 |
+
withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染
|
| 221 |
+
a := &Auth{Domain: "www.workbuddy.ai"}
|
| 222 |
+
if g := a.Realm(); g != "cn" {
|
| 223 |
+
t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g)
|
| 224 |
+
}
|
| 225 |
+
changed, got := a.BackfillRealm()
|
| 226 |
+
if !changed {
|
| 227 |
+
t.Fatal("backfill changed=false want true (escape hatch free)")
|
| 228 |
+
}
|
| 229 |
+
assertRealmStored(a, "global", t)
|
| 230 |
+
if got != "global" {
|
| 231 |
+
t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got)
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。
|
| 236 |
+
func TestBackfillRealmIdempotent(t *testing.T) {
|
| 237 |
+
t.Parallel()
|
| 238 |
+
a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖
|
| 239 |
+
changed, got := a.BackfillRealm()
|
| 240 |
+
if changed {
|
| 241 |
+
t.Errorf("backfill changed=true want false (existing realm must win)")
|
| 242 |
+
}
|
| 243 |
+
if got != "cn" {
|
| 244 |
+
t.Errorf("backfill got=%q want cn (existing realm preserved)", got)
|
| 245 |
+
}
|
| 246 |
+
assertRealmStored(a, "cn", t)
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
func assertRealmStored(a *Auth, want string, t *testing.T) {
|
| 250 |
+
t.Helper()
|
| 251 |
+
if a.realm != want {
|
| 252 |
+
t.Errorf("stored realm field=%q want %q", a.realm, want)
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm
|
| 257 |
+
// 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。
|
| 258 |
+
func TestResolveRealm(t *testing.T) {
|
| 259 |
+
t.Parallel() // 纯函数:不触碰全局开关
|
| 260 |
+
cases := []struct {
|
| 261 |
+
explicit, domain, want string
|
| 262 |
+
}{
|
| 263 |
+
{"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global
|
| 264 |
+
{"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn
|
| 265 |
+
{"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断
|
| 266 |
+
{"", "workbuddy.ai", "global"},
|
| 267 |
+
{"", "codebuddy.cn", "cn"},
|
| 268 |
+
{"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归)
|
| 269 |
+
}
|
| 270 |
+
for _, c := range cases {
|
| 271 |
+
if got := ResolveRealm(c.explicit, c.domain); got != c.want {
|
| 272 |
+
t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want)
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
internal/httpauth/httpauth.go
CHANGED
|
@@ -1,43 +1,43 @@
|
|
| 1 |
-
// Package httpauth 网关与面板共用的 Bearer 鉴权原语。
|
| 2 |
-
//
|
| 3 |
-
// 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权
|
| 4 |
-
// 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移,
|
| 5 |
-
// 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。
|
| 6 |
-
package httpauth
|
| 7 |
-
|
| 8 |
-
import (
|
| 9 |
-
"crypto/sha256"
|
| 10 |
-
"crypto/subtle"
|
| 11 |
-
"net/http"
|
| 12 |
-
"strings"
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
// bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。
|
| 16 |
-
const bearerPrefix = "Bearer "
|
| 17 |
-
|
| 18 |
-
// VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。
|
| 19 |
-
//
|
| 20 |
-
// key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。
|
| 21 |
-
// 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare:
|
| 22 |
-
// - 常量时间,不因前缀匹配长度而泄露信息;
|
| 23 |
-
// - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回);
|
| 24 |
-
// - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。
|
| 25 |
-
func VerifyBearer(r *http.Request, key string) bool {
|
| 26 |
-
if key == "" {
|
| 27 |
-
return true
|
| 28 |
-
}
|
| 29 |
-
authz := r.Header.Get("Authorization")
|
| 30 |
-
if !strings.HasPrefix(authz, bearerPrefix) {
|
| 31 |
-
// 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。
|
| 32 |
-
subtle.ConstantTimeCompare(digest(""), digest(key))
|
| 33 |
-
return false
|
| 34 |
-
}
|
| 35 |
-
tok := authz[len(bearerPrefix):]
|
| 36 |
-
return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
// digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。
|
| 40 |
-
func digest(s string) []byte {
|
| 41 |
-
sum := sha256.Sum256([]byte(s))
|
| 42 |
-
return sum[:]
|
| 43 |
-
}
|
|
|
|
| 1 |
+
// Package httpauth 网关与面板共用的 Bearer 鉴权原语。
|
| 2 |
+
//
|
| 3 |
+
// 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权
|
| 4 |
+
// 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移,
|
| 5 |
+
// 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。
|
| 6 |
+
package httpauth
|
| 7 |
+
|
| 8 |
+
import (
|
| 9 |
+
"crypto/sha256"
|
| 10 |
+
"crypto/subtle"
|
| 11 |
+
"net/http"
|
| 12 |
+
"strings"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。
|
| 16 |
+
const bearerPrefix = "Bearer "
|
| 17 |
+
|
| 18 |
+
// VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。
|
| 19 |
+
//
|
| 20 |
+
// key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。
|
| 21 |
+
// 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare:
|
| 22 |
+
// - 常量时间,不因前缀匹配长度而泄露信息;
|
| 23 |
+
// - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回);
|
| 24 |
+
// - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。
|
| 25 |
+
func VerifyBearer(r *http.Request, key string) bool {
|
| 26 |
+
if key == "" {
|
| 27 |
+
return true
|
| 28 |
+
}
|
| 29 |
+
authz := r.Header.Get("Authorization")
|
| 30 |
+
if !strings.HasPrefix(authz, bearerPrefix) {
|
| 31 |
+
// 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。
|
| 32 |
+
subtle.ConstantTimeCompare(digest(""), digest(key))
|
| 33 |
+
return false
|
| 34 |
+
}
|
| 35 |
+
tok := authz[len(bearerPrefix):]
|
| 36 |
+
return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。
|
| 40 |
+
func digest(s string) []byte {
|
| 41 |
+
sum := sha256.Sum256([]byte(s))
|
| 42 |
+
return sum[:]
|
| 43 |
+
}
|
internal/httpauth/httpauth_test.go
CHANGED
|
@@ -1,61 +1,61 @@
|
|
| 1 |
-
package httpauth
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"net/http"
|
| 5 |
-
"net/http/httptest"
|
| 6 |
-
"testing"
|
| 7 |
-
)
|
| 8 |
-
|
| 9 |
-
func req(authz string) *http.Request {
|
| 10 |
-
r := httptest.NewRequest("GET", "/", nil)
|
| 11 |
-
if authz != "" {
|
| 12 |
-
r.Header.Set("Authorization", authz)
|
| 13 |
-
}
|
| 14 |
-
return r
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
func TestVerifyBearer(t *testing.T) {
|
| 18 |
-
cases := []struct {
|
| 19 |
-
name string
|
| 20 |
-
key string
|
| 21 |
-
authz string
|
| 22 |
-
want bool
|
| 23 |
-
}{
|
| 24 |
-
{"空 key 放行(未启用鉴权)", "", "", true},
|
| 25 |
-
{"空 key 也放行任意头", "", "Bearer whatever", true},
|
| 26 |
-
{"正确 key", "sk-abc123", "Bearer sk-abc123", true},
|
| 27 |
-
{"错误 key", "sk-abc123", "Bearer sk-wrong", false},
|
| 28 |
-
{"缺 Authorization 头", "sk-abc123", "", false},
|
| 29 |
-
{"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false},
|
| 30 |
-
{"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false},
|
| 31 |
-
{"多余空格", "sk-abc123", "Bearer sk-abc123", false},
|
| 32 |
-
{"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false},
|
| 33 |
-
{"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false},
|
| 34 |
-
{"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false},
|
| 35 |
-
}
|
| 36 |
-
for _, c := range cases {
|
| 37 |
-
t.Run(c.name, func(t *testing.T) {
|
| 38 |
-
if got := VerifyBearer(req(c.authz), c.key); got != c.want {
|
| 39 |
-
t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want)
|
| 40 |
-
}
|
| 41 |
-
})
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
// TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异:
|
| 46 |
-
// 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。
|
| 47 |
-
func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) {
|
| 48 |
-
if VerifyBearer(req(""), "any-key") {
|
| 49 |
-
t.Error("missing header must not pass")
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
func TestDigestIsFixedLength(t *testing.T) {
|
| 54 |
-
// 不同长度输入摘要后应等长(这是常量时间比较的前提)
|
| 55 |
-
if len(digest("")) != len(digest("a-much-longer-secret-value")) {
|
| 56 |
-
t.Error("digest length must not depend on input length")
|
| 57 |
-
}
|
| 58 |
-
if len(digest("x")) != 32 {
|
| 59 |
-
t.Errorf("sha256 digest length = %d, want 32", len(digest("x")))
|
| 60 |
-
}
|
| 61 |
-
}
|
|
|
|
| 1 |
+
package httpauth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"net/http/httptest"
|
| 6 |
+
"testing"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func req(authz string) *http.Request {
|
| 10 |
+
r := httptest.NewRequest("GET", "/", nil)
|
| 11 |
+
if authz != "" {
|
| 12 |
+
r.Header.Set("Authorization", authz)
|
| 13 |
+
}
|
| 14 |
+
return r
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func TestVerifyBearer(t *testing.T) {
|
| 18 |
+
cases := []struct {
|
| 19 |
+
name string
|
| 20 |
+
key string
|
| 21 |
+
authz string
|
| 22 |
+
want bool
|
| 23 |
+
}{
|
| 24 |
+
{"空 key 放行(未启用鉴权)", "", "", true},
|
| 25 |
+
{"空 key 也放行任意头", "", "Bearer whatever", true},
|
| 26 |
+
{"正确 key", "sk-abc123", "Bearer sk-abc123", true},
|
| 27 |
+
{"错误 key", "sk-abc123", "Bearer sk-wrong", false},
|
| 28 |
+
{"缺 Authorization 头", "sk-abc123", "", false},
|
| 29 |
+
{"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false},
|
| 30 |
+
{"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false},
|
| 31 |
+
{"多余空格", "sk-abc123", "Bearer sk-abc123", false},
|
| 32 |
+
{"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false},
|
| 33 |
+
{"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false},
|
| 34 |
+
{"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false},
|
| 35 |
+
}
|
| 36 |
+
for _, c := range cases {
|
| 37 |
+
t.Run(c.name, func(t *testing.T) {
|
| 38 |
+
if got := VerifyBearer(req(c.authz), c.key); got != c.want {
|
| 39 |
+
t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want)
|
| 40 |
+
}
|
| 41 |
+
})
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异:
|
| 46 |
+
// 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。
|
| 47 |
+
func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) {
|
| 48 |
+
if VerifyBearer(req(""), "any-key") {
|
| 49 |
+
t.Error("missing header must not pass")
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func TestDigestIsFixedLength(t *testing.T) {
|
| 54 |
+
// 不同长度输入摘要后应等长(这是常量时间比较的前提)
|
| 55 |
+
if len(digest("")) != len(digest("a-much-longer-secret-value")) {
|
| 56 |
+
t.Error("digest length must not depend on input length")
|
| 57 |
+
}
|
| 58 |
+
if len(digest("x")) != 32 {
|
| 59 |
+
t.Errorf("sha256 digest length = %d, want 32", len(digest("x")))
|
| 60 |
+
}
|
| 61 |
+
}
|
internal/livecfg/livecfg.go
CHANGED
|
@@ -1,48 +1,48 @@
|
|
| 1 |
-
// Package livecfg 运行期可变配置的并发安全持有者。
|
| 2 |
-
//
|
| 3 |
-
// 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置,
|
| 4 |
-
// 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针:
|
| 5 |
-
// 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。
|
| 6 |
-
//
|
| 7 |
-
// 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter
|
| 8 |
-
// (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。
|
| 9 |
-
package livecfg
|
| 10 |
-
|
| 11 |
-
import (
|
| 12 |
-
"sync/atomic"
|
| 13 |
-
"time"
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
// Snapshot 一次读取的不可变配置视图。
|
| 17 |
-
type Snapshot struct {
|
| 18 |
-
APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权
|
| 19 |
-
SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认)
|
| 20 |
-
SanitizeFingerprints bool // 出站请求体指纹脱敏
|
| 21 |
-
RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA)
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
// Holder 原子持有当前快照。
|
| 25 |
-
type Holder struct {
|
| 26 |
-
p atomic.Pointer[Snapshot]
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
// New 以初始快照构建。
|
| 30 |
-
func New(s Snapshot) *Holder {
|
| 31 |
-
h := &Holder{}
|
| 32 |
-
h.Store(s)
|
| 33 |
-
return h
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
// Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。
|
| 37 |
-
func (h *Holder) Load() Snapshot {
|
| 38 |
-
if h == nil {
|
| 39 |
-
return Snapshot{}
|
| 40 |
-
}
|
| 41 |
-
if s := h.p.Load(); s != nil {
|
| 42 |
-
return *s
|
| 43 |
-
}
|
| 44 |
-
return Snapshot{}
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
// Store 整体替换快照。
|
| 48 |
-
func (h *Holder) Store(s Snapshot) { h.p.Store(&s) }
|
|
|
|
| 1 |
+
// Package livecfg 运行期可变配置的并发安全持有者。
|
| 2 |
+
//
|
| 3 |
+
// 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置,
|
| 4 |
+
// 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针:
|
| 5 |
+
// 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。
|
| 6 |
+
//
|
| 7 |
+
// 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter
|
| 8 |
+
// (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。
|
| 9 |
+
package livecfg
|
| 10 |
+
|
| 11 |
+
import (
|
| 12 |
+
"sync/atomic"
|
| 13 |
+
"time"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// Snapshot 一次读取的不可变配置视图。
|
| 17 |
+
type Snapshot struct {
|
| 18 |
+
APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权
|
| 19 |
+
SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认)
|
| 20 |
+
SanitizeFingerprints bool // 出站请求体指纹脱敏
|
| 21 |
+
RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Holder 原子持有当前快照。
|
| 25 |
+
type Holder struct {
|
| 26 |
+
p atomic.Pointer[Snapshot]
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// New 以初始快照构建。
|
| 30 |
+
func New(s Snapshot) *Holder {
|
| 31 |
+
h := &Holder{}
|
| 32 |
+
h.Store(s)
|
| 33 |
+
return h
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。
|
| 37 |
+
func (h *Holder) Load() Snapshot {
|
| 38 |
+
if h == nil {
|
| 39 |
+
return Snapshot{}
|
| 40 |
+
}
|
| 41 |
+
if s := h.p.Load(); s != nil {
|
| 42 |
+
return *s
|
| 43 |
+
}
|
| 44 |
+
return Snapshot{}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Store 整体替换快照。
|
| 48 |
+
func (h *Holder) Store(s Snapshot) { h.p.Store(&s) }
|
internal/logfmt/logfmt.go
CHANGED
|
@@ -1,166 +1,166 @@
|
|
| 1 |
-
// Package logfmt 统一网关日志的 uid 截断与模块前缀约定。
|
| 2 |
-
//
|
| 3 |
-
// 约定:
|
| 4 |
-
// - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐,
|
| 5 |
-
// 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。
|
| 6 |
-
// - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持;
|
| 7 |
-
// 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。
|
| 8 |
-
// - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。
|
| 9 |
-
//
|
| 10 |
-
// 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串
|
| 11 |
-
// helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。
|
| 12 |
-
package logfmt
|
| 13 |
-
|
| 14 |
-
import (
|
| 15 |
-
"strings"
|
| 16 |
-
"unicode/utf8"
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
-
// Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径
|
| 20 |
-
// 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文
|
| 21 |
-
// ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。
|
| 22 |
-
func Truncate(s string, n int) string {
|
| 23 |
-
if n <= 0 {
|
| 24 |
-
return ""
|
| 25 |
-
}
|
| 26 |
-
s = strings.TrimSpace(s)
|
| 27 |
-
if len(s) > n {
|
| 28 |
-
// s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在
|
| 29 |
-
// 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。
|
| 30 |
-
for n > 0 && !utf8.RuneStart(s[n]) {
|
| 31 |
-
n--
|
| 32 |
-
}
|
| 33 |
-
return s[:n]
|
| 34 |
-
}
|
| 35 |
-
return s
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
// UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。
|
| 39 |
-
//
|
| 40 |
-
// 用于调度类与非调度类日志行,把 <task> <full-uid>: ... 改为 <task> <uid8>: ...
|
| 41 |
-
// 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。
|
| 42 |
-
func UID8(uid string) string {
|
| 43 |
-
if uid == "" {
|
| 44 |
-
return "-"
|
| 45 |
-
}
|
| 46 |
-
if len(uid) > 8 {
|
| 47 |
-
return uid[:8]
|
| 48 |
-
}
|
| 49 |
-
return uid
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
// Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。
|
| 53 |
-
//
|
| 54 |
-
// 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号",
|
| 55 |
-
// 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随
|
| 56 |
-
// 登录落在 auths/<uid>.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的
|
| 57 |
-
// 标签——昵称认人、uid8 供 grep,两者都保留。
|
| 58 |
-
//
|
| 59 |
-
// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。
|
| 60 |
-
func Label(uid, nick string) string {
|
| 61 |
-
short := UID8(uid)
|
| 62 |
-
nick = strings.TrimSpace(nick)
|
| 63 |
-
if nick == "" {
|
| 64 |
-
return short
|
| 65 |
-
}
|
| 66 |
-
return nick + "(" + short + ")"
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。
|
| 70 |
-
//
|
| 71 |
-
// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占
|
| 72 |
-
// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数,
|
| 73 |
-
// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。
|
| 74 |
-
func DisplayWidth(s string) int {
|
| 75 |
-
w := 0
|
| 76 |
-
for _, r := range s {
|
| 77 |
-
w += runeWidth(r)
|
| 78 |
-
}
|
| 79 |
-
return w
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。
|
| 83 |
-
// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。
|
| 84 |
-
func Pad(s string, width int) string {
|
| 85 |
-
if width <= 0 {
|
| 86 |
-
return s
|
| 87 |
-
}
|
| 88 |
-
if d := width - DisplayWidth(s); d > 0 {
|
| 89 |
-
return s + strings.Repeat(" ", d)
|
| 90 |
-
}
|
| 91 |
-
return s
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、
|
| 95 |
-
// "python-requests/2.31.0" 这类常见客户端标签)。
|
| 96 |
-
const maxShortUALen = 40
|
| 97 |
-
|
| 98 |
-
// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA
|
| 99 |
-
// 恒定包含它们,拿它当客户端标签等于没信息。
|
| 100 |
-
var shortUAEngines = map[string]bool{
|
| 101 |
-
"mozilla": true, "applewebkit": true, "gecko": true, "khtml": true,
|
| 102 |
-
"like": true, "safari": true, "compatible": true, "msie": true, "trident": true,
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、
|
| 106 |
-
// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。
|
| 107 |
-
//
|
| 108 |
-
// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄
|
| 109 |
-
// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端",
|
| 110 |
-
// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。
|
| 111 |
-
//
|
| 112 |
-
// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串
|
| 113 |
-
// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。
|
| 114 |
-
func ShortUA(ua string) string {
|
| 115 |
-
ua = strings.TrimSpace(ua)
|
| 116 |
-
if ua == "" {
|
| 117 |
-
return ""
|
| 118 |
-
}
|
| 119 |
-
for _, tok := range strings.Fields(ua) {
|
| 120 |
-
tok = strings.Trim(tok, "(),;")
|
| 121 |
-
name, _, ok := strings.Cut(tok, "/")
|
| 122 |
-
if !ok || name == "" {
|
| 123 |
-
continue
|
| 124 |
-
}
|
| 125 |
-
if shortUAEngines[strings.ToLower(name)] {
|
| 126 |
-
continue
|
| 127 |
-
}
|
| 128 |
-
return Truncate(tok, maxShortUALen)
|
| 129 |
-
}
|
| 130 |
-
return Truncate(ua, maxShortUALen)
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的
|
| 134 |
-
// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。
|
| 135 |
-
func runeWidth(r rune) int {
|
| 136 |
-
switch {
|
| 137 |
-
case r == 0:
|
| 138 |
-
return 0
|
| 139 |
-
case r < 0x20 || (r >= 0x7f && r < 0xa0):
|
| 140 |
-
// 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。
|
| 141 |
-
return 0
|
| 142 |
-
case r < 0x1100:
|
| 143 |
-
return 1
|
| 144 |
-
case r <= 0x115f: // Hangul Jamo 初声
|
| 145 |
-
return 2
|
| 146 |
-
case r == 0x2329 || r == 0x232a:
|
| 147 |
-
return 2
|
| 148 |
-
case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符)
|
| 149 |
-
return 2
|
| 150 |
-
case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节
|
| 151 |
-
return 2
|
| 152 |
-
case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意
|
| 153 |
-
return 2
|
| 154 |
-
case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式
|
| 155 |
-
return 2
|
| 156 |
-
case r >= 0xff00 && r <= 0xff60: // 全角 ASCII
|
| 157 |
-
return 2
|
| 158 |
-
case r >= 0xffe0 && r <= 0xffe6: // 全角符号
|
| 159 |
-
return 2
|
| 160 |
-
case r >= 0x1f300 && r <= 0x1f9ff: // emoji
|
| 161 |
-
return 2
|
| 162 |
-
case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后
|
| 163 |
-
return 2
|
| 164 |
-
}
|
| 165 |
-
return 1
|
| 166 |
-
}
|
|
|
|
| 1 |
+
// Package logfmt 统一网关日志的 uid 截断与模块前缀约定。
|
| 2 |
+
//
|
| 3 |
+
// 约定:
|
| 4 |
+
// - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐,
|
| 5 |
+
// 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。
|
| 6 |
+
// - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持;
|
| 7 |
+
// 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。
|
| 8 |
+
// - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。
|
| 9 |
+
//
|
| 10 |
+
// 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串
|
| 11 |
+
// helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。
|
| 12 |
+
package logfmt
|
| 13 |
+
|
| 14 |
+
import (
|
| 15 |
+
"strings"
|
| 16 |
+
"unicode/utf8"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
// Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径
|
| 20 |
+
// 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文
|
| 21 |
+
// ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。
|
| 22 |
+
func Truncate(s string, n int) string {
|
| 23 |
+
if n <= 0 {
|
| 24 |
+
return ""
|
| 25 |
+
}
|
| 26 |
+
s = strings.TrimSpace(s)
|
| 27 |
+
if len(s) > n {
|
| 28 |
+
// s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在
|
| 29 |
+
// 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。
|
| 30 |
+
for n > 0 && !utf8.RuneStart(s[n]) {
|
| 31 |
+
n--
|
| 32 |
+
}
|
| 33 |
+
return s[:n]
|
| 34 |
+
}
|
| 35 |
+
return s
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。
|
| 39 |
+
//
|
| 40 |
+
// 用于调度类与非调度类日志行,把 <task> <full-uid>: ... 改为 <task> <uid8>: ...
|
| 41 |
+
// 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。
|
| 42 |
+
func UID8(uid string) string {
|
| 43 |
+
if uid == "" {
|
| 44 |
+
return "-"
|
| 45 |
+
}
|
| 46 |
+
if len(uid) > 8 {
|
| 47 |
+
return uid[:8]
|
| 48 |
+
}
|
| 49 |
+
return uid
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。
|
| 53 |
+
//
|
| 54 |
+
// 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号",
|
| 55 |
+
// 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随
|
| 56 |
+
// 登录落在 auths/<uid>.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的
|
| 57 |
+
// 标签——昵称认人、uid8 供 grep,两者都保留。
|
| 58 |
+
//
|
| 59 |
+
// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。
|
| 60 |
+
func Label(uid, nick string) string {
|
| 61 |
+
short := UID8(uid)
|
| 62 |
+
nick = strings.TrimSpace(nick)
|
| 63 |
+
if nick == "" {
|
| 64 |
+
return short
|
| 65 |
+
}
|
| 66 |
+
return nick + "(" + short + ")"
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。
|
| 70 |
+
//
|
| 71 |
+
// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占
|
| 72 |
+
// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数,
|
| 73 |
+
// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。
|
| 74 |
+
func DisplayWidth(s string) int {
|
| 75 |
+
w := 0
|
| 76 |
+
for _, r := range s {
|
| 77 |
+
w += runeWidth(r)
|
| 78 |
+
}
|
| 79 |
+
return w
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。
|
| 83 |
+
// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。
|
| 84 |
+
func Pad(s string, width int) string {
|
| 85 |
+
if width <= 0 {
|
| 86 |
+
return s
|
| 87 |
+
}
|
| 88 |
+
if d := width - DisplayWidth(s); d > 0 {
|
| 89 |
+
return s + strings.Repeat(" ", d)
|
| 90 |
+
}
|
| 91 |
+
return s
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、
|
| 95 |
+
// "python-requests/2.31.0" 这类常见客户端标签)。
|
| 96 |
+
const maxShortUALen = 40
|
| 97 |
+
|
| 98 |
+
// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA
|
| 99 |
+
// 恒定包含它们,拿它当客户端标签等于没信息。
|
| 100 |
+
var shortUAEngines = map[string]bool{
|
| 101 |
+
"mozilla": true, "applewebkit": true, "gecko": true, "khtml": true,
|
| 102 |
+
"like": true, "safari": true, "compatible": true, "msie": true, "trident": true,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、
|
| 106 |
+
// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。
|
| 107 |
+
//
|
| 108 |
+
// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄
|
| 109 |
+
// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端",
|
| 110 |
+
// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。
|
| 111 |
+
//
|
| 112 |
+
// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串
|
| 113 |
+
// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。
|
| 114 |
+
func ShortUA(ua string) string {
|
| 115 |
+
ua = strings.TrimSpace(ua)
|
| 116 |
+
if ua == "" {
|
| 117 |
+
return ""
|
| 118 |
+
}
|
| 119 |
+
for _, tok := range strings.Fields(ua) {
|
| 120 |
+
tok = strings.Trim(tok, "(),;")
|
| 121 |
+
name, _, ok := strings.Cut(tok, "/")
|
| 122 |
+
if !ok || name == "" {
|
| 123 |
+
continue
|
| 124 |
+
}
|
| 125 |
+
if shortUAEngines[strings.ToLower(name)] {
|
| 126 |
+
continue
|
| 127 |
+
}
|
| 128 |
+
return Truncate(tok, maxShortUALen)
|
| 129 |
+
}
|
| 130 |
+
return Truncate(ua, maxShortUALen)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的
|
| 134 |
+
// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。
|
| 135 |
+
func runeWidth(r rune) int {
|
| 136 |
+
switch {
|
| 137 |
+
case r == 0:
|
| 138 |
+
return 0
|
| 139 |
+
case r < 0x20 || (r >= 0x7f && r < 0xa0):
|
| 140 |
+
// 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。
|
| 141 |
+
return 0
|
| 142 |
+
case r < 0x1100:
|
| 143 |
+
return 1
|
| 144 |
+
case r <= 0x115f: // Hangul Jamo 初声
|
| 145 |
+
return 2
|
| 146 |
+
case r == 0x2329 || r == 0x232a:
|
| 147 |
+
return 2
|
| 148 |
+
case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符)
|
| 149 |
+
return 2
|
| 150 |
+
case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节
|
| 151 |
+
return 2
|
| 152 |
+
case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意
|
| 153 |
+
return 2
|
| 154 |
+
case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式
|
| 155 |
+
return 2
|
| 156 |
+
case r >= 0xff00 && r <= 0xff60: // 全角 ASCII
|
| 157 |
+
return 2
|
| 158 |
+
case r >= 0xffe0 && r <= 0xffe6: // 全角符号
|
| 159 |
+
return 2
|
| 160 |
+
case r >= 0x1f300 && r <= 0x1f9ff: // emoji
|
| 161 |
+
return 2
|
| 162 |
+
case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后
|
| 163 |
+
return 2
|
| 164 |
+
}
|
| 165 |
+
return 1
|
| 166 |
+
}
|
internal/logfmt/shortua_test.go
CHANGED
|
@@ -1,50 +1,50 @@
|
|
| 1 |
-
package logfmt
|
| 2 |
-
|
| 3 |
-
import "testing"
|
| 4 |
-
|
| 5 |
-
func TestShortUA(t *testing.T) {
|
| 6 |
-
cases := []struct {
|
| 7 |
-
name string
|
| 8 |
-
ua string
|
| 9 |
-
want string
|
| 10 |
-
}{
|
| 11 |
-
{"empty", "", ""},
|
| 12 |
-
{"blank", " ", ""},
|
| 13 |
-
{"curl", "curl/8.4.0", "curl/8.4.0"},
|
| 14 |
-
{"python", "python-requests/2.31.0", "python-requests/2.31.0"},
|
| 15 |
-
{"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"},
|
| 16 |
-
{"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"},
|
| 17 |
-
{
|
| 18 |
-
"chrome skips engine tokens",
|
| 19 |
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 20 |
-
"Chrome/120.0.0.0",
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
"firefox",
|
| 24 |
-
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
| 25 |
-
"Firefox/121.0",
|
| 26 |
-
},
|
| 27 |
-
{"no version token", "node", "node"},
|
| 28 |
-
{"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"},
|
| 29 |
-
}
|
| 30 |
-
for _, tc := range cases {
|
| 31 |
-
t.Run(tc.name, func(t *testing.T) {
|
| 32 |
-
if got := ShortUA(tc.ua); got != tc.want {
|
| 33 |
-
t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want)
|
| 34 |
-
}
|
| 35 |
-
})
|
| 36 |
-
}
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。
|
| 40 |
-
func TestShortUABounded(t *testing.T) {
|
| 41 |
-
long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3"
|
| 42 |
-
if got := ShortUA(long); len(got) > maxShortUALen {
|
| 43 |
-
t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got)
|
| 44 |
-
}
|
| 45 |
-
// 没有 name/version token 时回落整串,同样受上限约束。
|
| 46 |
-
got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy")
|
| 47 |
-
if len(got) > maxShortUALen {
|
| 48 |
-
t.Fatalf("fallback not truncated: %d", len(got))
|
| 49 |
-
}
|
| 50 |
-
}
|
|
|
|
| 1 |
+
package logfmt
|
| 2 |
+
|
| 3 |
+
import "testing"
|
| 4 |
+
|
| 5 |
+
func TestShortUA(t *testing.T) {
|
| 6 |
+
cases := []struct {
|
| 7 |
+
name string
|
| 8 |
+
ua string
|
| 9 |
+
want string
|
| 10 |
+
}{
|
| 11 |
+
{"empty", "", ""},
|
| 12 |
+
{"blank", " ", ""},
|
| 13 |
+
{"curl", "curl/8.4.0", "curl/8.4.0"},
|
| 14 |
+
{"python", "python-requests/2.31.0", "python-requests/2.31.0"},
|
| 15 |
+
{"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"},
|
| 16 |
+
{"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"},
|
| 17 |
+
{
|
| 18 |
+
"chrome skips engine tokens",
|
| 19 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 20 |
+
"Chrome/120.0.0.0",
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"firefox",
|
| 24 |
+
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
| 25 |
+
"Firefox/121.0",
|
| 26 |
+
},
|
| 27 |
+
{"no version token", "node", "node"},
|
| 28 |
+
{"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"},
|
| 29 |
+
}
|
| 30 |
+
for _, tc := range cases {
|
| 31 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 32 |
+
if got := ShortUA(tc.ua); got != tc.want {
|
| 33 |
+
t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want)
|
| 34 |
+
}
|
| 35 |
+
})
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。
|
| 40 |
+
func TestShortUABounded(t *testing.T) {
|
| 41 |
+
long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3"
|
| 42 |
+
if got := ShortUA(long); len(got) > maxShortUALen {
|
| 43 |
+
t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got)
|
| 44 |
+
}
|
| 45 |
+
// 没有 name/version token 时回落整串,同样受上限约束。
|
| 46 |
+
got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy")
|
| 47 |
+
if len(got) > maxShortUALen {
|
| 48 |
+
t.Fatalf("fallback not truncated: %d", len(got))
|
| 49 |
+
}
|
| 50 |
+
}
|
internal/panel/app.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
internal/panel/autotask.go
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
internal/panel/autotask_lock_test.go
CHANGED
|
@@ -1,52 +1,52 @@
|
|
| 1 |
-
package panel
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"testing"
|
| 5 |
-
)
|
| 6 |
-
|
| 7 |
-
// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败,
|
| 8 |
-
// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。
|
| 9 |
-
func TestTaskAccountLockSameAccountExclusive(t *testing.T) {
|
| 10 |
-
p := &Panel{}
|
| 11 |
-
uid := "u1"
|
| 12 |
-
|
| 13 |
-
if !p.tryLockAccount(uid) {
|
| 14 |
-
t.Fatal("首次加锁应成功")
|
| 15 |
-
}
|
| 16 |
-
if p.tryLockAccount(uid) {
|
| 17 |
-
t.Fatal("同账号第二次加锁应失败(互斥)")
|
| 18 |
-
}
|
| 19 |
-
p.unlockAccount(uid)
|
| 20 |
-
|
| 21 |
-
if !p.tryLockAccount(uid) {
|
| 22 |
-
t.Fatal("解锁后应可再次加锁")
|
| 23 |
-
}
|
| 24 |
-
p.unlockAccount(uid)
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。
|
| 28 |
-
func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) {
|
| 29 |
-
p := &Panel{}
|
| 30 |
-
if !p.tryLockAccount("u1") {
|
| 31 |
-
t.Fatal("u1 加锁应成功")
|
| 32 |
-
}
|
| 33 |
-
if !p.tryLockAccount("u2") {
|
| 34 |
-
t.Fatal("u2 加锁应成功(不同账号不互斥)")
|
| 35 |
-
}
|
| 36 |
-
p.unlockAccount("u2")
|
| 37 |
-
p.unlockAccount("u1")
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁
|
| 41 |
-
// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。
|
| 42 |
-
func TestTaskAccountLockCrossEntryShared(t *testing.T) {
|
| 43 |
-
p := &Panel{}
|
| 44 |
-
if !p.tryLockAccount("u1") {
|
| 45 |
-
t.Fatal("u1 加锁应成功")
|
| 46 |
-
}
|
| 47 |
-
// 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。
|
| 48 |
-
if p.tryLockAccount("u1") {
|
| 49 |
-
t.Fatal("同 uid 跨入口加锁应失败(共用锁)")
|
| 50 |
-
}
|
| 51 |
-
p.unlockAccount("u1")
|
| 52 |
-
}
|
|
|
|
| 1 |
+
package panel
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败,
|
| 8 |
+
// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。
|
| 9 |
+
func TestTaskAccountLockSameAccountExclusive(t *testing.T) {
|
| 10 |
+
p := &Panel{}
|
| 11 |
+
uid := "u1"
|
| 12 |
+
|
| 13 |
+
if !p.tryLockAccount(uid) {
|
| 14 |
+
t.Fatal("首次加锁应成功")
|
| 15 |
+
}
|
| 16 |
+
if p.tryLockAccount(uid) {
|
| 17 |
+
t.Fatal("同账号第二次加锁应失败(互斥)")
|
| 18 |
+
}
|
| 19 |
+
p.unlockAccount(uid)
|
| 20 |
+
|
| 21 |
+
if !p.tryLockAccount(uid) {
|
| 22 |
+
t.Fatal("解锁后应可再次加锁")
|
| 23 |
+
}
|
| 24 |
+
p.unlockAccount(uid)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。
|
| 28 |
+
func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) {
|
| 29 |
+
p := &Panel{}
|
| 30 |
+
if !p.tryLockAccount("u1") {
|
| 31 |
+
t.Fatal("u1 加锁应成功")
|
| 32 |
+
}
|
| 33 |
+
if !p.tryLockAccount("u2") {
|
| 34 |
+
t.Fatal("u2 加锁应成功(不同账号不互斥)")
|
| 35 |
+
}
|
| 36 |
+
p.unlockAccount("u2")
|
| 37 |
+
p.unlockAccount("u1")
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁
|
| 41 |
+
// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。
|
| 42 |
+
func TestTaskAccountLockCrossEntryShared(t *testing.T) {
|
| 43 |
+
p := &Panel{}
|
| 44 |
+
if !p.tryLockAccount("u1") {
|
| 45 |
+
t.Fatal("u1 加锁应成功")
|
| 46 |
+
}
|
| 47 |
+
// 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。
|
| 48 |
+
if p.tryLockAccount("u1") {
|
| 49 |
+
t.Fatal("同 uid 跨入口加锁应失败(共用锁)")
|
| 50 |
+
}
|
| 51 |
+
p.unlockAccount("u1")
|
| 52 |
+
}
|
internal/panel/config.go
CHANGED
|
@@ -1,57 +1,57 @@
|
|
| 1 |
-
// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。
|
| 2 |
-
//
|
| 3 |
-
// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做
|
| 4 |
-
// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成
|
| 5 |
-
// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。
|
| 6 |
-
package panel
|
| 7 |
-
|
| 8 |
-
import (
|
| 9 |
-
"io"
|
| 10 |
-
"log"
|
| 11 |
-
"net/http"
|
| 12 |
-
)
|
| 13 |
-
|
| 14 |
-
// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。
|
| 15 |
-
func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) {
|
| 16 |
-
if p.cfg.LoadConfig == nil {
|
| 17 |
-
writeErr(w, http.StatusNotImplemented, "config api not available")
|
| 18 |
-
return
|
| 19 |
-
}
|
| 20 |
-
cfg, err := p.cfg.LoadConfig()
|
| 21 |
-
if err != nil {
|
| 22 |
-
writeErr(w, http.StatusInternalServerError, "load config: "+err.Error())
|
| 23 |
-
return
|
| 24 |
-
}
|
| 25 |
-
writeJSON(w, http.StatusOK, map[string]any{
|
| 26 |
-
"ok": true,
|
| 27 |
-
"path": p.cfg.ConfigPath,
|
| 28 |
-
"config": cfg,
|
| 29 |
-
})
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。
|
| 33 |
-
// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。
|
| 34 |
-
func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) {
|
| 35 |
-
if p.cfg.SaveConfig == nil {
|
| 36 |
-
writeErr(w, http.StatusNotImplemented, "config api not available")
|
| 37 |
-
return
|
| 38 |
-
}
|
| 39 |
-
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
| 40 |
-
if err != nil {
|
| 41 |
-
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
| 42 |
-
return
|
| 43 |
-
}
|
| 44 |
-
restartRequired, err := p.cfg.SaveConfig(raw)
|
| 45 |
-
if err != nil {
|
| 46 |
-
writeErr(w, http.StatusBadRequest, err.Error())
|
| 47 |
-
return
|
| 48 |
-
}
|
| 49 |
-
if restartRequired == nil {
|
| 50 |
-
restartRequired = []string{}
|
| 51 |
-
}
|
| 52 |
-
log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired))
|
| 53 |
-
writeJSON(w, http.StatusOK, map[string]any{
|
| 54 |
-
"ok": true,
|
| 55 |
-
"restart_required": restartRequired,
|
| 56 |
-
})
|
| 57 |
-
}
|
|
|
|
| 1 |
+
// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。
|
| 2 |
+
//
|
| 3 |
+
// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做
|
| 4 |
+
// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成
|
| 5 |
+
// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。
|
| 6 |
+
package panel
|
| 7 |
+
|
| 8 |
+
import (
|
| 9 |
+
"io"
|
| 10 |
+
"log"
|
| 11 |
+
"net/http"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。
|
| 15 |
+
func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) {
|
| 16 |
+
if p.cfg.LoadConfig == nil {
|
| 17 |
+
writeErr(w, http.StatusNotImplemented, "config api not available")
|
| 18 |
+
return
|
| 19 |
+
}
|
| 20 |
+
cfg, err := p.cfg.LoadConfig()
|
| 21 |
+
if err != nil {
|
| 22 |
+
writeErr(w, http.StatusInternalServerError, "load config: "+err.Error())
|
| 23 |
+
return
|
| 24 |
+
}
|
| 25 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 26 |
+
"ok": true,
|
| 27 |
+
"path": p.cfg.ConfigPath,
|
| 28 |
+
"config": cfg,
|
| 29 |
+
})
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。
|
| 33 |
+
// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。
|
| 34 |
+
func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) {
|
| 35 |
+
if p.cfg.SaveConfig == nil {
|
| 36 |
+
writeErr(w, http.StatusNotImplemented, "config api not available")
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
| 40 |
+
if err != nil {
|
| 41 |
+
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
| 42 |
+
return
|
| 43 |
+
}
|
| 44 |
+
restartRequired, err := p.cfg.SaveConfig(raw)
|
| 45 |
+
if err != nil {
|
| 46 |
+
writeErr(w, http.StatusBadRequest, err.Error())
|
| 47 |
+
return
|
| 48 |
+
}
|
| 49 |
+
if restartRequired == nil {
|
| 50 |
+
restartRequired = []string{}
|
| 51 |
+
}
|
| 52 |
+
log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired))
|
| 53 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 54 |
+
"ok": true,
|
| 55 |
+
"restart_required": restartRequired,
|
| 56 |
+
})
|
| 57 |
+
}
|
internal/panel/frontend_test.go
CHANGED
|
@@ -1,647 +1,769 @@
|
|
| 1 |
-
package panel
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"bytes"
|
| 5 |
-
"net/http/httptest"
|
| 6 |
-
"os"
|
| 7 |
-
"os/exec"
|
| 8 |
-
"path/filepath"
|
| 9 |
-
"
|
| 10 |
-
"
|
| 11 |
-
"
|
| 12 |
-
"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
//
|
| 17 |
-
//
|
| 18 |
-
//
|
| 19 |
-
//
|
| 20 |
-
//
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
//
|
| 38 |
-
//
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
//
|
| 66 |
-
//
|
| 67 |
-
//
|
| 68 |
-
//
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
const
|
| 76 |
-
const
|
| 77 |
-
const
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
cmd
|
| 118 |
-
cmd.
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
const
|
| 137 |
-
const
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
vm.
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
const
|
| 184 |
-
const
|
| 185 |
-
const
|
| 186 |
-
const
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
vm.
|
| 191 |
-
|
| 192 |
-
const
|
| 193 |
-
const
|
| 194 |
-
const
|
| 195 |
-
const
|
| 196 |
-
const
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
const
|
| 228 |
-
const
|
| 229 |
-
const
|
| 230 |
-
const
|
| 231 |
-
const
|
| 232 |
-
const
|
| 233 |
-
const
|
| 234 |
-
const
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
vm.
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
const
|
| 245 |
-
const
|
| 246 |
-
const
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
const
|
| 281 |
-
const
|
| 282 |
-
const
|
| 283 |
-
const
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
vm.
|
| 288 |
-
|
| 289 |
-
const
|
| 290 |
-
const
|
| 291 |
-
const
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
})
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
const
|
| 329 |
-
const
|
| 330 |
-
const
|
| 331 |
-
const
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
vm.
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
{ id: 'cn:
|
| 339 |
-
{ id: '
|
| 340 |
-
{ id: '
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
const
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
`"
|
| 380 |
-
`"
|
| 381 |
-
`"
|
| 382 |
-
`"
|
| 383 |
-
`"
|
| 384 |
-
`"
|
| 385 |
-
`"
|
| 386 |
-
`"
|
| 387 |
-
`"
|
| 388 |
-
`"
|
| 389 |
-
`"
|
| 390 |
-
`"
|
| 391 |
-
`"
|
| 392 |
-
`"
|
| 393 |
-
`"
|
| 394 |
-
`"
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
//
|
| 402 |
-
//
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
const
|
| 410 |
-
const
|
| 411 |
-
const
|
| 412 |
-
const
|
| 413 |
-
const
|
| 414 |
-
const
|
| 415 |
-
const
|
| 416 |
-
const
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
const
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
vm.
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
{ t: '2026-09-
|
| 430 |
-
{ t: '2026-09-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
//
|
| 463 |
-
// -
|
| 464 |
-
// -
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
const
|
| 472 |
-
const
|
| 473 |
-
const
|
| 474 |
-
const
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
const
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
vm.
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
const
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
const
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
st
|
| 509 |
-
st.
|
| 510 |
-
st.
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
`"
|
| 534 |
-
`"
|
| 535 |
-
`"
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
}
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
const
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
const
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
{
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
{
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
if
|
| 645 |
-
t.
|
| 646 |
-
}
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package panel
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"net/http/httptest"
|
| 6 |
+
"os"
|
| 7 |
+
"os/exec"
|
| 8 |
+
"path/filepath"
|
| 9 |
+
"regexp"
|
| 10 |
+
"strconv"
|
| 11 |
+
"strings"
|
| 12 |
+
"testing"
|
| 13 |
+
"time"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。
|
| 17 |
+
//
|
| 18 |
+
// 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容——
|
| 19 |
+
// 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量)
|
| 20 |
+
// 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。
|
| 21 |
+
// 无 node 环境时跳过(不阻塞无 Node 的构建机)。
|
| 22 |
+
func TestAppJSSyntax(t *testing.T) {
|
| 23 |
+
node, err := exec.LookPath("node")
|
| 24 |
+
if err != nil {
|
| 25 |
+
t.Skip("node not available; skipping JS syntax check")
|
| 26 |
+
}
|
| 27 |
+
path, err := filepath.Abs("app.js")
|
| 28 |
+
if err != nil {
|
| 29 |
+
t.Fatal(err)
|
| 30 |
+
}
|
| 31 |
+
out, err := exec.Command(node, "--check", path).CombinedOutput()
|
| 32 |
+
if err != nil {
|
| 33 |
+
t.Fatalf("app.js syntax error:\n%s", out)
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// TestIndexHTMLNoInlineScript index.html 不得含内联 <script> 块:
|
| 38 |
+
// 严格 CSP(script-src 'self')会拦截内联脚本,页面将完全不可用。
|
| 39 |
+
// 外链形式 <script src="..."> 允许。
|
| 40 |
+
func TestIndexHTMLNoInlineScript(t *testing.T) {
|
| 41 |
+
p := newTestPanel()
|
| 42 |
+
rec := httptest.NewRecorder()
|
| 43 |
+
p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil))
|
| 44 |
+
body := rec.Body.String()
|
| 45 |
+
|
| 46 |
+
rest := body
|
| 47 |
+
for {
|
| 48 |
+
idx := strings.Index(rest, "<script")
|
| 49 |
+
if idx < 0 {
|
| 50 |
+
break
|
| 51 |
+
}
|
| 52 |
+
rest = rest[idx:]
|
| 53 |
+
end := strings.Index(rest, ">")
|
| 54 |
+
if end < 0 {
|
| 55 |
+
break
|
| 56 |
+
}
|
| 57 |
+
tag := rest[:end+1]
|
| 58 |
+
if !strings.Contains(tag, "src=") {
|
| 59 |
+
t.Fatalf("index.html contains inline <script> (blocked by CSP): %s", tag)
|
| 60 |
+
}
|
| 61 |
+
rest = rest[end:]
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// TestAppJSTopLevelSmoke app.js 顶层求值冒烟(v1.11.3/1.11.4 两连炸后补的运行时闸门):
|
| 66 |
+
// node + DOM 桩执行 app.js(含按 hash 落到各视图的 go() 顶层调用),抓 TDZ/
|
| 67 |
+
// ReferenceError 类运行时错误——Go 侧 frontend_test 不执行 JS,语法层检查对此全盲。
|
| 68 |
+
// 无 node 的环境跳过(CI/精简机不受影响);harness 与 app.js 同判(app.js 顶层
|
| 69 |
+
// start() 的 setInterval 会让 node 事件循环不退出,故成功路径显式 exit(0))。
|
| 70 |
+
func TestAppJSTopLevelSmoke(t *testing.T) {
|
| 71 |
+
node, err := exec.LookPath("node")
|
| 72 |
+
if err != nil {
|
| 73 |
+
t.Skip("node not installed; JS smoke skipped")
|
| 74 |
+
}
|
| 75 |
+
harness := `const fs = require('fs');
|
| 76 |
+
const vm = require('vm');
|
| 77 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 78 |
+
const inert = new Proxy(function () {}, {
|
| 79 |
+
get(t, k) { if (k === Symbol.toPrimitive) return () => ''; return inert; },
|
| 80 |
+
set() { return true; },
|
| 81 |
+
apply() { return inert; },
|
| 82 |
+
construct() { return inert; },
|
| 83 |
+
has() { return true; },
|
| 84 |
+
});
|
| 85 |
+
const sandbox = new Proxy({
|
| 86 |
+
location: { hash: process.env.SMOKE_HASH || '#taskscenter' },
|
| 87 |
+
history: { replaceState() {} },
|
| 88 |
+
localStorage: { getItem: () => null, setItem() {} },
|
| 89 |
+
navigator: { clipboard: { writeText: () => Promise.resolve() } },
|
| 90 |
+
document: { querySelectorAll: () => [], querySelector: () => inert, getElementById: () => inert, addEventListener() {}, documentElement: inert, head: inert, body: inert, createElement: () => inert, cookie: '' },
|
| 91 |
+
fetch: () => new Promise(() => {}),
|
| 92 |
+
addEventListener() {}, removeEventListener() {},
|
| 93 |
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
| 94 |
+
setInterval, clearInterval, setTimeout, clearTimeout,
|
| 95 |
+
console, JSON, Math, Date, Number, String, Boolean, Object, Array, Promise, Map, Set, RegExp, Error, TypeError, isNaN, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, URL, Symbol, Proxy, Reflect,
|
| 96 |
+
}, { get(t, k) { return t[k]; }, has() { return true; } });
|
| 97 |
+
sandbox.window = sandbox; sandbox.globalThis = sandbox;
|
| 98 |
+
vm.createContext(sandbox);
|
| 99 |
+
try {
|
| 100 |
+
vm.runInContext(src, sandbox, { filename: 'app.js' });
|
| 101 |
+
console.log('SMOKE OK');
|
| 102 |
+
process.exit(0);
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.log('SMOKE FAIL:', (e && e.stack ? e.stack : e).toString().split('\n').slice(0, 5).join('\n'));
|
| 105 |
+
process.exit(1);
|
| 106 |
+
}
|
| 107 |
+
`
|
| 108 |
+
hf, err := os.CreateTemp(t.TempDir(), "smoke-*.cjs")
|
| 109 |
+
if err != nil {
|
| 110 |
+
t.Fatal(err)
|
| 111 |
+
}
|
| 112 |
+
if _, err := hf.WriteString(harness); err != nil {
|
| 113 |
+
t.Fatal(err)
|
| 114 |
+
}
|
| 115 |
+
hf.Close()
|
| 116 |
+
for _, hash := range []string{"#taskscenter", "#accounts", "#usage", "#models", "#config", "#logs", "#packages"} {
|
| 117 |
+
cmd := exec.Command(node, hf.Name(), "app.js")
|
| 118 |
+
cmd.Dir = "." // 测试工作目录 = internal/panel
|
| 119 |
+
cmd.Env = append(os.Environ(), "SMOKE_HASH="+hash)
|
| 120 |
+
out, err := cmd.CombinedOutput()
|
| 121 |
+
if err != nil {
|
| 122 |
+
t.Fatalf("app.js 顶层求值 %s 崩溃: %v\n%s", hash, err, out)
|
| 123 |
+
}
|
| 124 |
+
if !bytes.Contains(out, []byte("SMOKE OK")) {
|
| 125 |
+
t.Fatalf("app.js smoke %s 未通过:\n%s", hash, out)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// 积分扣除维度的格式必须稳定,且缺样本/缺匹配 Token 时不能伪造比例。
|
| 131 |
+
func TestAppJSCreditDimensionFormatting(t *testing.T) {
|
| 132 |
+
node, err := exec.LookPath("node")
|
| 133 |
+
if err != nil {
|
| 134 |
+
t.Skip("node not installed; credit formatting test skipped")
|
| 135 |
+
}
|
| 136 |
+
script := `const fs = require('fs');
|
| 137 |
+
const vm = require('vm');
|
| 138 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 139 |
+
const start = src.indexOf('function trimFixed');
|
| 140 |
+
const end = src.indexOf('function usStat');
|
| 141 |
+
if (start < 0 || end < 0) throw new Error('credit helpers not found');
|
| 142 |
+
const ctx = { Number, String, RegExp };
|
| 143 |
+
vm.createContext(ctx);
|
| 144 |
+
vm.runInContext(
|
| 145 |
+
src.slice(start, end) +
|
| 146 |
+
'\nthis.fmtCredit=fmtCredit; this.fmtCreditRatio=fmtCreditRatio; this.fmtModelRate=fmtModelRate;',
|
| 147 |
+
ctx
|
| 148 |
+
);
|
| 149 |
+
process.stdout.write(JSON.stringify({
|
| 150 |
+
credit: ctx.fmtCredit(1.25),
|
| 151 |
+
zero: ctx.fmtCredit(0),
|
| 152 |
+
hundred: ctx.fmtCredit(100),
|
| 153 |
+
ratio: ctx.fmtCreditRatio(12.5, 2, 400),
|
| 154 |
+
noSamples: ctx.fmtCreditRatio(12.5, 0, 400),
|
| 155 |
+
noTokens: ctx.fmtCreditRatio(12.5, 2, 0),
|
| 156 |
+
rate: ctx.fmtModelRate('0.5'),
|
| 157 |
+
noRate: ctx.fmtModelRate(''),
|
| 158 |
+
}));`
|
| 159 |
+
f, err := os.CreateTemp(t.TempDir(), "credit-format-*.cjs")
|
| 160 |
+
if err != nil {
|
| 161 |
+
t.Fatal(err)
|
| 162 |
+
}
|
| 163 |
+
if _, err := f.WriteString(script); err != nil {
|
| 164 |
+
t.Fatal(err)
|
| 165 |
+
}
|
| 166 |
+
f.Close()
|
| 167 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 168 |
+
if err != nil {
|
| 169 |
+
t.Fatalf("credit formatting node test failed: %v\n%s", err, out)
|
| 170 |
+
}
|
| 171 |
+
const want = `{"credit":"1.25","zero":"0","hundred":"100","ratio":"12.5 / 1M","noSamples":"—","noTokens":"—","rate":"x0.5","noRate":"—"}`
|
| 172 |
+
if strings.TrimSpace(string(out)) != want {
|
| 173 |
+
t.Fatalf("credit formatting=%s want %s", out, want)
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 模型限流时间必须同时支持上游 reset_at、网关 until 和无重置时间三种形态。
|
| 178 |
+
func TestAppJSRateLimitMeta(t *testing.T) {
|
| 179 |
+
node, err := exec.LookPath("node")
|
| 180 |
+
if err != nil {
|
| 181 |
+
t.Skip("node not installed; rate limit formatting test skipped")
|
| 182 |
+
}
|
| 183 |
+
script := `const fs = require('fs');
|
| 184 |
+
const vm = require('vm');
|
| 185 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 186 |
+
const start = src.indexOf('function dur(');
|
| 187 |
+
const end = src.indexOf('function rateLimitRowsHtml');
|
| 188 |
+
if (start < 0 || end < 0) throw new Error('rate limit helpers not found');
|
| 189 |
+
const ctx = { Date, Number, String, Math };
|
| 190 |
+
vm.createContext(ctx);
|
| 191 |
+
vm.runInContext(src.slice(start, end) + '\nthis.rateLimitMeta=rateLimitMeta;', ctx);
|
| 192 |
+
const now = new Date(2026, 8, 28, 14, 0, 0).getTime();
|
| 193 |
+
const reset = new Date(2026, 8, 28, 16, 0, 0).getTime();
|
| 194 |
+
const until = new Date(2026, 8, 28, 15, 0, 0).getTime();
|
| 195 |
+
const rate = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit', reset_at: new Date(reset).toISOString(), until: new Date(until).toISOString() }, now);
|
| 196 |
+
const unavailable = ctx.rateLimitMeta({ model: 'missing', kind: 'model_unavailable', until: new Date(until).toISOString() }, now);
|
| 197 |
+
const unknown = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit' }, now);
|
| 198 |
+
process.stdout.write(JSON.stringify({
|
| 199 |
+
rate: rate.detail,
|
| 200 |
+
unavailable: unavailable.detail,
|
| 201 |
+
unknown: unknown.detail,
|
| 202 |
+
}));`
|
| 203 |
+
f, err := os.CreateTemp(t.TempDir(), "rate-limit-format-*.cjs")
|
| 204 |
+
if err != nil {
|
| 205 |
+
t.Fatal(err)
|
| 206 |
+
}
|
| 207 |
+
if _, err := f.WriteString(script); err != nil {
|
| 208 |
+
t.Fatal(err)
|
| 209 |
+
}
|
| 210 |
+
f.Close()
|
| 211 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 212 |
+
if err != nil {
|
| 213 |
+
t.Fatalf("rate-limit formatting node test failed: %v\n%s", err, out)
|
| 214 |
+
}
|
| 215 |
+
const want = `{"rate":"预计 2026-09-28 16:00 解封(剩余 2时00分) · 网关最快 1时00分 后重试","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}`
|
| 216 |
+
if strings.TrimSpace(string(out)) != want {
|
| 217 |
+
t.Fatalf("rate-limit formatting=%s want %s", out, want)
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// 请求记录行必须紧凑、可读,并带上调用来源(IP / UA);来源缺失时以 — 兜底。
|
| 222 |
+
func TestAppJSRequestLogFormatting(t *testing.T) {
|
| 223 |
+
node, err := exec.LookPath("node")
|
| 224 |
+
if err != nil {
|
| 225 |
+
t.Skip("node not installed; request log formatting test skipped")
|
| 226 |
+
}
|
| 227 |
+
script := `const fs = require('fs');
|
| 228 |
+
const vm = require('vm');
|
| 229 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 230 |
+
const escStart = src.indexOf('function esc(');
|
| 231 |
+
const escEnd = src.indexOf('function ago(');
|
| 232 |
+
const fmtStart = src.indexOf('function fmtTok(');
|
| 233 |
+
const fmtEnd = src.indexOf('function usStat(');
|
| 234 |
+
const reqStart = src.indexOf('function requestLogText');
|
| 235 |
+
const reqEnd = src.indexOf('function fmtBytes');
|
| 236 |
+
if ([escStart, escEnd, fmtStart, fmtEnd, reqStart, reqEnd].some(v => v < 0)) throw new Error('request log helpers not found');
|
| 237 |
+
const ctx = { Date, Number, String, Math, RegExp, isNaN };
|
| 238 |
+
vm.createContext(ctx);
|
| 239 |
+
vm.runInContext(
|
| 240 |
+
src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(reqStart, reqEnd) +
|
| 241 |
+
'\nthis.requestLogText=requestLogText;',
|
| 242 |
+
ctx
|
| 243 |
+
);
|
| 244 |
+
const time = new Date(2026, 8, 28, 14, 5, 6).toISOString();
|
| 245 |
+
const good = { time, status: 200, outcome: 'success', model: 'glm-5.3', account: '账号(uid8)', duration_ms: 1250, total_tokens: 2300, credit_known: true, credit: 0.12, request_id: 'req-1', client_ip: '203.0.113.7', user_agent: 'python-requests/2.31.0' };
|
| 246 |
+
const noSource = { ...good, request_id: 'req-3', client_ip: '', user_agent: '' };
|
| 247 |
+
const cached = { ...good, request_id: 'req-2', cache_hit_tokens: 2257, cache_miss_tokens: 43 };
|
| 248 |
+
process.stdout.write(JSON.stringify({
|
| 249 |
+
good: ctx.requestLogText(good),
|
| 250 |
+
noSource: ctx.requestLogText(noSource),
|
| 251 |
+
cached: ctx.requestLogText(cached),
|
| 252 |
+
}));`
|
| 253 |
+
f, err := os.CreateTemp(t.TempDir(), "request-log-format-*.cjs")
|
| 254 |
+
if err != nil {
|
| 255 |
+
t.Fatal(err)
|
| 256 |
+
}
|
| 257 |
+
if _, err := f.WriteString(script); err != nil {
|
| 258 |
+
t.Fatal(err)
|
| 259 |
+
}
|
| 260 |
+
f.Close()
|
| 261 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 262 |
+
if err != nil {
|
| 263 |
+
t.Fatalf("request log formatting node test failed: %v\n%s", err, out)
|
| 264 |
+
}
|
| 265 |
+
text := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | 203.0.113.7 | python-requests/2.31.0 | 1.25s | 2.3k tok | 0.12 credit | req-1"
|
| 266 |
+
noSource := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | — | — | 1.25s | 2.3k tok | 0.12 credit | req-3"
|
| 267 |
+
cached := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | 203.0.113.7 | python-requests/2.31.0 | 1.25s | 2.3k tok | 0.12 credit | 命中 98.1% | req-2"
|
| 268 |
+
want := `{"good":` + strconv.Quote(text) + `,"noSource":` + strconv.Quote(noSource) + `,"cached":` + strconv.Quote(cached) + `}`
|
| 269 |
+
if strings.TrimSpace(string(out)) != want {
|
| 270 |
+
t.Fatalf("request log formatting=%s want %s", out, want)
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 请求记录筛选:IP / UA / 模型 / 账号 / 请求 ID 的包含匹配(空格分词 AND)+ 结果精确匹配。
|
| 275 |
+
func TestAppJSRequestMatch(t *testing.T) {
|
| 276 |
+
node, err := exec.LookPath("node")
|
| 277 |
+
if err != nil {
|
| 278 |
+
t.Skip("node not installed; request filter test skipped")
|
| 279 |
+
}
|
| 280 |
+
script := `const fs = require('fs');
|
| 281 |
+
const vm = require('vm');
|
| 282 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 283 |
+
const start = src.indexOf('function reqMatch');
|
| 284 |
+
const end = src.indexOf('function reqOutcomeTag');
|
| 285 |
+
if (start < 0 || end < 0) throw new Error('reqMatch not found');
|
| 286 |
+
const ctx = {};
|
| 287 |
+
vm.createContext(ctx);
|
| 288 |
+
vm.runInContext(src.slice(start, end) + '\nthis.reqMatch=reqMatch;', ctx);
|
| 289 |
+
const base = { outcome: 'success', client_ip: '203.0.113.7', user_agent: 'python-requests/2.31.0', model: 'cn:glm-5.3', account: '示例(uid8)', request_id: 'req-1' };
|
| 290 |
+
const other = { outcome: 'http_error', client_ip: '198.51.100.4', user_agent: 'Mozilla/5.0 Chrome/120', model: 'global:hy3', account: '甲(uid9)', request_id: 'req-2' };
|
| 291 |
+
const rows = [base, other];
|
| 292 |
+
const pick = f => rows.filter(e => ctx.reqMatch(e, f)).map(e => e.request_id);
|
| 293 |
+
process.stdout.write(JSON.stringify({
|
| 294 |
+
all: pick({ q: '', outcome: '' }),
|
| 295 |
+
byIP: pick({ q: '203.0.113', outcome: '' }),
|
| 296 |
+
byUA: pick({ q: 'chrome/120', outcome: '' }),
|
| 297 |
+
byModel: pick({ q: 'glm', outcome: '' }),
|
| 298 |
+
multiKw: pick({ q: 'glm success', outcome: '' }),
|
| 299 |
+
multiMiss: pick({ q: 'glm chrome', outcome: '' }),
|
| 300 |
+
byOutcome: pick({ q: '', outcome: 'http_error' }),
|
| 301 |
+
combined: pick({ q: '198.51', outcome: 'http_error' }),
|
| 302 |
+
}));`
|
| 303 |
+
f, err := os.CreateTemp(t.TempDir(), "request-filter-*.cjs")
|
| 304 |
+
if err != nil {
|
| 305 |
+
t.Fatal(err)
|
| 306 |
+
}
|
| 307 |
+
if _, err := f.WriteString(script); err != nil {
|
| 308 |
+
t.Fatal(err)
|
| 309 |
+
}
|
| 310 |
+
f.Close()
|
| 311 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 312 |
+
if err != nil {
|
| 313 |
+
t.Fatalf("request filter node test failed: %v\n%s", err, out)
|
| 314 |
+
}
|
| 315 |
+
// q 对 outcome 不参与匹配(outcome 有独立下拉),multiKw 里的 success 命中不了任何字段。
|
| 316 |
+
const want = `{"all":["req-1","req-2"],"byIP":["req-1"],"byUA":["req-2"],"byModel":["req-1"],"multiKw":[],"multiMiss":[],"byOutcome":["req-2"],"combined":["req-2"]}`
|
| 317 |
+
if strings.TrimSpace(string(out)) != want {
|
| 318 |
+
t.Fatalf("request filter=%s want %s", out, want)
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// 模型按条件查询:域 / 能力 / 档位 / 价格 / 关键词,以及倍率、上下文、输出排序。
|
| 323 |
+
func TestAppJSModelFilter(t *testing.T) {
|
| 324 |
+
node, err := exec.LookPath("node")
|
| 325 |
+
if err != nil {
|
| 326 |
+
t.Skip("node not installed; model filter test skipped")
|
| 327 |
+
}
|
| 328 |
+
script := `const fs = require('fs');
|
| 329 |
+
const vm = require('vm');
|
| 330 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 331 |
+
const start = src.indexOf('function mdRateValue');
|
| 332 |
+
const end = src.indexOf('function mdRowHtml');
|
| 333 |
+
if (start < 0 || end < 0) throw new Error('model filter helpers not found');
|
| 334 |
+
const ctx = { Number, String, Array, Object, isFinite, parseFloat };
|
| 335 |
+
vm.createContext(ctx);
|
| 336 |
+
vm.runInContext(src.slice(start, end) + '\nthis.mdMatch=mdMatch; this.mdSortList=mdSortList; this.mdRateValue=mdRateValue;', ctx);
|
| 337 |
+
const models = [
|
| 338 |
+
{ id: 'cn:glm-5.2', name: 'GLM-5.2', vendor: 'Zhipu', tags: ['视觉'], supports_tool_call: true, supports_images: true, supports_reasoning: true, can_disable_thinking: true, supported_efforts: ['high', 'xhigh'], default_effort: 'high', is_default: false, credits: '0.79', promo_factor: 0.5, promo_credits: '0.40', promo_label: '夜间折扣', context_length: 1000000, max_output_tokens: 131000 },
|
| 339 |
+
{ id: 'cn:hy3', name: 'Hy3', supports_tool_call: true, supports_images: true, supports_reasoning: true, can_disable_thinking: false, supported_efforts: ['low', 'high'], default_effort: 'high', is_default: false, credits: '0', promo_factor: 0, promo_credits: '0', promo_label: '限时免费', context_length: 192000, max_output_tokens: 64000 },
|
| 340 |
+
{ id: 'global:hy3', name: 'Hy3 Global', supports_tool_call: false, supports_images: false, supports_reasoning: false, supported_efforts: [], is_default: false, credits: '0.11', context_length: 1000000, max_output_tokens: 393000 },
|
| 341 |
+
{ id: 'cn:auto', name: 'Auto', supports_tool_call: true, supports_images: true, supports_reasoning: true, is_default: true, credits: null, context_length: 256000, max_output_tokens: 32000 },
|
| 342 |
+
];
|
| 343 |
+
const ids = list => list.map(m => m.id);
|
| 344 |
+
const filter = f => ids(ctx.mdSortList(models.filter(m => ctx.mdMatch(m, f)), f));
|
| 345 |
+
process.stdout.write(JSON.stringify({
|
| 346 |
+
all: ids(models),
|
| 347 |
+
realm: filter({ realm: 'cn' }),
|
| 348 |
+
tool: filter({ cap: 'tool' }),
|
| 349 |
+
vision: filter({ cap: 'vision' }),
|
| 350 |
+
reasoning: filter({ cap: 'reasoning' }),
|
| 351 |
+
isDefault: filter({ cap: 'default' }),
|
| 352 |
+
effortOff: filter({ effort: 'off' }),
|
| 353 |
+
effortLow: filter({ effort: 'low' }),
|
| 354 |
+
free: filter({ promo: 'free' }),
|
| 355 |
+
promo: filter({ promo: 'promo' }),
|
| 356 |
+
discount: filter({ promo: 'discount' }),
|
| 357 |
+
q: filter({ q: 'glm zhipu' }),
|
| 358 |
+
qMiss: filter({ q: 'glm nosuch' }),
|
| 359 |
+
sortRate: filter({ sort: 'rate' }),
|
| 360 |
+
sortContext: filter({ sort: 'context' }),
|
| 361 |
+
sortOutput: filter({ sort: 'output' }),
|
| 362 |
+
sortName: filter({ sort: 'name' }),
|
| 363 |
+
rateFree: ctx.mdRateValue(models[1]),
|
| 364 |
+
rateMissing: ctx.mdRateValue(models[3]),
|
| 365 |
+
}));`
|
| 366 |
+
f, err := os.CreateTemp(t.TempDir(), "model-filter-*.cjs")
|
| 367 |
+
if err != nil {
|
| 368 |
+
t.Fatal(err)
|
| 369 |
+
}
|
| 370 |
+
if _, err := f.WriteString(script); err != nil {
|
| 371 |
+
t.Fatal(err)
|
| 372 |
+
}
|
| 373 |
+
f.Close()
|
| 374 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 375 |
+
if err != nil {
|
| 376 |
+
t.Fatalf("model filter node test failed: %v\n%s", err, out)
|
| 377 |
+
}
|
| 378 |
+
const want = `{"all":["cn:glm-5.2","cn:hy3","global:hy3","cn:auto"],` +
|
| 379 |
+
`"realm":["cn:glm-5.2","cn:hy3","cn:auto"],` +
|
| 380 |
+
`"tool":["cn:glm-5.2","cn:hy3","cn:auto"],` +
|
| 381 |
+
`"vision":["cn:glm-5.2","cn:hy3","cn:auto"],` +
|
| 382 |
+
`"reasoning":["cn:glm-5.2","cn:hy3","cn:auto"],` +
|
| 383 |
+
`"isDefault":["cn:auto"],` +
|
| 384 |
+
`"effortOff":["cn:glm-5.2"],` +
|
| 385 |
+
`"effortLow":["cn:hy3"],` +
|
| 386 |
+
`"free":["cn:hy3"],` +
|
| 387 |
+
`"promo":["cn:glm-5.2","cn:hy3"],` +
|
| 388 |
+
`"discount":["cn:glm-5.2"],` +
|
| 389 |
+
`"q":["cn:glm-5.2"],` +
|
| 390 |
+
`"qMiss":[],` +
|
| 391 |
+
`"sortRate":["cn:hy3","global:hy3","cn:glm-5.2","cn:auto"],` +
|
| 392 |
+
`"sortContext":["cn:glm-5.2","global:hy3","cn:auto","cn:hy3"],` +
|
| 393 |
+
`"sortOutput":["global:hy3","cn:glm-5.2","cn:hy3","cn:auto"],` +
|
| 394 |
+
`"sortName":["cn:auto","cn:glm-5.2","cn:hy3","global:hy3"],` +
|
| 395 |
+
`"rateFree":0,"rateMissing":null}`
|
| 396 |
+
if strings.TrimSpace(string(out)) != want {
|
| 397 |
+
t.Fatalf("model filter=%s\nwant %s", out, want)
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// 用量时序图的柱体类名不得叫 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里
|
| 402 |
+
// height 是 rect 的 CSS 几何属性——同名类会把每根柱子压成 3px 高,图看起来"没数据"。
|
| 403 |
+
// 这个坑只能在浏览器里看出来,所以在这里钉住类名。
|
| 404 |
+
func TestAppJSUsageChartBarClass(t *testing.T) {
|
| 405 |
+
node, err := exec.LookPath("node")
|
| 406 |
+
if err != nil {
|
| 407 |
+
t.Skip("node not installed; usage chart test skipped")
|
| 408 |
+
}
|
| 409 |
+
script := `const fs = require('fs');
|
| 410 |
+
const vm = require('vm');
|
| 411 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 412 |
+
const start = src.indexOf('function parsePointTime');
|
| 413 |
+
const end = src.indexOf('function fmtTokTip');
|
| 414 |
+
const escStart = src.indexOf('function esc(');
|
| 415 |
+
const escEnd = src.indexOf('function ago(');
|
| 416 |
+
const fmtStart = src.indexOf('function fmtTok(');
|
| 417 |
+
const fmtEnd = src.indexOf('function usStat(');
|
| 418 |
+
if ([start, end, escStart, escEnd, fmtStart, fmtEnd].some(v => v < 0)) throw new Error('usage chart helpers not found');
|
| 419 |
+
const host = { innerHTML: '', textContent: '' };
|
| 420 |
+
const ctx = {
|
| 421 |
+
Date, Number, String, Math, RegExp, isNaN, Set, Array, Object, Infinity,
|
| 422 |
+
document: { getElementById: () => host },
|
| 423 |
+
$: () => host,
|
| 424 |
+
};
|
| 425 |
+
vm.createContext(ctx);
|
| 426 |
+
vm.runInContext(src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(start, end) +
|
| 427 |
+
'\nthis.renderUsageChart=renderUsageChart;', ctx);
|
| 428 |
+
const series = [
|
| 429 |
+
{ t: '2026-09-30T09', scope: 'hour', prompt_tokens: 35, completion_tokens: 16, total_tokens: 51, requests: 1 },
|
| 430 |
+
{ t: '2026-09-30T11', scope: 'hour', prompt_tokens: 978324, completion_tokens: 20621, total_tokens: 998945, requests: 39 },
|
| 431 |
+
{ t: '2026-09-30T13', scope: 'hour', prompt_tokens: 27400952, completion_tokens: 104913, total_tokens: 27505865, requests: 200 },
|
| 432 |
+
];
|
| 433 |
+
ctx.renderUsageChart(series);
|
| 434 |
+
const svg = host.innerHTML;
|
| 435 |
+
process.stdout.write(JSON.stringify({
|
| 436 |
+
hasUsbar: svg.includes('class="usbar"'),
|
| 437 |
+
hasBareBar: /class="bar"/.test(svg),
|
| 438 |
+
hasGradient: svg.includes('usGradP') && svg.includes('usGradC'),
|
| 439 |
+
barCount: (svg.match(/class="usbar"/g) || []).length,
|
| 440 |
+
hasPeak: svg.includes('峰值'),
|
| 441 |
+
hasAvg: svg.includes('均值'),
|
| 442 |
+
emptyState: (function () { ctx.renderUsageChart([]); return host.innerHTML.includes('us-empty'); })(),
|
| 443 |
+
}));`
|
| 444 |
+
f, err := os.CreateTemp(t.TempDir(), "usage-chart-*.cjs")
|
| 445 |
+
if err != nil {
|
| 446 |
+
t.Fatal(err)
|
| 447 |
+
}
|
| 448 |
+
if _, err := f.WriteString(script); err != nil {
|
| 449 |
+
t.Fatal(err)
|
| 450 |
+
}
|
| 451 |
+
f.Close()
|
| 452 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 453 |
+
if err != nil {
|
| 454 |
+
t.Fatalf("usage chart node test failed: %v\n%s", err, out)
|
| 455 |
+
}
|
| 456 |
+
const want = `{"hasUsbar":true,"hasBareBar":false,"hasGradient":true,"barCount":6,"hasPeak":true,"hasAvg":true,"emptyState":true}`
|
| 457 |
+
if strings.TrimSpace(string(out)) != want {
|
| 458 |
+
t.Fatalf("usage chart=%s\nwant %s", out, want)
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// 时间范围控件:预设 → 查询参数的映射。要点:
|
| 463 |
+
// - 「今天」必须发浏览器本地时区的 00:00(服务端时区未必一致),且不带 to;
|
| 464 |
+
// - 滚动预设 rolling=true 发 hours(服务端整点对齐),rolling=false 折算成 from;
|
| 465 |
+
// - 「全部历史」两者都不发;「自定义」发用户挑的 from/to。
|
| 466 |
+
func TestAppJSTimeRangeQuery(t *testing.T) {
|
| 467 |
+
node, err := exec.LookPath("node")
|
| 468 |
+
if err != nil {
|
| 469 |
+
t.Skip("node not installed; time range test skipped")
|
| 470 |
+
}
|
| 471 |
+
script := `const fs = require('fs');
|
| 472 |
+
const vm = require('vm');
|
| 473 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 474 |
+
const start = src.indexOf('const TRANGE_PRESETS');
|
| 475 |
+
const end = src.indexOf('function rateLimitMeta');
|
| 476 |
+
if (start < 0 || end < 0 || end < start) throw new Error('trange helpers not found');
|
| 477 |
+
const host = { innerHTML: '' };
|
| 478 |
+
const ctx = {
|
| 479 |
+
Date, Number, String, Math, Map, Array, Object, isNaN, URLSearchParams,
|
| 480 |
+
document: { getElementById: () => host },
|
| 481 |
+
$: () => host,
|
| 482 |
+
esc: s => String(s == null ? '' : s),
|
| 483 |
+
};
|
| 484 |
+
vm.createContext(ctx);
|
| 485 |
+
vm.runInContext(src.slice(start, end) +
|
| 486 |
+
'\nthis.trangeState=trangeState; this.trangeQuery=trangeQuery; this.trangeLabel=trangeLabel; this.trangeMidnight=trangeMidnight;', ctx);
|
| 487 |
+
const q = (preset, rolling) => {
|
| 488 |
+
ctx.trangeState('t').preset = preset;
|
| 489 |
+
return ctx.trangeQuery('t', rolling).toString();
|
| 490 |
+
};
|
| 491 |
+
const secOf = d => String(Math.floor(d.getTime() / 1000));
|
| 492 |
+
const approx = (qs, wantSec) => {
|
| 493 |
+
const m = /(?:^|&)from=(\d+)/.exec(qs);
|
| 494 |
+
return m && Math.abs(Number(m[1]) - wantSec) < 120;
|
| 495 |
+
};
|
| 496 |
+
const now = Date.now();
|
| 497 |
+
const todayQ = q('today', true);
|
| 498 |
+
process.stdout.write(JSON.stringify({
|
| 499 |
+
todayIsMidnight: todayQ === 'from=' + secOf(ctx.trangeMidnight()),
|
| 500 |
+
todayNoTo: !/to=/.test(todayQ),
|
| 501 |
+
rolling24: q('24', true),
|
| 502 |
+
rolling72: q('72', true),
|
| 503 |
+
rolling0: q('0', true),
|
| 504 |
+
log24From: approx(q('24', false), Math.floor((now - 24 * 3600e3) / 1000)),
|
| 505 |
+
log24HasHours: /hours=/.test(q('24', false)),
|
| 506 |
+
log7dFrom: approx(q('168', false), Math.floor((now - 168 * 3600e3) / 1000)),
|
| 507 |
+
custom: (function () {
|
| 508 |
+
const st = ctx.trangeState('t');
|
| 509 |
+
st.preset = 'custom';
|
| 510 |
+
st.from = new Date(2026, 8, 30, 9, 0, 0);
|
| 511 |
+
st.to = new Date(2026, 8, 30, 18, 30, 0);
|
| 512 |
+
return ctx.trangeQuery('t', true).toString();
|
| 513 |
+
})(),
|
| 514 |
+
labelCustom: ctx.trangeLabel('t'),
|
| 515 |
+
labelToday: (function () { ctx.trangeState('t').preset = 'today'; return ctx.trangeLabel('t'); })(),
|
| 516 |
+
}));`
|
| 517 |
+
f, err := os.CreateTemp(t.TempDir(), "trange-*.cjs")
|
| 518 |
+
if err != nil {
|
| 519 |
+
t.Fatal(err)
|
| 520 |
+
}
|
| 521 |
+
if _, err := f.WriteString(script); err != nil {
|
| 522 |
+
t.Fatal(err)
|
| 523 |
+
}
|
| 524 |
+
f.Close()
|
| 525 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 526 |
+
if err != nil {
|
| 527 |
+
t.Fatalf("time range node test failed: %v\n%s", err, out)
|
| 528 |
+
}
|
| 529 |
+
local := func(h, m int) string {
|
| 530 |
+
return strconv.FormatInt(time.Date(2026, 9, 30, h, m, 0, 0, time.Local).Unix(), 10)
|
| 531 |
+
}
|
| 532 |
+
want := `{"todayIsMidnight":true,"todayNoTo":true,` +
|
| 533 |
+
`"rolling24":"hours=24","rolling72":"hours=72","rolling0":"",` +
|
| 534 |
+
`"log24From":true,"log24HasHours":false,"log7dFrom":true,` +
|
| 535 |
+
`"custom":"from=` + local(9, 0) + `&to=` + local(18, 30) + `",` +
|
| 536 |
+
`"labelCustom":"9-30 09:00 → 9-30 18:30","labelToday":"今天"}`
|
| 537 |
+
if strings.TrimSpace(string(out)) != want {
|
| 538 |
+
t.Fatalf("time range=%s\nwant %s", out, want)
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
// 配置表单与 CFG_MAP 必须一一对应,且面板声称"可在线改"的热生效键必须真的
|
| 543 |
+
// 出现在表单里。
|
| 544 |
+
//
|
| 545 |
+
// 为什么需要:`logging.request_client_info` 曾经在表单里存在过,后来在某次改动中
|
| 546 |
+
// 被连带删掉,而 Go 侧的配置键、livecfg 热生效通路、README 的描述都还在——面板
|
| 547 |
+
// 少了一个开关而 Go 测试全绿,只有人肉点开配置页才会发现。这里把"表单字段 ↔
|
| 548 |
+
// CFG_MAP"与"关键热改键必须在表单里"两条都钉住。
|
| 549 |
+
func TestConfigFormMatchesCFGMap(t *testing.T) {
|
| 550 |
+
src, err := os.ReadFile("app.js")
|
| 551 |
+
if err != nil {
|
| 552 |
+
t.Fatal(err)
|
| 553 |
+
}
|
| 554 |
+
js := string(src)
|
| 555 |
+
htmlBytes, err := os.ReadFile("index.html")
|
| 556 |
+
if err != nil {
|
| 557 |
+
t.Fatal(err)
|
| 558 |
+
}
|
| 559 |
+
html := string(htmlBytes)
|
| 560 |
+
|
| 561 |
+
// CFG_MAP 块(下面两条检查共用)。
|
| 562 |
+
mapBlock := js[strings.Index(js, "const CFG_MAP = {"):]
|
| 563 |
+
mapBlock = mapBlock[:strings.Index(mapBlock, "\n};")]
|
| 564 |
+
// 不能按行首匹配:CFG_MAP 里多个键写在同一行(`a: [...], b: [...]`),只有行首
|
| 565 |
+
// 那个带换行缩进。按「前面是行首或分隔符」判定才不漏。
|
| 566 |
+
inMap := func(name string) bool {
|
| 567 |
+
return regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `:\s*\[`).MatchString(mapBlock)
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
// 1) 表单里的每个 name 都要有 CFG_MAP 条目(否则收集/回填都拿不到它)。
|
| 571 |
+
form := html[strings.Index(html, `<form id="cfgForm">`):]
|
| 572 |
+
form = form[:strings.Index(form, "</form>")]
|
| 573 |
+
names := map[string]bool{}
|
| 574 |
+
for _, m := range regexp.MustCompile(`name="([a-z_0-9]+)"`).FindAllStringSubmatch(form, -1) {
|
| 575 |
+
names[m[1]] = true
|
| 576 |
+
}
|
| 577 |
+
if len(names) == 0 {
|
| 578 |
+
t.Fatal("未从配置表单解析出任何 name 字段")
|
| 579 |
+
}
|
| 580 |
+
for n := range names {
|
| 581 |
+
if !inMap(n) {
|
| 582 |
+
t.Errorf("表单字段 %q 在 CFG_MAP 里没有条目(保存时会被静默丢弃)", n)
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// 2) CFG_MAP 里的每个键都要在表单里有控件(否则回填/保存是空转)。
|
| 587 |
+
for _, m := range regexp.MustCompile(`(?:^|[\s,{])([a-z_0-9]+):\s*\[`).FindAllStringSubmatch(mapBlock, -1) {
|
| 588 |
+
if !names[m[1]] {
|
| 589 |
+
t.Errorf("CFG_MAP 键 %q 在配置表单里没有对应控件", m[1])
|
| 590 |
+
}
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
// 3) 明确断言这一个键:后端有配置项、README 说面板可改,UI 不能少。
|
| 594 |
+
if !strings.Contains(js, "request_client_info: ['logging', 'request_client_info']") {
|
| 595 |
+
t.Error("CFG_MAP 缺 request_client_info 条目")
|
| 596 |
+
}
|
| 597 |
+
if !names["request_client_info"] {
|
| 598 |
+
t.Error("配置表单缺「记录调用来源」开关(logging.request_client_info)")
|
| 599 |
+
}
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
// 同到期时间按面额降序;其余未用完包与零/负余额包分别聚合。
|
| 603 |
+
func TestAppJSDetailGroups(t *testing.T) {
|
| 604 |
+
node, err := exec.LookPath("node")
|
| 605 |
+
if err != nil {
|
| 606 |
+
t.Skip("node not installed; detail groups test skipped")
|
| 607 |
+
}
|
| 608 |
+
script := `const fs = require('fs');
|
| 609 |
+
const vm = require('vm');
|
| 610 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 611 |
+
const start = src.indexOf('const PK_DEFAULT_DETAIL_LIMIT');
|
| 612 |
+
const end = src.indexOf('function renderPackages');
|
| 613 |
+
if (start < 0 || end < 0) throw new Error('detail group functions not found');
|
| 614 |
+
const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite };
|
| 615 |
+
vm.createContext(ctx);
|
| 616 |
+
vm.runInContext(src.slice(start, end) + '\nthis.pkDetailGroups = pkDetailGroups; this.pkDetailLimit = pkDetailLimit;', ctx);
|
| 617 |
+
const input = [
|
| 618 |
+
{ id: 'small-late', size: 100, remain: 1, expires_at: 400 },
|
| 619 |
+
{ id: 'zero-early-b', size: 200, remain: 0, expires_at: 200 },
|
| 620 |
+
{ id: 'small-early', size: 100, remain: 2, expires_at: 200 },
|
| 621 |
+
{ id: 'large-unknown', size: 300, remain: 3, end_time: '' },
|
| 622 |
+
{ id: 'zero-early-a', size: 200, remain: -1, expires_at: 200 },
|
| 623 |
+
{ id: 'small-unknown', size: 100, remain: 1, end_time: '' },
|
| 624 |
+
{ id: 'large-early', size: 300, remain: 4, expires_at: 200 },
|
| 625 |
+
{ id: 'zero-late', size: 300, remain: 0, expires_at: 300 },
|
| 626 |
+
];
|
| 627 |
+
const before = input.map(p => p.id).join(',');
|
| 628 |
+
const out = ctx.pkDetailGroups(input, 2);
|
| 629 |
+
process.stdout.write(JSON.stringify({
|
| 630 |
+
visible: out.visible.map(p => p.id),
|
| 631 |
+
rest: out.rest.map(p => p.id),
|
| 632 |
+
used: out.used.map(p => p.id),
|
| 633 |
+
restSize: out.restSize,
|
| 634 |
+
restRemain: out.restRemain,
|
| 635 |
+
usedSize: out.usedSize,
|
| 636 |
+
defaultLimit: ctx.pkDetailLimit({}),
|
| 637 |
+
configuredLimit: ctx.pkDetailLimit({ panel: { package_detail_limit: 7 } }),
|
| 638 |
+
unchanged: input.map(p => p.id).join(',') === before,
|
| 639 |
+
}));`
|
| 640 |
+
f, err := os.CreateTemp(t.TempDir(), "detail-groups-*.cjs")
|
| 641 |
+
if err != nil {
|
| 642 |
+
t.Fatal(err)
|
| 643 |
+
}
|
| 644 |
+
if _, err := f.WriteString(script); err != nil {
|
| 645 |
+
t.Fatal(err)
|
| 646 |
+
}
|
| 647 |
+
f.Close()
|
| 648 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 649 |
+
if err != nil {
|
| 650 |
+
t.Fatalf("detail groups node test failed: %v\n%s", err, out)
|
| 651 |
+
}
|
| 652 |
+
const want = `{"visible":["large-early","small-early"],"rest":["small-late","large-unknown","small-unknown"],"used":["zero-early-b","zero-early-a","zero-late"],"restSize":500,"restRemain":5,"usedSize":700,"defaultLimit":5,"configuredLimit":7,"unchanged":true}`
|
| 653 |
+
if strings.TrimSpace(string(out)) != want {
|
| 654 |
+
t.Fatalf("detail groups=%s want %s", out, want)
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
// 精确剩余天数聚合、账号内按总余额钳制、无到期批次不进入图表。
|
| 659 |
+
func TestAppJSExpirySummary(t *testing.T) {
|
| 660 |
+
node, err := exec.LookPath("node")
|
| 661 |
+
if err != nil {
|
| 662 |
+
t.Skip("node not installed; expiry summary test skipped")
|
| 663 |
+
}
|
| 664 |
+
script := `const fs = require('fs');
|
| 665 |
+
const vm = require('vm');
|
| 666 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 667 |
+
const start = src.indexOf('const PK_ACCOUNT_COLORS');
|
| 668 |
+
const end = src.indexOf('function renderExpiryDistribution');
|
| 669 |
+
if (start < 0 || end < 0) throw new Error('expiry summary functions not found');
|
| 670 |
+
const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite };
|
| 671 |
+
vm.createContext(ctx);
|
| 672 |
+
vm.runInContext(src.slice(start, end) + '\nthis.summarizeCreditDays = summarizeCreditDays; this.pkAccountColorMap = pkAccountColorMap;', ctx);
|
| 673 |
+
const day = 86400000, now = 100000;
|
| 674 |
+
const out = ctx.summarizeCreditDays([
|
| 675 |
+
{ uid: 'a', remain: 100, packages: [
|
| 676 |
+
{ name: 'soon-a', remain: 30, expires_at: now + day },
|
| 677 |
+
{ name: 'later', remain: 70, expires_at: now + 7 * day },
|
| 678 |
+
] },
|
| 679 |
+
{ uid: 'b', remain: 55, packages: [
|
| 680 |
+
{ name: 'soon-b', remain: 20, expires_at: now + day },
|
| 681 |
+
{ name: 'unknown', remain: 5, end_time: '' },
|
| 682 |
+
] },
|
| 683 |
+
{ uid: 'err', error: 'offline' },
|
| 684 |
+
], now);
|
| 685 |
+
process.stdout.write(JSON.stringify({
|
| 686 |
+
rows: out.rows.map(row => ({ days: row.days, credits: row.credits })),
|
| 687 |
+
accountCount: out.accountCount,
|
| 688 |
+
unavailable: out.unavailable,
|
| 689 |
+
colorA: ctx.pkAccountColorMap([{ uid: 'b' }, { uid: 'a' }]).get('a'),
|
| 690 |
+
colorB: ctx.pkAccountColorMap([{ uid: 'a' }, { uid: 'b' }]).get('b'),
|
| 691 |
+
}));`
|
| 692 |
+
f, err := os.CreateTemp(t.TempDir(), "expiry-*.cjs")
|
| 693 |
+
if err != nil {
|
| 694 |
+
t.Fatal(err)
|
| 695 |
+
}
|
| 696 |
+
if _, err := f.WriteString(script); err != nil {
|
| 697 |
+
t.Fatal(err)
|
| 698 |
+
}
|
| 699 |
+
f.Close()
|
| 700 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 701 |
+
if err != nil {
|
| 702 |
+
t.Fatalf("expiry summary node test failed: %v\n%s", err, out)
|
| 703 |
+
}
|
| 704 |
+
const want = `{"rows":[{"days":1,"credits":50},{"days":7,"credits":70}],"accountCount":3,"unavailable":1,"colorA":"#4f8cff","colorB":"#25b08b"}`
|
| 705 |
+
if strings.TrimSpace(string(out)) != want {
|
| 706 |
+
t.Fatalf("expiry summary=%s want %s", out, want)
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
// TestAppJSCollectConfigClearable 钉住 collectConfig 的空串语义。
|
| 711 |
+
//
|
| 712 |
+
// 覆盖型字段(user_agent / prompt_file)空串必须照发:漏发会让面板显示"已保存"
|
| 713 |
+
// 而 config.json 里的值没变(issue #102 附带发现 2)。
|
| 714 |
+
//
|
| 715 |
+
// 同时钉住反面:其余文本字段空串仍然不下发。这条同样重要——若哪天为了修上面那个
|
| 716 |
+
// 问题改成"所有���串都发",表单里任何一个没填的框都会变成"请清空",静默抹掉配置。
|
| 717 |
+
func TestAppJSCollectConfigClearable(t *testing.T) {
|
| 718 |
+
node, err := exec.LookPath("node")
|
| 719 |
+
if err != nil {
|
| 720 |
+
t.Skip("node not installed; collectConfig test skipped")
|
| 721 |
+
}
|
| 722 |
+
script := `const fs = require('fs');
|
| 723 |
+
const vm = require('vm');
|
| 724 |
+
const src = fs.readFileSync(process.argv[2], 'utf8');
|
| 725 |
+
const start = src.indexOf('const CFG_MAP');
|
| 726 |
+
const end = src.indexOf('/* Go 时长字段即时校验');
|
| 727 |
+
if (start < 0 || end < 0 || end < start) throw new Error('collectConfig region not found');
|
| 728 |
+
const mk = v => ({ type: 'text', value: v });
|
| 729 |
+
const cfgForm = { elements: {
|
| 730 |
+
listen: mk(''),
|
| 731 |
+
api_key: mk('secret'),
|
| 732 |
+
user_agent: mk(''),
|
| 733 |
+
prompt_file: mk(''),
|
| 734 |
+
checkin_hours: mk(''),
|
| 735 |
+
}};
|
| 736 |
+
const ctx = {
|
| 737 |
+
Date, Number, String, Math, Map, Array, Object, isNaN, URLSearchParams, Set,
|
| 738 |
+
document: { getElementById: id => (id === 'cfgForm' ? cfgForm : null) },
|
| 739 |
+
$: id => (id === 'cfgForm' ? cfgForm : null),
|
| 740 |
+
};
|
| 741 |
+
vm.createContext(ctx);
|
| 742 |
+
vm.runInContext(src.slice(start, end) + '\nthis.collectConfig = collectConfig;', ctx);
|
| 743 |
+
const out = ctx.collectConfig();
|
| 744 |
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k);
|
| 745 |
+
process.stdout.write(JSON.stringify([
|
| 746 |
+
has(out.upstream, 'user_agent'), (out.upstream || {}).user_agent,
|
| 747 |
+
has(out.prompt, 'file'), (out.prompt || {}).file,
|
| 748 |
+
has(out, 'listen'),
|
| 749 |
+
has(out.schedule, 'checkin_hours'),
|
| 750 |
+
out.api_key
|
| 751 |
+
]));`
|
| 752 |
+
f, err := os.CreateTemp(t.TempDir(), "cfgc-*.cjs")
|
| 753 |
+
if err != nil {
|
| 754 |
+
t.Fatal(err)
|
| 755 |
+
}
|
| 756 |
+
if _, err := f.WriteString(script); err != nil {
|
| 757 |
+
t.Fatal(err)
|
| 758 |
+
}
|
| 759 |
+
f.Close()
|
| 760 |
+
out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
|
| 761 |
+
if err != nil {
|
| 762 |
+
t.Fatalf("collectConfig node test failed: %v\n%s", err, out)
|
| 763 |
+
}
|
| 764 |
+
// [user_agent 已发, 其值, prompt.file 已发, 其值, listen 未发, checkin_hours 未发, api_key]
|
| 765 |
+
const want = `[true,"",true,"",false,false,"secret"]`
|
| 766 |
+
if strings.TrimSpace(string(out)) != want {
|
| 767 |
+
t.Fatalf("collectConfig=%s want %s", strings.TrimSpace(string(out)), want)
|
| 768 |
+
}
|
| 769 |
+
}
|
internal/panel/import.go
CHANGED
|
@@ -1,181 +1,181 @@
|
|
| 1 |
-
package panel
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"encoding/json"
|
| 5 |
-
"fmt"
|
| 6 |
-
"io"
|
| 7 |
-
"log"
|
| 8 |
-
"net/http"
|
| 9 |
-
"path/filepath"
|
| 10 |
-
"strings"
|
| 11 |
-
"time"
|
| 12 |
-
|
| 13 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
// cockpitAccount 映射 cockpit tools 导出格式的单个账号。
|
| 17 |
-
type cockpitAccount struct {
|
| 18 |
-
ID string `json:"id"`
|
| 19 |
-
Email string `json:"email"`
|
| 20 |
-
UID string `json:"uid"`
|
| 21 |
-
Nickname string `json:"nickname"`
|
| 22 |
-
AccessToken string `json:"access_token"`
|
| 23 |
-
RefreshToken string `json:"refresh_token"`
|
| 24 |
-
TokenType string `json:"token_type"`
|
| 25 |
-
ExpiresAt int64 `json:"expires_at"`
|
| 26 |
-
Domain string `json:"domain"`
|
| 27 |
-
DosageNotify string `json:"dosage_notify_code"`
|
| 28 |
-
PaymentType string `json:"payment_type"`
|
| 29 |
-
Status string `json:"status"`
|
| 30 |
-
UsageUpdatedAt int64 `json:"usage_updated_at"`
|
| 31 |
-
LastCheckin int64 `json:"last_checkin_time"`
|
| 32 |
-
CheckinStreak int `json:"checkin_streak"`
|
| 33 |
-
CreatedAt int64 `json:"created_at"`
|
| 34 |
-
LastUsed int64 `json:"last_used"`
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
// importCockpit 接收 cockpit tools 导出的 JSON 文件,批量导入账号到池中。
|
| 38 |
-
//
|
| 39 |
-
// POST /panel/api/import/cockpit
|
| 40 |
-
// Content-Type: multipart/form-data
|
| 41 |
-
// Body: file=<json>
|
| 42 |
-
//
|
| 43 |
-
// 返回 {ok, total, imported, skipped, errors}。
|
| 44 |
-
func (p *Panel) importCockpit(w http.ResponseWriter, r *http.Request) {
|
| 45 |
-
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
| 46 |
-
writeErr(w, http.StatusBadRequest, "parse form: "+err.Error())
|
| 47 |
-
return
|
| 48 |
-
}
|
| 49 |
-
file, _, err := r.FormFile("file")
|
| 50 |
-
if err != nil {
|
| 51 |
-
writeErr(w, http.StatusBadRequest, "missing file field: "+err.Error())
|
| 52 |
-
return
|
| 53 |
-
}
|
| 54 |
-
defer file.Close()
|
| 55 |
-
|
| 56 |
-
raw, err := io.ReadAll(file)
|
| 57 |
-
if err != nil {
|
| 58 |
-
writeErr(w, http.StatusBadRequest, "read file: "+err.Error())
|
| 59 |
-
return
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
var accounts []cockpitAccount
|
| 63 |
-
if err := json.Unmarshal(raw, &accounts); err != nil {
|
| 64 |
-
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
| 65 |
-
return
|
| 66 |
-
}
|
| 67 |
-
if len(accounts) == 0 {
|
| 68 |
-
writeErr(w, http.StatusBadRequest, "empty accounts array")
|
| 69 |
-
return
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
var total, imported, skipped int
|
| 73 |
-
var errs []string
|
| 74 |
-
|
| 75 |
-
for _, acc := range accounts {
|
| 76 |
-
uid := strings.TrimSpace(acc.UID)
|
| 77 |
-
at := strings.TrimSpace(acc.AccessToken)
|
| 78 |
-
rt := strings.TrimSpace(acc.RefreshToken)
|
| 79 |
-
if uid == "" || at == "" || rt == "" {
|
| 80 |
-
skipped++
|
| 81 |
-
errs = append(errs, fmt.Sprintf("missing required fields (id=%s)", acc.ID))
|
| 82 |
-
continue
|
| 83 |
-
}
|
| 84 |
-
if !validImportUID(uid) {
|
| 85 |
-
skipped++
|
| 86 |
-
errs = append(errs, fmt.Sprintf("invalid uid (id=%s)", acc.ID))
|
| 87 |
-
continue
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
// 按 domain 推断 realm:workbuddy.ai 家族 → global,否则 cn。
|
| 91 |
-
realm := auth.ResolveRealm("", acc.Domain)
|
| 92 |
-
|
| 93 |
-
// cockpit tools 的 expires_at 为毫秒时间戳,转为秒。
|
| 94 |
-
expiresAt := acc.ExpiresAt / 1000
|
| 95 |
-
if expiresAt <= 0 {
|
| 96 |
-
expiresAt = time.Now().Add(365 * 24 * time.Hour).Unix()
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
nickname := acc.Nickname
|
| 100 |
-
if strings.TrimSpace(nickname) == "" {
|
| 101 |
-
nickname = acc.Email
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
a := &auth.Auth{
|
| 105 |
-
AccessToken: at,
|
| 106 |
-
RefreshToken: rt,
|
| 107 |
-
ExpiresAt: expiresAt,
|
| 108 |
-
Domain: acc.Domain,
|
| 109 |
-
UID: uid,
|
| 110 |
-
Nickname: nickname,
|
| 111 |
-
FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", uid)),
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
if realm == "global" {
|
| 115 |
-
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
|
| 116 |
-
skipped++
|
| 117 |
-
errs = append(errs, fmt.Sprintf("uid=%s: set realm failed: %v", uid, err))
|
| 118 |
-
continue
|
| 119 |
-
}
|
| 120 |
-
} else {
|
| 121 |
-
_, _ = a.BackfillRealm()
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
if err := a.SaveAtomic(); err != nil {
|
| 125 |
-
skipped++
|
| 126 |
-
errs = append(errs, fmt.Sprintf("uid=%s: save auth failed: %v", uid, err))
|
| 127 |
-
continue
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
p.cfg.Pool.Add(a)
|
| 131 |
-
p.cfg.Pool.Revive(uid)
|
| 132 |
-
|
| 133 |
-
// 顺带签到/激活(幂等;失败仅记日志,不阻断导入)。
|
| 134 |
-
if realm == "global" {
|
| 135 |
-
if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil {
|
| 136 |
-
log.Printf("panel: import global 注册激活 uid=%s: %v", uid, err)
|
| 137 |
-
} else if activated {
|
| 138 |
-
log.Printf("panel: import global 注册激活 uid=%s 完成", uid)
|
| 139 |
-
}
|
| 140 |
-
if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil {
|
| 141 |
-
log.Printf("panel: import global trial uid=%s: %v", uid, err)
|
| 142 |
-
} else if claimed {
|
| 143 |
-
log.Printf("panel: import global trial uid=%s 已领", uid)
|
| 144 |
-
}
|
| 145 |
-
} else {
|
| 146 |
-
if err := p.cfg.Upstream.DailyCheckin(a); err != nil {
|
| 147 |
-
log.Printf("panel: import checkin uid=%s: %v", uid, err)
|
| 148 |
-
}
|
| 149 |
-
}
|
| 150 |
-
if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil {
|
| 151 |
-
p.cfg.Pool.ReenableIfCredits(uid, rm, tt)
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
imported++
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
total = len(accounts)
|
| 158 |
-
log.Printf("panel: cockpit import finished total=%d imported=%d skipped=%d", total, imported, skipped)
|
| 159 |
-
writeJSON(w, http.StatusOK, map[string]any{
|
| 160 |
-
"ok": true,
|
| 161 |
-
"total": total,
|
| 162 |
-
"imported": imported,
|
| 163 |
-
"skipped": skipped,
|
| 164 |
-
"errors": errs,
|
| 165 |
-
})
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
// validImportUID 校验导入 uid 是否可用于拼文件名(同 login.go validUID 口径)。
|
| 169 |
-
func validImportUID(uid string) bool {
|
| 170 |
-
if uid == "" || len(uid) > 64 {
|
| 171 |
-
return false
|
| 172 |
-
}
|
| 173 |
-
for _, c := range uid {
|
| 174 |
-
switch {
|
| 175 |
-
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
|
| 176 |
-
default:
|
| 177 |
-
return false
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
-
return true
|
| 181 |
-
}
|
|
|
|
| 1 |
+
package panel
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"io"
|
| 7 |
+
"log"
|
| 8 |
+
"net/http"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"strings"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// cockpitAccount 映射 cockpit tools 导出格式的单个账号。
|
| 17 |
+
type cockpitAccount struct {
|
| 18 |
+
ID string `json:"id"`
|
| 19 |
+
Email string `json:"email"`
|
| 20 |
+
UID string `json:"uid"`
|
| 21 |
+
Nickname string `json:"nickname"`
|
| 22 |
+
AccessToken string `json:"access_token"`
|
| 23 |
+
RefreshToken string `json:"refresh_token"`
|
| 24 |
+
TokenType string `json:"token_type"`
|
| 25 |
+
ExpiresAt int64 `json:"expires_at"`
|
| 26 |
+
Domain string `json:"domain"`
|
| 27 |
+
DosageNotify string `json:"dosage_notify_code"`
|
| 28 |
+
PaymentType string `json:"payment_type"`
|
| 29 |
+
Status string `json:"status"`
|
| 30 |
+
UsageUpdatedAt int64 `json:"usage_updated_at"`
|
| 31 |
+
LastCheckin int64 `json:"last_checkin_time"`
|
| 32 |
+
CheckinStreak int `json:"checkin_streak"`
|
| 33 |
+
CreatedAt int64 `json:"created_at"`
|
| 34 |
+
LastUsed int64 `json:"last_used"`
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// importCockpit 接收 cockpit tools 导出的 JSON 文件,批量导入账号到池中。
|
| 38 |
+
//
|
| 39 |
+
// POST /panel/api/import/cockpit
|
| 40 |
+
// Content-Type: multipart/form-data
|
| 41 |
+
// Body: file=<json>
|
| 42 |
+
//
|
| 43 |
+
// 返回 {ok, total, imported, skipped, errors}。
|
| 44 |
+
func (p *Panel) importCockpit(w http.ResponseWriter, r *http.Request) {
|
| 45 |
+
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
| 46 |
+
writeErr(w, http.StatusBadRequest, "parse form: "+err.Error())
|
| 47 |
+
return
|
| 48 |
+
}
|
| 49 |
+
file, _, err := r.FormFile("file")
|
| 50 |
+
if err != nil {
|
| 51 |
+
writeErr(w, http.StatusBadRequest, "missing file field: "+err.Error())
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
defer file.Close()
|
| 55 |
+
|
| 56 |
+
raw, err := io.ReadAll(file)
|
| 57 |
+
if err != nil {
|
| 58 |
+
writeErr(w, http.StatusBadRequest, "read file: "+err.Error())
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
var accounts []cockpitAccount
|
| 63 |
+
if err := json.Unmarshal(raw, &accounts); err != nil {
|
| 64 |
+
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
if len(accounts) == 0 {
|
| 68 |
+
writeErr(w, http.StatusBadRequest, "empty accounts array")
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
var total, imported, skipped int
|
| 73 |
+
var errs []string
|
| 74 |
+
|
| 75 |
+
for _, acc := range accounts {
|
| 76 |
+
uid := strings.TrimSpace(acc.UID)
|
| 77 |
+
at := strings.TrimSpace(acc.AccessToken)
|
| 78 |
+
rt := strings.TrimSpace(acc.RefreshToken)
|
| 79 |
+
if uid == "" || at == "" || rt == "" {
|
| 80 |
+
skipped++
|
| 81 |
+
errs = append(errs, fmt.Sprintf("missing required fields (id=%s)", acc.ID))
|
| 82 |
+
continue
|
| 83 |
+
}
|
| 84 |
+
if !validImportUID(uid) {
|
| 85 |
+
skipped++
|
| 86 |
+
errs = append(errs, fmt.Sprintf("invalid uid (id=%s)", acc.ID))
|
| 87 |
+
continue
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// 按 domain 推断 realm:workbuddy.ai 家族 → global,否则 cn。
|
| 91 |
+
realm := auth.ResolveRealm("", acc.Domain)
|
| 92 |
+
|
| 93 |
+
// cockpit tools 的 expires_at 为毫秒时间戳,转为秒。
|
| 94 |
+
expiresAt := acc.ExpiresAt / 1000
|
| 95 |
+
if expiresAt <= 0 {
|
| 96 |
+
expiresAt = time.Now().Add(365 * 24 * time.Hour).Unix()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
nickname := acc.Nickname
|
| 100 |
+
if strings.TrimSpace(nickname) == "" {
|
| 101 |
+
nickname = acc.Email
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
a := &auth.Auth{
|
| 105 |
+
AccessToken: at,
|
| 106 |
+
RefreshToken: rt,
|
| 107 |
+
ExpiresAt: expiresAt,
|
| 108 |
+
Domain: acc.Domain,
|
| 109 |
+
UID: uid,
|
| 110 |
+
Nickname: nickname,
|
| 111 |
+
FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", uid)),
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if realm == "global" {
|
| 115 |
+
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
|
| 116 |
+
skipped++
|
| 117 |
+
errs = append(errs, fmt.Sprintf("uid=%s: set realm failed: %v", uid, err))
|
| 118 |
+
continue
|
| 119 |
+
}
|
| 120 |
+
} else {
|
| 121 |
+
_, _ = a.BackfillRealm()
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if err := a.SaveAtomic(); err != nil {
|
| 125 |
+
skipped++
|
| 126 |
+
errs = append(errs, fmt.Sprintf("uid=%s: save auth failed: %v", uid, err))
|
| 127 |
+
continue
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
p.cfg.Pool.Add(a)
|
| 131 |
+
p.cfg.Pool.Revive(uid)
|
| 132 |
+
|
| 133 |
+
// 顺带签到/激活(幂等;失败仅记日志,不阻断导入)。
|
| 134 |
+
if realm == "global" {
|
| 135 |
+
if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil {
|
| 136 |
+
log.Printf("panel: import global 注册激活 uid=%s: %v", uid, err)
|
| 137 |
+
} else if activated {
|
| 138 |
+
log.Printf("panel: import global 注册激活 uid=%s 完成", uid)
|
| 139 |
+
}
|
| 140 |
+
if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil {
|
| 141 |
+
log.Printf("panel: import global trial uid=%s: %v", uid, err)
|
| 142 |
+
} else if claimed {
|
| 143 |
+
log.Printf("panel: import global trial uid=%s 已领", uid)
|
| 144 |
+
}
|
| 145 |
+
} else {
|
| 146 |
+
if err := p.cfg.Upstream.DailyCheckin(a); err != nil {
|
| 147 |
+
log.Printf("panel: import checkin uid=%s: %v", uid, err)
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil {
|
| 151 |
+
p.cfg.Pool.ReenableIfCredits(uid, rm, tt)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
imported++
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
total = len(accounts)
|
| 158 |
+
log.Printf("panel: cockpit import finished total=%d imported=%d skipped=%d", total, imported, skipped)
|
| 159 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 160 |
+
"ok": true,
|
| 161 |
+
"total": total,
|
| 162 |
+
"imported": imported,
|
| 163 |
+
"skipped": skipped,
|
| 164 |
+
"errors": errs,
|
| 165 |
+
})
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// validImportUID 校验导入 uid 是否可用于拼文件名(同 login.go validUID 口径)。
|
| 169 |
+
func validImportUID(uid string) bool {
|
| 170 |
+
if uid == "" || len(uid) > 64 {
|
| 171 |
+
return false
|
| 172 |
+
}
|
| 173 |
+
for _, c := range uid {
|
| 174 |
+
switch {
|
| 175 |
+
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
|
| 176 |
+
default:
|
| 177 |
+
return false
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
return true
|
| 181 |
+
}
|
internal/panel/index.go
CHANGED
|
@@ -1,61 +1,61 @@
|
|
| 1 |
-
// index.go 面板静态资源与安全响应头。
|
| 2 |
-
//
|
| 3 |
-
// 资源经 go:embed 打进二进制(随服务部署,无外部构建步骤):
|
| 4 |
-
// - index.html 页面骨架
|
| 5 |
-
// - app.js 全部前端逻辑(独立文件而非内联,为了启用无需 unsafe-inline 的严格 CSP)
|
| 6 |
-
//
|
| 7 |
-
// 安全头对"面板页面与全部 /panel/api/* 响应"统一生效:CSP 限制脚本只能来自本服务,
|
| 8 |
-
// 禁止被 iframe 嵌套(防点击劫持),禁 MIME 嗅探,并声明不泄露 Referer 出去。
|
| 9 |
-
package panel
|
| 10 |
-
|
| 11 |
-
import (
|
| 12 |
-
_ "embed"
|
| 13 |
-
"net/http"
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
//go:embed index.html
|
| 17 |
-
var indexHTML []byte
|
| 18 |
-
|
| 19 |
-
//go:embed app.js
|
| 20 |
-
var appJS []byte
|
| 21 |
-
|
| 22 |
-
// csp 内容安全策略(严格版,无需 unsafe-inline):
|
| 23 |
-
// - default-src 'none' 默认全禁,逐个开口
|
| 24 |
-
// - script-src 'self' 只跑同源脚本(app.js);页面无内联事件处理器/内联脚本
|
| 25 |
-
// - style-src 'self' 'unsafe-inline'
|
| 26 |
-
// style 的内联是设计取舍:页面有少量 style="..." 属性(进度条宽度、表格列宽),
|
| 27 |
-
// 允许内联样式不会导致脚本执行;仍禁止外部样式域与 @import 外链。
|
| 28 |
-
// - connect-src 'self' 前端 fetch 只能打本服务
|
| 29 |
-
// - img-src 'self' data: 图标/内联图
|
| 30 |
-
// - form-action 'none' 页面无表单提交目标(配置页是 JS 提交)
|
| 31 |
-
// - frame-ancestors 'none' 禁止被任何站点 iframe 嵌套(点击劫持)
|
| 32 |
-
// - base-uri 'none' 禁止注入 <base> 改写相对路径
|
| 33 |
-
const csp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
| 34 |
-
"connect-src 'self'; img-src 'self' data:; form-action 'none'; " +
|
| 35 |
-
"frame-ancestors 'none'; base-uri 'none'"
|
| 36 |
-
|
| 37 |
-
// setSecurityHeaders 写入面板统一安全响应头(页面与 API 都要,API 也含 JSON 数据)。
|
| 38 |
-
func setSecurityHeaders(w http.ResponseWriter) {
|
| 39 |
-
w.Header().Set("Content-Security-Policy", csp)
|
| 40 |
-
w.Header().Set("X-Content-Type-Options", "nosniff") // 禁 MIME 嗅探
|
| 41 |
-
w.Header().Set("X-Frame-Options", "DENY") // 老浏览器兜底(CSP frame-ancestors 的等价项)
|
| 42 |
-
w.Header().Set("Referrer-Policy", "no-referrer") // 不外泄面板地址给外部站点
|
| 43 |
-
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
| 44 |
-
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
// index 输出面板页面(静态无秘密;数据接口 /panel/api/* 才走鉴权)。
|
| 48 |
-
func (p *Panel) index(w http.ResponseWriter, r *http.Request) {
|
| 49 |
-
setSecurityHeaders(w)
|
| 50 |
-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
| 51 |
-
w.WriteHeader(http.StatusOK)
|
| 52 |
-
_, _ = w.Write(indexHTML)
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
// appScript 输出前端逻辑(同源脚本,供 CSP script-src 'self' 加载)。
|
| 56 |
-
func (p *Panel) appScript(w http.ResponseWriter, r *http.Request) {
|
| 57 |
-
setSecurityHeaders(w)
|
| 58 |
-
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
| 59 |
-
w.WriteHeader(http.StatusOK)
|
| 60 |
-
_, _ = w.Write(appJS)
|
| 61 |
-
}
|
|
|
|
| 1 |
+
// index.go 面板静态资源与安全响应头。
|
| 2 |
+
//
|
| 3 |
+
// 资源经 go:embed 打进二进制(随服务部署,无外部构建步骤):
|
| 4 |
+
// - index.html 页面骨架
|
| 5 |
+
// - app.js 全部前端逻辑(独立文件而非内联,为了启用无需 unsafe-inline 的严格 CSP)
|
| 6 |
+
//
|
| 7 |
+
// 安全头对"面板页面与全部 /panel/api/* 响应"统一生效:CSP 限制脚本只能来自本服务,
|
| 8 |
+
// 禁止被 iframe 嵌套(防点击劫持),禁 MIME 嗅探,并声明不泄露 Referer 出去。
|
| 9 |
+
package panel
|
| 10 |
+
|
| 11 |
+
import (
|
| 12 |
+
_ "embed"
|
| 13 |
+
"net/http"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
//go:embed index.html
|
| 17 |
+
var indexHTML []byte
|
| 18 |
+
|
| 19 |
+
//go:embed app.js
|
| 20 |
+
var appJS []byte
|
| 21 |
+
|
| 22 |
+
// csp 内容安全策略(严格版,无需 unsafe-inline):
|
| 23 |
+
// - default-src 'none' 默认全禁,逐个开口
|
| 24 |
+
// - script-src 'self' 只跑同源脚本(app.js);页面无内联事件处理器/内联脚本
|
| 25 |
+
// - style-src 'self' 'unsafe-inline'
|
| 26 |
+
// style 的内联是设计取舍:页面有少量 style="..." 属性(进度条宽度、表格列宽),
|
| 27 |
+
// 允许内联样式不会导致脚本执行;仍禁止外部样式域与 @import 外链。
|
| 28 |
+
// - connect-src 'self' 前端 fetch 只能打本服务
|
| 29 |
+
// - img-src 'self' data: 图标/内联图
|
| 30 |
+
// - form-action 'none' 页面无表单提交目标(配置页是 JS 提交)
|
| 31 |
+
// - frame-ancestors 'none' 禁止被任何站点 iframe 嵌套(点击劫持)
|
| 32 |
+
// - base-uri 'none' 禁止注入 <base> 改写相对路径
|
| 33 |
+
const csp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
| 34 |
+
"connect-src 'self'; img-src 'self' data:; form-action 'none'; " +
|
| 35 |
+
"frame-ancestors 'none'; base-uri 'none'"
|
| 36 |
+
|
| 37 |
+
// setSecurityHeaders 写入面板统一安全响应头(页面与 API 都要,API 也含 JSON 数据)。
|
| 38 |
+
func setSecurityHeaders(w http.ResponseWriter) {
|
| 39 |
+
w.Header().Set("Content-Security-Policy", csp)
|
| 40 |
+
w.Header().Set("X-Content-Type-Options", "nosniff") // 禁 MIME 嗅探
|
| 41 |
+
w.Header().Set("X-Frame-Options", "DENY") // 老浏览器兜底(CSP frame-ancestors 的等价项)
|
| 42 |
+
w.Header().Set("Referrer-Policy", "no-referrer") // 不外泄面板地址给外部站点
|
| 43 |
+
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
| 44 |
+
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// index 输出面板页面(静态无秘密;数据接口 /panel/api/* 才走鉴权)。
|
| 48 |
+
func (p *Panel) index(w http.ResponseWriter, r *http.Request) {
|
| 49 |
+
setSecurityHeaders(w)
|
| 50 |
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
| 51 |
+
w.WriteHeader(http.StatusOK)
|
| 52 |
+
_, _ = w.Write(indexHTML)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// appScript 输出前端逻辑(同源脚本,供 CSP script-src 'self' 加载)。
|
| 56 |
+
func (p *Panel) appScript(w http.ResponseWriter, r *http.Request) {
|
| 57 |
+
setSecurityHeaders(w)
|
| 58 |
+
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
| 59 |
+
w.WriteHeader(http.StatusOK)
|
| 60 |
+
_, _ = w.Write(appJS)
|
| 61 |
+
}
|
internal/panel/index.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
internal/panel/login.go
CHANGED
|
@@ -1,322 +1,322 @@
|
|
| 1 |
-
// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。
|
| 2 |
-
//
|
| 3 |
-
// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp,
|
| 4 |
-
// 原方案在 Windows 上不可用),返回授权 URL;
|
| 5 |
-
// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false,
|
| 6 |
-
// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-<uid>.json、热加载进池
|
| 7 |
-
// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。
|
| 8 |
-
//
|
| 9 |
-
// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。
|
| 10 |
-
package panel
|
| 11 |
-
|
| 12 |
-
import (
|
| 13 |
-
"bytes"
|
| 14 |
-
"encoding/json"
|
| 15 |
-
"fmt"
|
| 16 |
-
"io"
|
| 17 |
-
"log"
|
| 18 |
-
"net/http"
|
| 19 |
-
"os"
|
| 20 |
-
"path/filepath"
|
| 21 |
-
"time"
|
| 22 |
-
|
| 23 |
-
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
const (
|
| 27 |
-
upstreamBaseCN = "https://copilot.tencent.com"
|
| 28 |
-
upstreamBaseGlobal = "https://www.workbuddy.ai"
|
| 29 |
-
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
|
| 30 |
-
originRefererCN = "https://www.codebuddy.cn"
|
| 31 |
-
originRefererGlobal = "https://www.workbuddy.ai"
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。
|
| 35 |
-
// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。
|
| 36 |
-
func loginEndpoints(realm string) (state, token, account, origin string) {
|
| 37 |
-
if realm == "global" {
|
| 38 |
-
base := upstreamBaseGlobal
|
| 39 |
-
return base + "/v2/plugin/auth/state?platform=CLI",
|
| 40 |
-
base + "/v2/plugin/auth/token?state=",
|
| 41 |
-
base + "/v2/plugin/login/account?state=",
|
| 42 |
-
originRefererGlobal
|
| 43 |
-
}
|
| 44 |
-
base := upstreamBaseCN
|
| 45 |
-
return base + "/v2/plugin/auth/state?platform=CLI",
|
| 46 |
-
base + "/v2/plugin/auth/token?state=",
|
| 47 |
-
base + "/v2/plugin/login/account?state=",
|
| 48 |
-
originRefererCN
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。
|
| 52 |
-
var loginHTTP = &http.Client{Timeout: 30 * time.Second}
|
| 53 |
-
|
| 54 |
-
func commonHeaders(req *http.Request, origin string) {
|
| 55 |
-
req.Header.Set("Content-Type", "application/json")
|
| 56 |
-
req.Header.Set("Accept", "application/json, text/plain, */*")
|
| 57 |
-
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
| 58 |
-
req.Header.Set("Origin", origin)
|
| 59 |
-
req.Header.Set("Referer", origin+"/")
|
| 60 |
-
req.Header.Set("User-Agent", clientUA)
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
// validUID 校验上游返回的 uid 是否可安全用于拼文件名。
|
| 64 |
-
// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态),
|
| 65 |
-
// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。
|
| 66 |
-
func validUID(uid string) bool {
|
| 67 |
-
if uid == "" || len(uid) > 64 {
|
| 68 |
-
return false
|
| 69 |
-
}
|
| 70 |
-
for _, c := range uid {
|
| 71 |
-
switch {
|
| 72 |
-
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
|
| 73 |
-
default:
|
| 74 |
-
return false
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
return true
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。
|
| 81 |
-
type apiEnvelope struct {
|
| 82 |
-
Code int `json:"code"`
|
| 83 |
-
Msg string `json:"msg"`
|
| 84 |
-
Data json.RawMessage `json:"data"`
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。
|
| 88 |
-
func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) {
|
| 89 |
-
req, err := http.NewRequest(method, fullURL, body)
|
| 90 |
-
if err != nil {
|
| 91 |
-
return nil, 0, err
|
| 92 |
-
}
|
| 93 |
-
commonHeaders(req, origin)
|
| 94 |
-
if bearer != "" {
|
| 95 |
-
req.Header.Set("Authorization", "Bearer "+bearer)
|
| 96 |
-
}
|
| 97 |
-
resp, err := loginHTTP.Do(req)
|
| 98 |
-
if err != nil {
|
| 99 |
-
return nil, 0, err
|
| 100 |
-
}
|
| 101 |
-
defer resp.Body.Close()
|
| 102 |
-
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
| 103 |
-
if resp.StatusCode >= 300 {
|
| 104 |
-
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
|
| 105 |
-
}
|
| 106 |
-
var env apiEnvelope
|
| 107 |
-
if err := json.Unmarshal(raw, &env); err != nil {
|
| 108 |
-
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
|
| 109 |
-
}
|
| 110 |
-
if env.Code != 0 {
|
| 111 |
-
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
|
| 112 |
-
}
|
| 113 |
-
return env.Data, resp.StatusCode, nil
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
// loginStart 发起设备授权:POST auth/state 拿授权 URL。
|
| 117 |
-
// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。
|
| 118 |
-
func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) {
|
| 119 |
-
realm := "cn"
|
| 120 |
-
if r.Body != nil {
|
| 121 |
-
var reqBody struct {
|
| 122 |
-
Realm string `json:"realm"`
|
| 123 |
-
}
|
| 124 |
-
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil {
|
| 125 |
-
if reqBody.Realm == "global" {
|
| 126 |
-
realm = "global"
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
}
|
| 130 |
-
epState, _, _, origin := loginEndpoints(realm)
|
| 131 |
-
data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin)
|
| 132 |
-
if err != nil {
|
| 133 |
-
writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err))
|
| 134 |
-
return
|
| 135 |
-
}
|
| 136 |
-
var st struct {
|
| 137 |
-
State string `json:"state"`
|
| 138 |
-
AuthURL string `json:"authUrl"`
|
| 139 |
-
}
|
| 140 |
-
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
|
| 141 |
-
writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl")
|
| 142 |
-
return
|
| 143 |
-
}
|
| 144 |
-
p.loginMu.Lock()
|
| 145 |
-
// 顺手回收过期会话,防"开弹窗走开"的 state 滞留。
|
| 146 |
-
for s, sess := range p.logins {
|
| 147 |
-
if time.Since(sess.created) > loginTTL {
|
| 148 |
-
delete(p.logins, s)
|
| 149 |
-
}
|
| 150 |
-
}
|
| 151 |
-
p.logins[st.State] = loginSession{created: time.Now(), realm: realm}
|
| 152 |
-
p.loginMu.Unlock()
|
| 153 |
-
log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))])
|
| 154 |
-
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm})
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。
|
| 158 |
-
func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) {
|
| 159 |
-
state := r.URL.Query().Get("state")
|
| 160 |
-
if state == "" {
|
| 161 |
-
writeErr(w, http.StatusBadRequest, "missing state")
|
| 162 |
-
return
|
| 163 |
-
}
|
| 164 |
-
p.loginMu.Lock()
|
| 165 |
-
sess, known := p.logins[state]
|
| 166 |
-
p.loginMu.Unlock()
|
| 167 |
-
if !known {
|
| 168 |
-
writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)")
|
| 169 |
-
return
|
| 170 |
-
}
|
| 171 |
-
_, epToken, epAcct, origin := loginEndpoints(sess.realm)
|
| 172 |
-
|
| 173 |
-
// auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。
|
| 174 |
-
tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin)
|
| 175 |
-
if err != nil {
|
| 176 |
-
// pending / 未完成:面板前端继续轮询。
|
| 177 |
-
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()})
|
| 178 |
-
return
|
| 179 |
-
}
|
| 180 |
-
var tok struct {
|
| 181 |
-
AccessToken string `json:"accessToken"`
|
| 182 |
-
RefreshToken string `json:"refreshToken"`
|
| 183 |
-
ExpiresIn int64 `json:"expiresIn"`
|
| 184 |
-
Domain string `json:"domain"`
|
| 185 |
-
}
|
| 186 |
-
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
|
| 187 |
-
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"})
|
| 188 |
-
return
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
// 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。
|
| 192 |
-
var acct struct {
|
| 193 |
-
UID string `json:"uid"`
|
| 194 |
-
EnterpriseID string `json:"enterpriseId"`
|
| 195 |
-
Nickname string `json:"nickname"`
|
| 196 |
-
}
|
| 197 |
-
if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil {
|
| 198 |
-
_ = json.Unmarshal(acctRaw, &acct)
|
| 199 |
-
}
|
| 200 |
-
if acct.UID == "" {
|
| 201 |
-
writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)")
|
| 202 |
-
return
|
| 203 |
-
}
|
| 204 |
-
// UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用
|
| 205 |
-
// (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。
|
| 206 |
-
// UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。
|
| 207 |
-
if !validUID(acct.UID) {
|
| 208 |
-
writeErr(w, http.StatusBadGateway, "上游返回的 uid
|
| 209 |
-
return
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
// 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。
|
| 213 |
-
if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil {
|
| 214 |
-
writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error())
|
| 215 |
-
return
|
| 216 |
-
}
|
| 217 |
-
a := &auth.Auth{
|
| 218 |
-
AccessToken: tok.AccessToken,
|
| 219 |
-
RefreshToken: tok.RefreshToken,
|
| 220 |
-
ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(),
|
| 221 |
-
Domain: tok.Domain,
|
| 222 |
-
UID: acct.UID,
|
| 223 |
-
EnterpriseID: acct.EnterpriseID,
|
| 224 |
-
Nickname: acct.Nickname,
|
| 225 |
-
FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)),
|
| 226 |
-
}
|
| 227 |
-
// global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。
|
| 228 |
-
if sess.realm == "global" {
|
| 229 |
-
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
|
| 230 |
-
writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error())
|
| 231 |
-
return
|
| 232 |
-
}
|
| 233 |
-
} else {
|
| 234 |
-
// CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。
|
| 235 |
-
_, _ = a.BackfillRealm()
|
| 236 |
-
}
|
| 237 |
-
if err := a.SaveAtomic(); err != nil {
|
| 238 |
-
writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error())
|
| 239 |
-
return
|
| 240 |
-
}
|
| 241 |
-
p.cfg.Pool.Add(a)
|
| 242 |
-
p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断
|
| 243 |
-
|
| 244 |
-
// 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。
|
| 245 |
-
// realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取
|
| 246 |
-
// (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。
|
| 247 |
-
checkinMsg := ""
|
| 248 |
-
remain := int64(-1)
|
| 249 |
-
total := int64(0)
|
| 250 |
-
if sess.realm == "global" {
|
| 251 |
-
// 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。
|
| 252 |
-
// 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。
|
| 253 |
-
if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil {
|
| 254 |
-
checkinMsg = "注册激活失败: " + err.Error()
|
| 255 |
-
log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err)
|
| 256 |
-
} else if activated {
|
| 257 |
-
log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID)
|
| 258 |
-
}
|
| 259 |
-
// trial 加油包(幂等 14051 = 已领过,非错误)。
|
| 260 |
-
if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil {
|
| 261 |
-
checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error())
|
| 262 |
-
log.Printf("panel: global trial uid=%s: %v", acct.UID, err)
|
| 263 |
-
} else if claimed {
|
| 264 |
-
log.Printf("panel: global trial uid=%s 已领", acct.UID)
|
| 265 |
-
}
|
| 266 |
-
} else {
|
| 267 |
-
if err := p.cfg.Upstream.DailyCheckin(a); err != nil {
|
| 268 |
-
checkinMsg = err.Error()
|
| 269 |
-
}
|
| 270 |
-
}
|
| 271 |
-
if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil {
|
| 272 |
-
remain, total = rm, tt
|
| 273 |
-
p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt)
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
p.loginMu.Lock()
|
| 277 |
-
delete(p.logins, state)
|
| 278 |
-
p.loginMu.Unlock()
|
| 279 |
-
log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm)
|
| 280 |
-
writeJSON(w, http.StatusOK, map[string]any{
|
| 281 |
-
"done": true,
|
| 282 |
-
"uid": acct.UID,
|
| 283 |
-
"nickname": acct.Nickname,
|
| 284 |
-
"realm": sess.realm,
|
| 285 |
-
"credits": remain,
|
| 286 |
-
"credits_total": total,
|
| 287 |
-
"checkin_message": checkinMsg,
|
| 288 |
-
})
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。
|
| 292 |
-
func joinMsg(parts ...string) string {
|
| 293 |
-
out := ""
|
| 294 |
-
for _, s := range parts {
|
| 295 |
-
if s == "" {
|
| 296 |
-
continue
|
| 297 |
-
}
|
| 298 |
-
if out != "" {
|
| 299 |
-
out += ";"
|
| 300 |
-
}
|
| 301 |
-
out += s
|
| 302 |
-
}
|
| 303 |
-
return out
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。
|
| 307 |
-
// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。
|
| 308 |
-
func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) {
|
| 309 |
-
// 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。
|
| 310 |
-
writeJSON(w, http.StatusOK, map[string]any{
|
| 311 |
-
"ok": true,
|
| 312 |
-
"regions": []map[string]string{
|
| 313 |
-
{"code": "HK", "name": "Hong Kong"},
|
| 314 |
-
{"code": "MO", "name": "Macao"},
|
| 315 |
-
{"code": "SG", "name": "Singapore"},
|
| 316 |
-
{"code": "TH", "name": "Thailand"},
|
| 317 |
-
{"code": "PH", "name": "Philippines"},
|
| 318 |
-
{"code": "MY", "name": "Malaysia"},
|
| 319 |
-
{"code": "ID", "name": "Indonesia"},
|
| 320 |
-
},
|
| 321 |
-
})
|
| 322 |
-
}
|
|
|
|
| 1 |
+
// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。
|
| 2 |
+
//
|
| 3 |
+
// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp,
|
| 4 |
+
// 原方案在 Windows 上不可用),返回授权 URL;
|
| 5 |
+
// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false,
|
| 6 |
+
// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-<uid>.json、热加载进池
|
| 7 |
+
// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。
|
| 8 |
+
//
|
| 9 |
+
// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。
|
| 10 |
+
package panel
|
| 11 |
+
|
| 12 |
+
import (
|
| 13 |
+
"bytes"
|
| 14 |
+
"encoding/json"
|
| 15 |
+
"fmt"
|
| 16 |
+
"io"
|
| 17 |
+
"log"
|
| 18 |
+
"net/http"
|
| 19 |
+
"os"
|
| 20 |
+
"path/filepath"
|
| 21 |
+
"time"
|
| 22 |
+
|
| 23 |
+
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
const (
|
| 27 |
+
upstreamBaseCN = "https://copilot.tencent.com"
|
| 28 |
+
upstreamBaseGlobal = "https://www.workbuddy.ai"
|
| 29 |
+
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
|
| 30 |
+
originRefererCN = "https://www.codebuddy.cn"
|
| 31 |
+
originRefererGlobal = "https://www.workbuddy.ai"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。
|
| 35 |
+
// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。
|
| 36 |
+
func loginEndpoints(realm string) (state, token, account, origin string) {
|
| 37 |
+
if realm == "global" {
|
| 38 |
+
base := upstreamBaseGlobal
|
| 39 |
+
return base + "/v2/plugin/auth/state?platform=CLI",
|
| 40 |
+
base + "/v2/plugin/auth/token?state=",
|
| 41 |
+
base + "/v2/plugin/login/account?state=",
|
| 42 |
+
originRefererGlobal
|
| 43 |
+
}
|
| 44 |
+
base := upstreamBaseCN
|
| 45 |
+
return base + "/v2/plugin/auth/state?platform=CLI",
|
| 46 |
+
base + "/v2/plugin/auth/token?state=",
|
| 47 |
+
base + "/v2/plugin/login/account?state=",
|
| 48 |
+
originRefererCN
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。
|
| 52 |
+
var loginHTTP = &http.Client{Timeout: 30 * time.Second}
|
| 53 |
+
|
| 54 |
+
func commonHeaders(req *http.Request, origin string) {
|
| 55 |
+
req.Header.Set("Content-Type", "application/json")
|
| 56 |
+
req.Header.Set("Accept", "application/json, text/plain, */*")
|
| 57 |
+
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
| 58 |
+
req.Header.Set("Origin", origin)
|
| 59 |
+
req.Header.Set("Referer", origin+"/")
|
| 60 |
+
req.Header.Set("User-Agent", clientUA)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// validUID 校验上游返回的 uid 是否可安全用于拼文件名。
|
| 64 |
+
// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态),
|
| 65 |
+
// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。
|
| 66 |
+
func validUID(uid string) bool {
|
| 67 |
+
if uid == "" || len(uid) > 64 {
|
| 68 |
+
return false
|
| 69 |
+
}
|
| 70 |
+
for _, c := range uid {
|
| 71 |
+
switch {
|
| 72 |
+
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
|
| 73 |
+
default:
|
| 74 |
+
return false
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
return true
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。
|
| 81 |
+
type apiEnvelope struct {
|
| 82 |
+
Code int `json:"code"`
|
| 83 |
+
Msg string `json:"msg"`
|
| 84 |
+
Data json.RawMessage `json:"data"`
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。
|
| 88 |
+
func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) {
|
| 89 |
+
req, err := http.NewRequest(method, fullURL, body)
|
| 90 |
+
if err != nil {
|
| 91 |
+
return nil, 0, err
|
| 92 |
+
}
|
| 93 |
+
commonHeaders(req, origin)
|
| 94 |
+
if bearer != "" {
|
| 95 |
+
req.Header.Set("Authorization", "Bearer "+bearer)
|
| 96 |
+
}
|
| 97 |
+
resp, err := loginHTTP.Do(req)
|
| 98 |
+
if err != nil {
|
| 99 |
+
return nil, 0, err
|
| 100 |
+
}
|
| 101 |
+
defer resp.Body.Close()
|
| 102 |
+
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
| 103 |
+
if resp.StatusCode >= 300 {
|
| 104 |
+
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
|
| 105 |
+
}
|
| 106 |
+
var env apiEnvelope
|
| 107 |
+
if err := json.Unmarshal(raw, &env); err != nil {
|
| 108 |
+
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
|
| 109 |
+
}
|
| 110 |
+
if env.Code != 0 {
|
| 111 |
+
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
|
| 112 |
+
}
|
| 113 |
+
return env.Data, resp.StatusCode, nil
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// loginStart 发起设备授权:POST auth/state 拿授权 URL。
|
| 117 |
+
// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。
|
| 118 |
+
func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) {
|
| 119 |
+
realm := "cn"
|
| 120 |
+
if r.Body != nil {
|
| 121 |
+
var reqBody struct {
|
| 122 |
+
Realm string `json:"realm"`
|
| 123 |
+
}
|
| 124 |
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil {
|
| 125 |
+
if reqBody.Realm == "global" {
|
| 126 |
+
realm = "global"
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
epState, _, _, origin := loginEndpoints(realm)
|
| 131 |
+
data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin)
|
| 132 |
+
if err != nil {
|
| 133 |
+
writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err))
|
| 134 |
+
return
|
| 135 |
+
}
|
| 136 |
+
var st struct {
|
| 137 |
+
State string `json:"state"`
|
| 138 |
+
AuthURL string `json:"authUrl"`
|
| 139 |
+
}
|
| 140 |
+
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
|
| 141 |
+
writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl")
|
| 142 |
+
return
|
| 143 |
+
}
|
| 144 |
+
p.loginMu.Lock()
|
| 145 |
+
// 顺手回收过期会话,防"开弹窗走开"的 state 滞留。
|
| 146 |
+
for s, sess := range p.logins {
|
| 147 |
+
if time.Since(sess.created) > loginTTL {
|
| 148 |
+
delete(p.logins, s)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
p.logins[st.State] = loginSession{created: time.Now(), realm: realm}
|
| 152 |
+
p.loginMu.Unlock()
|
| 153 |
+
log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))])
|
| 154 |
+
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm})
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。
|
| 158 |
+
func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) {
|
| 159 |
+
state := r.URL.Query().Get("state")
|
| 160 |
+
if state == "" {
|
| 161 |
+
writeErr(w, http.StatusBadRequest, "missing state")
|
| 162 |
+
return
|
| 163 |
+
}
|
| 164 |
+
p.loginMu.Lock()
|
| 165 |
+
sess, known := p.logins[state]
|
| 166 |
+
p.loginMu.Unlock()
|
| 167 |
+
if !known {
|
| 168 |
+
writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)")
|
| 169 |
+
return
|
| 170 |
+
}
|
| 171 |
+
_, epToken, epAcct, origin := loginEndpoints(sess.realm)
|
| 172 |
+
|
| 173 |
+
// auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。
|
| 174 |
+
tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin)
|
| 175 |
+
if err != nil {
|
| 176 |
+
// pending / 未完成:面板前端继续轮询。
|
| 177 |
+
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()})
|
| 178 |
+
return
|
| 179 |
+
}
|
| 180 |
+
var tok struct {
|
| 181 |
+
AccessToken string `json:"accessToken"`
|
| 182 |
+
RefreshToken string `json:"refreshToken"`
|
| 183 |
+
ExpiresIn int64 `json:"expiresIn"`
|
| 184 |
+
Domain string `json:"domain"`
|
| 185 |
+
}
|
| 186 |
+
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
|
| 187 |
+
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"})
|
| 188 |
+
return
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。
|
| 192 |
+
var acct struct {
|
| 193 |
+
UID string `json:"uid"`
|
| 194 |
+
EnterpriseID string `json:"enterpriseId"`
|
| 195 |
+
Nickname string `json:"nickname"`
|
| 196 |
+
}
|
| 197 |
+
if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil {
|
| 198 |
+
_ = json.Unmarshal(acctRaw, &acct)
|
| 199 |
+
}
|
| 200 |
+
if acct.UID == "" {
|
| 201 |
+
writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)")
|
| 202 |
+
return
|
| 203 |
+
}
|
| 204 |
+
// UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用
|
| 205 |
+
// (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。
|
| 206 |
+
// UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。
|
| 207 |
+
if !validUID(acct.UID) {
|
| 208 |
+
writeErr(w, http.StatusBadGateway, "上游返回的 uid ���非法字符,拒绝落盘(防路径穿越)")
|
| 209 |
+
return
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。
|
| 213 |
+
if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil {
|
| 214 |
+
writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error())
|
| 215 |
+
return
|
| 216 |
+
}
|
| 217 |
+
a := &auth.Auth{
|
| 218 |
+
AccessToken: tok.AccessToken,
|
| 219 |
+
RefreshToken: tok.RefreshToken,
|
| 220 |
+
ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(),
|
| 221 |
+
Domain: tok.Domain,
|
| 222 |
+
UID: acct.UID,
|
| 223 |
+
EnterpriseID: acct.EnterpriseID,
|
| 224 |
+
Nickname: acct.Nickname,
|
| 225 |
+
FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)),
|
| 226 |
+
}
|
| 227 |
+
// global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。
|
| 228 |
+
if sess.realm == "global" {
|
| 229 |
+
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
|
| 230 |
+
writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error())
|
| 231 |
+
return
|
| 232 |
+
}
|
| 233 |
+
} else {
|
| 234 |
+
// CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。
|
| 235 |
+
_, _ = a.BackfillRealm()
|
| 236 |
+
}
|
| 237 |
+
if err := a.SaveAtomic(); err != nil {
|
| 238 |
+
writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error())
|
| 239 |
+
return
|
| 240 |
+
}
|
| 241 |
+
p.cfg.Pool.Add(a)
|
| 242 |
+
p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断
|
| 243 |
+
|
| 244 |
+
// 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。
|
| 245 |
+
// realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取
|
| 246 |
+
// (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。
|
| 247 |
+
checkinMsg := ""
|
| 248 |
+
remain := int64(-1)
|
| 249 |
+
total := int64(0)
|
| 250 |
+
if sess.realm == "global" {
|
| 251 |
+
// 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。
|
| 252 |
+
// 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。
|
| 253 |
+
if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil {
|
| 254 |
+
checkinMsg = "注册激活失败: " + err.Error()
|
| 255 |
+
log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err)
|
| 256 |
+
} else if activated {
|
| 257 |
+
log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID)
|
| 258 |
+
}
|
| 259 |
+
// trial 加油包(幂等 14051 = 已领过,非错误)。
|
| 260 |
+
if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil {
|
| 261 |
+
checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error())
|
| 262 |
+
log.Printf("panel: global trial uid=%s: %v", acct.UID, err)
|
| 263 |
+
} else if claimed {
|
| 264 |
+
log.Printf("panel: global trial uid=%s 已领", acct.UID)
|
| 265 |
+
}
|
| 266 |
+
} else {
|
| 267 |
+
if err := p.cfg.Upstream.DailyCheckin(a); err != nil {
|
| 268 |
+
checkinMsg = err.Error()
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil {
|
| 272 |
+
remain, total = rm, tt
|
| 273 |
+
p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt)
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
p.loginMu.Lock()
|
| 277 |
+
delete(p.logins, state)
|
| 278 |
+
p.loginMu.Unlock()
|
| 279 |
+
log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm)
|
| 280 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 281 |
+
"done": true,
|
| 282 |
+
"uid": acct.UID,
|
| 283 |
+
"nickname": acct.Nickname,
|
| 284 |
+
"realm": sess.realm,
|
| 285 |
+
"credits": remain,
|
| 286 |
+
"credits_total": total,
|
| 287 |
+
"checkin_message": checkinMsg,
|
| 288 |
+
})
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。
|
| 292 |
+
func joinMsg(parts ...string) string {
|
| 293 |
+
out := ""
|
| 294 |
+
for _, s := range parts {
|
| 295 |
+
if s == "" {
|
| 296 |
+
continue
|
| 297 |
+
}
|
| 298 |
+
if out != "" {
|
| 299 |
+
out += ";"
|
| 300 |
+
}
|
| 301 |
+
out += s
|
| 302 |
+
}
|
| 303 |
+
return out
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。
|
| 307 |
+
// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。
|
| 308 |
+
func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) {
|
| 309 |
+
// 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。
|
| 310 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 311 |
+
"ok": true,
|
| 312 |
+
"regions": []map[string]string{
|
| 313 |
+
{"code": "HK", "name": "Hong Kong"},
|
| 314 |
+
{"code": "MO", "name": "Macao"},
|
| 315 |
+
{"code": "SG", "name": "Singapore"},
|
| 316 |
+
{"code": "TH", "name": "Thailand"},
|
| 317 |
+
{"code": "PH", "name": "Philippines"},
|
| 318 |
+
{"code": "MY", "name": "Malaysia"},
|
| 319 |
+
{"code": "ID", "name": "Indonesia"},
|
| 320 |
+
},
|
| 321 |
+
})
|
| 322 |
+
}
|