a3216 commited on
Commit
6d60378
·
verified ·
1 Parent(s): 158e71e

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
Files changed (50) hide show
  1. .dockerignore +28 -24
  2. .github/workflows/go-binaries.yml +156 -0
  3. .github/workflows/space-keepalive.yml +78 -0
  4. .gitignore +58 -47
  5. Dockerfile +42 -39
  6. LICENSE +22 -22
  7. README.md +160 -67
  8. cmd/credit/main.go +294 -294
  9. cmd/launcher/flags.go +119 -0
  10. cmd/launcher/main.go +484 -0
  11. cmd/login/main.go +336 -336
  12. cmd/login/realm_test.go +169 -169
  13. cmd/server/config.go +658 -616
  14. cmd/server/config_test.go +861 -816
  15. cmd/server/credit_floor_config_test.go +48 -48
  16. cmd/server/main.go +563 -550
  17. cmd/server/wiring.go +24 -24
  18. cmd/signin/main.go +140 -140
  19. cmd/sync/main.go +438 -0
  20. cmd/trial/main.go +133 -133
  21. config.default.json +102 -98
  22. config.example.json +105 -84
  23. credit.sh +14 -14
  24. deploy/cloudflare-worker/.dev.vars.example +24 -0
  25. deploy/cloudflare-worker/README.md +350 -0
  26. deploy/cloudflare-worker/dev-server.mjs +113 -0
  27. deploy/cloudflare-worker/worker.js +347 -0
  28. deploy/cloudflare-worker/wrangler.toml +81 -0
  29. docker-compose.yml +15 -15
  30. docker-entrypoint.sh +69 -69
  31. go.mod +11 -11
  32. go.sum +12 -12
  33. internal/auth/auth.go +378 -378
  34. internal/auth/auth_test.go +266 -266
  35. internal/auth/permhint_test.go +29 -29
  36. internal/auth/realm_test.go +275 -275
  37. internal/httpauth/httpauth.go +43 -43
  38. internal/httpauth/httpauth_test.go +61 -61
  39. internal/livecfg/livecfg.go +48 -48
  40. internal/logfmt/logfmt.go +166 -166
  41. internal/logfmt/shortua_test.go +50 -50
  42. internal/panel/app.js +0 -0
  43. internal/panel/autotask.go +0 -0
  44. internal/panel/autotask_lock_test.go +52 -52
  45. internal/panel/config.go +57 -57
  46. internal/panel/frontend_test.go +769 -647
  47. internal/panel/import.go +181 -181
  48. internal/panel/index.go +61 -61
  49. internal/panel/index.html +0 -0
  50. 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
- /signin_bin
15
- /wb2api.exe
16
- /login.exe
17
- /credit.exe
18
- # 真实配置不进镜像;镜像内模板是 config.default.json
19
- config.json
20
- PLAN.md
21
- SPEC.md
22
- LOOP.md
23
- README.md
24
- README.upstream.md
 
 
 
 
 
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
- # Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/)
11
- /login
12
- /credit
13
- /wb2api
14
- /signin_bin
15
-
16
- # Config with secrets (use config.example.json)
17
- config.json
18
-
19
- # Build artifacts
20
- *.tmp
21
- __pycache__/
22
-
23
- # Backups (contains auth tokens)
24
- backups/
25
-
26
- # IDE
27
- .idea/
28
- .vscode/
29
- LOOP.md
30
- PLAN.md
31
- SPEC.md
32
- docs/
33
-
34
- # 设计/计划文档(但 README.md 与 go:embed 的内置提示词必须进版本库)
35
- *.md
36
- !README.md
37
- !internal/prompt/defaultprompt.md
38
-
39
- # Windows binaries
40
- /wb2api.exe
41
- /login.exe
42
- /credit.exe
43
-
44
- # 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md)
45
- /cmd/desktopverify*/
46
- *.zip
47
- server.exe
 
 
 
 
 
 
 
 
 
 
 
 
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/signin_bin ./cmd/signin \
11
- && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \
12
- && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit
13
-
14
- # -------------------------------------------------------------- runtime stage
15
- FROM alpine:3.20
16
- # python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。
17
- RUN apk add --no-cache wget ca-certificates tzdata python3 bash \
18
- && mkdir -p /app/auths /app/data
19
- ENV TZ=Asia/Shanghai
20
- WORKDIR /app
21
-
22
- COPY --from=build /out/wb2api /app/wb2api
23
- COPY --from=build /out/signin_bin /app/signin_bin
24
- COPY --from=build /out/login /app/login
25
- COPY --from=build /out/credit /app/credit
26
- COPY login.sh signin.sh credit.sh /app/
27
- COPY scripts/probe_active.py /app/scripts/probe_active.py
28
- # config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。
29
- # 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。
30
- COPY config.default.json /app/config.json
31
- COPY docker-entrypoint.sh /app/docker-entrypoint.sh
32
- RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \
33
- && chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh
34
-
35
- # 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。
36
- EXPOSE 7861
37
- HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
38
- CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1
39
- ENTRYPOINT ["/app/docker-entrypoint.sh"]
 
 
 
 
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
- | `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) |
52
- | `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` |
53
- | `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README |
54
-
55
- ## 运维
56
-
57
- - **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。
58
- - **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。
59
- - **备份**:桶本身就是备份。本地拉一份:
60
- `hf buckets sync hf://buckets/a3216/wb2api-storage ./backup`
61
- - **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。
62
- - **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。
63
- 唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。
64
-
65
- ## 说明
66
-
67
- 上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Cooldown struct {
48
- // hard_credit / err_threshold / err_cooldown 三个历史键已退役:
49
- // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。
50
- // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。
51
- SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数
52
- // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。
53
- // 空值回落默认,非法值报错(处理风格同 soft_rate)。
54
- SoftRateMax string `json:"soft_rate_max"` // "2h"
55
- } `json:"cooldown"`
56
-
57
- Schedule struct {
58
- CheckinHours []int `json:"checkin_hours"` // [9,21]
59
- TravelHours []int `json:"travel_hours"` // [9,21]
60
- ActivityHours []int `json:"activity_hours"` // [10]
61
- KeepaliveHours []int `json:"keepalive_hours"` // [22]
62
- BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数)
63
- GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行)
64
- // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。
65
- //
66
- // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用":
67
- // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转
68
- // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true
69
- // 则对老配置零影响,向后完全兼容。
70
- // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。
71
- // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。
72
- // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。
73
- CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到
74
- TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行
75
- ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报
76
- KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活
77
- BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子
78
- GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程
79
-
80
- // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。
81
- // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。
82
- BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭
83
- BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5
84
- } `json:"schedule"`
85
-
86
- Global struct {
87
- // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/
88
- // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。
89
- // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global
90
- // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号
91
- // 恒判 cn,global base 只在 realm=global 的账号上���使用。
92
- Enabled bool `json:"enabled"`
93
- // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认
94
- // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。
95
- ChatBase string `json:"chat_base"`
96
- BillingBase string `json:"billing_base"`
97
- } `json:"global"`
98
-
99
- Upstream struct {
100
- // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。
101
- TimeoutSeconds int `json:"timeout_seconds"`
102
- // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。
103
- HeaderTimeoutSeconds int `json:"header_timeout_seconds"`
104
- // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。
105
- IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
106
- // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。
107
- // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。
108
- // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。
109
- UserAgent string `json:"user_agent"`
110
- // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` 与归属头
111
- // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。
112
- ClientVersion string `json:"client_version"`
113
- // CliVersion 出站 UA 中 `CLI/<ver>` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。
114
- CliVersion string `json:"cli_version"`
115
- // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。
116
- // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。
117
- ClientName string `json:"client_name"`
118
- // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。
119
- // 每号 auth 文件的 device_token 键优先于本项。
120
- DeviceToken string `json:"device_token"`
121
- // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。
122
- DeviceTokenFile string `json:"device_token_file"`
123
- // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。
124
- PassthroughIP bool `json:"passthrough_ip"`
125
- } `json:"upstream"`
126
-
127
- Features struct {
128
- // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。
129
- SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"`
130
- } `json:"features"`
131
-
132
- Prompt struct {
133
- // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded);
134
- // custom = 网关用自有系统提示词替换客户端 system/developer;
135
- // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。
136
- Mode string `json:"mode"` // "passthrough" / "custom" / "append"
137
- // File 提示词文件路径;空 = 内置默认 defaultprompt.md;
138
- // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。
139
- File string `json:"file"`
140
- } `json:"prompt"`
141
-
142
- // PromptText 解析后的系统提示词文本(custom/append 模式使用)。
143
- PromptText string `json:"-"`
144
-
145
- Upstash struct {
146
- URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host
147
- Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:<token>@<host>:6379
148
- } `json:"upstash"`
149
-
150
- Pool struct {
151
- MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限
152
- MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2
153
- BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3
154
- BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m"
155
- BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h"
156
- // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈
157
- // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。
158
- DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5
159
- DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m"
160
- DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制)
161
- IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重
162
- IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0
163
- // PreferExpiring 最早到期优先路由开关,默认 true。开启且 expiring_soon 窗口内
164
- // 存在有效批次时,按最早到期时间排序;关闭后完全不使用到期信息选号。
165
- PreferExpiring bool `json:"prefer_expiring"`
166
- // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在
167
- // 此窗口内的积分进入优先集,再按最早到期排序。空/0 = 禁用该路由门槛。
168
- ExpiringSoon string `json:"expiring_soon"`
169
- // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0
170
- // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切
171
- // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有
172
- // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为);
173
- // 空值回落默认。
174
- CostExploreInterval string `json:"cost_explore_interval"`
175
- // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再
176
- // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。
177
- // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。
178
- // 默认 0 = 关闭;负值钳 0。
179
- CreditFloor int64 `json:"credit_floor"`
180
- } `json:"pool"`
181
-
182
- SessionSticky struct {
183
- Enabled bool `json:"enabled"` // 默认 true
184
- TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m"
185
- GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m"
186
- } `json:"session_sticky"`
187
-
188
- // 解析后
189
- SoftRateDur time.Duration `json:"-"`
190
- SoftRateMaxDur time.Duration `json:"-"`
191
- BreakerCooldownDur time.Duration `json:"-"`
192
- BreakerCooldownMaxD time.Duration `json:"-"`
193
- DegradeCooldownDur time.Duration `json:"-"`
194
- DegradeCooldownMaxD time.Duration `json:"-"`
195
- SessionTTL time.Duration `json:"-"`
196
- SessionGCInterval time.Duration `json:"-"`
197
- BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false)
198
- ExpiringSoonDur time.Duration `json:"-"`
199
- // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。
200
- CostExploreIntervalDur time.Duration `json:"-"`
201
- }
202
-
203
- // Default 默认配置。
204
- func Default() *Config {
205
- c := &Config{
206
- Listen: ":7863",
207
- APIKey: "",
208
- AuthDir: "./auths",
209
- StateFile: "./data/state.json",
210
- }
211
- c.Cooldown.SoftRate = "600s"
212
- c.Cooldown.SoftRateMax = "2h"
213
- c.Panel.PackageDetailLimit = 5
214
- c.Logging.RequestArchiveEnabled = true
215
- c.Logging.RequestRetentionDays = 7
216
- c.Logging.RequestArchiveMaxMB = 100
217
- // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值,
218
- // 只有显式 false 才关闭来源记录。
219
- c.Logging.RequestClientInfo = true
220
- c.Schedule.CheckinHours = []int{9, 21}
221
- c.Schedule.TravelHours = []int{9, 21}
222
- c.Schedule.ActivityHours = []int{10}
223
- c.Schedule.KeepaliveHours = []int{22}
224
- c.Schedule.BlackcatHours = []int{23}
225
- c.Schedule.GrowthHours = []int{1}
226
- // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖,
227
- // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。
228
- c.Schedule.CheckinEnabled = true
229
- c.Schedule.GrowthEnabled = true
230
- c.Schedule.TravelEnabled = true
231
- c.Schedule.ActivityEnabled = true
232
- c.Schedule.KeepaliveEnabled = true
233
- c.Schedule.BlackcatEnabled = true
234
- c.Schedule.BalanceRefreshEnabled = true
235
- c.Schedule.BalanceRefreshMinutes = 5
236
- c.Upstream.TimeoutSeconds = 120
237
- // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。
238
- c.Upstream.HeaderTimeoutSeconds = 0
239
- c.Upstream.IdleTimeoutSeconds = 0
240
- // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用);
241
- // ChatBase/BillingBase 缺省空(回落内置默认)。
242
- c.Global.Enabled = true
243
- c.Features.SanitizeBlacklistFingerprints = true
244
- c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择)
245
- c.Pool.MaxInFlight = 3
246
- // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复
247
- // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键
248
- // 的 0 没有合理语义,回退分档默认最稳)。
249
- c.Pool.MaxInFlightGlobal = 2
250
- c.Pool.BreakerThreshold = 3
251
- c.Pool.BreakerCooldown = "30m"
252
- c.Pool.BreakerCooldownMax = "6h"
253
- c.Pool.DegradeThreshold = 5
254
- c.Pool.DegradeCooldown = "10m"
255
- c.Pool.DegradeCooldownMax = "2h"
256
- c.Pool.IdleWeightPerHour = 0.5
257
- c.Pool.IdleWeightMax = 5.0
258
- c.Pool.PreferExpiring = true
259
- c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期
260
- // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。
261
- c.Pool.CostExploreInterval = "30m"
262
- c.SessionSticky.Enabled = true
263
- c.SessionSticky.TTL = "30m"
264
- c.SessionSticky.GCInterval = "5m"
265
- return c
266
- }
267
-
268
- // Load 从文件读,再用 WB2A_* env 覆盖。
269
- func Load(path string) (*Config, error) {
270
- c := Default()
271
- if path != "" {
272
- // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录,
273
- // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。
274
- if st, statErr := os.Stat(path); statErr == nil && st.IsDir() {
275
- return nil, fmt.Errorf("config %s 是目录而非文件——"+
276
- "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+
277
- "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path)
278
- }
279
- raw, err := os.ReadFile(path)
280
- if err != nil {
281
- return nil, fmt.Errorf("read config: %w", err)
282
- }
283
- if _, err := ParseConfigInto(raw, c); err != nil {
284
- return nil, err
285
- }
286
- }
287
- applyEnv(c)
288
- if err := c.normalize(); err != nil {
289
- return nil, err
290
- }
291
- return c, nil
292
- }
293
-
294
- // ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。
295
- // 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。
296
- func ParseConfigInto(raw []byte, c *Config) (*Config, error) {
297
- if err := json.Unmarshal(raw, c); err != nil {
298
- return nil, fmt.Errorf("parse config: %w", err)
299
- }
300
- if err := c.normalize(); err != nil {
301
- return nil, err
302
- }
303
- return c, nil
304
- }
305
-
306
- // ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。
307
- func ParseConfig(raw []byte) (*Config, error) {
308
- return ParseConfigInto(raw, Default())
309
- }
310
-
311
- // WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。
312
- // 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成:
313
- // 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。
314
- // 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。
315
- func WriteDefault(path string) (string, error) {
316
- raw := make([]byte, 18)
317
- if _, err := rand.Read(raw); err != nil {
318
- return "", fmt.Errorf("gen api_key: %w", err)
319
- }
320
- key := "sk-" + base64.RawURLEncoding.EncodeToString(raw)
321
- c := Default()
322
- c.APIKey = key
323
- _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值
324
- out, err := json.MarshalIndent(c, "", " ")
325
- if err != nil {
326
- return "", fmt.Errorf("marshal config: %w", err)
327
- }
328
- if dir := filepath.Dir(path); dir != "" && dir != "." {
329
- if err := os.MkdirAll(dir, 0o755); err != nil {
330
- return "", fmt.Errorf("mkdir config dir: %w", err)
331
- }
332
- }
333
- // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。
334
- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
335
- if err != nil {
336
- return "", fmt.Errorf("write config: %w", err)
337
- }
338
- defer f.Close()
339
- if _, err := f.Write(out); err != nil {
340
- return "", fmt.Errorf("write config: %w", err)
341
- }
342
- return key, nil
343
- }
344
-
345
- func applyEnv(c *Config) {
346
- if v := os.Getenv("WB2A_LISTEN"); v != "" {
347
- c.Listen = v
348
- }
349
- if v := os.Getenv("WB2A_API_KEY"); v != "" {
350
- c.APIKey = v
351
- }
352
- if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
353
- c.AuthDir = v
354
- }
355
- if v := os.Getenv("WB2A_STATE_FILE"); v != "" {
356
- c.StateFile = v
357
- }
358
- if v := os.Getenv("WB2A_SOFT_RATE"); v != "" {
359
- c.Cooldown.SoftRate = v
360
- }
361
- if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" {
362
- c.Cooldown.SoftRateMax = v
363
- }
364
- if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" {
365
- if n, err := strconv.Atoi(v); err == nil {
366
- c.Upstream.TimeoutSeconds = n
367
- }
368
- }
369
- if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" {
370
- if n, err := strconv.Atoi(v); err == nil {
371
- c.Upstream.HeaderTimeoutSeconds = n
372
- }
373
- }
374
- if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" {
375
- if n, err := strconv.Atoi(v); err == nil {
376
- c.Upstream.IdleTimeoutSeconds = n
377
- }
378
- }
379
- if v := os.Getenv("WB2A_USER_AGENT"); v != "" {
380
- c.Upstream.UserAgent = v
381
- }
382
- if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" {
383
- c.Upstream.ClientVersion = v
384
- }
385
- if v := os.Getenv("WB2A_CLI_VERSION"); v != "" {
386
- c.Upstream.CliVersion = v
387
- }
388
- if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" {
389
- c.Upstream.ClientName = v
390
- }
391
- if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" {
392
- c.Upstream.DeviceToken = v
393
- }
394
- if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" {
395
- c.Upstream.DeviceTokenFile = v
396
- }
397
- if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" {
398
- if b, err := strconv.ParseBool(v); err == nil {
399
- c.Upstream.PassthroughIP = b
400
- }
401
- }
402
- if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" {
403
- if b, err := strconv.ParseBool(v); err == nil {
404
- c.Features.SanitizeBlacklistFingerprints = b
405
- }
406
- }
407
- if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" {
408
- c.Prompt.Mode = v
409
- }
410
- if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" {
411
- c.Prompt.File = v
412
- }
413
- if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" {
414
- c.Pool.ExpiringSoon = v
415
- }
416
- if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" {
417
- if b, err := strconv.ParseBool(v); err == nil {
418
- c.Pool.PreferExpiring = b
419
- }
420
- }
421
- }
422
-
423
- func (c *Config) normalize() error {
424
- var err error
425
- if c.Panel.PackageDetailLimit <= 0 {
426
- c.Panel.PackageDetailLimit = 5
427
- }
428
- if c.Logging.RequestRetentionDays <= 0 {
429
- c.Logging.RequestRetentionDays = 7
430
- }
431
- if c.Logging.RequestArchiveMaxMB <= 0 {
432
- c.Logging.RequestArchiveMaxMB = 100
433
- }
434
- if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil {
435
- return fmt.Errorf("cooldown.soft_rate: %w", err)
436
- }
437
- // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。
438
- if c.Cooldown.SoftRateMax == "" {
439
- c.Cooldown.SoftRateMax = "2h"
440
- }
441
- if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil {
442
- return fmt.Errorf("cooldown.soft_rate_max: %w", err)
443
- }
444
- if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil {
445
- return fmt.Errorf("pool.breaker_cooldown: %w", err)
446
- }
447
- if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil {
448
- return fmt.Errorf("pool.breaker_cooldown_max: %w", err)
449
- }
450
- if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil {
451
- return fmt.Errorf("pool.degrade_cooldown: %w", err)
452
- }
453
- if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil {
454
- return fmt.Errorf("pool.degrade_cooldown_max: %w", err)
455
- }
456
- if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil {
457
- return fmt.Errorf("session_sticky.ttl: %w", err)
458
- }
459
- if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil {
460
- return fmt.Errorf("session_sticky.gc_interval: %w", err)
461
- }
462
- // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。
463
- if c.Pool.ExpiringSoon != "" {
464
- if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil {
465
- return fmt.Errorf("pool.expiring_soon: %w", err)
466
- }
467
- }
468
- if c.ExpiringSoonDur < 0 {
469
- c.ExpiringSoonDur = 0
470
- c.Pool.ExpiringSoon = "0"
471
- }
472
- // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖
473
- // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停
474
- //("−5m" 无合理语义)。
475
- if c.Pool.CostExploreInterval == "" {
476
- c.Pool.CostExploreInterval = "30m"
477
- }
478
- if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil {
479
- return fmt.Errorf("pool.cost_explore_interval: %w", err)
480
- }
481
- if c.CostExploreIntervalDur < 0 {
482
- c.CostExploreIntervalDur = 0
483
- }
484
- // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。
485
- if c.Pool.CreditFloor < 0 {
486
- c.Pool.CreditFloor = 0
487
- }
488
- if c.Pool.BreakerThreshold <= 0 {
489
- c.Pool.BreakerThreshold = 3
490
- }
491
- // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。
492
- if c.Pool.DegradeThreshold <= 0 {
493
- c.Pool.DegradeThreshold = 5
494
- }
495
- if c.Pool.DegradeCooldown == "" {
496
- c.Pool.DegradeCooldown = "10m"
497
- }
498
- if c.Pool.DegradeCooldownMax == "" {
499
- c.Pool.DegradeCooldownMax = "2h"
500
- }
501
- // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。
502
- if c.Pool.MaxInFlightGlobal <= 0 {
503
- c.Pool.MaxInFlightGlobal = 2
504
- }
505
- if c.Pool.IdleWeightPerHour <= 0 {
506
- c.Pool.IdleWeightPerHour = 0.5
507
- }
508
- if c.Pool.IdleWeightMax <= 0 {
509
- c.Pool.IdleWeightMax = 5.0
510
- }
511
- if c.Upstream.TimeoutSeconds <= 0 {
512
- c.Upstream.TimeoutSeconds = 120
513
- }
514
- // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。
515
- // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。
516
- if c.Upstream.HeaderTimeoutSeconds <= 0 {
517
- c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds
518
- }
519
- if c.Upstream.IdleTimeoutSeconds <= 0 {
520
- c.Upstream.IdleTimeoutSeconds = 300
521
- }
522
- if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") {
523
- c.Listen = ":" + c.Listen
524
- }
525
- // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。
526
- // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。
527
- if len(c.Schedule.CheckinHours) == 0 {
528
- c.Schedule.CheckinHours = []int{9, 21}
529
- }
530
- if len(c.Schedule.TravelHours) == 0 {
531
- c.Schedule.TravelHours = []int{9, 21}
532
- }
533
- if len(c.Schedule.ActivityHours) == 0 {
534
- c.Schedule.ActivityHours = []int{10}
535
- }
536
- if len(c.Schedule.KeepaliveHours) == 0 {
537
- c.Schedule.KeepaliveHours = []int{22}
538
- }
539
- if len(c.Schedule.BlackcatHours) == 0 {
540
- c.Schedule.BlackcatHours = []int{23}
541
- }
542
- if len(c.Schedule.GrowthHours) == 0 {
543
- c.Schedule.GrowthHours = []int{1}
544
- }
545
- // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。
546
- if c.Schedule.BalanceRefreshEnabled {
547
- if c.Schedule.BalanceRefreshMinutes <= 0 {
548
- c.Schedule.BalanceRefreshMinutes = 5
549
- }
550
- c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute
551
- }
552
- if err := c.validateScheduleHours(); err != nil {
553
- return err
554
- }
555
- return c.normalizePrompt()
556
- }
557
-
558
- // normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。
559
- //
560
- // mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支;
561
- // custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认
562
- // (两模式共用同一加载路径,PromptText 均非空)。
563
- // passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。
564
- func (c *Config) normalizePrompt() error {
565
- switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m {
566
- case "", "passthrough":
567
- c.Prompt.Mode = "passthrough"
568
- case "custom":
569
- c.Prompt.Mode = "custom"
570
- case "append":
571
- c.Prompt.Mode = "append"
572
- default:
573
- return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode)
574
- }
575
- if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" {
576
- text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File)
577
- if err != nil {
578
- return err
579
- }
580
- c.PromptText = text
581
- }
582
- return nil
583
- }
584
-
585
- // validateScheduleHours 校验排程小时落在 0-23。
586
- //
587
- // 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到,
588
- // 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关
589
- // (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。
590
- func (c *Config) validateScheduleHours() error {
591
- if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil {
592
- return err
593
- }
594
- if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil {
595
- return err
596
- }
597
- if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil {
598
- return err
599
- }
600
- if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil {
601
- return err
602
- }
603
- if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil {
604
- return err
605
- }
606
- return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours)
607
- }
608
-
609
- func checkHourRange(field, switchKey string, hours []int) error {
610
- for _, h := range hours {
611
- if h < 0 || h > 23 {
612
- return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey)
613
- }
614
- }
615
- return nil
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 文件路径非空但��存在 → 启动报错(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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.11.11-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
- })
191
- switch {
192
- case !cfg.Schedule.CheckinEnabled:
193
- log.Printf("签到已禁用(schedule.checkin_enabled=false)")
194
- default:
195
- log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours)
196
- }
197
- switch {
198
- case !cfg.Schedule.TravelEnabled:
199
- log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)")
200
- default:
201
- log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours)
202
- }
203
- switch {
204
- case !cfg.Schedule.ActivityEnabled:
205
- log.Printf("活跃上报已禁用(schedule.activity_enabled=false)")
206
- default:
207
- log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours)
208
- }
209
- if !cfg.Schedule.KeepaliveEnabled {
210
- log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)")
211
- } else {
212
- log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours)
213
- }
214
- switch {
215
- case !cfg.Schedule.BlackcatEnabled:
216
- log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)")
217
- default:
218
- log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours)
219
- }
220
- switch {
221
- case !cfg.Schedule.BalanceRefreshEnabled:
222
- log.Printf("余额后台刷新已禁用(schedule.balance_refresh_enabled=false)")
223
- case cfg.BalanceRefreshInterval > 0:
224
- log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval)
225
- }
226
-
227
- // 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进
228
- // 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。
229
- // live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。
230
- live := livecfg.New(livecfg.Snapshot{
231
- APIKey: cfg.APIKey,
232
- SoftCooldown: cfg.SoftRateDur,
233
- SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints,
234
- RecordClientInfo: cfg.Logging.RequestClientInfo,
235
- })
236
- // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。
237
- // datapath 由 state 文件路径推出,避免再加一个配置项。
238
- usagePath := usagePathFor(cfg.StateFile)
239
- rec := usage.New(usagePath)
240
- rec.Start()
241
- defer rec.Stop()
242
- log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe())
243
-
244
- // 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。
245
- requestLog := reqlog.New(reqlog.Config{
246
- Dir: stateSibling(cfg.StateFile, "request-logs"),
247
- Enabled: cfg.Logging.RequestArchiveEnabled,
248
- RetentionDays: cfg.Logging.RequestRetentionDays,
249
- MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20,
250
- })
251
- defer requestLog.Close()
252
- rs := requestLog.Snapshot().Archive
253
- if rs.Enabled {
254
- log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)",
255
- rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB)
256
- } else {
257
- log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭")
258
- }
259
-
260
- pn := panel.New(panel.Config{
261
- Pool: p,
262
- Usage: rec,
263
- RequestLog: requestLog,
264
- Upstream: up,
265
- Scheduler: sch,
266
- AuthDir: cfg.AuthDir,
267
- APIKey: cfg.APIKey,
268
- RedisMode: redisMode,
269
- StickyCount: sessCount,
270
- Version: appVersion,
271
- Live: live,
272
- // 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入):
273
- // 与 state 文件同目录,缺省 data/output_probes.json。
274
- ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"),
275
- ConfigPath: *cfgPath,
276
- LoadConfig: func() (any, error) {
277
- return Load(*cfgPath)
278
- },
279
- SaveConfig: func(raw []byte) ([]string, error) {
280
- return saveConfig(raw, *cfgPath, live, p, up, sch)
281
- },
282
- })
283
- // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后
284
- // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。
285
- sch.SetGrowthHook(pn.RunGrowthQueueOnce)
286
- log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs()))
287
- server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs()))
288
-
289
- h := server.NewHandler(server.Config{
290
- Pool: p,
291
- Upstream: up,
292
- APIKey: cfg.APIKey,
293
- Session: sessRouter,
294
- StickyCount: sessCount,
295
- RedisMode: redisMode,
296
- SoftCooldown: cfg.SoftRateDur,
297
- Panel: pn,
298
- Live: live,
299
- Usage: rec,
300
- RequestLog: requestLog,
301
- PromptMode: cfg.Prompt.Mode,
302
- PromptText: cfg.PromptText,
303
- // 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的
304
- // 裸用/测试路径拿到同一缺省值。
305
- RecordClientInfo: cfg.Logging.RequestClientInfo,
306
- // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。
307
- GlobalEnabled: cfg.Global.Enabled,
308
- })
309
-
310
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
311
- defer stop()
312
- go sch.Run(ctx)
313
- sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval)
314
-
315
- // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时
316
- // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前,
317
- // ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号
318
- // 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费
319
- // 模型归零;倍率表当时尚未建立)。
320
- // 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。
321
- go warmModelRates(ctx, up, p)
322
-
323
- srv := &http.Server{
324
- Addr: cfg.Listen,
325
- Handler: h,
326
- ReadHeaderTimeout: 30 * time.Second,
327
- // ReadTimeout 覆盖整个请求读取(含 body):防慢速 body 拖死连接。
328
- // 请求体已无网关侧上限(max_body_mb 移除),60s 按常规带宽的数十 MB
329
- // 上传余量取值;超大 body 慢速上传若超时,由客户端重试。
330
- ReadTimeout: 60 * time.Second,
331
- // IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。
332
- // 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout
333
- // (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。
334
- IdleTimeout: 120 * time.Second,
335
- }
336
- go func() {
337
- <-ctx.Done()
338
- p.Flush() // 信号触发:先落盘再做优雅停机
339
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
340
- defer cancel()
341
- _ = srv.Shutdown(shutdownCtx)
342
- }()
343
-
344
- log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen))
345
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
346
- log.Fatalf("http: %v", err)
347
- }
348
- log.Printf("bye")
349
- }
350
-
351
- // warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。
352
- //
353
- // 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时
354
- // 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发
355
- // 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被
356
- // 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。
357
- //
358
- // 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域
359
- // 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。
360
- func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) {
361
- // 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。
362
- if ctx.Err() != nil {
363
- return
364
- }
365
- // CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。
366
- if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 {
367
- if a := p.AuthByUID(uids[0]); a != nil {
368
- if _, err := up.FetchModels(a); err != nil {
369
- log.Printf("WARN: [upstream] warm model rates (cn): %v", err)
370
- } else {
371
- log.Printf("[upstream] warm model rates: cn ok")
372
- }
373
- }
374
- }
375
- // global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。
376
- if up.GlobalEnabled && ctx.Err() == nil {
377
- if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 {
378
- if a := p.AuthByUID(uids[0]); a != nil {
379
- // FetchGlobalModelInfos 无错误返回(内部负缓存自行节流),
380
- // 仅按结果条数判断是否拿到目录。
381
- if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 {
382
- log.Printf("WARN: [upstream] warm model rates (global): empty model list")
383
- } else {
384
- log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos))
385
- }
386
- }
387
- }
388
- }
389
- }
390
-
391
- // panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL
392
- // (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。
393
- func panelListenPath(listen string) string {
394
- for i := len(listen) - 1; i >= 0; i-- {
395
- if listen[i] == ':' {
396
- return listen[i:]
397
- }
398
- }
399
- return listen
400
- }
401
-
402
- // saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。
403
- //
404
- // 热生效范围(设计取舍):
405
- // - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照
406
- // - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor
407
- // - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow
408
- //
409
- // 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖):
410
- // - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类)
411
- //
412
- // 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改
413
- // 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体
414
- // 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。
415
- func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) {
416
- // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。
417
- oldRaw, err := os.ReadFile(path)
418
- if err != nil {
419
- return nil, fmt.Errorf("read current config: %w", err)
420
- }
421
- var cur, incoming map[string]any
422
- if err := json.Unmarshal(oldRaw, &cur); err != nil {
423
- cur = map[string]any{}
424
- }
425
- if err := json.Unmarshal(raw, &incoming); err != nil {
426
- return nil, fmt.Errorf("parse submitted config: %w", err)
427
- }
428
- merged := mergeConfigMaps(cur, incoming)
429
-
430
- // 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。
431
- newCfg, err := ParseConfig(mergedJSON(merged))
432
- if err != nil {
433
- return nil, err
434
- }
435
-
436
- // 3) 落盘(原子替换)。
437
- out, err := json.MarshalIndent(merged, "", " ")
438
- if err != nil {
439
- return nil, fmt.Errorf("marshal config: %w", err)
440
- }
441
- tmp := path + ".tmp"
442
- if err := os.WriteFile(tmp, out, 0o600); err != nil {
443
- return nil, fmt.Errorf("write config: %w", err)
444
- }
445
- if err := os.Rename(tmp, path); err != nil {
446
- // A single-file Docker bind mount cannot be renamed over its mount
447
- // target (Linux returns EBUSY / "device or resource busy"). Keep the
448
- // atomic path for regular files, but update the mounted file in place
449
- // for this specific deployment shape.
450
- if !errors.Is(err, syscall.EBUSY) {
451
- return nil, fmt.Errorf("replace config: %w", err)
452
- }
453
- f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
454
- if openErr != nil {
455
- _ = os.Remove(tmp)
456
- return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr)
457
- }
458
- _, writeErr := f.Write(out)
459
- if writeErr == nil {
460
- writeErr = f.Sync()
461
- }
462
- closeErr := f.Close()
463
- // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容,
464
- // 可手工恢复);写成功才清理。
465
- if writeErr != nil {
466
- return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr)
467
- }
468
- _ = os.Remove(tmp)
469
- if closeErr != nil {
470
- return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr)
471
- }
472
- }
473
-
474
- // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。
475
- live.Store(livecfg.Snapshot{
476
- APIKey: newCfg.APIKey,
477
- SoftCooldown: newCfg.SoftRateDur,
478
- SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints,
479
- RecordClientInfo: newCfg.Logging.RequestClientInfo,
480
- })
481
- up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints)
482
- p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD)
483
- p.SetMaxInFlight(newCfg.Pool.MaxInFlight)
484
- p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal)
485
- p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD)
486
- p.SetSoftRateMax(newCfg.SoftRateMaxDur)
487
- p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停)
488
- p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭)
489
- p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax)
490
- p.SetPreferExpiring(newCfg.Pool.PreferExpiring)
491
- sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur)
492
- sch.Reconfigure(
493
- newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours,
494
- newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours,
495
- newCfg.Schedule.GrowthHours,
496
- !newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled,
497
- !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled,
498
- !newCfg.Schedule.GrowthEnabled)
499
- sch.SetBalanceInterval(newCfg.BalanceRefreshInterval)
500
-
501
- return restartRequiredFields(newCfg), nil
502
- }
503
-
504
- // restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。
505
- // 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。
506
- func restartRequiredFields(c *Config) []string {
507
- var out []string
508
- // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。
509
- if c.Listen != "" {
510
- out = append(out, "listen")
511
- }
512
- if c.AuthDir != "" {
513
- out = append(out, "auth_dir")
514
- }
515
- if c.StateFile != "" {
516
- out = append(out, "state_file")
517
- }
518
- out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds")
519
- if c.Upstash.URL != "" || c.Upstash.Token != "" {
520
- out = append(out, "upstash")
521
- }
522
- out = append(out, "session_sticky.ttl", "session_sticky.gc_interval")
523
- out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb")
524
- return out
525
- }
526
-
527
- // mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。
528
- // 对嵌套对象逐键覆盖而不是整体替换:面板表单只提交它管理的键,
529
- // 未提交的兄弟键(含用户手写的未知键)保持原样。
530
- func mergeConfigMaps(cur, incoming map[string]any) map[string]any {
531
- for k, v := range incoming {
532
- if inMap, ok := v.(map[string]any); ok {
533
- if curMap, ok := cur[k].(map[string]any); ok {
534
- cur[k] = mergeConfigMaps(curMap, inMap)
535
- continue
536
- }
537
- }
538
- cur[k] = v
539
- }
540
- return cur
541
- }
542
-
543
- // mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。
544
- func mergedJSON(m map[string]any) []byte {
545
- b, err := json.Marshal(m)
546
- if err != nil {
547
- return []byte("{}")
548
- }
549
- return b
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
- "panel": {
7
- "package_detail_limit": 5
8
- },
9
- "logging": {
10
- "request_archive_enabled": true,
11
- "request_retention_days": 7,
12
- "request_archive_max_mb": 100,
13
- "request_client_info": true
14
- },
15
- "cooldown": {
16
- "soft_rate": "600s",
17
- "soft_rate_max": "2h"
18
- },
19
- "schedule": {
20
- "checkin_hours": [
21
- 9,
22
- 21
23
- ],
24
- "travel_hours": [
25
- 9,
26
- 21
27
- ],
28
- "activity_hours": [
29
- 10
30
- ],
31
- "keepalive_hours": [
32
- 22
33
- ],
34
- "blackcat_hours": [
35
- 23
36
- ],
37
- "growth_hours": [
38
- 1
39
- ],
40
- "checkin_enabled": true,
41
- "travel_enabled": true,
42
- "activity_enabled": true,
43
- "keepalive_enabled": true,
44
- "blackcat_enabled": true,
45
- "growth_enabled": true,
46
- "balance_refresh_enabled": true,
47
- "balance_refresh_minutes": 5
48
- },
49
- "global": {
50
- "enabled": true,
51
- "chat_base": "",
52
- "billing_base": ""
53
- },
54
- "upstream": {
55
- "timeout_seconds": 120,
56
- "header_timeout_seconds": 120,
57
- "idle_timeout_seconds": 300,
58
- "user_agent": "",
59
- "client_version": "",
60
- "cli_version": "",
61
- "client_name": "",
62
- "device_token": "",
63
- "device_token_file": "",
64
- "passthrough_ip": false
65
- },
66
- "features": {
67
- "sanitize_blacklist_fingerprints": true
68
- },
69
- "prompt": {
70
- "mode": "passthrough",
71
- "file": ""
72
- },
73
- "upstash": {
74
- "url": "",
75
- "token": ""
76
- },
77
- "pool": {
78
- "max_in_flight": 3,
79
- "max_in_flight_global": 2,
80
- "breaker_threshold": 3,
81
- "breaker_cooldown": "30m",
82
- "breaker_cooldown_max": "6h",
83
- "degrade_threshold": 5,
84
- "degrade_cooldown": "10m",
85
- "degrade_cooldown_max": "2h",
86
- "idle_weight_per_hour": 0.5,
87
- "idle_weight_max": 5,
88
- "prefer_expiring": true,
89
- "expiring_soon": "168h",
90
- "cost_explore_interval": "30m",
91
- "credit_floor": 0
92
- },
93
- "session_sticky": {
94
- "enabled": true,
95
- "ttl": "30m",
96
- "gc_interval": "5m"
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
- "panel": {
7
- "package_detail_limit": 5
8
- },
9
- "logging": {
10
- "request_archive_enabled": true,
11
- "request_retention_days": 7,
12
- "request_archive_max_mb": 100,
13
- "request_client_info": true
14
- },
15
- "cooldown": {
16
- "soft_rate": "600s",
17
- "soft_rate_max": "2h"
18
- },
19
- "schedule": {
20
- "checkin_hours": [9, 21],
21
- "growth_hours": [1],
22
- "travel_hours": [9, 21],
23
- "activity_hours": [10],
24
- "keepalive_hours": [22],
25
- "blackcat_hours": [23],
26
- "checkin_enabled": true,
27
- "growth_enabled": true,
28
- "travel_enabled": true,
29
- "activity_enabled": true,
30
- "keepalive_enabled": true,
31
- "blackcat_enabled": true,
32
- "balance_refresh_enabled": true,
33
- "balance_refresh_minutes": 5
34
- },
35
- "global": {
36
- "enabled": true,
37
- "chat_base": "",
38
- "billing_base": ""
39
- },
40
- "upstream": {
41
- "timeout_seconds": 120,
42
- "header_timeout_seconds": 120,
43
- "idle_timeout_seconds": 300,
44
- "user_agent": "",
45
- "client_version": "",
46
- "cli_version": "",
47
- "client_name": "",
48
- "device_token": "",
49
- "device_token_file": "",
50
- "passthrough_ip": false
51
- },
52
- "features": {
53
- "sanitize_blacklist_fingerprints": true
54
- },
55
- "prompt": {
56
- "mode": "passthrough",
57
- "file": ""
58
- },
59
- "upstash": {
60
- "url": "",
61
- "token": ""
62
- },
63
- "pool": {
64
- "max_in_flight": 3,
65
- "max_in_flight_global": 2,
66
- "breaker_threshold": 3,
67
- "breaker_cooldown": "30m",
68
- "breaker_cooldown_max": "6h",
69
- "degrade_threshold": 5,
70
- "degrade_cooldown": "10m",
71
- "degrade_cooldown_max": "2h",
72
- "idle_weight_per_hour": 0.5,
73
- "idle_weight_max": 5.0,
74
- "prefer_expiring": true,
75
- "expiring_soon": "168h",
76
- "cost_explore_interval": "30m",
77
- "credit_floor": 100
78
- },
79
- "session_sticky": {
80
- "enabled": true,
81
- "ttl": "30m",
82
- "gc_interval": "5m"
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
- "strconv"
10
- "strings"
11
- "testing"
12
- "time"
13
- )
14
-
15
- // TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。
16
- //
17
- // 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容——
18
- // 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量)
19
- // 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。
20
- // 无 node 环境时跳过(不阻塞无 Node 的构建机)。
21
- func TestAppJSSyntax(t *testing.T) {
22
- node, err := exec.LookPath("node")
23
- if err != nil {
24
- t.Skip("node not available; skipping JS syntax check")
25
- }
26
- path, err := filepath.Abs("app.js")
27
- if err != nil {
28
- t.Fatal(err)
29
- }
30
- out, err := exec.Command(node, "--check", path).CombinedOutput()
31
- if err != nil {
32
- t.Fatalf("app.js syntax error:\n%s", out)
33
- }
34
- }
35
-
36
- // TestIndexHTMLNoInlineScript index.html 不得含内联 <script> 块:
37
- // 严格 CSP(script-src 'self')会拦截内联脚本,页面将完全不可用。
38
- // 外链形式 <script src="..."> 允许。
39
- func TestIndexHTMLNoInlineScript(t *testing.T) {
40
- p := newTestPanel()
41
- rec := httptest.NewRecorder()
42
- p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil))
43
- body := rec.Body.String()
44
-
45
- rest := body
46
- for {
47
- idx := strings.Index(rest, "<script")
48
- if idx < 0 {
49
- break
50
- }
51
- rest = rest[idx:]
52
- end := strings.Index(rest, ">")
53
- if end < 0 {
54
- break
55
- }
56
- tag := rest[:end+1]
57
- if !strings.Contains(tag, "src=") {
58
- t.Fatalf("index.html contains inline <script> (blocked by CSP): %s", tag)
59
- }
60
- rest = rest[end:]
61
- }
62
- }
63
-
64
- // TestAppJSTopLevelSmoke app.js 顶层求值冒烟(v1.11.3/1.11.4 两连炸后补的运行时闸门):
65
- // node + DOM 桩执行 app.js(含按 hash 落到各视图的 go() 顶层调用),抓 TDZ/
66
- // ReferenceError 类运行时错误——Go 侧 frontend_test 不执行 JS,语法层检查对此全盲。
67
- // 无 node 的环境跳过(CI/精简机不受影响);harness 与 app.js 同判(app.js 顶层
68
- // start() 的 setInterval 会让 node 事件循环不退出,故成功路径显式 exit(0))。
69
- func TestAppJSTopLevelSmoke(t *testing.T) {
70
- node, err := exec.LookPath("node")
71
- if err != nil {
72
- t.Skip("node not installed; JS smoke skipped")
73
- }
74
- harness := `const fs = require('fs');
75
- const vm = require('vm');
76
- const src = fs.readFileSync(process.argv[2], 'utf8');
77
- const inert = new Proxy(function () {}, {
78
- get(t, k) { if (k === Symbol.toPrimitive) return () => ''; return inert; },
79
- set() { return true; },
80
- apply() { return inert; },
81
- construct() { return inert; },
82
- has() { return true; },
83
- });
84
- const sandbox = new Proxy({
85
- location: { hash: process.env.SMOKE_HASH || '#taskscenter' },
86
- history: { replaceState() {} },
87
- localStorage: { getItem: () => null, setItem() {} },
88
- navigator: { clipboard: { writeText: () => Promise.resolve() } },
89
- document: { querySelectorAll: () => [], querySelector: () => inert, getElementById: () => inert, addEventListener() {}, documentElement: inert, head: inert, body: inert, createElement: () => inert, cookie: '' },
90
- fetch: () => new Promise(() => {}),
91
- addEventListener() {}, removeEventListener() {},
92
- matchMedia: () => ({ matches: false, addEventListener() {} }),
93
- setInterval, clearInterval, setTimeout, clearTimeout,
94
- console, JSON, Math, Date, Number, String, Boolean, Object, Array, Promise, Map, Set, RegExp, Error, TypeError, isNaN, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, URL, Symbol, Proxy, Reflect,
95
- }, { get(t, k) { return t[k]; }, has() { return true; } });
96
- sandbox.window = sandbox; sandbox.globalThis = sandbox;
97
- vm.createContext(sandbox);
98
- try {
99
- vm.runInContext(src, sandbox, { filename: 'app.js' });
100
- console.log('SMOKE OK');
101
- process.exit(0);
102
- } catch (e) {
103
- console.log('SMOKE FAIL:', (e && e.stack ? e.stack : e).toString().split('\n').slice(0, 5).join('\n'));
104
- process.exit(1);
105
- }
106
- `
107
- hf, err := os.CreateTemp(t.TempDir(), "smoke-*.cjs")
108
- if err != nil {
109
- t.Fatal(err)
110
- }
111
- if _, err := hf.WriteString(harness); err != nil {
112
- t.Fatal(err)
113
- }
114
- hf.Close()
115
- for _, hash := range []string{"#taskscenter", "#accounts", "#usage", "#models", "#config", "#logs", "#packages"} {
116
- cmd := exec.Command(node, hf.Name(), "app.js")
117
- cmd.Dir = "." // 测试工作目录 = internal/panel
118
- cmd.Env = append(os.Environ(), "SMOKE_HASH="+hash)
119
- out, err := cmd.CombinedOutput()
120
- if err != nil {
121
- t.Fatalf("app.js 顶层求值 %s 崩溃: %v\n%s", hash, err, out)
122
- }
123
- if !bytes.Contains(out, []byte("SMOKE OK")) {
124
- t.Fatalf("app.js smoke %s 未通过:\n%s", hash, out)
125
- }
126
- }
127
- }
128
-
129
- // 积分扣除维度的格式必须稳定,且缺样本/缺匹配 Token 时不能伪造比例。
130
- func TestAppJSCreditDimensionFormatting(t *testing.T) {
131
- node, err := exec.LookPath("node")
132
- if err != nil {
133
- t.Skip("node not installed; credit formatting test skipped")
134
- }
135
- script := `const fs = require('fs');
136
- const vm = require('vm');
137
- const src = fs.readFileSync(process.argv[2], 'utf8');
138
- const start = src.indexOf('function trimFixed');
139
- const end = src.indexOf('function usStat');
140
- if (start < 0 || end < 0) throw new Error('credit helpers not found');
141
- const ctx = { Number, String, RegExp };
142
- vm.createContext(ctx);
143
- vm.runInContext(
144
- src.slice(start, end) +
145
- '\nthis.fmtCredit=fmtCredit; this.fmtCreditRatio=fmtCreditRatio; this.fmtModelRate=fmtModelRate;',
146
- ctx
147
- );
148
- process.stdout.write(JSON.stringify({
149
- credit: ctx.fmtCredit(1.25),
150
- zero: ctx.fmtCredit(0),
151
- hundred: ctx.fmtCredit(100),
152
- ratio: ctx.fmtCreditRatio(12.5, 2, 400),
153
- noSamples: ctx.fmtCreditRatio(12.5, 0, 400),
154
- noTokens: ctx.fmtCreditRatio(12.5, 2, 0),
155
- rate: ctx.fmtModelRate('0.5'),
156
- noRate: ctx.fmtModelRate(''),
157
- }));`
158
- f, err := os.CreateTemp(t.TempDir(), "credit-format-*.cjs")
159
- if err != nil {
160
- t.Fatal(err)
161
- }
162
- if _, err := f.WriteString(script); err != nil {
163
- t.Fatal(err)
164
- }
165
- f.Close()
166
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
167
- if err != nil {
168
- t.Fatalf("credit formatting node test failed: %v\n%s", err, out)
169
- }
170
- const want = `{"credit":"1.25","zero":"0","hundred":"100","ratio":"12.5 / 1M","noSamples":"—","noTokens":"—","rate":"x0.5","noRate":"—"}`
171
- if strings.TrimSpace(string(out)) != want {
172
- t.Fatalf("credit formatting=%s want %s", out, want)
173
- }
174
- }
175
-
176
- // 模型限流时间必须同时支持上游 reset_at、网关 until 和无重置时间三种形态。
177
- func TestAppJSRateLimitMeta(t *testing.T) {
178
- node, err := exec.LookPath("node")
179
- if err != nil {
180
- t.Skip("node not installed; rate limit formatting test skipped")
181
- }
182
- script := `const fs = require('fs');
183
- const vm = require('vm');
184
- const src = fs.readFileSync(process.argv[2], 'utf8');
185
- const start = src.indexOf('function dur(');
186
- const end = src.indexOf('function rateLimitRowsHtml');
187
- if (start < 0 || end < 0) throw new Error('rate limit helpers not found');
188
- const ctx = { Date, Number, String, Math };
189
- vm.createContext(ctx);
190
- vm.runInContext(src.slice(start, end) + '\nthis.rateLimitMeta=rateLimitMeta;', ctx);
191
- const now = new Date(2026, 8, 28, 14, 0, 0).getTime();
192
- const reset = new Date(2026, 8, 28, 16, 0, 0).getTime();
193
- const until = new Date(2026, 8, 28, 15, 0, 0).getTime();
194
- const rate = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit', reset_at: new Date(reset).toISOString(), until: new Date(until).toISOString() }, now);
195
- const unavailable = ctx.rateLimitMeta({ model: 'missing', kind: 'model_unavailable', until: new Date(until).toISOString() }, now);
196
- const unknown = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit' }, now);
197
- process.stdout.write(JSON.stringify({
198
- rate: rate.detail,
199
- unavailable: unavailable.detail,
200
- unknown: unknown.detail,
201
- }));`
202
- f, err := os.CreateTemp(t.TempDir(), "rate-limit-format-*.cjs")
203
- if err != nil {
204
- t.Fatal(err)
205
- }
206
- if _, err := f.WriteString(script); err != nil {
207
- t.Fatal(err)
208
- }
209
- f.Close()
210
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
211
- if err != nil {
212
- t.Fatalf("rate-limit formatting node test failed: %v\n%s", err, out)
213
- }
214
- const want = `{"rate":"预计 2026-09-28 16:00 解封(剩余 2时00分) · 网关最快 1时00分 后重试","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}`
215
- if strings.TrimSpace(string(out)) != want {
216
- t.Fatalf("rate-limit formatting=%s want %s", out, want)
217
- }
218
- }
219
-
220
- // 请求记录行必须紧凑、可读,并带上调用来源(IP / UA);来源缺失时以 — 兜底。
221
- func TestAppJSRequestLogFormatting(t *testing.T) {
222
- node, err := exec.LookPath("node")
223
- if err != nil {
224
- t.Skip("node not installed; request log formatting test skipped")
225
- }
226
- script := `const fs = require('fs');
227
- const vm = require('vm');
228
- const src = fs.readFileSync(process.argv[2], 'utf8');
229
- const escStart = src.indexOf('function esc(');
230
- const escEnd = src.indexOf('function ago(');
231
- const fmtStart = src.indexOf('function fmtTok(');
232
- const fmtEnd = src.indexOf('function usStat(');
233
- const reqStart = src.indexOf('function requestLogText');
234
- const reqEnd = src.indexOf('function fmtBytes');
235
- if ([escStart, escEnd, fmtStart, fmtEnd, reqStart, reqEnd].some(v => v < 0)) throw new Error('request log helpers not found');
236
- const ctx = { Date, Number, String, Math, RegExp, isNaN };
237
- vm.createContext(ctx);
238
- vm.runInContext(
239
- src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(reqStart, reqEnd) +
240
- '\nthis.requestLogText=requestLogText;',
241
- ctx
242
- );
243
- const time = new Date(2026, 8, 28, 14, 5, 6).toISOString();
244
- 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' };
245
- const noSource = { ...good, request_id: 'req-3', client_ip: '', user_agent: '' };
246
- const cached = { ...good, request_id: 'req-2', cache_hit_tokens: 2257, cache_miss_tokens: 43 };
247
- process.stdout.write(JSON.stringify({
248
- good: ctx.requestLogText(good),
249
- noSource: ctx.requestLogText(noSource),
250
- cached: ctx.requestLogText(cached),
251
- }));`
252
- f, err := os.CreateTemp(t.TempDir(), "request-log-format-*.cjs")
253
- if err != nil {
254
- t.Fatal(err)
255
- }
256
- if _, err := f.WriteString(script); err != nil {
257
- t.Fatal(err)
258
- }
259
- f.Close()
260
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
261
- if err != nil {
262
- t.Fatalf("request log formatting node test failed: %v\n%s", err, out)
263
- }
264
- 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"
265
- noSource := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | — | — | 1.25s | 2.3k tok | 0.12 credit | req-3"
266
- 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"
267
- want := `{"good":` + strconv.Quote(text) + `,"noSource":` + strconv.Quote(noSource) + `,"cached":` + strconv.Quote(cached) + `}`
268
- if strings.TrimSpace(string(out)) != want {
269
- t.Fatalf("request log formatting=%s want %s", out, want)
270
- }
271
- }
272
-
273
- // 请求记录筛选:IP / UA / 模型 / 账号 / 请求 ID 的包含匹配(空格分词 AND)+ 结果精确匹配。
274
- func TestAppJSRequestMatch(t *testing.T) {
275
- node, err := exec.LookPath("node")
276
- if err != nil {
277
- t.Skip("node not installed; request filter test skipped")
278
- }
279
- script := `const fs = require('fs');
280
- const vm = require('vm');
281
- const src = fs.readFileSync(process.argv[2], 'utf8');
282
- const start = src.indexOf('function reqMatch');
283
- const end = src.indexOf('function reqOutcomeTag');
284
- if (start < 0 || end < 0) throw new Error('reqMatch not found');
285
- const ctx = {};
286
- vm.createContext(ctx);
287
- vm.runInContext(src.slice(start, end) + '\nthis.reqMatch=reqMatch;', ctx);
288
- 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' };
289
- 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' };
290
- const rows = [base, other];
291
- const pick = f => rows.filter(e => ctx.reqMatch(e, f)).map(e => e.request_id);
292
- process.stdout.write(JSON.stringify({
293
- all: pick({ q: '', outcome: '' }),
294
- byIP: pick({ q: '203.0.113', outcome: '' }),
295
- byUA: pick({ q: 'chrome/120', outcome: '' }),
296
- byModel: pick({ q: 'glm', outcome: '' }),
297
- multiKw: pick({ q: 'glm success', outcome: '' }),
298
- multiMiss: pick({ q: 'glm chrome', outcome: '' }),
299
- byOutcome: pick({ q: '', outcome: 'http_error' }),
300
- combined: pick({ q: '198.51', outcome: 'http_error' }),
301
- }));`
302
- f, err := os.CreateTemp(t.TempDir(), "request-filter-*.cjs")
303
- if err != nil {
304
- t.Fatal(err)
305
- }
306
- if _, err := f.WriteString(script); err != nil {
307
- t.Fatal(err)
308
- }
309
- f.Close()
310
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
311
- if err != nil {
312
- t.Fatalf("request filter node test failed: %v\n%s", err, out)
313
- }
314
- // q 对 outcome 不参与匹配(outcome 有独立下拉),multiKw 里的 success 命中不了任何字段。
315
- const want = `{"all":["req-1","req-2"],"byIP":["req-1"],"byUA":["req-2"],"byModel":["req-1"],"multiKw":[],"multiMiss":[],"byOutcome":["req-2"],"combined":["req-2"]}`
316
- if strings.TrimSpace(string(out)) != want {
317
- t.Fatalf("request filter=%s want %s", out, want)
318
- }
319
- }
320
-
321
- // 模型按条件查询:域 / 能力 / 档位 / 价格 / 关键词,以及倍率、上下文、输出排序。
322
- func TestAppJSModelFilter(t *testing.T) {
323
- node, err := exec.LookPath("node")
324
- if err != nil {
325
- t.Skip("node not installed; model filter test skipped")
326
- }
327
- script := `const fs = require('fs');
328
- const vm = require('vm');
329
- const src = fs.readFileSync(process.argv[2], 'utf8');
330
- const start = src.indexOf('function mdRateValue');
331
- const end = src.indexOf('function mdRowHtml');
332
- if (start < 0 || end < 0) throw new Error('model filter helpers not found');
333
- const ctx = { Number, String, Array, Object, isFinite, parseFloat };
334
- vm.createContext(ctx);
335
- vm.runInContext(src.slice(start, end) + '\nthis.mdMatch=mdMatch; this.mdSortList=mdSortList; this.mdRateValue=mdRateValue;', ctx);
336
- const models = [
337
- { 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 },
338
- { 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 },
339
- { 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 },
340
- { 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 },
341
- ];
342
- const ids = list => list.map(m => m.id);
343
- const filter = f => ids(ctx.mdSortList(models.filter(m => ctx.mdMatch(m, f)), f));
344
- process.stdout.write(JSON.stringify({
345
- all: ids(models),
346
- realm: filter({ realm: 'cn' }),
347
- tool: filter({ cap: 'tool' }),
348
- vision: filter({ cap: 'vision' }),
349
- reasoning: filter({ cap: 'reasoning' }),
350
- isDefault: filter({ cap: 'default' }),
351
- effortOff: filter({ effort: 'off' }),
352
- effortLow: filter({ effort: 'low' }),
353
- free: filter({ promo: 'free' }),
354
- promo: filter({ promo: 'promo' }),
355
- discount: filter({ promo: 'discount' }),
356
- q: filter({ q: 'glm zhipu' }),
357
- qMiss: filter({ q: 'glm nosuch' }),
358
- sortRate: filter({ sort: 'rate' }),
359
- sortContext: filter({ sort: 'context' }),
360
- sortOutput: filter({ sort: 'output' }),
361
- sortName: filter({ sort: 'name' }),
362
- rateFree: ctx.mdRateValue(models[1]),
363
- rateMissing: ctx.mdRateValue(models[3]),
364
- }));`
365
- f, err := os.CreateTemp(t.TempDir(), "model-filter-*.cjs")
366
- if err != nil {
367
- t.Fatal(err)
368
- }
369
- if _, err := f.WriteString(script); err != nil {
370
- t.Fatal(err)
371
- }
372
- f.Close()
373
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
374
- if err != nil {
375
- t.Fatalf("model filter node test failed: %v\n%s", err, out)
376
- }
377
- const want = `{"all":["cn:glm-5.2","cn:hy3","global:hy3","cn:auto"],` +
378
- `"realm":["cn:glm-5.2","cn:hy3","cn:auto"],` +
379
- `"tool":["cn:glm-5.2","cn:hy3","cn:auto"],` +
380
- `"vision":["cn:glm-5.2","cn:hy3","cn:auto"],` +
381
- `"reasoning":["cn:glm-5.2","cn:hy3","cn:auto"],` +
382
- `"isDefault":["cn:auto"],` +
383
- `"effortOff":["cn:glm-5.2"],` +
384
- `"effortLow":["cn:hy3"],` +
385
- `"free":["cn:hy3"],` +
386
- `"promo":["cn:glm-5.2","cn:hy3"],` +
387
- `"discount":["cn:glm-5.2"],` +
388
- `"q":["cn:glm-5.2"],` +
389
- `"qMiss":[],` +
390
- `"sortRate":["cn:hy3","global:hy3","cn:glm-5.2","cn:auto"],` +
391
- `"sortContext":["cn:glm-5.2","global:hy3","cn:auto","cn:hy3"],` +
392
- `"sortOutput":["global:hy3","cn:glm-5.2","cn:hy3","cn:auto"],` +
393
- `"sortName":["cn:auto","cn:glm-5.2","cn:hy3","global:hy3"],` +
394
- `"rateFree":0,"rateMissing":null}`
395
- if strings.TrimSpace(string(out)) != want {
396
- t.Fatalf("model filter=%s\nwant %s", out, want)
397
- }
398
- }
399
-
400
- // 用量时序图的柱体类名不得叫 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里
401
- // height 是 rect 的 CSS 几何属性——同名类会把每根柱子压成 3px 高,图看起来"没数据"。
402
- // 这个坑只能在浏览器里看出来,所以在这里钉住类名。
403
- func TestAppJSUsageChartBarClass(t *testing.T) {
404
- node, err := exec.LookPath("node")
405
- if err != nil {
406
- t.Skip("node not installed; usage chart test skipped")
407
- }
408
- script := `const fs = require('fs');
409
- const vm = require('vm');
410
- const src = fs.readFileSync(process.argv[2], 'utf8');
411
- const start = src.indexOf('function parsePointTime');
412
- const end = src.indexOf('function fmtTokTip');
413
- const escStart = src.indexOf('function esc(');
414
- const escEnd = src.indexOf('function ago(');
415
- const fmtStart = src.indexOf('function fmtTok(');
416
- const fmtEnd = src.indexOf('function usStat(');
417
- if ([start, end, escStart, escEnd, fmtStart, fmtEnd].some(v => v < 0)) throw new Error('usage chart helpers not found');
418
- const host = { innerHTML: '', textContent: '' };
419
- const ctx = {
420
- Date, Number, String, Math, RegExp, isNaN, Set, Array, Object, Infinity,
421
- document: { getElementById: () => host },
422
- $: () => host,
423
- };
424
- vm.createContext(ctx);
425
- vm.runInContext(src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(start, end) +
426
- '\nthis.renderUsageChart=renderUsageChart;', ctx);
427
- const series = [
428
- { t: '2026-09-30T09', scope: 'hour', prompt_tokens: 35, completion_tokens: 16, total_tokens: 51, requests: 1 },
429
- { t: '2026-09-30T11', scope: 'hour', prompt_tokens: 978324, completion_tokens: 20621, total_tokens: 998945, requests: 39 },
430
- { t: '2026-09-30T13', scope: 'hour', prompt_tokens: 27400952, completion_tokens: 104913, total_tokens: 27505865, requests: 200 },
431
- ];
432
- ctx.renderUsageChart(series);
433
- const svg = host.innerHTML;
434
- process.stdout.write(JSON.stringify({
435
- hasUsbar: svg.includes('class="usbar"'),
436
- hasBareBar: /class="bar"/.test(svg),
437
- hasGradient: svg.includes('usGradP') && svg.includes('usGradC'),
438
- barCount: (svg.match(/class="usbar"/g) || []).length,
439
- hasPeak: svg.includes('峰值'),
440
- hasAvg: svg.includes('均值'),
441
- emptyState: (function () { ctx.renderUsageChart([]); return host.innerHTML.includes('us-empty'); })(),
442
- }));`
443
- f, err := os.CreateTemp(t.TempDir(), "usage-chart-*.cjs")
444
- if err != nil {
445
- t.Fatal(err)
446
- }
447
- if _, err := f.WriteString(script); err != nil {
448
- t.Fatal(err)
449
- }
450
- f.Close()
451
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
452
- if err != nil {
453
- t.Fatalf("usage chart node test failed: %v\n%s", err, out)
454
- }
455
- const want = `{"hasUsbar":true,"hasBareBar":false,"hasGradient":true,"barCount":6,"hasPeak":true,"hasAvg":true,"emptyState":true}`
456
- if strings.TrimSpace(string(out)) != want {
457
- t.Fatalf("usage chart=%s\nwant %s", out, want)
458
- }
459
- }
460
-
461
- // 时间范围控件:预设 → 查询参数的映射。要点:
462
- // - 「今天」必须发浏览器本地时区的 00:00(服务端时区未必一致),且不带 to;
463
- // - 滚动预设 rolling=true 发 hours(服务端整点对齐),rolling=false 折算成 from;
464
- // - 「全部历史」两者都不发;「自定义」发用户挑的 from/to。
465
- func TestAppJSTimeRangeQuery(t *testing.T) {
466
- node, err := exec.LookPath("node")
467
- if err != nil {
468
- t.Skip("node not installed; time range test skipped")
469
- }
470
- script := `const fs = require('fs');
471
- const vm = require('vm');
472
- const src = fs.readFileSync(process.argv[2], 'utf8');
473
- const start = src.indexOf('const TRANGE_PRESETS');
474
- const end = src.indexOf('function rateLimitMeta');
475
- if (start < 0 || end < 0 || end < start) throw new Error('trange helpers not found');
476
- const host = { innerHTML: '' };
477
- const ctx = {
478
- Date, Number, String, Math, Map, Array, Object, isNaN, URLSearchParams,
479
- document: { getElementById: () => host },
480
- $: () => host,
481
- esc: s => String(s == null ? '' : s),
482
- };
483
- vm.createContext(ctx);
484
- vm.runInContext(src.slice(start, end) +
485
- '\nthis.trangeState=trangeState; this.trangeQuery=trangeQuery; this.trangeLabel=trangeLabel; this.trangeMidnight=trangeMidnight;', ctx);
486
- const q = (preset, rolling) => {
487
- ctx.trangeState('t').preset = preset;
488
- return ctx.trangeQuery('t', rolling).toString();
489
- };
490
- const secOf = d => String(Math.floor(d.getTime() / 1000));
491
- const approx = (qs, wantSec) => {
492
- const m = /(?:^|&)from=(\d+)/.exec(qs);
493
- return m && Math.abs(Number(m[1]) - wantSec) < 120;
494
- };
495
- const now = Date.now();
496
- const todayQ = q('today', true);
497
- process.stdout.write(JSON.stringify({
498
- todayIsMidnight: todayQ === 'from=' + secOf(ctx.trangeMidnight()),
499
- todayNoTo: !/to=/.test(todayQ),
500
- rolling24: q('24', true),
501
- rolling72: q('72', true),
502
- rolling0: q('0', true),
503
- log24From: approx(q('24', false), Math.floor((now - 24 * 3600e3) / 1000)),
504
- log24HasHours: /hours=/.test(q('24', false)),
505
- log7dFrom: approx(q('168', false), Math.floor((now - 168 * 3600e3) / 1000)),
506
- custom: (function () {
507
- const st = ctx.trangeState('t');
508
- st.preset = 'custom';
509
- st.from = new Date(2026, 8, 30, 9, 0, 0);
510
- st.to = new Date(2026, 8, 30, 18, 30, 0);
511
- return ctx.trangeQuery('t', true).toString();
512
- })(),
513
- labelCustom: ctx.trangeLabel('t'),
514
- labelToday: (function () { ctx.trangeState('t').preset = 'today'; return ctx.trangeLabel('t'); })(),
515
- }));`
516
- f, err := os.CreateTemp(t.TempDir(), "trange-*.cjs")
517
- if err != nil {
518
- t.Fatal(err)
519
- }
520
- if _, err := f.WriteString(script); err != nil {
521
- t.Fatal(err)
522
- }
523
- f.Close()
524
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
525
- if err != nil {
526
- t.Fatalf("time range node test failed: %v\n%s", err, out)
527
- }
528
- local := func(h, m int) string {
529
- return strconv.FormatInt(time.Date(2026, 9, 30, h, m, 0, 0, time.Local).Unix(), 10)
530
- }
531
- want := `{"todayIsMidnight":true,"todayNoTo":true,` +
532
- `"rolling24":"hours=24","rolling72":"hours=72","rolling0":"",` +
533
- `"log24From":true,"log24HasHours":false,"log7dFrom":true,` +
534
- `"custom":"from=` + local(9, 0) + `&to=` + local(18, 30) + `",` +
535
- `"labelCustom":"9-30 09:00 → 9-30 18:30","labelToday":"今天"}`
536
- if strings.TrimSpace(string(out)) != want {
537
- t.Fatalf("time range=%s\nwant %s", out, want)
538
- }
539
- }
540
-
541
- // 同到期时间按面额降序;其余未用完包与零/负余额包分别聚合。
542
- func TestAppJSDetailGroups(t *testing.T) {
543
- node, err := exec.LookPath("node")
544
- if err != nil {
545
- t.Skip("node not installed; detail groups test skipped")
546
- }
547
- script := `const fs = require('fs');
548
- const vm = require('vm');
549
- const src = fs.readFileSync(process.argv[2], 'utf8');
550
- const start = src.indexOf('const PK_DEFAULT_DETAIL_LIMIT');
551
- const end = src.indexOf('function renderPackages');
552
- if (start < 0 || end < 0) throw new Error('detail group functions not found');
553
- const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite };
554
- vm.createContext(ctx);
555
- vm.runInContext(src.slice(start, end) + '\nthis.pkDetailGroups = pkDetailGroups; this.pkDetailLimit = pkDetailLimit;', ctx);
556
- const input = [
557
- { id: 'small-late', size: 100, remain: 1, expires_at: 400 },
558
- { id: 'zero-early-b', size: 200, remain: 0, expires_at: 200 },
559
- { id: 'small-early', size: 100, remain: 2, expires_at: 200 },
560
- { id: 'large-unknown', size: 300, remain: 3, end_time: '' },
561
- { id: 'zero-early-a', size: 200, remain: -1, expires_at: 200 },
562
- { id: 'small-unknown', size: 100, remain: 1, end_time: '' },
563
- { id: 'large-early', size: 300, remain: 4, expires_at: 200 },
564
- { id: 'zero-late', size: 300, remain: 0, expires_at: 300 },
565
- ];
566
- const before = input.map(p => p.id).join(',');
567
- const out = ctx.pkDetailGroups(input, 2);
568
- process.stdout.write(JSON.stringify({
569
- visible: out.visible.map(p => p.id),
570
- rest: out.rest.map(p => p.id),
571
- used: out.used.map(p => p.id),
572
- restSize: out.restSize,
573
- restRemain: out.restRemain,
574
- usedSize: out.usedSize,
575
- defaultLimit: ctx.pkDetailLimit({}),
576
- configuredLimit: ctx.pkDetailLimit({ panel: { package_detail_limit: 7 } }),
577
- unchanged: input.map(p => p.id).join(',') === before,
578
- }));`
579
- f, err := os.CreateTemp(t.TempDir(), "detail-groups-*.cjs")
580
- if err != nil {
581
- t.Fatal(err)
582
- }
583
- if _, err := f.WriteString(script); err != nil {
584
- t.Fatal(err)
585
- }
586
- f.Close()
587
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
588
- if err != nil {
589
- t.Fatalf("detail groups node test failed: %v\n%s", err, out)
590
- }
591
- 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}`
592
- if strings.TrimSpace(string(out)) != want {
593
- t.Fatalf("detail groups=%s want %s", out, want)
594
- }
595
- }
596
-
597
- // 精确剩余天数聚合、账号内按总余额钳制、无到期批次不进入图表。
598
- func TestAppJSExpirySummary(t *testing.T) {
599
- node, err := exec.LookPath("node")
600
- if err != nil {
601
- t.Skip("node not installed; expiry summary test skipped")
602
- }
603
- script := `const fs = require('fs');
604
- const vm = require('vm');
605
- const src = fs.readFileSync(process.argv[2], 'utf8');
606
- const start = src.indexOf('const PK_ACCOUNT_COLORS');
607
- const end = src.indexOf('function renderExpiryDistribution');
608
- if (start < 0 || end < 0) throw new Error('expiry summary functions not found');
609
- const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite };
610
- vm.createContext(ctx);
611
- vm.runInContext(src.slice(start, end) + '\nthis.summarizeCreditDays = summarizeCreditDays; this.pkAccountColorMap = pkAccountColorMap;', ctx);
612
- const day = 86400000, now = 100000;
613
- const out = ctx.summarizeCreditDays([
614
- { uid: 'a', remain: 100, packages: [
615
- { name: 'soon-a', remain: 30, expires_at: now + day },
616
- { name: 'later', remain: 70, expires_at: now + 7 * day },
617
- ] },
618
- { uid: 'b', remain: 55, packages: [
619
- { name: 'soon-b', remain: 20, expires_at: now + day },
620
- { name: 'unknown', remain: 5, end_time: '' },
621
- ] },
622
- { uid: 'err', error: 'offline' },
623
- ], now);
624
- process.stdout.write(JSON.stringify({
625
- rows: out.rows.map(row => ({ days: row.days, credits: row.credits })),
626
- accountCount: out.accountCount,
627
- unavailable: out.unavailable,
628
- colorA: ctx.pkAccountColorMap([{ uid: 'b' }, { uid: 'a' }]).get('a'),
629
- colorB: ctx.pkAccountColorMap([{ uid: 'a' }, { uid: 'b' }]).get('b'),
630
- }));`
631
- f, err := os.CreateTemp(t.TempDir(), "expiry-*.cjs")
632
- if err != nil {
633
- t.Fatal(err)
634
- }
635
- if _, err := f.WriteString(script); err != nil {
636
- t.Fatal(err)
637
- }
638
- f.Close()
639
- out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput()
640
- if err != nil {
641
- t.Fatalf("expiry summary node test failed: %v\n%s", err, out)
642
- }
643
- const want = `{"rows":[{"days":1,"credits":50},{"days":7,"credits":70}],"accountCount":3,"unavailable":1,"colorA":"#4f8cff","colorB":"#25b08b"}`
644
- if strings.TrimSpace(string(out)) != want {
645
- t.Fatalf("expiry summary=%s want %s", out, want)
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
+ }