diff --git a/.dockerignore b/.dockerignore index a8cd8c26da2b394c4f32dcd3b492b04532ea4136..f665fad68f15b17e795c77bf2c27aa30261097ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,24 +1,28 @@ -auths/ -data/ -storage/ -*.tmp -.git/ -.github/ -.gitignore -docs/ -scripts/__pycache__/ -# 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐) -/login -/credit -/wb2api -/signin_bin -/wb2api.exe -/login.exe -/credit.exe -# 真实配置不进镜像;镜像内模板是 config.default.json -config.json -PLAN.md -SPEC.md -LOOP.md -README.md -README.upstream.md +auths/ +data/ +storage/ +*.tmp +.git/ +.github/ +.gitignore +docs/ +scripts/__pycache__/ +# 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐) +/login +/credit +/wb2api +/wb2api-launcher +/wb2api-sync +/signin_bin +/wb2api.exe +/wb2api-launcher.exe +/wb2api-sync.exe +/login.exe +/credit.exe +# 真实配置不进镜像;镜像内模板是 config.default.json +config.json +PLAN.md +SPEC.md +LOOP.md +README.md +README.upstream.md diff --git a/.github/workflows/go-binaries.yml b/.github/workflows/go-binaries.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b0e543fea8cb60c116bc84e0b252d40689a6bd5 --- /dev/null +++ b/.github/workflows/go-binaries.yml @@ -0,0 +1,156 @@ +name: go-binaries + +# 可运行二进制构建/发布: +# - PR / push main → 测试 + 五平台编译验证(产物仅存 artifact,不发布) +# - push v* tag → 同上 + 打包上传到 GitHub Release(zip/tar.gz + checksums) +# 固化的发布纪律(曾两次人祸,见仓库 HANDOFF):-s -w 剥离、二进制版本串 == 源码 +# appVersion、tag 版本 == 源码版本(-ci 后缀的测试 tag 豁免,用于演练发布线)。 +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + paths: + - "go.mod" + - "go.sum" + - "cmd/**" + - "internal/**" + - "config.example.json" + - ".github/workflows/go-binaries.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # ubuntu runner 自带 node → panel 包的 JS 运行时冒烟测试(TestAppJSTopLevelSmoke)会真实执行 + - run: go test ./... + + build: + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { goos: windows, goarch: amd64, ext: .zip } + - { goos: linux, goarch: amd64, ext: .tar.gz } + - { goos: linux, goarch: arm64, ext: .tar.gz } + - { goos: darwin, goarch: amd64, ext: .tar.gz } + - { goos: darwin, goarch: arm64, ext: .tar.gz } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: 取源码版本号 + id: ver + run: echo "v=$(grep -oP 'appVersion = "\K[^"]+' cmd/server/main.go)" >> "$GITHUB_OUTPUT" + + - name: 构建(剥离符号,对齐本地发布口径) + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: go build -ldflags "-s -w" -o "wb2api${{ matrix.goos == 'windows' && '.exe' || '' }}" ./cmd/server + + # 启动器单独构建:它是**独立程序**,作用是把新版的 wb2api 二进制换进去就能升级, + # 启动器本身不用动。所以刻意不参与上面的版本串断言(它有自己独立的版本号)。 + - name: 构建启动器(独立,不参与网关版本串断言) + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + LEXT="${{ matrix.goos == 'windows' && '.exe' || '' }}" + go build -ldflags "-s -w" -o "wb2api-launcher${LEXT}" ./cmd/launcher + ls -la "wb2api-launcher${LEXT}" + + # 版本串断言:二进制必须含源码 appVersion(防交叉编译矩阵里编错树/漏 ldflags) + - name: 断言二进制版本串 + run: | + BIN="wb2api${{ matrix.goos == 'windows' && '.exe' || '' }}" + if ! grep -qa "${{ steps.ver.outputs.v }}" "$BIN"; then + echo "::error::binary missing version string ${{ steps.ver.outputs.v }}" + exit 1 + fi + echo "version assert OK: ${{ steps.ver.outputs.v }}" + + - name: 打包(windows=zip 三件套,其余=tar.gz) + run: | + V="${{ steps.ver.outputs.v }}" + V="${V%-panel}" # 文件名不带 -panel 后缀(wb2api-panel-v1.2.3-... 既有惯例) + case "${{ matrix.goos }}" in + windows) + NAME="wb2api-panel-v${V}-windows-amd64.zip" + zip -q "$NAME" wb2api.exe wb2api-launcher.exe config.example.json README.md + ;; + *) + NAME="wb2api-panel-v${V}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" + tar czf "$NAME" wb2api wb2api-launcher config.example.json README.md + ;; + esac + echo "name=$NAME" >> "$GITHUB_ENV" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.name }} + path: ${{ env.name }} + retention-days: 7 + + # 仅 tag 触发:校验 tag == 源码版本(-ci 演练 tag 豁免)→ 汇总资产 → 建 Release + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # 取历史 tag 算 compare 链接 + - name: tag 与源码版本一致性断言 + run: | + SRC=$(grep -oP 'appVersion = "\K[^"]+' cmd/server/main.go) + SRC="${SRC%-panel}" # appVersion 带 -panel 后缀,比对前剥掉 + TAG="${GITHUB_REF_NAME#v}" + if [[ "$GITHUB_REF_NAME" != *-ci ]]; then + if [[ "$TAG" != "$SRC" ]]; then + echo "::error::tag $GITHUB_REF_NAME 与源码 appVersion=$SRC 不一致(先提交版本号再打 tag)" + exit 1 + fi + else + echo "演练 tag(-ci 后缀)跳过一致性断言:tag=$TAG src=$SRC" + fi + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: checksums + run: cd dist && sha256sum *.zip *.tar.gz > checksums.txt && ls -la + - name: 上一版 tag(compare 链接用) + id: prev + run: | + PREV=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true) + echo "prev=${PREV}" >> "$GITHUB_OUTPUT" + - uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }}-panel + prerelease: ${{ contains(github.ref_name, '-') }} + body: | + CI 自动构建发布(五平台二进制 + checksums)。 + + 完整变更:https://github.com/${{ github.repository }}/compare/${{ steps.prev.outputs.prev }}...${{ github.ref_name }} + files: dist/* diff --git a/.github/workflows/space-keepalive.yml b/.github/workflows/space-keepalive.yml new file mode 100644 index 0000000000000000000000000000000000000000..b943ef19eda4ba14817726870b3fc028c4b1e34d --- /dev/null +++ b/.github/workflows/space-keepalive.yml @@ -0,0 +1,78 @@ +name: hf-space-keepalive + +# 作用:定时给 Hugging Face Space 打一次 /healthz,阻止它因「48 小时无 HTTP 流量」被暂停。 +# 为什么需要它:Space 一被暂停,它里面的定时任务(签到 / 活跃 / 保活)就全部停摆, +# 而唤醒要等 30~60 秒冷启动。 +# +# 为什么用 GitHub Actions 而不是 Cloudflare Cron: +# Cloudflare 免费版**每个账号只有 5 个 cron 触发器**。如果那 5 个已经被别的 Worker 占了, +# 再给本 Worker 加 cron 会直接报 `code: 10072`(本仓库实测踩过)。GH Actions 没有这个限制。 +# +# 为什么 6 小时一次就够:HF 的判定阈值是 **48 小时**,所以哪怕 GitHub 的定时任务 +# 延迟几十分钟(高峰期常见)也毫无影响。刻意避开整点(:17)以减少排队。 +# +# ⚠️⚠️ 必须打 Space 的**直连域名**(*.hf.space),**不要**改成公网入口 +# cli.3216gemini.dpdns.org —— 实测那个入口的 `/healthz` 是 **Cloudflare 自己 +# 应答的 `ok`**(响应带 cf-ray,但既没有网关的 `x-service`,也没有 HF 的 +# `link: …/spaces/a3216/gcli2api` 头),请求根本没到 Space。拿它做保活 +# 等于白 ping:返回 200 看着很健康,Space 却照样被暂停。 +# (该入口只在 `/healthz` 上做了短路,`/status`、`/panel/*` 等仍是透传到 Space 的。) +# +# ⚠️ 两个 GitHub 侧的限制,知道一下就好: +# 1. 定时工作流只在**默认分支**上生效(所以这个文件要合进 main)。 +# 2. 仓库连续 60 天没有任何提交/活动,GitHub 会自动停用定时工作流并给你发邮件; +# 到时在 Actions 页面点一下 "Enable workflow" 即可(或随便提交一次)。 + +on: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: # 也支持手动跑一次(Actions 页面 → Run workflow) + +permissions: {} + +concurrency: + group: hf-space-keepalive + cancel-in-progress: false + +jobs: + ping: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: 唤醒 / 保活 HF Space + run: | + set -uo pipefail + + # 直连 Space 的域名(*.hf.space)。**不要**改成 cli.3216gemini.dpdns.org: + # 那个入口的 /healthz 由 Cloudflare 自己回 "ok",请求到不了 Space, + # 拿它保活是无效的(详见文件头注释)。 + # 想顺带把公网入口也探一遍,可以加进来 —— 但它只证明边缘在,不证明 Space 醒着。 + URLS=( + "https://a3216-gcli2api.hf.space/healthz" + ) + + fail=0 + for url in "${URLS[@]}"; do + echo "::group::$url" + ok=0 + # 冷启动可能要 30~60s,给足超时并重试三次。 + for attempt in 1 2 3; do + code=$(curl -s -o /dev/null -w '%{http_code}' -m 90 "$url" || echo "000") + echo "第 ${attempt} 次: HTTP ${code}" + # 关键判据:只要拿到了**任何** HTTP 状态码,就说明 Space 是醒着的。 + # 503 是网关「当前没有可用账号可服务」的业务语义,对保活而言完全正常。 + # 只有 000(连不上/超时)才算这次没打到。 + if [ "$code" != "000" ]; then + ok=1 + break + fi + sleep 20 + done + echo "::endgroup::" + if [ "$ok" -ne 1 ]; then + echo "::error::$url 三次都拿不到响应(Space 可能真的挂了,或正在长时间冷启动)" + fail=1 + fi + done + + exit "$fail" diff --git a/.gitignore b/.gitignore index bd53e6472753a75d9764cf479dc77308159d6eea..2e19b0bcbd2dcac7a07a5700c469a0c702a9a2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,58 @@ -# Auth & secrets -auths/ -data/ -*.key -*.pem -*.env -.env -.env.* - -# Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/) -/login -/credit -/wb2api -/signin_bin - -# Config with secrets (use config.example.json) -config.json - -# Build artifacts -*.tmp -__pycache__/ - -# Backups (contains auth tokens) -backups/ - -# IDE -.idea/ -.vscode/ -LOOP.md -PLAN.md -SPEC.md -docs/ - -# 设计/计划文档(但 README.md 与 go:embed 的内置提示词必须进版本库) -*.md -!README.md -!internal/prompt/defaultprompt.md - -# Windows binaries -/wb2api.exe -/login.exe -/credit.exe - -# 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md) -/cmd/desktopverify*/ -*.zip -server.exe +# Auth & secrets +auths/ +data/ +*.key +*.pem +*.env +.env +.env.* +# wrangler 本地变量(Cloudflare Worker 的 api_key 等,绝不进版本库) +.dev.vars +.dev.vars.* +# ……但模板必须能进版本库(里面只有占位符,没有真密钥) +!.dev.vars.example +.wrangler/ + +# Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/) +/login +/credit +/wb2api +/wb2api-launcher +/wb2api-sync +/signin_bin + +# Config with secrets (use config.example.json) +config.json + +# Build artifacts +*.tmp +__pycache__/ + +# Backups (contains auth tokens) +backups/ + +# IDE +.idea/ +.vscode/ +LOOP.md +PLAN.md +SPEC.md +docs/ + +# 设计/计划文档(但 README.md 与 go:embed 的内置提示词必须进版本库) +*.md +!README.md +!internal/prompt/defaultprompt.md + +# Windows binaries +/wb2api.exe +/wb2api-launcher.exe +/wb2api-sync.exe +/login.exe +/credit.exe + +# 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md) +/cmd/desktopverify*/ +*.zip +server.exe +.DS_Store diff --git a/Dockerfile b/Dockerfile index 86ba8a8ba85cd258c597bae61ebc1fda4587b27c..2c50f74c72fc57e55d267b203bc19b17c5a40cf3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,42 @@ -# syntax=docker/dockerfile:1 -# ---------------------------------------------------------------- build stage -FROM golang:1.23-alpine AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -# 全静态二进制:镜像里直接跑脚本,无需 libc。 -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \ - && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/signin_bin ./cmd/signin \ - && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \ - && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit - -# -------------------------------------------------------------- runtime stage -FROM alpine:3.20 -# python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。 -RUN apk add --no-cache wget ca-certificates tzdata python3 bash \ - && mkdir -p /app/auths /app/data -ENV TZ=Asia/Shanghai -WORKDIR /app - -COPY --from=build /out/wb2api /app/wb2api -COPY --from=build /out/signin_bin /app/signin_bin -COPY --from=build /out/login /app/login -COPY --from=build /out/credit /app/credit -COPY login.sh signin.sh credit.sh /app/ -COPY scripts/probe_active.py /app/scripts/probe_active.py -# config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。 -# 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。 -COPY config.default.json /app/config.json -COPY docker-entrypoint.sh /app/docker-entrypoint.sh -RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \ - && chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh - -# 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。 -EXPOSE 7861 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ - CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1 -ENTRYPOINT ["/app/docker-entrypoint.sh"] +# syntax=docker/dockerfile:1 +# ---------------------------------------------------------------- build stage +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# 全静态二进制:镜像里直接跑脚本,无需 libc。 +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api-launcher ./cmd/launcher \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/signin_bin ./cmd/signin \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit + +# -------------------------------------------------------------- runtime stage +FROM alpine:3.20 +# python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。 +RUN apk add --no-cache wget ca-certificates tzdata python3 bash \ + && mkdir -p /app/auths /app/data +ENV TZ=Asia/Shanghai +WORKDIR /app + +COPY --from=build /out/wb2api /app/wb2api +# 独立启动器:本地/容器里都能「先同步凭证、再拉起网关」,且换 wb2api 版本时它不用重编。 +COPY --from=build /out/wb2api-launcher /app/wb2api-launcher +COPY --from=build /out/signin_bin /app/signin_bin +COPY --from=build /out/login /app/login +COPY --from=build /out/credit /app/credit +COPY login.sh signin.sh credit.sh /app/ +COPY scripts/probe_active.py /app/scripts/probe_active.py +# config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。 +# 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。 +COPY config.default.json /app/config.json +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \ + && chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh + +# 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。 +EXPOSE 7861 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1 +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE index 7a7d27d9be1cced2adc8c460680e4c57c8db6215..b9ee0c3850a5b26a0aa7465a6d9b955b900cc1f2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,22 +1,22 @@ -MIT License - -Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api) -Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api) +Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fb3430498b55ce2acfaf284545c500bdaceaf394..baa4d419f6a5c44eb785a9f187b30fc49f2738dc 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,160 @@ ---- -title: WorkBuddy2API Panel -emoji: 🧩 -colorFrom: indigo -colorTo: blue -sdk: docker -app_port: 7861 -pinned: false -short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶 ---- - -# WorkBuddy2API Panel — Hugging Face Spaces 部署 - -把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游: -[linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。 -本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。 - -## 这个分支改了什么 - -Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。 -原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。 - -本分支的做法: - -- 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写); -- `docker-entrypoint.sh` 把运行配置指到卷上: - - `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有) - - `/app/storage/auths/*.json` — 账号凭证 - - `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档 -- 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的); -- **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。 - -## 一次性配置(已经做完,此处仅为记录) - -```python -from huggingface_hub import HfApi, Volume -api = HfApi() -api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True) -api.set_space_volumes( - "a3216/gcli2api", - volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")], -) -``` - -`set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。 - -## 环境变量 - -| 变量 | 作用 | -| --- | --- | -| `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) | -| `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` | -| `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README | - -## 运维 - -- **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。 -- **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。 -- **备份**:桶本身就是备份。本地拉一份: - `hf buckets sync hf://buckets/a3216/wb2api-storage ./backup` -- **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。 -- **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。 - 唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。 - -## 说明 - -上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。 +--- +title: WorkBuddy2API Panel +emoji: 🧩 +colorFrom: indigo +colorTo: blue +sdk: docker +app_port: 7861 +pinned: false +short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶 +--- + +# WorkBuddy2API Panel — Hugging Face Spaces 部署 + +把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游: +[linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。 +本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。 + +## 这个分支改了什么 + +Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。 +原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。 + +本分支的做法: + +- 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写); +- `docker-entrypoint.sh` 把运行配置指到卷上: + - `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有) + - `/app/storage/auths/*.json` — 账号凭证 + - `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档 +- 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的); +- **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。 + +## 一次性配置(已经做完,此处仅为记录) + +```python +from huggingface_hub import HfApi, Volume +api = HfApi() +api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True) +api.set_space_volumes( + "a3216/gcli2api", + volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")], +) +``` + +`set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。 + +## 本地 ↔ 桶:凭证同步与独立启动器 + +**要解决的问题**:同一批账号常常要两处跑 —— 本地机器给工具提供低延迟 API,Space 负责定时签到。 +两边各自维护 `auths/` 就会不同步:本地新加的号 Space 不知道(签到漏掉), +Space 刷新过的 token 本地还在用旧的;最糟的是两边**同时** refresh 同一个账号, +refreshToken 轮换后只有一方拿到新的,另一方直接掉线。 + +`wb2api-launcher` 就是为此而生:**先同步、后启动**。 + +```bash +# 独立 exe:不需要 Go / Python / PowerShell +wb2api-launcher # 同步 → 拉起网关 → 后台每 5 分钟对账 +wb2api-launcher -status # 只看两边差异,不启动网关(有差异退出码 2) +wb2api-launcher -sync-only # 同步一次就退出 +wb2api-launcher -no-sync # 离线启动(用本地已有凭证) +wb2api-launcher -h # 全部选项 +``` + +它是个**独立程序**:与网关之间只有两个契约 ——「`wb2api -config <路径>` 能被拉起来」 +和 `config.json` 里那几个键,不引用网关内部实现。所以**上游更新时你只需要换 `wb2api`, +启动器不用动**;同步引擎直接编在里面,也不需要第二个二进制。 +`start.ps1` / `start.sh` 只是「没有二进制就 `go build`,然后 exec 启动器」的引导壳。 + +配置见 `config.example.json` 的 `sync` 段: + +```json +"sync": { + "bucket": "a3216/wb2api-storage", + "token_file": "./data/hf_token.txt", + "writer": "auto" +} +``` + +两个方向的通道**刻意不同**(实测结论,不是设计偏好): + +| 方向 | 通道 | 为什么 | +| --- | --- | --- | +| **读** | HF Hub REST(纯 HTTPS) | 不触发 Space 冷启动,它休眠/暂停时照样能拉 —— 启动那一次同步必须快 | +| **写** | 官方 `hf` CLI | Bucket 上传必须先做 **Xet 内容寻址上传**,拿文件 SHA-256 当 `xetHash` 会被服务端拒绝(`File not found in Xet storage`)。该协议不适合在 Go 里重造 | + +冲突裁决(两端都有且内容不同时,按优先级):能被网关解析的一方赢 → **`expiresAt` 更晚的一方赢** +("谁刷新过用谁的",这条直接治凭证不同步)→ refreshToken 非空的一方赢 → mtime 更晚的一方赢 → +全平取本端(保证裁决确定性,否则两端会互相覆盖成无限拉锯)。 +刻意**不做字段级合并**:accessToken 与 refreshToken 必须成对来自同一次刷新,拼起来只会造出永远刷不动的凭证。 +也**不会删除账号** —— 删除只能在面板显式操作。 + +> ⚠️ **定时任务只能由一边跑。** 两边同时开签到/保活会互抢 refreshToken(轮换后只有一方拿到新的, +> 另一方直接掉线),这是同步工具救不了的**并发写冲突**。推荐分工:**Space 跑定时任务,本地只做 API** —— +> 把本地 `schedule.*_enabled` 全设 `false` 即可,网关处理请求时本来就会按需刷新 token,不影响可用性。 +> +> ⚠️ **手动禁用 / 暂停不会被同步。** `disabled` / `paused` 是每端各自的运维意图,不属于凭证。 +> 要两边一致请分别在两端面板里设置;也不要靠 `-include-data state.json` 去"抹平"它 +> (那会把对端的冷却/熔断状态一起搬过来)。 + +## 保活:别让 Space 睡过去 + +cpu-basic 免费硬件**连续 48 小时没有任何 HTTP 流量就被暂停**,暂停期间网关的定时任务 +(签到 / 领积分)不会执行。给它一个稳定的定时流量即可。 + +### 方案 A:GitHub Actions(推荐,不占 CF 配额) + +仓库自带 `.github/workflows/space-keepalive.yml`:每 6 小时 ping 一次 Space 的 `/healthz`。 + +**为什么 6 小时就够**:HF 的判定阈值是 **48 小时**,所以有 8 倍余量;GitHub 定时任务 +偶发延迟几十分钟毫无影响。它把**任何 HTTP 状态码都算成功**——`503` 是"当前没有可用账号" +的业务语义,但 Space 是醒着的,保活目的已经达到;只有连不上(`000`)才需要关注。 + +定时工作流只在**默认分支**生效;仓库连续 60 天无活动时 GitHub 会自动停用它 +(Actions 页面点一下重新启用,或随便提交一次)。 + +> ⚠️ **保活必须打 Space 的直连域名(`*.hf.space`),不能打公网入口 `cli.3216gemini.dpdns.org`。** +> 实测那个入口的 `/healthz` 是 **Cloudflare 自己应答的 `ok`** +> (响应带 `cf-ray`,但既没有网关的 `x-service`,也没有 HF 的 `link: …/spaces/a3216/gcli2api` 头), +> **请求根本没到 Space** —— 拿它做保活会稳定返回 200、看着很健康,而 Space 照样被暂停。 +> 该入口只在 `/healthz` 上短路,`/status`、`/panel/*` 仍是透传的。 +> 工作流里因此写死直连域名并注明了原因,**别"顺手优化"掉**。 + +### 方案 B:Cloudflare Worker + Cron + +`deploy/cloudflare-worker/` 是一份单文件 Worker:反代 `cli.3216gemini.dpdns.org/*` +(响应体流式回传,不破坏 SSE)+ `scheduled()` 保活。部署前可以先用 +`node dev-server.mjs` 在本地把它验一遍(该脚本原样 import 同一个 `worker.js`,转发到真实 Space)。 + +> ⚠️ **Cloudflare 免费版的 cron 限制是「每个账号 5 个」,不是每个 Worker 5 个。** +> 名额满了 `wrangler deploy` 会给出**半成功**结果:`Uploaded` / `Deployed` 都成功,但紧接着 +> `✘ [ERROR] ... reached the Workers Free limit of 5 cron triggers per account [code: 10072]` +> —— Worker 代码上线了、cron 没加上,于是它**空转,谁也唤不醒**。要么腾一个名额,要么用方案 A。 +> +> 另外:`wrangler deploy` 打印的 `*.workers.dev` 地址在国内常因 DNS 污染而连不上, +> 这不代表部署失败;`wrangler tail` 若报 400,是它走的 WebSocket 被本地抓包代理挡了, +> 换 Dashboard → Worker → Logs 看即可。 + +## 环境变量 + +| 变量 | 作用 | +| --- | --- | +| `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) | +| `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` | +| `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README | + +## 运维 + +- **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。 +- **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。 +- **备份**:桶本身就是备份。本地拉一份: + `hf buckets sync hf://buckets/a3216/wb2api-storage ./backup` +- **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。 +- **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。 + 唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。 + → **保活方案见 [保活:别让 Space 睡过去](#保活别让-space-睡过去)。** + +## 说明 + +上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。 diff --git a/cmd/credit/main.go b/cmd/credit/main.go index 50cf3e0d786e50ec0f4616d2b1e2e2c7f7052975..766de4954616c53f1b1985770a54d38c90dd2be6 100644 --- a/cmd/credit/main.go +++ b/cmd/credit/main.go @@ -1,294 +1,294 @@ -// credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。 -// -// 用法: -// -// go run ./cmd/credit # 或编译后 ./credit -// -// 输出结构: -// -// {"service":"workbuddy","ts":N, -// "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N}, -// "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]} -// -// 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的 -// Cycle* 字段,TotalDosage 作 size 下限。 -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" -) - -const billingBaseCN = "https://www.codebuddy.cn" - -// billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401: -// www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。 -const billingBaseGlobal = "https://www.workbuddy.ai" - -// billingBaseFor 按账号 realm 选择计费域。 -// -// 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在 -// workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域, -// 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。 -func billingBaseFor(af *authFile) string { - if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") { - return billingBaseGlobal - } - d := strings.ToLower(strings.TrimSpace(af.Auth.Domain)) - if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") { - return billingBaseGlobal - } - return billingBaseCN -} - -type authFile struct { - Auth struct { - AccessToken string `json:"accessToken"` - Domain string `json:"domain"` - Realm string `json:"realm"` - } `json:"auth"` - Account struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - } `json:"account"` -} - -type accountResult struct { - UID string `json:"uid"` - Nickname string `json:"nickname"` - Remain *int64 `json:"remain"` - Used *int64 `json:"used"` - Size *int64 `json:"size"` - Packages int `json:"packages,omitempty"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` -} - -type resourcePackage struct { - CapacityRemain int64 `json:"CapacityRemain"` - CapacityUsed int64 `json:"CapacityUsed"` - CapacitySize int64 `json:"CapacitySize"` - CycleCapacityRemain int64 `json:"CycleCapacityRemain"` - CycleCapacityUsed int64 `json:"CycleCapacityUsed"` - CycleCapacitySize int64 `json:"CycleCapacitySize"` -} - -// packageRemainUsed 与 billing.go:203-258 一致 -func packageRemainUsed(a resourcePackage) (remain, used, size int64) { - if a.CycleCapacitySize > 0 { - remain = a.CycleCapacityRemain - size = a.CycleCapacitySize - if remain < 0 { - remain = 0 - } - if remain > size { - remain = size - } - used = size - remain - if a.CycleCapacityUsed > used { - used = a.CycleCapacityUsed - if size >= used { - remain = size - used - } - } - return remain, used, size - } - remain = a.CapacityRemain - used = a.CapacityUsed - size = a.CapacitySize - if used == 0 && size > remain { - used = size - remain - } - return remain, used, size -} - -func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) { - now := time.Now() - body, _ := json.Marshal(map[string]any{ - "PageNumber": 1, - "PageSize": 100, - "ProductCode": "p_tcaca", - "Status": []int{0, 3}, - "PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"), - "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"), - }) - base := billingBaseFor(af) - // global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。 - path := "/v2/billing/meter/get-user-resource" - if base == billingBaseGlobal { - path = "/billing/meter/get-user-resource" - } - req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body)) - if err != nil { - return 0, 0, 0, 0, err - } - req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - if af.Account.UID != "" { - req.Header.Set("X-User-Id", af.Account.UID) - } - if af.Account.EnterpriseID != "" { - req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID) - req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID) - } - if af.Auth.Domain != "" { - req.Header.Set("X-Domain", af.Auth.Domain) - } - client := &http.Client{Timeout: 20 * time.Second} - resp, err := client.Do(req) - if err != nil { - return 0, 0, 0, 0, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode) - } - var env struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - Response struct { - Data struct { - TotalDosage int64 `json:"TotalDosage"` - Accounts []resourcePackage `json:"Accounts"` - } `json:"Data"` - } `json:"Response"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { - return 0, 0, 0, 0, err - } - if env.Code != 0 { - return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg) - } - for _, a := range env.Data.Response.Data.Accounts { - r, u, s := packageRemainUsed(a) - remain += r - used += u - size += s - } - packs = len(env.Data.Response.Data.Accounts) - if size > 0 { - if derived := size - remain; derived > used { - used = derived - } - } - if dosage := env.Data.Response.Data.TotalDosage; dosage > size { - size = dosage - if derived := size - remain; derived > used { - used = derived - } - } - return remain, used, size, packs, nil -} - -func main() { - pretty := len(os.Args) > 1 && os.Args[1] == "-pretty" - authDir := "./auths" - if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { - authDir = v - } - files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) - sort.Strings(files) - - accounts := make([]accountResult, 0, len(files)) - for _, f := range files { - var af authFile - raw, err := os.ReadFile(f) - if err != nil || json.Unmarshal(raw, &af) != nil { - continue - } - res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname} - if af.Auth.AccessToken == "" { - res.Error = "no accessToken" - accounts = append(accounts, res) - continue - } - remain, used, size, packs, err := fetchUserResource(&af) - if err != nil { - res.Error = err.Error() - } else { - res.Remain = &remain - res.Used = &used - res.Size = &size - res.Packages = packs - res.OK = true - } - accounts = append(accounts, res) - time.Sleep(200 * time.Millisecond) - } - - var totalRemain, totalUsed, totalSize int64 - okCount := 0 - for _, a := range accounts { - if a.OK { - okCount++ - if a.Remain != nil { - totalRemain += *a.Remain - } - if a.Used != nil { - totalUsed += *a.Used - } - if a.Size != nil { - totalSize += *a.Size - } - } - } - out := map[string]any{ - "service": "workbuddy", - "ts": time.Now().Unix(), - "total": map[string]any{ - "remain": totalRemain, - "used": totalUsed, - "size": totalSize, - "accounts": len(accounts), - "ok": okCount, - "failed": len(accounts) - okCount, - }, - "accounts": accounts, - } - if pretty { - printPretty(accounts, totalRemain, totalUsed, totalSize, okCount) - return - } - raw, _ := json.Marshal(out) - fmt.Println(string(raw)) -} - -// printPretty 人类可读日报:四行汇总,无账号明细。 -func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) { - withBalance := 0 - var failed []string - for _, a := range accounts { - if a.OK && a.Remain != nil && *a.Remain > 0 { - withBalance++ - } - if !a.OK { - name := a.Nickname - if name == "" && len(a.UID) >= 8 { - name = a.UID[:8] - } - failed = append(failed, name+" "+a.Error) - } - } - pct := int64(0) - if totalSize > 0 { - pct = totalRemain * 100 / totalSize - } - fmt.Printf("📊 WorkBuddy 积分日报\n") - fmt.Printf("账号: %d/%d\n", withBalance, len(accounts)) - fmt.Printf("总计: %d/%d\n", totalRemain, totalSize) - fmt.Printf("剩余: %d%%\n", pct) - for _, f := range failed { - fmt.Printf("⚠️ %s\n", f) - } -} +// credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。 +// +// 用法: +// +// go run ./cmd/credit # 或编译后 ./credit +// +// 输出结构: +// +// {"service":"workbuddy","ts":N, +// "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N}, +// "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]} +// +// 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的 +// Cycle* 字段,TotalDosage 作 size 下限。 +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const billingBaseCN = "https://www.codebuddy.cn" + +// billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401: +// www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。 +const billingBaseGlobal = "https://www.workbuddy.ai" + +// billingBaseFor 按账号 realm 选择计费域。 +// +// 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在 +// workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域, +// 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。 +func billingBaseFor(af *authFile) string { + if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") { + return billingBaseGlobal + } + d := strings.ToLower(strings.TrimSpace(af.Auth.Domain)) + if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") { + return billingBaseGlobal + } + return billingBaseCN +} + +type authFile struct { + Auth struct { + AccessToken string `json:"accessToken"` + Domain string `json:"domain"` + Realm string `json:"realm"` + } `json:"auth"` + Account struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } `json:"account"` +} + +type accountResult struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + Remain *int64 `json:"remain"` + Used *int64 `json:"used"` + Size *int64 `json:"size"` + Packages int `json:"packages,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type resourcePackage struct { + CapacityRemain int64 `json:"CapacityRemain"` + CapacityUsed int64 `json:"CapacityUsed"` + CapacitySize int64 `json:"CapacitySize"` + CycleCapacityRemain int64 `json:"CycleCapacityRemain"` + CycleCapacityUsed int64 `json:"CycleCapacityUsed"` + CycleCapacitySize int64 `json:"CycleCapacitySize"` +} + +// packageRemainUsed 与 billing.go:203-258 一致 +func packageRemainUsed(a resourcePackage) (remain, used, size int64) { + if a.CycleCapacitySize > 0 { + remain = a.CycleCapacityRemain + size = a.CycleCapacitySize + if remain < 0 { + remain = 0 + } + if remain > size { + remain = size + } + used = size - remain + if a.CycleCapacityUsed > used { + used = a.CycleCapacityUsed + if size >= used { + remain = size - used + } + } + return remain, used, size + } + remain = a.CapacityRemain + used = a.CapacityUsed + size = a.CapacitySize + if used == 0 && size > remain { + used = size - remain + } + return remain, used, size +} + +func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) { + now := time.Now() + body, _ := json.Marshal(map[string]any{ + "PageNumber": 1, + "PageSize": 100, + "ProductCode": "p_tcaca", + "Status": []int{0, 3}, + "PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"), + "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"), + }) + base := billingBaseFor(af) + // global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。 + path := "/v2/billing/meter/get-user-resource" + if base == billingBaseGlobal { + path = "/billing/meter/get-user-resource" + } + req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body)) + if err != nil { + return 0, 0, 0, 0, err + } + req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if af.Account.UID != "" { + req.Header.Set("X-User-Id", af.Account.UID) + } + if af.Account.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID) + req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID) + } + if af.Auth.Domain != "" { + req.Header.Set("X-Domain", af.Auth.Domain) + } + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, 0, 0, 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode) + } + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Response struct { + Data struct { + TotalDosage int64 `json:"TotalDosage"` + Accounts []resourcePackage `json:"Accounts"` + } `json:"Data"` + } `json:"Response"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return 0, 0, 0, 0, err + } + if env.Code != 0 { + return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg) + } + for _, a := range env.Data.Response.Data.Accounts { + r, u, s := packageRemainUsed(a) + remain += r + used += u + size += s + } + packs = len(env.Data.Response.Data.Accounts) + if size > 0 { + if derived := size - remain; derived > used { + used = derived + } + } + if dosage := env.Data.Response.Data.TotalDosage; dosage > size { + size = dosage + if derived := size - remain; derived > used { + used = derived + } + } + return remain, used, size, packs, nil +} + +func main() { + pretty := len(os.Args) > 1 && os.Args[1] == "-pretty" + authDir := "./auths" + if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { + authDir = v + } + files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) + sort.Strings(files) + + accounts := make([]accountResult, 0, len(files)) + for _, f := range files { + var af authFile + raw, err := os.ReadFile(f) + if err != nil || json.Unmarshal(raw, &af) != nil { + continue + } + res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname} + if af.Auth.AccessToken == "" { + res.Error = "no accessToken" + accounts = append(accounts, res) + continue + } + remain, used, size, packs, err := fetchUserResource(&af) + if err != nil { + res.Error = err.Error() + } else { + res.Remain = &remain + res.Used = &used + res.Size = &size + res.Packages = packs + res.OK = true + } + accounts = append(accounts, res) + time.Sleep(200 * time.Millisecond) + } + + var totalRemain, totalUsed, totalSize int64 + okCount := 0 + for _, a := range accounts { + if a.OK { + okCount++ + if a.Remain != nil { + totalRemain += *a.Remain + } + if a.Used != nil { + totalUsed += *a.Used + } + if a.Size != nil { + totalSize += *a.Size + } + } + } + out := map[string]any{ + "service": "workbuddy", + "ts": time.Now().Unix(), + "total": map[string]any{ + "remain": totalRemain, + "used": totalUsed, + "size": totalSize, + "accounts": len(accounts), + "ok": okCount, + "failed": len(accounts) - okCount, + }, + "accounts": accounts, + } + if pretty { + printPretty(accounts, totalRemain, totalUsed, totalSize, okCount) + return + } + raw, _ := json.Marshal(out) + fmt.Println(string(raw)) +} + +// printPretty 人类可读日报:四行汇总,无账号明细。 +func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) { + withBalance := 0 + var failed []string + for _, a := range accounts { + if a.OK && a.Remain != nil && *a.Remain > 0 { + withBalance++ + } + if !a.OK { + name := a.Nickname + if name == "" && len(a.UID) >= 8 { + name = a.UID[:8] + } + failed = append(failed, name+" "+a.Error) + } + } + pct := int64(0) + if totalSize > 0 { + pct = totalRemain * 100 / totalSize + } + fmt.Printf("📊 WorkBuddy 积分日报\n") + fmt.Printf("账号: %d/%d\n", withBalance, len(accounts)) + fmt.Printf("总计: %d/%d\n", totalRemain, totalSize) + fmt.Printf("剩余: %d%%\n", pct) + for _, f := range failed { + fmt.Printf("⚠️ %s\n", f) + } +} diff --git a/cmd/launcher/flags.go b/cmd/launcher/flags.go new file mode 100644 index 0000000000000000000000000000000000000000..87961ec591b8519fcb0df66cef3793ec8912f404 --- /dev/null +++ b/cmd/launcher/flags.go @@ -0,0 +1,119 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/syncer" +) + +// usageHead 帮助文本前半段。 +const usageHead = `wb2api-launcher —— WorkBuddy2API 独立启动器(同步凭证 + 拉起网关 + 后台对账) + +用法: + wb2api-launcher [标志] + +常用: + wb2api-launcher 同步一次 → 启动网关 → 后台每 5 分钟对账 + wb2api-launcher -status 只看两边差异,不启动网关(有差异退出码 2) + wb2api-launcher -sync-only 同步一次就退出 + wb2api-launcher -no-sync 离线启动(跳过同步,用本地已有凭证) + wb2api-launcher -no-watch 不起后台对账 + wb2api-launcher -mode pull 只拉不推 + wb2api-launcher -version 打印版本 + +标志: +` + +// usageTail 帮助文本后半段。 +const usageTail = ` +退出码: + 0 正常(网关退出时原样透出它的退出码) + 1 启动阶段失败 + 2 -status 模式下存在未同步的差异 + +配置查找: + config.json 默认取启动器同目录;相对路径一律以 config.json 所在目录为基准, + 网关子进程的工作目录也会被切到那里 —— 保证"同步写的目录"就是"网关读的目录"。 +` + +// parseFlags 解析命令行。返回 (选项, 是否只打印版本, error)。 +func parseFlags(args []string) (options, bool, error) { + var ( + opt options + showVersion bool + modeStr string + statusOnly bool + ) + + fs := flag.NewFlagSet("wb2api-launcher", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { + fmt.Fprint(os.Stderr, usageHead) + fs.PrintDefaults() + fmt.Fprint(os.Stderr, usageTail) + } + + fs.StringVar(&opt.configPath, "config", "", "配置文件路径(默认:启动器同目录的 config.json)") + fs.StringVar(&opt.serverBin, "server", "", "网关二进制路径(默认:同目录的 wb2api(.exe),再找 PATH)") + + fs.StringVar(&modeStr, "mode", "sync", "同步方向:sync / pull / push") + fs.BoolVar(&statusOnly, "status", false, "只看差异不写入,也不启动网关(等价 -mode sync -dry-run -sync-only)") + fs.BoolVar(&opt.syncOnly, "sync-only", false, "同步一次后退出,不启动网关") + + fs.BoolVar(&opt.noSync, "no-sync", false, "跳过同步,直接启动网关") + fs.BoolVar(&opt.noWatch, "no-watch", false, "不起后台定时对账") + fs.DurationVar(&opt.interval, "interval", 5*time.Minute, "后台对账间隔") + fs.DurationVar(&opt.shutdownGrace, "shutdown-grace", 10*time.Second, "退出时等待网关优雅收尾的宽限期") + fs.BoolVar(&opt.quiet, "quiet", false, "减少输出(仍会打印同步结果与告警)") + + // 同步参数覆盖(不写 config.json 也能跑) + fs.StringVar(&opt.bucket, "bucket", "", "HF 桶 ID,如 a3216/wb2api-storage") + fs.StringVar(&opt.token, "token", "", "HF token(更推荐写进 config.json 的 sync.token_file)") + fs.StringVar(&opt.endpoint, "endpoint", "", "HF 端点(默认 https://huggingface.co)") + fs.StringVar(&opt.include, "include-data", "", "额外同步的 data/ 文件名,逗号分隔(默认只同步凭证)") + fs.StringVar(&opt.writer, "writer", "", "写入通道:auto / hf / relay") + fs.StringVar(&opt.relayURL, "relay-url", "", "中转网关地址(relay 写入通道用)") + fs.StringVar(&opt.restart, "restart-space", "", "推了新凭证后重启该 Space,如 a3216/gcli2api") + fs.StringVar(&opt.caBundle, "ca-bundle", "", "额外 CA 证书包(PEM);本机跑抓包代理时给 hf CLI 用") + fs.BoolVar(&opt.dryRun, "dry-run", false, "只展示将要做什么,不写任何东西") + + fs.BoolVar(&showVersion, "version", false, "打印版本后退出") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + // -h 走 Usage(已经打印过了),按成功退出。 + return opt, true, nil + } + return opt, false, err + } + if rest := fs.Args(); len(rest) > 0 { + return opt, false, fmt.Errorf("未知参数: %s(用 -h 看帮助)", strings.Join(rest, " ")) + } + if showVersion { + return opt, true, nil + } + + mode, reportOnly, err := syncer.ParseCommand(modeStr) + if err != nil { + return opt, false, err + } + opt.mode = mode + // modeStr 写 "status" 时 ParseCommand 已告知只报告。 + opt.reportOnly = reportOnly + + // -status 等价于「按 sync 算一遍,但不落地、不启动网关」。 + if statusOnly { + opt.mode = syncer.ModeSync + opt.reportOnly = true + opt.syncOnly = true + } + // 只报告就不可能写入:把 dry-run 也定死,避免出现自相矛盾的组合。 + if opt.reportOnly { + opt.dryRun = true + } + return opt, false, nil +} diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go new file mode 100644 index 0000000000000000000000000000000000000000..f24cd3928d152e3671474de64d1906fda0446c5a --- /dev/null +++ b/cmd/launcher/main.go @@ -0,0 +1,484 @@ +// wb2api-launcher —— WorkBuddy2API 的**独立启动器**。 +// +// 为什么是一个独立 exe 而不是 shell 脚本: +// +// - 上游作者更新时,你只需要替换 wb2api.exe(网关本体),启动器完全不动。 +// 启动器只做三件事——同步凭证、拉起网关、转发信号——不依赖网关的任何内部实现, +// 也不依赖 go / python / bash / PowerShell 环境。 +// - 同步引擎(internal/syncer)直接**编进这个 exe**,不再需要第二个 wb2api-sync +// 二进制;后台定时对账也在同一个进程里跑,不会留下孤儿进程。 +// +// 启动顺序刻意是「先同步、后启动」:网关只在启动时扫一遍 auths/(没有文件监听), +// 在它扫描之前把远端凭证落下来,网关启动时就自然加载到最新的一批账号。 +// +// 用法: +// +// wb2api-launcher.exe # 同步 + 启动网关 + 后台对账 +// wb2api-launcher.exe -status # 只看两边差异,不启动网关 +// wb2api-launcher.exe -sync-only # 同步一次就退出 +// wb2api-launcher.exe -no-sync # 离线启动(跳过同步) +// wb2api-launcher.exe -no-watch # 不起后台对账 +// wb2api-launcher.exe -mode pull # 只拉不推 +// +// 退出码:网关的退出码原样透出;启动阶段失败返回 1;-status 有差异返回 2。 +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/syncer" +) + +// version 版本号(-version)。做成变量便于 -ldflags 注入。 +var version = "1.0.0" + +// 退出码约定。 +const ( + exitOK = 0 + exitError = 1 + exitPending = 2 // -status 模式下存在未同步的差异 +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "\n[launcher] 失败: %v\n", err) + os.Exit(exitError) + } +} + +// options 是解析后的启动参数。 +type options struct { + configPath string + serverBin string + + mode syncer.Mode + reportOnly bool + + noSync bool + noWatch bool + syncOnly bool + quiet bool + + interval time.Duration + shutdownGrace time.Duration + + // sync 覆盖项(不写 config.json 也能用) + bucket string + token string + endpoint string + include string + writer string + restart string + caBundle string + relayURL string + dryRun bool +} + +func run() error { + opt, showVersion, err := parseFlags(os.Args[1:]) + if err != nil { + return err + } + if showVersion { + fmt.Printf("wb2api-launcher %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) + return nil + } + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("无法定位启动器自身路径: %w", err) + } + exeDir := filepath.Dir(exePath) + + // 配置文件默认放在启动器旁边——"解压到目录、双击"即可用。 + if opt.configPath == "" { + opt.configPath = filepath.Join(exeDir, "config.json") + } else if !filepath.IsAbs(opt.configPath) { + opt.configPath = filepath.Join(exeDir, opt.configPath) + } + absCfg, err := filepath.Abs(opt.configPath) + if err != nil { + return err + } + cfgDir := filepath.Dir(absCfg) + + ensureConfig(absCfg, exeDir, cfgDir) + + // 关键:把工作目录切到 config.json 所在目录,再启动网关。 + // 这样配置里的相对路径(./auths、./data/state.json)对"启动器的同步"和 + // "网关子进程"是**同一个**目录,不会出现同步写 A、网关读 B 的错位。 + if err := os.Chdir(cfgDir); err != nil { + return fmt.Errorf("切换工作目录到 %s 失败: %w", cfgDir, err) + } + + syncCfg, err := loadSyncConfig(absCfg, opt) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var engine *syncer.Engine + if !opt.noSync { + engine, err = newEngine(ctx, syncCfg, opt) + if err != nil { + // 同步配置不全不是致命错误:照样能把网关拉起来(离线自用)。 + info("同步未启用:%v", err) + } + } + + if engine != nil { + if code := preflightSync(ctx, engine, opt); code != exitOK { + if opt.syncOnly || opt.reportOnly { + os.Exit(code) + } + // 启动阶段同步失败不阻断启动:断网/中转挂了不该让你连本地网关都用不了。 + } + } + + if opt.syncOnly || opt.reportOnly { + return nil + } + + serverBin, err := findServer(opt.serverBin, exeDir) + if err != nil { + return err + } + + info("网关二进制:%s", serverBin) + info("配置:%s", absCfg) + info("运行目录:%s", cfgDir) + + child := exec.Command(serverBin, "-config", absCfg) + child.Stdin = os.Stdin + child.Stdout = os.Stdout + child.Stderr = os.Stderr + child.Dir = cfgDir + if err := child.Start(); err != nil { + return fmt.Errorf("启动网关失败 (%s): %w", serverBin, err) + } + info("网关已启动 (PID %d)", child.Process.Pid) + + // 后台对账:跑在同一个进程里,不会留下孤儿进程。 + if engine != nil && !opt.noWatch { + go watchLoop(ctx, engine, opt) + } + + // 等子进程退出。Ctrl-C 在这个控制台里会同时送到启动器与网关, + // 网关收到后会 Flush() 落盘再做优雅停机——所以这里给一段宽限期让它自己收尾。 + done := make(chan error, 1) + go func() { done <- child.Wait() }() + + select { + case werr := <-done: + return exitCodeOf(werr) + case <-ctx.Done(): + info("收到中断信号,等待网关优雅退出(最多 %s)...", opt.shutdownGrace) + if err := waitOrKill(child, done, opt.shutdownGrace); err != nil { + return err + } + return nil + } +} + +// waitOrKill 给子进程一段宽限期自行退出,超时才强杀。 +// +// 为什么优先"等"而不是直接 Kill:网关在收到 SIGINT/SIGTERM 时会先 +// Pool.Flush() 把内存状态落盘再 Shutdown;直接杀掉会丢掉最后一次状态落盘 +// (积分/冷却/连登计数都是攒在内存里定时刷的)。 +func waitOrKill(child *exec.Cmd, done <-chan error, grace time.Duration) error { + select { + case <-done: + info("网关已优雅退出") + case <-time.After(grace): + info("宽限期已到,强制结束网关进程") + if child.Process != nil { + _ = child.Process.Kill() + } + <-done + } + return nil +} + +// exitCodeOf 把 Wait 的错误翻译成退出码。 +func exitCodeOf(err error) error { + if err == nil { + return nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + // 原样透出子进程退出码,便于外层脚本判断。 + os.Exit(ee.ExitCode()) + } + return err +} + +// preflightSync 启动前同步一次。返回退出码。 +func preflightSync(ctx context.Context, engine *syncer.Engine, opt options) int { + fmt.Println() + info("━━ 启动前同步(模式 %s)━━", opt.mode) + plan, res, err := engine.Sync(ctx, opt.mode, opt.dryRun) + if err != nil { + warn("同步失败:%v", err) + warn("按本地已有凭证继续启动,本次不做远端对账。") + return exitError + } + printPlan(plan, res, opt) + if res.Failed > 0 { + warn("%d 个文件同步失败,按本地已有凭证继续启动。", res.Failed) + return exitError + } + if opt.reportOnly && plan.Changes() > 0 { + return exitPending + } + if plan.Changes() == 0 { + info("已与中转一致(无改动)") + } + + // 本地一个凭证都没有时,网关起来是空池(/healthz 503),把原因说清楚。 + if n := countAuthFiles(engine.Config().AuthDir); n == 0 { + warn("auths/ 里没有任何账号 —— 网关会以空池启动(/healthz 会返回 503)。") + warn(" 检查 config.json 的 sync.bucket / sync.token。") + } + return exitOK +} + +// watchLoop 常驻后台对账:把本地运行期刷新出来的新 token 持续推回中转。 +func watchLoop(ctx context.Context, engine *syncer.Engine, opt options) { + t := time.NewTicker(opt.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + plan, res, err := engine.Sync(ctx, syncer.ModeSync, false) + switch { + case err != nil: + warn("[后台对账] 出错:%v", err) + case res.Failed > 0: + warn("[后台对账] 上传 %d / 下载 %d / 失败 %d", res.Uploaded, res.Downloaded, res.Failed) + case plan.Changes() > 0: + info("[后台对账] 上传 %d / 下载 %d", res.Uploaded, res.Downloaded) + for _, a := range plan.Actions { + if a.Op == syncer.OpNoop { + continue + } + info(" %s %s (%s)", opIcon(a.Op), a.Path, a.Reason) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// 配置 +// --------------------------------------------------------------------------- + +// loadSyncConfig 读取 config.json 的 sync 段并叠加命令行覆盖。 +func loadSyncConfig(cfgPath string, opt options) (syncer.Config, error) { + cfg, err := syncer.LoadConfigFile(cfgPath) + if err != nil && !os.IsNotExist(err) { + return syncer.Config{}, fmt.Errorf("读取 %s 失败: %w", cfgPath, err) + } + if opt.bucket != "" { + cfg.Bucket = opt.bucket + } + if opt.token != "" { + cfg.Token = opt.token + } + if opt.endpoint != "" { + cfg.Endpoint = opt.endpoint + } + if opt.writer != "" { + cfg.Writer = opt.writer + } + if opt.relayURL != "" { + cfg.RelayURL = opt.relayURL + } + if opt.restart != "" { + cfg.RestartSpace = opt.restart + } + if opt.caBundle != "" { + cfg.CABundle = opt.caBundle + } + if opt.include != "" { + var list []string + for _, p := range strings.Split(opt.include, ",") { + if p = strings.TrimSpace(p); p != "" { + list = append(list, p) + } + } + cfg.IncludeData = list + } + if cfg.Token == "" && cfg.TokenFile == "" { + if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" { + cfg.Token = t + } + } + cfg.Normalize() + if cfg.AuthDir == "" { + cfg.AuthDir = "auths" + } + if cfg.DataDir == "" { + cfg.DataDir = "data" + } + return cfg, nil +} + +// newEngine 构造同步引擎;必要时先从桶里取中转的 api_key(relay 写入通道用)。 +func newEngine(ctx context.Context, cfg syncer.Config, opt options) (*syncer.Engine, error) { + if cfg.Bucket == "" { + return nil, fmt.Errorf("config.json 的 sync.bucket 为空(未配置中转桶)") + } + eng, err := syncer.New(cfg) + if err != nil { + return nil, err + } + if eng.WriterName() == "relay" && eng.Config().RelayKey == "" { + if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil { + cfg.RelayKey = key + if eng, err = syncer.New(cfg); err != nil { + return nil, err + } + } + } + return eng, nil +} + +// ensureConfig 首启兜底:没有 config.json 时从 config.example.json 复制一份。 +// +// 刻意不在这里生成带随机 api_key 的最小配置:网关自己首启就会生成推荐配置 +// (含 crypto/rand 的 api_key),启动器不该抢着替它写一份半成品。 +func ensureConfig(cfgPath, exeDir, cfgDir string) { + if _, err := os.Stat(cfgPath); err == nil { + return + } + for _, cand := range []string{ + filepath.Join(cfgDir, "config.example.json"), + filepath.Join(exeDir, "config.example.json"), + } { + if raw, err := os.ReadFile(cand); err == nil { + if werr := os.WriteFile(cfgPath, raw, 0o600); werr == nil { + warn("未找到 config.json,已从 %s 复制一份。请按需修改(尤其是 api_key 与 sync 段)。", filepath.Base(cand)) + return + } + } + } + info("未找到 config.json,也没有 config.example.json —— 交给网关自行生成默认配置。") +} + +// findServer 定位网关二进制。 +// +// 查找顺序:-server 显式指定 → 启动器同目录(Windows 下 wb2api.exe)→ PATH。 +// 这样"把新下载的 wb2api.exe 丢到启动器旁边"就是完整的升级动作。 +func findServer(explicit, exeDir string) (string, error) { + names := []string{"wb2api"} + if runtime.GOOS == "windows" { + names = []string{"wb2api.exe", "wb2api"} + } + if explicit != "" { + p := explicit + if !filepath.IsAbs(p) { + p = filepath.Join(exeDir, explicit) + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + return "", fmt.Errorf("-server 指定的文件不存在: %s", p) + } + for _, n := range names { + p := filepath.Join(exeDir, n) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + for _, n := range names { + if p, err := exec.LookPath(n); err == nil { + return p, nil + } + } + return "", fmt.Errorf("找不到网关二进制 wb2api(找了 %s 与 PATH)。\n"+ + " 把 wb2api.exe 放在启动器同目录,或用 -server 指定路径", strings.Join(names, " / ")) +} + +// countAuthFiles 数本地凭证文件个数。 +func countAuthFiles(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + n++ + } + } + return n +} + +// --------------------------------------------------------------------------- +// 输出 +// --------------------------------------------------------------------------- + +func info(format string, a ...any) { + fmt.Printf("[launcher] "+format+"\n", a...) +} + +func warn(format string, a ...any) { + fmt.Printf("[launcher] ⚠ "+format+"\n", a...) +} + +func opIcon(op syncer.Op) string { + switch op { + case syncer.OpUpload: + return "↑" + case syncer.OpDownload: + return "↓" + default: + return "=" + } +} + +// printPlan 打印对账结果(只列有动作的行,一致的不刷屏)。 +func printPlan(plan *syncer.Plan, res *syncer.Result, opt options) { + cfgWriter := "" + if plan.Writer != "" { + cfgWriter = plan.Writer + } else { + cfgWriter = "(只读)" + } + info("桶 %s · 写入通道 %s · 本地 %d / 远端 %d 个文件", + plan.Bucket, cfgWriter, plan.LocalFiles, plan.RemoteFiles) + + for _, a := range plan.Actions { + if a.Op == syncer.OpNoop && !a.Conflict { + continue + } + line := fmt.Sprintf(" %s %-8s %s", opIcon(a.Op), a.Op, a.Path) + if a.Conflict { + line += " [冲突]" + } + fmt.Println(line) + fmt.Printf(" %s\n", a.Reason) + if a.Err != "" { + fmt.Printf(" ✗ %s\n", a.Err) + } + } + info("汇总:上传 %d / 下载 %d / 跳过 %d / 失败 %d", + res.Uploaded, res.Downloaded, res.Skipped, res.Failed) + for _, e := range res.Errors { + warn("%s", e) + } +} diff --git a/cmd/login/main.go b/cmd/login/main.go index d1b36a7b92ddbd3e4adc6e48be39bac00aa784ca..0c8ceb0040e970a7f36bc8b111672ad0f3727aa6 100644 --- a/cmd/login/main.go +++ b/cmd/login/main.go @@ -1,336 +1,336 @@ -// login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。 -// -// 两个子命令,由 login.sh 顺序驱动: -// -// login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl, -// state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL -// login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次, -// 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname, -// stdout 打印完整 token+account JSON(含 realm 键) -// -// --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer: -// -// cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn) -// global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai) -// -// state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。 -// 无 PKCE(workbuddy 设备流由服务端签发 state)。 -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "os" - "path/filepath" - "strings" - "time" - - auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai -// (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。 -const ( - upstreamBaseCN = "https://copilot.tencent.com" - upstreamBaseGlobal = "https://www.workbuddy.ai" - clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" - originRefererCN = "https://www.codebuddy.cn" - originRefererGlobal = "https://www.workbuddy.ai" -) - -// 登录 state 落盘路径(var 便于测试替换临时文件) -// Portable across OSes: the upstream hardcoded "/tmp/...", which on -// Windows resolves to :\tmp\... and aborts the OAuth flow with -// "The system cannot find the path specified". os.TempDir() is /tmp on Linux. -var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json") - -// exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。 -var exitFunc = os.Exit - -// realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global → -// (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。 -func realmConfig(realm string) (base, origin string) { - if realm == realmGlobal { - return upstreamBaseGlobal, originRefererGlobal - } - return upstreamBaseCN, originRefererCN -} - -// commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。 -// 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。 -func commonHeaders(origin string) func(*http.Request) { - return func(req *http.Request) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("X-Requested-With", "XMLHttpRequest") - req.Header.Set("Origin", origin) - req.Header.Set("Referer", origin+"/") - req.Header.Set("User-Agent", clientUA) - } -} - -// apiEnvelope 与 main.go:429-433 一致 -type apiEnvelope struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` -} - -// doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error -func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) { - req, err := http.NewRequest(method, fullURL, body) - if err != nil { - return nil, 0, err - } - if headers != nil { - headers(req) - } else { - // 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归) - commonHeaders(originRefererCN)(req) - } - resp, err := client.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) - } - if resp.StatusCode >= 300 { - return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode) - } - var env apiEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) - } - if env.Code != 0 { - return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) - } - return env.Data, resp.StatusCode, nil -} - -func fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...) - exitFunc(1) -} - -type loginState struct { - State string `json:"state"` - Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域 -} - -// realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。 -const ( - realmCN = "cn" - realmGlobal = "global" -) - -// parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm )flag,缺省 cn。 -// 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。 -func parseRealmArgs(args []string) (realm string, rest []string, err error) { - realm = realmCN - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case a == "--realm": - if i+1 >= len(args) { - return "", nil, fmt.Errorf("--realm requires a value") - } - v := strings.ToLower(strings.TrimSpace(args[i+1])) - if v != realmCN && v != realmGlobal { - return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1]) - } - realm = v - i++ - case strings.HasPrefix(a, "--realm="): - v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm="))) - if v != realmCN && v != realmGlobal { - return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v) - } - realm = v - default: - rest = append(rest, a) - } - } - return realm, rest, nil -} - -// resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支 -// 的核心决策,可测)。规则: -// -// "1"/"cn"(大小写不敏感)/""(回车默认)→ cn -// "2"/"global" → global -// 其他 → ("", false)(调用方回默认 cn) -func resolveRealmInput(input string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(input)) { - case "", "1", "cn": - return realmCN, true - case "2", "global": - return realmGlobal, true - } - return "", false -} - -// promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身), -// 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。 -func promptRealm(in io.Reader, out io.Writer) string { - fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ") - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && line == "" { - // EOF/非交互 → 回落默认 cn - return realmCN - } - if realm, ok := resolveRealmInput(line); ok { - return realm - } - fmt.Fprintln(out, "无效选择,默认国内版 cn") - return realmCN -} - -// validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域): -// state 无 realm(旧文件)放行;非空且不一致 → error。 -func validateRealmMatch(stateRealm, cliRealm string) error { - if stateRealm != "" && stateRealm != cliRealm { - return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm) - } - return nil -} - -// runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL, -// state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径 -// (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。 -func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) { - headers := commonHeaders(origin) - data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}"))) - if err != nil { - fatal("auth state failed: %v", err) - } - var st struct { - State string `json:"state"` - AuthURL string `json:"authUrl"` - } - if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { - fatal("auth state: missing state or authUrl") - } - raw, _ := json.Marshal(loginState{State: st.State, Realm: realm}) - if err := os.WriteFile(statePath, raw, 0o600); err != nil { - fatal("write state: %v", err) - } - fmt.Fprintln(out, st.AuthURL) -} - -// runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点 -// GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。 -// statePath 可注入临时文件便于测试。 -func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) { - raw, err := os.ReadFile(statePath) - if err != nil { - fatal("read state: %v (先跑 login url)", err) - } - var ls loginState - if err := json.Unmarshal(raw, &ls); err != nil { - fatal("parse state: %v", err) - } - // 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域) - if err := validateRealmMatch(ls.Realm, realm); err != nil { - fatal("%v", err) - } - headers := commonHeaders(origin) - // handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点, - // pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle - tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil) - if errTok != nil { - if status == 0 || status >= 500 { - fatal("token endpoint error: %v", errTok) - } - fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") - } - var tok struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int64 `json:"expiresIn"` - Domain string `json:"domain"` - } - if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { - fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") - } - // login/account 拿 uid/nickname(带 Bearer) - var acct struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - } - acctHeaders := func(r *http.Request) { - headers(r) - r.Header.Set("Authorization", "Bearer "+tok.AccessToken) - } - if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil { - _ = json.Unmarshal(acctRaw, &acct) - } - oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct)) - fmt.Fprintln(out, string(oraw)) - os.Remove(statePath) -} - -// buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。 -// realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断—— -// 保证登录落盘的 auth 文件恒带 realm 键。 -func buildLoginOutput(tok struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int64 `json:"expiresIn"` - Domain string `json:"domain"` -}, realm string, acct struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` -}) map[string]any { - return map[string]any{ - "access_token": tok.AccessToken, - "refresh_token": tok.RefreshToken, - "expires_in": tok.ExpiresIn, - "domain": tok.Domain, - "realm": auth2.ResolveRealm(realm, tok.Domain), - "uid": acct.UID, - "enterprise_id": acct.EnterpriseID, - "nickname": acct.Nickname, - } -} - -func main() { - realm, rest, err := parseRealmArgs(os.Args[1:]) - if err != nil { - fatal("%v (usage: login [--realm=cn|global] )", err) - } - if len(rest) < 1 { - fatal("usage: login [--realm=cn|global] ") - } - // 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话) - jar, _ := cookiejar.New(nil) - client := &http.Client{Timeout: 30 * time.Second, Jar: jar} - - base, origin := realmConfig(realm) - - switch rest[0] { - case "url": - runURL(base, origin, realm, stateFile, client, os.Stdout) - - case "poll": - runPoll(base, origin, realm, stateFile, client, os.Stdout) - - case "realm": - // 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。 - // 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。 - realm := promptRealm(os.Stdin, os.Stderr) - fmt.Println(realm) - - default: - fatal("unknown subcommand %q (want url|poll|realm)", rest[0]) - } -} +// login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。 +// +// 两个子命令,由 login.sh 顺序驱动: +// +// login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl, +// state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL +// login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次, +// 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname, +// stdout 打印完整 token+account JSON(含 realm 键) +// +// --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer: +// +// cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn) +// global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai) +// +// state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。 +// 无 PKCE(workbuddy 设备流由服务端签发 state)。 +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "time" + + auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai +// (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。 +const ( + upstreamBaseCN = "https://copilot.tencent.com" + upstreamBaseGlobal = "https://www.workbuddy.ai" + clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// 登录 state 落盘路径(var 便于测试替换临时文件) +// Portable across OSes: the upstream hardcoded "/tmp/...", which on +// Windows resolves to :\tmp\... and aborts the OAuth flow with +// "The system cannot find the path specified". os.TempDir() is /tmp on Linux. +var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json") + +// exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。 +var exitFunc = os.Exit + +// realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global → +// (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。 +func realmConfig(realm string) (base, origin string) { + if realm == realmGlobal { + return upstreamBaseGlobal, originRefererGlobal + } + return upstreamBaseCN, originRefererCN +} + +// commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。 +// 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。 +func commonHeaders(origin string) func(*http.Request) { + return func(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", clientUA) + } +} + +// apiEnvelope 与 main.go:429-433 一致 +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error +func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + if headers != nil { + headers(req) + } else { + // 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归) + commonHeaders(originRefererCN)(req) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) + } + if resp.StatusCode >= 300 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...) + exitFunc(1) +} + +type loginState struct { + State string `json:"state"` + Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域 +} + +// realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。 +const ( + realmCN = "cn" + realmGlobal = "global" +) + +// parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm )flag,缺省 cn。 +// 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。 +func parseRealmArgs(args []string) (realm string, rest []string, err error) { + realm = realmCN + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--realm": + if i+1 >= len(args) { + return "", nil, fmt.Errorf("--realm requires a value") + } + v := strings.ToLower(strings.TrimSpace(args[i+1])) + if v != realmCN && v != realmGlobal { + return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1]) + } + realm = v + i++ + case strings.HasPrefix(a, "--realm="): + v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm="))) + if v != realmCN && v != realmGlobal { + return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v) + } + realm = v + default: + rest = append(rest, a) + } + } + return realm, rest, nil +} + +// resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支 +// 的核心决策,可测)。规则: +// +// "1"/"cn"(大小写不敏感)/""(回车默认)→ cn +// "2"/"global" → global +// 其他 → ("", false)(调用方回默认 cn) +func resolveRealmInput(input string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "", "1", "cn": + return realmCN, true + case "2", "global": + return realmGlobal, true + } + return "", false +} + +// promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身), +// 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。 +func promptRealm(in io.Reader, out io.Writer) string { + fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ") + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && line == "" { + // EOF/非交互 → 回落默认 cn + return realmCN + } + if realm, ok := resolveRealmInput(line); ok { + return realm + } + fmt.Fprintln(out, "无效选择,默认国内版 cn") + return realmCN +} + +// validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域): +// state 无 realm(旧文件)放行;非空且不一致 → error。 +func validateRealmMatch(stateRealm, cliRealm string) error { + if stateRealm != "" && stateRealm != cliRealm { + return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm) + } + return nil +} + +// runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL, +// state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径 +// (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。 +func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) { + headers := commonHeaders(origin) + data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}"))) + if err != nil { + fatal("auth state failed: %v", err) + } + var st struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { + fatal("auth state: missing state or authUrl") + } + raw, _ := json.Marshal(loginState{State: st.State, Realm: realm}) + if err := os.WriteFile(statePath, raw, 0o600); err != nil { + fatal("write state: %v", err) + } + fmt.Fprintln(out, st.AuthURL) +} + +// runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点 +// GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。 +// statePath 可注入临时文件便于测试。 +func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) { + raw, err := os.ReadFile(statePath) + if err != nil { + fatal("read state: %v (先跑 login url)", err) + } + var ls loginState + if err := json.Unmarshal(raw, &ls); err != nil { + fatal("parse state: %v", err) + } + // 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域) + if err := validateRealmMatch(ls.Realm, realm); err != nil { + fatal("%v", err) + } + headers := commonHeaders(origin) + // handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点, + // pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle + tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil) + if errTok != nil { + if status == 0 || status >= 500 { + fatal("token endpoint error: %v", errTok) + } + fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + } + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y") + } + // login/account 拿 uid/nickname(带 Bearer) + var acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } + acctHeaders := func(r *http.Request) { + headers(r) + r.Header.Set("Authorization", "Bearer "+tok.AccessToken) + } + if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil { + _ = json.Unmarshal(acctRaw, &acct) + } + oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct)) + fmt.Fprintln(out, string(oraw)) + os.Remove(statePath) +} + +// buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。 +// realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断—— +// 保证登录落盘的 auth 文件恒带 realm 键。 +func buildLoginOutput(tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` +}, realm string, acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` +}) map[string]any { + return map[string]any{ + "access_token": tok.AccessToken, + "refresh_token": tok.RefreshToken, + "expires_in": tok.ExpiresIn, + "domain": tok.Domain, + "realm": auth2.ResolveRealm(realm, tok.Domain), + "uid": acct.UID, + "enterprise_id": acct.EnterpriseID, + "nickname": acct.Nickname, + } +} + +func main() { + realm, rest, err := parseRealmArgs(os.Args[1:]) + if err != nil { + fatal("%v (usage: login [--realm=cn|global] )", err) + } + if len(rest) < 1 { + fatal("usage: login [--realm=cn|global] ") + } + // 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话) + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + + base, origin := realmConfig(realm) + + switch rest[0] { + case "url": + runURL(base, origin, realm, stateFile, client, os.Stdout) + + case "poll": + runPoll(base, origin, realm, stateFile, client, os.Stdout) + + case "realm": + // 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。 + // 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。 + realm := promptRealm(os.Stdin, os.Stderr) + fmt.Println(realm) + + default: + fatal("unknown subcommand %q (want url|poll|realm)", rest[0]) + } +} diff --git a/cmd/login/realm_test.go b/cmd/login/realm_test.go index 0247464195f5781a39b001daaa27b43aa08706f2..faadc00199b84de74175c8d657f76b636f8ae42a 100644 --- a/cmd/login/realm_test.go +++ b/cmd/login/realm_test.go @@ -1,169 +1,169 @@ -package main - -import ( - "bytes" - "reflect" - "strings" - "testing" -) - -// TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、 -// 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。 -func TestParseRealmArgs(t *testing.T) { - cases := []struct { - name string - args []string - wantRealm string - wantRest []string - wantErr bool - }{ - {name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}}, - {name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, - {name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}}, - {name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, - {name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true}, - {name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true}, - {name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - realm, rest, err := parseRealmArgs(c.args) - if c.wantErr { - if err == nil { - t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args) - } - return - } - if err != nil { - t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err) - } - if realm != c.wantRealm { - t.Errorf("realm=%q want %q", realm, c.wantRealm) - } - if !reflect.DeepEqual(rest, c.wantRest) { - t.Errorf("rest=%v want %v", rest, c.wantRest) - } - }) - } -} - -// TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策): -// "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。 -func TestResolveRealmInput(t *testing.T) { - cases := []struct { - input string - want string - }{ - {input: "1", want: "cn"}, - {input: "cn", want: "cn"}, - {input: "CN", want: "cn"}, - {input: "", want: "cn"}, // 回车默认 - {input: "2", want: "global"}, - {input: "global", want: "global"}, - {input: "GLOBAL", want: "global"}, - } - for _, c := range cases { - got, ok := resolveRealmInput(c.input) - if !ok { - t.Errorf("resolveRealmInput(%q) ok=false want true", c.input) - continue - } - if got != c.want { - t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want) - } - } - // 非法输入 → (false)。 - for _, bad := range []string{"3", "cnn", "globalx", "foo"} { - if got, ok := resolveRealmInput(bad); ok { - t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got) - } - } -} - -// TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm): -// -// 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示; -// 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。 -func TestPromptRealm(t *testing.T) { - cases := []struct { - name string - input string - want string - wantHint bool // 输出中出现"选择登录版本"提示 - }{ - {name: "选 cn", input: "1\n", want: "cn", wantHint: true}, - {name: "选 global", input: "2\n", want: "global", wantHint: true}, - {name: "回车默认 cn", input: "\n", want: "cn", wantHint: true}, - {name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true}, - {name: "EOF 回落 cn", input: "", want: "cn", wantHint: true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - var out bytes.Buffer - got := promptRealm(strings.NewReader(c.input), &out) - if got != c.want { - t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want) - } - if c.wantHint && !strings.Contains(out.String(), "选择登录版本") { - t.Errorf("prompt should contain '选择登录版本', got %q", out.String()) - } - }) - } -} - -// TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用): -// 子命令剥离 --realm,剩余参数为首个 "realm"。 -func TestRealmSubcommandSelection(t *testing.T) { - realm, rest, err := parseRealmArgs([]string{"realm"}) - if err != nil { - t.Fatalf("parseRealmArgs(realm) err=%v", err) - } - if realm != "cn" { - t.Errorf("realm default=%q want cn", realm) - } - if len(rest) != 1 || rest[0] != "realm" { - t.Errorf("rest=%v want [realm]", rest) - } -} - -// TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键: -// 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。 -// 这是「登录落盘永远带 realm 标识」契约的测试载体。 -func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) { - tok := struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int64 `json:"expiresIn"` - Domain string `json:"domain"` - }{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"} - acct := struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - }{UID: "u1", Nickname: "n1"} - - cases := []struct { - name string - realm string - domain string - wantRealm string - }{ - {name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"}, - {name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"}, - {name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"}, - {name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - tok.Domain = c.domain - out := buildLoginOutput(tok, c.realm, acct) - m, ok := out["realm"].(string) - if !ok { - t.Fatalf("missing realm key in login output: %v", out) - } - if m != c.wantRealm { - t.Errorf("realm=%q want %q", m, c.wantRealm) - } - }) - } -} +package main + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +// TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、 +// 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。 +func TestParseRealmArgs(t *testing.T) { + cases := []struct { + name string + args []string + wantRealm string + wantRest []string + wantErr bool + }{ + {name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}}, + {name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + {name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}}, + {name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + {name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true}, + {name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true}, + {name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + realm, rest, err := parseRealmArgs(c.args) + if c.wantErr { + if err == nil { + t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args) + } + return + } + if err != nil { + t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err) + } + if realm != c.wantRealm { + t.Errorf("realm=%q want %q", realm, c.wantRealm) + } + if !reflect.DeepEqual(rest, c.wantRest) { + t.Errorf("rest=%v want %v", rest, c.wantRest) + } + }) + } +} + +// TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策): +// "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。 +func TestResolveRealmInput(t *testing.T) { + cases := []struct { + input string + want string + }{ + {input: "1", want: "cn"}, + {input: "cn", want: "cn"}, + {input: "CN", want: "cn"}, + {input: "", want: "cn"}, // 回车默认 + {input: "2", want: "global"}, + {input: "global", want: "global"}, + {input: "GLOBAL", want: "global"}, + } + for _, c := range cases { + got, ok := resolveRealmInput(c.input) + if !ok { + t.Errorf("resolveRealmInput(%q) ok=false want true", c.input) + continue + } + if got != c.want { + t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want) + } + } + // 非法输入 → (false)。 + for _, bad := range []string{"3", "cnn", "globalx", "foo"} { + if got, ok := resolveRealmInput(bad); ok { + t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got) + } + } +} + +// TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm): +// +// 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示; +// 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。 +func TestPromptRealm(t *testing.T) { + cases := []struct { + name string + input string + want string + wantHint bool // 输出中出现"选择登录版本"提示 + }{ + {name: "选 cn", input: "1\n", want: "cn", wantHint: true}, + {name: "选 global", input: "2\n", want: "global", wantHint: true}, + {name: "回车默认 cn", input: "\n", want: "cn", wantHint: true}, + {name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true}, + {name: "EOF 回落 cn", input: "", want: "cn", wantHint: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var out bytes.Buffer + got := promptRealm(strings.NewReader(c.input), &out) + if got != c.want { + t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want) + } + if c.wantHint && !strings.Contains(out.String(), "选择登录版本") { + t.Errorf("prompt should contain '选择登录版本', got %q", out.String()) + } + }) + } +} + +// TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用): +// 子命令剥离 --realm,剩余参数为首个 "realm"。 +func TestRealmSubcommandSelection(t *testing.T) { + realm, rest, err := parseRealmArgs([]string{"realm"}) + if err != nil { + t.Fatalf("parseRealmArgs(realm) err=%v", err) + } + if realm != "cn" { + t.Errorf("realm default=%q want cn", realm) + } + if len(rest) != 1 || rest[0] != "realm" { + t.Errorf("rest=%v want [realm]", rest) + } +} + +// TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键: +// 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。 +// 这是「登录落盘永远带 realm 标识」契约的测试载体。 +func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) { + tok := struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + }{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"} + acct := struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + }{UID: "u1", Nickname: "n1"} + + cases := []struct { + name string + realm string + domain string + wantRealm string + }{ + {name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"}, + {name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"}, + {name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"}, + {name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tok.Domain = c.domain + out := buildLoginOutput(tok, c.realm, acct) + m, ok := out["realm"].(string) + if !ok { + t.Fatalf("missing realm key in login output: %v", out) + } + if m != c.wantRealm { + t.Errorf("realm=%q want %q", m, c.wantRealm) + } + }) + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index ca77fd8bfefe04d27e95fe64046a2a4ec5e47b91..3453bc9d527b8ca7727b88baf2f01e5818d4043a 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -1,616 +1,658 @@ -// config.go 加载 JSON 配置 + 环境变量覆盖。 -package main - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" -) - -// Config 顶层配置。 -type Config struct { - Listen string `json:"listen"` // ":7863" - APIKey string `json:"api_key"` // 空 = 不鉴权 - AuthDir string `json:"auth_dir"` // ./auths - StateFile string `json:"state_file"` // ./data/state.json - - Panel struct { - // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。 - PackageDetailLimit int `json:"package_detail_limit"` - } `json:"panel"` - - Logging struct { - // RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。 - RequestArchiveEnabled bool `json:"request_archive_enabled"` - // RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。 - RequestRetentionDays int `json:"request_retention_days"` - // RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。 - RequestArchiveMaxMB int `json:"request_archive_max_mb"` - // RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行 - // 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。 - // - // 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索, - // 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要 - // 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。 - // 热生效(经 livecfg 快照),无需重启。 - RequestClientInfo bool `json:"request_client_info"` - } `json:"logging"` - - Cooldown struct { - // hard_credit / err_threshold / err_cooldown 三个历史键已退役: - // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。 - // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。 - SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数 - // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。 - // 空值回落默认,非法值报错(处理风格同 soft_rate)。 - SoftRateMax string `json:"soft_rate_max"` // "2h" - } `json:"cooldown"` - - Schedule struct { - CheckinHours []int `json:"checkin_hours"` // [9,21] - TravelHours []int `json:"travel_hours"` // [9,21] - ActivityHours []int `json:"activity_hours"` // [10] - KeepaliveHours []int `json:"keepalive_hours"` // [22] - BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数) - GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行) - // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。 - // - // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用": - // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转 - // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true - // 则对老配置零影响,向后完全兼容。 - // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。 - // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。 - // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。 - CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到 - TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行 - ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报 - KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活 - BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子 - GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程 - - // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。 - // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 - BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 - BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 - } `json:"schedule"` - - Global struct { - // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/ - // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。 - // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global - // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号 - // 恒判 cn,global base 只在 realm=global 的账号上被使用。 - Enabled bool `json:"enabled"` - // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认 - // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。 - ChatBase string `json:"chat_base"` - BillingBase string `json:"billing_base"` - } `json:"global"` - - Upstream struct { - // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 - TimeoutSeconds int `json:"timeout_seconds"` - // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。 - HeaderTimeoutSeconds int `json:"header_timeout_seconds"` - // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。 - IdleTimeoutSeconds int `json:"idle_timeout_seconds"` - // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。 - // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。 - // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。 - UserAgent string `json:"user_agent"` - // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/` 与归属头 - // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。 - ClientVersion string `json:"client_version"` - // CliVersion 出站 UA 中 `CLI/` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。 - CliVersion string `json:"cli_version"` - // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。 - // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。 - ClientName string `json:"client_name"` - // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。 - // 每号 auth 文件的 device_token 键优先于本项。 - DeviceToken string `json:"device_token"` - // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。 - DeviceTokenFile string `json:"device_token_file"` - // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。 - PassthroughIP bool `json:"passthrough_ip"` - } `json:"upstream"` - - Features struct { - // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。 - SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` - } `json:"features"` - - Prompt struct { - // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); - // custom = 网关用自有系统提示词替换客户端 system/developer; - // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。 - Mode string `json:"mode"` // "passthrough" / "custom" / "append" - // File 提示词文件路径;空 = 内置默认 defaultprompt.md; - // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。 - File string `json:"file"` - } `json:"prompt"` - - // PromptText 解析后的系统提示词文本(custom/append 模式使用)。 - PromptText string `json:"-"` - - Upstash struct { - URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host - Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:@:6379 - } `json:"upstash"` - - Pool struct { - MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 - MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 - BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 - BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" - BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" - // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 - // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 - DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 - DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m" - DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制) - IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重 - IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0 - // PreferExpiring 最早到期优先路由开关,默认 true。开启且 expiring_soon 窗口内 - // 存在有效批次时,按最早到期时间排序;关闭后完全不使用到期信息选号。 - PreferExpiring bool `json:"prefer_expiring"` - // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在 - // 此窗口内的积分进入优先集,再按最早到期排序。空/0 = 禁用该路由门槛。 - ExpiringSoon string `json:"expiring_soon"` - // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0 - // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切 - // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有 - // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为); - // 空值回落默认。 - CostExploreInterval string `json:"cost_explore_interval"` - // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再 - // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。 - // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 - // 默认 0 = 关闭;负值钳 0。 - CreditFloor int64 `json:"credit_floor"` - } `json:"pool"` - - SessionSticky struct { - Enabled bool `json:"enabled"` // 默认 true - TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m" - GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m" - } `json:"session_sticky"` - - // 解析后 - SoftRateDur time.Duration `json:"-"` - SoftRateMaxDur time.Duration `json:"-"` - BreakerCooldownDur time.Duration `json:"-"` - BreakerCooldownMaxD time.Duration `json:"-"` - DegradeCooldownDur time.Duration `json:"-"` - DegradeCooldownMaxD time.Duration `json:"-"` - SessionTTL time.Duration `json:"-"` - SessionGCInterval time.Duration `json:"-"` - BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) - ExpiringSoonDur time.Duration `json:"-"` - // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 - CostExploreIntervalDur time.Duration `json:"-"` -} - -// Default 默认配置。 -func Default() *Config { - c := &Config{ - Listen: ":7863", - APIKey: "", - AuthDir: "./auths", - StateFile: "./data/state.json", - } - c.Cooldown.SoftRate = "600s" - c.Cooldown.SoftRateMax = "2h" - c.Panel.PackageDetailLimit = 5 - c.Logging.RequestArchiveEnabled = true - c.Logging.RequestRetentionDays = 7 - c.Logging.RequestArchiveMaxMB = 100 - // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值, - // 只有显式 false 才关闭来源记录。 - c.Logging.RequestClientInfo = true - c.Schedule.CheckinHours = []int{9, 21} - c.Schedule.TravelHours = []int{9, 21} - c.Schedule.ActivityHours = []int{10} - c.Schedule.KeepaliveHours = []int{22} - c.Schedule.BlackcatHours = []int{23} - c.Schedule.GrowthHours = []int{1} - // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖, - // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。 - c.Schedule.CheckinEnabled = true - c.Schedule.GrowthEnabled = true - c.Schedule.TravelEnabled = true - c.Schedule.ActivityEnabled = true - c.Schedule.KeepaliveEnabled = true - c.Schedule.BlackcatEnabled = true - c.Schedule.BalanceRefreshEnabled = true - c.Schedule.BalanceRefreshMinutes = 5 - c.Upstream.TimeoutSeconds = 120 - // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 - c.Upstream.HeaderTimeoutSeconds = 0 - c.Upstream.IdleTimeoutSeconds = 0 - // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用); - // ChatBase/BillingBase 缺省空(回落内置默认)。 - c.Global.Enabled = true - c.Features.SanitizeBlacklistFingerprints = true - c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) - c.Pool.MaxInFlight = 3 - // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 - // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 - // 的 0 没有合理语义,回退分档默认最稳)。 - c.Pool.MaxInFlightGlobal = 2 - c.Pool.BreakerThreshold = 3 - c.Pool.BreakerCooldown = "30m" - c.Pool.BreakerCooldownMax = "6h" - c.Pool.DegradeThreshold = 5 - c.Pool.DegradeCooldown = "10m" - c.Pool.DegradeCooldownMax = "2h" - c.Pool.IdleWeightPerHour = 0.5 - c.Pool.IdleWeightMax = 5.0 - c.Pool.PreferExpiring = true - c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期 - // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。 - c.Pool.CostExploreInterval = "30m" - c.SessionSticky.Enabled = true - c.SessionSticky.TTL = "30m" - c.SessionSticky.GCInterval = "5m" - return c -} - -// Load 从文件读,再用 WB2A_* env 覆盖。 -func Load(path string) (*Config, error) { - c := Default() - if path != "" { - // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录, - // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。 - if st, statErr := os.Stat(path); statErr == nil && st.IsDir() { - return nil, fmt.Errorf("config %s 是目录而非文件——"+ - "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+ - "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path) - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - if _, err := ParseConfigInto(raw, c); err != nil { - return nil, err - } - } - applyEnv(c) - if err := c.normalize(); err != nil { - return nil, err - } - return c, nil -} - -// ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。 -// 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。 -func ParseConfigInto(raw []byte, c *Config) (*Config, error) { - if err := json.Unmarshal(raw, c); err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } - if err := c.normalize(); err != nil { - return nil, err - } - return c, nil -} - -// ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。 -func ParseConfig(raw []byte) (*Config, error) { - return ParseConfigInto(raw, Default()) -} - -// WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。 -// 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成: -// 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。 -// 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。 -func WriteDefault(path string) (string, error) { - raw := make([]byte, 18) - if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("gen api_key: %w", err) - } - key := "sk-" + base64.RawURLEncoding.EncodeToString(raw) - c := Default() - c.APIKey = key - _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值 - out, err := json.MarshalIndent(c, "", " ") - if err != nil { - return "", fmt.Errorf("marshal config: %w", err) - } - if dir := filepath.Dir(path); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("mkdir config dir: %w", err) - } - } - // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。 - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return "", fmt.Errorf("write config: %w", err) - } - defer f.Close() - if _, err := f.Write(out); err != nil { - return "", fmt.Errorf("write config: %w", err) - } - return key, nil -} - -func applyEnv(c *Config) { - if v := os.Getenv("WB2A_LISTEN"); v != "" { - c.Listen = v - } - if v := os.Getenv("WB2A_API_KEY"); v != "" { - c.APIKey = v - } - if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { - c.AuthDir = v - } - if v := os.Getenv("WB2A_STATE_FILE"); v != "" { - c.StateFile = v - } - if v := os.Getenv("WB2A_SOFT_RATE"); v != "" { - c.Cooldown.SoftRate = v - } - if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" { - c.Cooldown.SoftRateMax = v - } - if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - c.Upstream.TimeoutSeconds = n - } - } - if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - c.Upstream.HeaderTimeoutSeconds = n - } - } - if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - c.Upstream.IdleTimeoutSeconds = n - } - } - if v := os.Getenv("WB2A_USER_AGENT"); v != "" { - c.Upstream.UserAgent = v - } - if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" { - c.Upstream.ClientVersion = v - } - if v := os.Getenv("WB2A_CLI_VERSION"); v != "" { - c.Upstream.CliVersion = v - } - if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" { - c.Upstream.ClientName = v - } - if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" { - c.Upstream.DeviceToken = v - } - if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" { - c.Upstream.DeviceTokenFile = v - } - if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" { - if b, err := strconv.ParseBool(v); err == nil { - c.Upstream.PassthroughIP = b - } - } - if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" { - if b, err := strconv.ParseBool(v); err == nil { - c.Features.SanitizeBlacklistFingerprints = b - } - } - if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" { - c.Prompt.Mode = v - } - if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" { - c.Prompt.File = v - } - if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" { - c.Pool.ExpiringSoon = v - } - if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" { - if b, err := strconv.ParseBool(v); err == nil { - c.Pool.PreferExpiring = b - } - } -} - -func (c *Config) normalize() error { - var err error - if c.Panel.PackageDetailLimit <= 0 { - c.Panel.PackageDetailLimit = 5 - } - if c.Logging.RequestRetentionDays <= 0 { - c.Logging.RequestRetentionDays = 7 - } - if c.Logging.RequestArchiveMaxMB <= 0 { - c.Logging.RequestArchiveMaxMB = 100 - } - if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil { - return fmt.Errorf("cooldown.soft_rate: %w", err) - } - // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。 - if c.Cooldown.SoftRateMax == "" { - c.Cooldown.SoftRateMax = "2h" - } - if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil { - return fmt.Errorf("cooldown.soft_rate_max: %w", err) - } - if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil { - return fmt.Errorf("pool.breaker_cooldown: %w", err) - } - if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil { - return fmt.Errorf("pool.breaker_cooldown_max: %w", err) - } - if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil { - return fmt.Errorf("pool.degrade_cooldown: %w", err) - } - if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil { - return fmt.Errorf("pool.degrade_cooldown_max: %w", err) - } - if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil { - return fmt.Errorf("session_sticky.ttl: %w", err) - } - if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil { - return fmt.Errorf("session_sticky.gc_interval: %w", err) - } - // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。 - if c.Pool.ExpiringSoon != "" { - if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil { - return fmt.Errorf("pool.expiring_soon: %w", err) - } - } - if c.ExpiringSoonDur < 0 { - c.ExpiringSoonDur = 0 - c.Pool.ExpiringSoon = "0" - } - // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖 - // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停 - //("−5m" 无合理语义)。 - if c.Pool.CostExploreInterval == "" { - c.Pool.CostExploreInterval = "30m" - } - if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil { - return fmt.Errorf("pool.cost_explore_interval: %w", err) - } - if c.CostExploreIntervalDur < 0 { - c.CostExploreIntervalDur = 0 - } - // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。 - if c.Pool.CreditFloor < 0 { - c.Pool.CreditFloor = 0 - } - if c.Pool.BreakerThreshold <= 0 { - c.Pool.BreakerThreshold = 3 - } - // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。 - if c.Pool.DegradeThreshold <= 0 { - c.Pool.DegradeThreshold = 5 - } - if c.Pool.DegradeCooldown == "" { - c.Pool.DegradeCooldown = "10m" - } - if c.Pool.DegradeCooldownMax == "" { - c.Pool.DegradeCooldownMax = "2h" - } - // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。 - if c.Pool.MaxInFlightGlobal <= 0 { - c.Pool.MaxInFlightGlobal = 2 - } - if c.Pool.IdleWeightPerHour <= 0 { - c.Pool.IdleWeightPerHour = 0.5 - } - if c.Pool.IdleWeightMax <= 0 { - c.Pool.IdleWeightMax = 5.0 - } - if c.Upstream.TimeoutSeconds <= 0 { - c.Upstream.TimeoutSeconds = 120 - } - // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。 - // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。 - if c.Upstream.HeaderTimeoutSeconds <= 0 { - c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds - } - if c.Upstream.IdleTimeoutSeconds <= 0 { - c.Upstream.IdleTimeoutSeconds = 300 - } - if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") { - c.Listen = ":" + c.Listen - } - // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。 - // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。 - if len(c.Schedule.CheckinHours) == 0 { - c.Schedule.CheckinHours = []int{9, 21} - } - if len(c.Schedule.TravelHours) == 0 { - c.Schedule.TravelHours = []int{9, 21} - } - if len(c.Schedule.ActivityHours) == 0 { - c.Schedule.ActivityHours = []int{10} - } - if len(c.Schedule.KeepaliveHours) == 0 { - c.Schedule.KeepaliveHours = []int{22} - } - if len(c.Schedule.BlackcatHours) == 0 { - c.Schedule.BlackcatHours = []int{23} - } - if len(c.Schedule.GrowthHours) == 0 { - c.Schedule.GrowthHours = []int{1} - } - // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。 - if c.Schedule.BalanceRefreshEnabled { - if c.Schedule.BalanceRefreshMinutes <= 0 { - c.Schedule.BalanceRefreshMinutes = 5 - } - c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute - } - if err := c.validateScheduleHours(); err != nil { - return err - } - return c.normalizePrompt() -} - -// normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。 -// -// mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支; -// custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认 -// (两模式共用同一加载路径,PromptText 均非空)。 -// passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。 -func (c *Config) normalizePrompt() error { - switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m { - case "", "passthrough": - c.Prompt.Mode = "passthrough" - case "custom": - c.Prompt.Mode = "custom" - case "append": - c.Prompt.Mode = "append" - default: - return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode) - } - if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" { - text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File) - if err != nil { - return err - } - c.PromptText = text - } - return nil -} - -// validateScheduleHours 校验排程小时落在 0-23。 -// -// 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到, -// 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关 -// (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。 -func (c *Config) validateScheduleHours() error { - if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil { - return err - } - if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil { - return err - } - if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil { - return err - } - if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil { - return err - } - if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil { - return err - } - return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours) -} - -func checkHourRange(field, switchKey string, hours []int) error { - for _, h := range hours { - if h < 0 || h > 23 { - return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey) - } - } - return nil -} +// config.go 加载 JSON 配置 + 环境变量覆盖。 +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" +) + +// Config 顶层配置。 +type Config struct { + Listen string `json:"listen"` // ":7863" + APIKey string `json:"api_key"` // 空 = 不鉴权 + AuthDir string `json:"auth_dir"` // ./auths + StateFile string `json:"state_file"` // ./data/state.json + + Panel struct { + // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。 + PackageDetailLimit int `json:"package_detail_limit"` + } `json:"panel"` + + Logging struct { + // RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。 + RequestArchiveEnabled bool `json:"request_archive_enabled"` + // RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。 + RequestRetentionDays int `json:"request_retention_days"` + // RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。 + RequestArchiveMaxMB int `json:"request_archive_max_mb"` + // RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行 + // 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。 + // + // 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索, + // 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要 + // 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。 + // 热生效(经 livecfg 快照),无需重启。 + RequestClientInfo bool `json:"request_client_info"` + } `json:"logging"` + + Server struct { + // ReadTimeout 入站请求读取(含 body 上传)总时长上限(issue #100)。 + // http.Server 的 ReadTimeout 覆盖整个请求读取:大上下文/文件块请求经 + // 反代链转发时上传可超过旧固定值 60s,被掐后客户端拿到 + // 400 "read body: ... i/o timeout"。缺省 "300s";"0" = 不限制 + //(慢速 body 可无限占用连接,自担风险);改动需重启进程。 + ReadTimeout string `json:"read_timeout"` // "300s";"0" = 不限制 + } `json:"server"` + + Cooldown struct { + // hard_credit / err_threshold / err_cooldown 三个历史键已退役: + // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。 + // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。 + SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数 + // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。 + // 空值回落默认,非法值报错(处理风格同 soft_rate)。 + SoftRateMax string `json:"soft_rate_max"` // "2h" + } `json:"cooldown"` + + Schedule struct { + CheckinHours []int `json:"checkin_hours"` // [9,21] + TravelHours []int `json:"travel_hours"` // [9,21] + ActivityHours []int `json:"activity_hours"` // [10] + KeepaliveHours []int `json:"keepalive_hours"` // [22] + BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数) + GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行) + // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。 + // + // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用": + // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转 + // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true + // 则对老配置零影响,向后完全兼容。 + // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。 + // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。 + // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。 + CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到 + TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行 + ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报 + KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活 + BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子 + GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程 + + // IncludeDisabledInTasks 让「保号类」定时任务(签到 / 活跃上报 / token 保活 / + // 余额刷新)对**已禁用(disabled)**的账号也执行。 + // + // 为什么需要它:面板「禁用」的语义是「不再参与选号」(见面板确认文案),但这四类 + // 任务此前一律 `if st.Disabled { continue }`,等于把「停用流量」放大成「停止一切 + // 上游保号行为」——被禁用的号拿不到签到积分、不续 token、余额也不再刷新;而 + // ReenableIfCredits 明确不复活 disabled 账号(见 pool.state.go),于是签到这条唯一 + // 的自动回血路径也断了,账号只能靠人工「解冻」回来。 + // + // 对「一次只放开一个号、用禁用做流量开关」的轮换用法(同 IP 多号防风控),闲置 + // 待命的号恰恰是最需要签到的那批——本开关即为该用法提供出口。 + // + // 缺省 false = 保持既有行为,对老配置零影响。打开后禁用号仍会签到 / 保活,但 + // **依旧不参与选号**:pool 选号侧的 disabled 过滤不受本开关影响。 + IncludeDisabledInTasks bool `json:"include_disabled_in_tasks"` + + // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。 + // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 + BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 + BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 + } `json:"schedule"` + + Global struct { + // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/ + // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。 + // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global + // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号 + // 恒判 cn,global base 只在 realm=global 的账号上被使用。 + Enabled bool `json:"enabled"` + // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认 + // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。 + ChatBase string `json:"chat_base"` + BillingBase string `json:"billing_base"` + } `json:"global"` + + Upstream struct { + // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 + TimeoutSeconds int `json:"timeout_seconds"` + // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。 + HeaderTimeoutSeconds int `json:"header_timeout_seconds"` + // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。 + IdleTimeoutSeconds int `json:"idle_timeout_seconds"` + // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。 + // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。 + // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。 + UserAgent string `json:"user_agent"` + // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/` 与归属头 + // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。 + ClientVersion string `json:"client_version"` + // CliVersion 出站 UA 中 `CLI/` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。 + CliVersion string `json:"cli_version"` + // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。 + // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。 + ClientName string `json:"client_name"` + // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。 + // 每号 auth 文件的 device_token 键优先于本项。 + DeviceToken string `json:"device_token"` + // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。 + DeviceTokenFile string `json:"device_token_file"` + // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。 + PassthroughIP bool `json:"passthrough_ip"` + } `json:"upstream"` + + Features struct { + // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。 + SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` + } `json:"features"` + + Prompt struct { + // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); + // custom = 网关用自有系统提示词替换客户端 system/developer; + // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。 + Mode string `json:"mode"` // "passthrough" / "custom" / "append" + // File 提示词文件路径;空 = 内置默认 defaultprompt.md; + // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。 + File string `json:"file"` + } `json:"prompt"` + + // PromptText 解析后的系统提示词文本(custom/append 模式使用)。 + PromptText string `json:"-"` + + Upstash struct { + URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host + Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:@:6379 + } `json:"upstash"` + + Pool struct { + MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 + MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 + BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 + BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" + BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" + // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 + // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 + DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 + DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m" + DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制) + IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重 + IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0 + // PreferExpiring 快过期积分加权开关,默认 true。开启且 expiring_soon 窗口内 + // 存在有效批次时,该账号选号权重 ×3(虚拟实例,见 pool 路由加权); + // 不按到期时间排序、与批次金额无关(issue #101 对齐实现口径)。 + // 关闭后完全不使用到期信息选号。 + PreferExpiring bool `json:"prefer_expiring"` + // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在 + // 此窗口内的批次令账号命中上述 ×3 加权;窗口开大 → 命中账号变多、 + // 偏好被稀释。空/0 = 禁用该加权门槛。 + ExpiringSoon string `json:"expiring_soon"` + // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0 + // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切 + // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有 + // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为); + // 空值回落默认。 + CostExploreInterval string `json:"cost_explore_interval"` + // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再 + // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。 + // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 + // 默认 0 = 关闭;负值钳 0。 + CreditFloor int64 `json:"credit_floor"` + } `json:"pool"` + + SessionSticky struct { + Enabled bool `json:"enabled"` // 默认 true + TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m" + GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m" + } `json:"session_sticky"` + + // 解析后 + SoftRateDur time.Duration `json:"-"` + SoftRateMaxDur time.Duration `json:"-"` + BreakerCooldownDur time.Duration `json:"-"` + BreakerCooldownMaxD time.Duration `json:"-"` + DegradeCooldownDur time.Duration `json:"-"` + DegradeCooldownMaxD time.Duration `json:"-"` + SessionTTL time.Duration `json:"-"` + SessionGCInterval time.Duration `json:"-"` + BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) + ExpiringSoonDur time.Duration `json:"-"` + // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 + CostExploreIntervalDur time.Duration `json:"-"` + // ServerReadTimeoutDur 解析后的入站请求读取上限(issue #100);0 = 不限制。 + ServerReadTimeoutDur time.Duration `json:"-"` +} + +// Default 默认配置。 +func Default() *Config { + c := &Config{ + Listen: ":7863", + APIKey: "", + AuthDir: "./auths", + StateFile: "./data/state.json", + } + c.Cooldown.SoftRate = "600s" + c.Cooldown.SoftRateMax = "2h" + c.Server.ReadTimeout = "300s" + c.Panel.PackageDetailLimit = 5 + c.Logging.RequestArchiveEnabled = true + c.Logging.RequestRetentionDays = 7 + c.Logging.RequestArchiveMaxMB = 100 + // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值, + // 只有显式 false 才关闭来源记录。 + c.Logging.RequestClientInfo = true + c.Schedule.CheckinHours = []int{9, 21} + c.Schedule.TravelHours = []int{9, 21} + c.Schedule.ActivityHours = []int{10} + c.Schedule.KeepaliveHours = []int{22} + c.Schedule.BlackcatHours = []int{23} + c.Schedule.GrowthHours = []int{1} + // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖, + // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。 + c.Schedule.CheckinEnabled = true + c.Schedule.GrowthEnabled = true + c.Schedule.TravelEnabled = true + c.Schedule.ActivityEnabled = true + c.Schedule.KeepaliveEnabled = true + c.Schedule.BlackcatEnabled = true + c.Schedule.BalanceRefreshEnabled = true + c.Schedule.BalanceRefreshMinutes = 5 + c.Upstream.TimeoutSeconds = 120 + // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 + c.Upstream.HeaderTimeoutSeconds = 0 + c.Upstream.IdleTimeoutSeconds = 0 + // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用); + // ChatBase/BillingBase 缺省空(回落内置默认)。 + c.Global.Enabled = true + c.Features.SanitizeBlacklistFingerprints = true + c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) + c.Pool.MaxInFlight = 3 + // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 + // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 + // 的 0 没有合理语义,回退分档默认最稳)。 + c.Pool.MaxInFlightGlobal = 2 + c.Pool.BreakerThreshold = 3 + c.Pool.BreakerCooldown = "30m" + c.Pool.BreakerCooldownMax = "6h" + c.Pool.DegradeThreshold = 5 + c.Pool.DegradeCooldown = "10m" + c.Pool.DegradeCooldownMax = "2h" + c.Pool.IdleWeightPerHour = 0.5 + c.Pool.IdleWeightMax = 5.0 + c.Pool.PreferExpiring = true + c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期 + // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。 + c.Pool.CostExploreInterval = "30m" + c.SessionSticky.Enabled = true + c.SessionSticky.TTL = "30m" + c.SessionSticky.GCInterval = "5m" + return c +} + +// Load 从文件读,再用 WB2A_* env 覆盖。 +func Load(path string) (*Config, error) { + c := Default() + if path != "" { + // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录, + // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。 + if st, statErr := os.Stat(path); statErr == nil && st.IsDir() { + return nil, fmt.Errorf("config %s 是目录而非文件——"+ + "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+ + "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + if _, err := ParseConfigInto(raw, c); err != nil { + return nil, err + } + } + applyEnv(c) + if err := c.normalize(); err != nil { + return nil, err + } + return c, nil +} + +// ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。 +// 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。 +func ParseConfigInto(raw []byte, c *Config) (*Config, error) { + if err := json.Unmarshal(raw, c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if err := c.normalize(); err != nil { + return nil, err + } + return c, nil +} + +// ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。 +func ParseConfig(raw []byte) (*Config, error) { + return ParseConfigInto(raw, Default()) +} + +// WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。 +// 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成: +// 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。 +// 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。 +func WriteDefault(path string) (string, error) { + raw := make([]byte, 18) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("gen api_key: %w", err) + } + key := "sk-" + base64.RawURLEncoding.EncodeToString(raw) + c := Default() + c.APIKey = key + _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值 + out, err := json.MarshalIndent(c, "", " ") + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir config dir: %w", err) + } + } + // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。 + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", fmt.Errorf("write config: %w", err) + } + defer f.Close() + if _, err := f.Write(out); err != nil { + return "", fmt.Errorf("write config: %w", err) + } + return key, nil +} + +func applyEnv(c *Config) { + if v := os.Getenv("WB2A_LISTEN"); v != "" { + c.Listen = v + } + if v := os.Getenv("WB2A_API_KEY"); v != "" { + c.APIKey = v + } + if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { + c.AuthDir = v + } + if v := os.Getenv("WB2A_STATE_FILE"); v != "" { + c.StateFile = v + } + if v := os.Getenv("WB2A_SOFT_RATE"); v != "" { + c.Cooldown.SoftRate = v + } + if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" { + c.Cooldown.SoftRateMax = v + } + if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.TimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.HeaderTimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Upstream.IdleTimeoutSeconds = n + } + } + if v := os.Getenv("WB2A_USER_AGENT"); v != "" { + c.Upstream.UserAgent = v + } + if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" { + c.Upstream.ClientVersion = v + } + if v := os.Getenv("WB2A_CLI_VERSION"); v != "" { + c.Upstream.CliVersion = v + } + if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" { + c.Upstream.ClientName = v + } + if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" { + c.Upstream.DeviceToken = v + } + if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" { + c.Upstream.DeviceTokenFile = v + } + if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Upstream.PassthroughIP = b + } + } + if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Features.SanitizeBlacklistFingerprints = b + } + } + if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" { + c.Prompt.Mode = v + } + if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" { + c.Prompt.File = v + } + if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" { + c.Pool.ExpiringSoon = v + } + if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + c.Pool.PreferExpiring = b + } + } +} + +func (c *Config) normalize() error { + var err error + if c.Panel.PackageDetailLimit <= 0 { + c.Panel.PackageDetailLimit = 5 + } + if c.Logging.RequestRetentionDays <= 0 { + c.Logging.RequestRetentionDays = 7 + } + if c.Logging.RequestArchiveMaxMB <= 0 { + c.Logging.RequestArchiveMaxMB = 100 + } + // 入站读取上限(issue #100):空值回落默认 300s;"0" 合法(不限制); + // 负值无语义,fail fast(静默钳 0 会把保护悄悄关掉)。 + if c.Server.ReadTimeout == "" { + c.Server.ReadTimeout = "300s" + } + if c.ServerReadTimeoutDur, err = time.ParseDuration(c.Server.ReadTimeout); err != nil { + return fmt.Errorf("server.read_timeout: %w", err) + } + if c.ServerReadTimeoutDur < 0 { + return fmt.Errorf("server.read_timeout: 负时长 %q 无意义", c.Server.ReadTimeout) + } + if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil { + return fmt.Errorf("cooldown.soft_rate: %w", err) + } + // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。 + if c.Cooldown.SoftRateMax == "" { + c.Cooldown.SoftRateMax = "2h" + } + if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil { + return fmt.Errorf("cooldown.soft_rate_max: %w", err) + } + if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil { + return fmt.Errorf("pool.breaker_cooldown: %w", err) + } + if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil { + return fmt.Errorf("pool.breaker_cooldown_max: %w", err) + } + if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil { + return fmt.Errorf("pool.degrade_cooldown: %w", err) + } + if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil { + return fmt.Errorf("pool.degrade_cooldown_max: %w", err) + } + if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil { + return fmt.Errorf("session_sticky.ttl: %w", err) + } + if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil { + return fmt.Errorf("session_sticky.gc_interval: %w", err) + } + // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。 + if c.Pool.ExpiringSoon != "" { + if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil { + return fmt.Errorf("pool.expiring_soon: %w", err) + } + } + if c.ExpiringSoonDur < 0 { + c.ExpiringSoonDur = 0 + c.Pool.ExpiringSoon = "0" + } + // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖 + // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停 + //("−5m" 无合理语义)。 + if c.Pool.CostExploreInterval == "" { + c.Pool.CostExploreInterval = "30m" + } + if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil { + return fmt.Errorf("pool.cost_explore_interval: %w", err) + } + if c.CostExploreIntervalDur < 0 { + c.CostExploreIntervalDur = 0 + } + // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。 + if c.Pool.CreditFloor < 0 { + c.Pool.CreditFloor = 0 + } + if c.Pool.BreakerThreshold <= 0 { + c.Pool.BreakerThreshold = 3 + } + // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。 + if c.Pool.DegradeThreshold <= 0 { + c.Pool.DegradeThreshold = 5 + } + if c.Pool.DegradeCooldown == "" { + c.Pool.DegradeCooldown = "10m" + } + if c.Pool.DegradeCooldownMax == "" { + c.Pool.DegradeCooldownMax = "2h" + } + // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。 + if c.Pool.MaxInFlightGlobal <= 0 { + c.Pool.MaxInFlightGlobal = 2 + } + if c.Pool.IdleWeightPerHour <= 0 { + c.Pool.IdleWeightPerHour = 0.5 + } + if c.Pool.IdleWeightMax <= 0 { + c.Pool.IdleWeightMax = 5.0 + } + if c.Upstream.TimeoutSeconds <= 0 { + c.Upstream.TimeoutSeconds = 120 + } + // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。 + // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。 + if c.Upstream.HeaderTimeoutSeconds <= 0 { + c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds + } + if c.Upstream.IdleTimeoutSeconds <= 0 { + c.Upstream.IdleTimeoutSeconds = 300 + } + if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") { + c.Listen = ":" + c.Listen + } + // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。 + // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。 + if len(c.Schedule.CheckinHours) == 0 { + c.Schedule.CheckinHours = []int{9, 21} + } + if len(c.Schedule.TravelHours) == 0 { + c.Schedule.TravelHours = []int{9, 21} + } + if len(c.Schedule.ActivityHours) == 0 { + c.Schedule.ActivityHours = []int{10} + } + if len(c.Schedule.KeepaliveHours) == 0 { + c.Schedule.KeepaliveHours = []int{22} + } + if len(c.Schedule.BlackcatHours) == 0 { + c.Schedule.BlackcatHours = []int{23} + } + if len(c.Schedule.GrowthHours) == 0 { + c.Schedule.GrowthHours = []int{1} + } + // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。 + if c.Schedule.BalanceRefreshEnabled { + if c.Schedule.BalanceRefreshMinutes <= 0 { + c.Schedule.BalanceRefreshMinutes = 5 + } + c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute + } + if err := c.validateScheduleHours(); err != nil { + return err + } + return c.normalizePrompt() +} + +// normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。 +// +// mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支; +// custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认 +// (两模式共用同一加载路径,PromptText 均非空)。 +// passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。 +func (c *Config) normalizePrompt() error { + switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m { + case "", "passthrough": + c.Prompt.Mode = "passthrough" + case "custom": + c.Prompt.Mode = "custom" + case "append": + c.Prompt.Mode = "append" + default: + return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode) + } + if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" { + text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File) + if err != nil { + return err + } + c.PromptText = text + } + return nil +} + +// validateScheduleHours 校验排程小时落在 0-23。 +// +// 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到, +// 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关 +// (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。 +func (c *Config) validateScheduleHours() error { + if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil { + return err + } + if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil { + return err + } + if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil { + return err + } + if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil { + return err + } + if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil { + return err + } + return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours) +} + +func checkHourRange(field, switchKey string, hours []int) error { + for _, h := range hours { + if h < 0 || h > 23 { + return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey) + } + } + return nil +} diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index 4c1dd61f1bde6d29f4ed72deab97bcd5c8bf409a..2a99931b4f68f767155c3834376c128dbb50e1e1 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -1,816 +1,861 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestDefault(t *testing.T) { - c := Default() - if c.Listen != ":7863" { - t.Errorf("listen=%s", c.Listen) - } - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if c.SoftRateDur.Seconds() != 600 { - t.Errorf("soft=%v want 600s", c.SoftRateDur) - } -} - -func TestPanelPackageDetailLimit(t *testing.T) { - c := Default() - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if c.Panel.PackageDetailLimit != 5 { - t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit) - } - - configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`)) - if err != nil { - t.Fatalf("parse configured limit: %v", err) - } - if configured.Panel.PackageDetailLimit != 8 { - t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit) - } - - fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`)) - if err != nil { - t.Fatalf("parse fallback limit: %v", err) - } - if fallback.Panel.PackageDetailLimit != 5 { - t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit) - } -} - -func TestLoggingDefaults(t *testing.T) { - c := Default() - if err := c.normalize(); err != nil { - t.Fatal(err) - } - if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 { - t.Fatalf("logging defaults = %+v", c.Logging) - } - // 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。 - if !c.Logging.RequestClientInfo { - t.Fatalf("request_client_info default = false, want true: %+v", c.Logging) - } - configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`)) - if err != nil { - t.Fatal(err) - } - if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 { - t.Fatalf("configured logging = %+v", configured.Logging) - } - off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`)) - if err != nil { - t.Fatal(err) - } - if off.Logging.RequestClientInfo { - t.Fatalf("explicit false ignored: %+v", off.Logging) - } - fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`)) - if err != nil { - t.Fatal(err) - } - if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 { - t.Fatalf("logging fallback = %+v", fallback.Logging) - } -} - -func TestLoadFile(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Listen != ":9999" || c.APIKey != "k" { - t.Errorf("c=%+v", c) - } -} - -func TestEnvOverride(t *testing.T) { - t.Setenv("WB2A_LISTEN", ":7777") - t.Setenv("WB2A_API_KEY", "envkey") - c, err := Load("") - if err != nil { - t.Fatal(err) - } - if c.Listen != ":7777" || c.APIKey != "envkey" { - t.Errorf("c=%+v", c) - } -} - -func TestBadDuration(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for bad duration") - } -} - -func TestHardCreditKeyIgnored(t *testing.T) { - // 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。 - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatalf("hard_credit must be ignored (not validated): %v", err) - } - if c.SoftRateDur.Seconds() != 30 { - t.Errorf("soft_rate=%v want 30s", c.SoftRateDur) - } -} - -func TestNewPoolConfigDefaults(t *testing.T) { - c := Default() - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if c.Pool.MaxInFlight != 3 { - t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight) - } - if c.Pool.BreakerThreshold != 3 { - t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold) - } - if c.BreakerCooldownDur.Minutes() != 30 { - t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur) - } - if c.BreakerCooldownMaxD.Hours() != 6 { - t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD) - } - if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 { - t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) - } - if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour { - t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) - } - if c.SoftRateMaxDur.Hours() != 2 { - t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur) - } - if !c.SessionSticky.Enabled { - t.Error("session_sticky.enabled want true") - } - if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 { - t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) - } - if c.Upstash.URL != "" || c.Upstash.Token != "" { - t.Errorf("upstash default should be empty: %+v", c.Upstash) - } -} - -func TestPoolConfigParsedFromFile(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{ - "upstash":{"url":"https://foo.upstash.io","token":"tok"}, - "pool":{ - "max_in_flight":5, - "breaker_threshold":4, - "breaker_cooldown":"10m", - "breaker_cooldown_max":"2h", - "idle_weight_per_hour":0.7, - "idle_weight_max":8.0, - "prefer_expiring":false, - "expiring_soon":"72h" - }, - "session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"} - }`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" { - t.Errorf("upstash=%+v", c.Upstash) - } - if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 { - t.Errorf("pool=%+v", c.Pool) - } - if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 { - t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD) - } - if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 { - t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) - } - if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour { - t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) - } - if c.SessionSticky.Enabled { - t.Error("session_sticky.enabled want false from file") - } - if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 { - t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) - } -} - -func TestSoftRateMaxParsedFromFile(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.SoftRateDur.Minutes() != 5 { - t.Errorf("soft_rate=%v want 5m", c.SoftRateDur) - } - if c.SoftRateMaxDur.Minutes() != 45 { - t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur) - } -} - -func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if !c.Pool.PreferExpiring { - t.Fatal("missing prefer_expiring must preserve default true") - } -} - -func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" { - t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon) - } -} - -func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) { - // 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。 - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.SoftRateMaxDur.Hours() != 2 { - t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur) - } -} - -func TestBadSoftRateMax(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for bad soft_rate_max") - } -} - -func TestBadBreakerCooldown(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for bad breaker_cooldown") - } -} - -func TestUpstreamTimeoutDefaults(t *testing.T) { - // 默认:header 回落 timeout,idle 回落 300。 - c := Default() - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if c.Upstream.TimeoutSeconds != 120 { - t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds) - } - if c.Upstream.HeaderTimeoutSeconds != 120 { - t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds) - } - if c.Upstream.IdleTimeoutSeconds != 300 { - t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) - } -} - -func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) { - // 只设 timeout_seconds:header 回落同值,idle 回落 300。 - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Upstream.HeaderTimeoutSeconds != 60 { - t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds) - } - if c.Upstream.IdleTimeoutSeconds != 300 { - t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) - } -} - -func TestUpstreamExplicitHeaderIdle(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Upstream.HeaderTimeoutSeconds != 30 { - t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds) - } - if c.Upstream.IdleTimeoutSeconds != 600 { - t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds) - } -} - -func TestUpstreamEnvOverride(t *testing.T) { - t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45") - t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900") - c, err := Load("") - if err != nil { - t.Fatal(err) - } - if c.Upstream.HeaderTimeoutSeconds != 45 { - t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds) - } - if c.Upstream.IdleTimeoutSeconds != 900 { - t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds) - } -} - -// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。 -func TestRetiredTravelIntervalKeyIgnored(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatalf("retired key should not fail load: %v", err) - } - if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 { - t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours) - } -} - -// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true: -// 老 config 不写这些键,行为必须与从前完全一致。 -func TestScheduleEnabledByDefault(t *testing.T) { - c := Default() - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { - t.Errorf("enabled defaults want true/true, got %v/%v", - c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled) - } - if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { - t.Errorf("travel/activity enabled defaults want true/true, got %v/%v", - c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled) - } - if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { - t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours) - } - if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { - t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours) - } -} - -// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态, -// 新开关缺省 true、新 hours 回落默认——对老配置零影响。 -func TestScheduleLegacyConfigKeepsRunning(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { - t.Errorf("legacy config must stay enabled: %+v", c.Schedule) - } - if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { - t.Errorf("new switches must default true on legacy config: %+v", c.Schedule) - } - if len(c.Schedule.CheckinHours) != 2 { - t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours) - } - // 新 hours 缺省 → 回落默认(非空)。 - if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { - t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours) - } - if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { - t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) - } -} - -// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到 -// (issue #27 边界:此前无论怎么配小时都关不掉)。 -func TestScheduleExplicitDisable(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled { - t.Errorf("want both disabled: %+v", c.Schedule) - } - // 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。 - if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { - t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours) - } - if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { - t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours) - } -} - -// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。 -func TestScheduleTravelActivityExplicitDisable(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled { - t.Errorf("want travel/activity disabled: %+v", c.Schedule) - } - // 签到/保活开关缺省 true(互不干扰)。 - if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { - t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule) - } - // hours 仍回落默认。 - if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { - t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours) - } - if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { - t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours) - } -} - -// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。 -func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) { - cases := []struct{ body, wantSwitch string }{ - {`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"}, - {`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"}, - {`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"}, - {`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"}, - } - for _, tc := range cases { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(tc.body), 0o600) - _, err := Load(fp) - if err == nil { - t.Fatalf("want error for %s", tc.body) - } - if !strings.Contains(err.Error(), tc.wantSwitch) { - t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) - } - } -} - -// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。 -func TestScheduleTravelActivityExplicitHours(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { - t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours) - } - if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 { - t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours) - } -} - -// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。 -func TestScheduleDisableKeepsExplicitHours(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Schedule.CheckinEnabled { - t.Error("checkin should be disabled") - } - if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 { - t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours) - } -} - -// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。 -func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) { - cases := map[string]string{ - "absent": `{}`, - "empty": `{"schedule":{}}`, - "null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`, - "emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`, - } - for name, body := range cases { - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(body), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { - t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours) - } - if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { - t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours) - } - if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { - t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours) - } - if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { - t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) - } - if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { - t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) - } - if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { - t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) - } - }) - } -} - -// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户 -// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。 -func TestScheduleInvalidHourRejected(t *testing.T) { - cases := []struct{ body, wantSwitch string }{ - {`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"}, - {`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"}, - {`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"}, - } - for _, tc := range cases { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(tc.body), 0o600) - _, err := Load(fp) - if err == nil { - t.Fatalf("want error for %s", tc.body) - } - if !strings.Contains(err.Error(), tc.wantSwitch) { - t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) - } - } -} - -func TestBadSessionTTL(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for bad session_sticky.ttl") - } -} - -func TestWriteDefault(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建 - key, err := WriteDefault(fp) - if err != nil { - t.Fatal(err) - } - // key 形如 sk-<24字符随机串>,两次生成不重复 - if !strings.HasPrefix(key, "sk-") || len(key) < 20 { - t.Errorf("key=%q want sk-", key) - } - if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key { - t.Errorf("two generated keys identical: %q", key) - } - // 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效 - c, err := Load(fp) - if err != nil { - t.Fatalf("load generated config: %v", err) - } - if c.APIKey != key { - t.Errorf("api_key=%q want %q", c.APIKey, key) - } - if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" { - t.Errorf("generated defaults off: %+v", c) - } - if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled { - t.Errorf("generated schedule off: %+v", c.Schedule) - } - // 已存在的文件不覆盖:二次写入同一路径必须报错 - if _, err := WriteDefault(fp); err == nil { - t.Error("WriteDefault must refuse to overwrite existing file") - } -} - -func TestBalanceRefreshDefaults(t *testing.T) { - // 缺省:启用 + 30 分钟 - c := Default() - if err := c.normalize(); err != nil { - t.Fatal(err) - } - if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute { - t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval) - } - // 显式配置 10 分钟 - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600) - c2, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c2.BalanceRefreshInterval != 10*time.Minute { - t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval) - } - // 显式关闭:interval 归零(不启动) - os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600) - c3, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c3.BalanceRefreshInterval != 0 { - t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval) - } - // 启用但 minutes<=0 → 回落默认 30 - os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600) - c4, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c4.BalanceRefreshInterval != 5*time.Minute { - t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval) - } -} - -// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端 -// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。 -func TestPromptDefaultPassthrough(t *testing.T) { - c, err := Load("") - if err != nil { - t.Fatal(err) - } - if c.Prompt.Mode != "passthrough" { - t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode) - } - // passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。 -} - -// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。 -func TestPromptExplicitPassthrough(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Prompt.Mode != "passthrough" { - t.Errorf("mode=%q want passthrough", c.Prompt.Mode) - } - if c.PromptText != "" { - t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText)) - } -} - -// TestPromptInvalidMode 非法 mode 启动报错。 -func TestPromptInvalidMode(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for invalid prompt.mode") - } -} - -// TestPromptFileMissing 文件路径非空但不存在 → 启动报错(fail fast)。 -func TestPromptFileMissing(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600) - if _, err := Load(fp); err == nil { - t.Fatal("want error for missing prompt file") - } -} - -// TestPromptFileOverride 自定义 file 覆盖内置默认。 -func TestPromptFileOverride(t *testing.T) { - dir := t.TempDir() - pf := filepath.Join(dir, "my.md") - want := "我的自定义人格入口" - os.WriteFile(pf, []byte(want), 0o600) - cf := filepath.Join(dir, "c.json") - // 用 json.Marshal 拼路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。 - cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}}) - if err != nil { - t.Fatal(err) - } - os.WriteFile(cf, cfgJSON, 0o600) - c, err := Load(cf) - if err != nil { - t.Fatal(err) - } - if c.PromptText != want { - t.Errorf("PromptText=%q want %q", c.PromptText, want) - } -} - -// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。 -func TestPromptEnvOverride(t *testing.T) { - t.Setenv("WB2A_PROMPT_MODE", "passthrough") - c, err := Load("") - if err != nil { - t.Fatal(err) - } - if c.Prompt.Mode != "passthrough" { - t.Errorf("mode=%q want passthrough", c.Prompt.Mode) - } -} - -// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。 -func TestPromptLegacyConfigNoImpact(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Prompt.Mode != "passthrough" { - t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode) - } - if c.Listen != ":9999" { - t.Errorf("listen=%q", c.Listen) - } -} - -// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效, -// 缺省空串保持现状(headers 层回落到 clientUA)。 -func TestUpstreamUserAgentConfig(t *testing.T) { - // JSON 配置 - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Upstream.UserAgent != "WorkBuddy/1.2.3" { - t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent) - } - // 缺省为空 - if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" { - t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err) - } - // env 覆盖 - t.Setenv("WB2A_USER_AGENT", "EnvAgent/9") - c3, err := Load("") - if err != nil { - t.Fatal(err) - } - if c3.Upstream.UserAgent != "EnvAgent/9" { - t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent) - } -} - -// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。 -// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。 -// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。 -func TestLoadConfigPathIsDirectory(t *testing.T) { - dir := t.TempDir() - asDir := filepath.Join(dir, "config.json") - if err := os.Mkdir(asDir, 0o755); err != nil { - t.Fatal(err) - } - _, err := Load(asDir) - if err == nil { - t.Fatal("want error when config path is a directory") - } - msg := err.Error() - if !strings.Contains(msg, "是目录") { - t.Errorf("error should explain it is a directory: %v", err) - } - if !strings.Contains(msg, "config.example.json") { - t.Errorf("error should suggest the fix (cp config.example.json): %v", err) - } -} +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDefault(t *testing.T) { + c := Default() + if c.Listen != ":7863" { + t.Errorf("listen=%s", c.Listen) + } + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.SoftRateDur.Seconds() != 600 { + t.Errorf("soft=%v want 600s", c.SoftRateDur) + } +} + +func TestPanelPackageDetailLimit(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Panel.PackageDetailLimit != 5 { + t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit) + } + + configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`)) + if err != nil { + t.Fatalf("parse configured limit: %v", err) + } + if configured.Panel.PackageDetailLimit != 8 { + t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit) + } + + fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`)) + if err != nil { + t.Fatalf("parse fallback limit: %v", err) + } + if fallback.Panel.PackageDetailLimit != 5 { + t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit) + } +} + +func TestLoggingDefaults(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatal(err) + } + if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 { + t.Fatalf("logging defaults = %+v", c.Logging) + } + // 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。 + if !c.Logging.RequestClientInfo { + t.Fatalf("request_client_info default = false, want true: %+v", c.Logging) + } + configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`)) + if err != nil { + t.Fatal(err) + } + if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 { + t.Fatalf("configured logging = %+v", configured.Logging) + } + off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`)) + if err != nil { + t.Fatal(err) + } + if off.Logging.RequestClientInfo { + t.Fatalf("explicit false ignored: %+v", off.Logging) + } + fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`)) + if err != nil { + t.Fatal(err) + } + if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 { + t.Fatalf("logging fallback = %+v", fallback.Logging) + } +} + +func TestLoadFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Listen != ":9999" || c.APIKey != "k" { + t.Errorf("c=%+v", c) + } +} + +func TestEnvOverride(t *testing.T) { + t.Setenv("WB2A_LISTEN", ":7777") + t.Setenv("WB2A_API_KEY", "envkey") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Listen != ":7777" || c.APIKey != "envkey" { + t.Errorf("c=%+v", c) + } +} + +func TestBadDuration(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad duration") + } +} + +func TestHardCreditKeyIgnored(t *testing.T) { + // 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatalf("hard_credit must be ignored (not validated): %v", err) + } + if c.SoftRateDur.Seconds() != 30 { + t.Errorf("soft_rate=%v want 30s", c.SoftRateDur) + } +} + +func TestNewPoolConfigDefaults(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.MaxInFlight != 3 { + t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight) + } + if c.Pool.BreakerThreshold != 3 { + t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold) + } + if c.BreakerCooldownDur.Minutes() != 30 { + t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur) + } + if c.BreakerCooldownMaxD.Hours() != 6 { + t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD) + } + if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 { + t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) + } + if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour { + t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) + } + if c.SoftRateMaxDur.Hours() != 2 { + t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur) + } + if !c.SessionSticky.Enabled { + t.Error("session_sticky.enabled want true") + } + if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 { + t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) + } + if c.Upstash.URL != "" || c.Upstash.Token != "" { + t.Errorf("upstash default should be empty: %+v", c.Upstash) + } +} + +func TestPoolConfigParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{ + "upstash":{"url":"https://foo.upstash.io","token":"tok"}, + "pool":{ + "max_in_flight":5, + "breaker_threshold":4, + "breaker_cooldown":"10m", + "breaker_cooldown_max":"2h", + "idle_weight_per_hour":0.7, + "idle_weight_max":8.0, + "prefer_expiring":false, + "expiring_soon":"72h" + }, + "session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"} + }`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" { + t.Errorf("upstash=%+v", c.Upstash) + } + if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 { + t.Errorf("pool=%+v", c.Pool) + } + if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 { + t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD) + } + if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 { + t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax) + } + if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour { + t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur) + } + if c.SessionSticky.Enabled { + t.Error("session_sticky.enabled want false from file") + } + if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 { + t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval) + } +} + +func TestSoftRateMaxParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.SoftRateDur.Minutes() != 5 { + t.Errorf("soft_rate=%v want 5m", c.SoftRateDur) + } + if c.SoftRateMaxDur.Minutes() != 45 { + t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur) + } +} + +func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Pool.PreferExpiring { + t.Fatal("missing prefer_expiring must preserve default true") + } +} + +func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" { + t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon) + } +} + +func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) { + // 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.SoftRateMaxDur.Hours() != 2 { + t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur) + } +} + +func TestBadSoftRateMax(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad soft_rate_max") + } +} + +func TestBadBreakerCooldown(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad breaker_cooldown") + } +} + +func TestUpstreamTimeoutDefaults(t *testing.T) { + // 默认:header 回落 timeout,idle 回落 300。 + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Upstream.TimeoutSeconds != 120 { + t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds) + } + if c.Upstream.HeaderTimeoutSeconds != 120 { + t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 300 { + t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) { + // 只设 timeout_seconds:header 回落同值,idle 回落 300。 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 60 { + t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 300 { + t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamExplicitHeaderIdle(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 30 { + t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 600 { + t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds) + } +} + +func TestUpstreamEnvOverride(t *testing.T) { + t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45") + t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Upstream.HeaderTimeoutSeconds != 45 { + t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds) + } + if c.Upstream.IdleTimeoutSeconds != 900 { + t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds) + } +} + +// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。 +func TestRetiredTravelIntervalKeyIgnored(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatalf("retired key should not fail load: %v", err) + } + if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 { + t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours) + } +} + +// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true: +// 老 config 不写这些键,行为必须与从前完全一致。 +func TestScheduleEnabledByDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("enabled defaults want true/true, got %v/%v", + c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("travel/activity enabled defaults want true/true, got %v/%v", + c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours) + } +} + +// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态, +// 新开关缺省 true、新 hours 回落默认——对老配置零影响。 +func TestScheduleLegacyConfigKeepsRunning(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("legacy config must stay enabled: %+v", c.Schedule) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("new switches must default true on legacy config: %+v", c.Schedule) + } + if len(c.Schedule.CheckinHours) != 2 { + t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours) + } + // 新 hours 缺省 → 回落默认(非空)。 + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) + } +} + +// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到 +// (issue #27 边界:此前无论怎么配小时都关不掉)。 +func TestScheduleExplicitDisable(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled { + t.Errorf("want both disabled: %+v", c.Schedule) + } + // 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。 + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { + t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours) + } + if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { + t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours) + } +} + +// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。 +func TestScheduleTravelActivityExplicitDisable(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled { + t.Errorf("want travel/activity disabled: %+v", c.Schedule) + } + // 签到/保活开关缺省 true(互不干扰)。 + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule) + } + // hours 仍回落默认。 + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours) + } +} + +// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。 +func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) { + cases := []struct{ body, wantSwitch string }{ + {`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"}, + {`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"}, + {`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"}, + {`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"}, + } + for _, tc := range cases { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(tc.body), 0o600) + _, err := Load(fp) + if err == nil { + t.Fatalf("want error for %s", tc.body) + } + if !strings.Contains(err.Error(), tc.wantSwitch) { + t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) + } + } +} + +// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。 +func TestScheduleTravelActivityExplicitHours(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 { + t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours) + } +} + +// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。 +func TestScheduleDisableKeepsExplicitHours(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Schedule.CheckinEnabled { + t.Error("checkin should be disabled") + } + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 { + t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours) + } +} + +// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。 +func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) { + cases := map[string]string{ + "absent": `{}`, + "empty": `{"schedule":{}}`, + "null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`, + "emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(body), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 { + t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours) + } + if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 { + t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours) + } + if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 { + t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours) + } + if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 { + t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours) + } + if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled { + t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) + } + if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled { + t.Errorf("empty hours must not imply disabled: %+v", c.Schedule) + } + }) + } +} + +// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户 +// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。 +func TestScheduleInvalidHourRejected(t *testing.T) { + cases := []struct{ body, wantSwitch string }{ + {`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"}, + {`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"}, + {`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"}, + } + for _, tc := range cases { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(tc.body), 0o600) + _, err := Load(fp) + if err == nil { + t.Fatalf("want error for %s", tc.body) + } + if !strings.Contains(err.Error(), tc.wantSwitch) { + t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err) + } + } +} + +func TestBadSessionTTL(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for bad session_sticky.ttl") + } +} + +func TestWriteDefault(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建 + key, err := WriteDefault(fp) + if err != nil { + t.Fatal(err) + } + // key 形如 sk-<24字符随机串>,两次生成不重复 + if !strings.HasPrefix(key, "sk-") || len(key) < 20 { + t.Errorf("key=%q want sk-", key) + } + if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key { + t.Errorf("two generated keys identical: %q", key) + } + // 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效 + c, err := Load(fp) + if err != nil { + t.Fatalf("load generated config: %v", err) + } + if c.APIKey != key { + t.Errorf("api_key=%q want %q", c.APIKey, key) + } + if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" { + t.Errorf("generated defaults off: %+v", c) + } + if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled { + t.Errorf("generated schedule off: %+v", c.Schedule) + } + // 已存在的文件不覆盖:二次写入同一路径必须报错 + if _, err := WriteDefault(fp); err == nil { + t.Error("WriteDefault must refuse to overwrite existing file") + } +} + +func TestBalanceRefreshDefaults(t *testing.T) { + // 缺省:启用 + 30 分钟 + c := Default() + if err := c.normalize(); err != nil { + t.Fatal(err) + } + if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute { + t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval) + } + // 显式配置 10 分钟 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600) + c2, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c2.BalanceRefreshInterval != 10*time.Minute { + t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval) + } + // 显式关闭:interval 归零(不启动) + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600) + c3, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c3.BalanceRefreshInterval != 0 { + t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval) + } + // 启用但 minutes<=0 → 回落默认 30 + os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600) + c4, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c4.BalanceRefreshInterval != 5*time.Minute { + t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval) + } +} + +// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端 +// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。 +func TestPromptDefaultPassthrough(t *testing.T) { + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode) + } + // passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。 +} + +// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。 +func TestPromptExplicitPassthrough(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("mode=%q want passthrough", c.Prompt.Mode) + } + if c.PromptText != "" { + t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText)) + } +} + +// TestPromptInvalidMode 非法 mode 启动报错。 +func TestPromptInvalidMode(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for invalid prompt.mode") + } +} + +// TestPromptFileMissing 文件路径非空但不存在 → 启动报错(fail fast)。 +func TestPromptFileMissing(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600) + if _, err := Load(fp); err == nil { + t.Fatal("want error for missing prompt file") + } +} + +// TestPromptFileOverride 自定义 file 覆盖内置默认。 +func TestPromptFileOverride(t *testing.T) { + dir := t.TempDir() + pf := filepath.Join(dir, "my.md") + want := "我的自定义人格入口" + os.WriteFile(pf, []byte(want), 0o600) + cf := filepath.Join(dir, "c.json") + // 用 json.Marshal 拼路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。 + cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}}) + if err != nil { + t.Fatal(err) + } + os.WriteFile(cf, cfgJSON, 0o600) + c, err := Load(cf) + if err != nil { + t.Fatal(err) + } + if c.PromptText != want { + t.Errorf("PromptText=%q want %q", c.PromptText, want) + } +} + +// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。 +func TestPromptEnvOverride(t *testing.T) { + t.Setenv("WB2A_PROMPT_MODE", "passthrough") + c, err := Load("") + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("mode=%q want passthrough", c.Prompt.Mode) + } +} + +// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。 +func TestPromptLegacyConfigNoImpact(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Prompt.Mode != "passthrough" { + t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode) + } + if c.Listen != ":9999" { + t.Errorf("listen=%q", c.Listen) + } +} + +// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效, +// 缺省空串保持现状(headers 层回落到 clientUA)。 +func TestUpstreamUserAgentConfig(t *testing.T) { + // JSON 配置 + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Upstream.UserAgent != "WorkBuddy/1.2.3" { + t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent) + } + // 缺省为空 + if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" { + t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err) + } + // env 覆盖 + t.Setenv("WB2A_USER_AGENT", "EnvAgent/9") + c3, err := Load("") + if err != nil { + t.Fatal(err) + } + if c3.Upstream.UserAgent != "EnvAgent/9" { + t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent) + } +} + +// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。 +// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。 +// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。 +func TestLoadConfigPathIsDirectory(t *testing.T) { + dir := t.TempDir() + asDir := filepath.Join(dir, "config.json") + if err := os.Mkdir(asDir, 0o755); err != nil { + t.Fatal(err) + } + _, err := Load(asDir) + if err == nil { + t.Fatal("want error when config path is a directory") + } + msg := err.Error() + if !strings.Contains(msg, "是目录") { + t.Errorf("error should explain it is a directory: %v", err) + } + if !strings.Contains(msg, "config.example.json") { + t.Errorf("error should suggest the fix (cp config.example.json): %v", err) + } +} + +// TestServerReadTimeout 入站读取上限(issue #100):空值回落默认 300s; +// "0" = 显式不限制(0 是合法值不回落);负值 fail fast(静默钳 0 会把保护悄悄关掉)。 +func TestServerReadTimeout(t *testing.T) { + c := Default() + if c.Server.ReadTimeout != "300s" { + t.Errorf("default read_timeout=%q want 300s", c.Server.ReadTimeout) + } + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.ServerReadTimeoutDur != 300*time.Second { + t.Errorf("default dur=%v want 300s", c.ServerReadTimeoutDur) + } + + c = Default() + c.Server.ReadTimeout = "" // 显式清空 = 未配置 → 回落默认 + if err := c.normalize(); err != nil { + t.Fatalf("normalize empty: %v", err) + } + if c.ServerReadTimeoutDur != 300*time.Second { + t.Errorf("empty dur=%v want 300s", c.ServerReadTimeoutDur) + } + + c = Default() + c.Server.ReadTimeout = "0" // 显式 0 = 不限制(http.Server ReadTimeout 0 即无超时) + if err := c.normalize(); err != nil { + t.Fatalf("normalize zero: %v", err) + } + if c.ServerReadTimeoutDur != 0 { + t.Errorf("zero dur=%v want 0", c.ServerReadTimeoutDur) + } + + c = Default() + c.Server.ReadTimeout = "-5s" + if err := c.normalize(); err == nil { + t.Error("negative read_timeout should fail fast") + } + + c = Default() + c.Server.ReadTimeout = "bogus" + if err := c.normalize(); err == nil { + t.Error("unparsable read_timeout should fail fast") + } +} diff --git a/cmd/server/credit_floor_config_test.go b/cmd/server/credit_floor_config_test.go index b6ad53d64e66b9a42d91a326ffaf2d96c72e80fd..087ed94cc282a76a812055efe3e390c0e147d5f1 100644 --- a/cmd/server/credit_floor_config_test.go +++ b/cmd/server/credit_floor_config_test.go @@ -1,48 +1,48 @@ -// credit_floor_config_test.go pool.credit_floor 配置测试: -// 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。 -package main - -import ( - "os" - "path/filepath" - "testing" -) - -// TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。 -func TestCreditFloorDefault(t *testing.T) { - c := Default() - if err := c.normalize(); err != nil { - t.Fatalf("normalize: %v", err) - } - if c.Pool.CreditFloor != 0 { - t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor) - } -} - -// TestCreditFloorParsedFromFile 显式配置覆盖默认。 -func TestCreditFloorParsedFromFile(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Pool.CreditFloor != 100 { - t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor) - } -} - -// TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。 -func TestCreditFloorNegativeClamped(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "c.json") - os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600) - c, err := Load(fp) - if err != nil { - t.Fatal(err) - } - if c.Pool.CreditFloor != 0 { - t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor) - } -} +// credit_floor_config_test.go pool.credit_floor 配置测试: +// 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。 +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。 +func TestCreditFloorDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.CreditFloor != 0 { + t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor) + } +} + +// TestCreditFloorParsedFromFile 显式配置覆盖默认。 +func TestCreditFloorParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Pool.CreditFloor != 100 { + t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor) + } +} + +// TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。 +func TestCreditFloorNegativeClamped(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600) + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if c.Pool.CreditFloor != 0 { + t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 724775443ad822d3dd98653a4754f1ae42b49e58..e9c4e0be5524f84630f2472ebc3745aef148dda3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,550 +1,563 @@ -// main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。 -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "io/fs" - "log" - "net/http" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" - "github.com/linguo2625469/workbuddy2api-panel/internal/panel" - "github.com/linguo2625469/workbuddy2api-panel/internal/pool" - "github.com/linguo2625469/workbuddy2api-panel/internal/redisstore" - "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" - "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" - "github.com/linguo2625469/workbuddy2api-panel/internal/server" - "github.com/linguo2625469/workbuddy2api-panel/internal/session" - "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" - "github.com/linguo2625469/workbuddy2api-panel/internal/usage" -) - -// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。 -const appVersion = "1.11.11-panel" - -// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。 -// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。 -func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") } - -// stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。 -// usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。 -func stateSibling(stateFile, name string) string { - dir := filepath.Dir(stateFile) - if dir == "" || dir == "." { - return name - } - return filepath.Join(dir, name) -} - -func main() { - cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)") - flag.Parse() - - cfg, err := Load(*cfgPath) - if err != nil { - // errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。 - if errors.Is(err, fs.ErrNotExist) { - // 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。 - // 双击 exe / 裸跑 docker 即开,无需先手工复制样例。 - if key, werr := WriteDefault(*cfgPath); werr == nil { - log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key) - cfg, err = Load(*cfgPath) - } - if err != nil { - // 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。 - log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err) - cfg, err = Load("") - } - } - if err != nil { - log.Fatalf("load config: %v", err) - } - } - - auths, err := auth.LoadDir(cfg.AuthDir) - if err != nil { - log.Fatalf("load auths: %v", err) - } - log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir) - - // redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。 - store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token) - - p := pool.New(cfg.StateFile) - // 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store), - // 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。 - defer func() { - p.Close() - _ = store.Close() - }() - p.SetStore(store) - p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先 - p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留) - - // 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入, - // 非正值回退默认)。 - p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD) - p.SetMaxInFlight(cfg.Pool.MaxInFlight) - p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1) - p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD) - p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h) - p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停) - p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭) - p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) - p.SetPreferExpiring(cfg.Pool.PreferExpiring) - - // 会话粘性路由(可配关闭)。 - var sessRouter *session.Router - redisMode := "noop" - if _, ok := store.(redisstore.Noop); !ok { - redisMode = "upstash" - } - if cfg.SessionSticky.Enabled { - sessRouter = session.New(session.Config{ - TTL: cfg.SessionTTL, - GCInterval: cfg.SessionGCInterval, - Store: store, - Available: p.AvailableUIDs, - // realm 感知闭包:带前缀模型名按 realm 过滤可用账号(跨 realm 不泄漏); - // 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。 - AvailableForModel: realmAwareAvailableForModel(p), - }) - sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处) - sessRouter.StartGC() - defer sessRouter.StopGC() - } - sessCount := func() int { - if sessRouter != nil { - return sessRouter.Count() - } - return 0 - } - - up := upstream.New() - - // 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测 - // 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿 - // (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。 - // 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。 - p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) }) - - // 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。 - up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second - // 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。 - up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second - if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok { - tr.ResponseHeaderTimeout = up.HeaderTimeout - } - // 聊天 SSE 流中空闲上限(S3 空闲监控读取)。 - up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second - up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints) - // 出站 UA 与归属头(issue #42 + 上游同步): - // UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态; - // ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。 - up.UserAgent = cfg.Upstream.UserAgent - up.ClientVersion = cfg.Upstream.ClientVersion - up.CliVersion = cfg.Upstream.CliVersion - up.ClientName = cfg.Upstream.ClientName - up.DeviceToken = cfg.Upstream.DeviceToken - up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile - up.PassthroughIP = cfg.Upstream.PassthroughIP - // global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖; - // auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。 - up.GlobalEnabled = cfg.Global.Enabled - up.ChatBaseGlobal = cfg.Global.ChatBase - up.BillingBaseGlobal = cfg.Global.BillingBase - auth.SetGlobalEnabled(cfg.Global.Enabled) - // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): - // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 - // 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。 - upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json")) - - sch := scheduler.New(scheduler.Config{ - Pool: p, - Upstream: up, - CheckinHours: cfg.Schedule.CheckinHours, - TravelHours: cfg.Schedule.TravelHours, - ActivityHours: cfg.Schedule.ActivityHours, - KeepaliveHours: cfg.Schedule.KeepaliveHours, - BlackcatHours: cfg.Schedule.BlackcatHours, - GrowthHours: cfg.Schedule.GrowthHours, - // 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。 - ExpiringSoonWindow: cfg.ExpiringSoonDur, - CheckinDisabled: !cfg.Schedule.CheckinEnabled, - TravelDisabled: !cfg.Schedule.TravelEnabled, - ActivityDisabled: !cfg.Schedule.ActivityEnabled, - KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled, - BlackcatDisabled: !cfg.Schedule.BlackcatEnabled, - GrowthDisabled: !cfg.Schedule.GrowthEnabled, - }) - switch { - case !cfg.Schedule.CheckinEnabled: - log.Printf("签到已禁用(schedule.checkin_enabled=false)") - default: - log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours) - } - switch { - case !cfg.Schedule.TravelEnabled: - log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)") - default: - log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours) - } - switch { - case !cfg.Schedule.ActivityEnabled: - log.Printf("活跃上报已禁用(schedule.activity_enabled=false)") - default: - log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours) - } - if !cfg.Schedule.KeepaliveEnabled { - log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)") - } else { - log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours) - } - switch { - case !cfg.Schedule.BlackcatEnabled: - log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)") - default: - log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours) - } - switch { - case !cfg.Schedule.BalanceRefreshEnabled: - log.Printf("余额后台刷新已禁用(schedule.balance_refresh_enabled=false)") - case cfg.BalanceRefreshInterval > 0: - log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval) - } - - // 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进 - // 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。 - // live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。 - live := livecfg.New(livecfg.Snapshot{ - APIKey: cfg.APIKey, - SoftCooldown: cfg.SoftRateDur, - SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints, - RecordClientInfo: cfg.Logging.RequestClientInfo, - }) - // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。 - // datapath 由 state 文件路径推出,避免再加一个配置项。 - usagePath := usagePathFor(cfg.StateFile) - rec := usage.New(usagePath) - rec.Start() - defer rec.Stop() - log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe()) - - // 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。 - requestLog := reqlog.New(reqlog.Config{ - Dir: stateSibling(cfg.StateFile, "request-logs"), - Enabled: cfg.Logging.RequestArchiveEnabled, - RetentionDays: cfg.Logging.RequestRetentionDays, - MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20, - }) - defer requestLog.Close() - rs := requestLog.Snapshot().Archive - if rs.Enabled { - log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)", - rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB) - } else { - log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭") - } - - pn := panel.New(panel.Config{ - Pool: p, - Usage: rec, - RequestLog: requestLog, - Upstream: up, - Scheduler: sch, - AuthDir: cfg.AuthDir, - APIKey: cfg.APIKey, - RedisMode: redisMode, - StickyCount: sessCount, - Version: appVersion, - Live: live, - // 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入): - // 与 state 文件同目录,缺省 data/output_probes.json。 - ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"), - ConfigPath: *cfgPath, - LoadConfig: func() (any, error) { - return Load(*cfgPath) - }, - SaveConfig: func(raw []byte) ([]string, error) { - return saveConfig(raw, *cfgPath, live, p, up, sch) - }, - }) - // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 - // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 - sch.SetGrowthHook(pn.RunGrowthQueueOnce) - log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs())) - server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs())) - - h := server.NewHandler(server.Config{ - Pool: p, - Upstream: up, - APIKey: cfg.APIKey, - Session: sessRouter, - StickyCount: sessCount, - RedisMode: redisMode, - SoftCooldown: cfg.SoftRateDur, - Panel: pn, - Live: live, - Usage: rec, - RequestLog: requestLog, - PromptMode: cfg.Prompt.Mode, - PromptText: cfg.PromptText, - // 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的 - // 裸用/测试路径拿到同一缺省值。 - RecordClientInfo: cfg.Logging.RequestClientInfo, - // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。 - GlobalEnabled: cfg.Global.Enabled, - }) - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - go sch.Run(ctx) - sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval) - - // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时 - // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前, - // ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号 - // 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费 - // 模型归零;倍率表当时尚未建立)。 - // 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。 - go warmModelRates(ctx, up, p) - - srv := &http.Server{ - Addr: cfg.Listen, - Handler: h, - ReadHeaderTimeout: 30 * time.Second, - // ReadTimeout 覆盖整个请求读取(含 body):防慢速 body 拖死连接。 - // 请求体已无网关侧上限(max_body_mb 移除),60s 按常规带宽的数十 MB - // 上传余量取值;超大 body 慢速上传若超时,由客户端重试。 - ReadTimeout: 60 * time.Second, - // IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。 - // 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout - // (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。 - IdleTimeout: 120 * time.Second, - } - go func() { - <-ctx.Done() - p.Flush() // 信号触发:先落盘再做优雅停机 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = srv.Shutdown(shutdownCtx) - }() - - log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen)) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("http: %v", err) - } - log.Printf("bye") -} - -// warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。 -// -// 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时 -// 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发 -// 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被 -// 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。 -// -// 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域 -// 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。 -func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) { - // 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。 - if ctx.Err() != nil { - return - } - // CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。 - if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 { - if a := p.AuthByUID(uids[0]); a != nil { - if _, err := up.FetchModels(a); err != nil { - log.Printf("WARN: [upstream] warm model rates (cn): %v", err) - } else { - log.Printf("[upstream] warm model rates: cn ok") - } - } - } - // global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。 - if up.GlobalEnabled && ctx.Err() == nil { - if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 { - if a := p.AuthByUID(uids[0]); a != nil { - // FetchGlobalModelInfos 无错误返回(内部负缓存自行节流), - // 仅按结果条数判断是否拿到目录。 - if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 { - log.Printf("WARN: [upstream] warm model rates (global): empty model list") - } else { - log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos)) - } - } - } - } -} - -// panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL -// (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。 -func panelListenPath(listen string) string { - for i := len(listen) - 1; i >= 0; i-- { - if listen[i] == ':' { - return listen[i:] - } - } - return listen -} - -// saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。 -// -// 热生效范围(设计取舍): -// - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照 -// - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor -// - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow -// -// 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖): -// - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类) -// -// 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改 -// 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体 -// 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。 -func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) { - // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。 - oldRaw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read current config: %w", err) - } - var cur, incoming map[string]any - if err := json.Unmarshal(oldRaw, &cur); err != nil { - cur = map[string]any{} - } - if err := json.Unmarshal(raw, &incoming); err != nil { - return nil, fmt.Errorf("parse submitted config: %w", err) - } - merged := mergeConfigMaps(cur, incoming) - - // 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。 - newCfg, err := ParseConfig(mergedJSON(merged)) - if err != nil { - return nil, err - } - - // 3) 落盘(原子替换)。 - out, err := json.MarshalIndent(merged, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal config: %w", err) - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, out, 0o600); err != nil { - return nil, fmt.Errorf("write config: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - // A single-file Docker bind mount cannot be renamed over its mount - // target (Linux returns EBUSY / "device or resource busy"). Keep the - // atomic path for regular files, but update the mounted file in place - // for this specific deployment shape. - if !errors.Is(err, syscall.EBUSY) { - return nil, fmt.Errorf("replace config: %w", err) - } - f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) - if openErr != nil { - _ = os.Remove(tmp) - return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr) - } - _, writeErr := f.Write(out) - if writeErr == nil { - writeErr = f.Sync() - } - closeErr := f.Close() - // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容, - // 可手工恢复);写成功才清理。 - if writeErr != nil { - return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr) - } - _ = os.Remove(tmp) - if closeErr != nil { - return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr) - } - } - - // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。 - live.Store(livecfg.Snapshot{ - APIKey: newCfg.APIKey, - SoftCooldown: newCfg.SoftRateDur, - SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints, - RecordClientInfo: newCfg.Logging.RequestClientInfo, - }) - up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints) - p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD) - p.SetMaxInFlight(newCfg.Pool.MaxInFlight) - p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal) - p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD) - p.SetSoftRateMax(newCfg.SoftRateMaxDur) - p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停) - p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭) - p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) - p.SetPreferExpiring(newCfg.Pool.PreferExpiring) - sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) - sch.Reconfigure( - newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, - newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours, - newCfg.Schedule.GrowthHours, - !newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled, - !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled, - !newCfg.Schedule.GrowthEnabled) - sch.SetBalanceInterval(newCfg.BalanceRefreshInterval) - - return restartRequiredFields(newCfg), nil -} - -// restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 -// 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 -func restartRequiredFields(c *Config) []string { - var out []string - // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。 - if c.Listen != "" { - out = append(out, "listen") - } - if c.AuthDir != "" { - out = append(out, "auth_dir") - } - if c.StateFile != "" { - out = append(out, "state_file") - } - out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds") - if c.Upstash.URL != "" || c.Upstash.Token != "" { - out = append(out, "upstash") - } - out = append(out, "session_sticky.ttl", "session_sticky.gc_interval") - out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb") - return out -} - -// mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。 -// 对嵌套对象逐键覆盖而不是整体替换:面板表单只提交它管理的键, -// 未提交的兄弟键(含用户手写的未知键)保持原样。 -func mergeConfigMaps(cur, incoming map[string]any) map[string]any { - for k, v := range incoming { - if inMap, ok := v.(map[string]any); ok { - if curMap, ok := cur[k].(map[string]any); ok { - cur[k] = mergeConfigMaps(curMap, inMap) - continue - } - } - cur[k] = v - } - return cur -} - -// mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。 -func mergedJSON(m map[string]any) []byte { - b, err := json.Marshal(m) - if err != nil { - return []byte("{}") - } - return b -} +// main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。 +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/panel" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/redisstore" + "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/server" + "github.com/linguo2625469/workbuddy2api-panel/internal/session" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "github.com/linguo2625469/workbuddy2api-panel/internal/usage" +) + +// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。 +const appVersion = "1.12.0-panel" + +// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。 +// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。 +func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") } + +// stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。 +// usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。 +func stateSibling(stateFile, name string) string { + dir := filepath.Dir(stateFile) + if dir == "" || dir == "." { + return name + } + return filepath.Join(dir, name) +} + +func main() { + cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)") + flag.Parse() + + cfg, err := Load(*cfgPath) + if err != nil { + // errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。 + if errors.Is(err, fs.ErrNotExist) { + // 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。 + // 双击 exe / 裸跑 docker 即开,无需先手工复制样例。 + if key, werr := WriteDefault(*cfgPath); werr == nil { + log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key) + cfg, err = Load(*cfgPath) + } + if err != nil { + // 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。 + log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err) + cfg, err = Load("") + } + } + if err != nil { + log.Fatalf("load config: %v", err) + } + } + + auths, err := auth.LoadDir(cfg.AuthDir) + if err != nil { + log.Fatalf("load auths: %v", err) + } + log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir) + + // redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。 + store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token) + + p := pool.New(cfg.StateFile) + // 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store), + // 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。 + defer func() { + p.Close() + _ = store.Close() + }() + p.SetStore(store) + p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先 + p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留) + + // 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入, + // 非正值回退默认)。 + p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD) + p.SetMaxInFlight(cfg.Pool.MaxInFlight) + p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1) + p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD) + p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h) + p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停) + p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭) + p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) + p.SetPreferExpiring(cfg.Pool.PreferExpiring) + + // 会话粘性路由(可配关闭)。 + var sessRouter *session.Router + redisMode := "noop" + if _, ok := store.(redisstore.Noop); !ok { + redisMode = "upstash" + } + if cfg.SessionSticky.Enabled { + sessRouter = session.New(session.Config{ + TTL: cfg.SessionTTL, + GCInterval: cfg.SessionGCInterval, + Store: store, + Available: p.AvailableUIDs, + // realm 感知闭包:带前缀模型名按 realm 过滤可用账号(跨 realm 不泄漏); + // 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。 + AvailableForModel: realmAwareAvailableForModel(p), + }) + sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处) + sessRouter.StartGC() + defer sessRouter.StopGC() + } + sessCount := func() int { + if sessRouter != nil { + return sessRouter.Count() + } + return 0 + } + + up := upstream.New() + + // 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测 + // 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿 + // (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。 + // 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。 + p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) }) + + // 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。 + up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second + // 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。 + up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second + if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok { + tr.ResponseHeaderTimeout = up.HeaderTimeout + } + // 聊天 SSE 流中空闲上限(S3 空闲监控读取)。 + up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second + up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints) + // 出站 UA 与归属头(issue #42 + 上游同步): + // UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态; + // ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。 + up.UserAgent = cfg.Upstream.UserAgent + up.ClientVersion = cfg.Upstream.ClientVersion + up.CliVersion = cfg.Upstream.CliVersion + up.ClientName = cfg.Upstream.ClientName + up.DeviceToken = cfg.Upstream.DeviceToken + up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile + up.PassthroughIP = cfg.Upstream.PassthroughIP + // global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖; + // auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。 + up.GlobalEnabled = cfg.Global.Enabled + up.ChatBaseGlobal = cfg.Global.ChatBase + up.BillingBaseGlobal = cfg.Global.BillingBase + auth.SetGlobalEnabled(cfg.Global.Enabled) + // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): + // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 + // 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。 + upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json")) + + sch := scheduler.New(scheduler.Config{ + Pool: p, + Upstream: up, + CheckinHours: cfg.Schedule.CheckinHours, + TravelHours: cfg.Schedule.TravelHours, + ActivityHours: cfg.Schedule.ActivityHours, + KeepaliveHours: cfg.Schedule.KeepaliveHours, + BlackcatHours: cfg.Schedule.BlackcatHours, + GrowthHours: cfg.Schedule.GrowthHours, + // 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。 + ExpiringSoonWindow: cfg.ExpiringSoonDur, + CheckinDisabled: !cfg.Schedule.CheckinEnabled, + TravelDisabled: !cfg.Schedule.TravelEnabled, + ActivityDisabled: !cfg.Schedule.ActivityEnabled, + KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled, + BlackcatDisabled: !cfg.Schedule.BlackcatEnabled, + GrowthDisabled: !cfg.Schedule.GrowthEnabled, + // 保号类四任务是否覆盖禁用账号(缺省 false = 禁用即跳过,保持既有行为)。 + IncludeDisabledInTasks: cfg.Schedule.IncludeDisabledInTasks, + }) + switch { + case !cfg.Schedule.CheckinEnabled: + log.Printf("签到已禁用(schedule.checkin_enabled=false)") + default: + log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours) + } + switch { + case !cfg.Schedule.TravelEnabled: + log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)") + default: + log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours) + } + switch { + case !cfg.Schedule.ActivityEnabled: + log.Printf("活跃上报已禁用(schedule.activity_enabled=false)") + default: + log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours) + } + if !cfg.Schedule.KeepaliveEnabled { + log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)") + } else { + log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours) + } + switch { + case !cfg.Schedule.BlackcatEnabled: + log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)") + default: + log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours) + } + switch { + case !cfg.Schedule.BalanceRefreshEnabled: + log.Printf("余额后台刷新已禁用(schedule.balance_refresh_enabled=false)") + case cfg.BalanceRefreshInterval > 0: + log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval) + } + if cfg.Schedule.IncludeDisabledInTasks { + log.Printf("保号任务覆盖禁用账号(schedule.include_disabled_in_tasks=true):禁用号仍签到 / 活跃 / 保活 / 刷新余额,但不参与选号") + } + + // 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进 + // 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。 + // live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。 + live := livecfg.New(livecfg.Snapshot{ + APIKey: cfg.APIKey, + SoftCooldown: cfg.SoftRateDur, + SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints, + RecordClientInfo: cfg.Logging.RequestClientInfo, + }) + // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。 + // datapath 由 state 文件路径推出,避免再加一个配置项。 + usagePath := usagePathFor(cfg.StateFile) + rec := usage.New(usagePath) + rec.Start() + defer rec.Stop() + log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe()) + + // 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。 + requestLog := reqlog.New(reqlog.Config{ + Dir: stateSibling(cfg.StateFile, "request-logs"), + Enabled: cfg.Logging.RequestArchiveEnabled, + RetentionDays: cfg.Logging.RequestRetentionDays, + MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20, + }) + defer requestLog.Close() + rs := requestLog.Snapshot().Archive + if rs.Enabled { + log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)", + rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB) + } else { + log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭") + } + + pn := panel.New(panel.Config{ + Pool: p, + Usage: rec, + RequestLog: requestLog, + Upstream: up, + Scheduler: sch, + AuthDir: cfg.AuthDir, + APIKey: cfg.APIKey, + RedisMode: redisMode, + StickyCount: sessCount, + Version: appVersion, + Live: live, + // 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入): + // 与 state 文件同目录,缺省 data/output_probes.json。 + ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"), + ConfigPath: *cfgPath, + LoadConfig: func() (any, error) { + return Load(*cfgPath) + }, + SaveConfig: func(raw []byte) ([]string, error) { + return saveConfig(raw, *cfgPath, live, p, up, sch) + }, + }) + // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 + // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 + sch.SetGrowthHook(pn.RunGrowthQueueOnce) + log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs())) + server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs())) + + h := server.NewHandler(server.Config{ + Pool: p, + Upstream: up, + APIKey: cfg.APIKey, + Session: sessRouter, + StickyCount: sessCount, + RedisMode: redisMode, + SoftCooldown: cfg.SoftRateDur, + Panel: pn, + Live: live, + Usage: rec, + RequestLog: requestLog, + PromptMode: cfg.Prompt.Mode, + PromptText: cfg.PromptText, + // 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的 + // 裸用/测试路径拿到同一缺省值。 + RecordClientInfo: cfg.Logging.RequestClientInfo, + // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。 + GlobalEnabled: cfg.Global.Enabled, + }) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go sch.Run(ctx) + sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval) + + // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时 + // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前, + // ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号 + // 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费 + // 模型归零;倍率表当时尚未建立)。 + // 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。 + go warmModelRates(ctx, up, p) + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: h, + ReadHeaderTimeout: 30 * time.Second, + // ReadTimeout 覆盖整个请求读取(含 body 上传):防慢速 body 拖死连接。 + // 请求体已无网关侧上限(max_body_mb 移除)。缺省 300s(issue #100:旧固定 + // 60s 会掐掉大上下文/文件块经反代链的慢速上传,客户端收到 + // 400 "read body: ... i/o timeout");server.read_timeout="0" 显式关闭。 + // 改动需重启进程。 + ReadTimeout: cfg.ServerReadTimeoutDur, + // IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。 + // 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout + // (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。 + IdleTimeout: 120 * time.Second, + } + go func() { + <-ctx.Done() + p.Flush() // 信号触发:先落盘再做优雅停机 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } + log.Printf("bye") +} + +// warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。 +// +// 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时 +// 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发 +// 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被 +// 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。 +// +// 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域 +// 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。 +func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) { + // 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。 + if ctx.Err() != nil { + return + } + // CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。 + if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 { + if a := p.AuthByUID(uids[0]); a != nil { + if _, err := up.FetchModels(a); err != nil { + log.Printf("WARN: [upstream] warm model rates (cn): %v", err) + } else { + log.Printf("[upstream] warm model rates: cn ok") + } + } + } + // global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。 + if up.GlobalEnabled && ctx.Err() == nil { + if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 { + if a := p.AuthByUID(uids[0]); a != nil { + // FetchGlobalModelInfos 无错误返回(内部负缓存自行节流), + // 仅按结果条数判断是否拿到目录。 + if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 { + log.Printf("WARN: [upstream] warm model rates (global): empty model list") + } else { + log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos)) + } + } + } + } +} + +// panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL +// (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。 +func panelListenPath(listen string) string { + for i := len(listen) - 1; i >= 0; i-- { + if listen[i] == ':' { + return listen[i:] + } + } + return listen +} + +// saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。 +// +// 热生效范围(设计取舍): +// - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照 +// - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor +// - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow +// +// 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖): +// - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类) +// +// 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改 +// 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体 +// 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。 +func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) { + // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。 + oldRaw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read current config: %w", err) + } + var cur, incoming map[string]any + if err := json.Unmarshal(oldRaw, &cur); err != nil { + cur = map[string]any{} + } + if err := json.Unmarshal(raw, &incoming); err != nil { + return nil, fmt.Errorf("parse submitted config: %w", err) + } + merged := mergeConfigMaps(cur, incoming) + + // 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。 + newCfg, err := ParseConfig(mergedJSON(merged)) + if err != nil { + return nil, err + } + + // 3) 落盘(原子替换)。 + out, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, out, 0o600); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + // A single-file Docker bind mount cannot be renamed over its mount + // target (Linux returns EBUSY / "device or resource busy"). Keep the + // atomic path for regular files, but update the mounted file in place + // for this specific deployment shape. + if !errors.Is(err, syscall.EBUSY) { + return nil, fmt.Errorf("replace config: %w", err) + } + f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if openErr != nil { + _ = os.Remove(tmp) + return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr) + } + _, writeErr := f.Write(out) + if writeErr == nil { + writeErr = f.Sync() + } + closeErr := f.Close() + // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容, + // 可手工恢复);写成功才清理。 + if writeErr != nil { + return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr) + } + _ = os.Remove(tmp) + if closeErr != nil { + return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr) + } + } + + // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。 + live.Store(livecfg.Snapshot{ + APIKey: newCfg.APIKey, + SoftCooldown: newCfg.SoftRateDur, + SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints, + RecordClientInfo: newCfg.Logging.RequestClientInfo, + }) + up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints) + p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD) + p.SetMaxInFlight(newCfg.Pool.MaxInFlight) + p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal) + p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD) + p.SetSoftRateMax(newCfg.SoftRateMaxDur) + p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停) + p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭) + p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) + p.SetPreferExpiring(newCfg.Pool.PreferExpiring) + sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) + sch.Reconfigure( + newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, + newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours, + newCfg.Schedule.GrowthHours, + !newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled, + !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled, + !newCfg.Schedule.GrowthEnabled) + sch.SetBalanceInterval(newCfg.BalanceRefreshInterval) + sch.SetIncludeDisabledInTasks(newCfg.Schedule.IncludeDisabledInTasks) + + return restartRequiredFields(newCfg), nil +} + +// restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 +// 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 +func restartRequiredFields(c *Config) []string { + var out []string + // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。 + if c.Listen != "" { + out = append(out, "listen") + } + if c.AuthDir != "" { + out = append(out, "auth_dir") + } + if c.StateFile != "" { + out = append(out, "state_file") + } + out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds") + // upstream.user_agent 在装配期被写进出站 client(main.go 的 up.UserAgent = ...), + // 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完 + // 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。 + out = append(out, "upstream.user_agent") + if c.Upstash.URL != "" || c.Upstash.Token != "" { + out = append(out, "upstash") + } + out = append(out, "session_sticky.ttl", "session_sticky.gc_interval") + out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb") + out = append(out, "server.read_timeout") + return out +} + +// mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。 +// 对嵌套对象逐键覆盖而不是整体替换:面板表单只提交它管理的键, +// 未提交的兄弟键(含用户手写的未知键)保持原样。 +func mergeConfigMaps(cur, incoming map[string]any) map[string]any { + for k, v := range incoming { + if inMap, ok := v.(map[string]any); ok { + if curMap, ok := cur[k].(map[string]any); ok { + cur[k] = mergeConfigMaps(curMap, inMap) + continue + } + } + cur[k] = v + } + return cur +} + +// mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。 +func mergedJSON(m map[string]any) []byte { + b, err := json.Marshal(m) + if err != nil { + return []byte("{}") + } + return b +} diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go index a10476870ab365f9b905947231354c344a100a8f..1d1495d536c2b6339504c120e07827b45348e4fc 100644 --- a/cmd/server/wiring.go +++ b/cmd/server/wiring.go @@ -1,24 +1,24 @@ -package main - -import ( - "github.com/linguo2625469/workbuddy2api-panel/internal/pool" - "github.com/linguo2625469/workbuddy2api-panel/internal/server" -) - -// realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。 -// -// 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出 -// realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给 -// CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。 -// -// realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状 -// (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。 -// -// 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知 -// 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。 -func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string { - return func(model string) []string { - realm, bare := server.ResolveModel(model) - return p.WeightedAvailableUIDsForModelRealm(bare, realm) - } -} +package main + +import ( + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/server" +) + +// realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。 +// +// 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出 +// realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给 +// CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。 +// +// realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状 +// (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。 +// +// 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知 +// 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。 +func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string { + return func(model string) []string { + realm, bare := server.ResolveModel(model) + return p.WeightedAvailableUIDsForModelRealm(bare, realm) + } +} diff --git a/cmd/signin/main.go b/cmd/signin/main.go index 71e3731dc01a1dd18fccf0d9f2f26ffa1115cd9e..fad0eef1fb7b1c6b820a45a190d867ace6ca9e81 100644 --- a/cmd/signin/main.go +++ b/cmd/signin/main.go @@ -1,140 +1,140 @@ -// signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号, -// 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。 -package main - -import ( - "fmt" - "log" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" -) - -type row struct { - file string - uid string - nick string - status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR - detail string - remain int64 - hasQuota bool -} - -func main() { - dir := "auths" - if len(os.Args) > 1 { - dir = os.Args[1] - } - files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json")) - if err != nil || len(files) == 0 { - fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir) - os.Exit(1) - } - sort.Strings(files) - up := upstream.New() - - var rows []row - okN, alreadyN, failN := 0, 0, 0 - for _, f := range files { - r := row{file: filepath.Base(f)} - raw, err := os.ReadFile(f) - if err != nil { - r.status, r.detail = "LOAD_ERR", err.Error() - rows = append(rows, r) - failN++ - continue - } - a, err := auth.Parse(raw) - if err != nil { - r.status, r.detail = "LOAD_ERR", err.Error() - rows = append(rows, r) - failN++ - continue - } - a.FilePath = f - r.uid, r.nick = a.UID, a.Nickname - - // refresh 过期 token - if a.NeedsRefresh(2 * 3600) { - if err := up.RefreshToken(a); err != nil { - if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead { - r.status = "AUTH_INVALID" - } else { - r.status = "FAIL" - } - r.detail = "refresh: " + short(err.Error()) - rows = append(rows, r) - failN++ - continue - } - // refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token - if err := a.SaveAtomic(); err != nil { - log.Printf("signin %s save: %v", a.UID, err) - } - } - - err = up.DailyCheckin(a) - switch { - case err == nil: - r.status = "OK" - okN++ - default: - // DailyCheckin 已签到返回 code!=0 错误 - if isAlready(err.Error()) { - r.status = "ALREADY" - r.detail = short(err.Error()) - alreadyN++ - } else { - r.status = "FAIL" - r.detail = short(err.Error()) - failN++ - } - } - // 顺手查余额 - if remain, _, qerr := up.UserResource(a); qerr == nil { - r.remain, r.hasQuota = remain, true - } - rows = append(rows, r) - } - - // 报告 - fmt.Printf("uid | nick | status | remain | detail\n") - fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n") - for _, r := range rows { - remain := "-" - if r.hasQuota { - remain = fmt.Sprintf("%d", r.remain) - } - fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n", - trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail) - } - fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN) -} - -// 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样 -func isAlready(msg string) bool { - s := strings.ToLower(msg) - return strings.Contains(s, "已签到") || - strings.Contains(s, "already") || - strings.Contains(s, "checkin") || - strings.Contains(s, "code=400") -} - -func trunc(s string, n int) string { - if len(s) > n { - return s[:n] - } - return s -} - -func short(s string) string { - s = strings.ReplaceAll(s, "\n", " ") - if len(s) > 60 { - return s[:60] - } - return s -} +// signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号, +// 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。 +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +type row struct { + file string + uid string + nick string + status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR + detail string + remain int64 + hasQuota bool +} + +func main() { + dir := "auths" + if len(os.Args) > 1 { + dir = os.Args[1] + } + files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json")) + if err != nil || len(files) == 0 { + fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir) + os.Exit(1) + } + sort.Strings(files) + up := upstream.New() + + var rows []row + okN, alreadyN, failN := 0, 0, 0 + for _, f := range files { + r := row{file: filepath.Base(f)} + raw, err := os.ReadFile(f) + if err != nil { + r.status, r.detail = "LOAD_ERR", err.Error() + rows = append(rows, r) + failN++ + continue + } + a, err := auth.Parse(raw) + if err != nil { + r.status, r.detail = "LOAD_ERR", err.Error() + rows = append(rows, r) + failN++ + continue + } + a.FilePath = f + r.uid, r.nick = a.UID, a.Nickname + + // refresh 过期 token + if a.NeedsRefresh(2 * 3600) { + if err := up.RefreshToken(a); err != nil { + if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead { + r.status = "AUTH_INVALID" + } else { + r.status = "FAIL" + } + r.detail = "refresh: " + short(err.Error()) + rows = append(rows, r) + failN++ + continue + } + // refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token + if err := a.SaveAtomic(); err != nil { + log.Printf("signin %s save: %v", a.UID, err) + } + } + + err = up.DailyCheckin(a) + switch { + case err == nil: + r.status = "OK" + okN++ + default: + // DailyCheckin 已签到返回 code!=0 错误 + if isAlready(err.Error()) { + r.status = "ALREADY" + r.detail = short(err.Error()) + alreadyN++ + } else { + r.status = "FAIL" + r.detail = short(err.Error()) + failN++ + } + } + // 顺手查余额 + if remain, _, qerr := up.UserResource(a); qerr == nil { + r.remain, r.hasQuota = remain, true + } + rows = append(rows, r) + } + + // 报告 + fmt.Printf("uid | nick | status | remain | detail\n") + fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n") + for _, r := range rows { + remain := "-" + if r.hasQuota { + remain = fmt.Sprintf("%d", r.remain) + } + fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n", + trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail) + } + fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN) +} + +// 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样 +func isAlready(msg string) bool { + s := strings.ToLower(msg) + return strings.Contains(s, "已签到") || + strings.Contains(s, "already") || + strings.Contains(s, "checkin") || + strings.Contains(s, "code=400") +} + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +func short(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > 60 { + return s[:60] + } + return s +} diff --git a/cmd/sync/main.go b/cmd/sync/main.go new file mode 100644 index 0000000000000000000000000000000000000000..760be2578fea83b848ab5c450f6d079808c9bdfb --- /dev/null +++ b/cmd/sync/main.go @@ -0,0 +1,438 @@ +// sync 是本地 ↔ Hugging Face 中转之间的凭证/状态同步工具。 +// +// 用法(典型): +// +// wb2api-sync status # 只看差异,不写任何东西(有差异时退出码 2) +// wb2api-sync pull # 远端 → 本地(本地启动器在拉起网关前先跑这个) +// wb2api-sync push # 本地 → 远端 +// wb2api-sync sync # 双向对账(缺的补上,冲突按判据取新的一方) +// wb2api-sync watch -interval 5m # 常驻,周期性 sync +// +// 配置来源:默认读 ./config.json 的 auth_dir / state_file 与 sync 段; +// 命令行标志可逐项覆盖(未给 config.json 时也能用标志 + 环境变量跑起来)。 +// +// 为什么读取走桶、写入可走网关:见 internal/syncer 包注释(Xet 上传协议)。 +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/syncer" +) + +// 退出码:0 一切就绪/已对齐;1 出错;2 有差异但未应用(status 模式,供脚本判分支)。 +const ( + exitOK = 0 + exitError = 1 + exitPending = 2 +) + +// fileConfig 已移到 internal/syncer.LoadConfigFile:启动器(cmd/launcher)与 +// 本工具必须用完全同一套"从 config.json 取同步配置"的规则,否则两边对 +// auth_dir 的理解一旦漂移,就会出现"同步写 A 目录、网关读 B 目录"的诡异故障。 + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "[wb2api-sync] 失败: %v\n", err) + os.Exit(exitError) + } +} + +// options 是解析后的运行参数。 +type options struct { + mode syncer.Mode + config syncer.Config + dryRun bool + reportOnly bool // status/diff/plan:按双向算一遍但不落地 + jsonOut bool + quiet bool + watch bool + interval time.Duration + loaded string // 实际读到的 config.json 路径(空 = 没读) +} + +func run(args []string) error { + // 子命令可以是第一个位置参数;也允许省略(默认 sync)。 + cmd := "sync" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + cmd = args[0] + args = args[1:] + } + + fs := flag.NewFlagSet("wb2api-sync", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var ( + configPath = fs.String("config", "config.json", "配置文件路径(读 auth_dir/state_file 与 sync 段)") + noConfig = fs.Bool("no-config", false, "不读配置文件,只用标志/环境变量") + + bucket = fs.String("bucket", "", "HF 桶 ID,如 a3216/wb2api-storage") + endpoint = fs.String("endpoint", "", "HF 端点(默认 https://huggingface.co)") + token = fs.String("token", "", "HF token(建议改用 -token-file 或 HF_TOKEN 环境变量)") + tokenFile = fs.String("token-file", "", "存放 HF token 的文件") + + authDir = fs.String("auth-dir", "", "本地凭证目录(默认取 config.json 的 auth_dir)") + dataDir = fs.String("data-dir", "", "本地数据目录(默认取 state_file 所在目录)") + include = fs.String("include-data", "", "额外同步的 data/ 文件名,逗号分隔(默认只同步凭证)") + + writer = fs.String("writer", "", "写入通道:auto(默认)/ hf / relay") + hfBin = fs.String("hf-bin", "", "hf 可执行文件,默认 hf") + relayURL = fs.String("relay-url", "", "中转网关地址(relay 通道用)") + relayKey = fs.String("relay-key", "", "中转网关 api_key(留空时自动从桶的 config.json 读)") + restart = fs.String("restart-space", "", "推了新凭证后重启该 Space(如 a3216/gcli2api),让它加载新账号") + caBundle = fs.String("ca-bundle", "", "额外 CA 证书包(PEM);本机跑抓包代理时给 hf CLI 用") + + timeout = fs.String("timeout", "", "单次网络超时,默认 60s") + insecure = fs.Bool("insecure-skip-verify", false, "跳过 TLS 校验(仅供本地抓包代理排障)") + + dryRun = fs.Bool("dry-run", false, "只展示将要做什么,不写任何东西") + jsonOut = fs.Bool("json", false, "以 JSON 输出结果") + quiet = fs.Bool("quiet", false, "只输出汇总行") + watch = fs.Bool("watch", false, "常驻循环同步") + interval = fs.Duration("interval", 5*time.Minute, "watch 模式的同步间隔") + ) + + if cmd == "help" || cmd == "-h" || cmd == "--help" { + printHelp(fs) + return nil + } + if err := fs.Parse(args); err != nil { + return err + } + + mode, reportOnly, err := syncer.ParseCommand(cmd) + if err != nil { + return err + } + + opt := options{ + mode: mode, dryRun: *dryRun || reportOnly, jsonOut: *jsonOut, quiet: *quiet, + watch: *watch || cmd == "watch", interval: *interval, reportOnly: reportOnly, + } + if opt.watch { + // watch 必须是双向的:单方向常驻没有意义。 + opt.mode = syncer.ModeSync + opt.reportOnly = false + } + + // 1) 读配置文件打底 + if !*noConfig && *configPath != "" { + fc, err := syncer.LoadConfigFile(*configPath) + switch { + case err == nil: + opt.loaded = *configPath + opt.config = fc + case os.IsNotExist(err): + // 没有 config.json 不是错误:可以用标志 + 环境变量把同步跑起来。 + default: + return fmt.Errorf("读取 %s 失败: %w", *configPath, err) + } + } + + // 2) 标志覆盖 + if *bucket != "" { + opt.config.Bucket = *bucket + } + if *endpoint != "" { + opt.config.Endpoint = *endpoint + } + if *token != "" { + opt.config.Token = *token + } + if *tokenFile != "" { + opt.config.TokenFile = *tokenFile + } + if *authDir != "" { + opt.config.AuthDir = *authDir + } + if *dataDir != "" { + opt.config.DataDir = *dataDir + } + if *include != "" { + opt.config.IncludeData = splitCSV(*include) + } + if *writer != "" { + opt.config.Writer = *writer + } + if *hfBin != "" { + opt.config.HFBin = *hfBin + } + if *relayURL != "" { + opt.config.RelayURL = *relayURL + } + if *relayKey != "" { + opt.config.RelayKey = *relayKey + } + if *restart != "" { + opt.config.RestartSpace = *restart + } + if *caBundle != "" { + opt.config.CABundle = *caBundle + } + if *timeout != "" { + opt.config.Timeout = *timeout + } + if *insecure { + opt.config.InsecureSkipVerify = true + } + // 环境变量兜底(方便 CI / 临时用,不必把 token 落盘) + if opt.config.Token == "" && opt.config.TokenFile == "" { + if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" { + opt.config.Token = t + } + } + opt.config.Normalize() + + if opt.config.AuthDir == "" { + opt.config.AuthDir = "auths" + } + if opt.config.DataDir == "" { + opt.config.DataDir = "data" + } + if opt.config.Bucket == "" { + return fmt.Errorf("未配置桶:请在 config.json 的 sync.bucket 填桶 ID(形如 a3216/wb2api-storage),或用 -bucket") + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + eng, err := syncer.New(opt.config) + if err != nil { + return err + } + opt.config = eng.Config() // 拿回归一化后的值(含 token_file 解出来的 token) + + // relay 通道缺 api_key 时,从桶里的 config.json 自动取——省掉手工抄一遍密钥。 + if needsRelayKey(eng) { + if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil { + opt.config.RelayKey = key + if eng, err = syncer.New(opt.config); err != nil { + return err + } + } + } + + if opt.watch { + return runWatch(ctx, eng, opt) + } + return runOnce(ctx, eng, opt) +} + +// needsRelayKey 报告当前是否"选了 relay 通道但还没 key"。 +func needsRelayKey(eng *syncer.Engine) bool { + return eng.WriterName() == "relay" && eng.Config().RelayKey == "" +} + +// runOnce 跑一轮并输出结果。 +func runOnce(ctx context.Context, eng *syncer.Engine, opt options) error { + plan, res, err := eng.Sync(ctx, opt.mode, opt.dryRun) + if err != nil { + return err + } + if opt.jsonOut { + printJSON(plan, res, opt) + } else { + printHuman(plan, res, opt, eng) + } + if res.Failed > 0 { + return fmt.Errorf("%d 个文件同步失败", res.Failed) + } + // 推了新账号 → 让 Space 重启一次,否则它只在启动时扫凭证目录,加载不到新号。 + if !opt.dryRun && opt.config.RestartSpace != "" { + if n := uploadedAuths(res); n > 0 { + if err := syncer.RestartSpace(ctx, opt.config.Endpoint, opt.config.RestartSpace, + opt.config.Token, 90*time.Second, opt.config.InsecureSkipVerify); err != nil { + return fmt.Errorf("凭证已上传,但重启 Space 失败(新账号要等它下次重启才生效): %w", err) + } + if !opt.jsonOut { + fmt.Printf("\n已请求重启 Space %s(推送了 %d 个凭证)——冷启动约 30~60s 后新账号生效。\n", + opt.config.RestartSpace, n) + } + } + } + // status 模式:有差异时用退出码 2 告诉脚本"尚未对齐"(与 git diff --exit-code 同风格)。 + if opt.reportOnly && plan.Changes() > 0 { + os.Exit(exitPending) + } + return nil +} + +// uploadedAuths 统计本轮真正写成功的 auths/ 文件数——只有这些才需要触发 Space 重启。 +func uploadedAuths(res *syncer.Result) int { + n := 0 + for _, a := range res.Actions { + if a.Op == syncer.OpUpload && a.Applied && a.Err == "" && strings.HasPrefix(a.Path, "auths/") { + n++ + } + } + return n +} + +// runWatch 常驻循环。只有发生变化时才打印,避免刷屏。 +func runWatch(ctx context.Context, eng *syncer.Engine, opt options) error { + if !opt.jsonOut { + fmt.Printf("[wb2api-sync] watch 启动:每 %s 同步一次(Ctrl-C 退出)\n", opt.interval) + } + t := time.NewTicker(opt.interval) + defer t.Stop() + for { + plan, res, err := eng.Sync(ctx, syncer.ModeSync, false) + switch { + case err != nil: + fmt.Fprintf(os.Stderr, "[wb2api-sync] %s 同步出错: %v\n", time.Now().Format("15:04:05"), err) + case plan.Changes() > 0 || res.Failed > 0: + if opt.jsonOut { + printJSON(plan, res, opt) + } else { + fmt.Printf("[wb2api-sync] %s 上传 %d / 下载 %d / 失败 %d\n", + time.Now().Format("15:04:05"), res.Uploaded, res.Downloaded, res.Failed) + } + } + select { + case <-ctx.Done(): + return nil + case <-t.C: + } + } +} + +// --------------------------------------------------------------------------- +// 输出 +// --------------------------------------------------------------------------- + +func printHuman(plan *syncer.Plan, res *syncer.Result, opt options, eng *syncer.Engine) { + cfg := eng.Config() + mode := string(opt.mode) + if opt.dryRun { + mode += "(dry-run,未写入)" + } + if !opt.quiet { + fmt.Printf("[wb2api-sync] 桶 %s 端点 %s\n", cfg.Bucket, cfg.Endpoint) + fmt.Printf(" 模式 %s 写入通道 %s 凭证目录 %s\n", mode, writerLabel(eng), cfg.AuthDir) + if len(cfg.IncludeData) > 0 { + fmt.Printf(" 额外同步 data/: %s\n", strings.Join(cfg.IncludeData, ", ")) + } else { + fmt.Printf(" 额外同步 data/: 无(只同步 auths/ 凭证;需要时用 -include-data state.json 开启)\n") + } + fmt.Printf(" 本地 %d 个文件 / 远端 %d 个文件\n\n", plan.LocalFiles, plan.RemoteFiles) + } + + for _, a := range plan.Actions { + if a.Op == syncer.OpNoop && !a.Conflict { + continue // 一致的文件不刷屏 + } + icon := "=" + switch a.Op { + case syncer.OpUpload: + icon = "↑" + case syncer.OpDownload: + icon = "↓" + } + tag := "" + if a.Conflict { + tag = " [冲突]" + } + status := "" + if a.Err != "" { + status = " ✗ " + a.Err + } + fmt.Printf(" %s %-8s %s%s\n %s%s\n", icon, a.Op, a.Path, tag, a.Reason, status) + } + + if n := len(plan.Conflicts()); n > 0 && !opt.quiet { + fmt.Printf("\n 本轮有 %d 个真冲突(两端都有且内容不同),已按「accessToken 新鲜度 → refreshToken 有无 → mtime」裁决。\n", n) + } + fmt.Printf("\n汇总: 上传 %d / 下载 %d / 跳过 %d / 失败 %d\n", + res.Uploaded, res.Downloaded, res.Skipped, res.Failed) + for _, e := range res.Errors { + fmt.Fprintf(os.Stderr, " ! %s\n", e) + } +} + +func writerLabel(eng *syncer.Engine) string { + if n := eng.WriterName(); n != "" { + return n + } + return "(只读:未配置写入通道)" +} + +// jsonReport 是 -json 的输出结构。 +type jsonReport struct { + OK bool `json:"ok"` + Mode string `json:"mode"` + DryRun bool `json:"dry_run"` + Bucket string `json:"bucket"` + Writer string `json:"writer"` + Local int `json:"local_files"` + Remote int `json:"remote_files"` + Changes int `json:"changes"` + Uploaded int `json:"uploaded"` + Downld int `json:"downloaded"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Actions []syncer.Action `json:"actions"` + Errors []string `json:"errors,omitempty"` +} + +func printJSON(plan *syncer.Plan, res *syncer.Result, opt options) { + rep := jsonReport{ + OK: res.Failed == 0, Mode: string(opt.mode), DryRun: opt.dryRun, + Bucket: plan.Bucket, Writer: plan.Writer, + Local: plan.LocalFiles, Remote: plan.RemoteFiles, Changes: plan.Changes(), + Uploaded: res.Uploaded, Downld: res.Downloaded, Skipped: res.Skipped, Failed: res.Failed, + Actions: plan.Actions, Errors: res.Errors, + } + raw, _ := json.MarshalIndent(rep, "", " ") + fmt.Println(string(raw)) +} + +func printHelp(fs *flag.FlagSet) { + fmt.Print(`wb2api-sync — 本地与 Hugging Face 中转之间的凭证/状态同步 + +用法: + wb2api-sync status [标志] 只看差异(有差异退出码 2),不写任何东西 + wb2api-sync pull [标志] 远端 → 本地 + wb2api-sync push [标志] 本地 → 远端 + wb2api-sync sync [标志] 双向对账(默认) + wb2api-sync watch [标志] 常驻,周期性 sync + +退出码: 0 已对齐 / 1 出错 / 2 status 模式下存在差异 + +标志: +`) + fs.PrintDefaults() + fmt.Print(` +示例: + # 本地启动前先拉一次远端凭证 + wb2api-sync pull -config config.json + + # 看两边差在哪(不写盘) + wb2api-sync status -bucket a3216/wb2api-storage -token-file ./data/hf_token.txt + + # 常驻,每 5 分钟对账一次 + wb2api-sync watch -interval 5m +`) +} + +// --------------------------------------------------------------------------- +// 小工具 +// --------------------------------------------------------------------------- + +// splitCSV 切分逗号列表并去掉空项。 +func splitCSV(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/cmd/trial/main.go b/cmd/trial/main.go index 564673b549d63ba08a196c64840e3db4fa7bb8ef..10aaf9370e77b55121efc09ae0252a759be1f95e 100644 --- a/cmd/trial/main.go +++ b/cmd/trial/main.go @@ -1,133 +1,133 @@ -// trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号, -// 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。 -// -// 用法: -// -// # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run -// go run ./cmd/trial -// -// # 容器内:先 cp 进去再 exec -// docker cp trial workbuddy2api:/tmp/trial -// docker exec -w /app workbuddy2api /tmp/trial -// -// 结果逐账号输出到 stdout: -// -// uid | nick | status | detail -// ----+------+--------+------- -// ... | GLOBAL | OK | trial granted -// ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败) -// ... | CN | N/A | not applicable -package main - -import ( - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" -) - -// classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言): -// err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。 -func classifyTrial(claimed bool, err error) (trialStatus, string) { - switch { - case err != nil: - return trialFailed, err.Error() - case claimed: - return trialOK, "trial granted" - default: - return trialAlready, "already claimed (idempotent)" - } -} - -// trialStatus 单账号 trial 领取结果状态。 -type trialStatus string - -const ( - trialOK trialStatus = "OK" - trialAlready trialStatus = "ALREADY" - trialNotApp trialStatus = "N/A" // CN 账号不适用 - trialFailed trialStatus = "FAIL" -) - -type trialRow struct { - uid string - nick string - status trialStatus - detail string -} - -func main() { - authDir := "auths" - if len(os.Args) > 1 { - authDir = os.Args[1] - } - files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) - if err != nil || len(files) == 0 { - fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir) - os.Exit(1) - } - sort.Strings(files) - - up := upstream.New() - // trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的 - // GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。 - up.GlobalEnabled = true - var rows []trialRow - for _, f := range files { - r := trialRow{uid: filepath.Base(f)} - raw, err := os.ReadFile(f) - if err != nil { - r.status, r.detail = trialFailed, "load: "+err.Error() - rows = append(rows, r) - continue - } - a, err := auth.Parse(raw) - if err != nil { - r.status, r.detail = trialFailed, "parse: "+err.Error() - rows = append(rows, r) - continue - } - a.FilePath = f - r.uid, r.nick = a.UID, a.Nickname - - // 仅 global 账号适用:CN 明确提示不适用,不发任何请求。 - if !a.IsGlobal() { - r.status, r.detail = trialNotApp, "CN account not applicable" - rows = append(rows, r) - continue - } - - r.status, r.detail = classifyTrial(up.ClaimTrial(a)) - rows = append(rows, r) - } - - var okN, alreadyN, notAppN, failN int - fmt.Printf("uid | nick | status | detail\n") - fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n") - for _, r := range rows { - fmt.Printf("%-36s | %-11s | %-7s | %s\n", - trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail) - switch r.status { - case trialOK: - okN++ - case trialAlready: - alreadyN++ - case trialNotApp: - notAppN++ - default: - failN++ - } - } - fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n", - len(rows), okN, alreadyN, notAppN, failN) -} - -func trunc(s string, n int) string { - if len(s) > n { - return s[:n] - } - return s -} +// trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号, +// 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。 +// +// 用法: +// +// # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run +// go run ./cmd/trial +// +// # 容器内:先 cp 进去再 exec +// docker cp trial workbuddy2api:/tmp/trial +// docker exec -w /app workbuddy2api /tmp/trial +// +// 结果逐账号输出到 stdout: +// +// uid | nick | status | detail +// ----+------+--------+------- +// ... | GLOBAL | OK | trial granted +// ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败) +// ... | CN | N/A | not applicable +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言): +// err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。 +func classifyTrial(claimed bool, err error) (trialStatus, string) { + switch { + case err != nil: + return trialFailed, err.Error() + case claimed: + return trialOK, "trial granted" + default: + return trialAlready, "already claimed (idempotent)" + } +} + +// trialStatus 单账号 trial 领取结果状态。 +type trialStatus string + +const ( + trialOK trialStatus = "OK" + trialAlready trialStatus = "ALREADY" + trialNotApp trialStatus = "N/A" // CN 账号不适用 + trialFailed trialStatus = "FAIL" +) + +type trialRow struct { + uid string + nick string + status trialStatus + detail string +} + +func main() { + authDir := "auths" + if len(os.Args) > 1 { + authDir = os.Args[1] + } + files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json")) + if err != nil || len(files) == 0 { + fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir) + os.Exit(1) + } + sort.Strings(files) + + up := upstream.New() + // trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的 + // GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。 + up.GlobalEnabled = true + var rows []trialRow + for _, f := range files { + r := trialRow{uid: filepath.Base(f)} + raw, err := os.ReadFile(f) + if err != nil { + r.status, r.detail = trialFailed, "load: "+err.Error() + rows = append(rows, r) + continue + } + a, err := auth.Parse(raw) + if err != nil { + r.status, r.detail = trialFailed, "parse: "+err.Error() + rows = append(rows, r) + continue + } + a.FilePath = f + r.uid, r.nick = a.UID, a.Nickname + + // 仅 global 账号适用:CN 明确提示不适用,不发任何请求。 + if !a.IsGlobal() { + r.status, r.detail = trialNotApp, "CN account not applicable" + rows = append(rows, r) + continue + } + + r.status, r.detail = classifyTrial(up.ClaimTrial(a)) + rows = append(rows, r) + } + + var okN, alreadyN, notAppN, failN int + fmt.Printf("uid | nick | status | detail\n") + fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n") + for _, r := range rows { + fmt.Printf("%-36s | %-11s | %-7s | %s\n", + trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail) + switch r.status { + case trialOK: + okN++ + case trialAlready: + alreadyN++ + case trialNotApp: + notAppN++ + default: + failN++ + } + } + fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n", + len(rows), okN, alreadyN, notAppN, failN) +} + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/config.default.json b/config.default.json index 87207fc754eecbc59f0cf94657585c3e6baa205e..c2163f4a3603302ca056676d726b146930b4763c 100644 --- a/config.default.json +++ b/config.default.json @@ -1,98 +1,102 @@ -{ - "listen": ":7861", - "api_key": "", - "auth_dir": "/app/storage/auths", - "state_file": "/app/storage/data/state.json", - "panel": { - "package_detail_limit": 5 - }, - "logging": { - "request_archive_enabled": true, - "request_retention_days": 7, - "request_archive_max_mb": 100, - "request_client_info": true - }, - "cooldown": { - "soft_rate": "600s", - "soft_rate_max": "2h" - }, - "schedule": { - "checkin_hours": [ - 9, - 21 - ], - "travel_hours": [ - 9, - 21 - ], - "activity_hours": [ - 10 - ], - "keepalive_hours": [ - 22 - ], - "blackcat_hours": [ - 23 - ], - "growth_hours": [ - 1 - ], - "checkin_enabled": true, - "travel_enabled": true, - "activity_enabled": true, - "keepalive_enabled": true, - "blackcat_enabled": true, - "growth_enabled": true, - "balance_refresh_enabled": true, - "balance_refresh_minutes": 5 - }, - "global": { - "enabled": true, - "chat_base": "", - "billing_base": "" - }, - "upstream": { - "timeout_seconds": 120, - "header_timeout_seconds": 120, - "idle_timeout_seconds": 300, - "user_agent": "", - "client_version": "", - "cli_version": "", - "client_name": "", - "device_token": "", - "device_token_file": "", - "passthrough_ip": false - }, - "features": { - "sanitize_blacklist_fingerprints": true - }, - "prompt": { - "mode": "passthrough", - "file": "" - }, - "upstash": { - "url": "", - "token": "" - }, - "pool": { - "max_in_flight": 3, - "max_in_flight_global": 2, - "breaker_threshold": 3, - "breaker_cooldown": "30m", - "breaker_cooldown_max": "6h", - "degrade_threshold": 5, - "degrade_cooldown": "10m", - "degrade_cooldown_max": "2h", - "idle_weight_per_hour": 0.5, - "idle_weight_max": 5, - "prefer_expiring": true, - "expiring_soon": "168h", - "cost_explore_interval": "30m", - "credit_floor": 0 - }, - "session_sticky": { - "enabled": true, - "ttl": "30m", - "gc_interval": "5m" - } -} +{ + "listen": ":7861", + "api_key": "", + "auth_dir": "/app/storage/auths", + "state_file": "/app/storage/data/state.json", + "server": { + "read_timeout": "300s" + }, + "panel": { + "package_detail_limit": 5 + }, + "logging": { + "request_archive_enabled": true, + "request_retention_days": 7, + "request_archive_max_mb": 100, + "request_client_info": true + }, + "cooldown": { + "soft_rate": "600s", + "soft_rate_max": "2h" + }, + "schedule": { + "checkin_hours": [ + 9, + 21 + ], + "travel_hours": [ + 9, + 21 + ], + "activity_hours": [ + 10 + ], + "keepalive_hours": [ + 22 + ], + "blackcat_hours": [ + 23 + ], + "growth_hours": [ + 1 + ], + "checkin_enabled": true, + "travel_enabled": true, + "activity_enabled": true, + "keepalive_enabled": true, + "blackcat_enabled": true, + "growth_enabled": true, + "balance_refresh_enabled": true, + "balance_refresh_minutes": 5, + "include_disabled_in_tasks": false + }, + "global": { + "enabled": true, + "chat_base": "", + "billing_base": "" + }, + "upstream": { + "timeout_seconds": 120, + "header_timeout_seconds": 120, + "idle_timeout_seconds": 300, + "user_agent": "", + "client_version": "", + "cli_version": "", + "client_name": "", + "device_token": "", + "device_token_file": "", + "passthrough_ip": false + }, + "features": { + "sanitize_blacklist_fingerprints": true + }, + "prompt": { + "mode": "passthrough", + "file": "" + }, + "upstash": { + "url": "", + "token": "" + }, + "pool": { + "max_in_flight": 3, + "max_in_flight_global": 2, + "breaker_threshold": 3, + "breaker_cooldown": "30m", + "breaker_cooldown_max": "6h", + "degrade_threshold": 5, + "degrade_cooldown": "10m", + "degrade_cooldown_max": "2h", + "idle_weight_per_hour": 0.5, + "idle_weight_max": 5, + "prefer_expiring": true, + "expiring_soon": "168h", + "cost_explore_interval": "30m", + "credit_floor": 0 + }, + "session_sticky": { + "enabled": true, + "ttl": "30m", + "gc_interval": "5m" + } +} diff --git a/config.example.json b/config.example.json index bbfc4b7138c48380a959ffb7c23b814ba0d76979..1324486909af0e331e0c377bd99d31439163f17f 100644 --- a/config.example.json +++ b/config.example.json @@ -1,84 +1,105 @@ -{ - "listen": ":7863", - "api_key": "test_key", - "auth_dir": "./auths", - "state_file": "./data/state.json", - "panel": { - "package_detail_limit": 5 - }, - "logging": { - "request_archive_enabled": true, - "request_retention_days": 7, - "request_archive_max_mb": 100, - "request_client_info": true - }, - "cooldown": { - "soft_rate": "600s", - "soft_rate_max": "2h" - }, - "schedule": { - "checkin_hours": [9, 21], - "growth_hours": [1], - "travel_hours": [9, 21], - "activity_hours": [10], - "keepalive_hours": [22], - "blackcat_hours": [23], - "checkin_enabled": true, - "growth_enabled": true, - "travel_enabled": true, - "activity_enabled": true, - "keepalive_enabled": true, - "blackcat_enabled": true, - "balance_refresh_enabled": true, - "balance_refresh_minutes": 5 - }, - "global": { - "enabled": true, - "chat_base": "", - "billing_base": "" - }, - "upstream": { - "timeout_seconds": 120, - "header_timeout_seconds": 120, - "idle_timeout_seconds": 300, - "user_agent": "", - "client_version": "", - "cli_version": "", - "client_name": "", - "device_token": "", - "device_token_file": "", - "passthrough_ip": false - }, - "features": { - "sanitize_blacklist_fingerprints": true - }, - "prompt": { - "mode": "passthrough", - "file": "" - }, - "upstash": { - "url": "", - "token": "" - }, - "pool": { - "max_in_flight": 3, - "max_in_flight_global": 2, - "breaker_threshold": 3, - "breaker_cooldown": "30m", - "breaker_cooldown_max": "6h", - "degrade_threshold": 5, - "degrade_cooldown": "10m", - "degrade_cooldown_max": "2h", - "idle_weight_per_hour": 0.5, - "idle_weight_max": 5.0, - "prefer_expiring": true, - "expiring_soon": "168h", - "cost_explore_interval": "30m", - "credit_floor": 100 - }, - "session_sticky": { - "enabled": true, - "ttl": "30m", - "gc_interval": "5m" - } -} +{ + "listen": ":7863", + "api_key": "test_key", + "auth_dir": "./auths", + "state_file": "./data/state.json", + "server": { + "read_timeout": "300s" + }, + "panel": { + "package_detail_limit": 5 + }, + "logging": { + "request_archive_enabled": true, + "request_retention_days": 7, + "request_archive_max_mb": 100, + "request_client_info": true + }, + "cooldown": { + "soft_rate": "600s", + "soft_rate_max": "2h" + }, + "schedule": { + "checkin_hours": [9, 21], + "growth_hours": [1], + "travel_hours": [9, 21], + "activity_hours": [10], + "keepalive_hours": [22], + "blackcat_hours": [23], + "checkin_enabled": true, + "growth_enabled": true, + "travel_enabled": true, + "activity_enabled": true, + "keepalive_enabled": true, + "blackcat_enabled": true, + "balance_refresh_enabled": true, + "balance_refresh_minutes": 5, + "include_disabled_in_tasks": false + }, + "global": { + "enabled": true, + "chat_base": "", + "billing_base": "" + }, + "upstream": { + "timeout_seconds": 120, + "header_timeout_seconds": 120, + "idle_timeout_seconds": 300, + "user_agent": "", + "client_version": "", + "cli_version": "", + "client_name": "", + "device_token": "", + "device_token_file": "", + "passthrough_ip": false + }, + "features": { + "sanitize_blacklist_fingerprints": true + }, + "prompt": { + "mode": "passthrough", + "file": "" + }, + "upstash": { + "url": "", + "token": "" + }, + "pool": { + "max_in_flight": 3, + "max_in_flight_global": 2, + "breaker_threshold": 3, + "breaker_cooldown": "30m", + "breaker_cooldown_max": "6h", + "degrade_threshold": 5, + "degrade_cooldown": "10m", + "degrade_cooldown_max": "2h", + "idle_weight_per_hour": 0.5, + "idle_weight_max": 5.0, + "prefer_expiring": true, + "expiring_soon": "168h", + "cost_explore_interval": "30m", + "credit_floor": 100 + }, + "session_sticky": { + "enabled": true, + "ttl": "30m", + "gc_interval": "5m" + }, + "sync": { + "bucket": "", + "token": "", + "token_file": "", + "endpoint": "https://huggingface.co", + "auth_dir": "", + "data_dir": "", + "include_data": [], + "writer": "auto", + "hf_bin": "hf", + "relay_url": "", + "relay_key": "", + "restart_space": "", + "ca_bundle": "", + "timeout": "60s", + "insecure_skip_verify": false + } +} diff --git a/credit.sh b/credit.sh index ae0e085ceab21398e110fb317c3e6dbbd59be704..a1fddf5983568f1c93e234bfc64cc4ef49b6cdd4 100644 --- a/credit.sh +++ b/credit.sh @@ -1,14 +1,14 @@ -#!/usr/bin/env bash -# credit.sh — WorkBuddy 积分日报(默认美化输出) -# -# 用法: -# ./credit.sh # 人类可读日报 -# ./credit.sh -json # 原始 JSON -# -# 二进制升级: go build -o credit ./cmd/credit -set -euo pipefail -cd "$(dirname "$0")" -if [[ "${1:-}" == "-json" ]]; then - exec ./credit -fi -exec ./credit -pretty +#!/usr/bin/env bash +# credit.sh — WorkBuddy 积分日报(默认美化输出) +# +# 用法: +# ./credit.sh # 人类可读日报 +# ./credit.sh -json # 原始 JSON +# +# 二进制升级: go build -o credit ./cmd/credit +set -euo pipefail +cd "$(dirname "$0")" +if [[ "${1:-}" == "-json" ]]; then + exec ./credit +fi +exec ./credit -pretty diff --git a/deploy/cloudflare-worker/.dev.vars.example b/deploy/cloudflare-worker/.dev.vars.example new file mode 100644 index 0000000000000000000000000000000000000000..c1c1d0c1ee41c21c7f5802bad82acae98fc2b181 --- /dev/null +++ b/deploy/cloudflare-worker/.dev.vars.example @@ -0,0 +1,24 @@ +# 本地开发用的变量样例 —— 复制为 .dev.vars 后填写真实值。 +# +# cp .dev.vars.example .dev.vars +# +# ⚠️ .dev.vars 里放的是真实密钥,**永远不要提交**。 +# `.dev.vars.example`(本文件)只放占位值,可以进版本库。 +# +# 线上环境不要用这个文件,改用: +# npx wrangler secret put WB2A_API_KEY +# +# 格式:KEY="value",一行一个,不要加 export。 + +# 源站(一般不用改,改 Space 时才动) +ORIGIN="https://a3216-gcli2api.hf.space" + +# 保活探测路径 +HEALTH_PATH="/healthz" + +# 面板 / 网关的 Bearer 密钥。留空时 scheduled() 只做裸 ping,不发送鉴权请求。 +WB2A_API_KEY="" + +# —— 可选的带鉴权签到(默认关闭)—— +# CHECKIN_CRON="0 * * * *" +# CHECKIN_PATH="/panel/api/keepalive" diff --git a/deploy/cloudflare-worker/README.md b/deploy/cloudflare-worker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b8461c68777ac7a1aa50e21667cdd0e05809ee83 --- /dev/null +++ b/deploy/cloudflare-worker/README.md @@ -0,0 +1,350 @@ +# WorkBuddy2API — Cloudflare Worker:反代 + 保活 + +把公网入口 `cli.3216gemini.dpdns.org` 反代到 Hugging Face Space +`a3216/gcli2api`(直连域名 `https://a3216-gcli2api.hf.space`), +并用 Cron 定时打 `/healthz`,防止 Space 被 HF 暂停。 + +## 这个 Worker 做什么 +| 能力 | 说明 | +|---|---| +| 🔁 **全量反向代理** | 方法 / 路径 / query / 请求体 / 端到端请求头原样转发,源站的状态码、响应头、响应体原样回传 | +| ⚡ **零缓冲流式** | 直接返回 `originResponse.body`,不调用 `.text()` / `.json()`。`POST /v1/chat/completions` 是 SSE 长连接,缓冲会直接毁掉流式体验 | +| ↩️ **不吞后端 302** | `fetch(..., { redirect: 'manual' })`,所以 `GET /` → `/panel/` 的跳转是**浏览器**看到的,不会被 Worker 内部跟掉 | +| 🌐 **CORS 全透传** | 后端已经返回宽松的 CORS 头,Worker 一律不自己造 `Access-Control-*`,避免出现重复头 | +| 🧹 **逐跳头剥离** | 剥掉 `host` / `connection` / `keep-alive` / `transfer-encoding` / `upgrade` / `proxy-*` / `cf-*` / `x-forwarded-*`;`Host` 永远不会用公网域名转发过去 | +| 🍪 **set-cookie 原样** | 不做 domain / path 改写,多条也完整保留 | +| ⏰ **Cron 保活** | `scheduled()` 每 5 分钟 ping 一次源站 `/healthz`,25s 硬超时 + 失败重试 1 次 + 每次一行日志 | +| 🛡️ **不抛异常** | Cron 处理器内部吞掉所有异常,不会污染 Cron 运行记录 | + +涉及的后端接口(都不需要 Worker 特殊处理,纯透传): + +- `GET /healthz` → `{"healthy":2,"realm_servable":{...},"service":"workbuddy2api","total":3}`(无可服务账号时 503) +- `GET /` → 302 → `/panel/` +- `GET /panel/` → 单页管理面板 +- `/panel/api/*` → 面板 JSON API,需 `Authorization: Bearer ` +- `POST /v1/chat/completions` → OpenAI 兼容,**SSE 流式**,需同一个 Bearer 密钥 +- `GET /v1/models`、`GET /status` + +## 为什么需要这个 Cron + +Hugging Face 免费 `cpu-basic` Space 有一条硬规则:**连续 48 小时没有任何 HTTP 流量就被暂停**, +下一次访问要等 **30–60 秒**冷启动。 + +所以「反代」和「保活」其实是同一件事的两半:反代提供入口,Cron 保证入口后面那台机器不会睡着。 +只要有任意 HTTP 请求打到 Space,HF 的闲置计时器就会归零——**响应内容是什么完全不影响判断**, +这就是为什么一次裸的 `GET /healthz` 就够了。 + +## ⚠️ 接管前必读:现有入口的 `/healthz` 是 Cloudflare 自己应答的 + +同一时刻实测对比: + +| 目标 | `/healthz` 响应 | 判据 | +| --- | --- | --- | +| `https://a3216-gcli2api.hf.space/healthz` | `{"healthy":2,…,"service":"workbuddy2api"}` | 带 `x-service`、`x-proxied-replica`,以及 HF 的 `link: …/spaces/a3216/gcli2api` 头 | +| `https://cli.3216gemini.dpdns.org/healthz` | **`ok`(2 字节)** | 只有 `cf-ray`;**没有** `x-service`、**没有** `link` | + +**结论:现有入口把 `/healthz` 短路了,请求根本没到 Space。** 很可能是故意的 —— +避免探活 / 监控 / 负载均衡把 30~60s 冷启动打起来。它只短路了这一个路径: +`/status`、`/panel/*` 等仍是透传的(两边都带 HF 的 `link` 头,可自证)。 + +两个直接后果: + +1. **千万别拿 `cli.3216gemini.dpdns.org/healthz` 做保活。** 它会稳定返回 200、 + 看起来一切健康,但 Space 完全没被唤醒 —— 48 小时一到照样被暂停。 + 保活必须打 Space 的直连域名(见仓库根的 `.github/workflows/space-keepalive.yml`)。 +2. **本 Worker 接管路由后,`/healthz` 的行为会变**:它会把 `/healthz` 原样透传给 Space, + 于是返回的是网关 JSON 而不是 `ok`。对"真探活"来说这是**更正确**的 + (能反映账号可用性),但如果你有监控依赖那个 `ok`,请先调整它。 + 想让本 Worker 也短路 `/healthz`,在 `worker.js` 的 `fetch()` 里加一条早返回即可。 + +## ⚠️ 免费版只有 5 个 Cron(最容易踩的坑) + +**Cloudflare 免费版的 cron 触发器限制是「每个账号 5 个」,不是每个 Worker 5 个。** +如果你账号里别的 Worker 已经占了 5 个,再部署本 Worker 就会看到这种**半成功**: + +```text +Uploaded workbuddy2api-hf-proxy (1.63 sec) +Deployed workbuddy2api-hf-proxy triggers (1.62 sec) +✘ [ERROR] Trigger configuration for "workbuddy2api-hf-proxy" was only partially updated: + Cron schedules: ... failed. + - This account has reached the Workers Free limit of 5 cron triggers per account. + [code: 10072] + Successful trigger changes were not rolled back. +``` + +**怎么读这段**:Worker 的**代码确实上传成功了**,但 **cron 没加上**,而且 +"Successful trigger changes were not rolled back" 只是说"已成功的部分不回滚"。 +结果是这个 Worker 处于**空转**状态——没有 cron,它不会去唤醒任何人。 +(`* * * * *` 这种多行 crons 数组里**每一行都算一个**触发器,别写成 5 行。) + +两条出路: + +### 出路 A:腾一个 CF cron 名额(保持 CF 原生方案) + +Dashboard → Workers & Pages → **逐个 Worker** 看 Settings → Triggers → Cron Triggers, +找出已经不用的那个删掉,然后重新 `npx wrangler deploy`。 + +想用 API 一次性列全账号的 cron(需要 `CLOUDFLARE_API_TOKEN`,权限含 *Workers Scripts:Read*): + +```bash +ACC=a75c5f86fdd1b75eb3e860f5436a7970 # 你的 account id +for w in $(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[].id'); do + echo "== $w" + curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts/$w/schedules" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '.result.schedules' +done +``` + +### 出路 B:改用 GitHub Actions(**推荐,无数量限制**) + +本仓库自带 `.github/workflows/space-keepalive.yml`,每 6 小时 ping 一次 Space, +零成本、不占 CF 名额。**而且 6 小时一次完全够用**——HF 的判定阈值是 48 小时, +所以 GitHub 定时任务偶发延迟几十分钟毫无影响(反过来说,本 Worker 里 `*/5` 的 +5 分钟粒度其实是**严重过量**的,纯粹因为免费额度够用才这么写)。 + +两条路不冲突:Worker 留着做反代,保活交给 Actions 也完全可以。 + +## 部署 + +> **先看上一节**:如果你账号的 5 个 cron 名额已满,`wrangler deploy` 会报 `code: 10072`, +> 这时按「出路 A/B」处理。 + +### 1. 前置条件 + +```bash +node -v # 需要 Node 18+ +npx wrangler -v # 首次会提示安装 wrangler +npx wrangler login # 登录你的 Cloudflare 账号 +``` + +### 2. 部署 + +```bash +cd D:\workbuddy2api-panel\deploy\cloudflare-worker +npx wrangler deploy +``` + +部署成功后 Worker 名称为 `workbuddy2api-hf-proxy`(见 `wrangler.toml` 的 `name`)。 + +### 3. 接线路由 + +`wrangler.toml` 里的 `routes` **默认是注释掉的**,两种接法任选其一: + +- **A. 交给 wrangler**:取消 `routes` 注释后重新 `wrangler deploy`,路由自动创建; +- **B. 在 Dashboard 手动加**:Workers → 你的 Worker → Settings → Domains & Routes → + 添加 `cli.3216gemini.dpdns.org/*`(推荐,避免误删线上路由)。 + +> ⚠️ 启用路由前确认该主机名当前没有指向别处的「已代理」DNS 记录, +> 否则 Worker 路由和 DNS 记录会互相打架。正确做法是该主机名由 Worker 路由接管 +> (DNS 里留一条指向 `100::` 的 AAAA 占位记录)。 + +### 4. 设置密钥(可选) + +只有开启「可选唤醒/签到」时才需要。**密钥永远不要写进 `wrangler.toml`,也不要提交。** + +```bash +npx wrangler secret put WB2A_API_KEY +# 粘贴网关的 api_key(与面板登录用的是同一个),回车即可 +``` + +未设置时 `scheduled()` 只做 `/healthz` 裸 ping,不发送任何鉴权请求。 + +## 验证 + +> **先接路由再验**:下面这些 URL 打的是 `cli.3216gemini.dpdns.org`,只有在该主机名已经 +> 路由到本 Worker 时才是"在验 Worker"。没接路由的话,你验到的仍是原来的链路。 +> +> **`*.workers.dev` 在部分网络下不可用**(国内常见:DNS 被污染,解析到不相干的 IP、 +> 443 不通)。所以 `wrangler deploy` 打印的 `https://..workers.dev` +> 在你这里可能**根本连不上**——这**不代表部署失败**,用自定义域名的路由来验即可。 + +```bash +# 探活:能看到网关自己的 JSON,且带 total / service 字段 +curl -i https://cli.3216gemini.dpdns.org/healthz + +# 后端自带的 302 应该原样到达客户端(不要出现 -L 跟随) +curl -i https://cli.3216gemini.dpdns.org/ + +# 面板 +curl -I https://cli.3216gemini.dpdns.org/panel/ + +# 流式:应逐块吐出,而不是憋到最后一次性返回 +curl -N -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' \ + https://cli.3216gemini.dpdns.org/v1/chat/completions +``` + +看到 `server: cloudflare` 与 `cf-ray` 属正常——那是 Cloudflare 边缘加的,不代表后端没响应; +后端的响应头(如 `service`、`x-*`)应当同时原样出现。 + +## 看日志 + +```bash +cd D:\workbuddy2api-panel\deploy\cloudflare-worker +npx wrangler tail # 实时请求日志 +npx wrangler tail --format pretty # 人类可读格式 +``` + +> **`wrangler tail` 报 `Error: Unexpected server response: 400`?** +> 它走的是 WebSocket,经过本地抓包代理(DevSidecar / Reqable 之类)时经常被挡掉。 +> 换成这几条路即可: +> +> - Dashboard → Workers & Pages → 你的 Worker → **Logs** → Live / Cron Events(最省事); +> - 关掉代理再 `npx wrangler tail`; +> - 或者干脆不依赖 tail:Cron 有没有在跑,**看 Space 的 `last_checkin_day` 有没有变成当天** +> 最直接(桶里的 `data/state.json`),或看 `npx wrangler deployments list` 的更新时间。 + +Cron 每次运行会打一行: + +``` +[keepalive] 2025-01-01T00:05:03.412Z /healthz -> HTTP 200 (183ms) +``` + +失败时是 `warn` 级别,同时带上首次失败原因,便于区分「偶发抖动」和「真的没醒」: + +``` +[keepalive] 2025-01-01T00:05:03.412Z /healthz -> FAILED status=0 (25013ms) first=ping timeout detail=ping timeout +``` + +### Cron 粒度说明 + +- `[triggers] crons = ["*/5 * * * *"]` = 每 5 分钟一次,**这是 Cloudflare Cron Triggers 的最小粒度**(不支持每分钟); +- 频率换算:每 5 分钟 ≈ 288 次/天,对比 48 小时暂停阈值,余量极大; +- Cron 触发时间有**秒级抖动**(官方说明可能延迟数秒到数十秒),对保活完全无影响; +- 如果只是防休眠,**不需要再加密**;调成 `*/30` 之类也够用,但 5 分钟最稳。 + +## 本地调试 + +**推荐:先用 `dev-server.mjs` 把代理逻辑验完,再考虑 deploy。** 它用 Node 起一个本地服务, +**原样 `import` 这个 worker.js**(不做任何改写),转发到真实的 HF Space——不需要 wrangler、 +不需要登录: + +```bash +cd deploy/cloudflare-worker +node dev-server.mjs # 监听 127.0.0.1:8787 +# PORT=9000 node dev-server.mjs # 换端口 +# ORIGIN=https://other.hf.space node dev-server.mjs +``` + +另开一个终端逐条验: + +```bash +curl -i http://127.0.0.1:8787/ # 期望 302 → /panel/(不要加 -L) +curl -i http://127.0.0.1:8787/healthz # 期望网关自己的 JSON(带 total / service) +curl -i http://127.0.0.1:8787/status # 期望 401 invalid_api_key(说明真的打到了网关) +curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器,看控制台日志 +``` + +`/__ping` 与 `/__cron` 是**本地专用**路径,永远不会被代理到源站,部署后的 Worker 里没有它们。 + +也可以走 wrangler(需要它自己的本地环境): + +```bash +npx wrangler dev +curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # 临时触发 Cron 处理器 +``` + +### `.dev.vars` + +本地调试需要的变量(尤其是 `WB2A_API_KEY`)放在同目录 `.dev.vars` 里,**不要提交**: + +```bash +cp .dev.vars.example .dev.vars # 然后按需填写 +``` + +格式为 `KEY="value"`,一行一个。`wrangler dev` 会自动读取该文件中的变量与密钥。 + +> ✅ **`.gitignore` 已经覆盖到位**(根目录规则):`.dev.vars` / `.dev.vars.*` / `.wrangler/` +> 全部被忽略,同时用 `!.dev.vars.example` 把**模板**放回版本库(模板里只有占位符)。 +> 四个可提交文件:`worker.js`、`wrangler.toml`、`.dev.vars.example`、本 `README.md`。 +> +> **任何 secret 都不得提交**,线上一律走 `wrangler secret put`。 + +## 环境变量一览 + +| 名称 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `ORIGIN` | vars | `https://a3216-gcli2api.hf.space` | 源站地址。换 Space 只改这里 | +| `ROOT_REDIRECT` | vars(注释中) | `/panel/` | `GET /` 的跳转目标;设成空串则关闭跳转、直接透传后端的 404 | +| `HEALTH_PATH` | vars(注释中) | `/healthz` | 保活探测路径 | +| `CHECKIN_CRON` | vars(注释中) | 空 = 关闭 | 只在此 Cron 表达式触发时做签到 | +| `CHECKIN_PATH` | vars(注释中) | 空 = 关闭 | 带 Bearer 调用的路径,如 `/panel/api/keepalive` | +| `WB2A_API_KEY` | **secret** | 未设置 | 面板/网关 Bearer 密钥,仅签到功能需要 | + +> `ORIGIN` 若填成非法 URL,Worker 会**回落到默认源站**而不是整体 500——配置拼错时仍能提供服务。 + +## 如果你已有自己的反代 Worker + +**不要替换掉你现有的反代逻辑**,只把「保活」那一小块搬过去即可,改动量极小: + +1. **加 `scheduled()` 处理器**:从 `worker.js` 里复制 `scheduled(event, env, ctx)` 整个函数, + 连同它依赖的 `DEFAULT_ORIGIN` / `HEALTH_PATH` / `PING_TIMEOUT_MS` / `PING_RETRY_DELAY_MS` + 常量、`resolveOrigin()` / `normalizePath()` 两个工具函数一起拿走。 + (可选签到那段如果不需要,连同 `CHECKIN_*` 一起删掉即可。) +2. **确认你的默认导出里有 `scheduled`**:必须是 + `export default { fetch(request, env, ctx) {...}, scheduled(event, env, ctx) {...} }` 这种形状。 + 如果你原来是 `export default { async fetch(...) }`,直接把 `scheduled` 加进同一个对象即可, + **不要**改成 `export { scheduled }` 之外的写法。 +3. **在 `wrangler.toml` 加上 trigger 块**: + + ```toml + [triggers] + crons = ["*/5 * * * *"] + ``` + +4. **重新部署**:`npx wrangler deploy`。改 `[triggers]` 必须重新部署才会生效, + 只改 Dashboard 里的 Cron 是临时的、下次部署会被覆盖。 + +反代部分如果你已经有实现,**保持原样**——本 Worker 的 `fetch()` 只是一个满足 +「不缓冲 / 不跟随 302 / 剥离逐跳头 / 透传 CORS」四条硬约束的参考实现, +你的版本只要满足同样四条,效果完全等价。 + +## 常见问题 + +### 代理后 `/v1/chat/completions` 变成一次性返回,没有流式效果? + +几乎一定是某处调用了 `await res.text()` / `res.json()` / 中间做了缓冲(包括自己包一层 +`TransformStream` 却忘了 flush)。确认直接 `return new Response(originResponse.body, ...)`。 + +### `GET /` 返回的是 `/panel/` 的内容,看不到 302? + +说明 `fetch` 的 `redirect: 'manual'` 丢了。默认的 `redirect: 'follow'` 会在 Worker 内部 +把 302 跟掉,客户端就永远看不到那次跳转。 + +### `GET /` 到底该返回 302 还是后端原始的 404? + +**302 → `/panel/`。** 这是实测出来的既有行为,值得记一笔:网关只注册了 `/panel/`, +所以**直连 Space 的 `GET /` 是 404**;但公网入口 `https://cli.3216gemini.dpdns.org/` +返回的却是 302 → `/panel/`。同一路径两种结果,说明这层跳转是 **CF 侧加的**(Worker 或 +Redirect Rule)。`worker.js` 因此也自带了这个跳转(`ROOT_REDIRECT`,默认 `/panel/`), +换用本 Worker 后直接打开域名首页的体验不变。 + +想对比两种行为: + +```bash +curl -i https://a3216-gcli2api.hf.space/ # 404(后端真实行为) +curl -i https://cli.3216gemini.dpdns.org/ # 302 → /panel/(入口层加的) +``` + +想把跳转关掉(严格透传后端):把 `ROOT_REDIRECT` 设成空串。 + +### 浏览器报 CORS 重复头错误? + +Worker 自己造了 `Access-Control-Allow-Origin`,而后端也返回了同一个头。 +删掉 Worker 里的 CORS 处理,让它纯透传。 + +### Cron 日志里一直是 503,是不是保活失败了? + +**不是。** 503 是后端「当前没有可用账号可服务」的业务语义,但只要它回了 HTTP, +Space 就是醒着的,保活目的已经达到。只有 `status=0`(超时/网络失败)才需要关注—— +那通常意味着正在冷启动,或者 Space 真的挂了。 + +### 想确认 Cron 到底有没有在跑? + +```bash +npx wrangler tail --format pretty # 等 5 分钟就能看到 [keepalive] 行 +``` + +或者 Dashboard → Workers → 你的 Worker → Logs → 筛选 Cron Events。 diff --git a/deploy/cloudflare-worker/dev-server.mjs b/deploy/cloudflare-worker/dev-server.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d0be76c4602d186ca34a531a0f19c093cddda7b9 --- /dev/null +++ b/deploy/cloudflare-worker/dev-server.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * worker.js 本地开发/验证服务器。 + * + * 为什么需要它:Worker 只有在部署到 Cloudflare 之后才会真正接管 + * cli.3216gemini.dpdns.org —— 万一代理逻辑写错,你的域名当场就废了。 + * 这个脚本用 Node 起一个本地 HTTP 服务,**原样执行同一个 worker.js** + * (直接 import,不做任何改写),把它转发到真实的 HF Space, + * 于是你可以在本地先把每一条路径验完,再决定要不要 deploy。 + * + * 用法: + * node dev-server.mjs # 监听 127.0.0.1:8787 + * PORT=9000 node dev-server.mjs + * ORIGIN=https://other-space.hf.space node dev-server.mjs + * + * 然后另开一个终端: + * curl -i http://127.0.0.1:8787/healthz # 应返回网关的 JSON + * curl -i http://127.0.0.1:8787/ # 应是 302 → /panel/(不被跟随) + * curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器(看日志) + * + * 注意: + * - 本脚本会真实请求 HF Space。若 Space 正在休眠,第一次 /healthz 会等 + * 30~60s 冷启动,这是正常现象(也正是 cron 保活要解决的问题)。 + * - /__cron 是本地专用路径,永远不会被代理到源站,部署后的 Worker 里没有它。 + * - 绝不把 .dev.vars 提交进版本库(见同目录 README)。 + */ + +import http from "node:http"; +import { Readable } from "node:stream"; +import worker from "./worker.js"; + +const PORT = Number(process.env.PORT || 8787); +const HOST = process.env.HOST || "127.0.0.1"; +const CRON = process.env.CRON || "*/5 * * * *"; + +/** 把 Node 的 IncomingMessage 头转成 Web Headers。 */ +function toWebHeaders(nodeHeaders) { + const h = new Headers(); + for (const [name, value] of Object.entries(nodeHeaders)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) h.append(name, v); + } else { + h.set(name, value); + } + } + return h; +} + +const server = http.createServer(async (req, res) => { + try { + exphost: { + // 本地专用:手动触发一次 scheduled(),用来验证保活/重试/日志逻辑。 + if (req.url === "/__cron") { + console.log(`[dev] 手动触发 scheduled() cron="${CRON}"`); + await worker.scheduled({ cron: CRON }, process.env, {}); + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: true, triggered: CRON }, null, 2) + "\n"); + break exphost; + } + + // 本地专用:探活,不会打到源站。 + if (req.url === "/__ping") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("dev-server alive\n"); + break exphost; + } + + const url = new URL(req.url, `http://${HOST}:${PORT}`); + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + + // Node 要求流式请求体显式声明 duplex:"half";Cloudflare Workers 不需要, + // 所以这个参数只在这里出现,worker.js 本身保持平台无关。 + const request = new Request(url, { + method: req.method, + headers: toWebHeaders(req.headers), + body: hasBody ? req : undefined, + duplex: hasBody ? "half" : undefined, + redirect: "manual", + }); + + const response = await worker.fetch(request, process.env, {}); + + const outHeaders = {}; + for (const [k, v] of response.headers) outHeaders[k] = v; + res.writeHead(response.status, outHeaders); + + if (response.body) { + // 关键:流式回传,不缓冲 —— SSE 必须这样,否则流式体验当场报废。 + await new Promise((resolve, reject) => { + Readable.fromWeb(response.body).pipe(res).on("finish", resolve).on("error", reject); + }); + } else { + res.end(); + } + } + } catch (err) { + console.error("[dev] 处理请求失败:", err); + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); + } + res.end(JSON.stringify({ error: "dev_server_error", detail: String(err && err.message) }) + "\n"); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`[dev] worker.js 本地验证服务: http://${HOST}:${PORT}`); + console.log(`[dev] 源站 ORIGIN = ${process.env.ORIGIN || "(默认 https://a3216-gcli2api.hf.space)"}`); + console.log("[dev] 试试:"); + console.log(`[dev] curl -i http://${HOST}:${PORT}/healthz`); + console.log(`[dev] curl -i http://${HOST}:${PORT}/`); + console.log(`[dev] curl -s http://${HOST}:${PORT}/__cron`); +}); diff --git a/deploy/cloudflare-worker/worker.js b/deploy/cloudflare-worker/worker.js new file mode 100644 index 0000000000000000000000000000000000000000..eb081c97c63ce6bd9d108011b232c591c6591918 --- /dev/null +++ b/deploy/cloudflare-worker/worker.js @@ -0,0 +1,347 @@ +/** + * WorkBuddy2API — Cloudflare Worker:Hugging Face Space 反向代理 + 定时保活 + * + * 作用有两个,缺一不可: + * 1. 反代:把 https://cli.3216gemini.dpdns.org/* 的流量原样转发到 HF Space + * (默认 https://a3216-gcli2api.hf.space,可用 ORIGIN 变量覆盖), + * 方法 / 路径 / query / 请求体 / 端到端请求头全部保留, + * 响应状态码、响应头、响应体原样回传。 + * 2. 保活:HF 免费 cpu-basic Space 连续 48 小时没有 HTTP 流量就会被暂停, + * 冷启动要 30–60s。scheduled() 定时打一次 /healthz 就能避免被暂停。 + * + * 关键实现约束(改动前请先读懂): + * - 响应用 `return originResponse.body` 直接流式回传,绝不对代理响应调用 + * .text() / .json() / .arrayBuffer()——一旦缓冲,/v1/chat/completions 的 + * SSE 流就会退化成"憋完再吐",流式体验当场报废。 + * - fetch 必须带 `redirect: 'manual'`。后端的 `GET /` 返回 302 到 /panel/, + * 这是给浏览器的,不能在 Worker 内部被跟随掉。 + * - Host 头不能被转发(Workers 里 Host 由 URL 决定);同时去掉逐跳头, + * 否则 Cloudflare 会拒绝或产生"重复头"类的诡异问题。 + * - CORS 全部透传后端的:后端(ASP.NET/Kestrel)已经返回宽松的 CORS 头, + * Worker 一律不要自己造 Access-Control-*,否则会出现重复头把浏览器搞挂。 + * - set-cookie 原样透传(浏览器看的是公网域名,后端很少下发 cookie)。 + * + * 部署:见同目录 README.md 与 wrangler.toml。 + * 作者备注:本文件刻意保持零依赖、单文件,方便直接贴进已有反代 Worker。 + */ + +/** + * 默认源站:HF Space 的直连域名。 + * 与 Space 名 a3216/gcli2api 对应(下划线在 HF 域名里写作连字符)。 + */ +const DEFAULT_ORIGIN = "https://a3216-gcli2api.hf.space"; + +/** 保活探测路径:/healthz 是网关自带的探活端点,无鉴权、开销最小。 */ +const HEALTH_PATH = "/healthz"; + +/** 单次保活请求的硬超时(ms)。冷启动时 503/超时都属正常,必须能快速失败。 */ +const PING_TIMEOUT_MS = 25_000; + +/** 首次失败后的重试间隔(ms)。 */ +const PING_RETRY_DELAY_MS = 3_000; + +/** + * 根路径跳转目标。 + * + * 为什么需要这个:网关只注册了 `/panel/`,`GET /` 在它那里是 404。而现有入口 + * `https://cli.3216gemini.dpdns.org/` 实测返回 **302 → /panel/**(实测对比: + * 直连 Space 的 `/` 是 404,公网域名却是 302 —— 说明这层跳转是 CF 侧加的)。 + * 换用本 Worker 后如果不复刻,直接打开域名首页就会从"进入面板"变成"404 页"。 + * 设为空字符串即可关掉。 + */ +const DEFAULT_ROOT_REDIRECT = "/panel/"; + +/** + * 逐跳头 + 宿主相关头,禁止转发到源站,也禁止从源站回传给客户端。 + * 取值范围:RFC 7230 的 hop-by-hop 头 + Workers 运行时自带的 cf-* 头 + host。 + * 统一小写比较。 + */ +const STRIP_HEADERS = new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "http2-settings", + "cf-connecting-ip", + "cf-connecting-ipv6", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "cf-worker", + "cf-ew-via", + "cf-pseudo-ipv4", + "cdn-loop", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", +]); + +/** + * 判断一个头名是否要被剥掉。 + * 除白名单外,再兜掉所有 `proxy-` / `cf-` / `x-forwarded-` 前缀的头——逐跳语义 + * 的扩展头基本都长这样,逐个列举容易漏。 + */ +function shouldStripHeader(name) { + const lower = name.toLowerCase(); + if (STRIP_HEADERS.has(lower)) return true; + if (lower.startsWith("proxy-")) return true; + if (lower.startsWith("cf-")) return true; + if (lower.startsWith("x-forwarded-")) return true; + return false; +} + +/** 过滤一组 Headers,返回新的 Headers。 */ +function sanitizeHeaders(headers) { + const out = new Headers(); + for (const [name, value] of headers) { + if (shouldStripHeader(name)) continue; + // append 而不是 set:set-cookie 可能有多条,set 会互相覆盖。 + out.append(name, value); + } + return out; +} + +/** + * 把入站请求的 URL 映射到源站 URL。 + * 只保留 path + search,绝不把公网 Host 带过去——源站只认自己的域名。 + */ +function buildOriginUrl(origin, requestUrl) { + const incoming = new URL(requestUrl); + const base = origin.endsWith("/") ? origin.slice(0, -1) : origin; + return base + incoming.pathname + incoming.search; +} + +/** 解析 ORIGIN 变量;非法值回落到默认源站,避免整个 Worker 因配置拼错而 500。 */ +function resolveOrigin(env) { + const raw = env && typeof env.ORIGIN === "string" ? env.ORIGIN.trim() : ""; + if (!raw) return DEFAULT_ORIGIN; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return DEFAULT_ORIGIN; + } + return parsed.origin; + } catch { + return DEFAULT_ORIGIN; + } +} + +/** 归一化可选的唤醒/签到路径:允许 "healthz"、"//healthz"、"/healthz"。 */ +function normalizePath(rawPath) { + const trimmed = String(rawPath || "").trim(); + if (!trimmed) return ""; + return trimmed.startsWith("/") ? trimmed : "/" + trimmed; +} + +export default { + /** + * 全量反向代理。 + * + * 注意 `fetch(request, { redirect: 'manual' })`:这里直接把 Request 对象交给 + * Cloudflare 的 fetch,但 URL 已经被改写成源站域名。因为 Request 的 URL 是 + * 不可变的,需要先 new Request 出来再传。 + */ + async fetch(request, env, ctx) { + const origin = resolveOrigin(env); + + // 根路径跳转:复刻现有入口的行为(见 DEFAULT_ROOT_REDIRECT 的说明)。 + // env.ROOT_REDIRECT 可以改目标;显式设成空串则关闭,直接透传。 + const rootRedirect = + env && typeof env.ROOT_REDIRECT === "string" + ? env.ROOT_REDIRECT.trim() + : DEFAULT_ROOT_REDIRECT; + if (rootRedirect) { + const incoming = new URL(request.url); + if (incoming.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { + const target = rootRedirect + incoming.search; + return new Response(null, { + status: 302, + headers: { location: target, "cache-control": "no-store" }, + }); + } + } + + const targetUrl = buildOriginUrl(origin, request.url); + + // 构造转发请求:保留方法、请求体(stream 形式透传,不读进内存)、 + // 过滤后的端到端请求头。Authorization / Content-Type / Accept 等全部保留。 + const forwardHeaders = sanitizeHeaders(request.headers); + + // 把访客真实 IP 补回去。上面按逐跳规则剥掉了全部 cf-*,其中就包括 + // CF-Connecting-IP——而网关的「运行日志 / 请求归档」是按 + // X-Forwarded-For 首段 → X-Real-IP → TCP 对端 的顺序取来源 IP 的, + // 三段全被剥掉后,面板里所有调用来源都会显示成 HF 边缘节点,排障时没法看。 + // 这里在过滤之后显式补成网关认的那两个头(不要补 CF-Connecting-IP 本身, + // 它会在经过 HF 边缘时被覆盖掉)。 + const visitorIP = request.headers.get("cf-connecting-ip"); + if (visitorIP) { + forwardHeaders.set("x-forwarded-for", visitorIP); + forwardHeaders.set("x-real-ip", visitorIP); + } + + const outbound = new Request(targetUrl, { + method: request.method, + headers: forwardHeaders, + // GET / HEAD 不允许带 body,其余一律流式透传。 + body: + request.method === "GET" || request.method === "HEAD" + ? undefined + : request.body, + redirect: "manual", + }); + + let originResponse; + try { + originResponse = await fetch(outbound); + } catch (err) { + // 源站不可达 / 握手失败。给一个明确的 502,附带便于排查的提示, + // 不要把它伪装成后端的错误响应。 + const detail = err && err.message ? err.message : String(err); + return new Response( + JSON.stringify({ + error: "bad_gateway", + message: "上游 Hugging Face Space 不可达(可能正在冷启动,30–60s 后重试)", + origin, + detail, + }) + "\n", + { + status: 502, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + + // 响应头同样过滤逐跳头;set-cookie 原样保留(可能多条,append 保证不丢)。 + const responseHeaders = sanitizeHeaders(originResponse.headers); + + // 关键:body 直接流式回传,不做任何缓冲。SSE 就必须这样。 + return new Response(originResponse.body, { + status: originResponse.status, + statusText: originResponse.statusText, + headers: responseHeaders, + }); + }, + + /** + * 定时保活:击败 HF 免费 Space 的 48 小时无流量暂停。 + * + * 行为: + * - 打 ORIGIN + /healthz(可用 env.HEALTH_PATH 覆盖); + * - 25s 硬超时(AbortController)——冷启动期间 503 或超时都算预期; + * - 失败后等 3s 重试一次; + * - 每次运行只打一行日志:时间 / 状态 / 耗时; + * - 任何异常都在内部吞掉:绝不从 scheduled 抛出(抛了也只会污染 Cron 日志)。 + * + * 可选(默认关闭):当 WB2A_API_KEY 已设置、且本次 Cron 触发带了签到标记时, + * 额外对 env.CHECKIN_PATH 发一次带 Bearer 的 POST。 + * 一般来说根本不必要——裸 ping /healthz 就足以阻止 Space 休眠,因为 HF 只看 + * "有没有 HTTP 请求",不关心响应内容。只有当 Space 自身存在"需要主动调用才 + * 会真正醒来"的业务逻辑时,才需要打开它。 + */ + async scheduled(event, env, ctx) { + const origin = resolveOrigin(env); + const healthPath = normalizePath(env && env.HEALTH_PATH) || HEALTH_PATH; + + const attempt = async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("ping timeout"), PING_TIMEOUT_MS); + const startedAt = Date.now(); + try { + const res = await fetch(origin + healthPath, { + method: "GET", + // 不让 Cloudflare 边缘缓存探活结果,否则 Space 可能返回 200 但其实没人打到它。 + cache: "no-store", + redirect: "manual", + signal: controller.signal, + headers: { + "user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)", + accept: "*/*", + }, + }); + return { ok: true, status: res.status, ms: Date.now() - startedAt }; + } catch (err) { + const detail = err && err.message ? err.message : String(err); + return { ok: false, status: 0, ms: Date.now() - startedAt, detail }; + } finally { + clearTimeout(timer); + } + }; + + let result = await attempt(); + if (!result.ok || result.status >= 500) { + // 503 是网关"没有可用账号可服务"的语义,对保活而言无所谓—— + // 只要它回了 HTTP,Space 就是醒着的。但 5xx / 网络失败仍重试一次, + // 用来区分"偶发抖动"和"确实没醒"。 + await new Promise((resolve) => setTimeout(resolve, PING_RETRY_DELAY_MS)); + const retry = await attempt(); + result = { + ...retry, + // 首次失败原因在日志里一并带上,方便判断是不是冷启动。 + firstAttempt: result.detail || `HTTP ${result.status}`, + }; + } + + const stamp = new Date().toISOString(); + if (result.ok && result.status < 500) { + console.log( + `[keepalive] ${stamp} ${healthPath} -> HTTP ${result.status} (${result.ms}ms)`, + ); + } else { + console.warn( + `[keepalive] ${stamp} ${healthPath} -> FAILED status=${result.status} ` + + `(${result.ms}ms) first=${result.firstAttempt || "-"} detail=${result.detail || "-"}`, + ); + } + + // —— 可选的带鉴权签到(默认关闭)—— + // 开启条件三选三:设了 WB2A_API_KEY、设了 CHECKIN_PATH、且本次触发带标记。 + // 标记来源是 wrangler.toml 里给 Cron 表达式加的注释式约定(见该文件), + // 这里用 event.cron 与 env.CHECKIN_CRON 匹配;不配就永远不触发。 + const apiKey = env && env.WB2A_API_KEY; + const checkinPath = normalizePath(env && env.CHECKIN_PATH); + const cronExpr = event && typeof event.cron === "string" ? event.cron : ""; + const checkinCron = env && env.CHECKIN_CRON ? String(env.CHECKIN_CRON) : ""; + + if (!apiKey || !checkinPath || !checkinCron || cronExpr !== checkinCron) { + return; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("checkin timeout"), PING_TIMEOUT_MS); + const startedAt = Date.now(); + try { + const res = await fetch(origin + checkinPath, { + method: "POST", + cache: "no-store", + redirect: "manual", + signal: controller.signal, + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + "user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)", + accept: "application/json", + }, + body: "{}", + }); + console.log( + `[checkin] ${new Date().toISOString()} ${checkinPath} -> HTTP ${res.status} ` + + `(${Date.now() - startedAt}ms)`, + ); + } catch (err) { + // 同样不抛出:签到失败不该让 Cron 报错,保活本身已经完成了。 + const detail = err && err.message ? err.message : String(err); + console.warn( + `[checkin] ${new Date().toISOString()} ${checkinPath} -> FAILED ` + + `(${Date.now() - startedAt}ms) detail=${detail}`, + ); + } finally { + clearTimeout(timer); + } + }, +}; diff --git a/deploy/cloudflare-worker/wrangler.toml b/deploy/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000000000000000000000000000000000000..976a456bcd00942aeebd3d4a63eaaadf35e7372f --- /dev/null +++ b/deploy/cloudflare-worker/wrangler.toml @@ -0,0 +1,81 @@ +# WorkBuddy2API — Cloudflare Worker 配置 +# 本地路径:D:\workbuddy2api-panel\deploy\cloudflare-worker\worker.js +# +# 用法: +# cd D:\workbuddy2api-panel\deploy\cloudflare-worker +# npx wrangler deploy +# +# 注意:任何 secret 一律用 `wrangler secret put` 写入,绝对不要落到本文件里。 +# 本目录四个文件均可提交:worker.js / wrangler.toml / .dev.vars.example / README.md。 +# `.dev.vars`、`.dev.vars.*`、`.wrangler/` 已被根目录 .gitignore 忽略 +# (同时用 `!.dev.vars.example` 把模板放回版本库)。 + +name = "workbuddy2api-hf-proxy" +main = "worker.js" + +# 兼容性日期:固定在一个已发布版本上,不要写得太新也不要写 today。 +# 2025-01-01 已支持 Request/Response 流式透传、AbortController、 +# `redirect: "manual"` 与 crypto/Headers 全套 API,本 Worker 只用这些。 +compatibility_date = "2025-01-01" + +# --------------------------------------------------------------------------- +# 环境变量(非机密) +# --------------------------------------------------------------------------- +[vars] +# 源站:HF Space 的直连域名。改 Space 时只改这里,不用动 worker.js。 +ORIGIN = "https://a3216-gcli2api.hf.space" + +# 保活探测路径,默认 /healthz(网关自带探活端点,无鉴权、开销最小)。 +# HEALTH_PATH = "/healthz" + +# GET / 的跳转目标,默认 /panel/。 +# 网关只注册了 /panel/,直连 Space 的 GET / 是 404;而公网入口 +# cli.3216gemini.dpdns.org/ 实测返回 302 → /panel/ —— 说明这层跳转是 CF 侧加的。 +# 本 Worker 复刻该行为,换用后首页体验不变。设成空串则关闭跳转、严格透传后端 404。 +# ROOT_REDIRECT = "/panel/" + +# --- 以下为「可选唤醒/签到」相关,默认全部注释掉 = 功能关闭 --- +# 只有当你确实需要「主动调用某个接口才算真正唤醒」时才打开; +# 单纯的防休眠只需要 /healthz 的裸 ping,不需要任何鉴权。 +# +# CHECKIN_CRON:只在这个 Cron 表达式触发的运行里做签到。 +# CHECKIN_PATH:带 Bearer 调用的路径,例如 "/panel/api/keepalive"。 +# CHECKIN_CRON = "0 * * * *" +# CHECKIN_PATH = "/panel/api/keepalive" + +# --------------------------------------------------------------------------- +# 定时触发:Hugging Face 免费 cpu-basic Space 连续 48 小时无 HTTP 流量即暂停, +# 冷启动 30–60 秒。每 5 分钟打一次 /healthz 即可彻底避免被暂停。 +# 5 分钟是 Cloudflare Cron Triggers 的最小粒度,取最小值最稳。 +# --------------------------------------------------------------------------- +[triggers] +crons = ["*/5 * * * *"] + +# --------------------------------------------------------------------------- +# 路由:把公网入口 cli.3216gemini.dpdns.org 全部流量交给本 Worker。 +# 该域名必须在本 Cloudflare 账号下(zone: 3216gemini.dpdns.org)。 +# +# 两种接法,任选其一: +# A) 用 wrangler 管理路由——取消下面注释后 `wrangler deploy` 会自动建路由; +# B) 在 Cloudflare Dashboard → Workers → 你的 Worker → Settings → Domains & +# Routes 里手动添加,本文件保持注释即可(推荐,避免误删线上路由)。 +# +# ⚠️ 启用路由前请确认:该主机名当前的 DNS 记录不能同时是「已代理指向别处」的 +# A/AAAA/CNAME,否则 Worker 路由与 DNS 记录会打架。正确做法是让该主机名 +# 由 Worker 路由接管(DNS 里通常留一条指向 100:: 的 AAAA 占位记录)。 +# +# routes = [ +# { pattern = "cli.3216gemini.dpdns.org/*", zone_name = "3216gemini.dpdns.org" } +# ] + +# --------------------------------------------------------------------------- +# 机密(不要写进本文件、不要提交) +# --------------------------------------------------------------------------- +# WB2A_API_KEY —— 面板/网关的 Bearer 密钥。仅「可选签到」功能需要它。 +# npx wrangler secret put WB2A_API_KEY +# 未设置时,scheduled() 只做 /healthz 裸 ping,不发送任何鉴权请求。 +# +# 本地调试用 `.dev.vars`(同目录,格式 KEY="value",见 .dev.vars.example)。 +# 该文件与 `.wrangler/` 都已被根目录 .gitignore 忽略,但仍请自行确认不要 +# 把真实密钥提交上去;线上请用 `wrangler secret put`。 +# WB2A_API_KEY="你的密钥" diff --git a/docker-compose.yml b/docker-compose.yml index d60ba24a77c723a9738a01add4f83d48248d8490..33c59f221337e292529705d0dd7dc79873a32aa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,15 @@ -# 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。 -services: - wb2api: - build: . - container_name: workbuddy2api - restart: unless-stopped - environment: - - TZ=Asia/Shanghai - ports: - - "7861:7861" - volumes: - # 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致: - # storage/config.json、storage/auths/、storage/data/ - - ./storage:/app/storage - - ./storage/config.json:/app/config.json +# 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。 +services: + wb2api: + build: . + container_name: workbuddy2api + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "7861:7861" + volumes: + # 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致: + # storage/config.json、storage/auths/、storage/data/ + - ./storage:/app/storage + - ./storage/config.json:/app/config.json diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ce68a3b236ec1ddb45efee33bd31a597671d338d..1066b545a135ed102638988a7541ffb0d24e133c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,69 +1,69 @@ -#!/bin/sh -# WorkBuddy2API Panel — Hugging Face Spaces 入口脚本 -# -# 持久化模型: -# HF Storage Bucket(私有) -> 挂载到容器 /app/storage -# /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库) -# /app/storage/auths/*.json 账号凭证 -# /app/storage/data/*.json 水位/用量/模型探测/请求归档 -# -# 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。 -# 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。 -set -eu - -PERSIST="${WB2A_PERSIST_DIR:-/app/storage}" -TEMPLATE=/app/config.json -CFG="$TEMPLATE" - -echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" - -if [ -d "$PERSIST" ]; then - echo "[entrypoint] persistent volume detected at $PERSIST" - mkdir -p "$PERSIST/auths" "$PERSIST/data" - - # 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的 - if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \ - && mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \ - && rm -f "$PERSIST/.wb2a-rename-probe"; then - echo "[entrypoint] storage self-test: write+rename OK" - else - echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2 - rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true - fi - - if [ ! -f "$PERSIST/config.json" ]; then - if [ -f "$TEMPLATE" ]; then - echo "[entrypoint] no config.json on the volume -> seeding from image template" - cp "$TEMPLATE" "$PERSIST/config.json" - else - echo "[entrypoint] no config.json on the volume and no image template" >&2 - fi - fi - [ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json" -else - echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2 - mkdir -p /app/auths /app/data -fi - -# 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。 -# 空间是公网可达的,绝不允许以裸奔状态启动。 -if [ -n "${WB2A_API_KEY:-}" ]; then - echo "[entrypoint] api_key supplied via WB2A_API_KEY env" -else - KEY=$(python3 -c 'import json,sys -try: - print(json.load(open(sys.argv[1])).get("api_key") or "") -except Exception: - print("")' "$CFG" 2>/dev/null || true) - if [ -z "$KEY" ]; then - echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2 - echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2 - echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2 - exit 1 - fi - echo "[entrypoint] api_key loaded from config file" -fi - -echo "[entrypoint] config: $CFG" -echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)" -exec /app/wb2api -config "$CFG" +#!/bin/sh +# WorkBuddy2API Panel — Hugging Face Spaces 入口脚本 +# +# 持久化模型: +# HF Storage Bucket(私有) -> 挂载到容器 /app/storage +# /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库) +# /app/storage/auths/*.json 账号凭证 +# /app/storage/data/*.json 水位/用量/模型探测/请求归档 +# +# 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。 +# 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。 +set -eu + +PERSIST="${WB2A_PERSIST_DIR:-/app/storage}" +TEMPLATE=/app/config.json +CFG="$TEMPLATE" + +echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + +if [ -d "$PERSIST" ]; then + echo "[entrypoint] persistent volume detected at $PERSIST" + mkdir -p "$PERSIST/auths" "$PERSIST/data" + + # 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的 + if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \ + && mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \ + && rm -f "$PERSIST/.wb2a-rename-probe"; then + echo "[entrypoint] storage self-test: write+rename OK" + else + echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2 + rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true + fi + + if [ ! -f "$PERSIST/config.json" ]; then + if [ -f "$TEMPLATE" ]; then + echo "[entrypoint] no config.json on the volume -> seeding from image template" + cp "$TEMPLATE" "$PERSIST/config.json" + else + echo "[entrypoint] no config.json on the volume and no image template" >&2 + fi + fi + [ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json" +else + echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2 + mkdir -p /app/auths /app/data +fi + +# 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。 +# 空间是公网可达的,绝不允许以裸奔状态启动。 +if [ -n "${WB2A_API_KEY:-}" ]; then + echo "[entrypoint] api_key supplied via WB2A_API_KEY env" +else + KEY=$(python3 -c 'import json,sys +try: + print(json.load(open(sys.argv[1])).get("api_key") or "") +except Exception: + print("")' "$CFG" 2>/dev/null || true) + if [ -z "$KEY" ]; then + echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2 + echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2 + echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2 + exit 1 + fi + echo "[entrypoint] api_key loaded from config file" +fi + +echo "[entrypoint] config: $CFG" +echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)" +exec /app/wb2api -config "$CFG" diff --git a/go.mod b/go.mod index df5814506b73a6dcc709c08b3c114a1132061cc9..b15e48404bd47125c67b4538423322c8b1ccbad6 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ -module github.com/linguo2625469/workbuddy2api-panel - -go 1.22.5 - -require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect -) +module github.com/linguo2625469/workbuddy2api-panel + +go 1.22.5 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum index ca30962c24ecc6c4b9bda493feb2ce6d99732e61..d460412d4c891b643ebb8c4232f334234fff1520 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,12 @@ -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 109dc89d4ed997ee6565500b4e4ae2b881098336..6a94d7d4fdf2b06ce4a09ae52888ee16121bac12 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,378 +1,378 @@ -// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态), -// 提供 refresh 后的原子写回。 -package auth - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "log" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" -) - -// Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。 -type Auth struct { - // mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。 - mu sync.Mutex - - AccessToken string - RefreshToken string - ExpiresAt int64 // Unix 秒 - Domain string - // realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。 - // 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。 - // - // 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用 - // 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。 - realm string - UID string - EnterpriseID string - Nickname string - FilePath string // 来源文件;refresh 后原子写回此处 - - // DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。 - // 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。 - // 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形 - // 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。 - DeviceToken string -} - -// Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。 -func (a *Auth) Lock() { a.mu.Lock() } - -// Unlock 释放 a.Lock 获取的锁。 -func (a *Auth) Unlock() { a.mu.Unlock() } - -// AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。 -// -// 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt -// (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造 -// (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models / -// global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发: -// Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关), -// 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的 -// 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。 -func (a *Auth) AccessTokenValue() string { - if a == nil { - return "" - } - a.mu.Lock() - defer a.mu.Unlock() - return a.AccessToken -} - -// DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。 -func (a *Auth) DomainValue() string { - if a == nil { - return "" - } - a.mu.Lock() - defer a.mu.Unlock() - return a.Domain -} - -// RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内 -// 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的 -// `a.RefreshToken == ""`)必须经此取值,勿直读字段。 -func (a *Auth) RefreshTokenValue() string { - if a == nil { - return "" - } - a.mu.Lock() - defer a.mu.Unlock() - return a.RefreshToken -} - -// globalEnabled 全局开关:global realm 是否路由(D5 双保险)。 -// 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/ -// domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭 -// → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai -// 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。 -var globalEnabled atomic.Bool - -func init() { globalEnabled.Store(true) } - -// SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。 -func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) } - -// GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。 -func GlobalEnabled() bool { return globalEnabled.Load() } - -// Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global", -// 否则 "cn"。显式 global 优先于 domain 回落(D1)。 -// 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。 -// 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。 -func (a *Auth) Realm() string { - a.mu.Lock() - defer a.mu.Unlock() - return a.realmLocked() -} - -// realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入, -// 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内 -// 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。 -func (a *Auth) realmLocked() string { - if !globalEnabled.Load() { - return "cn" - } - if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) { - return "global" - } - return "cn" -} - -// ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断 -// (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定); -// domain 也为空 → "cn"(老 CN 凭证零回归)。 -func ResolveRealm(explicit, domain string) string { - if r := strings.TrimSpace(explicit); r != "" { - return r - } - if isGlobalDomain(domain) { - return "global" - } - return "cn" -} - -// BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」 -// 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。 -// -// 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门 -// (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证 -// (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。 -func (a *Auth) BackfillRealm() (bool, string) { - a.mu.Lock() - defer a.mu.Unlock() - if strings.TrimSpace(a.realm) != "" { - return false, a.realm - } - r := ResolveRealm("", a.Domain) - a.realm = r - return true, r -} - -// RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。 -func (a *Auth) RealmStored() string { - a.mu.Lock() - defer a.mu.Unlock() - return a.realm -} - -// BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了 -// global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global, -// 非法值报错(防写脏)。返回是否发生变更。 -func BackfillRealmFor(a *Auth, realm string) (bool, error) { - if a == nil { - return false, fmt.Errorf("nil auth") - } - switch strings.TrimSpace(realm) { - case "cn", "global": - default: - return false, fmt.Errorf("realm must be cn/global, got %q", realm) - } - a.mu.Lock() - defer a.mu.Unlock() - if a.realm == realm { - return false, nil - } - a.realm = realm - return true, nil -} - -// IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 -func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } - -// isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 -// 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 -func isGlobalDomain(d string) bool { - d = strings.ToLower(strings.TrimSpace(d)) - return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") -} - -// NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。 -func (a *Auth) NeedsRefresh(within time.Duration) bool { - a.mu.Lock() - defer a.mu.Unlock() - if a.ExpiresAt <= 0 { - return true - } - return time.Now().Add(within).Unix() >= a.ExpiresAt -} - -// Parse 兼容两种磁盘形态: -// -// 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出) -// 扁平形 {"accessToken":...,"uid":...} (手写/旧版) -func Parse(raw []byte) (*Auth, error) { - if len(raw) == 0 { - return nil, fmt.Errorf("empty auth storage") - } - var probe map[string]json.RawMessage - if err := json.Unmarshal(raw, &probe); err != nil { - return nil, fmt.Errorf("storage_parse_error: %w", err) - } - var a Auth - if _, nested := probe["auth"]; nested { - var n struct { - Auth struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresAt int64 `json:"expiresAt"` - Domain string `json:"domain"` - Realm string `json:"realm"` - } `json:"auth"` - Account struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - } `json:"account"` - // DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。 - DeviceToken string `json:"device_token"` - } - if err := json.Unmarshal(raw, &n); err != nil { - return nil, fmt.Errorf("storage_parse_error: %w", err) - } - a = Auth{ - AccessToken: n.Auth.AccessToken, - RefreshToken: n.Auth.RefreshToken, - ExpiresAt: n.Auth.ExpiresAt, - Domain: n.Auth.Domain, - realm: n.Auth.Realm, - UID: n.Account.UID, - EnterpriseID: n.Account.EnterpriseID, - Nickname: n.Account.Nickname, - DeviceToken: n.DeviceToken, - } - } else { - var f struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresAt int64 `json:"expiresAt"` - Domain string `json:"domain"` - Realm string `json:"realm"` - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - DeviceToken string `json:"device_token"` - } - if err := json.Unmarshal(raw, &f); err != nil { - return nil, fmt.Errorf("storage_parse_error: %w", err) - } - a = Auth{ - AccessToken: f.AccessToken, - RefreshToken: f.RefreshToken, - ExpiresAt: f.ExpiresAt, - Domain: f.Domain, - realm: f.Realm, - UID: f.UID, - EnterpriseID: f.EnterpriseID, - Nickname: f.Nickname, - DeviceToken: f.DeviceToken, - } - } - if strings.TrimSpace(a.AccessToken) == "" { - return nil, fmt.Errorf("parse_error: missing accessToken") - } - return &a, nil -} - -// SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。 -// 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。 -// 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。 -func (a *Auth) SaveAtomic() error { - a.mu.Lock() - defer a.mu.Unlock() - if strings.TrimSpace(a.AccessToken) == "" { - return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID) - } - if a.FilePath == "" { - return fmt.Errorf("no FilePath set") - } - doc := map[string]any{ - "auth": map[string]any{ - "accessToken": a.AccessToken, - "refreshToken": a.RefreshToken, - "expiresAt": a.ExpiresAt, - "domain": a.Domain, - "realm": a.realm, - }, - "account": map[string]any{ - "uid": a.UID, - "enterpriseId": a.EnterpriseID, - "nickname": a.Nickname, - }, - } - // DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键 - // (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。 - if a.DeviceToken != "" { - doc["device_token"] = a.DeviceToken - } - raw, err := json.MarshalIndent(doc, "", " ") - if err != nil { - return err - } - tmp := a.FilePath + ".tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - // Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001) - // 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。 - msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err) - if errors.Is(err, fs.ErrPermission) { - msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" + - "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" + - "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" + - "3) compose 设 user: \"0:0\" 以 root 运行)" - } - return errors.New(msg) - } - return os.Rename(tmp, a.FilePath) -} - -// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。 -// 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic -// 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续), -// 避免历史 auth 目录个别文件不可写时整个服务起不来。 -func LoadDir(dir string) ([]*Auth, error) { - files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json")) - if err != nil { - return nil, err - } - // seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零) - // 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。 - seenUID := make(map[string]string, len(files)) - var out []*Auth - for _, f := range files { - raw, err := os.ReadFile(f) - if err != nil { - continue - } - a, err := Parse(raw) - if err != nil { - continue - } - a.FilePath = f - if prev, ok := seenUID[a.UID]; ok { - log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)", - logfmt.Label(a.UID, a.Nickname), prev, f) - } - seenUID[a.UID] = f - if a.RealmStored() == "" { - if changed, r := a.BackfillRealm(); changed { - if err := a.SaveAtomic(); err != nil { - log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err) - } else if r == "global" { - log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain) - } - } - } - out = append(out, a) - } - return out, nil -} +// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态), +// 提供 refresh 后的原子写回。 +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" +) + +// Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。 +type Auth struct { + // mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。 + mu sync.Mutex + + AccessToken string + RefreshToken string + ExpiresAt int64 // Unix 秒 + Domain string + // realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。 + // 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。 + // + // 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用 + // 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。 + realm string + UID string + EnterpriseID string + Nickname string + FilePath string // 来源文件;refresh 后原子写回此处 + + // DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。 + // 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。 + // 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形 + // 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。 + DeviceToken string +} + +// Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。 +func (a *Auth) Lock() { a.mu.Lock() } + +// Unlock 释放 a.Lock 获取的锁。 +func (a *Auth) Unlock() { a.mu.Unlock() } + +// AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。 +// +// 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt +// (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造 +// (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models / +// global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发: +// Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关), +// 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的 +// 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。 +func (a *Auth) AccessTokenValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.AccessToken +} + +// DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。 +func (a *Auth) DomainValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.Domain +} + +// RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内 +// 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的 +// `a.RefreshToken == ""`)必须经此取值,勿直读字段。 +func (a *Auth) RefreshTokenValue() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.RefreshToken +} + +// globalEnabled 全局开关:global realm 是否路由(D5 双保险)。 +// 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/ +// domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭 +// → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai +// 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。 +var globalEnabled atomic.Bool + +func init() { globalEnabled.Store(true) } + +// SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。 +func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) } + +// GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。 +func GlobalEnabled() bool { return globalEnabled.Load() } + +// Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global", +// 否则 "cn"。显式 global 优先于 domain 回落(D1)。 +// 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。 +// 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。 +func (a *Auth) Realm() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.realmLocked() +} + +// realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入, +// 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内 +// 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。 +func (a *Auth) realmLocked() string { + if !globalEnabled.Load() { + return "cn" + } + if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) { + return "global" + } + return "cn" +} + +// ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断 +// (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定); +// domain 也为空 → "cn"(老 CN 凭证零回归)。 +func ResolveRealm(explicit, domain string) string { + if r := strings.TrimSpace(explicit); r != "" { + return r + } + if isGlobalDomain(domain) { + return "global" + } + return "cn" +} + +// BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」 +// 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。 +// +// 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门 +// (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证 +// (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。 +func (a *Auth) BackfillRealm() (bool, string) { + a.mu.Lock() + defer a.mu.Unlock() + if strings.TrimSpace(a.realm) != "" { + return false, a.realm + } + r := ResolveRealm("", a.Domain) + a.realm = r + return true, r +} + +// RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。 +func (a *Auth) RealmStored() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.realm +} + +// BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了 +// global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global, +// 非法值报错(防写脏)。返回是否发生变更。 +func BackfillRealmFor(a *Auth, realm string) (bool, error) { + if a == nil { + return false, fmt.Errorf("nil auth") + } + switch strings.TrimSpace(realm) { + case "cn", "global": + default: + return false, fmt.Errorf("realm must be cn/global, got %q", realm) + } + a.mu.Lock() + defer a.mu.Unlock() + if a.realm == realm { + return false, nil + } + a.realm = realm + return true, nil +} + +// IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 +func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } + +// isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 +// 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 +func isGlobalDomain(d string) bool { + d = strings.ToLower(strings.TrimSpace(d)) + return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") +} + +// NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。 +func (a *Auth) NeedsRefresh(within time.Duration) bool { + a.mu.Lock() + defer a.mu.Unlock() + if a.ExpiresAt <= 0 { + return true + } + return time.Now().Add(within).Unix() >= a.ExpiresAt +} + +// Parse 兼容两种磁盘形态: +// +// 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出) +// 扁平形 {"accessToken":...,"uid":...} (手写/旧版) +func Parse(raw []byte) (*Auth, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty auth storage") + } + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + var a Auth + if _, nested := probe["auth"]; nested { + var n struct { + Auth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Domain string `json:"domain"` + Realm string `json:"realm"` + } `json:"auth"` + Account struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } `json:"account"` + // DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。 + DeviceToken string `json:"device_token"` + } + if err := json.Unmarshal(raw, &n); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + a = Auth{ + AccessToken: n.Auth.AccessToken, + RefreshToken: n.Auth.RefreshToken, + ExpiresAt: n.Auth.ExpiresAt, + Domain: n.Auth.Domain, + realm: n.Auth.Realm, + UID: n.Account.UID, + EnterpriseID: n.Account.EnterpriseID, + Nickname: n.Account.Nickname, + DeviceToken: n.DeviceToken, + } + } else { + var f struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Domain string `json:"domain"` + Realm string `json:"realm"` + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + DeviceToken string `json:"device_token"` + } + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("storage_parse_error: %w", err) + } + a = Auth{ + AccessToken: f.AccessToken, + RefreshToken: f.RefreshToken, + ExpiresAt: f.ExpiresAt, + Domain: f.Domain, + realm: f.Realm, + UID: f.UID, + EnterpriseID: f.EnterpriseID, + Nickname: f.Nickname, + DeviceToken: f.DeviceToken, + } + } + if strings.TrimSpace(a.AccessToken) == "" { + return nil, fmt.Errorf("parse_error: missing accessToken") + } + return &a, nil +} + +// SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。 +// 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。 +// 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。 +func (a *Auth) SaveAtomic() error { + a.mu.Lock() + defer a.mu.Unlock() + if strings.TrimSpace(a.AccessToken) == "" { + return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID) + } + if a.FilePath == "" { + return fmt.Errorf("no FilePath set") + } + doc := map[string]any{ + "auth": map[string]any{ + "accessToken": a.AccessToken, + "refreshToken": a.RefreshToken, + "expiresAt": a.ExpiresAt, + "domain": a.Domain, + "realm": a.realm, + }, + "account": map[string]any{ + "uid": a.UID, + "enterpriseId": a.EnterpriseID, + "nickname": a.Nickname, + }, + } + // DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键 + // (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。 + if a.DeviceToken != "" { + doc["device_token"] = a.DeviceToken + } + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + tmp := a.FilePath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + // Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001) + // 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。 + msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err) + if errors.Is(err, fs.ErrPermission) { + msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" + + "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" + + "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" + + "3) compose 设 user: \"0:0\" 以 root 运行)" + } + return errors.New(msg) + } + return os.Rename(tmp, a.FilePath) +} + +// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。 +// 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic +// 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续), +// 避免历史 auth 目录个别文件不可写时整个服务起不来。 +func LoadDir(dir string) ([]*Auth, error) { + files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json")) + if err != nil { + return nil, err + } + // seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零) + // 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。 + seenUID := make(map[string]string, len(files)) + var out []*Auth + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + continue + } + a, err := Parse(raw) + if err != nil { + continue + } + a.FilePath = f + if prev, ok := seenUID[a.UID]; ok { + log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)", + logfmt.Label(a.UID, a.Nickname), prev, f) + } + seenUID[a.UID] = f + if a.RealmStored() == "" { + if changed, r := a.BackfillRealm(); changed { + if err := a.SaveAtomic(); err != nil { + log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err) + } else if r == "global" { + log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain) + } + } + } + out = append(out, a) + } + return out, nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 20254788ece4227ac5518bce46ef921d710edd0c..fa47c123b4325a3e85763e815029cff003530a16 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,266 +1,266 @@ -package auth - -import ( - "io" - "log" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestParseNested(t *testing.T) { - raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`) - sa, err := Parse(raw) - if err != nil { - t.Fatalf("nested parse err: %v", err) - } - if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 { - t.Errorf("tokens: %+v", sa) - } - if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" { - t.Errorf("account: %+v", sa) - } -} - -func TestParseFlat(t *testing.T) { - raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`) - sa, err := Parse(raw) - if err != nil || sa.UID != "u2" || sa.AccessToken != "at" { - t.Fatalf("flat: %+v %v", sa, err) - } -} - -func TestParseMissingToken(t *testing.T) { - if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil { - t.Fatal("want error for missing accessToken") - } -} - -func TestSaveAtomicRoundtrip(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "workbuddy-u1.json") - a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000, - UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp} - if err := a.SaveAtomic(); err != nil { - t.Fatalf("save: %v", err) - } - if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) { - t.Error("tmp file should not remain") - } - raw, err := os.ReadFile(fp) - if err != nil { - t.Fatalf("read: %v", err) - } - b, err := Parse(raw) - if err != nil { - t.Fatalf("reparse: %v", err) - } - if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" { - t.Errorf("roundtrip: %+v", b) - } -} - -// TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载, -// 解析失败的文件静默跳过。 -func TestLoadDirLoadsAllValid(t *testing.T) { - dir := t.TempDir() - cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}` - other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}` - bad := `not json` - os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600) - os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600) - os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600) - - list, err := LoadDir(dir) - if err != nil { - t.Fatalf("load: %v", err) - } - if len(list) != 2 { - t.Fatalf("want 2 valid accounts, got %+v", list) - } - for _, a := range list { - if a.FilePath == "" { - t.Error("FilePath not set") - } - } -} - -func TestNeedsRefresh(t *testing.T) { - a := &Auth{ExpiresAt: 0} - if !a.NeedsRefresh(0) { - t.Error("zero expiry should need refresh") - } - a.ExpiresAt = 9999999999 - if a.NeedsRefresh(0) { - t.Error("far future should not need refresh") - } -} - -// TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。 -func TestParseDeviceToken(t *testing.T) { - nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`) - sa, err := Parse(nested) - if err != nil { - t.Fatalf("nested parse: %v", err) - } - if sa.DeviceToken != "dev-tok-nested" { - t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested") - } - - flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`) - fa, err := Parse(flat) - if err != nil { - t.Fatalf("flat parse: %v", err) - } - if fa.DeviceToken != "dev-tok-flat" { - t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat") - } -} - -// TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。 -func TestSaveAtomicPreservesDeviceToken(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "workbuddy-dt.json") - a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, - UID: "u1", DeviceToken: "persisted-tok", FilePath: fp} - if err := a.SaveAtomic(); err != nil { - t.Fatalf("save: %v", err) - } - raw, err := os.ReadFile(fp) - if err != nil { - t.Fatalf("read: %v", err) - } - b, err := Parse(raw) - if err != nil { - t.Fatalf("reparse: %v", err) - } - if b.DeviceToken != "persisted-tok" { - t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok") - } -} - -// TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动 -// backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。 -func TestLoadDirBackfillsRealm(t *testing.T) { - t.Parallel() - dir := t.TempDir() - fixtures := map[string]string{ - "workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`, - "workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`, - // 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn - "workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`, - } - for name, body := range fixtures { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - - list, err := LoadDir(dir) - if err != nil { - t.Fatalf("load: %v", err) - } - if len(list) != 3 { - t.Fatalf("want 3 accounts, got %d", len(list)) - } - want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"} - for _, a := range list { - // 内存态已补标识 - if got := a.RealmStored(); got != want[a.UID] { - t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID]) - } - // 落盘文件也带 realm 键 - raw, err := os.ReadFile(a.FilePath) - if err != nil { - t.Fatalf("read %s: %v", a.FilePath, err) - } - b, err := Parse(raw) - if err != nil { - t.Fatalf("reparse %s: %v", a.FilePath, err) - } - if got := b.RealmStored(); got != want[a.UID] { - t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID]) - } - } -} - -// TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录 -// 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。 -// (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。) -func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) { - t.Parallel() - dir := t.TempDir() - good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}` - if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil { - t.Fatal(err) - } - // 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。 - if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil { - t.Fatal(err) - } - bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}` - if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil { - t.Fatal(err) - } - - list, err := LoadDir(dir) - if err != nil { - t.Fatalf("load err=%v want nil (write failure must not block startup)", err) - } - if len(list) != 2 { - t.Fatalf("want 2 accounts loaded, got %d", len(list)) - } - // 好文件迁移成功 - raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json")) - b, _ := Parse(raw) - if b.RealmStored() != "global" { - t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored()) - } -} - -// TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir -// 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、 -// 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。 -func TestLoadDirDuplicateUIDWarning(t *testing.T) { - dir := t.TempDir() - // 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...), - // global realm 文件在后 → 后载入者(global)胜出。 - cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}` - gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}` - if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil { - t.Fatal(err) - } - - // 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。 - old := log.Writer() - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - log.SetOutput(w) - - list, err := LoadDir(dir) - _ = w.Close() - raw, _ := io.ReadAll(r) - log.SetOutput(old) - - if err != nil { - t.Fatalf("load err=%v", err) - } - // 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir - // 的 UID 键 upsert),不 panic。 - if len(list) != 2 { - t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list)) - } - // WARN 已触发且含两文件路径。 - if !strings.Contains(string(raw), "WARN: uid") || - !strings.Contains(string(raw), "duplicated") || - !strings.Contains(string(raw), "workbuddy-a-cn.json") || - !strings.Contains(string(raw), "workbuddy-z-global.json") { - t.Errorf("expected WARN with both paths, got output: %s", string(raw)) - } -} +package auth + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseNested(t *testing.T) { + raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`) + sa, err := Parse(raw) + if err != nil { + t.Fatalf("nested parse err: %v", err) + } + if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 { + t.Errorf("tokens: %+v", sa) + } + if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" { + t.Errorf("account: %+v", sa) + } +} + +func TestParseFlat(t *testing.T) { + raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`) + sa, err := Parse(raw) + if err != nil || sa.UID != "u2" || sa.AccessToken != "at" { + t.Fatalf("flat: %+v %v", sa, err) + } +} + +func TestParseMissingToken(t *testing.T) { + if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil { + t.Fatal("want error for missing accessToken") + } +} + +func TestSaveAtomicRoundtrip(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-u1.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000, + UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) { + t.Error("tmp file should not remain") + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" { + t.Errorf("roundtrip: %+v", b) + } +} + +// TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载, +// 解析失败的文件静默跳过。 +func TestLoadDirLoadsAllValid(t *testing.T) { + dir := t.TempDir() + cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}` + other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}` + bad := `not json` + os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600) + os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600) + os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600) + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(list) != 2 { + t.Fatalf("want 2 valid accounts, got %+v", list) + } + for _, a := range list { + if a.FilePath == "" { + t.Error("FilePath not set") + } + } +} + +func TestNeedsRefresh(t *testing.T) { + a := &Auth{ExpiresAt: 0} + if !a.NeedsRefresh(0) { + t.Error("zero expiry should need refresh") + } + a.ExpiresAt = 9999999999 + if a.NeedsRefresh(0) { + t.Error("far future should not need refresh") + } +} + +// TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。 +func TestParseDeviceToken(t *testing.T) { + nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`) + sa, err := Parse(nested) + if err != nil { + t.Fatalf("nested parse: %v", err) + } + if sa.DeviceToken != "dev-tok-nested" { + t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested") + } + + flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`) + fa, err := Parse(flat) + if err != nil { + t.Fatalf("flat parse: %v", err) + } + if fa.DeviceToken != "dev-tok-flat" { + t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat") + } +} + +// TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。 +func TestSaveAtomicPreservesDeviceToken(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-dt.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "u1", DeviceToken: "persisted-tok", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.DeviceToken != "persisted-tok" { + t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok") + } +} + +// TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动 +// backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。 +func TestLoadDirBackfillsRealm(t *testing.T) { + t.Parallel() + dir := t.TempDir() + fixtures := map[string]string{ + "workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`, + "workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`, + // 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn + "workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`, + } + for name, body := range fixtures { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(list) != 3 { + t.Fatalf("want 3 accounts, got %d", len(list)) + } + want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"} + for _, a := range list { + // 内存态已补标识 + if got := a.RealmStored(); got != want[a.UID] { + t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID]) + } + // 落盘文件也带 realm 键 + raw, err := os.ReadFile(a.FilePath) + if err != nil { + t.Fatalf("read %s: %v", a.FilePath, err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse %s: %v", a.FilePath, err) + } + if got := b.RealmStored(); got != want[a.UID] { + t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID]) + } + } +} + +// TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录 +// 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。 +// (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。) +func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil { + t.Fatal(err) + } + // 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。 + if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil { + t.Fatal(err) + } + bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + + list, err := LoadDir(dir) + if err != nil { + t.Fatalf("load err=%v want nil (write failure must not block startup)", err) + } + if len(list) != 2 { + t.Fatalf("want 2 accounts loaded, got %d", len(list)) + } + // 好文件迁移成功 + raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json")) + b, _ := Parse(raw) + if b.RealmStored() != "global" { + t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored()) + } +} + +// TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir +// 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、 +// 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。 +func TestLoadDirDuplicateUIDWarning(t *testing.T) { + dir := t.TempDir() + // 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...), + // global realm 文件在后 → 后载入者(global)胜出。 + cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}` + gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}` + if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil { + t.Fatal(err) + } + + // 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。 + old := log.Writer() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + log.SetOutput(w) + + list, err := LoadDir(dir) + _ = w.Close() + raw, _ := io.ReadAll(r) + log.SetOutput(old) + + if err != nil { + t.Fatalf("load err=%v", err) + } + // 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir + // 的 UID 键 upsert),不 panic。 + if len(list) != 2 { + t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list)) + } + // WARN 已触发且含两文件路径。 + if !strings.Contains(string(raw), "WARN: uid") || + !strings.Contains(string(raw), "duplicated") || + !strings.Contains(string(raw), "workbuddy-a-cn.json") || + !strings.Contains(string(raw), "workbuddy-z-global.json") { + t.Errorf("expected WARN with both paths, got output: %s", string(raw)) + } +} diff --git a/internal/auth/permhint_test.go b/internal/auth/permhint_test.go index 120447006c5ea235ec9d2d6995c8577bab324c61..bbd93260eedf369bf4ee45d4fea2d152ea6fc33e 100644 --- a/internal/auth/permhint_test.go +++ b/internal/auth/permhint_test.go @@ -1,29 +1,29 @@ -package auth - -import ( - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。 -func TestSaveAtomicPermissionHint(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过") - } - dir := t.TempDir() - ro := filepath.Join(dir, "ro") - os.MkdirAll(ro, 0o555) // 只读目录 - defer os.Chmod(ro, 0o755) - a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, - UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")} - err := a.SaveAtomic() - if err == nil { - t.Fatal("只读目录保存应失败") - } - if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") { - t.Errorf("权限错误应包含 Docker 指引,实际: %v", err) - } -} +package auth + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。 +func TestSaveAtomicPermissionHint(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过") + } + dir := t.TempDir() + ro := filepath.Join(dir, "ro") + os.MkdirAll(ro, 0o555) // 只读目录 + defer os.Chmod(ro, 0o755) + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")} + err := a.SaveAtomic() + if err == nil { + t.Fatal("只读目录保存应失败") + } + if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") { + t.Errorf("权限错误应包含 Docker 指引,实际: %v", err) + } +} diff --git a/internal/auth/realm_test.go b/internal/auth/realm_test.go index 94b02b65b6eeb3b69891bfe847fa5e35e5db145c..82a6e7daed6c9e4c637212755eb652b86c518ee9 100644 --- a/internal/auth/realm_test.go +++ b/internal/auth/realm_test.go @@ -1,275 +1,275 @@ -package auth - -import ( - "os" - "path/filepath" - "testing" -) - -// withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保), -// 测试结束复位为开启态(缺省)。 -func withGlobalEnabled(t *testing.T) { - t.Helper() - globalEnabled.Store(true) - t.Cleanup(func() { globalEnabled.Store(true) }) -} - -// withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。 -func withGlobalDisabled(t *testing.T) { - t.Helper() - globalEnabled.Store(false) - t.Cleanup(func() { globalEnabled.Store(true) }) -} - -func TestRealmExplicitGlobal(t *testing.T) { - withGlobalEnabled(t) - a := &Auth{realm: "global"} - if got := a.Realm(); got != "global" { - t.Errorf("Realm()=%q want global", got) - } - if !a.IsGlobal() { - t.Error("IsGlobal()=false want true") - } -} - -func TestRealmExplicitCN(t *testing.T) { - a := &Auth{realm: "cn"} - if got := a.Realm(); got != "cn" { - t.Errorf("Realm()=%q want cn", got) - } - if a.IsGlobal() { - t.Error("IsGlobal()=true want false") - } -} - -func TestRealmDomainFallback(t *testing.T) { - withGlobalEnabled(t) - cases := []struct{ domain, want string }{ - {"www.workbuddy.ai", "global"}, - {"workbuddy.ai", "global"}, - {"sub.workbuddy.ai", "global"}, - {"www.codebuddy.cn", "cn"}, - {"", "cn"}, - } - for _, c := range cases { - a := &Auth{Domain: c.domain} - if got := a.Realm(); got != c.want { - t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want) - } - } -} - -func TestRealmEmptyFallsBackToCN(t *testing.T) { - // 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。 - a := &Auth{} - if got := a.Realm(); got != "cn" { - t.Errorf("Realm()=%q want cn", got) - } - // 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。 - ag := &Auth{realm: "global"} - if got := ag.Realm(); got != "global" { - t.Errorf("Realm()=%q want global", got) - } - // domain 回落照常(缺省开启识别 workbuddy.ai)。 - ad := &Auth{Domain: "www.workbuddy.ai"} - if got := ad.Realm(); got != "global" { - t.Errorf("Realm()=%q want global", got) - } -} - -// TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain) -// Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。 -func TestRealmDefaultOnForCNZeroRegression(t *testing.T) { - withGlobalEnabled(t) - cases := []*Auth{ - {}, - {Domain: "www.codebuddy.cn"}, - {Domain: "codebuddy.cn"}, - {realm: "cn"}, - {realm: "cn", Domain: "www.codebuddy.cn"}, - } - for _, a := range cases { - if got := a.Realm(); got != "cn" { - t.Errorf("%+v Realm()=%q want cn", a, got) - } - if a.IsGlobal() { - t.Errorf("%+v IsGlobal()=true want false", a) - } - } -} - -// TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn, -// 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。 -func TestRealmExplicitOffEscapeHatch(t *testing.T) { - withGlobalDisabled(t) - cases := []struct { - auth *Auth - }{ - {&Auth{realm: "global"}}, - {&Auth{realm: "global", Domain: "www.workbuddy.ai"}}, - {&Auth{Domain: "www.workbuddy.ai"}}, - } - for _, tc := range cases { - if got := tc.auth.Realm(); got != "cn" { - t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got) - } - if tc.auth.IsGlobal() { - t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth) - } - } -} - -func TestParseNestedRealm(t *testing.T) { - raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`) - sa, err := Parse(raw) - if err != nil { - t.Fatalf("nested parse err: %v", err) - } - if sa.realm != "global" { - t.Errorf("nested realm=%q want global", sa.realm) - } - // 嵌套形显式 realm 空 → 读不到,靠 domain 回落。 - raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`) - sa2, err := Parse(raw2) - if err != nil { - t.Fatalf("nested no-realm parse err: %v", err) - } - if sa2.realm != "" { - t.Errorf("nested missing realm key should be zero, got %q", sa2.realm) - } -} - -func TestParseFlatRealm(t *testing.T) { - withGlobalEnabled(t) - raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`) - fa, err := Parse(raw) - if err != nil { - t.Fatalf("flat parse err: %v", err) - } - if fa.realm != "global" { - t.Errorf("flat realm=%q want global", fa.realm) - } - if !fa.IsGlobal() { - t.Error("flat global account IsGlobal()=false want true") - } - // 扁平形缺 realm 键 → 零值 → CN。 - flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`) - fc, err := Parse(flatCN) - if err != nil { - t.Fatalf("flat cn parse err: %v", err) - } - if fc.realm != "" { - t.Errorf("flat missing realm should be zero, got %q", fc.realm) - } -} - -func TestSaveAtomicWritesRealm(t *testing.T) { - withGlobalEnabled(t) - dir := t.TempDir() - fp := filepath.Join(dir, "workbuddy-global.json") - a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, - UID: "g1", realm: "global", FilePath: fp} - if err := a.SaveAtomic(); err != nil { - t.Fatalf("save: %v", err) - } - raw, err := os.ReadFile(fp) - if err != nil { - t.Fatalf("read: %v", err) - } - b, err := Parse(raw) - if err != nil { - t.Fatalf("reparse: %v", err) - } - if b.realm != "global" { - t.Errorf("roundtrip realm=%q want global", b.realm) - } - if !b.IsGlobal() { - t.Error("roundtrip global account IsGlobal()=false") - } -} - -// TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。 -func TestBackfillRealmDomain(t *testing.T) { - t.Parallel() // 不触碰全局开关 - cases := []struct { - domain string - want string - }{ - {"www.workbuddy.ai", "global"}, - {"workbuddy.ai", "global"}, - {"sub.workbuddy.ai", "global"}, - {"www.codebuddy.cn", "cn"}, - {"codebuddy.cn", "cn"}, - {"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心) - } - for _, c := range cases { - a := &Auth{Domain: c.domain} - changed, got := a.BackfillRealm() - if !changed { - t.Errorf("Domain=%q backfill changed=false want true", c.domain) - } - assertRealmStored(a, c.want, t) - if got != c.want { - t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want) - } - } -} - -// TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。 -func TestBackfillRealmEscapeHatchFree(t *testing.T) { - withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染 - a := &Auth{Domain: "www.workbuddy.ai"} - if g := a.Realm(); g != "cn" { - t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g) - } - changed, got := a.BackfillRealm() - if !changed { - t.Fatal("backfill changed=false want true (escape hatch free)") - } - assertRealmStored(a, "global", t) - if got != "global" { - t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got) - } -} - -// TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。 -func TestBackfillRealmIdempotent(t *testing.T) { - t.Parallel() - a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖 - changed, got := a.BackfillRealm() - if changed { - t.Errorf("backfill changed=true want false (existing realm must win)") - } - if got != "cn" { - t.Errorf("backfill got=%q want cn (existing realm preserved)", got) - } - assertRealmStored(a, "cn", t) -} - -func assertRealmStored(a *Auth, want string, t *testing.T) { - t.Helper() - if a.realm != want { - t.Errorf("stored realm field=%q want %q", a.realm, want) - } -} - -// TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm -// 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。 -func TestResolveRealm(t *testing.T) { - t.Parallel() // 纯函数:不触碰全局开关 - cases := []struct { - explicit, domain, want string - }{ - {"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global - {"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn - {"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断 - {"", "workbuddy.ai", "global"}, - {"", "codebuddy.cn", "cn"}, - {"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归) - } - for _, c := range cases { - if got := ResolveRealm(c.explicit, c.domain); got != c.want { - t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want) - } - } -} +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +// withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保), +// 测试结束复位为开启态(缺省)。 +func withGlobalEnabled(t *testing.T) { + t.Helper() + globalEnabled.Store(true) + t.Cleanup(func() { globalEnabled.Store(true) }) +} + +// withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。 +func withGlobalDisabled(t *testing.T) { + t.Helper() + globalEnabled.Store(false) + t.Cleanup(func() { globalEnabled.Store(true) }) +} + +func TestRealmExplicitGlobal(t *testing.T) { + withGlobalEnabled(t) + a := &Auth{realm: "global"} + if got := a.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } + if !a.IsGlobal() { + t.Error("IsGlobal()=false want true") + } +} + +func TestRealmExplicitCN(t *testing.T) { + a := &Auth{realm: "cn"} + if got := a.Realm(); got != "cn" { + t.Errorf("Realm()=%q want cn", got) + } + if a.IsGlobal() { + t.Error("IsGlobal()=true want false") + } +} + +func TestRealmDomainFallback(t *testing.T) { + withGlobalEnabled(t) + cases := []struct{ domain, want string }{ + {"www.workbuddy.ai", "global"}, + {"workbuddy.ai", "global"}, + {"sub.workbuddy.ai", "global"}, + {"www.codebuddy.cn", "cn"}, + {"", "cn"}, + } + for _, c := range cases { + a := &Auth{Domain: c.domain} + if got := a.Realm(); got != c.want { + t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want) + } + } +} + +func TestRealmEmptyFallsBackToCN(t *testing.T) { + // 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。 + a := &Auth{} + if got := a.Realm(); got != "cn" { + t.Errorf("Realm()=%q want cn", got) + } + // 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。 + ag := &Auth{realm: "global"} + if got := ag.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } + // domain 回落照常(缺省开启识别 workbuddy.ai)。 + ad := &Auth{Domain: "www.workbuddy.ai"} + if got := ad.Realm(); got != "global" { + t.Errorf("Realm()=%q want global", got) + } +} + +// TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain) +// Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。 +func TestRealmDefaultOnForCNZeroRegression(t *testing.T) { + withGlobalEnabled(t) + cases := []*Auth{ + {}, + {Domain: "www.codebuddy.cn"}, + {Domain: "codebuddy.cn"}, + {realm: "cn"}, + {realm: "cn", Domain: "www.codebuddy.cn"}, + } + for _, a := range cases { + if got := a.Realm(); got != "cn" { + t.Errorf("%+v Realm()=%q want cn", a, got) + } + if a.IsGlobal() { + t.Errorf("%+v IsGlobal()=true want false", a) + } + } +} + +// TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn, +// 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。 +func TestRealmExplicitOffEscapeHatch(t *testing.T) { + withGlobalDisabled(t) + cases := []struct { + auth *Auth + }{ + {&Auth{realm: "global"}}, + {&Auth{realm: "global", Domain: "www.workbuddy.ai"}}, + {&Auth{Domain: "www.workbuddy.ai"}}, + } + for _, tc := range cases { + if got := tc.auth.Realm(); got != "cn" { + t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got) + } + if tc.auth.IsGlobal() { + t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth) + } + } +} + +func TestParseNestedRealm(t *testing.T) { + raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`) + sa, err := Parse(raw) + if err != nil { + t.Fatalf("nested parse err: %v", err) + } + if sa.realm != "global" { + t.Errorf("nested realm=%q want global", sa.realm) + } + // 嵌套形显式 realm 空 → 读不到,靠 domain 回落。 + raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`) + sa2, err := Parse(raw2) + if err != nil { + t.Fatalf("nested no-realm parse err: %v", err) + } + if sa2.realm != "" { + t.Errorf("nested missing realm key should be zero, got %q", sa2.realm) + } +} + +func TestParseFlatRealm(t *testing.T) { + withGlobalEnabled(t) + raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`) + fa, err := Parse(raw) + if err != nil { + t.Fatalf("flat parse err: %v", err) + } + if fa.realm != "global" { + t.Errorf("flat realm=%q want global", fa.realm) + } + if !fa.IsGlobal() { + t.Error("flat global account IsGlobal()=false want true") + } + // 扁平形缺 realm 键 → 零值 → CN。 + flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`) + fc, err := Parse(flatCN) + if err != nil { + t.Fatalf("flat cn parse err: %v", err) + } + if fc.realm != "" { + t.Errorf("flat missing realm should be zero, got %q", fc.realm) + } +} + +func TestSaveAtomicWritesRealm(t *testing.T) { + withGlobalEnabled(t) + dir := t.TempDir() + fp := filepath.Join(dir, "workbuddy-global.json") + a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1, + UID: "g1", realm: "global", FilePath: fp} + if err := a.SaveAtomic(); err != nil { + t.Fatalf("save: %v", err) + } + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read: %v", err) + } + b, err := Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if b.realm != "global" { + t.Errorf("roundtrip realm=%q want global", b.realm) + } + if !b.IsGlobal() { + t.Error("roundtrip global account IsGlobal()=false") + } +} + +// TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。 +func TestBackfillRealmDomain(t *testing.T) { + t.Parallel() // 不触碰全局开关 + cases := []struct { + domain string + want string + }{ + {"www.workbuddy.ai", "global"}, + {"workbuddy.ai", "global"}, + {"sub.workbuddy.ai", "global"}, + {"www.codebuddy.cn", "cn"}, + {"codebuddy.cn", "cn"}, + {"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心) + } + for _, c := range cases { + a := &Auth{Domain: c.domain} + changed, got := a.BackfillRealm() + if !changed { + t.Errorf("Domain=%q backfill changed=false want true", c.domain) + } + assertRealmStored(a, c.want, t) + if got != c.want { + t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want) + } + } +} + +// TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。 +func TestBackfillRealmEscapeHatchFree(t *testing.T) { + withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染 + a := &Auth{Domain: "www.workbuddy.ai"} + if g := a.Realm(); g != "cn" { + t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g) + } + changed, got := a.BackfillRealm() + if !changed { + t.Fatal("backfill changed=false want true (escape hatch free)") + } + assertRealmStored(a, "global", t) + if got != "global" { + t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got) + } +} + +// TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。 +func TestBackfillRealmIdempotent(t *testing.T) { + t.Parallel() + a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖 + changed, got := a.BackfillRealm() + if changed { + t.Errorf("backfill changed=true want false (existing realm must win)") + } + if got != "cn" { + t.Errorf("backfill got=%q want cn (existing realm preserved)", got) + } + assertRealmStored(a, "cn", t) +} + +func assertRealmStored(a *Auth, want string, t *testing.T) { + t.Helper() + if a.realm != want { + t.Errorf("stored realm field=%q want %q", a.realm, want) + } +} + +// TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm +// 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。 +func TestResolveRealm(t *testing.T) { + t.Parallel() // 纯函数:不触碰全局开关 + cases := []struct { + explicit, domain, want string + }{ + {"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global + {"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn + {"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断 + {"", "workbuddy.ai", "global"}, + {"", "codebuddy.cn", "cn"}, + {"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归) + } + for _, c := range cases { + if got := ResolveRealm(c.explicit, c.domain); got != c.want { + t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want) + } + } +} diff --git a/internal/httpauth/httpauth.go b/internal/httpauth/httpauth.go index b1a7fc36b0cc40eac193b418ef31dacc83cec30d..a94035573e26e5cdd78796c6a30f7c6d9464ea1d 100644 --- a/internal/httpauth/httpauth.go +++ b/internal/httpauth/httpauth.go @@ -1,43 +1,43 @@ -// Package httpauth 网关与面板共用的 Bearer 鉴权原语。 -// -// 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权 -// 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移, -// 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。 -package httpauth - -import ( - "crypto/sha256" - "crypto/subtle" - "net/http" - "strings" -) - -// bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。 -const bearerPrefix = "Bearer " - -// VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。 -// -// key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。 -// 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare: -// - 常量时间,不因前缀匹配长度而泄露信息; -// - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回); -// - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。 -func VerifyBearer(r *http.Request, key string) bool { - if key == "" { - return true - } - authz := r.Header.Get("Authorization") - if !strings.HasPrefix(authz, bearerPrefix) { - // 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。 - subtle.ConstantTimeCompare(digest(""), digest(key)) - return false - } - tok := authz[len(bearerPrefix):] - return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1 -} - -// digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。 -func digest(s string) []byte { - sum := sha256.Sum256([]byte(s)) - return sum[:] -} +// Package httpauth 网关与面板共用的 Bearer 鉴权原语。 +// +// 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权 +// 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移, +// 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。 +package httpauth + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" +) + +// bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。 +const bearerPrefix = "Bearer " + +// VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。 +// +// key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。 +// 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare: +// - 常量时间,不因前缀匹配长度而泄露信息; +// - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回); +// - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。 +func VerifyBearer(r *http.Request, key string) bool { + if key == "" { + return true + } + authz := r.Header.Get("Authorization") + if !strings.HasPrefix(authz, bearerPrefix) { + // 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。 + subtle.ConstantTimeCompare(digest(""), digest(key)) + return false + } + tok := authz[len(bearerPrefix):] + return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1 +} + +// digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。 +func digest(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} diff --git a/internal/httpauth/httpauth_test.go b/internal/httpauth/httpauth_test.go index bf1ab10a77822e989d8ecbfd5b2138f504912f0b..4d05ffd3edeb740d33b755f73164f6ae03a1e130 100644 --- a/internal/httpauth/httpauth_test.go +++ b/internal/httpauth/httpauth_test.go @@ -1,61 +1,61 @@ -package httpauth - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func req(authz string) *http.Request { - r := httptest.NewRequest("GET", "/", nil) - if authz != "" { - r.Header.Set("Authorization", authz) - } - return r -} - -func TestVerifyBearer(t *testing.T) { - cases := []struct { - name string - key string - authz string - want bool - }{ - {"空 key 放行(未启用鉴权)", "", "", true}, - {"空 key 也放行任意头", "", "Bearer whatever", true}, - {"正确 key", "sk-abc123", "Bearer sk-abc123", true}, - {"错误 key", "sk-abc123", "Bearer sk-wrong", false}, - {"缺 Authorization 头", "sk-abc123", "", false}, - {"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false}, - {"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false}, - {"多余空格", "sk-abc123", "Bearer sk-abc123", false}, - {"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false}, - {"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false}, - {"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := VerifyBearer(req(c.authz), c.key); got != c.want { - t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want) - } - }) - } -} - -// TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异: -// 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。 -func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) { - if VerifyBearer(req(""), "any-key") { - t.Error("missing header must not pass") - } -} - -func TestDigestIsFixedLength(t *testing.T) { - // 不同长度输入摘要后应等长(这是常量时间比较的前提) - if len(digest("")) != len(digest("a-much-longer-secret-value")) { - t.Error("digest length must not depend on input length") - } - if len(digest("x")) != 32 { - t.Errorf("sha256 digest length = %d, want 32", len(digest("x"))) - } -} +package httpauth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func req(authz string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + if authz != "" { + r.Header.Set("Authorization", authz) + } + return r +} + +func TestVerifyBearer(t *testing.T) { + cases := []struct { + name string + key string + authz string + want bool + }{ + {"空 key 放行(未启用鉴权)", "", "", true}, + {"空 key 也放行任意头", "", "Bearer whatever", true}, + {"正确 key", "sk-abc123", "Bearer sk-abc123", true}, + {"错误 key", "sk-abc123", "Bearer sk-wrong", false}, + {"缺 Authorization 头", "sk-abc123", "", false}, + {"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false}, + {"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false}, + {"多余空格", "sk-abc123", "Bearer sk-abc123", false}, + {"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false}, + {"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false}, + {"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := VerifyBearer(req(c.authz), c.key); got != c.want { + t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want) + } + }) + } +} + +// TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异: +// 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。 +func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) { + if VerifyBearer(req(""), "any-key") { + t.Error("missing header must not pass") + } +} + +func TestDigestIsFixedLength(t *testing.T) { + // 不同长度输入摘要后应等长(这是常量时间比较的前提) + if len(digest("")) != len(digest("a-much-longer-secret-value")) { + t.Error("digest length must not depend on input length") + } + if len(digest("x")) != 32 { + t.Errorf("sha256 digest length = %d, want 32", len(digest("x"))) + } +} diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go index 0db0c3412edf04990f13d072e8d40588e244f7ef..fec4483bc2905ba5f0d56d52a6d7741bfb5178ff 100644 --- a/internal/livecfg/livecfg.go +++ b/internal/livecfg/livecfg.go @@ -1,48 +1,48 @@ -// Package livecfg 运行期可变配置的并发安全持有者。 -// -// 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置, -// 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针: -// 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。 -// -// 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter -// (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。 -package livecfg - -import ( - "sync/atomic" - "time" -) - -// Snapshot 一次读取的不可变配置视图。 -type Snapshot struct { - APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 - SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) - SanitizeFingerprints bool // 出站请求体指纹脱敏 - RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) -} - -// Holder 原子持有当前快照。 -type Holder struct { - p atomic.Pointer[Snapshot] -} - -// New 以初始快照构建。 -func New(s Snapshot) *Holder { - h := &Holder{} - h.Store(s) - return h -} - -// Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。 -func (h *Holder) Load() Snapshot { - if h == nil { - return Snapshot{} - } - if s := h.p.Load(); s != nil { - return *s - } - return Snapshot{} -} - -// Store 整体替换快照。 -func (h *Holder) Store(s Snapshot) { h.p.Store(&s) } +// Package livecfg 运行期可变配置的并发安全持有者。 +// +// 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置, +// 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针: +// 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。 +// +// 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter +// (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。 +package livecfg + +import ( + "sync/atomic" + "time" +) + +// Snapshot 一次读取的不可变配置视图。 +type Snapshot struct { + APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 + SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) + SanitizeFingerprints bool // 出站请求体指纹脱敏 + RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) +} + +// Holder 原子持有当前快照。 +type Holder struct { + p atomic.Pointer[Snapshot] +} + +// New 以初始快照构建。 +func New(s Snapshot) *Holder { + h := &Holder{} + h.Store(s) + return h +} + +// Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。 +func (h *Holder) Load() Snapshot { + if h == nil { + return Snapshot{} + } + if s := h.p.Load(); s != nil { + return *s + } + return Snapshot{} +} + +// Store 整体替换快照。 +func (h *Holder) Store(s Snapshot) { h.p.Store(&s) } diff --git a/internal/logfmt/logfmt.go b/internal/logfmt/logfmt.go index e4d01c0824617bbe58c838bfbfd2c087ad43e05f..2a9aec57787f0945a0077a4232ee4132f79a91c6 100644 --- a/internal/logfmt/logfmt.go +++ b/internal/logfmt/logfmt.go @@ -1,166 +1,166 @@ -// Package logfmt 统一网关日志的 uid 截断与模块前缀约定。 -// -// 约定: -// - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐, -// 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。 -// - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持; -// 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。 -// - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。 -// -// 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串 -// helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。 -package logfmt - -import ( - "strings" - "unicode/utf8" -) - -// Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径 -// 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文 -// ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。 -func Truncate(s string, n int) string { - if n <= 0 { - return "" - } - s = strings.TrimSpace(s) - if len(s) > n { - // s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在 - // 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。 - for n > 0 && !utf8.RuneStart(s[n]) { - n-- - } - return s[:n] - } - return s -} - -// UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。 -// -// 用于调度类与非调度类日志行,把 : ... 改为 : ... -// 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。 -func UID8(uid string) string { - if uid == "" { - return "-" - } - if len(uid) > 8 { - return uid[:8] - } - return uid -} - -// Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。 -// -// 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号", -// 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随 -// 登录落在 auths/.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的 -// 标签——昵称认人、uid8 供 grep,两者都保留。 -// -// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。 -func Label(uid, nick string) string { - short := UID8(uid) - nick = strings.TrimSpace(nick) - if nick == "" { - return short - } - return nick + "(" + short + ")" -} - -// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。 -// -// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占 -// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数, -// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。 -func DisplayWidth(s string) int { - w := 0 - for _, r := range s { - w += runeWidth(r) - } - return w -} - -// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。 -// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。 -func Pad(s string, width int) string { - if width <= 0 { - return s - } - if d := width - DisplayWidth(s); d > 0 { - return s + strings.Repeat(" ", d) - } - return s -} - -// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、 -// "python-requests/2.31.0" 这类常见客户端标签)。 -const maxShortUALen = 40 - -// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA -// 恒定包含它们,拿它当客户端标签等于没信息。 -var shortUAEngines = map[string]bool{ - "mozilla": true, "applewebkit": true, "gecko": true, "khtml": true, - "like": true, "safari": true, "compatible": true, "msie": true, "trident": true, -} - -// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、 -// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。 -// -// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄 -// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端", -// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。 -// -// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串 -// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。 -func ShortUA(ua string) string { - ua = strings.TrimSpace(ua) - if ua == "" { - return "" - } - for _, tok := range strings.Fields(ua) { - tok = strings.Trim(tok, "(),;") - name, _, ok := strings.Cut(tok, "/") - if !ok || name == "" { - continue - } - if shortUAEngines[strings.ToLower(name)] { - continue - } - return Truncate(tok, maxShortUALen) - } - return Truncate(ua, maxShortUALen) -} - -// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的 -// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。 -func runeWidth(r rune) int { - switch { - case r == 0: - return 0 - case r < 0x20 || (r >= 0x7f && r < 0xa0): - // 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。 - return 0 - case r < 0x1100: - return 1 - case r <= 0x115f: // Hangul Jamo 初声 - return 2 - case r == 0x2329 || r == 0x232a: - return 2 - case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符) - return 2 - case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节 - return 2 - case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意 - return 2 - case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式 - return 2 - case r >= 0xff00 && r <= 0xff60: // 全角 ASCII - return 2 - case r >= 0xffe0 && r <= 0xffe6: // 全角符号 - return 2 - case r >= 0x1f300 && r <= 0x1f9ff: // emoji - return 2 - case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后 - return 2 - } - return 1 -} +// Package logfmt 统一网关日志的 uid 截断与模块前缀约定。 +// +// 约定: +// - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐, +// 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。 +// - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持; +// 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。 +// - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。 +// +// 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串 +// helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。 +package logfmt + +import ( + "strings" + "unicode/utf8" +) + +// Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径 +// 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文 +// ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。 +func Truncate(s string, n int) string { + if n <= 0 { + return "" + } + s = strings.TrimSpace(s) + if len(s) > n { + // s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在 + // 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。 + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + } + return s +} + +// UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。 +// +// 用于调度类与非调度类日志行,把 : ... 改为 : ... +// 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。 +func UID8(uid string) string { + if uid == "" { + return "-" + } + if len(uid) > 8 { + return uid[:8] + } + return uid +} + +// Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。 +// +// 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号", +// 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随 +// 登录落在 auths/.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的 +// 标签——昵称认人、uid8 供 grep,两者都保留。 +// +// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。 +func Label(uid, nick string) string { + short := UID8(uid) + nick = strings.TrimSpace(nick) + if nick == "" { + return short + } + return nick + "(" + short + ")" +} + +// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。 +// +// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占 +// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数, +// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。 +func DisplayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。 +// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。 +func Pad(s string, width int) string { + if width <= 0 { + return s + } + if d := width - DisplayWidth(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} + +// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、 +// "python-requests/2.31.0" 这类常见客户端标签)。 +const maxShortUALen = 40 + +// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA +// 恒定包含它们,拿它当客户端标签等于没信息。 +var shortUAEngines = map[string]bool{ + "mozilla": true, "applewebkit": true, "gecko": true, "khtml": true, + "like": true, "safari": true, "compatible": true, "msie": true, "trident": true, +} + +// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、 +// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。 +// +// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄 +// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端", +// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。 +// +// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串 +// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。 +func ShortUA(ua string) string { + ua = strings.TrimSpace(ua) + if ua == "" { + return "" + } + for _, tok := range strings.Fields(ua) { + tok = strings.Trim(tok, "(),;") + name, _, ok := strings.Cut(tok, "/") + if !ok || name == "" { + continue + } + if shortUAEngines[strings.ToLower(name)] { + continue + } + return Truncate(tok, maxShortUALen) + } + return Truncate(ua, maxShortUALen) +} + +// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的 +// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。 +func runeWidth(r rune) int { + switch { + case r == 0: + return 0 + case r < 0x20 || (r >= 0x7f && r < 0xa0): + // 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。 + return 0 + case r < 0x1100: + return 1 + case r <= 0x115f: // Hangul Jamo 初声 + return 2 + case r == 0x2329 || r == 0x232a: + return 2 + case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符) + return 2 + case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节 + return 2 + case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意 + return 2 + case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式 + return 2 + case r >= 0xff00 && r <= 0xff60: // 全角 ASCII + return 2 + case r >= 0xffe0 && r <= 0xffe6: // 全角符号 + return 2 + case r >= 0x1f300 && r <= 0x1f9ff: // emoji + return 2 + case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后 + return 2 + } + return 1 +} diff --git a/internal/logfmt/shortua_test.go b/internal/logfmt/shortua_test.go index ab0882fe38a43681dc47391770a09a9eb373ce4c..6d50c4dfa892c12d87ba8455c1c79ce210732cd6 100644 --- a/internal/logfmt/shortua_test.go +++ b/internal/logfmt/shortua_test.go @@ -1,50 +1,50 @@ -package logfmt - -import "testing" - -func TestShortUA(t *testing.T) { - cases := []struct { - name string - ua string - want string - }{ - {"empty", "", ""}, - {"blank", " ", ""}, - {"curl", "curl/8.4.0", "curl/8.4.0"}, - {"python", "python-requests/2.31.0", "python-requests/2.31.0"}, - {"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"}, - {"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"}, - { - "chrome skips engine tokens", - "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", - "Chrome/120.0.0.0", - }, - { - "firefox", - "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", - "Firefox/121.0", - }, - {"no version token", "node", "node"}, - {"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := ShortUA(tc.ua); got != tc.want { - t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want) - } - }) - } -} - -// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。 -func TestShortUABounded(t *testing.T) { - long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3" - if got := ShortUA(long); len(got) > maxShortUALen { - t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got) - } - // 没有 name/version token 时回落整串,同样受上限约束。 - got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy") - if len(got) > maxShortUALen { - t.Fatalf("fallback not truncated: %d", len(got)) - } -} +package logfmt + +import "testing" + +func TestShortUA(t *testing.T) { + cases := []struct { + name string + ua string + want string + }{ + {"empty", "", ""}, + {"blank", " ", ""}, + {"curl", "curl/8.4.0", "curl/8.4.0"}, + {"python", "python-requests/2.31.0", "python-requests/2.31.0"}, + {"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"}, + {"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"}, + { + "chrome skips engine tokens", + "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", + "Chrome/120.0.0.0", + }, + { + "firefox", + "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Firefox/121.0", + }, + {"no version token", "node", "node"}, + {"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ShortUA(tc.ua); got != tc.want { + t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want) + } + }) + } +} + +// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。 +func TestShortUABounded(t *testing.T) { + long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3" + if got := ShortUA(long); len(got) > maxShortUALen { + t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got) + } + // 没有 name/version token 时回落整串,同样受上限约束。 + got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy") + if len(got) > maxShortUALen { + t.Fatalf("fallback not truncated: %d", len(got)) + } +} diff --git a/internal/panel/app.js b/internal/panel/app.js index 00b571d8db1ae916df19d2b2a6dbda9b58111437..d4be786c39d3d41d367ea079f24d951da8275177 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -1,2604 +1,2809 @@ -'use strict'; -/* ── 状态 ─────────────────────────────────────────────────────────── */ -const LS_KEY = 'wb2api.key', LS_THEME = 'wb2api.theme'; -let theme = localStorage.getItem(LS_THEME) || 'auto'; // auto | light | dark -let view = 'accounts'; -let overviewData = null, cfgLoaded = null; -let logPin = true, loginState = null, loginTimer = null; -let refTimer = null; -/* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */ -let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; -let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined; -let reqFilter = { q: '', outcome: '' }; -let reqEntries = []; -let usDim = 'account', usCreditDim = 'account', usSort = 'total'; -let usageData = null; -let reqRangeState = null; // 请求记录的时间范围(用量页的见 trangeState) - -const $ = id => document.getElementById(id); - -/* ── 主题 ─────────────────────────────────────────────────────────── */ -/* 两态翻转(浅/深),首次访问跟随系统偏好;点击总是切换可见外观,符合直觉。 */ -function effTheme() { - return theme === 'auto' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : theme; -} -function applyTheme() { - const eff = effTheme(); - document.documentElement.dataset.theme = eff; - $('icoTheme').innerHTML = eff === 'light' - ? '' - : ''; - $('btnTheme').title = eff === 'light' ? '切换到深色' : '切换到浅色'; -} -addEventListener('change', applyTheme); -$('btnTheme').onclick = () => { - theme = effTheme() === 'light' ? 'dark' : 'light'; - localStorage.setItem(LS_THEME, theme); - applyTheme(); -}; -applyTheme(); - -/* ── 请求 ─────────────────────────────────────────────────────────── */ -async function api(path, opts = {}) { - const h = Object.assign({}, opts.headers || {}); - const k = localStorage.getItem(LS_KEY); - if (k) h['Authorization'] = 'Bearer ' + k; - if (opts.body) h['Content-Type'] = 'application/json'; - const r = await fetch('/panel/api/' + path, Object.assign({}, opts, { headers: h })); - if (r.status === 401) { openKey(); throw new Error('密钥无效或未填写'); } - const d = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); - return d; -} -function toast(msg, cls) { - const el = document.createElement('div'); - el.className = 'tst ' + (cls || ''); - el.textContent = msg; - $('toasts').appendChild(el); - setTimeout(() => el.remove(), 3600); -} -// esc 文本/属性双安全转义。不能只用 div.innerHTML(它转义 <>& 但不转义引号), -// 否则字符串拼进 HTML 属性(如 title="uid: ...")时引号可闭合属性并注入事件处理器。 -// 显式替换 5 个字符:& < > " '(& 必须最先,避免二次转义)。 -function esc(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} -function ago(iso) { - if (!iso || iso.startsWith('0001-')) return '—'; - const s = (Date.now() - new Date(iso)) / 1000; - if (s < 0) return '刚刚'; - if (s < 60) return Math.floor(s) + ' 秒前'; - if (s < 3600) return Math.floor(s / 60) + ' 分钟前'; - if (s < 86400) return Math.floor(s / 3600) + ' 小时前'; - return Math.floor(s / 86400) + ' 天前'; -} -function dur(sec) { - sec = Math.max(0, Math.round(sec)); - const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; - return h ? h + '时' + String(m).padStart(2, '0') + '分' : m ? m + '分' + String(s).padStart(2, '0') + '秒' : s + '秒'; -} -function parseAPITime(value) { - const text = String(value || ''); - if (!text || text.startsWith('0001-')) return 0; - const ms = Date.parse(text); - return Number.isFinite(ms) ? ms : 0; -} -function fmtLocalDateTime(ms) { - const d = new Date(ms); - const p = n => String(n).padStart(2, '0'); - return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + - p(d.getHours()) + ':' + p(d.getMinutes()); -} - -/* ── 时间范围控件(用量 / 请求记录共用)──────────────────────────────── - 预设项:今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义。 - - 为什么区间一律由前端算好再发: - - 「今天」必须是**浏览器本地时区**的 00:00 起。服务端时区未必与浏览器一致 - (容器常挂 TZ=Asia/Shanghai,而浏览器可能在任何时区),让服务端算"今天" - 会在跨时区时切错日子。 - - 「自定义」本来就是用户挑的具体时刻,没有任何服务端推导空间。 - - 滚动预设(近 N 小时/天)则保留 hours 参数:服务端按整点对齐的滚动窗口与旧 - 行为逐位一致,前端自己减 N 小时会多算/少算一个边界桶。 */ -const TRANGE_PRESETS = [ - ['today', '今天'], - ['24', '近 24 小时'], - ['72', '近 3 天'], - ['168', '近 7 天'], - ['720', '近 30 天'], - ['0', '全部历史'], - ['custom', '自定义…'], -]; -const TRANGE_DEFAULT = '72'; -const trangeStates = new Map(); // hostId → { preset, from: Date|null, to: Date|null } - -// dtLocalValue / dtLocalParse 与 的取值格式互转 -// (YYYY-MM-DDTHH:mm,本地时区;ES 里"带时间的日期串"按本地解析,正是我们要的)。 -function dtLocalValue(d) { - const p = n => String(n).padStart(2, '0'); - return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T' + - p(d.getHours()) + ':' + p(d.getMinutes()); -} -function dtLocalParse(s) { - if (!s) return null; - const d = new Date(s); - return isNaN(d.getTime()) ? null : d; -} - -// trangeMidnight 今天 00:00(本地时区)。 -function trangeMidnight() { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; -} - -function trangeState(id) { - if (!trangeStates.has(id)) { - // 「自定义」的初始值给一段有意义的默认:今天 00:00 → 现在。 - trangeStates.set(id, { preset: TRANGE_DEFAULT, from: trangeMidnight(), to: new Date() }); - } - return trangeStates.get(id); -} - -// trangeRender 画出控件骨架(幂等:重复调用会保留当前状态)。 -function trangeRender(id) { - const host = $(id); - if (!host) return; - const st = trangeState(id); - const custom = st.preset === 'custom'; - host.innerHTML = - '' + - '' + - '' + - '→' + - '' + - ''; - const preset = host.querySelector('.tr-preset'); - if (preset) preset.onchange = () => { - st.preset = preset.value; - // 从别的预设切到自定义时,把区间重置为"今天 00:00 → 现在", - // 免得用户上次留下的半年区间被无声沿用。 - if (st.preset === 'custom' && (!st.from || !st.to)) { st.from = trangeMidnight(); st.to = new Date(); } - trangeRender(id); - trangeEmit(id); - }; - const fromEl = host.querySelector('.tr-from'); - const toEl = host.querySelector('.tr-to'); - const readCustom = () => { - st.from = dtLocalParse(fromEl.value); - st.to = dtLocalParse(toEl.value); - // 起止颠倒就地标红(不静默纠正:用户可能正输到一半)。 - const bad = st.from && st.to && st.from > st.to; - fromEl.classList.toggle('tr-bad', !!bad); - toEl.classList.toggle('tr-bad', !!bad); - if (bad) return; - trangeEmit(id); - }; - if (fromEl) fromEl.onchange = readCustom; - if (toEl) toEl.onchange = readCustom; -} - -const trangeHandlers = new Map(); -// trangeBind 渲染控件并登记变化回调。**不**在绑定时触发回调:各视图的首次加载 -// 由 go() 统一驱动,这里再触发一次会让打开页面时打两遍接口。 -function trangeBind(id, onChange, preset) { - trangeHandlers.set(id, onChange); - if (preset) trangeState(id).preset = preset; - trangeRender(id); -} -function trangeEmit(id) { - const fn = trangeHandlers.get(id); - if (fn) fn(); -} - -// trangeQuery 把当前选择翻译成查询参数。 -// rolling=true → 滚动预设发 hours(服务端整点对齐),今天/自定义发 from/to -// rolling=false → 一律发 from/to(归档是线性日志,前端算区间更直观) -// 「全部历史」两者都不发。 -function trangeQuery(id, rolling) { - const st = trangeState(id); - const q = new URLSearchParams(); - const sec = d => Math.floor(d.getTime() / 1000); - if (st.preset === 'custom') { - if (st.from) q.set('from', sec(st.from)); - if (st.to) q.set('to', sec(st.to)); - return q; - } - if (st.preset === 'today') { - q.set('from', sec(trangeMidnight())); - return q; - } - if (st.preset === '0') return q; - if (rolling) { q.set('hours', st.preset); return q; } - q.set('from', sec(new Date(Date.now() - Number(st.preset) * 3600 * 1000))); - return q; -} - -// trangeLabel 人读口径,用于「用量总览」右上角这类需要回显区间的位置。 -function trangeLabel(id) { - const st = trangeState(id); - const found = TRANGE_PRESETS.find(p => p[0] === st.preset); - if (st.preset !== 'custom') return found ? found[1] : ''; - if (!st.from && !st.to) return '自定义'; - const f = d => d ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ' + - String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') : '…'; - return f(st.from) + ' → ' + f(st.to); -} -function rateLimitMeta(row, now) { - const model = String(row && row.model || '未知模型'); - const kind = String(row && row.kind || 'rate_limit'); - const resetAt = parseAPITime(row && row.reset_at); - const until = parseAPITime(row && row.until); - const deadline = resetAt || until; - const remaining = deadline > now ? Math.round((deadline - now) / 1000) : 0; - if (kind === 'model_unavailable') { - return { - model, - kind, - detail: remaining ? '预计 ' + dur(remaining) + ' 后重试' : '等待重新探测', - title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''), - }; - } - let detail = resetAt - ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') - : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); - const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知']; - if (until && resetAt && until < resetAt) { - detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试'; - title.push('网关最早重试:' + fmtLocalDateTime(until)); - } - return { model, kind, detail, title: title.join('\n') }; -} -function rateLimitRowsHtml(rows, now) { - const list = Array.isArray(rows) ? rows.filter(row => row && row.model) : []; - if (!list.length) return ''; - return '
' + list.map(row => { - const m = rateLimitMeta(row, now); - return '
' + esc(m.model) + '' + esc(m.detail) + '
'; - }).join('') + '
'; -} - -function formatTokenCount(tokens) { - if (tokens == null || tokens === '') return '—'; - const n = Number(tokens); - if (!Number.isFinite(n) || n < 0) return '—'; - if (n < 1000) return String(Math.round(n)); - const units = [['k', 1e3], ['m', 1e6], ['b', 1e9]]; - let unit = units[0]; - for (const candidate of units) { - if (n >= candidate[1]) unit = candidate; - } - let value = n / unit[1]; - let rounded = Number(value.toFixed(1)); - // 999999 → 1m,而不是 1000k;四舍五入后自动升级单位。 - const next = units[units.indexOf(unit) + 1]; - if (next && rounded >= 1000) { - unit = next; - value = n / unit[1]; - rounded = Number(value.toFixed(1)); - } - return rounded + unit[0]; -} - -function formatLatency(ms) { - if (ms == null || ms === '') return '—'; - const n = Number(ms); - if (!Number.isFinite(n) || n <= 0) return '—'; - return n < 1000 ? Math.round(n) + 'ms' : (n / 1000).toFixed(1).replace(/\.0$/, '') + 's'; -} -function formatRate(rate) { - if (rate == null || rate === '') return '—'; - const n = Number(rate); - if (!Number.isFinite(n) || n < 0) return '—'; - return n.toFixed(1) + 'tok/s'; -} - -/* ── 密钥门 ───────────────────────────────────────────────────────── */ -function openKey() { $('keyVeil').classList.add('on'); setTimeout(() => $('keyInput').focus(), 60); } -$('btnKey').onclick = async () => { - const v = $('keyInput').value.trim(); - if (!v) return; - localStorage.setItem(LS_KEY, v); - try { - await api('overview'); - $('keyErr').hidden = true; - $('keyVeil').classList.remove('on'); - start(); - } catch (e) { $('keyErr').hidden = false; } -}; -$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('btnKey').click(); }); - -/* ── 路由 ─────────────────────────────────────────────────────────── */ -const TITLES = { accounts: '账号池', usage: '用量', packages: '积分构成', taskscenter: '任务中心', models: '模型与档位', config: '配置', logs: '运行日志' }; -function go(v) { - view = v; - document.querySelectorAll('.view').forEach(s => s.hidden = s.id !== 'view-' + v); - document.querySelectorAll('.nav a').forEach(a => a.classList.toggle('on', a.dataset.view === v)); - $('ttl').textContent = TITLES[v]; - if (v === 'models' && !$('mdBody').children.length) loadModels(); - if (v === 'config') loadConfig(); - if (v === 'logs') loadLogs(); - if (v === 'usage') loadUsage(); - if (v === 'packages') loadPackages(); - if (v === 'taskscenter') reattachQueueView(); -} -document.querySelectorAll('.nav a').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.view); history.replaceState(null, '', '#' + a.dataset.view); }); -/* 首次进入延到本轮脚本求值之后再 go()。 - 原因:go() 会同步触发视图的数据加载(loadUsage/loadLogs/loadPackages…),而这些 - 函数读到的模块级 let/const(usageRateWarmAt、PK_* 等)在文件后半段才初始化—— - 直接深链 #usage / #packages 打开页面时会踩 TDZ("Cannot access 'x' before - initialization"),表现为该页永远显示"读取失败",而点导航进去一切正常。 - 延迟 0ms 让整份脚本先求值完,是修这一类问题最省事也最不容易再犯的办法。 */ -setTimeout(() => { - const hash = (location.hash || '#accounts').slice(1); - go(hash in TITLES ? hash : 'accounts'); -}, 0); - -/* ── 账号池 ───────────────────────────────────────────────────────── */ -function renderAccounts(list) { - const tb = $('accBody'); - if (!list.length) { - tb.innerHTML = '
账号池是空的
点击右上角「添加账号」,用浏览器登录一个 WorkBuddy 账号
'; - return; - } - // 有总额度(credits_total)→ 进度条按自身 剩余/总额 百分比;旧数据无总额 → 退回池内最高=100% - const maxCred = Math.max(1, ...list.map(s => s.credits || 0)); - tb.innerHTML = list.map(s => { - const bl = (new Date(s.breaker_until || 0) - Date.now()) / 1000; - const dg = (new Date(s.degrade_until || 0) - Date.now()) / 1000; - const cool = Math.max(s.cool_remaining_sec || 0, bl > 0 ? bl : 0, dg > 0 ? dg : 0); - let cls = '', tag; - if (s.disabled) { cls = 'off'; tag = '已禁用'; } - else if (cool > 0) { - cls = 'cool'; - const kind = bl > Math.max(s.cool_remaining_sec || 0, dg > 0 ? dg : 0) ? '熔断' - : (dg > (s.cool_remaining_sec || 0) ? '连败降权' : (s.cool_kind === 'hard_credit' ? '积分冷却' : '限流冷却')); - tag = '' + kind + ' · ' + dur(cool) + ''; - } else tag = '可用' + (s.in_flight ? '' : ''); - const note = s.reason ? '
' + esc(s.reason) + '
' : ''; - const rateLimits = rateLimitRowsHtml(s.rate_limited_models, Date.now()); - const short = s.uid.length > 16 ? s.uid.slice(0, 16) + '…' : s.uid; - const cred = s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits)); - const pct = s.credits_total > 0 - ? Math.min(100, Math.round((s.credits || 0) / s.credits_total * 100)) - : Math.round((s.credits || 0) / maxCred * 100); - // 成本台账 tooltip(model_costs):每模型实测单价(≤0 = 实测免费),运维据此 - // 看「为什么总选它」——免费号垄断 / 单价排序一眼可见。 - let credTip = s.credits_total > 0 ? '剩余 ' + s.credits + ' / 总额 ' + s.credits_total + '(' + pct + '%)' : '积分(相对池内最高)'; - const costs = (s.model_costs || []).filter(c => c.model); - if (costs.length) { - credTip += '\n实测单价(credits/1K):\n' + costs.map(c => - ' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n'); - } - const frozen = s.disabled || cool > 0; - const tu = s.token_usage || {}; - const req = tu.request_count || 0; - const totalTok = formatTokenCount(tu.total_tokens); - const totalTokUnit = totalTok === '—' ? '' : 'tok'; - const latency = formatLatency(tu.last_latency_ms); - const rate = formatRate(tu.last_tokens_per_second); - const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate; - return '' + - '' + - '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + '
' + esc(short) + '
' + - '' + tag + note + rateLimits + '' + - '
' + cred + '
' + - '' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + '' + - '' + (s.in_flight || 0) + '' + - '' + - '' + req + '次' + - '' + totalTok + '' + totalTokUnit + '' + - '' + latency + '' + - '' + rate + '' + - '' + - '' + ago(s.last_success) + '' + - '' + - '' + - '' + - '' + - (frozen ? '' - : '') + - '' + - ''; - }).join(''); -} - -async function loadOverview(quiet) { - try { - const d = await api('overview'); - overviewData = d; - $('sTotal').textContent = d.total; - $('sHealthy').textContent = d.healthy; - $('sCooling').textContent = d.cooling; - $('sDisabled').textContent = d.disabled; - const remSum = (d.accounts || []).reduce((a, s) => a + (s.credits || 0), 0); - const totSum = (d.accounts || []).reduce((a, s) => a + (s.credits_total || 0), 0); - $('sCredits').textContent = totSum > 0 ? remSum + ' / ' + totSum : remSum; - $('sSticky').textContent = d.sticky_sessions; - $('navSub').textContent = 'v' + d.version; - $('navVer').textContent = 'v' + d.version; - $('navRedis').textContent = d.redis_mode === 'upstash' ? 'Redis 镜像' : '本地内存'; - $('navState').textContent = d.healthy > 0 ? '服务正常' : (d.total ? '无可用账号' : '待添加账号'); - const p = $('navPulse'); - p.className = 'pulse' + (d.healthy > 0 ? '' : (d.total ? ' warn' : ' bad')); - $('accNote').textContent = d.in_flight_full ? d.in_flight_full + ' 个账号在途占满' : ''; - const up = Math.floor(d.uptime_sec); - $('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分'; - renderAccounts(d.accounts || []); - } catch (e) { if (!quiet) toast(e.message, 'err'); } -} - -$('accBody').addEventListener('click', async ev => { - const b = ev.target.closest('button[data-a]'); - if (!b) return; - const u = b.dataset.u, a = b.dataset.a; - if (a === 'remove' && !confirm('移除账号将删除池状态与 auths/ 下的凭证文件,且不可恢复。确认移除?')) return; - if (a === 'disable' && !confirm('禁用后该账号不再参与选号,需手动解冻才能恢复。确认禁用?')) return; - b.disabled = true; - try { - if (a === 'checkin') { - const r = await api('accounts/' + encodeURIComponent(u) + '/checkin', { method: 'POST' }); - toast('签到完成' + (r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + (r.checkin_message ? '(' + r.checkin_message + ')' : ''), 'ok'); - } else if (a === 'balance') { - const r = await api('accounts/' + encodeURIComponent(u) + '/balance', { method: 'POST' }); - toast('余额已更新:' + r.credits + (r.credits_total > 0 ? ' / ' + r.credits_total : ''), 'ok'); - } else if (a === 'revive') { - await api('accounts/' + encodeURIComponent(u) + '/revive', { method: 'POST' }); - toast('已解冻', 'ok'); - } else if (a === 'disable') { - await api('accounts/' + encodeURIComponent(u) + '/disable', { method: 'POST' }); - toast('已禁用', 'ok'); - } else if (a === 'tasks') { - openTasks(u); - } else if (a === 'remove') { - const r = await api('accounts/' + encodeURIComponent(u) + '/remove', { method: 'POST' }); - toast(r.file_error ? '已移除(凭证文件删除失败:' + r.file_error + ')' : '已移除', 'ok'); - } - } catch (e) { toast(e.message, 'err'); } - finally { b.disabled = false; loadOverview(true); } -}); - -$('btnCheckinAll').onclick = async () => { - try { await api('checkin_all', { method: 'POST' }); toast('全部签到已开始,结果见日志', 'ok'); } - catch (e) { toast(e.message, 'err'); } -}; -$('btnKeepaliveAll').onclick = async () => { - try { await api('keepalive_all', { method: 'POST' }); toast('全部保活已开始,结果见日志', 'ok'); } - catch (e) { toast(e.message, 'err'); } -}; -$('btnTravelAll').onclick = async () => { - try { await api('travel_all', { method: 'POST' }); toast('旅行巡检已开始(含领养链路),结果见日志', 'ok'); } - catch (e) { toast(e.message, 'err'); } -}; -$('btnActivityAll').onclick = async () => { - try { await api('activity_all', { method: 'POST' }); toast('活跃上报已开始,结果见日志', 'ok'); } - catch (e) { toast(e.message, 'err'); } -}; - -/* ── 模型 ─────────────────────────────────────────────────────────── */ -/* 实测上限标注:scripts/probe_max_tokens.py --panel-out 写入探测结果, - /panel/api/model_probes 只读透传。探测键带域前缀(cn:glm-5.2),模型表 - 显示裸名,按「精确命中或 :后缀」关联。无数据时本列退回上游声称值。 */ -function fmtK(n) { n = Number(n || 0); return n >= 1000 ? Math.round(n / 1000) + 'K' : String(n); } -function probeDays(ts) { - if (!ts) return null; - const t = new Date(String(ts).replace(' ', 'T')); - const d = (Date.now() - t.getTime()) / 86400000; - return isNaN(d) ? null : Math.floor(d); -} -function outCell(m, pr) { - if (!pr) return '' + (m.max_output_tokens ? fmtK(m.max_output_tokens) : '—') + ''; - const tip = '声称 ' + (pr.claimed ? fmtK(pr.claimed) : '?') + ' · 实测 ' + (pr.measured ? fmtK(pr.measured) : '?') + - (pr.note ? ' · ' + pr.note : '') + (pr.tested_at ? ' · 探测于 ' + pr.tested_at : ''); - const days = probeDays(pr.tested_at); - const stale = days !== null && days > 30 ? ' · ' + days + ' 天前' : ''; - if (pr.verdict === 'clamped' && pr.measured) { - if (pr.claimed && pr.measured < pr.claimed) { - const x = pr.claimed / pr.measured; - const xs = (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + '×'; - return '' + - fmtK(pr.measured) + ' ⚠
钳制 ' + xs + stale + '
'; - } - return '' + fmtK(pr.measured) + - (pr.claimed && pr.measured > pr.claimed ? ' ↑' : ' ✓') + ''; - } - if (pr.verdict === 'at_least' && pr.measured) - return '≥' + fmtK(pr.measured) + ''; - return '?
未测出' + stale + '
'; -} - -/* rateCell 倍率列:牌价 vs 生效价。上游 credits 是牌价(转正后基准倍率), - modelPromotions 给当前生效折扣(限时免费 factor=0 / 夜间五折 0.5 等)—— - WorkBuddy 客户端显示的正是生效价。有折扣:生效价大字 + 标签 + 划线牌价, - 悬停带时段说明;无 factor 只有标签(错峰类):牌价 + 标签。 */ -function rateCell(m) { - const tip = m.promo_note ? ' title="' + esc(m.promo_note) + '"' : ''; - if (m.promo_factor != null && m.promo_credits) { - const base = m.credits ? ' ' + esc(m.credits) + '' : ''; - const label = m.promo_label ? ' ' + esc(m.promo_label) + '' : ''; - return '' + esc(m.promo_credits) + '' + label + base + ''; - } - if (m.promo_label) { - return '' + (m.credits ? esc(m.credits) : '—') + - ' ' + esc(m.promo_label) + ''; - } - return m.credits ? esc(m.credits) : '—'; -} - -async function loadModels() { - const tb = $('mdBody'); - tb.innerHTML = '
正在向上游查询…
'; - try { - // 探测数据是可选增强:拉取失败不影响模型列表本身 - const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]); - mdAll = d.models || []; - mdProbes = pr.probes || {}; - if (!mdAll.length) { - tb.innerHTML = '
上游未返回模型
'; - $('mdCount').textContent = ''; - $('mdNote').textContent = '上游未返回模型'; - return; - } - // 探测键带域前缀(cn:glm-5.2),模型表显示裸名,按「精确命中或 :后缀」关联。 - const probeKeys = Object.keys(mdProbes); - mdProbeOf = id => mdProbes[id] || mdProbes[probeKeys.find(k => k.endsWith(':' + id))]; - const hit = mdAll.filter(m => mdProbeOf(m.id)).length; - $('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : ''); - renderModels(); - } catch (e) { - mdAll = []; - tb.innerHTML = '
' + esc(e.message) + '
'; - $('mdCount').textContent = ''; - } -} - -/* ── 模型筛选(按条件查询)─────────────────────────────────────────── - 模型目录一次拉全(几十条),筛选与排序全部在前端完成:改条件零延迟,且不会 - 因为调一次筛选就打一次上游——/panel/api/models 是直连上游的实时查询,很贵。 - 条件之间是 AND;每个条件为空即不参与判定。 */ -// mdRateValue 当前生效的积分倍率数值:优先促销价(限时免费 = 0),无倍率记为 -// Infinity 排到最后(排序时"没有价格"不该冒充最便宜)。 -function mdRateValue(m) { - const raw = (m.promo_credits != null && m.promo_credits !== '') ? m.promo_credits : m.credits; - const n = parseFloat(String(raw == null ? '' : raw).replace(/[^\d.]/g, '')); - return Number.isFinite(n) ? n : Infinity; -} - -// mdSearchText 参与关键字搜索的字段(ID / 展示名 / 厂商 / 描述 / 标签)。 -function mdSearchText(m) { - return [m.id, m.name, m.vendor, m.description, (m.tags || []).join(' ')] - .filter(Boolean).join(' ').toLowerCase(); -} - -// mdMatch 单个模型是否满足全部筛选条件。 -function mdMatch(m, f) { - f = f || mdFilter; - if (f.q) { - const text = mdSearchText(m); - // 空格分词后逐个匹配:多关键词是 AND,便于"cn 视觉"这类组合查询。 - for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { - if (!text.includes(kw)) return false; - } - } - if (f.realm && !String(m.id || '').startsWith(f.realm + ':')) return false; - if (f.cap === 'tool' && !m.supports_tool_call) return false; - if (f.cap === 'vision' && !m.supports_images) return false; - if (f.cap === 'reasoning' && !m.supports_reasoning) return false; - if (f.cap === 'default' && !m.is_default) return false; - if (f.effort === 'off') { - if (!m.can_disable_thinking) return false; - } else if (f.effort && !(m.supported_efforts || []).includes(f.effort)) { - return false; - } - const factor = m.promo_factor == null ? null : Number(m.promo_factor); - if (f.promo === 'promo' && factor == null && !m.promo_label) return false; - if (f.promo === 'free' && !(factor === 0)) return false; - if (f.promo === 'discount' && !(factor != null && factor > 0)) return false; - return true; -} - -// mdSortList 按当前排序条件返回新数组(不改动入参,保持上游原始顺序可回溯)。 -function mdSortList(list, f) { - f = f || mdFilter; - const out = list.slice(); - const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; - if (f.sort === 'rate') out.sort((a, b) => mdRateValue(a) - mdRateValue(b)); - else if (f.sort === 'context') out.sort((a, b) => num(b.context_length) - num(a.context_length)); - else if (f.sort === 'output') out.sort((a, b) => num(b.max_output_tokens) - num(a.max_output_tokens)); - else if (f.sort === 'name') out.sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))); - return out; -} - -// mdRowHtml 单个模型行(纯渲染,便于独立测试)。 -function mdRowHtml(m, pr) { - const eff = (m.supported_efforts || []).slice(); - if (m.can_disable_thinking && eff.length && !eff.includes('off')) eff.push('off(可关)'); - const effs = eff.length ? eff.map(e => '' + esc(e) + '').join(' ') - : '' + (m.supports_reasoning ? '固定档 · 默认 ' + esc(m.default_effort || '?') : '不支持思考') + ''; - // 能力徽标:默认模型 / 工具调用 / 视觉 / 纯推理(上游目录全字段透出,缺失不显示) - const caps = []; - if (m.is_default) caps.push('默认'); - if (m.supports_tool_call) caps.push('工具'); - if (m.supports_images) caps.push('视觉'); - if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开'); - const capHtml = caps.length ? '
' + caps.join(' ') + '
' : ''; - const tip = m.description ? ' title="' + esc(m.description) + '"' : ''; - return '
' + esc(m.id) + '
' + esc(m.name || '') + '
' + capHtml + '' + - '' + rateCell(m) + '' + - '' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + '' + - '' + effs + '' + - '' + (m.context_length ? Math.round(m.context_length / 1000) + 'K' : '—') + '' + - outCell(m, pr) + ''; -} - -function renderModels() { - const tb = $('mdBody'); - const list = mdSortList(mdAll.filter(m => mdMatch(m))); - if (!list.length) { - tb.innerHTML = '
没有符合当前筛选条件的模型
'; - } else { - tb.innerHTML = list.map(m => mdRowHtml(m, mdProbeOf(m.id))).join(''); - } - const filtered = list.length !== mdAll.length; - $('mdCount').textContent = !mdAll.length ? '' - : filtered ? '命中 ' + list.length + ' / ' + mdAll.length + ' 个模型' - : mdAll.length + ' 个模型'; - $('mdCount').className = filtered ? 'note src-off' : 'note'; -} - -function resetModelFilter() { - mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; - $('mdQ').value = ''; $('mdRealm').value = ''; $('mdCap').value = ''; - $('mdEffort').value = ''; $('mdPromo').value = ''; $('mdSort').value = 'default'; - renderModels(); -} - -// 筛选控件:输入框防抖 120ms(长列表逐字符重排不必每键一次),下拉即时。 -let mdQTimer = null; -$('mdQ').oninput = () => { - clearTimeout(mdQTimer); - mdQTimer = setTimeout(() => { mdFilter.q = $('mdQ').value.trim(); renderModels(); }, 120); -}; -for (const [id, key] of [['mdRealm', 'realm'], ['mdCap', 'cap'], ['mdEffort', 'effort'], ['mdPromo', 'promo'], ['mdSort', 'sort']]) { - const el = $(id); - if (!el) continue; - el.onchange = () => { mdFilter[key] = el.value; renderModels(); }; -} -$('mdReset').onclick = resetModelFilter; -$('btnModels').onclick = loadModels; - -/* ── 日志(频道:全部/任务/对话/系统) ─────────────────────────────── */ -let logCh = 'all'; -$('logChips').addEventListener('click', ev => { - const b = ev.target.closest('button[data-ch]'); - if (!b) return; - logCh = b.dataset.ch; - document.querySelectorAll('#logChips .chip').forEach(c => c.classList.toggle('on', c === b)); - loadLogs(); -}); -async function loadLogs() { - const box = $('logBox'); - const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 24; - const limit = ($('reqLimit') && $('reqLimit').value) || 100; - // 时间范围由归档侧过滤(不是前端筛已拉取的条目):区间落在更早的时间段时, - // 「最近 N 条」里根本不会有那些记录,必须让服务端按时间取。 - const rq = trangeQuery('reqRange', false); - rq.set('limit', limit); - try { - const [d, metrics, requestRows] = await Promise.all([ - api('logs'), - api('request_metrics').catch(() => ({})), - api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })), - ]); - // 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里 - // 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。 - // 只有归档关闭时才回落到内存指标,保证没有归档的部署仍能看到最近请求。 - const archiveOn = !!(metrics && metrics.archive && metrics.archive.enabled); - const recent = archiveOn ? (requestRows.entries || []) : (metrics.recent || []); - renderRequestMetrics(metrics, recent); - const entries = (d.entries || []).filter(e => logCh === 'all' || e.ch === logCh); - box.innerHTML = entries.length - ? entries.map(e => { - const lvl = /error|失败|错误/.test(e.text) ? ' e' : /warn|冷却|熔断/.test(e.text) ? ' w' : ''; - const t = e.ts ? new Date(e.ts).toLocaleTimeString('zh-CN', { hour12: false }) : ''; - const ch = logCh === 'all' ? '' + ({ task: '任务', chat: '对话', sys: '系统' }[e.ch] || e.ch) + '' : ''; - return '' + ch + esc(t + ' ' + e.text) + ''; - }).join('') - : '暂无日志'; - if (logPin && atEnd) box.scrollTop = box.scrollHeight; - const counts = {}; - for (const e of (d.entries || [])) counts[e.ch] = (counts[e.ch] || 0) + 1; - $('logNote').textContent = logCh === 'all' - ? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0) - : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行'; - } catch (e) { /* 概览已提示 */ } -} - -function renderRequestMetrics(m, entries) { - m = m || {}; - const a = m.archive || {}; - $('reqSummary').textContent = - '已完成 ' + fmtTok(m.completed) + - ' · 成功 ' + (m.success_rate == null ? '—' : Number(m.success_rate).toFixed(1) + '%') + - ' · HTTP ' + (m.http_success_rate == null ? '—' : Number(m.http_success_rate).toFixed(1) + '%') + - ' · 平均 ' + fmtMs(m.avg_duration_ms) + - ' · 进行中 ' + String(m.in_flight || 0); - $('reqNote').textContent = a.enabled - ? 'JSONL 归档 ' + fmtBytes(a.bytes) + (a.dropped_writes ? ' · 丢弃 ' + a.dropped_writes + ' 条' : '') + - (a.last_error ? ' · 错误:' + a.last_error : '') - : '仅内存指标,JSONL 归档已关闭'; - - reqEntries = entries || []; - renderRequestTable(); -} - -/* reqMatch 请求记录筛选:q 对 IP/UA/模型/账号/请求 ID 做空格分词的 AND 包含匹配, - outcome 精确匹配。两者都在已拉取的条目上做(最多 1000 条),不发新请求。 */ -function reqMatch(e, f) { - f = f || reqFilter; - if (f.outcome && String(e && e.outcome || '') !== f.outcome) return false; - if (f.q) { - const text = [e && e.client_ip, e && e.user_agent, e && e.model, e && e.account, e && e.request_id] - .filter(Boolean).join(' ').toLowerCase(); - for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { - if (!text.includes(kw)) return false; - } - } - return true; -} - -function reqOutcomeTag(e) { - const outcome = String(e && e.outcome || ''); - const label = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }[outcome] || outcome || '—'; - const cls = outcome === 'success' ? 'ok' - : outcome === 'interrupted' ? 'warn' - : outcome ? 'bad' : 'mute'; - return '' + esc(String(e && e.status || '—') + ' ' + label) + ''; -} - -function reqTokenCell(e) { - const total = Number(e && e.total_tokens || 0) || - (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); - return total ? fmtTok(total) : '—'; -} - -function reqCreditCell(e) { - if (!e || !e.credit_known) return '—'; - const v = Number(e.credit); - return Number.isFinite(v) ? trimFixed(v.toFixed(2)) : '—'; -} - -/* renderRequestTable 渲染请求记录表。来源列是这一版的重点:IP 用等宽字体方便扫, - UA 单行截断(完整值在 title 里,行本身用 requestLogText 作 tooltip)。 */ -function renderRequestTable() { - const list = reqEntries.filter(e => reqMatch(e)); - const tb = $('reqBody'); - if (!tb) return; - tb.innerHTML = list.map(e => { - const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; - const ip = e && e.client_ip ? e.client_ip : ''; - const ua = e && e.user_agent ? e.user_agent : ''; - const rid = e && e.request_id ? e.request_id : ''; - return '' + - '' + esc(when) + '' + - '' + reqOutcomeTag(e) + '' + - '' + esc(e && e.model || '—') + '' + - '' + esc(e && e.account || '—') + '' + - '' + (ip ? '' + esc(ip) + '' : '—') + '' + - '' + (ua ? '' + esc(ua) + '' : '—') + '' + - '' + fmtMs(e && e.duration_ms) + '' + - '' + reqTokenCell(e) + '' + - '' + reqCreditCell(e) + '' + - '' + (rid ? '' + esc(rid) + '' : '—') + '' + - ''; - }).join('') || '' + - (reqEntries.length ? '没有符合当前筛选条件的请求记录' : '暂无请求记录') + ''; - - const filtered = list.length !== reqEntries.length; - // 归档里的旧条目没有来源字段(该功能上线前写入):这时提示开关/历史原因, - // 而不是让人以为筛选坏了。 - const hasSource = reqEntries.some(e => e && (e.client_ip || e.user_agent)); - $('reqCount').textContent = !reqEntries.length ? '' - : (filtered ? '命中 ' + list.length + ' / ' + reqEntries.length + ' 条' : reqEntries.length + ' 条') + - (hasSource ? '' : ' · 来源未记录'); - $('reqCount').className = (filtered || !hasSource) ? 'note src-off' : 'note'; -} - -/* 请求记录筛选控件。搜索框防抖 150ms:最多 1000 行重渲染,不必每键一次。 - 这段顶层绑定放在 requestLogText 之前,是为了让"纯函数切片"式前端测试 - (slice requestLogText → fmtBytes)只拿到无副作用的格式化函数。 */ -let reqQTimer = null; -if ($('reqQ')) $('reqQ').oninput = () => { - clearTimeout(reqQTimer); - reqQTimer = setTimeout(() => { reqFilter.q = $('reqQ').value.trim(); renderRequestTable(); }, 150); -}; -if ($('reqOutcome')) $('reqOutcome').onchange = () => { - reqFilter.outcome = $('reqOutcome').value; - renderRequestTable(); -}; -if ($('reqLimit')) $('reqLimit').onchange = loadLogs; -if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs; -// 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条", -// 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。 -if ($('reqRange')) trangeBind('reqRange', loadLogs, '0'); - -function requestLogText(e) { - const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; - const outcomeLabel = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }; - const token = Number(e && e.total_tokens || 0) || - (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); - let credit = 'credit —'; - if (e && e.credit_known) { - const value = Number(e.credit); - if (Number.isFinite(value)) credit = String(Number(value.toFixed(2))) + ' credit'; - } - return [ - when, - String(e && e.status || '—') + ' ' + (outcomeLabel[e && e.outcome] || (e && e.outcome) || '—'), - e && e.model || '—', - e && e.account || '—', - e && e.client_ip || '—', - e && e.user_agent || '—', - fmtMs(e && e.duration_ms), - fmtTok(token) + ' tok', - credit, - cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens) === '—' ? '' : '命中 ' + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens), - e && e.request_id || '—', - ].filter(Boolean).join(' | '); -} - -/* 缓存命中率纯文本(issue #92):requestLogText 与积分表/kpi 卡共用。 - 自包含(不依赖 trimFixed):前端纯函数切片测试只截取本段。 */ -function cacheRateText(hit, miss) { - const h = Number(hit || 0), m = Number(miss || 0), total = h + m; - if (!total) return '—'; - return String(Math.round(h / total * 1000) / 10) + '%'; -} - -function fmtBytes(bytes) { - const n = Number(bytes || 0); - if (n < 1024) return n + ' B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; - return (n / 1024 / 1024).toFixed(1) + ' MB'; -} -$('btnLogPin').onclick = () => { - logPin = !logPin; - $('btnLogPin').textContent = '自动滚动:' + (logPin ? '开' : '关'); -}; - -/* ── 配置 ─────────────────────────────────────────────────────────── */ -const CFG_MAP = { - listen: ['listen'], api_key: ['api_key'], - package_detail_limit: ['panel', 'package_detail_limit'], - checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'], - travel_hours: ['schedule', 'travel_hours'], travel_enabled: ['schedule', 'travel_enabled'], - activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'], - keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'], - balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'], - max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'], - breaker_threshold: ['pool', 'breaker_threshold'], - degrade_threshold: ['pool', 'degrade_threshold'], degrade_cooldown: ['pool', 'degrade_cooldown'], - degrade_cooldown_max: ['pool', 'degrade_cooldown_max'], - cost_explore_interval: ['pool', 'cost_explore_interval'], - credit_floor: ['pool', 'credit_floor'], - prefer_expiring: ['pool', 'prefer_expiring'], expiring_soon: ['pool', 'expiring_soon'], - soft_rate: ['cooldown', 'soft_rate'], soft_rate_max: ['cooldown', 'soft_rate_max'], - breaker_cooldown: ['pool', 'breaker_cooldown'], breaker_cooldown_max: ['pool', 'breaker_cooldown_max'], - idle_weight_per_hour: ['pool', 'idle_weight_per_hour'], idle_weight_max: ['pool', 'idle_weight_max'], - ttl: ['session_sticky', 'ttl'], - timeout_seconds: ['upstream', 'timeout_seconds'], header_timeout_seconds: ['upstream', 'header_timeout_seconds'], - idle_timeout_seconds: ['upstream', 'idle_timeout_seconds'], user_agent: ['upstream', 'user_agent'], - prompt_mode: ['prompt', 'mode'], prompt_file: ['prompt', 'file'], - sanitize_blacklist_fingerprints: ['features', 'sanitize_blacklist_fingerprints'], - session_sticky_enabled: ['session_sticky', 'enabled'], - request_client_info: ['logging', 'request_client_info'], -}; -function dig(obj, path) { return path.reduce((o, k) => (o == null ? undefined : o[k]), obj); } -function put(obj, path, val) { - let o = obj; - for (let i = 0; i < path.length - 1; i++) { if (typeof o[path[i]] !== 'object' || o[path[i]] === null) o[path[i]] = {}; o = o[path[i]]; } - o[path[path.length - 1]] = val; -} - -async function loadConfig() { - try { - const d = await api('config'); - cfgLoaded = d.config; - $('cfgPath').textContent = d.path || ''; - const f = $('cfgForm'); - for (const [name, path] of Object.entries(CFG_MAP)) { - const el = f.elements[name]; - if (!el) continue; - const v = dig(cfgLoaded, path); - if (el.type === 'checkbox') el.checked = !!v; - else if (Array.isArray(v)) el.value = v.join(', '); - else el.value = v == null ? '' : v; - } - markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法) - $('cfgNote').textContent = ''; - } catch (e) { toast('读取配置失败:' + e.message, 'err'); } -} -function collectConfig() { - const f = $('cfgForm'), out = {}; - for (const [name, path] of Object.entries(CFG_MAP)) { - const el = f.elements[name]; - if (!el) continue; - let v; - if (el.type === 'checkbox') v = el.checked; - else if (el.type === 'number') { v = el.value.trim() === '' ? undefined : Number(el.value); } - else { - const raw = el.value.trim(); - if (raw === '') v = undefined; - else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number); - else v = raw; - } - if (v !== undefined) put(out, path, v); - } - return out; -} -/* Go 时长字段即时校验:空 = 沿用现值(collectConfig 跳过发送);非空必须是 - ParseDuration 语法(30m / 2h / 600s / 1h30m,可组合可带小数)。与后端 - config.go normalize() 的 time.ParseDuration 同口径,脏值在前端就地标红, - 不再等到保存被拒。 */ -const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; -const DURATION_FIELDS = ['soft_rate', 'soft_rate_max', 'breaker_cooldown', 'breaker_cooldown_max', - 'degrade_cooldown', 'degrade_cooldown_max', 'cost_explore_interval', 'expiring_soon', 'ttl']; -const DURATION_TIP = '格式应为 Go 时长:30m / 2h / 600s / 1h30m'; -function durationBad(name) { - const el = $('cfgForm').elements[name]; - if (!el) return false; - const v = el.value.trim(); - return v !== '' && !DURATION_RE.test(v); -} -function markDurationFields() { - for (const name of DURATION_FIELDS) { - const el = $('cfgForm').elements[name]; - if (!el) continue; - const bad = durationBad(name); - el.classList.toggle('invalid', bad); - el.title = bad ? DURATION_TIP : ''; - } -} -$('cfgForm').addEventListener('input', ev => { - if (DURATION_FIELDS.includes(ev.target.name)) markDurationFields(); -}); -$('btnEye').onclick = () => { - const el = $('cfgKey'); - const show = el.type === 'password'; - el.type = show ? 'text' : 'password'; - $('btnEye').textContent = show ? '隐藏' : '显示'; -}; -$('btnCfgReload').onclick = loadConfig; -$('cfgForm').onsubmit = async ev => { - ev.preventDefault(); - // 时长字段脏值拦截:标红 + toast 点名,不发保存请求(后端同样会拒,这里前置)。 - markDurationFields(); - const firstBad = DURATION_FIELDS.find(durationBad); - if (firstBad) { - const el = $('cfgForm').elements[firstBad]; - el.focus(); - toast('「' + (el.closest('.fld')?.querySelector('.lb')?.textContent || firstBad) + '」' + DURATION_TIP, 'err'); - return; - } - const btn = $('btnCfgSave'); - btn.disabled = true; btn.textContent = '保存中…'; - try { - const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) }); - const n = (r.restart_required || []).length; - toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok'); - // 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。 - const k = $('cfgKey').value.trim(); - if (k) localStorage.setItem(LS_KEY, k); - loadConfig(); - loadOverview(true); - } catch (e) { toast('保存失败:' + e.message, 'err'); } - finally { btn.disabled = false; btn.textContent = '保存配置'; } -}; - -/* ── 添加账号 ─────────────────────────────────────────────────────── */ -function openAdd() { - $('addVeil').classList.add('on'); - // 重置到登录标签 - switchAddTab('login'); - $('addPick').hidden = false; - $('addLoad').hidden = true; $('addReady').hidden = true; - $('addDone').hidden = true; $('addErr').hidden = true; - $('importDone').hidden = true; $('importErr').hidden = true; - $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true; - $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false; - stopPoll(); -} -function switchAddTab(tab) { - document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab)); - $('addTabLogin').hidden = tab !== 'login'; - $('addTabImport').hidden = tab !== 'import'; -} -document.querySelectorAll('#addTabs .tab').forEach(b => { - b.onclick = () => switchAddTab(b.dataset.tab); -}); -function startAddLogin() { - const realm = (document.querySelector('input[name="addRealm"]:checked') || {}).value || 'cn'; - $('btnStartLogin').disabled = true; - $('addLoad').hidden = false; $('addErr').hidden = true; - api('login/start', { method: 'POST', body: JSON.stringify({ realm }) }).then(r => { - loginState = r.state; - $('addUrl').textContent = r.url; - $('addPick').hidden = true; // 选域锁定(会话已按该域发起) - $('addLoad').hidden = true; $('addReady').hidden = false; - $('btnStartLogin').hidden = true; - $('btnCopyUrl').hidden = false; $('btnOpenUrl').hidden = false; - loginTimer = setInterval(pollLogin, 3000); - }).catch(e => { - $('addLoad').hidden = true; - $('btnStartLogin').disabled = false; - $('addErr').hidden = false; - $('addErr').textContent = e.message; - }); -} -function stopPoll() { if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } } -async function pollLogin() { - if (!loginState) return; - try { - const r = await api('login/poll?state=' + encodeURIComponent(loginState)); - if (r.done) { - stopPoll(); - $('addReady').hidden = true; - $('addDone').hidden = false; - $('addDone').textContent = '已添加 ' + (r.nickname || r.uid) + (r.realm === 'global' ? '(国际版)' : '') + (r.credits >= 0 ? ' · 积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + ',账号已载入池中'; - setTimeout(() => { closeAdd(); loadOverview(true); }, 1600); - } - } catch (e) { - stopPoll(); - $('addReady').hidden = true; - $('addErr').hidden = false; - $('addErr').textContent = e.message + '(关闭后重新添加)'; - } -} -function closeAdd() { stopPoll(); loginState = null; $('addVeil').classList.remove('on'); } -$('btnCloseAdd').onclick = closeAdd; -$('btnStartLogin').onclick = startAddLogin; -$('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank'); -$('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent) - .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err')); -$('importFile').onchange = async () => { - const file = $('importFile').files[0]; - if (!file) return; - $('importDone').hidden = true; $('importErr').hidden = true; - const fd = new FormData(); - fd.append('file', file); - const h = {}; - const k = localStorage.getItem(LS_KEY); - if (k) h['Authorization'] = 'Bearer ' + k; - try { - const r = await fetch('/panel/api/import/cockpit', { method: 'POST', body: fd, headers: h }); - const d = await r.json(); - if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); - $('importDone').hidden = false; - $('importDone').textContent = '导入完成:成功 ' + d.imported + ' 个' + (d.skipped ? ',跳过 ' + d.skipped + ' 个' : ''); - if (d.errors && d.errors.length) { - console.warn('import errors:', d.errors); - } - loadOverview(true); - } catch (e) { - $('importErr').hidden = false; - $('importErr').textContent = '导入失败:' + e.message; - } - $('importFile').value = ''; -}; - -/* ── 顶部动作 ─────────────────────────────────────────────────────── */ -$('btnAdd').onclick = openAdd; -$('btnRefresh').onclick = async () => { - const b = $('btnRefresh'); - b.disabled = true; b.textContent = '刷新中…'; - try { - await api('balance_all', { method: 'POST' }); - await loadOverview(true); - toast('余额已从上游刷新', 'ok'); - } catch (e) { toast('刷新失败:' + e.message, 'err'); await loadOverview(true); } - finally { b.disabled = false; b.textContent = '刷新'; } - if (view === 'logs') loadLogs(); -}; - -/* ── 轮询 ─────────────────────────────────────────────────────────── */ -function refreshVisible() { - if (view === 'accounts') loadOverview(true); - else if (view === 'logs') loadLogs(); - else if (view === 'taskscenter') reattachQueueView(); -} -function start() { - loadOverview(true); - if (refTimer) clearInterval(refTimer); - refTimer = setInterval(refreshVisible, 5000); - checkAuthGate(); -} -async function checkAuthGate() { - try { await api('overview'); } - catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; } -} -start(); - -/* ── 积分任务 ─────────────────────────────────────────────────────── */ -let taskUID = null; - -// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。 -// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。 -// 注意:键含点号(Model_chat_GLM5.2)必须加引号,否则会被解析成属性访问 + 数字字面量。 -const AUTO_TASKS = { - 'chat_5': '上报 5 条对话活跃事件(自动补足差额)', - 'first_buddy': '上报解锁 → 同意协议 → 领取第一只 Buddy', - 'Model_chat_GLM5.2': '接受任务 → glm-5.2 真实对话一次 → 对齐模型上报', - 'RichMeow_Chat': '桌面指纹事件链上报(已验证可点亮)', - 'Buddy_App': '上报「进入 Buddy 应用」事件链(已验证可点亮)', - 'Buddy_App_QQ': '上报「进入企鹅教师助手」事件链(已验证可点亮)', - 'automation_1': '上报「定时任务创建」事件(已验证可点亮)', - 'Library_read': '上报「读资料库介绍」事件(已验证可点亮)', - 'template_5': '上报「使用模板创建任务」事件组 ×5(三账号实测点亮)', - 'playbook_prompt': '上报「灵感案例做同款发送 Prompt」事件组(三账号实测点亮)', - 'create_canvas': '上报「设计创意画布创建」事件组(三账号实测点亮,+300 分)', - 'expert_5': '真实专家召唤+使用链 ×5(专家市场+真实 chat,三账号实测点亮)', - 'Expert_team_use_3': '真实专家团召唤+使用链 ×3(三账号实测点亮)', - 'Hp_Appearance': '设置主题 API + 皮肤生效事件(两账号实测点亮)', - 'black_cat': '夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示等 23 点排程)', - 'Expert_lighthouse': '真实轻量云专家召唤+使用链(真实对话 requestId,两账号实测点亮)', - 'skill_1': '真实对话 + skill_info 技能加载事件(实测点亮)', - 'school_season': '校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)', - 'Sequential_Tasks_1': '小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)', - 'Sequential_Tasks_2': '小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)', - 'Sequential_Tasks_3': '小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)', - 'Sequential_Tasks_4': '小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件 → 领奖(判据待解锁验证)', - 'Sequential_Tasks_5': '小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)', - 'Sequential_Tasks_6': '小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖', - 'Sequential_Tasks_7': '体验灵感功能(预留,疑 PC 口径):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)' -}; - -function openTasks(uid) { - taskUID = uid; - $('taskWho').textContent = uid.slice(0, 16); - $('taskVeil').classList.add('on'); - $('btnTaskReload').hidden = false; - loadTasks(); -} -function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; } -$('btnCloseTask').onclick = closeTasks; -$('btnTaskReload').onclick = loadTasks; - -// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。 -$('btnTaskAcceptAll').onclick = async () => { - if (!taskUID) return; - const btn = $('btnTaskAcceptAll'); - btn.disabled = true; btn.textContent = '接受中…'; - try { - const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/accept_all', { method: 'POST' }); - const n = r.accepted || 0; - if (r.failed && r.failed.length) { - toast(`已接受 ${n} 个,${r.failed.length} 个被上游拒绝(可重试)`, 'err'); - } else { - toast(n ? `已接受 ${n} 个任务` : (r.message || '所有任务均已接受'), 'ok'); - } - } catch (e) { toast(e.message, 'err'); } - finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); } -}; - -// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。 -$('btnTaskAutoAll').onclick = async () => { - if (!taskUID) return; - const btn = $('btnTaskAutoAll'); - if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return; - btn.disabled = true; btn.textContent = '执行中…'; - try { - const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' }); - const okN = (r.results || []).filter(x => x.status === 'done').length; - const skipN = (r.results || []).filter(x => x.status === 'skipped').length; - const errN = (r.results || []).filter(x => x.status === 'error').length; - toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok'); - console.log('auto_all results:', r.results); - } catch (e) { toast(e.message, 'err'); } - finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); } -}; - -async function loadTasks() { - if (!taskUID) return; - const st = $('taskState'), tb = $('taskTable'); - st.hidden = false; - st.className = 'state'; - st.innerHTML = '查询中'; - tb.hidden = true; - try { - const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks'); - const list = d.tasks || []; - if (!list.length) { - st.className = 'state'; - st.textContent = '该账号暂无任务'; - return; - } - // 有进度或可领取的排前面,已领取沉底——一眼看到"现在该做什么"。 - list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code))); - $('taskBody').innerHTML = list.map(t => { - // 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N" - const cur = t.current ?? 0, tgt = t.target ?? 0; - const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—'); - const parts = []; - if (t.credit) parts.push('+' + t.credit + ' 分'); - if (t.energy) parts.push('+' + t.energy + ' 能'); - if (t.reward_buddy) parts.push('Buddy'); - const reward = parts.length ? parts.join(' ') : '—'; - const badge = t.claimed ? '已领取' - : t.claimable ? '可领取' - : t.locked ? '未解锁' - : t.accept_status === 'accepted' ? '进行中' - : '未接受'; - const acted = t.claimed || t.locked ? '' - : t.claimable ? '' - : AUTO_TASKS[t.task_code] ? '' - : t.accept_status === 'accepted' ? '' - : ''; - // 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看 - const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n'); - return '' + - '
' + esc(t.title || t.task_code) + '
' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + '
' + - '' + esc(prog) + '' + - '' + esc(reward) + '' + - '' + badge + '' + - '' + acted + ''; - }).join(''); - st.hidden = true; - tb.hidden = false; - } catch (e) { - st.className = 'state err'; - st.textContent = e.message; - } -} - -$('taskBody').addEventListener('click', async ev => { - const b = ev.target.closest('button[data-t]'); - if (!b || !taskUID) return; - const kind = b.dataset.t, code = b.dataset.c; - b.disabled = true; - try { - if (kind === 'auto') { - // 一键完成:后端执行动作 → 回读进度 → 汇报(耗时可到分钟级,含真实对话) - b.textContent = '执行中…'; - const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', { - method: 'POST', body: JSON.stringify({ task_code: code }) - }); - if (r.skipped) { - toast(r.message || '已跳过', 'ok'); - } else { - const advanced = r.progress_before !== r.progress_after; - let msg = r.message || '已执行'; - if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`; - if (r.claimed) msg += ',奖励已自动到账'; - else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : ''; - else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端'; - toast(msg, (r.claimed || advanced) ? 'ok' : 'err'); - } - loadOverview(true); - } else { - const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept'); - const body = kind === 'claim' ? { task_code: code } : { task_codes: [code] }; - await api(path, { method: 'POST', body: JSON.stringify(body) }); - toast(kind === 'claim' ? '已领取奖励' : '已接受任务', 'ok'); - if (kind === 'claim') loadOverview(true); - } - } catch (e) { toast(e.message, 'err'); } - finally { loadTasks(); } -}); - -/* ── 任务中心:开学季 + 全账号扫描/队列 ──────────────────────────── */ -// 开学季任务单元:✓ 已领(绿)|◐ x/y 进行中(琥珀)|○ 未做(灰) -function staskHTML(t) { - if (!t) return '·—'; - if (t.status === 'claimed') return '✓已领'; - if (t.status === 'completed') return '◆可领'; - if (t.status === 'in_progress') { - const fr = t.target_count ? '' + t.progress + '/' + t.target_count + '' : ''; - return '◐' + fr + ''; - } - return '○未做'; -} -const LUCK_SVG = ''; -const SCHOOL_TITLES = { - share_invite: '分享活动 +100c', desktop_chat_1_time: '桌面端体验 +100c(单次)', - chat_3_times: '和 AI 对话 3 次 +50c', expert_use: '召唤开学季专家 +50c', - task_student_verify: '学生认证 +100c(需真实认证,不做)', -}; - -/* ── 精简 QR 编码器(券码二维码用)──────────────────────────────────── - 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 - 完整性:规范允许任选掩码(解码器按格式信息位自行去掩码),固定掩码不影响 - 可扫描性;已用 python qrcode 库对多输入多版本做逐像素交叉验证(强制 byte - 模式 + mask 0,5/5 全部 diff=0)。面板 CSP 只允许 self,外链 QR 服务不可用。 */ -// qr_gen.js —— 精简 QR 编码器(浏览器用 + node 可跑交叉验证) -// 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 -// 完整性说明:规范允许编码器任选掩码(解码器按格式信息位自行去掩码), -// 固定掩码不影响可扫描性;券码为短文本,v1-5(26 字节起)绰绰有余。 - -// GF(256) 对数/指数表(本原多项式 0x11d) -const QR_EXP = new Array(512), QR_LOG = new Array(256); -(() => { - let x = 1; - for (let i = 0; i < 255; i++) { QR_EXP[i] = x; QR_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } - for (let i = 255; i < 512; i++) QR_EXP[i] = QR_EXP[i - 255]; -})(); -const gmul = (a, b) => (a && b) ? QR_EXP[QR_LOG[a] + QR_LOG[b]] : 0; - -// 各版本参数(下标 = 版本-1):[数据码字数, 纠错码字数],ECC L 单块 -const QR_V = [[19, 7], [34, 10], [55, 15], [80, 20], [108, 26]]; -// 对齐图案中心坐标(v2+;与定位图案重叠的位置在放置时跳过) -const QR_ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]]; -const QR_MASK = (r, c) => (r + c) % 2 === 0; // 掩码模式 0 - -// 生成多项式(最高次系数在前,g[0] 恒为 1) -function qrGenPoly(deg) { - let g = [1]; - for (let i = 0; i < deg; i++) { - const a = QR_EXP[i], ng = new Array(g.length + 1).fill(0); - ng[0] = g[0]; - for (let j = 1; j < g.length; j++) ng[j] = g[j] ^ gmul(a, g[j - 1]); - ng[g.length] = gmul(a, g[g.length - 1]); - g = ng; - } - return g; -} - -// Reed-Solomon 求余(综合除法),返回 deg 个纠错码字 -function rsRem(data, deg) { - const g = qrGenPoly(deg); - const res = data.concat(new Array(deg).fill(0)); - for (let i = 0; i < data.length; i++) { - const f = res[i]; - if (f) for (let j = 0; j < g.length; j++) res[i + j] ^= gmul(g[j], f); - } - return res.slice(data.length); -} - -// 文本 → 码字流(byte 模式:0100 + 8 位计数 + 数据 + 终止符 + 0xEC/0x11 填充) -function qrDataCodewords(text, dataCap) { - const bytes = Array.from(new TextEncoder().encode(text)); - const bits = []; - const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); }; - push(4, 4); // byte 模式 - push(bytes.length, 8); // v1-9 计数 8 位 - for (const b of bytes) push(b, 8); - const cap = dataCap * 8; - push(0, Math.min(4, cap - bits.length)); // 终止符 - while (bits.length % 8) bits.push(0); - const out = []; - for (let i = 0; i < bits.length; i += 8) { - let v = 0; for (const b of bits.slice(i, i + 8)) v = (v << 1) | b; - out.push(v); - } - for (let p = 0; out.length < dataCap; p ^= 1) out.push(p ? 0x11 : 0xEC); - return out; -} - -// 主入口:text → 布尔矩阵(true=深色模块) -function qrMatrix(text) { - const bytes = Array.from(new TextEncoder().encode(text)); - // 版本选择:需求 ≈ 2 码字头 + 文本长度,取首个放得下的版本 - let ver = 0; - for (let v = 0; v < QR_V.length; v++) { if (bytes.length + 2 <= QR_V[v][0]) { ver = v + 1; break; } } - if (!ver) throw new Error('QR: text too long (>' + QR_V[4][0] + ' bytes)'); - const [dataCap, ecCap] = QR_V[ver - 1]; - const n = 17 + 4 * ver; - - const M = Array.from({ length: n }, () => new Array(n).fill(false)); - const F = Array.from({ length: n }, () => new Array(n).fill(false)); // 功能模块占位 - - const setF = (r, c, v) => { M[r][c] = v; F[r][c] = true; }; - // 定位图案 + 分隔带 - const finder = (r0, c0) => { - for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) { - const rr = r0 + r, cc = c0 + c; - if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue; - const dark = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); - setF(rr, cc, dark); - } - }; - finder(0, 0); finder(0, n - 7); finder(n - 7, 0); - // 校正图形(仅贯穿两定位图案之间:8..n-9,不得覆盖定位图案本体) - for (let r = 8; r <= n - 9; r++) setF(r, 6, r % 2 === 0); - for (let c = 8; c <= n - 9; c++) setF(6, c, c % 2 === 0); - // 对齐图案(v2+,跳过与定位重叠处) - const align = QR_ALIGN[ver - 1] || []; - for (const ar of align) for (const ac of align) { - if (F[ar][ac]) continue; - for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) - setF(ar + r, ac + c, Math.max(Math.abs(r), Math.abs(c)) !== 1); - } - // 暗模块 + 格式信息(ECC L=01,掩码 0)——BCH(15,5) + 0x5412 异或。 - // 位序遵循规范(与 python qrcode 逐位对齐验证):bit i 从 LSB 起数, - // 副本一走左上角 L 形、副本二走右下 L 形。 - let fmt = (1 << 3) | 0; // L<<3 | mask - let rem = fmt << 10; - for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10); - fmt = ((fmt << 10) | rem) ^ 0x5412; // 15 位 - const fb = i => (fmt >> i) & 1; - // 副本一(左上):位 0..5 → (i,8);6 → (7,8);7 → (8,8) - for (let i = 0; i <= 5; i++) setF(i, 8, !!fb(i)); - setF(7, 8, !!fb(6)); setF(8, 8, !!fb(7)); - // 副本一续 + 副本二(右下):位 8..14 → (n-15+i, 8);位 0..7 → (8, n-1-i);8 → (8,7);9..14 → (8,14-i) - for (let i = 8; i <= 14; i++) setF(n - 15 + i, 8, !!fb(i)); - for (let i = 0; i <= 7; i++) setF(8, n - 1 - i, !!fb(i)); - setF(8, 7, !!fb(8)); - for (let i = 9; i <= 14; i++) setF(8, 14 - i, !!fb(i)); - // 暗模块(恒为深色,位于副本一垂直段末端) - setF(n - 8, 8, true); - - - // 数据码字 + 纠错码字 → 位流 - const dcw = qrDataCodewords(text, dataCap); - const cw = dcw.concat(rsRem(dcw, ecCap)); - const bits = []; - for (const b of cw) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1); - - // 蛇形放置(成对列,从右向左,跳过第 6 列),写数据时直接异或掩码 - let bi = 0, up = true; - for (let x = n - 1; x > 0; x -= 2) { - if (x === 6) x--; - for (let i = 0; i < n; i++) { - const r = up ? n - 1 - i : i; - for (const c of [x, x - 1]) { - if (F[r][c]) continue; - const bit = bi < bits.length ? bits[bi++] : 0; - M[r][c] = bit ? !QR_MASK(r, c) : QR_MASK(r, c); - } - } - up = !up; - } - return M; -} - -// 矩阵 → SVG(quiet zone 4 模块) -function qrSVG(M, px) { - const n = M.length, q = 4, total = n + q * 2; - let s = ''; - for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) - if (M[r][c]) s += ''; - return s + ''; -} - -/* ── 开学季券码查询(弹窗,仿活动页 #/prizes?tab=vouchers)──────────── */ -/* copyText:clipboard API 只在 secure context(https/localhost)可用, - 远程 http 面板会拿不到 navigator.clipboard → 降级 execCommand。 */ -function copyText(text) { - if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); - return new Promise((resolve, reject) => { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;opacity:0'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); } - catch (e) { reject(e); } - finally { ta.remove(); } - }); -} - -function vcCard(v) { - const expired = v.valid_to && new Date(v.valid_to) < new Date(); - return '
' + - '
' + esc(v.prize_name || v.sku_code || '券') + '' + - (expired ? '已过期' : '可使用') + '
' + - '
' + - (v.valid_to ? '有效期至 ' + esc(v.valid_to) : '长期有效') + - (v.granted_at ? ' · ' + esc(v.granted_at.slice(0, 10)) + ' 抽中' : '') + - '
' + - '
' + - '
券码' + esc(v.code || '-') + '' + - '' + - (v.code ? '' : '') + - '' + - '
' + - '
'; -} - -async function loadSchoolVouchers() { - const body = $('vcBody'); - $('vcVeil').classList.add('on'); - body.innerHTML = '
查询中
'; - $('vcNote').textContent = ''; - try { - const d = await api('school/vouchers'); - const arr = d.accounts || []; - const ok = arr.filter(a => !a.error); - const total = ok.reduce((n, a) => n + (a.vouchers || []).length, 0); - body.innerHTML = ok.filter(a => (a.vouchers || []).length).map(a => - '
' + esc(a.nickname || a.uid) + '' + - '' + a.vouchers.length + ' 张
' + - a.vouchers.map(vcCard).join('') - ).join('') || '
🎟️
还没有抽到券
'; - $('vcNote').textContent = total ? total + ' 张券 · ' + ok.filter(a => !(a.vouchers || []).length).length + ' 个账号未抽中' : ''; - const errs = arr.filter(a => a.error); - if (errs.length) { - body.insertAdjacentHTML('beforeend', '
查询失败:' + - errs.map(a => esc(a.nickname || a.uid.slice(0, 8)) + '(' + esc(a.error) + ')').join('、') + '
'); - } - body.querySelectorAll('button[data-copy]').forEach(b => b.onclick = async () => { - try { await copyText(b.dataset.copy); toast('券码已复制', 'ok'); } - catch (e) { toast('复制失败,请手动选择券码', 'err'); } - }); - // 二维码:券码本体编码为 QR(到店出示扫描),点击切换显示/隐藏 - body.querySelectorAll('button[data-qr]').forEach(b => b.onclick = () => { - const card = b.closest('.vc'); - const old = card.querySelector('.vc-qr'); - if (old) { old.remove(); return; } - const box = document.createElement('div'); - box.className = 'vc-qr'; - try { box.innerHTML = qrSVG(qrMatrix(b.dataset.qr), 148); } - catch (e) { box.innerHTML = '二维码生成失败:' + esc(e.message) + ''; } - card.appendChild(box); - }); - } catch (e) { - body.innerHTML = '
' + esc(e.message) + '
'; - } -} -$('btnSchoolVouchers').onclick = loadSchoolVouchers; -$('btnVcClose').onclick = () => $('vcVeil').classList.remove('on'); -$('btnVcRefresh').onclick = loadSchoolVouchers; - -/* 成长任务队列。lastQueueSeq 记录本页启动过的队列代次:执行结束后的残留 items - (running=false 但 seq 停在旧值)不再回写视图——否则扫描结果 3 秒后被上一轮 - 队列状态覆盖。 */ -let queueTimer = null, lastQueueSeq = 0; -const GROWTH_TITLES = {}; // code → 展示名(扫描时从任务列表带出) -$('btnScanAll').onclick = async () => { - const b = $('btnScanAll'); - // 停掉队列轮询:显式扫描 = 切到待办视图。否则在途队列的下一 tick 会把扫描 - // 结果冲掉重渲染回队列视图(服务端执行不受影响,只是不再实时回写本视图)。 - if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } - b.disabled = true; b.textContent = '扫描中…'; - try { - const d = await api('tasks/scan_all', { method: 'POST' }); - renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。'); - } catch (e) { toast(e.message, 'err'); } - finally { b.disabled = false; b.textContent = '扫描待办'; } -}; -$('btnRunQueue').onclick = async () => { - const conc = Number($('qcConc').value) || 1; - if (!confirm('扫描全部账号待办并排队执行(账号并发 ' + conc + ',账号内串行)。\n含真实对话的任务耗时较长,确认继续?')) return; - const b = $('btnRunQueue'); - b.disabled = true; b.textContent = '启动中…'; - try { - const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) }); - if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; } - lastQueueSeq = r.seq || 0; - toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok'); - startQueuePolling(); - } catch (e) { toast(e.message, 'err'); } - finally { b.disabled = false; b.textContent = '执行全部待办'; } -}; -// 扫描结果 → 分组条目(无执行状态) -function groupItems(d) { - const groups = []; - for (const a of (d.accounts || [])) { - const rows = []; - for (const t of (a.growth || [])) { - GROWTH_TITLES[t.task_code] = t.title || t.task_code; - rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' }); - } - if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows }); - } - return groups; -} -const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' }; -function qrowHTML(it) { - const isSchool = it.kind === 'school'; - const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code); - const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait'; - const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status); - return '
' + - '' + esc(it.code) + '' + - '' + esc(title) + '' + (isSchool ? '开学季' : '') + '' + - '' + esc(it.prog || '') + '' + - '' + stWord + '' + - '' + esc(it.message || '') + '' + - '
'; -} -function renderQueue(groups, progress, emptyTitle, emptyDesc) { - const empty = $('tcEmpty'), list = $('qcList'); - if (!groups.length) { - empty.style.display = ''; - if (emptyTitle) empty.querySelector('.t').textContent = emptyTitle; - if (emptyDesc) empty.querySelector('.d').textContent = emptyDesc; - list.innerHTML = ''; - $('qProg').hidden = true; $('qcSummary').textContent = ''; - return; - } - empty.style.display = 'none'; - empty.style.display = 'none'; - let total = 0; - list.innerHTML = groups.map(g => { - total += g.rows.length; - return '
' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办
' + - g.rows.map(qrowHTML).join('') + '
'; - }).join(''); - $('qcSummary').textContent = total + ' 项'; - updateProgress(progress); -} -function updateProgress(q) { - if (!q || !q.items) { $('qProg').hidden = true; return; } - const total = q.items.length; - const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length; - $('qProg').hidden = false; - $('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%'; - $('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total; -} -// 队列状态 → 分组(执行时轮询) -function groupsFromQueue(items) { - const by = new Map(); - for (const it of items) { - if (!by.has(it.uid)) by.set(it.uid, { uid: it.uid, nick: it.nickname, rows: [] }); - by.get(it.uid).rows.push({ - kind: it.kind, code: it.code, - prog: it.kind === 'school' ? '—' : '', - status: it.status, message: it.message, - }); - } - return Array.from(by.values()); -} -function startQueuePolling() { - if (queueTimer) clearInterval(queueTimer); - queueTimer = setInterval(async () => { - let q; - try { q = await api('tasks/queue'); } catch (e) { return; } - if (!q.started) return; - // 只渲染本页启动过的那轮队列(刷新页面后不再接管旧队列)。 - if (lastQueueSeq && q.seq !== lastQueueSeq) return; - if (q.running) { - renderQueue(groupsFromQueue(q.items || []), q); - return; - } - // 结束:终态只渲染这一次,随即停表。此后残留的 items(running=false)不再 - // 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。 - renderQueue(groupsFromQueue(q.items || []), q); - clearInterval(queueTimer); queueTimer = null; - toast('任务队列执行结束', 'ok'); - }, 3000); -} -// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在 -// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。 -function reattachQueueView() { - // 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const - //(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError - // 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await - // 侥幸安全)。queueTimer 的"已在跑"判定也挪到 await 后,语义不变。 - (async () => { - try { - const q = await api('tasks/queue'); - if (queueTimer) return; // 轮询已在跑(跨视图不中断) - if (q.started && q.running && (!lastQueueSeq || q.seq === lastQueueSeq)) startQueuePolling(); - } catch (e) { /* 静默 */ } - })(); -} - -/* ── 用量 ─────────────────────────────────────────────────────────── */ -/* 图表用原生 SVG 手绘:面板是 go:embed 单文件、无构建步骤,引入图表库 - 就得带上打包器,得不偿失。这里只需要堆叠柱状图,二十行足够。 */ - -function fmtTok(n) { - n = Number(n || 0); - if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; - if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; - return String(n); -} -function fmtMs(ms) { - ms = Number(ms || 0); - if (!ms) return '—'; - if (ms >= 1000) return (ms / 1000).toFixed(2) + 's'; - return Math.round(ms) + 'ms'; -} -function fmtRate(r) { return r ? Number(r).toFixed(1) + ' tok/s' : '—'; } -function trimFixed(s) { - if (!String(s).includes('.')) return String(s); - return String(s).replace(/0+$/, '').replace(/\.$/, ''); -} -function fmtCredit(n) { - const v = Number(n || 0); - if (!Number.isFinite(v)) return '—'; - return trimFixed(v.toFixed(2)); -} -function fmtCreditRatio(v, samples, tokens) { - if (!samples || !tokens) return '—'; - const n = Number(v || 0); - if (!Number.isFinite(n)) return '—'; - return trimFixed(n.toFixed(4)) + ' / 1M'; -} -function fmtModelRate(rate) { - const s = String(rate || '').trim(); - return s ? 'x' + s : '—'; -} - -function usStat(v, k, cls) { - return '
' + esc(v) + - '
' + esc(k) + '
'; -} - -/* usKpi 用量页的指标卡。比账号池的 .stat 多两样:语义色轨(cls)与副标题(sub, - 放"占比 / 均速率"这类解释性数字);bar 是卡片内的构成条 HTML,只有需要时才传。 */ -function usKpi(v, k, cls, sub, bar) { - return '
' + - '
' + esc(k) + '
' + - '
' + esc(v) + '
' + - (bar || '') + - (sub ? '
' + esc(sub) + '
' : '') + - '
'; -} - -/* usMixBar prompt/completion 占比条。宽度按百分比而不是固定像素:表列宽随窗口变化, - 像素宽度在窄屏会溢出、宽屏又显得没信息。 */ -function usMixBar(prompt, completion, total) { - const t = Number(total || 0); - if (!t) return ''; - const pp = Math.max(0, Math.min(100, Number(prompt || 0) / t * 100)); - const pc = Math.max(0, Math.min(100, Number(completion || 0) / t * 100)); - return '' + - '' + - '' + - ''; -} - -/* usPct 占比文案(0 值不显示 "0.0%",直接 —,避免一行全是零)。 */ -function usPct(part, total) { - const t = Number(total || 0); - if (!t) return '—'; - return (Number(part || 0) / t * 100).toFixed(1) + '%'; -} - -/* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。 - withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */ -function usRow(name, sub, a, mid, withPerf) { - return '' + - '' + - '' + esc(name) + (sub ? '
' + esc(sub) + '
' : '') + '' + - (mid || '') + - '' + fmtTok(a.requests) + '' + - '' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + '' + - '' + fmtTok(a.prompt_tokens) + '' + - '' + fmtTok(a.completion_tokens) + '' + - '' + fmtTok(a.total_tokens) + - usMixBar(a.prompt_tokens, a.completion_tokens, a.total_tokens) + '' + - (withPerf - ? '' + fmtMs(a.avg_latency_ms) + '' + - '' + fmtRate(a.avg_tokens_per_second) + '' - : '') + - ''; -} - -/* ── 用量明细:三个维度共用一张表 + 页内切换 ────────────────────────── - 账号 / 模型 / 域三张表此前各自占一个 box,页面纵向拉得很长且表头结构几乎一样。 - 现在合成一个 box:表头由维度定义生成,行渲染复用 usRow,切换零请求。 */ -const US_DIMS = { - account: { key: 'by_account', title: '账号', withRealm: true, withPerf: true, span: 10 }, - model: { key: 'by_model', title: '模型', withRealm: false, withPerf: false, span: 7 }, - realm: { key: 'by_realm', title: 'realm', withRealm: false, withPerf: false, span: 7 }, -}; - -// usSortRows 按当前排序字段降序(默认合计 Token,最大者最相关)。 -function usSortRows(rows, sort) { - const out = rows.slice(); - const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; - const val = a => sort === 'requests' ? num(a.requests) - : sort === 'errors' ? num(a.errors) - : sort === 'latency' ? num(a.avg_latency_ms) - : num(a.total_tokens); - out.sort((a, b) => val(b) - val(a)); - return out; -} - -function usDimHead(dim) { - const m = US_DIMS[dim] || US_DIMS.account; - return '' + esc(m.title) + '' + - (m.withRealm ? '域' : '') + - '请求失败' + - 'PromptCompletion合计' + - (m.withPerf ? '均延迟均速率' : '') + - ''; -} - -/* usTabsHtml 维度切换按钮(带条数徽标)。整段 innerHTML 重写而不是逐个改 class: - 容器的 click 监听是委托式的,换掉子节点不会丢事件,代码也更短。 */ -function usTabsHtml(dims, active, counts) { - return dims.map(([k, label]) => { - const n = counts ? counts[k] : null; - return ''; - }).join(''); -} - -const US_DIM_TABS = [['account', '按账号'], ['model', '按模型'], ['realm', '按域']]; - -function renderUsageDim() { - const d = usageData || {}; - const m = US_DIMS[usDim] || US_DIMS.account; - const rows = usSortRows(d[m.key] || [], usSort); - $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, { - account: (d.by_account || []).length, - model: (d.by_model || []).length, - realm: (d.by_realm || []).length, - }); - $('usDimHead').innerHTML = usDimHead(usDim); - $('usDimBody').innerHTML = rows.map(x => - usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key, - usDim === 'account' ? (x.extra || '') : '', - x, - usDim === 'account' ? '' + esc(x.realm || '') + '' : '', - m.withPerf) - ).join('') || '暂无数据'; - $('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试'; -} - -/* 积分扣除:按账号 / 按模型两个维度共用一张表(同上,两个 box 合成一个)。 */ -function usCreditHead(dim) { - return dim === 'model' - ? '模型积分倍率' + - '请求扣除积分' + - '有效样本 Token积分 / 1M Token缓存命中率' - : '账号' + - '请求扣除积分' + - '有效样本 Token积分 / 1M Token缓存命中率'; -} - -/* 缓存命中率(issue #92):颜色即健康度——≥90% 绿 / 80–90% 黄 / <80% 红, - 样本不足灰。title 带命中/未命中绝对量,供逐项核对。 */ -function cacheRateCell(hit, miss) { - const h = Number(hit || 0), m = Number(miss || 0), total = h + m; - if (!total) return '—'; - const pct = h / total * 100; - const color = pct >= 90 ? 'var(--ok)' : (pct >= 80 ? 'var(--warn)' : 'var(--bad)'); - const txt = trimFixed(pct.toFixed(1)) + '%'; - return '' + txt + ''; -} - -function renderCreditDim() { - const d = usageData || {}; - $('usCreditHead').innerHTML = usCreditHead(usCreditDim); - const empty = '暂无积分扣除记录;升级前仅含 Token 的历史不会伪造积分。'; - if (usCreditDim === 'model') { - const models = d.credit_by_model || []; - $('usCreditBody').innerHTML = models.map(row => - '' + - '' + - '' + esc(row.key || '—') + '' + - '' + esc(fmtModelRate(row.rate)) + '' + - '' + fmtTok(row.requests) + '' + - '' + fmtCredit(row.credits) + '' + - '' + fmtTok(row.credit_tokens) + '' + - '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + - '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + - '' - ).join('') || '' + empty + ''; - } else { - const accounts = d.credit_by_account || []; - $('usCreditBody').innerHTML = accounts.map(row => { - const uid = String(row.key || ''); - const account = row.nickname || uid.slice(0, 8) || '—'; - return '' + - '' + - '' + esc(account) + '
' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + '
' + - '' + fmtTok(row.requests) + '' + - '' + fmtCredit(row.credits) + '' + - '' + fmtTok(row.credit_tokens) + '' + - '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + - '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + - ''; - }).join('') || '' + empty + ''; - } - $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, { - account: (d.credit_by_account || []).length, - model: (d.credit_by_model || []).length, - }); -} - -function renderUsage(d) { - usageData = d || {}; - const t = usageData.totals || {}; - const total = Number(t.total_tokens || 0); - const pt = Number(t.prompt_tokens || 0); - const ct = Number(t.completion_tokens || 0); - const reqs = Number(t.requests || 0); - const errs = Number(t.errors || 0); - const okRate = reqs ? (reqs - errs) / reqs * 100 : null; - // 构成条要的是合法 CSS 宽度,usPct 在无样本时返回 "—",不能直接拼进 style。 - const pctW = (part) => total ? Math.max(0, Math.min(100, Number(part || 0) / total * 100)).toFixed(2) + '%' : '0%'; - // 六张卡:主指标用强调色,completion 用成功色(与图表里的绿柱呼应), - // 失败/延迟只在有值时上语义色——全绿全黄的仪表盘等于没有重点。 - $('usStats').innerHTML = - usKpi(fmtTok(reqs), '请求数', 'c-accent', - errs ? '其中失败 ' + errs + ' 次' : '全部成功') + - usKpi(fmtTok(total), '总 token', 'c-accent', - 'prompt ' + usPct(pt, total) + ' · completion ' + usPct(ct, total), - '
' + - '
') + - usKpi(fmtTok(pt), 'prompt', 'c-soft', '占比 ' + usPct(pt, total)) + - usKpi(fmtTok(ct), 'completion', 'c-ok', '占比 ' + usPct(ct, total)) + - usKpi(String(errs), '失败尝试', errs ? 'c-warn' : 'c-mute', - okRate == null ? '—' : (errs ? '成功率 ' + okRate.toFixed(1) + '%' : '成功率 100%')) + - usKpi(fmtMs(t.avg_latency_ms), '平均延迟', 'c-soft', - t.avg_tokens_per_second ? '吐字 ' + fmtRate(t.avg_tokens_per_second) : '无速率样本'); - - // 卡片、明细表与时序图全部按所选窗口统计(切窗口数字随之变化); - // 「全部历史」含 90 天前折叠出的日桶。这里标注当前口径与数据起点。 - const winLabel = trangeLabel('usRange'); - // 服务端回显的实际区间优先(自定义区间下它就是权威口径);滚动窗口没有回显, - // 用控件自己的标签。 - const rangeEcho = usageData.window_from - ? String(usageData.window_from).replace('T', ' ').slice(0, 16) + - (usageData.window_to ? ' → ' + String(usageData.window_to).replace('T', ' ').slice(0, 16) : ' → 现在') - : ''; - const note = (rangeEcho || winLabel ? (rangeEcho || winLabel) + ' · ' : '') + - (usageData.buckets || 0) + ' 个分桶' + - (usageData.since ? ' · 数据自 ' + usageData.since.replace('T', ' ') : '') + - (usageData.file_bytes ? ' · 文件 ' + (usageData.file_bytes / 1024).toFixed(1) + ' KB' : ''); - $('usNote').textContent = note; - $('usNote').title = note; // 窄屏单行截断时靠悬停看全 - - // 积分扣除的四张卡片与说明。 - $('usCreditStats').innerHTML = - usKpi(fmtCredit(t.credits), '扣除积分', 'c-accent', '按上游 usage.credit 累计') + - usKpi(fmtTok(t.credit_tokens), '匹配 Token', 'c-mute', '与积分同时观测到的 Token') + - usKpi(fmtCreditRatio(t.credits_per_1m_tokens, t.credit_samples, t.credit_tokens), - '平均积分 / 1M Token', 'c-ok', '越低越划算') + - usKpi(String(t.credit_samples || 0), '有效积分样本', 'c-mute', '缺字段的历史不参与折算') + - usKpi(cacheRateText(t.cache_hit_tokens, t.cache_miss_tokens), '缓存命中率', 'c-mute', - '上游前缀缓存命中 / (命中+未命中);低命中意味着费用数倍放大'); - $('usCreditNote').textContent = - (usageData.credit_by_account || []).length + ' 个账号 · ' + - (usageData.credit_by_model || []).length + ' 个模型倍率分组 · 仅统计与积分同时观测到的 Token'; - - renderUsageDim(); - renderCreditDim(); - renderUsageChart(usageData.series || []); -} - -// 维度切换 / 排序控件。 -if ($('usDimTabs')) $('usDimTabs').addEventListener('click', ev => { - const b = ev.target.closest('button[data-dim]'); - if (!b) return; - usDim = b.dataset.dim; - renderUsageDim(); -}); -if ($('usCreditTabs')) $('usCreditTabs').addEventListener('click', ev => { - const b = ev.target.closest('button[data-dim]'); - if (!b) return; - usCreditDim = b.dataset.dim; - renderCreditDim(); -}); -if ($('usSort')) $('usSort').onchange = () => { - usSort = $('usSort').value; - renderUsageDim(); -}; - -/* renderUsageChart 画堆叠柱状图。 - * - * x 轴是**真实时间轴**,不是按序号等距。这一点很重要:数据里存在 1 小时的 - * 间隔,也存在 6~8 小时的断档(没请求的时段不产生桶),等距排布会把 8 小时 - * 画得和 1 小时一样宽,让「什么时候用的」完全失真。 - * - * 另外不再用 preserveAspectRatio="none":那会把 viewBox 横向拉伸到容器宽度, - * 柱子和文字都变形。改为固定比例、按容器宽度自适应高度。 - * - * viewBox 取 1200×200(原 760×180):SVG 以 width:100% 渲染,高宽比决定实际 - * 高度——旧比例在 1500px 宽的主区里会撑到 ~355px,只有一两根柱子时整块几乎是 - * 空白。宽 viewBox 把同宽度下的高度压到 ~250px,与下方表格的视觉重量相当。 - * - * 时间轴用本地时间解析(后端返回的就是本地时区),day 点按当天 00:00 参与定位, - * 与 hour 点在同一个连续轴上——日桶本来就是他那天所有小时的聚合。 - */ - -/* parsePointTime 把后端的 t 解析成毫秒时间戳。 */ -function parsePointTime(p) { - // hour: "2026-09-16T13" day: "2026-09-16" - const s = p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00'; - const d = new Date(s); - return isNaN(d.getTime()) ? null : d.getTime(); -} - -/* fmtTokTimeLabel 时间桶的短标签,与 x 轴刻度同一口径(日桶 MM-DD,小时桶 HH:00)。 */ -function fmtTokTimeLabel(p) { - const d = new Date(p.t); - return p.scope === 'day' - ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') - : String(d.getHours()).padStart(2, '0') + ':00'; -} - -function renderUsageChart(series) { - const host = $('usChart'); - - // 丢掉时间解析不出来的点,而不是让 NaN 传染整张图。 - const pts = []; - for (const p of series) { - const t = parsePointTime(p); - if (t === null) continue; - const pt = Number(p.prompt_tokens || 0); - const ct = Number(p.completion_tokens || 0); - pts.push({ t, scope: p.scope, raw: p.t, pt, ct, tt: Number(p.total_tokens || 0) || (pt + ct), - req: p.requests || 0 }); - } - if (!pts.length) { - host.innerHTML = '
暂无用量数据。发起一次对话后再刷新。
'; - $('usChartNote').textContent = '—'; - return; - } - - const W = 1200, H = 200, PL = 58, PR = 14, PT = 18, PB = 30; - const iw = W - PL - PR, ih = H - PT - PB; - - const t0 = pts[0].t; - const t1 = pts[pts.length - 1].t; - const span = Math.max(1, t1 - t0); - - const max = Math.max(1, ...pts.map(p => p.tt)); - const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]); - const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length; - $('usChartNote').textContent = - pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + fmtTokTimeLabel(peak) + - ' · 均值 ' + fmtTok(avg); - - // 柱宽取「最小真实间隔」的 70%,并夹在合理区间内——窗口拉到 30 天时柱子会 - // 变细,但不会细到看不见。 - let minGap = Infinity; - for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t); - if (!isFinite(minGap) || minGap <= 0) minGap = span; - const slot = iw * (minGap / span); - const bw = Math.max(2, Math.min(30, slot * 0.7)); - - // 首尾各让出半个柱宽:否则第一个点和最后一个点的柱子会各有一半跑到绘图区外 - // (末点柱子贴着卡片右边缘被切掉),刻度仍用同一个 xOf,标签与柱子始终对齐。 - const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw); - const yOf = v => PT + ih - ih * (v / max); - - let out = ''; - - // 柱体渐变:顶部实、底部略透,堆叠时两段仍能一眼分清(纯色块并排会糊成一片)。 - // 注意 stop-color 必须走 style 而不是 presentation 属性——Blink/WebKit 不解析 - // 属性里的 var(),写成 stop-color="var(--accent)" 会整条渐变失效(柱子全透明)。 - out += '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - // y 轴网格 + 刻度 - for (let i = 0; i <= 4; i++) { - const y = PT + ih - (ih * i / 4); - out += ''; - out += '' + fmtTok(max * i / 4) + ''; - } - - // 均值参考线:一眼看出"这根是不是异常高",比只给刻度省心。 - // 标签放左侧:右侧常被峰值柱占用(峰值柱往往就是最后一根),贴左不会被压住。 - if (avg > 0 && avg < max) { - const y = yOf(avg); - out += ''; - out += '均值 ' + fmtTok(avg) + ''; - } - - // 柱子 - const yBase = PT + ih; - for (const p of pts) { - const x = xOf(p.t) - bw / 2; - const hTot = ih * (p.tt / max); - const hP = p.tt ? hTot * (p.pt / p.tt) : 0; - const hC = Math.max(p.tt && p.ct ? 1 : 0, hTot - hP); - // 圆角只给堆叠顶端(贴轴的底边保持方角,柱子才像"立"在基线上)。 - // 类名用 usbar 而不是 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 - // height 是 rect 的 CSS 几何属性,同名类会把每根柱子压成 3px 高(踩过)。 - if (hP > 0) out += ' 0 ? '' : ' rx="1.5"') + '/>'; - if (hC > 0) out += ''; - out += '' + esc(p.raw) + ' ' + fmtTok(p.pt) + ' prompt / ' + - fmtTok(p.ct) + ' completion / ' + p.req + ' 次'; - } - - // 峰值标注:柱子够窄时文字压在柱顶,够宽时贴右侧避免和柱体重叠。 - { - const px = xOf(peak.t); - const py = yOf(peak.tt); - const anchor = px > W - PR - 90 ? 'end' : 'middle'; - out += '' + - '峰值 ' + fmtTok(peak.tt) + ''; - } - - // x 轴基线画在柱子之后,避免压在柱底 - out += ''; - - // x 轴刻度:按真实时间等距取 6 个位置,取该位置**最近的实际柱子**做标签, - // 所以标签永远落在有数据的点上,不会指到空档里。 - const TICKS = Math.min(6, pts.length); - const usedLabel = new Set(); - for (let k = 0; k < TICKS; k++) { - const target = t0 + span * (TICKS === 1 ? 0.5 : k / (TICKS - 1)); - let bi = 0, best = Infinity; - for (let i = 0; i < pts.length; i++) { - const d = Math.abs(pts[i].t - target); - if (d < best) { best = d; bi = i; } - } - if (usedLabel.has(bi)) continue; - usedLabel.add(bi); - const p = pts[bi]; - // 首尾标签靠边对齐,避免被裁掉 - const cx = xOf(p.t); - const anchor = cx < PL + 14 ? 'start' : (cx > W - PR - 14 ? 'end' : 'middle'); - out += '' + esc(fmtTokTimeLabel(p)) + ''; - } - - // 跨天时补一条日期分隔线,让「日界」在长窗口里可见 - let prevDay = null; - for (const p of pts) { - const d = new Date(p.t).getDate(); - if (prevDay !== null && d !== prevDay) { - const x = xOf(p.t).toFixed(1); - out += ''; - } - prevDay = d; - } - - out += ''; - host.innerHTML = out; -} - -function fmtTokTip(v) { return fmtTok(v); } - -let usageRateWarmAt = 0; -async function warmUsageModelRates() { - if (Date.now() - usageRateWarmAt < 10 * 60 * 1000) return; - try { - await api('models'); - } catch (e) { - // 倍率回填是可选增强;失败不阻塞用量统计,10 分钟后再试。 - } - usageRateWarmAt = Date.now(); -} - -async function loadUsage() { - const q = trangeQuery('usRange', true); - try { - await warmUsageModelRates(); - const d = await api('usage?' + q.toString()); - renderUsage(d); - } catch (e) { - // 失败时三块都要清干净:只改图表会留下上一次窗口的数字,看起来像"刷新成功"。 - usageData = null; - $('usChart').innerHTML = '
读取用量失败:' + esc(e.message) + '
'; - $('usChartNote').textContent = '—'; - $('usStats').innerHTML = ''; - $('usCreditStats').innerHTML = ''; - $('usNote').textContent = '—'; - $('usCreditNote').textContent = '—'; - $('usDimNote').textContent = '—'; - $('usDimHead').innerHTML = ''; - $('usCreditHead').innerHTML = ''; - $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, null); - $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, null); - $('usDimBody').innerHTML = '读取用量失败'; - $('usCreditBody').innerHTML = '读取用量失败'; - } -} - -if ($('btnUsage')) $('btnUsage').onclick = loadUsage; -// 时间范围控件绑定:任何改动(预设切换 / 自定义起止)都重新拉一次用量。 -if ($('usRange')) trangeBind('usRange', loadUsage); - -/* ── 积分构成 ─────────────────────────────────────────────────────── */ -/* 一个账号的余额是若干积分包之和。包按来源命名(「国内运营裂变包」「拉新权益包」 - 「个人体验版」…),面额从 6 到 1500 不等,且**按次发放**。所以两个任务完成度 - 完全一致的账号,余额可能差上千——差别只在包里。这里把逐包明细摊开,并给每个 - 包名一个稳定配色,跨账号对比时同色即同类。 */ - -const PK_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', '#e2607a', - '#5aa9e6', '#8fbf3f', '#b58b5a', '#7d8fa8', '#d4785c']; -const PK_ACCOUNT_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', - '#e2607a', '#20a4a4', '#8fbf3f', '#d4785c', - '#7c83db', '#c48a2f', '#b45f8c', '#5aa9e6']; - -function pkColor(i) { return PK_COLORS[i % PK_COLORS.length]; } - -// pkAccountColorMap 按 UID 稳定分配颜色:排序后分配,账号刷新/重排不会换色。 -function pkAccountColorMap(list) { - const uids = (list || []) - .filter(a => a && !a.error && a.uid) - .map(a => String(a.uid)) - .sort(); - const colors = new Map(); - uids.forEach((uid, i) => colors.set(uid, PK_ACCOUNT_COLORS[i % PK_ACCOUNT_COLORS.length])); - return colors; -} - -/* pkBySource 把包按名称归并,得到「来源 → 面额/余额/个数」。这是对比的关键视图: - 两个号的差异一定体现在某几个来源的面额上。 */ -function pkBySource(packs) { - const m = new Map(); - for (const p of packs) { - // 分组键用 code + name,而不是只 name:上游给「首登赠送」和普通活动包用了 - // **同一个 PackageName 和同一个 PackageCode**,只按 name 会把两类混成一类, - // 那正是当初「两个号为何差 1500」看不出来的原因。这里至少把 code 带进键里, - // 并在卡片上显示最早的发放时间。 - const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); - const e = m.get(k) || { - key: k, name: p.name || '(未命名)', code: p.package_code || '', - n: 0, remain: 0, size: 0, used: 0, minEnd: '', minCreated: '', - }; - e.n += 1; - e.remain += Number(p.remain || 0); - e.size += Number(p.size || 0); - e.used += Number(p.used || 0); - const t = (p.end_time || '').slice(0, 10); - if (t && (!e.minEnd || t < e.minEnd)) e.minEnd = t; - const c = (p.created_at || '').slice(0, 10); - if (c && (!e.minCreated || c < e.minCreated)) e.minCreated = c; - m.set(k, e); - } - return [...m.values()].sort((a, b) => b.size - a.size); -} - -const PK_DEFAULT_DETAIL_LIMIT = 5; - -function pkDetailLimitValue(raw) { - const n = Number(raw); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : PK_DEFAULT_DETAIL_LIMIT; -} - -function pkDetailLimit(cfg) { - return pkDetailLimitValue(cfg && cfg.panel && cfg.panel.package_detail_limit); -} - -const PK_DAY_MS = 24 * 3600 * 1000; - -function pkExpiryMs(p) { - const raw = Number(p && p.expires_at); - if (Number.isFinite(raw) && raw > 0) return raw; - const text = String((p && p.end_time) || '').trim(); - if (!text) return null; - let iso = text.includes('T') ? text : text.replace(' ', 'T'); - if (!/(?:Z|[+-]\d\d:\d\d)$/.test(iso)) iso += '+08:00'; - const parsed = Date.parse(iso); - return Number.isFinite(parsed) ? parsed : null; -} - -// pkDetailGroups 只服务单账号逐包明细:正余额包先按到期时间挑选默认展示项, -// 其余正余额包与已用完包分别折叠;同一到期时间按面额降序。 -function pkDetailCompare(a, b) { - const sizeOf = p => { - const n = Number(p && p.size); - return Number.isFinite(n) ? n : 0; - }; - const ea = pkExpiryMs(a), eb = pkExpiryMs(b); - if (ea == null && eb != null) return 1; - if (ea != null && eb == null) return -1; - if (ea != null && eb != null && ea !== eb) return ea - eb; - return sizeOf(b) - sizeOf(a); -} - -function pkDetailGroups(packs, limit) { - const active = [], used = []; - let usedSize = 0, restSize = 0, restRemain = 0; - for (const p of packs || []) { - const remain = Number(p && p.remain); - if (remain > 0) { - active.push(p); - continue; - } - used.push(p); - const size = Number(p && p.size); - if (Number.isFinite(size)) usedSize += size; - } - active.sort(pkDetailCompare); - used.sort(pkDetailCompare); - const visible = active.slice(0, pkDetailLimitValue(limit)); - const rest = active.slice(visible.length); - for (const p of rest) { - const size = Number(p && p.size); - if (Number.isFinite(size)) restSize += size; - const remain = Number(p && p.remain); - if (Number.isFinite(remain)) restRemain += remain; - } - return { visible, rest, used, restSize, restRemain, usedSize }; -} - -function pkCreditOpacity(days) { - if (days == null || !Number.isFinite(Number(days))) return 1; - return 0.25 + 0.75 * Math.max(0, Math.min(29, Number(days) - 1)) / 29; -} - -function pkExpiryText(expiresAt) { - if (!expiresAt) return '无到期时间'; - const diff = expiresAt - Date.now(); - if (diff <= 0) return '已到期'; - const minutes = Math.max(1, Math.ceil(diff / 60000)); - if (minutes < 60) return '剩余 ' + minutes + ' 分钟'; - const hours = Math.ceil(diff / 3600000); - if (hours < 24) return '剩余 ' + hours + ' 小时'; - return '剩余 ' + Math.ceil(diff / PK_DAY_MS) + ' 天'; -} - -function pkExpiryDateTime(expiresAt) { - if (!expiresAt) return '—'; - return new Date(expiresAt).toLocaleString('zh-CN', { - timeZone: 'Asia/Shanghai', hour12: false, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - }); -} - -function pkAccountSegments(a, now) { - let balance = Math.max(0, Number(a.remain || 0)); - const out = []; - for (const p of a.packages || []) { - const remain = Number(p.remain || 0); - if (!Number.isFinite(remain) || remain <= 0 || balance <= 0) continue; - const amount = Math.min(balance, remain); - const expiresAt = pkExpiryMs(p); - out.push({ - amount, - expiresAt, - days: expiresAt == null ? null : Math.max(0, Math.ceil((expiresAt - now) / PK_DAY_MS)), - source: p.name || '积分', - uid: String(a.uid || ''), - accountName: a.nickname || String(a.uid || '').slice(0, 8) || '未命名账号', - }); - balance -= amount; - } - return out.sort((x, y) => { - if (x.expiresAt == null && y.expiresAt != null) return 1; - if (x.expiresAt != null && y.expiresAt == null) return -1; - return (x.expiresAt || 0) - (y.expiresAt || 0); - }); -} - -// summarizeCreditDays 对齐 WorkDaddy:按精确剩余天数逐行聚合,无有效到期时间的余额 -// 不进入图表,也不猜测到期日。账号内先按总余额约束逐包金额,避免上游重复记录膨胀。 -function summarizeCreditDays(list, now) { - const buckets = new Map(); - let unavailable = 0; - for (const a of list || []) { - if (a.error || !Number.isFinite(Number(a.remain))) { - unavailable++; - continue; - } - for (const segment of pkAccountSegments(a, now)) { - if (segment.days == null) continue; - let row = buckets.get(segment.days); - if (!row) { - row = { days: segment.days, credits: 0, segments: [] }; - buckets.set(segment.days, row); - } - row.credits += segment.amount; - row.segments.push(segment); - } - } - const rows = [...buckets.values()].sort((a, b) => a.days - b.days); - for (const row of rows) { - row.segments.sort((a, b) => - (a.expiresAt || Infinity) - (b.expiresAt || Infinity) || - a.accountName.localeCompare(b.accountName) || - a.source.localeCompare(b.source)); - } - return { rows, accountCount: (list || []).length, unavailable }; -} - -function renderExpiryDistribution(list, now) { - const summary = summarizeCreditDays(list, now); - const colors = pkAccountColorMap(list); - const rows = summary.rows.map(row => { - const total = row.credits || 1; - const nodes = row.segments.map(segment => { - const color = colors.get(segment.uid) || 'var(--accent)'; - const title = segment.source + '\n' + fmtTok(segment.amount) + ' 积分\n到期时间 ' + - pkExpiryDateTime(segment.expiresAt) + '(' + pkExpiryText(segment.expiresAt) + ')\n' + - segment.accountName; - return ''; - }).join(''); - return '
' + esc(row.days === 0 ? '已到期' : row.days + ' 天') + - '
' + nodes + '
' + esc(fmtTok(row.credits)) + - '
'; - }).join(''); - const foot = summary.accountCount + ' 个账号' + - (summary.unavailable ? ' · ' + summary.unavailable + ' 个未获取余额' : ''); - const legend = (list || []).filter(a => - a && !a.error && a.uid && pkAccountSegments(a, now).some(s => s.days != null) - ).map(a => '' + esc(a.nickname || String(a.uid).slice(0, 8)) + '').join(''); - const hdr = '
剩余天数各账号该批剩余剩余积分
'; - $('pkExpiry').innerHTML = (rows - ? hdr + '
' + rows + '
' - : '
暂无可汇总积分
') + - (legend ? '
' + legend + '
' : '') + - '
' + esc(foot) + '
'; -} - -function renderPackages(d, detailLimit) { - const list = (d.accounts || []); - const now = Date.now(); - const expiryColors = pkAccountColorMap(list); - renderExpiryDistribution(list, now); - if (!list.length) { - $('pkSummary').innerHTML = '
没有账号
'; - return; - } - - // 包名 → 稳定色号(跨账号一致,方便肉眼对齐) - const names = []; - for (const a of list) for (const s of pkBySource(a.packages || [])) { - if (!names.includes(s.key)) names.push(s.key); - } - names.sort((x, y) => { - const sz = n => Math.max(...list.map(a => { - const f = pkBySource(a.packages || []).find(s => s.key === n); - return f ? f.size : 0; - })); - return sz(y) - sz(x); - }); - const colorOf = n => pkColor(names.indexOf(n)); - // 键 → 展示名,供卡片与明细表共用(同一来源必然同色同名)。 - const labelOf = {}; - for (const a of list) for (const s of pkBySource(a.packages || [])) labelOf[s.key] = s; - - const maxRemain = Math.max(1, ...list.map(a => Number(a.remain || 0))); - - $('pkSummary').innerHTML = list.map(a => { - if (a.error) { - return '
' + - esc((a.nickname || a.uid.slice(0, 8))) + '' + - '' + esc(a.realm || '') + '
' + - '
查询失败:' + esc(a.error) + '
'; - } - const srcs = pkBySource(a.packages || []); - const total = Math.max(1, Number(a.size || 0)); - const bar = srcs.map(s => - '' - ).join(''); - const legend = srcs.map(s => - '' + - esc(s.name.replace(/^CodeBuddy/, '')) + ' x' + s.n + ' · ' + fmtTok(s.size) + - (s.minCreated ? ' · 首发 ' + esc(s.minCreated.slice(5)) : '') + '' - ).join(''); - const expiry = pkAccountSegments(a, now); - const expiryTotal = Math.max(1, expiry.reduce((sum, s) => sum + s.amount, 0)); - const expiryColor = expiryColors.get(String(a.uid)) || 'var(--accent)'; - const expiryBar = expiry.length ? '' : ''; - return '
' + - '
' + esc(a.nickname || a.uid.slice(0, 8)) + '' + - '' + esc(a.realm || '') + '
' + - '
' + fmtTok(a.remain) + '
' + - '
共 ' + fmtTok(a.size) + ' · ' + (a.packages || []).length + - ' 个包 · 占最高 ' + (Number(a.remain || 0) / maxRemain * 100).toFixed(0) + '%
' + - '
' + bar + '
' + - expiryBar + - '
' + legend + '
' + - '
'; - }).join(''); - - $('pkNote').textContent = list.length + ' 个账号 · 实时查询上游'; - - // 逐包明细:每个账号一个表,包的**面额**列是重点 - $('pkDetail').innerHTML = list.map(a => { - if (a.error) return ''; - const groups = pkDetailGroups(a.packages || [], detailLimit); - const rowOf = (p, rowGroup) => { - const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); - const sub = (p.sub_product_code || '').replace(/^sp_tcaca_codebuddyide_?/, '') || - (p.package_code || '').replace(/^TCACA_/, ''); - return '' + - '' + esc(p.name || '(未命名)') + - (sub ? '
' + esc(sub) + '
' : '') + '' + - '' + fmtTok(p.size) + '' + - '' + fmtTok(p.remain) + '' + - '' + fmtTok(p.used) + '' + - '' + esc((p.created_at || '').slice(0, 16).replace('T', ' ') || '—') + '' + - '' + esc((p.end_time || '').slice(0, 10) || '—') + '' + - ''; - }; - const groupSummary = (group, label, count, size, remain) => - ''; - const rows = groups.visible.map(p => rowOf(p, '')).join(''); - const restSummary = groups.rest.length - ? groupSummary('rest', '其余未用完 ' + groups.rest.length + ' 个包(面额合计 ' + - fmtTok(groups.restSize) + ' · 剩余 ' + fmtTok(groups.restRemain) + ')', - groups.rest.length, groups.restSize, groups.restRemain) + - groups.rest.map(p => rowOf(p, 'rest')).join('') - : ''; - const usedSummary = groups.used.length - ? groupSummary('used', '已用完 ' + groups.used.length + ' 个包(面额合计 ' + - fmtTok(groups.usedSize) + ')', groups.used.length, groups.usedSize, 0) + - groups.used.map(p => rowOf(p, 'used')).join('') - : ''; - return '

' + - esc(a.nickname || a.uid.slice(0, 8)) + ' · ' + esc(a.realm || '') + - '

余额 ' + fmtTok(a.remain) + - ' / 总额 ' + fmtTok(a.size) + ' · 可用 ' + (groups.visible.length + groups.rest.length) + ' 个包' + - (groups.used.length ? ' / 已用完 ' + groups.used.length + ' 个' : '') + - ' · 默认展示最早到期 ' + pkDetailLimitValue(detailLimit) + ' 条' + - '
' + - '' + - '' + - '' + - '' + rows + restSummary + usedSummary + '
包名 / 来源面额剩余已用发放到期
'; - }).join(''); -} - -if ($('pkDetail')) $('pkDetail').addEventListener('click', ev => { - const btn = ev.target.closest('button[data-pk-group]'); - if (!btn) return; - const body = btn.closest('tbody'); - if (!body) return; - const group = btn.dataset.pkGroup; - const expanded = btn.getAttribute('aria-expanded') === 'true'; - body.querySelectorAll('tr[data-pk-row="' + group + '"]').forEach(row => { row.hidden = expanded; }); - const count = btn.dataset.count || '0'; - const size = btn.dataset.size || '0'; - const remain = btn.dataset.remain || '0'; - btn.setAttribute('aria-expanded', String(!expanded)); - if (group === 'rest') { - btn.textContent = expanded - ? '其余未用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + ' · 剩余 ' + - fmtTok(remain) + '),展开' - : '收起其余未用完 ' + count + ' 个包'; - } else { - btn.textContent = expanded - ? '已用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + '),展开' - : '收起已用完 ' + count + ' 个包'; - } -}); - -async function loadPackages() { - $('pkSummary').innerHTML = '
查询中…(逐账号向上游实时查询)
'; - $('pkDetail').innerHTML = ''; - $('pkExpiry').innerHTML = '
查询中…
'; - try { - const [d, c] = await Promise.all([ - api('packages'), - api('config').catch(() => null), - ]); - renderPackages(d, pkDetailLimit(c && c.config)); - } catch (e) { - $('pkSummary').innerHTML = '
读取失败:' + esc(e.message) + '
'; - $('pkExpiry').innerHTML = '
读取失败:' + esc(e.message) + '
'; - } -} - -if ($('btnPk')) $('btnPk').onclick = loadPackages; +'use strict'; +/* ── 状态 ─────────────────────────────────────────────────────────── */ +const LS_KEY = 'wb2api.key', LS_THEME = 'wb2api.theme'; +let theme = localStorage.getItem(LS_THEME) || 'auto'; // auto | light | dark +let view = 'accounts'; +let overviewData = null, cfgLoaded = null; +let logPin = true, loginState = null, loginTimer = null; +let refTimer = null; +/* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */ +let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; +let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined; +let reqFilter = { q: '', outcome: '' }; +let reqEntries = []; +let usDim = 'account', usCreditDim = 'account', usSort = 'total'; +let usageData = null; +let reqRangeState = null; // 请求记录的时间范围(用量页的见 trangeState) + +const $ = id => document.getElementById(id); + +/* ── 主题 ─────────────────────────────────────────────────────────── */ +/* 两态翻转(浅/深),首次访问跟随系统偏好;点击总是切换可见外观,符合直觉。 */ +function effTheme() { + return theme === 'auto' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : theme; +} +function applyTheme() { + const eff = effTheme(); + document.documentElement.dataset.theme = eff; + $('icoTheme').innerHTML = eff === 'light' + ? '' + : ''; + $('btnTheme').title = eff === 'light' ? '切换到深色' : '切换到浅色'; +} +addEventListener('change', applyTheme); +$('btnTheme').onclick = () => { + theme = effTheme() === 'light' ? 'dark' : 'light'; + localStorage.setItem(LS_THEME, theme); + applyTheme(); +}; +applyTheme(); + +/* ── 请求 ─────────────────────────────────────────────────────────── */ +async function api(path, opts = {}) { + const h = Object.assign({}, opts.headers || {}); + const k = localStorage.getItem(LS_KEY); + if (k) h['Authorization'] = 'Bearer ' + k; + if (opts.body) h['Content-Type'] = 'application/json'; + const r = await fetch('/panel/api/' + path, Object.assign({}, opts, { headers: h })); + if (r.status === 401) { openKey(); throw new Error('密钥无效或未填写'); } + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); + return d; +} +function toast(msg, cls) { + const el = document.createElement('div'); + el.className = 'tst ' + (cls || ''); + el.textContent = msg; + $('toasts').appendChild(el); + setTimeout(() => el.remove(), 3600); +} +// esc 文本/属性双安全转义。不能只用 div.innerHTML(它转义 <>& 但不转义引号), +// 否则字符串拼进 HTML 属性(如 title="uid: ...")时引号可闭合属性并注入事件处理器。 +// 显式替换 5 个字符:& < > " '(& 必须最先,避免二次转义)。 +function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +function ago(iso) { + if (!iso || iso.startsWith('0001-')) return '—'; + const s = (Date.now() - new Date(iso)) / 1000; + if (s < 0) return '刚刚'; + if (s < 60) return Math.floor(s) + ' 秒前'; + if (s < 3600) return Math.floor(s / 60) + ' 分钟前'; + if (s < 86400) return Math.floor(s / 3600) + ' 小时前'; + return Math.floor(s / 86400) + ' 天前'; +} +function dur(sec) { + sec = Math.max(0, Math.round(sec)); + const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; + return h ? h + '时' + String(m).padStart(2, '0') + '分' : m ? m + '分' + String(s).padStart(2, '0') + '秒' : s + '秒'; +} +function parseAPITime(value) { + const text = String(value || ''); + if (!text || text.startsWith('0001-')) return 0; + const ms = Date.parse(text); + return Number.isFinite(ms) ? ms : 0; +} +function fmtLocalDateTime(ms) { + const d = new Date(ms); + const p = n => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()); +} + +/* ── 时间范围控件(用量 / 请求记录共用)──────────────────────────────── + 预设项:今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义。 + + 为什么区间一律由前端算好再发: + - 「今天」必须是**浏览器本地时区**的 00:00 起。服务端时区未必与浏览器一致 + (容器常挂 TZ=Asia/Shanghai,而浏览器可能在任何时区),让服务端算"今天" + 会在跨时区时切错日子。 + - 「自定义」本来就是用户挑的具体时刻,没有任何服务端推导空间。 + + 滚动预设(近 N 小时/天)则保留 hours 参数:服务端按整点对齐的滚动窗口与旧 + 行为逐位一致,前端自己减 N 小时会多算/少算一个边界桶。 */ +const TRANGE_PRESETS = [ + ['today', '今天'], + ['24', '近 24 小时'], + ['72', '近 3 天'], + ['168', '近 7 天'], + ['720', '近 30 天'], + ['0', '全部历史'], + ['custom', '自定义…'], +]; +const TRANGE_DEFAULT = '72'; +const trangeStates = new Map(); // hostId → { preset, from: Date|null, to: Date|null } + +// dtLocalValue / dtLocalParse 与 的取值格式互转 +// (YYYY-MM-DDTHH:mm,本地时区;ES 里"带时间的日期串"按本地解析,正是我们要的)。 +function dtLocalValue(d) { + const p = n => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T' + + p(d.getHours()) + ':' + p(d.getMinutes()); +} +function dtLocalParse(s) { + if (!s) return null; + const d = new Date(s); + return isNaN(d.getTime()) ? null : d; +} + +// trangeMidnight 今天 00:00(本地时区)。 +function trangeMidnight() { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +function trangeState(id) { + if (!trangeStates.has(id)) { + // 「自定义」的初始值给一段有意义的默认:今天 00:00 → 现在。 + trangeStates.set(id, { preset: TRANGE_DEFAULT, from: trangeMidnight(), to: new Date() }); + } + return trangeStates.get(id); +} + +// trangeRender 画出控件骨架(幂等:重复调用会保留当前状态)。 +function trangeRender(id) { + const host = $(id); + if (!host) return; + const st = trangeState(id); + const custom = st.preset === 'custom'; + host.innerHTML = + '' + + '' + + '' + + '→' + + '' + + ''; + const preset = host.querySelector('.tr-preset'); + if (preset) preset.onchange = () => { + st.preset = preset.value; + // 从别的预设切到自定义时,把区间重置为"今天 00:00 → 现在", + // 免得用户上次留下的半年区间被无声沿用。 + if (st.preset === 'custom' && (!st.from || !st.to)) { st.from = trangeMidnight(); st.to = new Date(); } + trangeRender(id); + trangeEmit(id); + }; + const fromEl = host.querySelector('.tr-from'); + const toEl = host.querySelector('.tr-to'); + const readCustom = () => { + st.from = dtLocalParse(fromEl.value); + st.to = dtLocalParse(toEl.value); + // 起止颠倒就地标红(不静默纠正:用户可能正输到一半)。 + const bad = st.from && st.to && st.from > st.to; + fromEl.classList.toggle('tr-bad', !!bad); + toEl.classList.toggle('tr-bad', !!bad); + if (bad) return; + trangeEmit(id); + }; + if (fromEl) fromEl.onchange = readCustom; + if (toEl) toEl.onchange = readCustom; +} + +const trangeHandlers = new Map(); +// trangeBind 渲染控件并登记变化回调。**不**在绑定时触发回调:各视图的首次加载 +// 由 go() 统一驱动,这里再触发一次会让打开页面时打两遍接口。 +function trangeBind(id, onChange, preset) { + trangeHandlers.set(id, onChange); + if (preset) trangeState(id).preset = preset; + trangeRender(id); +} +function trangeEmit(id) { + const fn = trangeHandlers.get(id); + if (fn) fn(); +} + +// trangeQuery 把当前选择翻译成查询参数。 +// rolling=true → 滚动预设发 hours(服务端整点对齐),今天/自定义发 from/to +// rolling=false → 一律发 from/to(归档是线性日志,前端算区间更直观) +// 「全部历史」两者都不发。 +function trangeQuery(id, rolling) { + const st = trangeState(id); + const q = new URLSearchParams(); + const sec = d => Math.floor(d.getTime() / 1000); + if (st.preset === 'custom') { + if (st.from) q.set('from', sec(st.from)); + if (st.to) q.set('to', sec(st.to)); + return q; + } + if (st.preset === 'today') { + q.set('from', sec(trangeMidnight())); + return q; + } + if (st.preset === '0') return q; + if (rolling) { q.set('hours', st.preset); return q; } + q.set('from', sec(new Date(Date.now() - Number(st.preset) * 3600 * 1000))); + return q; +} + +// trangeLabel 人读口径,用于「用量总览」右上角这类需要回显区间的位置。 +function trangeLabel(id) { + const st = trangeState(id); + const found = TRANGE_PRESETS.find(p => p[0] === st.preset); + if (st.preset !== 'custom') return found ? found[1] : ''; + if (!st.from && !st.to) return '自定义'; + const f = d => d ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ' + + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') : '…'; + return f(st.from) + ' → ' + f(st.to); +} +function rateLimitMeta(row, now) { + const model = String(row && row.model || '未知模型'); + const kind = String(row && row.kind || 'rate_limit'); + const resetAt = parseAPITime(row && row.reset_at); + const until = parseAPITime(row && row.until); + const deadline = resetAt || until; + const remaining = deadline > now ? Math.round((deadline - now) / 1000) : 0; + if (kind === 'model_unavailable') { + return { + model, + kind, + detail: remaining ? '预计 ' + dur(remaining) + ' 后重试' : '等待重新探测', + title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''), + }; + } + let detail = resetAt + ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') + : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); + const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知']; + if (until && resetAt && until < resetAt) { + detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试'; + title.push('网关最早重试:' + fmtLocalDateTime(until)); + } + return { model, kind, detail, title: title.join('\n') }; +} +function rateLimitRowsHtml(rows, now) { + const list = Array.isArray(rows) ? rows.filter(row => row && row.model) : []; + if (!list.length) return ''; + return '
' + list.map(row => { + const m = rateLimitMeta(row, now); + return '
' + esc(m.model) + '' + esc(m.detail) + '
'; + }).join('') + '
'; +} + +function formatTokenCount(tokens) { + if (tokens == null || tokens === '') return '—'; + const n = Number(tokens); + if (!Number.isFinite(n) || n < 0) return '—'; + if (n < 1000) return String(Math.round(n)); + const units = [['k', 1e3], ['m', 1e6], ['b', 1e9]]; + let unit = units[0]; + for (const candidate of units) { + if (n >= candidate[1]) unit = candidate; + } + let value = n / unit[1]; + let rounded = Number(value.toFixed(1)); + // 999999 → 1m,而不是 1000k;四舍五入后自动升级单位。 + const next = units[units.indexOf(unit) + 1]; + if (next && rounded >= 1000) { + unit = next; + value = n / unit[1]; + rounded = Number(value.toFixed(1)); + } + return rounded + unit[0]; +} + +function formatLatency(ms) { + if (ms == null || ms === '') return '—'; + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return '—'; + return n < 1000 ? Math.round(n) + 'ms' : (n / 1000).toFixed(1).replace(/\.0$/, '') + 's'; +} +function formatRate(rate) { + if (rate == null || rate === '') return '—'; + const n = Number(rate); + if (!Number.isFinite(n) || n < 0) return '—'; + return n.toFixed(1) + 'tok/s'; +} + +/* ── 密钥门 ───────────────────────────────────────────────────────── */ +function openKey() { $('keyVeil').classList.add('on'); setTimeout(() => $('keyInput').focus(), 60); } +$('btnKey').onclick = async () => { + const v = $('keyInput').value.trim(); + if (!v) return; + localStorage.setItem(LS_KEY, v); + try { + await api('overview'); + $('keyErr').hidden = true; + $('keyVeil').classList.remove('on'); + start(); + } catch (e) { $('keyErr').hidden = false; } +}; +$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('btnKey').click(); }); + +/* ── 路由 ─────────────────────────────────────────────────────────── */ +/* 积分包明细共享缓存:「积分构成」视图与首页「积分到期提醒」卡片共用同一份 + /panel/api/packages 数据(逐账号实时查上游,能省一次是一次)。声明在路由区 + 是因为 go() 的初始调用就会触发首页卡片的渲染,必须先于它就位。 */ +let lastPackages = null; +let lastPackagesAt = 0; +let expFetching = false; // 到期卡片在途标记(防重复打上游) +const EXP_FRESH_MS = 2 * 60 * 1000; // 缓存新鲜窗口:2 分钟内复用 + +const TITLES = { accounts: '账号池', usage: '用量', packages: '积分构成', taskscenter: '任务中心', models: '模型与档位', config: '配置', logs: '运行日志' }; +function go(v) { + view = v; + document.querySelectorAll('.view').forEach(s => s.hidden = s.id !== 'view-' + v); + document.querySelectorAll('.nav a').forEach(a => a.classList.toggle('on', a.dataset.view === v)); + $('ttl').textContent = TITLES[v]; + if (v === 'models' && !$('mdBody').children.length) loadModels(); + if (v === 'config') loadConfig(); + if (v === 'logs') loadLogs(); + if (v === 'usage') loadUsage(); + if (v === 'packages') loadPackages(); + if (v === 'accounts') loadExpiry(); + if (v === 'taskscenter') reattachQueueView(); +} +document.querySelectorAll('.nav a').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.view); history.replaceState(null, '', '#' + a.dataset.view); }); +/* 首次进入延到本轮脚本求值之后再 go()。 + 原因:go() 会同步触发视图的数据加载(loadUsage/loadLogs/loadPackages…),而这些 + 函数读到的模块级 let/const(usageRateWarmAt、PK_* 等)在文件后半段才初始化—— + 直接深链 #usage / #packages 打开页面时会踩 TDZ("Cannot access 'x' before + initialization"),表现为该页永远显示"读取失败",而点导航进去一切正常。 + 延迟 0ms 让整份脚本先求值完,是修这一类问题最省事也最不容易再犯的办法。 */ +setTimeout(() => { + const hash = (location.hash || '#accounts').slice(1); + go(hash in TITLES ? hash : 'accounts'); +}, 0); + +/* ── 账号池 ───────────────────────────────────────────────────────── */ +function renderAccounts(list) { + const tb = $('accBody'); + if (!list.length) { + tb.innerHTML = '
账号池是空的
点击右上角「添加账号」,用浏览器登录一个 WorkBuddy 账号
'; + return; + } + // 有总额度(credits_total)→ 进度条按自身 剩余/总额 百分比;旧数据无总额 → 退回池内最高=100% + const maxCred = Math.max(1, ...list.map(s => s.credits || 0)); + tb.innerHTML = list.map(s => { + const bl = (new Date(s.breaker_until || 0) - Date.now()) / 1000; + const dg = (new Date(s.degrade_until || 0) - Date.now()) / 1000; + const cool = Math.max(s.cool_remaining_sec || 0, bl > 0 ? bl : 0, dg > 0 ? dg : 0); + let cls = '', tag; + if (s.disabled) { cls = 'off'; tag = '已禁用'; } + else if (s.paused) { cls = 'off'; tag = '已暂停选号'; } + else if (cool > 0) { + cls = 'cool'; + const kind = bl > Math.max(s.cool_remaining_sec || 0, dg > 0 ? dg : 0) ? '熔断' + : (dg > (s.cool_remaining_sec || 0) ? '连败降权' : (s.cool_kind === 'hard_credit' ? '积分冷却' : '限流冷却')); + tag = '' + kind + ' · ' + dur(cool) + ''; + } else tag = '可用' + (s.in_flight ? '' : ''); + const note = s.reason ? '
' + esc(s.reason) + '
' : ''; + const rateLimits = rateLimitRowsHtml(s.rate_limited_models, Date.now()); + const short = s.uid.length > 16 ? s.uid.slice(0, 16) + '…' : s.uid; + const cred = s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits)); + const pct = s.credits_total > 0 + ? Math.min(100, Math.round((s.credits || 0) / s.credits_total * 100)) + : Math.round((s.credits || 0) / maxCred * 100); + // 成本台账 tooltip(model_costs):每模型实测单价(≤0 = 实测免费),运维据此 + // 看「为什么总选它」——免费号垄断 / 单价排序一眼可见。 + let credTip = s.credits_total > 0 ? '剩余 ' + s.credits + ' / 总额 ' + s.credits_total + '(' + pct + '%)' : '积分(相对池内最高)'; + const costs = (s.model_costs || []).filter(c => c.model); + if (costs.length) { + credTip += '\n实测单价(credits/1K):\n' + costs.map(c => + ' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n'); + } + const frozen = s.disabled || cool > 0; + const tu = s.token_usage || {}; + const req = tu.request_count || 0; + const totalTok = formatTokenCount(tu.total_tokens); + const totalTokUnit = totalTok === '—' ? '' : 'tok'; + const latency = formatLatency(tu.last_latency_ms); + const rate = formatRate(tu.last_tokens_per_second); + const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate; + return '' + + '' + + '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + '
' + esc(short) + '
' + + '' + tag + note + rateLimits + '' + + '
' + cred + '
' + + '' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + '' + + '' + (s.in_flight || 0) + '' + + '' + + '' + req + '次' + + '' + totalTok + '' + totalTokUnit + '' + + '' + latency + '' + + '' + rate + '' + + '' + + '' + ago(s.last_success) + '' + + '' + + '' + + '' + + '' + + (frozen ? '' + : (s.paused ? '' + : '')) + + (s.disabled ? '' : '') + + '' + + ''; + }).join(''); +} + +// renderModelLocks 模型锁池:哪些模型不能用、锁了几个号、还要锁多久。 +// 后端 model_locks 已按「整池不可用 → 没号可用 → 部分限流」排好序,这里只做展示。 +function renderModelLocks(rows) { + const tb = $('mlBody'); + if (!tb) return; + const note = $('mlNote'); + if (!rows || !rows.length) { + tb.innerHTML = '
当前没有模型级限流 —— 所有模型均可选
'; + if (note) note.textContent = ''; + return; + } + const STATE = { locked: ['bad', '整池不可用'], starved: ['warn', '没号可用'], partial: ['warn', '部分限流'] }; + const left = iso => { + const ms = parseAPITime(iso); + return ms ? dur(Math.max(0, Math.round((ms - Date.now()) / 1000))) : '—'; + }; + tb.innerHTML = rows.map(r => { + const st = STATE[r.state] || ['mute', r.state || '—']; + const realm = r.realm === 'global' ? '国际版' : '国内版'; + return '' + + '' + esc(r.model) + '' + + '' + realm + '' + + '' + st[1] + '' + + '' + (r.servable || 0) + ' / ' + (r.total || 0) + '' + + '' + (r.locked || 0) + '' + + '' + left(r.unlock_at || r.fully_unlock_at) + '' + + '' + left(r.fully_unlock_at) + '' + + '' + (r.reason ? '
' + esc(r.reason) + '
' : '—') + '' + + ''; + }).join(''); + if (note) { + const bad = rows.filter(r => r.state === 'locked' || r.state === 'starved').length; + note.textContent = bad ? bad + ' 个模型整池不可用' : rows.length + ' 个模型部分限流'; + } +} + +async function loadOverview(quiet) { + try { + const d = await api('overview'); + overviewData = d; + $('sTotal').textContent = d.total; + $('sHealthy').textContent = d.healthy; + $('sCooling').textContent = d.cooling; + $('sDisabled').textContent = d.disabled; + const remSum = (d.accounts || []).reduce((a, s) => a + (s.credits || 0), 0); + const totSum = (d.accounts || []).reduce((a, s) => a + (s.credits_total || 0), 0); + $('sCredits').textContent = totSum > 0 ? remSum + ' / ' + totSum : remSum; + $('sSticky').textContent = d.sticky_sessions; + $('navSub').textContent = 'v' + d.version; + $('navVer').textContent = 'v' + d.version; + $('navRedis').textContent = d.redis_mode === 'upstash' ? 'Redis 镜像' : '本地内存'; + $('navState').textContent = d.healthy > 0 ? '服务正常' : (d.total ? '无可用账号' : '待添加账号'); + const p = $('navPulse'); + p.className = 'pulse' + (d.healthy > 0 ? '' : (d.total ? ' warn' : ' bad')); + $('accNote').textContent = d.in_flight_full ? d.in_flight_full + ' 个账号在途占满' : ''; + const up = Math.floor(d.uptime_sec); + $('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分'; + renderAccounts(d.accounts || []); + renderModelLocks(d.model_locks); + } catch (e) { if (!quiet) toast(e.message, 'err'); } +} + +$('accBody').addEventListener('click', async ev => { + const b = ev.target.closest('button[data-a]'); + if (!b) return; + const u = b.dataset.u, a = b.dataset.a; + if (a === 'remove' && !confirm('移除账号将删除池状态与 auths/ 下的凭证文件,且不可恢复。确认移除?')) return; + if (a === 'disable' && !confirm('禁用后该账号不再参与选号(保号任务默认也跳过),需手动解冻才能恢复。若只是想临时让位、仍要保号,请改用「暂停选号」。确认禁用?')) return; + b.disabled = true; + try { + if (a === 'checkin') { + const r = await api('accounts/' + encodeURIComponent(u) + '/checkin', { method: 'POST' }); + toast('签到完成' + (r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + (r.checkin_message ? '(' + r.checkin_message + ')' : ''), 'ok'); + } else if (a === 'balance') { + const r = await api('accounts/' + encodeURIComponent(u) + '/balance', { method: 'POST' }); + toast('余额已更新:' + r.credits + (r.credits_total > 0 ? ' / ' + r.credits_total : ''), 'ok'); + } else if (a === 'revive') { + await api('accounts/' + encodeURIComponent(u) + '/revive', { method: 'POST' }); + toast('已解冻', 'ok'); + } else if (a === 'disable') { + await api('accounts/' + encodeURIComponent(u) + '/disable', { method: 'POST' }); + toast('已禁用', 'ok'); + } else if (a === 'pause') { + await api('accounts/' + encodeURIComponent(u) + '/pause', { method: 'POST' }); + toast('已暂停选号(签到 / 保活照常)', 'ok'); + } else if (a === 'resume') { + await api('accounts/' + encodeURIComponent(u) + '/resume', { method: 'POST' }); + toast('已恢复选号', 'ok'); + } else if (a === 'tasks') { + openTasks(u); + } else if (a === 'remove') { + const r = await api('accounts/' + encodeURIComponent(u) + '/remove', { method: 'POST' }); + toast(r.file_error ? '已移除(凭证文件删除失败:' + r.file_error + ')' : '已移除', 'ok'); + } + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; loadOverview(true); } +}); + +$('btnCheckinAll').onclick = async () => { + try { await api('checkin_all', { method: 'POST' }); toast('全部签到已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnKeepaliveAll').onclick = async () => { + try { await api('keepalive_all', { method: 'POST' }); toast('全部保活已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnTravelAll').onclick = async () => { + try { await api('travel_all', { method: 'POST' }); toast('旅行巡检已开始(含领养链路),结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; +$('btnActivityAll').onclick = async () => { + try { await api('activity_all', { method: 'POST' }); toast('活跃上报已开始,结果见日志', 'ok'); } + catch (e) { toast(e.message, 'err'); } +}; + +/* ── 模型 ─────────────────────────────────────────────────────────── */ +/* 实测上限标注:scripts/probe_max_tokens.py --panel-out 写入探测结果, + /panel/api/model_probes 只读透传。探测键带域前缀(cn:glm-5.2),模型表 + 显示裸名,按「精确命中或 :后缀」关联。无数据时本列退回上游声称值。 */ +function fmtK(n) { n = Number(n || 0); return n >= 1000 ? Math.round(n / 1000) + 'K' : String(n); } +function probeDays(ts) { + if (!ts) return null; + const t = new Date(String(ts).replace(' ', 'T')); + const d = (Date.now() - t.getTime()) / 86400000; + return isNaN(d) ? null : Math.floor(d); +} +function outCell(m, pr) { + if (!pr) return '' + (m.max_output_tokens ? fmtK(m.max_output_tokens) : '—') + ''; + const tip = '声称 ' + (pr.claimed ? fmtK(pr.claimed) : '?') + ' · 实测 ' + (pr.measured ? fmtK(pr.measured) : '?') + + (pr.note ? ' · ' + pr.note : '') + (pr.tested_at ? ' · 探测于 ' + pr.tested_at : ''); + const days = probeDays(pr.tested_at); + const stale = days !== null && days > 30 ? ' · ' + days + ' 天前' : ''; + if (pr.verdict === 'clamped' && pr.measured) { + if (pr.claimed && pr.measured < pr.claimed) { + const x = pr.claimed / pr.measured; + const xs = (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + '×'; + return '' + + fmtK(pr.measured) + ' ⚠
钳制 ' + xs + stale + '
'; + } + return '' + fmtK(pr.measured) + + (pr.claimed && pr.measured > pr.claimed ? ' ↑' : ' ✓') + ''; + } + if (pr.verdict === 'at_least' && pr.measured) + return '≥' + fmtK(pr.measured) + ''; + return '?
未测出' + stale + '
'; +} + +/* rateCell 倍率列:牌价 vs 生效价。上游 credits 是牌价(转正后基准倍率), + modelPromotions 给当前生效折扣(限时免费 factor=0 / 夜间五折 0.5 等)—— + WorkBuddy 客户端显示的正是生效价。有折扣:生效价大字 + 标签 + 划线牌价, + 悬停带时段说明;无 factor 只有标签(错峰类):牌价 + 标签。 */ +function rateCell(m) { + const tip = m.promo_note ? ' title="' + esc(m.promo_note) + '"' : ''; + if (m.promo_factor != null && m.promo_credits) { + const base = m.credits ? ' ' + esc(m.credits) + '' : ''; + const label = m.promo_label ? ' ' + esc(m.promo_label) + '' : ''; + return '' + esc(m.promo_credits) + '' + label + base + ''; + } + if (m.promo_label) { + return '' + (m.credits ? esc(m.credits) : '—') + + ' ' + esc(m.promo_label) + ''; + } + return m.credits ? esc(m.credits) : '—'; +} + +async function loadModels() { + const tb = $('mdBody'); + tb.innerHTML = '
正在向上游查询…
'; + try { + // 探测数据是可选增强:拉取失败不影响模型列表本身 + const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]); + mdAll = d.models || []; + mdProbes = pr.probes || {}; + if (!mdAll.length) { + tb.innerHTML = '
上游未返回模型
'; + $('mdCount').textContent = ''; + $('mdNote').textContent = '上游未返回模型'; + return; + } + // 探测键带域前缀(cn:glm-5.2),模型表显示裸名,按「精确命中或 :后缀」关联。 + const probeKeys = Object.keys(mdProbes); + mdProbeOf = id => mdProbes[id] || mdProbes[probeKeys.find(k => k.endsWith(':' + id))]; + const hit = mdAll.filter(m => mdProbeOf(m.id)).length; + $('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : ''); + renderModels(); + } catch (e) { + mdAll = []; + tb.innerHTML = '
' + esc(e.message) + '
'; + $('mdCount').textContent = ''; + } +} + +/* ── 模型筛选(按条件查询)─────────────────────────────────────────── + 模型目录一次拉全(几十条),筛选与排序全部在前端完成:改条件零延迟,且不会 + 因为调一次筛选就打一次上游——/panel/api/models 是直连上游的实时查询,很贵。 + 条件之间是 AND;每个条件为空即不参与判定。 */ +// mdRateValue 当前生效的积分倍率数值:优先促销价(限时免费 = 0),无倍率记为 +// Infinity 排到最后(排序时"没有价格"不该冒充最便宜)。 +function mdRateValue(m) { + const raw = (m.promo_credits != null && m.promo_credits !== '') ? m.promo_credits : m.credits; + const n = parseFloat(String(raw == null ? '' : raw).replace(/[^\d.]/g, '')); + return Number.isFinite(n) ? n : Infinity; +} + +// mdSearchText 参与关键字搜索的字段(ID / 展示名 / 厂商 / 描述 / 标签)。 +function mdSearchText(m) { + return [m.id, m.name, m.vendor, m.description, (m.tags || []).join(' ')] + .filter(Boolean).join(' ').toLowerCase(); +} + +// mdMatch 单个模型是否满足全部筛选条件。 +function mdMatch(m, f) { + f = f || mdFilter; + if (f.q) { + const text = mdSearchText(m); + // 空格分词后逐个匹配:多关键词是 AND,便于"cn 视觉"这类组合查询。 + for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { + if (!text.includes(kw)) return false; + } + } + if (f.realm && !String(m.id || '').startsWith(f.realm + ':')) return false; + if (f.cap === 'tool' && !m.supports_tool_call) return false; + if (f.cap === 'vision' && !m.supports_images) return false; + if (f.cap === 'reasoning' && !m.supports_reasoning) return false; + if (f.cap === 'default' && !m.is_default) return false; + if (f.effort === 'off') { + if (!m.can_disable_thinking) return false; + } else if (f.effort && !(m.supported_efforts || []).includes(f.effort)) { + return false; + } + const factor = m.promo_factor == null ? null : Number(m.promo_factor); + if (f.promo === 'promo' && factor == null && !m.promo_label) return false; + if (f.promo === 'free' && !(factor === 0)) return false; + if (f.promo === 'discount' && !(factor != null && factor > 0)) return false; + return true; +} + +// mdSortList 按当前排序条件返回新数组(不改动入参,保持上游原始顺序可回溯)。 +function mdSortList(list, f) { + f = f || mdFilter; + const out = list.slice(); + const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; + if (f.sort === 'rate') out.sort((a, b) => mdRateValue(a) - mdRateValue(b)); + else if (f.sort === 'context') out.sort((a, b) => num(b.context_length) - num(a.context_length)); + else if (f.sort === 'output') out.sort((a, b) => num(b.max_output_tokens) - num(a.max_output_tokens)); + else if (f.sort === 'name') out.sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))); + return out; +} + +// mdRowHtml 单个模型行(纯渲染,便于独立测试)。 +function mdRowHtml(m, pr) { + const eff = (m.supported_efforts || []).slice(); + if (m.can_disable_thinking && eff.length && !eff.includes('off')) eff.push('off(可关)'); + const effs = eff.length ? eff.map(e => '' + esc(e) + '').join(' ') + : '' + (m.supports_reasoning ? '固定档 · 默认 ' + esc(m.default_effort || '?') : '不支持思考') + ''; + // 能力徽标:默认模型 / 工具调用 / 视觉 / 纯推理(上游目录全字段透出,缺失不显示) + const caps = []; + if (m.is_default) caps.push('默认'); + if (m.supports_tool_call) caps.push('工具'); + if (m.supports_images) caps.push('视觉'); + if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开'); + const capHtml = caps.length ? '
' + caps.join(' ') + '
' : ''; + const tip = m.description ? ' title="' + esc(m.description) + '"' : ''; + return '
' + esc(m.id) + '
' + esc(m.name || '') + '
' + capHtml + '' + + '' + rateCell(m) + '' + + '' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + '' + + '' + effs + '' + + '' + (m.context_length ? Math.round(m.context_length / 1000) + 'K' : '—') + '' + + outCell(m, pr) + ''; +} + +function renderModels() { + const tb = $('mdBody'); + const list = mdSortList(mdAll.filter(m => mdMatch(m))); + if (!list.length) { + tb.innerHTML = '
没有符合当前筛选条件的模型
'; + } else { + tb.innerHTML = list.map(m => mdRowHtml(m, mdProbeOf(m.id))).join(''); + } + const filtered = list.length !== mdAll.length; + $('mdCount').textContent = !mdAll.length ? '' + : filtered ? '命中 ' + list.length + ' / ' + mdAll.length + ' 个模型' + : mdAll.length + ' 个模型'; + $('mdCount').className = filtered ? 'note src-off' : 'note'; +} + +function resetModelFilter() { + mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; + $('mdQ').value = ''; $('mdRealm').value = ''; $('mdCap').value = ''; + $('mdEffort').value = ''; $('mdPromo').value = ''; $('mdSort').value = 'default'; + renderModels(); +} + +// 筛选控件:输入框防抖 120ms(长列表逐字符重排不必每键一次),下拉即时。 +let mdQTimer = null; +$('mdQ').oninput = () => { + clearTimeout(mdQTimer); + mdQTimer = setTimeout(() => { mdFilter.q = $('mdQ').value.trim(); renderModels(); }, 120); +}; +for (const [id, key] of [['mdRealm', 'realm'], ['mdCap', 'cap'], ['mdEffort', 'effort'], ['mdPromo', 'promo'], ['mdSort', 'sort']]) { + const el = $(id); + if (!el) continue; + el.onchange = () => { mdFilter[key] = el.value; renderModels(); }; +} +$('mdReset').onclick = resetModelFilter; +$('btnModels').onclick = loadModels; + +/* ── 日志(频道:全部/任务/对话/系统) ─────────────────────────────── */ +let logCh = 'all'; +$('logChips').addEventListener('click', ev => { + const b = ev.target.closest('button[data-ch]'); + if (!b) return; + logCh = b.dataset.ch; + document.querySelectorAll('#logChips .chip').forEach(c => c.classList.toggle('on', c === b)); + loadLogs(); +}); +async function loadLogs() { + const box = $('logBox'); + const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 24; + const limit = ($('reqLimit') && $('reqLimit').value) || 100; + // 时间范围由归档侧过滤(不是前端筛已拉取的条目):区间落在更早的时间段时, + // 「最近 N 条」里根本不会有那些记录,必须让服务端按时间取。 + const rq = trangeQuery('reqRange', false); + rq.set('limit', limit); + try { + const [d, metrics, requestRows] = await Promise.all([ + api('logs'), + api('request_metrics').catch(() => ({})), + api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })), + ]); + // 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里 + // 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。 + // 只有归档关闭时才回落到内存指标,保证没有归档的部署仍能看到最近请求。 + const archiveOn = !!(metrics && metrics.archive && metrics.archive.enabled); + const recent = archiveOn ? (requestRows.entries || []) : (metrics.recent || []); + renderRequestMetrics(metrics, recent); + const entries = (d.entries || []).filter(e => logCh === 'all' || e.ch === logCh); + box.innerHTML = entries.length + ? entries.map(e => { + const lvl = /error|失败|错误/.test(e.text) ? ' e' : /warn|冷却|熔断/.test(e.text) ? ' w' : ''; + const t = e.ts ? new Date(e.ts).toLocaleTimeString('zh-CN', { hour12: false }) : ''; + const ch = logCh === 'all' ? '' + ({ task: '任务', chat: '对话', sys: '系统' }[e.ch] || e.ch) + '' : ''; + return '' + ch + esc(t + ' ' + e.text) + ''; + }).join('') + : '暂无日志'; + if (logPin && atEnd) box.scrollTop = box.scrollHeight; + const counts = {}; + for (const e of (d.entries || [])) counts[e.ch] = (counts[e.ch] || 0) + 1; + $('logNote').textContent = logCh === 'all' + ? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0) + : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行'; + } catch (e) { /* 概览已提示 */ } +} + +function renderRequestMetrics(m, entries) { + m = m || {}; + const a = m.archive || {}; + $('reqSummary').textContent = + '已完成 ' + fmtTok(m.completed) + + ' · 成功 ' + (m.success_rate == null ? '—' : Number(m.success_rate).toFixed(1) + '%') + + ' · HTTP ' + (m.http_success_rate == null ? '—' : Number(m.http_success_rate).toFixed(1) + '%') + + ' · 平均 ' + fmtMs(m.avg_duration_ms) + + ' · 进行中 ' + String(m.in_flight || 0); + $('reqNote').textContent = a.enabled + ? 'JSONL 归档 ' + fmtBytes(a.bytes) + (a.dropped_writes ? ' · 丢弃 ' + a.dropped_writes + ' 条' : '') + + (a.last_error ? ' · 错误:' + a.last_error : '') + : '仅内存指标,JSONL 归档已关闭'; + + reqEntries = entries || []; + renderRequestTable(); +} + +/* reqMatch 请求记录筛选:q 对 IP/UA/模型/账号/请求 ID 做空格分词的 AND 包含匹配, + outcome 精确匹配。两者都在已拉取的条目上做(最多 1000 条),不发新请求。 */ +function reqMatch(e, f) { + f = f || reqFilter; + if (f.outcome && String(e && e.outcome || '') !== f.outcome) return false; + if (f.q) { + const text = [e && e.client_ip, e && e.user_agent, e && e.model, e && e.account, e && e.request_id] + .filter(Boolean).join(' ').toLowerCase(); + for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) { + if (!text.includes(kw)) return false; + } + } + return true; +} + +function reqOutcomeTag(e) { + const outcome = String(e && e.outcome || ''); + const label = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }[outcome] || outcome || '—'; + const cls = outcome === 'success' ? 'ok' + : outcome === 'interrupted' ? 'warn' + : outcome ? 'bad' : 'mute'; + return '' + esc(String(e && e.status || '—') + ' ' + label) + ''; +} + +function reqTokenCell(e) { + const total = Number(e && e.total_tokens || 0) || + (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); + return total ? fmtTok(total) : '—'; +} + +function reqCreditCell(e) { + if (!e || !e.credit_known) return '—'; + const v = Number(e.credit); + return Number.isFinite(v) ? trimFixed(v.toFixed(2)) : '—'; +} + +/* renderRequestTable 渲染请求记录表。来源列是这一版的重点:IP 用等宽字体方便扫, + UA 单行截断(完整值在 title 里,行本身用 requestLogText 作 tooltip)。 */ +function renderRequestTable() { + const list = reqEntries.filter(e => reqMatch(e)); + const tb = $('reqBody'); + if (!tb) return; + tb.innerHTML = list.map(e => { + const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; + const ip = e && e.client_ip ? e.client_ip : ''; + const ua = e && e.user_agent ? e.user_agent : ''; + const rid = e && e.request_id ? e.request_id : ''; + return '' + + '' + esc(when) + '' + + '' + reqOutcomeTag(e) + '' + + '' + esc(e && e.model || '—') + '' + + '' + esc(e && e.account || '—') + '' + + '' + (ip ? '' + esc(ip) + '' : '—') + '' + + '' + (ua ? '' + esc(ua) + '' : '—') + '' + + '' + fmtMs(e && e.duration_ms) + '' + + '' + reqTokenCell(e) + '' + + '' + reqCreditCell(e) + '' + + '' + (rid ? '' + esc(rid) + '' : '—') + '' + + ''; + }).join('') || '' + + (reqEntries.length ? '没有符合当前筛选条件的请求记录' : '暂无请求记录') + ''; + + const filtered = list.length !== reqEntries.length; + // 归档里的旧条目没有来源字段(该功能上线前写入):这时提示开关/历史原因, + // 而不是让人以为筛选坏了。 + const hasSource = reqEntries.some(e => e && (e.client_ip || e.user_agent)); + $('reqCount').textContent = !reqEntries.length ? '' + : (filtered ? '命中 ' + list.length + ' / ' + reqEntries.length + ' 条' : reqEntries.length + ' 条') + + (hasSource ? '' : ' · 来源未记录'); + $('reqCount').className = (filtered || !hasSource) ? 'note src-off' : 'note'; +} + +/* 请求记录筛选控件。搜索框防抖 150ms:最多 1000 行重渲染,不必每键一次。 + 这段顶层绑定放在 requestLogText 之前,是为了让"纯函数切片"式前端测试 + (slice requestLogText → fmtBytes)只拿到无副作用的格式化函数。 */ +let reqQTimer = null; +if ($('reqQ')) $('reqQ').oninput = () => { + clearTimeout(reqQTimer); + reqQTimer = setTimeout(() => { reqFilter.q = $('reqQ').value.trim(); renderRequestTable(); }, 150); +}; +if ($('reqOutcome')) $('reqOutcome').onchange = () => { + reqFilter.outcome = $('reqOutcome').value; + renderRequestTable(); +}; +if ($('reqLimit')) $('reqLimit').onchange = loadLogs; +if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs; +// 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条", +// 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。 +if ($('reqRange')) trangeBind('reqRange', loadLogs, '0'); + +function requestLogText(e) { + const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; + const outcomeLabel = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }; + const token = Number(e && e.total_tokens || 0) || + (Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0)); + let credit = 'credit —'; + if (e && e.credit_known) { + const value = Number(e.credit); + if (Number.isFinite(value)) credit = String(Number(value.toFixed(2))) + ' credit'; + } + return [ + when, + String(e && e.status || '—') + ' ' + (outcomeLabel[e && e.outcome] || (e && e.outcome) || '—'), + e && e.model || '—', + e && e.account || '—', + e && e.client_ip || '—', + e && e.user_agent || '—', + fmtMs(e && e.duration_ms), + fmtTok(token) + ' tok', + credit, + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens) === '—' ? '' : '命中 ' + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens), + e && e.request_id || '—', + ].filter(Boolean).join(' | '); +} + +/* 缓存命中率纯文本(issue #92):requestLogText 与积分表/kpi 卡共用。 + 自包含(不依赖 trimFixed):前端纯函数切片测试只截取本段。 */ +function cacheRateText(hit, miss) { + const h = Number(hit || 0), m = Number(miss || 0), total = h + m; + if (!total) return '—'; + return String(Math.round(h / total * 1000) / 10) + '%'; +} + +function fmtBytes(bytes) { + const n = Number(bytes || 0); + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} +$('btnLogPin').onclick = () => { + logPin = !logPin; + $('btnLogPin').textContent = '自动滚动:' + (logPin ? '开' : '关'); +}; + +/* ── 配置 ─────────────────────────────────────────────────────────── */ +const CFG_MAP = { + listen: ['listen'], api_key: ['api_key'], + package_detail_limit: ['panel', 'package_detail_limit'], + checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'], + travel_hours: ['schedule', 'travel_hours'], travel_enabled: ['schedule', 'travel_enabled'], + activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'], + keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'], + balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'], + include_disabled_in_tasks: ['schedule', 'include_disabled_in_tasks'], + max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'], + breaker_threshold: ['pool', 'breaker_threshold'], + degrade_threshold: ['pool', 'degrade_threshold'], degrade_cooldown: ['pool', 'degrade_cooldown'], + degrade_cooldown_max: ['pool', 'degrade_cooldown_max'], + cost_explore_interval: ['pool', 'cost_explore_interval'], + credit_floor: ['pool', 'credit_floor'], + prefer_expiring: ['pool', 'prefer_expiring'], expiring_soon: ['pool', 'expiring_soon'], + soft_rate: ['cooldown', 'soft_rate'], soft_rate_max: ['cooldown', 'soft_rate_max'], + breaker_cooldown: ['pool', 'breaker_cooldown'], breaker_cooldown_max: ['pool', 'breaker_cooldown_max'], + idle_weight_per_hour: ['pool', 'idle_weight_per_hour'], idle_weight_max: ['pool', 'idle_weight_max'], + ttl: ['session_sticky', 'ttl'], + timeout_seconds: ['upstream', 'timeout_seconds'], header_timeout_seconds: ['upstream', 'header_timeout_seconds'], + idle_timeout_seconds: ['upstream', 'idle_timeout_seconds'], user_agent: ['upstream', 'user_agent'], + prompt_mode: ['prompt', 'mode'], prompt_file: ['prompt', 'file'], + sanitize_blacklist_fingerprints: ['features', 'sanitize_blacklist_fingerprints'], + session_sticky_enabled: ['session_sticky', 'enabled'], + request_client_info: ['logging', 'request_client_info'], +}; +/* 「覆盖型」文本字段:空串本身是有意义的取值(= 回落到内置默认),必须照发。 + * + * 其余文本字段保持「空 = 不下发」的既有语义——那是防误清空的保护,不是 bug: + * 表单里某个框没填,通常意味着"没改",把它当成"请清空"会静默抹掉配置。 + * + * 但覆盖型字段正好相反:清空 = 明确要求回到默认。漏发它们会让面板显示"已保存" + * 而值其实没变(issue #102 附带发现 2:user_agent 清空后 config.json 里仍是旧值)。 + * + * 刻意不含 api_key:清空它 = 关闭整个鉴权,误触代价是网关变成无鉴权公开服务。 + * 该字段(以及提示文案"留空 = 不鉴权"与现状不符的问题)单独处理。 + */ +const CLEARABLE_CFG = new Set(['user_agent', 'prompt_file']); + +function dig(obj, path) { return path.reduce((o, k) => (o == null ? undefined : o[k]), obj); } +function put(obj, path, val) { + let o = obj; + for (let i = 0; i < path.length - 1; i++) { if (typeof o[path[i]] !== 'object' || o[path[i]] === null) o[path[i]] = {}; o = o[path[i]]; } + o[path[path.length - 1]] = val; +} + +async function loadConfig() { + try { + const d = await api('config'); + cfgLoaded = d.config; + $('cfgPath').textContent = d.path || ''; + const f = $('cfgForm'); + for (const [name, path] of Object.entries(CFG_MAP)) { + const el = f.elements[name]; + if (!el) continue; + const v = dig(cfgLoaded, path); + if (el.type === 'checkbox') el.checked = !!v; + else if (Array.isArray(v)) el.value = v.join(', '); + else el.value = v == null ? '' : v; + } + markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法) + $('cfgNote').textContent = ''; + } catch (e) { toast('读取配置失败:' + e.message, 'err'); } +} +function collectConfig() { + const f = $('cfgForm'), out = {}; + for (const [name, path] of Object.entries(CFG_MAP)) { + const el = f.elements[name]; + if (!el) continue; + let v; + if (el.type === 'checkbox') v = el.checked; + else if (el.type === 'number') { v = el.value.trim() === '' ? undefined : Number(el.value); } + else { + const raw = el.value.trim(); + // 覆盖型字段空串照发(见 CLEARABLE_CFG);其余空 = 不下发。 + if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined; + else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number); + else v = raw; + } + if (v !== undefined) put(out, path, v); + } + return out; +} +/* Go 时长字段即时校验:空 = 沿用现值(collectConfig 跳过发送);非空必须是 + ParseDuration 语法(30m / 2h / 600s / 1h30m,可组合可带小数)。与后端 + config.go normalize() 的 time.ParseDuration 同口径,脏值在前端就地标红, + 不再等到保存被拒。 */ +const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; +const DURATION_FIELDS = ['soft_rate', 'soft_rate_max', 'breaker_cooldown', 'breaker_cooldown_max', + 'degrade_cooldown', 'degrade_cooldown_max', 'cost_explore_interval', 'expiring_soon', 'ttl']; +const DURATION_TIP = '格式应为 Go 时长:30m / 2h / 600s / 1h30m'; +function durationBad(name) { + const el = $('cfgForm').elements[name]; + if (!el) return false; + const v = el.value.trim(); + return v !== '' && !DURATION_RE.test(v); +} +function markDurationFields() { + for (const name of DURATION_FIELDS) { + const el = $('cfgForm').elements[name]; + if (!el) continue; + const bad = durationBad(name); + el.classList.toggle('invalid', bad); + el.title = bad ? DURATION_TIP : ''; + } +} +$('cfgForm').addEventListener('input', ev => { + if (DURATION_FIELDS.includes(ev.target.name)) markDurationFields(); +}); +$('btnEye').onclick = () => { + const el = $('cfgKey'); + const show = el.type === 'password'; + el.type = show ? 'text' : 'password'; + $('btnEye').textContent = show ? '隐藏' : '显示'; +}; +$('btnCfgReload').onclick = loadConfig; +$('cfgForm').onsubmit = async ev => { + ev.preventDefault(); + // 时长字段脏值拦截:标红 + toast 点名,不发保存请求(后端同样会拒,这里前置)。 + markDurationFields(); + const firstBad = DURATION_FIELDS.find(durationBad); + if (firstBad) { + const el = $('cfgForm').elements[firstBad]; + el.focus(); + toast('「' + (el.closest('.fld')?.querySelector('.lb')?.textContent || firstBad) + '」' + DURATION_TIP, 'err'); + return; + } + const btn = $('btnCfgSave'); + btn.disabled = true; btn.textContent = '保存中…'; + try { + const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) }); + const n = (r.restart_required || []).length; + toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok'); + // 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。 + const k = $('cfgKey').value.trim(); + if (k) localStorage.setItem(LS_KEY, k); + loadConfig(); + loadOverview(true); + } catch (e) { toast('保存失败:' + e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '保存配置'; } +}; + +/* ── 添加账号 ─────────────────────────────────────────────────────── */ +function openAdd() { + $('addVeil').classList.add('on'); + // 重置到登录标签 + switchAddTab('login'); + $('addPick').hidden = false; + $('addLoad').hidden = true; $('addReady').hidden = true; + $('addDone').hidden = true; $('addErr').hidden = true; + $('importDone').hidden = true; $('importErr').hidden = true; + $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true; + $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false; + stopPoll(); +} +function switchAddTab(tab) { + document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab)); + $('addTabLogin').hidden = tab !== 'login'; + $('addTabImport').hidden = tab !== 'import'; +} +document.querySelectorAll('#addTabs .tab').forEach(b => { + b.onclick = () => switchAddTab(b.dataset.tab); +}); +function startAddLogin() { + const realm = (document.querySelector('input[name="addRealm"]:checked') || {}).value || 'cn'; + $('btnStartLogin').disabled = true; + $('addLoad').hidden = false; $('addErr').hidden = true; + api('login/start', { method: 'POST', body: JSON.stringify({ realm }) }).then(r => { + loginState = r.state; + $('addUrl').textContent = r.url; + $('addPick').hidden = true; // 选域锁定(会话已按该域发起) + $('addLoad').hidden = true; $('addReady').hidden = false; + $('btnStartLogin').hidden = true; + $('btnCopyUrl').hidden = false; $('btnOpenUrl').hidden = false; + loginTimer = setInterval(pollLogin, 3000); + }).catch(e => { + $('addLoad').hidden = true; + $('btnStartLogin').disabled = false; + $('addErr').hidden = false; + $('addErr').textContent = e.message; + }); +} +function stopPoll() { if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } } +async function pollLogin() { + if (!loginState) return; + try { + const r = await api('login/poll?state=' + encodeURIComponent(loginState)); + if (r.done) { + stopPoll(); + $('addReady').hidden = true; + $('addDone').hidden = false; + $('addDone').textContent = '已添加 ' + (r.nickname || r.uid) + (r.realm === 'global' ? '(国际版)' : '') + (r.credits >= 0 ? ' · 积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + ',账号已载入池中'; + setTimeout(() => { closeAdd(); loadOverview(true); }, 1600); + } + } catch (e) { + stopPoll(); + $('addReady').hidden = true; + $('addErr').hidden = false; + $('addErr').textContent = e.message + '(关闭后重新添加)'; + } +} +function closeAdd() { stopPoll(); loginState = null; $('addVeil').classList.remove('on'); } +$('btnCloseAdd').onclick = closeAdd; +$('btnStartLogin').onclick = startAddLogin; +$('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank'); +$('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent) + .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err')); +$('importFile').onchange = async () => { + const file = $('importFile').files[0]; + if (!file) return; + $('importDone').hidden = true; $('importErr').hidden = true; + const fd = new FormData(); + fd.append('file', file); + const h = {}; + const k = localStorage.getItem(LS_KEY); + if (k) h['Authorization'] = 'Bearer ' + k; + try { + const r = await fetch('/panel/api/import/cockpit', { method: 'POST', body: fd, headers: h }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); + $('importDone').hidden = false; + $('importDone').textContent = '导入完成:成功 ' + d.imported + ' 个' + (d.skipped ? ',跳过 ' + d.skipped + ' 个' : ''); + if (d.errors && d.errors.length) { + console.warn('import errors:', d.errors); + } + loadOverview(true); + } catch (e) { + $('importErr').hidden = false; + $('importErr').textContent = '导入失败:' + e.message; + } + $('importFile').value = ''; +}; + +/* ── 顶部动作 ─────────────────────────────────────────────────────── */ +$('btnAdd').onclick = openAdd; +$('btnRefresh').onclick = async () => { + const b = $('btnRefresh'); + b.disabled = true; b.textContent = '刷新中…'; + try { + await api('balance_all', { method: 'POST' }); + await loadOverview(true); + toast('余额已从上游刷新', 'ok'); + } catch (e) { toast('刷新失败:' + e.message, 'err'); await loadOverview(true); } + finally { b.disabled = false; b.textContent = '刷新'; } + if (view === 'logs') loadLogs(); +}; + +/* ── 轮询 ─────────────────────────────────────────────────────────── */ +function refreshVisible() { + if (view === 'accounts') loadOverview(true); + else if (view === 'logs') loadLogs(); + else if (view === 'taskscenter') reattachQueueView(); +} +function start() { + loadOverview(true); + if (refTimer) clearInterval(refTimer); + refTimer = setInterval(refreshVisible, 5000); + checkAuthGate(); +} +async function checkAuthGate() { + try { await api('overview'); } + catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; } +} +start(); + +/* ── 积分任务 ─────────────────────────────────────────────────────── */ +let taskUID = null; + +// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。 +// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。 +// 注意:键含点号(Model_chat_GLM5.2)必须加引号,否则会被解析成属性访问 + 数字字面量。 +const AUTO_TASKS = { + 'chat_5': '上报 5 条对话活跃事件(自动补足差额)', + 'first_buddy': '上报解锁 → 同意协议 → 领取第一只 Buddy', + 'Model_chat_GLM5.2': '接受任务 → glm-5.2 真实对话一次 → 对齐模型上报', + 'RichMeow_Chat': '桌面指纹事件链上报(已验证可点亮)', + 'Buddy_App': '上报「进入 Buddy 应用」事件链(已验证可点亮)', + 'Buddy_App_QQ': '上报「进入企鹅教师助手」事件链(已验证可点亮)', + 'automation_1': '上报「定时任务创建」事件(已验证可点亮)', + 'Library_read': '上报「读资料库介绍」事件(已验证可点亮)', + 'template_5': '上报「使用模板创建任务」事件组 ×5(三账号实测点亮)', + 'playbook_prompt': '上报「灵感案例做同款发送 Prompt」事件组(三账号实测点亮)', + 'create_canvas': '上报「设计创意画布创建」事件组(三账号实测点亮,+300 分)', + 'expert_5': '真实专家召唤+使用链 ×5(专家市场+真实 chat,三账号实测点亮)', + 'Expert_team_use_3': '真实专家团召唤+使用链 ×3(三账号实测点亮)', + 'Hp_Appearance': '设置主题 API + 皮肤生效事件(两账号实测点亮)', + 'black_cat': '夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示等 23 点排程)', + 'Expert_lighthouse': '真实轻量云专家召唤+使用链(真实对话 requestId,两账号实测点亮)', + 'skill_1': '真实对话 + skill_info 技能加载事件(实测点亮)', + 'school_season': '校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)', + 'Sequential_Tasks_1': '小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)', + 'Sequential_Tasks_2': '小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)', + 'Sequential_Tasks_3': '小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)', + 'Sequential_Tasks_4': '小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件 → 领奖(判据待解锁验证)', + 'Sequential_Tasks_5': '小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)', + 'Sequential_Tasks_6': '小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖', + 'Sequential_Tasks_7': '体验灵感功能(预留,疑 PC 口径):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)' +}; + +function openTasks(uid) { + taskUID = uid; + $('taskWho').textContent = uid.slice(0, 16); + $('taskVeil').classList.add('on'); + $('btnTaskReload').hidden = false; + loadTasks(); +} +function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; } +$('btnCloseTask').onclick = closeTasks; +$('btnTaskReload').onclick = loadTasks; + +// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。 +$('btnTaskAcceptAll').onclick = async () => { + if (!taskUID) return; + const btn = $('btnTaskAcceptAll'); + btn.disabled = true; btn.textContent = '接受中…'; + try { + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/accept_all', { method: 'POST' }); + const n = r.accepted || 0; + if (r.failed && r.failed.length) { + toast(`已接受 ${n} 个,${r.failed.length} 个被上游拒绝(可重试)`, 'err'); + } else { + toast(n ? `已接受 ${n} 个任务` : (r.message || '所有任务均已接受'), 'ok'); + } + } catch (e) { toast(e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); } +}; + +// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。 +$('btnTaskAutoAll').onclick = async () => { + if (!taskUID) return; + const btn = $('btnTaskAutoAll'); + if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return; + btn.disabled = true; btn.textContent = '执行中…'; + try { + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' }); + const okN = (r.results || []).filter(x => x.status === 'done').length; + const skipN = (r.results || []).filter(x => x.status === 'skipped').length; + const errN = (r.results || []).filter(x => x.status === 'error').length; + toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok'); + console.log('auto_all results:', r.results); + } catch (e) { toast(e.message, 'err'); } + finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); } +}; + +async function loadTasks() { + if (!taskUID) return; + const st = $('taskState'), tb = $('taskTable'); + st.hidden = false; + st.className = 'state'; + st.innerHTML = '查询中'; + tb.hidden = true; + try { + const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks'); + const list = d.tasks || []; + if (!list.length) { + st.className = 'state'; + st.textContent = '该账号暂无任务'; + return; + } + // 有进度或可领取的排前面,已领取沉底——一眼看到"现在该做什么"。 + list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code))); + $('taskBody').innerHTML = list.map(t => { + // 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N" + const cur = t.current ?? 0, tgt = t.target ?? 0; + const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—'); + const parts = []; + if (t.credit) parts.push('+' + t.credit + ' 分'); + if (t.energy) parts.push('+' + t.energy + ' 能'); + if (t.reward_buddy) parts.push('Buddy'); + const reward = parts.length ? parts.join(' ') : '—'; + const badge = t.claimed ? '已领取' + : t.claimable ? '可领取' + : t.locked ? '未解锁' + : t.accept_status === 'accepted' ? '进行中' + : '未接受'; + const acted = t.claimed || t.locked ? '' + : t.claimable ? '' + : AUTO_TASKS[t.task_code] ? '' + : t.accept_status === 'accepted' ? '' + : ''; + // 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看 + const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n'); + return '' + + '
' + esc(t.title || t.task_code) + '
' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + '
' + + '' + esc(prog) + '' + + '' + esc(reward) + '' + + '' + badge + '' + + '' + acted + ''; + }).join(''); + st.hidden = true; + tb.hidden = false; + } catch (e) { + st.className = 'state err'; + st.textContent = e.message; + } +} + +$('taskBody').addEventListener('click', async ev => { + const b = ev.target.closest('button[data-t]'); + if (!b || !taskUID) return; + const kind = b.dataset.t, code = b.dataset.c; + b.disabled = true; + try { + if (kind === 'auto') { + // 一键完成:后端执行动作 → 回读进度 → 汇报(耗时可到分钟级,含真实对话) + b.textContent = '执行中…'; + const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', { + method: 'POST', body: JSON.stringify({ task_code: code }) + }); + if (r.skipped) { + toast(r.message || '已跳过', 'ok'); + } else { + const advanced = r.progress_before !== r.progress_after; + let msg = r.message || '已执行'; + if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`; + if (r.claimed) msg += ',奖励已自动到账'; + else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : ''; + else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端'; + toast(msg, (r.claimed || advanced) ? 'ok' : 'err'); + } + loadOverview(true); + } else { + const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept'); + const body = kind === 'claim' ? { task_code: code } : { task_codes: [code] }; + await api(path, { method: 'POST', body: JSON.stringify(body) }); + toast(kind === 'claim' ? '已领取奖励' : '已接受任务', 'ok'); + if (kind === 'claim') loadOverview(true); + } + } catch (e) { toast(e.message, 'err'); } + finally { loadTasks(); } +}); + +/* ── 任务中心:开学季 + 全账号扫描/队列 ──────────────────────────── */ +// 开学季任务单元:✓ 已领(绿)|◐ x/y 进行中(琥珀)|○ 未做(灰) +function staskHTML(t) { + if (!t) return '·—'; + if (t.status === 'claimed') return '✓已领'; + if (t.status === 'completed') return '◆可领'; + if (t.status === 'in_progress') { + const fr = t.target_count ? '' + t.progress + '/' + t.target_count + '' : ''; + return '◐' + fr + ''; + } + return '○未做'; +} +const LUCK_SVG = ''; +const SCHOOL_TITLES = { + share_invite: '分享活动 +100c', desktop_chat_1_time: '桌面端体验 +100c(单次)', + chat_3_times: '和 AI 对话 3 次 +50c', expert_use: '召唤开学季专家 +50c', + task_student_verify: '学生认证 +100c(需真实认证,不做)', +}; + +/* ── 精简 QR 编码器(券码二维码用)──────────────────────────────────── + 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 + 完整性:规范允许任选掩码(解码器按格式信息位自行去掩码),固定掩码不影响 + 可扫描性;已用 python qrcode 库对多输入多版本做逐像素交叉验证(强制 byte + 模式 + mask 0,5/5 全部 diff=0)。面板 CSP 只允许 self,外链 QR 服务不可用。 */ +// qr_gen.js —— 精简 QR 编码器(浏览器用 + node 可跑交叉验证) +// 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。 +// 完整性说明:规范允许编码器任选掩码(解码器按格式信息位自行去掩码), +// 固定掩码不影响可扫描性;券码为短文本,v1-5(26 字节起)绰绰有余。 + +// GF(256) 对数/指数表(本原多项式 0x11d) +const QR_EXP = new Array(512), QR_LOG = new Array(256); +(() => { + let x = 1; + for (let i = 0; i < 255; i++) { QR_EXP[i] = x; QR_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } + for (let i = 255; i < 512; i++) QR_EXP[i] = QR_EXP[i - 255]; +})(); +const gmul = (a, b) => (a && b) ? QR_EXP[QR_LOG[a] + QR_LOG[b]] : 0; + +// 各版本参数(下标 = 版本-1):[数据码字数, 纠错码字数],ECC L 单块 +const QR_V = [[19, 7], [34, 10], [55, 15], [80, 20], [108, 26]]; +// 对齐图案中心坐标(v2+;与定位图案重叠的位置在放置时跳过) +const QR_ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]]; +const QR_MASK = (r, c) => (r + c) % 2 === 0; // 掩码模式 0 + +// 生成多项式(最高次系数在前,g[0] 恒为 1) +function qrGenPoly(deg) { + let g = [1]; + for (let i = 0; i < deg; i++) { + const a = QR_EXP[i], ng = new Array(g.length + 1).fill(0); + ng[0] = g[0]; + for (let j = 1; j < g.length; j++) ng[j] = g[j] ^ gmul(a, g[j - 1]); + ng[g.length] = gmul(a, g[g.length - 1]); + g = ng; + } + return g; +} + +// Reed-Solomon 求余(综合除法),返回 deg 个纠错码字 +function rsRem(data, deg) { + const g = qrGenPoly(deg); + const res = data.concat(new Array(deg).fill(0)); + for (let i = 0; i < data.length; i++) { + const f = res[i]; + if (f) for (let j = 0; j < g.length; j++) res[i + j] ^= gmul(g[j], f); + } + return res.slice(data.length); +} + +// 文本 → 码字流(byte 模式:0100 + 8 位计数 + 数据 + 终止符 + 0xEC/0x11 填充) +function qrDataCodewords(text, dataCap) { + const bytes = Array.from(new TextEncoder().encode(text)); + const bits = []; + const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); }; + push(4, 4); // byte 模式 + push(bytes.length, 8); // v1-9 计数 8 位 + for (const b of bytes) push(b, 8); + const cap = dataCap * 8; + push(0, Math.min(4, cap - bits.length)); // 终止符 + while (bits.length % 8) bits.push(0); + const out = []; + for (let i = 0; i < bits.length; i += 8) { + let v = 0; for (const b of bits.slice(i, i + 8)) v = (v << 1) | b; + out.push(v); + } + for (let p = 0; out.length < dataCap; p ^= 1) out.push(p ? 0x11 : 0xEC); + return out; +} + +// 主入口:text → 布尔矩阵(true=深色模块) +function qrMatrix(text) { + const bytes = Array.from(new TextEncoder().encode(text)); + // 版本选择:需求 ≈ 2 码字头 + 文本长度,取首个放得下的版本 + let ver = 0; + for (let v = 0; v < QR_V.length; v++) { if (bytes.length + 2 <= QR_V[v][0]) { ver = v + 1; break; } } + if (!ver) throw new Error('QR: text too long (>' + QR_V[4][0] + ' bytes)'); + const [dataCap, ecCap] = QR_V[ver - 1]; + const n = 17 + 4 * ver; + + const M = Array.from({ length: n }, () => new Array(n).fill(false)); + const F = Array.from({ length: n }, () => new Array(n).fill(false)); // 功能模块占位 + + const setF = (r, c, v) => { M[r][c] = v; F[r][c] = true; }; + // 定位图案 + 分隔带 + const finder = (r0, c0) => { + for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) { + const rr = r0 + r, cc = c0 + c; + if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue; + const dark = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); + setF(rr, cc, dark); + } + }; + finder(0, 0); finder(0, n - 7); finder(n - 7, 0); + // 校正图形(仅贯穿两定位图案之间:8..n-9,不得覆盖定位图案本体) + for (let r = 8; r <= n - 9; r++) setF(r, 6, r % 2 === 0); + for (let c = 8; c <= n - 9; c++) setF(6, c, c % 2 === 0); + // 对齐图案(v2+,跳过与定位重叠处) + const align = QR_ALIGN[ver - 1] || []; + for (const ar of align) for (const ac of align) { + if (F[ar][ac]) continue; + for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) + setF(ar + r, ac + c, Math.max(Math.abs(r), Math.abs(c)) !== 1); + } + // 暗模块 + 格式信息(ECC L=01,掩码 0)——BCH(15,5) + 0x5412 异或。 + // 位序遵循规范(与 python qrcode 逐位对齐验证):bit i 从 LSB 起数, + // 副本一走左上角 L 形、副本二走右下 L 形。 + let fmt = (1 << 3) | 0; // L<<3 | mask + let rem = fmt << 10; + for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10); + fmt = ((fmt << 10) | rem) ^ 0x5412; // 15 位 + const fb = i => (fmt >> i) & 1; + // 副本一(左上):位 0..5 → (i,8);6 → (7,8);7 → (8,8) + for (let i = 0; i <= 5; i++) setF(i, 8, !!fb(i)); + setF(7, 8, !!fb(6)); setF(8, 8, !!fb(7)); + // 副本一续 + 副本二(右下):位 8..14 → (n-15+i, 8);位 0..7 → (8, n-1-i);8 → (8,7);9..14 → (8,14-i) + for (let i = 8; i <= 14; i++) setF(n - 15 + i, 8, !!fb(i)); + for (let i = 0; i <= 7; i++) setF(8, n - 1 - i, !!fb(i)); + setF(8, 7, !!fb(8)); + for (let i = 9; i <= 14; i++) setF(8, 14 - i, !!fb(i)); + // 暗模块(恒为深色,位于副本一垂直段末端) + setF(n - 8, 8, true); + + + // 数据码字 + 纠错码字 → 位流 + const dcw = qrDataCodewords(text, dataCap); + const cw = dcw.concat(rsRem(dcw, ecCap)); + const bits = []; + for (const b of cw) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1); + + // 蛇形放置(成对列,从右向左,跳过第 6 列),写数据时直接异或掩码 + let bi = 0, up = true; + for (let x = n - 1; x > 0; x -= 2) { + if (x === 6) x--; + for (let i = 0; i < n; i++) { + const r = up ? n - 1 - i : i; + for (const c of [x, x - 1]) { + if (F[r][c]) continue; + const bit = bi < bits.length ? bits[bi++] : 0; + M[r][c] = bit ? !QR_MASK(r, c) : QR_MASK(r, c); + } + } + up = !up; + } + return M; +} + +// 矩阵 → SVG(quiet zone 4 模块) +function qrSVG(M, px) { + const n = M.length, q = 4, total = n + q * 2; + let s = ''; + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) + if (M[r][c]) s += ''; + return s + ''; +} + +/* ── 开学季券码查询(弹窗,仿活动页 #/prizes?tab=vouchers)──────────── */ +/* copyText:clipboard API 只在 secure context(https/localhost)可用, + 远程 http 面板会拿不到 navigator.clipboard → 降级 execCommand。 */ +function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + return new Promise((resolve, reject) => { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); } + catch (e) { reject(e); } + finally { ta.remove(); } + }); +} + +function vcCard(v) { + const expired = v.valid_to && new Date(v.valid_to) < new Date(); + return '
' + + '
' + esc(v.prize_name || v.sku_code || '券') + '' + + (expired ? '已过期' : '可使用') + '
' + + '
' + + (v.valid_to ? '有效期至 ' + esc(v.valid_to) : '长期有效') + + (v.granted_at ? ' · ' + esc(v.granted_at.slice(0, 10)) + ' 抽中' : '') + + '
' + + '
' + + '
券码' + esc(v.code || '-') + '' + + '' + + (v.code ? '' : '') + + '' + + '
' + + '
'; +} + +async function loadSchoolVouchers() { + const body = $('vcBody'); + $('vcVeil').classList.add('on'); + body.innerHTML = '
查询中
'; + $('vcNote').textContent = ''; + try { + const d = await api('school/vouchers'); + const arr = d.accounts || []; + const ok = arr.filter(a => !a.error); + const total = ok.reduce((n, a) => n + (a.vouchers || []).length, 0); + body.innerHTML = ok.filter(a => (a.vouchers || []).length).map(a => + '
' + esc(a.nickname || a.uid) + '' + + '' + a.vouchers.length + ' 张
' + + a.vouchers.map(vcCard).join('') + ).join('') || '
🎟️
还没有抽到券
'; + $('vcNote').textContent = total ? total + ' 张券 · ' + ok.filter(a => !(a.vouchers || []).length).length + ' 个账号未抽中' : ''; + const errs = arr.filter(a => a.error); + if (errs.length) { + body.insertAdjacentHTML('beforeend', '
查询失败:' + + errs.map(a => esc(a.nickname || a.uid.slice(0, 8)) + '(' + esc(a.error) + ')').join('、') + '
'); + } + body.querySelectorAll('button[data-copy]').forEach(b => b.onclick = async () => { + try { await copyText(b.dataset.copy); toast('券码已复制', 'ok'); } + catch (e) { toast('复制失败,请手动选择券码', 'err'); } + }); + // 二维码:券码本体编码为 QR(到店出示扫描),点击切换显示/隐藏 + body.querySelectorAll('button[data-qr]').forEach(b => b.onclick = () => { + const card = b.closest('.vc'); + const old = card.querySelector('.vc-qr'); + if (old) { old.remove(); return; } + const box = document.createElement('div'); + box.className = 'vc-qr'; + try { box.innerHTML = qrSVG(qrMatrix(b.dataset.qr), 148); } + catch (e) { box.innerHTML = '二维码生成失败:' + esc(e.message) + ''; } + card.appendChild(box); + }); + } catch (e) { + body.innerHTML = '
' + esc(e.message) + '
'; + } +} +$('btnSchoolVouchers').onclick = loadSchoolVouchers; +$('btnVcClose').onclick = () => $('vcVeil').classList.remove('on'); +$('btnVcRefresh').onclick = loadSchoolVouchers; + +/* 成长任务队列。lastQueueSeq 记录本页启动过的队列代次:执行结束后的残留 items + (running=false 但 seq 停在旧值)不再回写视图——否则扫描结果 3 秒后被上一轮 + 队列状态覆盖。 */ +let queueTimer = null, lastQueueSeq = 0; +const GROWTH_TITLES = {}; // code → 展示名(扫描时从任务列表带出) +$('btnScanAll').onclick = async () => { + const b = $('btnScanAll'); + // 停掉队列轮询:显式扫描 = 切到待办视图。否则在途队列的下一 tick 会把扫描 + // 结果冲掉重渲染回队列视图(服务端执行不受影响,只是不再实时回写本视图)。 + if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } + b.disabled = true; b.textContent = '扫描中…'; + try { + const d = await api('tasks/scan_all', { method: 'POST' }); + renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。'); + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; b.textContent = '扫描待办'; } +}; +$('btnRunQueue').onclick = async () => { + const conc = Number($('qcConc').value) || 1; + if (!confirm('扫描全部账号待办并排队执行(账号并发 ' + conc + ',账号内串行)。\n含真实对话的任务耗时较长,确认继续?')) return; + const b = $('btnRunQueue'); + b.disabled = true; b.textContent = '启动中…'; + try { + const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) }); + if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; } + lastQueueSeq = r.seq || 0; + toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok'); + startQueuePolling(); + } catch (e) { toast(e.message, 'err'); } + finally { b.disabled = false; b.textContent = '执行全部待办'; } +}; +// 扫描结果 → 分组条目(无执行状态) +function groupItems(d) { + const groups = []; + for (const a of (d.accounts || [])) { + const rows = []; + for (const t of (a.growth || [])) { + GROWTH_TITLES[t.task_code] = t.title || t.task_code; + rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' }); + } + if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows }); + } + return groups; +} +const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' }; +function qrowHTML(it) { + const isSchool = it.kind === 'school'; + const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code); + const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait'; + const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status); + return '
' + + '' + esc(it.code) + '' + + '' + esc(title) + '' + (isSchool ? '开学季' : '') + '' + + '' + esc(it.prog || '') + '' + + '' + stWord + '' + + '' + esc(it.message || '') + '' + + '
'; +} +function renderQueue(groups, progress, emptyTitle, emptyDesc) { + const empty = $('tcEmpty'), list = $('qcList'); + if (!groups.length) { + empty.style.display = ''; + if (emptyTitle) empty.querySelector('.t').textContent = emptyTitle; + if (emptyDesc) empty.querySelector('.d').textContent = emptyDesc; + list.innerHTML = ''; + $('qProg').hidden = true; $('qcSummary').textContent = ''; + return; + } + empty.style.display = 'none'; + empty.style.display = 'none'; + let total = 0; + list.innerHTML = groups.map(g => { + total += g.rows.length; + return '
' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办
' + + g.rows.map(qrowHTML).join('') + '
'; + }).join(''); + $('qcSummary').textContent = total + ' 项'; + updateProgress(progress); +} +function updateProgress(q) { + if (!q || !q.items) { $('qProg').hidden = true; return; } + const total = q.items.length; + const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length; + $('qProg').hidden = false; + $('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%'; + $('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total; +} +// 队列状态 → 分组(执行时轮询) +function groupsFromQueue(items) { + const by = new Map(); + for (const it of items) { + if (!by.has(it.uid)) by.set(it.uid, { uid: it.uid, nick: it.nickname, rows: [] }); + by.get(it.uid).rows.push({ + kind: it.kind, code: it.code, + prog: it.kind === 'school' ? '—' : '', + status: it.status, message: it.message, + }); + } + return Array.from(by.values()); +} +function startQueuePolling() { + if (queueTimer) clearInterval(queueTimer); + queueTimer = setInterval(async () => { + let q; + try { q = await api('tasks/queue'); } catch (e) { return; } + if (!q.started) return; + // 只渲染本页启动过的那轮队列(刷新页面后不再接管旧队列)。 + if (lastQueueSeq && q.seq !== lastQueueSeq) return; + if (q.running) { + renderQueue(groupsFromQueue(q.items || []), q); + return; + } + // 结束:终态只渲染这一次,随即停表。此后残留的 items(running=false)不再 + // 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。 + renderQueue(groupsFromQueue(q.items || []), q); + clearInterval(queueTimer); queueTimer = null; + toast('任务队列执行结束', 'ok'); + }, 3000); +} +// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在 +// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。 +function reattachQueueView() { + // 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const + //(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError + // 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await + // 侥幸安全)。queueTimer 的"已在跑"判定也挪到 await 后,语义不变。 + (async () => { + try { + const q = await api('tasks/queue'); + if (queueTimer) return; // 轮询已在跑(跨视图不中断) + if (q.started && q.running && (!lastQueueSeq || q.seq === lastQueueSeq)) startQueuePolling(); + } catch (e) { /* 静默 */ } + })(); +} + +/* ── 用量 ─────────────────────────────────────────────────────────── */ +/* 图表用原生 SVG 手绘:面板是 go:embed 单文件、无构建步骤,引入图表库 + 就得带上打包器,得不偿失。这里只需要堆叠柱状图,二十行足够。 */ + +function fmtTok(n) { + n = Number(n || 0); + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(n); +} +function fmtMs(ms) { + ms = Number(ms || 0); + if (!ms) return '—'; + if (ms >= 1000) return (ms / 1000).toFixed(2) + 's'; + return Math.round(ms) + 'ms'; +} +function fmtRate(r) { return r ? Number(r).toFixed(1) + ' tok/s' : '—'; } +function trimFixed(s) { + if (!String(s).includes('.')) return String(s); + return String(s).replace(/0+$/, '').replace(/\.$/, ''); +} +function fmtCredit(n) { + const v = Number(n || 0); + if (!Number.isFinite(v)) return '—'; + return trimFixed(v.toFixed(2)); +} +function fmtCreditRatio(v, samples, tokens) { + if (!samples || !tokens) return '—'; + const n = Number(v || 0); + if (!Number.isFinite(n)) return '—'; + return trimFixed(n.toFixed(4)) + ' / 1M'; +} +function fmtModelRate(rate) { + const s = String(rate || '').trim(); + return s ? 'x' + s : '—'; +} + +function usStat(v, k, cls) { + return '
' + esc(v) + + '
' + esc(k) + '
'; +} + +/* usKpi 用量页的指标卡。比账号池的 .stat 多两样:语义色轨(cls)与副标题(sub, + 放"占比 / 均速率"这类解释性数字);bar 是卡片内的构成条 HTML,只有需要时才传。 */ +function usKpi(v, k, cls, sub, bar) { + return '
' + + '
' + esc(k) + '
' + + '
' + esc(v) + '
' + + (bar || '') + + (sub ? '
' + esc(sub) + '
' : '') + + '
'; +} + +/* usMixBar prompt/completion 占比条。宽度按百分比而不是固定像素:表列宽随窗口变化, + 像素宽度在窄屏会溢出、宽屏又显得没信息。 */ +function usMixBar(prompt, completion, total) { + const t = Number(total || 0); + if (!t) return ''; + const pp = Math.max(0, Math.min(100, Number(prompt || 0) / t * 100)); + const pc = Math.max(0, Math.min(100, Number(completion || 0) / t * 100)); + return '' + + '' + + '' + + ''; +} + +/* usPct 占比文案(0 值不显示 "0.0%",直接 —,避免一行全是零)。 */ +function usPct(part, total) { + const t = Number(total || 0); + if (!t) return '—'; + return (Number(part || 0) / t * 100).toFixed(1) + '%'; +} + +/* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。 + withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */ +function usRow(name, sub, a, mid, withPerf) { + return '' + + '' + + '' + esc(name) + (sub ? '
' + esc(sub) + '
' : '') + '' + + (mid || '') + + '' + fmtTok(a.requests) + '' + + '' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + '' + + '' + fmtTok(a.prompt_tokens) + '' + + '' + fmtTok(a.completion_tokens) + '' + + '' + fmtTok(a.total_tokens) + + usMixBar(a.prompt_tokens, a.completion_tokens, a.total_tokens) + '' + + (withPerf + ? '' + fmtMs(a.avg_latency_ms) + '' + + '' + fmtRate(a.avg_tokens_per_second) + '' + : '') + + ''; +} + +/* ── 用量明细:三个维度共用一张表 + 页内切换 ────────────────────────── + 账号 / 模型 / 域三张表此前各自占一个 box,页面纵向拉得很长且表头结构几乎一样。 + 现在合成一个 box:表头由维度定义生成,行渲染复用 usRow,切换零请求。 */ +const US_DIMS = { + account: { key: 'by_account', title: '账号', withRealm: true, withPerf: true, span: 10 }, + model: { key: 'by_model', title: '模型', withRealm: false, withPerf: false, span: 7 }, + realm: { key: 'by_realm', title: 'realm', withRealm: false, withPerf: false, span: 7 }, +}; + +// usSortRows 按当前排序字段降序(默认合计 Token,最大者最相关)。 +function usSortRows(rows, sort) { + const out = rows.slice(); + const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; + const val = a => sort === 'requests' ? num(a.requests) + : sort === 'errors' ? num(a.errors) + : sort === 'latency' ? num(a.avg_latency_ms) + : num(a.total_tokens); + out.sort((a, b) => val(b) - val(a)); + return out; +} + +function usDimHead(dim) { + const m = US_DIMS[dim] || US_DIMS.account; + return '' + esc(m.title) + '' + + (m.withRealm ? '域' : '') + + '请求失败' + + 'PromptCompletion合计' + + (m.withPerf ? '均延迟均速率' : '') + + ''; +} + +/* usTabsHtml 维度切换按钮(带条数徽标)。整段 innerHTML 重写而不是逐个改 class: + 容器的 click 监听是委托式的,换掉子节点不会丢事件,代码也更短。 */ +function usTabsHtml(dims, active, counts) { + return dims.map(([k, label]) => { + const n = counts ? counts[k] : null; + return ''; + }).join(''); +} + +const US_DIM_TABS = [['account', '按账号'], ['model', '按模型'], ['realm', '按域']]; + +function renderUsageDim() { + const d = usageData || {}; + const m = US_DIMS[usDim] || US_DIMS.account; + const rows = usSortRows(d[m.key] || [], usSort); + $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, { + account: (d.by_account || []).length, + model: (d.by_model || []).length, + realm: (d.by_realm || []).length, + }); + $('usDimHead').innerHTML = usDimHead(usDim); + $('usDimBody').innerHTML = rows.map(x => + usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key, + usDim === 'account' ? (x.extra || '') : '', + x, + usDim === 'account' ? '' + esc(x.realm || '') + '' : '', + m.withPerf) + ).join('') || '暂无数据'; + $('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试'; +} + +/* 积分扣除:按账号 / 按模型两个维度共用一张表(同上,两个 box 合成一个)。 */ +function usCreditHead(dim) { + return dim === 'model' + ? '模型积分倍率' + + '请求扣除积分' + + '有效样本 Token积分 / 1M Token缓存命中率' + : '账号' + + '请求扣除积分' + + '有效样本 Token积分 / 1M Token缓存命中率'; +} + +/* 缓存命中率(issue #92):颜色即健康度——≥90% 绿 / 80–90% 黄 / <80% 红, + 样本不足灰。title 带命中/未命中绝对量,供逐项核对。 */ +function cacheRateCell(hit, miss) { + const h = Number(hit || 0), m = Number(miss || 0), total = h + m; + if (!total) return '—'; + const pct = h / total * 100; + const color = pct >= 90 ? 'var(--ok)' : (pct >= 80 ? 'var(--warn)' : 'var(--bad)'); + const txt = trimFixed(pct.toFixed(1)) + '%'; + return '' + txt + ''; +} + +function renderCreditDim() { + const d = usageData || {}; + $('usCreditHead').innerHTML = usCreditHead(usCreditDim); + const empty = '暂无积分扣除记录;升级前仅含 Token 的历史不会伪造积分。'; + if (usCreditDim === 'model') { + const models = d.credit_by_model || []; + $('usCreditBody').innerHTML = models.map(row => + '' + + '' + + '' + esc(row.key || '—') + '' + + '' + esc(fmtModelRate(row.rate)) + '' + + '' + fmtTok(row.requests) + '' + + '' + fmtCredit(row.credits) + '' + + '' + fmtTok(row.credit_tokens) + '' + + '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + + '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + + '' + ).join('') || '' + empty + ''; + } else { + const accounts = d.credit_by_account || []; + $('usCreditBody').innerHTML = accounts.map(row => { + const uid = String(row.key || ''); + const account = row.nickname || uid.slice(0, 8) || '—'; + return '' + + '' + + '' + esc(account) + '
' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + '
' + + '' + fmtTok(row.requests) + '' + + '' + fmtCredit(row.credits) + '' + + '' + fmtTok(row.credit_tokens) + '' + + '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + '' + + '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + '' + + ''; + }).join('') || '' + empty + ''; + } + $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, { + account: (d.credit_by_account || []).length, + model: (d.credit_by_model || []).length, + }); +} + +function renderUsage(d) { + usageData = d || {}; + const t = usageData.totals || {}; + const total = Number(t.total_tokens || 0); + const pt = Number(t.prompt_tokens || 0); + const ct = Number(t.completion_tokens || 0); + const reqs = Number(t.requests || 0); + const errs = Number(t.errors || 0); + const okRate = reqs ? (reqs - errs) / reqs * 100 : null; + // 构成条要的是合法 CSS 宽度,usPct 在无样本时返回 "—",不能直接拼进 style。 + const pctW = (part) => total ? Math.max(0, Math.min(100, Number(part || 0) / total * 100)).toFixed(2) + '%' : '0%'; + // 六张卡:主指标用强调色,completion 用成功色(与图表里的绿柱呼应), + // 失败/延迟只在有值时上语义色——全绿全黄的仪表盘等于没有重点。 + $('usStats').innerHTML = + usKpi(fmtTok(reqs), '请求数', 'c-accent', + errs ? '其中失败 ' + errs + ' 次' : '全部成功') + + usKpi(fmtTok(total), '总 token', 'c-accent', + 'prompt ' + usPct(pt, total) + ' · completion ' + usPct(ct, total), + '
' + + '
') + + usKpi(fmtTok(pt), 'prompt', 'c-soft', '占比 ' + usPct(pt, total)) + + usKpi(fmtTok(ct), 'completion', 'c-ok', '占比 ' + usPct(ct, total)) + + usKpi(String(errs), '失败尝试', errs ? 'c-warn' : 'c-mute', + okRate == null ? '—' : (errs ? '成功率 ' + okRate.toFixed(1) + '%' : '成功率 100%')) + + usKpi(fmtMs(t.avg_latency_ms), '平均延迟', 'c-soft', + t.avg_tokens_per_second ? '吐字 ' + fmtRate(t.avg_tokens_per_second) : '无速率样本'); + + // 卡片、明细表与时序图全部按所选窗口统计(切窗口数字随之变化); + // 「全部历史」含 90 天前折叠出的日桶。这里标注当前口径与数据起点。 + const winLabel = trangeLabel('usRange'); + // 服务端回显的实际区间优先(自定义区间下它就是权威口径);滚动窗口没有回显, + // 用控件自己的标签。 + const rangeEcho = usageData.window_from + ? String(usageData.window_from).replace('T', ' ').slice(0, 16) + + (usageData.window_to ? ' → ' + String(usageData.window_to).replace('T', ' ').slice(0, 16) : ' → 现在') + : ''; + const note = (rangeEcho || winLabel ? (rangeEcho || winLabel) + ' · ' : '') + + (usageData.buckets || 0) + ' 个分桶' + + (usageData.since ? ' · 数据自 ' + usageData.since.replace('T', ' ') : '') + + (usageData.file_bytes ? ' · 文件 ' + (usageData.file_bytes / 1024).toFixed(1) + ' KB' : ''); + $('usNote').textContent = note; + $('usNote').title = note; // 窄屏单行截断时靠悬停看全 + + // 积分扣除的四张卡片与说明。 + $('usCreditStats').innerHTML = + usKpi(fmtCredit(t.credits), '扣除积分', 'c-accent', '按上游 usage.credit 累计') + + usKpi(fmtTok(t.credit_tokens), '匹配 Token', 'c-mute', '与积分同时观测到的 Token') + + usKpi(fmtCreditRatio(t.credits_per_1m_tokens, t.credit_samples, t.credit_tokens), + '平均积分 / 1M Token', 'c-ok', '越低越划算') + + usKpi(String(t.credit_samples || 0), '有效积分样本', 'c-mute', '缺字段的历史不参与折算') + + usKpi(cacheRateText(t.cache_hit_tokens, t.cache_miss_tokens), '缓存命中率', 'c-mute', + '上游前缀缓存命中 / (命中+未命中);低命中意味着费用数倍放大'); + $('usCreditNote').textContent = + (usageData.credit_by_account || []).length + ' 个账号 · ' + + (usageData.credit_by_model || []).length + ' 个模型倍率分组 · 仅统计与积分同时观测到的 Token'; + + renderUsageDim(); + renderCreditDim(); + renderUsageChart(usageData.series || []); +} + +// 维度切换 / 排序控件。 +if ($('usDimTabs')) $('usDimTabs').addEventListener('click', ev => { + const b = ev.target.closest('button[data-dim]'); + if (!b) return; + usDim = b.dataset.dim; + renderUsageDim(); +}); +if ($('usCreditTabs')) $('usCreditTabs').addEventListener('click', ev => { + const b = ev.target.closest('button[data-dim]'); + if (!b) return; + usCreditDim = b.dataset.dim; + renderCreditDim(); +}); +if ($('usSort')) $('usSort').onchange = () => { + usSort = $('usSort').value; + renderUsageDim(); +}; + +/* renderUsageChart 画堆叠柱状图。 + * + * x 轴是**真实时间轴**,不是按序号等距。这一点很重要:数据里存在 1 小时的 + * 间隔,也存在 6~8 小时的断档(没请求的时段不产生桶),等距排布会把 8 小时 + * 画得和 1 小时一样宽,让「什么时候用的」完全失真。 + * + * 另外不再用 preserveAspectRatio="none":那会把 viewBox 横向拉伸到容器宽度, + * 柱子和文字都变形。改为固定比例、按容器宽度自适应高度。 + * + * viewBox 取 1200×200(原 760×180):SVG 以 width:100% 渲染,高宽比决定实际 + * 高度——旧比例在 1500px 宽的主区里会撑到 ~355px,只有一两根柱子时整块几乎是 + * 空白。宽 viewBox 把同宽度下的高度压到 ~250px,与下方表格的视觉重量相当。 + * + * 时间轴用本地时间解析(后端返回的就是本地时区),day 点按当天 00:00 参与定位, + * 与 hour 点在同一个连续轴上——日桶本来就是他那天所有小时的聚合。 + */ + +/* parsePointTime 把后端的 t 解析成毫秒时间戳。 */ +function parsePointTime(p) { + // hour: "2026-09-16T13" day: "2026-09-16" + const s = p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00'; + const d = new Date(s); + return isNaN(d.getTime()) ? null : d.getTime(); +} + +/* fmtTokTimeLabel 时间桶的短标签,与 x 轴刻度同一口径(日桶 MM-DD,小时桶 HH:00)。 */ +function fmtTokTimeLabel(p) { + const d = new Date(p.t); + return p.scope === 'day' + ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + : String(d.getHours()).padStart(2, '0') + ':00'; +} + +function renderUsageChart(series) { + const host = $('usChart'); + + // 丢掉时间解析不出来的点,而不是让 NaN 传染整张图。 + const pts = []; + for (const p of series) { + const t = parsePointTime(p); + if (t === null) continue; + const pt = Number(p.prompt_tokens || 0); + const ct = Number(p.completion_tokens || 0); + pts.push({ t, scope: p.scope, raw: p.t, pt, ct, tt: Number(p.total_tokens || 0) || (pt + ct), + req: p.requests || 0 }); + } + if (!pts.length) { + host.innerHTML = '
暂无用量数据。发起一次对话后再刷新。
'; + $('usChartNote').textContent = '—'; + return; + } + + const W = 1200, H = 200, PL = 58, PR = 14, PT = 18, PB = 30; + const iw = W - PL - PR, ih = H - PT - PB; + + const t0 = pts[0].t; + const t1 = pts[pts.length - 1].t; + const span = Math.max(1, t1 - t0); + + const max = Math.max(1, ...pts.map(p => p.tt)); + const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]); + const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length; + $('usChartNote').textContent = + pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + fmtTokTimeLabel(peak) + + ' · 均值 ' + fmtTok(avg); + + // 柱宽取「最小真实间隔」的 70%,并夹在合理区间内——窗口拉到 30 天时柱子会 + // 变细,但不会细到看不见。 + let minGap = Infinity; + for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t); + if (!isFinite(minGap) || minGap <= 0) minGap = span; + const slot = iw * (minGap / span); + const bw = Math.max(2, Math.min(30, slot * 0.7)); + + // 首尾各让出半个柱宽:否则第一个点和最后一个点的柱子会各有一半跑到绘图区外 + // (末点柱子贴着卡片右边缘被切掉),刻度仍用同一个 xOf,标签与柱子始终对齐。 + const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw); + const yOf = v => PT + ih - ih * (v / max); + + let out = ''; + + // 柱体渐变:顶部实、底部略透,堆叠时两段仍能一眼分清(纯色块并排会糊成一片)。 + // 注意 stop-color 必须走 style 而不是 presentation 属性——Blink/WebKit 不解析 + // 属性里的 var(),写成 stop-color="var(--accent)" 会整条渐变失效(柱子全透明)。 + out += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + // y 轴网格 + 刻度 + for (let i = 0; i <= 4; i++) { + const y = PT + ih - (ih * i / 4); + out += ''; + out += '' + fmtTok(max * i / 4) + ''; + } + + // 均值参考线:一眼看出"这根是不是异常高",比只给刻度省心。 + // 标签放左侧:右侧常被峰值柱占用(峰值柱往往就是最后一根),贴左不会被压住。 + if (avg > 0 && avg < max) { + const y = yOf(avg); + out += ''; + out += '均值 ' + fmtTok(avg) + ''; + } + + // 柱子 + const yBase = PT + ih; + for (const p of pts) { + const x = xOf(p.t) - bw / 2; + const hTot = ih * (p.tt / max); + const hP = p.tt ? hTot * (p.pt / p.tt) : 0; + const hC = Math.max(p.tt && p.ct ? 1 : 0, hTot - hP); + // 圆角只给堆叠顶端(贴轴的底边保持方角,柱子才像"立"在基线上)。 + // 类名用 usbar 而不是 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 + // height 是 rect 的 CSS 几何属性,同名类会把每根柱子压成 3px 高(踩过)。 + if (hP > 0) out += ' 0 ? '' : ' rx="1.5"') + '/>'; + if (hC > 0) out += ''; + out += '' + esc(p.raw) + ' ' + fmtTok(p.pt) + ' prompt / ' + + fmtTok(p.ct) + ' completion / ' + p.req + ' 次'; + } + + // 峰值标注:柱子够窄时文字压在柱顶,够宽时贴右侧避免和柱体重叠。 + { + const px = xOf(peak.t); + const py = yOf(peak.tt); + const anchor = px > W - PR - 90 ? 'end' : 'middle'; + out += '' + + '峰值 ' + fmtTok(peak.tt) + ''; + } + + // x 轴基线画在柱子之后,避免压在柱底 + out += ''; + + // x 轴刻度:按真实时间等距取 6 个位置,取该位置**最近的实际柱子**做标签, + // 所以标签永远落在有数据的点上,不会指到空档里。 + const TICKS = Math.min(6, pts.length); + const usedLabel = new Set(); + for (let k = 0; k < TICKS; k++) { + const target = t0 + span * (TICKS === 1 ? 0.5 : k / (TICKS - 1)); + let bi = 0, best = Infinity; + for (let i = 0; i < pts.length; i++) { + const d = Math.abs(pts[i].t - target); + if (d < best) { best = d; bi = i; } + } + if (usedLabel.has(bi)) continue; + usedLabel.add(bi); + const p = pts[bi]; + // 首尾标签靠边对齐,避免被裁掉 + const cx = xOf(p.t); + const anchor = cx < PL + 14 ? 'start' : (cx > W - PR - 14 ? 'end' : 'middle'); + out += '' + esc(fmtTokTimeLabel(p)) + ''; + } + + // 跨天时补一条日期分隔线,让「日界」在长窗口里可见 + let prevDay = null; + for (const p of pts) { + const d = new Date(p.t).getDate(); + if (prevDay !== null && d !== prevDay) { + const x = xOf(p.t).toFixed(1); + out += ''; + } + prevDay = d; + } + + out += ''; + host.innerHTML = out; +} + +function fmtTokTip(v) { return fmtTok(v); } + +let usageRateWarmAt = 0; +async function warmUsageModelRates() { + if (Date.now() - usageRateWarmAt < 10 * 60 * 1000) return; + try { + await api('models'); + } catch (e) { + // 倍率回填是可选增强;失败不阻塞用量统计,10 分钟后再试。 + } + usageRateWarmAt = Date.now(); +} + +async function loadUsage() { + const q = trangeQuery('usRange', true); + try { + await warmUsageModelRates(); + const d = await api('usage?' + q.toString()); + renderUsage(d); + } catch (e) { + // 失败时三块都要清干净:只改图表会留下上一次窗口的数字,看起来像"刷新成功"。 + usageData = null; + $('usChart').innerHTML = '
读取用量失败:' + esc(e.message) + '
'; + $('usChartNote').textContent = '—'; + $('usStats').innerHTML = ''; + $('usCreditStats').innerHTML = ''; + $('usNote').textContent = '—'; + $('usCreditNote').textContent = '—'; + $('usDimNote').textContent = '—'; + $('usDimHead').innerHTML = ''; + $('usCreditHead').innerHTML = ''; + $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, null); + $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, null); + $('usDimBody').innerHTML = '读取用量失败'; + $('usCreditBody').innerHTML = '读取用量失败'; + } +} + +if ($('btnUsage')) $('btnUsage').onclick = loadUsage; +// 时间范围控件绑定:任何改动(预设切换 / 自定义起止)都重新拉一次用量。 +if ($('usRange')) trangeBind('usRange', loadUsage); + +/* ── 积分构成 ─────────────────────────────────────────────────────── */ +/* 一个账号的余额是若干积分包之和。包按来源命名(「国内运营裂变包」「拉新权益包」 + 「个人体验版」…),面额从 6 到 1500 不等,且**按次发放**。所以两个任务完成度 + 完全一致的账号,余额可能差上千——差别只在包里。这里把逐包明细摊开,并给每个 + 包名一个稳定配色,跨账号对比时同色即同类。 */ + +const PK_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', '#e2607a', + '#5aa9e6', '#8fbf3f', '#b58b5a', '#7d8fa8', '#d4785c']; +const PK_ACCOUNT_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', + '#e2607a', '#20a4a4', '#8fbf3f', '#d4785c', + '#7c83db', '#c48a2f', '#b45f8c', '#5aa9e6']; + +function pkColor(i) { return PK_COLORS[i % PK_COLORS.length]; } + +// pkAccountColorMap 按 UID 稳定分配颜色:排序后分配,账号刷新/重排不会换色。 +function pkAccountColorMap(list) { + const uids = (list || []) + .filter(a => a && !a.error && a.uid) + .map(a => String(a.uid)) + .sort(); + const colors = new Map(); + uids.forEach((uid, i) => colors.set(uid, PK_ACCOUNT_COLORS[i % PK_ACCOUNT_COLORS.length])); + return colors; +} + +/* pkBySource 把包按名称归并,得到「来源 → 面额/余额/个数」。这是对比的关键视图: + 两个号的差异一定体现在某几个来源的面额上。 */ +function pkBySource(packs) { + const m = new Map(); + for (const p of packs) { + // 分组键用 code + name,而不是只 name:上游给「首登赠送」和普通活动包用了 + // **同一个 PackageName 和同一个 PackageCode**,只按 name 会把两类混成一类, + // 那正是当初「两个号为何差 1500」看不出来的原因。这里至少把 code 带进键里, + // 并在卡片上显示最早的发放时间。 + const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); + const e = m.get(k) || { + key: k, name: p.name || '(未命名)', code: p.package_code || '', + n: 0, remain: 0, size: 0, used: 0, minEnd: '', minCreated: '', + }; + e.n += 1; + e.remain += Number(p.remain || 0); + e.size += Number(p.size || 0); + e.used += Number(p.used || 0); + const t = (p.end_time || '').slice(0, 10); + if (t && (!e.minEnd || t < e.minEnd)) e.minEnd = t; + const c = (p.created_at || '').slice(0, 10); + if (c && (!e.minCreated || c < e.minCreated)) e.minCreated = c; + m.set(k, e); + } + return [...m.values()].sort((a, b) => b.size - a.size); +} + +const PK_DEFAULT_DETAIL_LIMIT = 5; + +function pkDetailLimitValue(raw) { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : PK_DEFAULT_DETAIL_LIMIT; +} + +function pkDetailLimit(cfg) { + return pkDetailLimitValue(cfg && cfg.panel && cfg.panel.package_detail_limit); +} + +const PK_DAY_MS = 24 * 3600 * 1000; + +function pkExpiryMs(p) { + const raw = Number(p && p.expires_at); + if (Number.isFinite(raw) && raw > 0) return raw; + const text = String((p && p.end_time) || '').trim(); + if (!text) return null; + let iso = text.includes('T') ? text : text.replace(' ', 'T'); + if (!/(?:Z|[+-]\d\d:\d\d)$/.test(iso)) iso += '+08:00'; + const parsed = Date.parse(iso); + return Number.isFinite(parsed) ? parsed : null; +} + +// pkDetailCompare 只服务单账号逐包明细:正余额包先按到期时间挑选默认展示项, +// 其余正余额包与已用完包分别折叠;同一到期时间按面额降序。主键跟随视图排序 +// 模式(pkSortMode,声明在本区块末尾的绑定块):end_asc 到期升序、size_desc +// 面额降序(同面额按到期升序)。typeof 守卫:前端 harness 的区域切片求值里 +// 没有该全局,回落 end_asc(= 上游原有语义,切片测试的期望序不受影响)。 +function pkDetailCompare(a, b) { + const sizeOf = p => { + const n = Number(p && p.size); + return Number.isFinite(n) ? n : 0; + }; + const mode = (typeof pkSortMode === 'string' && pkSortMode) || 'end_asc'; + if (mode === 'size_desc') { + const d = sizeOf(b) - sizeOf(a); + if (d !== 0) return d; + } + const ea = pkExpiryMs(a), eb = pkExpiryMs(b); + if (ea == null && eb != null) return 1; + if (ea != null && eb == null) return -1; + if (ea != null && eb != null && ea !== eb) return ea - eb; + return sizeOf(b) - sizeOf(a); +} + +function pkDetailGroups(packs, limit) { + const active = [], used = []; + let usedSize = 0, restSize = 0, restRemain = 0; + for (const p of packs || []) { + const remain = Number(p && p.remain); + if (remain > 0) { + active.push(p); + continue; + } + used.push(p); + const size = Number(p && p.size); + if (Number.isFinite(size)) usedSize += size; + } + active.sort(pkDetailCompare); + used.sort(pkDetailCompare); + const visible = active.slice(0, pkDetailLimitValue(limit)); + const rest = active.slice(visible.length); + for (const p of rest) { + const size = Number(p && p.size); + if (Number.isFinite(size)) restSize += size; + const remain = Number(p && p.remain); + if (Number.isFinite(remain)) restRemain += remain; + } + return { visible, rest, used, restSize, restRemain, usedSize }; +} + +function pkCreditOpacity(days) { + if (days == null || !Number.isFinite(Number(days))) return 1; + return 0.25 + 0.75 * Math.max(0, Math.min(29, Number(days) - 1)) / 29; +} + +function pkExpiryText(expiresAt) { + if (!expiresAt) return '无到期时间'; + const diff = expiresAt - Date.now(); + if (diff <= 0) return '已到期'; + const minutes = Math.max(1, Math.ceil(diff / 60000)); + if (minutes < 60) return '剩余 ' + minutes + ' 分钟'; + const hours = Math.ceil(diff / 3600000); + if (hours < 24) return '剩余 ' + hours + ' 小时'; + return '剩余 ' + Math.ceil(diff / PK_DAY_MS) + ' 天'; +} + +function pkExpiryDateTime(expiresAt) { + if (!expiresAt) return '—'; + return new Date(expiresAt).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +} + +function pkAccountSegments(a, now) { + let balance = Math.max(0, Number(a.remain || 0)); + const out = []; + for (const p of a.packages || []) { + const remain = Number(p.remain || 0); + if (!Number.isFinite(remain) || remain <= 0 || balance <= 0) continue; + const amount = Math.min(balance, remain); + const expiresAt = pkExpiryMs(p); + out.push({ + amount, + expiresAt, + days: expiresAt == null ? null : Math.max(0, Math.ceil((expiresAt - now) / PK_DAY_MS)), + source: p.name || '积分', + uid: String(a.uid || ''), + accountName: a.nickname || String(a.uid || '').slice(0, 8) || '未命名账号', + }); + balance -= amount; + } + return out.sort((x, y) => { + if (x.expiresAt == null && y.expiresAt != null) return 1; + if (x.expiresAt != null && y.expiresAt == null) return -1; + return (x.expiresAt || 0) - (y.expiresAt || 0); + }); +} + +// summarizeCreditDays 对齐 WorkDaddy:按精确剩余天数逐行聚合,无有效到期时间的余额 +// 不进入图表,也不猜测到期日。账号内先按总余额约束逐包金额,避免上游重复记录膨胀。 +function summarizeCreditDays(list, now) { + const buckets = new Map(); + let unavailable = 0; + for (const a of list || []) { + if (a.error || !Number.isFinite(Number(a.remain))) { + unavailable++; + continue; + } + for (const segment of pkAccountSegments(a, now)) { + if (segment.days == null) continue; + let row = buckets.get(segment.days); + if (!row) { + row = { days: segment.days, credits: 0, segments: [] }; + buckets.set(segment.days, row); + } + row.credits += segment.amount; + row.segments.push(segment); + } + } + const rows = [...buckets.values()].sort((a, b) => a.days - b.days); + for (const row of rows) { + row.segments.sort((a, b) => + (a.expiresAt || Infinity) - (b.expiresAt || Infinity) || + a.accountName.localeCompare(b.accountName) || + a.source.localeCompare(b.source)); + } + return { rows, accountCount: (list || []).length, unavailable }; +} + +function renderExpiryDistribution(list, now) { + const summary = summarizeCreditDays(list, now); + const colors = pkAccountColorMap(list); + const rows = summary.rows.map(row => { + const total = row.credits || 1; + const nodes = row.segments.map(segment => { + const color = colors.get(segment.uid) || 'var(--accent)'; + const title = segment.source + '\n' + fmtTok(segment.amount) + ' 积分\n到期时间 ' + + pkExpiryDateTime(segment.expiresAt) + '(' + pkExpiryText(segment.expiresAt) + ')\n' + + segment.accountName; + return ''; + }).join(''); + return '
' + esc(row.days === 0 ? '已到期' : row.days + ' 天') + + '
' + nodes + '
' + esc(fmtTok(row.credits)) + + '
'; + }).join(''); + const foot = summary.accountCount + ' 个账号' + + (summary.unavailable ? ' · ' + summary.unavailable + ' 个未获取余额' : ''); + const legend = (list || []).filter(a => + a && !a.error && a.uid && pkAccountSegments(a, now).some(s => s.days != null) + ).map(a => '' + esc(a.nickname || String(a.uid).slice(0, 8)) + '').join(''); + const hdr = '
剩余天数各账号该批剩余剩余积分
'; + $('pkExpiry').innerHTML = (rows + ? hdr + '
' + rows + '
' + : '
暂无可汇总积分
') + + (legend ? '
' + legend + '
' : '') + + '
' + esc(foot) + '
'; +} + +function renderPackages(d, detailLimit) { + const list = (d.accounts || []); + const now = Date.now(); + const expiryColors = pkAccountColorMap(list); + renderExpiryDistribution(list, now); + if (!list.length) { + $('pkSummary').innerHTML = '
没有账号
'; + return; + } + + // 包名 → 稳定色号(跨账号一致,方便肉眼对齐) + const names = []; + for (const a of list) for (const s of pkBySource(a.packages || [])) { + if (!names.includes(s.key)) names.push(s.key); + } + names.sort((x, y) => { + const sz = n => Math.max(...list.map(a => { + const f = pkBySource(a.packages || []).find(s => s.key === n); + return f ? f.size : 0; + })); + return sz(y) - sz(x); + }); + const colorOf = n => pkColor(names.indexOf(n)); + // 键 → 展示名,供卡片与明细表共用(同一来源必然同色同名)。 + const labelOf = {}; + for (const a of list) for (const s of pkBySource(a.packages || [])) labelOf[s.key] = s; + + const maxRemain = Math.max(1, ...list.map(a => Number(a.remain || 0))); + + $('pkSummary').innerHTML = list.map(a => { + if (a.error) { + return '
' + + esc((a.nickname || a.uid.slice(0, 8))) + '' + + '' + esc(a.realm || '') + '
' + + '
查询失败:' + esc(a.error) + '
'; + } + const srcs = pkBySource(a.packages || []); + const total = Math.max(1, Number(a.size || 0)); + const bar = srcs.map(s => + '' + ).join(''); + const legend = srcs.map(s => + '' + + esc(s.name.replace(/^CodeBuddy/, '')) + ' x' + s.n + ' · ' + fmtTok(s.size) + + (s.minCreated ? ' · 首发 ' + esc(s.minCreated.slice(5)) : '') + '' + ).join(''); + const expiry = pkAccountSegments(a, now); + const expiryTotal = Math.max(1, expiry.reduce((sum, s) => sum + s.amount, 0)); + const expiryColor = expiryColors.get(String(a.uid)) || 'var(--accent)'; + const expiryBar = expiry.length ? '' : ''; + return '
' + + '
' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(a.realm || '') + '
' + + '
' + fmtTok(a.remain) + '
' + + '
共 ' + fmtTok(a.size) + ' · ' + (a.packages || []).length + + ' 个包 · 占最高 ' + (Number(a.remain || 0) / maxRemain * 100).toFixed(0) + '%
' + + '
' + bar + '
' + + expiryBar + + '
' + legend + '
' + + '
'; + }).join(''); + + $('pkNote').textContent = list.length + ' 个账号 · 实时查询上游'; + + // 逐包明细:每个账号一个表,排序规则由视图顶部的选择器决定(默认到期近的在前) + $('pkDetail').innerHTML = list.map(a => { + if (a.error) return ''; + const groups = pkDetailGroups(a.packages || [], detailLimit); + const rowOf = (p, rowGroup) => { + const k = (p.package_code || '') + '|' + (p.name || '(未命名)'); + const sub = (p.sub_product_code || '').replace(/^sp_tcaca_codebuddyide_?/, '') || + (p.package_code || '').replace(/^TCACA_/, ''); + return '' + + '' + esc(p.name || '(未命名)') + + (sub ? '
' + esc(sub) + '
' : '') + '' + + '' + fmtTok(p.size) + '' + + '' + fmtTok(p.remain) + '' + + '' + fmtTok(p.used) + '' + + '' + esc((p.created_at || '').slice(0, 16).replace('T', ' ') || '—') + '' + + '' + esc((p.end_time || '').slice(0, 10) || '—') + '' + + ''; + }; + const groupSummary = (group, label, count, size, remain) => + ''; + const rows = groups.visible.map(p => rowOf(p, '')).join(''); + const restSummary = groups.rest.length + ? groupSummary('rest', '其余未用完 ' + groups.rest.length + ' 个包(面额合计 ' + + fmtTok(groups.restSize) + ' · 剩余 ' + fmtTok(groups.restRemain) + ')', + groups.rest.length, groups.restSize, groups.restRemain) + + groups.rest.map(p => rowOf(p, 'rest')).join('') + : ''; + const usedSummary = groups.used.length + ? groupSummary('used', '已用完 ' + groups.used.length + ' 个包(面额合计 ' + + fmtTok(groups.usedSize) + ')', groups.used.length, groups.usedSize, 0) + + groups.used.map(p => rowOf(p, 'used')).join('') + : ''; + return '

' + + esc(a.nickname || a.uid.slice(0, 8)) + ' · ' + esc(a.realm || '') + + '

余额 ' + fmtTok(a.remain) + + ' / 总额 ' + fmtTok(a.size) + ' · 可用 ' + (groups.visible.length + groups.rest.length) + ' 个包' + + (groups.used.length ? ' / 已用完 ' + groups.used.length + ' 个' : '') + + ' · 默认展示最早到期 ' + pkDetailLimitValue(detailLimit) + ' 条(' + + esc(PK_SORT_LABELS[pkSortMode] || '') + ')' + + '
' + + '' + + '' + + '' + + '' + rows + restSummary + usedSummary + '
包名 / 来源面额剩余已用发放到期
'; + }).join(''); +} + +if ($('pkDetail')) $('pkDetail').addEventListener('click', ev => { + const btn = ev.target.closest('button[data-pk-group]'); + if (!btn) return; + const body = btn.closest('tbody'); + if (!body) return; + const group = btn.dataset.pkGroup; + const expanded = btn.getAttribute('aria-expanded') === 'true'; + body.querySelectorAll('tr[data-pk-row="' + group + '"]').forEach(row => { row.hidden = expanded; }); + const count = btn.dataset.count || '0'; + const size = btn.dataset.size || '0'; + const remain = btn.dataset.remain || '0'; + btn.setAttribute('aria-expanded', String(!expanded)); + if (group === 'rest') { + btn.textContent = expanded + ? '其余未用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + ' · 剩余 ' + + fmtTok(remain) + '),展开' + : '收起其余未用完 ' + count + ' 个包'; + } else { + btn.textContent = expanded + ? '已用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + '),展开' + : '收起已用完 ' + count + ' 个包'; + } +}); + +/* ── 逐包明细排序模式 ─────────────────────────────────────────────── */ +/* 逐包明细的排序规则(选择持久化在 localStorage,跨会话记住): + end_asc 到期升序(默认)——快过期的包排最前,提醒优先消耗;无到期时间的 + 包(上游没下发 end_time)没有可比的日期,统一垫底,不掺进日期序里; + size_desc 面额降序——原来的展示口径,看「钱从哪来」。 + 行序统一由 pkDetailCompare 实现(明细折叠分组共用同一比较器,切换模式时 + 折叠组内行序同步跟随)。本块整体放在 renderPackages / renderExpiryDistribution + 之后:前端 harness 按区域切片求值([PK_ACCOUNT_COLORS, renderExpiryDistribution) + 与 [PK_DEFAULT_DETAIL_LIMIT, renderPackages)),顶层 localStorage/$ 语句落进 + 切片区会让无 DOM 桩的求值环境 ReferenceError——上游测试的切片边界不动。 */ +const LS_PK_SORT = 'pkSortMode'; +let pkSortMode = localStorage.getItem(LS_PK_SORT) || 'end_asc'; +const PK_SORT_LABELS = { end_asc: '按到期升序 · 近的在前', size_desc: '按面额降序' }; +// 排序切换时重排明细需要 detailLimit(上游折叠配置),loadPackages 拉到后缓存。 +let lastDetailLimit = PK_DEFAULT_DETAIL_LIMIT; + +// 排序规则控件:恢复上次选择并绑定切换。 +if ($('pkSort')) { + $('pkSort').value = pkSortMode; + if ($('pkSort').value !== pkSortMode) { // localStorage 里存了废弃值:回落默认 + pkSortMode = 'end_asc'; + localStorage.setItem(LS_PK_SORT, pkSortMode); // 不用 removeItem:harness 桩无此方法 + } + $('pkSort').onchange = () => { + pkSortMode = $('pkSort').value; + localStorage.setItem(LS_PK_SORT, pkSortMode); + if (lastPackages) renderPackages(lastPackages, lastDetailLimit); // 数据在内存,直接重排 + }; +} + +async function loadPackages() { + $('pkSummary').innerHTML = '
查询中…(逐账号向上游实时查询)
'; + $('pkDetail').innerHTML = ''; + $('pkExpiry').innerHTML = '
查询中…
'; + try { + const [d, c] = await Promise.all([ + api('packages'), + api('config').catch(() => null), + ]); + lastPackages = d; // 缓存供首页到期卡片与排序切换复用 + lastPackagesAt = Date.now(); + lastDetailLimit = pkDetailLimit(c && c.config); + renderPackages(d, lastDetailLimit); + } catch (e) { + $('pkSummary').innerHTML = '
读取失败:' + esc(e.message) + '
'; + $('pkExpiry').innerHTML = '
读取失败:' + esc(e.message) + '
'; + } +} + +/* ── 积分到期提醒(首页卡片)────────────────────────────────────────── */ +/* 积分不是永久的:签到/任务发的裂变包约一个月失效。只看「剩余积分 ÷ 日消耗」 + 会系统性偏乐观——用不完的部分到期直接蒸发。这里把「最近要过期的是哪批、 + 有多少、到期前每天要至少消耗多少」顶到首页,数据源与「积分构成」共用 + (lastPackages 缓存,EXP_FRESH_MS 内复用,不重复打上游)。 */ + +// expBatches 把某账号的包聚合成「到期日 → 该日作废积分」升序列表。 +// 只统计 remain>0 且有到期时间的包——没余额/长期包到期没有任何影响。 +function expBatches(packs) { + const byDay = new Map(); + for (const p of packs || []) { + const r = Number(p.remain || 0); + const t = (p.end_time || '').slice(0, 10); + if (r <= 0 || !t) continue; + byDay.set(t, (byDay.get(t) || 0) + r); + } + return [...byDay.entries()] + .map(([date, remain]) => ({ date, remain })) + .sort((a, b) => (a.date < b.date ? -1 : 1)); +} + +function expDaysLeft(dateStr, today) { + return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000); +} + +function renderExpiry(d) { + const list = (d.accounts || []); + const today = new Date(); today.setHours(0, 0, 0, 0); + const rows = list.map(a => { + if (a.error) { + return '
' + + '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '查询失败:' + esc(a.error) + '
'; + } + const bs = expBatches(a.packages).filter(b => expDaysLeft(b.date, today) >= 0); + if (!bs.length) { + return '
' + + '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '7 天内无到期积分
'; + } + const first = bs[0]; + const days = expDaysLeft(first.date, today); + const daily = Math.ceil(first.remain / Math.max(1, days)); + const week = bs.filter(b => expDaysLeft(b.date, today) <= 7) + .reduce((s, b) => s + b.remain, 0); + // 危险度:≤3 天红(不抓紧就真没了)、≤7 天琥珀、更远绿。 + // 上游扣包是 FEFO(按失效时刻升序,实测两号口径一致):这些快过期批次正是 + // 被消耗得最快的,日均需耗给的是「哪怕单靠这个账号的自然流量也能对齐」的参照。 + const cls = days <= 3 ? 'var(--bad)' : days <= 7 ? 'var(--warn)' : 'var(--ok)'; + const dayWord = days === 0 ? '今天到期' : days === 1 ? '明天到期' : days + ' 天后到期'; + const more = bs.length > 4 ? ' 等 ' + bs.length + ' 批' : ''; + const rest = bs.slice(1, 4).map(b => + '随后 ' + esc(b.date.slice(5)) + ' · ' + fmtTok(b.remain)).join(' ') + more; + return '
' + + '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '最近到期 ' + esc(first.date) + '(' + dayWord + + ')· 该批 ' + fmtTok(first.remain) + ' 积分 · 到期前日均需耗 ≥' + + fmtTok(daily) + '' + + (week > first.remain ? ' · 7 天内合计 ' + fmtTok(week) : '') + + (rest ? '
' + rest + '
' : '') + + '
'; + }).join(''); + $('expList').innerHTML = rows || '
没有账号
'; + // 数据新鲜度透明化:走缓存时标注年龄,免得把旧数据误当实时。 + const ageMin = lastPackages ? Math.floor((Date.now() - lastPackagesAt) / 60000) : 0; + $('expNote').textContent = (lastPackagesAt && ageMin > 0) + ? list.length + ' 个账号 · ' + ageMin + ' 分钟前的数据,可点「检查」刷新' + : list.length + ' 个账号 · 实时查询上游'; + $('expBox').hidden = false; +} + +async function loadExpiry(force) { + if (!$('expBox')) return; + if (expFetching) return; + const fresh = lastPackages && (Date.now() - lastPackagesAt) < EXP_FRESH_MS; + if (fresh && !force) { renderExpiry(lastPackages); return; } + expFetching = true; + $('expBox').hidden = false; + if (!$('expList').children.length) $('expList').innerHTML = '
查询中…(逐账号向上游实时查询)
'; + $('expNote').textContent = '查询中…'; + try { + const d = await api('packages'); + lastPackages = d; // 与「积分构成」视图共用同一份缓存 + lastPackagesAt = Date.now(); + renderExpiry(d); + } catch (e) { + $('expNote').textContent = '查询失败:' + esc(e.message); + } + expFetching = false; +} + +if ($('btnExp')) $('btnExp').onclick = () => loadExpiry(true); + +if ($('btnPk')) $('btnPk').onclick = loadPackages; diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index bfe1a2f80cab2d1521ae32578f0f368c8a80713d..bfcd984f4265e8fa501d7c24fa370367feba37bf 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -1,1265 +1,1265 @@ -// autotask.go 面板「一键完成」任务的动作实现。 -// -// 设计依据:上游 scripts/task_*.py 实测结论 + 2026-09-12 桌面指纹协议逆向 -// (data/desktop-task-protocol.md)—— -// - first_buddy(+300 分):report(前置解锁)→ agreement → buddy/first -// - chat_5(+100 分):累计 5 条 chat_request_send 上报 -// - Model_chat_GLM5.2(+100 分):accept → 用 glm-5.2 真实对话一次 → 上报(模型字段对齐) -// - RichMeow_Chat:桌面指纹(workbuddy-desktop)完整对话事件链,纯 API 可点亮(三账号实测) -// - Buddy_App / Buddy_App_QQ:buddyapp 五连事件,纯 API 可点亮(两账号实测) -// - automation_1:automated_task_create_suc 事件,纯 API 可点亮(两账号实测) -// - Library_read:web 域 web_element_click(library_doc_intro_click)(三账号实测) -// - template_5 / playbook_prompt / create_canvas:asar 逆向出的判据事件 -// (template_used / playbook_prompt_send / wbx_design_canvas_*),纯 API 可点亮(三账号实测) -// - expert_5 / Expert_team_use_3:真实专家列表 + 召唤链 + 真实 chat(服务端 requestId) -// - expert_actual_use(三账号实测) -// - Hp_Appearance:appearance/set + appearance_skin_apply 事件(两账号实测) -// -// 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。 -// 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。 -// -// 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。 -package panel - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "math/rand/v2" - "net/http" - "strings" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" - "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" -) - -// autoAction 一个可自动化的任务动作。 -type autoAction struct { - TaskCode string // 目标任务 code - Desc string // 展示用说明 - Attempt bool // true = 尝试型(上游未证实可脚本化,跑了可能不点亮) - run func(p *Panel, a *auth.Auth) (string, error) -} - -// autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。 -// first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。 -var autoActions = []autoAction{ - { - TaskCode: "chat_5", - Desc: "上报 5 条对话活跃事件(自动补足差额)", - run: runChat5, - }, - { - TaskCode: "first_buddy", - Desc: "上报解锁 → 同意协议 → 领取第一只 Buddy(+300 分)", - run: runFirstBuddy, - }, - { - TaskCode: "Model_chat_GLM5.2", - Desc: "接受任务 → glm-5.2 真实对话一次 → 对齐模型上报", - run: runModelChat, - }, - { - TaskCode: "RichMeow_Chat", - Desc: "桌面指纹事件链上报(已验证:纯 API 可点亮,三账号实测)", - run: runRichMeow, - }, - { - TaskCode: "Buddy_App", - Desc: "上报「进入 Buddy 应用」事件链(已验证:纯 API 可点亮)", - run: runBuddyApp, - }, - { - TaskCode: "Buddy_App_QQ", - Desc: "上报「进入企鹅教师助手」事件链(已验证:纯 API 可点亮)", - run: runBuddyApp, - }, - { - TaskCode: "automation_1", - Desc: "上报「定时任务创建」事件(已验证:纯 API 可点亮)", - run: runAutomationCreate, - }, - { - TaskCode: "Library_read", - Desc: "上报「读资料库介绍」事件(已验证:纯 API 可点亮)", - run: runLibraryRead, - }, - { - TaskCode: "template_5", - Desc: "上报「使用模板创建任务」事件组 ×5(已验证:三账号点亮)", - run: runTemplateUse, - }, - { - TaskCode: "playbook_prompt", - Desc: "上报「灵感案例做同款发送 Prompt」事件组(已验证:三账号点亮)", - run: runPlaybookPrompt, - }, - { - TaskCode: "create_canvas", - Desc: "上报「设计创意画布创建」事件组(已验证:三账号点亮,+300 分)", - run: runCreateCanvas, - }, - { - TaskCode: "expert_5", - Desc: "真实专家召唤+使用链 ×5(专家市场列表+真实 chat,已验证:三账号点亮)", - run: runExpertUse, - }, - { - TaskCode: "Expert_team_use_3", - Desc: "真实专家团召唤+使用链 ×3(已验证:三账号点亮)", - run: runExpertTeamUse, - }, - { - TaskCode: "Hp_Appearance", - Desc: "设置主题 API + 皮肤生效事件(已验证:两账号点亮)", - run: runAppearance, - }, - { - TaskCode: "skill_1", - Desc: "真实对话 + skill_info 技能加载事件(已验证:人杰2 点亮)", - run: runSkillFresh, - }, - { - TaskCode: "Expert_lighthouse", - Desc: "真实轻量云专家召唤+使用链(chat 链带 has_expert,已验证:两账号点亮)", - run: runExpertLighthouse, - }, - { - TaskCode: "black_cat", - Desc: "夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示稍后再试)", - Attempt: true, - run: runBlackCat, - }, - { - TaskCode: "school_season", - Desc: "校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)", - run: runSchoolSeason, - }, - { - TaskCode: "Sequential_Tasks_1", - Desc: "小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)", - run: runSequentialChat, - }, - { - TaskCode: "Sequential_Tasks_2", - Desc: "小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)", - run: runMiniExpert, - }, - { - TaskCode: "Sequential_Tasks_3", - Desc: "小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)", - run: runSequentialChat5, - }, - { - TaskCode: "Sequential_Tasks_4", - Desc: "小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件(PC 同源)→ 领奖(判据待解锁验证)", - run: runSequentialAutomation, - }, - { - TaskCode: "Sequential_Tasks_5", - Desc: "小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)", - run: runSequentialModelChat, - }, - { - TaskCode: "Sequential_Tasks_6", - Desc: "小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖", - run: runSequentialChat10, - }, - { - TaskCode: "Sequential_Tasks_7", - Desc: "体验灵感功能(预留,疑 PC 口径 +500c+5e):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)", - run: runSequentialPlaybook, - }, -} - -// autoActionFor 查任务对应的动作;无则返回 nil(不可自动化)。 -func autoActionFor(code string) *autoAction { - for i := range autoActions { - if autoActions[i].TaskCode == strings.TrimSpace(code) { - return &autoActions[i] - } - } - return nil -} - -// autoActionIndex 任务在 autoActions 中的顺序(队列执行按依赖序排;未知返回大值)。 -func autoActionIndex(code string) int { - for i := range autoActions { - if autoActions[i].TaskCode == code { - return i - } - } - return 1 << 20 -} - -// mpTaskCodes 小程序口径专属下发的成长任务:默认(无 mp 头)列表不出现, -// accept/claim 均要求 X-Client-Platform: miniprogram。新任务出现时在此登记。 -var mpTaskCodes = map[string]bool{ - "school_season": true, // 校园日(mini chat + activityId) - "Sequential_Tasks_1": true, // 小程序首对话(mini chat,无 activityId) - "Sequential_Tasks_2": true, // 小程序选中专家并完成有效对话(mp 指纹 expert_actual_use) - "Sequential_Tasks_3": true, // 小程序完成 5 次对话(与 Tasks_1 同形状,target=5 逐条累加) - // Tasks_4..7 存在性已实测(accept 返回 prerequisite not met 链式依赖;Tasks_8 not - // found 封顶)。链条每日零点解锁一环(task locked until 次日),判据为 issue #42 - // 描述 + mpsrc 事件形状预置,解锁后逐个实测校正。 - "Sequential_Tasks_4": true, - "Sequential_Tasks_5": true, - "Sequential_Tasks_6": true, - "Sequential_Tasks_7": true, -} - -// isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。 -func isMPTaskCode(code string) bool { return mpTaskCodes[code] } - -// taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。 -// 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码, -// 未知码不多打一次上游)。 -func (p *Panel) taskByCode(a *auth.Auth, code string) (*upstream.Task, error) { - tasks, err := p.cfg.Upstream.ListTasks(a) - if err != nil { - return nil, err - } - for i := range tasks { - if tasks[i].TaskCode == code { - return &tasks[i], nil - } - } - if isMPTaskCode(code) { - return p.taskByCodeMP(a, code) - } - return nil, nil -} - -// claimPollAttempts / claimPollGap 达标回读的有界轮询参数。 -// 背景:上游计分是**异步**的——行为事件上报后进度要数秒才刷新(实测 Model_chat -// 对话完成后立即回读仍是 0/1,约 5-8 秒后才变 1/1)。一次性回读会误判"未达标", -// 从而跳过自动领奖。这里最多轮询 N 次、每次间隔 gap,总预算约 12 秒。 -var ( - claimPollAttempts = 4 - claimPollGap = 3 * time.Second -) - -// taskByCodeWaiting 回读任务,若未达标则在有界预算内轮询等待(上游异步计分)。 -// 已达标(claimable)立即返回;预算耗尽返回最后一次结果(可能仍未达标)。 -func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, error) { - t, err := p.taskByCode(a, code) - if err != nil || t == nil { - return t, err - } - if t.Claimable || t.Claimed { - return t, nil - } - for i := 1; i < claimPollAttempts; i++ { - time.Sleep(claimPollGap) - t2, err2 := p.taskByCode(a, code) - if err2 != nil { - return t, nil // 轮询期间的查询失败不覆盖已拿到的结果 - } - if t2 != nil { - t = t2 - if t.Claimable || t.Claimed { - return t, nil - } - } - } - return t, nil -} - -// taskByCodeMP 以小程序口径拉取任务列表并定位单个任务;未找到返回 nil。 -// 小程序限定任务(school_season / Sequential_Tasks_1)在默认口径列表不出现。 -func (p *Panel) taskByCodeMP(a *auth.Auth, code string) (*upstream.Task, error) { - tasks, err := p.cfg.Upstream.ListTasksMP(a) - if err != nil { - return nil, err - } - for i := range tasks { - if tasks[i].TaskCode == code { - return &tasks[i], nil - } - } - return nil, nil -} - -// acceptWithVerifyMP accept 并回读验证登记生效:上游存在 200+OK 但 accept 未真正 -// 登记的形态(此时上报事件全部不归账,任务永远点不亮,上游 task_runner c793ae3 -// 实测)——判定以回读 accept_status 为准,未生效重试一次。 -func (p *Panel) acceptWithVerifyMP(a *auth.Auth, code string) bool { - for attempt := 1; attempt <= 2; attempt++ { - if err := p.cfg.Upstream.AcceptTasksMP(a, []string{code}); err != nil { - log.Printf("autotask %s %s: accept 尝试%d: %v", logfmt.Label(a.UID, a.Nickname), code, attempt, err) - continue - } - time.Sleep(mpActionGap) - t, err := p.taskByCodeMP(a, code) - if err == nil && t != nil && t.AcceptStatus != "not_accepted" && t.AcceptStatus != "" { - return true - } - log.Printf("autotask %s %s: accept 尝试%d 未登记生效(回读=%q)", logfmt.Label(a.UID, a.Nickname), code, attempt, acceptStatusOr(t)) - } - return false -} - -// acceptStatusOr 安全读取任务 accept_status(nil 任务返回 "?")。 -func acceptStatusOr(t *upstream.Task) string { - if t == nil { - return "?" - } - if t.AcceptStatus == "" { - return "?" - } - return t.AcceptStatus -} - -// mpActionGap mp 任务写动作间隔(accept/上报/领奖之间,防频控)。 -var mpActionGap = 2 * time.Second - -// mpChatEventGap mp 对话事件(chat_request_send)的真人节奏间隔。上游对 -// Sequential_Tasks_3「5 次有效对话」有反作弊校验:数秒级连发的事件会先被计入 -// 进度(回读 5/5、accept_status 甚至短暂转 completed),随后被判定无效整体回滚 -// (进度回落、claim 返回 400 "task not completed")——2026-09-26 实测 2s 连发 -// 4 条全灭,45s 间隔逐条上报全存活且 claim +300c+5e 成功。每条上报前 -// sleep gap + 0~10s 抖动;首条也等(上一轮残留进度被回滚后立即重报同样无效)。 -var mpChatEventGap = 45 * time.Second - -// runMPMiniChatTask growth 域小程序限定任务通用闭环: -// mp 查询 → accept(带登记回读验证)→ mini chat 事件上报(withActivityId 决定 -// 是否带开学季 activityId:school_season 必带,Sequential_Tasks_1 不带——服务端按 -// source=mini_program 指纹关联)→ 回读 → 达标即领奖。 -func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool) (string, error) { - t, err := p.taskByCodeMP(a, code) - if err != nil { - return "", err - } - if t == nil { - return "mp 口径未下发该任务(活动可能已结束)", nil - } - if t.Claimed { - return "已领取", nil - } - if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { - if !p.acceptWithVerifyMP(a, code) { - return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil - } - // accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报—— - // Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖 - // (claim 400 task not completed)。接受后回读一次拿真实 target/current。 - if t2, err := p.taskByCodeMP(a, code); err == nil && t2 != nil { - t = t2 - } - } - // 已达标(含 completed 未领):直接领奖。 - target := t.Target - if target <= 0 { - target = 1 - } - if t.Current >= target || t.AcceptStatus == "completed" { - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil - } - // 判据上报:按差额补 mini chat 事件。每条前 sleep mpChatEventGap+抖动—— - // 连发会被上游反作弊判无效(见 mpChatEventGap 注释),宁可慢不可白报。 - need := target - t.Current - for i := int64(0); i < need; i++ { - time.Sleep(mpChatEventGap + time.Duration(rand.Int64N(int64(10*time.Second)))) - conv := fmt.Sprintf("wb2api-mp-%d-%d", time.Now().UnixMilli(), i) - var ev map[string]any - if withActivityId { - ev = upstream.SchoolSeasonChatEvent(conv) - } else { - ev = upstream.SchoolChatTimesEvents(conv) - } - if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { - return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil - } - } - // 回读(异步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。 - for i := 0; i < 2; i++ { - time.Sleep(claimPollGap) - t2, err2 := p.taskByCodeMP(a, code) - if err2 != nil || t2 == nil { - continue - } - t = t2 - if t.Claimable || t.Claimed || t.Current >= target { - break - } - } - if t.Claimed { - return "本轮已入账(claimed)", nil - } - if t.Current < target { - return fmt.Sprintf("已上报 %d 次但进度未达 %d/%d(异步计分未归账,下次重试)", need, t.Current, target), nil - } - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil -} - -// runSchoolSeason 完成 school_season「校园日」(growth 域小程序限定)。 -// 判据 = mini chat_request_send + activityId=school_open_day_2026(无 activityId -// 不点亮,与 school 域开学季同活动关联;上游 task_runner e2e 实测 +100c+5e)。 -func runSchoolSeason(p *Panel, a *auth.Auth) (string, error) { - return p.runMPMiniChatTask(a, "school_season", true) -} - -// runSequentialChat 完成 Sequential_Tasks_1「小程序内完成 1 次有效对话」。 -// 判据 = mini chat_request_send(无 activityId,服务端按 source=mini_program -// 指纹关联;上游 task_runner 实测 +100c+5e)。 -func runSequentialChat(p *Panel, a *auth.Auth) (string, error) { - return p.runMPMiniChatTask(a, "Sequential_Tasks_1", false) -} - -// runSequentialChat5 完成 Sequential_Tasks_3「在小程序内完成 5 次有效对话」。 -// 判据与 Sequential_Tasks_1 同形状(mini 指纹 chat_request_send,无 activityId), -// 仅 target=5——服务端按上报条数累加进度,但**要求真人节奏**:连发事件先计数 -// 后被反作弊回滚(claim 400 "task not completed"),由 mpChatEventGap 间隔保证 -// (2026-09-26 实测:45s 间隔补满 5/5 → claim +300c+5e 成功,领后 accept_status -// =claimed 稳定不回滚)。 -func runSequentialChat5(p *Panel, a *auth.Auth) (string, error) { - return p.runMPMiniChatTask(a, "Sequential_Tasks_3", false) -} - -// runSequentialChat10 完成 Sequential_Tasks_6「在小程序内完成 10 次有效对话」(预留)。 -// 判据假定与 Tasks_1/3 同形状(mini chat_request_send),target 由任务自带(回读), -// runMPMiniChatTask 按差额补报——issue #42 称 target=10,以解锁后实际下发为准。 -// 真人节奏间隔同样适用(mpChatEventGap):9 条 × ~50s ≈ 8 分钟/账号,夜间队列可接受。 -func runSequentialChat10(p *Panel, a *auth.Auth) (string, error) { - return p.runMPMiniChatTask(a, "Sequential_Tasks_6", false) -} - -// runSequentialEventTask Sequential 链预留任务通用骨架:mp 查询 → accept(带验证) -// → 判据事件上报(primary;未点亮且 fallback 非空时补一轮)→ 回读 → 达标领奖。 -// 每日零点解锁一环:locked 期间 accept 不落账,返回等下次调度(无需人工干预)。 -func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallback func() error) (string, error) { - t, err := p.taskByCodeMP(a, code) - if err != nil { - return "", err - } - if t == nil { - return "mp 口径未下发该任务(前置任务未完成或活动未开始)", nil - } - if t.Claimed { - return "已领取", nil - } - target := t.Target - if target <= 0 { - target = 1 - } - if t.Current >= target || t.AcceptStatus == "completed" { - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil - } - if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { - if !p.acceptWithVerifyMP(a, code) { - return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil - } - } - if err := primary(); err != nil { - return fmt.Sprintf("判据上报失败: %v", err), nil - } - // 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。 - for round := 0; round < 2; round++ { - time.Sleep(claimPollGap) - t2, err2 := p.taskByCodeMP(a, code) - if err2 != nil || t2 == nil { - continue - } - t = t2 - if t.Claimable || t.Claimed || t.Current >= target { - break - } - if round == 0 && fallback != nil { - if err := fallback(); err != nil { - return fmt.Sprintf("备选判据上报失败: %v", err), nil - } - } - } - if t.Claimed { - return "本轮已入账(claimed)", nil - } - if t.Current < target { - return "已上报但进度未点亮(判据形态待解锁后校正,下次重试)", nil - } - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil -} - -// runSequentialAutomation 完成 Sequential_Tasks_4「创建定时任务」(预留)。 -// mp 源码无 automation 事件发射点 → 判据疑为 PC 口径:复用 automation_1 同源 -// 事件(DesktopAutomationCreateEvent,PC 任务三账号实测点亮)。 -func runSequentialAutomation(p *Panel, a *auth.Auth) (string, error) { - return p.runSequentialEventTask(a, "Sequential_Tasks_4", - func() error { - return p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopAutomationCreateEvent("wb2api 自动化")) - }, nil) -} - -// runSequentialModelChat 完成 Sequential_Tasks_5「使用 GLM5.2」(预留)。 -// primary:mp 对话事件带 requestModelId/requestModelName=glm-5.2(mpsrc main 32904 -// 发射点实测形状);fallback:PC 域模型活跃上报(Model_chat_GLM5.2 同源)。 -func runSequentialModelChat(p *Panel, a *auth.Auth) (string, error) { - return p.runSequentialEventTask(a, "Sequential_Tasks_5", - func() error { - conv := fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()) - return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniChatModelEvent(conv, "glm-5.2", "GLM-5.2")) - }, - func() error { - return p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()), "", "glm-5.2", "GLM-5.2") - }) -} - -// runSequentialPlaybook 完成 Sequential_Tasks_7「体验灵感功能」(预留,疑 PC 口径)。 -// primary:PC 灵感事件组(DesktopPlaybookPromptSequence,playbook_prompt 三账号 -// 实测点亮);fallback:mp 指纹灵感事件组(MiniPlaybookEvents,mpsrc 形状)。 -func runSequentialPlaybook(p *Panel, a *auth.Auth) (string, error) { - ms := time.Now().UnixMilli() - return p.runSequentialEventTask(a, "Sequential_Tasks_7", - func() error { - conv := fmt.Sprintf("wb2api-pb-%d", ms) - req := fmt.Sprintf("wb2api-pb-req-%d", ms) - return p.cfg.Upstream.ReportDesktopEvent(a, - upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) - }, - func() error { - return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniPlaybookEvents("pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) - }) -} - -// runMiniExpert 完成 Sequential_Tasks_2「在小程序内选中专家并完成有效对话」。 -// 判据 = mp 指纹 expert_actual_use(**不带** activityId/conversationId、 -// extVersion=2.2.8、type=send_message——小程序源码实测形状,与 school 域 expert -// 事件两套口径勿混;上游 task_runner 实测上报即 completed,claim +200c+5e)。 -// 专家 id 必须是市场真实 ex_ id(空 id 服务端不入账)→ **accept 之前**先解析市场 -// 列表:拉不到就整任务不动作,避免留下「已登记未上报」的半程态(上游 9a26ae7 -// 的 ids 前置判定同款)。复用既有 MarketExpertList(expert_5 任务同源,实测可用)。 -func runMiniExpert(p *Panel, a *auth.Auth) (string, error) { - const code = "Sequential_Tasks_2" - t, err := p.taskByCodeMP(a, code) - if err != nil { - return "", err - } - if t == nil { - return "mp 口径未下发该任务(活动可能已结束)", nil - } - if t.Claimed { - return "已领取", nil - } - target := t.Target - if target <= 0 { - target = 1 // 未 accept 的 mp 任务 progress 为 null,target 兜底(上游实测) - } - // 已达标(含 completed 未领):直接领奖。 - if t.Current >= target || t.AcceptStatus == "completed" { - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil - } - // 判据载体前置(accept 之前):市场真实专家 id。 - experts, merr := p.cfg.Upstream.MarketExpertList(a, "") - if merr != nil || len(experts) == 0 { - return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil - } - e := experts[0] - name := e.DisplayNameZH - if name == "" { - name = e.ProfessionZH - } - if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { - if !p.acceptWithVerifyMP(a, code) { - return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil - } - } - ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType) - if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { - return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil - } - // 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。 - for i := 0; i < 2; i++ { - time.Sleep(claimPollGap) - t2, err2 := p.taskByCodeMP(a, code) - if err2 != nil || t2 == nil { - continue - } - t = t2 - if t.Claimable || t.Claimed || t.Current >= target { - break - } - } - if t.Claimed { - return "本轮已入账(claimed)", nil - } - if t.Current < target { - return "已上报但进度未归账(异步计分,下次重试)", nil - } - credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) - if err != nil { - return "", err - } - return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil -} - -// accountTaskAuto 一键完成单个任务:执行对应动作 → 回读进度 → 汇报结果。 -func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - a := p.accountByUID(w, uid) - if a == nil { - return - } - var body struct { - TaskCode string `json:"task_code"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TaskCode == "" { - writeErr(w, http.StatusBadRequest, "task_code required") - return - } - act := autoActionFor(body.TaskCode) - if act == nil { - writeErr(w, http.StatusNotImplemented, - "该任务需要客户端内交互(无对应接口),无法自动完成;请按任务说明在官方客户端操作") - return - } - // per-account 互斥:同账号的任务动作正在跑(单任务或全量)时直接 409, - // 不并发重跑(动作幂等但 expert/skill 系含真实对话,重跑浪费配额)。 - if !p.tryLockAccount(uid) { - writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") - return - } - defer p.unlockAccount(uid) - // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。 - // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。 - before, err := p.taskByCode(a, act.TaskCode) - if err != nil { - writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error()) - return - } - if before == nil { - writeErr(w, http.StatusNotFound, "该账号没有此任务") - return - } - isMP := isMPTaskCode(act.TaskCode) - if before.Claimed { - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"}) - return - } - msg, err := act.run(p, a) - if err != nil { - writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error()) - return - } - // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步), - // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。 - var after *upstream.Task - var aerr error - if isMP { - after, aerr = p.taskByCodeMP(a, act.TaskCode) - } else { - after, aerr = p.taskByCodeWaiting(a, act.TaskCode) - } - progressBefore, progressAfter := taskProgressText(before), "" - claimable := false - if aerr == nil && after != nil { - progressAfter = taskProgressText(after) - claimable = after.Claimable - } - resp := map[string]any{ - "ok": true, - "message": msg, - "progress_before": progressBefore, - "progress_after": progressAfter, - "claimable": claimable, - "attempt": act.Attempt, - "verify_supported": true, - } - // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。 - if claimable { - var credit, energy int64 - var cerr error - if isMP { - credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) - } else { - credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) - } - if cerr == nil { - resp["claimed"] = true - resp["credit"] = credit - resp["energy"] = energy - if credit > 0 || energy > 0 { - resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) - } else { - resp["message"] = msg + ";奖励此前已领取" - } - } else { - resp["claim_error"] = cerr.Error() - resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试" - } - } - log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v", - uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"]) - writeJSON(w, http.StatusOK, resp) -} - -// taskProgressText 任务进度的可读表示(回读对比用)。 -func taskProgressText(t *upstream.Task) string { - if t == nil { - return "?" - } - if t.Target > 0 { - return fmt.Sprintf("%d/%d", t.Current, t.Target) - } - if t.Claimed { - return "claimed" - } - return t.AcceptStatus -} - -// truncateStr 截断错误文本(避免把上游长响应原样透给前端)。 -func truncateStr(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -// --------------------------------------------------------------------------- -// 各任务动作实现 -// --------------------------------------------------------------------------- - -// reportGap 连续上报之间的间隔(对齐上游脚本实测的 1.05s 口径,避免风控)。 -var reportGap = 1050 * time.Millisecond - -// runChat5 补足 chat_5 的进度:按差额上报 chat_request_send。 -func runChat5(p *Panel, a *auth.Auth) (string, error) { - t, err := p.taskByCode(a, "chat_5") - if err != nil { - return "", err - } - if t == nil { - return "", fmt.Errorf("任务不存在") - } - target := t.Target - if target <= 0 { - target = 5 - } - need := target - t.Current - if need <= 0 { - return "进度已达标,无需上报", nil - } - for i := int64(0); i < need; i++ { - cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i) - if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil { - return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil - } - if i < need-1 { - time.Sleep(reportGap) - } - } - return fmt.Sprintf("已补报 %d 条对话事件", need), nil -} - -// runFirstBuddy 领养:report(解锁前置)→ agreement → first。 -func runFirstBuddy(p *Panel, a *auth.Auth) (string, error) { - if err := p.cfg.Upstream.ReportChatActivity(a, fmt.Sprintf("wb2api-adopt-%d", time.Now().UnixMilli()), ""); err != nil { - return "", fmt.Errorf("前置上报: %w", err) - } - time.Sleep(reportGap) // 给上游事件处理留时间(脚本实测口径) - if err := p.cfg.Upstream.BuddyAgreement(a); err != nil { - return "", fmt.Errorf("同意协议: %w", err) - } - if err := p.cfg.Upstream.BuddyFirst(a); err != nil { - if upstream.IsBuddyTaskIncomplete(err) { - return "前置已上报,但领养门槛未过(上游要求当日活跃),请稍后重试", nil - } - return "", fmt.Errorf("领取 Buddy: %w", err) - } - return "已领取 Buddy(+300 分 +8 能量)", nil -} - -// runModelChat 完成 Model_chat_GLM5.2:accept → 真实对话 → 对齐模型上报。 -func runModelChat(p *Panel, a *auth.Auth) (string, error) { - const code, modelID, modelName = "Model_chat_GLM5.2", "glm-5.2", "GLM-5.2" - // 1. accept(报名;失败不阻塞——行为事件才是判据) - if err := p.cfg.Upstream.AcceptTasks(a, []string{code}); err != nil { - log.Printf("panel: accept %s: %v(继续走行为链路)", code, err) - } - time.Sleep(reportGap) - // 2. 真实对话一次(判据的最直接证据) - body, _ := json.Marshal(map[string]any{ - "model": modelID, - "messages": []map[string]any{ - {"role": "user", "content": "hi,请回复一句话"}, - }, - "stream": true, - }) - rc, status, respBody, err := p.cfg.Upstream.ChatStream(a, body, "", upstream.ChatMeta{}) - if err != nil { - return "", fmt.Errorf("对话请求: %w", err) - } - if status >= 400 { - rc.Close() - return "", fmt.Errorf("对话失败 http=%d: %s", status, truncateStr(string(respBody), 160)) - } - // 读干 SSE(网关对上游强制 flow:不读完会残留连接) - _, _ = io.Copy(io.Discard, io.LimitReader(rc, 1<<20)) - rc.Close() - time.Sleep(reportGap) - // 3. 对齐模型的上报(触发进度) - if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil { - return "对话已完成,但进度上报失败:" + err.Error(), nil - } - return "已完成 glm-5.2 对话并上报", nil -} - -// runRichMeow 完成 RichMeow_Chat(桌面端对话1次)。 -// 2026-09-12 三账号实测验证:以桌面指纹(extName=workbuddy-desktop)向 -// copilot.tencent.com/v2/report 上报完整对话事件链(agent_task_created → -// chat_message_response isSuccessful=true 等 6 事件),纯 API 即可点亮并领奖 -// (紫川/人杰2 两账号无桌面客户端登录状态下 3 秒内 0/1 → 1/1)。 -// Hp_Appearance 的纯 API set 不计分(需客户端在主题下活跃),区别对待。 -func runRichMeow(p *Panel, a *auth.Auth) (string, error) { - ms := time.Now().UnixMilli() - conv := fmt.Sprintf("wb2api-rm-%d", ms) - req := fmt.Sprintf("wb2api-rm-req-%d", ms) - msg := fmt.Sprintf("req-%d-user", ms) - events := upstream.DesktopChatSequence(conv, req, msg, "fast-model", "fast-model") - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", err - } - return "已按桌面端指纹上报完整对话事件链(agent_task_created→chat_response)", nil -} - -// runBuddyApp 完成 Buddy_App / Buddy_App_QQ(进入 Buddy 应用)。 -// 2026-09-12 两账号实测:buddyapp 五连事件(discover→show→enter→auth_confirm→ -// bind_skip)以 workbuddy-desktop 指纹上报即点亮,服务端不校验真实授权。 -// 用企鹅教师助手(Buddy_App_QQ 判据应用)作载体,同一组事件同时满足 -// Buddy_App「进入任一应用」——两个表项共用本 run,幂等由任务状态跳过兜底。 -func runBuddyApp(p *Panel, a *auth.Auth) (string, error) { - events := upstream.DesktopBuddyAppSequence("cb_y5Dy46tPQGGWtueMxXbe", "企鹅教师助手") - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", err - } - return "已上报 buddyapp 进入五连事件(同时覆盖 Buddy_App 与 Buddy_App_QQ)", nil -} - -// runAutomationCreate 完成 automation_1(设置自动化任务)。 -// 2026-09-12 两账号实测:automated_task_create_suc 事件纯 API 上报即点亮, -// 无需真实创建定时任务。 -func runAutomationCreate(p *Panel, a *auth.Auth) (string, error) { - if err := p.cfg.Upstream.ReportDesktopEvent(a, - upstream.DesktopAutomationCreateEvent("wb2api 自动化")); err != nil { - return "", err - } - return "已上报定时任务创建事件", nil -} - -// runLibraryRead 完成 Library_read(体验资料库)。 -// 2026-09-12 三账号实测:web 域 /v2/report 上报 web_element_click -// (elementId=library_doc_intro_click) 即点亮(space 的 open/WS/inlong 都不是判据)。 -func runLibraryRead(p *Panel, a *auth.Auth) (string, error) { - const docURL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm" - if err := p.cfg.Upstream.ReportWebEvent(a, "web_element_click", docURL, - "library_doc_intro_click", "WorkBuddy资料库介绍"); err != nil { - return "", err - } - return "已上报资料库介绍阅读事件", nil -} - -// runBlackCat 完成 black_cat(夜猫子,夜间 23:00–08:00 计数)。 -// 判据 = 夜间窗口内 glm-5.2 真实对话 + chat 事件上报(WorkBuddy-Daily 实测口径)。 -// 窗口外不做(提示等排程);网关 blackcat_hours(默认 23 点)排程会自动补足。 -func runBlackCat(p *Panel, a *auth.Auth) (string, error) { - if !upstream.InNightWindow(time.Now()) { - return "当前不在 23:00–08:00 计数窗口,行为不计分;网关会在每日 23 点自动补足", nil - } - need, err := p.cfg.Upstream.BlackcatNeed(a) - if err != nil { - return "", err - } - if need <= 0 { - return "进度已达标,无需补足", nil - } - ok, err := p.cfg.Upstream.RunNightChats(a, int(need)) - if err != nil { - return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil - } - return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil -} - -// runSkillFresh 完成 skill_1(尝鲜热门技能)。 -// 2026-09-12 判据(紫川手动完成抓包 row 209):`skill_info` 事件(桌面指纹)—— -// {id:<技能名>, skillId, skillVersion, toolStatus:"success", fileCount, -// source:"workbuddy-desktop"} JOIN 真实会话(conversationId/requestId=服务端 -// id)。此前的 skill_request_send/skill_installed/skill_action 全是错误方向。 -// 人杰2 实测 0/1 → 1/1 点亮。 -func runSkillFresh(p *Panel, a *auth.Auth) (string, error) { - conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, "") - if err != nil { - return "", fmt.Errorf("真实对话: %w", err) - } - msgID := "msg-" + req[len(req)-8:] - events := upstream.DesktopChatSequence(conv, req, msgID, "fast-model", "fast-model") - for _, ev := range events { - if ev["eventCode"] == "chat_message_response" { - ev["finishReason"] = "tool_calls" // 模型发起工具调用(技能加载)语义 - } - } - events = append(events, upstream.DesktopEvent{ - "eventCode": "skill_info", - "id": "润泽小馆·日报撰写", - "skillId": "skill_2097350077599879168", - "skillVersion": "1.0.0", - "toolStatus": "success", - "fileCount": 56, - "source": "workbuddy-desktop", - "conversationId": conv, "requestId": req, "messageId": msgID, - "requestModelId": "fast-model", "requestModelName": "fast-model", - "traceId": req, - }) - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", fmt.Errorf("skill_info 事件: %w", err) - } - return "已上报真实对话 + skill_info 技能加载事件", nil -} - -// runExpertLighthouse 完成 Expert_lighthouse(体验「腾讯轻量云」专家)。 -// 2026-09-12 判据(真实样本 Sunny row 868):与 expert_5 同构,但两处差异—— -// chat 链的 agent_task_created 需带 has_expert:true + expert_id(我们默认 false), -// expert_actual_use 的 mode 为 "LOCAL"(非 craft)。id 固定为轻量云专家 -// ex_2cvvUZQhDyeJ;requestId 必须是真实 chat 的服务端 id。紫川/人杰2 实测点亮。 -func runExpertLighthouse(p *Panel, a *auth.Auth) (string, error) { - const lhID = "ex_2cvvUZQhDyeJ" - lh := upstream.MarketExpert{ - ExpertID: lhID, ExpertType: "agent", - DisplayNameZH: "腾讯轻量云专家", ProfessionZH: "腾讯轻量云专家", Version: "1.0.2", - } - // 市场列表若命中真实条目则用其信息(version 等以服务端为准)。 - if experts, err := p.cfg.Upstream.MarketExpertList(a, "agent"); err == nil { - for _, e := range experts { - if e.ExpertID == lhID { - lh = e - break - } - } - } - if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopExpertSummonSequence(lh)...); err != nil { - return "", fmt.Errorf("召唤链: %w", err) - } - conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, lhID) - if err != nil { - return "", fmt.Errorf("真实对话: %w", err) - } - events := upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model") - for _, ev := range events { - if ev["eventCode"] == "agent_task_created" { - ev["has_expert"] = true - ev["expert_id"] = lh.ExpertID - ev["expert_name"] = lh.DisplayNameZH - ev["expert_industry_id"] = "" - } - } - events = append(events, upstream.DesktopExpertActualUseLocal(lh, conv, req)) - // 对齐真实样本细节:轻量云专家 actual_use 的 type 为空、cost=0。 - events[len(events)-1]["type"] = "" - events[len(events)-1]["cost"] = 0 - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", fmt.Errorf("使用事件: %w", err) - } - return "已上报轻量云专家召唤+使用链(真实对话 requestId)", nil -} - -// runAppearance 完成 Hp_Appearance(换主题)。 -// 2026-09-12 紫川/人杰2 实测(desktopverify12):判据是 appearance_skin_apply -// 事件 {action:"apply",source:"settings_close",id:,...}(客户端在 -// 主题生效状态下离开设置页时上报)——早期"纯 API set 不计分"的结论不准确, -// 真相是当时只调了 appearance/set 没发事件。组合:set API 留痕 + 事件上报。 -func runAppearance(p *Panel, a *auth.Auth) (string, error) { - const themeKey = "theme-tkmw7j" // 和平精英激战金秋(Hp_Appearance 判据主题) - if err := p.cfg.Upstream.SetAppearanceTheme(a, themeKey); err != nil { - return "", fmt.Errorf("设置主题: %w", err) - } - time.Sleep(2 * time.Second) - if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopEvent{ - "eventCode": "appearance_skin_apply", "action": "apply", "source": "settings_close", - "id": themeKey, "vipLevel": 0, "series": "", "type": "unknown", - }); err != nil { - return "", err - } - return "已设置主题并上报皮肤生效事件", nil -} - -// runTemplateUse 完成 template_5(使用 5 个模板创建任务)。 -// 2026-09-12 三账号实测:agent_task_created_with_template + template_used 事件组 -// (JOIN chat 链)一次上报 5 组即 5/5 点亮。template_id 服务端不校验真实性。 -func runTemplateUse(p *Panel, a *auth.Auth) (string, error) { - templates := [][2]string{{"1", "深度研究"}, {"2", "周报生成"}, {"3", "竞品分析"}, {"4", "活动策划"}, {"5", "代码评审"}} - for i, tp := range templates { - ms := time.Now().UnixMilli() - conv := fmt.Sprintf("wb2api-tpl-%d-%d", ms, i) - req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i) - events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1]) - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil - } - time.Sleep(300 * time.Millisecond) - } - return "已上报 template_used ×5", nil -} - -// runPlaybookPrompt 完成 playbook_prompt(灵感案例 Dialog 中发送 Prompt)。 -// 判据是 playbook_prompt_send(Dialog 发送)而非卡片曝光/点击——asar 逆向确认。 -func runPlaybookPrompt(p *Panel, a *auth.Auth) (string, error) { - ms := time.Now().UnixMilli() - conv := fmt.Sprintf("wb2api-pb-%d", ms) - req := fmt.Sprintf("wb2api-pb-req-%d", ms) - events := upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸") - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", err - } - return "已上报 playbook_cta_click + playbook_prompt_send", nil -} - -// runCreateCanvas 完成 create_canvas(设计创意模式创建画布,+300 分)。 -// 判据是 wbx_design_canvas_task_create/open(Ardot create_design 工具完成遥测)。 -func runCreateCanvas(p *Panel, a *auth.Auth) (string, error) { - ms := time.Now().UnixMilli() - conv := fmt.Sprintf("wb2api-canvas-%d", ms) - req := fmt.Sprintf("wb2api-canvas-req-%d", ms) - events := upstream.DesktopDesignCanvasSequence(conv, req) - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - return "", err - } - return "已上报 wbx_design_canvas_task_create/open", nil -} - -// expertSummonGap 专家召唤链的间隔(真实使用节奏,v11 实测 8s 成功率 100%)。 -const expertSummonGap = 6 * time.Second - -// runExpertUse 完成 expert_5(使用 5 个平台专家)。 -// 2026-09-12 三账号实测公式:真实专家列表(id 必须真实存在)→ 召唤链 -// (summon_click/summoned)→ 真实 chat 拿服务端 requestId → expert_actual_use。 -// 自造专家 id 或自造 requestId 均不计数。 -func runExpertUse(p *Panel, a *auth.Auth) (string, error) { - return runExpertBatch(p, a, "agent", 5) -} - -// runExpertTeamUse 完成 Expert_team_use_3(使用 3 个专家团,expertType=team)。 -func runExpertTeamUse(p *Panel, a *auth.Auth) (string, error) { - return runExpertBatch(p, a, "team", 3) -} - -// runExpertBatch 专家召唤+使用的公共实现。失败逐个继续,返回汇总信息。 -func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (string, error) { - experts, err := p.cfg.Upstream.MarketExpertList(a, expertType) - if err != nil { - return "", fmt.Errorf("拉取专家列表: %w", err) - } - if len(experts) == 0 { - return "", fmt.Errorf("专家市场列表为空") - } - ok, fail := 0, 0 - for i, e := range experts { - if ok >= count { - break - } - // 召唤链(web_element_click + summon_click + summoned)。 - summonEvents := upstream.DesktopExpertSummonSequence(e) - if err := p.cfg.Upstream.ReportDesktopEvent(a, summonEvents...); err != nil { - fail++ - continue - } - // 真实 chat(带 X-Expert-Id)→ 服务端 requestId。 - conv, req, cerr := p.cfg.Upstream.DesktopChatWithExpert(a, e.ExpertID) - if cerr != nil { - fail++ - continue - } - // 使用事件(JOIN 服务端 requestId)+ chat 链。 - events := append(upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model"), - upstream.DesktopExpertActualUseEvent(e, conv, req)) - if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { - fail++ - continue - } - ok++ - if i < len(experts)-1 { - time.Sleep(expertSummonGap) - } - } - _ = fail - return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil -} - -// --------------------------------------------------------------------------- -// 全量自动完成 -// --------------------------------------------------------------------------- - -// runAutoAll 对单账号依次执行所有可自动化任务,返回逐项结果。 -// 供「一键完成全部可自动任务」使用;单项失败不影响后续项。 -// -// 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"), -// 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。 -func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { - var out []map[string]any - - // 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。 - if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil { - var codes []string - for _, t := range tasks { - if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { - codes = append(codes, t.TaskCode) - } - } - if len(codes) > 0 { - if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil { - out = append(out, map[string]any{ - "task_code": "(批量接受)", "status": "error", - "message": "接受任务失败(不阻塞后续): " + err.Error(), - }) - } else { - out = append(out, map[string]any{ - "task_code": "(批量接受)", "status": "done", - "message": fmt.Sprintf("已接受 %d 个任务", len(codes)), - }) - time.Sleep(reportGap) - } - } - } - - // 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。 - if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil { - var mpCodes []string - for _, t := range mpTasks { - if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { - mpCodes = append(mpCodes, t.TaskCode) - } - } - if len(mpCodes) > 0 { - if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil { - out = append(out, map[string]any{ - "task_code": "(批量接受-mp)", "status": "error", - "message": "接受小程序任务失败(不阻塞后续): " + err.Error(), - }) - } else { - out = append(out, map[string]any{ - "task_code": "(批量接受-mp)", "status": "done", - "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)), - }) - time.Sleep(reportGap) - } - } - } - - for _, act := range autoActions { - item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc} - before, err := p.taskByCode(a, act.TaskCode) - if err != nil { - item["status"] = "error" - item["message"] = "查询失败: " + err.Error() - out = append(out, item) - continue - } - if before == nil { - item["status"] = "skipped" - item["message"] = "该账号无此任务" - out = append(out, item) - continue - } - if before.Claimed || before.Current >= before.Target && before.Target > 0 { - item["status"] = "skipped" - item["message"] = "已完成(" + taskProgressText(before) + ")" - out = append(out, item) - continue - } - msg, err := act.run(p, a) - if err != nil { - item["status"] = "error" - item["message"] = err.Error() - out = append(out, item) - continue - } - var after *upstream.Task - if isMPTaskCode(act.TaskCode) { - after, _ = p.taskByCodeMP(a, act.TaskCode) - } else { - after, _ = p.taskByCodeWaiting(a, act.TaskCode) - } - item["status"] = "done" - item["message"] = msg - item["progress_after"] = taskProgressText(after) - // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。 - // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 - if after != nil && after.Claimable { - item["claimable"] = true - var credit, energy int64 - var cerr error - if isMPTaskCode(act.TaskCode) { - credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) - } else { - credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) - } - if cerr == nil { - item["claimed"] = true - item["credit"] = credit - item["energy"] = energy - if credit > 0 || energy > 0 { - item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) - } else { - item["message"] = msg + ";奖励此前已领取" - } - } else { - item["claim_error"] = cerr.Error() - item["message"] = msg + ";达标但领奖失败(可在列表手动重试)" - } - } - out = append(out, item) - time.Sleep(reportGap) // 项间节流 - } - return out -} - -// accountTaskAutoAll 一键完成该账号全部可自动任务。 -func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - a := p.accountByUID(w, uid) - if a == nil { - return - } - // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 - if !p.tryLockAccount(uid) { - writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") - return - } - // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。 - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) - defer cancel() - done := make(chan []map[string]any, 1) - go func() { - defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁 - done <- p.runAutoAll(a) - }() - select { - case results := <-done: - log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results)) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results}) - case <-ctx.Done(): - // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放, - // 期间重复点击会被 409 挡住,不会出现两轮并发。 - writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)") - } -} +// autotask.go 面板「一键完成」任务的动作实现。 +// +// 设计依据:上游 scripts/task_*.py 实测结论 + 2026-09-12 桌面指纹协议逆向 +// (data/desktop-task-protocol.md)—— +// - first_buddy(+300 分):report(前置解锁)→ agreement → buddy/first +// - chat_5(+100 分):累计 5 条 chat_request_send 上报 +// - Model_chat_GLM5.2(+100 分):accept → 用 glm-5.2 真实对话一次 → 上报(模型字段对齐) +// - RichMeow_Chat:桌面指纹(workbuddy-desktop)完整对话事件链,纯 API 可点亮(三账号实测) +// - Buddy_App / Buddy_App_QQ:buddyapp 五连事件,纯 API 可点亮(两账号实测) +// - automation_1:automated_task_create_suc 事件,纯 API 可点亮(两账号实测) +// - Library_read:web 域 web_element_click(library_doc_intro_click)(三账号实测) +// - template_5 / playbook_prompt / create_canvas:asar 逆向出的判据事件 +// (template_used / playbook_prompt_send / wbx_design_canvas_*),纯 API 可点亮(三账号实测) +// - expert_5 / Expert_team_use_3:真实专家列表 + 召唤链 + 真实 chat(服务端 requestId) +// - expert_actual_use(三账号实测) +// - Hp_Appearance:appearance/set + appearance_skin_apply 事件(两账号实测) +// +// 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。 +// 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。 +// +// 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。 +package panel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand/v2" + "net/http" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// autoAction 一个可自动化的任务动作。 +type autoAction struct { + TaskCode string // 目标任务 code + Desc string // 展示用说明 + Attempt bool // true = 尝试型(上游未证实可脚本化,跑了可能不点亮) + run func(p *Panel, a *auth.Auth) (string, error) +} + +// autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。 +// first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。 +var autoActions = []autoAction{ + { + TaskCode: "chat_5", + Desc: "上报 5 条对话活跃事件(自动补足差额)", + run: runChat5, + }, + { + TaskCode: "first_buddy", + Desc: "上报解锁 → 同意协议 → 领取第一只 Buddy(+300 分)", + run: runFirstBuddy, + }, + { + TaskCode: "Model_chat_GLM5.2", + Desc: "接受任务 → glm-5.2 真实对话一次 → 对齐模型上报", + run: runModelChat, + }, + { + TaskCode: "RichMeow_Chat", + Desc: "桌面指纹事件链上报(已验证:纯 API 可点亮,三账号实测)", + run: runRichMeow, + }, + { + TaskCode: "Buddy_App", + Desc: "上报「进入 Buddy 应用」事件链(已验证:纯 API 可点亮)", + run: runBuddyApp, + }, + { + TaskCode: "Buddy_App_QQ", + Desc: "上报「进入企鹅教师助手」事件链(已验证:纯 API 可点亮)", + run: runBuddyApp, + }, + { + TaskCode: "automation_1", + Desc: "上报「定时任务创建」事件(已验证:纯 API 可点亮)", + run: runAutomationCreate, + }, + { + TaskCode: "Library_read", + Desc: "上报「读资料库介绍」事件(已验证:纯 API 可点亮)", + run: runLibraryRead, + }, + { + TaskCode: "template_5", + Desc: "上报「使用模板创建任务」事件组 ×5(已验证:三账号点亮)", + run: runTemplateUse, + }, + { + TaskCode: "playbook_prompt", + Desc: "上报「灵感案例做同款发送 Prompt」事件组(已验证:三账号点亮)", + run: runPlaybookPrompt, + }, + { + TaskCode: "create_canvas", + Desc: "上报「设计创意画布创建」事件组(已验证:三账号点亮,+300 分)", + run: runCreateCanvas, + }, + { + TaskCode: "expert_5", + Desc: "真实专家召唤+使用链 ×5(专家市场列表+真实 chat,已验证:三账号点亮)", + run: runExpertUse, + }, + { + TaskCode: "Expert_team_use_3", + Desc: "真实专家团召唤+使用链 ×3(已验证:三账号点亮)", + run: runExpertTeamUse, + }, + { + TaskCode: "Hp_Appearance", + Desc: "设置主题 API + 皮肤生效事件(已验证:两账号点亮)", + run: runAppearance, + }, + { + TaskCode: "skill_1", + Desc: "真实对话 + skill_info 技能加载事件(已验证:人杰2 点亮)", + run: runSkillFresh, + }, + { + TaskCode: "Expert_lighthouse", + Desc: "真实轻量云专家召唤+使用链(chat 链带 has_expert,已验证:两账号点亮)", + run: runExpertLighthouse, + }, + { + TaskCode: "black_cat", + Desc: "夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示稍后再试)", + Attempt: true, + run: runBlackCat, + }, + { + TaskCode: "school_season", + Desc: "校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)", + run: runSchoolSeason, + }, + { + TaskCode: "Sequential_Tasks_1", + Desc: "小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)", + run: runSequentialChat, + }, + { + TaskCode: "Sequential_Tasks_2", + Desc: "小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)", + run: runMiniExpert, + }, + { + TaskCode: "Sequential_Tasks_3", + Desc: "小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)", + run: runSequentialChat5, + }, + { + TaskCode: "Sequential_Tasks_4", + Desc: "小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件(PC 同源)→ 领奖(判据待解锁验证)", + run: runSequentialAutomation, + }, + { + TaskCode: "Sequential_Tasks_5", + Desc: "小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)", + run: runSequentialModelChat, + }, + { + TaskCode: "Sequential_Tasks_6", + Desc: "小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖", + run: runSequentialChat10, + }, + { + TaskCode: "Sequential_Tasks_7", + Desc: "体验灵感功能(预留,疑 PC 口径 +500c+5e):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)", + run: runSequentialPlaybook, + }, +} + +// autoActionFor 查任务对应的动作;无则返回 nil(不可自动化)。 +func autoActionFor(code string) *autoAction { + for i := range autoActions { + if autoActions[i].TaskCode == strings.TrimSpace(code) { + return &autoActions[i] + } + } + return nil +} + +// autoActionIndex 任务在 autoActions 中的顺序(队列执行按依赖序排;未知返回大值)。 +func autoActionIndex(code string) int { + for i := range autoActions { + if autoActions[i].TaskCode == code { + return i + } + } + return 1 << 20 +} + +// mpTaskCodes 小程序口径专属下发的成长任务:默认(无 mp 头)列表不出现, +// accept/claim 均要求 X-Client-Platform: miniprogram。新任务出现时在此登记。 +var mpTaskCodes = map[string]bool{ + "school_season": true, // 校园日(mini chat + activityId) + "Sequential_Tasks_1": true, // 小程序首对话(mini chat,无 activityId) + "Sequential_Tasks_2": true, // 小程序选中专家并完成有效对话(mp 指纹 expert_actual_use) + "Sequential_Tasks_3": true, // 小程序完成 5 次对话(与 Tasks_1 同形状,target=5 逐条累加) + // Tasks_4..7 存在性已实测(accept 返回 prerequisite not met 链式依赖;Tasks_8 not + // found 封顶)。链条每日零点解锁一环(task locked until 次日),判据为 issue #42 + // 描述 + mpsrc 事件形状预置,解锁后逐个实测校正。 + "Sequential_Tasks_4": true, + "Sequential_Tasks_5": true, + "Sequential_Tasks_6": true, + "Sequential_Tasks_7": true, +} + +// isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。 +func isMPTaskCode(code string) bool { return mpTaskCodes[code] } + +// taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。 +// 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码, +// 未知码不多打一次上游)。 +func (p *Panel) taskByCode(a *auth.Auth, code string) (*upstream.Task, error) { + tasks, err := p.cfg.Upstream.ListTasks(a) + if err != nil { + return nil, err + } + for i := range tasks { + if tasks[i].TaskCode == code { + return &tasks[i], nil + } + } + if isMPTaskCode(code) { + return p.taskByCodeMP(a, code) + } + return nil, nil +} + +// claimPollAttempts / claimPollGap 达标回读的有界轮询参数。 +// 背景:上游计分是**异步**的——行为事件上报后进度要数秒才刷新(实测 Model_chat +// 对话完成后立即回读仍是 0/1,约 5-8 秒后才变 1/1)。一次性回读会误判"未达标", +// 从而跳过自动领奖。这里最多轮询 N 次、每次间隔 gap,总预算约 12 秒。 +var ( + claimPollAttempts = 4 + claimPollGap = 3 * time.Second +) + +// taskByCodeWaiting 回读任务,若未达标则在有界预算内轮询等待(上游异步计分)。 +// 已达标(claimable)立即返回;预算耗尽返回最后一次结果(可能仍未达标)。 +func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, error) { + t, err := p.taskByCode(a, code) + if err != nil || t == nil { + return t, err + } + if t.Claimable || t.Claimed { + return t, nil + } + for i := 1; i < claimPollAttempts; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCode(a, code) + if err2 != nil { + return t, nil // 轮询期间的查询失败不覆盖已拿到的结果 + } + if t2 != nil { + t = t2 + if t.Claimable || t.Claimed { + return t, nil + } + } + } + return t, nil +} + +// taskByCodeMP 以小程序口径拉取任务列表并定位单个任务;未找到返回 nil。 +// 小程序限定任务(school_season / Sequential_Tasks_1)在默认口径列表不出现。 +func (p *Panel) taskByCodeMP(a *auth.Auth, code string) (*upstream.Task, error) { + tasks, err := p.cfg.Upstream.ListTasksMP(a) + if err != nil { + return nil, err + } + for i := range tasks { + if tasks[i].TaskCode == code { + return &tasks[i], nil + } + } + return nil, nil +} + +// acceptWithVerifyMP accept 并回读验证登记生效:上游存在 200+OK 但 accept 未真正 +// 登记的形态(此时上报事件全部不归账,任务永远点不亮,上游 task_runner c793ae3 +// 实测)——判定以回读 accept_status 为准,未生效重试一次。 +func (p *Panel) acceptWithVerifyMP(a *auth.Auth, code string) bool { + for attempt := 1; attempt <= 2; attempt++ { + if err := p.cfg.Upstream.AcceptTasksMP(a, []string{code}); err != nil { + log.Printf("autotask %s %s: accept 尝试%d: %v", logfmt.Label(a.UID, a.Nickname), code, attempt, err) + continue + } + time.Sleep(mpActionGap) + t, err := p.taskByCodeMP(a, code) + if err == nil && t != nil && t.AcceptStatus != "not_accepted" && t.AcceptStatus != "" { + return true + } + log.Printf("autotask %s %s: accept 尝试%d 未登记生效(回读=%q)", logfmt.Label(a.UID, a.Nickname), code, attempt, acceptStatusOr(t)) + } + return false +} + +// acceptStatusOr 安全读取任务 accept_status(nil 任务返回 "?")。 +func acceptStatusOr(t *upstream.Task) string { + if t == nil { + return "?" + } + if t.AcceptStatus == "" { + return "?" + } + return t.AcceptStatus +} + +// mpActionGap mp 任务写动作间隔(accept/上报/领奖之间,防频控)。 +var mpActionGap = 2 * time.Second + +// mpChatEventGap mp 对话事件(chat_request_send)的真人节奏间隔。上游对 +// Sequential_Tasks_3「5 次有效对话」有反作弊校验:数秒级连发的事件会先被计入 +// 进度(回读 5/5、accept_status 甚至短暂转 completed),随后被判定无效整体回滚 +// (进度回落、claim 返回 400 "task not completed")——2026-09-26 实测 2s 连发 +// 4 条全灭,45s 间隔逐条上报全存活且 claim +300c+5e 成功。每条上报前 +// sleep gap + 0~10s 抖动;首条也等(上一轮残留进度被回滚后立即重报同样无效)。 +var mpChatEventGap = 45 * time.Second + +// runMPMiniChatTask growth 域小程序限定任务通用闭环: +// mp 查询 → accept(带登记回读验证)→ mini chat 事件上报(withActivityId 决定 +// 是否带开学季 activityId:school_season 必带,Sequential_Tasks_1 不带——服务端按 +// source=mini_program 指纹关联)→ 回读 → 达标即领奖。 +func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool) (string, error) { + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(活动可能已结束)", nil + } + if t.Claimed { + return "已领取", nil + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil + } + // accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报—— + // Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖 + // (claim 400 task not completed)。接受后回读一次拿真实 target/current。 + if t2, err := p.taskByCodeMP(a, code); err == nil && t2 != nil { + t = t2 + } + } + // 已达标(含 completed 未领):直接领奖。 + target := t.Target + if target <= 0 { + target = 1 + } + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + // 判据上报:按差额补 mini chat 事件。每条前 sleep mpChatEventGap+抖动—— + // 连发会被上游反作弊判无效(见 mpChatEventGap 注释),宁可慢不可白报。 + need := target - t.Current + for i := int64(0); i < need; i++ { + time.Sleep(mpChatEventGap + time.Duration(rand.Int64N(int64(10*time.Second)))) + conv := fmt.Sprintf("wb2api-mp-%d-%d", time.Now().UnixMilli(), i) + var ev map[string]any + if withActivityId { + ev = upstream.SchoolSeasonChatEvent(conv) + } else { + ev = upstream.SchoolChatTimesEvents(conv) + } + if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { + return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil + } + } + // 回读(异步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。 + for i := 0; i < 2; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return fmt.Sprintf("已上报 %d 次但进度未达 %d/%d(异步计分未归账,下次重试)", need, t.Current, target), nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// runSchoolSeason 完成 school_season「校园日」(growth 域小程序限定)。 +// 判据 = mini chat_request_send + activityId=school_open_day_2026(无 activityId +// 不点亮,与 school 域开学季同活动关联;上游 task_runner e2e 实测 +100c+5e)。 +func runSchoolSeason(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "school_season", true) +} + +// runSequentialChat 完成 Sequential_Tasks_1「小程序内完成 1 次有效对话」。 +// 判据 = mini chat_request_send(无 activityId,服务端按 source=mini_program +// 指纹关联;上游 task_runner 实测 +100c+5e)。 +func runSequentialChat(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_1", false) +} + +// runSequentialChat5 完成 Sequential_Tasks_3「在小程序内完成 5 次有效对话」。 +// 判据与 Sequential_Tasks_1 同形状(mini 指纹 chat_request_send,无 activityId), +// 仅 target=5——服务端按上报条数累加进度,但**要求真人节奏**:连发事件先计数 +// 后被反作弊回滚(claim 400 "task not completed"),由 mpChatEventGap 间隔保证 +// (2026-09-26 实测:45s 间隔补满 5/5 → claim +300c+5e 成功,领后 accept_status +// =claimed 稳定不回滚)。 +func runSequentialChat5(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_3", false) +} + +// runSequentialChat10 完成 Sequential_Tasks_6「在小程序内完成 10 次有效对话」(预留)。 +// 判据假定与 Tasks_1/3 同形状(mini chat_request_send),target 由任务自带(回读), +// runMPMiniChatTask 按差额补报——issue #42 称 target=10,以解锁后实际下发为准。 +// 真人节奏间隔同样适用(mpChatEventGap):9 条 × ~50s ≈ 8 分钟/账号,夜间队列可接受。 +func runSequentialChat10(p *Panel, a *auth.Auth) (string, error) { + return p.runMPMiniChatTask(a, "Sequential_Tasks_6", false) +} + +// runSequentialEventTask Sequential 链预留任务通用骨架:mp 查询 → accept(带验证) +// → 判据事件上报(primary;未点亮且 fallback 非空时补一轮)→ 回读 → 达标领奖。 +// 每日零点解锁一环:locked 期间 accept 不落账,返回等下次调度(无需人工干预)。 +func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallback func() error) (string, error) { + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(前置任务未完成或活动未开始)", nil + } + if t.Claimed { + return "已领取", nil + } + target := t.Target + if target <= 0 { + target = 1 + } + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil + } + } + if err := primary(); err != nil { + return fmt.Sprintf("判据上报失败: %v", err), nil + } + // 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。 + for round := 0; round < 2; round++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + if round == 0 && fallback != nil { + if err := fallback(); err != nil { + return fmt.Sprintf("备选判据上报失败: %v", err), nil + } + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return "已上报但进度未点亮(判据形态待解锁后校正,下次重试)", nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// runSequentialAutomation 完成 Sequential_Tasks_4「创建定时任务」(预留)。 +// mp 源码无 automation 事件发射点 → 判据疑为 PC 口径:复用 automation_1 同源 +// 事件(DesktopAutomationCreateEvent,PC 任务三账号实测点亮)。 +func runSequentialAutomation(p *Panel, a *auth.Auth) (string, error) { + return p.runSequentialEventTask(a, "Sequential_Tasks_4", + func() error { + return p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopAutomationCreateEvent("wb2api 自动化")) + }, nil) +} + +// runSequentialModelChat 完成 Sequential_Tasks_5「使用 GLM5.2」(预留)。 +// primary:mp 对话事件带 requestModelId/requestModelName=glm-5.2(mpsrc main 32904 +// 发射点实测形状);fallback:PC 域模型活跃上报(Model_chat_GLM5.2 同源)。 +func runSequentialModelChat(p *Panel, a *auth.Auth) (string, error) { + return p.runSequentialEventTask(a, "Sequential_Tasks_5", + func() error { + conv := fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()) + return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniChatModelEvent(conv, "glm-5.2", "GLM-5.2")) + }, + func() error { + return p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()), "", "glm-5.2", "GLM-5.2") + }) +} + +// runSequentialPlaybook 完成 Sequential_Tasks_7「体验灵感功能」(预留,疑 PC 口径)。 +// primary:PC 灵感事件组(DesktopPlaybookPromptSequence,playbook_prompt 三账号 +// 实测点亮);fallback:mp 指纹灵感事件组(MiniPlaybookEvents,mpsrc 形状)。 +func runSequentialPlaybook(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + return p.runSequentialEventTask(a, "Sequential_Tasks_7", + func() error { + conv := fmt.Sprintf("wb2api-pb-%d", ms) + req := fmt.Sprintf("wb2api-pb-req-%d", ms) + return p.cfg.Upstream.ReportDesktopEvent(a, + upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) + }, + func() error { + return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniPlaybookEvents("pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...) + }) +} + +// runMiniExpert 完成 Sequential_Tasks_2「在小程序内选中专家并完成有效对话」。 +// 判据 = mp 指纹 expert_actual_use(**不带** activityId/conversationId、 +// extVersion=2.2.8、type=send_message——小程序源码实测形状,与 school 域 expert +// 事件两套口径勿混;上游 task_runner 实测上报即 completed,claim +200c+5e)。 +// 专家 id 必须是市场真实 ex_ id(空 id 服务端不入账)→ **accept 之前**先解析市场 +// 列表:拉不到就整任务不动作,避免留下「已登记未上报」的半程态(上游 9a26ae7 +// 的 ids 前置判定同款)。复用既有 MarketExpertList(expert_5 任务同源,实测可用)。 +func runMiniExpert(p *Panel, a *auth.Auth) (string, error) { + const code = "Sequential_Tasks_2" + t, err := p.taskByCodeMP(a, code) + if err != nil { + return "", err + } + if t == nil { + return "mp 口径未下发该任务(活动可能已结束)", nil + } + if t.Claimed { + return "已领取", nil + } + target := t.Target + if target <= 0 { + target = 1 // 未 accept 的 mp 任务 progress 为 null,target 兜底(上游实测) + } + // 已达标(含 completed 未领):直接领奖。 + if t.Current >= target || t.AcceptStatus == "completed" { + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil + } + // 判据载体前置(accept 之前):市场真实专家 id。 + experts, merr := p.cfg.Upstream.MarketExpertList(a, "") + if merr != nil || len(experts) == 0 { + return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil + } + e := experts[0] + name := e.DisplayNameZH + if name == "" { + name = e.ProfessionZH + } + if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" { + if !p.acceptWithVerifyMP(a, code) { + return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil + } + } + ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType) + if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil { + return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil + } + // 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。 + for i := 0; i < 2; i++ { + time.Sleep(claimPollGap) + t2, err2 := p.taskByCodeMP(a, code) + if err2 != nil || t2 == nil { + continue + } + t = t2 + if t.Claimable || t.Claimed || t.Current >= target { + break + } + } + if t.Claimed { + return "本轮已入账(claimed)", nil + } + if t.Current < target { + return "已上报但进度未归账(异步计分,下次重试)", nil + } + credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code) + if err != nil { + return "", err + } + return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil +} + +// accountTaskAuto 一键完成单个任务:执行对应动作 → 回读进度 → 汇报结果。 +func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.accountByUID(w, uid) + if a == nil { + return + } + var body struct { + TaskCode string `json:"task_code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TaskCode == "" { + writeErr(w, http.StatusBadRequest, "task_code required") + return + } + act := autoActionFor(body.TaskCode) + if act == nil { + writeErr(w, http.StatusNotImplemented, + "该任务需要客户端内交互(无对应接口),无法自动完成;请按任务说明在官方客户端操作") + return + } + // per-account 互斥:同账号的任务动作正在跑(单任务或全量)时直接 409, + // 不并发重跑(动作幂等但 expert/skill 系含真实对话,重跑浪费配额)。 + if !p.tryLockAccount(uid) { + writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") + return + } + defer p.unlockAccount(uid) + // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。 + // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。 + before, err := p.taskByCode(a, act.TaskCode) + if err != nil { + writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error()) + return + } + if before == nil { + writeErr(w, http.StatusNotFound, "该账号没有此任务") + return + } + isMP := isMPTaskCode(act.TaskCode) + if before.Claimed { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"}) + return + } + msg, err := act.run(p, a) + if err != nil { + writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error()) + return + } + // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步), + // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。 + var after *upstream.Task + var aerr error + if isMP { + after, aerr = p.taskByCodeMP(a, act.TaskCode) + } else { + after, aerr = p.taskByCodeWaiting(a, act.TaskCode) + } + progressBefore, progressAfter := taskProgressText(before), "" + claimable := false + if aerr == nil && after != nil { + progressAfter = taskProgressText(after) + claimable = after.Claimable + } + resp := map[string]any{ + "ok": true, + "message": msg, + "progress_before": progressBefore, + "progress_after": progressAfter, + "claimable": claimable, + "attempt": act.Attempt, + "verify_supported": true, + } + // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。 + if claimable { + var credit, energy int64 + var cerr error + if isMP { + credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) + } else { + credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) + } + if cerr == nil { + resp["claimed"] = true + resp["credit"] = credit + resp["energy"] = energy + if credit > 0 || energy > 0 { + resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) + } else { + resp["message"] = msg + ";奖励此前已领取" + } + } else { + resp["claim_error"] = cerr.Error() + resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试" + } + } + log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v", + uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"]) + writeJSON(w, http.StatusOK, resp) +} + +// taskProgressText 任务进度的可读表示(回读对比用)。 +func taskProgressText(t *upstream.Task) string { + if t == nil { + return "?" + } + if t.Target > 0 { + return fmt.Sprintf("%d/%d", t.Current, t.Target) + } + if t.Claimed { + return "claimed" + } + return t.AcceptStatus +} + +// truncateStr 截断错误文本(避免把上游长响应原样透给前端)。 +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// --------------------------------------------------------------------------- +// 各任务动作实现 +// --------------------------------------------------------------------------- + +// reportGap 连续上报之间的间隔(对齐上游脚本实测的 1.05s 口径,避免风控)。 +var reportGap = 1050 * time.Millisecond + +// runChat5 补足 chat_5 的进度:按差额上报 chat_request_send。 +func runChat5(p *Panel, a *auth.Auth) (string, error) { + t, err := p.taskByCode(a, "chat_5") + if err != nil { + return "", err + } + if t == nil { + return "", fmt.Errorf("任务不存在") + } + target := t.Target + if target <= 0 { + target = 5 + } + need := target - t.Current + if need <= 0 { + return "进度已达标,无需上报", nil + } + for i := int64(0); i < need; i++ { + cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i) + if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil { + return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil + } + if i < need-1 { + time.Sleep(reportGap) + } + } + return fmt.Sprintf("已补报 %d 条对话事件", need), nil +} + +// runFirstBuddy 领养:report(解锁前置)→ agreement → first。 +func runFirstBuddy(p *Panel, a *auth.Auth) (string, error) { + if err := p.cfg.Upstream.ReportChatActivity(a, fmt.Sprintf("wb2api-adopt-%d", time.Now().UnixMilli()), ""); err != nil { + return "", fmt.Errorf("前置上报: %w", err) + } + time.Sleep(reportGap) // 给上游事件处理留时间(脚本实测口径) + if err := p.cfg.Upstream.BuddyAgreement(a); err != nil { + return "", fmt.Errorf("同意协议: %w", err) + } + if err := p.cfg.Upstream.BuddyFirst(a); err != nil { + if upstream.IsBuddyTaskIncomplete(err) { + return "前置已上报,但领养门槛未过(上游要求当日活跃),请稍后重试", nil + } + return "", fmt.Errorf("领取 Buddy: %w", err) + } + return "已领取 Buddy(+300 分 +8 能量)", nil +} + +// runModelChat 完成 Model_chat_GLM5.2:accept → 真实对话 → 对齐模型上报。 +func runModelChat(p *Panel, a *auth.Auth) (string, error) { + const code, modelID, modelName = "Model_chat_GLM5.2", "glm-5.2", "GLM-5.2" + // 1. accept(报名;失败不阻塞——行为事件才是判据) + if err := p.cfg.Upstream.AcceptTasks(a, []string{code}); err != nil { + log.Printf("panel: accept %s: %v(继续走行为链路)", code, err) + } + time.Sleep(reportGap) + // 2. 真实对话一次(判据的最直接证据) + body, _ := json.Marshal(map[string]any{ + "model": modelID, + "messages": []map[string]any{ + {"role": "user", "content": "hi,请回复一句话"}, + }, + "stream": true, + }) + rc, status, respBody, err := p.cfg.Upstream.ChatStream(a, body, "", upstream.ChatMeta{}) + if err != nil { + return "", fmt.Errorf("对话请求: %w", err) + } + if status >= 400 { + rc.Close() + return "", fmt.Errorf("对话失败 http=%d: %s", status, truncateStr(string(respBody), 160)) + } + // 读干 SSE(网关对上游强制 flow:不读完会残留连接) + _, _ = io.Copy(io.Discard, io.LimitReader(rc, 1<<20)) + rc.Close() + time.Sleep(reportGap) + // 3. 对齐模型的上报(触发进度) + if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil { + return "对话已完成,但进度上报失败:" + err.Error(), nil + } + return "已完成 glm-5.2 对话并上报", nil +} + +// runRichMeow 完成 RichMeow_Chat(桌面端对话1次)。 +// 2026-09-12 三账号实测验证:以桌面指纹(extName=workbuddy-desktop)向 +// copilot.tencent.com/v2/report 上报完整对话事件链(agent_task_created → +// chat_message_response isSuccessful=true 等 6 事件),纯 API 即可点亮并领奖 +// (紫川/人杰2 两账号无桌面客户端登录状态下 3 秒内 0/1 → 1/1)。 +// Hp_Appearance 的纯 API set 不计分(需客户端在主题下活跃),区别对待。 +func runRichMeow(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-rm-%d", ms) + req := fmt.Sprintf("wb2api-rm-req-%d", ms) + msg := fmt.Sprintf("req-%d-user", ms) + events := upstream.DesktopChatSequence(conv, req, msg, "fast-model", "fast-model") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已按桌面端指纹上报完整对话事件链(agent_task_created→chat_response)", nil +} + +// runBuddyApp 完成 Buddy_App / Buddy_App_QQ(进入 Buddy 应用)。 +// 2026-09-12 两账号实测:buddyapp 五连事件(discover→show→enter→auth_confirm→ +// bind_skip)以 workbuddy-desktop 指纹上报即点亮,服务端不校验真实授权。 +// 用企鹅教师助手(Buddy_App_QQ 判据应用)作载体,同一组事件同时满足 +// Buddy_App「进入任一应用」——两个表项共用本 run,幂等由任务状态跳过兜底。 +func runBuddyApp(p *Panel, a *auth.Auth) (string, error) { + events := upstream.DesktopBuddyAppSequence("cb_y5Dy46tPQGGWtueMxXbe", "企鹅教师助手") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 buddyapp 进入五连事件(同时覆盖 Buddy_App 与 Buddy_App_QQ)", nil +} + +// runAutomationCreate 完成 automation_1(设置自动化任务)。 +// 2026-09-12 两账号实测:automated_task_create_suc 事件纯 API 上报即点亮, +// 无需真实创建定时任务。 +func runAutomationCreate(p *Panel, a *auth.Auth) (string, error) { + if err := p.cfg.Upstream.ReportDesktopEvent(a, + upstream.DesktopAutomationCreateEvent("wb2api 自动化")); err != nil { + return "", err + } + return "已上报定时任务创建事件", nil +} + +// runLibraryRead 完成 Library_read(体验资料库)。 +// 2026-09-12 三账号实测:web 域 /v2/report 上报 web_element_click +// (elementId=library_doc_intro_click) 即点亮(space 的 open/WS/inlong 都不是判据)。 +func runLibraryRead(p *Panel, a *auth.Auth) (string, error) { + const docURL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm" + if err := p.cfg.Upstream.ReportWebEvent(a, "web_element_click", docURL, + "library_doc_intro_click", "WorkBuddy资料库介绍"); err != nil { + return "", err + } + return "已上报资料库介绍阅读事件", nil +} + +// runBlackCat 完成 black_cat(夜猫子,夜间 23:00–08:00 计数)。 +// 判据 = 夜间窗口内 glm-5.2 真实对话 + chat 事件上报(WorkBuddy-Daily 实测口径)。 +// 窗口外不做(提示等排程);网关 blackcat_hours(默认 23 点)排程会自动补足。 +func runBlackCat(p *Panel, a *auth.Auth) (string, error) { + if !upstream.InNightWindow(time.Now()) { + return "当前不在 23:00–08:00 计数窗口,行为不计分;网关会在每日 23 点自动补足", nil + } + need, err := p.cfg.Upstream.BlackcatNeed(a) + if err != nil { + return "", err + } + if need <= 0 { + return "进度已达标,无需补足", nil + } + ok, err := p.cfg.Upstream.RunNightChats(a, int(need)) + if err != nil { + return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil + } + return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil +} + +// runSkillFresh 完成 skill_1(尝鲜热门技能)。 +// 2026-09-12 判据(紫川手动完成抓包 row 209):`skill_info` 事件(桌面指纹)—— +// {id:<技能名>, skillId, skillVersion, toolStatus:"success", fileCount, +// source:"workbuddy-desktop"} JOIN 真实会话(conversationId/requestId=服务端 +// id)。此前的 skill_request_send/skill_installed/skill_action 全是错误方向。 +// 人杰2 实测 0/1 → 1/1 点亮。 +func runSkillFresh(p *Panel, a *auth.Auth) (string, error) { + conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, "") + if err != nil { + return "", fmt.Errorf("真实对话: %w", err) + } + msgID := "msg-" + req[len(req)-8:] + events := upstream.DesktopChatSequence(conv, req, msgID, "fast-model", "fast-model") + for _, ev := range events { + if ev["eventCode"] == "chat_message_response" { + ev["finishReason"] = "tool_calls" // 模型发起工具调用(技能加载)语义 + } + } + events = append(events, upstream.DesktopEvent{ + "eventCode": "skill_info", + "id": "润泽小馆·日报撰写", + "skillId": "skill_2097350077599879168", + "skillVersion": "1.0.0", + "toolStatus": "success", + "fileCount": 56, + "source": "workbuddy-desktop", + "conversationId": conv, "requestId": req, "messageId": msgID, + "requestModelId": "fast-model", "requestModelName": "fast-model", + "traceId": req, + }) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", fmt.Errorf("skill_info 事件: %w", err) + } + return "已上报真实对话 + skill_info 技能加载事件", nil +} + +// runExpertLighthouse 完成 Expert_lighthouse(体验「腾讯轻量云」专家)。 +// 2026-09-12 判据(真实样本 Sunny row 868):与 expert_5 同构,但两处差异—— +// chat 链的 agent_task_created 需带 has_expert:true + expert_id(我们默认 false), +// expert_actual_use 的 mode 为 "LOCAL"(非 craft)。id 固定为轻量云专家 +// ex_2cvvUZQhDyeJ;requestId 必须是真实 chat 的服务端 id。紫川/人杰2 实测点亮。 +func runExpertLighthouse(p *Panel, a *auth.Auth) (string, error) { + const lhID = "ex_2cvvUZQhDyeJ" + lh := upstream.MarketExpert{ + ExpertID: lhID, ExpertType: "agent", + DisplayNameZH: "腾讯轻量云专家", ProfessionZH: "腾讯轻量云专家", Version: "1.0.2", + } + // 市场列表若命中真实条目则用其信息(version 等以服务端为准)。 + if experts, err := p.cfg.Upstream.MarketExpertList(a, "agent"); err == nil { + for _, e := range experts { + if e.ExpertID == lhID { + lh = e + break + } + } + } + if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopExpertSummonSequence(lh)...); err != nil { + return "", fmt.Errorf("召唤链: %w", err) + } + conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, lhID) + if err != nil { + return "", fmt.Errorf("真实对话: %w", err) + } + events := upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model") + for _, ev := range events { + if ev["eventCode"] == "agent_task_created" { + ev["has_expert"] = true + ev["expert_id"] = lh.ExpertID + ev["expert_name"] = lh.DisplayNameZH + ev["expert_industry_id"] = "" + } + } + events = append(events, upstream.DesktopExpertActualUseLocal(lh, conv, req)) + // 对齐真实样本细节:轻量云专家 actual_use 的 type 为空、cost=0。 + events[len(events)-1]["type"] = "" + events[len(events)-1]["cost"] = 0 + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", fmt.Errorf("使用事件: %w", err) + } + return "已上报轻量云专家召唤+使用链(真实对话 requestId)", nil +} + +// runAppearance 完成 Hp_Appearance(换主题)。 +// 2026-09-12 紫川/人杰2 实测(desktopverify12):判据是 appearance_skin_apply +// 事件 {action:"apply",source:"settings_close",id:,...}(客户端在 +// 主题生效状态下离开设置页时上报)——早期"纯 API set 不计分"的结论不准确, +// 真相是当时只调了 appearance/set 没发事件。组合:set API 留痕 + 事件上报。 +func runAppearance(p *Panel, a *auth.Auth) (string, error) { + const themeKey = "theme-tkmw7j" // 和平精英激战金秋(Hp_Appearance 判据主题) + if err := p.cfg.Upstream.SetAppearanceTheme(a, themeKey); err != nil { + return "", fmt.Errorf("设置主题: %w", err) + } + time.Sleep(2 * time.Second) + if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopEvent{ + "eventCode": "appearance_skin_apply", "action": "apply", "source": "settings_close", + "id": themeKey, "vipLevel": 0, "series": "", "type": "unknown", + }); err != nil { + return "", err + } + return "已设置主题并上报皮肤生效事件", nil +} + +// runTemplateUse 完成 template_5(使用 5 个模板创建任务)。 +// 2026-09-12 三账号实测:agent_task_created_with_template + template_used 事件组 +// (JOIN chat 链)一次上报 5 组即 5/5 点亮。template_id 服务端不校验真实性。 +func runTemplateUse(p *Panel, a *auth.Auth) (string, error) { + templates := [][2]string{{"1", "深度研究"}, {"2", "周报生成"}, {"3", "竞品分析"}, {"4", "活动策划"}, {"5", "代码评审"}} + for i, tp := range templates { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-tpl-%d-%d", ms, i) + req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i) + events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1]) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil + } + time.Sleep(300 * time.Millisecond) + } + return "已上报 template_used ×5", nil +} + +// runPlaybookPrompt 完成 playbook_prompt(灵感案例 Dialog 中发送 Prompt)。 +// 判据是 playbook_prompt_send(Dialog 发送)而非卡片曝光/点击——asar 逆向确认。 +func runPlaybookPrompt(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-pb-%d", ms) + req := fmt.Sprintf("wb2api-pb-req-%d", ms) + events := upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸") + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 playbook_cta_click + playbook_prompt_send", nil +} + +// runCreateCanvas 完成 create_canvas(设计创意模式创建画布,+300 分)。 +// 判据是 wbx_design_canvas_task_create/open(Ardot create_design 工具完成遥测)。 +func runCreateCanvas(p *Panel, a *auth.Auth) (string, error) { + ms := time.Now().UnixMilli() + conv := fmt.Sprintf("wb2api-canvas-%d", ms) + req := fmt.Sprintf("wb2api-canvas-req-%d", ms) + events := upstream.DesktopDesignCanvasSequence(conv, req) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + return "", err + } + return "已上报 wbx_design_canvas_task_create/open", nil +} + +// expertSummonGap 专家召唤链的间隔(真实使用节奏,v11 实测 8s 成功率 100%)。 +const expertSummonGap = 6 * time.Second + +// runExpertUse 完成 expert_5(使用 5 个平台专家)。 +// 2026-09-12 三账号实测公式:真实专家列表(id 必须真实存在)→ 召唤链 +// (summon_click/summoned)→ 真实 chat 拿服务端 requestId → expert_actual_use。 +// 自造专家 id 或自造 requestId 均不计数。 +func runExpertUse(p *Panel, a *auth.Auth) (string, error) { + return runExpertBatch(p, a, "agent", 5) +} + +// runExpertTeamUse 完成 Expert_team_use_3(使用 3 个专家团,expertType=team)。 +func runExpertTeamUse(p *Panel, a *auth.Auth) (string, error) { + return runExpertBatch(p, a, "team", 3) +} + +// runExpertBatch 专家召唤+使用的公共实现。失败逐个继续,返回汇总信息。 +func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (string, error) { + experts, err := p.cfg.Upstream.MarketExpertList(a, expertType) + if err != nil { + return "", fmt.Errorf("拉取专家列表: %w", err) + } + if len(experts) == 0 { + return "", fmt.Errorf("专家市场列表为空") + } + ok, fail := 0, 0 + for i, e := range experts { + if ok >= count { + break + } + // 召唤链(web_element_click + summon_click + summoned)。 + summonEvents := upstream.DesktopExpertSummonSequence(e) + if err := p.cfg.Upstream.ReportDesktopEvent(a, summonEvents...); err != nil { + fail++ + continue + } + // 真实 chat(带 X-Expert-Id)→ 服务端 requestId。 + conv, req, cerr := p.cfg.Upstream.DesktopChatWithExpert(a, e.ExpertID) + if cerr != nil { + fail++ + continue + } + // 使用事件(JOIN 服务端 requestId)+ chat 链。 + events := append(upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model"), + upstream.DesktopExpertActualUseEvent(e, conv, req)) + if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil { + fail++ + continue + } + ok++ + if i < len(experts)-1 { + time.Sleep(expertSummonGap) + } + } + _ = fail + return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil +} + +// --------------------------------------------------------------------------- +// 全量自动完成 +// --------------------------------------------------------------------------- + +// runAutoAll 对单账号依次执行所有可自动化任务,返回逐项结果。 +// 供「一键完成全部可自动任务」使用;单项失败不影响后续项。 +// +// 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"), +// 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。 +func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { + var out []map[string]any + + // 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。 + if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil { + var codes []string + for _, t := range tasks { + if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { + codes = append(codes, t.TaskCode) + } + } + if len(codes) > 0 { + if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil { + out = append(out, map[string]any{ + "task_code": "(批量接受)", "status": "error", + "message": "接受任务失败(不阻塞后续): " + err.Error(), + }) + } else { + out = append(out, map[string]any{ + "task_code": "(批量接受)", "status": "done", + "message": fmt.Sprintf("已接受 %d 个任务", len(codes)), + }) + time.Sleep(reportGap) + } + } + } + + // 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。 + if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil { + var mpCodes []string + for _, t := range mpTasks { + if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" { + mpCodes = append(mpCodes, t.TaskCode) + } + } + if len(mpCodes) > 0 { + if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil { + out = append(out, map[string]any{ + "task_code": "(批量接受-mp)", "status": "error", + "message": "接受小程序任务失败(不阻塞后续): " + err.Error(), + }) + } else { + out = append(out, map[string]any{ + "task_code": "(批量接受-mp)", "status": "done", + "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)), + }) + time.Sleep(reportGap) + } + } + } + + for _, act := range autoActions { + item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc} + before, err := p.taskByCode(a, act.TaskCode) + if err != nil { + item["status"] = "error" + item["message"] = "查询失败: " + err.Error() + out = append(out, item) + continue + } + if before == nil { + item["status"] = "skipped" + item["message"] = "该账号无此任务" + out = append(out, item) + continue + } + if before.Claimed || before.Current >= before.Target && before.Target > 0 { + item["status"] = "skipped" + item["message"] = "已完成(" + taskProgressText(before) + ")" + out = append(out, item) + continue + } + msg, err := act.run(p, a) + if err != nil { + item["status"] = "error" + item["message"] = err.Error() + out = append(out, item) + continue + } + var after *upstream.Task + if isMPTaskCode(act.TaskCode) { + after, _ = p.taskByCodeMP(a, act.TaskCode) + } else { + after, _ = p.taskByCodeWaiting(a, act.TaskCode) + } + item["status"] = "done" + item["message"] = msg + item["progress_after"] = taskProgressText(after) + // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。 + // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 + if after != nil && after.Claimable { + item["claimable"] = true + var credit, energy int64 + var cerr error + if isMPTaskCode(act.TaskCode) { + credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) + } else { + credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) + } + if cerr == nil { + item["claimed"] = true + item["credit"] = credit + item["energy"] = energy + if credit > 0 || energy > 0 { + item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy) + } else { + item["message"] = msg + ";奖励此前已领取" + } + } else { + item["claim_error"] = cerr.Error() + item["message"] = msg + ";达标但领奖失败(可在列表手动重试)" + } + } + out = append(out, item) + time.Sleep(reportGap) // 项间节流 + } + return out +} + +// accountTaskAutoAll 一键完成该账号全部可自动任务。 +func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.accountByUID(w, uid) + if a == nil { + return + } + // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 + if !p.tryLockAccount(uid) { + writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") + return + } + // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + done := make(chan []map[string]any, 1) + go func() { + defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁 + done <- p.runAutoAll(a) + }() + select { + case results := <-done: + log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results}) + case <-ctx.Done(): + // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放, + // 期间重复点击会被 409 挡住,不会出现两轮并发。 + writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)") + } +} diff --git a/internal/panel/autotask_lock_test.go b/internal/panel/autotask_lock_test.go index e7d0e6706cc051ec070cf4206e914c1cf4ae2e7c..04a28e91779e9a667b362eda2f6176a806f4f186 100644 --- a/internal/panel/autotask_lock_test.go +++ b/internal/panel/autotask_lock_test.go @@ -1,52 +1,52 @@ -package panel - -import ( - "testing" -) - -// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败, -// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。 -func TestTaskAccountLockSameAccountExclusive(t *testing.T) { - p := &Panel{} - uid := "u1" - - if !p.tryLockAccount(uid) { - t.Fatal("首次加锁应成功") - } - if p.tryLockAccount(uid) { - t.Fatal("同账号第二次加锁应失败(互斥)") - } - p.unlockAccount(uid) - - if !p.tryLockAccount(uid) { - t.Fatal("解锁后应可再次加锁") - } - p.unlockAccount(uid) -} - -// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。 -func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) { - p := &Panel{} - if !p.tryLockAccount("u1") { - t.Fatal("u1 加锁应成功") - } - if !p.tryLockAccount("u2") { - t.Fatal("u2 加锁应成功(不同账号不互斥)") - } - p.unlockAccount("u2") - p.unlockAccount("u1") -} - -// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁 -// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。 -func TestTaskAccountLockCrossEntryShared(t *testing.T) { - p := &Panel{} - if !p.tryLockAccount("u1") { - t.Fatal("u1 加锁应成功") - } - // 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。 - if p.tryLockAccount("u1") { - t.Fatal("同 uid 跨入口加锁应失败(共用锁)") - } - p.unlockAccount("u1") -} +package panel + +import ( + "testing" +) + +// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败, +// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。 +func TestTaskAccountLockSameAccountExclusive(t *testing.T) { + p := &Panel{} + uid := "u1" + + if !p.tryLockAccount(uid) { + t.Fatal("首次加锁应成功") + } + if p.tryLockAccount(uid) { + t.Fatal("同账号第二次加锁应失败(互斥)") + } + p.unlockAccount(uid) + + if !p.tryLockAccount(uid) { + t.Fatal("解锁后应可再次加锁") + } + p.unlockAccount(uid) +} + +// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。 +func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) { + p := &Panel{} + if !p.tryLockAccount("u1") { + t.Fatal("u1 加锁应成功") + } + if !p.tryLockAccount("u2") { + t.Fatal("u2 加锁应成功(不同账号不互斥)") + } + p.unlockAccount("u2") + p.unlockAccount("u1") +} + +// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁 +// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。 +func TestTaskAccountLockCrossEntryShared(t *testing.T) { + p := &Panel{} + if !p.tryLockAccount("u1") { + t.Fatal("u1 加锁应成功") + } + // 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。 + if p.tryLockAccount("u1") { + t.Fatal("同 uid 跨入口加锁应失败(共用锁)") + } + p.unlockAccount("u1") +} diff --git a/internal/panel/config.go b/internal/panel/config.go index efda8ae327dd8e6099bacd1a8ee7e63f6c6d8bcc..73800ed383a8e696014e419dc5433b065a82d056 100644 --- a/internal/panel/config.go +++ b/internal/panel/config.go @@ -1,57 +1,57 @@ -// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。 -// -// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做 -// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成 -// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。 -package panel - -import ( - "io" - "log" - "net/http" -) - -// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。 -func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) { - if p.cfg.LoadConfig == nil { - writeErr(w, http.StatusNotImplemented, "config api not available") - return - } - cfg, err := p.cfg.LoadConfig() - if err != nil { - writeErr(w, http.StatusInternalServerError, "load config: "+err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "path": p.cfg.ConfigPath, - "config": cfg, - }) -} - -// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。 -// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。 -func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) { - if p.cfg.SaveConfig == nil { - writeErr(w, http.StatusNotImplemented, "config api not available") - return - } - raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - writeErr(w, http.StatusBadRequest, "read body: "+err.Error()) - return - } - restartRequired, err := p.cfg.SaveConfig(raw) - if err != nil { - writeErr(w, http.StatusBadRequest, err.Error()) - return - } - if restartRequired == nil { - restartRequired = []string{} - } - log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired)) - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "restart_required": restartRequired, - }) -} +// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。 +// +// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做 +// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成 +// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。 +package panel + +import ( + "io" + "log" + "net/http" +) + +// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。 +func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) { + if p.cfg.LoadConfig == nil { + writeErr(w, http.StatusNotImplemented, "config api not available") + return + } + cfg, err := p.cfg.LoadConfig() + if err != nil { + writeErr(w, http.StatusInternalServerError, "load config: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "path": p.cfg.ConfigPath, + "config": cfg, + }) +} + +// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。 +// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。 +func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) { + if p.cfg.SaveConfig == nil { + writeErr(w, http.StatusNotImplemented, "config api not available") + return + } + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeErr(w, http.StatusBadRequest, "read body: "+err.Error()) + return + } + restartRequired, err := p.cfg.SaveConfig(raw) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if restartRequired == nil { + restartRequired = []string{} + } + log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired)) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "restart_required": restartRequired, + }) +} diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index d9d3873026875b53875bd108a47c9fa0d121c869..f3d8066232afb04e58bb5aea5da9ef37f85fb4d5 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -1,647 +1,769 @@ -package panel - -import ( - "bytes" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" -) - -// TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。 -// -// 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容—— -// 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量) -// 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。 -// 无 node 环境时跳过(不阻塞无 Node 的构建机)。 -func TestAppJSSyntax(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Skip("node not available; skipping JS syntax check") - } - path, err := filepath.Abs("app.js") - if err != nil { - t.Fatal(err) - } - out, err := exec.Command(node, "--check", path).CombinedOutput() - if err != nil { - t.Fatalf("app.js syntax error:\n%s", out) - } -} - -// TestIndexHTMLNoInlineScript index.html 不得含内联 - - + + + + + +WorkBuddy2API · 控制台 + + + +
+ + +
+
+

账号池

+ / + - +
+ + + +
+ + +
+
+
-
账号总数
+
-
可用
+
-
冷却中
+
-
已禁用
+
-
积分剩余 / 总额
+
-
粘性会话
+
+ + + +
+
+

账号池

+ + + + + + +
+
+ + + + + + + +
账号状态积分成功 / 失败在途用量最近成功
+
+
+ +
+
+

模型锁池 哪些模型不能用、还要锁多久

+ + +
+
+ + + + + + +
模型域状态可选 / 总数锁定账号最早解锁全池解锁原因
+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+

积分任务

+
查询上游任务进度;「接受」为报名(幂等),「领取」在进度达标后可用。所有操作走网关,无需外部脚本。
+
+
+
加载中
+
+ + + + +
+
+
+ + + + + +
+
+
+ + +
+
+
+

开学季 · 我的券码

+
抽奖抽中的第三方券(KFC / 瑞幸 / 酷狗等),券码到店到对应app/小程序兑换。
+
+
+
+ + + + +
+
+
+ + +
+
+
+

添加账号

+
+ + +
+
+
+ +
+
+
+ 版本: + + +
+
国际版登录后,网关自动完成注册地区、激活与试用额度领取,全程无需手动操作。
+
+ + + + +
+ + +
+
+ + + + + +
+
+
+ + +
+
+
+

需要访问密钥

+
该网关已启用 api_key 鉴权,请输入 config.json 中的密钥。
+
+
+
+ +
+
+ + +
+
+
+ +
+ + + + diff --git a/internal/panel/login.go b/internal/panel/login.go index c11fd477afc46337d503eb9721cb00df797d5f3e..fe27dc0a53d00561073f3c64f52e33cb3ddb7b06 100644 --- a/internal/panel/login.go +++ b/internal/panel/login.go @@ -1,322 +1,322 @@ -// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。 -// -// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp, -// 原方案在 Windows 上不可用),返回授权 URL; -// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false, -// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-.json、热加载进池 -// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。 -// -// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。 -package panel - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -const ( - upstreamBaseCN = "https://copilot.tencent.com" - upstreamBaseGlobal = "https://www.workbuddy.ai" - clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" - originRefererCN = "https://www.codebuddy.cn" - originRefererGlobal = "https://www.workbuddy.ai" -) - -// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。 -// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。 -func loginEndpoints(realm string) (state, token, account, origin string) { - if realm == "global" { - base := upstreamBaseGlobal - return base + "/v2/plugin/auth/state?platform=CLI", - base + "/v2/plugin/auth/token?state=", - base + "/v2/plugin/login/account?state=", - originRefererGlobal - } - base := upstreamBaseCN - return base + "/v2/plugin/auth/state?platform=CLI", - base + "/v2/plugin/auth/token?state=", - base + "/v2/plugin/login/account?state=", - originRefererCN -} - -// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。 -var loginHTTP = &http.Client{Timeout: 30 * time.Second} - -func commonHeaders(req *http.Request, origin string) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("X-Requested-With", "XMLHttpRequest") - req.Header.Set("Origin", origin) - req.Header.Set("Referer", origin+"/") - req.Header.Set("User-Agent", clientUA) -} - -// validUID 校验上游返回的 uid 是否可安全用于拼文件名。 -// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态), -// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。 -func validUID(uid string) bool { - if uid == "" || len(uid) > 64 { - return false - } - for _, c := range uid { - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': - default: - return false - } - } - return true -} - -// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。 -type apiEnvelope struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` -} - -// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。 -func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) { - req, err := http.NewRequest(method, fullURL, body) - if err != nil { - return nil, 0, err - } - commonHeaders(req, origin) - if bearer != "" { - req.Header.Set("Authorization", "Bearer "+bearer) - } - resp, err := loginHTTP.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode >= 300 { - return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) - } - var env apiEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) - } - if env.Code != 0 { - return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) - } - return env.Data, resp.StatusCode, nil -} - -// loginStart 发起设备授权:POST auth/state 拿授权 URL。 -// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。 -func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) { - realm := "cn" - if r.Body != nil { - var reqBody struct { - Realm string `json:"realm"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil { - if reqBody.Realm == "global" { - realm = "global" - } - } - } - epState, _, _, origin := loginEndpoints(realm) - data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin) - if err != nil { - writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err)) - return - } - var st struct { - State string `json:"state"` - AuthURL string `json:"authUrl"` - } - if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { - writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl") - return - } - p.loginMu.Lock() - // 顺手回收过期会话,防"开弹窗走开"的 state 滞留。 - for s, sess := range p.logins { - if time.Since(sess.created) > loginTTL { - delete(p.logins, s) - } - } - p.logins[st.State] = loginSession{created: time.Now(), realm: realm} - p.loginMu.Unlock() - log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))]) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm}) -} - -// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。 -func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) { - state := r.URL.Query().Get("state") - if state == "" { - writeErr(w, http.StatusBadRequest, "missing state") - return - } - p.loginMu.Lock() - sess, known := p.logins[state] - p.loginMu.Unlock() - if !known { - writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)") - return - } - _, epToken, epAcct, origin := loginEndpoints(sess.realm) - - // auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。 - tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin) - if err != nil { - // pending / 未完成:面板前端继续轮询。 - writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()}) - return - } - var tok struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int64 `json:"expiresIn"` - Domain string `json:"domain"` - } - if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { - writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"}) - return - } - - // 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。 - var acct struct { - UID string `json:"uid"` - EnterpriseID string `json:"enterpriseId"` - Nickname string `json:"nickname"` - } - if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil { - _ = json.Unmarshal(acctRaw, &acct) - } - if acct.UID == "" { - writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)") - return - } - // UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用 - // (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。 - // UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。 - if !validUID(acct.UID) { - writeErr(w, http.StatusBadGateway, "上游返回的 uid 含非法字符,拒绝落盘(防路径穿越)") - return - } - - // 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。 - if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil { - writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error()) - return - } - a := &auth.Auth{ - AccessToken: tok.AccessToken, - RefreshToken: tok.RefreshToken, - ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(), - Domain: tok.Domain, - UID: acct.UID, - EnterpriseID: acct.EnterpriseID, - Nickname: acct.Nickname, - FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)), - } - // global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。 - if sess.realm == "global" { - if _, err := auth.BackfillRealmFor(a, "global"); err != nil { - writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error()) - return - } - } else { - // CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。 - _, _ = a.BackfillRealm() - } - if err := a.SaveAtomic(); err != nil { - writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error()) - return - } - p.cfg.Pool.Add(a) - p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断 - - // 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。 - // realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取 - // (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。 - checkinMsg := "" - remain := int64(-1) - total := int64(0) - if sess.realm == "global" { - // 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。 - // 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。 - if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil { - checkinMsg = "注册激活失败: " + err.Error() - log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err) - } else if activated { - log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID) - } - // trial 加油包(幂等 14051 = 已领过,非错误)。 - if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil { - checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error()) - log.Printf("panel: global trial uid=%s: %v", acct.UID, err) - } else if claimed { - log.Printf("panel: global trial uid=%s 已领", acct.UID) - } - } else { - if err := p.cfg.Upstream.DailyCheckin(a); err != nil { - checkinMsg = err.Error() - } - } - if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil { - remain, total = rm, tt - p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt) - } - - p.loginMu.Lock() - delete(p.logins, state) - p.loginMu.Unlock() - log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm) - writeJSON(w, http.StatusOK, map[string]any{ - "done": true, - "uid": acct.UID, - "nickname": acct.Nickname, - "realm": sess.realm, - "credits": remain, - "credits_total": total, - "checkin_message": checkinMsg, - }) -} - -// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。 -func joinMsg(parts ...string) string { - out := "" - for _, s := range parts { - if s == "" { - continue - } - if out != "" { - out += ";" - } - out += s - } - return out -} - -// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。 -// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。 -func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) { - // 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。 - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "regions": []map[string]string{ - {"code": "HK", "name": "Hong Kong"}, - {"code": "MO", "name": "Macao"}, - {"code": "SG", "name": "Singapore"}, - {"code": "TH", "name": "Thailand"}, - {"code": "PH", "name": "Philippines"}, - {"code": "MY", "name": "Malaysia"}, - {"code": "ID", "name": "Indonesia"}, - }, - }) -} +// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。 +// +// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp, +// 原方案在 Windows 上不可用),返回授权 URL; +// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false, +// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-.json、热加载进池 +// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。 +// +// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。 +package panel + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + upstreamBaseCN = "https://copilot.tencent.com" + upstreamBaseGlobal = "https://www.workbuddy.ai" + clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。 +// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。 +func loginEndpoints(realm string) (state, token, account, origin string) { + if realm == "global" { + base := upstreamBaseGlobal + return base + "/v2/plugin/auth/state?platform=CLI", + base + "/v2/plugin/auth/token?state=", + base + "/v2/plugin/login/account?state=", + originRefererGlobal + } + base := upstreamBaseCN + return base + "/v2/plugin/auth/state?platform=CLI", + base + "/v2/plugin/auth/token?state=", + base + "/v2/plugin/login/account?state=", + originRefererCN +} + +// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。 +var loginHTTP = &http.Client{Timeout: 30 * time.Second} + +func commonHeaders(req *http.Request, origin string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", clientUA) +} + +// validUID 校验上游返回的 uid 是否可安全用于拼文件名。 +// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态), +// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。 +func validUID(uid string) bool { + if uid == "" || len(uid) > 64 { + return false + } + for _, c := range uid { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + default: + return false + } + } + return true +} + +// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。 +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。 +func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + commonHeaders(req, origin) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := loginHTTP.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +// loginStart 发起设备授权:POST auth/state 拿授权 URL。 +// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。 +func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) { + realm := "cn" + if r.Body != nil { + var reqBody struct { + Realm string `json:"realm"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil { + if reqBody.Realm == "global" { + realm = "global" + } + } + } + epState, _, _, origin := loginEndpoints(realm) + data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin) + if err != nil { + writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err)) + return + } + var st struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { + writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl") + return + } + p.loginMu.Lock() + // 顺手回收过期会话,防"开弹窗走开"的 state 滞留。 + for s, sess := range p.logins { + if time.Since(sess.created) > loginTTL { + delete(p.logins, s) + } + } + p.logins[st.State] = loginSession{created: time.Now(), realm: realm} + p.loginMu.Unlock() + log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))]) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm}) +} + +// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。 +func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + if state == "" { + writeErr(w, http.StatusBadRequest, "missing state") + return + } + p.loginMu.Lock() + sess, known := p.logins[state] + p.loginMu.Unlock() + if !known { + writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)") + return + } + _, epToken, epAcct, origin := loginEndpoints(sess.realm) + + // auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。 + tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin) + if err != nil { + // pending / 未完成:面板前端继续轮询。 + writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()}) + return + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + } + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"}) + return + } + + // 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。 + var acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } + if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil { + _ = json.Unmarshal(acctRaw, &acct) + } + if acct.UID == "" { + writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)") + return + } + // UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用 + // (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。 + // UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。 + if !validUID(acct.UID) { + writeErr(w, http.StatusBadGateway, "上游返回的 uid 含非法字符,拒绝落盘(防路径穿越)") + return + } + + // 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。 + if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil { + writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error()) + return + } + a := &auth.Auth{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(), + Domain: tok.Domain, + UID: acct.UID, + EnterpriseID: acct.EnterpriseID, + Nickname: acct.Nickname, + FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)), + } + // global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。 + if sess.realm == "global" { + if _, err := auth.BackfillRealmFor(a, "global"); err != nil { + writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error()) + return + } + } else { + // CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。 + _, _ = a.BackfillRealm() + } + if err := a.SaveAtomic(); err != nil { + writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error()) + return + } + p.cfg.Pool.Add(a) + p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断 + + // 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。 + // realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取 + // (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。 + checkinMsg := "" + remain := int64(-1) + total := int64(0) + if sess.realm == "global" { + // 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。 + // 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。 + if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil { + checkinMsg = "注册激活失败: " + err.Error() + log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err) + } else if activated { + log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID) + } + // trial 加油包(幂等 14051 = 已领过,非错误)。 + if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil { + checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error()) + log.Printf("panel: global trial uid=%s: %v", acct.UID, err) + } else if claimed { + log.Printf("panel: global trial uid=%s 已领", acct.UID) + } + } else { + if err := p.cfg.Upstream.DailyCheckin(a); err != nil { + checkinMsg = err.Error() + } + } + if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil { + remain, total = rm, tt + p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt) + } + + p.loginMu.Lock() + delete(p.logins, state) + p.loginMu.Unlock() + log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm) + writeJSON(w, http.StatusOK, map[string]any{ + "done": true, + "uid": acct.UID, + "nickname": acct.Nickname, + "realm": sess.realm, + "credits": remain, + "credits_total": total, + "checkin_message": checkinMsg, + }) +} + +// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。 +func joinMsg(parts ...string) string { + out := "" + for _, s := range parts { + if s == "" { + continue + } + if out != "" { + out += ";" + } + out += s + } + return out +} + +// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。 +// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。 +func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) { + // 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。 + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "regions": []map[string]string{ + {"code": "HK", "name": "Hong Kong"}, + {"code": "MO", "name": "Macao"}, + {"code": "SG", "name": "Singapore"}, + {"code": "TH", "name": "Thailand"}, + {"code": "PH", "name": "Philippines"}, + {"code": "MY", "name": "Malaysia"}, + {"code": "ID", "name": "Indonesia"}, + }, + }) +} diff --git a/internal/panel/login_realm_test.go b/internal/panel/login_realm_test.go index 3c246483d5aa314dc6e987191531594f8fee96a3..ccec1229822ca31cb188c0b8547baccff5e7e7fe 100644 --- a/internal/panel/login_realm_test.go +++ b/internal/panel/login_realm_test.go @@ -1,43 +1,43 @@ -package panel - -import ( - "testing" -) - -// TestLoginEndpoints realm→端点映射:global 三端点全在 workbuddy.ai 域, -// cn/空/非法值 → copilot.tencent.com(零回归兜底)。 -func TestLoginEndpoints(t *testing.T) { - cases := []struct { - realm string - wantState string - wantOrigin string - }{ - {"global", "https://www.workbuddy.ai/v2/plugin/auth/state?platform=CLI", "https://www.workbuddy.ai"}, - {"cn", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, - {"", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, - {"weird", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, - } - for _, c := range cases { - t.Run(c.realm, func(t *testing.T) { - st, tok, acct, origin := loginEndpoints(c.realm) - if st != c.wantState { - t.Errorf("realm=%q state=%q want %q", c.realm, st, c.wantState) - } - if origin != c.wantOrigin { - t.Errorf("realm=%q origin=%q want %q", c.realm, origin, c.wantOrigin) - } - // token/account 端点必与 state 同 base。 - if c.realm == "global" { - if tok[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" || - acct[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" { - t.Errorf("global token/account endpoints should be workbuddy.ai: %q %q", tok, acct) - } - } else { - if tok[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" || - acct[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" { - t.Errorf("cn token/account endpoints should be copilot.tencent.com: %q %q", tok, acct) - } - } - }) - } -} +package panel + +import ( + "testing" +) + +// TestLoginEndpoints realm→端点映射:global 三端点全在 workbuddy.ai 域, +// cn/空/非法值 → copilot.tencent.com(零回归兜底)。 +func TestLoginEndpoints(t *testing.T) { + cases := []struct { + realm string + wantState string + wantOrigin string + }{ + {"global", "https://www.workbuddy.ai/v2/plugin/auth/state?platform=CLI", "https://www.workbuddy.ai"}, + {"cn", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + {"", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + {"weird", "https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI", "https://www.codebuddy.cn"}, + } + for _, c := range cases { + t.Run(c.realm, func(t *testing.T) { + st, tok, acct, origin := loginEndpoints(c.realm) + if st != c.wantState { + t.Errorf("realm=%q state=%q want %q", c.realm, st, c.wantState) + } + if origin != c.wantOrigin { + t.Errorf("realm=%q origin=%q want %q", c.realm, origin, c.wantOrigin) + } + // token/account 端点必与 state 同 base。 + if c.realm == "global" { + if tok[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" || + acct[:len("https://www.workbuddy.ai")] != "https://www.workbuddy.ai" { + t.Errorf("global token/account endpoints should be workbuddy.ai: %q %q", tok, acct) + } + } else { + if tok[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" || + acct[:len("https://copilot.tencent.com")] != "https://copilot.tencent.com" { + t.Errorf("cn token/account endpoints should be copilot.tencent.com: %q %q", tok, acct) + } + } + }) + } +} diff --git a/internal/panel/model_probes_test.go b/internal/panel/model_probes_test.go index 6e5dd06b592fd7dd8ccc3b44e3465fc0b0b85501..ead3be6cf4d700f820a9218706a6697fe9e92336 100644 --- a/internal/panel/model_probes_test.go +++ b/internal/panel/model_probes_test.go @@ -1,92 +1,92 @@ -package panel - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -// model_probes 端点三态:文件存在 → 透传 + exists:true + updated_at; -// 未配置 / 文件缺失 → 空集(面板退化为无标注);文件损坏 → 502。 -// 契约字段(claimed/measured/verdict…)由前端消费,网关只透传不解析。 -func TestModelProbesEndpoint(t *testing.T) { - dir := t.TempDir() - f := filepath.Join(dir, "output_probes.json") - body := `{"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000,` + - `"verdict":"clamped","note":"钳制","tested_at":"2026-09-15 18:30:00","source":"probe_max_tokens.py"}}}` - if err := os.WriteFile(f, []byte(body), 0o600); err != nil { - t.Fatal(err) - } - - get := func(cfg Config) *httptest.ResponseRecorder { - p := New(cfg) - req := httptest.NewRequest("GET", "/panel/api/model_probes", nil) - req.Header.Set("Authorization", "Bearer test-key") - rec := httptest.NewRecorder() - p.ServeHTTP(rec, req) - return rec - } - - // 1) 文件存在:透传 + exists + updated_at - rec := get(Config{Version: "test", APIKey: "test-key", ProbeFile: f}) - if rec.Code != http.StatusOK { - t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) - } - var got struct { - Probes map[string]json.RawMessage `json:"probes"` - Exists bool `json:"exists"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if !got.Exists || len(got.Probes) != 1 || got.UpdatedAt == "" { - t.Fatalf("exists=%v probes=%d updated_at=%q", got.Exists, len(got.Probes), got.UpdatedAt) - } - var rec1 struct { - Verdict string `json:"verdict"` - Measured int64 `json:"measured"` - } - if err := json.Unmarshal(got.Probes["cn:glm-5.2"], &rec1); err != nil { - t.Fatal(err) - } - if rec1.Verdict != "clamped" || rec1.Measured != 48000 { - t.Fatalf("probe passthrough = %+v", rec1) - } - - // 2) 文件缺失:空集 200(不是错误——无数据 = 无标注)。 - // 注意每个 case 用全新结构体:向已非 nil 的 map 再次 Unmarshal 是合并不是替换。 - rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: filepath.Join(dir, "nope.json")}) - if rec.Code != http.StatusOK { - t.Fatalf("missing file: code=%d want 200", rec.Code) - } - var gotEmpty struct { - Probes map[string]json.RawMessage `json:"probes"` - Exists bool `json:"exists"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &gotEmpty); err != nil { - t.Fatal(err) - } - if gotEmpty.Exists || len(gotEmpty.Probes) != 0 { - t.Fatalf("missing file: exists=%v probes=%d, want false/0", gotEmpty.Exists, len(gotEmpty.Probes)) - } - - // 3) 未配置:同文件缺失 - rec = get(Config{Version: "test", APIKey: "test-key"}) - if rec.Code != http.StatusOK { - t.Fatalf("unconfigured: code=%d want 200", rec.Code) - } - - // 4) 文件损坏:502(让面板显示读取失败而不是静默空白) - bad := filepath.Join(dir, "bad.json") - if err := os.WriteFile(bad, []byte(`{not json`), 0o600); err != nil { - t.Fatal(err) - } - rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: bad}) - if rec.Code != http.StatusBadGateway { - t.Fatalf("corrupt file: code=%d want 502", rec.Code) - } -} +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// model_probes 端点三态:文件存在 → 透传 + exists:true + updated_at; +// 未配置 / 文件缺失 → 空集(面板退化为无标注);文件损坏 → 502。 +// 契约字段(claimed/measured/verdict…)由前端消费,网关只透传不解析。 +func TestModelProbesEndpoint(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "output_probes.json") + body := `{"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000,` + + `"verdict":"clamped","note":"钳制","tested_at":"2026-09-15 18:30:00","source":"probe_max_tokens.py"}}}` + if err := os.WriteFile(f, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + get := func(cfg Config) *httptest.ResponseRecorder { + p := New(cfg) + req := httptest.NewRequest("GET", "/panel/api/model_probes", nil) + req.Header.Set("Authorization", "Bearer test-key") + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec + } + + // 1) 文件存在:透传 + exists + updated_at + rec := get(Config{Version: "test", APIKey: "test-key", ProbeFile: f}) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Probes map[string]json.RawMessage `json:"probes"` + Exists bool `json:"exists"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.Exists || len(got.Probes) != 1 || got.UpdatedAt == "" { + t.Fatalf("exists=%v probes=%d updated_at=%q", got.Exists, len(got.Probes), got.UpdatedAt) + } + var rec1 struct { + Verdict string `json:"verdict"` + Measured int64 `json:"measured"` + } + if err := json.Unmarshal(got.Probes["cn:glm-5.2"], &rec1); err != nil { + t.Fatal(err) + } + if rec1.Verdict != "clamped" || rec1.Measured != 48000 { + t.Fatalf("probe passthrough = %+v", rec1) + } + + // 2) 文件缺失:空集 200(不是错误——无数据 = 无标注)。 + // 注意每个 case 用全新结构体:向已非 nil 的 map 再次 Unmarshal 是合并不是替换。 + rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: filepath.Join(dir, "nope.json")}) + if rec.Code != http.StatusOK { + t.Fatalf("missing file: code=%d want 200", rec.Code) + } + var gotEmpty struct { + Probes map[string]json.RawMessage `json:"probes"` + Exists bool `json:"exists"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &gotEmpty); err != nil { + t.Fatal(err) + } + if gotEmpty.Exists || len(gotEmpty.Probes) != 0 { + t.Fatalf("missing file: exists=%v probes=%d, want false/0", gotEmpty.Exists, len(gotEmpty.Probes)) + } + + // 3) 未配置:同文件缺失 + rec = get(Config{Version: "test", APIKey: "test-key"}) + if rec.Code != http.StatusOK { + t.Fatalf("unconfigured: code=%d want 200", rec.Code) + } + + // 4) 文件损坏:502(让面板显示读取失败而不是静默空白) + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte(`{not json`), 0o600); err != nil { + t.Fatal(err) + } + rec = get(Config{Version: "test", APIKey: "test-key", ProbeFile: bad}) + if rec.Code != http.StatusBadGateway { + t.Fatalf("corrupt file: code=%d want 502", rec.Code) + } +} diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 4148a43317d89086c4ba8a7e659ccc186a536736..9f7d7bfdb819c5eacb29e9534ba6f744e18b6fa2 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -1,816 +1,845 @@ -// Package panel 内嵌式 Web 管理面板:账号池总览、单号运维(解冻/禁用/签到/ -// 刷新余额/移除)、浏览器内 OAuth 添加账号(免重启热加载进池)、手动批量 -// 签到/保活,以及运行日志环形缓冲(镜像 log 包与 chat 表格日志)。 -// -// 设计约束: -// - 前端 go:embed 单文件(index.html),无任何外部构建依赖,与二进制同体部署; -// - 鉴权复用网关 api_key(Bearer),与 /v1/* 同一口径;api_key 为空 = 不鉴权 -// (仅本机/私网使用)。面板 HTML 本身无秘密,可匿名加载,密钥只发给 /panel/api/*; -// - 不改写既有池语义:所有运维操作落到 pool 已有入口(Revive/Disable/Remove...), -// 添加账号走 auth.SaveAtomic + pool.Add,重启后与 auths/ 目录天然对齐。 -package panel - -import ( - "encoding/json" - "log" - "net/http" - "os" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/httpauth" - "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" - "github.com/linguo2625469/workbuddy2api-panel/internal/pool" - "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" - "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" - "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" - "github.com/linguo2625469/workbuddy2api-panel/internal/usage" -) - -// Config 面板依赖(main 装配注入)。 -type Config struct { - Pool *pool.Pool - Upstream *upstream.Client - Scheduler *scheduler.Scheduler // 手动触发签到/保活;nil 时对应接口返回 501 - AuthDir string // OAuth 登录完成后凭证落盘目录 - APIKey string // 空 = 不鉴权(与主服务同语义);与 Live 同时给出时 Live 优先 - RedisMode string // "upstash" / "noop",仅观测透出 - Version string // 面板版本号(展示用) - - // Live 运行期可变配置(在线改配置立即生效)。 - Live *livecfg.Holder - - // ConfigPath config.json 路径与加载器(配置页读写用)。 - // LoadConfig 返回解析后的配置对象(前端展示/校验用,具体类型由 main 注入的闭包决定); - // nil 时配置页返回 501。 - ConfigPath string - LoadConfig func() (any, error) - // SaveConfig 校验并落盘配置,返回需要重启才能生效的字段列表;随后由 main 注入的 - // ApplyConfig 闭包完成热生效(池参数/排程/密钥/脱敏)。error 时配置不写盘。 - SaveConfig func(raw []byte) (restartRequired []string, err error) - - // StickyCount 返回粘性会话绑定数;nil 时报告 0。 - StickyCount func() int - - // Usage 逐请求用量记录器(nil = 用量接口返回 501)。 - Usage *usage.Recorder - // RequestLog 请求指标与归档(nil = 对应接口返回 501)。 - RequestLog *reqlog.Recorder - - // ProbeFile 模型输出上限探测结果文件(scripts/probe_max_tokens.py --panel-out - // 写入;空或文件不存在 = model_probes 端点返回空集,面板不显示任何实测标注)。 - // 只读展示:网关不解析、不依赖其内容做任何路由/出站决策。 - ProbeFile string -} - -// Panel 管理面板 handler。挂载方式:外层 mux Handle("/panel/", panel), -// 本 mux 的 pattern 均带 /panel 前缀(外层不做前缀剥离)。 -type Panel struct { - cfg Config - mux *http.ServeMux - started time.Time - logs *Ring - - // logins 进行中的 OAuth 设备授权会话(state → 会话信息)。 - // poll 成功或超时(loginTTL)后剔除;面板常驻进程,容量天然有界。 - loginMu sync.Mutex - logins map[string]loginSession - - // taskMu/taskLocks 一键完成任务的 per-account 互斥:同一账号的任务动作 - // (单任务 / 全量)同时只允许一条在跑。重复点击直接返回 409"仍在执行", - // 而不是并发跑两遍浪费上游请求(动作虽幂等,expert 系每遍含 8 次真实对话)。 - // 不同账号之间不互斥(并行照旧)。TryLock 语义,锁条目常驻(账号数有界)。 - taskMu sync.Mutex - taskLocks map[string]*sync.Mutex - - // 任务中心执行队列(taskcenter.go)。 - queueOnce sync.Once - q *queueState -} - -// tryLockAccount 尝试锁定账号的任务执行;已在执行返回 false。 -func (p *Panel) tryLockAccount(uid string) bool { - p.taskMu.Lock() - if p.taskLocks == nil { - p.taskLocks = make(map[string]*sync.Mutex) - } - mu := p.taskLocks[uid] - if mu == nil { - mu = &sync.Mutex{} - p.taskLocks[uid] = mu - } - p.taskMu.Unlock() - return mu.TryLock() -} - -// unlockAccount 释放账号任务锁(与 tryLockAccount 配对)。 -func (p *Panel) unlockAccount(uid string) { - p.taskMu.Lock() - mu := p.taskLocks[uid] - p.taskMu.Unlock() - if mu != nil { - mu.Unlock() - } -} - -// loginTTL 授权 URL 的最长有效期:超时的 state 直接回收, -// 防止"开了添加账号弹窗就走开"的会话永久滞留。 -const loginTTL = 15 * time.Minute - -// loginSession 进行中的 OAuth 会话:创建时刻 + realm(cn/global,用于落盘与端点切换)。 -type loginSession struct { - created time.Time - realm string // "cn" / "global",缺省 cn -} - -// New 构建面板。 -func New(cfg Config) *Panel { - if cfg.RedisMode == "" { - cfg.RedisMode = "noop" - } - p := &Panel{ - cfg: cfg, - mux: http.NewServeMux(), - started: time.Now(), - logs: NewRing(500), - logins: map[string]loginSession{}, - } - p.routes() - return p -} - -// Logs 返回日志环形缓冲(main 经 MultiWriter 镜像 log 与 chat 表格日志进来)。 -func (p *Panel) Logs() *Ring { return p.logs } - -func (p *Panel) routes() { - p.mux.HandleFunc("GET /panel/{$}", p.index) - p.mux.HandleFunc("GET /panel/app.js", p.appScript) - p.mux.HandleFunc("GET /panel/api/overview", p.withAuth(p.overview)) - p.mux.HandleFunc("GET /panel/api/logs", p.withAuth(p.logsHandler)) - p.mux.HandleFunc("GET /panel/api/request_metrics", p.withAuth(p.requestMetrics)) - p.mux.HandleFunc("GET /panel/api/request_logs", p.withAuth(p.requestLogs)) - p.mux.HandleFunc("GET /panel/api/models", p.withAuth(p.models)) - p.mux.HandleFunc("POST /panel/api/login/start", p.withAuth(p.loginStart)) - p.mux.HandleFunc("GET /panel/api/login/poll", p.withAuth(p.loginPoll)) - p.mux.HandleFunc("GET /panel/api/login/regions", p.withAuth(p.loginRegions)) - p.mux.HandleFunc("POST /panel/api/import/cockpit", p.withAuth(p.importCockpit)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/revive", p.withAuth(p.accountRevive)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/disable", p.withAuth(p.accountDisable)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/checkin", p.withAuth(p.accountCheckin)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/balance", p.withAuth(p.accountBalance)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/remove", p.withAuth(p.accountRemove)) - p.mux.HandleFunc("GET /panel/api/accounts/{uid}/tasks", p.withAuth(p.accountTasks)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept", p.withAuth(p.accountTaskAccept)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept_all", p.withAuth(p.taskAcceptAll)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/claim", p.withAuth(p.accountTaskClaim)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto", p.withAuth(p.accountTaskAuto)) - p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto_all", p.withAuth(p.accountTaskAutoAll)) - p.mux.HandleFunc("POST /panel/api/tasks/scan_all", p.withAuth(p.tasksScanAll)) - p.mux.HandleFunc("POST /panel/api/tasks/run_queue", p.withAuth(p.tasksRunQueue)) - p.mux.HandleFunc("GET /panel/api/tasks/queue", p.withAuth(p.tasksQueueStatus)) - p.mux.HandleFunc("GET /panel/api/school/vouchers", p.withAuth(p.schoolVouchers)) - p.mux.HandleFunc("POST /panel/api/checkin_all", p.withAuth(p.checkinAll)) - p.mux.HandleFunc("POST /panel/api/travel_all", p.withAuth(p.travelAll)) - p.mux.HandleFunc("POST /panel/api/activity_all", p.withAuth(p.activityAll)) - p.mux.HandleFunc("POST /panel/api/keepalive_all", p.withAuth(p.keepaliveAll)) - p.mux.HandleFunc("POST /panel/api/balance_all", p.withAuth(p.balanceAll)) - p.mux.HandleFunc("GET /panel/api/packages", p.withAuth(p.packages)) - p.mux.HandleFunc("GET /panel/api/usage", p.withAuth(p.usage)) - p.mux.HandleFunc("POST /panel/api/usage/save", p.withAuth(p.usageSave)) - p.mux.HandleFunc("GET /panel/api/model_probes", p.withAuth(p.modelProbes)) - p.mux.HandleFunc("GET /panel/api/config", p.withAuth(p.getConfig)) - p.mux.HandleFunc("POST /panel/api/config", p.withAuth(p.saveConfig)) -} - -// ServeHTTP 统一入口:先写安全响应头再分发,保证页面、静态资源、API -// 与 401 错误响应全都带上(API 也可能在浏览器里被直接打开)。 -func (p *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request) { - setSecurityHeaders(w) - p.mux.ServeHTTP(w, r) -} - -// withAuth 与 server 包同口径的 Bearer 鉴权(经 httpauth 常量时间比较); -// api_key 为空时放行。密钥经 livecfg 快照读取:面板里改了 api_key,下一个请求 -// 即用新值(无需重启)。 -func (p *Panel) withAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !httpauth.VerifyBearer(r, p.apiKey()) { - writeErr(w, http.StatusUnauthorized, "invalid_api_key") - return - } - next(w, r) - } -} - -// apiKey 当前生效密钥(Live 优先,回落静态字段)。 -func (p *Panel) apiKey() string { - if p.cfg.Live != nil { - return p.cfg.Live.Load().APIKey - } - return p.cfg.APIKey -} - -// expiringSoonWindow 返回调度器当前生效的快过期路由窗口;测试面板无调度器时返回 0。 -func (p *Panel) expiringSoonWindow() time.Duration { - if p.cfg.Scheduler == nil { - return 0 - } - return p.cfg.Scheduler.ExpiringSoonWindow() -} - -// --------------------------------------------------------------------------- -// 只读接口 -// --------------------------------------------------------------------------- - -// overview 总览:池计数 + 每账号状态 + 面板元信息。 -func (p *Panel) overview(w http.ResponseWriter, r *http.Request) { - total, healthy, cooling, disabled, inFlightFull := p.cfg.Pool.CountsDetailed() - sticky := 0 - if p.cfg.StickyCount != nil { - sticky = p.cfg.StickyCount() - } - writeJSON(w, http.StatusOK, map[string]any{ - "version": p.cfg.Version, - "uptime_sec": int(time.Since(p.started).Seconds()), - "auth_required": p.apiKey() != "", - "redis_mode": p.cfg.RedisMode, - "sticky_sessions": sticky, - "total": total, - "healthy": healthy, - "cooling": cooling, - "disabled": disabled, - "in_flight_full": inFlightFull, - "accounts": p.cfg.Pool.List(), - }) -} - -// logsHandler 返回日志环形缓冲快照(时间升序,含频道标记 chat/task/sys)。 -func (p *Panel) logsHandler(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"entries": p.logs.Snapshot()}) -} - -// requestMetrics 返回进程内请求指标、最近 100 条与归档状态。 -func (p *Panel) requestMetrics(w http.ResponseWriter, r *http.Request) { - if p.cfg.RequestLog == nil { - writeErr(w, http.StatusNotImplemented, "request logger not available") - return - } - writeJSON(w, http.StatusOK, p.cfg.RequestLog.Snapshot()) -} - -// requestLogs 从 JSONL 归档读取最近请求;limit 默认 200、最大 1000。 -// 支持按 outcome/account/model/client_ip/user_agent 过滤(字符串字段为包含匹配) -// 与 from/to 时间区间(闭区间,unix 秒或 RFC3339)——面板「运行日志」的筛选框、 -// 来源查询与「今天 / 自定义区间」都走这里。 -func (p *Panel) requestLogs(w http.ResponseWriter, r *http.Request) { - if p.cfg.RequestLog == nil { - writeErr(w, http.StatusNotImplemented, "request logger not available") - return - } - limit := 200 - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - limit = n - } - } - if limit > 1000 { - limit = 1000 - } - q := r.URL.Query() - rows, err := p.cfg.RequestLog.ReadArchive(limit, reqlog.Filter{ - Outcome: q.Get("outcome"), - Account: q.Get("account"), - Model: q.Get("model"), - ClientIP: q.Get("client_ip"), - UserAgent: q.Get("user_agent"), - From: parseTimeParam(q.Get("from")), - To: parseTimeParam(q.Get("to")), - }) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - // 空结果回 [](而不是 JSON null):前端把 null 与"归档关闭"混在一起会走错分支, - // 显示成不满足筛选条件的最近请求。 - if rows == nil { - rows = []reqlog.Event{} - } - writeJSON(w, http.StatusOK, map[string]any{"entries": rows, "limit": limit}) -} - -// models 实时查询上游模型列表与 reasoning 实际档位(直连上游,不读路由层 1h 缓存): -// 回答"该模型到底支持哪几档思考"。顺带刷新 client 的 effort 降级能力缓存。 -// 与 /v1/models 同口径的双域输出:CN 域模型加 "cn:" 前缀、global 域加 "global:" 前缀 -// (gateway 路由协议,前端显示的 id 就是调用时要填的完整 model 值)。 -// 各域独立探测、独立容错:某域无可用账号则整域跳过;两域全空时才报错 -// (有错误明细回 502,一个账号都没有回 503)。 -func (p *Panel) models(w http.ResponseWriter, r *http.Request) { - out := make([]map[string]any, 0) - var fetchErrs []string - - // CN 域:有可用 CN 账号才查(此前无条件 Pool.Pick()+FetchModels——选中 global - // 账号时打 CN 端点必然失败,混合池表现为偶发 502,纯 global 池必炸)。 - if uids := p.cfg.Pool.AvailableUIDsForRealm("cn"); len(uids) > 0 { - if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { - infos, err := p.cfg.Upstream.FetchModels(acct) - if err != nil { - fetchErrs = append(fetchErrs, "cn: "+err.Error()) - } else { - for _, mi := range infos { - out = append(out, panelModelEntry("cn", mi, mi.Efforts, mi.DefaultEffort, p.cfg.Upstream.HTTP)) - } - } - } - } - - // global 域:路由开关开且有可用 global 账号才查(独立目录端点,FetchGlobalModelInfos; - // Upstream.GlobalEnabled 是探测侧同一道闸,与 main 装配的 config global.enabled 一致)。 - if p.cfg.Upstream.GlobalEnabled { - if uids := p.cfg.Pool.AvailableUIDsForRealm("global"); len(uids) > 0 { - if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { - infos := p.cfg.Upstream.FetchGlobalModelInfos(acct) - if len(infos) == 0 { - fetchErrs = append(fetchErrs, "global: 上游未返回可用模型") - } else { - efforts, defaults := p.cfg.Upstream.GlobalEffortSnapshot() - for _, mi := range infos { - out = append(out, panelModelEntry("global", mi, efforts[mi.ID], defaults[mi.ID], p.cfg.Upstream.HTTP)) - } - } - } - } - } - - if len(out) == 0 { - if len(fetchErrs) > 0 { - writeErr(w, http.StatusBadGateway, "fetch models: "+strings.Join(fetchErrs, "; ")) - return - } - writeErr(w, http.StatusServiceUnavailable, "没有可用账号:请先在面板添加账号再查询") - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": out}) -} - -// panelModelEntry 构造单个模型条目(两域共用):id 带 realm 前缀(调用值即显示值), -// context_length / max_output_tokens 走四级查找链,effort 档位按 realm 域取 -// EffortListing(远端权威 ∪ 静态兜底表)——与 /v1/models 同一口径,两侧不再漂移。 -func panelModelEntry(realm string, mi upstream.ModelInfo, remoteEfforts []string, remoteDefault string, httpc *http.Client) map[string]any { - entry := map[string]any{ - "id": realm + ":" + mi.ID, - "name": mi.Name, - "default_effort": mi.DefaultEffort, - "supported_efforts": mi.Efforts, - "can_disable_thinking": mi.CanDisableThinking, - "supports_reasoning": mi.SupportsReasoning, - "supports_images": mi.SupportsImages, - "credits": mi.Credits, - "description": mi.Description, - "tags": mi.Tags, - "vendor": mi.Vendor, - "is_default": mi.IsDefault, - "supports_tool_call": mi.SupportsToolCall, - "only_reasoning": mi.OnlyReasoning, - "reasoning_effort": mi.ReasoningEffort, - "reasoning_summary": mi.ReasoningSummary, - } - // 限时优惠(modelPromotions):credits 是牌价,promo_* 是当前生效折扣 - //(WorkBuddy 客户端显示的就是这个生效价)。前端据此显示「生效价+标签+划线牌价」。 - if mi.PromoFactor != nil { - entry["promo_factor"] = *mi.PromoFactor - entry["promo_credits"] = mi.PromoCredits - } - if mi.PromoLabel != "" { - entry["promo_label"] = mi.PromoLabel - } - if mi.PromoNote != "" { - entry["promo_note"] = mi.PromoNote - } - if mi.MaxAllowedSize > 0 { - entry["max_allowed_size"] = mi.MaxAllowedSize - } - entry["context_length"] = upstream.ContextWindowListingV4(mi.ID, mi.ContextWindow, httpc) - if mo, ok := upstream.MaxOutputTokensListingV4(mi.ID, mi.MaxTokens, httpc); ok { - entry["max_output_tokens"] = mo - } - if efforts, def := upstream.EffortListing(realm, mi.ID, remoteEfforts, remoteDefault); efforts != nil { - entry["supported_efforts"] = efforts - if def != "" { - entry["default_effort"] = def - } - } - return entry -} - -// modelProbes 返回模型输出上限的探测结果(scripts/probe_max_tokens.py --panel-out -// 写入的契约文件),供前端在「模型与档位」的实测列做风险标注。 -// -// 设计边界:纯只读透传——文件缺失/未配置返回空集(面板退化为无标注,与历史行为 -// 一致),网关自身不解析字段语义、不据此做任何路由或出站决策;上游改了限制后 -// 重跑一次工具、下次查询即刷新,无需重启网关。 -func (p *Panel) modelProbes(w http.ResponseWriter, r *http.Request) { - out := map[string]any{"probes": map[string]json.RawMessage{}, "exists": false} - if p.cfg.ProbeFile == "" { - writeJSON(w, http.StatusOK, out) - return - } - raw, err := os.ReadFile(p.cfg.ProbeFile) - if err != nil { - if os.IsNotExist(err) { - writeJSON(w, http.StatusOK, out) - return - } - writeErr(w, http.StatusInternalServerError, "read probes: "+err.Error()) - return - } - var f struct { - Version int `json:"version"` - Probes map[string]json.RawMessage `json:"probes"` - } - if err := json.Unmarshal(raw, &f); err != nil { - writeErr(w, http.StatusBadGateway, "parse probes: "+err.Error()) - return - } - if f.Probes == nil { - f.Probes = map[string]json.RawMessage{} - } - out["probes"] = f.Probes - out["exists"] = true - if fi, err := os.Stat(p.cfg.ProbeFile); err == nil { - out["updated_at"] = fi.ModTime().Format(time.RFC3339) - } - writeJSON(w, http.StatusOK, out) -} - -// --------------------------------------------------------------------------- -// 账号运维 -// --------------------------------------------------------------------------- - -// accountRevive 手动复活:清禁用 + 冷却 + 熔断(运维口径无条件恢复)。 -func (p *Panel) accountRevive(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - if _, ok := p.cfg.Pool.Status(uid); !ok { - writeErr(w, http.StatusNotFound, "account not found") - return - } - p.cfg.Pool.Revive(uid) - log.Printf("panel: revive uid=%s(人工清除禁用/冷却/熔断)", uid) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// accountDisable 人工禁用(不再参与选号,需面板 revive 或重登恢复)。 -func (p *Panel) accountDisable(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - if _, ok := p.cfg.Pool.Status(uid); !ok { - writeErr(w, http.StatusNotFound, "account not found") - return - } - p.cfg.Pool.Disable(uid, "manual disable (panel)") - log.Printf("panel: disable uid=%s(人工禁用)", uid) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// accountCheckin 单号签到:DailyCheckin + 余额查询解冻(已签到等业务错误不阻塞余额刷新), -// 与 scheduler.RunCheckinNow 的单号语义一致。 -func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - a := p.cfg.Pool.AuthByUID(uid) - if a == nil { - writeErr(w, http.StatusNotFound, "account not found") - return - } - checkinMsg := "" - checkinDone := false - if err := p.cfg.Upstream.DailyCheckin(a); err != nil { - checkinMsg = err.Error() // "今天已签到"等业务错误照常查余额 - // 幂等拒绝同样是「今日已签」,标记后按钮显示「已签」。 - if upstream.IsAlreadyCheckin(err) { - p.cfg.Pool.NoteCheckinDone(uid) - checkinDone = true - } - } else { - p.cfg.Pool.NoteCheckinDone(uid) - checkinDone = true - } - resp := map[string]any{"ok": true, "checkin_done": checkinDone} - if checkinMsg != "" { - resp["checkin_message"] = checkinMsg - } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) - if err != nil { - resp["balance_error"] = err.Error() - writeJSON(w, http.StatusOK, resp) - return - } - p.cfg.Pool.ReenableIfCredits(uid, remain, total) - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) - resp["credits"] = remain - resp["credits_total"] = total - log.Printf("panel: checkin uid=%s msg=%q credits=%d/%d", uid, checkinMsg, remain, total) - writeJSON(w, http.StatusOK, resp) -} - -// accountBalance 单号余额刷新:更新余额与到期快照,不触碰冷却状态。 -func (p *Panel) accountBalance(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - a := p.cfg.Pool.AuthByUID(uid) - if a == nil { - writeErr(w, http.StatusNotFound, "account not found") - return - } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) - if err != nil { - writeErr(w, http.StatusBadGateway, "user resource: "+err.Error()) - return - } - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "credits": remain, "credits_total": total}) -} - -// accountRemove 移除账号:先出池(立即落盘 state),再删 auth 文件。 -func (p *Panel) accountRemove(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - a := p.cfg.Pool.Remove(uid) - if a == nil { - writeErr(w, http.StatusNotFound, "account not found") - return - } - fileMsg := "" - if a.FilePath != "" { - if err := os.Remove(a.FilePath); err != nil && !os.IsNotExist(err) { - fileMsg = err.Error() - } - } - if fileMsg != "" { - log.Printf("panel: remove uid=%s(auth 文件删除失败: %s)", uid, fileMsg) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "file_error": fileMsg}) - return - } - log.Printf("panel: remove uid=%s(已出池并删除凭证文件)", uid) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// --------------------------------------------------------------------------- -// 批量任务 -// --------------------------------------------------------------------------- - -// checkinAll 手动触发全量签到(异步执行,进度看日志区/账号状态变化)。 -func (p *Panel) checkinAll(w http.ResponseWriter, r *http.Request) { - if p.cfg.Scheduler == nil { - writeErr(w, http.StatusNotImplemented, "scheduler not available") - return - } - go p.cfg.Scheduler.RunCheckinNow() - log.Printf("panel: 手动全量签到已触发(含猫猫旅行)") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) -} - -// travelAll 手动触发全量猫猫旅行巡检(异步执行)。 -func (p *Panel) travelAll(w http.ResponseWriter, r *http.Request) { - if p.cfg.Scheduler == nil { - writeErr(w, http.StatusNotImplemented, "scheduler not available") - return - } - go p.cfg.Scheduler.RunTravelNow() - log.Printf("panel: 手动全量旅行巡检已触发") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) -} - -// activityAll 手动触发全量活跃上报(异步执行;点亮连登 + 解锁领养前置)。 -func (p *Panel) activityAll(w http.ResponseWriter, r *http.Request) { - if p.cfg.Scheduler == nil { - writeErr(w, http.StatusNotImplemented, "scheduler not available") - return - } - go p.cfg.Scheduler.RunActivityNow() - log.Printf("panel: 手动全量活跃上报已触发") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) -} - -// keepaliveAll 手动触发全量 token 保活(异步执行)。 -func (p *Panel) keepaliveAll(w http.ResponseWriter, r *http.Request) { - if p.cfg.Scheduler == nil { - writeErr(w, http.StatusNotImplemented, "scheduler not available") - return - } - go p.cfg.Scheduler.RunKeepaliveNow() - log.Printf("panel: 手动全量保活已触发") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) -} - -// balanceAll 手动全量刷新余额:并发查上游、写回池内 credits(含解冻语义), -// 完成后返回——面板紧接着拉 overview 即是最新值。账号量小(个位数), -// 同步等待(上限受短 RPC 超时约束)比"触发后盲刷"体验更确定。 -func (p *Panel) balanceAll(w http.ResponseWriter, r *http.Request) { - if p.cfg.Scheduler == nil { - writeErr(w, http.StatusNotImplemented, "scheduler not available") - return - } - p.cfg.Scheduler.RunBalanceRefreshNow() - p.syncNicknames() - log.Printf("panel: 手动全量余额刷新完成") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "accounts": p.cfg.Pool.List()}) -} - -// syncNicknames 手动刷新时的昵称同步(issue #94:上游改名免重登)。 -// 只在面板手动「刷新」路径调用——后台余额定时器不触发(用户明确要求资料接口 -// 仅手动触达)。逐号拉 /console/account,只取 nickname(手机号等敏感字段在 -// upstream.FetchAccountProfile 内即被丢弃);单号失败静默跳过,不打断余额刷新 -// 的既有结果。 -func (p *Panel) syncNicknames() { - type job struct { - uid string - a *auth.Auth - } - var jobs []job - for _, st := range p.cfg.Pool.List() { - if st.Disabled { - continue - } - if a := p.cfg.Pool.AuthByUID(st.UID); a != nil && a.AccessTokenValue() != "" { - jobs = append(jobs, job{uid: st.UID, a: a}) - } - } - if len(jobs) == 0 { - return - } - var ( - mu sync.Mutex - updated int - failed int - sem = make(chan struct{}, 3) - wg sync.WaitGroup - ) - for _, j := range jobs { - wg.Add(1) - go func(j job) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - nick, err := p.cfg.Upstream.FetchAccountProfile(j.a) - if err != nil { - mu.Lock() - failed++ - mu.Unlock() - return - } - if p.cfg.Pool.SetNickname(j.uid, nick) { - mu.Lock() - updated++ - mu.Unlock() - } - }(j) - } - wg.Wait() - if updated > 0 || failed > 0 { - log.Printf("panel: 昵称同步:更新 %d 个,失败 %d 个(未变化不计数)", updated, failed) - } -} - -// --------------------------------------------------------------------------- -// helpers -// --------------------------------------------------------------------------- - -// usage 返回逐请求用量聚合。统计窗口三选一: -// - from/to(unix 秒):显式区间,用于「今天」与「自定义」——区间由浏览器按 -// 本地时区算好再发,服务端时区与浏览器不一致时「今天」才不会被算错; -// - hours:滚动窗口(默认 72,上限 1440=60 天),卡片汇总/按域/按账号/按模型/ -// 时序**全部**按该窗口统计;显式 hours=0 表示全部历史(含 90 天前折叠出的日桶); -// - 都不给:等同于 hours=72(保持旧调用方行为)。 -func (p *Panel) usage(w http.ResponseWriter, r *http.Request) { - if p.cfg.Usage == nil { - writeErr(w, http.StatusNotImplemented, "usage recorder not available") - return - } - q := r.URL.Query() - win := usage.Window{} - win.From = parseTimeParam(q.Get("from")) - win.To = parseTimeParam(q.Get("to")) - if win.From.IsZero() && win.To.IsZero() { - win.Hours = 72 - if v := q.Get("hours"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - win.Hours = n - } - } - if win.Hours > 1440 { - win.Hours = 1440 - } - } - // 昵称仅用于展示,取自池快照(不含任何凭证)。 - nicks := map[string]string{} - for _, s := range p.cfg.Pool.List() { - if s.Nickname != "" { - nicks[s.UID] = s.Nickname - } - } - var currentRate func(realm, model string) string - if p.cfg.Upstream != nil { - currentRate = p.cfg.Upstream.ModelRate - } - writeJSON(w, http.StatusOK, p.cfg.Usage.SnapshotWindow(win, nicks, currentRate)) -} - -// parseTimeParam 解析时间查询参数:unix 秒(前端默认)或 RFC3339(便于手工调 -// 接口/写脚本)。空串与非法值都返回零值 = 该侧不设界,不报错——区间参数是可选 -// 增强,拼错一个 from 不该让整页用量打不开。 -func parseTimeParam(v string) time.Time { - v = strings.TrimSpace(v) - if v == "" { - return time.Time{} - } - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - if n <= 0 { - return time.Time{} - } - // 兼容秒与毫秒(前端可能直接把 Date.now() 传上来)。 - if n > 1e12 { - return time.UnixMilli(n) - } - return time.Unix(n, 0) - } - if t, err := time.Parse(time.RFC3339, v); err == nil { - return t - } - if t, err := time.ParseInLocation("2006-01-02T15:04", v, time.Local); err == nil { - return t - } - return time.Time{} -} - -// usageSave 立即把内存中的用量桶落盘(正常由后台 30s 防抖刷新负责)。 -func (p *Panel) usageSave(w http.ResponseWriter, r *http.Request) { - if p.cfg.Usage == nil { - writeErr(w, http.StatusNotImplemented, "usage recorder not available") - return - } - p.cfg.Usage.Save() - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// packages 返回全部账号的积分包构成,供「积分构成」视图对比。 -// -// 逐个账号向上游查(并发有上限,避免瞬时打满上游限流),失败只在对应账号上 -// 标 error,不影响其它账号——一个号 token 失效不该让整页空白。 -func (p *Panel) packages(w http.ResponseWriter, r *http.Request) { - accts := p.cfg.Pool.List() - type row struct { - UID string `json:"uid"` - Nickname string `json:"nickname"` - Realm string `json:"realm"` - Remain int64 `json:"remain"` - Size int64 `json:"size"` - Packages []upstream.CreditPackage `json:"packages"` - Error string `json:"error,omitempty"` - } - out := make([]row, len(accts)) - - sem := make(chan struct{}, 3) - var wg sync.WaitGroup - for i, s := range accts { - wg.Add(1) - go func(i int, s pool.Status) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - it := row{UID: s.UID, Nickname: s.Nickname, Realm: s.Realm} - a := p.cfg.Pool.AuthByUID(s.UID) - if a == nil { - it.Error = "account not loaded" - out[i] = it - return - } - packs, remain, size, err := p.cfg.Upstream.CreditPackages(a) - if err != nil { - it.Error = err.Error() - out[i] = it - return - } - it.Packages = packs - it.Remain = remain - it.Size = size - out[i] = it - }(i, s) - } - wg.Wait() - - // 余额降序:多的在前,便于和少的对比。 - sort.SliceStable(out, func(i, j int) bool { return out[i].Remain > out[j].Remain }) - writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - raw, _ := json.Marshal(v) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = w.Write(raw) -} - -func writeErr(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]any{"ok": false, "error": msg}) -} +// Package panel 内嵌式 Web 管理面板:账号池总览、单号运维(解冻/禁用/签到/ +// 刷新余额/移除)、浏览器内 OAuth 添加账号(免重启热加载进池)、手动批量 +// 签到/保活,以及运行日志环形缓冲(镜像 log 包与 chat 表格日志)。 +// +// 设计约束: +// - 前端 go:embed 单文件(index.html),无任何外部构建依赖,与二进制同体部署; +// - 鉴权复用网关 api_key(Bearer),与 /v1/* 同一口径;api_key 为空 = 不鉴权 +// (仅本机/私网使用)。面板 HTML 本身无秘密,可匿名加载,密钥只发给 /panel/api/*; +// - 不改写既有池语义:所有运维操作落到 pool 已有入口(Revive/Disable/Remove...), +// 添加账号走 auth.SaveAtomic + pool.Add,重启后与 auths/ 目录天然对齐。 +package panel + +import ( + "encoding/json" + "log" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/httpauth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "github.com/linguo2625469/workbuddy2api-panel/internal/usage" +) + +// Config 面板依赖(main 装配注入)。 +type Config struct { + Pool *pool.Pool + Upstream *upstream.Client + Scheduler *scheduler.Scheduler // 手动触发签到/保活;nil 时对应接口返回 501 + AuthDir string // OAuth 登录完成后凭证落盘目录 + APIKey string // 空 = 不鉴权(与主服务同语义);与 Live 同时给出时 Live 优先 + RedisMode string // "upstash" / "noop",仅观测透出 + Version string // 面板版本号(展示用) + + // Live 运行期可变配置(在线改配置立即生效)。 + Live *livecfg.Holder + + // ConfigPath config.json 路径与加载器(配置页读写用)。 + // LoadConfig 返回解析后的配置对象(前端展示/校验用,具体类型由 main 注入的闭包决定); + // nil 时配置页返回 501。 + ConfigPath string + LoadConfig func() (any, error) + // SaveConfig 校验并落盘配置,返回需要重启才能生效的字段列表;随后由 main 注入的 + // ApplyConfig 闭包完成热生效(池参数/排程/密钥/脱敏)。error 时配置不写盘。 + SaveConfig func(raw []byte) (restartRequired []string, err error) + + // StickyCount 返回粘性会话绑定数;nil 时报告 0。 + StickyCount func() int + + // Usage 逐请求用量记录器(nil = 用量接口返回 501)。 + Usage *usage.Recorder + // RequestLog 请求指标与归档(nil = 对应接口返回 501)。 + RequestLog *reqlog.Recorder + + // ProbeFile 模型输出上限探测结果文件(scripts/probe_max_tokens.py --panel-out + // 写入;空或文件不存在 = model_probes 端点返回空集,面板不显示任何实测标注)。 + // 只读展示:网关不解析、不依赖其内容做任何路由/出站决策。 + ProbeFile string +} + +// Panel 管理面板 handler。挂载方式:外层 mux Handle("/panel/", panel), +// 本 mux 的 pattern 均带 /panel 前缀(外层不做前缀剥离)。 +type Panel struct { + cfg Config + mux *http.ServeMux + started time.Time + logs *Ring + + // logins 进行中的 OAuth 设备授权会话(state → 会话信息)。 + // poll 成功或超时(loginTTL)后剔除;面板常驻进程,容量天然有界。 + loginMu sync.Mutex + logins map[string]loginSession + + // taskMu/taskLocks 一键完成任务的 per-account 互斥:同一账号的任务动作 + // (单任务 / 全量)同时只允许一条在跑。重复点击直接返回 409"仍在执行", + // 而不是并发跑两遍浪费上游请求(动作虽幂等,expert 系每遍含 8 次真实对话)。 + // 不同账号之间不互斥(并行照旧)。TryLock 语义,锁条目常驻(账号数有界)。 + taskMu sync.Mutex + taskLocks map[string]*sync.Mutex + + // 任务中心执行队列(taskcenter.go)。 + queueOnce sync.Once + q *queueState +} + +// tryLockAccount 尝试锁定账号的任务执行;已在执行返回 false。 +func (p *Panel) tryLockAccount(uid string) bool { + p.taskMu.Lock() + if p.taskLocks == nil { + p.taskLocks = make(map[string]*sync.Mutex) + } + mu := p.taskLocks[uid] + if mu == nil { + mu = &sync.Mutex{} + p.taskLocks[uid] = mu + } + p.taskMu.Unlock() + return mu.TryLock() +} + +// unlockAccount 释放账号任务锁(与 tryLockAccount 配对)。 +func (p *Panel) unlockAccount(uid string) { + p.taskMu.Lock() + mu := p.taskLocks[uid] + p.taskMu.Unlock() + if mu != nil { + mu.Unlock() + } +} + +// loginTTL 授权 URL 的最长有效期:超时的 state 直接回收, +// 防止"开了添加账号弹窗就走开"的会话永久滞留。 +const loginTTL = 15 * time.Minute + +// loginSession 进行中的 OAuth 会话:创建时刻 + realm(cn/global,用于落盘与端点切换)。 +type loginSession struct { + created time.Time + realm string // "cn" / "global",缺省 cn +} + +// New 构建面板。 +func New(cfg Config) *Panel { + if cfg.RedisMode == "" { + cfg.RedisMode = "noop" + } + p := &Panel{ + cfg: cfg, + mux: http.NewServeMux(), + started: time.Now(), + logs: NewRing(500), + logins: map[string]loginSession{}, + } + p.routes() + return p +} + +// Logs 返回日志环形缓冲(main 经 MultiWriter 镜像 log 与 chat 表格日志进来)。 +func (p *Panel) Logs() *Ring { return p.logs } + +func (p *Panel) routes() { + p.mux.HandleFunc("GET /panel/{$}", p.index) + p.mux.HandleFunc("GET /panel/app.js", p.appScript) + p.mux.HandleFunc("GET /panel/api/overview", p.withAuth(p.overview)) + p.mux.HandleFunc("GET /panel/api/logs", p.withAuth(p.logsHandler)) + p.mux.HandleFunc("GET /panel/api/request_metrics", p.withAuth(p.requestMetrics)) + p.mux.HandleFunc("GET /panel/api/request_logs", p.withAuth(p.requestLogs)) + p.mux.HandleFunc("GET /panel/api/models", p.withAuth(p.models)) + p.mux.HandleFunc("POST /panel/api/login/start", p.withAuth(p.loginStart)) + p.mux.HandleFunc("GET /panel/api/login/poll", p.withAuth(p.loginPoll)) + p.mux.HandleFunc("GET /panel/api/login/regions", p.withAuth(p.loginRegions)) + p.mux.HandleFunc("POST /panel/api/import/cockpit", p.withAuth(p.importCockpit)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/revive", p.withAuth(p.accountRevive)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/disable", p.withAuth(p.accountDisable)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/pause", p.withAuth(p.accountPause)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/resume", p.withAuth(p.accountResume)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/checkin", p.withAuth(p.accountCheckin)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/balance", p.withAuth(p.accountBalance)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/remove", p.withAuth(p.accountRemove)) + p.mux.HandleFunc("GET /panel/api/accounts/{uid}/tasks", p.withAuth(p.accountTasks)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept", p.withAuth(p.accountTaskAccept)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept_all", p.withAuth(p.taskAcceptAll)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/claim", p.withAuth(p.accountTaskClaim)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto", p.withAuth(p.accountTaskAuto)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/auto_all", p.withAuth(p.accountTaskAutoAll)) + p.mux.HandleFunc("POST /panel/api/tasks/scan_all", p.withAuth(p.tasksScanAll)) + p.mux.HandleFunc("POST /panel/api/tasks/run_queue", p.withAuth(p.tasksRunQueue)) + p.mux.HandleFunc("GET /panel/api/tasks/queue", p.withAuth(p.tasksQueueStatus)) + p.mux.HandleFunc("GET /panel/api/school/vouchers", p.withAuth(p.schoolVouchers)) + p.mux.HandleFunc("POST /panel/api/checkin_all", p.withAuth(p.checkinAll)) + p.mux.HandleFunc("POST /panel/api/travel_all", p.withAuth(p.travelAll)) + p.mux.HandleFunc("POST /panel/api/activity_all", p.withAuth(p.activityAll)) + p.mux.HandleFunc("POST /panel/api/keepalive_all", p.withAuth(p.keepaliveAll)) + p.mux.HandleFunc("POST /panel/api/balance_all", p.withAuth(p.balanceAll)) + p.mux.HandleFunc("GET /panel/api/packages", p.withAuth(p.packages)) + p.mux.HandleFunc("GET /panel/api/usage", p.withAuth(p.usage)) + p.mux.HandleFunc("POST /panel/api/usage/save", p.withAuth(p.usageSave)) + p.mux.HandleFunc("GET /panel/api/model_probes", p.withAuth(p.modelProbes)) + p.mux.HandleFunc("GET /panel/api/config", p.withAuth(p.getConfig)) + p.mux.HandleFunc("POST /panel/api/config", p.withAuth(p.saveConfig)) +} + +// ServeHTTP 统一入口:先写安全响应头再分发,保证页面、静态资源、API +// 与 401 错误响应全都带上(API 也可能在浏览器里被直接打开)。 +func (p *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + setSecurityHeaders(w) + p.mux.ServeHTTP(w, r) +} + +// withAuth 与 server 包同口径的 Bearer 鉴权(经 httpauth 常量时间比较); +// api_key 为空时放行。密钥经 livecfg 快照读取:面板里改了 api_key,下一个请求 +// 即用新值(无需重启)。 +func (p *Panel) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !httpauth.VerifyBearer(r, p.apiKey()) { + writeErr(w, http.StatusUnauthorized, "invalid_api_key") + return + } + next(w, r) + } +} + +// apiKey 当前生效密钥(Live 优先,回落静态字段)。 +func (p *Panel) apiKey() string { + if p.cfg.Live != nil { + return p.cfg.Live.Load().APIKey + } + return p.cfg.APIKey +} + +// expiringSoonWindow 返回调度器当前生效的快过期路由窗口;测试面板无调度器时返回 0。 +func (p *Panel) expiringSoonWindow() time.Duration { + if p.cfg.Scheduler == nil { + return 0 + } + return p.cfg.Scheduler.ExpiringSoonWindow() +} + +// --------------------------------------------------------------------------- +// 只读接口 +// --------------------------------------------------------------------------- + +// overview 总览:池计数 + 每账号状态 + 面板元信息。 +func (p *Panel) overview(w http.ResponseWriter, r *http.Request) { + total, healthy, cooling, disabled, inFlightFull := p.cfg.Pool.CountsDetailed() + sticky := 0 + if p.cfg.StickyCount != nil { + sticky = p.cfg.StickyCount() + } + writeJSON(w, http.StatusOK, map[string]any{ + "version": p.cfg.Version, + "uptime_sec": int(time.Since(p.started).Seconds()), + "auth_required": p.apiKey() != "", + "redis_mode": p.cfg.RedisMode, + "sticky_sessions": sticky, + "total": total, + "healthy": healthy, + "cooling": cooling, + "disabled": disabled, + "in_flight_full": inFlightFull, + "accounts": p.cfg.Pool.List(), + // model_locks 模型级限流全清单(哪些模型不能用、锁了几个号、还要锁多久): + // 与 accounts 的账号池视图互补,前端「模型锁池」表直接渲染。无锁时为 null。 + "model_locks": p.cfg.Pool.ModelLockView(), + }) +} + +// logsHandler 返回日志环形缓冲快照(时间升序,含频道标记 chat/task/sys)。 +func (p *Panel) logsHandler(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"entries": p.logs.Snapshot()}) +} + +// requestMetrics 返回进程内请求指标、最近 100 条与归档状态。 +func (p *Panel) requestMetrics(w http.ResponseWriter, r *http.Request) { + if p.cfg.RequestLog == nil { + writeErr(w, http.StatusNotImplemented, "request logger not available") + return + } + writeJSON(w, http.StatusOK, p.cfg.RequestLog.Snapshot()) +} + +// requestLogs 从 JSONL 归档读取最近请求;limit 默认 200、最大 1000。 +// 支持按 outcome/account/model/client_ip/user_agent 过滤(字符串字段为包含匹配) +// 与 from/to 时间区间(闭区间,unix 秒或 RFC3339)——面板「运行日志」的筛选框、 +// 来源查询与「今天 / 自定义区间」都走这里。 +func (p *Panel) requestLogs(w http.ResponseWriter, r *http.Request) { + if p.cfg.RequestLog == nil { + writeErr(w, http.StatusNotImplemented, "request logger not available") + return + } + limit := 200 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if limit > 1000 { + limit = 1000 + } + q := r.URL.Query() + rows, err := p.cfg.RequestLog.ReadArchive(limit, reqlog.Filter{ + Outcome: q.Get("outcome"), + Account: q.Get("account"), + Model: q.Get("model"), + ClientIP: q.Get("client_ip"), + UserAgent: q.Get("user_agent"), + From: parseTimeParam(q.Get("from")), + To: parseTimeParam(q.Get("to")), + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + // 空结果回 [](而不是 JSON null):前端把 null 与"归档关闭"混在一起会走错分支, + // 显示成不满足筛选条件的最近请求。 + if rows == nil { + rows = []reqlog.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"entries": rows, "limit": limit}) +} + +// models 实时查询上游模型列表与 reasoning 实际档位(直连上游,不读路由层 1h 缓存): +// 回答"该模型到底支持哪几档思考"。顺带刷新 client 的 effort 降级能力缓存。 +// 与 /v1/models 同口径的双域输出:CN 域模型加 "cn:" 前缀、global 域加 "global:" 前缀 +// (gateway 路由协议,前端显示的 id 就是调用时要填的完整 model 值)。 +// 各域独立探测、独立容错:某域无可用账号则整域跳过;两域全空时才报错 +// (有错误明细回 502,一个账号都没有回 503)。 +func (p *Panel) models(w http.ResponseWriter, r *http.Request) { + out := make([]map[string]any, 0) + var fetchErrs []string + + // CN 域:有可用 CN 账号才查(此前无条件 Pool.Pick()+FetchModels——选中 global + // 账号时打 CN 端点必然失败,混合池表现为偶发 502,纯 global 池必炸)。 + if uids := p.cfg.Pool.AvailableUIDsForRealm("cn"); len(uids) > 0 { + if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { + infos, err := p.cfg.Upstream.FetchModels(acct) + if err != nil { + fetchErrs = append(fetchErrs, "cn: "+err.Error()) + } else { + for _, mi := range infos { + out = append(out, panelModelEntry("cn", mi, mi.Efforts, mi.DefaultEffort, p.cfg.Upstream.HTTP)) + } + } + } + } + + // global 域:路由开关开且有可用 global 账号才查(独立目录端点,FetchGlobalModelInfos; + // Upstream.GlobalEnabled 是探测侧同一道闸,与 main 装配的 config global.enabled 一致)。 + if p.cfg.Upstream.GlobalEnabled { + if uids := p.cfg.Pool.AvailableUIDsForRealm("global"); len(uids) > 0 { + if acct := p.cfg.Pool.AuthByUID(uids[0]); acct != nil { + infos := p.cfg.Upstream.FetchGlobalModelInfos(acct) + if len(infos) == 0 { + fetchErrs = append(fetchErrs, "global: 上游未返回可用模型") + } else { + efforts, defaults := p.cfg.Upstream.GlobalEffortSnapshot() + for _, mi := range infos { + out = append(out, panelModelEntry("global", mi, efforts[mi.ID], defaults[mi.ID], p.cfg.Upstream.HTTP)) + } + } + } + } + } + + if len(out) == 0 { + if len(fetchErrs) > 0 { + writeErr(w, http.StatusBadGateway, "fetch models: "+strings.Join(fetchErrs, "; ")) + return + } + writeErr(w, http.StatusServiceUnavailable, "没有可用账号:请先在面板添加账号再查询") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": out}) +} + +// panelModelEntry 构造单个模型条目(两域共用):id 带 realm 前缀(调用值即显示值), +// context_length / max_output_tokens 走四级查找链,effort 档位按 realm 域取 +// EffortListing(远端权威 ∪ 静态兜底表)——与 /v1/models 同一口径,两侧不再漂移。 +func panelModelEntry(realm string, mi upstream.ModelInfo, remoteEfforts []string, remoteDefault string, httpc *http.Client) map[string]any { + entry := map[string]any{ + "id": realm + ":" + mi.ID, + "name": mi.Name, + "default_effort": mi.DefaultEffort, + "supported_efforts": mi.Efforts, + "can_disable_thinking": mi.CanDisableThinking, + "supports_reasoning": mi.SupportsReasoning, + "supports_images": mi.SupportsImages, + "credits": mi.Credits, + "description": mi.Description, + "tags": mi.Tags, + "vendor": mi.Vendor, + "is_default": mi.IsDefault, + "supports_tool_call": mi.SupportsToolCall, + "only_reasoning": mi.OnlyReasoning, + "reasoning_effort": mi.ReasoningEffort, + "reasoning_summary": mi.ReasoningSummary, + } + // 限时优惠(modelPromotions):credits 是牌价,promo_* 是当前生效折扣 + //(WorkBuddy 客户端显示的就是这个生效价)。前端据此显示「生效价+标签+划线牌价」。 + if mi.PromoFactor != nil { + entry["promo_factor"] = *mi.PromoFactor + entry["promo_credits"] = mi.PromoCredits + } + if mi.PromoLabel != "" { + entry["promo_label"] = mi.PromoLabel + } + if mi.PromoNote != "" { + entry["promo_note"] = mi.PromoNote + } + if mi.MaxAllowedSize > 0 { + entry["max_allowed_size"] = mi.MaxAllowedSize + } + entry["context_length"] = upstream.ContextWindowListingV4(mi.ID, mi.ContextWindow, httpc) + if mo, ok := upstream.MaxOutputTokensListingV4(mi.ID, mi.MaxTokens, httpc); ok { + entry["max_output_tokens"] = mo + } + if efforts, def := upstream.EffortListing(realm, mi.ID, remoteEfforts, remoteDefault); efforts != nil { + entry["supported_efforts"] = efforts + if def != "" { + entry["default_effort"] = def + } + } + return entry +} + +// modelProbes 返回模型输出上限的探测结果(scripts/probe_max_tokens.py --panel-out +// 写入的契约文件),供前端在「模型与档位」的实测列做风险标注。 +// +// 设计边界:纯只读透传——文件缺失/未配置返回空集(面板退化为无标注,与历史行为 +// 一致),网关自身不解析字段语义、不据此做任何路由或出站决策;上游改了限制后 +// 重跑一次工具、下次查询即刷新,无需重启网关。 +func (p *Panel) modelProbes(w http.ResponseWriter, r *http.Request) { + out := map[string]any{"probes": map[string]json.RawMessage{}, "exists": false} + if p.cfg.ProbeFile == "" { + writeJSON(w, http.StatusOK, out) + return + } + raw, err := os.ReadFile(p.cfg.ProbeFile) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, out) + return + } + writeErr(w, http.StatusInternalServerError, "read probes: "+err.Error()) + return + } + var f struct { + Version int `json:"version"` + Probes map[string]json.RawMessage `json:"probes"` + } + if err := json.Unmarshal(raw, &f); err != nil { + writeErr(w, http.StatusBadGateway, "parse probes: "+err.Error()) + return + } + if f.Probes == nil { + f.Probes = map[string]json.RawMessage{} + } + out["probes"] = f.Probes + out["exists"] = true + if fi, err := os.Stat(p.cfg.ProbeFile); err == nil { + out["updated_at"] = fi.ModTime().Format(time.RFC3339) + } + writeJSON(w, http.StatusOK, out) +} + +// --------------------------------------------------------------------------- +// 账号运维 +// --------------------------------------------------------------------------- + +// accountRevive 手动复活:清禁用 + 冷却 + 熔断(运维口径无条件恢复)。 +func (p *Panel) accountRevive(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if _, ok := p.cfg.Pool.Status(uid); !ok { + writeErr(w, http.StatusNotFound, "account not found") + return + } + p.cfg.Pool.Revive(uid) + log.Printf("panel: revive uid=%s(人工清除禁用/冷却/熔断)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountDisable 人工禁用(不再参与选号,需面板 revive 或重登恢复)。 +func (p *Panel) accountDisable(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if _, ok := p.cfg.Pool.Status(uid); !ok { + writeErr(w, http.StatusNotFound, "account not found") + return + } + p.cfg.Pool.Disable(uid, "manual disable (panel)") + log.Printf("panel: disable uid=%s(人工禁用)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountPause 暂停选号:账号退出选号候选,但**照常参与**签到 / 活跃上报 / 保活 / +// 余额刷新。与 disable 的区别:不写 reason、不清冷却域、不重置计数——账号是「临时 +// 让位」而非「判死」,点「恢复选号」即可立刻回到池子(无需重登或解冻)。 +func (p *Panel) accountPause(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if !p.cfg.Pool.Pause(uid) { + writeErr(w, http.StatusNotFound, "account not found") + return + } + log.Printf("panel: pause uid=%s(暂停选号,保号任务照常)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountResume 解除暂停选号(幂等,对未暂停账号为空操作)。 +func (p *Panel) accountResume(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + if !p.cfg.Pool.Resume(uid) { + writeErr(w, http.StatusNotFound, "account not found") + return + } + log.Printf("panel: resume uid=%s(恢复参与选号)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// accountCheckin 单号签到:DailyCheckin + 余额查询解冻(已签到等业务错误不阻塞余额刷新), +// 与 scheduler.RunCheckinNow 的单号语义一致。 +func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + checkinMsg := "" + checkinDone := false + if err := p.cfg.Upstream.DailyCheckin(a); err != nil { + checkinMsg = err.Error() // "今天已签到"等业务错误照常查余额 + // 幂等拒绝同样是「今日已签」,标记后按钮显示「已签」。 + if upstream.IsAlreadyCheckin(err) { + p.cfg.Pool.NoteCheckinDone(uid) + checkinDone = true + } + } else { + p.cfg.Pool.NoteCheckinDone(uid) + checkinDone = true + } + resp := map[string]any{"ok": true, "checkin_done": checkinDone} + if checkinMsg != "" { + resp["checkin_message"] = checkinMsg + } + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + resp["balance_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + p.cfg.Pool.ReenableIfCredits(uid, remain, total) + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + resp["credits"] = remain + resp["credits_total"] = total + log.Printf("panel: checkin uid=%s msg=%q credits=%d/%d", uid, checkinMsg, remain, total) + writeJSON(w, http.StatusOK, resp) +} + +// accountBalance 单号余额刷新:更新余额与到期快照,不触碰冷却状态。 +func (p *Panel) accountBalance(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + writeErr(w, http.StatusBadGateway, "user resource: "+err.Error()) + return + } + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "credits": remain, "credits_total": total}) +} + +// accountRemove 移除账号:先出池(立即落盘 state),再删 auth 文件。 +func (p *Panel) accountRemove(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + a := p.cfg.Pool.Remove(uid) + if a == nil { + writeErr(w, http.StatusNotFound, "account not found") + return + } + fileMsg := "" + if a.FilePath != "" { + if err := os.Remove(a.FilePath); err != nil && !os.IsNotExist(err) { + fileMsg = err.Error() + } + } + if fileMsg != "" { + log.Printf("panel: remove uid=%s(auth 文件删除失败: %s)", uid, fileMsg) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "file_error": fileMsg}) + return + } + log.Printf("panel: remove uid=%s(已出池并删除凭证文件)", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// --------------------------------------------------------------------------- +// 批量任务 +// --------------------------------------------------------------------------- + +// checkinAll 手动触发全量签到(异步执行,进度看日志区/账号状态变化)。 +func (p *Panel) checkinAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunCheckinNow() + log.Printf("panel: 手动全量签到已触发(含猫猫旅行)") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// travelAll 手动触发全量猫猫旅行巡检(异步执行)。 +func (p *Panel) travelAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunTravelNow() + log.Printf("panel: 手动全量旅行巡检已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// activityAll 手动触发全量活跃上报(异步执行;点亮连登 + 解锁领养前置)。 +func (p *Panel) activityAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunActivityNow() + log.Printf("panel: 手动全量活跃上报已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// keepaliveAll 手动触发全量 token 保活(异步执行)。 +func (p *Panel) keepaliveAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + go p.cfg.Scheduler.RunKeepaliveNow() + log.Printf("panel: 手动全量保活已触发") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) +} + +// balanceAll 手动全量刷新余额:并发查上游、写回池内 credits(含解冻语义), +// 完成后返回——面板紧接着拉 overview 即是最新值。账号量小(个位数), +// 同步等待(上限受短 RPC 超时约束)比"触发后盲刷"体验更确定。 +func (p *Panel) balanceAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + p.cfg.Scheduler.RunBalanceRefreshNow() + p.syncNicknames() + log.Printf("panel: 手动全量余额刷新完成") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "accounts": p.cfg.Pool.List()}) +} + +// syncNicknames 手动刷新时的昵称同步(issue #94:上游改名免重登)。 +// 只在面板手动「刷新」路径调用——后台余额定时器不触发(用户明确要求资料接口 +// 仅手动触达)。逐号拉 /console/account,只取 nickname(手机号等敏感字段在 +// upstream.FetchAccountProfile 内即被丢弃);单号失败静默跳过,不打断余额刷新 +// 的既有结果。 +func (p *Panel) syncNicknames() { + type job struct { + uid string + a *auth.Auth + } + var jobs []job + for _, st := range p.cfg.Pool.List() { + if st.Disabled { + continue + } + if a := p.cfg.Pool.AuthByUID(st.UID); a != nil && a.AccessTokenValue() != "" { + jobs = append(jobs, job{uid: st.UID, a: a}) + } + } + if len(jobs) == 0 { + return + } + var ( + mu sync.Mutex + updated int + failed int + sem = make(chan struct{}, 3) + wg sync.WaitGroup + ) + for _, j := range jobs { + wg.Add(1) + go func(j job) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + nick, err := p.cfg.Upstream.FetchAccountProfile(j.a) + if err != nil { + mu.Lock() + failed++ + mu.Unlock() + return + } + if p.cfg.Pool.SetNickname(j.uid, nick) { + mu.Lock() + updated++ + mu.Unlock() + } + }(j) + } + wg.Wait() + if updated > 0 || failed > 0 { + log.Printf("panel: 昵称同步:更新 %d 个,失败 %d 个(未变化不计数)", updated, failed) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// usage 返回逐请求用量聚合。统计窗口三选一: +// - from/to(unix 秒):显式区间,用于「今天」与「自定义」——区间由浏览器按 +// 本地时区算好再发,服务端时区与浏览器不一致时「今天」才不会被算错; +// - hours:滚动窗口(默认 72,上限 1440=60 天),卡片汇总/按域/按账号/按模型/ +// 时序**全部**按该窗口统计;显式 hours=0 表示全部历史(含 90 天前折叠出的日桶); +// - 都不给:等同于 hours=72(保持旧调用方行为)。 +func (p *Panel) usage(w http.ResponseWriter, r *http.Request) { + if p.cfg.Usage == nil { + writeErr(w, http.StatusNotImplemented, "usage recorder not available") + return + } + q := r.URL.Query() + win := usage.Window{} + win.From = parseTimeParam(q.Get("from")) + win.To = parseTimeParam(q.Get("to")) + if win.From.IsZero() && win.To.IsZero() { + win.Hours = 72 + if v := q.Get("hours"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + win.Hours = n + } + } + if win.Hours > 1440 { + win.Hours = 1440 + } + } + // 昵称仅用于展示,取自池快照(不含任何凭证)。 + nicks := map[string]string{} + for _, s := range p.cfg.Pool.List() { + if s.Nickname != "" { + nicks[s.UID] = s.Nickname + } + } + var currentRate func(realm, model string) string + if p.cfg.Upstream != nil { + currentRate = p.cfg.Upstream.ModelRate + } + writeJSON(w, http.StatusOK, p.cfg.Usage.SnapshotWindow(win, nicks, currentRate)) +} + +// parseTimeParam 解析时间查询参数:unix 秒(前端默认)或 RFC3339(便于手工调 +// 接口/写脚本)。空串与非法值都返回零值 = 该侧不设界,不报错——区间参数是可选 +// 增强,拼错一个 from 不该让整页用量打不开。 +func parseTimeParam(v string) time.Time { + v = strings.TrimSpace(v) + if v == "" { + return time.Time{} + } + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + if n <= 0 { + return time.Time{} + } + // 兼容秒与毫秒(前端可能直接把 Date.now() 传上来)。 + if n > 1e12 { + return time.UnixMilli(n) + } + return time.Unix(n, 0) + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + if t, err := time.ParseInLocation("2006-01-02T15:04", v, time.Local); err == nil { + return t + } + return time.Time{} +} + +// usageSave 立即把内存中的用量桶落盘(正常由后台 30s 防抖刷新负责)。 +func (p *Panel) usageSave(w http.ResponseWriter, r *http.Request) { + if p.cfg.Usage == nil { + writeErr(w, http.StatusNotImplemented, "usage recorder not available") + return + } + p.cfg.Usage.Save() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// packages 返回全部账号的积分包构成,供「积分构成」视图对比。 +// +// 逐个账号向上游查(并发有上限,避免瞬时打满上游限流),失败只在对应账号上 +// 标 error,不影响其它账号——一个号 token 失效不该让整页空白。 +func (p *Panel) packages(w http.ResponseWriter, r *http.Request) { + accts := p.cfg.Pool.List() + type row struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + Realm string `json:"realm"` + Remain int64 `json:"remain"` + Size int64 `json:"size"` + Packages []upstream.CreditPackage `json:"packages"` + Error string `json:"error,omitempty"` + } + out := make([]row, len(accts)) + + sem := make(chan struct{}, 3) + var wg sync.WaitGroup + for i, s := range accts { + wg.Add(1) + go func(i int, s pool.Status) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + it := row{UID: s.UID, Nickname: s.Nickname, Realm: s.Realm} + a := p.cfg.Pool.AuthByUID(s.UID) + if a == nil { + it.Error = "account not loaded" + out[i] = it + return + } + packs, remain, size, err := p.cfg.Upstream.CreditPackages(a) + if err != nil { + it.Error = err.Error() + out[i] = it + return + } + it.Packages = packs + it.Remain = remain + it.Size = size + out[i] = it + }(i, s) + } + wg.Wait() + + // 余额降序:多的在前,便于和少的对比。 + sort.SliceStable(out, func(i, j int) bool { return out[i].Remain > out[j].Remain }) + writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + raw, _ := json.Marshal(v) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(raw) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"ok": false, "error": msg}) +} diff --git a/internal/panel/ring.go b/internal/panel/ring.go index 37a74f53c02d7e1ea17c3edb3bdccaa16448464d..52b88bf2a28606f1beaecffdb9195e91b98392b8 100644 --- a/internal/panel/ring.go +++ b/internal/panel/ring.go @@ -1,94 +1,94 @@ -// ring.go 固定容量的结构化日志环形缓冲(并发安全,实现 io.Writer)。 -// main 把 log 包输出与 chat 表格日志经 MultiWriter 镜像进来,面板 -// /panel/api/logs 读取快照;超出容量的旧行按 FIFO 淘汰。 -// -// 每行入环时按前缀规则归类频道(chat=对话请求表格行 / task=任务动作 / -// sys=系统与其它),面板日志视图按频道筛选——对话流量大时任务结果不被冲掉。 -package panel - -import ( - "regexp" - "strings" - "sync" - "time" -) - -// 日志频道。 -const ( - ChChat = "chat" - ChTask = "task" - ChSys = "sys" -) - -// LogEntry 单条日志(时间戳取写入时刻;log 包行的行首日期时间已被剥离)。 -type LogEntry struct { - TS time.Time `json:"ts"` - Ch string `json:"ch"` - Text string `json:"text"` -} - -// taskPrefixes 任务动作日志的行首标识(scheduler 与 panel 的既有口径)。 -var taskPrefixes = []string{ - "school ", "streak-bonus ", "travel ", "blackcat ", "lottery ", - "checkin ", "activity ", "keepalive ", "balance ", "user-resource ", - "panel: 任务", "panel: 一键", "panel: checkin", "panel: 手动", - "panel: 队列", "panel: 券码", -} - -// tsPrefixRe log 包默认 flags(日期 时间)产生的行首时间戳。 -var tsPrefixRe = regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `) - -// classifyLine 按行首特征归类频道。 -func classifyLine(line string) string { - if strings.HasPrefix(line, "| #") { // chat 表格日志(server/logging.go logChatRow) - return ChChat - } - for _, p := range taskPrefixes { - if strings.HasPrefix(line, p) { - return ChTask - } - } - return ChSys -} - -// Ring 日志环形缓冲。 -type Ring struct { - mu sync.Mutex - entries []LogEntry - cap int -} - -// NewRing 构建容量为 capacity 的日志环(非正值回退 500)。 -func NewRing(capacity int) *Ring { - if capacity <= 0 { - capacity = 500 - } - return &Ring{cap: capacity} -} - -// Write 按 \n 切分入环(实现 io.Writer)。空行丢弃;超容量淘汰最旧行。 -func (r *Ring) Write(p []byte) (int, error) { - now := time.Now() - r.mu.Lock() - defer r.mu.Unlock() - for _, line := range strings.Split(strings.TrimRight(string(p), "\r\n"), "\n") { - if line == "" { - continue - } - text := tsPrefixRe.ReplaceAllString(line, "") - r.entries = append(r.entries, LogEntry{TS: now, Ch: classifyLine(text), Text: text}) - if overflow := len(r.entries) - r.cap; overflow > 0 { - r.entries = r.entries[overflow:] - } - } - return len(p), nil -} - -// Snapshot 按写入顺序返回缓冲内全部条目(拷贝,调用方可安全持有)。 -func (r *Ring) Snapshot() []LogEntry { - r.mu.Lock() - defer r.mu.Unlock() - out := make([]LogEntry, len(r.entries)) - copy(out, r.entries) - return out -} +// ring.go 固定容量的结构化日志环形缓冲(并发安全,实现 io.Writer)。 +// main 把 log 包输出与 chat 表格日志经 MultiWriter 镜像进来,面板 +// /panel/api/logs 读取快照;超出容量的旧行按 FIFO 淘汰。 +// +// 每行入环时按前缀规则归类频道(chat=对话请求表格行 / task=任务动作 / +// sys=系统与其它),面板日志视图按频道筛选——对话流量大时任务结果不被冲掉。 +package panel + +import ( + "regexp" + "strings" + "sync" + "time" +) + +// 日志频道。 +const ( + ChChat = "chat" + ChTask = "task" + ChSys = "sys" +) + +// LogEntry 单条日志(时间戳取写入时刻;log 包行的行首日期时间已被剥离)。 +type LogEntry struct { + TS time.Time `json:"ts"` + Ch string `json:"ch"` + Text string `json:"text"` +} + +// taskPrefixes 任务动作日志的行首标识(scheduler 与 panel 的既有口径)。 +var taskPrefixes = []string{ + "school ", "streak-bonus ", "travel ", "blackcat ", "lottery ", + "checkin ", "activity ", "keepalive ", "balance ", "user-resource ", + "panel: 任务", "panel: 一键", "panel: checkin", "panel: 手动", + "panel: 队列", "panel: 券码", +} + +// tsPrefixRe log 包默认 flags(日期 时间)产生的行首时间戳。 +var tsPrefixRe = regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `) + +// classifyLine 按行首特征归类频道。 +func classifyLine(line string) string { + if strings.HasPrefix(line, "| #") { // chat 表格日志(server/logging.go logChatRow) + return ChChat + } + for _, p := range taskPrefixes { + if strings.HasPrefix(line, p) { + return ChTask + } + } + return ChSys +} + +// Ring 日志环形缓冲。 +type Ring struct { + mu sync.Mutex + entries []LogEntry + cap int +} + +// NewRing 构建容量为 capacity 的日志环(非正值回退 500)。 +func NewRing(capacity int) *Ring { + if capacity <= 0 { + capacity = 500 + } + return &Ring{cap: capacity} +} + +// Write 按 \n 切分入环(实现 io.Writer)。空行丢弃;超容量淘汰最旧行。 +func (r *Ring) Write(p []byte) (int, error) { + now := time.Now() + r.mu.Lock() + defer r.mu.Unlock() + for _, line := range strings.Split(strings.TrimRight(string(p), "\r\n"), "\n") { + if line == "" { + continue + } + text := tsPrefixRe.ReplaceAllString(line, "") + r.entries = append(r.entries, LogEntry{TS: now, Ch: classifyLine(text), Text: text}) + if overflow := len(r.entries) - r.cap; overflow > 0 { + r.entries = r.entries[overflow:] + } + } + return len(p), nil +} + +// Snapshot 按写入顺序返回缓冲内全部条目(拷贝,调用方可安全持有)。 +func (r *Ring) Snapshot() []LogEntry { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]LogEntry, len(r.entries)) + copy(out, r.entries) + return out +} diff --git a/internal/panel/ring_test.go b/internal/panel/ring_test.go index 7976c03039ed1488b0f3b0257f30927f95b74633..b3f71cf14c7a565afab592a892146f32d73a05a5 100644 --- a/internal/panel/ring_test.go +++ b/internal/panel/ring_test.go @@ -1,50 +1,50 @@ -package panel - -import ( - "strings" - "testing" - "time" -) - -func TestClassifyLine(t *testing.T) { - cases := map[string]string{ - "| #001 | glm-5.2 | stream | 200 | uid=c8a3e793 | TTFB=120ms |": ChChat, - "school c8a3e793: ★ 分享任务完成": ChTask, - "streak-bonus 5c162cc9: 🎊 新手礼包 +100c": ChTask, - "blackcat c8a3e793: 完成 3 次夜间对话": ChTask, - "checkin 5c162cc9: 已签到": ChTask, - "panel: 任务动作 uid=x code=chat_5": ChTask, - "panel: 队列启动:6 项(并发 2)": ChTask, - "panel: revive uid=x": ChSys, - "workbuddy2api listening on :7863": ChSys, - "scheduler: 余额后台刷新每 5m0s": ChSys, - } - for line, want := range cases { - if got := classifyLine(line); got != want { - t.Errorf("classifyLine(%q)=%q want %q", line, got, want) - } - } -} - -func TestRingWriteStripsTimestamp(t *testing.T) { - r := NewRing(4) - if _, err := r.Write([]byte("2026/09/14 00:12:34 school x: done\n")); err != nil { - t.Fatal(err) - } - if _, err := r.Write([]byte("| #002 | glm | stream | 200 | ok |")); err != nil { - t.Fatal(err) - } - es := r.Snapshot() - if len(es) != 2 { - t.Fatalf("entries=%d want 2", len(es)) - } - if strings.HasPrefix(es[0].Text, "2026/") { - t.Errorf("timestamp not stripped: %q", es[0].Text) - } - if es[0].Ch != ChTask || es[1].Ch != ChChat { - t.Errorf("channels: %q %q", es[0].Ch, es[1].Ch) - } - if time.Since(es[0].TS) > 5*time.Second { - t.Errorf("stale ts: %v", es[0].TS) - } -} +package panel + +import ( + "strings" + "testing" + "time" +) + +func TestClassifyLine(t *testing.T) { + cases := map[string]string{ + "| #001 | glm-5.2 | stream | 200 | uid=c8a3e793 | TTFB=120ms |": ChChat, + "school c8a3e793: ★ 分享任务完成": ChTask, + "streak-bonus 5c162cc9: 🎊 新手礼包 +100c": ChTask, + "blackcat c8a3e793: 完成 3 次夜间对话": ChTask, + "checkin 5c162cc9: 已签到": ChTask, + "panel: 任务动作 uid=x code=chat_5": ChTask, + "panel: 队列启动:6 项(并发 2)": ChTask, + "panel: revive uid=x": ChSys, + "workbuddy2api listening on :7863": ChSys, + "scheduler: 余额后台刷新每 5m0s": ChSys, + } + for line, want := range cases { + if got := classifyLine(line); got != want { + t.Errorf("classifyLine(%q)=%q want %q", line, got, want) + } + } +} + +func TestRingWriteStripsTimestamp(t *testing.T) { + r := NewRing(4) + if _, err := r.Write([]byte("2026/09/14 00:12:34 school x: done\n")); err != nil { + t.Fatal(err) + } + if _, err := r.Write([]byte("| #002 | glm | stream | 200 | ok |")); err != nil { + t.Fatal(err) + } + es := r.Snapshot() + if len(es) != 2 { + t.Fatalf("entries=%d want 2", len(es)) + } + if strings.HasPrefix(es[0].Text, "2026/") { + t.Errorf("timestamp not stripped: %q", es[0].Text) + } + if es[0].Ch != ChTask || es[1].Ch != ChChat { + t.Errorf("channels: %q %q", es[0].Ch, es[1].Ch) + } + if time.Since(es[0].TS) > 5*time.Second { + t.Errorf("stale ts: %v", es[0].TS) + } +} diff --git a/internal/panel/security_test.go b/internal/panel/security_test.go index 0ae27341fc9e8738480ba77d919d423a4c2b09e1..2505644868fab4fb7bb020a163eb50c7bd8c12ce 100644 --- a/internal/panel/security_test.go +++ b/internal/panel/security_test.go @@ -1,146 +1,146 @@ -package panel - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func newTestPanel() *Panel { - // 启用鉴权:未带 key 的请求一律 401,不进入依赖 Pool/Upstream 的 handler。 - return New(Config{Version: "test", APIKey: "test-key"}) -} - -// 面板安全响应头必须覆盖:页面、静态脚本、鉴权失败响应。 -func TestSecurityHeadersOnAllPanelResponses(t *testing.T) { - p := newTestPanel() - paths := []struct{ method, path string }{ - {"GET", "/panel/"}, - {"GET", "/panel/app.js"}, - {"GET", "/panel/api/overview"}, // 401(未提供 key) - {"POST", "/panel/api/config"}, // 401 - {"GET", "/panel/api/nonexistent"}, - } - for _, c := range paths { - rec := httptest.NewRecorder() - p.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) - h := rec.Header() - if got := h.Get("Content-Security-Policy"); got == "" { - t.Errorf("%s %s: missing CSP", c.method, c.path) - } - if h.Get("X-Content-Type-Options") != "nosniff" { - t.Errorf("%s %s: X-Content-Type-Options=%q", c.method, c.path, h.Get("X-Content-Type-Options")) - } - if h.Get("X-Frame-Options") != "DENY" { - t.Errorf("%s %s: X-Frame-Options=%q", c.method, c.path, h.Get("X-Frame-Options")) - } - if h.Get("Referrer-Policy") != "no-referrer" { - t.Errorf("%s %s: Referrer-Policy=%q", c.method, c.path, h.Get("Referrer-Policy")) - } - } -} - -// CSP 必须禁止内联脚本与 iframe 嵌套(严格策略的核心约束)。 -func TestCSPDisallowsInlineScriptAndFraming(t *testing.T) { - p := newTestPanel() - rec := httptest.NewRecorder() - p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil)) - csp := rec.Header().Get("Content-Security-Policy") - - for _, must := range []string{ - "script-src 'self'", - "frame-ancestors 'none'", - "base-uri 'none'", - "default-src 'none'", - } { - if !strings.Contains(csp, must) { - t.Errorf("CSP missing %q; got: %s", must, csp) - } - } - if strings.Contains(csp, "script-src 'self' 'unsafe-inline'") || strings.Contains(csp, "script-src 'unsafe-inline'") { - t.Errorf("CSP must not allow unsafe-inline scripts; got: %s", csp) - } -} - -// 页面必须引用外部脚本(内联脚本会被上面的 CSP 拦掉,页面将完全不可用)。 -func TestIndexReferencesExternalScript(t *testing.T) { - p := newTestPanel() - rec := httptest.NewRecorder() - p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil)) - body := rec.Body.String() - - if !strings.Contains(body, ``) { - t.Error("index.html must load app.js externally (inline script is blocked by CSP)") - } - // 反例保护:出现内联 内容块即为回归 - if strings.Contains(body, "`) { + t.Error("index.html must load app.js externally (inline script is blocked by CSP)") + } + // 反例保护:出现内联 内容块即为回归 + if strings.Contains(body, "blocked`, ErrWafBlock}, - // 403 带业务信封的仍走既有分类(P0-1 约束:不劫持业务 403)。 - {403, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, - {403, `{"code":60001,"msg":"quota exceeded"}`, ErrHardCredit}, - {403, `{"code":1,"msg":"unknown business error"}`, ErrClient}, - // 非 403 的无信封错误体不进 WAF 分类(WAF 判定绑定 403 形态)。 - {400, `bad request`, ErrClient}, - {429, ``, ErrSoftRate}, - } - for _, c := range cases { - if got := Classify(c.status, c.body); got != c.want { - t.Errorf("Classify(%d,%q)=%v want %v", c.status, c.body, got, c.want) - } - } -} - -// TestIsModelRateLimit 判断 429 body 是否明确指向模型级限流(code 6004)。 -func TestIsModelRateLimit(t *testing.T) { - cases := []struct { - body string - want bool - }{ - // 6004:模型级限流(issue #31 的核心场景)。 - {`{"code":6004,"msg":"将在 2026-09-11 18:33:27 UTC+8 重置"}`, true}, - {`{"code": 6004,"msg":"x"}`, true}, - // 其他 code(非模型级限流)→ 不算。 - {`{"code":11140,"msg":"The model provider is rate-limiting requests."}`, false}, - {`{"code":1,"msg":"429 rate limit"}`, false}, - } - for _, c := range cases { - if got := IsModelRateLimit(c.body); got != c.want { - t.Errorf("IsModelRateLimit(%q)=%v want %v", c.body, got, c.want) - } - } -} - -// TestParseSoftRateReset 解析上游 429 6004 msg 里的「将在 … 重置」时间(## UTC+8)。 -func TestParseSoftRateReset(t *testing.T) { - future := time.Now().Add(35 * time.Minute) - ts := future.In(softRateResetLoc).Format("2006-01-02 15:04:05") - cases := []struct { - name string - body string - ok bool - }{ - {"6004 带时间+UTC+8 后缀", `{"code":6004,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, - {"6004 带时间无后缀", `{"code":6004,"msg":"将在 ` + ts + ` 重置"}`, true}, - {"6004 无时间文案", `{"code":6004,"msg":"model usage limit exceeded"}`, false}, - {"非 6004 但带时间(ParseRateReset 统一解析;模型级豁免由调用侧按 6004 判定)", `{"code":11140,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, - {"非法时间格式", `{"code":6004,"msg":"将在 明天 重置"}`, false}, - {"空 body", ``, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got, ok := ParseRateReset(c.body) - if ok != c.ok { - t.Fatalf("ok=%v want %v (body=%s)", ok, c.ok, c.body) - } - if ok { - // 解析结果 = ts 在 UTC+8 解释下的墙钟(截断到分钟),应与 future 相差 ±2 分钟。 - if d := got.Sub(future); d < -2*time.Minute || d > 2*time.Minute { - t.Errorf("parsed=%v want ~%v (diff %v)", got, future, d) - } - if got.Location() != time.UTC { - // 不同指针的 FixedZone 实例相等性按 offset 判,这里只断言 offset。 - if _, off := got.Zone(); off != 8*60*60 { - t.Errorf("zone offset=%d want +08:00", off) - } - } - } - }) - } -} - -type rtFunc func(*http.Request) (*http.Response, error) - -func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -func jsonResp(status int, body string) *http.Response { - return &http.Response{ - StatusCode: status, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(body)), - } -} - -func testClient(fn rtFunc) *Client { - return &Client{ - HTTP: &http.Client{Transport: fn}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - } -} - -func TestRefreshSuccess(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/v2/plugin/auth/token/refresh") { - return nil, errors.New("wrong path: " + r.URL.Path) - } - if r.Header.Get("X-Refresh-Token") != "oldrt" { - return nil, errors.New("missing X-Refresh-Token") - } - return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"newat","refreshToken":"newrt","expiresIn":3600}}`), nil - }) - a := &auth.Auth{AccessToken: "at", RefreshToken: "oldrt", ExpiresAt: 1} - if err := c.RefreshToken(a); err != nil { - t.Fatalf("refresh: %v", err) - } - if a.AccessToken != "newat" || a.RefreshToken != "newrt" { - t.Errorf("tokens not updated: %+v", a) - } - if a.ExpiresAt <= 1 { - t.Errorf("expiresAt not advanced: %d", a.ExpiresAt) - } -} - -func TestRefreshPreservesExpiryWhenOmitted(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":0,"data":{"accessToken":"newat"}}`), nil - }) - a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000} - if err := c.RefreshToken(a); err != nil { - t.Fatalf("refresh: %v", err) - } - if a.ExpiresAt != 1753600000 { - t.Errorf("expiresAt should be preserved, got %d", a.ExpiresAt) - } - if a.RefreshToken != "rt" { - t.Errorf("refreshToken should be preserved, got %s", a.RefreshToken) - } -} - -func TestRefreshSessionDead(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 401, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"Offline user session not found"}`)), - }, nil - }) - a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1} - err := c.RefreshToken(a) - if err == nil { - t.Fatal("want error") - } - var ue *Error - if !errors.As(err, &ue) { - t.Fatalf("want *Error, got %T %v", err, err) - } - if ue.Kind != ErrSessionDead { - t.Errorf("kind=%v want ErrSessionDead", ue.Kind) - } -} - -func TestChatStreamSendsHeadersAndStreamTrue(t *testing.T) { - var gotAuth, gotUID, gotProduct string - var gotBody []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - gotAuth = r.Header.Get("Authorization") - gotUID = r.Header.Get("X-User-Id") - gotProduct = r.Header.Get("X-Product") - gotBody, _ = io.ReadAll(r.Body) - return &http.Response{ - StatusCode: 200, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), - }, nil - }) - a := &auth.Auth{AccessToken: "at", UID: "u1", EnterpriseID: "e1"} - rc, status, respBody, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) - if err != nil || status != 200 { - t.Fatalf("chat: status=%d err=%v", status, err) - } - if respBody != nil { - t.Errorf("200 response should carry nil body, got %q", respBody) - } - rc.Close() - if gotAuth != "Bearer at" || gotUID != "u1" || gotProduct != "WorkBuddy" { - t.Errorf("headers: auth=%q uid=%q product=%q", gotAuth, gotUID, gotProduct) - } - if !bytes.Contains(gotBody, []byte(`"stream":true`)) { - t.Errorf("stream not forced: %s", gotBody) - } -} - -func TestFetchModelsEffortsDriveBodyDowngrade(t *testing.T) { - var outbound []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - switch { - case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): - return jsonResp(200, `{"code":0,"data":{"models":[ - {"id":"glm-5.2","name":"GLM-5.2","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":["low","high"]}} - ],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil - case strings.HasSuffix(r.URL.Path, "/v3/config"): - return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil - default: - outbound, _ = io.ReadAll(r.Body) - return &http.Response{ - StatusCode: 200, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), - }, nil - } - }) - a := &auth.Auth{AccessToken: "at", UID: "u1"} - infos, err := c.FetchModels(a) - if err != nil { - t.Fatalf("fetch models: %v", err) - } - if len(infos) != 1 { - t.Fatalf("infos=%+v", infos) - } - // ModelInfo.Efforts 应携带 supportedEfforts,DefaultEffort 应携带 reasoning.effort - if len(infos[0].Efforts) != 2 || infos[0].Efforts[0] != "low" { - t.Errorf("infos[0].Efforts=%v", infos[0].Efforts) - } - if infos[0].DefaultEffort != "high" { - t.Errorf("infos[0].DefaultEffort=%q want high", infos[0].DefaultEffort) - } - // glm-5.2 只支持 low/high,请求 max → 降级为 high - rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`), "", ChatMeta{}) - if err != nil || status != 200 { - t.Fatalf("chat: status=%d err=%v", status, err) - } - rc.Close() - var m map[string]any - if err := json.Unmarshal(outbound, &m); err != nil { - t.Fatalf("outbound unmarshal: %v (%s)", err, outbound) - } - if got, _ := m["reasoning_effort"].(string); got != "high" { - t.Errorf("reasoning_effort=%v want high (outbound=%s)", m["reasoning_effort"], outbound) - } -} - -func TestChatStreamHardCreditError(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(402, `{"code":1,"msg":"余额不足"}`), nil - }) - a := &auth.Auth{AccessToken: "at", UID: "u1"} - _, status, respBody, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) - if status != 402 { - t.Errorf("status=%d", status) - } - // 错误信封一次成型:≥400 返回已分类的 *Error(Kind + body 全量仍经 respBody 透出) - var ue *Error - if !errors.As(err, &ue) || ue.Kind != ErrHardCredit { - t.Fatalf("hard credit should return classified *Error envelope, got %v", err) - } - if len(respBody) == 0 { - t.Errorf("body should still be returned for passthrough") - } -} - -// TestChatStreamReadsMultipleChunksOverRealTransport 走真实 net/http 传输层, -// 回归 defer cancel() 导致第二块起 body Read 返回 context canceled 的断流 bug。 -func TestChatStreamReadsMultipleChunksOverRealTransport(t *testing.T) { - const frames = 6 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - flusher, ok := w.(http.Flusher) - if !ok { - t.Error("http.ResponseWriter does not implement http.Flusher") - return - } - for i := 1; i <= frames; i++ { - if _, err := fmt.Fprintf(w, "data: chunk-%d\n\n", i); err != nil { - return - } - flusher.Flush() - time.Sleep(20 * time.Millisecond) - } - })) - defer srv.Close() - - c := New() - c.ChatBaseCN = srv.URL - c.IdleTimeout = 5 * time.Second - - a := &auth.Auth{AccessToken: "at", UID: "u1"} - rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) - if err != nil || status != 200 { - t.Fatalf("chat: status=%d err=%v", status, err) - } - defer rc.Close() - - buf := make([]byte, 1) - var got string - for i := 0; i < frames; i++ { - if _, err := io.ReadFull(rc, buf); err != nil { - t.Fatalf("read %d: %v (real transport body must not be cut)", i, err) - } - got += string(buf) - } - if strings.Contains(got, "context canceled") { - t.Fatalf("body read hit context canceled, got %q", got) - } -} - -func TestUserResourceAggregation(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { - return nil, errors.New("wrong path: " + r.URL.Path) - } - if r.Method != http.MethodPost { - return nil, errors.New("want POST") - } - body, _ := io.ReadAll(r.Body) - if !bytes.Contains(body, []byte(`"ProductCode":"p_tcaca"`)) { - return nil, errors.New("missing ProductCode: " + string(body)) - } - return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"TotalCount":2,"TotalDosage":3000,"Accounts":[ - {"PackageName":"签到包","CapacitySize":2000,"CapacityRemain":1200,"CapacityUsed":800,"CycleCapacitySize":2000,"CycleCapacityRemain":1200,"CycleCapacityUsed":800}, - {"PackageName":"体验包","CapacitySize":1000,"CapacityRemain":300,"CapacityUsed":700,"CycleCapacitySize":1000,"CycleCapacityRemain":300,"CycleCapacityUsed":700} - ]}}}}`), nil - }) - a := &auth.Auth{AccessToken: "at", UID: "u1"} - remain, total, err := c.UserResource(a) - if err != nil { - t.Fatalf("resource: %v", err) - } - if remain != 1500 { - t.Errorf("remain=%d want 1500", remain) - } - if total != 3000 { - t.Errorf("total=%d want 3000", total) - } -} - -func TestUserResourceNegativeClamped(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[ - {"PackageName":"p","CycleCapacitySize":100,"CycleCapacityRemain":-50,"CycleCapacityUsed":150} - ]}}}}`), nil - }) - remain, total, err := c.UserResource(&auth.Auth{AccessToken: "at"}) - if err != nil || remain != 0 { - t.Errorf("remain=%d err=%v, want 0 (clamped)", remain, err) - } - if total != 100 { - t.Errorf("total=%d want 100", total) - } -} - -func TestDailyCheckinAlready(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/daily-checkin") { - return nil, errors.New("wrong path") - } - return jsonResp(200, `{"code":14001,"msg":"今日已签到"}`), nil - }) - err := c.DailyCheckin(&auth.Auth{AccessToken: "at"}) - if err == nil || !strings.Contains(err.Error(), "已签到") { - t.Errorf("err=%v", err) - } -} - -func TestBasesAlwaysCN(t *testing.T) { - c := testClient(nil) - cn := &auth.Auth{Domain: ""} - other := &auth.Auth{Domain: "example.com"} - if c.chatBase(cn) != "https://chat.example" || c.billingBase(cn) != "https://billing.example" { - t.Error("cn bases wrong") - } - // 恒 CN:domain 不同不改变上游 host。 - if c.chatBase(other) != c.chatBase(cn) || c.billingBase(other) != c.billingBase(cn) { - t.Error("bases must be CN regardless of domain") - } -} - -func TestNewChatClientNoTotalTimeoutAndSharedTransport(t *testing.T) { - c := New() - if c.ChatHTTP == nil { - t.Fatal("ChatHTTP should be initialized") - } - if c.ChatHTTP.Timeout != 0 { - t.Errorf("ChatHTTP.Timeout=%v want 0 (no total cap)", c.ChatHTTP.Timeout) - } - // 共享同一个 Transport 实例,连接池不重复。 - if c.ChatHTTP.Transport != c.HTTP.Transport { - t.Errorf("ChatHTTP and HTTP must share the same *http.Transport") - } - htr, ok := c.ChatHTTP.Transport.(*http.Transport) - if !ok { - t.Fatalf("Transport type=%T", c.ChatHTTP.Transport) - } - if htr.ResponseHeaderTimeout != 60*time.Second { // 连接层加固:响应头上限从 120s 收到 60s(慢冷启动留 3.75× 余量) - t.Errorf("ResponseHeaderTimeout=%v want 60s", htr.ResponseHeaderTimeout) - } -} - -func TestChatStreamRoutesToChatHTTP(t *testing.T) { - // 显式注入 ChatHTTP(可辨识标记),验证 ChatStream 走它而非 HTTP。 - chatHit, httpHit := false, false - c := testClient(func(*http.Request) (*http.Response, error) { - httpHit = true - return jsonResp(200, `{}`), nil - }) - c.ChatHTTP = &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { - chatHit = true - return &http.Response{ - StatusCode: 200, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), - }, nil - })} - a := &auth.Auth{AccessToken: "at", UID: "u1"} - rc, status, _, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) - if err != nil || status != 200 { - t.Fatalf("chat: status=%d err=%v", status, err) - } - rc.Close() - if !chatHit { - t.Error("ChatStream should use ChatHTTP") - } - if httpHit { - t.Error("ChatStream must not use HTTP") - } -} - -func TestChatHTTPNilFallsBackToHTTP(t *testing.T) { - c := testClient(func(*http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 200, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), - }, nil - }) - if c.chatHTTP() != c.HTTP { - t.Error("chatHTTP() should fall back to HTTP when ChatHTTP is nil") - } -} - -func TestFetchModelsDefaultEffortDualKeyAndSizes(t *testing.T) { - // 上游双键:老模型 reasoning.effort(auto),新模型(glm-5.3 系)只有 reasoning.defaultEffort; - // credits/maxAllowedSize/canDisableThinking/supportsReasoning 等尺寸与能力字段应一并透出。 - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":0,"data":{"models":[ - {"id":"glm-5.3","maxInputTokens":1000000,"maxOutputTokens":48000,"maxAllowedSize":1000000,"credits":"x0.79","supportsReasoning":true,"reasoning":{"defaultEffort":"high","canDisableThinking":true,"supportedEfforts":["low","high","max"]}}, - {"id":"auto","maxInputTokens":168000,"maxOutputTokens":32000,"supportsReasoning":true,"reasoning":{"effort":"high"}} - ],"agents":[{"name":"cli","models":["glm-5.3","auto"]}]}}`), nil - }) - a := &auth.Auth{AccessToken: "at", UID: "u1"} - infos, err := c.FetchModels(a) - if err != nil { - t.Fatalf("fetch models: %v", err) - } - byID := map[string]ModelInfo{} - for _, mi := range infos { - byID[mi.ID] = mi - } - g := byID["glm-5.3"] - if g.DefaultEffort != "high" { - t.Errorf("glm-5.3 DefaultEffort=%q want high (from defaultEffort key)", g.DefaultEffort) - } - if !g.CanDisableThinking || !g.SupportsReasoning { - t.Errorf("glm-5.3 capability flags: canDisable=%v supportsReasoning=%v want true/true", g.CanDisableThinking, g.SupportsReasoning) - } - if g.MaxAllowedSize != 1000000 || g.MaxTokens != 48000 || g.Credits != "x0.79" { - t.Errorf("glm-5.3 sizes: maxAllowed=%d maxOut=%d credits=%q", g.MaxAllowedSize, g.MaxTokens, g.Credits) - } - if got := c.ModelRate("cn", "glm-5.3"); got != "0.79" { - t.Errorf("glm-5.3 ModelRate=%q want 0.79", got) - } - if au := byID["auto"]; au.DefaultEffort != "high" { - t.Errorf("auto DefaultEffort=%q want high (from legacy effort key)", au.DefaultEffort) - } -} - -func TestModelRateCacheEffectiveAndNormalized(t *testing.T) { - c := New() - factor := 0.5 - c.storeModelRates("cn", []ModelInfo{ - {ID: "base", Credits: "x0.50 credits"}, - {ID: "promo", Credits: "x0.80", PromoFactor: &factor, PromoCredits: "0.50x"}, - }) - if got := c.ModelRate("cn", "base"); got != "0.5" { - t.Fatalf("base rate=%q want 0.5", got) - } - if got := c.ModelRate("cn", "promo"); got != "0.5" { - t.Fatalf("promo rate=%q want 0.5", got) - } - if got := normalizeModelRate("x0.05 credits"); got != "0.05" { - t.Fatalf("normalizeModelRate=%q want 0.05", got) - } - c.storeModelRates("cn", []ModelInfo{{ID: "base", Credits: "x0.79"}}) - if got := c.ModelRate("cn", "base"); got != "0.79" { - t.Fatalf("refreshed base rate=%q want 0.79", got) - } - if got := c.ModelRate("cn", "promo"); got != "" { - t.Fatalf("stale promo rate=%q want empty after full refresh", got) - } -} - -func TestFetchModelsOverlaysV3ConfigCapabilities(t *testing.T) { - // CLI 目录给 flash 精简字段(128K / 固定 high);IDE /v3/config 给完整能力。 - var sawIDE bool - c := testClient(func(r *http.Request) (*http.Response, error) { - switch { - case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): - return jsonResp(200, `{"code":0,"data":{"models":[ - {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":128000,"credits":"x0.03 credits","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"effort":"high","summary":"auto"}} - ],"agents":[{"name":"cli","models":["deepseek-v4.1-flash"]}]}}`), nil - case strings.HasSuffix(r.URL.Path, "/v3/config"): - sawIDE = true - if r.Header.Get("User-Agent") != codeBuddyIDEUA { - t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), codeBuddyIDEUA) - } - if r.Header.Get("X-Product") != "SaaS" { - t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) - } - if r.Header.Get("X-User-Id") != "u1" { - t.Errorf("X-User-Id=%q want u1", r.Header.Get("X-User-Id")) - } - return jsonResp(200, `{"code":0,"data":{"models":[ - {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":393216,"credits":"x0.03","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"canDisableThinking":true,"defaultEffort":"high","summary":"auto","supportedEfforts":["low","high","max"]}} - ]}}`), nil - default: - t.Errorf("unexpected path %s", r.URL.Path) - return jsonResp(404, `{}`), nil - } - }) - a := &auth.Auth{AccessToken: "at", UID: "u1", Domain: "copilot.tencent.com"} - infos, err := c.FetchModels(a) - if err != nil { - t.Fatalf("fetch models: %v", err) - } - if !sawIDE { - t.Fatal("expected /v3/config request") - } - if len(infos) != 1 { - t.Fatalf("infos=%+v", infos) - } - mi := infos[0] - if mi.MaxTokens != 393216 { - t.Errorf("MaxTokens=%d want 393216", mi.MaxTokens) - } - if mi.ContextWindow != 1000000 { - t.Errorf("ContextWindow=%d want 1000000", mi.ContextWindow) - } - if !mi.CanDisableThinking || !mi.SupportsReasoning { - t.Errorf("flags canDisable=%v supportsReasoning=%v", mi.CanDisableThinking, mi.SupportsReasoning) - } - if mi.DefaultEffort != "high" { - t.Errorf("DefaultEffort=%q want high", mi.DefaultEffort) - } - if got := strings.Join(mi.Efforts, ","); got != "low,high,max" { - t.Errorf("Efforts=%v want low,high,max", mi.Efforts) - } -} +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestClassify(t *testing.T) { + cases := []struct { + status int + body string + want ErrKind + }{ + {402, ``, ErrHardCredit}, + {400, `{"code":1,"msg":"余额不足"}`, ErrHardCredit}, + {403, `insufficient credits`, ErrHardCredit}, + {200, `{"code":10001,"msg":"积分不足,请充值"}`, ErrHardCredit}, + {400, `{"code":1,"msg":"额度用尽"}`, ErrHardCredit}, + {429, ``, ErrSoftRate}, + // 限流文案(issue #28):状态码不是 429 时也必须识别为软限流, + // 否则账号不会被冷却,下次请求仍会被选中。 + {200, `{"code":11140,"msg":"The model provider is rate-limiting requests. Please wait a moment and try again."}`, ErrSoftRate}, + {400, `rate limit`, ErrSoftRate}, + {403, `usage limit reached`, ErrSoftRate}, + // "model usage limit exceeded" 不是余额语义(无 credit/quota/积分/额度 等计费词), + // 属于模型侧用量节流 → 短冷却(误判为硬冷却会把有余量的号停到次日 04:00)。 + {200, `{"code":1,"msg":"model usage limit exceeded"}`, ErrSoftRate}, + {200, `{"code":1,"msg":"too many requests"}`, ErrSoftRate}, + {500, `rate-limited upstream`, ErrSoftRate}, // 限流文案优先于 5xx 分类 + // 内容策略拦截(HTTP 400 + 审核文案):误报信号,不罚账号,走降级重试。 + {400, `Illegal API invocation from an unapproved channel`, ErrContentBlocked}, + {400, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, + {400, `unapproved channel`, ErrContentBlocked}, + // 通用 4xx(非审核文案):仍判 ErrClient,只换号不罚。 + {400, `bad request`, ErrClient}, + // ErrBadParams:请求体解析失败(HTTP 400 + Unmarshal chat params failed / code 11101)。 + // 这是"发给上游的 body 有问题"(网关截断已由 413 消灭,剩余为客户端畸形 JSON), + // 换了账号也一样 400,不罚号。具体词优先于通用 4xx。 + {400, `{"code":11101,"msg":"Unmarshal chat params failed with error: unexpected EOF"}`, ErrBadParams}, + {400, `Unmarshal chat params failed`, ErrBadParams}, + {400, `{"code":11101,"msg":"x"}`, ErrBadParams}, + // 图片格式/数据错误是确定性请求错误,分类后不轮转、不罚号。 + {400, `{"code":11101,"msg":"Parse message failed: invalid image_url content at index 2: json: cannot unmarshal string into Go value of type v2.ImageContent"}`, ErrImageInvalid}, + {400, `{"code":11135,"msg":"invalid_image_data"}`, ErrImageInvalid}, + {400, `invalid_image_data`, ErrImageInvalid}, + // 11135 业务码须容忍 JSON 空白(5d5223d:字面量 marker 只覆盖紧凑形态)。 + {400, `{"code": 11135, "msg":"image data invalid"}`, ErrImageInvalid}, + {400, `{"error":{"code": "11135", "message":"image invalid"}}`, ErrImageInvalid}, + // 防过宽:11133(模型不支持图片)不进 image_invalid。 + {400, `{"code": 11133, "msg":"model does not support image"}`, ErrClient}, + {200, `quota exceeded`, ErrHardCredit}, + // session 死亡优先于限流文案(401+12153 需人工重登,短冷却无意义)。 + {401, `{"code":12153,"msg":"Offline user session not found, rate limit"}`, ErrSessionDead}, + {401, `Offline user session not found`, ErrSessionDead}, + {401, `{"code":12153,"msg":"Offline user session not found"}`, ErrSessionDead}, + {401, `{"code":9999,"msg":"bad token"}`, ErrClient}, + {500, `boom`, ErrServer}, + {503, `unavailable`, ErrServer}, + {200, ``, ErrNone}, + // 11102「该后端无此模型」:确定性答复,归 ErrModelBlocked((账号,模型) 负缓存避让)。 + {404, `{"code":11102,"msg":"model [deepseek-v3-2-volc] service info not found"}`, ErrModelBlocked}, + {400, `{"error":{"code":"11102","message":"model service info not found"}}`, ErrModelBlocked}, + {400, `{"msg":"service info not found"}`, ErrModelBlocked}, + // 11102 撞在 requestId 上不算(不得误避让可用模型)。 + {404, `{"requestId":"11102","msg":"ok"}`, ErrNotFound}, + // 429 + 11102 → 限流语义(ErrSoftRate),不是模型不存在。 + {429, `{"code":11102,"msg":"service info not found"}`, ErrSoftRate}, + // 429 + 余额措辞 → 限流语义(fork-scan-absorb T-3,本次修复点):限流响应 + // body 高频携带 "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞, + // hardRule 在 429 之前会误判 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。 + // 状态码是比关键词更权威的信号:真余额耗尽走 402,非 429 的 quota 措辞 + // 仍归 hardRule(上方 {200,"quota exceeded"} 语义不变)。 + {429, `quota exceeded`, ErrSoftRate}, + {429, `{"code":1,"msg":"quota exceeded, please wait"}`, ErrSoftRate}, + {429, `insufficient credits`, ErrSoftRate}, + {429, `{"code":1,"msg":"额度不足"}`, ErrSoftRate}, + {429, `积分不足,请充值`, ErrSoftRate}, + // 429 + 账号级故障码防回归(accountFault 仍先于 429 判定):429+14017 若 + // 落到 status==429 兜底会误归 soft_rate,账号级故障等不来自愈。 + {429, `{"code":14017,"msg":"trial not activated"}`, ErrAccountFault}, + {429, `{"error":{"data":{"code":11140,"msg":"request illegal"}}}`, ErrAccountFault}, + // Issue #175:14018 明确表示账号积分耗尽,即使 HTTP 状态是 429 也必须 + // 走硬积分冷却;仅有相同文案而无该业务码的普通 429 仍保持软限流。 + {429, `{"code":14018,"msg":"Credits exhausted"}`, ErrHardCredit}, + {429, `{"error":{"data":{"code":"14018","msg":"Credits exhausted"}}}`, ErrHardCredit}, + {429, `{"requestId":"14018","msg":"Credits exhausted"}`, ErrSoftRate}, + {429, `{"code":1,"msg":"Credits exhausted"}`, ErrSoftRate}, + // WAF 403(P0-1):403 + 无业务信封(无 "code":/"msg": 字段)→ ErrWafBlock。 + // 空体 / HTML 拦截页 / 纯文本 / 非信封 JSON 均命中。 + {403, ``, ErrWafBlock}, + {403, `403 Forbidden`, ErrWafBlock}, + {403, `Forbidden`, ErrWafBlock}, + {403, `{"message":"blocked by waf"}`, ErrWafBlock}, + {403, `blocked`, ErrWafBlock}, + // 403 带业务信封的仍走既有分类(P0-1 约束:不劫持业务 403)。 + {403, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, + {403, `{"code":60001,"msg":"quota exceeded"}`, ErrHardCredit}, + {403, `{"code":1,"msg":"unknown business error"}`, ErrClient}, + // 非 403 的无信封错误体不进 WAF 分类(WAF 判定绑定 403 形态)。 + {400, `bad request`, ErrClient}, + {429, ``, ErrSoftRate}, + } + for _, c := range cases { + if got := Classify(c.status, c.body); got != c.want { + t.Errorf("Classify(%d,%q)=%v want %v", c.status, c.body, got, c.want) + } + } +} + +// TestIsModelRateLimit 判断 429 body 是否明确指向模型级限流(code 6004)。 +func TestIsModelRateLimit(t *testing.T) { + cases := []struct { + body string + want bool + }{ + // 6004:模型级限流(issue #31 的核心场景)。 + {`{"code":6004,"msg":"将在 2026-09-11 18:33:27 UTC+8 重置"}`, true}, + {`{"code": 6004,"msg":"x"}`, true}, + // 其他 code(非模型级限流)→ 不算。 + {`{"code":11140,"msg":"The model provider is rate-limiting requests."}`, false}, + {`{"code":1,"msg":"429 rate limit"}`, false}, + } + for _, c := range cases { + if got := IsModelRateLimit(c.body); got != c.want { + t.Errorf("IsModelRateLimit(%q)=%v want %v", c.body, got, c.want) + } + } +} + +// TestParseSoftRateReset 解析上游 429 6004 msg 里的「将在 … 重置」时间(## UTC+8)。 +func TestParseSoftRateReset(t *testing.T) { + future := time.Now().Add(35 * time.Minute) + ts := future.In(softRateResetLoc).Format("2006-01-02 15:04:05") + cases := []struct { + name string + body string + ok bool + }{ + {"6004 带时间+UTC+8 后缀", `{"code":6004,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, + {"6004 带时间无后缀", `{"code":6004,"msg":"将在 ` + ts + ` 重置"}`, true}, + {"6004 无时间文案", `{"code":6004,"msg":"model usage limit exceeded"}`, false}, + {"非 6004 但带时间(ParseRateReset 统一解析;模型级豁免由调用侧按 6004 判定)", `{"code":11140,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, + {"非法时间格式", `{"code":6004,"msg":"将在 明天 重置"}`, false}, + {"空 body", ``, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := ParseRateReset(c.body) + if ok != c.ok { + t.Fatalf("ok=%v want %v (body=%s)", ok, c.ok, c.body) + } + if ok { + // 解析结果 = ts 在 UTC+8 解释下的墙钟(截断到分钟),应与 future 相差 ±2 分钟。 + if d := got.Sub(future); d < -2*time.Minute || d > 2*time.Minute { + t.Errorf("parsed=%v want ~%v (diff %v)", got, future, d) + } + if got.Location() != time.UTC { + // 不同指针的 FixedZone 实例相等性按 offset 判,这里只断言 offset。 + if _, off := got.Zone(); off != 8*60*60 { + t.Errorf("zone offset=%d want +08:00", off) + } + } + } + }) + } +} + +type rtFunc func(*http.Request) (*http.Response, error) + +func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func jsonResp(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func testClient(fn rtFunc) *Client { + return &Client{ + HTTP: &http.Client{Transport: fn}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } +} + +func TestRefreshSuccess(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/plugin/auth/token/refresh") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + if r.Header.Get("X-Refresh-Token") != "oldrt" { + return nil, errors.New("missing X-Refresh-Token") + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"newat","refreshToken":"newrt","expiresIn":3600}}`), nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "oldrt", ExpiresAt: 1} + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh: %v", err) + } + if a.AccessToken != "newat" || a.RefreshToken != "newrt" { + t.Errorf("tokens not updated: %+v", a) + } + if a.ExpiresAt <= 1 { + t.Errorf("expiresAt not advanced: %d", a.ExpiresAt) + } +} + +func TestRefreshPreservesExpiryWhenOmitted(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"accessToken":"newat"}}`), nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000} + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh: %v", err) + } + if a.ExpiresAt != 1753600000 { + t.Errorf("expiresAt should be preserved, got %d", a.ExpiresAt) + } + if a.RefreshToken != "rt" { + t.Errorf("refreshToken should be preserved, got %s", a.RefreshToken) + } +} + +func TestRefreshSessionDead(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"Offline user session not found"}`)), + }, nil + }) + a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1} + err := c.RefreshToken(a) + if err == nil { + t.Fatal("want error") + } + var ue *Error + if !errors.As(err, &ue) { + t.Fatalf("want *Error, got %T %v", err, err) + } + if ue.Kind != ErrSessionDead { + t.Errorf("kind=%v want ErrSessionDead", ue.Kind) + } +} + +func TestChatStreamSendsHeadersAndStreamTrue(t *testing.T) { + var gotAuth, gotUID, gotProduct string + var gotBody []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + gotAuth = r.Header.Get("Authorization") + gotUID = r.Header.Get("X-User-Id") + gotProduct = r.Header.Get("X-Product") + gotBody, _ = io.ReadAll(r.Body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1", EnterpriseID: "e1"} + rc, status, respBody, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if respBody != nil { + t.Errorf("200 response should carry nil body, got %q", respBody) + } + rc.Close() + if gotAuth != "Bearer at" || gotUID != "u1" || gotProduct != "WorkBuddy" { + t.Errorf("headers: auth=%q uid=%q product=%q", gotAuth, gotUID, gotProduct) + } + if !bytes.Contains(gotBody, []byte(`"stream":true`)) { + t.Errorf("stream not forced: %s", gotBody) + } +} + +func TestFetchModelsEffortsDriveBodyDowngrade(t *testing.T) { + var outbound []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"glm-5.2","name":"GLM-5.2","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":["low","high"]}} + ],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil + case strings.HasSuffix(r.URL.Path, "/v3/config"): + return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil + default: + outbound, _ = io.ReadAll(r.Body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + } + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + if len(infos) != 1 { + t.Fatalf("infos=%+v", infos) + } + // ModelInfo.Efforts 应携带 supportedEfforts,DefaultEffort 应携带 reasoning.effort + if len(infos[0].Efforts) != 2 || infos[0].Efforts[0] != "low" { + t.Errorf("infos[0].Efforts=%v", infos[0].Efforts) + } + if infos[0].DefaultEffort != "high" { + t.Errorf("infos[0].DefaultEffort=%q want high", infos[0].DefaultEffort) + } + // glm-5.2 只支持 low/high,请求 max → 降级为 high + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + rc.Close() + var m map[string]any + if err := json.Unmarshal(outbound, &m); err != nil { + t.Fatalf("outbound unmarshal: %v (%s)", err, outbound) + } + if got, _ := m["reasoning_effort"].(string); got != "high" { + t.Errorf("reasoning_effort=%v want high (outbound=%s)", m["reasoning_effort"], outbound) + } +} + +func TestChatStreamHardCreditError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(402, `{"code":1,"msg":"余额不足"}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + _, status, respBody, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) + if status != 402 { + t.Errorf("status=%d", status) + } + // 错误信封一次成型:≥400 返回已分类的 *Error(Kind + body 全量仍经 respBody 透出) + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrHardCredit { + t.Fatalf("hard credit should return classified *Error envelope, got %v", err) + } + if len(respBody) == 0 { + t.Errorf("body should still be returned for passthrough") + } +} + +// TestChatStreamReadsMultipleChunksOverRealTransport 走真实 net/http 传输层, +// 回归 defer cancel() 导致第二块起 body Read 返回 context canceled 的断流 bug。 +func TestChatStreamReadsMultipleChunksOverRealTransport(t *testing.T) { + const frames = 6 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Error("http.ResponseWriter does not implement http.Flusher") + return + } + for i := 1; i <= frames; i++ { + if _, err := fmt.Fprintf(w, "data: chunk-%d\n\n", i); err != nil { + return + } + flusher.Flush() + time.Sleep(20 * time.Millisecond) + } + })) + defer srv.Close() + + c := New() + c.ChatBaseCN = srv.URL + c.IdleTimeout = 5 * time.Second + + a := &auth.Auth{AccessToken: "at", UID: "u1"} + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + defer rc.Close() + + buf := make([]byte, 1) + var got string + for i := 0; i < frames; i++ { + if _, err := io.ReadFull(rc, buf); err != nil { + t.Fatalf("read %d: %v (real transport body must not be cut)", i, err) + } + got += string(buf) + } + if strings.Contains(got, "context canceled") { + t.Fatalf("body read hit context canceled, got %q", got) + } +} + +func TestUserResourceAggregation(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + body, _ := io.ReadAll(r.Body) + if !bytes.Contains(body, []byte(`"ProductCode":"p_tcaca"`)) { + return nil, errors.New("missing ProductCode: " + string(body)) + } + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"TotalCount":2,"TotalDosage":3000,"Accounts":[ + {"PackageName":"签到包","CapacitySize":2000,"CapacityRemain":1200,"CapacityUsed":800,"CycleCapacitySize":2000,"CycleCapacityRemain":1200,"CycleCapacityUsed":800}, + {"PackageName":"体验包","CapacitySize":1000,"CapacityRemain":300,"CapacityUsed":700,"CycleCapacitySize":1000,"CycleCapacityRemain":300,"CycleCapacityUsed":700} + ]}}}}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + remain, total, err := c.UserResource(a) + if err != nil { + t.Fatalf("resource: %v", err) + } + if remain != 1500 { + t.Errorf("remain=%d want 1500", remain) + } + if total != 3000 { + t.Errorf("total=%d want 3000", total) + } +} + +func TestUserResourceNegativeClamped(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[ + {"PackageName":"p","CycleCapacitySize":100,"CycleCapacityRemain":-50,"CycleCapacityUsed":150} + ]}}}}`), nil + }) + remain, total, err := c.UserResource(&auth.Auth{AccessToken: "at"}) + if err != nil || remain != 0 { + t.Errorf("remain=%d err=%v, want 0 (clamped)", remain, err) + } + if total != 100 { + t.Errorf("total=%d want 100", total) + } +} + +func TestDailyCheckinAlready(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/daily-checkin") { + return nil, errors.New("wrong path") + } + return jsonResp(200, `{"code":14001,"msg":"今日已签到"}`), nil + }) + err := c.DailyCheckin(&auth.Auth{AccessToken: "at"}) + if err == nil || !strings.Contains(err.Error(), "已签到") { + t.Errorf("err=%v", err) + } +} + +func TestBasesAlwaysCN(t *testing.T) { + c := testClient(nil) + cn := &auth.Auth{Domain: ""} + other := &auth.Auth{Domain: "example.com"} + if c.chatBase(cn) != "https://chat.example" || c.billingBase(cn) != "https://billing.example" { + t.Error("cn bases wrong") + } + // 恒 CN:domain 不同不改变上游 host。 + if c.chatBase(other) != c.chatBase(cn) || c.billingBase(other) != c.billingBase(cn) { + t.Error("bases must be CN regardless of domain") + } +} + +func TestNewChatClientNoTotalTimeoutAndSharedTransport(t *testing.T) { + c := New() + if c.ChatHTTP == nil { + t.Fatal("ChatHTTP should be initialized") + } + if c.ChatHTTP.Timeout != 0 { + t.Errorf("ChatHTTP.Timeout=%v want 0 (no total cap)", c.ChatHTTP.Timeout) + } + // 共享同一个 Transport 实例,连接池不重复。 + if c.ChatHTTP.Transport != c.HTTP.Transport { + t.Errorf("ChatHTTP and HTTP must share the same *http.Transport") + } + htr, ok := c.ChatHTTP.Transport.(*http.Transport) + if !ok { + t.Fatalf("Transport type=%T", c.ChatHTTP.Transport) + } + if htr.ResponseHeaderTimeout != 60*time.Second { // 连接层加固:响应头上限从 120s 收到 60s(慢冷启动留 3.75× 余量) + t.Errorf("ResponseHeaderTimeout=%v want 60s", htr.ResponseHeaderTimeout) + } +} + +func TestChatStreamRoutesToChatHTTP(t *testing.T) { + // 显式注入 ChatHTTP(可辨识标记),验证 ChatStream 走它而非 HTTP。 + chatHit, httpHit := false, false + c := testClient(func(*http.Request) (*http.Response, error) { + httpHit = true + return jsonResp(200, `{}`), nil + }) + c.ChatHTTP = &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { + chatHit = true + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + })} + a := &auth.Auth{AccessToken: "at", UID: "u1"} + rc, status, _, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) + if err != nil || status != 200 { + t.Fatalf("chat: status=%d err=%v", status, err) + } + rc.Close() + if !chatHit { + t.Error("ChatStream should use ChatHTTP") + } + if httpHit { + t.Error("ChatStream must not use HTTP") + } +} + +func TestChatHTTPNilFallsBackToHTTP(t *testing.T) { + c := testClient(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }, nil + }) + if c.chatHTTP() != c.HTTP { + t.Error("chatHTTP() should fall back to HTTP when ChatHTTP is nil") + } +} + +func TestFetchModelsDefaultEffortDualKeyAndSizes(t *testing.T) { + // 上游双键:老模型 reasoning.effort(auto),新模型(glm-5.3 系)只有 reasoning.defaultEffort; + // credits/maxAllowedSize/canDisableThinking/supportsReasoning 等尺寸与能力字段应一并透出。 + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"glm-5.3","maxInputTokens":1000000,"maxOutputTokens":48000,"maxAllowedSize":1000000,"credits":"x0.79","supportsReasoning":true,"reasoning":{"defaultEffort":"high","canDisableThinking":true,"supportedEfforts":["low","high","max"]}}, + {"id":"auto","maxInputTokens":168000,"maxOutputTokens":32000,"supportsReasoning":true,"reasoning":{"effort":"high"}} + ],"agents":[{"name":"cli","models":["glm-5.3","auto"]}]}}`), nil + }) + a := &auth.Auth{AccessToken: "at", UID: "u1"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + byID := map[string]ModelInfo{} + for _, mi := range infos { + byID[mi.ID] = mi + } + g := byID["glm-5.3"] + if g.DefaultEffort != "high" { + t.Errorf("glm-5.3 DefaultEffort=%q want high (from defaultEffort key)", g.DefaultEffort) + } + if !g.CanDisableThinking || !g.SupportsReasoning { + t.Errorf("glm-5.3 capability flags: canDisable=%v supportsReasoning=%v want true/true", g.CanDisableThinking, g.SupportsReasoning) + } + if g.MaxAllowedSize != 1000000 || g.MaxTokens != 48000 || g.Credits != "x0.79" { + t.Errorf("glm-5.3 sizes: maxAllowed=%d maxOut=%d credits=%q", g.MaxAllowedSize, g.MaxTokens, g.Credits) + } + if got := c.ModelRate("cn", "glm-5.3"); got != "0.79" { + t.Errorf("glm-5.3 ModelRate=%q want 0.79", got) + } + if au := byID["auto"]; au.DefaultEffort != "high" { + t.Errorf("auto DefaultEffort=%q want high (from legacy effort key)", au.DefaultEffort) + } +} + +func TestModelRateCacheEffectiveAndNormalized(t *testing.T) { + c := New() + factor := 0.5 + c.storeModelRates("cn", []ModelInfo{ + {ID: "base", Credits: "x0.50 credits"}, + {ID: "promo", Credits: "x0.80", PromoFactor: &factor, PromoCredits: "0.50x"}, + }) + if got := c.ModelRate("cn", "base"); got != "0.5" { + t.Fatalf("base rate=%q want 0.5", got) + } + if got := c.ModelRate("cn", "promo"); got != "0.5" { + t.Fatalf("promo rate=%q want 0.5", got) + } + if got := normalizeModelRate("x0.05 credits"); got != "0.05" { + t.Fatalf("normalizeModelRate=%q want 0.05", got) + } + c.storeModelRates("cn", []ModelInfo{{ID: "base", Credits: "x0.79"}}) + if got := c.ModelRate("cn", "base"); got != "0.79" { + t.Fatalf("refreshed base rate=%q want 0.79", got) + } + if got := c.ModelRate("cn", "promo"); got != "" { + t.Fatalf("stale promo rate=%q want empty after full refresh", got) + } +} + +func TestFetchModelsOverlaysV3ConfigCapabilities(t *testing.T) { + // CLI 目录给 flash 精简字段(128K / 固定 high);IDE /v3/config 给完整能力。 + var sawIDE bool + c := testClient(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":128000,"credits":"x0.03 credits","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"effort":"high","summary":"auto"}} + ],"agents":[{"name":"cli","models":["deepseek-v4.1-flash"]}]}}`), nil + case strings.HasSuffix(r.URL.Path, "/v3/config"): + sawIDE = true + if r.Header.Get("User-Agent") != codeBuddyIDEUA { + t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), codeBuddyIDEUA) + } + if r.Header.Get("X-Product") != "SaaS" { + t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) + } + if r.Header.Get("X-User-Id") != "u1" { + t.Errorf("X-User-Id=%q want u1", r.Header.Get("X-User-Id")) + } + return jsonResp(200, `{"code":0,"data":{"models":[ + {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":393216,"credits":"x0.03","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"canDisableThinking":true,"defaultEffort":"high","summary":"auto","supportedEfforts":["low","high","max"]}} + ]}}`), nil + default: + t.Errorf("unexpected path %s", r.URL.Path) + return jsonResp(404, `{}`), nil + } + }) + a := &auth.Auth{AccessToken: "at", UID: "u1", Domain: "copilot.tencent.com"} + infos, err := c.FetchModels(a) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + if !sawIDE { + t.Fatal("expected /v3/config request") + } + if len(infos) != 1 { + t.Fatalf("infos=%+v", infos) + } + mi := infos[0] + if mi.MaxTokens != 393216 { + t.Errorf("MaxTokens=%d want 393216", mi.MaxTokens) + } + if mi.ContextWindow != 1000000 { + t.Errorf("ContextWindow=%d want 1000000", mi.ContextWindow) + } + if !mi.CanDisableThinking || !mi.SupportsReasoning { + t.Errorf("flags canDisable=%v supportsReasoning=%v", mi.CanDisableThinking, mi.SupportsReasoning) + } + if mi.DefaultEffort != "high" { + t.Errorf("DefaultEffort=%q want high", mi.DefaultEffort) + } + if got := strings.Join(mi.Efforts, ","); got != "low,high,max" { + t.Errorf("Efforts=%v want low,high,max", mi.Efforts) + } +} diff --git a/internal/upstream/context_catalog.go b/internal/upstream/context_catalog.go index 0985bfb8323871aad95d42674cd8e11d4f17c3b1..d928897fc55dc340c37549916950e16ef8786f81 100644 --- a/internal/upstream/context_catalog.go +++ b/internal/upstream/context_catalog.go @@ -1,106 +1,106 @@ -// context_catalog.go context_length / max_output_tokens 字段级静态兜底知识表。 -// -// 数据来源四级(model-json-dynamic 任务书;查找链入口在 model_catalog.go 的 -// ContextWindowListingV4 / MaxOutputTokensListingV4,本文件是第 2 级): -// - 上游动态值(ModelInfo.ContextWindow/MaxTokens,即 maxInputTokens/maxOutputTokens)权威,优先; -// - 本文件静态知识表(远端零值时补齐;model.json 缺失/损坏时的编译期兜底); -// - model.json 本地缓存(数据目录,含运行时 models.dev 按需补值,见 model_catalog.go); -// - models.dev 按需拉取(异步不阻塞;仍未知 → context_length 1M 兜底——宁可高估 -// 不低估:高估代价是客户端不截断、上游报错可重试;低估代价是下游客户端 -// (Codex/ZCode/Claude Code 按 context_length 提前截断)白白丢上下文; -// max_output_tokens 未知 → 省略字段(输出上限无合估算据,不编造))。 -// -// 知识表**一处定义、CN/global 两域共用**:context_length 是模型固有属性——fork -// 706412584 实测结论「两区是同一套 API 的两次部署」,同 id 上下文一致,无按 realm -// 分表必要(与 effort 档位的 realm 分表刻意不同)。 -// -// 值来源两类,逐条注释标注: -// - fork 实测:706412584 直连上游 /console/enterprises/personal/models 的 -// maxInputTokens(/tmp/fork_diffs/70_927f57f9.diff,2026-09-13 实测; -// global 侧无法直测、按同 id 外推); -// - models.dev:https://models.dev/ 收录值(2026-09-16 查询,取多 provider 共识值; -// 官方源如 moonshotai/zai 优先)。未收录或歧义大者不编造。 -package upstream - -// DefaultContextWindow 知识表也未收录的模型的 context_length 兜底:1M。 -// 上游多数大窗口模型的实际量级;高估优于低估(见文件头)。 -const DefaultContextWindow int64 = 1000000 - -// contextCap 一个模型的上下文能力(字段级兜底条目)。 -// context 必为正(否则条目无意义,直接走 1M 兜底); -// maxOutput 为 0 表示输出上限未知 → max_output_tokens 字段省略(不编造)。 -type contextCap struct { - context int64 - maxOutput int64 -} - -// contextCapFallback context_length / max_output_tokens 知识表(CN/global 共用)。 -// 每条注释标注来源:实测 = fork 706412584 直连 CN /console 实测 maxInputTokens -// (global 侧为同 id 外推);models.dev = 2026-09-16 收录共识值;估算 = 同族外推。 -var contextCapFallback = map[string]contextCap{ - // ---- GLM 家族(z-ai)---- - "glm-5.2": {context: 1000000, maxOutput: 131072}, // 实测(CN 1M;models.dev 共识 1M/131072) - "glm-5.1": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) - "glm-5.3": {context: 1000000, maxOutput: 131072}, // 实测外推 + models.dev 共识 1M/131072 - "glm-5.3-flash": {context: 1000000, maxOutput: 131072}, // models.dev 共识 1M/131072 - "glm-5v-turbo": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) - - // ---- Kimi 家族(moonshot)---- - "kimi-k2.7": {context: 256000, maxOutput: 65536}, // 实测(CN 256K);输出 65536 为 models.dev kimi-k2.7-code 同族估算 - "kimi-k2.6": {context: 256000, maxOutput: 262144}, // 实测(CN 256K);输出 models.dev 官方 262144 - "kimi-k2.5": {context: 164000, maxOutput: 262144}, // 实测(global 侧同 id 外推 164K);输出 models.dev 共识 262144 - "kimi-k3": {context: 1048576, maxOutput: 131072}, // models.dev 官方(moonshotai 1M/128K) - "kimi-k2.8-preview": {context: 1048576, maxOutput: 0}, // models.dev(Kimi K2.8 Preview 1M;输出上限未收录,省略) - - // ---- MiniMax / 混元(tencent)---- - "minimax-m3": {context: 512000, maxOutput: 512000}, // 实测(CN 512K);输出 models.dev 共识 512000 - "hy3": {context: 192000, maxOutput: 64000}, // 实测(CN 192K/64K,repo hy3 抓取样本同值) - "hy3-preview": {context: 262144, maxOutput: 64000}, // models.dev(共识 262144/64000) - "hy4-preview": {context: 1000000, maxOutput: 64000}, // 实测外推 + models.dev(~1M/64000) - "hy4-preview-x": {context: 1000000, maxOutput: 64000}, // 实测外推(1M);输出同族 hy4-preview 估算 - - // ---- DeepSeek 家族 ---- - "deepseek-v4-pro": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) - "deepseek-v4-flash": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) - "deepseek-v4.1-flash": {context: 1000000, maxOutput: 384000}, // 实测外推 + models.dev 共识 1M/384000 - - // ---- OpenAI / Google(global 域家族)---- - "gpt-6-astra": {context: 1050000, maxOutput: 128000}, // models.dev(全 provider 一致 1050000/128000) - "gpt-5.6-sol": {context: 1050000, maxOutput: 128000}, // models.dev 共识 - "gpt-5.6-terra": {context: 1050000, maxOutput: 128000}, // models.dev 共识 - "gpt-5.6-luna": {context: 1050000, maxOutput: 128000}, // models.dev 共识 - "gpt-5.5": {context: 1050000, maxOutput: 128000}, // models.dev 共识 - "gpt-5.4": {context: 1050000, maxOutput: 128000}, // models.dev 共识 - "gpt-5.3-codex": {context: 400000, maxOutput: 128000}, // models.dev(全 provider 一致 400000/128000) - "gemini-3.5-flash": {context: 1048576, maxOutput: 65536}, // models.dev 共识 - - // ---- global 域路由别名/别名模型 ---- - "auto": {context: 168000, maxOutput: 0}, // 实测外推(fork global 静态表 168K);输出上限未知,省略 -} - -// ContextWindowListing 模型在 /v1/models 的 context_length(三级查找): -// remote(上游 maxInputTokens)>0 时权威;否则查知识表;仍未收录 → DefaultContextWindow -// (1M,宁可高估不低估)。绝不再透出假 131072。 -func ContextWindowListing(model string, remote int64) int64 { - if remote > 0 { - return remote - } - if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { - return cap.context - } - return DefaultContextWindow -} - -// MaxOutputTokensListing 模型在 /v1/models 的 max_output_tokens(三级查找): -// remote(上游 maxOutputTokens)>0 时权威;否则查知识表;仍未收录 → ok=false -// (调用方省略字段,不编造输出上限)。与 ContextWindowListing 的 1M 兜底刻意不同: -// 输出上限无「宁可高估」的安全侧,未知即省略。 -func MaxOutputTokensListing(model string, remote int64) (int64, bool) { - if remote > 0 { - return remote, true - } - if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { - return cap.maxOutput, true - } - return 0, false -} +// context_catalog.go context_length / max_output_tokens 字段级静态兜底知识表。 +// +// 数据来源四级(model-json-dynamic 任务书;查找链入口在 model_catalog.go 的 +// ContextWindowListingV4 / MaxOutputTokensListingV4,本文件是第 2 级): +// - 上游动态值(ModelInfo.ContextWindow/MaxTokens,即 maxInputTokens/maxOutputTokens)权威,优先; +// - 本文件静态知识表(远端零值时补齐;model.json 缺失/损坏时的编译期兜底); +// - model.json 本地缓存(数据目录,含运行时 models.dev 按需补值,见 model_catalog.go); +// - models.dev 按需拉取(异步不阻塞;仍未知 → context_length 1M 兜底——宁可高估 +// 不低估:高估代价是客户端不截断、上游报错可重试;低估代价是下游客户端 +// (Codex/ZCode/Claude Code 按 context_length 提前截断)白白丢上下文; +// max_output_tokens 未知 → 省略字段(输出上限无合估算据,不编造))。 +// +// 知识表**一处定义、CN/global 两域共用**:context_length 是模型固有属性——fork +// 706412584 实测结论「两区是同一套 API 的两次部署」,同 id 上下文一致,无按 realm +// 分表必要(与 effort 档位的 realm 分表刻意不同)。 +// +// 值来源两类,逐条注释标注: +// - fork 实测:706412584 直连上游 /console/enterprises/personal/models 的 +// maxInputTokens(/tmp/fork_diffs/70_927f57f9.diff,2026-09-13 实测; +// global 侧无法直测、按同 id 外推); +// - models.dev:https://models.dev/ 收录值(2026-09-16 查询,取多 provider 共识值; +// 官方源如 moonshotai/zai 优先)。未收录或歧义大者不编造。 +package upstream + +// DefaultContextWindow 知识表也未收录的模型的 context_length 兜底:1M。 +// 上游多数大窗口模型的实际量级;高估优于低估(见文件头)。 +const DefaultContextWindow int64 = 1000000 + +// contextCap 一个模型的上下文能力(字段级兜底条目)。 +// context 必为正(否则条目无意义,直接走 1M 兜底); +// maxOutput 为 0 表示输出上限未知 → max_output_tokens 字段省略(不编造)。 +type contextCap struct { + context int64 + maxOutput int64 +} + +// contextCapFallback context_length / max_output_tokens 知识表(CN/global 共用)。 +// 每条注释标注来源:实测 = fork 706412584 直连 CN /console 实测 maxInputTokens +// (global 侧为同 id 外推);models.dev = 2026-09-16 收录共识值;估算 = 同族外推。 +var contextCapFallback = map[string]contextCap{ + // ---- GLM 家族(z-ai)---- + "glm-5.2": {context: 1000000, maxOutput: 131072}, // 实测(CN 1M;models.dev 共识 1M/131072) + "glm-5.1": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) + "glm-5.3": {context: 1000000, maxOutput: 131072}, // 实测外推 + models.dev 共识 1M/131072 + "glm-5.3-flash": {context: 1000000, maxOutput: 131072}, // models.dev 共识 1M/131072 + "glm-5v-turbo": {context: 200000, maxOutput: 131072}, // 实测(CN 200K;models.dev 共识 200K/131072) + + // ---- Kimi 家族(moonshot)---- + "kimi-k2.7": {context: 256000, maxOutput: 65536}, // 实测(CN 256K);输出 65536 为 models.dev kimi-k2.7-code 同族估算 + "kimi-k2.6": {context: 256000, maxOutput: 262144}, // 实测(CN 256K);输出 models.dev 官方 262144 + "kimi-k2.5": {context: 164000, maxOutput: 262144}, // 实测(global 侧同 id 外推 164K);输出 models.dev 共识 262144 + "kimi-k3": {context: 1048576, maxOutput: 131072}, // models.dev 官方(moonshotai 1M/128K) + "kimi-k2.8-preview": {context: 1048576, maxOutput: 0}, // models.dev(Kimi K2.8 Preview 1M;输出上限未收录,省略) + + // ---- MiniMax / 混元(tencent)---- + "minimax-m3": {context: 512000, maxOutput: 512000}, // 实测(CN 512K);输出 models.dev 共识 512000 + "hy3": {context: 192000, maxOutput: 64000}, // 实测(CN 192K/64K,repo hy3 抓取样本同值) + "hy3-preview": {context: 262144, maxOutput: 64000}, // models.dev(共识 262144/64000) + "hy4-preview": {context: 1000000, maxOutput: 64000}, // 实测外推 + models.dev(~1M/64000) + "hy4-preview-x": {context: 1000000, maxOutput: 64000}, // 实测外推(1M);输出同族 hy4-preview 估算 + + // ---- DeepSeek 家族 ---- + "deepseek-v4-pro": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) + "deepseek-v4-flash": {context: 1000000, maxOutput: 384000}, // 实测(CN 1M;models.dev 共识 1M/384000) + "deepseek-v4.1-flash": {context: 1000000, maxOutput: 384000}, // 实测外推 + models.dev 共识 1M/384000 + + // ---- OpenAI / Google(global 域家族)---- + "gpt-6-astra": {context: 1050000, maxOutput: 128000}, // models.dev(全 provider 一致 1050000/128000) + "gpt-5.6-sol": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.6-terra": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.6-luna": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.5": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.4": {context: 1050000, maxOutput: 128000}, // models.dev 共识 + "gpt-5.3-codex": {context: 400000, maxOutput: 128000}, // models.dev(全 provider 一致 400000/128000) + "gemini-3.5-flash": {context: 1048576, maxOutput: 65536}, // models.dev 共识 + + // ---- global 域路由别名/别名模型 ---- + "auto": {context: 168000, maxOutput: 0}, // 实测外推(fork global 静态表 168K);输出上限未知,省略 +} + +// ContextWindowListing 模型在 /v1/models 的 context_length(三级查找): +// remote(上游 maxInputTokens)>0 时权威;否则查知识表;仍未收录 → DefaultContextWindow +// (1M,宁可高估不低估)。绝不再透出假 131072。 +func ContextWindowListing(model string, remote int64) int64 { + if remote > 0 { + return remote + } + if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { + return cap.context + } + return DefaultContextWindow +} + +// MaxOutputTokensListing 模型在 /v1/models 的 max_output_tokens(三级查找): +// remote(上游 maxOutputTokens)>0 时权威;否则查知识表;仍未收录 → ok=false +// (调用方省略字段,不编造输出上限)。与 ContextWindowListing 的 1M 兜底刻意不同: +// 输出上限无「宁可高估」的安全侧,未知即省略。 +func MaxOutputTokensListing(model string, remote int64) (int64, bool) { + if remote > 0 { + return remote, true + } + if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { + return cap.maxOutput, true + } + return 0, false +} diff --git a/internal/upstream/desktop.go b/internal/upstream/desktop.go index 0271b4b80e589767c18a814f6614e2c4178b0713..e63418b332119f33a32896ddf89f40e6096646ac 100644 --- a/internal/upstream/desktop.go +++ b/internal/upstream/desktop.go @@ -1,588 +1,588 @@ -// desktop.go 桌面客户端(WorkBuddy Desktop 5.5.6)行为指纹上报。 -// -// 来源:2026-09-12 Sunny 抓包实测(data/desktop-task-protocol.md)。桌面端点亮 -// 「需电脑端」类任务的关键不是独立端点,而是同一 POST /v2/report 通道上 -// **不同的客户端指纹**: -// -// POST https://copilot.tencent.com/v2/report ← chatBase(CLI 上报走 billingBase) -// User-Agent: WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1 -// X-Domain: copilot.tencent.com, X-Product: SaaS, X-User-Id: -// Body: [ {...event...} ] ← 数组 -// -// 每个事件除业务字段外必带桌面指纹(ideName/ideType=WorkBuddy、 -// extName=workbuddy-desktop 等)。实测点亮记录: -// - RichMeow_Chat(桌面端对话1次):agent_task_created + 成功的 -// chat_message_response(isSuccessful=true) → 1/1 + UR Buddy。 -// - Hp_Appearance(主题任务):独立 API -// POST /v2/user-asset/appearance/set {kind:"theme",resource_key} → SetAppearanceTheme。 -// -// 注意:service 端对事件链有一定真实性校验倾向(RichMeow 需要消息成功回执), -// 本模块按实测事件形状发送,不保证所有任务都能 API 侧点亮——autotask 侧 -// 仍按 attempt 语义处理结果。 -package upstream - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "regexp" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -const ( - desktopReportPath = "/v2/report" - desktopAppearanceSet = "/v2/user-asset/appearance/set" - // desktopUA 实测桌面客户端 UA(5.5.6 内嵌 CLI 2.137.1)。 - desktopUA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1" -) - -// desktopBase 桌面端 /v2/report 与 user-asset 走 chatBase(copilot.tencent.com)。 -func (c *Client) desktopBase(a *auth.Auth) string { return c.chatBase(a) } - -// deriveID 由 uid 稳定派生一个 36 位 hex 设备标识(machineId/qimei36 复用), -// 幂等:同一账号每次生成相同值,模拟固定设备。 -func deriveID(a *auth.Auth, salt string) string { - sum := sha256.Sum256([]byte(salt + ":" + a.UID)) - return hex.EncodeToString(sum[:18]) // 36 hex chars -} - -// DesktopEvent 桌面端事件:业务字段任意(map),公共指纹由 ReportDesktopEvent 注入。 -type DesktopEvent map[string]any - -// desktopFingerprint 公共桌面指纹字段(注入每个事件,覆盖同名业务键)。 -func desktopFingerprint(a *auth.Auth) map[string]any { - now := time.Now().UnixMilli() - return map[string]any{ - "timezone": "Asia/Shanghai", - "reportDelay": 2000, - "userId": a.UID, - "username": a.Nickname, - "userNickname": a.Nickname, - "product": "SaaS", - "releaseDate": int64(1789036585355), - "commit": "5f9692923c93033111c51ad7b003eb80204a9b75", - "ideName": "WorkBuddy", - "ideType": "WorkBuddy", - "ideVersion": "5.5.6", - "machineId": deriveID(a, "machine"), - "sessionId": deriveID(a, "session"), - "extName": "workbuddy-desktop", - "extVersion": "5.5.6", - "os": "win32", - "arch": "x64", - "osVersion": "10.0.26220", - "cpuCores": 20, - "memorySize": 24, - "timestamp": now, - "presentAt": now, - } -} - -// ReportDesktopEvent 以桌面客户端指纹向 copilot.tencent.com/v2/report 批量上报事件。 -// events 为业务载荷(eventCode 等字段由调用方给出);公共指纹自动注入, -// 业务字段优先(可用于覆盖 qimei36/machineId 等设备标识做真实设备对齐)。 -func (c *Client) ReportDesktopEvent(a *auth.Auth, events ...DesktopEvent) error { - if len(events) == 0 { - return fmt.Errorf("desktop report: no events") - } - fp := desktopFingerprint(a) - arr := make([]map[string]any, 0, len(events)) - for _, ev := range events { - m := map[string]any{} - for k, v := range fp { - m[k] = v - } - for k, v := range ev { - m[k] = v - } - arr = append(arr, m) - } - raw, err := json.Marshal(arr) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopReportPath, bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Content-Type", "application/json;charset=UTF-8") - req.Header.Set("User-Agent", desktopUA) - req.Header.Set("X-Domain", c.desktopBase(a)) - req.Header.Set("X-Product", "SaaS") - req.Header.Set("X-Request-ID", deriveID(a, "req")+fmt.Sprintf("%d", time.Now().UnixNano()%1e6)) - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - _, err = c.doJSON(req) - return err -} - -// DesktopChatSequence 构造一次「桌面端成功对话」的完整事件链 -// (agent_task_created → chat_message_send → chat_request_send → -// chat_message_response(isSuccessful) → chat_message_status → chat_request_response)。 -// 实测该链点亮 RichMeow_Chat。conversationID/requestID/messageID 由调用方生成。 -func DesktopChatSequence(conversationID, requestID, messageID, modelID, modelName string) []DesktopEvent { - uuid := func() string { return requestID } - mk := func(code string, extra map[string]any) DesktopEvent { - ev := DesktopEvent{"eventCode": code} - for k, v := range extra { - ev[k] = v - } - return ev - } - return []DesktopEvent{ - mk("agent_task_created", map[string]any{ - "source": "LOCAL", "name": "working", "task_target": "local", "mode": "craft", - "requestModelId": modelID, "requestModelName": modelName, - "has_repo": false, "repo_type": "none", "workspace_type": "empty", - "has_connector": false, "connector_types": []any{}, - "has_mention": false, "mention_types": []any{}, - "has_template": false, "action": "", "template_name": "", - "has_expert": false, "expert_id": "", "expert_name": "", "expert_industry_id": "", - "has_skill": false, "skill_names": []any{}, - "conversationId": conversationID, "messageId": messageID, - "buddyId": "", "buddyName": "", - }), - mk("chat_message_send", map[string]any{ - "messageId": messageID + "-assistant", "historyCount": 0, - "isContextTruncated": false, "currentStepCount": 1, - "traceId": uuid(), "rootRequestId": requestID, - "parentConversationId": conversationID, - "agentName": "cli", "agentType": "main", - }), - mk("chat_request_send", map[string]any{ - "inputLength": 24, "isPlan": false, "isAutoExecuteTerminal": false, - "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, - "maxSteps": 500, "temperature": 0, "maxRetries": 0, - "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, - "codebaseId": "", "mentionContextCount": 0, "command": "", - "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, - "traceId": uuid(), "rootRequestId": requestID, - "parentConversationId": conversationID, - "agentName": "cli", "agentType": "main", - "codebuddy.session_id": conversationID, - "codebuddy.conversation_request_id": requestID, - }), - mk("chat_message_response", map[string]any{ - "messageId": messageID + "-assistant", "responseModelId": modelID, - "inputToken": 120, "outputToken": 80, "totalToken": 200, - "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, - "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", - "firstTokenAt": time.Now().UnixMilli(), "traceId": uuid(), - "conversationId": conversationID, - "rootRequestId": requestID, "parentConversationId": conversationID, - "agentName": "cli", "agentType": "main", - "codebuddy.session_id": conversationID, - "codebuddy.conversation_request_id": requestID, - }), - mk("chat_message_status", map[string]any{ - "messageId": messageID + "-assistant", "messageErrorCode": "0", - "traceId": uuid(), "rootRequestId": requestID, - "parentConversationId": conversationID, - "agentName": "cli", "agentType": "main", - }), - mk("chat_request_response", map[string]any{ - "mode": "craft", "toolCallCount": 0, - "inputToken": 120, "outputToken": 80, "totalToken": 200, - "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, - "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", - "rootRequestId": requestID, "parentConversationId": conversationID, - }), - } -} - -// SetAppearanceTheme 应用外观主题(实测:POST copilot.tencent.com/v2/user-asset/appearance/set, -// 和品主题 resource_key 为 "theme-tkmw7j",浅色 "light"、深色 "dark")。纯 API set 不计 -// Hp_Appearance 分(需客户端切主题后真实活跃),保留供调色/还原与后续验证用。 -func (c *Client) SetAppearanceTheme(a *auth.Auth, resourceKey string) error { - body := map[string]string{"kind": "theme", "resource_key": resourceKey} - raw, err := json.Marshal(body) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopAppearanceSet, bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Content-Type", "application/json;charset=UTF-8") - req.Header.Set("User-Agent", desktopUA) - req.Header.Set("X-Product", "SaaS") - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - _, err = c.doJSON(req) - return err -} - -// DesktopBuddyAppSequence 构造「进入 Buddy 应用」五连事件(实测两账号纯 API 点亮 -// Buddy_App 与 Buddy_App_QQ):discover → show → enter_click → auth_confirm → -// bindaccount_skip。buddyID 固定用企鹅教师助手 cb_y5Dy46tPQGGWtueMxXbe -// (Buddy_App_QQ 的判据应用),同时满足 Buddy_App「进入任一应用」。 -func DesktopBuddyAppSequence(buddyID, buddyName string) []DesktopEvent { - mk := func(code string, extra map[string]any) DesktopEvent { - ev := DesktopEvent{ - "eventCode": code, "mode": "LOCAL", - "buddyId": buddyID, "buddyName": buddyName, - } - for k, v := range extra { - ev[k] = v - } - return ev - } - return []DesktopEvent{ - mk("buddyapp_discover_click", nil), - mk("buddyapp_show", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2}), - mk("buddyapp_enter_click", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2, "isFirstPage": "1"}), - mk("buddyapp_auth_confirm_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), - mk("buddyapp_bindaccount_skip_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), - } -} - -// DesktopAutomationCreateEvent 构造「定时任务创建成功」事件(实测两账号纯 API -// 点亮 automation_1)。name 为任务名,可与真实创建语义对齐。 -func DesktopAutomationCreateEvent(name string) DesktopEvent { - return DesktopEvent{ - "eventCode": "automated_task_create_suc", "name": name, - "source": "manually", "modelId": "fast-model", "modelIsThinking": true, - "connectorCount": 0, "skills": "", "skillCount": 0, - "scheduleType": "once", "mode": "LOCAL", - } -} - -// ReportWebEvent 以 Web 端指纹向 www.workbuddy.cn/v2/report 上报单事件。 -// 与桌面指纹(copilot 域)不同:web 域事件是浏览器形状(os/machineId/userAgent), -// 用于 Library_read 等页面行为类任务(实测 library_doc_intro_click 4 秒点亮)。 -func (c *Client) ReportWebEvent(a *auth.Auth, eventCode, pageURL, elementID, elementName string) error { - ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" - ev := map[string]any{ - "eventCode": eventCode, "timestamp": time.Now().UnixMilli(), "reportDelay": 0, - "pageURL": pageURL, "elementId": elementID, "elementName": elementName, - "os": "Win32", "arch": "", "osVersion": "10.0", "userAgent": ua, - "machineId": deriveID(a, "webmachine"), "userId": a.UID, - "userNickname": a.Nickname, "enterpriseId": a.EnterpriseID, - } - raw, err := json.Marshal([]map[string]any{ev}) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, c.webBase(a)+"/v2/report", bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("x-client-platform", "web") - req.Header.Set("Origin", c.webBase(a)) - req.Header.Set("Referer", pageURL) - req.Header.Set("User-Agent", ua) - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - _, err = c.doJSON(req) - return err -} - -// --------------------------------------------------------------------------- -// 2026-09-12 第五轮:客户端 asar 逆向 + Sunny 真实样本驱动的新判据(三账号实测点亮)。 -// 来源:WorkBuddy.exe 5.5.6 app.asar 渲染层事件枚举/载荷 + 抓包真实专家召唤样本 -// (data/desktop-task-protocol.md §7.3)。 -// --------------------------------------------------------------------------- - -// DesktopTemplateUseSequence 构造「使用模板创建任务」事件组(实测 template_5 计数): -// agent_task_created_with_template {mode,isCustomModel,id,name,requestId} + -// template_used {template_id,task_mode},JOIN 一条完整 chat 链。 -// 三账号实测:5 组(不同模板)一次上报 → 5/5 点亮。 -func DesktopTemplateUseSequence(conversationID, requestID, templateID, templateName string) []DesktopEvent { - events := DesktopChatSequence(conversationID, requestID, "msg-"+templateID, "fast-model", "fast-model") - events = append(events, - DesktopEvent{ - "eventCode": "agent_task_created_with_template", "mode": "working", - "isCustomModel": false, "id": templateID, "name": templateName, "requestId": requestID, - }, - DesktopEvent{"eventCode": "template_used", "template_id": templateID, "task_mode": "working"}, - ) - return events -} - -// DesktopPlaybookPromptSequence 构造「灵感案例做同款」事件组(实测 playbook_prompt 计数): -// web_element_click(playbook_ctaClick) + playbook_cta_click + playbook_prompt_send -// (Dialog 发送 Prompt,带 conversationId/requestId JOIN chat 链)。三账号实测 1/1 点亮。 -func DesktopPlaybookPromptSequence(conversationID, requestID, caseID, caseName string) []DesktopEvent { - events := DesktopChatSequence(conversationID, requestID, "msg-pb", "fast-model", "fast-model") - payload := map[string]any{ - "id": caseID, "name": caseName, "type": "document", - "categoryId": "", "categoryName": "", - } - events = append(events, - DesktopEvent{ - "eventCode": "web_element_click", "pageName": "playbook_detail", - "elementId": "playbook_ctaClick", "elementName": caseName, "source": "discover", - }, - DesktopEvent(func() map[string]any { - m := map[string]any{"eventCode": "playbook_cta_click", "source": "discover", "position": 0} - for k, v := range payload { - m[k] = v - } - return m - }()), - DesktopEvent(func() map[string]any { - m := map[string]any{"eventCode": "playbook_prompt_send", "conversationId": conversationID, "requestId": requestID} - for k, v := range payload { - m[k] = v - } - return m - }()), - ) - return events -} - -// DesktopDesignCanvasSequence 构造「设计创意画布」事件组(实测 create_canvas 计数): -// wbx_design_canvas_task_create + wbx_design_canvas_open(Ardot create_design 工具 -// 完成时客户端经 metrics 通道上报,同一 /v2/report 端点)。三账号实测 1/1 点亮。 -func DesktopDesignCanvasSequence(conversationID, requestID string) []DesktopEvent { - events := DesktopChatSequence(conversationID, requestID, "msg-canvas", "fast-model", "fast-model") - return append(events, - DesktopEvent{ - "eventCode": "wbx_design_canvas_task_create", "conversationId": conversationID, - "requestId": requestID, "source": "summon_keyword", "cost": 12000, "isSuccessful": true, - }, - DesktopEvent{ - "eventCode": "wbx_design_canvas_open", "conversationId": conversationID, - "requestId": requestID, "id": "ardot-file-" + requestID[len(requestID)-8:], - "source": "summon_keyword", "type": "page", "cost": 13000, "isSuccessful": true, - }, - ) -} - -// MarketExpert 专家市场的单个专家(/portal/operation-platform/market/expert/list 响应子集)。 -type MarketExpert struct { - ExpertID string `json:"expert_id"` - ExpertType string `json:"expert_type"` - DisplayNameZH string `json:"display_name_zh"` - ProfessionZH string `json:"profession_zh"` - Version string `json:"version"` - Categories []any `json:"categories"` -} - -// MarketExpertList 拉取专家市场真实专家列表(expertType: "agent" 单专家 / "team" 专家团)。 -// expert_actual_use 的判据校验要求 id 是平台上真实存在的专家(编造 id 不计数)。 -func (c *Client) MarketExpertList(a *auth.Auth, expertType string) ([]MarketExpert, error) { - body := map[string]any{"page": 1, "page_size": 20, "sort_by": "reco_rank", "sort_order": "desc"} - if expertType != "" { - body["expert_type"] = expertType - } - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/portal/operation-platform/market/expert/list", bytes.NewReader(raw)) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", desktopUA) - req.Header.Set("X-Domain", c.chatBase(a)) - req.Header.Set("X-Product", "SaaS") - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - var out struct { - Experts []MarketExpert `json:"experts"` - } - data, err := c.doJSON(req) - if err != nil { - return nil, err - } - if err := json.Unmarshal(data, &out); err != nil { - return nil, fmt.Errorf("expert list parse: %w", err) - } - return out.Experts, nil -} - -// DesktopChatWithExpert 发一条真实桌面指纹 chat 请求(可带 X-Expert-Id),从 SSE 流 -// 解析**服务端返回的 requestId**(data.id,如 cmb-xxxx / 32hex)并返回。 -// expert_actual_use 等 JOIN 事件的 requestId 必须是该服务端 id——自造 UUID 不计数 -// (客户端 resolveRealRequestId 同款语义,Sunny row 2113 实证)。 -func (c *Client) DesktopChatWithExpert(a *auth.Auth, expertID string) (conversationID, requestID string, err error) { - conversationID = fmt.Sprintf("wb2api-conv-%d", time.Now().UnixNano()) - body := map[string]any{ - "model": "fast-model", - "messages": []any{ - map[string]any{"role": "system", "content": "You are a helpful assistant. 当前处于中文环境,使用简体中文回答。"}, - map[string]any{"role": "user", "content": "1+1等于几?直接回答。"}, - }, - "agent": "cli", - "temperature": 1, - "stream": true, - "stream_options": map[string]any{"include_usage": true}, - } - raw, err := json.Marshal(body) - if err != nil { - return "", "", err - } - req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/v2/chat/completions", bytes.NewReader(raw)) - if err != nil { - return "", "", err - } - h := req.Header - h.Set("Authorization", "Bearer "+a.AccessTokenValue()) - h.Set("Content-Type", "application/json") - h.Set("Accept", "text/event-stream") - h.Set("User-Agent", desktopUA) - h.Set("X-Domain", c.chatBase(a)) - h.Set("X-Product", "SaaS") - h.Set("X-User-Id", a.UID) - h.Set("X-Conversation-ID", conversationID) - h.Set("X-Request-ID", fmt.Sprintf("%d", time.Now().UnixNano())) - h.Set("X-Agent-Intent", "craft") - h.Set("X-Agent-Type", "main") - h.Set("X-IDE-Name", "WorkBuddy") - h.Set("X-IDE-Type", "WorkBuddy") - h.Set("X-IDE-Version", "5.5.6") - h.Set("x-codebuddy-request", "1") - if expertID != "" { - h.Set("X-Expert-Id", expertID) - } - if os.Getenv("WB2A_DEBUG_CHAT") != "" { - fmt.Printf("[dbg] URL=%s\n", req.URL) - for k := range req.Header { - fmt.Printf("[dbg] %s: %s\n", k, req.Header.Get(k)) - } - } - resp, err := c.HTTP.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - if os.Getenv("WB2A_DEBUG_CHAT") != "" { - fmt.Printf("[dbg] status=%d enc=%q\n", resp.StatusCode, resp.Header.Get("Content-Encoding")) - } - if resp.StatusCode != 200 { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) - return "", "", fmt.Errorf("chat http %d: %s", resp.StatusCode, b) - } - // 从 SSE 流抓第一个 data.id 作为服务端 requestId(读干流避免残留连接)。 - buf := make([]byte, 0, 1<<20) - tmp := make([]byte, 8192) - searchFrom := 0 - for { - n, rerr := resp.Body.Read(tmp) - if n > 0 { - buf = append(buf, tmp[:n]...) - if os.Getenv("WB2A_DEBUG_CHAT") != "" { - dbg := string(tmp[:n]) - if len(dbg) > 120 { - dbg = dbg[:120] - } - fmt.Printf("[dbg-rd %d] %q\n", n, dbg) - } - // 从上次搜索位置继续:SSE 里 `"id":"` 可能先出现在消息 id 等字段, - // 若不推进偏移,首个不匹配的 id 会让循环永远重复命中同一位置, - // 读满 1MB 后误报"未找到 requestId"。 - if i := bytes.Index(buf[searchFrom:], []byte(`"id":"`)); i >= 0 { - abs := searchFrom + i - rest := buf[abs+6:] - if end := bytes.IndexByte(rest, '"'); end > 0 { - id := string(rest[:end]) - if os.Getenv("WB2A_DEBUG_CHAT") != "" { - fmt.Printf("[dbg-id] %q match=%v\n", id, idRegex.MatchString(id)) - } - if idRegex.MatchString(id) { - return conversationID, id, nil - } - searchFrom = abs + 1 - } - } - } - if rerr != nil || len(buf) > 1<<20 { - break - } - } - return "", "", fmt.Errorf("SSE 中未找到服务端 requestId") -} - -// idRegex 服务端 requestId 形状(cmb- 前缀 32hex 或裸 32hex)。 -var idRegex = regexp.MustCompile(`^(cmb-)?[0-9a-f]{32}$`) - -// DesktopExpertSummonSequence 构造「召唤平台专家」事件组(expert_summon_click 等), -// 载荷对齐真实抓包样本(Sunny row 2644)。需配合 DesktopChatWithExpert + -// DesktopExpertActualUseEvent 完成一次完整「召唤+使用」。 -func DesktopExpertSummonSequence(e MarketExpert) []DesktopEvent { - cat := "expert-all" - if len(e.Categories) > 0 { - if s, ok := e.Categories[0].(string); ok { - cat = s - } - } - ver := e.Version - if ver == "" { - ver = "1.0.0" - } - return []DesktopEvent{ - { - "eventCode": "web_element_click", "source": e.ExpertID, "type": cat, "version": ver, - "elementId": "expert_summon_click", "elementName": "立即召唤", - "pageURL": "/C:/Program%20Files/WorkBuddy/resources/app.asar/renderer/index.html", - }, - { - "eventCode": "expert_summon_click", "id": e.ExpertID, "name": e.DisplayNameZH, - "expertTitle": e.ProfessionZH, "type": "expert-all", "position": 0, - "expertType": e.ExpertType, "version": ver, "mode": "LOCAL", - }, - { - "eventCode": "expert_summoned", "id": e.ExpertID, "name": e.DisplayNameZH, - "expertTitle": e.ProfessionZH, "type": "expert-all", - }, - } -} - -// DesktopExpertActualUseEvent 构造「专家真实使用」事件(expert_5/Expert_team_use_3 计数)。 -// requestID 必须是 DesktopChatWithExpert 返回的服务端 requestId。 -func DesktopExpertActualUseEvent(e MarketExpert, conversationID, requestID string) DesktopEvent { - ev := desktopExpertActualUse(e, conversationID, requestID) - ev["mode"] = "craft" - return ev -} - -// DesktopExpertActualUseLocal mode:"LOCAL" 变体(Expert_lighthouse 判据要求 LOCAL, -// 对齐真实样本 Sunny row 868:轻量云专家使用时 mode=LOCAL、type 为空、cost=0)。 -func DesktopExpertActualUseLocal(e MarketExpert, conversationID, requestID string) DesktopEvent { - ev := desktopExpertActualUse(e, conversationID, requestID) - ev["mode"] = "LOCAL" - return ev -} - -// desktopExpertActualUse expert_actual_use 公共载荷。 -func desktopExpertActualUse(e MarketExpert, conversationID, requestID string) DesktopEvent { - cat := "expert-all" - if len(e.Categories) > 0 { - if s, ok := e.Categories[0].(string); ok { - cat = s - } - } - ver := e.Version - if ver == "" { - ver = "1.0.0" - } - return DesktopEvent{ - "eventCode": "expert_actual_use", - "id": e.ExpertID, "name": e.DisplayNameZH, "expertTitle": e.ProfessionZH, - "type": cat, "expertType": e.ExpertType, "source": "builtin", "version": ver, - "cost": 9000, "characterCount": 14, - "conversationId": conversationID, "requestId": requestID, "messageId": "msg-" + requestID[len(requestID)-8:], - "requestModelId": "fast-model", "requestModelName": "fast-model", - } -} +// desktop.go 桌面客户端(WorkBuddy Desktop 5.5.6)行为指纹上报。 +// +// 来源:2026-09-12 Sunny 抓包实测(data/desktop-task-protocol.md)。桌面端点亮 +// 「需电脑端」类任务的关键不是独立端点,而是同一 POST /v2/report 通道上 +// **不同的客户端指纹**: +// +// POST https://copilot.tencent.com/v2/report ← chatBase(CLI 上报走 billingBase) +// User-Agent: WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1 +// X-Domain: copilot.tencent.com, X-Product: SaaS, X-User-Id: +// Body: [ {...event...} ] ← 数组 +// +// 每个事件除业务字段外必带桌面指纹(ideName/ideType=WorkBuddy、 +// extName=workbuddy-desktop 等)。实测点亮记录: +// - RichMeow_Chat(桌面端对话1次):agent_task_created + 成功的 +// chat_message_response(isSuccessful=true) → 1/1 + UR Buddy。 +// - Hp_Appearance(主题任务):独立 API +// POST /v2/user-asset/appearance/set {kind:"theme",resource_key} → SetAppearanceTheme。 +// +// 注意:service 端对事件链有一定真实性校验倾向(RichMeow 需要消息成功回执), +// 本模块按实测事件形状发送,不保证所有任务都能 API 侧点亮——autotask 侧 +// 仍按 attempt 语义处理结果。 +package upstream + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + desktopReportPath = "/v2/report" + desktopAppearanceSet = "/v2/user-asset/appearance/set" + // desktopUA 实测桌面客户端 UA(5.5.6 内嵌 CLI 2.137.1)。 + desktopUA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1" +) + +// desktopBase 桌面端 /v2/report 与 user-asset 走 chatBase(copilot.tencent.com)。 +func (c *Client) desktopBase(a *auth.Auth) string { return c.chatBase(a) } + +// deriveID 由 uid 稳定派生一个 36 位 hex 设备标识(machineId/qimei36 复用), +// 幂等:同一账号每次生成相同值,模拟固定设备。 +func deriveID(a *auth.Auth, salt string) string { + sum := sha256.Sum256([]byte(salt + ":" + a.UID)) + return hex.EncodeToString(sum[:18]) // 36 hex chars +} + +// DesktopEvent 桌面端事件:业务字段任意(map),公共指纹由 ReportDesktopEvent 注入。 +type DesktopEvent map[string]any + +// desktopFingerprint 公共桌面指纹字段(注入每个事件,覆盖同名业务键)。 +func desktopFingerprint(a *auth.Auth) map[string]any { + now := time.Now().UnixMilli() + return map[string]any{ + "timezone": "Asia/Shanghai", + "reportDelay": 2000, + "userId": a.UID, + "username": a.Nickname, + "userNickname": a.Nickname, + "product": "SaaS", + "releaseDate": int64(1789036585355), + "commit": "5f9692923c93033111c51ad7b003eb80204a9b75", + "ideName": "WorkBuddy", + "ideType": "WorkBuddy", + "ideVersion": "5.5.6", + "machineId": deriveID(a, "machine"), + "sessionId": deriveID(a, "session"), + "extName": "workbuddy-desktop", + "extVersion": "5.5.6", + "os": "win32", + "arch": "x64", + "osVersion": "10.0.26220", + "cpuCores": 20, + "memorySize": 24, + "timestamp": now, + "presentAt": now, + } +} + +// ReportDesktopEvent 以桌面客户端指纹向 copilot.tencent.com/v2/report 批量上报事件。 +// events 为业务载荷(eventCode 等字段由调用方给出);公共指纹自动注入, +// 业务字段优先(可用于覆盖 qimei36/machineId 等设备标识做真实设备对齐)。 +func (c *Client) ReportDesktopEvent(a *auth.Auth, events ...DesktopEvent) error { + if len(events) == 0 { + return fmt.Errorf("desktop report: no events") + } + fp := desktopFingerprint(a) + arr := make([]map[string]any, 0, len(events)) + for _, ev := range events { + m := map[string]any{} + for k, v := range fp { + m[k] = v + } + for k, v := range ev { + m[k] = v + } + arr = append(arr, m) + } + raw, err := json.Marshal(arr) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopReportPath, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Domain", c.desktopBase(a)) + req.Header.Set("X-Product", "SaaS") + req.Header.Set("X-Request-ID", deriveID(a, "req")+fmt.Sprintf("%d", time.Now().UnixNano()%1e6)) + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// DesktopChatSequence 构造一次「桌面端成功对话」的完整事件链 +// (agent_task_created → chat_message_send → chat_request_send → +// chat_message_response(isSuccessful) → chat_message_status → chat_request_response)。 +// 实测该链点亮 RichMeow_Chat。conversationID/requestID/messageID 由调用方生成。 +func DesktopChatSequence(conversationID, requestID, messageID, modelID, modelName string) []DesktopEvent { + uuid := func() string { return requestID } + mk := func(code string, extra map[string]any) DesktopEvent { + ev := DesktopEvent{"eventCode": code} + for k, v := range extra { + ev[k] = v + } + return ev + } + return []DesktopEvent{ + mk("agent_task_created", map[string]any{ + "source": "LOCAL", "name": "working", "task_target": "local", "mode": "craft", + "requestModelId": modelID, "requestModelName": modelName, + "has_repo": false, "repo_type": "none", "workspace_type": "empty", + "has_connector": false, "connector_types": []any{}, + "has_mention": false, "mention_types": []any{}, + "has_template": false, "action": "", "template_name": "", + "has_expert": false, "expert_id": "", "expert_name": "", "expert_industry_id": "", + "has_skill": false, "skill_names": []any{}, + "conversationId": conversationID, "messageId": messageID, + "buddyId": "", "buddyName": "", + }), + mk("chat_message_send", map[string]any{ + "messageId": messageID + "-assistant", "historyCount": 0, + "isContextTruncated": false, "currentStepCount": 1, + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + }), + mk("chat_request_send", map[string]any{ + "inputLength": 24, "isPlan": false, "isAutoExecuteTerminal": false, + "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, + "maxSteps": 500, "temperature": 0, "maxRetries": 0, + "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, + "codebaseId": "", "mentionContextCount": 0, "command": "", + "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": requestID, + }), + mk("chat_message_response", map[string]any{ + "messageId": messageID + "-assistant", "responseModelId": modelID, + "inputToken": 120, "outputToken": 80, "totalToken": 200, + "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, + "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", + "firstTokenAt": time.Now().UnixMilli(), "traceId": uuid(), + "conversationId": conversationID, + "rootRequestId": requestID, "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": requestID, + }), + mk("chat_message_status", map[string]any{ + "messageId": messageID + "-assistant", "messageErrorCode": "0", + "traceId": uuid(), "rootRequestId": requestID, + "parentConversationId": conversationID, + "agentName": "cli", "agentType": "main", + }), + mk("chat_request_response", map[string]any{ + "mode": "craft", "toolCallCount": 0, + "inputToken": 120, "outputToken": 80, "totalToken": 200, + "cachedTokens": 0, "cachedWriteTokens": 0, "cachedMissTokens": 0, + "isSuccessful": true, "messageErrorCode": "", "finishReason": "stop", + "rootRequestId": requestID, "parentConversationId": conversationID, + }), + } +} + +// SetAppearanceTheme 应用外观主题(实测:POST copilot.tencent.com/v2/user-asset/appearance/set, +// 和品主题 resource_key 为 "theme-tkmw7j",浅色 "light"、深色 "dark")。纯 API set 不计 +// Hp_Appearance 分(需客户端切主题后真实活跃),保留供调色/还原与后续验证用。 +func (c *Client) SetAppearanceTheme(a *auth.Auth, resourceKey string) error { + body := map[string]string{"kind": "theme", "resource_key": resourceKey} + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.desktopBase(a)+desktopAppearanceSet, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Product", "SaaS") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// DesktopBuddyAppSequence 构造「进入 Buddy 应用」五连事件(实测两账号纯 API 点亮 +// Buddy_App 与 Buddy_App_QQ):discover → show → enter_click → auth_confirm → +// bindaccount_skip。buddyID 固定用企鹅教师助手 cb_y5Dy46tPQGGWtueMxXbe +// (Buddy_App_QQ 的判据应用),同时满足 Buddy_App「进入任一应用」。 +func DesktopBuddyAppSequence(buddyID, buddyName string) []DesktopEvent { + mk := func(code string, extra map[string]any) DesktopEvent { + ev := DesktopEvent{ + "eventCode": code, "mode": "LOCAL", + "buddyId": buddyID, "buddyName": buddyName, + } + for k, v := range extra { + ev[k] = v + } + return ev + } + return []DesktopEvent{ + mk("buddyapp_discover_click", nil), + mk("buddyapp_show", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2}), + mk("buddyapp_enter_click", map[string]any{"elementId": buddyID, "elementName": buddyName, "position": 2, "isFirstPage": "1"}), + mk("buddyapp_auth_confirm_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), + mk("buddyapp_bindaccount_skip_click", map[string]any{"elementId": buddyID, "elementName": buddyName}), + } +} + +// DesktopAutomationCreateEvent 构造「定时任务创建成功」事件(实测两账号纯 API +// 点亮 automation_1)。name 为任务名,可与真实创建语义对齐。 +func DesktopAutomationCreateEvent(name string) DesktopEvent { + return DesktopEvent{ + "eventCode": "automated_task_create_suc", "name": name, + "source": "manually", "modelId": "fast-model", "modelIsThinking": true, + "connectorCount": 0, "skills": "", "skillCount": 0, + "scheduleType": "once", "mode": "LOCAL", + } +} + +// ReportWebEvent 以 Web 端指纹向 www.workbuddy.cn/v2/report 上报单事件。 +// 与桌面指纹(copilot 域)不同:web 域事件是浏览器形状(os/machineId/userAgent), +// 用于 Library_read 等页面行为类任务(实测 library_doc_intro_click 4 秒点亮)。 +func (c *Client) ReportWebEvent(a *auth.Auth, eventCode, pageURL, elementID, elementName string) error { + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" + ev := map[string]any{ + "eventCode": eventCode, "timestamp": time.Now().UnixMilli(), "reportDelay": 0, + "pageURL": pageURL, "elementId": elementID, "elementName": elementName, + "os": "Win32", "arch": "", "osVersion": "10.0", "userAgent": ua, + "machineId": deriveID(a, "webmachine"), "userId": a.UID, + "userNickname": a.Nickname, "enterpriseId": a.EnterpriseID, + } + raw, err := json.Marshal([]map[string]any{ev}) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.webBase(a)+"/v2/report", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-client-platform", "web") + req.Header.Set("Origin", c.webBase(a)) + req.Header.Set("Referer", pageURL) + req.Header.Set("User-Agent", ua) + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + _, err = c.doJSON(req) + return err +} + +// --------------------------------------------------------------------------- +// 2026-09-12 第五轮:客户端 asar 逆向 + Sunny 真实样本驱动的新判据(三账号实测点亮)。 +// 来源:WorkBuddy.exe 5.5.6 app.asar 渲染层事件枚举/载荷 + 抓包真实专家召唤样本 +// (data/desktop-task-protocol.md §7.3)。 +// --------------------------------------------------------------------------- + +// DesktopTemplateUseSequence 构造「使用模板创建任务」事件组(实测 template_5 计数): +// agent_task_created_with_template {mode,isCustomModel,id,name,requestId} + +// template_used {template_id,task_mode},JOIN 一条完整 chat 链。 +// 三账号实测:5 组(不同模板)一次上报 → 5/5 点亮。 +func DesktopTemplateUseSequence(conversationID, requestID, templateID, templateName string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-"+templateID, "fast-model", "fast-model") + events = append(events, + DesktopEvent{ + "eventCode": "agent_task_created_with_template", "mode": "working", + "isCustomModel": false, "id": templateID, "name": templateName, "requestId": requestID, + }, + DesktopEvent{"eventCode": "template_used", "template_id": templateID, "task_mode": "working"}, + ) + return events +} + +// DesktopPlaybookPromptSequence 构造「灵感案例做同款」事件组(实测 playbook_prompt 计数): +// web_element_click(playbook_ctaClick) + playbook_cta_click + playbook_prompt_send +// (Dialog 发送 Prompt,带 conversationId/requestId JOIN chat 链)。三账号实测 1/1 点亮。 +func DesktopPlaybookPromptSequence(conversationID, requestID, caseID, caseName string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-pb", "fast-model", "fast-model") + payload := map[string]any{ + "id": caseID, "name": caseName, "type": "document", + "categoryId": "", "categoryName": "", + } + events = append(events, + DesktopEvent{ + "eventCode": "web_element_click", "pageName": "playbook_detail", + "elementId": "playbook_ctaClick", "elementName": caseName, "source": "discover", + }, + DesktopEvent(func() map[string]any { + m := map[string]any{"eventCode": "playbook_cta_click", "source": "discover", "position": 0} + for k, v := range payload { + m[k] = v + } + return m + }()), + DesktopEvent(func() map[string]any { + m := map[string]any{"eventCode": "playbook_prompt_send", "conversationId": conversationID, "requestId": requestID} + for k, v := range payload { + m[k] = v + } + return m + }()), + ) + return events +} + +// DesktopDesignCanvasSequence 构造「设计创意画布」事件组(实测 create_canvas 计数): +// wbx_design_canvas_task_create + wbx_design_canvas_open(Ardot create_design 工具 +// 完成时客户端经 metrics 通道上报,同一 /v2/report 端点)。三账号实测 1/1 点亮。 +func DesktopDesignCanvasSequence(conversationID, requestID string) []DesktopEvent { + events := DesktopChatSequence(conversationID, requestID, "msg-canvas", "fast-model", "fast-model") + return append(events, + DesktopEvent{ + "eventCode": "wbx_design_canvas_task_create", "conversationId": conversationID, + "requestId": requestID, "source": "summon_keyword", "cost": 12000, "isSuccessful": true, + }, + DesktopEvent{ + "eventCode": "wbx_design_canvas_open", "conversationId": conversationID, + "requestId": requestID, "id": "ardot-file-" + requestID[len(requestID)-8:], + "source": "summon_keyword", "type": "page", "cost": 13000, "isSuccessful": true, + }, + ) +} + +// MarketExpert 专家市场的单个专家(/portal/operation-platform/market/expert/list 响应子集)。 +type MarketExpert struct { + ExpertID string `json:"expert_id"` + ExpertType string `json:"expert_type"` + DisplayNameZH string `json:"display_name_zh"` + ProfessionZH string `json:"profession_zh"` + Version string `json:"version"` + Categories []any `json:"categories"` +} + +// MarketExpertList 拉取专家市场真实专家列表(expertType: "agent" 单专家 / "team" 专家团)。 +// expert_actual_use 的判据校验要求 id 是平台上真实存在的专家(编造 id 不计数)。 +func (c *Client) MarketExpertList(a *auth.Auth, expertType string) ([]MarketExpert, error) { + body := map[string]any{"page": 1, "page_size": 20, "sort_by": "reco_rank", "sort_order": "desc"} + if expertType != "" { + body["expert_type"] = expertType + } + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/portal/operation-platform/market/expert/list", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", desktopUA) + req.Header.Set("X-Domain", c.chatBase(a)) + req.Header.Set("X-Product", "SaaS") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + var out struct { + Experts []MarketExpert `json:"experts"` + } + data, err := c.doJSON(req) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("expert list parse: %w", err) + } + return out.Experts, nil +} + +// DesktopChatWithExpert 发一条真实桌面指纹 chat 请求(可带 X-Expert-Id),从 SSE 流 +// 解析**服务端返回的 requestId**(data.id,如 cmb-xxxx / 32hex)并返回。 +// expert_actual_use 等 JOIN 事件的 requestId 必须是该服务端 id——自造 UUID 不计数 +// (客户端 resolveRealRequestId 同款语义,Sunny row 2113 实证)。 +func (c *Client) DesktopChatWithExpert(a *auth.Auth, expertID string) (conversationID, requestID string, err error) { + conversationID = fmt.Sprintf("wb2api-conv-%d", time.Now().UnixNano()) + body := map[string]any{ + "model": "fast-model", + "messages": []any{ + map[string]any{"role": "system", "content": "You are a helpful assistant. 当前处于中文环境,使用简体中文回答。"}, + map[string]any{"role": "user", "content": "1+1等于几?直接回答。"}, + }, + "agent": "cli", + "temperature": 1, + "stream": true, + "stream_options": map[string]any{"include_usage": true}, + } + raw, err := json.Marshal(body) + if err != nil { + return "", "", err + } + req, err := http.NewRequest(http.MethodPost, c.chatBase(a)+"/v2/chat/completions", bytes.NewReader(raw)) + if err != nil { + return "", "", err + } + h := req.Header + h.Set("Authorization", "Bearer "+a.AccessTokenValue()) + h.Set("Content-Type", "application/json") + h.Set("Accept", "text/event-stream") + h.Set("User-Agent", desktopUA) + h.Set("X-Domain", c.chatBase(a)) + h.Set("X-Product", "SaaS") + h.Set("X-User-Id", a.UID) + h.Set("X-Conversation-ID", conversationID) + h.Set("X-Request-ID", fmt.Sprintf("%d", time.Now().UnixNano())) + h.Set("X-Agent-Intent", "craft") + h.Set("X-Agent-Type", "main") + h.Set("X-IDE-Name", "WorkBuddy") + h.Set("X-IDE-Type", "WorkBuddy") + h.Set("X-IDE-Version", "5.5.6") + h.Set("x-codebuddy-request", "1") + if expertID != "" { + h.Set("X-Expert-Id", expertID) + } + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg] URL=%s\n", req.URL) + for k := range req.Header { + fmt.Printf("[dbg] %s: %s\n", k, req.Header.Get(k)) + } + } + resp, err := c.HTTP.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg] status=%d enc=%q\n", resp.StatusCode, resp.Header.Get("Content-Encoding")) + } + if resp.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return "", "", fmt.Errorf("chat http %d: %s", resp.StatusCode, b) + } + // 从 SSE 流抓第一个 data.id 作为服务端 requestId(读干流避免残留连接)。 + buf := make([]byte, 0, 1<<20) + tmp := make([]byte, 8192) + searchFrom := 0 + for { + n, rerr := resp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + dbg := string(tmp[:n]) + if len(dbg) > 120 { + dbg = dbg[:120] + } + fmt.Printf("[dbg-rd %d] %q\n", n, dbg) + } + // 从上次搜索位置继续:SSE 里 `"id":"` 可能先出现在消息 id 等字段, + // 若不推进偏移,首个不匹配的 id 会让循环永远重复命中同一位置, + // 读满 1MB 后误报"未找到 requestId"。 + if i := bytes.Index(buf[searchFrom:], []byte(`"id":"`)); i >= 0 { + abs := searchFrom + i + rest := buf[abs+6:] + if end := bytes.IndexByte(rest, '"'); end > 0 { + id := string(rest[:end]) + if os.Getenv("WB2A_DEBUG_CHAT") != "" { + fmt.Printf("[dbg-id] %q match=%v\n", id, idRegex.MatchString(id)) + } + if idRegex.MatchString(id) { + return conversationID, id, nil + } + searchFrom = abs + 1 + } + } + } + if rerr != nil || len(buf) > 1<<20 { + break + } + } + return "", "", fmt.Errorf("SSE 中未找到服务端 requestId") +} + +// idRegex 服务端 requestId 形状(cmb- 前缀 32hex 或裸 32hex)。 +var idRegex = regexp.MustCompile(`^(cmb-)?[0-9a-f]{32}$`) + +// DesktopExpertSummonSequence 构造「召唤平台专家」事件组(expert_summon_click 等), +// 载荷对齐真实抓包样本(Sunny row 2644)。需配合 DesktopChatWithExpert + +// DesktopExpertActualUseEvent 完成一次完整「召唤+使用」。 +func DesktopExpertSummonSequence(e MarketExpert) []DesktopEvent { + cat := "expert-all" + if len(e.Categories) > 0 { + if s, ok := e.Categories[0].(string); ok { + cat = s + } + } + ver := e.Version + if ver == "" { + ver = "1.0.0" + } + return []DesktopEvent{ + { + "eventCode": "web_element_click", "source": e.ExpertID, "type": cat, "version": ver, + "elementId": "expert_summon_click", "elementName": "立即召唤", + "pageURL": "/C:/Program%20Files/WorkBuddy/resources/app.asar/renderer/index.html", + }, + { + "eventCode": "expert_summon_click", "id": e.ExpertID, "name": e.DisplayNameZH, + "expertTitle": e.ProfessionZH, "type": "expert-all", "position": 0, + "expertType": e.ExpertType, "version": ver, "mode": "LOCAL", + }, + { + "eventCode": "expert_summoned", "id": e.ExpertID, "name": e.DisplayNameZH, + "expertTitle": e.ProfessionZH, "type": "expert-all", + }, + } +} + +// DesktopExpertActualUseEvent 构造「专家真实使用」事件(expert_5/Expert_team_use_3 计数)。 +// requestID 必须是 DesktopChatWithExpert 返回的服务端 requestId。 +func DesktopExpertActualUseEvent(e MarketExpert, conversationID, requestID string) DesktopEvent { + ev := desktopExpertActualUse(e, conversationID, requestID) + ev["mode"] = "craft" + return ev +} + +// DesktopExpertActualUseLocal mode:"LOCAL" 变体(Expert_lighthouse 判据要求 LOCAL, +// 对齐真实样本 Sunny row 868:轻量云专家使用时 mode=LOCAL、type 为空、cost=0)。 +func DesktopExpertActualUseLocal(e MarketExpert, conversationID, requestID string) DesktopEvent { + ev := desktopExpertActualUse(e, conversationID, requestID) + ev["mode"] = "LOCAL" + return ev +} + +// desktopExpertActualUse expert_actual_use 公共载荷。 +func desktopExpertActualUse(e MarketExpert, conversationID, requestID string) DesktopEvent { + cat := "expert-all" + if len(e.Categories) > 0 { + if s, ok := e.Categories[0].(string); ok { + cat = s + } + } + ver := e.Version + if ver == "" { + ver = "1.0.0" + } + return DesktopEvent{ + "eventCode": "expert_actual_use", + "id": e.ExpertID, "name": e.DisplayNameZH, "expertTitle": e.ProfessionZH, + "type": cat, "expertType": e.ExpertType, "source": "builtin", "version": ver, + "cost": 9000, "characterCount": 14, + "conversationId": conversationID, "requestId": requestID, "messageId": "msg-" + requestID[len(requestID)-8:], + "requestModelId": "fast-model", "requestModelName": "fast-model", + } +} diff --git a/internal/upstream/desktop_test.go b/internal/upstream/desktop_test.go index 8b654685d3db6cf5c09b83bdc0cece182d50b0be..9bdf82c821e1727b03af06215f3bd031a7e3b45d 100644 --- a/internal/upstream/desktop_test.go +++ b/internal/upstream/desktop_test.go @@ -1,121 +1,121 @@ -package upstream - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// TestReportDesktopEventFingerprint 断言桌面指纹上报:走 chatBase、UA 为桌面形状、 -// 事件数组自动注入 workbuddy-desktop 公共指纹(覆盖同名业务键)。 -func TestReportDesktopEventFingerprint(t *testing.T) { - var got []map[string]any - var gotUA string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v2/report" { - t.Errorf("path=%s want /v2/report", r.URL.Path) - } - gotUA = r.Header.Get("User-Agent") - if r.Header.Get("X-User-Id") != "u-dt" { - t.Errorf("X-User-Id=%q want u-dt", r.Header.Get("X-User-Id")) - } - if r.Header.Get("X-Product") != "SaaS" { - t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) - } - raw, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("body must be array: %v", err) - } - w.Write([]byte(`{"code":0,"msg":"OK"}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} - err := c.ReportDesktopEvent(&auth.Auth{AccessToken: "at", UID: "u-dt", Nickname: "RenJie"}, - DesktopEvent{"eventCode": "agent_task_created", "mode": "craft"}) - if err != nil { - t.Fatalf("report desktop: %v", err) - } - if gotUA != desktopUA { - t.Errorf("UA=%q want %q", gotUA, desktopUA) - } - if len(got) != 1 { - t.Fatalf("events=%d want 1", len(got)) - } - ev := got[0] - if ev["extName"] != "workbuddy-desktop" { - t.Errorf("extName=%v want workbuddy-desktop", ev["extName"]) - } - if ev["ideType"] != "WorkBuddy" || ev["ideName"] != "WorkBuddy" { - t.Errorf("ideType/ideName=%v/%v want WorkBuddy", ev["ideType"], ev["ideName"]) - } - if ev["userId"] != "u-dt" { - t.Errorf("userId=%v want u-dt", ev["userId"]) - } - if ev["userNickname"] != "RenJie" { - t.Errorf("userNickname=%v want RenJie", ev["userNickname"]) - } - if ev["mode"] != "craft" { - t.Errorf("业务字段 mode=%v 被指纹覆盖或缺失", ev["mode"]) - } - // machineId 应为 uid 稳定派生(36 hex)。 - if len(ev["machineId"].(string)) != 36 { - t.Errorf("machineId 长度=%d want 36", len(ev["machineId"].(string))) - } -} - -// TestDesktopChatSequenceShape 断言完整对话事件链的事件码顺序与关键字段。 -func TestDesktopChatSequenceShape(t *testing.T) { - events := DesktopChatSequence("conv-1", "req-1", "msg-1", "fast-model", "fast-model") - wantCodes := []string{ - "agent_task_created", "chat_message_send", "chat_request_send", - "chat_message_response", "chat_message_status", "chat_request_response", - } - if len(events) != len(wantCodes) { - t.Fatalf("events=%d want %d", len(events), len(wantCodes)) - } - for i, want := range wantCodes { - if events[i]["eventCode"] != want { - t.Errorf("events[%d].eventCode=%v want %v", i, events[i]["eventCode"], want) - } - } - resp := events[3] - if resp["isSuccessful"] != true { - t.Errorf("chat_message_response.isSuccessful=%v want true(RichMeow 判据)", resp["isSuccessful"]) - } - if resp["finishReason"] != "stop" { - t.Errorf("finishReason=%v want stop", resp["finishReason"]) - } - if resp["conversationId"] != "conv-1" { - t.Errorf("conversationId=%v want conv-1", resp["conversationId"]) - } -} - -// TestSetAppearanceTheme 断言主题设置端点形状(点亮 Hp_Appearance 的 API)。 -func TestSetAppearanceTheme(t *testing.T) { - var got map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v2/user-asset/appearance/set" { - t.Errorf("path=%s want /v2/user-asset/appearance/set", r.URL.Path) - } - if r.Header.Get("User-Agent") != desktopUA { - t.Errorf("UA=%q want desktop UA", r.Header.Get("User-Agent")) - } - raw, _ := io.ReadAll(r.Body) - json.Unmarshal(raw, &got) - w.Write([]byte(`{"code":0,"msg":"OK"}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} - if err := c.SetAppearanceTheme(&auth.Auth{AccessToken: "at", UID: "u1"}, "theme-tkmw7j"); err != nil { - t.Fatalf("set theme: %v", err) - } - if got["kind"] != "theme" || got["resource_key"] != "theme-tkmw7j" { - t.Errorf("body=%v want kind=theme resource_key=theme-tkmw7j", got) - } -} +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestReportDesktopEventFingerprint 断言桌面指纹上报:走 chatBase、UA 为桌面形状、 +// 事件数组自动注入 workbuddy-desktop 公共指纹(覆盖同名业务键)。 +func TestReportDesktopEventFingerprint(t *testing.T) { + var got []map[string]any + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/report" { + t.Errorf("path=%s want /v2/report", r.URL.Path) + } + gotUA = r.Header.Get("User-Agent") + if r.Header.Get("X-User-Id") != "u-dt" { + t.Errorf("X-User-Id=%q want u-dt", r.Header.Get("X-User-Id")) + } + if r.Header.Get("X-Product") != "SaaS" { + t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) + } + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("body must be array: %v", err) + } + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} + err := c.ReportDesktopEvent(&auth.Auth{AccessToken: "at", UID: "u-dt", Nickname: "RenJie"}, + DesktopEvent{"eventCode": "agent_task_created", "mode": "craft"}) + if err != nil { + t.Fatalf("report desktop: %v", err) + } + if gotUA != desktopUA { + t.Errorf("UA=%q want %q", gotUA, desktopUA) + } + if len(got) != 1 { + t.Fatalf("events=%d want 1", len(got)) + } + ev := got[0] + if ev["extName"] != "workbuddy-desktop" { + t.Errorf("extName=%v want workbuddy-desktop", ev["extName"]) + } + if ev["ideType"] != "WorkBuddy" || ev["ideName"] != "WorkBuddy" { + t.Errorf("ideType/ideName=%v/%v want WorkBuddy", ev["ideType"], ev["ideName"]) + } + if ev["userId"] != "u-dt" { + t.Errorf("userId=%v want u-dt", ev["userId"]) + } + if ev["userNickname"] != "RenJie" { + t.Errorf("userNickname=%v want RenJie", ev["userNickname"]) + } + if ev["mode"] != "craft" { + t.Errorf("业务字段 mode=%v 被指纹覆盖或缺失", ev["mode"]) + } + // machineId 应为 uid 稳定派生(36 hex)。 + if len(ev["machineId"].(string)) != 36 { + t.Errorf("machineId 长度=%d want 36", len(ev["machineId"].(string))) + } +} + +// TestDesktopChatSequenceShape 断言完整对话事件链的事件码顺序与关键字段。 +func TestDesktopChatSequenceShape(t *testing.T) { + events := DesktopChatSequence("conv-1", "req-1", "msg-1", "fast-model", "fast-model") + wantCodes := []string{ + "agent_task_created", "chat_message_send", "chat_request_send", + "chat_message_response", "chat_message_status", "chat_request_response", + } + if len(events) != len(wantCodes) { + t.Fatalf("events=%d want %d", len(events), len(wantCodes)) + } + for i, want := range wantCodes { + if events[i]["eventCode"] != want { + t.Errorf("events[%d].eventCode=%v want %v", i, events[i]["eventCode"], want) + } + } + resp := events[3] + if resp["isSuccessful"] != true { + t.Errorf("chat_message_response.isSuccessful=%v want true(RichMeow 判据)", resp["isSuccessful"]) + } + if resp["finishReason"] != "stop" { + t.Errorf("finishReason=%v want stop", resp["finishReason"]) + } + if resp["conversationId"] != "conv-1" { + t.Errorf("conversationId=%v want conv-1", resp["conversationId"]) + } +} + +// TestSetAppearanceTheme 断言主题设置端点形状(点亮 Hp_Appearance 的 API)。 +func TestSetAppearanceTheme(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/user-asset/appearance/set" { + t.Errorf("path=%s want /v2/user-asset/appearance/set", r.URL.Path) + } + if r.Header.Get("User-Agent") != desktopUA { + t.Errorf("UA=%q want desktop UA", r.Header.Get("User-Agent")) + } + raw, _ := io.ReadAll(r.Body) + json.Unmarshal(raw, &got) + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL} + if err := c.SetAppearanceTheme(&auth.Auth{AccessToken: "at", UID: "u1"}, "theme-tkmw7j"); err != nil { + t.Fatalf("set theme: %v", err) + } + if got["kind"] != "theme" || got["resource_key"] != "theme-tkmw7j" { + t.Errorf("body=%v want kind=theme resource_key=theme-tkmw7j", got) + } +} diff --git a/internal/upstream/device_token.go b/internal/upstream/device_token.go index fe87421b26912cf09680e7382b593f85cdfa34c7..5dfa1eed69ff9d7c551341ce69b8786df63e05e4 100644 --- a/internal/upstream/device_token.go +++ b/internal/upstream/device_token.go @@ -1,83 +1,83 @@ -// device_token.go X-Device-Token 的文件兜底读取(与桌面端共用状态文件)。 -// -// 容器内无桌面端 Turing Shield SDK,无法像 Python fork(xiaofan6ya/converter.py)那样 -// 现取 token。这里提供另一条路径:宿主把桌面端生成的 device token 落 /app/data/device_token -// (或任意挂载路径),网关定期读取注入。读取频率限 5 分钟一次缓存,>1KB 或读失败则忽略 -// (优雅降级不注入,不影响主流程)。 -package upstream - -import ( - "errors" - "os" - "strings" - "sync" - "time" -) - -var errDeviceTokenTooLarge = errors.New("device token file too large") - -// deviceTokenFile 文件读取缓存 TTL(秒)。桌面端 SDK 自身也有缓存,这里再兜一层 -// 避免每次出站请求都 stat+read 文件。 -const deviceTokenFileTTL = 5 * time.Minute - -// deviceTokenFileMaxLen token 文件最大字节数。token 通常几百字节;超过 1KB -// 视为异常(非 token 内容 / 文件被误用),忽略不注入。 -const deviceTokenFileMaxLen = 1024 - -// deviceTokenFileCache 缓存 device token 文件读取结果(path → token+读取时刻)。 -type deviceTokenFileCache struct { - mu sync.Mutex - path string - token string - readAt time.Time - lastErr error -} - -var dtFileCache = &deviceTokenFileCache{} - -// readDeviceTokenFile 读取并缓存 device token 文件;5 分钟内复用上次结果。 -// 返回空串表示无可用 token(文件未配置 / 读失败 / 内容过长 / 空白)。 -func readDeviceTokenFile(path string) string { - if path == "" { - return "" - } - // 快路径:缓存命中且未过期,直接返回缓存值。注意全程持锁(defer Unlock)—— - // 含 5 分钟一次的过期重读(锁内读文件)。调用频率极低(每 5min 最多一次 - // 文件 IO,文件上限 1KB),锁内 IO 可接受;若未来出现 NFS 挂载 + 高并发的 - // 部署形态,再上 singleflight 包住重读段(YAGNI,现在不做)。 - dtFileCache.mu.Lock() - defer dtFileCache.mu.Unlock() - if path == dtFileCache.path && time.Since(dtFileCache.readAt) < deviceTokenFileTTL { - return dtFileCache.token - } - // 缓存未命中或过期:重新读文件。 - dtFileCache.path = path - tok, err := readTrimmedFile(path, deviceTokenFileMaxLen) - if err != nil { - // 读失败:清空缓存 token,避免注入过期/错误的值。 - dtFileCache.token = "" - dtFileCache.lastErr = err - dtFileCache.readAt = time.Now() - return "" - } - dtFileCache.token = tok - dtFileCache.lastErr = nil - dtFileCache.readAt = time.Now() - return tok -} - -// readTrimmedFile 读文件并 trim 首尾空白,超过 maxLen 返回错误(拒绝过长内容)。 -func readTrimmedFile(path string, maxLen int) (string, error) { - info, err := os.Stat(path) - if err != nil { - return "", err - } - if info.Size() > int64(maxLen) { - return "", errDeviceTokenTooLarge - } - raw, err := os.ReadFile(path) - if err != nil { - return "", err - } - return strings.TrimSpace(string(raw)), nil -} +// device_token.go X-Device-Token 的文件兜底读取(与桌面端共用状态文件)。 +// +// 容器内无桌面端 Turing Shield SDK,无法像 Python fork(xiaofan6ya/converter.py)那样 +// 现取 token。这里提供另一条路径:宿主把桌面端生成的 device token 落 /app/data/device_token +// (或任意挂载路径),网关定期读取注入。读取频率限 5 分钟一次缓存,>1KB 或读失败则忽略 +// (优雅降级不注入,不影响主流程)。 +package upstream + +import ( + "errors" + "os" + "strings" + "sync" + "time" +) + +var errDeviceTokenTooLarge = errors.New("device token file too large") + +// deviceTokenFile 文件读取缓存 TTL(秒)。桌面端 SDK 自身也有缓存,这里再兜一层 +// 避免每次出站请求都 stat+read 文件。 +const deviceTokenFileTTL = 5 * time.Minute + +// deviceTokenFileMaxLen token 文件最大字节数。token 通常几百字节;超过 1KB +// 视为异常(非 token 内容 / 文件被误用),忽略不注入。 +const deviceTokenFileMaxLen = 1024 + +// deviceTokenFileCache 缓存 device token 文件读取结果(path → token+读取时刻)。 +type deviceTokenFileCache struct { + mu sync.Mutex + path string + token string + readAt time.Time + lastErr error +} + +var dtFileCache = &deviceTokenFileCache{} + +// readDeviceTokenFile 读取并缓存 device token 文件;5 分钟内复用上次结果。 +// 返回空串表示无可用 token(文件未配置 / 读失败 / 内容过长 / 空白)。 +func readDeviceTokenFile(path string) string { + if path == "" { + return "" + } + // 快路径:缓存命中且未过期,直接返回缓存值。注意全程持锁(defer Unlock)—— + // 含 5 分钟一次的过期重读(锁内读文件)。调用频率极低(每 5min 最多一次 + // 文件 IO,文件上限 1KB),锁内 IO 可接受;若未来出现 NFS 挂载 + 高并发的 + // 部署形态,再上 singleflight 包住重读段(YAGNI,现在不做)。 + dtFileCache.mu.Lock() + defer dtFileCache.mu.Unlock() + if path == dtFileCache.path && time.Since(dtFileCache.readAt) < deviceTokenFileTTL { + return dtFileCache.token + } + // 缓存未命中或过期:重新读文件。 + dtFileCache.path = path + tok, err := readTrimmedFile(path, deviceTokenFileMaxLen) + if err != nil { + // 读失败:清空缓存 token,避免注入过期/错误的值。 + dtFileCache.token = "" + dtFileCache.lastErr = err + dtFileCache.readAt = time.Now() + return "" + } + dtFileCache.token = tok + dtFileCache.lastErr = nil + dtFileCache.readAt = time.Now() + return tok +} + +// readTrimmedFile 读文件并 trim 首尾空白,超过 maxLen 返回错误(拒绝过长内容)。 +func readTrimmedFile(path string, maxLen int) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Size() > int64(maxLen) { + return "", errDeviceTokenTooLarge + } + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/internal/upstream/device_token_test.go b/internal/upstream/device_token_test.go index d906c23e2319ca35114d9e4d65e87f4c4c64ecc7..bebd0c2b6ade96797e536495f54430ab5d7e843c 100644 --- a/internal/upstream/device_token_test.go +++ b/internal/upstream/device_token_test.go @@ -1,180 +1,180 @@ -// device_token_test.go X-Device-Token 注入 + 文件兜底读取单测。 -package upstream - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// TestDeviceTokenInjected_WhenSet auth.Auth.DeviceToken 非空时 chat/billing 请求均注入。 -func TestDeviceTokenInjected_WhenSet(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: "tok-from-auth"} - // 用 server 端验证而非 RoundTripper 捕获:更贴近真实注入路径。 - for _, tc := range []struct { - name string - apply func(c *Client, req *http.Request) - wantPath string - }{ - {"chat", func(c *Client, req *http.Request) { c.ChatHeaders(req, a, "", ChatMeta{}) }, "/v2/chat/completions"}, - {"billing", func(c *Client, req *http.Request) { c.BillingHeaders(req, a) }, "/v2/report"}, - } { - t.Run(tc.name, func(t *testing.T) { - var got string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = r.Header.Get("X-Device-Token") - w.WriteHeader(200) - _, _ = w.Write([]byte(`{"code":0}`)) - })) - defer srv.Close() - c := &Client{ - HTTP: srv.Client(), - ChatHTTP: srv.Client(), - ChatBaseCN: srv.URL, - BillingBaseCN: srv.URL, - } - req, _ := http.NewRequest(http.MethodPost, srv.URL+tc.wantPath, nil) - tc.apply(c, req) - resp, err := c.HTTP.Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - resp.Body.Close() - if got != "tok-from-auth" { - t.Errorf("X-Device-Token = %q want %q", got, "tok-from-auth") - } - }) - } -} - -// TestDeviceTokenNotInjected_WhenEmpty 所有来源皆空时不注入该头。 -func TestDeviceTokenNotInjected_WhenEmpty(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} // DeviceToken 空 - var got string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = r.Header.Get("X-Device-Token") - w.WriteHeader(200) - _, _ = w.Write([]byte(`{"code":0}`)) - })) - defer srv.Close() - c := &Client{ - HTTP: srv.Client(), - ChatHTTP: srv.Client(), - ChatBaseCN: srv.URL, - BillingBaseCN: srv.URL, - // DeviceToken / DeviceTokenFile 皆空 - } - req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) - c.ChatHeaders(req, a, "", ChatMeta{}) - resp, err := c.HTTP.Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - resp.Body.Close() - if got != "" { - t.Errorf("X-Device-Token = %q want empty (not injected)", got) - } -} - -// TestDeviceTokenFromConfigOrFile_Overrides 优先级:auth > config > 文件。 -// auth 有值时覆盖 config;auth 空时 config 兜底;auth 与 config 皆空时读文件。 -func TestDeviceTokenFromConfigOrFile_Overrides(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "device_token") - if err := os.WriteFile(fp, []byte("tok-from-file\n"), 0o600); err != nil { - t.Fatalf("write file: %v", err) - } - - cases := []struct { - name string - auth string - cfg string - want string - }{ - {"auth_over_config", "tok-auth", "tok-config", "tok-auth"}, - {"config_when_auth_empty", "", "tok-config", "tok-config"}, - {"file_when_auth_and_config_empty", "", "", "tok-from-file"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - // 每个用例用独立缓存:device token 文件缓存 5 分钟,case 间会串扰。 - save := resetDeviceTokenFileCache(fp) - defer save() - a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: tc.auth} - var got string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = r.Header.Get("X-Device-Token") - w.WriteHeader(200) - _, _ = w.Write([]byte(`{"code":0}`)) - })) - defer srv.Close() - c := &Client{ - HTTP: srv.Client(), - ChatHTTP: srv.Client(), - ChatBaseCN: srv.URL, - BillingBaseCN: srv.URL, - DeviceToken: tc.cfg, - DeviceTokenFile: fp, - } - req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) - c.ChatHeaders(req, a, "", ChatMeta{}) - resp, err := c.HTTP.Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - resp.Body.Close() - if got != tc.want { - t.Errorf("X-Device-Token = %q want %q", got, tc.want) - } - }) - } -} - -// TestDeviceTokenFileTooLarge 文件超过 1KB 时忽略不注入。 -func TestDeviceTokenFileTooLarge(t *testing.T) { - dir := t.TempDir() - fp := filepath.Join(dir, "device_token") - if err := os.WriteFile(fp, []byte(strings.Repeat("x", 2048)), 0o600); err != nil { - t.Fatalf("write file: %v", err) - } - save := resetDeviceTokenFileCache(fp) - defer save() - a := &auth.Auth{AccessToken: "at", UID: "u1"} - c := &Client{DeviceTokenFile: fp} - if tok := c.resolveDeviceToken(a); tok != "" { - t.Errorf("resolveDeviceToken() = %q want empty (file too large)", tok) - } -} - -// resetDeviceTokenFileCache 替换全局 device token 文件缓存并返回恢复函数。 -// 文件缓存 5 分钟 TTL,测试间需清空避免串扰。 -func resetDeviceTokenFileCache(path string) (restore func()) { - dtFileCache.mu.Lock() - origPath := dtFileCache.path - origTok := dtFileCache.token - origRead := dtFileCache.readAt - origErr := dtFileCache.lastErr - dtFileCache.path = path - dtFileCache.token = "" - dtFileCache.readAt = time.Time{} - dtFileCache.lastErr = nil - dtFileCache.mu.Unlock() - var once sync.Once - return func() { - once.Do(func() { - dtFileCache.mu.Lock() - dtFileCache.path = origPath - dtFileCache.token = origTok - dtFileCache.readAt = origRead - dtFileCache.lastErr = origErr - dtFileCache.mu.Unlock() - }) - } -} +// device_token_test.go X-Device-Token 注入 + 文件兜底读取单测。 +package upstream + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestDeviceTokenInjected_WhenSet auth.Auth.DeviceToken 非空时 chat/billing 请求均注入。 +func TestDeviceTokenInjected_WhenSet(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: "tok-from-auth"} + // 用 server 端验证而非 RoundTripper 捕获:更贴近真实注入路径。 + for _, tc := range []struct { + name string + apply func(c *Client, req *http.Request) + wantPath string + }{ + {"chat", func(c *Client, req *http.Request) { c.ChatHeaders(req, a, "", ChatMeta{}) }, "/v2/chat/completions"}, + {"billing", func(c *Client, req *http.Request) { c.BillingHeaders(req, a) }, "/v2/report"}, + } { + t.Run(tc.name, func(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+tc.wantPath, nil) + tc.apply(c, req) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != "tok-from-auth" { + t.Errorf("X-Device-Token = %q want %q", got, "tok-from-auth") + } + }) + } +} + +// TestDeviceTokenNotInjected_WhenEmpty 所有来源皆空时不注入该头。 +func TestDeviceTokenNotInjected_WhenEmpty(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} // DeviceToken 空 + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + // DeviceToken / DeviceTokenFile 皆空 + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) + c.ChatHeaders(req, a, "", ChatMeta{}) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != "" { + t.Errorf("X-Device-Token = %q want empty (not injected)", got) + } +} + +// TestDeviceTokenFromConfigOrFile_Overrides 优先级:auth > config > 文件。 +// auth 有值时覆盖 config;auth 空时 config 兜底;auth 与 config 皆空时读文件。 +func TestDeviceTokenFromConfigOrFile_Overrides(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "device_token") + if err := os.WriteFile(fp, []byte("tok-from-file\n"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + cases := []struct { + name string + auth string + cfg string + want string + }{ + {"auth_over_config", "tok-auth", "tok-config", "tok-auth"}, + {"config_when_auth_empty", "", "tok-config", "tok-config"}, + {"file_when_auth_and_config_empty", "", "", "tok-from-file"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 每个用例用独立缓存:device token 文件缓存 5 分钟,case 间会串扰。 + save := resetDeviceTokenFileCache(fp) + defer save() + a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: tc.auth} + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("X-Device-Token") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + c := &Client{ + HTTP: srv.Client(), + ChatHTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + DeviceToken: tc.cfg, + DeviceTokenFile: fp, + } + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil) + c.ChatHeaders(req, a, "", ChatMeta{}) + resp, err := c.HTTP.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if got != tc.want { + t.Errorf("X-Device-Token = %q want %q", got, tc.want) + } + }) + } +} + +// TestDeviceTokenFileTooLarge 文件超过 1KB 时忽略不注入。 +func TestDeviceTokenFileTooLarge(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "device_token") + if err := os.WriteFile(fp, []byte(strings.Repeat("x", 2048)), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + save := resetDeviceTokenFileCache(fp) + defer save() + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{DeviceTokenFile: fp} + if tok := c.resolveDeviceToken(a); tok != "" { + t.Errorf("resolveDeviceToken() = %q want empty (file too large)", tok) + } +} + +// resetDeviceTokenFileCache 替换全局 device token 文件缓存并返回恢复函数。 +// 文件缓存 5 分钟 TTL,测试间需清空避免串扰。 +func resetDeviceTokenFileCache(path string) (restore func()) { + dtFileCache.mu.Lock() + origPath := dtFileCache.path + origTok := dtFileCache.token + origRead := dtFileCache.readAt + origErr := dtFileCache.lastErr + dtFileCache.path = path + dtFileCache.token = "" + dtFileCache.readAt = time.Time{} + dtFileCache.lastErr = nil + dtFileCache.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + dtFileCache.mu.Lock() + dtFileCache.path = origPath + dtFileCache.token = origTok + dtFileCache.readAt = origRead + dtFileCache.lastErr = origErr + dtFileCache.mu.Unlock() + }) + } +} diff --git a/internal/upstream/effort_catalog.go b/internal/upstream/effort_catalog.go index b814fe176f9a7e10b4eb960701af6eff1bd4f2bc..13ba67beac5eb0dea7b084ac77d7c7c3ae23e8fc 100644 --- a/internal/upstream/effort_catalog.go +++ b/internal/upstream/effort_catalog.go @@ -1,145 +1,145 @@ -// effort_catalog.go 推理档位(reasoning effort)产品级静态兜底表。 -// -// 数据来源三级(吸收参考仓库 reconcileWithFallback/buddy-adapter.ts:499-523 语义): -// - 远端 FetchModels / global 探测已解析的 supportedEfforts/defaultEffort 桶(权威,优先); -// - 本文件按 realm 分开的产品级静态兜底表(远端缺失时补齐); -// - 两者皆无 → 不在 /v1/models 输出 effort 字段(omitted,不是空数组)。 -// -// 档位值照抄参考仓库 dsh-codearts:CN/CodeBuddy 面取 src/product.ts CODEBUDDY_FALLBACK_MODELS -// ∪ src/buddy-adapter.ts:126-137 REASONING_EFFORTS;global/WorkBuddy 面取 src/product.ts -// WORKBUDDY_FALLBACK_MODELS。两个 realm 对同一模型给出不同档位(如 deepseek-v4.1-flash: -// CN 三档 ['low','high','max']、global 仅 ['high']),故**按 realm 分表**,绝不混用。 -// -// 只列**可枚举**档位的模型;仅有固定默认档(glm-5.1/kimi-* 的 medium)同样入表, -// 但其档位是「可枚举单档」而非「无选择器」,照抄参考仓库如实暴露。 -package upstream - -// effortCap 一个模型的档位能力(对齐产品兜底表条目 reasoningEfforts + defaultReasoningEffort)。 -type effortCap struct { - efforts []string - defaultEffort string -} - -// cnEffortFallback CN / CodeBuddy 面静态兜底表。 -// 三档模型 defaultEffort 均为 high(product.ts CODEBUDDY_FALLBACK_MODELS 逐条 defaultReasoningEffort)。 -var cnEffortFallback = map[string]effortCap{ - "deepseek-v4-flash": {efforts: []string{"low", "high", "max"}}, - "deepseek-v4.1-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, - "deepseek-v4-pro": {efforts: []string{"low", "high", "xhigh"}, defaultEffort: "high"}, - "hy4-preview": {efforts: []string{"high"}, defaultEffort: "high"}, - "hy4-preview-x": {efforts: []string{"high"}}, - "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, - "hy3-x": {efforts: []string{"low", "high"}, defaultEffort: "high"}, - "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, - "glm-5.3-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, - "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, - "glm-5.1": {efforts: []string{"medium"}}, - "glm-5v-turbo": {efforts: []string{"medium"}}, - "kimi-k3-1": {efforts: []string{"medium"}}, - "kimi-k2.7": {efforts: []string{"medium"}}, - "kimi-k2.6": {efforts: []string{"medium"}}, - "minimax-m3": {efforts: []string{"medium"}}, -} - -// globalEffortFallback global / WorkBuddy 国际版面静态兜底表。 -// 注意 deepseek-v4.1-flash 在国际版**只有 ['high']**(product.ts:190 实测 IDE 缓存), -// 与 CN 面的三档刻意不同——往 WorkBuddy 上游发 low/max 是非法参数 400。 -var globalEffortFallback = map[string]effortCap{ - "fast-model": {efforts: []string{"medium"}}, - "balanced-model": {efforts: []string{"medium"}}, - "primary-model": {efforts: []string{"high"}}, - "hy4-preview-f": {efforts: []string{"high"}, defaultEffort: "high"}, - "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, - "deepseek-v4.1-flash": {efforts: []string{"high"}}, - "gpt-6-astra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, - "gpt-5.6-sol": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, - "gpt-5.6-terra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, - "gpt-5.6-luna": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, - "gpt-5.5": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, - "gpt-5.4": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, - "gpt-5.3-codex": {efforts: []string{"medium"}}, - "gemini-3.5-flash": {efforts: []string{"medium"}}, - "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, - "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, - "kimi-k3": {efforts: []string{"medium"}}, - "kimi-k2.6": {efforts: []string{"medium"}}, -} - -// staticEffortCap 按 realm 取静态兜底条目;未命中返回 zero effortCap(efforts=nil)。 -// realm 经 realmKey 归一化(空 → "cn"),与 efforts 缓存桶同口径。 -func staticEffortCap(realm, model string) effortCap { - table := cnEffortFallback - if realmKey(realm) == "global" { - table = globalEffortFallback - } - return table[model] -} - -// EffortListing 计算模型在 /v1/models 应暴露的 effort 能力(三级查找 + 默认档防御)。 -// -// remoteEfforts/remoteDefault 为远端(FetchModels / global 探测)已解析值; -// remoteEfforts 非空时以其为权威(不回落到静态表),否则落到产品级静态兜底表; -// 两者皆无 → efforts 返回 nil(调用方省略字段,不输出空数组)。 -// -// defaultEffort 仅在「efforts 非空且 default 命中 efforts」时才返回 -// (对齐参考仓库 resolveModel 的 `defaultEffort ∈ efforts` 防御:不宣称不支持的默认档)。 -// remoteDefault 空串不回落到静态默认档——默认档随档位表同源:remote 有档位就用 remote 默认档, -// 静态兜底档位就用静态默认档,避免跨源拼接出「档位是静态、默认档是 remote」的矛盾组合。 -func EffortListing(realm, model string, remoteEfforts []string, remoteDefault string) (efforts []string, defaultEffort string) { - var src effortCap - switch { - case len(remoteEfforts) > 0: - src = effortCap{efforts: remoteEfforts, defaultEffort: remoteDefault} - default: - src = staticEffortCap(realm, model) - } - if len(src.efforts) == 0 { - return nil, "" - } - efforts = append([]string(nil), src.efforts...) - if src.defaultEffort != "" && containsEffort(efforts, src.defaultEffort) { - defaultEffort = src.defaultEffort - } - return efforts, defaultEffort -} - -// containsEffort 档位成员判定(精确匹配,对齐参考仓库 `efforts.includes(defaultEffort)`)。 -func containsEffort(efforts []string, want string) bool { - for _, e := range efforts { - if e == want { - return true - } - } - return false -} - -// globalEffortMap global 域降级用的 effort 能力表:静态兜底表为基,远端桶覆盖(权威优先)。 -// -// prepareBody 对 global 请求调此函数(而非直接用远端桶),因为 global 上游可能不下发 -// supportedEfforts——此时也必须按产品静态表降级(issue #84:deepseek-v4.1-flash 国际版 -// 只认 high,客户端传 low/max 必降级到 high,否则上游 400 毁掉请求)。 -// 语义对齐参考仓库 effortsFor(remoteMeta → productFallback → 静态表),只取前两级: -// 远端桶(探测已解析)→ 本产品静态表(本文件),缺档位即无(不再到通用静态表)。 -func globalEffortMap(remoteEfforts map[string][]string, remoteDefaults map[string]string) (map[string][]string, map[string]string) { - efforts := make(map[string][]string, len(globalEffortFallback)+len(remoteEfforts)) - defs := make(map[string]string, len(globalEffortFallback)+len(remoteDefaults)) - // 静态兜底为基。 - for id, cap := range globalEffortFallback { - efforts[id] = append([]string(nil), cap.efforts...) - if cap.defaultEffort != "" { - defs[id] = cap.defaultEffort - } - } - // 远端权威覆盖(仅当远端确实下发了该模型档位)。 - for id, v := range remoteEfforts { - if len(v) > 0 { - efforts[id] = v - } - } - for id, v := range remoteDefaults { - if v != "" { - defs[id] = v - } - } - return efforts, defs -} +// effort_catalog.go 推理档位(reasoning effort)产品级静态兜底表。 +// +// 数据来源三级(吸收参考仓库 reconcileWithFallback/buddy-adapter.ts:499-523 语义): +// - 远端 FetchModels / global 探测已解析的 supportedEfforts/defaultEffort 桶(权威,优先); +// - 本文件按 realm 分开的产品级静态兜底表(远端缺失时补齐); +// - 两者皆无 → 不在 /v1/models 输出 effort 字段(omitted,不是空数组)。 +// +// 档位值照抄参考仓库 dsh-codearts:CN/CodeBuddy 面取 src/product.ts CODEBUDDY_FALLBACK_MODELS +// ∪ src/buddy-adapter.ts:126-137 REASONING_EFFORTS;global/WorkBuddy 面取 src/product.ts +// WORKBUDDY_FALLBACK_MODELS。两个 realm 对同一模型给出不同档位(如 deepseek-v4.1-flash: +// CN 三档 ['low','high','max']、global 仅 ['high']),故**按 realm 分表**,绝不混用。 +// +// 只列**可枚举**档位的模型;仅有固定默认档(glm-5.1/kimi-* 的 medium)同样入表, +// 但其档位是「可枚举单档」而非「无选择器」,照抄参考仓库如实暴露。 +package upstream + +// effortCap 一个模型的档位能力(对齐产品兜底表条目 reasoningEfforts + defaultReasoningEffort)。 +type effortCap struct { + efforts []string + defaultEffort string +} + +// cnEffortFallback CN / CodeBuddy 面静态兜底表。 +// 三档模型 defaultEffort 均为 high(product.ts CODEBUDDY_FALLBACK_MODELS 逐条 defaultReasoningEffort)。 +var cnEffortFallback = map[string]effortCap{ + "deepseek-v4-flash": {efforts: []string{"low", "high", "max"}}, + "deepseek-v4.1-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "deepseek-v4-pro": {efforts: []string{"low", "high", "xhigh"}, defaultEffort: "high"}, + "hy4-preview": {efforts: []string{"high"}, defaultEffort: "high"}, + "hy4-preview-x": {efforts: []string{"high"}}, + "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "hy3-x": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.3-flash": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, + "glm-5.1": {efforts: []string{"medium"}}, + "glm-5v-turbo": {efforts: []string{"medium"}}, + "kimi-k3-1": {efforts: []string{"medium"}}, + "kimi-k2.7": {efforts: []string{"medium"}}, + "kimi-k2.6": {efforts: []string{"medium"}}, + "minimax-m3": {efforts: []string{"medium"}}, +} + +// globalEffortFallback global / WorkBuddy 国际版面静态兜底表。 +// 注意 deepseek-v4.1-flash 在国际版**只有 ['high']**(product.ts:190 实测 IDE 缓存), +// 与 CN 面的三档刻意不同——往 WorkBuddy 上游发 low/max 是非法参数 400。 +var globalEffortFallback = map[string]effortCap{ + "fast-model": {efforts: []string{"medium"}}, + "balanced-model": {efforts: []string{"medium"}}, + "primary-model": {efforts: []string{"high"}}, + "hy4-preview-f": {efforts: []string{"high"}, defaultEffort: "high"}, + "hy3": {efforts: []string{"low", "high"}, defaultEffort: "high"}, + "deepseek-v4.1-flash": {efforts: []string{"high"}}, + "gpt-6-astra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-sol": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-terra": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.6-luna": {efforts: []string{"low", "medium", "high", "xhigh", "max"}, defaultEffort: "high"}, + "gpt-5.5": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, + "gpt-5.4": {efforts: []string{"low", "medium", "high", "xhigh"}, defaultEffort: "high"}, + "gpt-5.3-codex": {efforts: []string{"medium"}}, + "gemini-3.5-flash": {efforts: []string{"medium"}}, + "glm-5.3": {efforts: []string{"low", "high", "max"}, defaultEffort: "high"}, + "glm-5.2": {efforts: []string{"high", "xhigh"}, defaultEffort: "high"}, + "kimi-k3": {efforts: []string{"medium"}}, + "kimi-k2.6": {efforts: []string{"medium"}}, +} + +// staticEffortCap 按 realm 取静态兜底条目;未命中返回 zero effortCap(efforts=nil)。 +// realm 经 realmKey 归一化(空 → "cn"),与 efforts 缓存桶同口径。 +func staticEffortCap(realm, model string) effortCap { + table := cnEffortFallback + if realmKey(realm) == "global" { + table = globalEffortFallback + } + return table[model] +} + +// EffortListing 计算模型在 /v1/models 应暴露的 effort 能力(三级查找 + 默认档防御)。 +// +// remoteEfforts/remoteDefault 为远端(FetchModels / global 探测)已解析值; +// remoteEfforts 非空时以其为权威(不回落到静态表),否则落到产品级静态兜底表; +// 两者皆无 → efforts 返回 nil(调用方省略字段,不输出空数组)。 +// +// defaultEffort 仅在「efforts 非空且 default 命中 efforts」时才返回 +// (对齐参考仓库 resolveModel 的 `defaultEffort ∈ efforts` 防御:不宣称不支持的默认档)。 +// remoteDefault 空串不回落到静态默认档——默认档随档位表同源:remote 有档位就用 remote 默认档, +// 静态兜底档位就用静态默认档,避免跨源拼接出「档位是静态、默认档是 remote」的矛盾组合。 +func EffortListing(realm, model string, remoteEfforts []string, remoteDefault string) (efforts []string, defaultEffort string) { + var src effortCap + switch { + case len(remoteEfforts) > 0: + src = effortCap{efforts: remoteEfforts, defaultEffort: remoteDefault} + default: + src = staticEffortCap(realm, model) + } + if len(src.efforts) == 0 { + return nil, "" + } + efforts = append([]string(nil), src.efforts...) + if src.defaultEffort != "" && containsEffort(efforts, src.defaultEffort) { + defaultEffort = src.defaultEffort + } + return efforts, defaultEffort +} + +// containsEffort 档位成员判定(精确匹配,对齐参考仓库 `efforts.includes(defaultEffort)`)。 +func containsEffort(efforts []string, want string) bool { + for _, e := range efforts { + if e == want { + return true + } + } + return false +} + +// globalEffortMap global 域降级用的 effort 能力表:静态兜底表为基,远端桶覆盖(权威优先)。 +// +// prepareBody 对 global 请求调此函数(而非直接用远端桶),因为 global 上游可能不下发 +// supportedEfforts——此时也必须按产品静态表降级(issue #84:deepseek-v4.1-flash 国际版 +// 只认 high,客户端传 low/max 必降级到 high,否则上游 400 毁掉请求)。 +// 语义对齐参考仓库 effortsFor(remoteMeta → productFallback → 静态表),只取前两级: +// 远端桶(探测已解析)→ 本产品静态表(本文件),缺档位即无(不再到通用静态表)。 +func globalEffortMap(remoteEfforts map[string][]string, remoteDefaults map[string]string) (map[string][]string, map[string]string) { + efforts := make(map[string][]string, len(globalEffortFallback)+len(remoteEfforts)) + defs := make(map[string]string, len(globalEffortFallback)+len(remoteDefaults)) + // 静态兜底为基。 + for id, cap := range globalEffortFallback { + efforts[id] = append([]string(nil), cap.efforts...) + if cap.defaultEffort != "" { + defs[id] = cap.defaultEffort + } + } + // 远端权威覆盖(仅当远端确实下发了该模型档位)。 + for id, v := range remoteEfforts { + if len(v) > 0 { + efforts[id] = v + } + } + for id, v := range remoteDefaults { + if v != "" { + defs[id] = v + } + } + return efforts, defs +} diff --git a/internal/upstream/global_models.go b/internal/upstream/global_models.go index 05c2da1fee4593f821ad7578826ec59ca856ce91..89fb8c023d016268d36647d1da75035a5d624591 100644 --- a/internal/upstream/global_models.go +++ b/internal/upstream/global_models.go @@ -1,620 +1,668 @@ -// global 模型目录探测:纯动态产出模型名及其窗口 / 能力元数据(v3-config-merge)。 -// -// 探测两路并发:/v3/config(主路,IDE UA 完整能力版)+ 企业端点家族 -// (/v2 → /console 补缺),并集 = v3 条目为主、企业端点补 v3 缺失的 id -// (如 gpt-5.3-codex 只在 /v2 下发)。倍率字段(credits)虽随目录下发,但 -// 只透出展示,不注入 costTier、不参与选号。 -// -// 纯动态:不再回落任何静态名单——拉不出目录即意味着该域上游不可用, -// 假名单只会让客户端选到 11102 的模型(产品决策:无兜底)。 -package upstream - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "sort" - "strings" - "sync" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// GlobalModelNames 国际版(global realm)历史静态名单(PLAN §7.2 附录 21 名)。 -// 纯动态化后**不再作为模型目录的基底/兜底**:/v1/models 只透出上游实际下发的模型。 -// 保留仅作历史对照(global e2e 观测日志差集参照)。 -var GlobalModelNames = []string{ - "default-model", - "fast-model", - "balanced-model", - "primary-model", - "hy4-preview", - "gpt-5.6-sol", - "gpt-5.6-terra", - "deep-model", - "deepseek-v4.1-flash", - "gpt-6-astra", - "hy4-preview-f", - "hy3", - "glm-5.2", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4", - "gpt-5.3-codex", - "gemini-3.5-flash", - "glm-5.3", - "kimi-k3", - "kimi-k2.6", -} - -// fetchGlobalModelsCache 探测结果缓存(语义参照 CN 侧 handler.dynamicModelsCache:1h TTL + -// 5min 失败负缓存)。按 Client 实例持有(effortsMu 同模式),测试新建 Client 即隔离。 -// Mutex 内嵌,与 modelList 无并发读路径竞争(唯一读写点本文件内)。 -type fetchGlobalModelsCache struct { - sync.Mutex - names []string // 成功缓存:并集模型名(已去重);nil = 未探测/失败 - infos []ModelInfo // 成功缓存:对象形态的全字段条目(窄表/失败形态为 nil) - fetched time.Time - lastFail time.Time -} - -// globalModelsTTL / globalModelsFailCooldown 探测缓存时长:成功 1h,失败 5min 负缓存。 -const ( - globalModelsTTL = time.Hour - globalModelsFailCooldown = 5 * time.Minute -) - -// globalModelsProbePaths global 企业模型目录端点候选序列(按 realm 切 base,路径"家族"): -// /v2 家族优先(实测 /v2/enterprises/personal/models 200 含完整模型表), -// /console 作 fallback(同域旧路径,或 500)。v3-config-merge 后该家族降为企业补充路 -// (/v3/config 为主路,与家族并发探测;gpt-5.3-codex 等家族独有模型经此进并集)。 -var globalModelsProbePaths = []string{ - "/v2/enterprises/personal/models", - "/console/enterprises/personal/models", -} - -// FetchGlobalModels 探测 global 账号的模型名目录并返回**模型名列表**(无元数据)。 -// -// 纯动态:成功返回并集结果(去重),缓存 1h;失败(两路全非 2xx / 解析失败 / -// 空列表)记 5min 负缓存,返回 nil(无静态回落)。缓存/负缓存命中:直接返回,零上游调用。 -// -// 调用方负责:仅在有 global 账号时调用(无则不探测);GlobalEnabled 关闭时(逃生门) -// 不得调用——本方法由 globalOn(a) 内部兜底,若账号因开关回落 cn 则返回 nil。 -func (c *Client) FetchGlobalModels(a *auth.Auth) []string { - names, _ := c.fetchGlobalModelsOnce(a) - return names -} - -// FetchGlobalModelInfos 探测 global 账号的模型目录并返回全字段 ModelInfo 列表。 -// 与 FetchGlobalModels 共享同一次探测与缓存(names + infos 一体落缓存): -// 对象形态 200 → 全字段条目;窄表形态 / 探测失败 / 负缓存 / 非 global 路由账号 -// → nil(调用方按 ID 名单输出裸条目,不编造字段)。 -// 账号因 GlobalEnabled 开关回落 cn 时不探测(globalOn 兜底,零上游调用)。 -func (c *Client) FetchGlobalModelInfos(a *auth.Auth) []ModelInfo { - _, infos := c.fetchGlobalModelsOnce(a) - return infos -} - -// fetchGlobalModelsOnce 单次探测决策(缓存命中/负缓存/触发探测),返回 (names, infos)。 -// 纯动态:成功 = 并集结果去重;一切失败 = nil(不回落静态)。 -// infos 仅对象形态成功探测时非 nil。 -func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []ModelInfo) { - if !c.globalOn(a) { - // 逃生门兜底:账号不路由 global 上游 → 不探测(零上游调用)。 - return nil, nil - } - - c.globalModels.Lock() - if len(c.globalModels.names) > 0 && time.Since(c.globalModels.fetched) < globalModelsTTL { - names, infos := c.globalModels.names, c.globalModels.infos - c.globalModels.Unlock() - return names, infos - } - if !c.globalModels.lastFail.IsZero() && time.Since(c.globalModels.lastFail) < globalModelsFailCooldown { - // 负缓存冷却期内:避免反复打上游,直接按失败处理(无静态回落)。 - c.globalModels.Unlock() - return nil, nil - } - c.globalModels.Unlock() - - names, infos, efforts, defaults, err := c.probeGlobalModels(a) - if err != nil || len(names) == 0 { - // 探测失败:负缓存 + 返回 nil(effort 桶不写,prepareBody 走 globalEffortMap 静态兜底)。 - c.globalModels.Lock() - c.globalModels.lastFail = time.Now() - c.globalModels.names = nil - c.globalModels.infos = nil - c.globalModels.Unlock() - return nil, nil - } - // global 域 effort 能力:探测下发的 supportedEfforts/defaultEffort 权威写入 global 桶 - // (raw remote,不并入静态表——静态兜底在 prepareBody 的 globalEffortMap 与 - // /v1/models 的 EffortListing 里按需 fallback)。空探测不写(防清既有桶)。 - if len(efforts) > 0 || len(defaults) > 0 { - c.storeEfforts("global", efforts, defaults) - } - c.storeModelRates("global", infos) - - // 成功:探测结果去重。names/infos 均落缓存;倍率等选号敏感字段只透出展示, - // 不注入 costTier。 - seen := make(map[string]bool, len(names)) - merged := make([]string, 0, len(names)) - for _, id := range names { - if id == "" || seen[id] { - continue - } - seen[id] = true - merged = append(merged, id) - } - - c.globalModels.Lock() - c.globalModels.names = merged - c.globalModels.infos = infos - c.globalModels.fetched = time.Now() - c.globalModels.lastFail = time.Time{} - c.globalModels.Unlock() - return merged, infos -} - -// probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge): -// /v3/config(主,IDE UA 完整能力版)与企业端点家族(/v2 → /console 兜底,补缺) -// **并发**探测后并集合并。返回模型名列表(已合并、未再去重——去重在 -// fetchGlobalModelsOnce)、全字段 ModelInfo(对象形态;窄表为 nil)及 effort -// 能力桶(supportedEfforts/defaultEffort,可为空)。合并口径:v3 条目为主 -// (credits 等字段以 v3 为准),企业端点只补 v3 缺失的模型 id;去重 key = -// 模型 id,输出顺序稳定。两路全失败才返回错误(等价原「家族端点全非 2xx」 -// 负缓存语义);单路失败降级为另一路结果 + warn 日志,互不拖累。 -func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { - type probeResult struct { - names []string - infos []ModelInfo - err error - } - // probeV3 单次 /v3/config 探测(UA 参数化)。该端点对不同 UA 下发**不同模型集合**: - // IDE UA 与 CLI UA 各有独有模型(见 codeBuddyCLIUA 注释),故并发两路取并集。 - probeV3 := func(ua string) chan probeResult { - ch := make(chan probeResult, 1) - go func() { - // chatBase 已按 realm 切 global base。 - byID, perr := c.fetchV3ConfigModelMap(a, ua) - if perr != nil { - ch <- probeResult{err: perr} - return - } - ids := make([]string, 0, len(byID)) - outInfos := make([]ModelInfo, 0, len(byID)) - for _, mi := range byID { - if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) { - continue - } - ids = append(ids, mi.ID) - outInfos = append(outInfos, mi) - } - sort.Strings(ids) // map 迭代序随机,排序保输出稳定 - ch <- probeResult{names: ids, infos: outInfos} - }() - return ch - } - v3IDECh := probeV3(codeBuddyIDEUA) - v3CLICh := probeV3(codeBuddyCLIUA) - enterpriseCh := make(chan probeResult, 1) - go func() { - // 企业端点家族:/v2 首选 → /console 兜底(既有探活序,零回归)。 - var lastErr error - for _, path := range globalModelsProbePaths { - names, infos, perr := c.globalModelsOnce(a, path) - if perr != nil { - lastErr = perr - continue - } - enterpriseCh <- probeResult{names: names, infos: infos} - return - } - enterpriseCh <- probeResult{err: lastErr} - }() - v3IDE := <-v3IDECh - v3CLI := <-v3CLICh - enterprise := <-enterpriseCh - // v3 两路自合并:IDE 路字段权威(响应更大、单条字段更全),CLI 路只补缺失的模型 id。 - // 单路成功即用该路;两路全失败才带 err 进入下游降级判断。 - var v3 probeResult - switch { - case v3IDE.err != nil && v3CLI.err != nil: - v3 = probeResult{err: v3IDE.err} - case v3IDE.err != nil: - log.Printf("WARN: [upstream] global models: v3/config IDE-UA probe failed (CLI-UA only): %v", v3IDE.err) - v3 = v3CLI - case v3CLI.err != nil: - log.Printf("WARN: [upstream] global models: v3/config CLI-UA probe failed (IDE-UA only): %v", v3CLI.err) - v3 = v3IDE - default: - vn, vi := mergeGlobalCatalog(v3IDE.names, v3IDE.infos, v3CLI.names, v3CLI.infos) - v3 = probeResult{names: vn, infos: vi} - } - - if v3.err != nil && enterprise.err != nil { - // 两路全失败 → 负缓存语义(等价原家族端点全非 2xx)。 - return nil, nil, nil, nil, v3.err - } - if v3.err != nil { - // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 - log.Printf("WARN: [upstream] global models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) - names, infos, efforts, defaults = extractEfforts(enterprise.infos) - return names, infos, efforts, defaults, nil - } - if enterprise.err != nil { - log.Printf("WARN: [upstream] global models: enterprise endpoint failed (v3/config only): %v", enterprise.err) - names, infos, efforts, defaults = extractEfforts(v3.infos) - return names, infos, efforts, defaults, nil - } - // 两路皆成功:v3 为主、企业端点补缺合并(含 effort 桶合并,v3 权威)。 - v3Names, v3Infos, v3Efforts, v3Defaults := extractEfforts(v3.infos) - if len(v3Names) == 0 { - v3Names = v3.names - } - entNames, entInfos, entEfforts, entDefaults := extractEfforts(enterprise.infos) - if len(entNames) == 0 { - entNames = enterprise.names - } - names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) - efforts = mergeEffortBuckets(v3Efforts, entEfforts) - defaults = mergeEffortDefaults(v3Defaults, entDefaults) - return names, infos, efforts, defaults, nil -} - -// extractEfforts 从条目列表抽取 effort 能力桶(supportedEfforts 数组优先; -// 缺数组但 defaultEffort 单档非空也入 defaults 桶)并顺带返回有序 names。 -func extractEfforts(infos []ModelInfo) (names []string, out []ModelInfo, efforts map[string][]string, defaults map[string]string) { - names = make([]string, 0, len(infos)) - for _, mi := range infos { - if mi.ID == "" { - continue - } - names = append(names, mi.ID) - out = append(out, mi) - if len(mi.Efforts) > 0 { - if efforts == nil { - efforts = make(map[string][]string) - } - efforts[mi.ID] = mi.Efforts - } - if mi.DefaultEffort != "" { - if defaults == nil { - defaults = make(map[string]string) - } - defaults[mi.ID] = mi.DefaultEffort - } - } - return names, out, efforts, defaults -} - -// mergeGlobalCatalog 两路合并(v3 主、企业补缺):names 按 id 去重(v3 原序在前、 -// 企业端点补充项在其原序后追加——稳定输出);infos 同步合并(v3 条目字段权威, -// 企业端点条目只在 id 缺失时进并集)。 -// 窄表形态(infos nil)时保持 nil——无对象字段不编造。 -func mergeGlobalCatalog(primaryNames []string, primaryInfos []ModelInfo, secondaryNames []string, secondaryInfos []ModelInfo) (names []string, infos []ModelInfo) { - if len(secondaryNames) == 0 { - return primaryNames, primaryInfos - } - seen := make(map[string]bool, len(primaryNames)+len(secondaryNames)) - out := make([]string, 0, len(primaryNames)+len(secondaryNames)) - for _, id := range primaryNames { - if id == "" || seen[id] { - continue - } - seen[id] = true - out = append(out, id) - } - var outInfos []ModelInfo - if primaryInfos != nil { - outInfos = make([]ModelInfo, 0, len(primaryInfos)+len(secondaryInfos)) - outInfos = append(outInfos, primaryInfos...) - } - for _, id := range secondaryNames { - if id == "" || seen[id] { - continue - } - seen[id] = true - out = append(out, id) - // 窄表企业响应(secondaryInfos nil / 超出条目数)时该 id 无对象字段, - // infos 保持原样(调用方按 id 名单输出裸条目,不编造字段)。 - for _, mi := range secondaryInfos { - if mi.ID == id { - outInfos = append(outInfos, mi) - break - } - } - } - return out, outInfos -} - -// mergeEffortBuckets 合并两路 effort 桶:主路(v3)权威,企业端点只补主路缺失的模型档位。 -func mergeEffortBuckets(primary, secondary map[string][]string) map[string][]string { - if len(secondary) == 0 { - return primary - } - out := primary - if out == nil { - out = make(map[string][]string, len(secondary)) - } - for id, v := range secondary { - if _, ok := out[id]; !ok { - out[id] = v - } - } - return out -} - -// mergeEffortDefaults 合并两路 defaultEffort:主路(v3)权威,企业端点只补缺失。 -func mergeEffortDefaults(primary, secondary map[string]string) map[string]string { - if len(secondary) == 0 { - return primary - } - out := primary - if out == nil { - out = make(map[string]string, len(secondary)) - } - for id, v := range secondary { - if _, ok := out[id]; !ok { - out[id] = v - } - } - return out -} - -// globalModelsOnce 单端点探测。2xx + 解析出非空名单 → (names, infos, nil);否则 (nil, nil, err)。 -func (c *Client) globalModelsOnce(a *auth.Auth, path string) ([]string, []ModelInfo, error) { - url := c.chatBase(a) + path // 按 realm 切 base:global 账号 → global base - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, nil, err - } - c.CommonHeaders(req, a) // 共享请求头(Origin/Referer/UA),与 FetchModels 同款 - // AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。 - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, nil, err - } - defer resp.Body.Close() - raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - // 读失败 → 传输层错误:半截 body 不进解析(探测负缓存走 lastFail,不罚号)。 - return nil, nil, fmt.Errorf("read body: %w", err) - } - if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("global models status %d: %s", resp.StatusCode, truncate(string(raw), 120)) - } - names, infos, _, _, err := parseGlobalModelNames(raw) - return names, infos, err -} - -// parseGlobalModelNames 多信封兼容解析模型目录(国际站曾在多种 envelope 间切换, -// 只认单一形态会把登录成功的账号误判为"无模型"): -// - 信封:code 存在且非 0 才拒(无 code 字段也放行);payload = data(非空非 -// null 时)否则整包; -// - 模型数组定位:payload 本身是数组,或是 map 下 models/items/list/data/result -// 任一键(递归一层层往下找第一个非空数组)——覆盖 data.models / data.items / -// data.list / 顶层 models 等变体; -// - 数组主形态:对象数组按 dynModelEntry 全字段解析(与 CN 目录同构,字段零 -// 漂移):maxInputTokens/maxOutputTokens/maxAllowedSize/supportsReasoning/ -// supportsImages/reasoning.*;id 缺省时回退 name;disabled 剔除; -// - 窄表:字符串数组 → 仅 ID,元数据留空(窗口由调用方四级查找链兜底); -// - 动态兜底:主形态解析不出任何可用条目时(上游换了键名),逐对象宽松解析—— -// id 依次回退 id/modelId/model/name,窗口键回退 contextWindow/maxTokens, -// reasoning 档位(defaultEffort 新键优先、effort 老键兜底)保留。 -// -// 同时产出 effort 能力桶(supportedEfforts/defaultEffort)。 -// 解析成功但名单为空 → 返回错误(等价"该端点没给全")。 -func parseGlobalModelNames(raw []byte) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { - fail := func(e error) ([]string, []ModelInfo, map[string][]string, map[string]string, error) { - return nil, nil, nil, nil, e - } - // 信封层:code 拒绝非 0 业务码(字段缺失 = 放行,兼容无信封直出的目录端点)。 - var envelope map[string]json.RawMessage - if err := json.Unmarshal(raw, &envelope); err != nil { - return fail(fmt.Errorf("global models parse: %w", err)) - } - if codeRaw, ok := envelope["code"]; ok { - var code int - if json.Unmarshal(codeRaw, &code) == nil && code != 0 { - return fail(fmt.Errorf("global models code=%d", code)) - } - } - payload := json.RawMessage(raw) - if data, ok := envelope["data"]; ok { - if t := strings.TrimSpace(string(data)); t != "" && t != "null" { - payload = data - } - } - arr, ok := resolveGlobalModelsArray(payload) - if !ok { - return fail(fmt.Errorf("global models empty list")) - } - - // 主形态:对象数组 → dynModelEntry 全字段(与 CN FetchModels 共用解析,零口径漂移)。 - var entries []dynModelEntry - if json.Unmarshal(arr, &entries) == nil { - out := make([]string, 0, len(entries)) - objInfos := make([]ModelInfo, 0, len(entries)) - for _, m := range entries { - // id 回退链 id→modelId→model→name(宽松键仅出现在 global 目录变体里)。 - id := m.ID - if id == "" { - id = m.ModelID - } - if id == "" { - id = m.Model - } - if id == "" { - id = m.Name - } - if id == "" || m.Disabled { - continue - } - out = append(out, id) - mi := m.modelInfo() - mi.ID = id // name 兜底形态下 id 取自 name,对齐 names 输出 - objInfos = append(objInfos, mi) - if len(m.Reasoning.SupportedEfforts) > 0 { - if efforts == nil { - efforts = make(map[string][]string) - } - efforts[id] = m.Reasoning.SupportedEfforts - } - if d := m.Reasoning.DefaultEffort; d != "" { - if defaults == nil { - defaults = make(map[string]string) - } - defaults[id] = d - } - } - if len(out) > 0 { - return out, objInfos, efforts, defaults, nil - } - // 可用条目为 0(键名全对不上):落入下方动态兜底,不在这里报空。 - efforts, defaults = nil, nil - } - - // 窄表:字符串数组 → 仅 ID(无 effort 元数据、无对象字段 → infos nil)。 - var strs []string - if json.Unmarshal(arr, &strs) == nil { - out := make([]string, 0, len(strs)) - for _, id := range strs { - if id = strings.TrimSpace(id); id != "" { - out = append(out, id) - } - } - if len(out) > 0 { - return out, nil, nil, nil, nil - } - return fail(fmt.Errorf("global models empty list")) - } - - // 动态兜底:逐对象宽松解析(上游换键名时不至于整域空列表)。 - var items []any - if json.Unmarshal(arr, &items) != nil { - return fail(fmt.Errorf("global models parse: unsupported payload shape")) - } - out := make([]string, 0, len(items)) - infos = make([]ModelInfo, 0, len(items)) - for _, item := range items { - switch v := item.(type) { - case string: // 混合数组里的裸 ID:按窄表条目处理 - if id := strings.TrimSpace(v); id != "" { - out = append(out, id) - infos = append(infos, ModelInfo{ID: id}) - } - case map[string]any: - mi, ok := parseGlobalModelLoose(v) - if !ok { - continue - } - out = append(out, mi.ID) - infos = append(infos, mi) - if len(mi.Efforts) > 0 { - if efforts == nil { - efforts = make(map[string][]string) - } - efforts[mi.ID] = mi.Efforts - } - if mi.DefaultEffort != "" { - if defaults == nil { - defaults = make(map[string]string) - } - defaults[mi.ID] = mi.DefaultEffort - } - } - } - if len(out) == 0 { - return fail(fmt.Errorf("global models empty list")) - } - return out, infos, efforts, defaults, nil -} - -// resolveGlobalModelsArray 从 payload 定位模型数组:payload 本身是数组直接用; -// 是 map 则依次尝试 models/items/list/data/result 键(递归下钻,取第一个能解析出 -// 数组的分支)。找不到数组 → false。 -func resolveGlobalModelsArray(payload json.RawMessage) (json.RawMessage, bool) { - trimmed := strings.TrimSpace(string(payload)) - if strings.HasPrefix(trimmed, "[") { - return payload, true - } - var obj map[string]json.RawMessage - if json.Unmarshal(payload, &obj) != nil { - return nil, false - } - for _, key := range []string{"models", "items", "list", "data", "result"} { - if nested, ok := obj[key]; ok { - if arr, ok2 := resolveGlobalModelsArray(nested); ok2 { - return arr, true - } - } - } - return nil, false -} - -// parseGlobalModelLoose 单模型对象的宽松解析(多信封兜底路径):id 依次回退 -// id/modelId/model/name;窗口/上限键回退 contextWindow/maxTokens;reasoning 档位 -// defaultEffort 新键优先、effort 老键兜底。disabled 剔除。Credits 恒不解析 -// (PLAN §3.D2:倍率不进 global 路径)。 -func parseGlobalModelLoose(obj map[string]any) (ModelInfo, bool) { - str := func(key string) string { - s, _ := obj[key].(string) - return strings.TrimSpace(s) - } - id := str("id") - for _, k := range []string{"modelId", "model", "name"} { - if id != "" { - break - } - id = str(k) - } - if id == "" { - return ModelInfo{}, false - } - if disabled, ok := obj["disabled"].(bool); ok && disabled { - return ModelInfo{}, false - } - num := func(keys ...string) int64 { - for _, k := range keys { - if n, ok := obj[k].(float64); ok && n > 0 { - return int64(n) - } - } - return 0 - } - mi := ModelInfo{ - ID: id, - Name: str("name"), - ContextWindow: num("maxInputTokens", "contextWindow"), - MaxTokens: num("maxOutputTokens", "maxTokens"), - MaxAllowedSize: num("maxAllowedSize"), - } - mi.SupportsReasoning, _ = obj["supportsReasoning"].(bool) - mi.SupportsImages, _ = obj["supportsImages"].(bool) - if r, ok := obj["reasoning"].(map[string]any); ok { - reasonStr := func(key string) string { - s, _ := r[key].(string) - return strings.TrimSpace(s) - } - mi.DefaultEffort = reasonStr("defaultEffort") - if mi.DefaultEffort == "" { - mi.DefaultEffort = reasonStr("effort") // 老模型键兜底,与 CN 侧同款 - } - mi.CanDisableThinking, _ = r["canDisableThinking"].(bool) - if arr, ok := r["supportedEfforts"].([]any); ok { - for _, item := range arr { - if s, ok := item.(string); ok { - if s = strings.TrimSpace(s); s != "" { - mi.Efforts = append(mi.Efforts, s) - } - } - } - } - } - return mi, true -} +// global 模型目录探测:纯动态产出模型名及其窗口 / 能力元数据(v3-config-merge)。 +// +// 探测并发四路:/v3/config 的三种 UA(桌面端主路 + IDE + CLI)与企业端点家族 +// (/v2 → /console 补缺),并集 = v3 条目为主、企业端点补 v3 缺失的 id +// (如 gpt-5.3-codex 只在 /v2 下发)。倍率字段(credits)虽随目录下发,但 +// 只透出展示,不注入 costTier、不参与选号。 +// +// 纯动态:不再回落任何静态名单——拉不出目录即意味着该域上游不可用, +// 假名单只会让客户端选到 11102 的模型(产品决策:无兜底)。 +package upstream + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// GlobalModelNames 国际版(global realm)历史静态名单(PLAN §7.2 附录 21 名)。 +// 纯动态化后**不再作为模型目录的基底/兜底**:/v1/models 只透出上游实际下发的模型。 +// 保留仅作历史对照(global e2e 观测日志差集参照)。 +var GlobalModelNames = []string{ + "default-model", + "fast-model", + "balanced-model", + "primary-model", + "hy4-preview", + "gpt-5.6-sol", + "gpt-5.6-terra", + "deep-model", + "deepseek-v4.1-flash", + "gpt-6-astra", + "hy4-preview-f", + "hy3", + "glm-5.2", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gemini-3.5-flash", + "glm-5.3", + "kimi-k3", + "kimi-k2.6", +} + +// fetchGlobalModelsCache 探测结果缓存(语义参照 CN 侧 handler.dynamicModelsCache:1h TTL + +// 5min 失败负缓存)。按 Client 实例持有(effortsMu 同模式),测试新建 Client 即隔离。 +// Mutex 内嵌,与 modelList 无并发读路径竞争(唯一读写点本文件内)。 +type fetchGlobalModelsCache struct { + sync.Mutex + names []string // 成功缓存:并集模型名(已去重);nil = 未探测/失败 + infos []ModelInfo // 成功缓存:对象形态的全字段条目(窄表/失败形态为 nil) + fetched time.Time + lastFail time.Time +} + +// globalModelsTTL / globalModelsFailCooldown 探测缓存时长:成功 1h,失败 5min 负缓存。 +const ( + globalModelsTTL = time.Hour + globalModelsFailCooldown = 5 * time.Minute +) + +// globalModelsProbePaths global 企业模型目录端点候选序列(按 realm 切 base,路径"家族"): +// /v2 家族优先(实测 /v2/enterprises/personal/models 200 含完整模型表), +// /console 作 fallback(同域旧路径,或 500)。v3-config-merge 后该家族降为企业补充路 +// (/v3/config 为主路,与家族并发探测;gpt-5.3-codex 等家族独有模型经此进并集)。 +var globalModelsProbePaths = []string{ + "/v2/enterprises/personal/models", + "/console/enterprises/personal/models", +} + +// FetchGlobalModels 探测 global 账号的模型名目录并返回**模型名列表**(无元数据)。 +// +// 纯动态:成功返回并集结果(去重),缓存 1h;失败(两路全非 2xx / 解析失败 / +// 空列表)记 5min 负缓存,返回 nil(无静态回落)。缓存/负缓存命中:直接返回,零上游调用。 +// +// 调用方负责:仅在有 global 账号时调用(无则不探测);GlobalEnabled 关闭时(逃生门) +// 不得调用——本方法由 globalOn(a) 内部兜底,若账号因开关回落 cn 则返回 nil。 +func (c *Client) FetchGlobalModels(a *auth.Auth) []string { + names, _ := c.fetchGlobalModelsOnce(a) + return names +} + +// FetchGlobalModelInfos 探测 global 账号的模型目录并返回全字段 ModelInfo 列表。 +// 与 FetchGlobalModels 共享同一次探测与缓存(names + infos 一体落缓存): +// 对象形态 200 → 全字段条目;窄表形态 / 探测失败 / 负缓存 / 非 global 路由账号 +// → nil(调用方按 ID 名单输出裸条目,不编造字段)。 +// 账号因 GlobalEnabled 开关回落 cn 时不探测(globalOn 兜底,零上游调用)。 +func (c *Client) FetchGlobalModelInfos(a *auth.Auth) []ModelInfo { + _, infos := c.fetchGlobalModelsOnce(a) + return infos +} + +// fetchGlobalModelsOnce 单次探测决策(缓存命中/负缓存/触发探测),返回 (names, infos)。 +// 纯动态:成功 = 并集结果去重;一切失败 = nil(不回落静态)。 +// infos 仅对象形态成功探测时非 nil。 +func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []ModelInfo) { + if !c.globalOn(a) { + // 逃生门兜底:账号不路由 global 上游 → 不探测(零上游调用)。 + return nil, nil + } + + c.globalModels.Lock() + if len(c.globalModels.names) > 0 && time.Since(c.globalModels.fetched) < globalModelsTTL { + names, infos := c.globalModels.names, c.globalModels.infos + c.globalModels.Unlock() + return names, infos + } + if !c.globalModels.lastFail.IsZero() && time.Since(c.globalModels.lastFail) < globalModelsFailCooldown { + // 负缓存冷却期内:避免反复打上游,直接按失败处理(无静态回落)。 + c.globalModels.Unlock() + return nil, nil + } + c.globalModels.Unlock() + + names, infos, efforts, defaults, err := c.probeGlobalModels(a) + if err != nil || len(names) == 0 { + // 探测失败:负缓存 + 返回 nil(effort 桶不写,prepareBody 走 globalEffortMap 静态兜底)。 + c.globalModels.Lock() + c.globalModels.lastFail = time.Now() + c.globalModels.names = nil + c.globalModels.infos = nil + c.globalModels.Unlock() + return nil, nil + } + // global 域 effort 能力:探测下发的 supportedEfforts/defaultEffort 权威写入 global 桶 + // (raw remote,不并入静态表——静态兜底在 prepareBody 的 globalEffortMap 与 + // /v1/models 的 EffortListing 里按需 fallback)。空探测不写(防清既有桶)。 + if len(efforts) > 0 || len(defaults) > 0 { + c.storeEfforts("global", efforts, defaults) + } + c.storeModelRates("global", infos) + + // 成功:探测结果去重。names/infos 均落缓存;倍率等选号敏感字段只透出展示, + // 不注入 costTier。 + seen := make(map[string]bool, len(names)) + merged := make([]string, 0, len(names)) + for _, id := range names { + if id == "" || seen[id] { + continue + } + seen[id] = true + merged = append(merged, id) + } + + c.globalModels.Lock() + c.globalModels.names = merged + c.globalModels.infos = infos + c.globalModels.fetched = time.Now() + c.globalModels.lastFail = time.Time{} + c.globalModels.Unlock() + return merged, infos +} + +// probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge + desktop-ua): +// /v3/config(三路 UA:桌面端主路 + IDE + CLI)与企业端点家族(/v2 → /console 兜底, +// 补缺)**并发**探测后并集合并。返回模型名列表(已合并、未再去重——去重在 +// fetchGlobalModelsOnce)、全字段 ModelInfo(对象形态;窄表为 nil)及 effort +// 能力桶(supportedEfforts/defaultEffort,可为空)。 +// +// 三路 UA 缺一不可(2026-10-02 实测,同一 global 账号): +// - 桌面端 UA → 29 条:**唯一含 gpt-6-sol / gpt-6-luna / grok-4.7 / gemini-3.8-flash** +// - IDE UA → 13 条:独有 o4-mini / enhance-1.0 / auto-chat +// - CLI UA → 22 条:独有 deepseek 系列 / gpt-6-astra / kimi-k2.8-preview +// +// 桌面端 UA 为主路:它是项目 chat 路径实际使用的 UA(与客户端看到的目录同源), +// 且实测在两路共有的模型上字段完整度不低于另两路(窗口/effort/credits 齐全)。 +// 合并口径:桌面端条目字段权威,IDE/CLI 只补桌面端缺失的模型 id;去重 key = +// 模型 id,输出顺序稳定。全部 v3 路失败且企业端点也失败才返回错误;单路失败 +// 降级为其余路结果 + warn 日志,互不拖累。 +func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { + // probeV3 单次 /v3/config 探测(UA 参数化)。该端点对不同 UA 下发**不同模型集合**: + // 桌面端 / IDE / CLI 三路各有独有模型(见 probeGlobalModels 注释),故并发三路取并集。 + probeV3 := func(ua string) chan probeResult { + ch := make(chan probeResult, 1) + go func() { + // chatBase 已按 realm 切 global base。 + byID, perr := c.fetchV3ConfigModelMap(a, ua) + if perr != nil { + ch <- probeResult{err: perr} + return + } + ids := make([]string, 0, len(byID)) + outInfos := make([]ModelInfo, 0, len(byID)) + for _, mi := range byID { + if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) { + continue + } + ids = append(ids, mi.ID) + outInfos = append(outInfos, mi) + } + sort.Strings(ids) // map 迭代序随机,排序保输出稳定 + ch <- probeResult{names: ids, infos: outInfos} + }() + return ch + } + // 桌面端 UA(主路):项目 chat 路径实际使用的 UA,与官方客户端看到的目录同源。 + // 按账号 realm 生成(global → `WorkBuddy AI` 平台段),故必须传 a 而非用常量。 + v3DesktopCh := probeV3(c.defaultWorkBuddyUAFor(a)) + v3IDECh := probeV3(codeBuddyIDEUA) + v3CLICh := probeV3(codeBuddyCLIUA) + enterpriseCh := make(chan probeResult, 1) + go func() { + // 企业端点家族:/v2 首选 → /console 兜底(既有探活序,零回归)。 + var lastErr error + for _, path := range globalModelsProbePaths { + names, infos, perr := c.globalModelsOnce(a, path) + if perr != nil { + lastErr = perr + continue + } + enterpriseCh <- probeResult{names: names, infos: infos} + return + } + enterpriseCh <- probeResult{err: lastErr} + }() + v3Desktop := <-v3DesktopCh + v3IDE := <-v3IDECh + v3CLI := <-v3CLICh + enterprise := <-enterpriseCh + // v3 三路自合并:桌面端路字段权威(主路),IDE/CLI 只补主路缺失的模型 id + // (只补 id,字段仍取自各自条目——主路没有该 id 时才轮到它们)。 + // 逐路容错:全失败才带 err 进入下游降级判断,部分失败 warn 后继续。 + v3 := mergeV3Routes([]struct { + label string + res probeResult + }{ + {"desktop-UA", v3Desktop}, + {"IDE-UA", v3IDE}, + {"CLI-UA", v3CLI}, + }) + + if v3.err != nil && enterprise.err != nil { + // 两路全失败 → 负缓存语义(等价原家族端点全非 2xx)。 + return nil, nil, nil, nil, v3.err + } + if v3.err != nil { + // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 + log.Printf("WARN: [upstream] global models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) + names, infos, efforts, defaults = extractEfforts(enterprise.infos) + return names, infos, efforts, defaults, nil + } + if enterprise.err != nil { + log.Printf("WARN: [upstream] global models: enterprise endpoint failed (v3/config only): %v", enterprise.err) + names, infos, efforts, defaults = extractEfforts(v3.infos) + return names, infos, efforts, defaults, nil + } + // 两路皆成功:v3 为主、企业端点补缺合并(含 effort 桶合并,v3 权威)。 + v3Names, v3Infos, v3Efforts, v3Defaults := extractEfforts(v3.infos) + if len(v3Names) == 0 { + v3Names = v3.names + } + entNames, entInfos, entEfforts, entDefaults := extractEfforts(enterprise.infos) + if len(entNames) == 0 { + entNames = enterprise.names + } + names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) + efforts = mergeEffortBuckets(v3Efforts, entEfforts) + defaults = mergeEffortDefaults(v3Defaults, entDefaults) + return names, infos, efforts, defaults, nil +} + +// probeResult 单路模型目录探测结果(/v3/config 某 UA 路,或企业端点家族)。 +// infos 为对象形态条目(窄表探测为 nil),names 与之平行;err 非 nil 表示该路失败。 +type probeResult struct { + names []string + infos []ModelInfo + err error +} + +// mergeV3Routes 按优先级合并 /v3/config 多路 UA 探测结果:routes 必须**按权威 +// 顺序**传入(主路在前)。前序路的字段权威,后续路只补前序路缺失的模型 id +// (条目字段仍取自其所属路——该 id 只有那一路知道)。 +// +// 容错:全路失败才返回 err(取首路错误,与既有「v3 全失败」降级语义一致); +// 部分失败 warn 日志后用成功路继续,单路故障不拖垮整次探测。 +// 全路成功但并集为空时保留空结果(由调用方按「空列表 = 失败」处理)。 +func mergeV3Routes(routes []struct { + label string + res probeResult +}) probeResult { + var out probeResult + var firstErr error + nOK := 0 + for _, r := range routes { + if r.res.err != nil { + log.Printf("WARN: [upstream] global models: v3/config %s probe failed: %v", r.label, r.res.err) + if firstErr == nil { + firstErr = r.res.err + } + continue + } + nOK++ + if out.err != nil || (len(out.names) == 0 && len(out.infos) == 0) { + // 首个成功路:整体作为基底(字段权威)。 + out = r.res + continue + } + // 后续成功路:只补缺失的 id。 + mergedNames, mergedInfos := mergeGlobalCatalog(out.names, out.infos, r.res.names, r.res.infos) + out.names, out.infos = mergedNames, mergedInfos + } + if nOK == 0 { + return probeResult{err: firstErr} + } + return out +} + +// extractEfforts 从条目列表抽取 effort 能力桶(supportedEfforts 数组优先; +// 缺数组但 defaultEffort 单档非空也入 defaults 桶)并顺带返回有序 names。 +func extractEfforts(infos []ModelInfo) (names []string, out []ModelInfo, efforts map[string][]string, defaults map[string]string) { + names = make([]string, 0, len(infos)) + for _, mi := range infos { + if mi.ID == "" { + continue + } + names = append(names, mi.ID) + out = append(out, mi) + if len(mi.Efforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[mi.ID] = mi.Efforts + } + if mi.DefaultEffort != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[mi.ID] = mi.DefaultEffort + } + } + return names, out, efforts, defaults +} + +// mergeGlobalCatalog 两路合并(v3 主、企业补缺):names 按 id 去重(v3 原序在前、 +// 企业端点补充项在其原序后追加——稳定输出);infos 同步合并(v3 条目字段权威, +// 企业端点条目只在 id 缺失时进并集)。 +// 窄表形态(infos nil)时保持 nil——无对象字段不编造。 +func mergeGlobalCatalog(primaryNames []string, primaryInfos []ModelInfo, secondaryNames []string, secondaryInfos []ModelInfo) (names []string, infos []ModelInfo) { + if len(secondaryNames) == 0 { + return primaryNames, primaryInfos + } + seen := make(map[string]bool, len(primaryNames)+len(secondaryNames)) + out := make([]string, 0, len(primaryNames)+len(secondaryNames)) + for _, id := range primaryNames { + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + var outInfos []ModelInfo + if primaryInfos != nil { + outInfos = make([]ModelInfo, 0, len(primaryInfos)+len(secondaryInfos)) + outInfos = append(outInfos, primaryInfos...) + } + for _, id := range secondaryNames { + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + // 窄表企业响应(secondaryInfos nil / 超出条目数)时该 id 无对象字段, + // infos 保持原样(调用方按 id 名单输出裸条目,不编造字段)。 + for _, mi := range secondaryInfos { + if mi.ID == id { + outInfos = append(outInfos, mi) + break + } + } + } + return out, outInfos +} + +// mergeEffortBuckets 合并两路 effort 桶:主路(v3)权威,企业端点只补主路缺失的模型档位。 +func mergeEffortBuckets(primary, secondary map[string][]string) map[string][]string { + if len(secondary) == 0 { + return primary + } + out := primary + if out == nil { + out = make(map[string][]string, len(secondary)) + } + for id, v := range secondary { + if _, ok := out[id]; !ok { + out[id] = v + } + } + return out +} + +// mergeEffortDefaults 合并两路 defaultEffort:主路(v3)权威,企业端点只补缺失。 +func mergeEffortDefaults(primary, secondary map[string]string) map[string]string { + if len(secondary) == 0 { + return primary + } + out := primary + if out == nil { + out = make(map[string]string, len(secondary)) + } + for id, v := range secondary { + if _, ok := out[id]; !ok { + out[id] = v + } + } + return out +} + +// globalModelsOnce 单端点探测。2xx + 解析出非空名单 → (names, infos, nil);否则 (nil, nil, err)。 +func (c *Client) globalModelsOnce(a *auth.Auth, path string) ([]string, []ModelInfo, error) { + url := c.chatBase(a) + path // 按 realm 切 base:global 账号 → global base + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + c.CommonHeaders(req, a) // 共享请求头(Origin/Referer/UA),与 FetchModels 同款 + // AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + // 读失败 → 传输层错误:半截 body 不进解析(探测负缓存走 lastFail,不罚号)。 + return nil, nil, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("global models status %d: %s", resp.StatusCode, truncate(string(raw), 120)) + } + names, infos, _, _, err := parseGlobalModelNames(raw) + return names, infos, err +} + +// parseGlobalModelNames 多信封兼容解析模型目录(国际站曾在多种 envelope 间切换, +// 只认单一形态会把登录成功的账号误判为"无模型"): +// - 信封:code 存在且非 0 才拒(无 code 字段也放行);payload = data(非空非 +// null 时)否则整包; +// - 模型数组定位:payload 本身是数组,或是 map 下 models/items/list/data/result +// 任一键(递归一层层往下找第一个非空数组)——覆盖 data.models / data.items / +// data.list / 顶层 models 等变体; +// - 数组主形态:对象数组按 dynModelEntry 全字段解析(与 CN 目录同构,字段零 +// 漂移):maxInputTokens/maxOutputTokens/maxAllowedSize/supportsReasoning/ +// supportsImages/reasoning.*;id 缺省时回退 name;disabled 剔除; +// - 窄表:字符串数组 → 仅 ID,元数据留空(窗口由调用方四级查找链兜底); +// - 动态兜底:主形态解析不出任何可用条目时(上游换了键名),逐对象宽松解析—— +// id 依次回退 id/modelId/model/name,窗口键回退 contextWindow/maxTokens, +// reasoning 档位(defaultEffort 新键优先、effort 老键兜底)保留。 +// +// 同时产出 effort 能力桶(supportedEfforts/defaultEffort)。 +// 解析成功但名单为空 → 返回错误(等价"该端点没给全")。 +func parseGlobalModelNames(raw []byte) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { + fail := func(e error) ([]string, []ModelInfo, map[string][]string, map[string]string, error) { + return nil, nil, nil, nil, e + } + // 信封层:code 拒绝非 0 业务码(字段缺失 = 放行,兼容无信封直出的目录端点)。 + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return fail(fmt.Errorf("global models parse: %w", err)) + } + if codeRaw, ok := envelope["code"]; ok { + var code int + if json.Unmarshal(codeRaw, &code) == nil && code != 0 { + return fail(fmt.Errorf("global models code=%d", code)) + } + } + payload := json.RawMessage(raw) + if data, ok := envelope["data"]; ok { + if t := strings.TrimSpace(string(data)); t != "" && t != "null" { + payload = data + } + } + arr, ok := resolveGlobalModelsArray(payload) + if !ok { + return fail(fmt.Errorf("global models empty list")) + } + + // 主形态:对象数组 → dynModelEntry 全字段(与 CN FetchModels 共用解析,零口径漂移)。 + var entries []dynModelEntry + if json.Unmarshal(arr, &entries) == nil { + out := make([]string, 0, len(entries)) + objInfos := make([]ModelInfo, 0, len(entries)) + for _, m := range entries { + // id 回退链 id→modelId→model→name(宽松键仅出现在 global 目录变体里)。 + id := m.ID + if id == "" { + id = m.ModelID + } + if id == "" { + id = m.Model + } + if id == "" { + id = m.Name + } + if id == "" || m.Disabled { + continue + } + out = append(out, id) + mi := m.modelInfo() + mi.ID = id // name 兜底形态下 id 取自 name,对齐 names 输出 + objInfos = append(objInfos, mi) + if len(m.Reasoning.SupportedEfforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[id] = m.Reasoning.SupportedEfforts + } + if d := m.Reasoning.DefaultEffort; d != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[id] = d + } + } + if len(out) > 0 { + return out, objInfos, efforts, defaults, nil + } + // 可用条目为 0(键名全对不上):落入下方动态兜底,不在这里报空。 + efforts, defaults = nil, nil + } + + // 窄表:字符串数组 → 仅 ID(无 effort 元数据、无对象字段 → infos nil)。 + var strs []string + if json.Unmarshal(arr, &strs) == nil { + out := make([]string, 0, len(strs)) + for _, id := range strs { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + if len(out) > 0 { + return out, nil, nil, nil, nil + } + return fail(fmt.Errorf("global models empty list")) + } + + // 动态兜底:逐对象宽松解析(上游换键名时不至于整域空列表)。 + var items []any + if json.Unmarshal(arr, &items) != nil { + return fail(fmt.Errorf("global models parse: unsupported payload shape")) + } + out := make([]string, 0, len(items)) + infos = make([]ModelInfo, 0, len(items)) + for _, item := range items { + switch v := item.(type) { + case string: // 混合数组里的裸 ID:按窄表条目处理 + if id := strings.TrimSpace(v); id != "" { + out = append(out, id) + infos = append(infos, ModelInfo{ID: id}) + } + case map[string]any: + mi, ok := parseGlobalModelLoose(v) + if !ok { + continue + } + out = append(out, mi.ID) + infos = append(infos, mi) + if len(mi.Efforts) > 0 { + if efforts == nil { + efforts = make(map[string][]string) + } + efforts[mi.ID] = mi.Efforts + } + if mi.DefaultEffort != "" { + if defaults == nil { + defaults = make(map[string]string) + } + defaults[mi.ID] = mi.DefaultEffort + } + } + } + if len(out) == 0 { + return fail(fmt.Errorf("global models empty list")) + } + return out, infos, efforts, defaults, nil +} + +// resolveGlobalModelsArray 从 payload 定位模型数组:payload 本身是数组直接用; +// 是 map 则依次尝试 models/items/list/data/result 键(递归下钻,取第一个能解析出 +// 数组的分支)。找不到数组 → false。 +func resolveGlobalModelsArray(payload json.RawMessage) (json.RawMessage, bool) { + trimmed := strings.TrimSpace(string(payload)) + if strings.HasPrefix(trimmed, "[") { + return payload, true + } + var obj map[string]json.RawMessage + if json.Unmarshal(payload, &obj) != nil { + return nil, false + } + for _, key := range []string{"models", "items", "list", "data", "result"} { + if nested, ok := obj[key]; ok { + if arr, ok2 := resolveGlobalModelsArray(nested); ok2 { + return arr, true + } + } + } + return nil, false +} + +// parseGlobalModelLoose 单模型对象的宽松解析(多信封兜底路径):id 依次回退 +// id/modelId/model/name;窗口/上限键回退 contextWindow/maxTokens;reasoning 档位 +// defaultEffort 新键优先、effort 老键兜底。disabled 剔除。Credits 恒不解析 +// (PLAN §3.D2:倍率不进 global 路径)。 +func parseGlobalModelLoose(obj map[string]any) (ModelInfo, bool) { + str := func(key string) string { + s, _ := obj[key].(string) + return strings.TrimSpace(s) + } + id := str("id") + for _, k := range []string{"modelId", "model", "name"} { + if id != "" { + break + } + id = str(k) + } + if id == "" { + return ModelInfo{}, false + } + if disabled, ok := obj["disabled"].(bool); ok && disabled { + return ModelInfo{}, false + } + num := func(keys ...string) int64 { + for _, k := range keys { + if n, ok := obj[k].(float64); ok && n > 0 { + return int64(n) + } + } + return 0 + } + mi := ModelInfo{ + ID: id, + Name: str("name"), + ContextWindow: num("maxInputTokens", "contextWindow"), + MaxTokens: num("maxOutputTokens", "maxTokens"), + MaxAllowedSize: num("maxAllowedSize"), + } + mi.SupportsReasoning, _ = obj["supportsReasoning"].(bool) + mi.SupportsImages, _ = obj["supportsImages"].(bool) + if r, ok := obj["reasoning"].(map[string]any); ok { + reasonStr := func(key string) string { + s, _ := r[key].(string) + return strings.TrimSpace(s) + } + mi.DefaultEffort = reasonStr("defaultEffort") + if mi.DefaultEffort == "" { + mi.DefaultEffort = reasonStr("effort") // 老模型键兜底,与 CN 侧同款 + } + mi.CanDisableThinking, _ = r["canDisableThinking"].(bool) + if arr, ok := r["supportedEfforts"].([]any); ok { + for _, item := range arr { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + mi.Efforts = append(mi.Efforts, s) + } + } + } + } + } + return mi, true +} diff --git a/internal/upstream/global_models_routes_test.go b/internal/upstream/global_models_routes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ad55a2efe4e3d1c69740f1b3fe2f96832d8603fa --- /dev/null +++ b/internal/upstream/global_models_routes_test.go @@ -0,0 +1,172 @@ +package upstream + +import ( + "errors" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestMergeV3RoutesPriority 主路字段权威:同 id 在三路都出现时,字段取自主路 +// (桌面端 UA),后续路不得覆盖其窗口/effort/credits。 +func TestMergeV3RoutesPriority(t *testing.T) { + mk := func(id string, ctx, out int64, credits string, efforts []string) ModelInfo { + return ModelInfo{ID: id, ContextWindow: ctx, MaxTokens: out, Credits: credits, Efforts: efforts} + } + desktop := probeResult{ + names: []string{"shared", "only-desktop"}, + infos: []ModelInfo{ + mk("shared", 1000000, 128000, "x1.33", []string{"low", "high"}), + mk("only-desktop", 500000, 64000, "x0.10", []string{"medium"}), + }, + } + ide := probeResult{ + names: []string{"shared", "only-ide"}, + infos: []ModelInfo{ + // 同 id 但字段不同:不得覆盖主路。 + mk("shared", 176000, 24000, "x9.99", []string{"max"}), + mk("only-ide", 200000, 32000, "x0.20", nil), + }, + } + cli := probeResult{ + names: []string{"shared", "only-cli"}, + infos: []ModelInfo{ + mk("shared", 272000, 72000, "x8.88", []string{"xhigh"}), + mk("only-cli", 256000, 32000, "x0.30", nil), + }, + } + got := mergeV3Routes([]struct { + label string + res probeResult + }{ + {"desktop-UA", desktop}, + {"IDE-UA", ide}, + {"CLI-UA", cli}, + }) + if got.err != nil { + t.Fatalf("unexpected err: %v", got.err) + } + wantIDs := []string{"shared", "only-desktop", "only-ide", "only-cli"} + if len(got.names) != len(wantIDs) { + t.Fatalf("names = %v, want %v", got.names, wantIDs) + } + for i, id := range wantIDs { + if got.names[i] != id { + t.Errorf("names[%d] = %q, want %q (full %v)", i, got.names[i], id, got.names) + } + } + // 主路字段权威:shared 的窗口必须是主路值。 + var shared *ModelInfo + for i := range got.infos { + if got.infos[i].ID == "shared" { + shared = &got.infos[i] + } + } + if shared == nil { + t.Fatal("shared model missing from infos") + } + if shared.ContextWindow != 1000000 || shared.MaxTokens != 128000 { + t.Errorf("shared window = %d/%d, want 1000000/128000 (primary route must win)", shared.ContextWindow, shared.MaxTokens) + } + if shared.Credits != "x1.33" { + t.Errorf("shared credits = %q, want x1.33", shared.Credits) + } + if len(shared.Efforts) != 2 || shared.Efforts[0] != "low" { + t.Errorf("shared efforts = %v, want [low high]", shared.Efforts) + } +} + +// TestMergeV3RoutesPartialFailure 单路失败不拖垮整次探测:失败的路由被跳过, +// 成功路并集照常产出;全路失败才返回 err。 +func TestMergeV3RoutesPartialFailure(t *testing.T) { + mk := func(id string) ModelInfo { return ModelInfo{ID: id} } + ok := probeResult{names: []string{"a"}, infos: []ModelInfo{mk("a")}} + bad := probeResult{err: errors.New("v3/config status 500")} + + got := mergeV3Routes([]struct { + label string + res probeResult + }{ + {"desktop-UA", bad}, + {"IDE-UA", ok}, + {"CLI-UA", bad}, + }) + if got.err != nil { + t.Fatalf("partial failure should not error, got %v", got.err) + } + if len(got.names) != 1 || got.names[0] != "a" { + t.Errorf("names = %v, want [a]", got.names) + } + + // 全路失败 → err(取首路错误,供调用方降级到企业端点)。 + all := mergeV3Routes([]struct { + label string + res probeResult + }{ + {"desktop-UA", bad}, + {"IDE-UA", bad}, + {"CLI-UA", bad}, + }) + if all.err == nil { + t.Fatal("all routes failed: want err, got nil") + } +} + +// TestNonChatModelGenerationTags 生成类(图片/视频)模型不得进对话目录。 +// 回归用例:nonChatModel 早期只拦 text-to-image,桌面端目录下的 +// text-to-video / image-to-video(seedance 系列)会漏过并混进 /v1/models。 +func TestNonChatModelGenerationTags(t *testing.T) { + cases := []struct { + name string + id string + out int64 + tags []string + want bool + }{ + {"图片生成(既有规则)", "hunyuan-image-alpha", 0, []string{"text-to-image"}, true}, + {"图生图", "gpt-image-2.5-sunburst", 0, []string{"text-to-image", "image-to-image"}, true}, + {"文生视频(本次修复)", "seedance-2.5", 0, []string{"text-to-video", "image-to-video"}, true}, + {"图生视频(本次修复)", "seedance-2.5-pro", 1024, []string{"image-to-video"}, true}, + {"普通对话模型", "gpt-6-sol", 128000, nil, false}, + {"新模型无 tags", "grok-4.7", 128000, []string{}, false}, + {"对话模型带无关 tag", "balanced-model", 32000, []string{"craft"}, false}, + {"tiny 输出仍剔除", "completion-1.0", 256, nil, true}, + {"nes 前缀仍剔除", "nes-1.2", 8192, nil, true}, + } + for _, c := range cases { + if got := nonChatModel(c.id, c.out, c.tags); got != c.want { + t.Errorf("%s: nonChatModel(%q, %d, %v) = %v, want %v", c.name, c.id, c.out, c.tags, got, c.want) + } + } +} + +// TestDesktopUAForGlobalAccount 桌面端 UA 按 realm 切平台段:global 账号必须是 +// `WorkBuddy AI`(送错平台段会触发上游 403 code=11140 风控)。探测主路用这个 UA, +// 与 chat 路径同源——global 目录探测因此拿到客户端同款模型集。 +func TestDesktopUAForGlobalAccount(t *testing.T) { + c := New() + global := &auth.Auth{UID: "u1", Domain: "www.workbuddy.ai"} + global.BackfillRealm() + ua := c.defaultWorkBuddyUAFor(global) + if want := "WorkBuddy AI"; !contains(ua, want) { + t.Errorf("global desktop UA = %q, want it to contain %q", ua, want) + } + if !contains(ua, "CLI/") { + t.Errorf("global desktop UA = %q, want CLI segment", ua) + } + // CN 账号保持 WorkBuddy(零回归)。 + cn := &auth.Auth{UID: "u2", Domain: "copilot.tencent.com"} + cn.BackfillRealm() + if ua := c.defaultWorkBuddyUAFor(cn); contains(ua, "WorkBuddy AI") { + t.Errorf("cn desktop UA = %q, must not contain 'WorkBuddy AI'", ua) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/upstream/global_register.go b/internal/upstream/global_register.go index 0bf2373d5dce6e2948a25bc2d911585ddf984992..82474fe22d5cbf37f6441974fa5a72cb2afa6dd4 100644 --- a/internal/upstream/global_register.go +++ b/internal/upstream/global_register.go @@ -1,232 +1,232 @@ -// global_register.go 国际版(global realm)新账号注册激活与地区完善。 -// -// 背景(ANALYSIS-workbuddy-client-reverse.md):新 global 账号需先完成注册地区 -// (/login/register/user/complete 补地区)再调 register 接口激活 Trial,chat 才不报 -// 14017 trial not activated。链路(逆向自 web 注册完善页 RegisterRegion-*.js): -// -// POST /billing/area/get-country-code {filterForbidden:1} → 可取国家列表 -// POST /billing/area/get-user-area-info {action:getUserAreaInfo} → 检测当前地区 -// POST /console/login/account {attributes:{countryCode,countryFullName,countryName}} → 提交地区(幂等) -// GET /auth/realms/copilot/overseas/user/register?userId= → 注册激活(code:200 成功;code:500 "region required" 需补地区) -// POST /billing/ide/trial → 一次性加油包(幂等码 14051,见 trial.go) -// -// 响应体注意:get-country-code / get-user-area-info 的 data 是 JSON 字符串 -// (双层信封),需二次解析。 -package upstream - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// globalWebUA 国际版 web 端 UA(注册完善页走 web 指纹,非桌面端 CLI 指纹)。 -const globalWebUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - -// GlobalCountry 可选注册地区(对应 get-country-code 的 list 元素)。 -type GlobalCountry struct { - EnName string `json:"EnName"` // 英文全名(countryFullName) - Name string `json:"Name"` // 显示名 - IOS2 string `json:"IOS2"` // 二字码(countryName) - IOS3 string `json:"IOS3"` - Code string `json:"Code"` // 数字码(countryCode) -} - -// globalRegisterBase 注册激活端点的 base(注册链路在 www.workbuddy.ai,与 globalBillingBase 同域)。 -// 独立成方法便于测试替换。 -func (c *Client) globalRegisterBase() string { - if c.BillingBaseGlobal != "" { - return c.BillingBaseGlobal - } - return defaultGlobalBase -} - -// globalRegisterReq 注册链路通用请求构造:web 指纹 UA + Origin/Referer 同域 + Bearer。 -func (c *Client) globalRegisterReq(method, url, token string, body any) (*http.Request, error) { - var rdr io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, url, rdr) - if err != nil { - return nil, err - } - base := c.globalRegisterBase() - req.Header.Set("User-Agent", globalWebUA) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", base) - req.Header.Set("Referer", base+"/") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - return req, nil -} - -// globalRegisterJSON 发注册链路请求并解外层信封(code/msg)。 -func (c *Client) globalRegisterJSON(req *http.Request) (code int, msg string, raw json.RawMessage, err error) { - resp, err := c.HTTP.Do(req) - if err != nil { - return 0, "", nil, err - } - defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - var env struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` - } - if err := json.Unmarshal(data, &env); err != nil { - return 0, "", nil, fmt.Errorf("global register parse: %w", err) - } - return env.Code, env.Msg, env.Data, nil -} - -// GlobalFetchCountries 拉取可选注册地区列表(global 账号登录后调用)。 -// intlOnly=true 时按国际版 web 白名单过滤(HK/MO/SG/TH/PH/MY/ID,对齐 web 展示集)。 -func (c *Client) GlobalFetchCountries(a *auth.Auth, intlOnly bool) ([]GlobalCountry, error) { - if a == nil || a.Realm() != "global" { - return nil, fmt.Errorf("fetch countries: only global accounts") - } - req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/billing/area/get-country-code", a.AccessTokenValue(), map[string]any{"filterForbidden": 1}) - if err != nil { - return nil, err - } - code, msg, raw, err := c.globalRegisterJSON(req) - if err != nil { - return nil, err - } - if code != 0 { - return nil, fmt.Errorf("get-country-code: %s (code=%d)", msg, code) - } - // data 是 JSON 字符串(双层信封)或对象,需二次解析。 - var inner struct { - Data struct { - List []GlobalCountry `json:"list"` - } `json:"data"` - } - if s := strings.TrimSpace(string(raw)); strings.HasPrefix(s, "\"") { - var s2 string - if err := json.Unmarshal(raw, &s2); err != nil { - return nil, fmt.Errorf("country list unwrap: %w", err) - } - raw = json.RawMessage(s2) - } - if err := json.Unmarshal(raw, &inner); err != nil { - return nil, fmt.Errorf("country list parse: %w", err) - } - list := inner.Data.List - if !intlOnly { - return list, nil - } - // 国际版 web 白名单过滤(HK, MO, SG, TH, PH, MY, ID,顺序对齐 web 展示)。 - whitelist := []string{"HK", "MO", "SG", "TH", "PH", "MY", "ID"} - byCode := make(map[string]GlobalCountry, len(list)) - for _, ctry := range list { - byCode[ctry.IOS2] = ctry - } - out := make([]GlobalCountry, 0, len(whitelist)) - for _, code := range whitelist { - if ctry, ok := byCode[code]; ok { - out = append(out, ctry) - } - } - return out, nil -} - -// GlobalRegisterStatus 报告 global 账号注册激活状态:是否需要补地区、是否已激活。 -func (c *Client) GlobalRegisterStatus(a *auth.Auth) (activated bool, needsRegion bool, msg string, err error) { - if a == nil || a.Realm() != "global" { - return false, false, "", fmt.Errorf("register status: only global accounts") - } - req, err := c.globalRegisterReq(http.MethodGet, - c.globalRegisterBase()+"/auth/realms/copilot/overseas/user/register?userId="+a.UID, - a.AccessTokenValue(), nil) - if err != nil { - return false, false, "", err - } - req.Header.Set("X-User-Id", a.UID) - code, m, _, err := c.globalRegisterJSON(req) - if err != nil { - return false, false, "", err - } - switch { - case code == 200: - return true, false, "register success", nil - case code == 500 || strings.Contains(strings.ToLower(m), "region required"): - return false, true, m, nil - default: - return false, false, m, nil - } -} - -// GlobalSubmitRegion 提交注册地区(幂等)。country 来自 GlobalFetchCountries。 -func (c *Client) GlobalSubmitRegion(a *auth.Auth, country GlobalCountry) error { - if a == nil || a.Realm() != "global" { - return fmt.Errorf("submit region: only global accounts") - } - attrs := map[string]any{ - "countryCode": []string{country.Code}, - "countryFullName": []string{country.EnName}, - "countryName": []string{country.IOS2}, - } - req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/console/login/account", - a.AccessTokenValue(), map[string]any{"attributes": attrs}) - if err != nil { - return err - } - code, msg, _, err := c.globalRegisterJSON(req) - if err != nil { - return err - } - if code != 0 { - return fmt.Errorf("submit region: %s (code=%d)", msg, code) - } - return nil -} - -// GlobalCompleteRegistration 一键注册激活:先查状态,需补地区则按默认地区(白名单首个, -// 通常 HK)提交后重新激活。activated 表示调用后账号已激活可用。幂等(已激活直接返回)。 -// 失败不阻断调用方(panel login 已落盘),仅返回错误供日志。 -func (c *Client) GlobalCompleteRegistration(a *auth.Auth) (activated bool, err error) { - activated, needsRegion, msg, err := c.GlobalRegisterStatus(a) - if err != nil { - return false, err - } - if activated { - return true, nil - } - if !needsRegion { - return false, fmt.Errorf("register not activated: %s", msg) - } - // 需补地区:拉白名单,取首个(HK)提交。 - countries, err := c.GlobalFetchCountries(a, true) - if err != nil { - return false, fmt.Errorf("fetch countries: %w", err) - } - if len(countries) == 0 { - return false, fmt.Errorf("no countries available") - } - if err := c.GlobalSubmitRegion(a, countries[0]); err != nil { - return false, fmt.Errorf("submit region: %w", err) - } - // 重新激活验证。 - activated, needsRegion, msg, err = c.GlobalRegisterStatus(a) - if err != nil { - return false, err - } - if !activated { - return false, fmt.Errorf("register still not activated after region submit: %s", msg) - } - return true, nil -} +// global_register.go 国际版(global realm)新账号注册激活与地区完善。 +// +// 背景(ANALYSIS-workbuddy-client-reverse.md):新 global 账号需先完成注册地区 +// (/login/register/user/complete 补地区)再调 register 接口激活 Trial,chat 才不报 +// 14017 trial not activated。链路(逆向自 web 注册完善页 RegisterRegion-*.js): +// +// POST /billing/area/get-country-code {filterForbidden:1} → 可取国家列表 +// POST /billing/area/get-user-area-info {action:getUserAreaInfo} → 检测当前地区 +// POST /console/login/account {attributes:{countryCode,countryFullName,countryName}} → 提交地区(幂等) +// GET /auth/realms/copilot/overseas/user/register?userId= → 注册激活(code:200 成功;code:500 "region required" 需补地区) +// POST /billing/ide/trial → 一次性加油包(幂等码 14051,见 trial.go) +// +// 响应体注意:get-country-code / get-user-area-info 的 data 是 JSON 字符串 +// (双层信封),需二次解析。 +package upstream + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// globalWebUA 国际版 web 端 UA(注册完善页走 web 指纹,非桌面端 CLI 指纹)。 +const globalWebUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +// GlobalCountry 可选注册地区(对应 get-country-code 的 list 元素)。 +type GlobalCountry struct { + EnName string `json:"EnName"` // 英文全名(countryFullName) + Name string `json:"Name"` // 显示名 + IOS2 string `json:"IOS2"` // 二字码(countryName) + IOS3 string `json:"IOS3"` + Code string `json:"Code"` // 数字码(countryCode) +} + +// globalRegisterBase 注册激活端点的 base(注册链路在 www.workbuddy.ai,与 globalBillingBase 同域)。 +// 独立成方法便于测试替换。 +func (c *Client) globalRegisterBase() string { + if c.BillingBaseGlobal != "" { + return c.BillingBaseGlobal + } + return defaultGlobalBase +} + +// globalRegisterReq 注册链路通用请求构造:web 指纹 UA + Origin/Referer 同域 + Bearer。 +func (c *Client) globalRegisterReq(method, url, token string, body any) (*http.Request, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + return nil, err + } + base := c.globalRegisterBase() + req.Header.Set("User-Agent", globalWebUA) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", base) + req.Header.Set("Referer", base+"/") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +// globalRegisterJSON 发注册链路请求并解外层信封(code/msg)。 +func (c *Client) globalRegisterJSON(req *http.Request) (code int, msg string, raw json.RawMessage, err error) { + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, "", nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &env); err != nil { + return 0, "", nil, fmt.Errorf("global register parse: %w", err) + } + return env.Code, env.Msg, env.Data, nil +} + +// GlobalFetchCountries 拉取可选注册地区列表(global 账号登录后调用)。 +// intlOnly=true 时按国际版 web 白名单过滤(HK/MO/SG/TH/PH/MY/ID,对齐 web 展示集)。 +func (c *Client) GlobalFetchCountries(a *auth.Auth, intlOnly bool) ([]GlobalCountry, error) { + if a == nil || a.Realm() != "global" { + return nil, fmt.Errorf("fetch countries: only global accounts") + } + req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/billing/area/get-country-code", a.AccessTokenValue(), map[string]any{"filterForbidden": 1}) + if err != nil { + return nil, err + } + code, msg, raw, err := c.globalRegisterJSON(req) + if err != nil { + return nil, err + } + if code != 0 { + return nil, fmt.Errorf("get-country-code: %s (code=%d)", msg, code) + } + // data 是 JSON 字符串(双层信封)或对象,需二次解析。 + var inner struct { + Data struct { + List []GlobalCountry `json:"list"` + } `json:"data"` + } + if s := strings.TrimSpace(string(raw)); strings.HasPrefix(s, "\"") { + var s2 string + if err := json.Unmarshal(raw, &s2); err != nil { + return nil, fmt.Errorf("country list unwrap: %w", err) + } + raw = json.RawMessage(s2) + } + if err := json.Unmarshal(raw, &inner); err != nil { + return nil, fmt.Errorf("country list parse: %w", err) + } + list := inner.Data.List + if !intlOnly { + return list, nil + } + // 国际版 web 白名单过滤(HK, MO, SG, TH, PH, MY, ID,顺序对齐 web 展示)。 + whitelist := []string{"HK", "MO", "SG", "TH", "PH", "MY", "ID"} + byCode := make(map[string]GlobalCountry, len(list)) + for _, ctry := range list { + byCode[ctry.IOS2] = ctry + } + out := make([]GlobalCountry, 0, len(whitelist)) + for _, code := range whitelist { + if ctry, ok := byCode[code]; ok { + out = append(out, ctry) + } + } + return out, nil +} + +// GlobalRegisterStatus 报告 global 账号注册激活状态:是否需要补地区、是否已激活。 +func (c *Client) GlobalRegisterStatus(a *auth.Auth) (activated bool, needsRegion bool, msg string, err error) { + if a == nil || a.Realm() != "global" { + return false, false, "", fmt.Errorf("register status: only global accounts") + } + req, err := c.globalRegisterReq(http.MethodGet, + c.globalRegisterBase()+"/auth/realms/copilot/overseas/user/register?userId="+a.UID, + a.AccessTokenValue(), nil) + if err != nil { + return false, false, "", err + } + req.Header.Set("X-User-Id", a.UID) + code, m, _, err := c.globalRegisterJSON(req) + if err != nil { + return false, false, "", err + } + switch { + case code == 200: + return true, false, "register success", nil + case code == 500 || strings.Contains(strings.ToLower(m), "region required"): + return false, true, m, nil + default: + return false, false, m, nil + } +} + +// GlobalSubmitRegion 提交注册地区(幂等)。country 来自 GlobalFetchCountries。 +func (c *Client) GlobalSubmitRegion(a *auth.Auth, country GlobalCountry) error { + if a == nil || a.Realm() != "global" { + return fmt.Errorf("submit region: only global accounts") + } + attrs := map[string]any{ + "countryCode": []string{country.Code}, + "countryFullName": []string{country.EnName}, + "countryName": []string{country.IOS2}, + } + req, err := c.globalRegisterReq(http.MethodPost, c.globalRegisterBase()+"/console/login/account", + a.AccessTokenValue(), map[string]any{"attributes": attrs}) + if err != nil { + return err + } + code, msg, _, err := c.globalRegisterJSON(req) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("submit region: %s (code=%d)", msg, code) + } + return nil +} + +// GlobalCompleteRegistration 一键注册激活:先查状态,需补地区则按默认地区(白名单首个, +// 通常 HK)提交后重新激活。activated 表示调用后账号已激活可用。幂等(已激活直接返回)。 +// 失败不阻断调用方(panel login 已落盘),仅返回错误供日志。 +func (c *Client) GlobalCompleteRegistration(a *auth.Auth) (activated bool, err error) { + activated, needsRegion, msg, err := c.GlobalRegisterStatus(a) + if err != nil { + return false, err + } + if activated { + return true, nil + } + if !needsRegion { + return false, fmt.Errorf("register not activated: %s", msg) + } + // 需补地区:拉白名单,取首个(HK)提交。 + countries, err := c.GlobalFetchCountries(a, true) + if err != nil { + return false, fmt.Errorf("fetch countries: %w", err) + } + if len(countries) == 0 { + return false, fmt.Errorf("no countries available") + } + if err := c.GlobalSubmitRegion(a, countries[0]); err != nil { + return false, fmt.Errorf("submit region: %w", err) + } + // 重新激活验证。 + activated, needsRegion, msg, err = c.GlobalRegisterStatus(a) + if err != nil { + return false, err + } + if !activated { + return false, fmt.Errorf("register still not activated after region submit: %s", msg) + } + return true, nil +} diff --git a/internal/upstream/headers.go b/internal/upstream/headers.go index a121a8ea86f631d7b6f984c35c51359b034715f1..9d99388f109be22843a8369038fd952ded031d82 100644 --- a/internal/upstream/headers.go +++ b/internal/upstream/headers.go @@ -1,411 +1,411 @@ -// Package headers 构造三类上游请求头(common / chat / billing / refresh)。 -// 规则来自 docs/api-reference.md §0/§4/§6。 -package upstream - -import ( - "crypto/sha256" - "encoding/hex" - "net/http" - "strings" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "github.com/linguo2625469/workbuddy2api-panel/internal/session" -) - -const ( - // defaultClientVersion 出站 WorkBuddy 客户端版本段(UA 的 `WorkBuddy/` 与 - // 白名单头组的 X-IDE-Version)。对齐官方 WorkBuddy Desktop 分发包版本(5.5.4)。 - // config upstream.client_version 可覆盖(空 = 内置默认)。 - defaultClientVersion = "5.5.4" - // defaultCliVersion 出站 UA 中 `CLI/` 段版本。对齐官方内置 CLI(2.137.1)。 - // config upstream.cli_version 可覆盖(空 = 内置默认)。 - defaultCliVersion = "2.137.1" - - originRefererCN = "https://www.codebuddy.cn" - originRefererGlobal = "https://www.workbuddy.ai" -) - -// originRefererFor 按账号 realm 返回 Origin/Referer 基础域: -// global → https://www.workbuddy.ai;cn(含全局开关未开)→ https://www.codebuddy.cn。 -func originRefererFor(a *auth.Auth) string { - if a != nil && a.IsGlobal() { - return originRefererGlobal - } - return originRefererCN -} - -// clientVersion 生效的 WorkBuddy 客户端版本:Client.ClientVersion 非空则取之, -// 否则内置默认 defaultClientVersion。 -func (c *Client) clientVersion() string { - if c != nil && c.ClientVersion != "" { - return c.ClientVersion - } - return defaultClientVersion -} - -// cliVersion 生效的 CLI 版本:Client.CliVersion 非空则取之,否则内置默认 defaultCliVersion。 -func (c *Client) cliVersion() string { - if c != nil && c.CliVersion != "" { - return c.CliVersion - } - return defaultCliVersion -} - -// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方桌面端 RestOperations 层形状): -// `WorkBuddy/ / CLI/`。 -// 平台段(第二段)品牌按 realm 切换——CN 用 applicationName 同值 `WorkBuddy`, -// global 用官方国际版 productName `WorkBuddy AI`(intl 项目逆向证据: -// `WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2`)。 -// global 账号送错平台段(`WorkBuddy` 非 `WorkBuddy AI`)可能触发上游 403 code 11140 -// "request illegal" 风控。官方无任何 UA 随机化,故默认确定性。 -func (c *Client) defaultWorkBuddyUAFor(a *auth.Auth) string { - platform := "WorkBuddy" - if a != nil && a.IsGlobal() { - platform = "WorkBuddy AI" - } - return "WorkBuddy/" + c.clientVersion() + " " + platform + "/" + c.clientVersion() + " CLI/" + c.cliVersion() -} - -// defaultWorkBuddyUA 返回 CN 形态的默认 UA(默认账号形态即 CN,零回归兼容既有调用/测试)。 -func (c *Client) defaultWorkBuddyUA() string { - return c.defaultWorkBuddyUAFor(nil) -} - -// userAgent 返回当前出站 UA(客户端出站路径:chat/refresh/FetchModels)。 -// 优先级:Client.UserAgent(config user_agent)显式覆盖 > 按账号 realm 的默认 WorkBuddy 三段式。 -// 显式覆盖兼容既有覆盖逻辑:用户配了即以用户值为准(自定义品牌/版本), -// 未配则走官方桌面端默认形态(global 换 `WorkBuddy AI` 平台段)。 -func (c *Client) userAgent(a *auth.Auth) string { - if c != nil && c.UserAgent != "" { - return c.UserAgent - } - return c.defaultWorkBuddyUAFor(a) -} - -// billingUA 白名单类(billing/checkin/banner)出站 UA:单段 `WorkBuddy/` -// (官方 banner 显式覆写形态,不带 CLI 段)。默认生效(伪造官方桌面端指纹); -// 显式 client_name="SaaS" 才不设 UA(还原旧行为,Go 默认 UA)。 -func (c *Client) billingUA() string { - if c == nil || c.attributionClientName() == "SaaS" { - return "" - } - return "WorkBuddy/" + c.clientVersion() -} - -// resolveDeviceToken 解析本次请求的 X-Device-Token 取值。 -// 优先级:auth.Auth.DeviceToken(每号)> Client.DeviceToken(config 全局)> 文件兜底。 -// 三者皆空/读失败则返回空串(调用方不注入该头,优雅降级)。 -func (c *Client) resolveDeviceToken(a *auth.Auth) string { - if a != nil && a.DeviceToken != "" { - return a.DeviceToken - } - if c != nil && c.DeviceToken != "" { - return c.DeviceToken - } - if c != nil && c.DeviceTokenFile != "" { - return readDeviceTokenFile(c.DeviceTokenFile) - } - return "" -} - -// injectDeviceToken 在 req 注入 X-Device-Token 头(仅当取到非空 token)。 -func (c *Client) injectDeviceToken(req *http.Request, a *auth.Auth) { - if tok := c.resolveDeviceToken(a); tok != "" { - req.Header.Set("X-Device-Token", tok) - } -} - -// deriveAccountStableID 按 uid + 用途盐稳定派生 36 hex 设备/会话标识。 -// 跨重启稳定(固定盐 "wb2a:",不随进程换——这是与 session 包派生盐的本质差异: -// 那是会话键维度的进程级随机盐,重启换新;本函数是账号维度,必须跨重启恒定)、 -// 账号间互异(uid 不同则不同)、同 uid 同用途恒同值(幂等)。用 sha256 与项目 -// 既有派生(session/ids.go、cache_key.go)保持一致;截 36 hex 提供更长熵。 -// -// 两个用途: -// - purpose="machine" → X-Machine-ID(设备级,跨会话稳定) -// - purpose="session" → X-Session-ID(账号固定会话,跨重启稳定) -// -// 与 injectDeviceToken 的 X-Device-Token 并存不冲突:那是登录时上游签发的 -// 真实设备令牌(有则发,权威);本对头是「每账号一台固定虚拟设备」的稳定指纹, -// 防多号被上游按设备指纹缺失/漂移关联风控。两者是不同头族,官方桌面端都发。 -func deriveAccountStableID(uid, purpose string) string { - sum := sha256.Sum256([]byte("wb2a:" + purpose + ":" + uid)) - return hex.EncodeToString(sum[:18]) // 36 hex chars -} - -// injectAccountStableHeaders 在 req 注入 X-Machine-ID / X-Session-ID:按 uid 稳定 -// 派生,跨重启固定、账号间互异。uid 为空时不注入(匿名请求无设备标识,上游不要求)。 -func (c *Client) injectAccountStableHeaders(req *http.Request, a *auth.Auth) { - if a == nil || a.UID == "" { - return - } - req.Header.Set("X-Machine-ID", deriveAccountStableID(a.UID, "machine")) - req.Header.Set("X-Session-ID", deriveAccountStableID(a.UID, "session")) -} - -// CommonHeaders 设置所有 API 共享的请求头。 -func (c *Client) CommonHeaders(req *http.Request, a *auth.Auth) { - req.Header.Set("Content-Type", "application/json") - // Accept 非流式默认 application/json(D6:去掉宽松的 text/plain, */*)。 - // chat 流式路径在 ChatHeaders 覆盖为 event-stream。 - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Requested-With", "XMLHttpRequest") - origin := originRefererFor(a) - req.Header.Set("Origin", origin) - req.Header.Set("Referer", origin+"/") - req.Header.Set("User-Agent", c.userAgent(a)) - // X-CodeBuddy-Request: 1(官方客户端风控闸门头,所有 API 请求必带,D1)。 - req.Header.Set("X-CodeBuddy-Request", "1") - // Accept-Language 按 realm 切(D5):CN zh-CN,global en-US。官方客户端按账号域 - // 发对应语言标识,对齐避免上游风控按语言缺失误判。 - req.Header.Set("Accept-Language", acceptLanguageFor(a)) - // X-Machine-ID / X-Session-ID:按 uid 稳定派生的账号级设备头(见 - // injectAccountStableHeaders)。注入在 CommonHeaders——chat 经 ChatHeaders - // 叠加 CommonHeaders 天然继承;billing 域另行注入,全出站覆盖。 - c.injectAccountStableHeaders(req, a) -} - -// acceptLanguageFor 按账号 realm 返回 Accept-Language:global → en-US,cn → zh-CN。 -func acceptLanguageFor(a *auth.Auth) string { - if a != nil && a.IsGlobal() { - return "en-US" - } - return "zh-CN" -} - -// injectCodeBuddyRequest 在 req 注入 X-CodeBuddy-Request: 1。 -// billing 域未走 CommonHeaders,单独注入保证全出站覆盖(D1)。 -func (c *Client) injectCodeBuddyRequest(req *http.Request) { - req.Header.Set("X-CodeBuddy-Request", "1") -} - -// ChatMeta 一次 chat 出站的会话头族元数据(issue #35:后台按 X-Conversation-Request-ID -// 聚合请求,官方客户端一次 user send 内所有 tool call/重试/换号复用同一个 ID)。 -// handler 在轮转循环外生成 conversationID / conversationRequestID,循环内每次出站 -// 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。 -// messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。 -type ChatMeta struct { - ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造) - ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发 - TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID -} - -// ChatHeaders 在 common 之上加 chat 专属的账号头。 -// 缺省字段用 X-No-* 约定(与 CodeBuddy 官方 CLI 一致)。 -// clientIP 为本次请求的客户端 IP(按参数传递,不读共享字段——避免并发串扰); -// PassthroughIP=false 或 clientIP 为空时不注入 IP 头。 -// meta 为会话头族元数据(纯新增,不改既有头),见 injectConversationHeaders。 -func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, meta ChatMeta) { - c.CommonHeaders(req, a) - // chat 流式 Accept 覆盖 CommonHeaders 的非流式默认(D6)。 - req.Header.Set("Accept", "application/json, text/event-stream") - // AccessToken 加锁快照:keepalive 定时刷新会在 a.mu 内改写它,锁外直读构成数据竞争 - // (见 auth.AccessTokenValue 注释)。 - if at := a.AccessTokenValue(); at != "" { - req.Header.Set("Authorization", "Bearer "+at) - } else { - req.Header.Set("X-No-Authorization", "1") - } - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } else { - req.Header.Set("X-No-User-Id", "1") - } - // 安全红线:绝不在 chat 请求里携带 X-Refresh-Token。 - // 企业与域头按 realm 分发:CN 走既有分支(EnterpriseID/Domain 原样透传,缺省 X-No-*); - // global 账号由 injectGlobalChatHeaders 统一覆写为国际客户端形态 - // (X-No-Enterprise-Id=1 声明无企业 + X-Domain=www.workbuddy.ai 声明国际版域), - // 且不回退 X-Domain 到登录会话原值——对齐 intl 项目出站头。 - if a != nil && !a.IsGlobal() { - if a.EnterpriseID != "" { - req.Header.Set("X-Enterprise-Id", a.EnterpriseID) - } else { - req.Header.Set("X-No-Enterprise-Id", "1") - } - if d := a.DomainValue(); d != "" { - req.Header.Set("X-Domain", d) - } else { - req.Header.Set("X-No-Department-Info", "1") - } - } else { - c.injectGlobalChatHeaders(req, a) - } - // 用量归属头:默认伪造 WorkBuddy 桌面端指纹(client_name="SaaS" 还原旧行为)。 - c.injectAttribution(req) - // 客户端 IP 透传(仅 PassthroughIP=true 且本次请求带 IP)。 - c.injectClientIP(req, clientIP) - // 设备风控头:auth 每号 > config 全局 > 文件兜底;空则不注入。 - c.injectDeviceToken(req, a) - // 会话头族(对话/请求/消息/B3 链路),见 injectConversationHeaders。 - c.injectConversationHeaders(req, meta) -} - -// injectGlobalChatHeaders global 账号(无企业 ID)的 chat 专属声明头,对齐 intl 项目 -// (ANALYSIS-global-chat-solutions.md): -// - X-No-Enterprise-Id: 1 个人账号无企业 ID,显式声明(避免上游按缺省/可疑判定) -// - X-Domain: www.workbuddy.ai 显式声明国际版域(与 Origin/Referer 同域) -// -// 仅 global realm 注入;CN 账号走既有 X-No-Department-Info 等分支,零回归。 -func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) { - if a == nil || !a.IsGlobal() { - return - } - req.Header.Set("X-No-Enterprise-Id", "1") - req.Header.Set("X-Domain", "www.workbuddy.ai") -} - -// injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。 -// 头族分四层,各司其职: -// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发—— -// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。 -// - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的 -// 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。 -// - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。 -// - X-Root-Request-ID:= conversationRequestID(根请求追踪)。 -// - X-Trace-ID:入站透传或 = conversationRequestID。 -// - X-B3-TraceId / X-B3-SpanId / X-B3-Sampled:链路族。B3 规范只认 16/32 hex -// TraceId 与 16 hex SpanId;入站 conversationRequestID 非法时 TraceId 回落 -// messageID(恒 32 hex),SpanId 取 messageID[:16](每消息新)。 -func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) { - convReqID := meta.ConversationRequestID - if convReqID == "" { - // 零值 meta(直接调 ChatHeaders 的调用方/测试)也要保证聚合主键必发: - // 本级补一个 32 hex,调用方(handler)已生成稳定的不走到这里。 - convReqID = session.NewMessageID() - } - messageID := session.NewMessageID() - if meta.ConversationID != "" { - req.Header.Set("X-Conversation-ID", meta.ConversationID) - } - req.Header.Set("X-Conversation-Request-ID", convReqID) - req.Header.Set("X-Conversation-Message-ID", messageID) - req.Header.Set("X-Request-ID", messageID) - req.Header.Set("X-Root-Request-ID", convReqID) - traceID := meta.TraceID - if traceID == "" { - traceID = convReqID - } - req.Header.Set("X-Trace-ID", traceID) - b3Trace := convReqID - if !validTraceID(b3Trace) { - b3Trace = messageID // 非法 B3 TraceId → 回落恒 32 hex 的消息级 ID - } - req.Header.Set("X-B3-TraceId", b3Trace) - req.Header.Set("X-B3-SpanId", messageID[:16]) - req.Header.Set("X-B3-Sampled", "1") -} - -// validTraceID 判断 B3 TraceId 是否合法:16 或 32 位 hex(大小写均可)。 -// 官方客户端生成的 conversationRequestId 是 32 位 hex(UUID 去横线),入站透传值 -// 可能是任意形状(含横线/超长/非 hex),直接塞进 B3 头会破坏链路关联(issue #35)。 -func validTraceID(s string) bool { - if len(s) != 16 && len(s) != 32 { - return false - } - for i := 0; i < len(s); i++ { - ch := s[i] - if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { - return false - } - } - return true -} - -// attributionClientName 生效的用量归属名:ClientName 非空取之; -// 空默认 "WorkBuddy"(伪造官方桌面端指纹;显式配 "SaaS" 可还原旧行为)。 -func (c *Client) attributionClientName() string { - if c != nil && c.ClientName != "" { - return c.ClientName - } - return "WorkBuddy" -} - -// injectAttribution 注入用量归属头(X-Agent-Purpose / X-IDE-* / X-Product)。 -// 仅在 chat/completions 路径生效(ChatHeaders 调用)。 -// -// 默认(ClientName 空)即对齐官方 WorkBuddy 桌面端指纹:X-Agent-Purpose="conversation" -// + X-IDE-Name/Type/Product="WorkBuddy" + X-IDE-Version=client_version,上游用量归因 -// 不再出现 client/agentPurpose 为空的「网关特征」。显式 ClientName="SaaS" 还原旧行为 -// (仅 X-Product="SaaS",不设 X-IDE-*);配其他值则四头跟随该值。 -func (c *Client) injectAttribution(req *http.Request) { - name := c.attributionClientName() - if name == "SaaS" { - req.Header.Set("X-Product", "SaaS") - return - } - req.Header.Set("X-Agent-Purpose", "conversation") - req.Header.Set("X-IDE-Name", name) - req.Header.Set("X-IDE-Type", name) - req.Header.Set("X-IDE-Version", c.clientVersion()) - req.Header.Set("X-Product", name) -} - -// injectClientIP 在 PassthroughIP 开启时把 clientIP 参数透传给上游(三等价头)。 -func (c *Client) injectClientIP(req *http.Request, clientIP string) { - if c == nil || !c.PassthroughIP || clientIP == "" { - return - } - req.Header.Set("X-Forwarded-For", clientIP) - req.Header.Set("X-Real-IP", clientIP) - req.Header.Set("X-Client-IP", clientIP) -} - -// ExtractClientIP 从入站请求提取客户端 IP 首段(X-Forwarded-For 首段,回落 X-Real-IP)。 -func ExtractClientIP(r *http.Request) string { - if r == nil { - return "" - } - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - for i := 0; i < len(xff); i++ { - if xff[i] == ',' { - return strings.TrimSpace(xff[:i]) - } - } - return strings.TrimSpace(xff) - } - if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { - return real - } - return "" -} - -// BillingHeaders billing 接口请求头。 -// UA 语义:默认**不设置**(保持现状,Go 客户端自带默认 UA);仅当显式配置 -// c.UserAgent 非空才覆盖——避免默认路径给 billing 引入新的 UA 指纹。 -func (c *Client) BillingHeaders(req *http.Request, a *auth.Auth) { - // AccessToken 加锁快照(同 ChatHeaders:keepalive 可在 a.mu 内改写)。 - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - c.injectCodeBuddyRequest(req) - // Accept-Language 按 realm 切(D5,billing 域未走 CommonHeaders,单独注入)。 - req.Header.Set("Accept-Language", acceptLanguageFor(a)) - if c != nil && c.UserAgent != "" { - req.Header.Set("User-Agent", c.UserAgent) - } else if ua := c.billingUA(); ua != "" { - req.Header.Set("User-Agent", ua) - } - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - if a.EnterpriseID != "" { - req.Header.Set("X-Enterprise-Id", a.EnterpriseID) - req.Header.Set("X-Tenant-Id", a.EnterpriseID) - } - if d := a.DomainValue(); d != "" { - req.Header.Set("X-Domain", d) - } - // 设备风控头:billing 域(report/travel/balance/checkin)同样注入。 - c.injectDeviceToken(req, a) -} - -// RefreshHeaders refresh 端点专属头(X-Refresh-Token 只允许出现在这里)。 -func (c *Client) RefreshHeaders(req *http.Request, a *auth.Auth) { - c.CommonHeaders(req, a) - req.Header.Set("X-Refresh-Token", a.RefreshToken) - if a.EnterpriseID != "" { - req.Header.Set("X-Enterprise-Id", a.EnterpriseID) - } - // X-Auth-Refresh-Source 对齐官方客户端 refresh 渠道标识 "plugin"(D3)。 - req.Header.Set("X-Auth-Refresh-Source", "plugin") -} +// Package headers 构造三类上游请求头(common / chat / billing / refresh)。 +// 规则来自 docs/api-reference.md §0/§4/§6。 +package upstream + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/session" +) + +const ( + // defaultClientVersion 出站 WorkBuddy 客户端版本段(UA 的 `WorkBuddy/` 与 + // 白名单头组的 X-IDE-Version)。对齐官方 WorkBuddy Desktop 分发包版本(5.5.4)。 + // config upstream.client_version 可覆盖(空 = 内置默认)。 + defaultClientVersion = "5.5.4" + // defaultCliVersion 出站 UA 中 `CLI/` 段版本。对齐官方内置 CLI(2.137.1)。 + // config upstream.cli_version 可覆盖(空 = 内置默认)。 + defaultCliVersion = "2.137.1" + + originRefererCN = "https://www.codebuddy.cn" + originRefererGlobal = "https://www.workbuddy.ai" +) + +// originRefererFor 按账号 realm 返回 Origin/Referer 基础域: +// global → https://www.workbuddy.ai;cn(含全局开关未开)→ https://www.codebuddy.cn。 +func originRefererFor(a *auth.Auth) string { + if a != nil && a.IsGlobal() { + return originRefererGlobal + } + return originRefererCN +} + +// clientVersion 生效的 WorkBuddy 客户端版本:Client.ClientVersion 非空则取之, +// 否则内置默认 defaultClientVersion。 +func (c *Client) clientVersion() string { + if c != nil && c.ClientVersion != "" { + return c.ClientVersion + } + return defaultClientVersion +} + +// cliVersion 生效的 CLI 版本:Client.CliVersion 非空则取之,否则内置默认 defaultCliVersion。 +func (c *Client) cliVersion() string { + if c != nil && c.CliVersion != "" { + return c.CliVersion + } + return defaultCliVersion +} + +// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方桌面端 RestOperations 层形状): +// `WorkBuddy/ / CLI/`。 +// 平台段(第二段)品牌按 realm 切换——CN 用 applicationName 同值 `WorkBuddy`, +// global 用官方国际版 productName `WorkBuddy AI`(intl 项目逆向证据: +// `WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2`)。 +// global 账号送错平台段(`WorkBuddy` 非 `WorkBuddy AI`)可能触发上游 403 code 11140 +// "request illegal" 风控。官方无任何 UA 随机化,故默认确定性。 +func (c *Client) defaultWorkBuddyUAFor(a *auth.Auth) string { + platform := "WorkBuddy" + if a != nil && a.IsGlobal() { + platform = "WorkBuddy AI" + } + return "WorkBuddy/" + c.clientVersion() + " " + platform + "/" + c.clientVersion() + " CLI/" + c.cliVersion() +} + +// defaultWorkBuddyUA 返回 CN 形态的默认 UA(默认账号形态即 CN,零回归兼容既有调用/测试)。 +func (c *Client) defaultWorkBuddyUA() string { + return c.defaultWorkBuddyUAFor(nil) +} + +// userAgent 返回当前出站 UA(客户端出站路径:chat/refresh/FetchModels)。 +// 优先级:Client.UserAgent(config user_agent)显式覆盖 > 按账号 realm 的默认 WorkBuddy 三段式。 +// 显式覆盖兼容既有覆盖逻辑:用户配了即以用户值为准(自定义品牌/版本), +// 未配则走官方桌面端默认形态(global 换 `WorkBuddy AI` 平台段)。 +func (c *Client) userAgent(a *auth.Auth) string { + if c != nil && c.UserAgent != "" { + return c.UserAgent + } + return c.defaultWorkBuddyUAFor(a) +} + +// billingUA 白名单类(billing/checkin/banner)出站 UA:单段 `WorkBuddy/` +// (官方 banner 显式覆写形态,不带 CLI 段)。默认生效(伪造官方桌面端指纹); +// 显式 client_name="SaaS" 才不设 UA(还原旧行为,Go 默认 UA)。 +func (c *Client) billingUA() string { + if c == nil || c.attributionClientName() == "SaaS" { + return "" + } + return "WorkBuddy/" + c.clientVersion() +} + +// resolveDeviceToken 解析本次请求的 X-Device-Token 取值。 +// 优先级:auth.Auth.DeviceToken(每号)> Client.DeviceToken(config 全局)> 文件兜底。 +// 三者皆空/读失败则返回空串(调用方不注入该头,优雅降级)。 +func (c *Client) resolveDeviceToken(a *auth.Auth) string { + if a != nil && a.DeviceToken != "" { + return a.DeviceToken + } + if c != nil && c.DeviceToken != "" { + return c.DeviceToken + } + if c != nil && c.DeviceTokenFile != "" { + return readDeviceTokenFile(c.DeviceTokenFile) + } + return "" +} + +// injectDeviceToken 在 req 注入 X-Device-Token 头(仅当取到非空 token)。 +func (c *Client) injectDeviceToken(req *http.Request, a *auth.Auth) { + if tok := c.resolveDeviceToken(a); tok != "" { + req.Header.Set("X-Device-Token", tok) + } +} + +// deriveAccountStableID 按 uid + 用途盐稳定派生 36 hex 设备/会话标识。 +// 跨重启稳定(固定盐 "wb2a:",不随进程换——这是与 session 包派生盐的本质差异: +// 那是会话键维度的进程级随机盐,重启换新;本函数是账号维度,必须跨重启恒定)、 +// 账号间互异(uid 不同则不同)、同 uid 同用途恒同值(幂等)。用 sha256 与项目 +// 既有派生(session/ids.go、cache_key.go)保持一致;截 36 hex 提供更长熵。 +// +// 两个用途: +// - purpose="machine" → X-Machine-ID(设备级,跨会话稳定) +// - purpose="session" → X-Session-ID(账号固定会话,跨重启稳定) +// +// 与 injectDeviceToken 的 X-Device-Token 并存不冲突:那是登录时上游签发的 +// 真实设备令牌(有则发,权威);本对头是「每账号一台固定虚拟设备」的稳定指纹, +// 防多号被上游按设备指纹缺失/漂移关联风控。两者是不同头族,官方桌面端都发。 +func deriveAccountStableID(uid, purpose string) string { + sum := sha256.Sum256([]byte("wb2a:" + purpose + ":" + uid)) + return hex.EncodeToString(sum[:18]) // 36 hex chars +} + +// injectAccountStableHeaders 在 req 注入 X-Machine-ID / X-Session-ID:按 uid 稳定 +// 派生,跨重启固定、账号间互异。uid 为空时不注入(匿名请求无设备标识,上游不要求)。 +func (c *Client) injectAccountStableHeaders(req *http.Request, a *auth.Auth) { + if a == nil || a.UID == "" { + return + } + req.Header.Set("X-Machine-ID", deriveAccountStableID(a.UID, "machine")) + req.Header.Set("X-Session-ID", deriveAccountStableID(a.UID, "session")) +} + +// CommonHeaders 设置所有 API 共享的请求头。 +func (c *Client) CommonHeaders(req *http.Request, a *auth.Auth) { + req.Header.Set("Content-Type", "application/json") + // Accept 非流式默认 application/json(D6:去掉宽松的 text/plain, */*)。 + // chat 流式路径在 ChatHeaders 覆盖为 event-stream。 + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + origin := originRefererFor(a) + req.Header.Set("Origin", origin) + req.Header.Set("Referer", origin+"/") + req.Header.Set("User-Agent", c.userAgent(a)) + // X-CodeBuddy-Request: 1(官方客户端风控闸门头,所有 API 请求必带,D1)。 + req.Header.Set("X-CodeBuddy-Request", "1") + // Accept-Language 按 realm 切(D5):CN zh-CN,global en-US。官方客户端按账号域 + // 发对应语言标识,对齐避免上游风控按语言缺失误判。 + req.Header.Set("Accept-Language", acceptLanguageFor(a)) + // X-Machine-ID / X-Session-ID:按 uid 稳定派生的账号级设备头(见 + // injectAccountStableHeaders)。注入在 CommonHeaders——chat 经 ChatHeaders + // 叠加 CommonHeaders 天然继承;billing 域另行注入,全出站覆盖。 + c.injectAccountStableHeaders(req, a) +} + +// acceptLanguageFor 按账号 realm 返回 Accept-Language:global → en-US,cn → zh-CN。 +func acceptLanguageFor(a *auth.Auth) string { + if a != nil && a.IsGlobal() { + return "en-US" + } + return "zh-CN" +} + +// injectCodeBuddyRequest 在 req 注入 X-CodeBuddy-Request: 1。 +// billing 域未走 CommonHeaders,单独注入保证全出站覆盖(D1)。 +func (c *Client) injectCodeBuddyRequest(req *http.Request) { + req.Header.Set("X-CodeBuddy-Request", "1") +} + +// ChatMeta 一次 chat 出站的会话头族元数据(issue #35:后台按 X-Conversation-Request-ID +// 聚合请求,官方客户端一次 user send 内所有 tool call/重试/换号复用同一个 ID)。 +// handler 在轮转循环外生成 conversationID / conversationRequestID,循环内每次出站 +// 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。 +// messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。 +type ChatMeta struct { + ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造) + ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发 + TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID +} + +// ChatHeaders 在 common 之上加 chat 专属的账号头。 +// 缺省字段用 X-No-* 约定(与 CodeBuddy 官方 CLI 一致)。 +// clientIP 为本次请求的客户端 IP(按参数传递,不读共享字段——避免并发串扰); +// PassthroughIP=false 或 clientIP 为空时不注入 IP 头。 +// meta 为会话头族元数据(纯新增,不改既有头),见 injectConversationHeaders。 +func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, meta ChatMeta) { + c.CommonHeaders(req, a) + // chat 流式 Accept 覆盖 CommonHeaders 的非流式默认(D6)。 + req.Header.Set("Accept", "application/json, text/event-stream") + // AccessToken 加锁快照:keepalive 定时刷新会在 a.mu 内改写它,锁外直读构成数据竞争 + // (见 auth.AccessTokenValue 注释)。 + if at := a.AccessTokenValue(); at != "" { + req.Header.Set("Authorization", "Bearer "+at) + } else { + req.Header.Set("X-No-Authorization", "1") + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } else { + req.Header.Set("X-No-User-Id", "1") + } + // 安全红线:绝不在 chat 请求里携带 X-Refresh-Token。 + // 企业与域头按 realm 分发:CN 走既有分支(EnterpriseID/Domain 原样透传,缺省 X-No-*); + // global 账号由 injectGlobalChatHeaders 统一覆写为国际客户端形态 + // (X-No-Enterprise-Id=1 声明无企业 + X-Domain=www.workbuddy.ai 声明国际版域), + // 且不回退 X-Domain 到登录会话原值——对齐 intl 项目出站头。 + if a != nil && !a.IsGlobal() { + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + } else { + req.Header.Set("X-No-Enterprise-Id", "1") + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } else { + req.Header.Set("X-No-Department-Info", "1") + } + } else { + c.injectGlobalChatHeaders(req, a) + } + // 用量归属头:默认伪造 WorkBuddy 桌面端指纹(client_name="SaaS" 还原旧行为)。 + c.injectAttribution(req) + // 客户端 IP 透传(仅 PassthroughIP=true 且本次请求带 IP)。 + c.injectClientIP(req, clientIP) + // 设备风控头:auth 每号 > config 全局 > 文件兜底;空则不注入。 + c.injectDeviceToken(req, a) + // 会话头族(对话/请求/消息/B3 链路),见 injectConversationHeaders。 + c.injectConversationHeaders(req, meta) +} + +// injectGlobalChatHeaders global 账号(无企业 ID)的 chat 专属声明头,对齐 intl 项目 +// (ANALYSIS-global-chat-solutions.md): +// - X-No-Enterprise-Id: 1 个人账号无企业 ID,显式声明(避免上游按缺省/可疑判定) +// - X-Domain: www.workbuddy.ai 显式声明国际版域(与 Origin/Referer 同域) +// +// 仅 global realm 注入;CN 账号走既有 X-No-Department-Info 等分支,零回归。 +func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) { + if a == nil || !a.IsGlobal() { + return + } + req.Header.Set("X-No-Enterprise-Id", "1") + req.Header.Set("X-Domain", "www.workbuddy.ai") +} + +// injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。 +// 头族分四层,各司其职: +// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发—— +// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。 +// - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的 +// 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。 +// - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。 +// - X-Root-Request-ID:= conversationRequestID(根请求追踪)。 +// - X-Trace-ID:入站透传或 = conversationRequestID。 +// - X-B3-TraceId / X-B3-SpanId / X-B3-Sampled:链路族。B3 规范只认 16/32 hex +// TraceId 与 16 hex SpanId;入站 conversationRequestID 非法时 TraceId 回落 +// messageID(恒 32 hex),SpanId 取 messageID[:16](每消息新)。 +func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) { + convReqID := meta.ConversationRequestID + if convReqID == "" { + // 零值 meta(直接调 ChatHeaders 的调用方/测试)也要保证聚合主键必发: + // 本级补一个 32 hex,调用方(handler)已生成稳定的不走到这里。 + convReqID = session.NewMessageID() + } + messageID := session.NewMessageID() + if meta.ConversationID != "" { + req.Header.Set("X-Conversation-ID", meta.ConversationID) + } + req.Header.Set("X-Conversation-Request-ID", convReqID) + req.Header.Set("X-Conversation-Message-ID", messageID) + req.Header.Set("X-Request-ID", messageID) + req.Header.Set("X-Root-Request-ID", convReqID) + traceID := meta.TraceID + if traceID == "" { + traceID = convReqID + } + req.Header.Set("X-Trace-ID", traceID) + b3Trace := convReqID + if !validTraceID(b3Trace) { + b3Trace = messageID // 非法 B3 TraceId → 回落恒 32 hex 的消息级 ID + } + req.Header.Set("X-B3-TraceId", b3Trace) + req.Header.Set("X-B3-SpanId", messageID[:16]) + req.Header.Set("X-B3-Sampled", "1") +} + +// validTraceID 判断 B3 TraceId 是否合法:16 或 32 位 hex(大小写均可)。 +// 官方客户端生成的 conversationRequestId 是 32 位 hex(UUID 去横线),入站透传值 +// 可能是任意形状(含横线/超长/非 hex),直接塞进 B3 头会破坏链路关联(issue #35)。 +func validTraceID(s string) bool { + if len(s) != 16 && len(s) != 32 { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { + return false + } + } + return true +} + +// attributionClientName 生效的用量归属名:ClientName 非空取之; +// 空默认 "WorkBuddy"(伪造官方桌面端指纹;显式配 "SaaS" 可还原旧行为)。 +func (c *Client) attributionClientName() string { + if c != nil && c.ClientName != "" { + return c.ClientName + } + return "WorkBuddy" +} + +// injectAttribution 注入用量归属头(X-Agent-Purpose / X-IDE-* / X-Product)。 +// 仅在 chat/completions 路径生效(ChatHeaders 调用)。 +// +// 默认(ClientName 空)即对齐官方 WorkBuddy 桌面端指纹:X-Agent-Purpose="conversation" +// + X-IDE-Name/Type/Product="WorkBuddy" + X-IDE-Version=client_version,上游用量归因 +// 不再出现 client/agentPurpose 为空的「网关特征」。显式 ClientName="SaaS" 还原旧行为 +// (仅 X-Product="SaaS",不设 X-IDE-*);配其他值则四头跟随该值。 +func (c *Client) injectAttribution(req *http.Request) { + name := c.attributionClientName() + if name == "SaaS" { + req.Header.Set("X-Product", "SaaS") + return + } + req.Header.Set("X-Agent-Purpose", "conversation") + req.Header.Set("X-IDE-Name", name) + req.Header.Set("X-IDE-Type", name) + req.Header.Set("X-IDE-Version", c.clientVersion()) + req.Header.Set("X-Product", name) +} + +// injectClientIP 在 PassthroughIP 开启时把 clientIP 参数透传给上游(三等价头)。 +func (c *Client) injectClientIP(req *http.Request, clientIP string) { + if c == nil || !c.PassthroughIP || clientIP == "" { + return + } + req.Header.Set("X-Forwarded-For", clientIP) + req.Header.Set("X-Real-IP", clientIP) + req.Header.Set("X-Client-IP", clientIP) +} + +// ExtractClientIP 从入站请求提取客户端 IP 首段(X-Forwarded-For 首段,回落 X-Real-IP)。 +func ExtractClientIP(r *http.Request) string { + if r == nil { + return "" + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return strings.TrimSpace(xff[:i]) + } + } + return strings.TrimSpace(xff) + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + return real + } + return "" +} + +// BillingHeaders billing 接口请求头。 +// UA 语义:默认**不设置**(保持现状,Go 客户端自带默认 UA);仅当显式配置 +// c.UserAgent 非空才覆盖——避免默认路径给 billing 引入新的 UA 指纹。 +func (c *Client) BillingHeaders(req *http.Request, a *auth.Auth) { + // AccessToken 加锁快照(同 ChatHeaders:keepalive 可在 a.mu 内改写)。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + c.injectCodeBuddyRequest(req) + // Accept-Language 按 realm 切(D5,billing 域未走 CommonHeaders,单独注入)。 + req.Header.Set("Accept-Language", acceptLanguageFor(a)) + if c != nil && c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } else if ua := c.billingUA(); ua != "" { + req.Header.Set("User-Agent", ua) + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + req.Header.Set("X-Tenant-Id", a.EnterpriseID) + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } + // 设备风控头:billing 域(report/travel/balance/checkin)同样注入。 + c.injectDeviceToken(req, a) +} + +// RefreshHeaders refresh 端点专属头(X-Refresh-Token 只允许出现在这里)。 +func (c *Client) RefreshHeaders(req *http.Request, a *auth.Auth) { + c.CommonHeaders(req, a) + req.Header.Set("X-Refresh-Token", a.RefreshToken) + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + } + // X-Auth-Refresh-Source 对齐官方客户端 refresh 渠道标识 "plugin"(D3)。 + req.Header.Set("X-Auth-Refresh-Source", "plugin") +} diff --git a/internal/upstream/hint.go b/internal/upstream/hint.go index 55bfa248032f30b95501d7aa7b4a1c290bebb4ab..4bcab9adbf8eafa65db220335ad017e354dbf1f0 100644 --- a/internal/upstream/hint.go +++ b/internal/upstream/hint.go @@ -1,149 +1,149 @@ -// hint.go 网关错误附加说明字段(error.gateway_hint)的单一事实来源。 -// -// 纪律(任务书 gateway-hint): -// - error.message 永远是上游 body 原文透传(5755fe3 透传原则不动); -// gateway_hint 只做与 message **并列**的网关视角补充说明,绝不替换/包装 message。 -// - 文案集中在本文件(一张 Kind 表 + 11133/11135 形态判定),按 ErrKind + 上下文 -// (请求带图/模型目录能力)映射,不散落 handler 的 if-else。 -// - 未覆盖形态返回空串 → 响应不带该字段(不编造)。 -// - hint 措辞是英文(错误响应面向客户端工具链,英文是通用口径)。 -package upstream - -import ( - "encoding/json" - "net/http" - "strings" -) - -// GatewayHint 按错误形态返回网关视角的补充说明(error.gateway_hint 字段值)。 -// msg 是上游错误 body 原文(或 SSE error 帧 payload);kind 是权威分类 -// (Classify / *Error 信封)。返回空串 = 未覆盖形态,调用方不带字段。 -// -// ctx 携带判定 hint 所需的请求侧上下文(零值合法,信息缺失时相关形态退为中性 -// hint 或无 hint): -// - HasImage:请求体是否携带 image_url part(11133 的「模型不支持图片」指向前提); -// - Model / ModelInCatalog / ModelSupportsImages:模型目录对该模型的 -// supports_images 声明(目录未收录 → 不做「不支持」判定,防查不到误判成不支持)。 -// -// 判定次序:11133/11135 上游业务码**先于** Kind 表——实测这两族归 ErrClient/ -// ErrBadParams 皆有可能(Classify 词表不含 11133),hint 层自带判定(hint 是补充 -// 说明非权威分类,误判代价只是多一条中性补充说明);其余走 Kind 一对一映射。 -func GatewayHint(kind ErrKind, msg string, ctx HintContext) string { - // 11133 model_param_invalid 家族(图片回归实测:不支持图片的模型传图,或任意 - // 参数被模型供应商拒绝)。只有请求确实带图、且目录能对该模型做出「不支持图片」 - // 的判定时才给「换模型」指向,否则退中性参数形态(可能是任意参数问题,不点名图片)。 - if isModelParamInvalid(msg) { - if ctx.HasImage && ctx.ModelInCatalog && !ctx.ModelSupportsImages { - return "model " + ctx.Model + " does not support images; pick one with supports_images=true from /v1/models" - } - return "request parameters were rejected by the model provider; check message format and model capabilities" - } - // 11135 invalid_image_data 家族(图片数据无效,Discussion #77 实测形态)。 - if isInvalidImageData(msg) { - return "image data rejected by upstream; use a real/valid image, may need a new conversation" - } - switch kind { - case ErrPromptTooLong: - return "request context exceeds the model's limit; reduce history/message size" - case ErrImageInvalid: - return "image request was rejected by upstream; check image_url format and image data" - case ErrWafBlock: - // 账号级 WAF 403 与 IP 级 fail-fast 同 hint:两者对客户端的动作一致 - // (等待窗口过去再试,换号/立刻重试无意义)。 - return "upstream WAF blocked the gateway; retry after the block window" - case ErrSoftRate: - return "rate limited by upstream; retry after reset" - case ErrAccountFault: - return "account-level fault at upstream (auth/quota state); the gateway will rotate or disable this account" - case ErrSessionDead: - return "account session expired at upstream; the account is disabled until re-login" - case ErrHardCredit: - return "account credits exhausted at upstream; waiting for daily check-in to restore" - case ErrModelBlocked: - return "upstream has no such model on this backend; switch model or retry on another account" - case ErrContentBlocked: - // 措辞不含 "upstream":content_blocked 响应有不含上游字样的既有口径 - // (handler_test 的泄漏守卫),hint 遵守同一口径。 - return "request content was rejected by content policy; adjust the prompt and retry" - default: - // ErrNone/ErrNotFound/ErrServer/ErrBadParams/ErrClient 等未覆盖形态:无 hint。 - return "" - } -} - -// HintContext gateway_hint 判定所需的请求侧上下文(handler 侧组装,见 -// Handler.hintContext)。零值合法。 -type HintContext struct { - Model string // 请求裸模型名(可空) - HasImage bool // 请求体是否携带 image_url part - ModelSupportsImages bool // 模型目录 supports_images 声明(仅 ModelInCatalog 时有意义) - ModelInCatalog bool // 模型目录是否收录该模型(「不支持」判定的前提) -} - -// noHealthyHint 本地调度类错误(池中无健康号可用/传输层抖动,无上游原文可透传) -// 的固定 hint。不进 GatewayHint:它没有 ErrKind,是网关自己的调度事实。 -const noHealthyHint = "no healthy account available in pool; check /status or retry later" - -// NoHealthyAccountHint 本地调度错误的 gateway_hint(与 no_healthy_account code 配套)。 -func NoHealthyAccountHint() string { return noHealthyHint } - -// FrameHintFunc 返回 SSE error 帧的 gateway_hint 判定函数(Stream 的可选参数)。 -// ctxFn 惰性求值:仅在实际撞到 error 帧才调用(正常流零开销,模型目录查询 -// 不会为每个成功请求触发)。 -func FrameHintFunc(ctxFn func() HintContext) func(string) string { - return func(payload string) string { - if payload == "" || payload == "[DONE]" { - return "" - } - return GatewayHint(FrameKind(payload), payload, ctxFn()) - } -} - -// FrameKind 从 SSE error 帧 payload 判定 ErrKind:6004 模型级限流的流式形态 -// (IsModelRateLimit 对帧 JSON 直接命中)优先;其余取帧内 error.message 走 -// Classify(请求级 400 口径)。判不出 → ErrNone(无 hint)。 -func FrameKind(payload string) ErrKind { - if IsModelRateLimit(payload) { - return ErrSoftRate - } - var f struct { - Error struct { - Message string `json:"message"` - } `json:"error"` - } - if json.Unmarshal([]byte(payload), &f) != nil || f.Error.Message == "" { - return ErrNone - } - return Classify(http.StatusBadRequest, f.Error.Message) -} - -// isModelParamInvalid 上游 11133 body 判定(code 11133 / extError.code= -// model_param_invalid / msg 文案家族)。子串口径:hint 是补充说明非权威分类, -// 宁宽勿漏。 -func isModelParamInvalid(body string) bool { - lower := strings.ToLower(body) - return codeMarker(lower, "11133") || - strings.Contains(lower, "model_param_invalid") || - strings.Contains(lower, "invalid request parameters") || - strings.Contains(lower, "request parameters do not meet the current model requirements") -} - -// isInvalidImageData 上游 11135 body 判定(code 11135 / invalid_image_data / -// "replace the image" msg 家族)。 -func isInvalidImageData(body string) bool { - lower := strings.ToLower(body) - return codeMarker(lower, "11135") || - strings.Contains(lower, "invalid_image_data") || - strings.Contains(lower, "replace the image") -} - -// codeMarker JSON code 字段命中(`"code":N` / `"code": N` / `"code":"N"` 形态, -// 与 IsModelBlocked 的 code 判定同容差口径)。lower 须为小写 body。 -func codeMarker(lower, code string) bool { - for _, v := range []string{`"code":` + code, `"code": ` + code, `"code":"` + code + `"`, `"code": "` + code + `"`, `"code":" ` + code + `"`, `"code": '` + code + `'`} { - if strings.Contains(lower, v) { - return true - } - } - return false -} +// hint.go 网关错误附加说明字段(error.gateway_hint)的单一事实来源。 +// +// 纪律(任务书 gateway-hint): +// - error.message 永远是上游 body 原文透传(5755fe3 透传原则不动); +// gateway_hint 只做与 message **并列**的网关视角补充说明,绝不替换/包装 message。 +// - 文案集中在本文件(一张 Kind 表 + 11133/11135 形态判定),按 ErrKind + 上下文 +// (请求带图/模型目录能力)映射,不散落 handler 的 if-else。 +// - 未覆盖形态返回空串 → 响应不带该字段(不编造)。 +// - hint 措辞是英文(错误响应面向客户端工具链,英文是通用口径)。 +package upstream + +import ( + "encoding/json" + "net/http" + "strings" +) + +// GatewayHint 按错误形态返回网关视角的补充说明(error.gateway_hint 字段值)。 +// msg 是上游错误 body 原文(或 SSE error 帧 payload);kind 是权威分类 +// (Classify / *Error 信封)。返回空串 = 未覆盖形态,调用方不带字段。 +// +// ctx 携带判定 hint 所需的请求侧上下文(零值合法,信息缺失时相关形态退为中性 +// hint 或无 hint): +// - HasImage:请求体是否携带 image_url part(11133 的「模型不支持图片」指向前提); +// - Model / ModelInCatalog / ModelSupportsImages:模型目录对该模型的 +// supports_images 声明(目录未收录 → 不做「不支持」判定,防查不到误判成不支持)。 +// +// 判定次序:11133/11135 上游业务码**先于** Kind 表——实测这两族归 ErrClient/ +// ErrBadParams 皆有可能(Classify 词表不含 11133),hint 层自带判定(hint 是补充 +// 说明非权威分类,误判代价只是多一条中性补充说明);其余走 Kind 一对一映射。 +func GatewayHint(kind ErrKind, msg string, ctx HintContext) string { + // 11133 model_param_invalid 家族(图片回归实测:不支持图片的模型传图,或任意 + // 参数被模型供应商拒绝)。只有请求确实带图、且目录能对该模型做出「不支持图片」 + // 的判定时才给「换模型」指向,否则退中性参数形态(可能是任意参数问题,不点名图片)。 + if isModelParamInvalid(msg) { + if ctx.HasImage && ctx.ModelInCatalog && !ctx.ModelSupportsImages { + return "model " + ctx.Model + " does not support images; pick one with supports_images=true from /v1/models" + } + return "request parameters were rejected by the model provider; check message format and model capabilities" + } + // 11135 invalid_image_data 家族(图片数据无效,Discussion #77 实测形态)。 + if isInvalidImageData(msg) { + return "image data rejected by upstream; use a real/valid image, may need a new conversation" + } + switch kind { + case ErrPromptTooLong: + return "request context exceeds the model's limit; reduce history/message size" + case ErrImageInvalid: + return "image request was rejected by upstream; check image_url format and image data" + case ErrWafBlock: + // 账号级 WAF 403 与 IP 级 fail-fast 同 hint:两者对客户端的动作一致 + // (等待窗口过去再试,换号/立刻重试无意义)。 + return "upstream WAF blocked the gateway; retry after the block window" + case ErrSoftRate: + return "rate limited by upstream; retry after reset" + case ErrAccountFault: + return "account-level fault at upstream (auth/quota state); the gateway will rotate or disable this account" + case ErrSessionDead: + return "account session expired at upstream; the account is disabled until re-login" + case ErrHardCredit: + return "account credits exhausted at upstream; waiting for daily check-in to restore" + case ErrModelBlocked: + return "upstream has no such model on this backend; switch model or retry on another account" + case ErrContentBlocked: + // 措辞不含 "upstream":content_blocked 响应有不含上游字样的既有口径 + // (handler_test 的泄漏守卫),hint 遵守同一口径。 + return "request content was rejected by content policy; adjust the prompt and retry" + default: + // ErrNone/ErrNotFound/ErrServer/ErrBadParams/ErrClient 等未覆盖形态:无 hint。 + return "" + } +} + +// HintContext gateway_hint 判定所需的请求侧上下文(handler 侧组装,见 +// Handler.hintContext)。零值合法。 +type HintContext struct { + Model string // 请求裸模型名(可空) + HasImage bool // 请求体是否携带 image_url part + ModelSupportsImages bool // 模型目录 supports_images 声明(仅 ModelInCatalog 时有意义) + ModelInCatalog bool // 模型目录是否收录该模型(「不支持」判定的前提) +} + +// noHealthyHint 本地调度类错误(池中无健康号可用/传输层抖动,无上游原文可透传) +// 的固定 hint。不进 GatewayHint:它没有 ErrKind,是网关自己的调度事实。 +const noHealthyHint = "no healthy account available in pool; check /status or retry later" + +// NoHealthyAccountHint 本地调度错误的 gateway_hint(与 no_healthy_account code 配套)。 +func NoHealthyAccountHint() string { return noHealthyHint } + +// FrameHintFunc 返回 SSE error 帧的 gateway_hint 判定函数(Stream 的可选参数)。 +// ctxFn 惰性求值:仅在实际撞到 error 帧才调用(正常流零开销,模型目录查询 +// 不会为每个成功请求触发)。 +func FrameHintFunc(ctxFn func() HintContext) func(string) string { + return func(payload string) string { + if payload == "" || payload == "[DONE]" { + return "" + } + return GatewayHint(FrameKind(payload), payload, ctxFn()) + } +} + +// FrameKind 从 SSE error 帧 payload 判定 ErrKind:6004 模型级限流的流式形态 +// (IsModelRateLimit 对帧 JSON 直接命中)优先;其余取帧内 error.message 走 +// Classify(请求级 400 口径)。判不出 → ErrNone(无 hint)。 +func FrameKind(payload string) ErrKind { + if IsModelRateLimit(payload) { + return ErrSoftRate + } + var f struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(payload), &f) != nil || f.Error.Message == "" { + return ErrNone + } + return Classify(http.StatusBadRequest, f.Error.Message) +} + +// isModelParamInvalid 上游 11133 body 判定(code 11133 / extError.code= +// model_param_invalid / msg 文案家族)。子串口径:hint 是补充说明非权威分类, +// 宁宽勿漏。 +func isModelParamInvalid(body string) bool { + lower := strings.ToLower(body) + return codeMarker(lower, "11133") || + strings.Contains(lower, "model_param_invalid") || + strings.Contains(lower, "invalid request parameters") || + strings.Contains(lower, "request parameters do not meet the current model requirements") +} + +// isInvalidImageData 上游 11135 body 判定(code 11135 / invalid_image_data / +// "replace the image" msg 家族)。 +func isInvalidImageData(body string) bool { + lower := strings.ToLower(body) + return codeMarker(lower, "11135") || + strings.Contains(lower, "invalid_image_data") || + strings.Contains(lower, "replace the image") +} + +// codeMarker JSON code 字段命中(`"code":N` / `"code": N` / `"code":"N"` 形态, +// 与 IsModelBlocked 的 code 判定同容差口径)。lower 须为小写 body。 +func codeMarker(lower, code string) bool { + for _, v := range []string{`"code":` + code, `"code": ` + code, `"code":"` + code + `"`, `"code": "` + code + `"`, `"code":" ` + code + `"`, `"code": '` + code + `'`} { + if strings.Contains(lower, v) { + return true + } + } + return false +} diff --git a/internal/upstream/idle.go b/internal/upstream/idle.go index ae3283b48b9da31237d6b1bd8ac4e5e7916de43e..bed254563f53a6ce292d98ab1903985dec7ad610 100644 --- a/internal/upstream/idle.go +++ b/internal/upstream/idle.go @@ -1,87 +1,87 @@ -// idle.go 聊天 SSE 流中空闲监控:活跃吐数据续命不掐,静默超过阈值才断流(释放租约)。 -package upstream - -import ( - "context" - "io" - "sync" - "time" -) - -// idleMonitoringBody 包在聊天 SSE body 外层: -// 每次读到底层数据(n>0)就刷新 lastRead;后台 goroutine 周期检查, -// 静默超过 idle 就 cancel 请求 context,中断阻塞中的 Read。 -type idleMonitoringBody struct { - rc io.ReadCloser - mu sync.Mutex - lastRead time.Time - stopOnce sync.Once - stopCh chan struct{} - cancel context.CancelFunc -} - -func (b *idleMonitoringBody) Read(p []byte) (int, error) { - n, err := b.rc.Read(p) - if n > 0 { - b.mu.Lock() - b.lastRead = time.Now() - b.mu.Unlock() - } - return n, err -} - -// Close 停掉后台 goroutine、取消请求 context、关闭底流,保证无泄漏。 -func (b *idleMonitoringBody) Close() error { - b.stopOnce.Do(func() { close(b.stopCh) }) - b.cancel() - return b.rc.Close() -} - -func (b *idleMonitoringBody) idleFor() time.Duration { - b.mu.Lock() - defer b.mu.Unlock() - return time.Since(b.lastRead) -} - -// monitorBody 若 idle<=0 直接返回原底流(禁用空闲监控); -// 否则包上流中空闲监控。计时从返回 body 之后开始——首字节阶段由 -// Transport.ResponseHeaderTimeout 管,这里不抢跑。 -func monitorBody(rc io.ReadCloser, idle time.Duration, cancel context.CancelFunc) io.ReadCloser { - if idle <= 0 { - return rc - } - b := &idleMonitoringBody{ - rc: rc, - lastRead: time.Now(), - stopCh: make(chan struct{}), - cancel: cancel, - } - go func() { - t := time.NewTicker(idleTick(idle)) - defer t.Stop() - for { - select { - case <-b.stopCh: - return - case <-t.C: - if b.idleFor() > idle { - cancel() - return - } - } - } - }() - return b -} - -// idleTick 返回监控周期:idle/4,钳在 [10ms, 1s]。小 idle 也能快速发现,大小值避免空转。 -func idleTick(idle time.Duration) time.Duration { - d := idle / 4 - if d > time.Second { - d = time.Second - } - if d < 10*time.Millisecond { - d = 10 * time.Millisecond - } - return d -} +// idle.go 聊天 SSE 流中空闲监控:活跃吐数据续命不掐,静默超过阈值才断流(释放租约)。 +package upstream + +import ( + "context" + "io" + "sync" + "time" +) + +// idleMonitoringBody 包在聊天 SSE body 外层: +// 每次读到底层数据(n>0)就刷新 lastRead;后台 goroutine 周期检查, +// 静默超过 idle 就 cancel 请求 context,中断阻塞中的 Read。 +type idleMonitoringBody struct { + rc io.ReadCloser + mu sync.Mutex + lastRead time.Time + stopOnce sync.Once + stopCh chan struct{} + cancel context.CancelFunc +} + +func (b *idleMonitoringBody) Read(p []byte) (int, error) { + n, err := b.rc.Read(p) + if n > 0 { + b.mu.Lock() + b.lastRead = time.Now() + b.mu.Unlock() + } + return n, err +} + +// Close 停掉后台 goroutine、取消请求 context、关闭底流,保证无泄漏。 +func (b *idleMonitoringBody) Close() error { + b.stopOnce.Do(func() { close(b.stopCh) }) + b.cancel() + return b.rc.Close() +} + +func (b *idleMonitoringBody) idleFor() time.Duration { + b.mu.Lock() + defer b.mu.Unlock() + return time.Since(b.lastRead) +} + +// monitorBody 若 idle<=0 直接返回原底流(禁用空闲监控); +// 否则包上流中空闲监控。计时从返回 body 之后开始——首字节阶段由 +// Transport.ResponseHeaderTimeout 管,这里不抢跑。 +func monitorBody(rc io.ReadCloser, idle time.Duration, cancel context.CancelFunc) io.ReadCloser { + if idle <= 0 { + return rc + } + b := &idleMonitoringBody{ + rc: rc, + lastRead: time.Now(), + stopCh: make(chan struct{}), + cancel: cancel, + } + go func() { + t := time.NewTicker(idleTick(idle)) + defer t.Stop() + for { + select { + case <-b.stopCh: + return + case <-t.C: + if b.idleFor() > idle { + cancel() + return + } + } + } + }() + return b +} + +// idleTick 返回监控周期:idle/4,钳在 [10ms, 1s]。小 idle 也能快速发现,大小值避免空转。 +func idleTick(idle time.Duration) time.Duration { + d := idle / 4 + if d > time.Second { + d = time.Second + } + if d < 10*time.Millisecond { + d = 10 * time.Millisecond + } + return d +} diff --git a/internal/upstream/idle_test.go b/internal/upstream/idle_test.go index e3aab5f6223dd78d8b3badb3851451a1501680ed..35250f6910b7b0126250b90b7ef9c402f07551b5 100644 --- a/internal/upstream/idle_test.go +++ b/internal/upstream/idle_test.go @@ -1,115 +1,115 @@ -package upstream - -import ( - "context" - "io" - "testing" - "time" -) - -// TestMonitorBodyIdleCutoff 静默超过 idle(配小值)→ Read 返回错误(context canceled)。 -func TestMonitorBodyIdleCutoff(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // 模拟真实 resp.Body:阻塞直到请求 context 被 cancel 才返回 error。 - body := monitorBody(&ctxBoundReader{ctx: ctx}, 50*time.Millisecond, cancel) - defer body.Close() - if _, err := body.Read(make([]byte, 16)); err == nil { - t.Fatal("expect read error after idle cutoff") - } -} - -// TestMonitorBodyRenewsOnActivity 持续活跃(周期吐数据,总时长 > idle)→ 流不被掐。 -func TestMonitorBodyRenewsOnActivity(t *testing.T) { - _, cancel := context.WithCancel(context.Background()) - defer cancel() - // 每 30ms 吐 1 字节;idle=80ms。总读 10 字节耗时 300ms > idle: - // 若空闲续命失效(绝对 deadline 语义)早在 80ms 就被掐断。 - body := monitorBody(nopCloserBody{Reader: periodicReader{period: 30 * time.Millisecond}}, 80*time.Millisecond, cancel) - defer body.Close() - buf := make([]byte, 1) - for i := 0; i < 10; i++ { - if _, err := io.ReadFull(body, buf); err != nil { - t.Fatalf("read %d: %v (active stream must not be cut)", i, err) - } - } -} - -// TestMonitorBodyDisabledWhenIdleZero idle<=0 直接返回原底流。 -func TestMonitorBodyDisabledWhenIdleZero(t *testing.T) { - _, cancel := context.WithCancel(context.Background()) - defer cancel() - rc := nopCloserBody{Reader: &oneByteReader{}} - out := monitorBody(rc, 0, cancel) - if _, ok := out.(nopCloserBody); !ok { - t.Fatalf("idle<=0 should return underlying body verbatim, got %T", out) - } - buf := make([]byte, 1) - if _, err := out.Read(buf); err != nil || buf[0] != 'a' { - t.Fatalf("read=%q err=%v", buf, err) - } -} - -// TestMonitorBodyCloseStopsGoroutine Close 停掉 goroutine,无泄漏(等效监控循环 + 短周期)。 -func TestMonitorBodyCloseStopsGoroutine(t *testing.T) { - m := &idleMonitoringBody{ - rc: &ctxBoundReader{ctx: context.Background()}, - lastRead: time.Now(), - stopCh: make(chan struct{}), - cancel: func() {}, - } - stopped := make(chan struct{}) - go func() { - tk := time.NewTicker(idleTick(40 * time.Millisecond)) - defer tk.Stop() - for { - select { - case <-m.stopCh: - close(stopped) - return - case <-tk.C: - } - } - }() - m.Close() - select { - case <-stopped: - case <-time.After(time.Second): - t.Fatal("goroutine not stopped after Close") - } -} - -// —— helpers —— - -// ctxBoundReader 模拟 net/http resp.Body:Read 阻塞在 ctx.Done() 上,ctx cancel 后返回错误。 -type ctxBoundReader struct{ ctx context.Context } - -func (r *ctxBoundReader) Read([]byte) (int, error) { - <-r.ctx.Done() - return 0, r.ctx.Err() -} - -func (r *ctxBoundReader) Close() error { return nil } - -type periodicReader struct{ period time.Duration } - -func (r periodicReader) Read(p []byte) (int, error) { - time.Sleep(r.period) - p[0] = 'x' - return 1, nil -} - -type oneByteReader struct{ done bool } - -func (r *oneByteReader) Read(p []byte) (int, error) { - if r.done { - return 0, io.EOF - } - r.done = true - p[0] = 'a' - return 1, nil -} - -type nopCloserBody struct{ io.Reader } - -func (nopCloserBody) Close() error { return nil } +package upstream + +import ( + "context" + "io" + "testing" + "time" +) + +// TestMonitorBodyIdleCutoff 静默超过 idle(配小值)→ Read 返回错误(context canceled)。 +func TestMonitorBodyIdleCutoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // 模拟真实 resp.Body:阻塞直到请求 context 被 cancel 才返回 error。 + body := monitorBody(&ctxBoundReader{ctx: ctx}, 50*time.Millisecond, cancel) + defer body.Close() + if _, err := body.Read(make([]byte, 16)); err == nil { + t.Fatal("expect read error after idle cutoff") + } +} + +// TestMonitorBodyRenewsOnActivity 持续活跃(周期吐数据,总时长 > idle)→ 流不被掐。 +func TestMonitorBodyRenewsOnActivity(t *testing.T) { + _, cancel := context.WithCancel(context.Background()) + defer cancel() + // 每 30ms 吐 1 字节;idle=80ms。总读 10 字节耗时 300ms > idle: + // 若空闲续命失效(绝对 deadline 语义)早在 80ms 就被掐断。 + body := monitorBody(nopCloserBody{Reader: periodicReader{period: 30 * time.Millisecond}}, 80*time.Millisecond, cancel) + defer body.Close() + buf := make([]byte, 1) + for i := 0; i < 10; i++ { + if _, err := io.ReadFull(body, buf); err != nil { + t.Fatalf("read %d: %v (active stream must not be cut)", i, err) + } + } +} + +// TestMonitorBodyDisabledWhenIdleZero idle<=0 直接返回原底流。 +func TestMonitorBodyDisabledWhenIdleZero(t *testing.T) { + _, cancel := context.WithCancel(context.Background()) + defer cancel() + rc := nopCloserBody{Reader: &oneByteReader{}} + out := monitorBody(rc, 0, cancel) + if _, ok := out.(nopCloserBody); !ok { + t.Fatalf("idle<=0 should return underlying body verbatim, got %T", out) + } + buf := make([]byte, 1) + if _, err := out.Read(buf); err != nil || buf[0] != 'a' { + t.Fatalf("read=%q err=%v", buf, err) + } +} + +// TestMonitorBodyCloseStopsGoroutine Close 停掉 goroutine,无泄漏(等效监控循环 + 短周期)。 +func TestMonitorBodyCloseStopsGoroutine(t *testing.T) { + m := &idleMonitoringBody{ + rc: &ctxBoundReader{ctx: context.Background()}, + lastRead: time.Now(), + stopCh: make(chan struct{}), + cancel: func() {}, + } + stopped := make(chan struct{}) + go func() { + tk := time.NewTicker(idleTick(40 * time.Millisecond)) + defer tk.Stop() + for { + select { + case <-m.stopCh: + close(stopped) + return + case <-tk.C: + } + } + }() + m.Close() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("goroutine not stopped after Close") + } +} + +// —— helpers —— + +// ctxBoundReader 模拟 net/http resp.Body:Read 阻塞在 ctx.Done() 上,ctx cancel 后返回错误。 +type ctxBoundReader struct{ ctx context.Context } + +func (r *ctxBoundReader) Read([]byte) (int, error) { + <-r.ctx.Done() + return 0, r.ctx.Err() +} + +func (r *ctxBoundReader) Close() error { return nil } + +type periodicReader struct{ period time.Duration } + +func (r periodicReader) Read(p []byte) (int, error) { + time.Sleep(r.period) + p[0] = 'x' + return 1, nil +} + +type oneByteReader struct{ done bool } + +func (r *oneByteReader) Read(p []byte) (int, error) { + if r.done { + return 0, io.EOF + } + r.done = true + p[0] = 'a' + return 1, nil +} + +type nopCloserBody struct{ io.Reader } + +func (nopCloserBody) Close() error { return nil } diff --git a/internal/upstream/model.json b/internal/upstream/model.json index 68e8c3b54b4d9cbb1d92b81fe8341afb50d1a652..3886fe00c746c28bbd5aea882fa2a4c010930a7f 100644 --- a/internal/upstream/model.json +++ b/internal/upstream/model.json @@ -1,135 +1,135 @@ -{ - "glm-5.2": { - "context_length": 1000000, - "max_output_tokens": 131072, - "source": "seed" - }, - "glm-5.1": { - "context_length": 200000, - "max_output_tokens": 131072, - "source": "seed" - }, - "glm-5.3": { - "context_length": 1000000, - "max_output_tokens": 131072, - "source": "seed" - }, - "glm-5.3-flash": { - "context_length": 1000000, - "max_output_tokens": 131072, - "source": "seed" - }, - "glm-5v-turbo": { - "context_length": 200000, - "max_output_tokens": 131072, - "source": "seed" - }, - "kimi-k2.7": { - "context_length": 256000, - "max_output_tokens": 65536, - "source": "seed" - }, - "kimi-k2.6": { - "context_length": 256000, - "max_output_tokens": 262144, - "source": "seed" - }, - "kimi-k2.5": { - "context_length": 164000, - "max_output_tokens": 262144, - "source": "seed" - }, - "kimi-k3": { - "context_length": 1048576, - "max_output_tokens": 131072, - "source": "seed" - }, - "kimi-k2.8-preview": { - "context_length": 1048576, - "source": "seed" - }, - "minimax-m3": { - "context_length": 512000, - "max_output_tokens": 512000, - "source": "seed" - }, - "hy3": { - "context_length": 192000, - "max_output_tokens": 64000, - "source": "seed" - }, - "hy3-preview": { - "context_length": 262144, - "max_output_tokens": 64000, - "source": "seed" - }, - "hy4-preview": { - "context_length": 1000000, - "max_output_tokens": 64000, - "source": "seed" - }, - "hy4-preview-x": { - "context_length": 1000000, - "max_output_tokens": 64000, - "source": "seed" - }, - "deepseek-v4-pro": { - "context_length": 1000000, - "max_output_tokens": 384000, - "source": "seed" - }, - "deepseek-v4-flash": { - "context_length": 1000000, - "max_output_tokens": 384000, - "source": "seed" - }, - "deepseek-v4.1-flash": { - "context_length": 1000000, - "max_output_tokens": 384000, - "source": "seed" - }, - "gpt-6-astra": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.6-sol": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.6-terra": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.6-luna": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.5": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.4": { - "context_length": 1050000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gpt-5.3-codex": { - "context_length": 400000, - "max_output_tokens": 128000, - "source": "seed" - }, - "gemini-3.5-flash": { - "context_length": 1048576, - "max_output_tokens": 65536, - "source": "seed" - }, - "auto": { - "context_length": 168000, - "source": "seed" - } -} +{ + "glm-5.2": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.1": { + "context_length": 200000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.3": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5.3-flash": { + "context_length": 1000000, + "max_output_tokens": 131072, + "source": "seed" + }, + "glm-5v-turbo": { + "context_length": 200000, + "max_output_tokens": 131072, + "source": "seed" + }, + "kimi-k2.7": { + "context_length": 256000, + "max_output_tokens": 65536, + "source": "seed" + }, + "kimi-k2.6": { + "context_length": 256000, + "max_output_tokens": 262144, + "source": "seed" + }, + "kimi-k2.5": { + "context_length": 164000, + "max_output_tokens": 262144, + "source": "seed" + }, + "kimi-k3": { + "context_length": 1048576, + "max_output_tokens": 131072, + "source": "seed" + }, + "kimi-k2.8-preview": { + "context_length": 1048576, + "source": "seed" + }, + "minimax-m3": { + "context_length": 512000, + "max_output_tokens": 512000, + "source": "seed" + }, + "hy3": { + "context_length": 192000, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy3-preview": { + "context_length": 262144, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy4-preview": { + "context_length": 1000000, + "max_output_tokens": 64000, + "source": "seed" + }, + "hy4-preview-x": { + "context_length": 1000000, + "max_output_tokens": 64000, + "source": "seed" + }, + "deepseek-v4-pro": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "deepseek-v4-flash": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "deepseek-v4.1-flash": { + "context_length": 1000000, + "max_output_tokens": 384000, + "source": "seed" + }, + "gpt-6-astra": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-sol": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-terra": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.6-luna": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.5": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.4": { + "context_length": 1050000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gpt-5.3-codex": { + "context_length": 400000, + "max_output_tokens": 128000, + "source": "seed" + }, + "gemini-3.5-flash": { + "context_length": 1048576, + "max_output_tokens": 65536, + "source": "seed" + }, + "auto": { + "context_length": 168000, + "source": "seed" + } +} diff --git a/internal/upstream/model_catalog.go b/internal/upstream/model_catalog.go index fc117002f42136ab2c468f66c798462eda596955..309d221b5ffcb93b8cee2e5c0bf4601e062e0f34 100644 --- a/internal/upstream/model_catalog.go +++ b/internal/upstream/model_catalog.go @@ -1,320 +1,320 @@ -// model_catalog.go context_length / max_output_tokens 四级查找链 + model.json -// 本地缓存(任务书 model-json-dynamic)。 -// -// 查找链(32a3c13 三级 → 四级,上游动态值永远权威不变): -// 1. 上游动态值(ModelInfo.ContextWindow/MaxTokens)——权威,永远压过 model.json -// (即使后者更新:上游才是权威,任务书 §清理); -// 2. 静态种子表(context_catalog.go 的 contextCapFallback,编译期兜底); -// 3. model.json 本地缓存(数据目录,含运行时从 models.dev 补的值 + 仓库种子 -// embed 的初值);损坏 → 降级种子并 WARN 不崩溃(手动维护入口的容错); -// 4. 触发 models.dev 按需拉取(异步,不阻塞本次响应)→ 值写入 model.json → -// 本次先落 1M/省略,下次命中缓存;负缓存 24h。 -// -// model.json 读写并发安全(单 sync.Mutex 全程持锁 + 原子落盘 tmp+rename, -// 多请求同时 miss 同一模型只写一次);文件损坏/不可写均静默降级(种子表→1M)。 -// -// 状态归属:包级 catalogState 单例(与 modelsDev fetcher 同模式,进程一份)。 -// 测试用 resetModelCatalog / loadModelCatalogAt 隔离。 -package upstream - -import ( - "embed" - "encoding/json" - "log" - "net/http" - "os" - "path/filepath" - "sync" - "time" -) - -// modelSeedFS 仓库种子版 model.json(context_catalog 静态表 27 值迁移,source=seed)。 -// 只作 model.json 缺失时的初值来源;运行目录的 model.json 一旦存在则以它为准 -// (用户手动维护入口——直接编辑文件,格式容错由 loadModelCatalog 的校验兜底)。 -// -//go:embed model.json -var modelSeedFS embed.FS - -// ModelCapEntry model.json 单条目(与静态表同字段口径 + 来源与抓取时间)。 -// Source:seed(仓库种子迁移)/ modelsdev(运行时按需拉取)/ manual(用户手编 -// ——无法区分手编与 seed,手编条目保留其原 source 字符串,语义等同「非拉取」)。 -// Context 必须 >0(零/负条目校验拒绝);MaxOutput 0 = 输出上限未知 → 省略字段。 -type ModelCapEntry struct { - ContextLength int64 `json:"context_length"` - MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` - FetchedAt string `json:"fetched_at,omitempty"` // RFC3339;seed 条目为空 - Source string `json:"source"` -} - -// modelCatalog model.json 缓存状态机(包级 catalogState 单例的字段载体)。 -type modelCatalog struct { - mu sync.Mutex - - path string // 落盘路径(空 = 禁用持久化:纯内存 + 种子) - entries map[string]ModelCapEntry - loaded bool // entries 已初始化(含损坏降级形态) -} - -// catalogState 包级单例:全进程一份 model.json 状态。 -var catalogState = &modelCatalog{} - -// initModelCatalogLocked 确保 entries 已加载(持锁调用): -// - 运行目录 model.json 存在且合法 → 全量加载(校验失败的条目剔除并 WARN); -// - 文件不存在 / 整体损坏(非法 JSON)→ 种子 embed 初值(不落盘:保持「用户 -// 尚无缓存」状态,首次拉取成功后再落盘); -// - path 为空(未接线,如测试/工具进程)→ 种子初值。 -func (c *modelCatalog) initLocked() { - if c.loaded { - return - } - c.entries = map[string]ModelCapEntry{} - c.loaded = true - if c.path == "" { - c.loadSeedLocked() - return - } - raw, err := os.ReadFile(c.path) - if err != nil { - // 不存在 / 不可读 → 种子(首次启动形态)。 - c.loadSeedLocked() - return - } - var file map[string]ModelCapEntry - if err := json.Unmarshal(raw, &file); err != nil { - // 整体损坏:降级种子 + WARN 不崩溃(任务书 §手动维护入口容错)。 - log.Printf("WARN: [upstream] model.json 损坏(降级内置种子表): path=%s err=%v", c.path, err) - c.loadSeedLocked() - return - } - for id, e := range file { - if !validCapEntry(e) { - log.Printf("WARN: [upstream] model.json 条目非法剔除: model=%s entry=%+v", id, e) - continue - } - c.entries[id] = e - } -} - -// loadSeedLocked 把仓库种子 model.json 灌入 entries(持锁调用)。 -func (c *modelCatalog) loadSeedLocked() { - raw, err := modelSeedFS.ReadFile("model.json") - if err != nil { - // embed 编译期保证存在,理论不可达;防御性兜底走静态表(initLocked 调用方 - // 查找链第 2 级本来就会兜,这里只需保持 entries 为空)。 - return - } - var seed map[string]ModelCapEntry - if err := json.Unmarshal(raw, &seed); err != nil { - return - } - for id, e := range seed { - if validCapEntry(e) { - c.entries[id] = e - } - } -} - -// validCapEntry 条目级校验:context 正数 + 输出非负(任务书 §值校验的写入侧)。 -func validCapEntry(e ModelCapEntry) bool { - return e.ContextLength > 0 && e.MaxOutputTokens >= 0 -} - -// get 查 model.json 缓存(第 3 级)。返回条目与是否命中。 -// 只读内存,不发网络;加载与网络动作由 ensure/触发侧负责。 -func (c *modelCatalog) get(model string) (ModelCapEntry, bool) { - c.mu.Lock() - defer c.mu.Unlock() - c.initLocked() - e, ok := c.entries[model] - if !ok || !validCapEntry(e) { - return ModelCapEntry{}, false - } - return e, true -} - -// put 写一条缓存(第 4 级拉取成功后调用)并落盘。同模型已存在(用户手动维护过) -// 仍覆盖——运行时拉取值采信 models.dev 官方源优先口径,比手编更可信; -// 若不希望覆盖,删掉 model.json 里对应条目即可(加载后手编值只在本次进程生效)。 -func (c *modelCatalog) put(model string, e ModelCapEntry) { - if model == "" || !validCapEntry(e) { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.initLocked() - c.entries[model] = e - c.saveLocked() -} - -// saveLocked 原子落盘(tmp + rename,pool state.json 同模式)。持锁调用。 -// path 为空 / 目录不可写 / 序列化失败 → 静默(内存缓存仍生效,下次进程重拉)。 -func (c *modelCatalog) saveLocked() { - if c.path == "" { - return - } - raw, err := json.MarshalIndent(c.entries, "", " ") - if err != nil { - return - } - if dir := filepath.Dir(c.path); dir != "" { - _ = os.MkdirAll(dir, 0o755) - } - tmp := c.path + ".tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - log.Printf("WARN: [upstream] model.json 落盘失败(内存缓存仍生效): path=%s err=%v", c.path, err) - return - } - if err := os.Rename(tmp, c.path); err != nil { - log.Printf("WARN: [upstream] model.json 落盘改名失败: path=%s err=%v", c.path, err) - } -} - -// ---- 包级 API(查找链 3/4 级 + 接线)---- - -// SetModelCatalogPath 接线 model.json 落盘路径(cmd/server 启动时调用, -// 数据目录与 state.json 同风格)。首次调用生效;后续调用在已加载后仅更新路径 -// (不重载——进程内以内存 entries 为准)。 -func SetModelCatalogPath(path string) { - c := catalogState - c.mu.Lock() - defer c.mu.Unlock() - c.path = path -} - -// modelCatalogGet 第 3 级:model.json 缓存命中(内含种子初值与损坏降级)。 -func modelCatalogGet(model string) (ModelCapEntry, bool) { - return catalogState.get(model) -} - -// modelCatalogPut 第 4 级写入:models.dev 拉到的值落缓存(含落盘)。 -func modelCatalogPut(model string, context, maxOutput int64) { - modelCatalogPutSourced(model, context, maxOutput, "modelsdev") -} - -// modelCatalogPutSourced 写入指定来源的条目(测试可注入 fetched_at 检查落盘格式)。 -func modelCatalogPutSourced(model string, context, maxOutput int64, source string) { - catalogState.put(model, ModelCapEntry{ - ContextLength: context, - MaxOutputTokens: maxOutput, - FetchedAt: time.Now().UTC().Format(time.RFC3339), - Source: source, - }) -} - -// resetModelCatalog 测试隔离:清空单例状态(entries/path/loaded)。 -func resetModelCatalog() { - c := catalogState - c.mu.Lock() - defer c.mu.Unlock() - c.entries = nil - c.path = "" - c.loaded = false -} - -// loadModelCatalogAt 测试接线:指向指定路径后立即触发一次加载(同步,可断言文件 -// 解析行为)。生产路径用 SetModelCatalogPath(惰性首次 get 触发加载)。 -func loadModelCatalogAt(path string) { - SetModelCatalogPath(path) - catalogState.mu.Lock() - defer catalogState.mu.Unlock() - catalogState.initLocked() -} - -// ResetLookupChainForTest 跨包测试钩子:清空 model.json 缓存与 models.dev fetcher -// 的全部包级单例状态(含在途拉取冷却——防 server 包测试末尾的异步 goroutine 打 -// 真网、防跨测试缓存污染)。仅测试引用(upstream 包内用 resetModelsDev / -// resetModelCatalog 等价内联)。 -func ResetLookupChainForTest() { - resetModelCatalog() - resetModelsDev() -} - -// ---- 四级查找链(对 handler 暴露的入口,签名与 32a3c13 三级版兼容)---- - -// ContextWindowListingV4 四级查找链的 context_length 决策: -// 1. remote>0 权威透出(上游动态值永远压过 model.json,任务书 §清理); -// 2. 静态种子表(contextCapFallback); -// 3. model.json 缓存(内含种子初值 / models.dev 运行时补充值); -// 4. 全链 miss 且非负缓存 → 异步触发 models.dev 拉取(本次返回 DefaultContextWindow -// 1M,不阻塞;拉到后写 model.json 供下次命中)。 -func ContextWindowListingV4(model string, remote int64, client *http.Client) int64 { - if remote > 0 { - return remote - } - if model == "" { - return DefaultContextWindow - } - if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { - return cap.context // 第 2 级:静态种子表(编译期兜底,永远可用) - } - if e, ok := modelCatalogGet(model); ok { - return e.ContextLength // 第 3 级:model.json - } - if !modelsDev.negativeFresh(model) { - // 第 4 级触发:先查进程内文档索引(拉过一次即常驻),命中直接入缓存 - // 返回(不等待异步拉取);未命中 → 记负缓存 + 异步拉取(本次先回 1M)。 - if e, ok := modelsDev.lookup(model); ok { - modelCatalogPut(model, e.Context, e.Output) - return e.Context - } - modelsDev.ensureDocAsync(client, "") - } - return DefaultContextWindow // 第 4 级兜底:1M(本次先回,拉到后下次命中) -} - -// MaxOutputTokensListingV4 四级查找链的 max_output_tokens 决策(与 context 口径 -// 刻意不同:未知 → 省略,无「宁可高估」安全侧)。 -func MaxOutputTokensListingV4(model string, remote int64, client *http.Client) (int64, bool) { - if remote > 0 { - return remote, true - } - if model == "" { - return 0, false - } - if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { - return cap.maxOutput, true // 第 2 级 - } - if e, ok := modelCatalogGet(model); ok && e.MaxOutputTokens > 0 { - return e.MaxOutputTokens, true // 第 3 级 - } - if !modelsDev.negativeFresh(model) { - // 与 ContextWindowListingV4 同触发:lookup 命中先入缓存再返回 - // (两条查找链并发 miss 同一模型时,第二调用方直接拿到刚写入的值)。 - if e, ok := modelsDev.lookup(model); ok { - modelCatalogPut(model, e.Context, e.Output) - return e.Output, e.Output > 0 - } - modelsDev.ensureDocAsync(client, "") - } - return 0, false // 第 4 级兜底:省略(输出上限不编造) -} - -// ---- 第 4 级拉取值的回流(fetchDoc 成功后调用,写 model.json)---- - -// noteModelsDevMiss 查询未命中 models.dev 索引 → 负缓存已由 lookup 记录。 -// 本函数是 lookup + 写缓存的粘合层:fetchDoc 拉到文档后对「曾 miss 过的模型」 -// 重查一次并写入 model.json(下次 /v1/models 直接命中第 3 级)。 -func (f *modelsDevFetcher) backfillMisses() { - f.mu.Lock() - doc := f.doc - missed := make([]string, 0, len(f.negatives)) - for m := range f.negatives { - missed = append(missed, m) - } - f.mu.Unlock() - if doc == nil { - return - } - for _, m := range missed { - e, ok := doc[m] - if !ok { - continue // 仍查不到:负缓存 24h 生效,不写 - } - modelCatalogPut(m, e.Context, e.Output) - // 回流成功:清除负缓存条目(该模型已有值,后续走第 3 级缓存, - // 不再进本清单)。 - f.mu.Lock() - delete(f.negatives, m) - f.mu.Unlock() - } -} +// model_catalog.go context_length / max_output_tokens 四级查找链 + model.json +// 本地缓存(任务书 model-json-dynamic)。 +// +// 查找链(32a3c13 三级 → 四级,上游动态值永远权威不变): +// 1. 上游动态值(ModelInfo.ContextWindow/MaxTokens)——权威,永远压过 model.json +// (即使后者更新:上游才是权威,任务书 §清理); +// 2. 静态种子表(context_catalog.go 的 contextCapFallback,编译期兜底); +// 3. model.json 本地缓存(数据目录,含运行时从 models.dev 补的值 + 仓库种子 +// embed 的初值);损坏 → 降级种子并 WARN 不崩溃(手动维护入口的容错); +// 4. 触发 models.dev 按需拉取(异步,不阻塞本次响应)→ 值写入 model.json → +// 本次先落 1M/省略,下次命中缓存;负缓存 24h。 +// +// model.json 读写并发安全(单 sync.Mutex 全程持锁 + 原子落盘 tmp+rename, +// 多请求同时 miss 同一模型只写一次);文件损坏/不可写均静默降级(种子表→1M)。 +// +// 状态归属:包级 catalogState 单例(与 modelsDev fetcher 同模式,进程一份)。 +// 测试用 resetModelCatalog / loadModelCatalogAt 隔离。 +package upstream + +import ( + "embed" + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// modelSeedFS 仓库种子版 model.json(context_catalog 静态表 27 值迁移,source=seed)。 +// 只作 model.json 缺失时的初值来源;运行目录的 model.json 一旦存在则以它为准 +// (用户手动维护入口——直接编辑文件,格式容错由 loadModelCatalog 的校验兜底)。 +// +//go:embed model.json +var modelSeedFS embed.FS + +// ModelCapEntry model.json 单条目(与静态表同字段口径 + 来源与抓取时间)。 +// Source:seed(仓库种子迁移)/ modelsdev(运行时按需拉取)/ manual(用户手编 +// ——无法区分手编与 seed,手编条目保留其原 source 字符串,语义等同「非拉取」)。 +// Context 必须 >0(零/负条目校验拒绝);MaxOutput 0 = 输出上限未知 → 省略字段。 +type ModelCapEntry struct { + ContextLength int64 `json:"context_length"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + FetchedAt string `json:"fetched_at,omitempty"` // RFC3339;seed 条目为空 + Source string `json:"source"` +} + +// modelCatalog model.json 缓存状态机(包级 catalogState 单例的字段载体)。 +type modelCatalog struct { + mu sync.Mutex + + path string // 落盘路径(空 = 禁用持久化:纯内存 + 种子) + entries map[string]ModelCapEntry + loaded bool // entries 已初始化(含损坏降级形态) +} + +// catalogState 包级单例:全进程一份 model.json 状态。 +var catalogState = &modelCatalog{} + +// initModelCatalogLocked 确保 entries 已加载(持锁调用): +// - 运行目录 model.json 存在且合法 → 全量加载(校验失败的条目剔除并 WARN); +// - 文件不存在 / 整体损坏(非法 JSON)→ 种子 embed 初值(不落盘:保持「用户 +// 尚无缓存」状态,首次拉取成功后再落盘); +// - path 为空(未接线,如测试/工具进程)→ 种子初值。 +func (c *modelCatalog) initLocked() { + if c.loaded { + return + } + c.entries = map[string]ModelCapEntry{} + c.loaded = true + if c.path == "" { + c.loadSeedLocked() + return + } + raw, err := os.ReadFile(c.path) + if err != nil { + // 不存在 / 不可读 → 种子(首次启动形态)。 + c.loadSeedLocked() + return + } + var file map[string]ModelCapEntry + if err := json.Unmarshal(raw, &file); err != nil { + // 整体损坏:降级种子 + WARN 不崩溃(任务书 §手动维护入口容错)。 + log.Printf("WARN: [upstream] model.json 损坏(降级内置种子表): path=%s err=%v", c.path, err) + c.loadSeedLocked() + return + } + for id, e := range file { + if !validCapEntry(e) { + log.Printf("WARN: [upstream] model.json 条目非法剔除: model=%s entry=%+v", id, e) + continue + } + c.entries[id] = e + } +} + +// loadSeedLocked 把仓库种子 model.json 灌入 entries(持锁调用)。 +func (c *modelCatalog) loadSeedLocked() { + raw, err := modelSeedFS.ReadFile("model.json") + if err != nil { + // embed 编译期保证存在,理论不可达;防御性兜底走静态表(initLocked 调用方 + // 查找链第 2 级本来就会兜,这里只需保持 entries 为空)。 + return + } + var seed map[string]ModelCapEntry + if err := json.Unmarshal(raw, &seed); err != nil { + return + } + for id, e := range seed { + if validCapEntry(e) { + c.entries[id] = e + } + } +} + +// validCapEntry 条目级校验:context 正数 + 输出非负(任务书 §值校验的写入侧)。 +func validCapEntry(e ModelCapEntry) bool { + return e.ContextLength > 0 && e.MaxOutputTokens >= 0 +} + +// get 查 model.json 缓存(第 3 级)。返回条目与是否命中。 +// 只读内存,不发网络;加载与网络动作由 ensure/触发侧负责。 +func (c *modelCatalog) get(model string) (ModelCapEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.initLocked() + e, ok := c.entries[model] + if !ok || !validCapEntry(e) { + return ModelCapEntry{}, false + } + return e, true +} + +// put 写一条缓存(第 4 级拉取成功后调用)并落盘。同模型已存在(用户手动维护过) +// 仍覆盖——运行时拉取值采信 models.dev 官方源优先口径,比手编更可信; +// 若不希望覆盖,删掉 model.json 里对应条目即可(加载后手编值只在本次进程生效)。 +func (c *modelCatalog) put(model string, e ModelCapEntry) { + if model == "" || !validCapEntry(e) { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.initLocked() + c.entries[model] = e + c.saveLocked() +} + +// saveLocked 原子落盘(tmp + rename,pool state.json 同模式)。持锁调用。 +// path 为空 / 目录不可写 / 序列化失败 → 静默(内存缓存仍生效,下次进程重拉)。 +func (c *modelCatalog) saveLocked() { + if c.path == "" { + return + } + raw, err := json.MarshalIndent(c.entries, "", " ") + if err != nil { + return + } + if dir := filepath.Dir(c.path); dir != "" { + _ = os.MkdirAll(dir, 0o755) + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + log.Printf("WARN: [upstream] model.json 落盘失败(内存缓存仍生效): path=%s err=%v", c.path, err) + return + } + if err := os.Rename(tmp, c.path); err != nil { + log.Printf("WARN: [upstream] model.json 落盘改名失败: path=%s err=%v", c.path, err) + } +} + +// ---- 包级 API(查找链 3/4 级 + 接线)---- + +// SetModelCatalogPath 接线 model.json 落盘路径(cmd/server 启动时调用, +// 数据目录与 state.json 同风格)。首次调用生效;后续调用在已加载后仅更新路径 +// (不重载——进程内以内存 entries 为准)。 +func SetModelCatalogPath(path string) { + c := catalogState + c.mu.Lock() + defer c.mu.Unlock() + c.path = path +} + +// modelCatalogGet 第 3 级:model.json 缓存命中(内含种子初值与损坏降级)。 +func modelCatalogGet(model string) (ModelCapEntry, bool) { + return catalogState.get(model) +} + +// modelCatalogPut 第 4 级写入:models.dev 拉到的值落缓存(含落盘)。 +func modelCatalogPut(model string, context, maxOutput int64) { + modelCatalogPutSourced(model, context, maxOutput, "modelsdev") +} + +// modelCatalogPutSourced 写入指定来源的条目(测试可注入 fetched_at 检查落盘格式)。 +func modelCatalogPutSourced(model string, context, maxOutput int64, source string) { + catalogState.put(model, ModelCapEntry{ + ContextLength: context, + MaxOutputTokens: maxOutput, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Source: source, + }) +} + +// resetModelCatalog 测试隔离:清空单例状态(entries/path/loaded)。 +func resetModelCatalog() { + c := catalogState + c.mu.Lock() + defer c.mu.Unlock() + c.entries = nil + c.path = "" + c.loaded = false +} + +// loadModelCatalogAt 测试接线:指向指定路径后立即触发一次加载(同步,可断言文件 +// 解析行为)。生产路径用 SetModelCatalogPath(惰性首次 get 触发加载)。 +func loadModelCatalogAt(path string) { + SetModelCatalogPath(path) + catalogState.mu.Lock() + defer catalogState.mu.Unlock() + catalogState.initLocked() +} + +// ResetLookupChainForTest 跨包测试钩子:清空 model.json 缓存与 models.dev fetcher +// 的全部包级单例状态(含在途拉取冷却——防 server 包测试末尾的异步 goroutine 打 +// 真网、防跨测试缓存污染)。仅测试引用(upstream 包内用 resetModelsDev / +// resetModelCatalog 等价内联)。 +func ResetLookupChainForTest() { + resetModelCatalog() + resetModelsDev() +} + +// ---- 四级查找链(对 handler 暴露的入口,签名与 32a3c13 三级版兼容)---- + +// ContextWindowListingV4 四级查找链的 context_length 决策: +// 1. remote>0 权威透出(上游动态值永远压过 model.json,任务书 §清理); +// 2. 静态种子表(contextCapFallback); +// 3. model.json 缓存(内含种子初值 / models.dev 运行时补充值); +// 4. 全链 miss 且非负缓存 → 异步触发 models.dev 拉取(本次返回 DefaultContextWindow +// 1M,不阻塞;拉到后写 model.json 供下次命中)。 +func ContextWindowListingV4(model string, remote int64, client *http.Client) int64 { + if remote > 0 { + return remote + } + if model == "" { + return DefaultContextWindow + } + if cap, ok := contextCapFallback[model]; ok && cap.context > 0 { + return cap.context // 第 2 级:静态种子表(编译期兜底,永远可用) + } + if e, ok := modelCatalogGet(model); ok { + return e.ContextLength // 第 3 级:model.json + } + if !modelsDev.negativeFresh(model) { + // 第 4 级触发:先查进程内文档索引(拉过一次即常驻),命中直接入缓存 + // 返回(不等待异步拉取);未命中 → 记负缓存 + 异步拉取(本次先回 1M)。 + if e, ok := modelsDev.lookup(model); ok { + modelCatalogPut(model, e.Context, e.Output) + return e.Context + } + modelsDev.ensureDocAsync(client, "") + } + return DefaultContextWindow // 第 4 级兜底:1M(本次先回,拉到后下次命中) +} + +// MaxOutputTokensListingV4 四级查找链的 max_output_tokens 决策(与 context 口径 +// 刻意不同:未知 → 省略,无「宁可高估」安全侧)。 +func MaxOutputTokensListingV4(model string, remote int64, client *http.Client) (int64, bool) { + if remote > 0 { + return remote, true + } + if model == "" { + return 0, false + } + if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 { + return cap.maxOutput, true // 第 2 级 + } + if e, ok := modelCatalogGet(model); ok && e.MaxOutputTokens > 0 { + return e.MaxOutputTokens, true // 第 3 级 + } + if !modelsDev.negativeFresh(model) { + // 与 ContextWindowListingV4 同触发:lookup 命中先入缓存再返回 + // (两条查找链并发 miss 同一模型时,第二调用方直接拿到刚写入的值)。 + if e, ok := modelsDev.lookup(model); ok { + modelCatalogPut(model, e.Context, e.Output) + return e.Output, e.Output > 0 + } + modelsDev.ensureDocAsync(client, "") + } + return 0, false // 第 4 级兜底:省略(输出上限不编造) +} + +// ---- 第 4 级拉取值的回流(fetchDoc 成功后调用,写 model.json)---- + +// noteModelsDevMiss 查询未命中 models.dev 索引 → 负缓存已由 lookup 记录。 +// 本函数是 lookup + 写缓存的粘合层:fetchDoc 拉到文档后对「曾 miss 过的模型」 +// 重查一次并写入 model.json(下次 /v1/models 直接命中第 3 级)。 +func (f *modelsDevFetcher) backfillMisses() { + f.mu.Lock() + doc := f.doc + missed := make([]string, 0, len(f.negatives)) + for m := range f.negatives { + missed = append(missed, m) + } + f.mu.Unlock() + if doc == nil { + return + } + for _, m := range missed { + e, ok := doc[m] + if !ok { + continue // 仍查不到:负缓存 24h 生效,不写 + } + modelCatalogPut(m, e.Context, e.Output) + // 回流成功:清除负缓存条目(该模型已有值,后续走第 3 级缓存, + // 不再进本清单)。 + f.mu.Lock() + delete(f.negatives, m) + f.mu.Unlock() + } +} diff --git a/internal/upstream/modelsdev.go b/internal/upstream/modelsdev.go index 273f4eb2b318b1c79a06926b02fde9567fbd4014..23d09ab1d051d443381e3c793c24cadf8eabca08 100644 --- a/internal/upstream/modelsdev.go +++ b/internal/upstream/modelsdev.go @@ -1,358 +1,358 @@ -// modelsdev.go models.dev 按需兜底(context_length 四级查找链的第 4 级)。 -// -// 定位:只对「上游动态值缺失 + 静态种子表未收录 + model.json 未缓存」的模型查 -// models.dev,是兜底的兜底——超时短(默认 5s)、失败静默降级(context_length 落 -// DefaultContextWindow=1M),绝不阻塞 /v1/models 主路径(查找异步化,本次请求 -// 直接返回兜底值,拉到后写 model.json 供下次命中)。 -// -// 数据源(2026-09-16 逆向,结论详见 .claude/reports/model-json-dynamic.md): -// - 官方聚合 JSON 端点 https://models.dev/api.json:~4.7MB 单文档、217 provider、 -// 免鉴权、Cloudflare 托管静态站; -// - 无按模型/按 provider 子端点(/z-ai.json 等 302 回 /),「按需」的实现是 -// 单次拉全量文档 + 建裸 id 索引(拉一次只发生一次,此后进程内复用索引); -// - schema:{ "": { "models": { "": { "limit": {"context": N, -// "output": N} } } } },模型 id 有裸名(glm-5.2)与带命名空间(openai/gpt-5.5) -// 两种形态,均取尾段做索引 key; -// - 多 provider 同名值会分歧(聚合网关常自报改动):vendor 官方源(zai/ -// moonshotai/openai/google/deepseek/minimax)优先,其余取众数(共识值)。 -package upstream - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "sort" - "strings" - "sync" - "time" -) - -// ModelsDevURL models.dev 官方聚合 JSON 端点(唯一端点,见文件头逆向结论)。 -const ModelsDevURL = "https://models.dev/api.json" - -// modelsDevTimeout 单次拉取超时:兜底的兜底,不值得等(任务书 §2:如 5s)。 -const modelsDevTimeout = 5 * time.Second - -// modelsDevFetchCooldown 拉取节流(进程级):文档是全量聚合体,5min 内不重拉 -// (同模型 24h 负缓存之外的整体节流,防短窗反复打 models.dev)。 -const modelsDevFetchCooldown = 5 * time.Minute - -// modelsDevNegativeTTL 同模型负缓存:查不到的模型 24h 内不重查 -// (任务书 §2:如同模型 24h 内不重查,查不到的模型负缓存防反复打)。 -const modelsDevNegativeTTL = 24 * time.Hour - -// modelsDevNegativesSoftCap 负缓存 map 的软上限:规模超过它时才做一次过期条目淘汰 -// 扫描。分批摊销是为避免每次 lookup 都做 O(n) 全扫——第 4 级触发点在 /v1/models 里 -// 每个模型各调一次(handler 遍历模型列表逐条 V4),n 大时全扫会被请求数放大成 CPU -// 开销。淘汰只针对 TTL 已过期的条目,故低于上限时不扫也不会让任何有效条目过期失效。 -// -// 注意「只扫过期条目」不足以保证有界:TTL 从**首次未命中**起算(见 lookup 的写回 -// 判断),若模型名持续出现在 /v1/models 名单里,一个 TTL 窗口内所有条目都会被反复 -// 盖章成 fresh,扫描一条也删不掉,规模只增不减。因此超过**硬上限**(两倍软上限) -// 时额外丢弃最旧的条目——负缓存是纯性能优化(miss 时多查一次进程内索引),丢条目 -// 只可能让某个模型重新进一次扫描,不改变任何对外语义。 -const modelsDevNegativesSoftCap = 1024 - -// modelsDevNegativesHardCap 负缓存 map 的硬上限:超过即按时间序丢弃最旧条目 -// (见 modelsDevNegativesSoftCap 注释)。取两倍软上限,给「一轮 /v1/models 新增」 -// 留出余量,正常规模远达不到。 -const modelsDevNegativesHardCap = 2 * modelsDevNegativesSoftCap - -// modelsDevMaxBody 拉取响应体上限(文档实测 ~4.7MB,留余量;防异常大响应拖死)。 -const modelsDevMaxBody = 32 << 20 - -// modelsDevValueMax 值校验上限:context/output 超过 1e9 视为脏数据拒绝 -// (量级上限校验,任务书 §2 值校验;正数下界在 catalog 写入侧兜底)。 -const modelsDevValueMax = int64(1e9) - -// modelsDevVendorSources 官方 vendor provider 优先名单:models.dev 收录 217 个 -// provider,聚合网关(merge-gateway/nano-gpt 等)自报的 limit 常与官方源分歧, -// 采值优先级 = 本名单命中 > 众数共识。 -var modelsDevVendorSources = map[string]bool{ - "zai": true, // Z.AI(glm 家族官方) - "moonshotai": true, // Moonshot AI(kimi 家族官方,国际版) - "moonshotai-cn": true, // Moonshot AI 中国版 - "openai": true, - "google": true, - "deepseek": true, - "minimax": true, -} - -// modelsDevEntry models.dev 单模型采值结果(modelsdev json 的 limit 子集)。 -type modelsDevEntry struct { - Context int64 - Output int64 -} - -// modelsDevFetcher models.dev 按需拉取器:进程级单例语义(包级变量 modelsDev), -// 拉取节流 + 裸 id 索引缓存 + 同模型负缓存。测试用 resetModelsDev / 独立 base URL -// 注入隔离(newModelsDevForTest)。 -type modelsDevFetcher struct { - mu sync.Mutex - - // doc 文档解析后的裸 id 索引(多 provider 同名合并采值:vendor 优先/众数)。 - // nil = 未拉取;空 map(非 nil)= 拉取过但索引为空(视作失败冷却)。 - doc map[string]modelsDevEntry - - lastFetch time.Time // 最近一次拉取尝试(成功与失败都算,冷却节流) - fetched bool // 是否已拉取过(doc 字段区分成败) - - negatives map[string]time.Time // 查询未命中的模型 → 记录时间(24h 负缓存) -} - -// modelsDev 包级拉取器实例(单例:全进程共享一份文档索引与节流状态)。 -var modelsDev = &modelsDevFetcher{} - -// resetModelsDev 测试隔离:清空单例状态(doc/fetched/lastFetch/negatives)。 -func resetModelsDev() { - modelsDev.mu.Lock() - modelsDev.doc = nil - modelsDev.fetched = false - modelsDev.lastFetch = time.Time{} - modelsDev.negatives = nil - modelsDev.mu.Unlock() -} - -// lookup 查询一个模型的 (context, output, found): -// - 索引命中且值合法 → found=true; -// - 索引未命中(含索引尚未就绪)→ 记入 negatives(既是 24h 负缓存,也是 -// fetchDoc 成功后 backfillMisses 的回流清单——「曾 miss 过的模型」),found=false。 -// -// 只读内存索引,不发网络请求;网络动作由 ensureDocAsync(goroutine 内)负责。 -// 注意:doc 就绪前的 miss 也记 negatives——backfillMisses 回流时查到即写 -// model.json 并清除负缓存条目(freshLookup),查不到的保持 24h 负缓存。 -func (f *modelsDevFetcher) lookup(model string) (modelsDevEntry, bool) { - f.mu.Lock() - defer f.mu.Unlock() - if f.doc != nil { - if e, ok := f.doc[model]; ok { - return e, true - } - } - // 未命中(索引在但模型不在,或索引尚未就绪):记 miss。 - if f.negatives == nil { - f.negatives = make(map[string]time.Time) - } - now := time.Now() - // 未命中「重复查询」不再刷新记录时刻:TTL 只从**首次未命中**起算。 - // 否则覆盖写等于每次查询都把条目续期——一个持续出现在 /v1/models 名单里的 - // 未知模型(每条 listing 经 ContextWindowListingV4 + MaxOutputTokensListingV4 - // 各查一次,即每请求 2 次写回)其条目永远 fresh,下面的惰性淘汰一条也扫不掉; - // 又因淘汰只在超软上限时才扫,规模超限后只增不减(进程生命周期内无界增长)。 - // 续期对行为零影响:negativeFresh 只判是否存在 + within TTL,未过 TTL 的条目 - // 无论是否续期都同样短路第 4 级触发。 - if _, seen := f.negatives[model]; !seen { - f.negatives[model] = now - } - // 惰性淘汰已过期的负缓存条目:TTL 到期后 negativeFresh 本就判 false(等效不存在), - // 条目继续留着只是内存泄漏——models.dev 永不收录的模型名(model 由客户端任意指定) - // 查一次就永久驻留,而全库唯一的删除点 backfillMisses 只删「文档里查到」的模型, - // 永远不会回收这些条目,map 在进程生命周期内无界增长。 - // 仅在规模超软上限时才扫一遍(摊销 O(1),理由见 modelsDevNegativesSoftCap 注释)。 - if len(f.negatives) > modelsDevNegativesSoftCap { - for m, t := range f.negatives { - if now.Sub(t) >= modelsDevNegativeTTL { - delete(f.negatives, m) - } - } - } - // 硬上限兜底:TTL 未到期的条目本就无可淘汰(上一轮扫描已删净过期项),若规模 - // 仍超硬上限说明输入模型名太多,按时间序丢弃最旧条目、削回软上限 - // (纯性能优化:负缓存 miss 只会多查一次进程内索引,丢条目无语义影响)。 - if len(f.negatives) > modelsDevNegativesHardCap { - cut := len(f.negatives) - modelsDevNegativesSoftCap - oldest := make([]string, 0, len(f.negatives)) - for m := range f.negatives { - oldest = append(oldest, m) - } - sort.Slice(oldest, func(i, j int) bool { return f.negatives[oldest[i]].Before(f.negatives[oldest[j]]) }) - for _, m := range oldest[:cut] { - delete(f.negatives, m) - } - } - return modelsDevEntry{}, false -} - -// negativeFresh 模型是否在负缓存有效期内(供查找链短路第 4 级触发)。 -func (f *modelsDevFetcher) negativeFresh(model string) bool { - f.mu.Lock() - defer f.mu.Unlock() - t, ok := f.negatives[model] - return ok && time.Since(t) < modelsDevNegativeTTL -} - -// ensureDocAsync 确保 models.dev 文档索引可用(异步,不阻塞调用方): -// 已有索引 / 拉取冷却期内 / 已有人在拉(in-flight 去重)→ 直接返回。 -// 否则起 goroutine 拉取解析,完成后落 f.doc(失败静默:只刷新 lastFetch 冷却, -// 下次查找仍走 1M 兜底,不重试风暴)。 -func (f *modelsDevFetcher) ensureDocAsync(client *http.Client, baseOverride string) { - f.mu.Lock() - if f.doc != nil { - f.mu.Unlock() - return - } - if f.fetched && time.Since(f.lastFetch) < modelsDevFetchCooldown { - // 拉取过(成功或失败)且冷却期内:不再打 models.dev。 - f.mu.Unlock() - return - } - // in-flight 去重:把 fetched/lastFetch 先置为「本次进行中」, - // 后续并发调用在冷却窗口内直接返回,不重复起拉取。 - f.fetched = true - f.lastFetch = time.Now() - f.mu.Unlock() - - go f.fetchDoc(client, baseOverride) -} - -// fetchDoc 拉取并解析 models.dev 文档,建裸 id 索引(goroutine 内执行,永不 panic -// 上抛:任何失败只静默冷却)。 -// 拒绝 nil client(不回落 http.DefaultClient):生产调用方恒传非 nil,nil 只意味着 -// 测试疏漏——DefaultClient 无超时(挂起隐患)且会打真网(测试污染 + 不确定延迟), -// 静默 WARN + 返回(与 fetch 失败同语义,降级 1M 兜底)让疏漏显式化。 -func (f *modelsDevFetcher) fetchDoc(client *http.Client, baseOverride string) { - if client == nil { - log.Printf("WARN: [upstream] models.dev fetch: nil client rejected (no DefaultClient fallback, silent fallback to 1M)") - return - } - url := ModelsDevURL - if baseOverride != "" { - url = baseOverride - } - ctx, cancel := context.WithTimeout(context.Background(), modelsDevTimeout) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - log.Printf("WARN: [upstream] models.dev fetch: build request: %v", err) - return - } - resp, err := client.Do(req) - if err != nil { - log.Printf("WARN: [upstream] models.dev fetch failed (silent fallback to 1M): %v", err) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - log.Printf("WARN: [upstream] models.dev fetch status %d (silent fallback to 1M)", resp.StatusCode) - return - } - raw, err := io.ReadAll(io.LimitReader(resp.Body, modelsDevMaxBody)) - if err != nil { - log.Printf("WARN: [upstream] models.dev fetch read: %v", err) - return - } - doc, err := parseModelsDevDoc(raw) - if err != nil { - log.Printf("WARN: [upstream] models.dev parse failed (silent fallback to 1M): %v", err) - return - } - f.mu.Lock() - f.doc = doc - f.mu.Unlock() - // 文档就绪后把「曾 miss 过的模型」回流 model.json(第 4 级 → 第 3 级, - // 下次 /v1/models 直接命中缓存)。仍查不到的保持负缓存。 - f.backfillMisses() -} - -// parseModelsDevDoc 解析 models.dev api.json:{provider:{models:{id:{limit:{context, -// output}}}}} → 裸 id 索引。同名多 provider 采值优先级四级:官方 vendor 源 -// (modelsDevVendorSources)> 票数众数 > provider 字典序 > 先出现。 -// 第 3 级 provider 字典序是确定性 tie-break:聚合时维护候选的最小 provider 名 -// (minProvider,同 doc 稳定的选择器身份),消灭 map 迭代序随机化导致的 -// 「同票先到先得」值抖动(同 binary 两次拉取同一文档可能落不同的值进 model.json, -// /v1/models 的 context_length 不可复现)。不引入「值字典序」——那会把 -// 「选谁」变成「选什么值」的启发式,语义不如 provider 名干净。 -func parseModelsDevDoc(raw []byte) (map[string]modelsDevEntry, error) { - var doc map[string]struct { - Models map[string]struct { - Limit *struct { - Context int64 `json:"context"` - Output int64 `json:"output"` - } `json:"limit"` - } `json:"models"` - } - if err := json.Unmarshal(raw, &doc); err != nil { - return nil, fmt.Errorf("models.dev doc: %w", err) - } - // 同名 id 的候选值收集:vendorOfficial 标记官方源,votes 计众数, - // minProvider 维护该候选已见的最小 provider 名(tie-break 用)。 - type candidate struct { - entry modelsDevEntry - vendor bool - votes int - aggKey string // 去重聚合 key(同值多 provider 只计票不重复存) - minProvider string - } - byModel := map[string][]candidate{} - for provider, pv := range doc { - for fullID, mv := range pv.Models { - if mv.Limit == nil { - continue - } - id := fullID - if i := strings.LastIndex(fullID, "/"); i >= 0 { - id = fullID[i+1:] - } - if id == "" { - continue - } - // 值校验(任务书 §2):正数 + 量级上限,脏值不进索引。 - ctx, out := mv.Limit.Context, mv.Limit.Output - if ctx <= 0 || ctx > modelsDevValueMax { - continue - } - if out < 0 || out > modelsDevValueMax { - continue - } - key := fmt.Sprintf("%d/%d", ctx, out) - cs := byModel[id] - dup := false - for i := range cs { - if cs[i].aggKey == key { - cs[i].votes++ - if modelsDevVendorSources[provider] { - cs[i].vendor = true - } - if provider < cs[i].minProvider { - cs[i].minProvider = provider - } - dup = true - break - } - } - if !dup { - byModel[id] = append(cs, candidate{ - entry: modelsDevEntry{Context: ctx, Output: out}, - vendor: modelsDevVendorSources[provider], - votes: 1, - aggKey: key, - minProvider: provider, - }) - } - } - } - out := make(map[string]modelsDevEntry, len(byModel)) - for id, cs := range byModel { - best := 0 - for i, c := range cs { - // 优先级:官方 vendor 源 > 票数众数 > provider 字典序(tie-break 确定性)。 - cur := cs[best] - better := false - if c.vendor && !cur.vendor { - better = true - } else if c.vendor == cur.vendor && c.votes > cur.votes { - better = true - } else if c.vendor == cur.vendor && c.votes == cur.votes && c.minProvider < cur.minProvider { - better = true - } - if better { - best = i - } - } - out[id] = cs[best].entry - } - return out, nil -} +// modelsdev.go models.dev 按需兜底(context_length 四级查找链的第 4 级)。 +// +// 定位:只对「上游动态值缺失 + 静态种子表未收录 + model.json 未缓存」的模型查 +// models.dev,是兜底的兜底——超时短(默认 5s)、失败静默降级(context_length 落 +// DefaultContextWindow=1M),绝不阻塞 /v1/models 主路径(查找异步化,本次请求 +// 直接返回兜底值,拉到后写 model.json 供下次命中)。 +// +// 数据源(2026-09-16 逆向,结论详见 .claude/reports/model-json-dynamic.md): +// - 官方聚合 JSON 端点 https://models.dev/api.json:~4.7MB 单文档、217 provider、 +// 免鉴权、Cloudflare 托管静态站; +// - 无按模型/按 provider 子端点(/z-ai.json 等 302 回 /),「按需」的实现是 +// 单次拉全量文档 + 建裸 id 索引(拉一次只发生一次,此后进程内复用索引); +// - schema:{ "": { "models": { "": { "limit": {"context": N, +// "output": N} } } } },模型 id 有裸名(glm-5.2)与带命名空间(openai/gpt-5.5) +// 两种形态,均取尾段做索引 key; +// - 多 provider 同名值会分歧(聚合网关常自报改动):vendor 官方源(zai/ +// moonshotai/openai/google/deepseek/minimax)优先,其余取众数(共识值)。 +package upstream + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// ModelsDevURL models.dev 官方聚合 JSON 端点(唯一端点,见文件头逆向结论)。 +const ModelsDevURL = "https://models.dev/api.json" + +// modelsDevTimeout 单次拉取超时:兜底的兜底,不值得等(任务书 §2:如 5s)。 +const modelsDevTimeout = 5 * time.Second + +// modelsDevFetchCooldown 拉取节流(进程级):文档是全量聚合体,5min 内不重拉 +// (同模型 24h 负缓存之外的整体节流,防短窗反复打 models.dev)。 +const modelsDevFetchCooldown = 5 * time.Minute + +// modelsDevNegativeTTL 同模型负缓存:查不到的模型 24h 内不重查 +// (任务书 §2:如同模型 24h 内不重查,查不到的模型负缓存防反复打)。 +const modelsDevNegativeTTL = 24 * time.Hour + +// modelsDevNegativesSoftCap 负缓存 map 的软上限:规模超过它时才做一次过期条目淘汰 +// 扫描。分批摊销是为避免每次 lookup 都做 O(n) 全扫——第 4 级触发点在 /v1/models 里 +// 每个模型各调一次(handler 遍历模型列表逐条 V4),n 大时全扫会被请求数放大成 CPU +// 开销。淘汰只针对 TTL 已过期的条目,故低于上限时不扫也不会让任何有效条目过期失效。 +// +// 注意「只扫过期条目」不足以保证有界:TTL 从**首次未命中**起算(见 lookup 的写回 +// 判断),若模型名持续出现在 /v1/models 名单里,一个 TTL 窗口内所有条目都会被反复 +// 盖章成 fresh,扫描一条也删不掉,规模只增不减。因此超过**硬上限**(两倍软上限) +// 时额外丢弃最旧的条目——负缓存是纯性能优化(miss 时多查一次进程内索引),丢条目 +// 只可能让某个模型重新进一次扫描,不改变任何对外语义。 +const modelsDevNegativesSoftCap = 1024 + +// modelsDevNegativesHardCap 负缓存 map 的硬上限:超过即按时间序丢弃最旧条目 +// (见 modelsDevNegativesSoftCap 注释)。取两倍软上限,给「一轮 /v1/models 新增」 +// 留出余量,正常规模远达不到。 +const modelsDevNegativesHardCap = 2 * modelsDevNegativesSoftCap + +// modelsDevMaxBody 拉取响应体上限(文档实测 ~4.7MB,留余量;防异常大响应拖死)。 +const modelsDevMaxBody = 32 << 20 + +// modelsDevValueMax 值校验上限:context/output 超过 1e9 视为脏数据拒绝 +// (量级上限校验,任务书 §2 值校验;正数下界在 catalog 写入侧兜底)。 +const modelsDevValueMax = int64(1e9) + +// modelsDevVendorSources 官方 vendor provider 优先名单:models.dev 收录 217 个 +// provider,聚合网关(merge-gateway/nano-gpt 等)自报的 limit 常与官方源分歧, +// 采值优先级 = 本名单命中 > 众数共识。 +var modelsDevVendorSources = map[string]bool{ + "zai": true, // Z.AI(glm 家族官方) + "moonshotai": true, // Moonshot AI(kimi 家族官方,国际版) + "moonshotai-cn": true, // Moonshot AI 中国版 + "openai": true, + "google": true, + "deepseek": true, + "minimax": true, +} + +// modelsDevEntry models.dev 单模型采值结果(modelsdev json 的 limit 子集)。 +type modelsDevEntry struct { + Context int64 + Output int64 +} + +// modelsDevFetcher models.dev 按需拉取器:进程级单例语义(包级变量 modelsDev), +// 拉取节流 + 裸 id 索引缓存 + 同模型负缓存。测试用 resetModelsDev / 独立 base URL +// 注入隔离(newModelsDevForTest)。 +type modelsDevFetcher struct { + mu sync.Mutex + + // doc 文档解析后的裸 id 索引(多 provider 同名合并采值:vendor 优先/众数)。 + // nil = 未拉取;空 map(非 nil)= 拉取过但索引为空(视作失败冷却)。 + doc map[string]modelsDevEntry + + lastFetch time.Time // 最近一次拉取尝试(成功与失败都算,冷却节流) + fetched bool // 是否已拉取过(doc 字段区分成败) + + negatives map[string]time.Time // 查询未命中的模型 → 记录时间(24h 负缓存) +} + +// modelsDev 包级拉取器实例(单例:全进程共享一份文档索引与节流状态)。 +var modelsDev = &modelsDevFetcher{} + +// resetModelsDev 测试隔离:清空单例状态(doc/fetched/lastFetch/negatives)。 +func resetModelsDev() { + modelsDev.mu.Lock() + modelsDev.doc = nil + modelsDev.fetched = false + modelsDev.lastFetch = time.Time{} + modelsDev.negatives = nil + modelsDev.mu.Unlock() +} + +// lookup 查询一个模型的 (context, output, found): +// - 索引命中且值合法 → found=true; +// - 索引未命中(含索引尚未就绪)→ 记入 negatives(既是 24h 负缓存,也是 +// fetchDoc 成功后 backfillMisses 的回流清单——「曾 miss 过的模型」),found=false。 +// +// 只读内存索引,不发网络请求;网络动作由 ensureDocAsync(goroutine 内)负责。 +// 注意:doc 就绪前的 miss 也记 negatives——backfillMisses 回流时查到即写 +// model.json 并清除负缓存条目(freshLookup),查不到的保持 24h 负缓存。 +func (f *modelsDevFetcher) lookup(model string) (modelsDevEntry, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.doc != nil { + if e, ok := f.doc[model]; ok { + return e, true + } + } + // 未命中(索引在但模型不在,或索引尚未就绪):记 miss。 + if f.negatives == nil { + f.negatives = make(map[string]time.Time) + } + now := time.Now() + // 未命中「重复查询」不再刷新记录时刻:TTL 只从**首次未命中**起算。 + // 否则覆盖写等于每次查询都把条目续期——一个持续出现在 /v1/models 名单里的 + // 未知模型(每条 listing 经 ContextWindowListingV4 + MaxOutputTokensListingV4 + // 各查一次,即每请求 2 次写回)其条目永远 fresh,下面的惰性淘汰一条也扫不掉; + // 又因淘汰只在超软上限时才扫,规模超限后只增不减(进程生命周期内无界增长)。 + // 续期对行为零影响:negativeFresh 只判是否存在 + within TTL,未过 TTL 的条目 + // 无论是否续期都同样短路第 4 级触发。 + if _, seen := f.negatives[model]; !seen { + f.negatives[model] = now + } + // 惰性淘汰已过期的负缓存条目:TTL 到期后 negativeFresh 本就判 false(等效不存在), + // 条目继续留着只是内存泄漏——models.dev 永不收录的模型名(model 由客户端任意指定) + // 查一次就永久驻留,而全库唯一的删除点 backfillMisses 只删「文档里查到」的模型, + // 永远不会回收这些条目,map 在进程生命周期内无界增长。 + // 仅在规模超软上限时才扫一遍(摊销 O(1),理由见 modelsDevNegativesSoftCap 注释)。 + if len(f.negatives) > modelsDevNegativesSoftCap { + for m, t := range f.negatives { + if now.Sub(t) >= modelsDevNegativeTTL { + delete(f.negatives, m) + } + } + } + // 硬上限兜底:TTL 未到期的条目本就无可淘汰(上一轮扫描已删净过期项),若规模 + // 仍超硬上限说明输入模型名太多,按时间序丢弃最旧条目、削回软上限 + // (纯性能优化:负缓存 miss 只会多查一次进程内索引,丢条目无语义影响)。 + if len(f.negatives) > modelsDevNegativesHardCap { + cut := len(f.negatives) - modelsDevNegativesSoftCap + oldest := make([]string, 0, len(f.negatives)) + for m := range f.negatives { + oldest = append(oldest, m) + } + sort.Slice(oldest, func(i, j int) bool { return f.negatives[oldest[i]].Before(f.negatives[oldest[j]]) }) + for _, m := range oldest[:cut] { + delete(f.negatives, m) + } + } + return modelsDevEntry{}, false +} + +// negativeFresh 模型是否在负缓存有效期内(供查找链短路第 4 级触发)。 +func (f *modelsDevFetcher) negativeFresh(model string) bool { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.negatives[model] + return ok && time.Since(t) < modelsDevNegativeTTL +} + +// ensureDocAsync 确保 models.dev 文档索引可用(异步,不阻塞调用方): +// 已有索引 / 拉取冷却期内 / 已有人在拉(in-flight 去重)→ 直接返回。 +// 否则起 goroutine 拉取解析,完成后落 f.doc(失败静默:只刷新 lastFetch 冷却, +// 下次查找仍走 1M 兜底,不重试风暴)。 +func (f *modelsDevFetcher) ensureDocAsync(client *http.Client, baseOverride string) { + f.mu.Lock() + if f.doc != nil { + f.mu.Unlock() + return + } + if f.fetched && time.Since(f.lastFetch) < modelsDevFetchCooldown { + // 拉取过(成功或失败)且冷却期内:不再打 models.dev。 + f.mu.Unlock() + return + } + // in-flight 去重:把 fetched/lastFetch 先置为「本次进行中」, + // 后续并发调用在冷却窗口内直接返回,不重复起拉取。 + f.fetched = true + f.lastFetch = time.Now() + f.mu.Unlock() + + go f.fetchDoc(client, baseOverride) +} + +// fetchDoc 拉取并解析 models.dev 文档,建裸 id 索引(goroutine 内执行,永不 panic +// 上抛:任何失败只静默冷却)。 +// 拒绝 nil client(不回落 http.DefaultClient):生产调用方恒传非 nil,nil 只意味着 +// 测试疏漏——DefaultClient 无超时(挂起隐患)且会打真网(测试污染 + 不确定延迟), +// 静默 WARN + 返回(与 fetch 失败同语义,降级 1M 兜底)让疏漏显式化。 +func (f *modelsDevFetcher) fetchDoc(client *http.Client, baseOverride string) { + if client == nil { + log.Printf("WARN: [upstream] models.dev fetch: nil client rejected (no DefaultClient fallback, silent fallback to 1M)") + return + } + url := ModelsDevURL + if baseOverride != "" { + url = baseOverride + } + ctx, cancel := context.WithTimeout(context.Background(), modelsDevTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch: build request: %v", err) + return + } + resp, err := client.Do(req) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch failed (silent fallback to 1M): %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("WARN: [upstream] models.dev fetch status %d (silent fallback to 1M)", resp.StatusCode) + return + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, modelsDevMaxBody)) + if err != nil { + log.Printf("WARN: [upstream] models.dev fetch read: %v", err) + return + } + doc, err := parseModelsDevDoc(raw) + if err != nil { + log.Printf("WARN: [upstream] models.dev parse failed (silent fallback to 1M): %v", err) + return + } + f.mu.Lock() + f.doc = doc + f.mu.Unlock() + // 文档就绪后把「曾 miss 过的模型」回流 model.json(第 4 级 → 第 3 级, + // 下次 /v1/models 直接命中缓存)。仍查不到的保持负缓存。 + f.backfillMisses() +} + +// parseModelsDevDoc 解析 models.dev api.json:{provider:{models:{id:{limit:{context, +// output}}}}} → 裸 id 索引。同名多 provider 采值优先级四级:官方 vendor 源 +// (modelsDevVendorSources)> 票数众数 > provider 字典序 > 先出现。 +// 第 3 级 provider 字典序是确定性 tie-break:聚合时维护候选的最小 provider 名 +// (minProvider,同 doc 稳定的选择器身份),消灭 map 迭代序随机化导致的 +// 「同票先到先得」值抖动(同 binary 两次拉取同一文档可能落不同的值进 model.json, +// /v1/models 的 context_length 不可复现)。不引入「值字典序」——那会把 +// 「选谁」变成「选什么值」的启发式,语义不如 provider 名干净。 +func parseModelsDevDoc(raw []byte) (map[string]modelsDevEntry, error) { + var doc map[string]struct { + Models map[string]struct { + Limit *struct { + Context int64 `json:"context"` + Output int64 `json:"output"` + } `json:"limit"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("models.dev doc: %w", err) + } + // 同名 id 的候选值收集:vendorOfficial 标记官方源,votes 计众数, + // minProvider 维护该候选已见的最小 provider 名(tie-break 用)。 + type candidate struct { + entry modelsDevEntry + vendor bool + votes int + aggKey string // 去重聚合 key(同值多 provider 只计票不重复存) + minProvider string + } + byModel := map[string][]candidate{} + for provider, pv := range doc { + for fullID, mv := range pv.Models { + if mv.Limit == nil { + continue + } + id := fullID + if i := strings.LastIndex(fullID, "/"); i >= 0 { + id = fullID[i+1:] + } + if id == "" { + continue + } + // 值校验(任务书 §2):正数 + 量级上限,脏值不进索引。 + ctx, out := mv.Limit.Context, mv.Limit.Output + if ctx <= 0 || ctx > modelsDevValueMax { + continue + } + if out < 0 || out > modelsDevValueMax { + continue + } + key := fmt.Sprintf("%d/%d", ctx, out) + cs := byModel[id] + dup := false + for i := range cs { + if cs[i].aggKey == key { + cs[i].votes++ + if modelsDevVendorSources[provider] { + cs[i].vendor = true + } + if provider < cs[i].minProvider { + cs[i].minProvider = provider + } + dup = true + break + } + } + if !dup { + byModel[id] = append(cs, candidate{ + entry: modelsDevEntry{Context: ctx, Output: out}, + vendor: modelsDevVendorSources[provider], + votes: 1, + aggKey: key, + minProvider: provider, + }) + } + } + } + out := make(map[string]modelsDevEntry, len(byModel)) + for id, cs := range byModel { + best := 0 + for i, c := range cs { + // 优先级:官方 vendor 源 > 票数众数 > provider 字典序(tie-break 确定性)。 + cur := cs[best] + better := false + if c.vendor && !cur.vendor { + better = true + } else if c.vendor == cur.vendor && c.votes > cur.votes { + better = true + } else if c.vendor == cur.vendor && c.votes == cur.votes && c.minProvider < cur.minProvider { + better = true + } + if better { + best = i + } + } + out[id] = cs[best].entry + } + return out, nil +} diff --git a/internal/upstream/payload.go b/internal/upstream/payload.go index 45b29d477ca004dffdf4a169f2da1f9ca800880b..635f62e0c0d105f22ef53cedf7000eb53e6503cf 100644 --- a/internal/upstream/payload.go +++ b/internal/upstream/payload.go @@ -1,401 +1,439 @@ -// payload.go 改写发往上游的 chat 请求体: -// 1. 强制 stream:true(上游拒绝非流式) -// 2. tool_choice 归一化(上游该字段是 string,对象形式会 400 code=11101) -// 3. image_url 归一化(上游只认 OpenAI 对象形态,字符串会 400 code=11101) -package upstream - -import ( - "encoding/json" - "log" - "strings" -) - -// PrepareBodyOpt 单 pass 改写;sanitize=false 时行为完全还原(仅强制 stream + 归一化 tool_choice)。 -func PrepareBodyOpt(src []byte, sanitize bool) []byte { - return PrepareBodyOptWithEffortsAndDefault(src, sanitize, nil, nil) -} - -// PrepareBodyOptWithEfforts 在 PrepareBodyOpt 基础上按模型 supportedEfforts 降级 reasoning_effort: -// 仅当请求显式携带且模型不支持该档位时,改为 ≤请求档位的最高支持档;支持档全部高于请求档时取最低档; -// 未知模型/未知档位/未携带该字段一律透传。efforts 为 nil 表示未知(不降级)。 -// -// 向后兼容封装:不传 defaultEfforts(无模型声明默认档),thinking.go 回退硬编码 high。 -func PrepareBodyOptWithEfforts(src []byte, sanitize bool, efforts map[string][]string) []byte { - return PrepareBodyOptWithEffortsAndDefault(src, sanitize, efforts, nil) -} - -// PrepareBodyOptWithEffortsAndDefault 完整管线:efforts 降级 + thinking.go 按 -// defaultEfforts(模型声明默认档)补档。defaultEfforts 为 nil 时与旧行为一致 -// (deepseek 缺档回退硬编码 high)。 -func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[string][]string, defaultEfforts map[string]string) []byte { - if len(src) == 0 { - return src - } - var obj map[string]any - if err := json.Unmarshal(src, &obj); err != nil { - return src - } - obj["stream"] = true - // max_completion_tokens → max_tokens 翻译(吸收上游 PR #116,Closes #117): - // OpenAI 规范里 max_tokens 已 deprecated、max_completion_tokens 是新字段; - // DeepSeek Harness 等新客户端只发别名。WorkBuddy 上游(CN /v2 与 global - // /console 同源)只认 max_tokens——别名透传会被上游忽略后回落默认输出上限 - // (实测 32000),长流任务被截。 - translateMaxCompletionTokens(obj) - // stream_options 仅当 body 未显式带时补 {include_usage: true}(D7): - // 官方 CLI 流式必发该字段,上游据此在末帧返回 usage 用量;显式带则不覆盖。 - if _, has := obj["stream_options"]; !has { - obj["stream_options"] = map[string]any{"include_usage": true} - } - normalizeToolChoice(obj) - normalizeToolPatterns(obj) - normalizeRoles(obj) - normalizeImageURL(obj) - // tool 配对两步(见 tool_pairing.go):先重排再清理。所有模型一律执行(独立于 - // deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的 - // tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除; - // 插在结果中间的非 tool 消息(Codex image_resize_notice)同样判配对断裂, - // 先 repack 挪后,再 cleanup 删孤儿,两侧同口径。 - if msgs, ok := obj["messages"].([]any); ok { - msgs, _ = repackToolResultBlocks(msgs) - msgs, _ = cleanupOrphanToolCalls(msgs) - // 无改动时两步都返回原 slice,这里回写等于零操作;任一步重排/删除 - // (哪怕后续步骤零改动)也必须落到 obj——不能只在「最后一步改动」时回写, - // 否则 repack 单独生效的结果会被原 slice 覆盖丢失。 - obj["messages"] = msgs - } - // DeepSeek 思维链开关(见 thinking.go):注入 thinking.type=enabled + 缺档补默认档。 - // 先于 normalizeReasoningEffort 执行:补入的默认档也要走既有降级管线, - // 模型不支持默认档时自动落到 ≤ 默认档的最高支持档(不出站不合规档位)。 - modelName, _ := obj["model"].(string) - injectThinking(obj, lookupDefaultEffort(defaultEfforts, modelName)) - normalizeReasoningEffort(obj, efforts) - // DeepSeek 多轮一致性:assistant 消息带 reasoning 痕迹时回填 reasoning_content - // (requiresReasoningContentOnAssistantMessages,见 thinking.go)。 - backfillReasoningContent(obj) - if sanitize { - if msgs, ok := obj["messages"].([]any); ok { - sanitizeMessages(msgs) - } - } - out, err := json.Marshal(obj) - if err != nil { - return src - } - return out -} - -// translateMaxCompletionTokens 把 OpenAI 别名 max_completion_tokens 翻译为上游 -// 认的 max_tokens(吸收上游 PR #116)。规则:显式 max_tokens 优先(别名只删); -// 别名非正数值(0/null/负数)不翻译(0/null 语义是「未设置」,负数是非法值, -// 翻译等于把垃圾搬进 max_tokens);非数值别名(字符串等畸形)不翻译(原样 -// 透传由上游报 11101 参数错)。两域同口径:CN /v2 与 global /console 是同一套 -// API,翻译不分 realm。 -func translateMaxCompletionTokens(obj map[string]any) { - alias, has := obj["max_completion_tokens"] - delete(obj, "max_completion_tokens") // 无论翻译与否,别名一律删(减少 body 体积与排障噪音) - if !has { - return - } - if _, explicit := obj["max_tokens"]; explicit { - return // 显式 max_tokens 优先:别名只删不译 - } - // json.Unmarshal 数字 → float64(整数去整后回写,避免 1.28e5 科学计数法/小数 - // 尾巴进上游 body);其他数值类型防御性兼容(int 家族——手构造 map 的调用方)。 - switch v := alias.(type) { - case float64: - if v > 0 && v == float64(int64(v)) { - obj["max_tokens"] = int64(v) - } - case int64: - if v > 0 { - obj["max_tokens"] = v - } - case int: - if v > 0 { - obj["max_tokens"] = int64(v) - } - } -} - -// effortRank 档位从低到高。 -var effortRank = map[string]int{"off": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6} - -// normalizeReasoningEffort 按模型 supportedEfforts 降级 reasoning_effort(snake/camel 双字段兼容)。 -// - 请求档位模型支持 → 原样透传 -// - 请求档位不支持 → 改为 ≤请求档位的最高支持档(降级) -// - 支持档全部高于请求档 → 取最低支持档(偏离最小) -// - 未知模型/未知档位/未携带字段/模型未缓存 → 一律透传 -func normalizeReasoningEffort(obj map[string]any, efforts map[string][]string) { - if len(efforts) == 0 { - return - } - model, _ := obj["model"].(string) - if model == "" { - return - } - supported, ok := efforts[model] - if !ok || len(supported) == 0 { - return - } - key := "" - if _, present := obj["reasoning_effort"]; present { - key = "reasoning_effort" - } else if _, present := obj["reasoningEffort"]; present { - key = "reasoningEffort" - } else { - return - } - reqStr, ok := obj[key].(string) - if !ok { - return - } - reqStr = strings.TrimSpace(strings.ToLower(reqStr)) - reqIdx, known := effortRank[reqStr] - if !known { - return - } - // 在 ≤请求档位的支持档里选最高档;命中且与请求不同才改写。 - best, bestIdx := "", -1 - for _, s := range supported { - idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] - if k && idx <= reqIdx && idx > bestIdx { - best, bestIdx = s, idx - } - } - if best != "" { - if !strings.EqualFold(best, reqStr) { - obj[key] = best - log.Printf("reasoning_effort downgraded model=%s %s -> %s", model, reqStr, best) - } - return - } - // 支持档全部高于请求档:取最低支持档。 - lowest, lowestIdx := "", 1<<30 - for _, s := range supported { - idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] - if k && idx < lowestIdx { - lowest, lowestIdx = s, idx - } - } - if lowest != "" { - obj[key] = lowest - log.Printf("reasoning_effort floored model=%s %s -> %s", model, reqStr, lowest) - } -} - -// normalizeRoles 把 messages 里的 developer 角色归一为 system。 -// -// 背景:上游对 messages 的 role 字段做白名单校验,developer 不在白名单内, -// 命中即 HTTP 400 code=11128。developer 是 OpenAI 新规范里 system 的别名 -// (Codex / Cursor 等新客户端用它承载 system 级指令),改写为 system 不丢语义。 -// -// 此归一化是「协议兼容」(补上游 role 白名单),不是「内容脱敏」, -// 因此有意与 SanitizeFingerprints / sanitize 参数解耦:即使 sanitize=false 也照常归一。 -// -// 只认 developer 这一个值:其余 role(system/user/assistant/tool/任意未知值)一律原样保留, -// 不合并、不重排、不删除任何消息(上游对多 system 的行为尚未实测,合并会引入新变量)。 -func normalizeRoles(obj map[string]any) { - msgs, ok := obj["messages"].([]any) - if !ok { - return - } - for i, m := range msgs { - msg, ok := m.(map[string]any) - if !ok { - continue - } - role, ok := msg["role"].(string) - if !ok { - continue - } - if strings.EqualFold(strings.TrimSpace(role), "developer") { - msg["role"] = "system" - log.Printf("role normalized developer->system idx=%d", i) - } - } -} - -// normalizeImageURL 兼容 OpenAI chat 多模态内容的两种 image_url 写法。 -// -// OpenAI Chat Completions 规范使用对象形态 {"url":"...","detail":"..."}, -// 部分客户端(以及 Responses -> Chat 转换器)会发送字符串形态 "data:..." 或 -// "https://..."。WorkBuddy 上游只接受对象形态,字符串会返回 400 code=11101 -// "cannot unmarshal string into ... ImageContent"。 -// -// 这里只做形状转换:字符串转 {"url": 原值};已有对象及其中 url/detail/mime_type -// 原样保留;空字符串、缺失值、对象内非法 url 一律不补默认值,让上游返回真实错误。 -func normalizeImageURL(obj map[string]any) { - msgs, ok := obj["messages"].([]any) - if !ok { - return - } - for _, rawMsg := range msgs { - msg, ok := rawMsg.(map[string]any) - if !ok { - continue - } - parts, ok := msg["content"].([]any) - if !ok { - continue - } - for _, rawPart := range parts { - part, ok := rawPart.(map[string]any) - if !ok || part["type"] != "image_url" { - continue - } - imageURL, ok := part["image_url"].(string) - if !ok || imageURL == "" { - continue - } - part["image_url"] = map[string]any{"url": imageURL} - } - } -} - -// ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128): -// 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。 -// 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。 -// body 不可解析时原样返回(与 prepareBody 语义一致:坏 body 不在这里二次错误化)。 -func ensureConsoleSystem(body []byte) []byte { - if len(body) == 0 { - return body - } - var obj map[string]any - if err := json.Unmarshal(body, &obj); err != nil { - return body - } - msgs, ok := obj["messages"].([]any) - if !ok || len(msgs) == 0 { - return body - } - first, ok := msgs[0].(map[string]any) - if ok { - if role, _ := first["role"].(string); strings.EqualFold(strings.TrimSpace(role), "system") { - return body // 首条已是 system:不注入 - } - } - obj["messages"] = append([]any{map[string]any{"role": "system", "content": "You are a helpful assistant."}}, msgs...) - out, err := json.Marshal(obj) - if err != nil { - return body - } - return out -} - -// normalizeToolChoice 按上游 Go struct(string 类型)改写 OpenAI tool_choice。 -// - "none" → 删 tool_choice + 删 tools/functions -// - {"type":"none"} → 同上 -// - {"type":"auto"/"required"} → 字符串 "auto"/"required" -// - {"type":"function","function":{"name":"x"}} → 字符串 "x" -// - 其他对象/非标量 → 删 tool_choice -func normalizeToolChoice(obj map[string]any) { - suppress := func() { - delete(obj, "tools") - delete(obj, "functions") - } - tc, present := obj["tool_choice"] - if !present { - return - } - switch v := tc.(type) { - case string: - if strings.EqualFold(strings.TrimSpace(v), "none") { - delete(obj, "tool_choice") - suppress() - } - case map[string]any: - typ, _ := v["type"].(string) - typ = strings.ToLower(strings.TrimSpace(typ)) - switch typ { - case "none": - delete(obj, "tool_choice") - suppress() - case "auto", "required": - obj["tool_choice"] = typ - case "function": - name := "" - if fn, ok := v["function"].(map[string]any); ok { - name, _ = fn["name"].(string) - } - if name == "" { - name, _ = v["name"].(string) - } - if name = strings.TrimSpace(name); name != "" { - obj["tool_choice"] = name - } else { - obj["tool_choice"] = "auto" - } - default: - delete(obj, "tool_choice") - } - default: - delete(obj, "tool_choice") - } -} - -// normalizeToolPatterns 归一化 tools 子树里 pattern 的非标准转义 `\_`(→ `_`)。 -// -// 上游对 tools[].function.parameters 做严格 JSON Schema/正则文法校验,pattern 含 -// `\_`(转义的字面量下划线)会整体拒收:400 code=11129 invalid_function_call_ -// parameters(displayMsg「工具定义不合规」)。`\_` 不是任何正则文法的合法转义, -// 但所有主流引擎(RE2/PCRE/JS Annex B)都宽容地视为 `_` 本身——上游校验器比它们 -// 全部更严(对照 V8 严格文法 u 标志,唯一同样拒绝的实现)。实案:ZCode 的 exa 插件 -// agent_run 工具 runId/previousRunId 带 `^agent\_run\_`,deepseek 系全家确定性 400 -// → 网关侧归 ErrClient 只换号不罚但喂连败计数 → 轮转烧满 5 连败触发连败降权、 -// 客户端 503(2026-09-29/30 两次实案)。schema 级拒绝换账号无用,只能在发送前修。 -// -// 归一无损:`\_` 与 `_` 在所有引擎匹配语义相同(各引擎实测 + 上游对照探针:归一后 -// 200),工具方功能不变。只动 tools 子树(pattern 值 + patternProperties 键); -// 消息正文里的 `\_`(如 Windows 路径 C:\_x)不碰。其余非标转义(`\:` 等)未证实 -// 触发,不扩面——有实案再议。独立于 sanitize 开关:这是「让请求通过」,不是脱敏。 -func normalizeToolPatterns(obj map[string]any) { - rawTools, ok := obj["tools"].([]any) - if !ok { - return - } - for _, raw := range rawTools { - tool, ok := raw.(map[string]any) - if !ok { - continue - } - // OpenAI 形态 tools[].function.parameters;裸 tools[].parameters 兼容。 - if fn, ok := tool["function"].(map[string]any); ok { - unescapePatternLiteralEscapes(fn["parameters"]) - } - unescapePatternLiteralEscapes(tool["parameters"]) - } -} - -// unescapePatternLiteralEscapes 递归改写 schema 树里 pattern 值与 patternProperties -// 键中的 `\_` → `_`(patternProperties 的键也是正则;map 键不可原地改,命中时重建 -// 该层)。 -func unescapePatternLiteralEscapes(node any) { - switch n := node.(type) { - case map[string]any: - if p, ok := n["pattern"].(string); ok && strings.Contains(p, `\_`) { - n["pattern"] = strings.ReplaceAll(p, `\_`, `_`) - } - if props, ok := n["patternProperties"].(map[string]any); ok { - rebuilt := false - fixed := make(map[string]any, len(props)) - for k, v := range props { - if strings.Contains(k, `\_`) { - k = strings.ReplaceAll(k, `\_`, `_`) - rebuilt = true - } - fixed[k] = v - } - if rebuilt { - n["patternProperties"] = fixed - } - } - for _, v := range n { - unescapePatternLiteralEscapes(v) - } - case []any: - for _, v := range n { - unescapePatternLiteralEscapes(v) - } - } -} +// payload.go 改写发往上游的 chat 请求体: +// 1. 强制 stream:true(上游拒绝非流式) +// 2. tool_choice 归一化(上游该字段是 string,对象形式会 400 code=11101) +// 3. image_url 归一化(上游只认 OpenAI 对象形态,字符串会 400 code=11101) +package upstream + +import ( + "encoding/json" + "log" + "strings" +) + +// PrepareBodyOpt 单 pass 改写;sanitize=false 时行为完全还原(仅强制 stream + 归一化 tool_choice)。 +func PrepareBodyOpt(src []byte, sanitize bool) []byte { + return PrepareBodyOptWithEffortsAndDefault(src, sanitize, nil, nil) +} + +// PrepareBodyOptWithEfforts 在 PrepareBodyOpt 基础上按模型 supportedEfforts 降级 reasoning_effort: +// 仅当请求显式携带且模型不支持该档位时,改为 ≤请求档位的最高支持档;支持档全部高于请求档时取最低档; +// 未知模型/未知档位/未携带该字段一律透传。efforts 为 nil 表示未知(不降级)。 +// +// 向后兼容封装:不传 defaultEfforts(无模型声明默认档),thinking.go 回退硬编码 high。 +func PrepareBodyOptWithEfforts(src []byte, sanitize bool, efforts map[string][]string) []byte { + return PrepareBodyOptWithEffortsAndDefault(src, sanitize, efforts, nil) +} + +// PrepareBodyOptWithEffortsAndDefault 完整管线:efforts 降级 + thinking.go 按 +// defaultEfforts(模型声明默认档)补档。defaultEfforts 为 nil 时与旧行为一致 +// (deepseek 缺档回退硬编码 high)。 +func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[string][]string, defaultEfforts map[string]string) []byte { + if len(src) == 0 { + return src + } + var obj map[string]any + if err := json.Unmarshal(src, &obj); err != nil { + return src + } + obj["stream"] = true + // max_completion_tokens → max_tokens 翻译(吸收上游 PR #116,Closes #117): + // OpenAI 规范里 max_tokens 已 deprecated、max_completion_tokens 是新字段; + // DeepSeek Harness 等新客户端只发别名。WorkBuddy 上游(CN /v2 与 global + // /console 同源)只认 max_tokens——别名透传会被上游忽略后回落默认输出上限 + // (实测 32000),长流任务被截。 + translateMaxCompletionTokens(obj) + clampGPTMinMaxTokens(obj) + // stream_options 仅当 body 未显式带时补 {include_usage: true}(D7): + // 官方 CLI 流式必发该字段,上游据此在末帧返回 usage 用量;显式带则不覆盖。 + if _, has := obj["stream_options"]; !has { + obj["stream_options"] = map[string]any{"include_usage": true} + } + normalizeToolChoice(obj) + normalizeToolPatterns(obj) + normalizeRoles(obj) + normalizeImageURL(obj) + // tool 配对三步(见 tool_pairing.go):先合并再重排再清理。所有模型一律执行(独立于 + // deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的 + // tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除; + // 插在结果中间的非 tool 消息(Codex image_resize_notice)同样判配对断裂, + // 先 repack 挪后,再 cleanup 删孤儿,两侧同口径。 + // + // 顺序不能换:mergeAdjacentToolCalls 必须最先跑——它把「背靠背的两条 + // assistant.tool_calls」合成一条(部分 agent 客户端回放并行调用的报文形状),是上游 + // deepseek 系模型 11148 的正面修复;先合并再 repack,repack 才看得到完整的一批调用。 + if msgs, ok := obj["messages"].([]any); ok { + msgs, _ = mergeAdjacentToolCalls(msgs) + msgs, _ = repackToolResultBlocks(msgs) + msgs, _ = cleanupOrphanToolCalls(msgs) + // 无改动时两步都返回原 slice,这里回写等于零操作;任一步重排/删除 + // (哪怕后续步骤零改动)也必须落到 obj——不能只在「最后一步改动」时回写, + // 否则 repack 单独生效的结果会被原 slice 覆盖丢失。 + obj["messages"] = msgs + } + // DeepSeek 思维链开关(见 thinking.go):注入 thinking.type=enabled + 缺档补默认档。 + // 先于 normalizeReasoningEffort 执行:补入的默认档也要走既有降级管线, + // 模型不支持默认档时自动落到 ≤ 默认档的最高支持档(不出站不合规档位)。 + modelName, _ := obj["model"].(string) + injectThinking(obj, lookupDefaultEffort(defaultEfforts, modelName)) + normalizeReasoningEffort(obj, efforts) + // DeepSeek 多轮一致性:assistant 消息带 reasoning 痕迹时回填 reasoning_content + // (requiresReasoningContentOnAssistantMessages,见 thinking.go)。 + backfillReasoningContent(obj) + if sanitize { + if msgs, ok := obj["messages"].([]any); ok { + sanitizeMessages(msgs) + } + } + out, err := json.Marshal(obj) + if err != nil { + return src + } + return out +} + +// translateMaxCompletionTokens 把 OpenAI 别名 max_completion_tokens 翻译为上游 +// 认的 max_tokens(吸收上游 PR #116)。规则:显式 max_tokens 优先(别名只删); +// 别名非正数值(0/null/负数)不翻译(0/null 语义是「未设置」,负数是非法值, +// 翻译等于把垃圾搬进 max_tokens);非数值别名(字符串等畸形)不翻译(原样 +// 透传由上游报 11101 参数错)。两域同口径:CN /v2 与 global /console 是同一套 +// API,翻译不分 realm。 +func translateMaxCompletionTokens(obj map[string]any) { + alias, has := obj["max_completion_tokens"] + delete(obj, "max_completion_tokens") // 无论翻译与否,别名一律删(减少 body 体积与排障噪音) + if !has { + return + } + if _, explicit := obj["max_tokens"]; explicit { + return // 显式 max_tokens 优先:别名只删不译 + } + // json.Unmarshal 数字 → float64(整数去整后回写,避免 1.28e5 科学计数法/小数 + // 尾巴进上游 body);其他数值类型防御性兼容(int 家族——手构造 map 的调用方)。 + switch v := alias.(type) { + case float64: + if v > 0 && v == float64(int64(v)) { + obj["max_tokens"] = int64(v) + } + case int64: + if v > 0 { + obj["max_tokens"] = v + } + case int: + if v > 0 { + obj["max_tokens"] = int64(v) + } + } +} + +// gptMinMaxTokens GPT 系上游接受的 max_tokens 下限。 +const gptMinMaxTokens = 16 + +// clampGPTMinMaxTokens 把 GPT 系模型过小的 max_tokens 抬到下限。 +// +// 背景:上游 GPT 系(实测 gpt-6-sol / gpt-6-luna / gpt-5.6-sol)对 max_tokens < 16 +// 一律 400 code=11133 model_param_invalid(15 拒、16 过,同号同 body 对照);hy4 等 +// 非 GPT 模型无此限制。Claude Code 切模型时发 max_tokens 极小的探针,全号轮转同样 +// 被拒 → 客户端 503,模型永远切不过去。账号与 body 其余部分无关,换号无用,只能 +// 在发送前修。抬到下限只放宽输出上限、不改语义;未携带字段 / 非数值 / 已达下限一律不动。 +func clampGPTMinMaxTokens(obj map[string]any) { + model, _ := obj["model"].(string) + if !strings.Contains(strings.ToLower(model), "gpt-") { + return + } + var v int64 + switch n := obj["max_tokens"].(type) { + case float64: + v = int64(n) + case int64: + v = n + case int: + v = int64(n) + default: + return + } + if v < gptMinMaxTokens { + obj["max_tokens"] = int64(gptMinMaxTokens) + log.Printf("max_tokens clamped model=%s %d -> %d", model, v, gptMinMaxTokens) + } +} + +// effortRank 档位从低到高。 +var effortRank = map[string]int{"off": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6} + +// normalizeReasoningEffort 按模型 supportedEfforts 降级 reasoning_effort(snake/camel 双字段兼容)。 +// - 请求档位模型支持 → 原样透传 +// - 请求档位不支持 → 改为 ≤请求档位的最高支持档(降级) +// - 支持档全部高于请求档 → 取最低支持档(偏离最小) +// - 未知模型/未知档位/未携带字段/模型未缓存 → 一律透传 +func normalizeReasoningEffort(obj map[string]any, efforts map[string][]string) { + if len(efforts) == 0 { + return + } + model, _ := obj["model"].(string) + if model == "" { + return + } + supported, ok := efforts[model] + if !ok || len(supported) == 0 { + return + } + key := "" + if _, present := obj["reasoning_effort"]; present { + key = "reasoning_effort" + } else if _, present := obj["reasoningEffort"]; present { + key = "reasoningEffort" + } else { + return + } + reqStr, ok := obj[key].(string) + if !ok { + return + } + reqStr = strings.TrimSpace(strings.ToLower(reqStr)) + reqIdx, known := effortRank[reqStr] + if !known { + return + } + // 在 ≤请求档位的支持档里选最高档;命中且与请求不同才改写。 + best, bestIdx := "", -1 + for _, s := range supported { + idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] + if k && idx <= reqIdx && idx > bestIdx { + best, bestIdx = s, idx + } + } + if best != "" { + if !strings.EqualFold(best, reqStr) { + obj[key] = best + log.Printf("reasoning_effort downgraded model=%s %s -> %s", model, reqStr, best) + } + return + } + // 支持档全部高于请求档:取最低支持档。 + lowest, lowestIdx := "", 1<<30 + for _, s := range supported { + idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))] + if k && idx < lowestIdx { + lowest, lowestIdx = s, idx + } + } + if lowest != "" { + obj[key] = lowest + log.Printf("reasoning_effort floored model=%s %s -> %s", model, reqStr, lowest) + } +} + +// normalizeRoles 把 messages 里的 developer 角色归一为 system。 +// +// 背景:上游对 messages 的 role 字段做白名单校验,developer 不在白名单内, +// 命中即 HTTP 400 code=11128。developer 是 OpenAI 新规范里 system 的别名 +// (Codex / Cursor 等新客户端用它承载 system 级指令),改写为 system 不丢语义。 +// +// 此归一化是「协议兼容」(补上游 role 白名单),不是「内容脱敏」, +// 因此有意与 SanitizeFingerprints / sanitize 参数解耦:即使 sanitize=false 也照常归一。 +// +// 只认 developer 这一个值:其余 role(system/user/assistant/tool/任意未知值)一律原样保留, +// 不合并、不重排、不删除任何消息(上游对多 system 的行为尚未实测,合并会引入新变量)。 +func normalizeRoles(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + for i, m := range msgs { + msg, ok := m.(map[string]any) + if !ok { + continue + } + role, ok := msg["role"].(string) + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(role), "developer") { + msg["role"] = "system" + log.Printf("role normalized developer->system idx=%d", i) + } + } +} + +// normalizeImageURL 兼容 OpenAI chat 多模态内容的两种 image_url 写法。 +// +// OpenAI Chat Completions 规范使用对象形态 {"url":"...","detail":"..."}, +// 部分客户端(以及 Responses -> Chat 转换器)会发送字符串形态 "data:..." 或 +// "https://..."。WorkBuddy 上游只接受对象形态,字符串会返回 400 code=11101 +// "cannot unmarshal string into ... ImageContent"。 +// +// 这里只做形状转换:字符串转 {"url": 原值};已有对象及其中 url/detail/mime_type +// 原样保留;空字符串、缺失值、对象内非法 url 一律不补默认值,让上游返回真实错误。 +func normalizeImageURL(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + for _, rawMsg := range msgs { + msg, ok := rawMsg.(map[string]any) + if !ok { + continue + } + parts, ok := msg["content"].([]any) + if !ok { + continue + } + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok || part["type"] != "image_url" { + continue + } + imageURL, ok := part["image_url"].(string) + if !ok || imageURL == "" { + continue + } + part["image_url"] = map[string]any{"url": imageURL} + } + } +} + +// ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128): +// 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。 +// 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。 +// body 不可解析时原样返回(与 prepareBody 语义一致:坏 body 不在这里二次错误化)。 +func ensureConsoleSystem(body []byte) []byte { + if len(body) == 0 { + return body + } + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return body + } + msgs, ok := obj["messages"].([]any) + if !ok || len(msgs) == 0 { + return body + } + first, ok := msgs[0].(map[string]any) + if ok { + if role, _ := first["role"].(string); strings.EqualFold(strings.TrimSpace(role), "system") { + return body // 首条已是 system:不注入 + } + } + obj["messages"] = append([]any{map[string]any{"role": "system", "content": "You are a helpful assistant."}}, msgs...) + out, err := json.Marshal(obj) + if err != nil { + return body + } + return out +} + +// normalizeToolChoice 按上游 Go struct(string 类型)改写 OpenAI tool_choice。 +// - "none" → 删 tool_choice + 删 tools/functions +// - {"type":"none"} → 同上 +// - {"type":"auto"/"required"} → 字符串 "auto"/"required" +// - {"type":"function","function":{"name":"x"}} → 字符串 "x" +// - 其他对象/非标量 → 删 tool_choice +func normalizeToolChoice(obj map[string]any) { + suppress := func() { + delete(obj, "tools") + delete(obj, "functions") + } + tc, present := obj["tool_choice"] + if !present { + return + } + switch v := tc.(type) { + case string: + if strings.EqualFold(strings.TrimSpace(v), "none") { + delete(obj, "tool_choice") + suppress() + } + case map[string]any: + typ, _ := v["type"].(string) + typ = strings.ToLower(strings.TrimSpace(typ)) + switch typ { + case "none": + delete(obj, "tool_choice") + suppress() + case "auto", "required": + obj["tool_choice"] = typ + case "function": + name := "" + if fn, ok := v["function"].(map[string]any); ok { + name, _ = fn["name"].(string) + } + if name == "" { + name, _ = v["name"].(string) + } + if name = strings.TrimSpace(name); name != "" { + obj["tool_choice"] = name + } else { + obj["tool_choice"] = "auto" + } + default: + delete(obj, "tool_choice") + } + default: + delete(obj, "tool_choice") + } +} + +// normalizeToolPatterns 归一化 tools 子树里 pattern 的非标准转义 `\_`(→ `_`)。 +// +// 上游对 tools[].function.parameters 做严格 JSON Schema/正则文法校验,pattern 含 +// `\_`(转义的字面量下划线)会整体拒收:400 code=11129 invalid_function_call_ +// parameters(displayMsg「工具定义不合规」)。`\_` 不是任何正则文法的合法转义, +// 但所有主流引擎(RE2/PCRE/JS Annex B)都宽容地视为 `_` 本身——上游校验器比它们 +// 全部更严(对照 V8 严格文法 u 标志,唯一同样拒绝的实现)。实案:ZCode 的 exa 插件 +// agent_run 工具 runId/previousRunId 带 `^agent\_run\_`,deepseek 系全家确定性 400 +// → 网关侧归 ErrClient 只换号不罚但喂连败计数 → 轮转烧满 5 连败触发连败降权、 +// 客户端 503(2026-09-29/30 两次实案)。schema 级拒绝换账号无用,只能在发送前修。 +// +// 归一无损:`\_` 与 `_` 在所有引擎匹配语义相同(各引擎实测 + 上游对照探针:归一后 +// 200),工具方功能不变。只动 tools 子树(pattern 值 + patternProperties 键); +// 消息正文里的 `\_`(如 Windows 路径 C:\_x)不碰。其余非标转义(`\:` 等)未证实 +// 触发,不扩面——有实案再议。独立于 sanitize 开关:这是「让请求通过」,不是脱敏。 +func normalizeToolPatterns(obj map[string]any) { + rawTools, ok := obj["tools"].([]any) + if !ok { + return + } + for _, raw := range rawTools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + // OpenAI 形态 tools[].function.parameters;裸 tools[].parameters 兼容。 + if fn, ok := tool["function"].(map[string]any); ok { + unescapePatternLiteralEscapes(fn["parameters"]) + } + unescapePatternLiteralEscapes(tool["parameters"]) + } +} + +// unescapePatternLiteralEscapes 递归改写 schema 树里 pattern 值与 patternProperties +// 键中的 `\_` → `_`(patternProperties 的键也是正则;map 键不可原地改,命中时重建 +// 该层)。 +func unescapePatternLiteralEscapes(node any) { + switch n := node.(type) { + case map[string]any: + if p, ok := n["pattern"].(string); ok && strings.Contains(p, `\_`) { + n["pattern"] = strings.ReplaceAll(p, `\_`, `_`) + } + if props, ok := n["patternProperties"].(map[string]any); ok { + rebuilt := false + fixed := make(map[string]any, len(props)) + for k, v := range props { + if strings.Contains(k, `\_`) { + k = strings.ReplaceAll(k, `\_`, `_`) + rebuilt = true + } + fixed[k] = v + } + if rebuilt { + n["patternProperties"] = fixed + } + } + for _, v := range n { + unescapePatternLiteralEscapes(v) + } + case []any: + for _, v := range n { + unescapePatternLiteralEscapes(v) + } + } +} diff --git a/internal/upstream/payload_test.go b/internal/upstream/payload_test.go index 3888e204b089aea63f710f93ca71c536b37f75eb..b02033df533a5b59810fffaacb6e85469b0cd3e7 100644 --- a/internal/upstream/payload_test.go +++ b/internal/upstream/payload_test.go @@ -1,430 +1,467 @@ -package upstream - -import ( - "encoding/json" - "reflect" - "strings" - "testing" -) - -// TestNormalizeRoles 验证出站请求体把 developer 角色归一为 system。 -// 上游 role 白名单不含 developer(OpenAI 新规范的 system 别名), -// 命中即 HTTP 400 code=11128;此处走 PrepareBodyOptWithEfforts 全链路断言。 -func TestNormalizeRoles(t *testing.T) { - cases := []struct { - name string - body string - wantRoles []string // 与输出 messages 逐条对应的期望 role;len 即消息数 - }{ - {"developer 改写为 system", - `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, - {"Developer 首字母大写改写", - `{"messages":[{"role":"Developer","content":"x"}]}`, []string{"system"}}, - {"DEVELOPER 全大写改写", - `{"messages":[{"role":"DEVELOPER","content":"x"}]}`, []string{"system"}}, - {"前后空白 TrimSpace 后改写", - `{"messages":[{"role":" developer ","content":"x"}]}`, []string{"system"}}, - {"system 原样保留", - `{"messages":[{"role":"system","content":"x"}]}`, []string{"system"}}, - {"user 原样保留", - `{"messages":[{"role":"user","content":"x"}]}`, []string{"user"}}, - {"assistant 原样保留", - `{"messages":[{"role":"assistant","content":"x"}]}`, []string{"assistant"}}, - {"tool 原样保留(不因未知而改写)", - `{"messages":[{"role":"tool","content":"x"}]}`, []string{"tool"}}, - {"messages 缺失不 panic 且其余字段不变", - `{"model":"glm-5.2"}`, []string{}}, - {"messages 为空数组不 panic", - `{"messages":[]}`, []string{}}, - {"混合消息仅 developer 被改写", - `{"messages":[{"role":"developer","content":"a"},{"role":"user","content":"b"},{"role":"developer","content":"c"}]}`, - []string{"system", "user", "system"}}, - {"sanitize=false 时仍归一(与脱敏解耦)", - `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, - {"非对象消息元素跳过、其余正常处理", - `{"messages":["str",{"role":"developer","content":"x"},42]}`, []string{"system"}}, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - // 全程 sanitize=false:验证 role 归一与内容脱敏开关无关(D4)。 - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - var obj map[string]any - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatalf("unmarshal: %v (out=%s)", err, out) - } - - // 提取输出 messages 里的 role(非对象元素跳过,不 panic)。 - var got []string - if msgs, ok := obj["messages"].([]any); ok { - for _, m := range msgs { - msg, ok := m.(map[string]any) - if !ok { - continue - } - if role, ok := msg["role"].(string); ok { - got = append(got, role) - } - } - } - - if len(got) != len(c.wantRoles) { - t.Fatalf("role 数量不符: got %v (%d) want %v (%d)", got, len(got), c.wantRoles, len(c.wantRoles)) - } - for i := range got { - if got[i] != c.wantRoles[i] { - t.Errorf("role[%d] = %q want %q", i, got[i], c.wantRoles[i]) - } - } - }) - } - - // messages 缺失时,其余字段必须原样保留(除强制 stream)。 - t.Run("messages 缺失时其余字段不变", func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","temperature":0.7}`), false, nil) - var obj map[string]any - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if obj["model"] != "glm-5.2" || obj["temperature"] != 0.7 { - t.Errorf("其余字段被改动: %v", obj) - } - }) -} - -func TestPrepareBodyOptWithEfforts(t *testing.T) { - efforts := map[string][]string{ - "glm-5.2": {"off", "low", "high"}, - "glm-5.2-mini": {"low", "medium"}, - "glm-5.2-max": {"high", "xhigh"}, - } - cases := []struct { - name string - body string - efforts map[string][]string - wantKey string // 输出应带有的 effort 字段名;空表示该字段应不存在 - wantVal string // 期望值 - }{ - {"downgrade to highest supported at or below request", - `{"model":"glm-5.2-mini","reasoning_effort":"high"}`, efforts, "reasoning_effort", "medium"}, - {"floor to lowest when all supported above request", - `{"model":"glm-5.2-max","reasoning_effort":"low"}`, efforts, "reasoning_effort", "high"}, - {"supported effort passes through unchanged", - `{"model":"glm-5.2","reasoning_effort":"low"}`, efforts, "reasoning_effort", "low"}, - {"camelCase field name downgrades and keeps key", - `{"model":"glm-5.2-mini","reasoningEffort":"high"}`, efforts, "reasoningEffort", "medium"}, - {"unknown model passes through", - `{"model":"unknown","reasoning_effort":"max"}`, efforts, "reasoning_effort", "max"}, - {"unknown effort value passes through", - `{"model":"glm-5.2","reasoning_effort":"ultra"}`, efforts, "reasoning_effort", "ultra"}, - {"empty cache passes through", - `{"model":"glm-5.2","reasoning_effort":"max"}`, map[string][]string{}, "reasoning_effort", "max"}, - {"no effort field untouched", - `{"model":"glm-5.2-mini","messages":[]}`, efforts, "", ""}, - {"nil efforts map passes through", - `{"model":"glm-5.2","reasoning_effort":"max"}`, nil, "reasoning_effort", "max"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(c.body), false, c.efforts) - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("unmarshal: %v (body=%s)", err, out) - } - if c.wantKey == "" { - if _, ok := m["reasoning_effort"]; ok { - t.Errorf("reasoning_effort should be absent, got %v", m["reasoning_effort"]) - } - if _, ok := m["reasoningEffort"]; ok { - t.Errorf("reasoningEffort should be absent, got %v", m["reasoningEffort"]) - } - return - } - got, ok := m[c.wantKey].(string) - if !ok || got != c.wantVal { - t.Errorf("%s: got %v (%T) want %q", c.wantKey, m[c.wantKey], m[c.wantKey], c.wantVal) - } - }) - } -} - -// TestPrepareBodyStreamOptions body 未显式带 stream_options 时注入 -// {include_usage: true}(D7,官方 CLI 流式必发);body 已带则不覆盖。 -func TestPrepareBodyStreamOptions(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","messages":[]}`), false, nil) - var obj map[string]any - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatalf("unmarshal: %v (out=%s)", err, out) - } - so, ok := obj["stream_options"].(map[string]any) - if !ok { - t.Fatalf("stream_options not injected: %v", obj["stream_options"]) - } - if so["include_usage"] != true { - t.Errorf("stream_options.include_usage = %v want true", so["include_usage"]) - } - - out2 := PrepareBodyOptWithEffertsPreserve(t, `{"model":"glm-5.2","messages":[],"stream_options":{"include_usage":false}}`) - obj2, err := decodeBody(out2) - if err != nil { - t.Fatalf("unmarshal: %v", err) - } - so2, ok := obj2["stream_options"].(map[string]any) - if !ok { - t.Fatalf("stream_options lost: %v", obj2["stream_options"]) - } - if so2["include_usage"] != false { - t.Errorf("stream_options.include_usage = %v want false (not overwritten)", so2["include_usage"]) - } -} - -// PrepareBodyOptWithEffertsPreserve helper:PrepareBodyOptWithEfforts 包装。 -func PrepareBodyOptWithEffertsPreserve(t *testing.T, body string) []byte { - t.Helper() - return PrepareBodyOptWithEfforts([]byte(body), false, nil) -} - -// decodeBody helper:解析 body JSON。 -func decodeBody(b []byte) (map[string]any, error) { - var obj map[string]any - err := json.Unmarshal(b, &obj) - return obj, err -} - -// TestPrepareBodyDeterministic 序列化稳定性:同输入跑多遍出站字节级一致 -// (prompt_cache_key 前缀命中的前提——链中不得注入时间/随机/ID 类不确定源)。 -func TestPrepareBodyDeterministic(t *testing.T) { - inputs := []string{ - `{"model":"glm-5.2","messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"你好"}],"reasoning_effort":"high"}`, - `{"model":"deepseek-v4","messages":[{"role":"user","content":"写个函数"}],"tool_choice":{"type":"auto"},"tools":[{"type":"function","function":{"name":"f"}}]}`, - `{"model":"glm-5.3","messages":[{"role":"developer","content":"sys"},{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, - } - for i, in := range inputs { - var first []byte - for round := 0; round < 5; round++ { - out := PrepareBodyOptWithEfforts([]byte(in), true, map[string][]string{"glm-5.2": {"off", "low", "high"}}) - if round == 0 { - first = out - continue - } - if string(out) != string(first) { - t.Fatalf("input #%d round %d differs from round 0:\n%s\n%s", i, round, first, out) - } - } - } -} - -// TestNormalizeImageURL 覆盖 OpenAI chat 多模态内容的 image_url 兼容: -// 字符串形态必须转为上游需要的对象形态;对象形态及其中字段必须原样保留; -// 无效输入不补默认值,继续交给上游返回真实错误。 -func TestNormalizeImageURL(t *testing.T) { - tests := []struct { - name string - body string - want any - }{ - { - name: "data url string to object", - body: `{"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":"data:image/png;base64,QUJD"}]}]}`, - want: map[string]any{"url": "data:image/png;base64,QUJD"}, - }, - { - name: "http url string to object", - body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":"https://example.test/a.png"}]}]}`, - want: map[string]any{"url": "https://example.test/a.png"}, - }, - { - name: "object with detail preserved", - body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD","detail":"low","mime_type":"image/png"}}]}]}`, - want: map[string]any{"url": "data:image/png;base64,QUJD", "detail": "low", "mime_type": "image/png"}, - }, - { - name: "invalid object url type preserved", - body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":123}}]}]}`, - want: map[string]any{"url": float64(123)}, - }, - { - name: "missing image url preserved", - body: `{"messages":[{"role":"user","content":[{"type":"image_url"}]}]}`, - want: nil, - }, - { - name: "empty string preserved", - body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":""}]}]}`, - want: "", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - for _, sanitize := range []bool{false, true} { - out := PrepareBodyOptWithEfforts([]byte(tc.body), sanitize, nil) - obj, err := decodeBody(out) - if err != nil { - t.Fatalf("sanitize=%v unmarshal: %v (out=%s)", sanitize, err, out) - } - msgs := obj["messages"].([]any) - content := msgs[0].(map[string]any)["content"].([]any) - var part map[string]any - for _, rawPart := range content { - candidate, ok := rawPart.(map[string]any) - if ok && candidate["type"] == "image_url" { - part = candidate - break - } - } - if part == nil { - t.Fatal("image_url part not found") - } - if tc.want == nil { - if _, exists := part["image_url"]; exists { - t.Fatalf("sanitize=%v: missing image_url should stay missing, got %#v", sanitize, part) - } - continue - } - if got := part["image_url"]; !reflect.DeepEqual(got, tc.want) { - t.Errorf("sanitize=%v: image_url=%#v want %#v", sanitize, got, tc.want) - } - } - }) - } -} - -// TestNormalizeToolPatterns 工具 schema pattern 的 `\_` 转义归一(11129 实案: -// exa agent_run 的 `^agent\_run\_` 被 deepseek 系确定性拒收,归一后上游 200)。 -// 覆盖:function.parameters 与裸 parameters 两形态、嵌套 schema、patternProperties -// 键、消息正文不碰、无 tools no-op、sanitize 开关两态一致。 -func TestNormalizeToolPatterns(t *testing.T) { - lookupPattern := func(t *testing.T, body string, path ...any) string { - t.Helper() - var obj map[string]any - if err := json.Unmarshal([]byte(body), &obj); err != nil { - t.Fatalf("out not json: %v", err) - } - cur := any(obj) - for _, p := range path { - switch k := p.(type) { - case string: - m, ok := cur.(map[string]any) - if !ok { - t.Fatalf("path %v: want object, got %T", path, cur) - } - cur = m[k] - case int: - l, ok := cur.([]any) - if !ok || k >= len(l) { - t.Fatalf("path %v: bad array at %T", path, cur) - } - cur = l[k] - } - } - s, _ := cur.(string) - return s - } - - t.Run("exa agent_run pattern normalized", func(t *testing.T) { - bs := string(byte(92)) // 反斜杠,测试体经工具链多层转义易被吞,运行时拼装保真 - body := `{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"agent_run","parameters":{"type":"object","properties":{"runId":{"type":"string","pattern":"^agent` + bs + bs + `_run` + bs + bs + `_"}` + `,"query":{"type":"string"}},"required":["query"]}}}]}` //nolint:lll // 实案 body 原样 - out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) - if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "runId", "pattern"); got != `^agent_run_` { - t.Fatalf("pattern = %q, want %q", got, `^agent_run_`) - } - // 同 schema 的兄弟字段不受影响 - if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "query", "type"); got != "string" { - t.Fatalf("sibling field disturbed: %q", got) - } - }) - - t.Run("nested anyOf items pattern normalized", func(t *testing.T) { - bs := string(byte(92)) - body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"anyOf":[{"properties":{"code":{"pattern":"x` + bs + bs + `_y"}}},{"type":"string"}]}}}]}` //nolint:lll - out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) - if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "anyOf", 0, "properties", "code", "pattern"); got != "x_y" { - t.Fatalf("nested pattern = %q, want %q", got, "x_y") - } - }) - - t.Run("bare tool parameters form normalized", func(t *testing.T) { - bs := string(byte(92)) - body := `{"messages":[],"tools":[{"name":"t","parameters":{"properties":{"id":{"pattern":"p` + bs + bs + `_q"}}}}]}` - out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) - if got := lookupPattern(t, out, "tools", 0, "parameters", "properties", "id", "pattern"); got != "p_q" { - t.Fatalf("bare parameters pattern = %q, want %q", got, "p_q") - } - }) - - t.Run("patternProperties key normalized", func(t *testing.T) { - bs := string(byte(92)) - body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"patternProperties":{"^a` + bs + bs + `_b` + bs + bs + `_":{"type":"string"}}}}}]}` //nolint:lll - var obj map[string]any - out := PrepareBodyOptWithEfforts([]byte(body), false, nil) - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatalf("out not json: %v", err) - } - tool := obj["tools"].([]any)[0].(map[string]any)["function"].(map[string]any)["parameters"].(map[string]any) - props := tool["patternProperties"].(map[string]any) - if _, ok := props["^a_b_"]; !ok { - t.Fatalf("normalized key missing: %v", props) - } - for k := range props { - if strings.Contains(k, string(byte(92))) { - t.Fatalf("patternProperties key not normalized: %q", k) - } - } - }) - - t.Run("message content backslash untouched", func(t *testing.T) { - bs := string(byte(92)) - raw := `{"messages":[{"role":"user","content":"path C:` + bs + bs + `_dir and regex a` + bs + bs + `_b"}],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"q` + bs + bs + `_r"}}}}}]}` //nolint:lll - out := string(PrepareBodyOptWithEfforts([]byte(raw), false, nil)) - var obj map[string]any - if err := json.Unmarshal([]byte(out), &obj); err != nil { - t.Fatalf("out not json: %v", err) - } - msg := obj["messages"].([]any)[0].(map[string]any) - if got := msg["content"]; got != `path C:\_dir and regex a\_b` { - t.Fatalf("message content mutated: %q", got) - } - }) - - t.Run("clean pattern untouched", func(t *testing.T) { - bs := string(byte(92)) - body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"^[a-z]+` + bs + bs + `d_$"}}}}}]}` //nolint:lll - out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) - if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != `^[a-z]+\d_$` { - t.Fatalf("clean pattern mutated: %q", got) - } - }) - - t.Run("no tools is no-op", func(t *testing.T) { - bs := string(byte(92)) - body := `{"model":"m","messages":[{"role":"user","content":"a` + bs + bs + `_b"}]}` - var inObj, outObj map[string]any - if err := json.Unmarshal([]byte(body), &inObj); err != nil { - t.Fatal(err) - } - out := PrepareBodyOptWithEfforts([]byte(body), false, nil) - if err := json.Unmarshal(out, &outObj); err != nil { - t.Fatalf("out not json: %v", err) - } - inMsg, _ := json.Marshal(inObj["messages"]) - outMsg, _ := json.Marshal(outObj["messages"]) - if string(inMsg) != string(outMsg) { - t.Fatalf("messages changed without tools: %s -> %s", inMsg, outMsg) - } - if _, has := outObj["tools"]; has { - t.Fatal("tools appeared out of nowhere") - } - }) - - t.Run("sanitize on and off behave the same", func(t *testing.T) { - bs := string(byte(92)) - body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"a` + bs + bs + `_b"}}}}}]}` //nolint:lll - for _, sanitize := range []bool{false, true} { - out := string(PrepareBodyOptWithEfforts([]byte(body), sanitize, nil)) - if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != "a_b" { - t.Fatalf("sanitize=%v: pattern = %q, want %q", sanitize, got, "a_b") - } - } - }) -} +package upstream + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// TestNormalizeRoles 验证出站请求体把 developer 角色归一为 system。 +// 上游 role 白名单不含 developer(OpenAI 新规范的 system 别名), +// 命中即 HTTP 400 code=11128;此处走 PrepareBodyOptWithEfforts 全链路断言。 +func TestNormalizeRoles(t *testing.T) { + cases := []struct { + name string + body string + wantRoles []string // 与输出 messages 逐条对应的期望 role;len 即消息数 + }{ + {"developer 改写为 system", + `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, + {"Developer 首字母大写改写", + `{"messages":[{"role":"Developer","content":"x"}]}`, []string{"system"}}, + {"DEVELOPER 全大写改写", + `{"messages":[{"role":"DEVELOPER","content":"x"}]}`, []string{"system"}}, + {"前后空白 TrimSpace 后改写", + `{"messages":[{"role":" developer ","content":"x"}]}`, []string{"system"}}, + {"system 原样保留", + `{"messages":[{"role":"system","content":"x"}]}`, []string{"system"}}, + {"user 原样保留", + `{"messages":[{"role":"user","content":"x"}]}`, []string{"user"}}, + {"assistant 原样保留", + `{"messages":[{"role":"assistant","content":"x"}]}`, []string{"assistant"}}, + {"tool 原样保留(不因未知而改写)", + `{"messages":[{"role":"tool","content":"x"}]}`, []string{"tool"}}, + {"messages 缺失不 panic 且其余字段不变", + `{"model":"glm-5.2"}`, []string{}}, + {"messages 为空数组不 panic", + `{"messages":[]}`, []string{}}, + {"混合消息仅 developer 被改写", + `{"messages":[{"role":"developer","content":"a"},{"role":"user","content":"b"},{"role":"developer","content":"c"}]}`, + []string{"system", "user", "system"}}, + {"sanitize=false 时仍归一(与脱敏解耦)", + `{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}}, + {"非对象消息元素跳过、其余正常处理", + `{"messages":["str",{"role":"developer","content":"x"},42]}`, []string{"system"}}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 全程 sanitize=false:验证 role 归一与内容脱敏开关无关(D4)。 + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + + // 提取输出 messages 里的 role(非对象元素跳过,不 panic)。 + var got []string + if msgs, ok := obj["messages"].([]any); ok { + for _, m := range msgs { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, ok := msg["role"].(string); ok { + got = append(got, role) + } + } + } + + if len(got) != len(c.wantRoles) { + t.Fatalf("role 数量不符: got %v (%d) want %v (%d)", got, len(got), c.wantRoles, len(c.wantRoles)) + } + for i := range got { + if got[i] != c.wantRoles[i] { + t.Errorf("role[%d] = %q want %q", i, got[i], c.wantRoles[i]) + } + } + }) + } + + // messages 缺失时,其余字段必须原样保留(除强制 stream)。 + t.Run("messages 缺失时其余字段不变", func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","temperature":0.7}`), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if obj["model"] != "glm-5.2" || obj["temperature"] != 0.7 { + t.Errorf("其余字段被改动: %v", obj) + } + }) +} + +func TestPrepareBodyOptWithEfforts(t *testing.T) { + efforts := map[string][]string{ + "glm-5.2": {"off", "low", "high"}, + "glm-5.2-mini": {"low", "medium"}, + "glm-5.2-max": {"high", "xhigh"}, + } + cases := []struct { + name string + body string + efforts map[string][]string + wantKey string // 输出应带有的 effort 字段名;空表示该字段应不存在 + wantVal string // 期望值 + }{ + {"downgrade to highest supported at or below request", + `{"model":"glm-5.2-mini","reasoning_effort":"high"}`, efforts, "reasoning_effort", "medium"}, + {"floor to lowest when all supported above request", + `{"model":"glm-5.2-max","reasoning_effort":"low"}`, efforts, "reasoning_effort", "high"}, + {"supported effort passes through unchanged", + `{"model":"glm-5.2","reasoning_effort":"low"}`, efforts, "reasoning_effort", "low"}, + {"camelCase field name downgrades and keeps key", + `{"model":"glm-5.2-mini","reasoningEffort":"high"}`, efforts, "reasoningEffort", "medium"}, + {"unknown model passes through", + `{"model":"unknown","reasoning_effort":"max"}`, efforts, "reasoning_effort", "max"}, + {"unknown effort value passes through", + `{"model":"glm-5.2","reasoning_effort":"ultra"}`, efforts, "reasoning_effort", "ultra"}, + {"empty cache passes through", + `{"model":"glm-5.2","reasoning_effort":"max"}`, map[string][]string{}, "reasoning_effort", "max"}, + {"no effort field untouched", + `{"model":"glm-5.2-mini","messages":[]}`, efforts, "", ""}, + {"nil efforts map passes through", + `{"model":"glm-5.2","reasoning_effort":"max"}`, nil, "reasoning_effort", "max"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, c.efforts) + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, out) + } + if c.wantKey == "" { + if _, ok := m["reasoning_effort"]; ok { + t.Errorf("reasoning_effort should be absent, got %v", m["reasoning_effort"]) + } + if _, ok := m["reasoningEffort"]; ok { + t.Errorf("reasoningEffort should be absent, got %v", m["reasoningEffort"]) + } + return + } + got, ok := m[c.wantKey].(string) + if !ok || got != c.wantVal { + t.Errorf("%s: got %v (%T) want %q", c.wantKey, m[c.wantKey], m[c.wantKey], c.wantVal) + } + }) + } +} + +// TestPrepareBodyStreamOptions body 未显式带 stream_options 时注入 +// {include_usage: true}(D7,官方 CLI 流式必发);body 已带则不覆盖。 +func TestPrepareBodyStreamOptions(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","messages":[]}`), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + so, ok := obj["stream_options"].(map[string]any) + if !ok { + t.Fatalf("stream_options not injected: %v", obj["stream_options"]) + } + if so["include_usage"] != true { + t.Errorf("stream_options.include_usage = %v want true", so["include_usage"]) + } + + out2 := PrepareBodyOptWithEffertsPreserve(t, `{"model":"glm-5.2","messages":[],"stream_options":{"include_usage":false}}`) + obj2, err := decodeBody(out2) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + so2, ok := obj2["stream_options"].(map[string]any) + if !ok { + t.Fatalf("stream_options lost: %v", obj2["stream_options"]) + } + if so2["include_usage"] != false { + t.Errorf("stream_options.include_usage = %v want false (not overwritten)", so2["include_usage"]) + } +} + +// PrepareBodyOptWithEffertsPreserve helper:PrepareBodyOptWithEfforts 包装。 +func PrepareBodyOptWithEffertsPreserve(t *testing.T, body string) []byte { + t.Helper() + return PrepareBodyOptWithEfforts([]byte(body), false, nil) +} + +// decodeBody helper:解析 body JSON。 +func decodeBody(b []byte) (map[string]any, error) { + var obj map[string]any + err := json.Unmarshal(b, &obj) + return obj, err +} + +// TestPrepareBodyDeterministic 序列化稳定性:同输入跑多遍出站字节级一致 +// (prompt_cache_key 前缀命中的前提——链中不得注入时间/随机/ID 类不确定源)。 +func TestPrepareBodyDeterministic(t *testing.T) { + inputs := []string{ + `{"model":"glm-5.2","messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"你好"}],"reasoning_effort":"high"}`, + `{"model":"deepseek-v4","messages":[{"role":"user","content":"写个函数"}],"tool_choice":{"type":"auto"},"tools":[{"type":"function","function":{"name":"f"}}]}`, + `{"model":"glm-5.3","messages":[{"role":"developer","content":"sys"},{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, + } + for i, in := range inputs { + var first []byte + for round := 0; round < 5; round++ { + out := PrepareBodyOptWithEfforts([]byte(in), true, map[string][]string{"glm-5.2": {"off", "low", "high"}}) + if round == 0 { + first = out + continue + } + if string(out) != string(first) { + t.Fatalf("input #%d round %d differs from round 0:\n%s\n%s", i, round, first, out) + } + } + } +} + +// TestNormalizeImageURL 覆盖 OpenAI chat 多模态内容的 image_url 兼容: +// 字符串形态必须转为上游需要的对象形态;对象形态及其中字段必须原样保留; +// 无效输入不补默认值,继续交给上游返回真实错误。 +func TestNormalizeImageURL(t *testing.T) { + tests := []struct { + name string + body string + want any + }{ + { + name: "data url string to object", + body: `{"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":"data:image/png;base64,QUJD"}]}]}`, + want: map[string]any{"url": "data:image/png;base64,QUJD"}, + }, + { + name: "http url string to object", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":"https://example.test/a.png"}]}]}`, + want: map[string]any{"url": "https://example.test/a.png"}, + }, + { + name: "object with detail preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD","detail":"low","mime_type":"image/png"}}]}]}`, + want: map[string]any{"url": "data:image/png;base64,QUJD", "detail": "low", "mime_type": "image/png"}, + }, + { + name: "invalid object url type preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":123}}]}]}`, + want: map[string]any{"url": float64(123)}, + }, + { + name: "missing image url preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url"}]}]}`, + want: nil, + }, + { + name: "empty string preserved", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":""}]}]}`, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, sanitize := range []bool{false, true} { + out := PrepareBodyOptWithEfforts([]byte(tc.body), sanitize, nil) + obj, err := decodeBody(out) + if err != nil { + t.Fatalf("sanitize=%v unmarshal: %v (out=%s)", sanitize, err, out) + } + msgs := obj["messages"].([]any) + content := msgs[0].(map[string]any)["content"].([]any) + var part map[string]any + for _, rawPart := range content { + candidate, ok := rawPart.(map[string]any) + if ok && candidate["type"] == "image_url" { + part = candidate + break + } + } + if part == nil { + t.Fatal("image_url part not found") + } + if tc.want == nil { + if _, exists := part["image_url"]; exists { + t.Fatalf("sanitize=%v: missing image_url should stay missing, got %#v", sanitize, part) + } + continue + } + if got := part["image_url"]; !reflect.DeepEqual(got, tc.want) { + t.Errorf("sanitize=%v: image_url=%#v want %#v", sanitize, got, tc.want) + } + } + }) + } +} + +// TestNormalizeToolPatterns 工具 schema pattern 的 `\_` 转义归一(11129 实案: +// exa agent_run 的 `^agent\_run\_` 被 deepseek 系确定性拒收,归一后上游 200)。 +// 覆盖:function.parameters 与裸 parameters 两形态、嵌套 schema、patternProperties +// 键、消息正文不碰、无 tools no-op、sanitize 开关两态一致。 +func TestNormalizeToolPatterns(t *testing.T) { + lookupPattern := func(t *testing.T, body string, path ...any) string { + t.Helper() + var obj map[string]any + if err := json.Unmarshal([]byte(body), &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + cur := any(obj) + for _, p := range path { + switch k := p.(type) { + case string: + m, ok := cur.(map[string]any) + if !ok { + t.Fatalf("path %v: want object, got %T", path, cur) + } + cur = m[k] + case int: + l, ok := cur.([]any) + if !ok || k >= len(l) { + t.Fatalf("path %v: bad array at %T", path, cur) + } + cur = l[k] + } + } + s, _ := cur.(string) + return s + } + + t.Run("exa agent_run pattern normalized", func(t *testing.T) { + bs := string(byte(92)) // 反斜杠,测试体经工具链多层转义易被吞,运行时拼装保真 + body := `{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"agent_run","parameters":{"type":"object","properties":{"runId":{"type":"string","pattern":"^agent` + bs + bs + `_run` + bs + bs + `_"}` + `,"query":{"type":"string"}},"required":["query"]}}}]}` //nolint:lll // 实案 body 原样 + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "runId", "pattern"); got != `^agent_run_` { + t.Fatalf("pattern = %q, want %q", got, `^agent_run_`) + } + // 同 schema 的兄弟字段不受影响 + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "query", "type"); got != "string" { + t.Fatalf("sibling field disturbed: %q", got) + } + }) + + t.Run("nested anyOf items pattern normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"anyOf":[{"properties":{"code":{"pattern":"x` + bs + bs + `_y"}}},{"type":"string"}]}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "anyOf", 0, "properties", "code", "pattern"); got != "x_y" { + t.Fatalf("nested pattern = %q, want %q", got, "x_y") + } + }) + + t.Run("bare tool parameters form normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"name":"t","parameters":{"properties":{"id":{"pattern":"p` + bs + bs + `_q"}}}}]}` + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "parameters", "properties", "id", "pattern"); got != "p_q" { + t.Fatalf("bare parameters pattern = %q, want %q", got, "p_q") + } + }) + + t.Run("patternProperties key normalized", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"patternProperties":{"^a` + bs + bs + `_b` + bs + bs + `_":{"type":"string"}}}}}]}` //nolint:lll + var obj map[string]any + out := PrepareBodyOptWithEfforts([]byte(body), false, nil) + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + tool := obj["tools"].([]any)[0].(map[string]any)["function"].(map[string]any)["parameters"].(map[string]any) + props := tool["patternProperties"].(map[string]any) + if _, ok := props["^a_b_"]; !ok { + t.Fatalf("normalized key missing: %v", props) + } + for k := range props { + if strings.Contains(k, string(byte(92))) { + t.Fatalf("patternProperties key not normalized: %q", k) + } + } + }) + + t.Run("message content backslash untouched", func(t *testing.T) { + bs := string(byte(92)) + raw := `{"messages":[{"role":"user","content":"path C:` + bs + bs + `_dir and regex a` + bs + bs + `_b"}],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"q` + bs + bs + `_r"}}}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(raw), false, nil)) + var obj map[string]any + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Fatalf("out not json: %v", err) + } + msg := obj["messages"].([]any)[0].(map[string]any) + if got := msg["content"]; got != `path C:\_dir and regex a\_b` { + t.Fatalf("message content mutated: %q", got) + } + }) + + t.Run("clean pattern untouched", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"^[a-z]+` + bs + bs + `d_$"}}}}}]}` //nolint:lll + out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != `^[a-z]+\d_$` { + t.Fatalf("clean pattern mutated: %q", got) + } + }) + + t.Run("no tools is no-op", func(t *testing.T) { + bs := string(byte(92)) + body := `{"model":"m","messages":[{"role":"user","content":"a` + bs + bs + `_b"}]}` + var inObj, outObj map[string]any + if err := json.Unmarshal([]byte(body), &inObj); err != nil { + t.Fatal(err) + } + out := PrepareBodyOptWithEfforts([]byte(body), false, nil) + if err := json.Unmarshal(out, &outObj); err != nil { + t.Fatalf("out not json: %v", err) + } + inMsg, _ := json.Marshal(inObj["messages"]) + outMsg, _ := json.Marshal(outObj["messages"]) + if string(inMsg) != string(outMsg) { + t.Fatalf("messages changed without tools: %s -> %s", inMsg, outMsg) + } + if _, has := outObj["tools"]; has { + t.Fatal("tools appeared out of nowhere") + } + }) + + t.Run("sanitize on and off behave the same", func(t *testing.T) { + bs := string(byte(92)) + body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"a` + bs + bs + `_b"}}}}}]}` //nolint:lll + for _, sanitize := range []bool{false, true} { + out := string(PrepareBodyOptWithEfforts([]byte(body), sanitize, nil)) + if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != "a_b" { + t.Fatalf("sanitize=%v: pattern = %q, want %q", sanitize, got, "a_b") + } + } + }) +} + +// TestClampGPTMinMaxTokens GPT 系上游要求 max_tokens ≥ 16(实测 gpt-6-sol/gpt-6-luna/ +// gpt-5.6-sol:15 → 400 code=11133 model_param_invalid,16 → 200)。Claude Code 切 +// 模型时的探针请求 max_tokens 极小,全号轮转同样被拒 → 客户端 503。非 GPT 模型不动。 +func TestClampGPTMinMaxTokens(t *testing.T) { + cases := []struct { + name string + body string + want any // nil 表示字段不存在 + }{ + {"gpt below floor", `{"model":"gpt-6-sol","max_tokens":1,"messages":[]}`, float64(16)}, + {"gpt global prefix", `{"model":"global:gpt-5.6-sol","max_tokens":15,"messages":[]}`, float64(16)}, + {"gpt at floor", `{"model":"gpt-6-luna","max_tokens":16,"messages":[]}`, float64(16)}, + {"gpt above floor", `{"model":"gpt-6-sol","max_tokens":32000,"messages":[]}`, float64(32000)}, + {"gpt alias translated then clamped", `{"model":"gpt-6-sol","max_completion_tokens":1,"messages":[]}`, float64(16)}, + {"gpt absent untouched", `{"model":"gpt-6-sol","messages":[]}`, nil}, + {"non-gpt untouched", `{"model":"hy4-preview-f","max_tokens":1,"messages":[]}`, float64(1)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + obj, err := decodeBody(PrepareBodyOptWithEffertsPreserve(t, c.body)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + got, has := obj["max_tokens"] + if c.want == nil { + if has { + t.Fatalf("max_tokens = %v, want absent", got) + } + return + } + if got != c.want { + t.Fatalf("max_tokens = %v, want %v", got, c.want) + } + }) + } +} diff --git a/internal/upstream/profile.go b/internal/upstream/profile.go index 866d416e824076bd8b9577e9d1856fd1571c6825..001ce90c28bfd86cddb6cc03037961c7cd6b6bfa 100644 --- a/internal/upstream/profile.go +++ b/internal/upstream/profile.go @@ -1,51 +1,51 @@ -// profile.go Web 控制台账号资料(issue #94:改名后免重登同步昵称)。 -// -// GET {webBase}/console/account,Bearer + x-client-platform: web(与 tasks/claim -// 同一鉴权形态,实测 2026-10-01:无需 Web 会话 cookie)。 -// -// 隐私边界(重要):该接口响应包含手机号(phoneNumber)等个人敏感信息。本方法 -// 只解析 nickname 与 uid(uid 仅做一致性核对),其余字段一概不解析、不落日志、 -// 不透传——调用方也拿不到。昵称同步只在面板手动「刷新」时触发,后台余额定时 -// 刷新不调用本接口。 -package upstream - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// FetchAccountProfile 拉取账号资料并返回最新昵称。uid 与凭证不一致时报错 -// (防串号);业务/网络错误原样返回,调用方静默跳过即可。 -func (c *Client) FetchAccountProfile(a *auth.Auth) (string, error) { - req, err := http.NewRequest(http.MethodGet, c.webBase(a)+"/console/account", nil) - if err != nil { - return "", err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("x-client-platform", "web") - req.Header.Set("Origin", "https://www.workbuddy.cn") - req.Header.Set("Referer", "https://www.workbuddy.cn/profile/account-settings") - if ua := c.userAgent(a); ua != "" { - req.Header.Set("User-Agent", ua) - } - data, err := c.doJSON(req) - if err != nil { - return "", err - } - // 只取两个字段:敏感信息(手机号等)在这里就被丢弃,不进入任何后续路径。 - var resp struct { - UID string `json:"uid"` - Nickname string `json:"nickname"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return "", fmt.Errorf("profile parse: %w", err) - } - if resp.UID != "" && a.UID != "" && resp.UID != a.UID { - return "", fmt.Errorf("profile uid mismatch: resp=%s auth=%s", resp.UID, a.UID) - } - return resp.Nickname, nil -} +// profile.go Web 控制台账号资料(issue #94:改名后免重登同步昵称)。 +// +// GET {webBase}/console/account,Bearer + x-client-platform: web(与 tasks/claim +// 同一鉴权形态,实测 2026-10-01:无需 Web 会话 cookie)。 +// +// 隐私边界(重要):该接口响应包含手机号(phoneNumber)等个人敏感信息。本方法 +// 只解析 nickname 与 uid(uid 仅做一致性核对),其余字段一概不解析、不落日志、 +// 不透传——调用方也拿不到。昵称同步只在面板手动「刷新」时触发,后台余额定时 +// 刷新不调用本接口。 +package upstream + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// FetchAccountProfile 拉取账号资料并返回最新昵称。uid 与凭证不一致时报错 +// (防串号);业务/网络错误原样返回,调用方静默跳过即可。 +func (c *Client) FetchAccountProfile(a *auth.Auth) (string, error) { + req, err := http.NewRequest(http.MethodGet, c.webBase(a)+"/console/account", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("x-client-platform", "web") + req.Header.Set("Origin", "https://www.workbuddy.cn") + req.Header.Set("Referer", "https://www.workbuddy.cn/profile/account-settings") + if ua := c.userAgent(a); ua != "" { + req.Header.Set("User-Agent", ua) + } + data, err := c.doJSON(req) + if err != nil { + return "", err + } + // 只取两个字段:敏感信息(手机号等)在这里就被丢弃,不进入任何后续路径。 + var resp struct { + UID string `json:"uid"` + Nickname string `json:"nickname"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return "", fmt.Errorf("profile parse: %w", err) + } + if resp.UID != "" && a.UID != "" && resp.UID != a.UID { + return "", fmt.Errorf("profile uid mismatch: resp=%s auth=%s", resp.UID, a.UID) + } + return resp.Nickname, nil +} diff --git a/internal/upstream/profile_test.go b/internal/upstream/profile_test.go index adf32b495f2fcc200ecf36553c6c617df1aaa842..921a0b07e3cfd7b7f0b29697271921ebcef60621 100644 --- a/internal/upstream/profile_test.go +++ b/internal/upstream/profile_test.go @@ -1,50 +1,50 @@ -// profile_test.go 钉住账号资料拉取(issue #94):Bearer + web 平台头可访问 -// /console/account;只解析 nickname,uid 不一致防串号;业务错误原样返回。 -package upstream - -import ( - "errors" - "net/http" - "strings" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -func TestFetchAccountProfile(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/console/account") { - return nil, errors.New("wrong path") - } - if r.Header.Get("Authorization") == "" || r.Header.Get("x-client-platform") != "web" { - return nil, errors.New("missing bearer / web platform header") - } - // 响应刻意带 phoneNumber:方法必须只解析 nickname/uid,敏感字段不得进入返回值。 - return jsonResp(200, `{"code":0,"msg":"OK","data":{"uid":"u1","nickname":"新名字","phoneNumber":"13800000000"}}`), nil - }) - c.WebBaseCN = "https://web.example" - nick, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}) - if err != nil || nick != "新名字" { - t.Fatalf("nick=%q err=%v, want 新名字 nil", nick, err) - } -} - -func TestFetchAccountProfileUIDMismatch(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":0,"data":{"uid":"someone-else","nickname":"x"}}`), nil - }) - c.WebBaseCN = "https://web.example" - if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}); err == nil { - t.Fatal("uid 不一致应报错(防串号)") - } -} - -func TestFetchAccountProfileBusinessError(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(401, `{"code":1002,"msg":"unauthorized"}`), nil - }) - c.WebBaseCN = "https://web.example" - if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "bad"}); err == nil { - t.Fatal("401 应返回错误") - } -} +// profile_test.go 钉住账号资料拉取(issue #94):Bearer + web 平台头可访问 +// /console/account;只解析 nickname,uid 不一致防串号;业务错误原样返回。 +package upstream + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestFetchAccountProfile(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/console/account") { + return nil, errors.New("wrong path") + } + if r.Header.Get("Authorization") == "" || r.Header.Get("x-client-platform") != "web" { + return nil, errors.New("missing bearer / web platform header") + } + // 响应刻意带 phoneNumber:方法必须只解析 nickname/uid,敏感字段不得进入返回值。 + return jsonResp(200, `{"code":0,"msg":"OK","data":{"uid":"u1","nickname":"新名字","phoneNumber":"13800000000"}}`), nil + }) + c.WebBaseCN = "https://web.example" + nick, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}) + if err != nil || nick != "新名字" { + t.Fatalf("nick=%q err=%v, want 新名字 nil", nick, err) + } +} + +func TestFetchAccountProfileUIDMismatch(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"uid":"someone-else","nickname":"x"}}`), nil + }) + c.WebBaseCN = "https://web.example" + if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "at"}); err == nil { + t.Fatal("uid 不一致应报错(防串号)") + } +} + +func TestFetchAccountProfileBusinessError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(401, `{"code":1002,"msg":"unauthorized"}`), nil + }) + c.WebBaseCN = "https://web.example" + if _, err := c.FetchAccountProfile(&auth.Auth{UID: "u1", AccessToken: "bad"}); err == nil { + t.Fatal("401 应返回错误") + } +} diff --git a/internal/upstream/report.go b/internal/upstream/report.go index fde5a6582aac881d6645b8ec52118ff14f728492..3ca4e505e75c80a0d35beaa02dbb507dbaf2c837 100644 --- a/internal/upstream/report.go +++ b/internal/upstream/report.go @@ -1,206 +1,206 @@ -// report.go growth 域「对话活跃上报」接口:POST {billingBase}/v2/report。 -// 照抄客户端 chat_request_send 事件形状(含 conversationId/mode/inputLength 等全字段, -// 勿用最小 3 字段,防上游后续加严)。事件必须带 userId(=账号 uid),缺失则服务端 -// 200 但静默丢弃(实测见 REPORT-active-map.md §2)。 -// -// 一条上报同时点亮 growth 连登 + 解锁 first_buddy 任务(领养前置)。 -// 风控口径:每号每天 1 次即可(activity_hours 单时点),不做多时点高频上报。 -package upstream - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// reportPath 活跃上报通道(实测)。 -const reportPath = "/v2/report" - -// billingJSON 发 billing 域(billingBase,codebuddy.cn)请求并解信封;body 为 nil 时不带请求体。 -// 与 travel.go 的 growthJSON 对称(growth 域走 chatBase + BillingHeaders;billing 域走 billingBase)。 -// report/checkin 等 billing 端点共用:请求头统一 BillingHeaders,信封与错误语义同 doJSON。 -func (c *Client) billingJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { - var rdr io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, c.billingBase(a)+path, rdr) - if err != nil { - return nil, err - } - c.BillingHeaders(req, a) - return c.doJSON(req) -} - -// billingMeterJSON 仅对 /billing/meter 族端点(get-user-resource / daily-checkin) -// 按 realm 走双路径 fallback:global 先无 /v2 前缀,ErrNotFound 时二次换有 /v2 前缀 -// (上游新旧路径分叉);cn 单路径(有 /v2)现状不变。仅 global realm 才有多路径。 -func (c *Client) billingMeterJSON(a *auth.Auth, paths []string, method string, body any) (json.RawMessage, error) { - var lastErr error - for i, path := range paths { - data, err := c.billingJSON(a, method, path, body) - if err == nil { - return data, nil - } - lastErr = err - // 仅 404 换路径(路径不存在才值得 fallback);其他错误直接返回。 - var ue *Error - if !errors.As(err, &ue) || ue.Kind != ErrNotFound || i == len(paths)-1 { - return nil, err - } - } - return nil, lastErr -} - -// billingRetryDelay 签到/余额等维护类计费调用瞬时错误重试的间隔基数。 -// 独立变量供测试缩短(生产固定 2s:第 1 次重试等 2s、第 2 次等 4s)。 -var billingRetryDelay = 2 * time.Second - -// isTransientBillingErr 报告 err 是否值得对计费维护类调用做有界重试: -// 上游 5xx(ErrServer,实测偶发 "code 10000 / API request failed with status -// code: 500")或网络层错误(非 *Error 的传输失败)。业务错误(code!=0 的 -// 已签到/参数错、4xx、限流)不重试——重试只会原样再失败一次。 -func isTransientBillingErr(err error) bool { - if err == nil { - return false - } - var ue *Error - if errors.As(err, &ue) { - return ue.Kind == ErrServer - } - return true -} - -// retryBillingTransient 对签到/余额这类低频维护调用做瞬时错误有界重试: -// 最多补打 2 次(间隔 2s、4s),首次成功或非瞬时错误立即返回。chat 热路径 -// 不用本策略——它有自己的换号轮转语义,重试会放大在途请求。 -func (c *Client) retryBillingTransient(fn func() error) error { - err := fn() - if err == nil || !isTransientBillingErr(err) { - return err - } - for i := 1; i <= 2; i++ { - time.Sleep(time.Duration(i) * billingRetryDelay) - if err = fn(); err == nil || !isTransientBillingErr(err) { - return err - } - } - return err -} - -// chatRequestEvent 客户端 chat_request_send 事件完整形状(与 probe_active.py chat_event 对齐)。 -// userId 为必填字段(= a.UID);conversationId 由调用方生成,无需真实会话。 -type chatRequestEvent struct { - EventCode string `json:"eventCode"` - Timestamp int64 `json:"timestamp"` - ReportDelay int `json:"reportDelay"` - Mode string `json:"mode"` - ConversationID string `json:"conversationId"` - RequestID string `json:"requestId"` - InputLength int `json:"inputLength"` - RequestModelID string `json:"requestModelId"` - RequestModelName string `json:"requestModelName"` - IsPlan bool `json:"isPlan"` - IsAutoExecuteTerminal bool `json:"isAutoExecuteTerminal"` - IsAutoModify bool `json:"isAutoModify"` - CodebaseEnable bool `json:"codebaseEnable"` - MaxToken int `json:"maxToken"` - MaxSteps int `json:"maxSteps"` - Temperature int `json:"temperature"` - MaxRetries int `json:"maxRetries"` - MentionContexts []any `json:"mentionContexts"` - KnowledgeID []any `json:"knowledgeId"` - KnowledgeName []any `json:"knowledgeName"` - CodebaseID string `json:"codebaseId"` - MentionContextCount int `json:"mentionContextCount"` - Command string `json:"command"` - ExpertID string `json:"expertId"` - RecommendID string `json:"recommendId"` - SkillID string `json:"skillId"` - SkillCount int `json:"skillCount"` - TotalCount int `json:"totalCount"` - FileURI string `json:"fileUri"` - PresentAt int64 `json:"presentAt"` - TraceID string `json:"traceId"` - RootRequestID string `json:"rootRequestId"` - ParentConversationID string `json:"parentConversationId"` - AgentName string `json:"agentName"` - AgentType string `json:"agentType"` - UserID string `json:"userId"` -} - -// ReportChatActivity 向上游发送一条对话活跃上报(chat_request_send)。 -// conversationID 由调用方生成(如 wb2api-),无需真实会话——服务端不校验一致性。 -// requestID 为本轮请求独立标识(多轮同会话上报时各条不同);空时回落 conversationID。 -// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 -func (c *Client) ReportChatActivity(a *auth.Auth, conversationID, requestID string) error { - return c.ReportChatActivityModel(a, conversationID, requestID, "deepseek-v4-flash", "DeepSeek V4 Flash") -} - -// ReportChatActivityModel 同上,但可指定上报携带的模型:供「体验某模型」类任务 -// 对齐实际模型(如 Model_chat_GLM5.2 需 requestModelId=glm-5.2 与独立 requestID)。 -func (c *Client) ReportChatActivityModel(a *auth.Auth, conversationID, requestID, modelID, modelName string) error { - if requestID == "" { - requestID = conversationID - } - if modelID == "" { - modelID = "deepseek-v4-flash" - } - if modelName == "" { - modelName = modelID - } - now := time.Now().UnixMilli() - ev := chatRequestEvent{ - EventCode: "chat_request_send", - Timestamp: now, - ReportDelay: 0, - Mode: "craft", - ConversationID: conversationID, - RequestID: requestID, - InputLength: 12, - RequestModelID: modelID, - RequestModelName: modelName, - IsPlan: false, - IsAutoExecuteTerminal: false, - IsAutoModify: false, - CodebaseEnable: false, - MaxToken: 0, - MaxSteps: 0, - Temperature: 0, - MaxRetries: 0, - MentionContexts: []any{}, - KnowledgeID: []any{}, - KnowledgeName: []any{}, - CodebaseID: "", - MentionContextCount: 0, - Command: "", - ExpertID: "", - RecommendID: "", - SkillID: "", - SkillCount: 0, - TotalCount: 0, - FileURI: "", - PresentAt: now, - TraceID: "", - RootRequestID: requestID, - ParentConversationID: conversationID, - AgentName: "default", - AgentType: "conversation", - UserID: a.UID, - } - raw, err := json.Marshal([]chatRequestEvent{ev}) - if err != nil { - return err - } - _, err = c.billingJSON(a, http.MethodPost, reportPath, json.RawMessage(raw)) - return err -} +// report.go growth 域「对话活跃上报」接口:POST {billingBase}/v2/report。 +// 照抄客户端 chat_request_send 事件形状(含 conversationId/mode/inputLength 等全字段, +// 勿用最小 3 字段,防上游后续加严)。事件必须带 userId(=账号 uid),缺失则服务端 +// 200 但静默丢弃(实测见 REPORT-active-map.md §2)。 +// +// 一条上报同时点亮 growth 连登 + 解锁 first_buddy 任务(领养前置)。 +// 风控口径:每号每天 1 次即可(activity_hours 单时点),不做多时点高频上报。 +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// reportPath 活跃上报通道(实测)。 +const reportPath = "/v2/report" + +// billingJSON 发 billing 域(billingBase,codebuddy.cn)请求并解信封;body 为 nil 时不带请求体。 +// 与 travel.go 的 growthJSON 对称(growth 域走 chatBase + BillingHeaders;billing 域走 billingBase)。 +// report/checkin 等 billing 端点共用:请求头统一 BillingHeaders,信封与错误语义同 doJSON。 +func (c *Client) billingJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.billingBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + return c.doJSON(req) +} + +// billingMeterJSON 仅对 /billing/meter 族端点(get-user-resource / daily-checkin) +// 按 realm 走双路径 fallback:global 先无 /v2 前缀,ErrNotFound 时二次换有 /v2 前缀 +// (上游新旧路径分叉);cn 单路径(有 /v2)现状不变。仅 global realm 才有多路径。 +func (c *Client) billingMeterJSON(a *auth.Auth, paths []string, method string, body any) (json.RawMessage, error) { + var lastErr error + for i, path := range paths { + data, err := c.billingJSON(a, method, path, body) + if err == nil { + return data, nil + } + lastErr = err + // 仅 404 换路径(路径不存在才值得 fallback);其他错误直接返回。 + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrNotFound || i == len(paths)-1 { + return nil, err + } + } + return nil, lastErr +} + +// billingRetryDelay 签到/余额等维护类计费调用瞬时错误重试的间隔基数。 +// 独立变量供测试缩短(生产固定 2s:第 1 次重试等 2s、第 2 次等 4s)。 +var billingRetryDelay = 2 * time.Second + +// isTransientBillingErr 报告 err 是否值得对计费维护类调用做有界重试: +// 上游 5xx(ErrServer,实测偶发 "code 10000 / API request failed with status +// code: 500")或网络层错误(非 *Error 的传输失败)。业务错误(code!=0 的 +// 已签到/参数错、4xx、限流)不重试——重试只会原样再失败一次。 +func isTransientBillingErr(err error) bool { + if err == nil { + return false + } + var ue *Error + if errors.As(err, &ue) { + return ue.Kind == ErrServer + } + return true +} + +// retryBillingTransient 对签到/余额这类低频维护调用做瞬时错误有界重试: +// 最多补打 2 次(间隔 2s、4s),首次成功或非瞬时错误立即返回。chat 热路径 +// 不用本策略——它有自己的换号轮转语义,重试会放大在途请求。 +func (c *Client) retryBillingTransient(fn func() error) error { + err := fn() + if err == nil || !isTransientBillingErr(err) { + return err + } + for i := 1; i <= 2; i++ { + time.Sleep(time.Duration(i) * billingRetryDelay) + if err = fn(); err == nil || !isTransientBillingErr(err) { + return err + } + } + return err +} + +// chatRequestEvent 客户端 chat_request_send 事件完整形状(与 probe_active.py chat_event 对齐)。 +// userId 为必填字段(= a.UID);conversationId 由调用方生成,无需真实会话。 +type chatRequestEvent struct { + EventCode string `json:"eventCode"` + Timestamp int64 `json:"timestamp"` + ReportDelay int `json:"reportDelay"` + Mode string `json:"mode"` + ConversationID string `json:"conversationId"` + RequestID string `json:"requestId"` + InputLength int `json:"inputLength"` + RequestModelID string `json:"requestModelId"` + RequestModelName string `json:"requestModelName"` + IsPlan bool `json:"isPlan"` + IsAutoExecuteTerminal bool `json:"isAutoExecuteTerminal"` + IsAutoModify bool `json:"isAutoModify"` + CodebaseEnable bool `json:"codebaseEnable"` + MaxToken int `json:"maxToken"` + MaxSteps int `json:"maxSteps"` + Temperature int `json:"temperature"` + MaxRetries int `json:"maxRetries"` + MentionContexts []any `json:"mentionContexts"` + KnowledgeID []any `json:"knowledgeId"` + KnowledgeName []any `json:"knowledgeName"` + CodebaseID string `json:"codebaseId"` + MentionContextCount int `json:"mentionContextCount"` + Command string `json:"command"` + ExpertID string `json:"expertId"` + RecommendID string `json:"recommendId"` + SkillID string `json:"skillId"` + SkillCount int `json:"skillCount"` + TotalCount int `json:"totalCount"` + FileURI string `json:"fileUri"` + PresentAt int64 `json:"presentAt"` + TraceID string `json:"traceId"` + RootRequestID string `json:"rootRequestId"` + ParentConversationID string `json:"parentConversationId"` + AgentName string `json:"agentName"` + AgentType string `json:"agentType"` + UserID string `json:"userId"` +} + +// ReportChatActivity 向上游发送一条对话活跃上报(chat_request_send)。 +// conversationID 由调用方生成(如 wb2api-),无需真实会话——服务端不校验一致性。 +// requestID 为本轮请求独立标识(多轮同会话上报时各条不同);空时回落 conversationID。 +// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 +func (c *Client) ReportChatActivity(a *auth.Auth, conversationID, requestID string) error { + return c.ReportChatActivityModel(a, conversationID, requestID, "deepseek-v4-flash", "DeepSeek V4 Flash") +} + +// ReportChatActivityModel 同上,但可指定上报携带的模型:供「体验某模型」类任务 +// 对齐实际模型(如 Model_chat_GLM5.2 需 requestModelId=glm-5.2 与独立 requestID)。 +func (c *Client) ReportChatActivityModel(a *auth.Auth, conversationID, requestID, modelID, modelName string) error { + if requestID == "" { + requestID = conversationID + } + if modelID == "" { + modelID = "deepseek-v4-flash" + } + if modelName == "" { + modelName = modelID + } + now := time.Now().UnixMilli() + ev := chatRequestEvent{ + EventCode: "chat_request_send", + Timestamp: now, + ReportDelay: 0, + Mode: "craft", + ConversationID: conversationID, + RequestID: requestID, + InputLength: 12, + RequestModelID: modelID, + RequestModelName: modelName, + IsPlan: false, + IsAutoExecuteTerminal: false, + IsAutoModify: false, + CodebaseEnable: false, + MaxToken: 0, + MaxSteps: 0, + Temperature: 0, + MaxRetries: 0, + MentionContexts: []any{}, + KnowledgeID: []any{}, + KnowledgeName: []any{}, + CodebaseID: "", + MentionContextCount: 0, + Command: "", + ExpertID: "", + RecommendID: "", + SkillID: "", + SkillCount: 0, + TotalCount: 0, + FileURI: "", + PresentAt: now, + TraceID: "", + RootRequestID: requestID, + ParentConversationID: conversationID, + AgentName: "default", + AgentType: "conversation", + UserID: a.UID, + } + raw, err := json.Marshal([]chatRequestEvent{ev}) + if err != nil { + return err + } + _, err = c.billingJSON(a, http.MethodPost, reportPath, json.RawMessage(raw)) + return err +} diff --git a/internal/upstream/report_test.go b/internal/upstream/report_test.go index 61eefda8895be924e2d604fc8041d8a21707fc9f..badf29b1ff0421ba5dd153bb01ff0bf7bd41f36d 100644 --- a/internal/upstream/report_test.go +++ b/internal/upstream/report_test.go @@ -1,69 +1,69 @@ -package upstream - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// TestReportChatActivitySendsArrayWithUserID 断言出站 body 是数组、含 userId、eventCode 正确。 -func TestReportChatActivitySendsArrayWithUserID(t *testing.T) { - var got []map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v2/report" { - t.Errorf("path=%s want /v2/report", r.URL.Path) - } - if r.Method != http.MethodPost { - t.Errorf("method=%s want POST", r.Method) - } - raw, _ := io.ReadAll(r.Body) - var arr []map[string]any - if err := json.Unmarshal(raw, &arr); err != nil { - t.Fatalf("body must be a JSON array: %v (body=%s)", err, raw) - } - got = arr - w.Write([]byte(`{"code":0,"msg":"OK"}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} - if err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u-active"}, "wb2api-123", ""); err != nil { - t.Fatalf("report: %v", err) - } - if len(got) != 1 { - t.Fatalf("events=%d want 1", len(got)) - } - ev := got[0] - if ev["eventCode"] != "chat_request_send" { - t.Errorf("eventCode=%v want chat_request_send", ev["eventCode"]) - } - if ev["userId"] != "u-active" { - t.Errorf("userId=%v want u-active(缺失则服务端 200 但静默丢弃)", ev["userId"]) - } - if ev["conversationId"] != "wb2api-123" { - t.Errorf("conversationId=%v want wb2api-123", ev["conversationId"]) - } - if ev["mode"] != "craft" { - t.Errorf("mode=%v want craft", ev["mode"]) - } - // 出站 body 必须是数组(以 [ 开头),不是单个对象。 -} - -// TestReportChatActivityServerError 业务 code 非 0 返回 *Error。 -func TestReportChatActivityServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - w.Write([]byte(`boom`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} - err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") - if err == nil { - t.Fatal("want error on 500") - } -} +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestReportChatActivitySendsArrayWithUserID 断言出站 body 是数组、含 userId、eventCode 正确。 +func TestReportChatActivitySendsArrayWithUserID(t *testing.T) { + var got []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/report" { + t.Errorf("path=%s want /v2/report", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method=%s want POST", r.Method) + } + raw, _ := io.ReadAll(r.Body) + var arr []map[string]any + if err := json.Unmarshal(raw, &arr); err != nil { + t.Fatalf("body must be a JSON array: %v (body=%s)", err, raw) + } + got = arr + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} + if err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u-active"}, "wb2api-123", ""); err != nil { + t.Fatalf("report: %v", err) + } + if len(got) != 1 { + t.Fatalf("events=%d want 1", len(got)) + } + ev := got[0] + if ev["eventCode"] != "chat_request_send" { + t.Errorf("eventCode=%v want chat_request_send", ev["eventCode"]) + } + if ev["userId"] != "u-active" { + t.Errorf("userId=%v want u-active(缺失则服务端 200 但静默丢弃)", ev["userId"]) + } + if ev["conversationId"] != "wb2api-123" { + t.Errorf("conversationId=%v want wb2api-123", ev["conversationId"]) + } + if ev["mode"] != "craft" { + t.Errorf("mode=%v want craft", ev["mode"]) + } + // 出站 body 必须是数组(以 [ 开头),不是单个对象。 +} + +// TestReportChatActivityServerError 业务 code 非 0 返回 *Error。 +func TestReportChatActivityServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} + err := c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") + if err == nil { + t.Fatal("want error on 500") + } +} diff --git a/internal/upstream/sanitize.go b/internal/upstream/sanitize.go index c24fb6511f981f642717e201051a1c72634c152e..3f753918dea73039a3863b457628a020b4cd5042 100644 --- a/internal/upstream/sanitize.go +++ b/internal/upstream/sanitize.go @@ -1,210 +1,220 @@ -// sanitize.go 出站请求体脱敏:剥离上游内容审核黑名单指纹。 -// -// 背景:客户端(Claude Code 类 CLI)在 system prompt 注入若干固定模板句, -// 上游内容审核按逐字精确匹配拦截(非语义审核),一字改动即可绕过。 -// 策略:键值/header 型指纹整段剥离;承载语义的模板句最小改写(换一词),语义不变。 -package upstream - -import ( - "regexp" - "strings" -) - -// sanitizeFeatures 特征预检:任一命中才进入净化(strings.Contains 快速路径, -// 普通请求全不中 → 原样返回,零分配)。 -var sanitizeFeatures = []string{ - "x-anthropic-billing-header", // header 键值段键名 - "cc_entrypoint=", // 尾随裸键值(截断前缀即可命中) - "You are Claude Code", // 身份句(截断前缀即可命中) - "Main branch (", // 注入指令句(截断前缀即可命中) - "You are a coding agent running in the Codex CLI", // Codex instructions 首段(截断前缀即可命中) - "github.com/anthropics/", // 反馈句里的 Anthropic 仓库链接 - "11128", // 上游反探测:裸数字错误码 -} - -// sanitizeHdrRe 剥离层:header 键名即触发(与值无关),整段删除。 -var sanitizeHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header:[^;\n]*;?\s*`) - -// sanitizeBareHdrRe 兜底层:裸键名(无冒号无值)同样是指纹——2026-09-13 实验 F4 -// 证实 assistant 消息里反引号引用裸键名即触发 11128,而剥离层要求冒号、对裸串无效。 -// 键值形态被整段删除后,残留的裸键名做最小缩写(header→hdr):破坏逐字匹配、 -// 语义不变、保留可读性。大小写不敏感,覆盖 X-Anthropic-... 变体。 -// -// 注意该正则不要求冒号,是 sanitizeHdrRe 的超集——hasFingerprint 与 sanitizeText -// 中两者并用:先删键值形态(sanitizeHdrRe),再缩写残留裸键名(本正则), -// 替换语义不同(整段删除 vs 最小缩写),不可合并为一个正则。 -var sanitizeBareHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header`) - -// sanitizeKvRe 剥离层:尾随裸键值(cc_xxx=...;)循环清理。 -var sanitizeKvRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\n]*;?\s*`) - -// sanitizeRewrites 改写层:全模板句逐字替换(每句只改一个词,语义不变)。 -// -// 身份句的匹配串**不带结尾标点**(只到 "…for Claude" 为止): -// CLI 版这句以句号收尾("…for Claude."),桌面版(claude-desktop-3p / Agent SDK) -// 以逗号接后继内容("…for Claude, running within the Claude Agent SDK.")。 -// 带句号的整句只匹配前者,桌面版会漏网、指纹原样发上游 → 400 code=11128。 -// 去掉结尾标点后两种形态一并覆盖(替换串同样不带标点,让原有标点原样保留)。 -// 注意仍要求 "You are Claude Code, " 前缀,不做更宽的子串替换, -// 以免误伤 TestExactMatchOnlyVariantNotTouched 所保护的零散文本。 -var sanitizeRewrites = [][2]string{ - { - "You are Claude Code, Anthropic's official CLI for Claude", - "You are Claude Code, Anthropic's official CLI tool for Claude", - }, - { - "Main branch (you will usually use this for PRs)", - "Default branch (you will usually use this for PRs)", - }, - { - "You are a coding agent running in the Codex CLI, a terminal-based coding assistant.", - "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.", - }, - { - // 反馈句:整句带 Anthropic 仓库链接,上游按整句拦截(只留链接或只留半边均不拦, - // 实测需整句同时出现)。give→provide 一词之差即可绕过,语义不变。 - "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", - "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", - }, - { - // 上游反探测:只要请求体里出现裸数字 11128 就整单拦截(与该数字的上下文无关—— - // "code=11128" / 裸 "11128" / "错误码 11128" / "Code=11128" 全部命中; - // 相邻的 11148 / 11101 / 11115 / 99999 均放行)。11128 正是本类拦截自身的错误码, - // 上游据此识别"在讨论/回显其内部错误码"的请求。 - // 代价:用户对话中任何 11128 都会被改写——但这串数字出现在请求里本身就是拦截条件, - // 不改写必然失败。插入连字符保留可读性与指代(零宽空格无效,实测上游会归一化)。 - "11128", - "11-128", - }, -} - -// sanitizeText 单段文本净化:预检不中 → 返回原串(零分配)。 -func sanitizeText(text string) string { - if !hasFingerprint(text) { - return text - } - for _, rw := range sanitizeRewrites { - text = strings.ReplaceAll(text, rw[0], rw[1]) - } - if sanitizeHdrRe.MatchString(text) { - text = sanitizeHdrRe.ReplaceAllString(text, "") - } - if strings.Contains(text, "cc_") { - prev := "" - for prev != text { // 清尾随裸 kv(cc_version=...; cc_entrypoint=...;) - prev = text - text = sanitizeKvRe.ReplaceAllString(text, "") - } - } - // 兜底:键值形态已在上面整段删除,这里只剩裸键名(引用/示例文本形态)。 - text = sanitizeBareHdrRe.ReplaceAllString(text, "x-anthropic-billing-hdr") - return strings.TrimSpace(text) -} - -// hasFingerprint 特征预检:先走 strings.Contains 快速路径(零分配); -// header 键名有大小写变体(X-Anthropic-...)且可能以裸键名形态出现(无冒号), -// Contains 大小写敏感、sanitizeHdrRe 要求冒号——两者都会漏掉「混合大小写 + 裸键名」, -// 必须再用不要求冒号的 (?i) 正则兜底(sanitizeBareHdrRe),否则整条净化被跳过。 -// sanitizeBareHdrRe 不要求冒号,是 sanitizeHdrRe 的超集,故无需再单独匹配后者。 -func hasFingerprint(text string) bool { - for _, f := range sanitizeFeatures { - if strings.Contains(text, f) { - return true - } - } - return sanitizeBareHdrRe.MatchString(text) -} - -// sanitizeContent 兼容字符串与多模态数组;只动 text part,image 等 part 不动。 -// 返回净化后的值及是否发生变化。 -func sanitizeContent(v any) (any, bool) { - switch c := v.(type) { - case string: - s := sanitizeText(c) - return s, s != c - case []any: - changed := false - for _, p := range c { - m, ok := p.(map[string]any) - if !ok { - continue - } - text, ok := m["text"].(string) - if !ok { - continue - } - if s := sanitizeText(text); s != text { - m["text"] = s - changed = true - } - } - return c, changed - } - return v, false -} - -// sanitizeToolCalls 净化 assistant.tool_calls[].function.arguments。 -// -// arguments 是**字符串化的 JSON**(不是对象),因此按文本走 sanitizeText 即可。 -// 这块长期是盲区:工具调用消息的 content 通常是 null,而旧版 sanitizeMessages -// 在 content 缺失时直接 continue,整条消息连 tool_calls 一起被跳过—— -// 于是历史里任何写进工具参数的被拦字符串(文件名、命令、写入内容)都会原样漏出。 -func sanitizeToolCalls(v any) bool { - callList, ok := v.([]any) - if !ok { - return false - } - changed := false - for _, c := range callList { - call, ok := c.(map[string]any) - if !ok { - continue - } - fn, ok := call["function"].(map[string]any) - if !ok { - continue - } - args, ok := fn["arguments"].(string) - if !ok { - continue - } - if s := sanitizeText(args); s != args { - fn["arguments"] = s - changed = true - } - } - return changed -} - -// sanitizeMessages 净化 messages 中的 content 与 tool_calls;任一命中返回 true。 -func sanitizeMessages(messages []any) bool { - changed := false - for _, msg := range messages { - m, ok := msg.(map[string]any) - if !ok { - continue - } - // content 与 tool_calls 各自独立判断:content 可以为 null(工具调用轮), - // 早期版本在此 continue,导致这类消息的 tool_calls 完全不被净化。 - if c, ok := m["content"]; ok { - if nc, ch := sanitizeContent(c); ch { - m["content"] = nc - changed = true - } - } - // reasoning_content(思维链回填字段,见 thinking.go/sse.go)实测同样 - // 携带指纹,与 content 同等净化。string 形态直接走 sanitizeText。 - if rc, ok := m["reasoning_content"].(string); ok { - if s := sanitizeText(rc); s != rc { - m["reasoning_content"] = s - changed = true - } - } - if tc, ok := m["tool_calls"]; ok { - if sanitizeToolCalls(tc) { - changed = true - } - } - } - return changed -} +// sanitize.go 出站请求体脱敏:剥离上游内容审核黑名单指纹。 +// +// 背景:客户端(Claude Code 类 CLI)在 system prompt 注入若干固定模板句, +// 上游内容审核按逐字精确匹配拦截(非语义审核),一字改动即可绕过。 +// 策略:键值/header 型指纹整段剥离;承载语义的模板句最小改写(换一词),语义不变。 +package upstream + +import ( + "regexp" + "strings" +) + +// sanitizeFeatures 特征预检:任一命中才进入净化(strings.Contains 快速路径, +// 普通请求全不中 → 原样返回,零分配)。 +var sanitizeFeatures = []string{ + "x-anthropic-billing-header", // header 键值段键名 + "cc_entrypoint=", // 尾随裸键值(截断前缀即可命中) + "You are Claude Code", // 身份句(截断前缀即可命中) + "Main branch (", // 注入指令句(截断前缀即可命中) + "You are a coding agent running in the Codex CLI", // Codex instructions 首段(截断前缀即可命中) + "github.com/anthropics/", // 反馈句里的 Anthropic 仓库链接 + "11128", // 上游反探测:裸数字错误码 +} + +// sanitizeHdrRe 剥离层:header 键名即触发(与值无关),整段删除。 +var sanitizeHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header:[^;\n]*;?\s*`) + +// sanitizeBareHdrRe 兜底层:裸键名(无冒号无值)同样是指纹——2026-09-13 实验 F4 +// 证实 assistant 消息里反引号引用裸键名即触发 11128,而剥离层要求冒号、对裸串无效。 +// 键值形态被整段删除后,残留的裸键名做最小缩写(header→hdr):破坏逐字匹配、 +// 语义不变、保留可读性。大小写不敏感,覆盖 X-Anthropic-... 变体。 +// +// 注意该正则不要求冒号,是 sanitizeHdrRe 的超集——hasFingerprint 与 sanitizeText +// 中两者并用:先删键值形态(sanitizeHdrRe),再缩写残留裸键名(本正则), +// 替换语义不同(整段删除 vs 最小缩写),不可合并为一个正则。 +var sanitizeBareHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header`) + +// sanitizeKvRe 剥离层:尾随裸键值(cc_xxx=...;)循环清理。 +var sanitizeKvRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\n]*;?\s*`) + +// sanitizeRewrites 改写层:全模板句逐字替换(每句只改一个词,语义不变)。 +// +// 身份句的匹配串**不带结尾标点**(只到 "…for Claude" 为止): +// CLI 版这句以句号收尾("…for Claude."),桌面版(claude-desktop-3p / Agent SDK) +// 以逗号接后继内容("…for Claude, running within the Claude Agent SDK.")。 +// 带句号的整句只匹配前者,桌面版会漏网、指纹原样发上游 → 400 code=11128。 +// 去掉结尾标点后两种形态一并覆盖(替换串同样不带标点,让原有标点原样保留)。 +// 注意仍要求 "You are Claude Code, " 前缀,不做更宽的子串替换, +// 以免误伤 TestExactMatchOnlyVariantNotTouched 所保护的零散文本。 +var sanitizeRewrites = [][2]string{ + { + "You are Claude Code, Anthropic's official CLI for Claude", + "You are Claude Code, Anthropic's official CLI tool for Claude", + }, + { + "Main branch (you will usually use this for PRs)", + "Default branch (you will usually use this for PRs)", + }, + { + "You are a coding agent running in the Codex CLI, a terminal-based coding assistant.", + "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.", + }, + { + // 反馈句:整句带 Anthropic 仓库链接,上游按整句拦截(只留链接或只留半边均不拦, + // 实测需整句同时出现)。give→provide 一词之差即可绕过,语义不变。 + "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", + "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues", + }, + { + // 上游反探测:只要请求体里出现裸数字 11128 就整单拦截(与该数字的上下文无关—— + // "code=11128" / 裸 "11128" / "错误码 11128" / "Code=11128" 全部命中; + // 相邻的 11148 / 11101 / 11115 / 99999 均放行)。11128 正是本类拦截自身的错误码, + // 上游据此识别"在讨论/回显其内部错误码"的请求。 + // 代价:用户对话中任何 11128 都会被改写——但这串数字出现在请求里本身就是拦截条件, + // 不改写必然失败。插入连字符保留可读性与指代(零宽空格无效,实测上游会归一化)。 + "11128", + "11-128", + }, +} + +// sanitizeText 单段文本净化:预检不中 → 返回原串(零分配)。 +func sanitizeText(text string) string { + if !hasFingerprint(text) { + return text + } + for _, rw := range sanitizeRewrites { + text = strings.ReplaceAll(text, rw[0], rw[1]) + } + if sanitizeHdrRe.MatchString(text) { + text = sanitizeHdrRe.ReplaceAllString(text, "") + } + if strings.Contains(text, "cc_") { + prev := "" + for prev != text { // 清尾随裸 kv(cc_version=...; cc_entrypoint=...;) + prev = text + text = sanitizeKvRe.ReplaceAllString(text, "") + } + } + // 兜底:键值形态已在上面整段删除,这里只剩裸键名(引用/示例文本形态)。 + text = sanitizeBareHdrRe.ReplaceAllString(text, "x-anthropic-billing-hdr") + return strings.TrimSpace(text) +} + +// hasFingerprint 特征预检:先走 strings.Contains 快速路径(零分配); +// header 键名有大小写变体(X-Anthropic-...)且可能以裸键名形态出现(无冒号), +// Contains 大小写敏感、sanitizeHdrRe 要求冒号——两者都会漏掉「混合大小写 + 裸键名」, +// 必须再用不要求冒号的 (?i) 正则兜底(sanitizeBareHdrRe),否则整条净化被跳过。 +// sanitizeBareHdrRe 不要求冒号,是 sanitizeHdrRe 的超集,故无需再单独匹配后者。 +func hasFingerprint(text string) bool { + for _, f := range sanitizeFeatures { + if strings.Contains(text, f) { + return true + } + } + return sanitizeBareHdrRe.MatchString(text) +} + +// sanitizeContent 兼容字符串与多模态数组;只动 text part,image 等 part 不动。 +// 返回净化后的值及是否发生变化。 +func sanitizeContent(v any) (any, bool) { + switch c := v.(type) { + case string: + s := sanitizeText(c) + return s, s != c + case []any: + changed := false + for _, p := range c { + m, ok := p.(map[string]any) + if !ok { + continue + } + text, ok := m["text"].(string) + if !ok { + continue + } + if s := sanitizeText(text); s != text { + m["text"] = s + changed = true + } + } + return c, changed + } + return v, false +} + +// sanitizeToolCalls 净化 assistant.tool_calls[].function.arguments。 +// +// arguments 是**字符串化的 JSON**(不是对象),因此按文本走 sanitizeText 即可。 +// 这块长期是盲区:工具调用消息的 content 通常是 null,而旧版 sanitizeMessages +// 在 content 缺失时直接 continue,整条消息连 tool_calls 一起被跳过—— +// 于是历史里任何写进工具参数的被拦字符串(文件名、命令、写入内容)都会原样漏出。 +func sanitizeToolCalls(v any) bool { + callList, ok := v.([]any) + if !ok { + return false + } + changed := false + for _, c := range callList { + call, ok := c.(map[string]any) + if !ok { + continue + } + fn, ok := call["function"].(map[string]any) + if !ok { + continue + } + args, ok := fn["arguments"].(string) + if !ok { + continue + } + if s := sanitizeText(args); s != args { + fn["arguments"] = s + changed = true + } + } + return changed +} + +// sanitizeMessages 净化 messages 中的 content 与 tool_calls;任一命中返回 true。 +func sanitizeMessages(messages []any) bool { + changed := false + for _, msg := range messages { + m, ok := msg.(map[string]any) + if !ok { + continue + } + // content 与 tool_calls 各自独立判断:content 可以为 null(工具调用轮), + // 早期版本在此 continue,导致这类消息的 tool_calls 完全不被净化。 + if c, ok := m["content"]; ok { + if nc, ch := sanitizeContent(c); ch { + m["content"] = nc + changed = true + } + } + // reasoning_content(思维链回填字段,见 thinking.go/sse.go)实测同样 + // 携带指纹,与 content 同等净化。string 形态直接走 sanitizeText。 + if rc, ok := m["reasoning_content"].(string); ok { + if s := sanitizeText(rc); s != rc { + m["reasoning_content"] = s + changed = true + } + } + // reasoning:另一形态的思维链字段。thinking.go 的回填会把客户端送来的 + // reasoning_content 镜像进 reasoning(反之亦然),而此前只洗 content / + // reasoning_content / tool_calls —— 镜像进 reasoning 的指纹原样出站, + // 裸 "11128" 这种反探测串同样致命。 + if r, ok := m["reasoning"].(string); ok { + if s := sanitizeText(r); s != r { + m["reasoning"] = s + changed = true + } + } + if tc, ok := m["tool_calls"]; ok { + if sanitizeToolCalls(tc) { + changed = true + } + } + } + return changed +} diff --git a/internal/upstream/sanitize_test.go b/internal/upstream/sanitize_test.go index 2bfc2d4933dd39d009f7b1276464297c6d685678..55dfca9e1f4da742798d0b9411847413fbdea74c 100644 --- a/internal/upstream/sanitize_test.go +++ b/internal/upstream/sanitize_test.go @@ -1,374 +1,393 @@ -package upstream - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -const ( - ccIdentity = "You are Claude Code, Anthropic's official CLI for Claude." - ccBranch = "Main branch (you will usually use this for PRs)" - ccHeader = "x-anthropic-billing-header: cc_version=1.0; cc_entrypoint=cli;" - // Codex instructions 首段(上游逐字精确指纹,三句缺一不可)。 - codexInstructions = "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful." -) - -func TestIdentityRewritten(t *testing.T) { - out := sanitizeText(ccIdentity) - if !strings.Contains(out, "official CLI tool for Claude.") { - t.Errorf("identity not rewritten: %q", out) - } - if strings.Contains(out, ccIdentity) { - t.Errorf("original identity still present: %q", out) - } -} - -// 桌面版(claude-desktop-3p / Agent SDK)的身份句以逗号接后继内容,结尾不是句号。 -// 回归用例:匹配串曾带结尾句号,导致该形态漏网、指纹原样发上游 → 400 code=11128。 -func TestIdentityDesktopVariantRewritten(t *testing.T) { - in := "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." - out := sanitizeText(in) - if strings.Contains(out, "official CLI for Claude") { - t.Errorf("desktop identity not rewritten: %q", out) - } - if !strings.Contains(out, "official CLI tool for Claude, running within the Claude Agent SDK.") { - t.Errorf("desktop identity suffix not preserved: %q", out) - } -} - -func TestBranchRewritten(t *testing.T) { - out := sanitizeText(ccBranch) - if !strings.Contains(out, "Default branch (you will usually use this for PRs)") { - t.Errorf("branch not rewritten: %q", out) - } - if strings.Contains(out, "Main branch") { - t.Errorf("original branch still present: %q", out) - } -} - -// 反馈句带 Anthropic 仓库链接,上游按整句拦截(实测只留链接或只留半边均不拦)。 -// 回归用例:give→provide 一词之差即可绕过。 -func TestFeedbackSentenceRewritten(t *testing.T) { - in := "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues" - out := sanitizeText(in) - if strings.Contains(out, "To give feedback") { - t.Errorf("feedback sentence not rewritten: %q", out) - } - if !strings.Contains(out, "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues") { - t.Errorf("feedback sentence not rewritten as expected: %q", out) - } -} - -// 上游反探测:请求体里出现裸数字 11128 即整单拦截(与上下文无关)。 -// 回归用例:该串会被改写为 11-128 以打断精确匹配。 -func TestUpstreamErrorCodeRewritten(t *testing.T) { - in := "upstream returned code=11128 for this request" - out := sanitizeText(in) - if strings.Contains(out, "11128") { - t.Errorf("error code not rewritten: %q", out) - } - if !strings.Contains(out, "11-128") { - t.Errorf("error code not rewritten as expected: %q", out) - } -} - -// 回归:工具调用消息的 content 常为 null,而旧版 sanitizeMessages 在 content 缺失时 -// 直接 continue,整条消息连 tool_calls 一起被跳过 → arguments 里的被拦字符串原样漏出。 -func TestToolCallArgumentsSanitized(t *testing.T) { - msgs := []any{ - map[string]any{"role": "user", "content": "run"}, - map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ - map[string]any{"id": "c1", "type": "function", "function": map[string]any{ - "name": "Bash", - "arguments": `{"command":"echo 11128"}`, - }}, - }}, - } - if !sanitizeMessages(msgs) { - t.Fatal("sanitizeMessages 未报告任何改动,tool_calls 被跳过") - } - fn := msgs[1].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) - got := fn["arguments"].(string) - if strings.Contains(got, "11128") { - t.Errorf("tool_call arguments 未被净化: %q", got) - } -} - -func TestBillingHeaderStrippedValueIrrelevant(t *testing.T) { - out := sanitizeText(ccHeader) - if strings.Contains(out, "x-anthropic-billing-header") { - t.Errorf("header not stripped: %q", out) - } -} - -// 附加验证:正常对话里出现 github.com/anthropics/ 链接(但不是反馈句整句) -// 时,预检特征命中(进入净化),但改写层只动精确匹配的整句——普通链接文本 -// 不该被改写。同理,既不含 11128 也不含反馈整句的文本原样返回。 -func TestNormalAnthropicLinkNotRewritten(t *testing.T) { - in := "see https://github.com/anthropics/anthropic-cookbook for examples" - if out := sanitizeText(in); out != in { - t.Errorf("normal anthropic link should be untouched: %q -> %q", in, out) - } -} - -// Codex instructions 首段:命中预告且整句改写,逐字指纹被破坏、语义保留。 -func TestCodexInstructionsRewritten(t *testing.T) { - out := sanitizeText(codexInstructions) - if strings.Contains(out, codexInstructions) { - t.Errorf("codex fingerprint still present: %q", out) - } - if !strings.Contains(out, "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.") { - t.Errorf("codex first sentence not rewritten: %q", out) - } - // 其余两句原样保留,语义不变。 - if !strings.Contains(out, "Codex CLI is an open source project led by OpenAI.") || - !strings.Contains(out, "You are expected to be precise, safe, and helpful.") { - t.Errorf("codex remaining sentences altered: %q", out) - } -} - -// 预检必须能认出 Codex 特征(此前只含 Claude Code,导致提前放行)。 -func TestCodexFingerprintDetected(t *testing.T) { - if !hasFingerprint(codexInstructions) { - t.Error("codex fingerprint not detected by precheck") - } -} - -// 非精确变体不应被改写:仅去掉其中一个词即视为已破坏,无需改动。 -func TestCodexVariantNotTouched(t *testing.T) { - in := "You are a coding agent running in a CLI, a terminal-based coding assistant." - if out := sanitizeText(in); out != in { - t.Errorf("already-broken variant should be untouched: %q -> %q", in, out) - } -} - -func TestBillingHeaderCaseInsensitive(t *testing.T) { - alt := "X-Anthropic-Billing-Header: cc_version=1.0;" - out := strings.ToLower(sanitizeText(alt)) - if strings.Contains(out, "billing") { - t.Errorf("case-insensitive header not stripped: %q", out) - } -} - -func TestTrailingKVStripped(t *testing.T) { - out := sanitizeText("...; cc_version=2.0; cc_entrypoint=cli;") - if strings.Contains(out, "cc_version") || strings.Contains(out, "cc_entrypoint") { - t.Errorf("trailing kv not stripped: %q", out) - } -} - -func TestExactMatchOnlyVariantNotTouched(t *testing.T) { - in := "...official CLI for Claude!" - if out := sanitizeText(in); out != in { - t.Errorf("variant should be untouched: %q -> %q", in, out) - } -} - -func TestUserFreeTextNotTouched(t *testing.T) { - in := "please use main branch for this repo" - if out := sanitizeText(in); out != in { - t.Errorf("free text should be untouched: %q -> %q", in, out) - } -} - -func TestNoFeatureReturnsSameString(t *testing.T) { - in := "ordinary user message" - if out := sanitizeText(in); out != in { - t.Errorf("no-feature text should pass through unchanged: %q -> %q", in, out) - } -} - -func TestMultimodalTextPartOnly(t *testing.T) { - imgPart := map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": "..."}} - content := []any{ - map[string]any{"type": "text", "text": ccIdentity}, - imgPart, - } - out, changed := sanitizeContent(content) - if !changed { - t.Fatal("expected change") - } - parts := out.([]any) - txt, _ := parts[0].(map[string]any)["text"].(string) - if !strings.Contains(txt, "CLI tool") { - t.Errorf("text part not sanitized: %q", txt) - } - img, _ := parts[1].(map[string]any) - if img["type"] != "image" || img["source"].(map[string]any)["data"] != "..." { - t.Error("image part modified") - } -} - -// 集成:完整请求体经 PrepareBodyOpt 净化后无残留指纹,且 stream/tool_choice 行为不受影响。 -func TestPrepareBodyOptSanitizesSystem(t *testing.T) { - body := []byte(`{"model":"glm-5.2","messages":[` + - `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + - `{"role":"user","content":"hi"}]}`) - out := PrepareBodyOpt(body, true) - var obj map[string]any - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatal(err) - } - if obj["stream"] != true { - t.Error("stream not forced") - } - msgs := obj["messages"].([]any) - sys, _ := msgs[0].(map[string]any)["content"].(string) - if strings.Contains(sys, "x-anthropic-billing-header") || strings.Contains(sys, ccIdentity) || strings.Contains(sys, ccBranch) { - t.Errorf("fingerprints remain: %q", sys) - } - if !strings.Contains(sys, "CLI tool") { - t.Errorf("rewrite missing: %q", sys) - } -} - -func TestPrepareBodyOptDisabledPreservesFingerprints(t *testing.T) { - body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) - out := PrepareBodyOpt(body, false) - if !strings.Contains(string(out), ccIdentity) { - t.Error("sanitize=false should preserve fingerprints") - } - // 但 stream 仍强制 - var obj map[string]any - _ = json.Unmarshal(out, &obj) - if obj["stream"] != true { - t.Error("stream should still be forced") - } -} - -// PrepareBody 默认行为 = 开启脱敏(保持向后兼容)。 -func TestPrepareBodyDefaultSanitizes(t *testing.T) { - body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) - out := PrepareBodyOpt(body, true) - if strings.Contains(string(out), ccIdentity) { - t.Error("PrepareBody default should sanitize") - } -} - -// 出站边界集成:ChatStream 发往上游的 wire body 必须无残留指纹。 -func TestChatStreamWireBodySanitized(t *testing.T) { - var gotBody []byte - ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { - gotBody, _ = io.ReadAll(r.Body) - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) - }) - defer ts.Close() - - c := New() - c.SanitizeFingerprints.Store(true) - c.ChatBaseCN = ts.URL - acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} - - body := []byte(`{"model":"glm-5.2","messages":[` + - `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + - `{"role":"user","content":"hi"}]}`) - rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) - if err != nil { - t.Fatal(err) - } - defer rc.Close() - if status >= 400 { - t.Fatalf("upstream status %d: %s", status, respBody) - } - // 上游收到的 body:stream 强制 + 指纹已净化 - var obj map[string]any - if err := json.Unmarshal(gotBody, &obj); err != nil { - t.Fatalf("wire body not json: %v", err) - } - if obj["stream"] != true { - t.Error("wire body stream not forced") - } - sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) - for _, fp := range []string{"x-anthropic-billing-header", ccIdentity, ccBranch} { - if strings.Contains(sys, fp) { - t.Errorf("wire body contains fingerprint %q: %q", fp, sys) - } - } -} - -// 出站边界(Codex 场景):CPA 把 instructions 折成 role=system 的首条消息, -// 净化后 wire body 不得残留 Codex 逐字指纹;其余消息不受影响。 -func TestChatStreamWireBodyCodexInstructionsSanitized(t *testing.T) { - var gotBody []byte - ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { - gotBody, _ = io.ReadAll(r.Body) - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) - }) - defer ts.Close() - - c := New() - c.SanitizeFingerprints.Store(true) - c.ChatBaseCN = ts.URL - acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} - - body := []byte(`{"model":"kimi-k3","messages":[` + - `{"role":"system","content":"` + codexInstructions + `"},` + - `{"role":"user","content":"say ok"}]}`) - rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) - if err != nil { - t.Fatal(err) - } - defer rc.Close() - if status >= 400 { - t.Fatalf("upstream status %d: %s", status, respBody) - } - var obj map[string]any - if err := json.Unmarshal(gotBody, &obj); err != nil { - t.Fatalf("wire body not json: %v", err) - } - sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) - if strings.Contains(sys, codexInstructions) || - strings.Contains(sys, "in the Codex CLI, a terminal-based coding assistant.") { - t.Errorf("wire body still contains codex fingerprint: %q", sys) - } - if !strings.Contains(sys, "running in the Codex CLI tool, a terminal-based") { - t.Errorf("codex rewrite missing on wire: %q", sys) - } - user, _ := obj["messages"].([]any)[1].(map[string]any)["content"].(string) - if user != "say ok" { - t.Errorf("user message altered: %q", user) - } -} - -// 出站边界:关闭脱敏后 wire body 原样保留指纹(验证开关真实有效)。 -func TestChatStreamWireBodySanitizeDisabled(t *testing.T) { - var gotBody []byte - ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { - gotBody, _ = io.ReadAll(r.Body) - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: [DONE]\n\n")) - }) - defer ts.Close() - - c := New() - c.SanitizeFingerprints.Store(false) - c.ChatBaseCN = ts.URL - acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} - - body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) - rc, status, _, err := c.ChatStream(acct, body, "", ChatMeta{}) - if err != nil { - t.Fatal(err) - } - defer rc.Close() - if status >= 400 { - t.Fatalf("upstream status %d", status) - } - if !strings.Contains(string(gotBody), ccIdentity) { - t.Error("sanitize disabled should preserve fingerprint on wire") - } -} - -// newTestUpstream 起一个假上游并捕获请求。 -func newTestUpstream(t *testing.T, h http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(h) -} +package upstream + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + ccIdentity = "You are Claude Code, Anthropic's official CLI for Claude." + ccBranch = "Main branch (you will usually use this for PRs)" + ccHeader = "x-anthropic-billing-header: cc_version=1.0; cc_entrypoint=cli;" + // Codex instructions 首段(上游逐字精确指纹,三句缺一不可)。 + codexInstructions = "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful." +) + +func TestIdentityRewritten(t *testing.T) { + out := sanitizeText(ccIdentity) + if !strings.Contains(out, "official CLI tool for Claude.") { + t.Errorf("identity not rewritten: %q", out) + } + if strings.Contains(out, ccIdentity) { + t.Errorf("original identity still present: %q", out) + } +} + +// 桌面版(claude-desktop-3p / Agent SDK)的身份句以逗号接后继内容,结尾不是句号。 +// 回归用例:匹配串曾带结尾句号,导致该形态漏网、指纹原样发上游 → 400 code=11128。 +func TestIdentityDesktopVariantRewritten(t *testing.T) { + in := "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." + out := sanitizeText(in) + if strings.Contains(out, "official CLI for Claude") { + t.Errorf("desktop identity not rewritten: %q", out) + } + if !strings.Contains(out, "official CLI tool for Claude, running within the Claude Agent SDK.") { + t.Errorf("desktop identity suffix not preserved: %q", out) + } +} + +func TestBranchRewritten(t *testing.T) { + out := sanitizeText(ccBranch) + if !strings.Contains(out, "Default branch (you will usually use this for PRs)") { + t.Errorf("branch not rewritten: %q", out) + } + if strings.Contains(out, "Main branch") { + t.Errorf("original branch still present: %q", out) + } +} + +// 反馈句带 Anthropic 仓库链接,上游按整句拦截(实测只留链接或只留半边均不拦)。 +// 回归用例:give→provide 一词之差即可绕过。 +func TestFeedbackSentenceRewritten(t *testing.T) { + in := "To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues" + out := sanitizeText(in) + if strings.Contains(out, "To give feedback") { + t.Errorf("feedback sentence not rewritten: %q", out) + } + if !strings.Contains(out, "To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues") { + t.Errorf("feedback sentence not rewritten as expected: %q", out) + } +} + +// 上游反探测:请求体里出现裸数字 11128 即整单拦截(与上下文无关)。 +// 回归用例:该串会被改写为 11-128 以打断精确匹配。 +func TestUpstreamErrorCodeRewritten(t *testing.T) { + in := "upstream returned code=11128 for this request" + out := sanitizeText(in) + if strings.Contains(out, "11128") { + t.Errorf("error code not rewritten: %q", out) + } + if !strings.Contains(out, "11-128") { + t.Errorf("error code not rewritten as expected: %q", out) + } +} + +// 回归:工具调用消息的 content 常为 null,而旧版 sanitizeMessages 在 content 缺失时 +// 直接 continue,整条消息连 tool_calls 一起被跳过 → arguments 里的被拦字符串原样漏出。 +func TestToolCallArgumentsSanitized(t *testing.T) { + msgs := []any{ + map[string]any{"role": "user", "content": "run"}, + map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ + map[string]any{"id": "c1", "type": "function", "function": map[string]any{ + "name": "Bash", + "arguments": `{"command":"echo 11128"}`, + }}, + }}, + } + if !sanitizeMessages(msgs) { + t.Fatal("sanitizeMessages 未报告任何改动,tool_calls 被跳过") + } + fn := msgs[1].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + got := fn["arguments"].(string) + if strings.Contains(got, "11128") { + t.Errorf("tool_call arguments 未被净化: %q", got) + } +} + +func TestBillingHeaderStrippedValueIrrelevant(t *testing.T) { + out := sanitizeText(ccHeader) + if strings.Contains(out, "x-anthropic-billing-header") { + t.Errorf("header not stripped: %q", out) + } +} + +// 附加验证:正常对话里出现 github.com/anthropics/ 链接(但不是反馈句整句) +// 时,预检特征命中(进入净化),但改写层只动精确匹配的整句——普通链接文本 +// 不该被改写。同理,既不含 11128 也不含反馈整句的文本原样返回。 +func TestNormalAnthropicLinkNotRewritten(t *testing.T) { + in := "see https://github.com/anthropics/anthropic-cookbook for examples" + if out := sanitizeText(in); out != in { + t.Errorf("normal anthropic link should be untouched: %q -> %q", in, out) + } +} + +// Codex instructions 首段:命中预告且整句改写,逐字指纹被破坏、语义保留。 +func TestCodexInstructionsRewritten(t *testing.T) { + out := sanitizeText(codexInstructions) + if strings.Contains(out, codexInstructions) { + t.Errorf("codex fingerprint still present: %q", out) + } + if !strings.Contains(out, "You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.") { + t.Errorf("codex first sentence not rewritten: %q", out) + } + // 其余两句原样保留,语义不变。 + if !strings.Contains(out, "Codex CLI is an open source project led by OpenAI.") || + !strings.Contains(out, "You are expected to be precise, safe, and helpful.") { + t.Errorf("codex remaining sentences altered: %q", out) + } +} + +// 预检必须能认出 Codex 特征(此前只含 Claude Code,导致提前放行)。 +func TestCodexFingerprintDetected(t *testing.T) { + if !hasFingerprint(codexInstructions) { + t.Error("codex fingerprint not detected by precheck") + } +} + +// 非精确变体不应被改写:仅去掉其中一个词即视为已破坏,无需改动。 +func TestCodexVariantNotTouched(t *testing.T) { + in := "You are a coding agent running in a CLI, a terminal-based coding assistant." + if out := sanitizeText(in); out != in { + t.Errorf("already-broken variant should be untouched: %q -> %q", in, out) + } +} + +func TestBillingHeaderCaseInsensitive(t *testing.T) { + alt := "X-Anthropic-Billing-Header: cc_version=1.0;" + out := strings.ToLower(sanitizeText(alt)) + if strings.Contains(out, "billing") { + t.Errorf("case-insensitive header not stripped: %q", out) + } +} + +func TestTrailingKVStripped(t *testing.T) { + out := sanitizeText("...; cc_version=2.0; cc_entrypoint=cli;") + if strings.Contains(out, "cc_version") || strings.Contains(out, "cc_entrypoint") { + t.Errorf("trailing kv not stripped: %q", out) + } +} + +func TestExactMatchOnlyVariantNotTouched(t *testing.T) { + in := "...official CLI for Claude!" + if out := sanitizeText(in); out != in { + t.Errorf("variant should be untouched: %q -> %q", in, out) + } +} + +func TestUserFreeTextNotTouched(t *testing.T) { + in := "please use main branch for this repo" + if out := sanitizeText(in); out != in { + t.Errorf("free text should be untouched: %q -> %q", in, out) + } +} + +func TestNoFeatureReturnsSameString(t *testing.T) { + in := "ordinary user message" + if out := sanitizeText(in); out != in { + t.Errorf("no-feature text should pass through unchanged: %q -> %q", in, out) + } +} + +func TestMultimodalTextPartOnly(t *testing.T) { + imgPart := map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": "..."}} + content := []any{ + map[string]any{"type": "text", "text": ccIdentity}, + imgPart, + } + out, changed := sanitizeContent(content) + if !changed { + t.Fatal("expected change") + } + parts := out.([]any) + txt, _ := parts[0].(map[string]any)["text"].(string) + if !strings.Contains(txt, "CLI tool") { + t.Errorf("text part not sanitized: %q", txt) + } + img, _ := parts[1].(map[string]any) + if img["type"] != "image" || img["source"].(map[string]any)["data"] != "..." { + t.Error("image part modified") + } +} + +// 集成:完整请求体经 PrepareBodyOpt 净化后无残留指纹,且 stream/tool_choice 行为不受影响。 +func TestPrepareBodyOptSanitizesSystem(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[` + + `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + + `{"role":"user","content":"hi"}]}`) + out := PrepareBodyOpt(body, true) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatal(err) + } + if obj["stream"] != true { + t.Error("stream not forced") + } + msgs := obj["messages"].([]any) + sys, _ := msgs[0].(map[string]any)["content"].(string) + if strings.Contains(sys, "x-anthropic-billing-header") || strings.Contains(sys, ccIdentity) || strings.Contains(sys, ccBranch) { + t.Errorf("fingerprints remain: %q", sys) + } + if !strings.Contains(sys, "CLI tool") { + t.Errorf("rewrite missing: %q", sys) + } +} + +func TestPrepareBodyOptDisabledPreservesFingerprints(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + out := PrepareBodyOpt(body, false) + if !strings.Contains(string(out), ccIdentity) { + t.Error("sanitize=false should preserve fingerprints") + } + // 但 stream 仍强制 + var obj map[string]any + _ = json.Unmarshal(out, &obj) + if obj["stream"] != true { + t.Error("stream should still be forced") + } +} + +// PrepareBody 默认行为 = 开启脱敏(保持向后兼容)。 +func TestPrepareBodyDefaultSanitizes(t *testing.T) { + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + out := PrepareBodyOpt(body, true) + if strings.Contains(string(out), ccIdentity) { + t.Error("PrepareBody default should sanitize") + } +} + +// 出站边界集成:ChatStream 发往上游的 wire body 必须无残留指纹。 +func TestChatStreamWireBodySanitized(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(true) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"glm-5.2","messages":[` + + `{"role":"system","content":"` + ccIdentity + ` ` + ccHeader + `"},` + + `{"role":"user","content":"hi"}]}`) + rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d: %s", status, respBody) + } + // 上游收到的 body:stream 强制 + 指纹已净化 + var obj map[string]any + if err := json.Unmarshal(gotBody, &obj); err != nil { + t.Fatalf("wire body not json: %v", err) + } + if obj["stream"] != true { + t.Error("wire body stream not forced") + } + sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) + for _, fp := range []string{"x-anthropic-billing-header", ccIdentity, ccBranch} { + if strings.Contains(sys, fp) { + t.Errorf("wire body contains fingerprint %q: %q", fp, sys) + } + } +} + +// 出站边界(Codex 场景):CPA 把 instructions 折成 role=system 的首条消息, +// 净化后 wire body 不得残留 Codex 逐字指纹;其余消息不受影响。 +func TestChatStreamWireBodyCodexInstructionsSanitized(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(true) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"kimi-k3","messages":[` + + `{"role":"system","content":"` + codexInstructions + `"},` + + `{"role":"user","content":"say ok"}]}`) + rc, status, respBody, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d: %s", status, respBody) + } + var obj map[string]any + if err := json.Unmarshal(gotBody, &obj); err != nil { + t.Fatalf("wire body not json: %v", err) + } + sys, _ := obj["messages"].([]any)[0].(map[string]any)["content"].(string) + if strings.Contains(sys, codexInstructions) || + strings.Contains(sys, "in the Codex CLI, a terminal-based coding assistant.") { + t.Errorf("wire body still contains codex fingerprint: %q", sys) + } + if !strings.Contains(sys, "running in the Codex CLI tool, a terminal-based") { + t.Errorf("codex rewrite missing on wire: %q", sys) + } + user, _ := obj["messages"].([]any)[1].(map[string]any)["content"].(string) + if user != "say ok" { + t.Errorf("user message altered: %q", user) + } +} + +// 出站边界:关闭脱敏后 wire body 原样保留指纹(验证开关真实有效)。 +func TestChatStreamWireBodySanitizeDisabled(t *testing.T) { + var gotBody []byte + ts := newTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: [DONE]\n\n")) + }) + defer ts.Close() + + c := New() + c.SanitizeFingerprints.Store(false) + c.ChatBaseCN = ts.URL + acct := &auth.Auth{AccessToken: "test-token", Domain: "copilot.tencent.com", UID: "u1"} + + body := []byte(`{"model":"glm-5.2","messages":[{"role":"system","content":"` + ccIdentity + `"}]}`) + rc, status, _, err := c.ChatStream(acct, body, "", ChatMeta{}) + if err != nil { + t.Fatal(err) + } + defer rc.Close() + if status >= 400 { + t.Fatalf("upstream status %d", status) + } + if !strings.Contains(string(gotBody), ccIdentity) { + t.Error("sanitize disabled should preserve fingerprint on wire") + } +} + +// TestSanitizeMessagesScrubsReasoningField thinking.go 的回填会把客户端送来的 +// reasoning_content 镜像进 reasoning 字段;此前只洗 content / reasoning_content / +// tool_calls → 镜像进 reasoning 的指纹(裸 "11128" 这类反探测串)原样出站, +// 而请求体里出现裸 11128 本身就是上游整单拦截条件。 +func TestSanitizeMessagesScrubsReasoningField(t *testing.T) { + ms := []any{map[string]any{ + "role": "assistant", + "content": "hi", + "reasoning": "upstream said 11128", + }} + if !sanitizeMessages(ms) { + t.Fatal("reasoning 字段里的指纹未被净化") + } + got, _ := ms[0].(map[string]any)["reasoning"].(string) + if strings.Contains(got, "11128") { + t.Fatalf("reasoning 仍含裸指纹: %q", got) + } +} + +// newTestUpstream 起一个假上游并捕获请求。 +func newTestUpstream(t *testing.T, h http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(h) +} diff --git a/internal/upstream/school.go b/internal/upstream/school.go index 7d10bed503d8bd93b23d0471b880e6e846eabb4c..fb0d512bbada4d721999c2e6bdec22935b2588b8 100644 --- a/internal/upstream/school.go +++ b/internal/upstream/school.go @@ -1,242 +1,242 @@ -// school.go 开学季活动(school-season,活动期 2026-09-13 ~ 09-24)纯 API 自动化。 -// -// 判据(2026-09-13 小程序 MCP 逆向 + 三账号实测,protocol.md §7.11): -// - share_invite(每日 +100c +1抽奖):POST /tasks/share-complete {channel:"wechat"} -// 即点亮——纯前端上报,服务端不校验真实分享回执。本模块的主目标。 -// - chat_3_times / expert_use:判据绑定小程序原生沙箱会话(e2b runtime), -// webchat 普通会话不计数,纯 API 不做(需小程序内人工对话)。 -// - 抽奖:POST /wheel/draw {draw_uuid}(前端生成 uuid,消耗 1 chance)。 -// -// 端点基址 www.codebuddy.cn(billing 同域);信封 {code,msg,data},code=0 成功。 -package upstream - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -const schoolBase = "/portal/activity/school" - -// schoolJSON 学院活动 API 请求(剥信封,业务 code≠0 返回带 msg 的 error)。 -func (c *Client) schoolJSON(a *auth.Auth, method, path string, body map[string]any, out any) error { - var raw []byte - if body != nil { - raw, _ = json.Marshal(body) - } - req, err := http.NewRequest(method, c.billingBase(a)+schoolBase+path, bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - data, err := c.doJSON(req) - if err != nil { - return err - } - if out != nil { - return json.Unmarshal(data, out) - } - return nil -} - -// - chat_3_times:3 条 chat_request_send 即 3/3(conversationId 任意、桌面/mp -// 头族均可计数,无需真实沙箱会话)。 -// - expert_use:mp 指纹事件链 expert_summon_click + expert_summoned + -// expert_actual_use + chat_request_send(开学季分类专家)即点亮。 - -const mpReportPath = "/v2/report" - -// schoolOpenDayActivityID 开学季/校园日活动 id(事件 activityId 字段值,两域共用)。 -const schoolOpenDayActivityID = "school_open_day_2026" - -// mpEventBase 小程序埋点公共指纹(appservice wQ()+Ao() 对齐)。 -func mpEventBase(a *auth.Auth) map[string]any { - return map[string]any{ - "timestamp": time.Now().UnixMilli(), - "ideType": "WorkBuddy_MP", - "ideVersion": "2.4.0", - "extName": "workbuddy-mp", - "extVersion": "2.4.0", - "product": "SaaS", - "ideName": "wx_app_cloud", - "platform": "mini_program", - "os": "windows", - "osVersion": "11", - "arch": "x64", - "machineId": "0655736a-607f-4d9d-b430-58176ee9a090", - "timezone": "Asia/Shanghai", - "userId": a.UID, - "userNickname": a.Nickname, - } -} - -// ReportMPEvent 以小程序指纹向 www.codebuddy.cn/v2/report 批量上报事件。 -func (c *Client) ReportMPEvent(a *auth.Auth, events ...map[string]any) error { - if len(events) == 0 { - return fmt.Errorf("mp report: no events") - } - base := mpEventBase(a) - arr := make([]map[string]any, 0, len(events)) - for _, ev := range events { - m := map[string]any{} - for k, v := range base { - m[k] = v - } - for k, v := range ev { - m[k] = v - } - arr = append(arr, m) - } - raw, err := json.Marshal(arr) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, c.BillingBaseCN+mpReportPath, bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - req.Header.Set("X-Client-Product", "workbuddy-mp") - req.Header.Set("X-Client-Version", "2.4.0") - req.Header.Set("X-Client-Platform", "mp-weixin") - req.Header.Set("X-Platform", "wechatmp") - _, err = c.doJSON(req) - return err -} - -// SchoolChatTimesEvents 构造一条 chat_request_send 事件(chat_3_times 计数)。 -func SchoolChatTimesEvents(conversationID string) map[string]any { - rid := "wb2api-" + clientToken() - return map[string]any{ - "eventCode": "chat_request_send", - "inputLength": 14, "isPlan": false, "isAutoExecuteTerminal": false, - "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, - "maxSteps": 500, "temperature": 0, "maxRetries": 0, - "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, - "codebaseId": "", "mentionContextCount": 0, "command": "", - "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, - "traceId": rid, "rootRequestId": rid, - "parentConversationId": conversationID, "conversationId": conversationID, - "messageId": "msg-" + rid[len(rid)-8:], - "agentName": "mp", "agentType": "main", - "codebuddy.session_id": conversationID, - "codebuddy.conversation_request_id": rid, - } -} - -// SchoolSeasonChatEvent 构造 growth 域「校园日」(school_season)判据事件: -// mini 指纹 chat_request_send + activityId=school_open_day_2026(与 school 域 -// 开学季同 activityId 关联;实测无 activityId 的事件不点亮)。事件形状与 -// SchoolChatTimesEvents 同构(school 域 chat_3_times 同款),仅追加 activityId。 -func SchoolSeasonChatEvent(conversationID string) map[string]any { - ev := SchoolChatTimesEvents(conversationID) - ev["activityId"] = schoolOpenDayActivityID - return ev -} - -// MiniExpertUseEvent 构造 growth 域 Sequential_Tasks_2「在小程序内选中专家并完成 -// 有效对话」的判据事件:mp 指纹 expert_actual_use。形状对齐小程序源码 -// app-service.js 的真实发射点(上游 task_runner 实测 2026-09-23:上报即 completed, -// claim +200c+5e)。与 school 域的 SchoolExpertUseEvents 是**两套口径**,勿照抄: -// - 不带 conversationId/activityId——真实事件就是这两个字段都不带; -// - extVersion 用小程序自身版本 2.2.8(覆盖 mpEventBase 的 2.4.0); -// - source=mini_program + type 固定 "send_message"(小程序恒发此值)。 -// -// expertID 必须是专家市场真实 ex_ id(ListMarketExperts),空 id 服务端不入账。 -func MiniExpertUseEvent(expertID, expertName, expertType string) map[string]any { - if expertType == "" { - expertType = "agent" - } - if expertName == "" { - expertName = expertID - } - return map[string]any{ - "eventCode": "expert_actual_use", "reportDelay": 0, - "extVersion": "2.2.8", "source": "mini_program", - "id": expertID, "name": expertID, - "expertTitle": expertName, "type": "send_message", - "characterCount": 12, "expertType": expertType, - } -} - -// MiniChatModelEvent mp 对话事件 + 模型字段(Sequential_Tasks_5「使用 GLM5.2」判据 -// 载体):小程序 chat_request_send 真实发射点(mpsrc main 32904 模块)带 -// requestModelId / requestModelName——Tasks_1/3 的裸对话事件不带模型,模型任务 -// 须用本形态(判据待解锁实测验证)。 -func MiniChatModelEvent(conversationID, modelID, modelName string) map[string]any { - ev := SchoolChatTimesEvents(conversationID) - ev["requestModelId"] = modelID - ev["requestModelName"] = modelName - return ev -} - -// MiniPlaybookEvents mp 指纹灵感事件组(Sequential_Tasks_7「体验灵感功能」判据 -// 载体,形状对齐 mpsrc main 73640/73665 发射点:playbook_cta_click → -// playbook_prompt_send)。issue #42 称该任务为 PC 口径(+500c+5e)——PC 序列 -// (DesktopPlaybookPromptSequence)已实测点亮 playbook_prompt,本组作为 mp 形态 -// 补充(任务在 mp 链上,判据究竟认哪侧待解锁实测)。 -func MiniPlaybookEvents(caseID, caseName string) []map[string]any { - base := map[string]any{ - "id": caseID, "name": caseName, "type": "document", - "categoryId": "", "categoryName": "", - "skills": "", "skillNames": "", - } - cta := map[string]any{ - "eventCode": "playbook_cta_click", "source": "discover", "position": 1, - "extVersion": "2.2.8", - } - for k, v := range base { - cta[k] = v - } - send := map[string]any{ - "eventCode": "playbook_prompt_send", "source": "discover", - "promptLength": 96, "isOfficial": 1, - "conversationId": "wb2api-mp-pb-" + clientToken(), - "extVersion": "2.2.8", - } - for k, v := range base { - send[k] = v - } - return []map[string]any{cta, send} -} - -// ---- 我的券码(#/prizes?tab=vouchers,2026-09-16 接入)---- - -// SchoolVoucher 开学季抽奖抽中的第三方券(KFC/瑞幸/酷狗等)。 -// 字段结构按真实响应样本:GET /vouchers 单次拉全(无分页),data.items[]。 -type SchoolVoucher struct { - GrantID int64 `json:"grant_id"` - DrawUUID string `json:"draw_uuid,omitempty"` - SKUCode string `json:"sku_code,omitempty"` // kfc_ice_cream / voucher_luckin / voucher_kugou … - PrizeName string `json:"prize_name,omitempty"` // 肯德基冰淇淋 - Code string `json:"code"` // 券码本体(复制给店员核销) - ValidFrom string `json:"valid_from,omitempty"` // 上游常为空 - ValidTo string `json:"valid_to,omitempty"` // "2026-10-24" - GrantedAt string `json:"granted_at,omitempty"` // RFC3339 -} - -// SchoolVouchers 查询账号的开学季券码列表(只读)。 -// 抽到积分的记录不在此端点(那是 /rewards 的 type=credit 条目)。 -func (c *Client) SchoolVouchers(a *auth.Auth) ([]SchoolVoucher, error) { - var out struct { - Items []SchoolVoucher `json:"items"` - } - if err := c.schoolJSON(a, http.MethodGet, "/vouchers", nil, &out); err != nil { - return nil, err - } - return out.Items, nil -} +// school.go 开学季活动(school-season,活动期 2026-09-13 ~ 09-24)纯 API 自动化。 +// +// 判据(2026-09-13 小程序 MCP 逆向 + 三账号实测,protocol.md §7.11): +// - share_invite(每日 +100c +1抽奖):POST /tasks/share-complete {channel:"wechat"} +// 即点亮——纯前端上报,服务端不校验真实分享回执。本模块的主目标。 +// - chat_3_times / expert_use:判据绑定小程序原生沙箱会话(e2b runtime), +// webchat 普通会话不计数,纯 API 不做(需小程序内人工对话)。 +// - 抽奖:POST /wheel/draw {draw_uuid}(前端生成 uuid,消耗 1 chance)。 +// +// 端点基址 www.codebuddy.cn(billing 同域);信封 {code,msg,data},code=0 成功。 +package upstream + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const schoolBase = "/portal/activity/school" + +// schoolJSON 学院活动 API 请求(剥信封,业务 code≠0 返回带 msg 的 error)。 +func (c *Client) schoolJSON(a *auth.Auth, method, path string, body map[string]any, out any) error { + var raw []byte + if body != nil { + raw, _ = json.Marshal(body) + } + req, err := http.NewRequest(method, c.billingBase(a)+schoolBase+path, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + data, err := c.doJSON(req) + if err != nil { + return err + } + if out != nil { + return json.Unmarshal(data, out) + } + return nil +} + +// - chat_3_times:3 条 chat_request_send 即 3/3(conversationId 任意、桌面/mp +// 头族均可计数,无需真实沙箱会话)。 +// - expert_use:mp 指纹事件链 expert_summon_click + expert_summoned + +// expert_actual_use + chat_request_send(开学季分类专家)即点亮。 + +const mpReportPath = "/v2/report" + +// schoolOpenDayActivityID 开学季/校园日活动 id(事件 activityId 字段值,两域共用)。 +const schoolOpenDayActivityID = "school_open_day_2026" + +// mpEventBase 小程序埋点公共指纹(appservice wQ()+Ao() 对齐)。 +func mpEventBase(a *auth.Auth) map[string]any { + return map[string]any{ + "timestamp": time.Now().UnixMilli(), + "ideType": "WorkBuddy_MP", + "ideVersion": "2.4.0", + "extName": "workbuddy-mp", + "extVersion": "2.4.0", + "product": "SaaS", + "ideName": "wx_app_cloud", + "platform": "mini_program", + "os": "windows", + "osVersion": "11", + "arch": "x64", + "machineId": "0655736a-607f-4d9d-b430-58176ee9a090", + "timezone": "Asia/Shanghai", + "userId": a.UID, + "userNickname": a.Nickname, + } +} + +// ReportMPEvent 以小程序指纹向 www.codebuddy.cn/v2/report 批量上报事件。 +func (c *Client) ReportMPEvent(a *auth.Auth, events ...map[string]any) error { + if len(events) == 0 { + return fmt.Errorf("mp report: no events") + } + base := mpEventBase(a) + arr := make([]map[string]any, 0, len(events)) + for _, ev := range events { + m := map[string]any{} + for k, v := range base { + m[k] = v + } + for k, v := range ev { + m[k] = v + } + arr = append(arr, m) + } + raw, err := json.Marshal(arr) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.BillingBaseCN+mpReportPath, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + req.Header.Set("X-Client-Product", "workbuddy-mp") + req.Header.Set("X-Client-Version", "2.4.0") + req.Header.Set("X-Client-Platform", "mp-weixin") + req.Header.Set("X-Platform", "wechatmp") + _, err = c.doJSON(req) + return err +} + +// SchoolChatTimesEvents 构造一条 chat_request_send 事件(chat_3_times 计数)。 +func SchoolChatTimesEvents(conversationID string) map[string]any { + rid := "wb2api-" + clientToken() + return map[string]any{ + "eventCode": "chat_request_send", + "inputLength": 14, "isPlan": false, "isAutoExecuteTerminal": false, + "isAutoModify": false, "codebaseEnable": false, "maxToken": 0, + "maxSteps": 500, "temperature": 0, "maxRetries": 0, + "mentionContexts": []any{}, "knowledgeId": []any{}, "knowledgeName": []any{}, + "codebaseId": "", "mentionContextCount": 0, "command": "", + "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, + "traceId": rid, "rootRequestId": rid, + "parentConversationId": conversationID, "conversationId": conversationID, + "messageId": "msg-" + rid[len(rid)-8:], + "agentName": "mp", "agentType": "main", + "codebuddy.session_id": conversationID, + "codebuddy.conversation_request_id": rid, + } +} + +// SchoolSeasonChatEvent 构造 growth 域「校园日」(school_season)判据事件: +// mini 指纹 chat_request_send + activityId=school_open_day_2026(与 school 域 +// 开学季同 activityId 关联;实测无 activityId 的事件不点亮)。事件形状与 +// SchoolChatTimesEvents 同构(school 域 chat_3_times 同款),仅追加 activityId。 +func SchoolSeasonChatEvent(conversationID string) map[string]any { + ev := SchoolChatTimesEvents(conversationID) + ev["activityId"] = schoolOpenDayActivityID + return ev +} + +// MiniExpertUseEvent 构造 growth 域 Sequential_Tasks_2「在小程序内选中专家并完成 +// 有效对话」的判据事件:mp 指纹 expert_actual_use。形状对齐小程序源码 +// app-service.js 的真实发射点(上游 task_runner 实测 2026-09-23:上报即 completed, +// claim +200c+5e)。与 school 域的 SchoolExpertUseEvents 是**两套口径**,勿照抄: +// - 不带 conversationId/activityId——真实事件就是这两个字段都不带; +// - extVersion 用小程序自身版本 2.2.8(覆盖 mpEventBase 的 2.4.0); +// - source=mini_program + type 固定 "send_message"(小程序恒发此值)。 +// +// expertID 必须是专家市场真实 ex_ id(ListMarketExperts),空 id 服务端不入账。 +func MiniExpertUseEvent(expertID, expertName, expertType string) map[string]any { + if expertType == "" { + expertType = "agent" + } + if expertName == "" { + expertName = expertID + } + return map[string]any{ + "eventCode": "expert_actual_use", "reportDelay": 0, + "extVersion": "2.2.8", "source": "mini_program", + "id": expertID, "name": expertID, + "expertTitle": expertName, "type": "send_message", + "characterCount": 12, "expertType": expertType, + } +} + +// MiniChatModelEvent mp 对话事件 + 模型字段(Sequential_Tasks_5「使用 GLM5.2」判据 +// 载体):小程序 chat_request_send 真实发射点(mpsrc main 32904 模块)带 +// requestModelId / requestModelName——Tasks_1/3 的裸对话事件不带模型,模型任务 +// 须用本形态(判据待解锁实测验证)。 +func MiniChatModelEvent(conversationID, modelID, modelName string) map[string]any { + ev := SchoolChatTimesEvents(conversationID) + ev["requestModelId"] = modelID + ev["requestModelName"] = modelName + return ev +} + +// MiniPlaybookEvents mp 指纹灵感事件组(Sequential_Tasks_7「体验灵感功能」判据 +// 载体,形状对齐 mpsrc main 73640/73665 发射点:playbook_cta_click → +// playbook_prompt_send)。issue #42 称该任务为 PC 口径(+500c+5e)——PC 序列 +// (DesktopPlaybookPromptSequence)已实测点亮 playbook_prompt,本组作为 mp 形态 +// 补充(任务在 mp 链上,判据究竟认哪侧待解锁实测)。 +func MiniPlaybookEvents(caseID, caseName string) []map[string]any { + base := map[string]any{ + "id": caseID, "name": caseName, "type": "document", + "categoryId": "", "categoryName": "", + "skills": "", "skillNames": "", + } + cta := map[string]any{ + "eventCode": "playbook_cta_click", "source": "discover", "position": 1, + "extVersion": "2.2.8", + } + for k, v := range base { + cta[k] = v + } + send := map[string]any{ + "eventCode": "playbook_prompt_send", "source": "discover", + "promptLength": 96, "isOfficial": 1, + "conversationId": "wb2api-mp-pb-" + clientToken(), + "extVersion": "2.2.8", + } + for k, v := range base { + send[k] = v + } + return []map[string]any{cta, send} +} + +// ---- 我的券码(#/prizes?tab=vouchers,2026-09-16 接入)---- + +// SchoolVoucher 开学季抽奖抽中的第三方券(KFC/瑞幸/酷狗等)。 +// 字段结构按真实响应样本:GET /vouchers 单次拉全(无分页),data.items[]。 +type SchoolVoucher struct { + GrantID int64 `json:"grant_id"` + DrawUUID string `json:"draw_uuid,omitempty"` + SKUCode string `json:"sku_code,omitempty"` // kfc_ice_cream / voucher_luckin / voucher_kugou … + PrizeName string `json:"prize_name,omitempty"` // 肯德基冰淇淋 + Code string `json:"code"` // 券码本体(复制给店员核销) + ValidFrom string `json:"valid_from,omitempty"` // 上游常为空 + ValidTo string `json:"valid_to,omitempty"` // "2026-10-24" + GrantedAt string `json:"granted_at,omitempty"` // RFC3339 +} + +// SchoolVouchers 查询账号的开学季券码列表(只读)。 +// 抽到积分的记录不在此端点(那是 /rewards 的 type=credit 条目)。 +func (c *Client) SchoolVouchers(a *auth.Auth) ([]SchoolVoucher, error) { + var out struct { + Items []SchoolVoucher `json:"items"` + } + if err := c.schoolJSON(a, http.MethodGet, "/vouchers", nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} diff --git a/internal/upstream/school_mp_test.go b/internal/upstream/school_mp_test.go index d40f2a380956b4120c66383e96f52c77b0327b8b..e278696cca510e9dce2d8202e08b68b99e1ba88f 100644 --- a/internal/upstream/school_mp_test.go +++ b/internal/upstream/school_mp_test.go @@ -1,37 +1,37 @@ -package upstream - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -func TestMPEventBase(t *testing.T) { - a := &auth.Auth{UID: "u-1", Nickname: "测试"} - base := mpEventBase(a) - for _, k := range []string{"ideType", "extName", "ideName", "platform", "userId"} { - if _, ok := base[k]; !ok { - t.Errorf("missing common field %s", k) - } - } - if base["ideType"] != "WorkBuddy_MP" || base["extName"] != "workbuddy-mp" { - t.Errorf("fingerprint ideType=%v extName=%v", base["ideType"], base["extName"]) - } -} - -func TestSchoolChatTimesEvents(t *testing.T) { - ev := SchoolChatTimesEvents("conv-1") - if ev["eventCode"] != "chat_request_send" { - t.Errorf("eventCode=%v", ev["eventCode"]) - } - if ev["conversationId"] != "conv-1" || ev["codebuddy.session_id"] != "conv-1" { - t.Errorf("conversation join fields missing: %v", ev) - } - b, _ := json.Marshal(ev) - if !strings.Contains(string(b), "agentName") { - t.Error("agentName missing") - } -} - +package upstream + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestMPEventBase(t *testing.T) { + a := &auth.Auth{UID: "u-1", Nickname: "测试"} + base := mpEventBase(a) + for _, k := range []string{"ideType", "extName", "ideName", "platform", "userId"} { + if _, ok := base[k]; !ok { + t.Errorf("missing common field %s", k) + } + } + if base["ideType"] != "WorkBuddy_MP" || base["extName"] != "workbuddy-mp" { + t.Errorf("fingerprint ideType=%v extName=%v", base["ideType"], base["extName"]) + } +} + +func TestSchoolChatTimesEvents(t *testing.T) { + ev := SchoolChatTimesEvents("conv-1") + if ev["eventCode"] != "chat_request_send" { + t.Errorf("eventCode=%v", ev["eventCode"]) + } + if ev["conversationId"] != "conv-1" || ev["codebuddy.session_id"] != "conv-1" { + t.Errorf("conversation join fields missing: %v", ev) + } + b, _ := json.Marshal(ev) + if !strings.Contains(string(b), "agentName") { + t.Error("agentName missing") + } +} + diff --git a/internal/upstream/sse.go b/internal/upstream/sse.go index c48f351a254afcbb5018b9c1c5a509eba95e94b1..53deab8e7f553cc15a1934e6f5df93b80e773187 100644 --- a/internal/upstream/sse.go +++ b/internal/upstream/sse.go @@ -1,644 +1,670 @@ -// sse.go 处理上游 SSE 流:聚合成单个 OpenAI 响应,或透传给客户端。 -package upstream - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "sort" - "strings" - "time" -) - -// errEmptyStream 上游返回 200 但没有有效 SSE 数据帧(空流/只有注释/[DONE])。 -// 用哨兵错误替代裸 fmt.Errorf:StreamHint 的调用方(handler 流式路径)需要区分 -// 「上游空流」与「客户端断连写失败」——空流是上游缺陷,应记 502 观测;写失败是 -// 客户端已走,日志口径不同。Aggregate 与 StreamHint 共用同一哨兵(errors.Is 判定)。 -var errEmptyStream = errors.New("upstream stream contained no valid data events") - -// IsEmptyStreamError 报告错误是否为「上游空流」(无有效 SSE 帧)——供 handler -// 在流式路径把空流记为失败观测(HTTP 头已发出只能 200,但日志/状态应收敛到 -// upstream_parse 同语义),与客户端断连类错误区分。 -func IsEmptyStreamError(err error) bool { return errors.Is(err, errEmptyStream) } - -// Aggregate 读取完整 SSE 流,聚合 delta.content 为单个 OpenAI chat.completion 响应。 -// 分片/半行由 bufio.Reader.ReadString 处理;遇到 "data: [DONE]" 结束。 -// tool_calls 以流式 delta 到达(按 index 合并:首片带 id/type/name,后续只带 arguments 片段)。 -func Aggregate(r io.Reader) (map[string]any, error) { - br := bufio.NewReaderSize(r, 64*1024) - var ( - id, model string - created float64 - content strings.Builder - reasoning strings.Builder - role = "assistant" - finishReason = "stop" - usage map[string]any - gotAnyContent bool - validEvents int - sawDone bool // 上游显式发过 data: [DONE](正常收尾) - toolCalls = map[int]map[string]any{} - toolOrder []int - // toolSeq 缺 index 的 tool_call 的分配序号源:跨帧延续「最近分配」槽位, - // 同帧内递增(见 mergeToolCallsChunk 注释)。 - toolSeq int - // idIndex id → 已分配的 index:跨帧持续(延续碎片的 id 常出现在后续帧), - // 供缺 index 时按 id 归位既有调用(见 mergeToolCallsChunk 注释)。 - idIndex = map[string]int{} - ) - // appendContent 是「已取到正文」(gotAnyContent latch)的唯一写入点:delta 与 - // message 两路 content 都必须经此并入,规约只有一份(issue #142)—— - // S1 空串不算「已取到正文」、不占 latch 名额:OpenAI 风格 role-only 首帧 - // (delta.content="")和整条空 message 帧是常态帧,若空串置位 latch, - // 后续真正文会被 message 回退分支的 !gotAnyContent 守卫静默拒绝; - // S2 空串本身也无可追加字节,跳过 WriteString 与追加语义自洽。 - appendContent := func(txt string) { - if txt == "" { - return - } - content.WriteString(txt) - gotAnyContent = true - } - // mergeToolCallsChunk 把一段 tool_calls 数组按 index 合并进累计表。 - // delta(流式分片,按 index 累积)与 message(非 delta 整条)共用同一合并逻辑, - // 保证「上游给的身份/函数名不丢、arguments 拼接语义一致」。 - // - // index 缺失兼容:OpenAI 规范要求 delta 帧的 tool_call 带 index(标记分片归属), - // 但部分上游省略它。此前缺 index 一律归 0——多调用场景下不同 call 被合并进同一 - // index 槽,arguments 串联、name 互相覆盖(数据污染)。修法按「id 优先、lastIdx 兜底」: - // - 带 index → 按 index 累积(合规形态,零改动); - // - 缺 index 带 id 且 id 已见过 → 延续该 id 所在 index; - // - 缺 index 带 id 且 id 是新的 → 开新序号(多调用不合并); - // - 缺 index 无 id → 追加到最近收到碎片的 index(单个调用的延续分片无 id - // 是标准形态),无既往则开新号。 - // 带 index 的碎片照常按 index 累积,不受影响。 - // nextToolIndex 分配下一个不冲突的缺 index 序号:从 toolSeq 起递增跳过既有 - // index(合规流的 index 是 0..N-1,缺 index 的补位不能覆盖它们)。 - nextToolIndex := func() int { - for { - idx := toolSeq - toolSeq++ - if _, used := toolCalls[idx]; !used { - return idx - } - } - } - mergeToolCallsChunk := func(tcs []any) { - for _, tc := range tcs { - call, ok := tc.(map[string]any) - if !ok { - continue - } - idx := -1 - if v, ok := call["index"].(float64); ok { - idx = int(v) - } else if cid, _ := call["id"].(string); cid != "" { - if mid, seen := idIndex[cid]; seen { - idx = mid // 该 id 已归位过:延续既有调用(跨帧有效) - } else { - idx = nextToolIndex() - } - } else if len(toolOrder) > 0 { - idx = toolOrder[len(toolOrder)-1] // 无 id 碎片:延续最近调用 - } else { - idx = nextToolIndex() - } - merged, seen := toolCalls[idx] - if !seen { - merged = map[string]any{"index": idx} - toolCalls[idx] = merged - toolOrder = append(toolOrder, idx) - } - if cid, _ := call["id"].(string); cid != "" { - idIndex[cid] = idx - } - if cid, _ := merged["id"].(string); cid != "" { - idIndex[cid] = idx - } - mergeToolCallDelta(merged, call) - } - } - // mergeMessageFields 把非 delta 的完整 message 内容并入聚合(message 是整条下发, - // 非流式拼接,content 只取一次)。role/reasoning_content/tool_calls 与 delta 分支 - // 同构透出;content 同样置 gotAnyContent,与 delta 路径的 latch 语义一致 - // (一帧整条 message 之后,后续 delta 帧不重复追加)。 - mergeMessageFields := func(msg map[string]any) { - if r2, ok := msg["role"].(string); ok && r2 != "" { - role = r2 - } - if txt, ok := msg["content"].(string); ok { - appendContent(txt) - } - if rc, ok := msg["reasoning_content"].(string); ok { - reasoning.WriteString(rc) - } - if tcs, ok := msg["tool_calls"].([]any); ok { - mergeToolCallsChunk(tcs) - } - } - for { - line, err := br.ReadString('\n') - if err != nil && err != io.EOF { - return nil, err - } - line = strings.TrimRight(line, "\r\n") - if strings.HasPrefix(line, "data: ") { - payload := strings.TrimPrefix(line, "data: ") - if payload == "[DONE]" { - // 上游显式结束:停止读取,DONE 之后的任何数据一律忽略。 - sawDone = true - break - } else { - var chunk map[string]any - if json.Unmarshal([]byte(payload), &chunk) == nil { - // 有效事件计数:仅 JSON 解析成功的数据帧计入(解析失败沿用静默 continue)。 - validEvents++ - if v, ok := chunk["id"].(string); ok && id == "" { - id = v - } - if v, ok := chunk["model"].(string); ok && model == "" { - model = v - } - if v, ok := chunk["created"].(float64); ok && created == 0 { - created = v - } - if u, ok := chunk["usage"].(map[string]any); ok { - usage = u - } - if ch, ok := chunk["choices"].([]any); ok { - for _, ci := range ch { - c, _ := ci.(map[string]any) - if c == nil { - continue - } - if fr, ok := c["finish_reason"].(string); ok && fr != "" { - finishReason = fr - } - if delta, ok := c["delta"].(map[string]any); ok { - if r2, ok := delta["role"].(string); ok && r2 != "" { - role = r2 - } - if txt, ok := delta["content"].(string); ok { - appendContent(txt) - } - if rc, ok := delta["reasoning_content"].(string); ok { - reasoning.WriteString(rc) - } - if tcs, ok := delta["tool_calls"].([]any); ok { - mergeToolCallsChunk(tcs) - } - } - // 有的上游把完整消息放在 message 里(非 delta): - // 整条并入(role/content/reasoning_content/tool_calls), - // 与 delta 分支同构。delta 已取过正文(gotAnyContent)则跳过 - // (避免与 delta 路径重复拼接——PR #134 的 latch 语义)。 - if msg, ok := c["message"].(map[string]any); ok && !gotAnyContent { - mergeMessageFields(msg) - } - } - } - } - } - } - if err == io.EOF { - break - } - } - if validEvents == 0 { - // 上游返回 200 但没有任何有效数据事件(空流/只有 [DONE]/只有注释行): - // 不再合成空 content 的假成功响应,直接报错,由 handler 映射为 502 upstream_parse。 - return nil, errEmptyStream - } - if id == "" { - id = fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) - } - if created == 0 { - created = float64(time.Now().Unix()) - } - message := map[string]any{ - "role": role, - "content": content.String(), - } - if reasoning.Len() > 0 { - message["reasoning_content"] = reasoning.String() - } - if len(toolOrder) > 0 { - sort.Ints(toolOrder) - calls := make([]map[string]any, 0, len(toolOrder)) - for _, idx := range toolOrder { - calls = append(calls, toolCalls[idx]) - } - // P1b:流被截断时 tool_call 的 arguments 是残缺 JSON(解析失败),不把脏参数 - // 交给客户端——残留分片会被客户端解析成非法 JSON 卡死会话。截断的两个来源: - // - finish_reason=="length"(模型因 max_tokens 提前中止); - // - 上游连接中断(EOF 收尾但未发 data: [DONE],sawDone=false)。 - // 完整参数原样保留(正例零改动);空参数(无参工具)不是截断,同样保留。 - // 此前只认 finish_reason=="length",EOF 截断的 tool_calls 残缺参数被原样下发。 - if finishReason == "length" || !sawDone { - calls = dropTruncatedToolCalls(calls) - } - if len(calls) > 0 { - message["tool_calls"] = calls - } - } - resp := map[string]any{ - "id": id, - "object": "chat.completion", - "created": int64(created), - "model": model, - "choices": []any{ - map[string]any{ - "index": 0, - "message": message, - "finish_reason": finishReason, - }, - }, - } - if usage != nil { - // OpenAI 非流式 usage 必含 total_tokens。上游若只发 prompt_tokens + - // completion_tokens(部分上游末帧缺 total),网关合成补齐——否则严格按 - // schema 校验的客户端收不到 total_tokens。已有 total 或二者缺一不补 - // (不臆造:单边有值无法合成可信的 total)。 - resp["usage"] = normalizeUsageCacheAliases(ensureUsageTotal(usage)) - } - return resp, nil -} - -// ensureUsageTotal 在 usage 缺 total_tokens 但 prompt_tokens/completion_tokens 都在时 -// 补齐 total = prompt + completion(通过新 map 合并,不修改原上游 map)。 -// 任一缺失或已有 total 时原样返回。 -func ensureUsageTotal(u map[string]any) map[string]any { - if _, ok := u["total_tokens"]; ok { - return u - } - pt, pok := num64(u["prompt_tokens"]) - ct, cok := num64(u["completion_tokens"]) - if !pok || !cok { - return u - } - out := make(map[string]any, len(u)+1) - for k, v := range u { - out[k] = v - } - out["total_tokens"] = pt + ct - return out -} - -// num64 把 JSON number(float64/int64 均可)归一为 float64;非数字返回 ok=false。 -// -// 注意与 payload.go 的类型 switch 语义不同:那边是翻译请求别名字段(非数字拒绝 -// 整个请求),这边是聚合响应求和(非数字只跳过合成)。防后人合并两处。 -func num64(v any) (float64, bool) { - switch n := v.(type) { - case float64: - return n, true - case int64: - return float64(n), true - case int: - return float64(n), true - default: - return 0, false - } -} - -// mergeToolCallDelta 把流式 tool_call 片段合并到累计对象: -// id/type/function.name 直覆盖(后续分片通常缺省),function.arguments 拼接。 -func mergeToolCallDelta(merged, delta map[string]any) { - if v, ok := delta["id"].(string); ok && v != "" { - merged["id"] = v - } - if v, ok := delta["type"].(string); ok && v != "" { - merged["type"] = v - } - df, _ := delta["function"].(map[string]any) - if df == nil { - return - } - mf, _ := merged["function"].(map[string]any) - if mf == nil { - mf = map[string]any{} - merged["function"] = mf - } - if v, ok := df["name"].(string); ok && v != "" { - mf["name"] = v - } - if v, ok := df["arguments"].(string); ok && v != "" { - if prev, _ := mf["arguments"].(string); prev != "" { - mf["arguments"] = prev + v - } else { - mf["arguments"] = v - } - } -} - -// stripToolCallNames 收敛流式 tool_calls 的 name 语义为「每个 index 只出现一次」: -// 首片保留 function.name,同一 index 后续分片里的 name 键一律删除(无论上游是 -// 空串还是重复非空串)。这是 OpenAI 官方流的真实形态——首帧带 name,后续帧只带 -// arguments 片段、不再出现 name 键——因此是累加型与覆盖型客户端的共同祖先行为。 -// -// 两类消费模型在该形态下同时正确: -// - 累加型(官方 WorkBuddy/CodeBuddy `name += tc_function?.name || ""`): -// 后续分片 name 键缺失 → 追加空串,累积 name 保持唯一,不再拼成 Bash×帧数(issue #82)。 -// - 覆盖型(hawklithm#2 / Grok Build `name ?? state.name` 或 `if (name) state.name = name`): -// 后续分片 name 键缺失 → 保留已建好的首帧 name,不被空串意外清空。 -// 键缺失是比空串更安全的形态:`??` 与 truthy 守卫对缺失键必然保留旧值, -// 而对空串,`??` 会误判为重设并清空工具名。 -// -// seen 记录每个 index 是否已发过首片(与 name 是否非空无关);删除是幂等的。 -// 只动 function.name 键,id/type/arguments 原样透传。 -func stripToolCallNames(obj map[string]any, seen map[int]bool) { - choices, _ := obj["choices"].([]any) - for _, ci := range choices { - c, _ := ci.(map[string]any) - if c == nil { - continue - } - delta, _ := c["delta"].(map[string]any) - if delta == nil { - continue - } - tcs, _ := delta["tool_calls"].([]any) - for _, tci := range tcs { - tc, _ := tci.(map[string]any) - if tc == nil { - continue - } - idx := 0 - if v, ok := tc["index"].(float64); ok { - idx = int(v) - } - if seen[idx] { - // 已发过首片:删除本分片的 name 键(存在即删,幂等)。 - if fn, _ := tc["function"].(map[string]any); fn != nil { - delete(fn, "name") - } - continue - } - // 首现:保留 name 键原样(上游首片通常带非空 name;空 name 也照发, - // 与 OpenAI 对「首帧无 name」的容忍一致),随后分片统一删除。 - seen[idx] = true - } - } -} - -// normalizeFrame 以 OpenAI 流式规范白名单重建帧:仅保留标准字段, -// 剔除上游噪声(finish_reason:"" → null、空 content/refusal、空 tool_calls 列表、 -// 空占位 function_call、顶层未知字段),空 delta 键一律省略, -// usage 缺失 → null,保证任意标准客户端按规范解析。 -func normalizeFrame(obj map[string]any) map[string]any { - out := map[string]any{} - for _, k := range []string{"id", "object", "created", "model", "system_fingerprint", "service_tier"} { - if v, ok := obj[k]; ok && v != nil { - out[k] = v - } - } - if _, ok := out["object"]; !ok { - out["object"] = "chat.completion.chunk" - } - if _, ok := out["id"]; !ok { - out["id"] = "chatcmpl-wb2api" - } - if chs, ok := obj["choices"].([]any); ok { - nchs := make([]any, 0, len(chs)) - for _, ci := range chs { - c, ok := ci.(map[string]any) - if !ok { - continue - } - nc := map[string]any{} - if idx, ok := c["index"]; ok { - nc["index"] = idx - } - delta := map[string]any{} - if d, ok := c["delta"].(map[string]any); ok { - if v, ok := d["role"].(string); ok && v != "" { - delta["role"] = v - } - if v, ok := d["content"].(string); ok && v != "" { - delta["content"] = v - } - if v, ok := d["reasoning_content"].(string); ok && v != "" { - delta["reasoning_content"] = v - } - if v, ok := d["refusal"].(string); ok && v != "" { - delta["refusal"] = v - } - if tcs, ok := d["tool_calls"].([]any); ok && len(tcs) > 0 { - delta["tool_calls"] = tcs - } - if fc, ok := d["function_call"]; ok && fc != nil { - // 空占位 function_call(name/arguments 全空)视为噪声剔除 - keep := false - if fcm, ok2 := fc.(map[string]any); ok2 { - n, _ := fcm["name"].(string) - a, _ := fcm["arguments"].(string) - keep = n != "" || a != "" - } else { - keep = true - } - if keep { - delta["function_call"] = fc - } - } - } - nc["delta"] = delta - if fr, ok := c["finish_reason"].(string); ok && fr != "" { - nc["finish_reason"] = fr - } else { - nc["finish_reason"] = nil - } - nchs = append(nchs, nc) - } - out["choices"] = nchs - } - if rawUsage, ok := obj["usage"]; ok { - if u, ok := rawUsage.(map[string]any); ok { - out["usage"] = normalizeUsageCacheAliases(u) - } else { - out["usage"] = rawUsage - } - } else { - out["usage"] = nil - } - return out -} - -// Stream 透传上游 SSE 到 w(逐帧规范化后 flush),保证至少写一个 [DONE]。 -// 调用方必须先设置过 status 200;本函数自设 SSE headers。 -// 流式策略:逐帧透传(规范化已剥空 content 噪声),恢复与上游一致的平滑流式。 -// -// StreamHint 变体(gateway_hint 任务):上游 error 帧透传时附加 -// error.gateway_hint 字段——message 原文不动,hint 并列补充;hintFn 返回空串 -// 或 nil 时与 Stream 行为逐字节一致。 -func Stream(w http.ResponseWriter, r io.Reader) error { - return StreamHint(w, r, nil) -} - -// StreamHint 同 Stream,但上游 error 帧透出前把 hintFn(payload) 的返回值写入 -// error.gateway_hint。hintFn 为 nil 或返回空串 → 原样透传(零改写)。 -// 空流兜底 error 帧("empty upstream stream")不带 hint(网关本地故障形态 -// 未覆盖,不编造)。 -func StreamHint(w http.ResponseWriter, r io.Reader, hintFn func(string) string) error { - h := w.Header() - h.Set("Content-Type", "text/event-stream") - h.Set("Cache-Control", "no-cache") - h.Set("Connection", "keep-alive") - h.Set("X-Accel-Buffering", "no") - fl, _ := w.(http.Flusher) - - // toolCallSeen 跨帧记录 delta.tool_calls 里已发过首片的 index, - // 供逐 chunk 透传时收敛 name 为「每 index 一次」(对齐 OpenAI 官方流)。 - toolCallSeen := map[int]bool{} - - // firstID 透传流的消息级 id 基准:缓存首个非空上游 id,后续帧缺失/空串时复用 - // (issue #35:同一条 SSE 消息所有帧共用一个真实 id,后台按 id 归并;此前中间帧 - // 一律补 chatcmpl-wb2api 哨兵,造成同流 id 分裂)。全流无真实 id → 才出现哨兵。 - firstID := "" - - // writeRaw 原样写出一帧(绕过 normalizeFrame)并 flush。上游 error 帧(error-passthrough) - // 与空流错误帧需保留 error 字段,不能被白名单剥掉,故经此写出。 - // gateway_hint:上游 error 帧透出前按 hintFn 附加 error.gateway_hint 字段 - // (error 对象上加一个键,message/code/requestId 等原文不动;hintFn 为 - // nil / 空串 / 非 JSON 帧 → 原样写出,零改写)。 - writeRaw := func(payload string) error { - if hint := frameGatewayHint(hintFn, payload); hint != "" { - payload = attachHintToErrorFrame(payload, hint) - } - if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { - return werr - } - if fl != nil { - fl.Flush() - } - return nil - } - - // writeFrame 把 payload 按规范白名单重建后以 data: 帧写出并 flush。 - // 仅 JSON 解析成功时计数记为一次有效转发(JSON 解析失败照常降级原样写出,但不计数)。 - writeFrame := func(payload string) (int, error) { - var obj map[string]any - valid := 0 - if json.Unmarshal([]byte(payload), &obj) == nil { - // 上游错误帧透传(error-passthrough):带 error 键的帧**原样写出**,不走 - // normalizeFrame 白名单——白名单会剥掉 error 字段,客户端就看不到上游 - // code/msg/requestId。error.message 即上游原文(如 6004 限流、审核拦截), - // 计入有效帧(避免误判空流补写 "empty upstream stream")。 - if _, hasErr := obj["error"]; hasErr { - if werr := writeRaw(payload); werr != nil { - return 0, werr - } - return 1, nil - } - // 先按 index 收敛 tool_calls name(每 index 仅首片保留,后续分片删 name 键),再规范化透传。 - stripToolCallNames(obj, toolCallSeen) - // id 续传:首帧非空真实 id 缓存;后续帧缺 id / 空 id 一律用缓存值, - // 有自己 id 的帧保持原样(不同流分裂的帧允许各自 id)。 - if firstID == "" { - if v, ok := obj["id"].(string); ok && v != "" { - firstID = v - } - } else { - if v, ok := obj["id"].(string); !ok || v == "" { - obj["id"] = firstID - } - } - if raw, err := json.Marshal(normalizeFrame(obj)); err == nil { - payload = string(raw) - } - valid = 1 - } - if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { - return 0, werr - } - if fl != nil { - fl.Flush() - } - return valid, nil - } - - br := bufio.NewReaderSize(r, 64*1024) - validFrames := 0 -readLoop: - for { - line, err := br.ReadString('\n') - trimmed := strings.TrimRight(line, "\r\n") - switch { - case strings.HasPrefix(trimmed, "data: [DONE]"): - // 上游显式结束:停止读取,DONE 之后的任何数据(含垃圾帧)一律不再透传。 - // [DONE] 统一在循环结束后写出,保证恰好一个。 - break readLoop - case strings.HasPrefix(trimmed, "data: "): - n, werr := writeFrame(strings.TrimPrefix(trimmed, "data: ")) - validFrames += n - if werr != nil { - return werr - } - case trimmed != "": - // 注释/其他行:原样透传 - if _, werr := io.WriteString(w, line); werr != nil { - return werr - } - if fl != nil { - fl.Flush() - } - } - // 空行(帧分隔)吞掉:本函数自产 "\n\n" - if err != nil { - if err == io.EOF { - break - } - return err - } - } - // 空流(0 有效帧):先写一帧 error(绕过 normalizeFrame 原样保留 error 字段), - // 再补 [DONE] 保证客户端能正常收尾,并返回非 nil error 供调用方记录。 - // 网关本地空流兜底帧走 hintFn=nil 的直写路径:该形态未覆盖(不编造 hint), - // 且 writeRaw 的 hintFn 闭包在空流路径下可能携带上一帧的上下文造成误配。 - if validFrames == 0 { - _ = writeRaw(`{"error":{"message":"empty upstream stream","type":"upstream_error","code":"upstream_parse"}}`) - } - // 保证恰好写一个 [DONE](上游漏发时兜底补上)。 - if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil { - return err - } - if fl != nil { - fl.Flush() - } - if validFrames == 0 { - return errEmptyStream - } - return nil -} - -// frameGatewayHint 取 error 帧的 gateway_hint(hintFn 缺失/异常返回空串 → 不附加)。 -func frameGatewayHint(hintFn func(string) string, payload string) string { - if hintFn == nil { - return "" - } - // panic 隔离:hint 判定是补充功能,任何实现缺陷不得击穿流透传主路径。 - defer func() { _ = recover() }() - return strings.TrimSpace(hintFn(payload)) -} - -// attachHintToErrorFrame 在 error 帧的 error 对象上附加 gateway_hint 字段。 -// message/code/requestId 等既有键原样保留(只加不改);非 JSON / 无 error 对象 → -// payload 原样返回(宁可不加 hint 也不破坏原文透传)。 -func attachHintToErrorFrame(payload, hint string) string { - var obj map[string]any - if json.Unmarshal([]byte(payload), &obj) != nil { - return payload - } - e, ok := obj["error"].(map[string]any) - if !ok { - return payload - } - e["gateway_hint"] = hint - out, err := json.Marshal(obj) - if err != nil { - return payload - } - return string(out) -} +// sse.go 处理上游 SSE 流:聚合成单个 OpenAI 响应,或透传给客户端。 +package upstream + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// errEmptyStream 上游返回 200 但没有有效 SSE 数据帧(空流/只有注释/[DONE])。 +// 用哨兵错误替代裸 fmt.Errorf:StreamHint 的调用方(handler 流式路径)需要区分 +// 「上游空流」与「客户端断连写失败」——空流是上游缺陷,应记 502 观测;写失败是 +// 客户端已走,日志口径不同。Aggregate 与 StreamHint 共用同一哨兵(errors.Is 判定)。 +var errEmptyStream = errors.New("upstream stream contained no valid data events") + +// IsEmptyStreamError 报告错误是否为「上游空流」(无有效 SSE 帧)——供 handler +// 在流式路径把空流记为失败观测(HTTP 头已发出只能 200,但日志/状态应收敛到 +// upstream_parse 同语义),与客户端断连类错误区分。 +func IsEmptyStreamError(err error) bool { return errors.Is(err, errEmptyStream) } + +// Aggregate 读取完整 SSE 流,聚合 delta.content 为单个 OpenAI chat.completion 响应。 +// 分片/半行由 bufio.Reader.ReadString 处理;遇到 "data: [DONE]" 结束。 +// tool_calls 以流式 delta 到达(按 index 合并:首片带 id/type/name,后续只带 arguments 片段)。 +func Aggregate(r io.Reader) (map[string]any, error) { + br := bufio.NewReaderSize(r, 64*1024) + var ( + id, model string + created float64 + content strings.Builder + reasoning strings.Builder + role = "assistant" + finishReason = "stop" + usage map[string]any + gotAnyContent bool + validEvents int + sawDone bool // 上游显式发过 data: [DONE](正常收尾) + toolCalls = map[int]map[string]any{} + toolOrder []int + // toolSeq 缺 index 的 tool_call 的分配序号源:跨帧延续「最近分配」槽位, + // 同帧内递增(见 mergeToolCallsChunk 注释)。 + toolSeq int + // idIndex id → 已分配的 index:跨帧持续(延续碎片的 id 常出现在后续帧), + // 供缺 index 时按 id 归位既有调用(见 mergeToolCallsChunk 注释)。 + idIndex = map[string]int{} + ) + // appendContent 是「已取到正文」(gotAnyContent latch)的唯一写入点:delta 与 + // message 两路 content 都必须经此并入,规约只有一份(issue #142)—— + // S1 空串不算「已取到正文」、不占 latch 名额:OpenAI 风格 role-only 首帧 + // (delta.content="")和整条空 message 帧是常态帧,若空串置位 latch, + // 后续真正文会被 message 回退分支的 !gotAnyContent 守卫静默拒绝; + // S2 空串本身也无可追加字节,跳过 WriteString 与追加语义自洽。 + appendContent := func(txt string) { + if txt == "" { + return + } + content.WriteString(txt) + gotAnyContent = true + } + // mergeToolCallsChunk 把一段 tool_calls 数组按 index 合并进累计表。 + // delta(流式分片,按 index 累积)与 message(非 delta 整条)共用同一合并逻辑, + // 保证「上游给的身份/函数名不丢、arguments 拼接语义一致」。 + // + // index 缺失兼容:OpenAI 规范要求 delta 帧的 tool_call 带 index(标记分片归属), + // 但部分上游省略它。此前缺 index 一律归 0——多调用场景下不同 call 被合并进同一 + // index 槽,arguments 串联、name 互相覆盖(数据污染)。修法按「id 优先、lastIdx 兜底」: + // - 带 index → 按 index 累积(合规形态,零改动); + // - 缺 index 带 id 且 id 已见过 → 延续该 id 所在 index; + // - 缺 index 带 id 且 id 是新的 → 开新序号(多调用不合并); + // - 缺 index 无 id → 追加到最近收到碎片的 index(单个调用的延续分片无 id + // 是标准形态),无既往则开新号。 + // 带 index 的碎片照常按 index 累积,不受影响。 + // nextToolIndex 分配下一个不冲突的缺 index 序号:从 toolSeq 起递增跳过既有 + // index(合规流的 index 是 0..N-1,缺 index 的补位不能覆盖它们)。 + nextToolIndex := func() int { + for { + idx := toolSeq + toolSeq++ + if _, used := toolCalls[idx]; !used { + return idx + } + } + } + mergeToolCallsChunk := func(tcs []any) { + for _, tc := range tcs { + call, ok := tc.(map[string]any) + if !ok { + continue + } + idx := -1 + if v, ok := call["index"].(float64); ok { + idx = int(v) + } else if cid, _ := call["id"].(string); cid != "" { + if mid, seen := idIndex[cid]; seen { + idx = mid // 该 id 已归位过:延续既有调用(跨帧有效) + } else { + idx = nextToolIndex() + } + } else if len(toolOrder) > 0 { + idx = toolOrder[len(toolOrder)-1] // 无 id 碎片:延续最近调用 + } else { + idx = nextToolIndex() + } + merged, seen := toolCalls[idx] + if !seen { + merged = map[string]any{"index": idx} + toolCalls[idx] = merged + toolOrder = append(toolOrder, idx) + } + if cid, _ := call["id"].(string); cid != "" { + idIndex[cid] = idx + } + if cid, _ := merged["id"].(string); cid != "" { + idIndex[cid] = idx + } + mergeToolCallDelta(merged, call) + } + } + // mergeMessageFields 把非 delta 的完整 message 内容并入聚合(message 是整条下发, + // 非流式拼接,content 只取一次)。role/reasoning_content/tool_calls 与 delta 分支 + // 同构透出;content 同样置 gotAnyContent,与 delta 路径的 latch 语义一致 + // (一帧整条 message 之后,后续 delta 帧不重复追加)。 + mergeMessageFields := func(msg map[string]any) { + if r2, ok := msg["role"].(string); ok && r2 != "" { + role = r2 + } + if txt, ok := msg["content"].(string); ok { + appendContent(txt) + } + if rc, ok := msg["reasoning_content"].(string); ok { + reasoning.WriteString(rc) + } + if tcs, ok := msg["tool_calls"].([]any); ok { + mergeToolCallsChunk(tcs) + } + } + for { + line, err := br.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + // 上游显式结束:停止读取,DONE 之后的任何数据一律忽略。 + sawDone = true + break + } else { + var chunk map[string]any + if json.Unmarshal([]byte(payload), &chunk) == nil { + // 有效事件计数:仅 JSON 解析成功的数据帧计入(解析失败沿用静默 continue)。 + validEvents++ + if v, ok := chunk["id"].(string); ok && id == "" { + id = v + } + if v, ok := chunk["model"].(string); ok && model == "" { + model = v + } + if v, ok := chunk["created"].(float64); ok && created == 0 { + created = v + } + if u, ok := chunk["usage"].(map[string]any); ok { + usage = u + } + if ch, ok := chunk["choices"].([]any); ok { + for _, ci := range ch { + c, _ := ci.(map[string]any) + if c == nil { + continue + } + if fr, ok := c["finish_reason"].(string); ok && fr != "" { + finishReason = fr + } + if delta, ok := c["delta"].(map[string]any); ok { + if r2, ok := delta["role"].(string); ok && r2 != "" { + role = r2 + } + if txt, ok := delta["content"].(string); ok { + appendContent(txt) + } + if rc, ok := delta["reasoning_content"].(string); ok { + reasoning.WriteString(rc) + } + if tcs, ok := delta["tool_calls"].([]any); ok { + mergeToolCallsChunk(tcs) + } + } + // 有的上游把完整消息放在 message 里(非 delta): + // 整条并入(role/content/reasoning_content/tool_calls), + // 与 delta 分支同构。delta 已取过正文(gotAnyContent)则跳过 + // (避免与 delta 路径重复拼接——PR #134 的 latch 语义)。 + if msg, ok := c["message"].(map[string]any); ok && !gotAnyContent { + mergeMessageFields(msg) + } + } + } + } + } + } + if err == io.EOF { + break + } + } + if validEvents == 0 { + // 上游返回 200 但没有任何有效数据事件(空流/只有 [DONE]/只有注释行): + // 不再合成空 content 的假成功响应,直接报错,由 handler 映射为 502 upstream_parse。 + return nil, errEmptyStream + } + if id == "" { + id = fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) + } + if created == 0 { + created = float64(time.Now().Unix()) + } + message := map[string]any{ + "role": role, + "content": content.String(), + } + if reasoning.Len() > 0 { + message["reasoning_content"] = reasoning.String() + } + if len(toolOrder) > 0 { + sort.Ints(toolOrder) + calls := make([]map[string]any, 0, len(toolOrder)) + for _, idx := range toolOrder { + calls = append(calls, toolCalls[idx]) + } + // P1b:流被截断时 tool_call 的 arguments 是残缺 JSON(解析失败),不把脏参数 + // 交给客户端——残留分片会被客户端解析成非法 JSON 卡死会话。截断的两个来源: + // - finish_reason=="length"(模型因 max_tokens 提前中止); + // - 上游连接中断(EOF 收尾但未发 data: [DONE],sawDone=false)。 + // 完整参数原样保留(正例零改动);空参数(无参工具)不是截断,同样保留。 + // 此前只认 finish_reason=="length",EOF 截断的 tool_calls 残缺参数被原样下发。 + if finishReason == "length" || !sawDone { + calls = dropTruncatedToolCalls(calls) + } + if len(calls) > 0 { + message["tool_calls"] = calls + } + } + resp := map[string]any{ + "id": id, + "object": "chat.completion", + "created": int64(created), + "model": model, + "choices": []any{ + map[string]any{ + "index": 0, + "message": message, + "finish_reason": finishReason, + }, + }, + } + if usage != nil { + // OpenAI 非流式 usage 必含 total_tokens。上游若只发 prompt_tokens + + // completion_tokens(部分上游末帧缺 total),网关合成补齐——否则严格按 + // schema 校验的客户端收不到 total_tokens。已有 total 或二者缺一不补 + // (不臆造:单边有值无法合成可信的 total)。 + resp["usage"] = normalizeUsageCacheAliases(ensureUsageTotal(usage)) + } + return resp, nil +} + +// ensureUsageTotal 在 usage 缺 total_tokens 但 prompt_tokens/completion_tokens 都在时 +// 补齐 total = prompt + completion(通过新 map 合并,不修改原上游 map)。 +// 任一缺失或已有 total 时原样返回。 +func ensureUsageTotal(u map[string]any) map[string]any { + if _, ok := u["total_tokens"]; ok { + return u + } + pt, pok := num64(u["prompt_tokens"]) + ct, cok := num64(u["completion_tokens"]) + if !pok || !cok { + return u + } + out := make(map[string]any, len(u)+1) + for k, v := range u { + out[k] = v + } + out["total_tokens"] = pt + ct + return out +} + +// num64 把 JSON number(float64/int64 均可)归一为 float64;非数字返回 ok=false。 +// +// 注意与 payload.go 的类型 switch 语义不同:那边是翻译请求别名字段(非数字拒绝 +// 整个请求),这边是聚合响应求和(非数字只跳过合成)。防后人合并两处。 +func num64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case int: + return float64(n), true + default: + return 0, false + } +} + +// mergeToolCallDelta 把流式 tool_call 片段合并到累计对象: +// id/type/function.name 直覆盖(后续分片通常缺省),function.arguments 拼接。 +func mergeToolCallDelta(merged, delta map[string]any) { + if v, ok := delta["id"].(string); ok && v != "" { + merged["id"] = v + } + if v, ok := delta["type"].(string); ok && v != "" { + merged["type"] = v + } + df, _ := delta["function"].(map[string]any) + if df == nil { + return + } + mf, _ := merged["function"].(map[string]any) + if mf == nil { + mf = map[string]any{} + merged["function"] = mf + } + if v, ok := df["name"].(string); ok && v != "" { + mf["name"] = v + } + if v, ok := df["arguments"].(string); ok && v != "" { + if prev, _ := mf["arguments"].(string); prev != "" { + mf["arguments"] = prev + v + } else { + mf["arguments"] = v + } + } +} + +// stripToolCallNames 收敛流式 tool_calls 的 name 语义为「每个 index 只出现一次」: +// 首片保留 function.name,同一 index 后续分片里的 name 键一律删除(无论上游是 +// 空串还是重复非空串)。这是 OpenAI 官方流的真实形态——首帧带 name,后续帧只带 +// arguments 片段、不再出现 name 键——因此是累加型与覆盖型客户端的共同祖先行为。 +// +// 两类消费模型在该形态下同时正确: +// - 累加型(官方 WorkBuddy/CodeBuddy `name += tc_function?.name || ""`): +// 后续分片 name 键缺失 → 追加空串,累积 name 保持唯一,不再拼成 Bash×帧数(issue #82)。 +// - 覆盖型(hawklithm#2 / Grok Build `name ?? state.name` 或 `if (name) state.name = name`): +// 后续分片 name 键缺失 → 保留已建好的首帧 name,不被空串意外清空。 +// 键缺失是比空串更安全的形态:`??` 与 truthy 守卫对缺失键必然保留旧值, +// 而对空串,`??` 会误判为重设并清空工具名。 +// +// seen 记录每个 index 是否已发过首片(与 name 是否非空无关);删除是幂等的。 +// 只动 function.name 键,id/type/arguments 原样透传。 +func stripToolCallNames(obj map[string]any, seen map[int]bool) { + choices, _ := obj["choices"].([]any) + for _, ci := range choices { + c, _ := ci.(map[string]any) + if c == nil { + continue + } + delta, _ := c["delta"].(map[string]any) + if delta == nil { + continue + } + tcs, _ := delta["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + if tc == nil { + continue + } + idx := 0 + if v, ok := tc["index"].(float64); ok { + idx = int(v) + } + if seen[idx] { + // 已发过首片:删除本分片的 name 键(存在即删,幂等)。 + if fn, _ := tc["function"].(map[string]any); fn != nil { + delete(fn, "name") + } + continue + } + // 首现:保留 name 键原样(上游首片通常带非空 name;空 name 也照发, + // 与 OpenAI 对「首帧无 name」的容忍一致),随后分片统一删除。 + seen[idx] = true + } + } +} + +// normalizeFrame 以 OpenAI 流式规范白名单重建帧:仅保留标准字段, +// 剔除上游噪声(finish_reason:"" → null、空 content/refusal、空 tool_calls 列表、 +// 空占位 function_call、顶层未知字段),空 delta 键一律省略, +// usage 缺失 → null,保证任意标准客户端按规范解析。 +func normalizeFrame(obj map[string]any) map[string]any { + out := map[string]any{} + for _, k := range []string{"id", "object", "created", "model", "system_fingerprint", "service_tier"} { + if v, ok := obj[k]; ok && v != nil { + out[k] = v + } + } + if _, ok := out["object"]; !ok { + out["object"] = "chat.completion.chunk" + } + if _, ok := out["id"]; !ok { + out["id"] = "chatcmpl-wb2api" + } + if chs, ok := obj["choices"].([]any); ok { + nchs := make([]any, 0, len(chs)) + for _, ci := range chs { + c, ok := ci.(map[string]any) + if !ok { + continue + } + nc := map[string]any{} + if idx, ok := c["index"]; ok { + nc["index"] = idx + } + delta := map[string]any{} + if d, ok := c["delta"].(map[string]any); ok { + if v, ok := d["role"].(string); ok && v != "" { + delta["role"] = v + } + if v, ok := d["content"].(string); ok && v != "" { + delta["content"] = v + } + if v, ok := d["reasoning_content"].(string); ok && v != "" { + delta["reasoning_content"] = v + } + if v, ok := d["refusal"].(string); ok && v != "" { + delta["refusal"] = v + } + if tcs, ok := d["tool_calls"].([]any); ok && len(tcs) > 0 { + delta["tool_calls"] = tcs + } + if fc, ok := d["function_call"]; ok && fc != nil { + // 空占位 function_call(name/arguments 全空)视为噪声剔除 + keep := false + if fcm, ok2 := fc.(map[string]any); ok2 { + n, _ := fcm["name"].(string) + a, _ := fcm["arguments"].(string) + keep = n != "" || a != "" + } else { + keep = true + } + if keep { + delta["function_call"] = fc + } + } + } + nc["delta"] = delta + if fr, ok := c["finish_reason"].(string); ok && fr != "" { + nc["finish_reason"] = fr + } else { + nc["finish_reason"] = nil + } + nchs = append(nchs, nc) + } + out["choices"] = nchs + } + if rawUsage, ok := obj["usage"]; ok { + if u, ok := rawUsage.(map[string]any); ok { + out["usage"] = normalizeUsageCacheAliases(u) + } else { + out["usage"] = rawUsage + } + } else { + out["usage"] = nil + } + return out +} + +// Stream 透传上游 SSE 到 w(逐帧规范化后 flush),保证至少写一个 [DONE]。 +// 调用方必须先设置过 status 200;本函数自设 SSE headers。 +// 流式策略:逐帧透传(规范化已剥空 content 噪声),恢复与上游一致的平滑流式。 +// +// StreamHint 变体(gateway_hint 任务):上游 error 帧透传时附加 +// error.gateway_hint 字段——message 原文不动,hint 并列补充;hintFn 返回空串 +// 或 nil 时与 Stream 行为逐字节一致。 +func Stream(w http.ResponseWriter, r io.Reader) error { + return StreamHint(w, r, nil) +} + +// StreamOption StreamHint 的可选行为开关(均不影响透传字节,只做旁路观测)。 +type StreamOption func(*streamOptions) + +type streamOptions struct { + onErrorFrame func(payload string) +} + +// WithErrorFrameObserver 注册"上游以 error 帧报错"的观察者:透传该帧之前先用 +// payload(原始 data 内容)回调一次。给调用方一个**账号处置**的挂载点—— +// 上游「200 + error 帧」是真实形态(6004 限流、内容拦截、审核),此前网关在读 +// 第一帧之前就把账号记成功,限流号被当成健康号;有观察者后可在流尾按帧分类处置。 +func WithErrorFrameObserver(fn func(payload string)) StreamOption { + return func(o *streamOptions) { o.onErrorFrame = fn } +} + +// StreamHint 同 Stream,但上游 error 帧透出前把 hintFn(payload) 的返回值写入 +// error.gateway_hint。hintFn 为 nil 或返回空串 → 原样透传(零改写)。 +// 空流兜底 error 帧("empty upstream stream")不带 hint(网关本地故障形态 +// 未覆盖,不编造)。 +// opts:旁路观测开关(见 WithErrorFrameObserver),不改变任何透传字节。 +func StreamHint(w http.ResponseWriter, r io.Reader, hintFn func(string) string, opts ...StreamOption) error { + var o streamOptions + for _, f := range opts { + if f != nil { + f(&o) + } + } + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + + // toolCallSeen 跨帧记录 delta.tool_calls 里已发过首片的 index, + // 供逐 chunk 透传时收敛 name 为「每 index 一次」(对齐 OpenAI 官方流)。 + toolCallSeen := map[int]bool{} + + // firstID 透传流的消息级 id 基准:缓存首个非空上游 id,后续帧缺失/空串时复用 + // (issue #35:同一条 SSE 消息所有帧共用一个真实 id,后台按 id 归并;此前中间帧 + // 一律补 chatcmpl-wb2api 哨兵,造成同流 id 分裂)。全流无真实 id → 才出现哨兵。 + firstID := "" + + // writeRaw 原样写出一帧(绕过 normalizeFrame)并 flush。上游 error 帧(error-passthrough) + // 与空流错误帧需保留 error 字段,不能被白名单剥掉,故经此写出。 + // gateway_hint:上游 error 帧透出前按 hintFn 附加 error.gateway_hint 字段 + // (error 对象上加一个键,message/code/requestId 等原文不动;hintFn 为 + // nil / 空串 / 非 JSON 帧 → 原样写出,零改写)。 + writeRaw := func(payload string) error { + if hint := frameGatewayHint(hintFn, payload); hint != "" { + payload = attachHintToErrorFrame(payload, hint) + } + if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { + return werr + } + if fl != nil { + fl.Flush() + } + return nil + } + + // writeFrame 把 payload 按规范白名单重建后以 data: 帧写出并 flush。 + // 仅 JSON 解析成功时计数记为一次有效转发(JSON 解析失败照常降级原样写出,但不计数)。 + writeFrame := func(payload string) (int, error) { + var obj map[string]any + valid := 0 + if json.Unmarshal([]byte(payload), &obj) == nil { + // 上游错误帧透传(error-passthrough):带 error 键的帧**原样写出**,不走 + // normalizeFrame 白名单——白名单会剥掉 error 字段,客户端就看不到上游 + // code/msg/requestId。error.message 即上游原文(如 6004 限流、审核拦截), + // 计入有效帧(避免误判空流补写 "empty upstream stream")。 + if _, hasErr := obj["error"]; hasErr { + // 旁路通知(账号处置用):原始 payload 交给观察者,透传字节不变。 + if o.onErrorFrame != nil { + o.onErrorFrame(payload) + } + if werr := writeRaw(payload); werr != nil { + return 0, werr + } + return 1, nil + } + // 先按 index 收敛 tool_calls name(每 index 仅首片保留,后续分片删 name 键),再规范化透传。 + stripToolCallNames(obj, toolCallSeen) + // id 续传:首帧非空真实 id 缓存;后续帧缺 id / 空 id 一律用缓存值, + // 有自己 id 的帧保持原样(不同流分裂的帧允许各自 id)。 + if firstID == "" { + if v, ok := obj["id"].(string); ok && v != "" { + firstID = v + } + } else { + if v, ok := obj["id"].(string); !ok || v == "" { + obj["id"] = firstID + } + } + if raw, err := json.Marshal(normalizeFrame(obj)); err == nil { + payload = string(raw) + } + valid = 1 + } + if _, werr := io.WriteString(w, "data: "+payload+"\n\n"); werr != nil { + return 0, werr + } + if fl != nil { + fl.Flush() + } + return valid, nil + } + + br := bufio.NewReaderSize(r, 64*1024) + validFrames := 0 +readLoop: + for { + line, err := br.ReadString('\n') + trimmed := strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(trimmed, "data: [DONE]"): + // 上游显式结束:停止读取,DONE 之后的任何数据(含垃圾帧)一律不再透传。 + // [DONE] 统一在循环结束后写出,保证恰好一个。 + break readLoop + case strings.HasPrefix(trimmed, "data: "): + n, werr := writeFrame(strings.TrimPrefix(trimmed, "data: ")) + validFrames += n + if werr != nil { + return werr + } + case trimmed != "": + // 注释/其他行:原样透传 + if _, werr := io.WriteString(w, line); werr != nil { + return werr + } + if fl != nil { + fl.Flush() + } + } + // 空行(帧分隔)吞掉:本函数自产 "\n\n" + if err != nil { + if err == io.EOF { + break + } + return err + } + } + // 空流(0 有效帧):先写一帧 error(绕过 normalizeFrame 原样保留 error 字段), + // 再补 [DONE] 保证客户端能正常收尾,并返回非 nil error 供调用方记录。 + // 网关本地空流兜底帧走 hintFn=nil 的直写路径:该形态未覆盖(不编造 hint), + // 且 writeRaw 的 hintFn 闭包在空流路径下可能携带上一帧的上下文造成误配。 + if validFrames == 0 { + _ = writeRaw(`{"error":{"message":"empty upstream stream","type":"upstream_error","code":"upstream_parse"}}`) + } + // 保证恰好写一个 [DONE](上游漏发时兜底补上)。 + if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil { + return err + } + if fl != nil { + fl.Flush() + } + if validFrames == 0 { + return errEmptyStream + } + return nil +} + +// frameGatewayHint 取 error 帧的 gateway_hint(hintFn 缺失/异常返回空串 → 不附加)。 +func frameGatewayHint(hintFn func(string) string, payload string) string { + if hintFn == nil { + return "" + } + // panic 隔离:hint 判定是补充功能,任何实现缺陷不得击穿流透传主路径。 + defer func() { _ = recover() }() + return strings.TrimSpace(hintFn(payload)) +} + +// attachHintToErrorFrame 在 error 帧的 error 对象上附加 gateway_hint 字段。 +// message/code/requestId 等既有键原样保留(只加不改);非 JSON / 无 error 对象 → +// payload 原样返回(宁可不加 hint 也不破坏原文透传)。 +func attachHintToErrorFrame(payload, hint string) string { + var obj map[string]any + if json.Unmarshal([]byte(payload), &obj) != nil { + return payload + } + e, ok := obj["error"].(map[string]any) + if !ok { + return payload + } + e["gateway_hint"] = hint + out, err := json.Marshal(obj) + if err != nil { + return payload + } + return string(out) +} diff --git a/internal/upstream/sse_test.go b/internal/upstream/sse_test.go index a093198ab9a790b199b5f97eacad18cbd23462da..194371d2fd9d7aab2e70ee761562fe21e80b4e13 100644 --- a/internal/upstream/sse_test.go +++ b/internal/upstream/sse_test.go @@ -1,894 +1,934 @@ -package upstream - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -func TestPrepareBodyForcesStream(t *testing.T) { - out := PrepareBodyOpt([]byte(`{"model":"glm-5.2","messages":[]}`), true) - var m map[string]any - json.Unmarshal(out, &m) - if m["stream"] != true { - t.Errorf("stream=%v", m["stream"]) - } -} - -func TestPrepareBodyToolChoiceFunctionObject(t *testing.T) { - out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"function","function":{"name":"get_weather"}},"tools":[{"type":"function"}]}`), true) - var m map[string]any - json.Unmarshal(out, &m) - if m["tool_choice"] != "get_weather" { - t.Errorf("tool_choice=%v", m["tool_choice"]) - } - if _, ok := m["tools"]; !ok { - t.Error("tools should be kept for function choice") - } -} - -func TestPrepareBodyToolChoiceNone(t *testing.T) { - for _, in := range []string{ - `{"tool_choice":"none","tools":[{}],"functions":[{}]}`, - `{"tool_choice":{"type":"none"},"tools":[{}]}`, - } { - out := PrepareBodyOpt([]byte(in), true) - var m map[string]any - json.Unmarshal(out, &m) - if _, ok := m["tool_choice"]; ok { - t.Errorf("%s: tool_choice should be deleted", in) - } - if _, ok := m["tools"]; ok { - t.Errorf("%s: tools should be deleted", in) - } - if _, ok := m["functions"]; ok { - t.Errorf("%s: functions should be deleted", in) - } - } -} - -func TestPrepareBodyToolChoiceAuto(t *testing.T) { - out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"auto"}}`), true) - var m map[string]any - json.Unmarshal(out, &m) - if m["tool_choice"] != "auto" { - t.Errorf("tool_choice=%v", m["tool_choice"]) - } -} - -func TestPrepareBodyInvalidJSON(t *testing.T) { - in := []byte(`{broken`) - out := PrepareBodyOpt(in, true) - if string(out) != string(in) { - t.Error("invalid json should pass through unchanged") - } -} - -const sseFixture = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"你好\"}}]}\n\n" + - "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",世界\"}}]}\n\n" + - "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n" + - "data: [DONE]\n\n" - -func TestAggregate(t *testing.T) { - resp, err := Aggregate(strings.NewReader(sseFixture)) - if err != nil { - t.Fatal(err) - } - if resp["object"] != "chat.completion" { - t.Errorf("object=%v", resp["object"]) - } - if resp["model"] != "glm-5.2" { - t.Errorf("model=%v", resp["model"]) - } - choices := resp["choices"].([]any) - msg := choices[0].(map[string]any)["message"].(map[string]any) - if msg["content"] != "你好,世界" { - t.Errorf("content=%q", msg["content"]) - } - if msg["role"] != "assistant" { - t.Errorf("role=%v", msg["role"]) - } - if choices[0].(map[string]any)["finish_reason"] != "stop" { - t.Errorf("finish_reason=%v", choices[0].(map[string]any)["finish_reason"]) - } - usage := resp["usage"].(map[string]any) - if usage["total_tokens"].(float64) != 7 { - t.Errorf("usage=%v", usage) - } -} - -func TestAggregateSkipsNonDataLines(t *testing.T) { - raw := ": comment\n\n" + sseFixture - resp, err := Aggregate(strings.NewReader(raw)) - if err != nil { - t.Fatal(err) - } - msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) - if msg["content"] != "你好,世界" { - t.Errorf("content=%q", msg["content"]) - } -} - -func TestAggregateToolCalls(t *testing.T) { - // 流式 tool_calls:首片带 id/type/name + 空 arguments,后续只带 arguments 片段 - raw := `data: {"id":"x1","model":"deepseek-v4-pro","created":1,"choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"id":"call_a","type":"function","function":{"name":"get_weather","arguments":""},"index":0}]}}],"usage":null} - -data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"{\"city\":"},"index":0}]}}]} - -data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"\"北京\"}"},"index":0}]}}]} - -data: {"id":"x1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"total_tokens":11}} - -data: [DONE] - -` - resp, err := Aggregate(strings.NewReader(raw)) - if err != nil { - t.Fatal(err) - } - choice := resp["choices"].([]any)[0].(map[string]any) - if choice["finish_reason"] != "tool_calls" { - t.Errorf("finish_reason=%v", choice["finish_reason"]) - } - msg := choice["message"].(map[string]any) - calls, ok := msg["tool_calls"].([]map[string]any) - if !ok || len(calls) != 1 { - t.Fatalf("tool_calls=%#v", msg["tool_calls"]) - } - if calls[0]["id"] != "call_a" || calls[0]["type"] != "function" { - t.Errorf("call meta=%v", calls[0]) - } - fn := calls[0]["function"].(map[string]any) - if fn["name"] != "get_weather" { - t.Errorf("fn.name=%v", fn["name"]) - } - if fn["arguments"] != `{"city":"北京"}` { - t.Errorf("fn.arguments=%q", fn["arguments"]) - } -} - -// TestStripToolCallNames 直测跨帧 name 收敛:首片保留 name、同 index 后续分片删除 -// name 键(空串或重复非空串都删),不同 index 互不串扰,非 tool_calls 帧零影响。 -func TestStripToolCallNames(t *testing.T) { - mkFrame := func(idx float64, name, args string) map[string]any { - fn := map[string]any{} - if name != "" { - fn["name"] = name - } - if args != "" { - fn["arguments"] = args - } - return map[string]any{"choices": []any{ - map[string]any{"delta": map[string]any{"tool_calls": []any{ - map[string]any{"index": idx, "function": fn}, - }}}, - }} - } - getFn := func(f map[string]any) map[string]any { - return f["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) - } - seen := map[int]bool{} - - // 首片带 name:保留,seen 建立 - f0 := mkFrame(0, "lookup", "") - stripToolCallNames(f0, seen) - if !seen[0] { - t.Fatal("index 0 should be marked seen after first chunk") - } - if getFn(f0)["name"] != "lookup" { - t.Errorf("first chunk name=%v want lookup", getFn(f0)["name"]) - } - - // 后续 chunk name 为空串:删除 name 键 - f1 := mkFrame(0, "", `{"term":"x"}`) - stripToolCallNames(f1, seen) - if _, ok := getFn(f1)["name"]; ok { - t.Errorf("subsequent empty name should be stripped: %#v", getFn(f1)) - } - if getFn(f1)["arguments"] != `{"term":"x"}` { - t.Errorf("arguments altered: %#v", getFn(f1)["arguments"]) - } - - // 后续 chunk 重复非空 name(上游噪声):同样删除,arguments 原样 - f2 := mkFrame(0, "lookup", "y") - stripToolCallNames(f2, seen) - if _, ok := getFn(f2)["name"]; ok { - t.Errorf("subsequent duplicate non-empty name should be stripped: %#v", getFn(f2)) - } - if getFn(f2)["arguments"] != "y" { - t.Errorf("arguments altered: %#v", getFn(f2)["arguments"]) - } - - // 不同 index 互不串扰:index 1 首片保留 name - f3 := mkFrame(1, "other", "") - stripToolCallNames(f3, seen) - if getFn(f3)["name"] != "other" { - t.Errorf("index 1 first name=%v want other", getFn(f3)["name"]) - } - if !seen[1] { - t.Error("index 1 should be marked seen") - } - - // 非 tool_calls 帧(content only)零影响 - f4 := map[string]any{"choices": []any{ - map[string]any{"delta": map[string]any{"content": "hi"}}, - }} - stripToolCallNames(f4, seen) - if got := f4["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any); len(got) != 1 || got["content"] != "hi" { - t.Errorf("content-only frame altered: %#v", got) - } -} - -// TestStreamToolCallNameOnce 11 帧 tool_call:首帧 name=Bash,后续 10 帧不得携带 -// name 键,arguments 逐帧原样透传(issue #82:累加型客户端把每个分片 name 拼接成 -// Bash×帧数;正确行为是 name 只在首帧出现一次)。 -func TestStreamToolCallNameOnce(t *testing.T) { - const nFrames = 11 - var sb strings.Builder - for i := 0; i < nFrames; i++ { - sb.WriteString(`data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"Bash","arguments":"arg` + string(rune('0'+i)) + `"}}]}}]}`) - sb.WriteString("\n\n") - } - sb.WriteString("data: [DONE]\n\n") - - frames, done := streamFrames(t, sb.String()) - if done != 1 { - t.Fatalf("done=%d want 1", done) - } - if len(frames) != nFrames { - t.Fatalf("frames=%d want %d", len(frames), nFrames) - } - gotName := 0 - for i, fr := range frames { - chs, _ := fr["choices"].([]any) - d, _ := chs[0].(map[string]any)["delta"].(map[string]any) - tcs, _ := d["tool_calls"].([]any) - if len(tcs) != 1 { - t.Fatalf("frame %d tool_calls len=%d want 1", i, len(tcs)) - } - fn, _ := tcs[0].(map[string]any)["function"].(map[string]any) - if _, ok := fn["name"]; ok { - gotName++ - if i != 0 || fn["name"] != "Bash" { - t.Errorf("frame %d unexpected name=%v (name 只能出现在首帧且为 Bash)", i, fn["name"]) - } - } - if want := "arg" + string(rune('0'+i)); fn["arguments"] != want { - t.Errorf("frame %d arguments=%v want %q", i, fn["arguments"], want) - } - } - if gotName != 1 { - t.Errorf("name 出现帧数=%d want 1", gotName) - } -} - -// TestStreamToolCallParallelFragments 多 tool_call(index 0 与 1 并行分片交错下发): -// 每个 index 只保留自己的首帧 name,后续分片互不串扰、arguments 各自原样。 -func TestStreamToolCallParallelFragments(t *testing.T) { - raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":0,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"Read\",\"arguments\":\"\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"a0\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"Read\",\"arguments\":\"a1\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + - "data: [DONE]\n\n" - - frames, done := streamFrames(t, raw) - if done != 1 { - t.Fatalf("done=%d want 1", done) - } - - argByIndex := map[int]string{} - nameCount := map[int]int{} - for _, fr := range frames { - chs, _ := fr["choices"].([]any) - d, _ := chs[0].(map[string]any)["delta"].(map[string]any) - tcs, _ := d["tool_calls"].([]any) - for _, tci := range tcs { - tc, _ := tci.(map[string]any) - idx := int(tc["index"].(float64)) - fn, _ := tc["function"].(map[string]any) - if _, ok := fn["name"]; ok { - nameCount[idx]++ - } - if a, _ := fn["arguments"].(string); a != "" { - argByIndex[idx] = a - } - } - } - // 每个 index 恰好出现一次 name,arguments 逐片原样(a0/a1 各自保留) - if nameCount[0] != 1 || nameCount[1] != 1 { - t.Errorf("name 出现次数 index0=%d index1=%d,各 want 1", nameCount[0], nameCount[1]) - } - if argByIndex[0] != "a0" || argByIndex[1] != "a1" { - t.Errorf("arguments index0=%q index1=%q want a0/a1", argByIndex[0], argByIndex[1]) - } -} - -// TestStreamToolCallNoiseEmptyName 上游后续帧带空串 name(噪声形态)→ 输出帧无 name 键。 -// 键缺失是比空串更安全的形态,客户端「键缺失则保留旧值」不会清空工具名。 -func TestStreamToolCallNoiseEmptyName(t *testing.T) { - raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg1\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg2\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + - "data: [DONE]\n\n" - - frames, done := streamFrames(t, raw) - if done != 1 { - t.Fatalf("done=%d want 1", done) - } - for i, fr := range frames { - chs, _ := fr["choices"].([]any) - d, _ := chs[0].(map[string]any)["delta"].(map[string]any) - tcs, _ := d["tool_calls"].([]any) - for _, tci := range tcs { - tc, _ := tci.(map[string]any) - fn, _ := tc["function"].(map[string]any) - if i == 0 { - if fn["name"] != "lookup" { - t.Errorf("frame 0 name=%v want lookup", fn["name"]) - } - continue - } - if _, ok := fn["name"]; ok { - t.Errorf("frame %d: 空串 name 应被剥离为键缺失, got %#v", i, fn) - } - } - } -} - -// TestStreamToolCallOverwriteClientSemantics 覆盖型语义验证:模拟「键缺失则保留旧值」 -// 的覆盖型客户端(name ?? state.name / if (name) state.name = name),在输出流上逐帧 -// 重建 name,最终必须收敛为 Bash——证明键缺失形态不会清空工具名。 -func TestStreamToolCallOverwriteClientSemantics(t *testing.T) { - raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]}}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + - "data: [DONE]\n\n" - - frames, done := streamFrames(t, raw) - if done != 1 { - t.Fatalf("done=%d want 1", done) - } - state := map[int]string{} - reconstructed := map[int]string{} - for _, fr := range frames { - chs, _ := fr["choices"].([]any) - d, _ := chs[0].(map[string]any)["delta"].(map[string]any) - tcs, _ := d["tool_calls"].([]any) - for _, tci := range tcs { - tc, _ := tci.(map[string]any) - idx := int(tc["index"].(float64)) - fn, _ := tc["function"].(map[string]any) - // 覆盖型语义:键缺失 → ?? 保留旧值;非空 name → 覆盖。 - if name, ok := fn["name"]; ok { - state[idx] = name.(string) - } - if state[idx] != "" { - reconstructed[idx] = state[idx] - } - } - } - // 覆盖型客户端重建后最终 name 必须是 Bash(首帧建立,后续空/重复分片均不破坏)。 - if len(reconstructed) != 1 || reconstructed[0] != "Bash" { - t.Errorf("覆盖型重建 name=%v want map[0:Bash]", reconstructed) - } -} - -// streamFrames 把原始 SSE 输入经 Stream 处理后解析出所有 JSON 帧及 [DONE] 计数。 -func streamFrames(t *testing.T, raw string) (frames []map[string]any, doneCount int) { - t.Helper() - rec := httptest.NewRecorder() - if err := Stream(rec, strings.NewReader(raw)); err != nil { - t.Fatal(err) - } - body := rec.Body.String() - for _, ln := range strings.Split(body, "\n") { - ln = strings.TrimSpace(ln) - if strings.HasPrefix(ln, "data: [DONE]") { - doneCount++ - continue - } - if strings.HasPrefix(ln, "data: ") { - var obj map[string]any - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &obj); err != nil { - t.Fatalf("bad frame %q: %v", ln, err) - } - frames = append(frames, obj) - } - } - return frames, doneCount -} - -func TestNormalizeFrame(t *testing.T) { - cases := []struct { - name string - in map[string]any - want string // 规范化后 marshal 的期望 JSON(Go map 键按字典序输出) - }{ - {"empty content/refusal and finish_reason empty string", - map[string]any{"id": "x", "choices": []any{ - map[string]any{"index": 0, "delta": map[string]any{"content": "", "refusal": ""}, "finish_reason": ""}, - }}, - `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"x","object":"chat.completion.chunk","usage":null}`}, - {"non-empty tool_calls kept", - map[string]any{"choices": []any{ - map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"id": "c1", "type": "function"}}}}, - }}, - `{"choices":[{"delta":{"tool_calls":[{"id":"c1","type":"function"}]},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, - {"empty tool_calls list dropped", - map[string]any{"choices": []any{ - map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{}, "content": "hi"}}, - }}, - `{"choices":[{"delta":{"content":"hi"},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, - {"empty placeholder function_call dropped", - map[string]any{"choices": []any{ - map[string]any{"index": 0, "delta": map[string]any{"function_call": map[string]any{"name": "", "arguments": ""}}}, - }}, - `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, - {"top-level unknown fields dropped, usage null when absent", - map[string]any{"id": "x", "object": "chat.completion.chunk", "created": 1, "junk": "noise", "choices": []any{ - map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}, - }}, - `{"choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":1,"id":"x","object":"chat.completion.chunk","usage":null}`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - raw, err := json.Marshal(normalizeFrame(c.in)) - if err != nil { - t.Fatal(err) - } - if string(raw) != c.want { - t.Errorf("got %s\nwant %s", raw, c.want) - } - }) - } -} - -func TestStreamNormalizesFrames(t *testing.T) { - // 混合噪声帧:空 content/reasoning/refusal/function_call + 空 tool_calls + 顶层非标字段, - // 随后非空 content + tool_calls 帧,最后 finish/usage 帧。 - raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"\",\"refusal\":\"\",\"tool_calls\":[],\"function_call\":{\"name\":\"\",\"arguments\":\"\"}},\"finish_reason\":\"\"}],\"extra_field\":\"junk\"}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"北京\\\"}\"},\"index\":0}]},\"finish_reason\":\"\"}]}\n\n" + - "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + - "data: [DONE]\n\n" - - frames, done := streamFrames(t, raw) - if done != 1 { - t.Fatalf("done frames=%d want 1", done) - } - if len(frames) != 3 { - t.Fatalf("frames=%d want 3", len(frames)) - } - - // 帧 1:噪声全剔除,finish_reason ""→null,usage 缺失→null,顶层非标字段剥除 - f0 := frames[0] - if _, ok := f0["extra_field"]; ok { - t.Error("top-level extra_field should be dropped") - } - if f0["usage"] != nil { - t.Errorf("usage should be null when absent, got %v", f0["usage"]) - } - ch0 := f0["choices"].([]any)[0].(map[string]any) - if ch0["finish_reason"] != nil { - t.Errorf("frame1 finish_reason=%v want null", ch0["finish_reason"]) - } - d := ch0["delta"].(map[string]any) - // role 是合法白名单键保留;空 content/reasoning/refusal/tool_calls/function_call 噪声全剔除 - if len(d) != 1 || d["role"] != "assistant" { - t.Errorf("frame1 delta should only keep role, got %#v", d) - } - for _, noise := range []string{"content", "reasoning_content", "refusal", "tool_calls", "function_call"} { - if _, ok := d[noise]; ok { - t.Errorf("frame1 delta should drop %q, got %#v", noise, d) - } - } - - // 帧 2:非空 content 与 tool_calls 保留,finish_reason ""→null - f1 := frames[1] - ch1 := f1["choices"].([]any)[0].(map[string]any) - d1 := ch1["delta"].(map[string]any) - if d1["content"] != "hello" { - t.Errorf("frame2 content=%v", d1["content"]) - } - tcs, ok := d1["tool_calls"].([]any) - if !ok || len(tcs) != 1 { - t.Fatalf("frame2 tool_calls=%#v", d1["tool_calls"]) - } - if ch1["finish_reason"] != nil { - t.Errorf("frame2 finish_reason=%v want null (input empty string)", ch1["finish_reason"]) - } - - // 帧 3:finish_reason 非空保留,usage 保留 - f2 := frames[2] - ch2 := f2["choices"].([]any)[0].(map[string]any) - if ch2["finish_reason"] != "stop" { - t.Errorf("frame3 finish_reason=%v want stop", ch2["finish_reason"]) - } - if f2["usage"].(map[string]any)["total_tokens"].(float64) != 7 { - t.Errorf("frame3 usage=%v", f2["usage"]) - } -} - -// TestStreamFirstIdPassthrough 帧混合(首帧有 id / 中间帧无 id / 空串 id):输出每帧 id -// 必须连续一致(取首帧真实值),不再一律 chatcmpl-wb2api(issue #35 后台聚合:透传流里 -// 每帧同 id 才能按消息归并)。 -func TestStreamFirstIdPassthrough(t *testing.T) { - raw := "data: {\"id\":\"chatcmpl-upstream-9\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"}}]}\n\n" + - // 中间帧无 id:应复用首帧 id。 - "data: {\"object\":\"chat.completion.chunk\",\"created\":1,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"}}]}\n\n" + - // 中间帧 id 为空串:同样复用首帧 id。 - "data: {\"id\":\"\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n" + - "data: [DONE]\n\n" - - frames, done := streamFrames(t, raw) - if done != 1 { - t.Fatalf("done=%d want 1", done) - } - if len(frames) != 3 { - t.Fatalf("frames=%d want 3", len(frames)) - } - for i, fr := range frames { - if got := fr["id"]; got != "chatcmpl-upstream-9" { - t.Errorf("frame %d id=%v want chatcmpl-upstream-9 (首帧真实 id 续传)", i, got) - } - } -} - -// TestStreamNoIdFallsBackToSentinel 全流无任何真实 id → 兜底 chatcmpl-wb2api -// (整流无 id 时的既有哨兵,帧与帧之间仍一惯性存在)。 -func TestStreamNoIdFallsBackToSentinel(t *testing.T) { - raw := "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n" + - "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{}," + - "\"finish_reason\":\"stop\"}]}\n\n" + - "data: [DONE]\n\n" - frames, _ := streamFrames(t, raw) - if len(frames) != 2 { - t.Fatalf("frames=%d want 2", len(frames)) - } - for i, fr := range frames { - if got := fr["id"]; got != "chatcmpl-wb2api" { - t.Errorf("frame %d id=%v want sentinel chatcmpl-wb2api (无真实 id)", i, got) - } - } -} - -func TestStreamDoneFallback(t *testing.T) { - // 上游流在无 [DONE] 时 EOF,Stream 必须兜底写一个 [DONE] - rec := httptest.NewRecorder() - err := Stream(rec, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")) - if err != nil { - t.Fatal(err) - } - body := rec.Body.String() - if !strings.HasSuffix(strings.TrimRight(body, "\n"), "data: [DONE]") { - t.Errorf("missing [DONE] fallback: %q", body) - } - - // 已有 [DONE] 时只写一次,不重复 - rec2 := httptest.NewRecorder() - if err := Stream(rec2, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n")); err != nil { - t.Fatal(err) - } - if n := strings.Count(rec2.Body.String(), "data: [DONE]"); n != 1 { - t.Errorf("[DONE] count=%d want 1: %q", n, rec2.Body.String()) - } -} - -func TestStreamPassthrough(t *testing.T) { - rec := httptest.NewRecorder() - err := Stream(rec, strings.NewReader(sseFixture)) - if err != nil { - t.Fatal(err) - } - body := rec.Body.String() - if !strings.Contains(body, "你好") || !strings.Contains(body, "data: [DONE]") { - t.Errorf("body missing chunks: %q", body) - } - // 逐行仍是合法 SSE(每行以 data: 开头或是空行) - for _, ln := range strings.Split(strings.TrimRight(body, "\n"), "\n") { - if ln != "" && !strings.HasPrefix(ln, "data: ") { - t.Errorf("bad line: %q", ln) - } - } - ct := rec.Header().Get("Content-Type") - if !strings.Contains(ct, "text/event-stream") { - t.Errorf("content-type=%q", ct) - } -} - -// TestAggregateEmptyStreamCases 覆盖空流检测:0 有效事件必须报错、[DONE] 即 break、 -// [DONE] 后垃圾不进聚合、正常聚合回归。 -func TestAggregateEmptyStreamCases(t *testing.T) { - // 正常回归基流:content + finish_reason + usage,[DONE] 收尾。 - valid := "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n" + - "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + - "data: [DONE]\n\n" - - cases := []struct { - name string - raw string - wantErr bool - // 回归断言(仅在 wantErr=false 时校验) - wantContent string - wantUsage float64 - }{ - { - name: "空流(EOF 即止)", - raw: "", - wantErr: true, - }, - { - name: "只有注释行和空行加 DONE", - raw: ": comment\n\n: another comment\n\ndata: [DONE]\n\n", - wantErr: true, - }, - { - name: "DONE 后跟垃圾帧不进聚合", - raw: valid[:len(valid)-len("data: [DONE]\n\n")] + "data: [DONE]\n\ndata: {\"junk\":\"should not aggregate\"}\n\n", - wantErr: false, - // 与 valid 基流一致的聚合期望 - wantContent: "hi", - wantUsage: 7, - }, - { - name: "正常流回归", - raw: valid, - wantErr: false, - wantContent: "hi", - wantUsage: 7, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - resp, err := Aggregate(strings.NewReader(c.raw)) - if c.wantErr { - if err == nil { - t.Fatalf("expected error, got nil (resp=%v)", resp) - } - return - } - if err != nil { - t.Fatal(err) - } - msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) - if msg["content"] != c.wantContent { - t.Errorf("content=%q want %q", msg["content"], c.wantContent) - } - if u, ok := resp["usage"].(map[string]any); ok { - if u["total_tokens"].(float64) != c.wantUsage { - t.Errorf("usage=%v want %v", u["total_tokens"], c.wantUsage) - } - } else { - t.Errorf("usage missing") - } - }) - } -} - -// TestAggregateEmptyStreamError 校验空流错误信息形如约定文案。 -func TestAggregateEmptyStreamError(t *testing.T) { - _, err := Aggregate(strings.NewReader("")) - if err == nil || !strings.Contains(err.Error(), "no valid data events") { - t.Fatalf("err=%v", err) - } -} - -// TestStreamEmptyFramesCase 覆盖流式空流检测:0 有效帧时写 error 帧(error 字段存活), -// 恰好一个 [DONE],并返回非 nil error。 -func TestStreamEmptyFramesCase(t *testing.T) { - cases := []struct { - name string - raw string - }{ - {"空流", ""}, - {"只有注释行", ": comment\n\n"}, - {"只有 DONE", "data: [DONE]\n\n"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - err := Stream(rec, strings.NewReader(c.raw)) - if err == nil { - t.Fatalf("expected error, got nil") - } - body := rec.Body.String() - if n := strings.Count(body, "data: [DONE]"); n != 1 { - t.Errorf("[DONE] count=%d want 1: %q", n, body) - } - // error 帧必须原样保留 error 字段(未被 normalizeFrame 白名单剥掉) - var e map[string]any - found := false - for _, ln := range strings.Split(body, "\n") { - ln = strings.TrimSpace(ln) - if strings.HasPrefix(ln, "data: ") { - payload := strings.TrimPrefix(ln, "data: ") - if payload == "[DONE]" { - continue - } - if json.Unmarshal([]byte(payload), &e) == nil { - if em, ok := e["error"].(map[string]any); ok && em["message"] == "empty upstream stream" && em["type"] == "upstream_error" { - found = true - } - } - } - } - if !found { - t.Errorf("error frame absent or error field stripped: %q", body) - } - }) - } -} - -// TestStreamGarbageAfterDone 校验 DONE 之后的垃圾帧不出现在响应里。 -func TestStreamGarbageAfterDone(t *testing.T) { - raw := "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}\n\n" + - "data: [DONE]\n\n" + - "data: {\"should\":\"not appear\"}\n\n" - rec := httptest.NewRecorder() - if err := Stream(rec, strings.NewReader(raw)); err != nil { - t.Fatal(err) - } - body := rec.Body.String() - if strings.Contains(body, "should") { - t.Errorf("garbage after DONE leaked into response: %q", body) - } - if n := strings.Count(body, "data: [DONE]"); n != 1 { - t.Errorf("[DONE] count=%d want 1: %q", n, body) - } - // 有效帧仍被透传 - if !strings.Contains(body, "hello") { - t.Errorf("valid frame missing: %q", body) - } -} - -// TestStreamNormalPassthroughRegression 校验正常透传回归:帧被 normalize 后透传、 -// 末尾恰好一个 [DONE]、无 error 帧;上游漏发 DONE 时自动补。 -func TestStreamNormalPassthroughRegression(t *testing.T) { - cases := []struct { - name string - raw string - }{ - {"带 DONE 的正常流", sseFixture}, - {"漏发 DONE 自动补", "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - if err := Stream(rec, strings.NewReader(c.raw)); err != nil { - t.Fatal(err) - } - body := rec.Body.String() - if strings.Contains(body, `"error"`) { - t.Errorf("unexpected error frame: %q", body) - } - if n := strings.Count(body, "data: [DONE]"); n != 1 { - t.Errorf("[DONE] count=%d want 1: %q", n, body) - } - // 帧被规范化:含 "id" 且有标准 object 字段 - if !strings.Contains(body, `"object":"chat.completion.chunk"`) { - t.Errorf("frame not normalized: %q", body) - } - }) - } -} - -// TestNormalizeUsageCacheAliasesMirrorsNestedHit / PreservesZeroResult 自 -// usage_test.go 迁入(PR #57 原新文件按仓库规则不收,核心断言保留在此—— -// normalize 钩子就挂在 sse.go 的 Aggregate/normalizeFrame 两个出口)。 -func TestNormalizeUsageCacheAliasesMirrorsNestedHit(t *testing.T) { - usage := map[string]any{ - "prompt_tokens": 21041.0, - "completion_tokens": 8.0, - "total_tokens": 21049.0, - "cache_read_input_tokens": 0.0, - "cached_tokens": 0.0, - "prompt_cache_hit_tokens": 0.0, - "prompt_cache_miss_tokens": 177.0, - "prompt_tokens_details": map[string]any{ - "cached_tokens": 20864.0, - }, - } - - got := normalizeUsageCacheAliases(usage) - - for _, key := range []string{ - "cache_read_input_tokens", - "cached_tokens", - "prompt_cache_hit_tokens", - } { - if got[key] != 20864.0 { - t.Fatalf("%s=%v want 20864", key, got[key]) - } - } - details := got["prompt_tokens_details"].(map[string]any) - if details["cached_tokens"] != 20864.0 { - t.Fatalf("prompt_tokens_details.cached_tokens=%v want 20864", details["cached_tokens"]) - } -} - -func TestNormalizeUsageCacheAliasesPreservesZeroResult(t *testing.T) { - usage := map[string]any{ - "prompt_tokens": 35.0, - "completion_tokens": 2.0, - "total_tokens": 37.0, - "cache_read_input_tokens": 0.0, - "prompt_cache_hit_tokens": 0.0, - "prompt_tokens_details": map[string]any{ - "cached_tokens": 0.0, - }, - } - - got := normalizeUsageCacheAliases(usage) - - details := got["prompt_tokens_details"].(map[string]any) - if details["cached_tokens"] != 0.0 { - t.Fatalf("prompt_tokens_details.cached_tokens=%v want 0", details["cached_tokens"]) - } -} - -func TestUserResourceDetailedWithExpirySnapshot(t *testing.T) { - now := time.Now().In(softRateResetLoc) - soon := now.Add(24 * time.Hour).Truncate(time.Second) - later := now.Add(10 * 24 * time.Hour).Truncate(time.Second) - payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + - `{"PackageName":"soon-a","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + - `{"PackageName":"soon-b","CycleCapacitySize":15,"CycleCapacityRemain":15,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + - `{"PackageName":"later","CycleCapacitySize":20,"CycleCapacityRemain":20,"CycleCapacityUsed":0,"CycleEndTime":"` + later.Format(packageEndLayout) + `"},` + - `{"PackageName":"unknown","CycleCapacitySize":5,"CycleCapacityRemain":5,"CycleCapacityUsed":0}` + - `]}}}}` - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { - return nil, errors.New("wrong path: " + r.URL.Path) - } - return jsonResp(200, payload), nil - }) - - remain, total, expiring, earliestAt, earliestRemaining, err := c.UserResourceDetailedWithExpiry( - &auth.Auth{AccessToken: "at", UID: "u1"}, 48*time.Hour, - ) - if err != nil { - t.Fatalf("resource: %v", err) - } - if remain != 50 || total != 50 || expiring != 25 { - t.Fatalf("remain/total/expiring=%d/%d/%d want 50/50/25", remain, total, expiring) - } - if earliestRemaining != 25 || !earliestAt.Equal(soon) { - t.Fatalf("earliest=%v/%d want %v/25", earliestAt, earliestRemaining, soon) - } -} -func TestCreditPackagesExpiryTimestamp(t *testing.T) { - end := time.Now().In(softRateResetLoc).Add(7 * 24 * time.Hour).Truncate(time.Second) - c := testClient(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { - return nil, errors.New("wrong path: " + r.URL.Path) - } - return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[`+ - `{"PackageName":"gift","CycleCapacitySize":100,"CycleCapacityRemain":80,"CycleCapacityUsed":20,"CycleEndTime":"`+ - end.Format(packageEndLayout)+`"},`+ - `{"PackageName":"unknown","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0}`+ - `]}}}}`), nil - }) - packs, remain, size, err := c.CreditPackages(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil { - t.Fatalf("packages: %v", err) - } - if remain != 90 || size != 110 { - t.Fatalf("remain/size=%d/%d want 90/110", remain, size) - } - var found bool - for _, p := range packs { - if p.Name == "gift" { - found = p.ExpiresAt == end.UnixMilli() - } - } - if !found { - t.Fatalf("gift pack missing Unix-ms expiry: %+v", packs) - } -} +package upstream + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func TestPrepareBodyForcesStream(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"model":"glm-5.2","messages":[]}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["stream"] != true { + t.Errorf("stream=%v", m["stream"]) + } +} + +func TestPrepareBodyToolChoiceFunctionObject(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"function","function":{"name":"get_weather"}},"tools":[{"type":"function"}]}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["tool_choice"] != "get_weather" { + t.Errorf("tool_choice=%v", m["tool_choice"]) + } + if _, ok := m["tools"]; !ok { + t.Error("tools should be kept for function choice") + } +} + +func TestPrepareBodyToolChoiceNone(t *testing.T) { + for _, in := range []string{ + `{"tool_choice":"none","tools":[{}],"functions":[{}]}`, + `{"tool_choice":{"type":"none"},"tools":[{}]}`, + } { + out := PrepareBodyOpt([]byte(in), true) + var m map[string]any + json.Unmarshal(out, &m) + if _, ok := m["tool_choice"]; ok { + t.Errorf("%s: tool_choice should be deleted", in) + } + if _, ok := m["tools"]; ok { + t.Errorf("%s: tools should be deleted", in) + } + if _, ok := m["functions"]; ok { + t.Errorf("%s: functions should be deleted", in) + } + } +} + +func TestPrepareBodyToolChoiceAuto(t *testing.T) { + out := PrepareBodyOpt([]byte(`{"tool_choice":{"type":"auto"}}`), true) + var m map[string]any + json.Unmarshal(out, &m) + if m["tool_choice"] != "auto" { + t.Errorf("tool_choice=%v", m["tool_choice"]) + } +} + +func TestPrepareBodyInvalidJSON(t *testing.T) { + in := []byte(`{broken`) + out := PrepareBodyOpt(in, true) + if string(out) != string(in) { + t.Error("invalid json should pass through unchanged") + } +} + +const sseFixture = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"你好\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",世界\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1753600000,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + +func TestAggregate(t *testing.T) { + resp, err := Aggregate(strings.NewReader(sseFixture)) + if err != nil { + t.Fatal(err) + } + if resp["object"] != "chat.completion" { + t.Errorf("object=%v", resp["object"]) + } + if resp["model"] != "glm-5.2" { + t.Errorf("model=%v", resp["model"]) + } + choices := resp["choices"].([]any) + msg := choices[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "你好,世界" { + t.Errorf("content=%q", msg["content"]) + } + if msg["role"] != "assistant" { + t.Errorf("role=%v", msg["role"]) + } + if choices[0].(map[string]any)["finish_reason"] != "stop" { + t.Errorf("finish_reason=%v", choices[0].(map[string]any)["finish_reason"]) + } + usage := resp["usage"].(map[string]any) + if usage["total_tokens"].(float64) != 7 { + t.Errorf("usage=%v", usage) + } +} + +func TestAggregateSkipsNonDataLines(t *testing.T) { + raw := ": comment\n\n" + sseFixture + resp, err := Aggregate(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "你好,世界" { + t.Errorf("content=%q", msg["content"]) + } +} + +func TestAggregateToolCalls(t *testing.T) { + // 流式 tool_calls:首片带 id/type/name + 空 arguments,后续只带 arguments 片段 + raw := `data: {"id":"x1","model":"deepseek-v4-pro","created":1,"choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"id":"call_a","type":"function","function":{"name":"get_weather","arguments":""},"index":0}]}}],"usage":null} + +data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"{\"city\":"},"index":0}]}}]} + +data: {"id":"x1","choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"\"北京\"}"},"index":0}]}}]} + +data: {"id":"x1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"total_tokens":11}} + +data: [DONE] + +` + resp, err := Aggregate(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + choice := resp["choices"].([]any)[0].(map[string]any) + if choice["finish_reason"] != "tool_calls" { + t.Errorf("finish_reason=%v", choice["finish_reason"]) + } + msg := choice["message"].(map[string]any) + calls, ok := msg["tool_calls"].([]map[string]any) + if !ok || len(calls) != 1 { + t.Fatalf("tool_calls=%#v", msg["tool_calls"]) + } + if calls[0]["id"] != "call_a" || calls[0]["type"] != "function" { + t.Errorf("call meta=%v", calls[0]) + } + fn := calls[0]["function"].(map[string]any) + if fn["name"] != "get_weather" { + t.Errorf("fn.name=%v", fn["name"]) + } + if fn["arguments"] != `{"city":"北京"}` { + t.Errorf("fn.arguments=%q", fn["arguments"]) + } +} + +// TestStripToolCallNames 直测跨帧 name 收敛:首片保留 name、同 index 后续分片删除 +// name 键(空串或重复非空串都删),不同 index 互不串扰,非 tool_calls 帧零影响。 +func TestStripToolCallNames(t *testing.T) { + mkFrame := func(idx float64, name, args string) map[string]any { + fn := map[string]any{} + if name != "" { + fn["name"] = name + } + if args != "" { + fn["arguments"] = args + } + return map[string]any{"choices": []any{ + map[string]any{"delta": map[string]any{"tool_calls": []any{ + map[string]any{"index": idx, "function": fn}, + }}}, + }} + } + getFn := func(f map[string]any) map[string]any { + return f["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + } + seen := map[int]bool{} + + // 首片带 name:保留,seen 建立 + f0 := mkFrame(0, "lookup", "") + stripToolCallNames(f0, seen) + if !seen[0] { + t.Fatal("index 0 should be marked seen after first chunk") + } + if getFn(f0)["name"] != "lookup" { + t.Errorf("first chunk name=%v want lookup", getFn(f0)["name"]) + } + + // 后续 chunk name 为空串:删除 name 键 + f1 := mkFrame(0, "", `{"term":"x"}`) + stripToolCallNames(f1, seen) + if _, ok := getFn(f1)["name"]; ok { + t.Errorf("subsequent empty name should be stripped: %#v", getFn(f1)) + } + if getFn(f1)["arguments"] != `{"term":"x"}` { + t.Errorf("arguments altered: %#v", getFn(f1)["arguments"]) + } + + // 后续 chunk 重复非空 name(上游噪声):同样删除,arguments 原样 + f2 := mkFrame(0, "lookup", "y") + stripToolCallNames(f2, seen) + if _, ok := getFn(f2)["name"]; ok { + t.Errorf("subsequent duplicate non-empty name should be stripped: %#v", getFn(f2)) + } + if getFn(f2)["arguments"] != "y" { + t.Errorf("arguments altered: %#v", getFn(f2)["arguments"]) + } + + // 不同 index 互不串扰:index 1 首片保留 name + f3 := mkFrame(1, "other", "") + stripToolCallNames(f3, seen) + if getFn(f3)["name"] != "other" { + t.Errorf("index 1 first name=%v want other", getFn(f3)["name"]) + } + if !seen[1] { + t.Error("index 1 should be marked seen") + } + + // 非 tool_calls 帧(content only)零影响 + f4 := map[string]any{"choices": []any{ + map[string]any{"delta": map[string]any{"content": "hi"}}, + }} + stripToolCallNames(f4, seen) + if got := f4["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any); len(got) != 1 || got["content"] != "hi" { + t.Errorf("content-only frame altered: %#v", got) + } +} + +// TestStreamToolCallNameOnce 11 帧 tool_call:首帧 name=Bash,后续 10 帧不得携带 +// name 键,arguments 逐帧原样透传(issue #82:累加型客户端把每个分片 name 拼接成 +// Bash×帧数;正确行为是 name 只在首帧出现一次)。 +func TestStreamToolCallNameOnce(t *testing.T) { + const nFrames = 11 + var sb strings.Builder + for i := 0; i < nFrames; i++ { + sb.WriteString(`data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"Bash","arguments":"arg` + string(rune('0'+i)) + `"}}]}}]}`) + sb.WriteString("\n\n") + } + sb.WriteString("data: [DONE]\n\n") + + frames, done := streamFrames(t, sb.String()) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + if len(frames) != nFrames { + t.Fatalf("frames=%d want %d", len(frames), nFrames) + } + gotName := 0 + for i, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + if len(tcs) != 1 { + t.Fatalf("frame %d tool_calls len=%d want 1", i, len(tcs)) + } + fn, _ := tcs[0].(map[string]any)["function"].(map[string]any) + if _, ok := fn["name"]; ok { + gotName++ + if i != 0 || fn["name"] != "Bash" { + t.Errorf("frame %d unexpected name=%v (name 只能出现在首帧且为 Bash)", i, fn["name"]) + } + } + if want := "arg" + string(rune('0'+i)); fn["arguments"] != want { + t.Errorf("frame %d arguments=%v want %q", i, fn["arguments"], want) + } + } + if gotName != 1 { + t.Errorf("name 出现帧数=%d want 1", gotName) + } +} + +// TestStreamToolCallParallelFragments 多 tool_call(index 0 与 1 并行分片交错下发): +// 每个 index 只保留自己的首帧 name,后续分片互不串扰、arguments 各自原样。 +func TestStreamToolCallParallelFragments(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":0,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"Read\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"a0\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"Read\",\"arguments\":\"a1\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + + argByIndex := map[int]string{} + nameCount := map[int]int{} + for _, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + idx := int(tc["index"].(float64)) + fn, _ := tc["function"].(map[string]any) + if _, ok := fn["name"]; ok { + nameCount[idx]++ + } + if a, _ := fn["arguments"].(string); a != "" { + argByIndex[idx] = a + } + } + } + // 每个 index 恰好出现一次 name,arguments 逐片原样(a0/a1 各自保留) + if nameCount[0] != 1 || nameCount[1] != 1 { + t.Errorf("name 出现次数 index0=%d index1=%d,各 want 1", nameCount[0], nameCount[1]) + } + if argByIndex[0] != "a0" || argByIndex[1] != "a1" { + t.Errorf("arguments index0=%q index1=%q want a0/a1", argByIndex[0], argByIndex[1]) + } +} + +// TestStreamToolCallNoiseEmptyName 上游后续帧带空串 name(噪声形态)→ 输出帧无 name 键。 +// 键缺失是比空串更安全的形态,客户端「键缺失则保留旧值」不会清空工具名。 +func TestStreamToolCallNoiseEmptyName(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg1\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"arg2\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + for i, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + fn, _ := tc["function"].(map[string]any) + if i == 0 { + if fn["name"] != "lookup" { + t.Errorf("frame 0 name=%v want lookup", fn["name"]) + } + continue + } + if _, ok := fn["name"]; ok { + t.Errorf("frame %d: 空串 name 应被剥离为键缺失, got %#v", i, fn) + } + } + } +} + +// TestStreamToolCallOverwriteClientSemantics 覆盖型语义验证:模拟「键缺失则保留旧值」 +// 的覆盖型客户端(name ?? state.name / if (name) state.name = name),在输出流上逐帧 +// 重建 name,最终必须收敛为 Bash——证明键缺失形态不会清空工具名。 +func TestStreamToolCallOverwriteClientSemantics(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Bash\",\"arguments\":\"\"}}]}}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + state := map[int]string{} + reconstructed := map[int]string{} + for _, fr := range frames { + chs, _ := fr["choices"].([]any) + d, _ := chs[0].(map[string]any)["delta"].(map[string]any) + tcs, _ := d["tool_calls"].([]any) + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + idx := int(tc["index"].(float64)) + fn, _ := tc["function"].(map[string]any) + // 覆盖型语义:键缺失 → ?? 保留旧值;非空 name → 覆盖。 + if name, ok := fn["name"]; ok { + state[idx] = name.(string) + } + if state[idx] != "" { + reconstructed[idx] = state[idx] + } + } + } + // 覆盖型客户端重建后最终 name 必须是 Bash(首帧建立,后续空/重复分片均不破坏)。 + if len(reconstructed) != 1 || reconstructed[0] != "Bash" { + t.Errorf("覆盖型重建 name=%v want map[0:Bash]", reconstructed) + } +} + +// streamFrames 把原始 SSE 输入经 Stream 处理后解析出所有 JSON 帧及 [DONE] 计数。 +func streamFrames(t *testing.T, raw string) (frames []map[string]any, doneCount int) { + t.Helper() + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + for _, ln := range strings.Split(body, "\n") { + ln = strings.TrimSpace(ln) + if strings.HasPrefix(ln, "data: [DONE]") { + doneCount++ + continue + } + if strings.HasPrefix(ln, "data: ") { + var obj map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &obj); err != nil { + t.Fatalf("bad frame %q: %v", ln, err) + } + frames = append(frames, obj) + } + } + return frames, doneCount +} + +func TestNormalizeFrame(t *testing.T) { + cases := []struct { + name string + in map[string]any + want string // 规范化后 marshal 的期望 JSON(Go map 键按字典序输出) + }{ + {"empty content/refusal and finish_reason empty string", + map[string]any{"id": "x", "choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"content": "", "refusal": ""}, "finish_reason": ""}, + }}, + `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"x","object":"chat.completion.chunk","usage":null}`}, + {"non-empty tool_calls kept", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"id": "c1", "type": "function"}}}}, + }}, + `{"choices":[{"delta":{"tool_calls":[{"id":"c1","type":"function"}]},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"empty tool_calls list dropped", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{}, "content": "hi"}}, + }}, + `{"choices":[{"delta":{"content":"hi"},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"empty placeholder function_call dropped", + map[string]any{"choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{"function_call": map[string]any{"name": "", "arguments": ""}}}, + }}, + `{"choices":[{"delta":{},"finish_reason":null,"index":0}],"id":"chatcmpl-wb2api","object":"chat.completion.chunk","usage":null}`}, + {"top-level unknown fields dropped, usage null when absent", + map[string]any{"id": "x", "object": "chat.completion.chunk", "created": 1, "junk": "noise", "choices": []any{ + map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}, + }}, + `{"choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":1,"id":"x","object":"chat.completion.chunk","usage":null}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw, err := json.Marshal(normalizeFrame(c.in)) + if err != nil { + t.Fatal(err) + } + if string(raw) != c.want { + t.Errorf("got %s\nwant %s", raw, c.want) + } + }) + } +} + +func TestStreamNormalizesFrames(t *testing.T) { + // 混合噪声帧:空 content/reasoning/refusal/function_call + 空 tool_calls + 顶层非标字段, + // 随后非空 content + tool_calls 帧,最后 finish/usage 帧。 + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"\",\"refusal\":\"\",\"tool_calls\":[],\"function_call\":{\"name\":\"\",\"arguments\":\"\"}},\"finish_reason\":\"\"}],\"extra_field\":\"junk\"}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"北京\\\"}\"},\"index\":0}]},\"finish_reason\":\"\"}]}\n\n" + + "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done frames=%d want 1", done) + } + if len(frames) != 3 { + t.Fatalf("frames=%d want 3", len(frames)) + } + + // 帧 1:噪声全剔除,finish_reason ""→null,usage 缺失→null,顶层非标字段剥除 + f0 := frames[0] + if _, ok := f0["extra_field"]; ok { + t.Error("top-level extra_field should be dropped") + } + if f0["usage"] != nil { + t.Errorf("usage should be null when absent, got %v", f0["usage"]) + } + ch0 := f0["choices"].([]any)[0].(map[string]any) + if ch0["finish_reason"] != nil { + t.Errorf("frame1 finish_reason=%v want null", ch0["finish_reason"]) + } + d := ch0["delta"].(map[string]any) + // role 是合法白名单键保留;空 content/reasoning/refusal/tool_calls/function_call 噪声全剔除 + if len(d) != 1 || d["role"] != "assistant" { + t.Errorf("frame1 delta should only keep role, got %#v", d) + } + for _, noise := range []string{"content", "reasoning_content", "refusal", "tool_calls", "function_call"} { + if _, ok := d[noise]; ok { + t.Errorf("frame1 delta should drop %q, got %#v", noise, d) + } + } + + // 帧 2:非空 content 与 tool_calls 保留,finish_reason ""→null + f1 := frames[1] + ch1 := f1["choices"].([]any)[0].(map[string]any) + d1 := ch1["delta"].(map[string]any) + if d1["content"] != "hello" { + t.Errorf("frame2 content=%v", d1["content"]) + } + tcs, ok := d1["tool_calls"].([]any) + if !ok || len(tcs) != 1 { + t.Fatalf("frame2 tool_calls=%#v", d1["tool_calls"]) + } + if ch1["finish_reason"] != nil { + t.Errorf("frame2 finish_reason=%v want null (input empty string)", ch1["finish_reason"]) + } + + // 帧 3:finish_reason 非空保留,usage 保留 + f2 := frames[2] + ch2 := f2["choices"].([]any)[0].(map[string]any) + if ch2["finish_reason"] != "stop" { + t.Errorf("frame3 finish_reason=%v want stop", ch2["finish_reason"]) + } + if f2["usage"].(map[string]any)["total_tokens"].(float64) != 7 { + t.Errorf("frame3 usage=%v", f2["usage"]) + } +} + +// TestStreamFirstIdPassthrough 帧混合(首帧有 id / 中间帧无 id / 空串 id):输出每帧 id +// 必须连续一致(取首帧真实值),不再一律 chatcmpl-wb2api(issue #35 后台聚合:透传流里 +// 每帧同 id 才能按消息归并)。 +func TestStreamFirstIdPassthrough(t *testing.T) { + raw := "data: {\"id\":\"chatcmpl-upstream-9\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"}}]}\n\n" + + // 中间帧无 id:应复用首帧 id。 + "data: {\"object\":\"chat.completion.chunk\",\"created\":1,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"}}]}\n\n" + + // 中间帧 id 为空串:同样复用首帧 id。 + "data: {\"id\":\"\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + + frames, done := streamFrames(t, raw) + if done != 1 { + t.Fatalf("done=%d want 1", done) + } + if len(frames) != 3 { + t.Fatalf("frames=%d want 3", len(frames)) + } + for i, fr := range frames { + if got := fr["id"]; got != "chatcmpl-upstream-9" { + t.Errorf("frame %d id=%v want chatcmpl-upstream-9 (首帧真实 id 续传)", i, got) + } + } +} + +// TestStreamNoIdFallsBackToSentinel 全流无任何真实 id → 兜底 chatcmpl-wb2api +// (整流无 id 时的既有哨兵,帧与帧之间仍一惯性存在)。 +func TestStreamNoIdFallsBackToSentinel(t *testing.T) { + raw := "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n" + + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{}," + + "\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + frames, _ := streamFrames(t, raw) + if len(frames) != 2 { + t.Fatalf("frames=%d want 2", len(frames)) + } + for i, fr := range frames { + if got := fr["id"]; got != "chatcmpl-wb2api" { + t.Errorf("frame %d id=%v want sentinel chatcmpl-wb2api (无真实 id)", i, got) + } + } +} + +func TestStreamDoneFallback(t *testing.T) { + // 上游流在无 [DONE] 时 EOF,Stream 必须兜底写一个 [DONE] + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")) + if err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if !strings.HasSuffix(strings.TrimRight(body, "\n"), "data: [DONE]") { + t.Errorf("missing [DONE] fallback: %q", body) + } + + // 已有 [DONE] 时只写一次,不重复 + rec2 := httptest.NewRecorder() + if err := Stream(rec2, strings.NewReader("data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n")); err != nil { + t.Fatal(err) + } + if n := strings.Count(rec2.Body.String(), "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, rec2.Body.String()) + } +} + +func TestStreamPassthrough(t *testing.T) { + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader(sseFixture)) + if err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if !strings.Contains(body, "你好") || !strings.Contains(body, "data: [DONE]") { + t.Errorf("body missing chunks: %q", body) + } + // 逐行仍是合法 SSE(每行以 data: 开头或是空行) + for _, ln := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + if ln != "" && !strings.HasPrefix(ln, "data: ") { + t.Errorf("bad line: %q", ln) + } + } + ct := rec.Header().Get("Content-Type") + if !strings.Contains(ct, "text/event-stream") { + t.Errorf("content-type=%q", ct) + } +} + +// TestAggregateEmptyStreamCases 覆盖空流检测:0 有效事件必须报错、[DONE] 即 break、 +// [DONE] 后垃圾不进聚合、正常聚合回归。 +func TestAggregateEmptyStreamCases(t *testing.T) { + // 正常回归基流:content + finish_reason + usage,[DONE] 收尾。 + valid := "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n" + + "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + + cases := []struct { + name string + raw string + wantErr bool + // 回归断言(仅在 wantErr=false 时校验) + wantContent string + wantUsage float64 + }{ + { + name: "空流(EOF 即止)", + raw: "", + wantErr: true, + }, + { + name: "只有注释行和空行加 DONE", + raw: ": comment\n\n: another comment\n\ndata: [DONE]\n\n", + wantErr: true, + }, + { + name: "DONE 后跟垃圾帧不进聚合", + raw: valid[:len(valid)-len("data: [DONE]\n\n")] + "data: [DONE]\n\ndata: {\"junk\":\"should not aggregate\"}\n\n", + wantErr: false, + // 与 valid 基流一致的聚合期望 + wantContent: "hi", + wantUsage: 7, + }, + { + name: "正常流回归", + raw: valid, + wantErr: false, + wantContent: "hi", + wantUsage: 7, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp, err := Aggregate(strings.NewReader(c.raw)) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (resp=%v)", resp) + } + return + } + if err != nil { + t.Fatal(err) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != c.wantContent { + t.Errorf("content=%q want %q", msg["content"], c.wantContent) + } + if u, ok := resp["usage"].(map[string]any); ok { + if u["total_tokens"].(float64) != c.wantUsage { + t.Errorf("usage=%v want %v", u["total_tokens"], c.wantUsage) + } + } else { + t.Errorf("usage missing") + } + }) + } +} + +// TestAggregateEmptyStreamError 校验空流错误信息形如约定文案。 +func TestAggregateEmptyStreamError(t *testing.T) { + _, err := Aggregate(strings.NewReader("")) + if err == nil || !strings.Contains(err.Error(), "no valid data events") { + t.Fatalf("err=%v", err) + } +} + +// TestStreamEmptyFramesCase 覆盖流式空流检测:0 有效帧时写 error 帧(error 字段存活), +// 恰好一个 [DONE],并返回非 nil error。 +func TestStreamEmptyFramesCase(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"空流", ""}, + {"只有注释行", ": comment\n\n"}, + {"只有 DONE", "data: [DONE]\n\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + err := Stream(rec, strings.NewReader(c.raw)) + if err == nil { + t.Fatalf("expected error, got nil") + } + body := rec.Body.String() + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // error 帧必须原样保留 error 字段(未被 normalizeFrame 白名单剥掉) + var e map[string]any + found := false + for _, ln := range strings.Split(body, "\n") { + ln = strings.TrimSpace(ln) + if strings.HasPrefix(ln, "data: ") { + payload := strings.TrimPrefix(ln, "data: ") + if payload == "[DONE]" { + continue + } + if json.Unmarshal([]byte(payload), &e) == nil { + if em, ok := e["error"].(map[string]any); ok && em["message"] == "empty upstream stream" && em["type"] == "upstream_error" { + found = true + } + } + } + } + if !found { + t.Errorf("error frame absent or error field stripped: %q", body) + } + }) + } +} + +// TestStreamGarbageAfterDone 校验 DONE 之后的垃圾帧不出现在响应里。 +func TestStreamGarbageAfterDone(t *testing.T) { + raw := "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: [DONE]\n\n" + + "data: {\"should\":\"not appear\"}\n\n" + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if strings.Contains(body, "should") { + t.Errorf("garbage after DONE leaked into response: %q", body) + } + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // 有效帧仍被透传 + if !strings.Contains(body, "hello") { + t.Errorf("valid frame missing: %q", body) + } +} + +// TestStreamNormalPassthroughRegression 校验正常透传回归:帧被 normalize 后透传、 +// 末尾恰好一个 [DONE]、无 error 帧;上游漏发 DONE 时自动补。 +func TestStreamNormalPassthroughRegression(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"带 DONE 的正常流", sseFixture}, + {"漏发 DONE 自动补", "data: {\"id\":\"x1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + if err := Stream(rec, strings.NewReader(c.raw)); err != nil { + t.Fatal(err) + } + body := rec.Body.String() + if strings.Contains(body, `"error"`) { + t.Errorf("unexpected error frame: %q", body) + } + if n := strings.Count(body, "data: [DONE]"); n != 1 { + t.Errorf("[DONE] count=%d want 1: %q", n, body) + } + // 帧被规范化:含 "id" 且有标准 object 字段 + if !strings.Contains(body, `"object":"chat.completion.chunk"`) { + t.Errorf("frame not normalized: %q", body) + } + }) + } +} + +// TestNormalizeUsageCacheAliasesMirrorsNestedHit / PreservesZeroResult 自 +// usage_test.go 迁入(PR #57 原新文件按仓库规则不收,核心断言保留在此—— +// normalize 钩子就挂在 sse.go 的 Aggregate/normalizeFrame 两个出口)。 +func TestNormalizeUsageCacheAliasesMirrorsNestedHit(t *testing.T) { + usage := map[string]any{ + "prompt_tokens": 21041.0, + "completion_tokens": 8.0, + "total_tokens": 21049.0, + "cache_read_input_tokens": 0.0, + "cached_tokens": 0.0, + "prompt_cache_hit_tokens": 0.0, + "prompt_cache_miss_tokens": 177.0, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 20864.0, + }, + } + + got := normalizeUsageCacheAliases(usage) + + for _, key := range []string{ + "cache_read_input_tokens", + "cached_tokens", + "prompt_cache_hit_tokens", + } { + if got[key] != 20864.0 { + t.Fatalf("%s=%v want 20864", key, got[key]) + } + } + details := got["prompt_tokens_details"].(map[string]any) + if details["cached_tokens"] != 20864.0 { + t.Fatalf("prompt_tokens_details.cached_tokens=%v want 20864", details["cached_tokens"]) + } +} + +func TestNormalizeUsageCacheAliasesPreservesZeroResult(t *testing.T) { + usage := map[string]any{ + "prompt_tokens": 35.0, + "completion_tokens": 2.0, + "total_tokens": 37.0, + "cache_read_input_tokens": 0.0, + "prompt_cache_hit_tokens": 0.0, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 0.0, + }, + } + + got := normalizeUsageCacheAliases(usage) + + details := got["prompt_tokens_details"].(map[string]any) + if details["cached_tokens"] != 0.0 { + t.Fatalf("prompt_tokens_details.cached_tokens=%v want 0", details["cached_tokens"]) + } +} + +// TestStreamHintErrorFrameObserver 上游 error 帧必须旁路通知观察者(账号处置挂载点), +// 且透传字节不变——error 帧原文(code/msg/requestId)照常到达客户端。 +// 正常数据帧不得触发观察者。移植自 OkRoromori 分支的 WithErrorFrameObserver。 +func TestStreamHintErrorFrameObserver(t *testing.T) { + const errPayload = `{"error":{"code":6004,"message":"模型限流,将在 2026-09-27 01:00:00 重置"}}` + raw := "data: {\"id\":\"x1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n" + + "data: " + errPayload + "\n\n" + + "data: [DONE]\n\n" + + var observed []string + rec := httptest.NewRecorder() + if err := StreamHint(rec, strings.NewReader(raw), nil, WithErrorFrameObserver(func(payload string) { + observed = append(observed, payload) + })); err != nil { + t.Fatal(err) + } + + if len(observed) != 1 || observed[0] != errPayload { + t.Fatalf("观察者回调 = %v want [%s]", observed, errPayload) + } + // 透传字节不变:error 帧原文必须仍在响应里(error-passthrough 语义)。 + if !strings.Contains(rec.Body.String(), "6004") { + t.Fatalf("error 帧原文未透传: %s", rec.Body.String()) + } + + // 正常流(无 error 帧):观察者零回调。 + var normalObserved int + raw2 := "data: {\"id\":\"x2\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n" + + "data: [DONE]\n\n" + rec2 := httptest.NewRecorder() + if err := StreamHint(rec2, strings.NewReader(raw2), nil, WithErrorFrameObserver(func(payload string) { + normalObserved++ + })); err != nil { + t.Fatal(err) + } + if normalObserved != 0 { + t.Fatalf("正常流触发了观察者 %d 次,want 0", normalObserved) + } +} + +func TestUserResourceDetailedWithExpirySnapshot(t *testing.T) { + now := time.Now().In(softRateResetLoc) + soon := now.Add(24 * time.Hour).Truncate(time.Second) + later := now.Add(10 * 24 * time.Hour).Truncate(time.Second) + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + + `{"PackageName":"soon-a","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + + `{"PackageName":"soon-b","CycleCapacitySize":15,"CycleCapacityRemain":15,"CycleCapacityUsed":0,"CycleEndTime":"` + soon.Format(packageEndLayout) + `"},` + + `{"PackageName":"later","CycleCapacitySize":20,"CycleCapacityRemain":20,"CycleCapacityUsed":0,"CycleEndTime":"` + later.Format(packageEndLayout) + `"},` + + `{"PackageName":"unknown","CycleCapacitySize":5,"CycleCapacityRemain":5,"CycleCapacityUsed":0}` + + `]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, payload), nil + }) + + remain, total, expiring, earliestAt, earliestRemaining, err := c.UserResourceDetailedWithExpiry( + &auth.Auth{AccessToken: "at", UID: "u1"}, 48*time.Hour, + ) + if err != nil { + t.Fatalf("resource: %v", err) + } + if remain != 50 || total != 50 || expiring != 25 { + t.Fatalf("remain/total/expiring=%d/%d/%d want 50/50/25", remain, total, expiring) + } + if earliestRemaining != 25 || !earliestAt.Equal(soon) { + t.Fatalf("earliest=%v/%d want %v/25", earliestAt, earliestRemaining, soon) + } +} +func TestCreditPackagesExpiryTimestamp(t *testing.T) { + end := time.Now().In(softRateResetLoc).Add(7 * 24 * time.Hour).Truncate(time.Second) + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[`+ + `{"PackageName":"gift","CycleCapacitySize":100,"CycleCapacityRemain":80,"CycleCapacityUsed":20,"CycleEndTime":"`+ + end.Format(packageEndLayout)+`"},`+ + `{"PackageName":"unknown","CycleCapacitySize":10,"CycleCapacityRemain":10,"CycleCapacityUsed":0}`+ + `]}}}}`), nil + }) + packs, remain, size, err := c.CreditPackages(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("packages: %v", err) + } + if remain != 90 || size != 110 { + t.Fatalf("remain/size=%d/%d want 90/110", remain, size) + } + var found bool + for _, p := range packs { + if p.Name == "gift" { + found = p.ExpiresAt == end.UnixMilli() + } + } + if !found { + t.Fatalf("gift pack missing Unix-ms expiry: %+v", packs) + } +} diff --git a/internal/upstream/streak.go b/internal/upstream/streak.go index 4f550d3bbc1bc91614fbcf18974643c9be820f98..d647bb2d003693f4b036ce39bfe4c1bad0181b6f 100644 --- a/internal/upstream/streak.go +++ b/internal/upstream/streak.go @@ -1,106 +1,106 @@ -// streak.go 成长中心连登兑换 + 抽奖 API(2026-09-12 成长中心 bundle 逆向 + 页面实测)。 -// -// 机制:连登档位(7d/14d/28d)按**连续登录天数**解锁;兑换(POST /activity/growth/redeem) -// 发 credit/energy/补签卡/**抽奖次数**;抽奖(POST /activity/growth/lottery/draw)每次消耗 -// 1 次 chances。未解锁兑换返回 HTTP 403「连续登录天数不足」。 -// client_token 为前端生成的幂等令牌(randomUUID)。 -package upstream - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - - "fmt" - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" - "net/http" -) - -// streakRedeemPath / lottery 路径(growth 域,growthJSON 走 www.workbuddy.cn)。 -const ( - streakRedeemPath = "/activity/growth/redeem" - lotterySummaryPath = "/activity/growth/lottery/summary" - lotteryDrawPath = "/activity/growth/lottery/draw" -) - -// clientToken 幂等令牌(前端 randomUUID 同款语义)。 -func clientToken() string { - b := make([]byte, 16) - _, _ = rand.Read(b) - return fmt.Sprintf("%s-%s-%s-%s-%s", - hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), - hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16])) -} - -// StreakFull 连登完整状态(GET /activity/growth/streak)。 -type StreakFull struct { - Streak struct { - Days int `json:"days"` - MonthTotalDays int `json:"month_total_days"` - NextTier string `json:"next_tier"` - NextTierRemaining int `json:"next_tier_remaining"` - } `json:"streak"` - MakeupCards struct { - Balance int `json:"balance"` - Max int `json:"max"` - } `json:"makeup_cards"` - RedemptionStatus struct { - Tier7dStatus string `json:"tier_7d_status"` - Tier14dStatus string `json:"tier_14d_status"` - Tier28dStatus string `json:"tier_28d_status"` - RemainingDays int `json:"remaining_days"` - Tiers []struct { - Tier string `json:"tier"` - Days int `json:"days"` - Credit int `json:"credit"` - Energy int `json:"energy"` - Cards int `json:"cards"` - Chances int `json:"chances"` - } `json:"tiers"` - } `json:"redemption_status"` -} - -// GrowthStreakFull 拉取连登完整状态。 -func (c *Client) GrowthStreakFull(a *auth.Auth) (*StreakFull, error) { - data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) - if err != nil { - return nil, err - } - out := &StreakFull{} - if err := json.Unmarshal(data, out); err != nil { - return nil, err - } - return out, nil -} - -// GrowthRedeemTier 兑换连登档位(tier: "7d"|"14d"|"28d")。 -// 未解锁返回 *Error(HTTP 403「连续登录天数不足」),调用方按 locked 状态跳过即可。 -func (c *Client) GrowthRedeemTier(a *auth.Auth, tier string) error { - _, err := c.growthJSON(a, http.MethodPost, streakRedeemPath, - map[string]any{"tier": tier, "client_token": clientToken()}) - return err -} - -// LotteryChances 当前抽奖次数(GET /activity/growth/lottery/summary)。 -func (c *Client) LotteryChances(a *auth.Auth) (int, error) { - data, err := c.growthJSON(a, http.MethodGet, lotterySummaryPath, nil) - if err != nil { - return 0, err - } - var resp struct { - Chances int `json:"chances"` - Module struct { - Enabled bool `json:"enabled"` - } `json:"module"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return 0, err - } - return resp.Chances, nil -} - -// LotteryDraw 抽奖一次,返回原始奖品载荷(prize 字段形状由活动期决定,透传给调用方)。 -func (c *Client) LotteryDraw(a *auth.Auth) (json.RawMessage, error) { - return c.growthJSON(a, http.MethodPost, lotteryDrawPath, - map[string]any{"client_token": clientToken()}) -} +// streak.go 成长中心连登兑换 + 抽奖 API(2026-09-12 成长中心 bundle 逆向 + 页面实测)。 +// +// 机制:连登档位(7d/14d/28d)按**连续登录天数**解锁;兑换(POST /activity/growth/redeem) +// 发 credit/energy/补签卡/**抽奖次数**;抽奖(POST /activity/growth/lottery/draw)每次消耗 +// 1 次 chances。未解锁兑换返回 HTTP 403「连续登录天数不足」。 +// client_token 为前端生成的幂等令牌(randomUUID)。 +package upstream + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + + "fmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "net/http" +) + +// streakRedeemPath / lottery 路径(growth 域,growthJSON 走 www.workbuddy.cn)。 +const ( + streakRedeemPath = "/activity/growth/redeem" + lotterySummaryPath = "/activity/growth/lottery/summary" + lotteryDrawPath = "/activity/growth/lottery/draw" +) + +// clientToken 幂等令牌(前端 randomUUID 同款语义)。 +func clientToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return fmt.Sprintf("%s-%s-%s-%s-%s", + hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), + hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16])) +} + +// StreakFull 连登完整状态(GET /activity/growth/streak)。 +type StreakFull struct { + Streak struct { + Days int `json:"days"` + MonthTotalDays int `json:"month_total_days"` + NextTier string `json:"next_tier"` + NextTierRemaining int `json:"next_tier_remaining"` + } `json:"streak"` + MakeupCards struct { + Balance int `json:"balance"` + Max int `json:"max"` + } `json:"makeup_cards"` + RedemptionStatus struct { + Tier7dStatus string `json:"tier_7d_status"` + Tier14dStatus string `json:"tier_14d_status"` + Tier28dStatus string `json:"tier_28d_status"` + RemainingDays int `json:"remaining_days"` + Tiers []struct { + Tier string `json:"tier"` + Days int `json:"days"` + Credit int `json:"credit"` + Energy int `json:"energy"` + Cards int `json:"cards"` + Chances int `json:"chances"` + } `json:"tiers"` + } `json:"redemption_status"` +} + +// GrowthStreakFull 拉取连登完整状态。 +func (c *Client) GrowthStreakFull(a *auth.Auth) (*StreakFull, error) { + data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) + if err != nil { + return nil, err + } + out := &StreakFull{} + if err := json.Unmarshal(data, out); err != nil { + return nil, err + } + return out, nil +} + +// GrowthRedeemTier 兑换连登档位(tier: "7d"|"14d"|"28d")。 +// 未解锁返回 *Error(HTTP 403「连续登录天数不足」),调用方按 locked 状态跳过即可。 +func (c *Client) GrowthRedeemTier(a *auth.Auth, tier string) error { + _, err := c.growthJSON(a, http.MethodPost, streakRedeemPath, + map[string]any{"tier": tier, "client_token": clientToken()}) + return err +} + +// LotteryChances 当前抽奖次数(GET /activity/growth/lottery/summary)。 +func (c *Client) LotteryChances(a *auth.Auth) (int, error) { + data, err := c.growthJSON(a, http.MethodGet, lotterySummaryPath, nil) + if err != nil { + return 0, err + } + var resp struct { + Chances int `json:"chances"` + Module struct { + Enabled bool `json:"enabled"` + } `json:"module"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, err + } + return resp.Chances, nil +} + +// LotteryDraw 抽奖一次,返回原始奖品载荷(prize 字段形状由活动期决定,透传给调用方)。 +func (c *Client) LotteryDraw(a *auth.Auth) (json.RawMessage, error) { + return c.growthJSON(a, http.MethodPost, lotteryDrawPath, + map[string]any{"client_token": clientToken()}) +} diff --git a/internal/upstream/streak_test.go b/internal/upstream/streak_test.go index dc76fc7164f7253db3838122ea74d37e4ff5b874..c6f7c0697795a6e7af8744052235be8b55128020 100644 --- a/internal/upstream/streak_test.go +++ b/internal/upstream/streak_test.go @@ -1,77 +1,77 @@ -package upstream - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// TestGrowthStreakParsesDays 解析 data.streak.days(probe_active.py 同口径)。 -func TestGrowthStreakParsesDays(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/activity/growth/streak" { - t.Errorf("path=%s want /activity/growth/streak", r.URL.Path) - } - if r.Method != http.MethodGet { - t.Errorf("method=%s want GET", r.Method) - } - w.Write([]byte(`{"code":0,"msg":"ok","data":{"streak":{"days":3,"total_rewards":1}}}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} - days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil { - t.Fatalf("GrowthStreak: %v", err) - } - if days != 3 { - t.Errorf("days=%d want 3", days) - } -} - -// TestGrowthStreakDefaultZero 缺 streak/days 字段 → 0(days==0 即自检告警信号)。 -func TestGrowthStreakDefaultZero(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"code":0,"data":{}}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} - days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil { - t.Fatalf("GrowthStreak: %v", err) - } - if days != 0 { - t.Errorf("days=%d want 0(缺字段零值)", days) - } -} - -// TestGrowthStreakServerError HTTP 非 2xx → *Error。 -func TestGrowthStreakServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - w.Write([]byte(`boom`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} - if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { - t.Fatal("want error on 500") - } -} - -// TestGrowthStreakBusinessCode 业务 code 非 0 → 错误(非 2xx 之外的静默失败也要能被观测)。 -func TestGrowthStreakBusinessCode(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - w.Write([]byte(`{"code":12000,"msg":"internal"}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} - if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { - t.Fatal("want error on non-zero business code") - } -} +package upstream + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestGrowthStreakParsesDays 解析 data.streak.days(probe_active.py 同口径)。 +func TestGrowthStreakParsesDays(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/activity/growth/streak" { + t.Errorf("path=%s want /activity/growth/streak", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("method=%s want GET", r.Method) + } + w.Write([]byte(`{"code":0,"msg":"ok","data":{"streak":{"days":3,"total_rewards":1}}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GrowthStreak: %v", err) + } + if days != 3 { + t.Errorf("days=%d want 3", days) + } +} + +// TestGrowthStreakDefaultZero 缺 streak/days 字段 → 0(days==0 即自检告警信号)。 +func TestGrowthStreakDefaultZero(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"data":{}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + days, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GrowthStreak: %v", err) + } + if days != 0 { + t.Errorf("days=%d want 0(缺字段零值)", days) + } +} + +// TestGrowthStreakServerError HTTP 非 2xx → *Error。 +func TestGrowthStreakServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on 500") + } +} + +// TestGrowthStreakBusinessCode 业务 code 非 0 → 错误(非 2xx 之外的静默失败也要能被观测)。 +func TestGrowthStreakBusinessCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"code":12000,"msg":"internal"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GrowthStreak(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on non-zero business code") + } +} diff --git a/internal/upstream/tasks.go b/internal/upstream/tasks.go index b48c9755bcd0b0e7c2767fab42b2b3803c9a2e5e..4a0910b0e5972b368ef8d5bfe5725d6196bb2ae0 100644 --- a/internal/upstream/tasks.go +++ b/internal/upstream/tasks.go @@ -1,272 +1,272 @@ -// tasks.go growth 域「任务」接口:列表查询 / 接受 / 领取奖励。 -// -// 来源:上游 scripts/task_common.py 实测口径(list_tasks/accept_tasks/claim_reward), -// 集成进主程序后不再需要外部脚本。 -// -// 端点(chatBase,BillingHeaders): -// - GET /v2/activity/growth/tasks 全量任务列表(含 progress/accept_status) -// - POST /v2/activity/growth/tasks/accept {"task_codes":[...]} not_accepted → accepted -// - POST /v2/activity/growth/tasks/reward/claim {"task_code":"..."} 完成态领奖 -// -// 语义要点: -// - accept 是"报名",不产生进度;进度由服务端行为事件点亮(如 chat_request_send 上报、 -// 真实对话),故 accept 后可幂等重放。 -// - claim 仅在 progress 达标后可领;重复领返回业务错误(安全,不需要前置状态判断)。 -package upstream - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/url" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// growth 域任务路径(与 scripts/task_common.py 对齐)。 -const ( - tasksListPath = "/v2/activity/growth/tasks" - tasksAcceptPath = "/v2/activity/growth/tasks/accept" -) - -// mpPlatform 小程序口径头值:小程序限定任务(Sequential_Tasks_1 / school_season) -// 的列表下发、accept、claim 全链路要求 X-Client-Platform: miniprogram。 -const mpPlatform = "miniprogram" - -// Task 单个任务的对外视图(字段名与上游 JSON 对齐,多余字段不透出)。 -type Task struct { - TaskCode string `json:"task_code"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` // 操作指引(含跳转说明) - TaskDesc string `json:"task_desc,omitempty"` // 达成条件简述 - Credit int64 `json:"credit,omitempty"` // 奖励积分(上游 reward_credit) - Energy int64 `json:"energy,omitempty"` // 奖励能量(上游 reward_energy) - HasReward bool `json:"has_reward,omitempty"` // 是否带奖励 - RewardBuddy bool `json:"reward_buddy,omitempty"` - TaskType string `json:"task_type,omitempty"` // single(一次性)/ 累计型 - Tag string `json:"tag,omitempty"` // 端标记(PC 等) - JumpURL string `json:"jump_url,omitempty"` // 客户端跳转协议(workbuddy://...) - Locked bool `json:"locked,omitempty"` // 上游标记未解锁 - Target int64 `json:"target"` // 目标次数(恒输出:0 是有效进度值) - Current int64 `json:"current"` // 当前进度(恒输出:0 是有效进度值) - AcceptStatus string `json:"accept_status,omitempty"` - Status string `json:"status,omitempty"` // 上游任务状态(complete 等) - Claimable bool `json:"claimable,omitempty"` // 进度达标且未领取(本地推算) - Claimed bool `json:"claimed,omitempty"` // 已领取(accept_status == claimed) -} - -// ListTasks 拉取全量任务列表(默认口径,无端标记头)。 -// 响应形如 data.tasks[],元素字段随任务类型变化(progress 可能是 {current,target} 或平铺), -// 这里做宽松解析:两种形状都尝试。 -func (c *Client) ListTasks(a *auth.Auth) ([]Task, error) { - data, err := c.growthJSON(a, http.MethodGet, tasksListPath, nil) - if err != nil { - return nil, err - } - return parseGrowthTasks(data) -} - -// ListTasksMP 拉取小程序口径的任务列表(X-Client-Platform: miniprogram)。 -// 小程序限定任务(Sequential_Tasks_1「小程序首对话」/ school_season「校园日」等) -// 仅在该口径下发——默认列表不出现,accept/claim 同样要求该头(缺头 accept 返回 -// task not found,上游 task_runner 实测)。**实测 mp 列表是默认口径的超集** -// (含 RichMeow/Model_chat 等常规任务 + wb_wechat_oa_subscribe_task 等 mp 专属), -// 合并时调用方须按 task_code 去重。 -func (c *Client) ListTasksMP(a *auth.Auth) ([]Task, error) { - data, err := c.growthJSONMP(a, http.MethodGet, tasksListPath, nil) - if err != nil { - return nil, err - } - return parseGrowthTasks(data) -} - -// growthJSONMP 发 growth 域请求(小程序口径:叠加 X-Client-Platform: miniprogram) -// 并解信封。语义同 growthJSON。 -func (c *Client) growthJSONMP(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { - var rdr io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) - if err != nil { - return nil, err - } - c.BillingHeaders(req, a) - req.Header.Set("X-Client-Platform", mpPlatform) - return c.doJSON(req) -} - -// AcceptTasksMP 接受小程序限定任务(mp 头;缺头实测 task not found)。 -// 幂等语义同 AcceptTasks。 -func (c *Client) AcceptTasksMP(a *auth.Auth, taskCodes []string) error { - _, err := c.growthJSONMP(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) - return err -} - -// ClaimRewardMP 领取小程序限定任务奖励:chat 域 /activity/growth/tasks/{code}/claim -// + mp 头(上游 task_runner claim_one(mp=True) 同款);chat 域 400 时降级 Web 域 -// 领奖端点(ClaimReward,x-client-platform: web 形态)。返回 (credit, energy, err)。 -func (c *Client) ClaimRewardMP(a *auth.Auth, taskCode string) (credit, energy int64, err error) { - req, err := http.NewRequest(http.MethodPost, - c.chatBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) - if err != nil { - return 0, 0, err - } - c.BillingHeaders(req, a) - req.Header.Set("X-Client-Platform", mpPlatform) - data, err := c.doJSON(req) - if err != nil { - // chat 域对该路径 400(部分任务/租户形态)→ Web 域降级(已实测可领)。 - if ue, ok := err.(*Error); ok && ue.Status == http.StatusBadRequest { - return c.ClaimReward(a, taskCode) - } - return 0, 0, err - } - return parseClaimReward(data) -} - -// parseClaimReward 解析领奖响应 data:{"already_claimed":bool,"credit":n,"energy":n}。 -func parseClaimReward(data json.RawMessage) (credit, energy int64, err error) { - var resp struct { - AlreadyClaimed bool `json:"already_claimed"` - Credit int64 `json:"credit"` - Energy int64 `json:"energy"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return 0, 0, err - } - return resp.Credit, resp.Energy, nil -} - -// parseGrowthTasks 解析 growth 任务列表 data.tasks[](默认与 mp 口径共用)。 -func parseGrowthTasks(data json.RawMessage) ([]Task, error) { - var resp struct { - Tasks []struct { - TaskCode string `json:"task_code"` - Title string `json:"title"` - Description string `json:"description"` - TaskDesc string `json:"task_desc"` - RewardCredit int64 `json:"reward_credit"` // 上游实际字段名(reward_ 前缀) - RewardEnergy int64 `json:"reward_energy"` - HasReward bool `json:"has_reward"` - RewardBuddy bool `json:"reward_buddy"` - TaskType string `json:"task_type"` - Tag string `json:"tag"` - JumpURL string `json:"jump_url"` - Locked bool `json:"locked"` - AcceptStatus string `json:"accept_status"` - Status string `json:"status"` - Target int64 `json:"target"` - Current int64 `json:"current"` - Progress json.RawMessage `json:"progress"` - } `json:"tasks"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, err - } - out := make([]Task, 0, len(resp.Tasks)) - for _, t := range resp.Tasks { - cur, tgt := t.Current, t.Target - // progress 可能是 {current,target} 对象(实测口径),覆盖平铺字段。 - if len(t.Progress) > 0 && string(t.Progress) != "null" { - var pr struct { - Current int64 `json:"current"` - Target int64 `json:"target"` - } - if json.Unmarshal(t.Progress, &pr) == nil && (pr.Target > 0 || pr.Current > 0) { - cur, tgt = pr.Current, pr.Target - } - } - claimed := t.AcceptStatus == "claimed" - out = append(out, Task{ - TaskCode: t.TaskCode, - Title: t.Title, - Description: t.Description, - TaskDesc: t.TaskDesc, - Credit: t.RewardCredit, - Energy: t.RewardEnergy, - HasReward: t.HasReward, - RewardBuddy: t.RewardBuddy, - TaskType: t.TaskType, - Tag: t.Tag, - JumpURL: t.JumpURL, - Locked: t.Locked, - Target: tgt, - Current: cur, - AcceptStatus: t.AcceptStatus, - Status: t.Status, - Claimable: !claimed && tgt > 0 && cur >= tgt, - Claimed: claimed, - }) - } - return out, nil -} - -// AcceptTasks 接受任务(幂等:已 accepted 时上游返回成功或业务提示,均不视为致命错误)。 -func (c *Client) AcceptTasks(a *auth.Auth, taskCodes []string) error { - _, err := c.growthJSON(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) - return err -} - -// ClaimReward 领取单个任务奖励。 -// -// 端点来源(实测):Web 成长中心的领奖请求 —— -// -// POST https://www.workbuddy.cn/activity/growth/tasks//claim -// (任务码在**路径**里,无 body;带 x-client-platform: web 头,Bearer 鉴权) -// -// 关键区别:此前误用 CLI 域 copilot.tencent.com 的 -// /v2/activity/growth/tasks/reward/claim(task_code 放 body),该路径**不存在**, -// 一直返回 400 "task not completed",是此前领奖失败的真实原因。 -// 本实现返回 (credit, energy, err):credit/energy 为本次到账奖励(已领取过时为 0)。 -func (c *Client) ClaimReward(a *auth.Auth, taskCode string) (credit, energy int64, err error) { - req, err := http.NewRequest(http.MethodPost, - c.webBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) - if err != nil { - return 0, 0, err - } - // Web 端请求头形状(对照浏览器实际请求):Origin/Referer 指向 workbuddy.cn 成长中心, - // 带 x-client-platform: web 标记来源端。 - req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://www.workbuddy.cn") - req.Header.Set("Referer", "https://www.workbuddy.cn/profile/growth-center") - req.Header.Set("x-client-platform", "web") - if ua := c.userAgent(a); ua != "" { - req.Header.Set("User-Agent", ua) - } - if a.UID != "" { - req.Header.Set("X-User-Id", a.UID) - } - if a.EnterpriseID != "" { - req.Header.Set("X-Enterprise-Id", a.EnterpriseID) - req.Header.Set("X-Tenant-Id", a.EnterpriseID) - } - if d := a.DomainValue(); d != "" { - req.Header.Set("X-Domain", d) - } - - data, err := c.doJSON(req) - if err != nil { - return 0, 0, err - } - // 响应 data:{"already_claimed":bool,"credit":100,"energy":5,...} - var resp struct { - AlreadyClaimed bool `json:"already_claimed"` - Credit int64 `json:"credit"` - Energy int64 `json:"energy"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return 0, 0, err - } - if resp.AlreadyClaimed { - return 0, 0, nil // 幂等:重复领取不算错误,但无新增奖励 - } - return resp.Credit, resp.Energy, nil -} +// tasks.go growth 域「任务」接口:列表查询 / 接受 / 领取奖励。 +// +// 来源:上游 scripts/task_common.py 实测口径(list_tasks/accept_tasks/claim_reward), +// 集成进主程序后不再需要外部脚本。 +// +// 端点(chatBase,BillingHeaders): +// - GET /v2/activity/growth/tasks 全量任务列表(含 progress/accept_status) +// - POST /v2/activity/growth/tasks/accept {"task_codes":[...]} not_accepted → accepted +// - POST /v2/activity/growth/tasks/reward/claim {"task_code":"..."} 完成态领奖 +// +// 语义要点: +// - accept 是"报名",不产生进度;进度由服务端行为事件点亮(如 chat_request_send 上报、 +// 真实对话),故 accept 后可幂等重放。 +// - claim 仅在 progress 达标后可领;重复领返回业务错误(安全,不需要前置状态判断)。 +package upstream + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// growth 域任务路径(与 scripts/task_common.py 对齐)。 +const ( + tasksListPath = "/v2/activity/growth/tasks" + tasksAcceptPath = "/v2/activity/growth/tasks/accept" +) + +// mpPlatform 小程序口径头值:小程序限定任务(Sequential_Tasks_1 / school_season) +// 的列表下发、accept、claim 全链路要求 X-Client-Platform: miniprogram。 +const mpPlatform = "miniprogram" + +// Task 单个任务的对外视图(字段名与上游 JSON 对齐,多余字段不透出)。 +type Task struct { + TaskCode string `json:"task_code"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` // 操作指引(含跳转说明) + TaskDesc string `json:"task_desc,omitempty"` // 达成条件简述 + Credit int64 `json:"credit,omitempty"` // 奖励积分(上游 reward_credit) + Energy int64 `json:"energy,omitempty"` // 奖励能量(上游 reward_energy) + HasReward bool `json:"has_reward,omitempty"` // 是否带奖励 + RewardBuddy bool `json:"reward_buddy,omitempty"` + TaskType string `json:"task_type,omitempty"` // single(一次性)/ 累计型 + Tag string `json:"tag,omitempty"` // 端标记(PC 等) + JumpURL string `json:"jump_url,omitempty"` // 客户端跳转协议(workbuddy://...) + Locked bool `json:"locked,omitempty"` // 上游标记未解锁 + Target int64 `json:"target"` // 目标次数(恒输出:0 是有效进度值) + Current int64 `json:"current"` // 当前进度(恒输出:0 是有效进度值) + AcceptStatus string `json:"accept_status,omitempty"` + Status string `json:"status,omitempty"` // 上游任务状态(complete 等) + Claimable bool `json:"claimable,omitempty"` // 进度达标且未领取(本地推算) + Claimed bool `json:"claimed,omitempty"` // 已领取(accept_status == claimed) +} + +// ListTasks 拉取全量任务列表(默认口径,无端标记头)。 +// 响应形如 data.tasks[],元素字段随任务类型变化(progress 可能是 {current,target} 或平铺), +// 这里做宽松解析:两种形状都尝试。 +func (c *Client) ListTasks(a *auth.Auth) ([]Task, error) { + data, err := c.growthJSON(a, http.MethodGet, tasksListPath, nil) + if err != nil { + return nil, err + } + return parseGrowthTasks(data) +} + +// ListTasksMP 拉取小程序口径的任务列表(X-Client-Platform: miniprogram)。 +// 小程序限定任务(Sequential_Tasks_1「小程序首对话」/ school_season「校园日」等) +// 仅在该口径下发——默认列表不出现,accept/claim 同样要求该头(缺头 accept 返回 +// task not found,上游 task_runner 实测)。**实测 mp 列表是默认口径的超集** +// (含 RichMeow/Model_chat 等常规任务 + wb_wechat_oa_subscribe_task 等 mp 专属), +// 合并时调用方须按 task_code 去重。 +func (c *Client) ListTasksMP(a *auth.Auth) ([]Task, error) { + data, err := c.growthJSONMP(a, http.MethodGet, tasksListPath, nil) + if err != nil { + return nil, err + } + return parseGrowthTasks(data) +} + +// growthJSONMP 发 growth 域请求(小程序口径:叠加 X-Client-Platform: miniprogram) +// 并解信封。语义同 growthJSON。 +func (c *Client) growthJSONMP(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + req.Header.Set("X-Client-Platform", mpPlatform) + return c.doJSON(req) +} + +// AcceptTasksMP 接受小程序限定任务(mp 头;缺头实测 task not found)。 +// 幂等语义同 AcceptTasks。 +func (c *Client) AcceptTasksMP(a *auth.Auth, taskCodes []string) error { + _, err := c.growthJSONMP(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) + return err +} + +// ClaimRewardMP 领取小程序限定任务奖励:chat 域 /activity/growth/tasks/{code}/claim +// + mp 头(上游 task_runner claim_one(mp=True) 同款);chat 域 400 时降级 Web 域 +// 领奖端点(ClaimReward,x-client-platform: web 形态)。返回 (credit, energy, err)。 +func (c *Client) ClaimRewardMP(a *auth.Auth, taskCode string) (credit, energy int64, err error) { + req, err := http.NewRequest(http.MethodPost, + c.chatBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) + if err != nil { + return 0, 0, err + } + c.BillingHeaders(req, a) + req.Header.Set("X-Client-Platform", mpPlatform) + data, err := c.doJSON(req) + if err != nil { + // chat 域对该路径 400(部分任务/租户形态)→ Web 域降级(已实测可领)。 + if ue, ok := err.(*Error); ok && ue.Status == http.StatusBadRequest { + return c.ClaimReward(a, taskCode) + } + return 0, 0, err + } + return parseClaimReward(data) +} + +// parseClaimReward 解析领奖响应 data:{"already_claimed":bool,"credit":n,"energy":n}。 +func parseClaimReward(data json.RawMessage) (credit, energy int64, err error) { + var resp struct { + AlreadyClaimed bool `json:"already_claimed"` + Credit int64 `json:"credit"` + Energy int64 `json:"energy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, 0, err + } + return resp.Credit, resp.Energy, nil +} + +// parseGrowthTasks 解析 growth 任务列表 data.tasks[](默认与 mp 口径共用)。 +func parseGrowthTasks(data json.RawMessage) ([]Task, error) { + var resp struct { + Tasks []struct { + TaskCode string `json:"task_code"` + Title string `json:"title"` + Description string `json:"description"` + TaskDesc string `json:"task_desc"` + RewardCredit int64 `json:"reward_credit"` // 上游实际字段名(reward_ 前缀) + RewardEnergy int64 `json:"reward_energy"` + HasReward bool `json:"has_reward"` + RewardBuddy bool `json:"reward_buddy"` + TaskType string `json:"task_type"` + Tag string `json:"tag"` + JumpURL string `json:"jump_url"` + Locked bool `json:"locked"` + AcceptStatus string `json:"accept_status"` + Status string `json:"status"` + Target int64 `json:"target"` + Current int64 `json:"current"` + Progress json.RawMessage `json:"progress"` + } `json:"tasks"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + out := make([]Task, 0, len(resp.Tasks)) + for _, t := range resp.Tasks { + cur, tgt := t.Current, t.Target + // progress 可能是 {current,target} 对象(实测口径),覆盖平铺字段。 + if len(t.Progress) > 0 && string(t.Progress) != "null" { + var pr struct { + Current int64 `json:"current"` + Target int64 `json:"target"` + } + if json.Unmarshal(t.Progress, &pr) == nil && (pr.Target > 0 || pr.Current > 0) { + cur, tgt = pr.Current, pr.Target + } + } + claimed := t.AcceptStatus == "claimed" + out = append(out, Task{ + TaskCode: t.TaskCode, + Title: t.Title, + Description: t.Description, + TaskDesc: t.TaskDesc, + Credit: t.RewardCredit, + Energy: t.RewardEnergy, + HasReward: t.HasReward, + RewardBuddy: t.RewardBuddy, + TaskType: t.TaskType, + Tag: t.Tag, + JumpURL: t.JumpURL, + Locked: t.Locked, + Target: tgt, + Current: cur, + AcceptStatus: t.AcceptStatus, + Status: t.Status, + Claimable: !claimed && tgt > 0 && cur >= tgt, + Claimed: claimed, + }) + } + return out, nil +} + +// AcceptTasks 接受任务(幂等:已 accepted 时上游返回成功或业务提示,均不视为致命错误)。 +func (c *Client) AcceptTasks(a *auth.Auth, taskCodes []string) error { + _, err := c.growthJSON(a, http.MethodPost, tasksAcceptPath, map[string]any{"task_codes": taskCodes}) + return err +} + +// ClaimReward 领取单个任务奖励。 +// +// 端点来源(实测):Web 成长中心的领奖请求 —— +// +// POST https://www.workbuddy.cn/activity/growth/tasks//claim +// (任务码在**路径**里,无 body;带 x-client-platform: web 头,Bearer 鉴权) +// +// 关键区别:此前误用 CLI 域 copilot.tencent.com 的 +// /v2/activity/growth/tasks/reward/claim(task_code 放 body),该路径**不存在**, +// 一直返回 400 "task not completed",是此前领奖失败的真实原因。 +// 本实现返回 (credit, energy, err):credit/energy 为本次到账奖励(已领取过时为 0)。 +func (c *Client) ClaimReward(a *auth.Auth, taskCode string) (credit, energy int64, err error) { + req, err := http.NewRequest(http.MethodPost, + c.webBase(a)+"/activity/growth/tasks/"+url.PathEscape(taskCode)+"/claim", nil) + if err != nil { + return 0, 0, err + } + // Web 端请求头形状(对照浏览器实际请求):Origin/Referer 指向 workbuddy.cn 成长中心, + // 带 x-client-platform: web 标记来源端。 + req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://www.workbuddy.cn") + req.Header.Set("Referer", "https://www.workbuddy.cn/profile/growth-center") + req.Header.Set("x-client-platform", "web") + if ua := c.userAgent(a); ua != "" { + req.Header.Set("User-Agent", ua) + } + if a.UID != "" { + req.Header.Set("X-User-Id", a.UID) + } + if a.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", a.EnterpriseID) + req.Header.Set("X-Tenant-Id", a.EnterpriseID) + } + if d := a.DomainValue(); d != "" { + req.Header.Set("X-Domain", d) + } + + data, err := c.doJSON(req) + if err != nil { + return 0, 0, err + } + // 响应 data:{"already_claimed":bool,"credit":100,"energy":5,...} + var resp struct { + AlreadyClaimed bool `json:"already_claimed"` + Credit int64 `json:"credit"` + Energy int64 `json:"energy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, 0, err + } + if resp.AlreadyClaimed { + return 0, 0, nil // 幂等:重复领取不算错误,但无新增奖励 + } + return resp.Credit, resp.Energy, nil +} diff --git a/internal/upstream/tasks_test.go b/internal/upstream/tasks_test.go index 456d57e1d876e4fb3daa9d8a69b3a85a1d962263..02d22f676134ed127029f06176722fb945e0c8f8 100644 --- a/internal/upstream/tasks_test.go +++ b/internal/upstream/tasks_test.go @@ -1,85 +1,85 @@ -package upstream - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// TestClaimRewardWebEndpoint 领奖走 Web 域(workbuddy.cn)、任务码在路径里、无 body。 -// 这是与 CLI 域(copilot.tencent.com/v2/.../reward/claim,task_code 在 body)的关键区别—— -// 后者路径不存在,曾导致长期 400 "task not completed" 误判为"上游不支持领取"。 -func TestClaimRewardWebEndpoint(t *testing.T) { - var gotPath, gotMethod, gotBody string - var gotPlatform, gotReferer string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath, gotMethod = r.URL.Path, r.Method - gotPlatform = r.Header.Get("x-client-platform") - gotReferer = r.Header.Get("Referer") - buf := make([]byte, 64) - n, _ := r.Body.Read(buf) - gotBody = string(buf[:n]) - w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":false,"credit":100,"energy":5}}`)) - })) - defer srv.Close() - - c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL} - a := &auth.Auth{AccessToken: "at", UID: "u1"} - - credit, energy, err := c.ClaimReward(a, "Model_chat_GLM5.2") - if err != nil { - t.Fatalf("claim: %v", err) - } - if credit != 100 || energy != 5 { - t.Errorf("credit/energy = %d/%d, want 100/5", credit, energy) - } - if gotMethod != http.MethodPost { - t.Errorf("method=%s want POST", gotMethod) - } - if want := "/activity/growth/tasks/Model_chat_GLM5.2/claim"; gotPath != want { - t.Errorf("path=%q want %q(任务码必须在路径里)", gotPath, want) - } - if gotBody != "" { - t.Errorf("claim 不应携带 body,got %q", gotBody) - } - if gotPlatform != "web" { - t.Errorf("x-client-platform=%q want web", gotPlatform) - } - if !strings.Contains(gotReferer, "workbuddy.cn") { - t.Errorf("Referer=%q 应指向 workbuddy.cn", gotReferer) - } -} - -// TestClaimRewardAlreadyClaimed 重复领取:上游返回 already_claimed=true, -// 本地应视为"无新增奖励但不报错"(幂等语义)。 -func TestClaimRewardAlreadyClaimed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":true}}`)) - })) - defer srv.Close() - c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} - credit, energy, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5") - if err != nil { - t.Fatalf("already_claimed should not error: %v", err) - } - if credit != 0 || energy != 0 { - t.Errorf("already claimed should yield 0/0, got %d/%d", credit, energy) - } -} - -// TestClaimRewardNotCompleted 未达标:上游 400 + task not completed 应作为错误透出。 -func TestClaimRewardNotCompleted(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(400) - json.NewEncoder(w).Encode(map[string]any{"code": 400, "msg": "task not completed"}) - })) - defer srv.Close() - c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} - if _, _, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5"); err == nil { - t.Fatal("want error for not-completed task") - } -} +package upstream + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestClaimRewardWebEndpoint 领奖走 Web 域(workbuddy.cn)、任务码在路径里、无 body。 +// 这是与 CLI 域(copilot.tencent.com/v2/.../reward/claim,task_code 在 body)的关键区别—— +// 后者路径不存在,曾导致长期 400 "task not completed" 误判为"上游不支持领取"。 +func TestClaimRewardWebEndpoint(t *testing.T) { + var gotPath, gotMethod, gotBody string + var gotPlatform, gotReferer string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + gotPlatform = r.Header.Get("x-client-platform") + gotReferer = r.Header.Get("Referer") + buf := make([]byte, 64) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":false,"credit":100,"energy":5}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL} + a := &auth.Auth{AccessToken: "at", UID: "u1"} + + credit, energy, err := c.ClaimReward(a, "Model_chat_GLM5.2") + if err != nil { + t.Fatalf("claim: %v", err) + } + if credit != 100 || energy != 5 { + t.Errorf("credit/energy = %d/%d, want 100/5", credit, energy) + } + if gotMethod != http.MethodPost { + t.Errorf("method=%s want POST", gotMethod) + } + if want := "/activity/growth/tasks/Model_chat_GLM5.2/claim"; gotPath != want { + t.Errorf("path=%q want %q(任务码必须在路径里)", gotPath, want) + } + if gotBody != "" { + t.Errorf("claim 不应携带 body,got %q", gotBody) + } + if gotPlatform != "web" { + t.Errorf("x-client-platform=%q want web", gotPlatform) + } + if !strings.Contains(gotReferer, "workbuddy.cn") { + t.Errorf("Referer=%q 应指向 workbuddy.cn", gotReferer) + } +} + +// TestClaimRewardAlreadyClaimed 重复领取:上游返回 already_claimed=true, +// 本地应视为"无新增奖励但不报错"(幂等语义)。 +func TestClaimRewardAlreadyClaimed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":true}}`)) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} + credit, energy, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5") + if err != nil { + t.Fatalf("already_claimed should not error: %v", err) + } + if credit != 0 || energy != 0 { + t.Errorf("already claimed should yield 0/0, got %d/%d", credit, energy) + } +} + +// TestClaimRewardNotCompleted 未达标:上游 400 + task not completed 应作为错误透出。 +func TestClaimRewardNotCompleted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]any{"code": 400, "msg": "task not completed"}) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), WebBaseCN: srv.URL} + if _, _, err := c.ClaimReward(&auth.Auth{AccessToken: "at", UID: "u1"}, "chat_5"); err == nil { + t.Fatal("want error for not-completed task") + } +} diff --git a/internal/upstream/thinking.go b/internal/upstream/thinking.go index 3d154943138cb09515047c973d31e3c41d5b3c80..5b262b74891cec3852533355c548a9fdf0c305db 100644 --- a/internal/upstream/thinking.go +++ b/internal/upstream/thinking.go @@ -1,204 +1,204 @@ -// thinking.go DeepSeek 思维链开启:出站请求体注入 thinking:{type:"enabled"} + 默认档位。 -// -// 根因(issue #43,Hermes 逆向官方客户端 codebuddy.js 已确认): -// 官方客户端对 deepseek 系模型标记 thinkingFormat:"deepseek" + requiresReasoningContentOnAssistantMessages, -// 发请求时「开思考」必须显式带 thinking:{type:"enabled"},否则上游默认按不思考应答 -// (思维链不返回)。网关 payload 层此前完全不感知该字段,透传请求没有这个开关 -// → 上游不给思维链;glm/kimi 走其他 thinkingFormat(qwen 系 enable_thinking 或默认开)所以正常。 -// -// 打回修复(Hermes #43 验收实测): -// -// thinking.type=enabled 单一字段不足——真实上游 deepseek-v4-flash 对「不带 reasoning_effort」的裸请求 -// 仍然按不思考应答(reasoning_content 长度 0),带 reasoning_effort:high 才有思维链。 -// 逆向 codebuddy.js 证实:isThinkingEnabled = !!(reasoning_summary || reasoning_effort || reasoning?.effort), -// case "deepseek" 的 enabled 分支在实际出站里同时保留 reasoning_effort,官方「开思考」= thinking.type:enabled -// + 某档 effort;默认档来自 reasoning.defaultEffort ?? 兜底 "high"(configure thinking 无来源时 warn fallback to 'high')。 -// -// 行为对齐官方客户端(两路组合): -// - thinking.type 已显式 enabled / disabled → 客户端显式控制,绝不覆盖;disabled 时照抄 case 行为 -// 删 reasoning_effort(snake/camel 双字段)。enabled 但缺 effort → 补默认档(官方 configure 行为)。 -// - 无 thinking / thinking.type 空 / 已有 reasoning_effort → 注入 {type:"enabled"} + 补默认档。 -// - 显式 reasoning_effort 一律不覆盖、不降级(降级交给 payload.go normalizeReasoningEffort)。 -// - 非 deepseek 模型(glm/kimi/qwen 等)→ 零改动。 -package upstream - -import ( - "strings" -) - -// defaultDeepSeekEffort 官方客户端默认档兜底(configure thinking 无来源时 warn fallback to 'high', -// REASONING_SUPPLEMENTS.defaultEffort 亦为 "high")。补入后走 normalizeReasoningEffort 降级管线, -// 模型不支持 high 时自动落到 ≤high 的最高支持档。 -const defaultDeepSeekEffort = "high" - -// lookupDefaultEffort 从 FetchModels 缓存的 defaultEfforts 表按模型名查默认档。 -// nil map 或模型未缓存 → 空串(thinking.go 回退硬编码 high)。 -// 键为模型 ID 原样(与 efforts 缓存对齐:normalizeReasoningEffort 精确匹配 model)。 -func lookupDefaultEffort(defaultEfforts map[string]string, model string) string { - if len(defaultEfforts) == 0 || model == "" { - return "" - } - return defaultEfforts[model] -} - -// isDeepSeekModel 模型名以 deepseek 为前缀(不区分大小写)。 -// 覆盖 deepseek-v4.1-flash / deepseek-v4-pro / deepseek-r1 等变体; -// 前缀匹配对齐官方 thinkingFormat:"deepseek" 的判定口径,避免漏注。 -func isDeepSeekModel(model string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek") -} - -// backfillReasoningContent DeepSeek 多轮一致性:保证每条 assistant 消息带 -// reasoning_content 字段且值为 string——即 requiresReasoningContentOnAssistantMessages -// (官方客户端 matches 规则,issue #165 对齐官方 apply 门控)。 -// -// 门控(对齐官方 ReasoningContentBackfillRule:thinkingEnabled || hasTrace), -// thinkingEnabled 取自注入后请求体的 thinking.type == "enabled"(injectThinking 先行, -// payload.go 管线顺序已保证;本网关对 deepseek 无条件注入 enabled,等价于非 disabled 一律补): -// - 非 deepseek 模型 → 零改动(isDeepSeekModel 闸不动)。 -// - deepseek + enabled(含 L1 注入后)→ 每条 assistant 保证 reasoning_content 是 -// string:已有 string 原样保留(不覆盖);reasoning 是非空 string 且 rc 非 string → -// 复制 reasoning 值;两者皆无 → 补空串 ""。第三方客户端丢推理回传(零痕迹)形态 -// 下官方本就补,网关此前只移植了 hasTrace 半边(issue #165 修复点)。 -// - deepseek + disabled + 无痕迹 → 零改动(官方 thinkingEnabled=false 且 ec=false → 不补)。 -// - deepseek + disabled + 有痕迹 → 照补(官方 hasTrace 半边,双方一致)。 -// -// 归一化对齐官方 "string"!=typeof 语义:reasoning_content 为 null/数字等非 string -// 值时不算「已有」,落补 ""/复制分支(旧代码键存在即跳过,null 会被当「已有」漏补)。 -// hasTrace = 会话内任一消息带非空 reasoning(string)或已有 reasoning_content 字段 -// (比官方仅扫 assistant 的口径宽,只影响 disabled 分支,装饰性差异)。 -func backfillReasoningContent(obj map[string]any) { - model, _ := obj["model"].(string) - if !isDeepSeekModel(model) { - return - } - msgs, ok := obj["messages"].([]any) - if !ok || len(msgs) == 0 { - return - } - // thinkingEnabled 半边:读注入后的 thinking.type(与官方 el.thinkingEnabled 对应)。 - thinkingEnabled := false - if th, ok := obj["thinking"].(map[string]any); ok { - if typ, _ := th["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "enabled") { - thinkingEnabled = true - } - } - // hasTrace 半边:检测是否有任何 reasoning 痕迹(非空 reasoning 或已有 reasoning_content)。 - hasTrace := false - for _, mm := range msgs { - msg, ok := mm.(map[string]any) - if !ok { - continue - } - if r, ok := msg["reasoning"].(string); ok && r != "" { - hasTrace = true - break - } - if _, ok := msg["reasoning_content"]; ok { - hasTrace = true - break - } - } - if !thinkingEnabled && !hasTrace { - return - } - // 第二遍:所有 assistant 消息补/复制 reasoning_content 字段,并镜像保证 - // reasoning 字段存在且非空(issue #165 追评——部分账号/租户对 thinking 形态 - // 校验 len(reasoning)>0:缺失/null/空串 400,空白串 200;官方 CLI 本就给 - // assistant 挂上一轮 reasoning 文本,见 itemsToMessages 的 applyPendingReasoning)。 - // 跳过条件只认 string(官方 "string"!=typeof 才动手):null/数字归一化。 - // - reasoning 已是非空 string → 不动; - // - rc 是非空 string → 镜像写入 rc 值(两字段最终都存在且非空); - // - 两者皆无/皆空 → 补单个空格 " "(上游 len>0 不 trim:空白串过闸、空串 - // 不过——空白串占位有官方 Moonshot 规则 "-" 同款先例,且对模型上下文 - // 无语义影响:该字段是透传校验位非内容消费位)。 - for _, mm := range msgs { - msg, ok := mm.(map[string]any) - if !ok { - continue - } - role, _ := msg["role"].(string) - if role != "assistant" { - continue - } - rc, hasRC := msg["reasoning_content"].(string) - if hasRC { - // rc 已有 string → 不覆盖(原有语义保留)。 - } else if r, ok := msg["reasoning"].(string); ok { - rc = r - msg["reasoning_content"] = rc - } else { - rc = "" - msg["reasoning_content"] = rc - } - // 镜像:reasoning 缺失/null/空串 → 归一化(非空 rc 优先,皆无补 " ")。 - if r, ok := msg["reasoning"].(string); ok && r != "" { - continue // 已非空 → 不覆盖 - } - if rc != "" { - msg["reasoning"] = rc - } else { - msg["reasoning"] = " " - } - } -} - -// injectThinking 按 DeepSeek 思维链开关规则改写请求体。非 deepseek 零改动。 -// -// 核心逻辑(对齐官方客户端): -// - 「开思考」必须 thinking.type=enabled + 有 effort 档位(Hermes #43 打回证据)。 -// - 显式 thinking.type 非空 → 客户端显式控制:enabled 缺 effort 时补默认档; -// disabled 尊重并删 reasoning_effort(snake/camel 双字段)。 -// - 无 thinking / type 空 / 已有 effort → 注入 enabled 并补默认档(已有 effort 不覆盖)。 -// -// defaultEffort 为该模型声明的默认档(来自 FetchModels 缓存 reasoning.defaultEffort); -// 空串时回退硬编码 defaultDeepSeekEffort(向后兼容)。 -func injectThinking(obj map[string]any, defaultEffort string) { - model, _ := obj["model"].(string) - if !isDeepSeekModel(model) { - return - } - th, ok := obj["thinking"].(map[string]any) - typ := "" - if ok { - typ, _ = th["type"].(string) - typ = strings.TrimSpace(typ) - } - // 显式控制分支:type 非空(enabled/disabled 均为明确意图)→ 不改 type。 - if typ != "" { - if strings.EqualFold(typ, "disabled") { - delete(obj, "reasoning_effort") - delete(obj, "reasoningEffort") - return // disabled:关思考且不带任何 effort(照抄客户端 case 行为) - } - ensureDeepSeekEffort(obj, defaultEffort) // 显式 enabled 缺 effort → 补默认档 - return - } - // 无 thinking(或 thinking 非法非对象值)或 thinking 对象 type 缺失/为空: - // 注入 enabled(客户端 case "deepseek" 行为)。有 reasoning_effort 也走此分支 - // (effort 保留给既有降级逻辑,开关照开)。 - if !ok { - obj["thinking"] = map[string]any{"type": "enabled"} - } else { - th["type"] = "enabled" - } - ensureDeepSeekEffort(obj, defaultEffort) -} - -// ensureDeepSeekEffort 缺 effort 档位时补默认档(snake 优先,camel 兜底)。 -// 已有任一 effort → 不覆盖(显式档位不做任何改写,降级交给 normalizeReasoningEffort)。 -// defaultEffort 空串 → 回退 defaultDeepSeekEffort(硬编码 "high")。 -func ensureDeepSeekEffort(obj map[string]any, defaultEffort string) { - _, hasSnake := obj["reasoning_effort"] - if hasSnake { - return - } - _, hasCamel := obj["reasoningEffort"] - if hasCamel { - return - } - if defaultEffort == "" { - defaultEffort = defaultDeepSeekEffort - } - obj["reasoning_effort"] = defaultEffort -} +// thinking.go DeepSeek 思维链开启:出站请求体注入 thinking:{type:"enabled"} + 默认档位。 +// +// 根因(issue #43,Hermes 逆向官方客户端 codebuddy.js 已确认): +// 官方客户端对 deepseek 系模型标记 thinkingFormat:"deepseek" + requiresReasoningContentOnAssistantMessages, +// 发请求时「开思考」必须显式带 thinking:{type:"enabled"},否则上游默认按不思考应答 +// (思维链不返回)。网关 payload 层此前完全不感知该字段,透传请求没有这个开关 +// → 上游不给思维链;glm/kimi 走其他 thinkingFormat(qwen 系 enable_thinking 或默认开)所以正常。 +// +// 打回修复(Hermes #43 验收实测): +// +// thinking.type=enabled 单一字段不足——真实上游 deepseek-v4-flash 对「不带 reasoning_effort」的裸请求 +// 仍然按不思考应答(reasoning_content 长度 0),带 reasoning_effort:high 才有思维链。 +// 逆向 codebuddy.js 证实:isThinkingEnabled = !!(reasoning_summary || reasoning_effort || reasoning?.effort), +// case "deepseek" 的 enabled 分支在实际出站里同时保留 reasoning_effort,官方「开思考」= thinking.type:enabled +// + 某档 effort;默认档来自 reasoning.defaultEffort ?? 兜底 "high"(configure thinking 无来源时 warn fallback to 'high')。 +// +// 行为对齐官方客户端(两路组合): +// - thinking.type 已显式 enabled / disabled → 客户端显式控制,绝不覆盖;disabled 时照抄 case 行为 +// 删 reasoning_effort(snake/camel 双字段)。enabled 但缺 effort → 补默认档(官方 configure 行为)。 +// - 无 thinking / thinking.type 空 / 已有 reasoning_effort → 注入 {type:"enabled"} + 补默认档。 +// - 显式 reasoning_effort 一律不覆盖、不降级(降级交给 payload.go normalizeReasoningEffort)。 +// - 非 deepseek 模型(glm/kimi/qwen 等)→ 零改动。 +package upstream + +import ( + "strings" +) + +// defaultDeepSeekEffort 官方客户端默认档兜底(configure thinking 无来源时 warn fallback to 'high', +// REASONING_SUPPLEMENTS.defaultEffort 亦为 "high")。补入后走 normalizeReasoningEffort 降级管线, +// 模型不支持 high 时自动落到 ≤high 的最高支持档。 +const defaultDeepSeekEffort = "high" + +// lookupDefaultEffort 从 FetchModels 缓存的 defaultEfforts 表按模型名查默认档。 +// nil map 或模型未缓存 → 空串(thinking.go 回退硬编码 high)。 +// 键为模型 ID 原样(与 efforts 缓存对齐:normalizeReasoningEffort 精确匹配 model)。 +func lookupDefaultEffort(defaultEfforts map[string]string, model string) string { + if len(defaultEfforts) == 0 || model == "" { + return "" + } + return defaultEfforts[model] +} + +// isDeepSeekModel 模型名以 deepseek 为前缀(不区分大小写)。 +// 覆盖 deepseek-v4.1-flash / deepseek-v4-pro / deepseek-r1 等变体; +// 前缀匹配对齐官方 thinkingFormat:"deepseek" 的判定口径,避免漏注。 +func isDeepSeekModel(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek") +} + +// backfillReasoningContent DeepSeek 多轮一致性:保证每条 assistant 消息带 +// reasoning_content 字段且值为 string——即 requiresReasoningContentOnAssistantMessages +// (官方客户端 matches 规则,issue #165 对齐官方 apply 门控)。 +// +// 门控(对齐官方 ReasoningContentBackfillRule:thinkingEnabled || hasTrace), +// thinkingEnabled 取自注入后请求体的 thinking.type == "enabled"(injectThinking 先行, +// payload.go 管线顺序已保证;本网关对 deepseek 无条件注入 enabled,等价于非 disabled 一律补): +// - 非 deepseek 模型 → 零改动(isDeepSeekModel 闸不动)。 +// - deepseek + enabled(含 L1 注入后)→ 每条 assistant 保证 reasoning_content 是 +// string:已有 string 原样保留(不覆盖);reasoning 是非空 string 且 rc 非 string → +// 复制 reasoning 值;两者皆无 → 补空串 ""。第三方客户端丢推理回传(零痕迹)形态 +// 下官方本就补,网关此前只移植了 hasTrace 半边(issue #165 修复点)。 +// - deepseek + disabled + 无痕迹 → 零改动(官方 thinkingEnabled=false 且 ec=false → 不补)。 +// - deepseek + disabled + 有痕迹 → 照补(官方 hasTrace 半边,双方一致)。 +// +// 归一化对齐官方 "string"!=typeof 语义:reasoning_content 为 null/数字等非 string +// 值时不算「已有」,落补 ""/复制分支(旧代码键存在即跳过,null 会被当「已有」漏补)。 +// hasTrace = 会话内任一消息带非空 reasoning(string)或已有 reasoning_content 字段 +// (比官方仅扫 assistant 的口径宽,只影响 disabled 分支,装饰性差异)。 +func backfillReasoningContent(obj map[string]any) { + model, _ := obj["model"].(string) + if !isDeepSeekModel(model) { + return + } + msgs, ok := obj["messages"].([]any) + if !ok || len(msgs) == 0 { + return + } + // thinkingEnabled 半边:读注入后的 thinking.type(与官方 el.thinkingEnabled 对应)。 + thinkingEnabled := false + if th, ok := obj["thinking"].(map[string]any); ok { + if typ, _ := th["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "enabled") { + thinkingEnabled = true + } + } + // hasTrace 半边:检测是否有任何 reasoning 痕迹(非空 reasoning 或已有 reasoning_content)。 + hasTrace := false + for _, mm := range msgs { + msg, ok := mm.(map[string]any) + if !ok { + continue + } + if r, ok := msg["reasoning"].(string); ok && r != "" { + hasTrace = true + break + } + if _, ok := msg["reasoning_content"]; ok { + hasTrace = true + break + } + } + if !thinkingEnabled && !hasTrace { + return + } + // 第二遍:所有 assistant 消息补/复制 reasoning_content 字段,并镜像保证 + // reasoning 字段存在且非空(issue #165 追评——部分账号/租户对 thinking 形态 + // 校验 len(reasoning)>0:缺失/null/空串 400,空白串 200;官方 CLI 本就给 + // assistant 挂上一轮 reasoning 文本,见 itemsToMessages 的 applyPendingReasoning)。 + // 跳过条件只认 string(官方 "string"!=typeof 才动手):null/数字归一化。 + // - reasoning 已是非空 string → 不动; + // - rc 是非空 string → 镜像写入 rc 值(两字段最终都存在且非空); + // - 两者皆无/皆空 → 补单个空格 " "(上游 len>0 不 trim:空白串过闸、空串 + // 不过——空白串占位有官方 Moonshot 规则 "-" 同款先例,且对模型上下文 + // 无语义影响:该字段是透传校验位非内容消费位)。 + for _, mm := range msgs { + msg, ok := mm.(map[string]any) + if !ok { + continue + } + role, _ := msg["role"].(string) + if role != "assistant" { + continue + } + rc, hasRC := msg["reasoning_content"].(string) + if hasRC { + // rc 已有 string → 不覆盖(原有语义保留)。 + } else if r, ok := msg["reasoning"].(string); ok { + rc = r + msg["reasoning_content"] = rc + } else { + rc = "" + msg["reasoning_content"] = rc + } + // 镜像:reasoning 缺失/null/空串 → 归一化(非空 rc 优先,皆无补 " ")。 + if r, ok := msg["reasoning"].(string); ok && r != "" { + continue // 已非空 → 不覆盖 + } + if rc != "" { + msg["reasoning"] = rc + } else { + msg["reasoning"] = " " + } + } +} + +// injectThinking 按 DeepSeek 思维链开关规则改写请求体。非 deepseek 零改动。 +// +// 核心逻辑(对齐官方客户端): +// - 「开思考」必须 thinking.type=enabled + 有 effort 档位(Hermes #43 打回证据)。 +// - 显式 thinking.type 非空 → 客户端显式控制:enabled 缺 effort 时补默认档; +// disabled 尊重并删 reasoning_effort(snake/camel 双字段)。 +// - 无 thinking / type 空 / 已有 effort → 注入 enabled 并补默认档(已有 effort 不覆盖)。 +// +// defaultEffort 为该模型声明的默认档(来自 FetchModels 缓存 reasoning.defaultEffort); +// 空串时回退硬编码 defaultDeepSeekEffort(向后兼容)。 +func injectThinking(obj map[string]any, defaultEffort string) { + model, _ := obj["model"].(string) + if !isDeepSeekModel(model) { + return + } + th, ok := obj["thinking"].(map[string]any) + typ := "" + if ok { + typ, _ = th["type"].(string) + typ = strings.TrimSpace(typ) + } + // 显式控制分支:type 非空(enabled/disabled 均为明确意图)→ 不改 type。 + if typ != "" { + if strings.EqualFold(typ, "disabled") { + delete(obj, "reasoning_effort") + delete(obj, "reasoningEffort") + return // disabled:关思考且不带任何 effort(照抄客户端 case 行为) + } + ensureDeepSeekEffort(obj, defaultEffort) // 显式 enabled 缺 effort → 补默认档 + return + } + // 无 thinking(或 thinking 非法非对象值)或 thinking 对象 type 缺失/为空: + // 注入 enabled(客户端 case "deepseek" 行为)。有 reasoning_effort 也走此分支 + // (effort 保留给既有降级逻辑,开关照开)。 + if !ok { + obj["thinking"] = map[string]any{"type": "enabled"} + } else { + th["type"] = "enabled" + } + ensureDeepSeekEffort(obj, defaultEffort) +} + +// ensureDeepSeekEffort 缺 effort 档位时补默认档(snake 优先,camel 兜底)。 +// 已有任一 effort → 不覆盖(显式档位不做任何改写,降级交给 normalizeReasoningEffort)。 +// defaultEffort 空串 → 回退 defaultDeepSeekEffort(硬编码 "high")。 +func ensureDeepSeekEffort(obj map[string]any, defaultEffort string) { + _, hasSnake := obj["reasoning_effort"] + if hasSnake { + return + } + _, hasCamel := obj["reasoningEffort"] + if hasCamel { + return + } + if defaultEffort == "" { + defaultEffort = defaultDeepSeekEffort + } + obj["reasoning_effort"] = defaultEffort +} diff --git a/internal/upstream/thinking_test.go b/internal/upstream/thinking_test.go index e2157df3addc166da2727921745b7331e9e91321..da1dffd1641ceac028091843a914738f8d2a8644 100644 --- a/internal/upstream/thinking_test.go +++ b/internal/upstream/thinking_test.go @@ -1,305 +1,305 @@ -package upstream - -import ( - "encoding/json" - "strings" - "testing" -) - -// getThinkingType 从输出 body 提取 thinking.type(缺字段返回空串 + 是否存在)。 -func getThinkingType(t *testing.T, out []byte) (typ string, present bool) { - t.Helper() - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("unmarshal: %v (out=%s)", err, out) - } - th, ok := m["thinking"].(map[string]any) - if !ok { - return "", false - } - s, ok := th["type"].(string) - if !ok { - return "", true - } - return s, true -} - -// TestInjectThinkingDeepSeekEnabled 开思考开关注入:deepseek 系模型请求体不带 -// thinking 时必须注入 {type:"enabled"},否则上游默认按不思考应答(思维链不显示)。 -func TestInjectThinkingDeepSeekEnabled(t *testing.T) { - cases := []struct { - name string - body string - wantTyp string - }{ - {"deepseek 无 thinking 注入 enabled", - `{"model":"deepseek-v4-flash","messages":[]}`, "enabled"}, - {"DeepSeek 大小写不敏感", - `{"model":"DeepSeek-v4.1-flash","messages":[]}`, "enabled"}, - {"DEEPSEEK 全大写不敏感", - `{"model":"DEEPSEEK-R1","messages":[]}`, "enabled"}, - {"deepseek 带 reasoning_effort 无 thinking 注入 enabled", - `{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`, "enabled"}, - {"deepseek thinking 对象 type 空 补 enabled", - `{"model":"deepseek-v4-flash","thinking":{},"messages":[]}`, "enabled"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - typ, present := getThinkingType(t, out) - if !present { - t.Fatalf("thinking 字段缺失 (out=%s)", out) - } - if typ != c.wantTyp { - t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) - } - }) - } -} - -// TestInjectThinkingDefaultEffort 打回修复主证据:无 effort 裸请求必须同时带 -// thinking.type=enabled 与默认档 reasoning_effort(否则上游 deepseek-v4-flash 不开思维链)。 -// 默认档 = 官方客户端兜底 "high",并带上 supportedEfforts 时经降级管线落到合法档。 -func TestInjectThinkingDefaultEffort(t *testing.T) { - // 裸请求无任何思考参数 → 注入 enabled + reasoning_effort="high"。 - out := PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}]}`), - false, nil) - typ, present := getThinkingType(t, out) - if !present || typ != "enabled" { - t.Fatalf("thinking.type=%q present=%v want enabled (out=%s)", typ, present, out) - } - eff, ok := objFieldString(t, out, "reasoning_effort") - if !ok || eff != "high" { - t.Errorf("reasoning_effort=%q ok=%v want high(默认档)(out=%s)", eff, ok, out) - } - - // 模型只支持 low/high → 默认 high 经降级管线后仍是 high(合法档)。 - out = PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","messages":[]}`), - false, map[string][]string{"deepseek-v4-flash": {"low", "high"}}) - eff, _ = objFieldString(t, out, "reasoning_effort") - if eff != "high" { - t.Errorf("supportedEfforts=[low high] 下默认档=%q want high (out=%s)", eff, out) - } - - // 模型只支持 minimal/low → 默认 high 降级到 low(≤high 的最高支持档)。 - out = PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","messages":[]}`), - false, map[string][]string{"deepseek-v4-flash": {"minimal", "low"}}) - eff, _ = objFieldString(t, out, "reasoning_effort") - if eff != "low" { - t.Errorf("supportedEfforts=[minimal low] 下默认档降级=%q want low (out=%s)", eff, out) - } - - // 显式 enabled + 缺 effort → 同样补默认档(官方 configure thinking 行为)。 - out = PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`), - false, nil) - if eff, _ = objFieldString(t, out, "reasoning_effort"); eff != "high" { - t.Errorf("显式 enabled 缺 effort 应补默认档, got %q (out=%s)", eff, out) - } -} - -// TestInjectThinkingEffortNotOverridden 已有显式 reasoning_effort(snake/camel) -// 一律不覆盖、不降级、不删除;降级由 normalizeReasoningEffort 单独负责。 -func TestInjectThinkingEffortNotOverridden(t *testing.T) { - cases := []struct { - name string - body string - }{ - {"snake effort 原样保留", - `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"medium","messages":[]}`}, - {"camel effort 原样保留", - `{"model":"deepseek-v4-flash","reasoningEffort":"low","messages":[]}`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - typ, _ := getThinkingType(t, out) - if typ != "enabled" { - t.Fatalf("thinking.type=%q want enabled (out=%s)", typ, out) - } - // camel 分支:输入只有 camel,injectThinking 不得另加 snake 默认档。 - if strings.Contains(c.body, "reasoningEffort") { - if _, ok := objFieldString(t, out, "reasoning_effort"); ok { - t.Errorf("已有 reasoningEffort 却新增 reasoning_effort 默认档 (out=%s)", out) - } - } - }) - } - // 显式 effort + 无 thinking → 注入 enabled 但 effort 不覆盖。 - out := PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`), - false, nil) - if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != "medium" { - t.Errorf("显式 effort 被改写 %q (out=%s)", eff, out) - } -} - -// TestInjectThinkingDisabledNoDefaultEffort disabled 保持既有语义: -// thinking.type=disabled 尊重关闭意图;reasoning_effort 删除;不得再补默认档。 -func TestInjectThinkingDisabledNoDefaultEffort(t *testing.T) { - out := PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`), - false, nil) - typ, present := getThinkingType(t, out) - if !present || typ != "disabled" { - t.Fatalf("thinking.type=%q present=%v want disabled (out=%s)", typ, present, out) - } - for _, k := range []string{"reasoning_effort", "reasoningEffort"} { - if _, ok := objFieldString(t, out, k); ok { - t.Errorf("%s 应被删除且不得补默认档(disabled 时)(out=%s)", k, out) - } - } -} - -// objFieldString 提取顶层字段 string 值。 -func objFieldString(t *testing.T, out []byte, key string) (string, bool) { - t.Helper() - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("unmarshal: %v", err) - } - s, ok := m[key].(string) - return s, ok -} - -// TestInjectThinkingDeepSeekExplicitControl 客户端显式控制时必须尊重: -// thinking.type 非空(enabled/disabled)都不得被覆盖。 -func TestInjectThinkingDeepSeekExplicitControl(t *testing.T) { - cases := []struct { - name string - body string - wantTyp string - wantEff string // 期望 reasoning_effort 值;"" 且 wantEffAbsent=true 表示应删除 - }{ - {"已有 enabled 不动", - `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`, - "enabled", ""}, - {"已有 enabled 且带 reasoning_effort 保持(不删 effort)", - `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"high","messages":[]}`, - "enabled", "high"}, - {"已有 disabled 保留(尊重关闭意图)", - `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"messages":[]}`, - "disabled", ""}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - typ, present := getThinkingType(t, out) - if !present { - t.Fatalf("thinking 字段缺失 (out=%s)", out) - } - if typ != c.wantTyp { - t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) - } - if c.wantEff != "" { - if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != c.wantEff { - t.Errorf("reasoning_effort = %q want %q (out=%s)", eff, c.wantEff, out) - } - } - }) - } -} - -// TestInjectThinkingDisabledDeletesEffort 显式 disabled 时 reasoning_effort 一并删除 -// (照抄官方客户端 case "deepseek" 行为:默认分支 delete reasoning_effort)。 -// snow/camel 双字段都删。 -func TestInjectThinkingDisabledDeletesEffort(t *testing.T) { - cases := []struct { - name string - body string - }{ - {"disabled + snake effort 删除", - `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`}, - {"disabled + camel effort 删除", - `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoningEffort":"high","messages":[]}`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - typ, present := getThinkingType(t, out) - if !present || typ != "disabled" { - t.Fatalf("thinking.type = %q present=%v want disabled (out=%s)", typ, present, out) - } - for _, k := range []string{"reasoning_effort", "reasoningEffort"} { - if _, ok := objFieldString(t, out, k); ok { - t.Errorf("%s 应被删除(显式 disabled 时)(out=%s)", k, out) - } - } - }) - } -} - -// TestInjectThinkingSkipNonDeepSeek 非 deepseek 模型零改动:无 thinking 不得凭空添加, -// 已有 thinking 原样保留。 -func TestInjectThinkingSkipNonDeepSeek(t *testing.T) { - cases := []struct { - name string - body string - }{ - {"glm 无 thinking 不注入", `{"model":"glm-5.2","messages":[]}`}, - {"glm 已有 thinking 保留", `{"model":"glm-5.2","thinking":{"type":"enabled"},"messages":[]}`}, - {"kimi 无 thinking 不注入", `{"model":"kimi-k2.5","messages":[]}`}, - {"qwen 系不注入", `{"model":"qwen2.5-coder-32b","messages":[]}`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - // 记录输入是否已带 thinking 及值,输出必须逐字节语义不变。 - var in map[string]any - if err := json.Unmarshal([]byte(c.body), &in); err != nil { - t.Fatalf("unmarshal input: %v", err) - } - inTh, inHadThink := in["thinking"] - out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) - var got map[string]any - if err := json.Unmarshal(out, &got); err != nil { - t.Fatalf("unmarshal out: %v (out=%s)", err, out) - } - outTh, outHadThink := got["thinking"] - if inHadThink != outHadThink { - t.Errorf("thinking 出现性变化: in=%v(had=%v) out=%v(had=%v)", - inTh, inHadThink, outTh, outHadThink) - } - if inHadThink { - // 保留的 thinking 必须原值相等(JSON 语义级)。 - inJSON, _ := json.Marshal(inTh) - outJSON, _ := json.Marshal(outTh) - if string(inJSON) != string(outJSON) { - t.Errorf("thinking 被改动: in=%s out=%s", inJSON, outJSON) - } - } - // 非 deepseek 不得新增 thinking 相关字段;允许的既有新增字段: - // stream(强制流式)+ stream_options(D7 include_usage,CLI 流式必发)。 - allowedNew := map[string]bool{"stream": true, "stream_options": true} - if len(got) > len(in)+len(allowedNew) { - for k := range got { - if _, had := in[k]; !had && !allowedNew[k] { - t.Errorf("非 deepseek 新增字段 %q (out=%s)", k, out) - } - } - } - }) - } -} - -// TestInjectThinkingStringPreserved 注入不得破坏 model/messages 等既有字段。 -func TestInjectThinkingStringPreserved(t *testing.T) { - out := PrepareBodyOptWithEfforts( - []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0.7}`), - false, nil) - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if m["model"] != "deepseek-v4-flash" || m["temperature"] != 0.7 { - t.Errorf("既有字段被改动: %v", m) - } - if _, ok := m["messages"].([]any); !ok { - t.Errorf("messages 结构破坏: %v", m) - } - if !strings.Contains(string(out), `"stream":true`) { - t.Errorf("stream 未强制: %s", out) - } -} +package upstream + +import ( + "encoding/json" + "strings" + "testing" +) + +// getThinkingType 从输出 body 提取 thinking.type(缺字段返回空串 + 是否存在)。 +func getThinkingType(t *testing.T, out []byte) (typ string, present bool) { + t.Helper() + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v (out=%s)", err, out) + } + th, ok := m["thinking"].(map[string]any) + if !ok { + return "", false + } + s, ok := th["type"].(string) + if !ok { + return "", true + } + return s, true +} + +// TestInjectThinkingDeepSeekEnabled 开思考开关注入:deepseek 系模型请求体不带 +// thinking 时必须注入 {type:"enabled"},否则上游默认按不思考应答(思维链不显示)。 +func TestInjectThinkingDeepSeekEnabled(t *testing.T) { + cases := []struct { + name string + body string + wantTyp string + }{ + {"deepseek 无 thinking 注入 enabled", + `{"model":"deepseek-v4-flash","messages":[]}`, "enabled"}, + {"DeepSeek 大小写不敏感", + `{"model":"DeepSeek-v4.1-flash","messages":[]}`, "enabled"}, + {"DEEPSEEK 全大写不敏感", + `{"model":"DEEPSEEK-R1","messages":[]}`, "enabled"}, + {"deepseek 带 reasoning_effort 无 thinking 注入 enabled", + `{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`, "enabled"}, + {"deepseek thinking 对象 type 空 补 enabled", + `{"model":"deepseek-v4-flash","thinking":{},"messages":[]}`, "enabled"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present { + t.Fatalf("thinking 字段缺失 (out=%s)", out) + } + if typ != c.wantTyp { + t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) + } + }) + } +} + +// TestInjectThinkingDefaultEffort 打回修复主证据:无 effort 裸请求必须同时带 +// thinking.type=enabled 与默认档 reasoning_effort(否则上游 deepseek-v4-flash 不开思维链)。 +// 默认档 = 官方客户端兜底 "high",并带上 supportedEfforts 时经降级管线落到合法档。 +func TestInjectThinkingDefaultEffort(t *testing.T) { + // 裸请求无任何思考参数 → 注入 enabled + reasoning_effort="high"。 + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}]}`), + false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "enabled" { + t.Fatalf("thinking.type=%q present=%v want enabled (out=%s)", typ, present, out) + } + eff, ok := objFieldString(t, out, "reasoning_effort") + if !ok || eff != "high" { + t.Errorf("reasoning_effort=%q ok=%v want high(默认档)(out=%s)", eff, ok, out) + } + + // 模型只支持 low/high → 默认 high 经降级管线后仍是 high(合法档)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[]}`), + false, map[string][]string{"deepseek-v4-flash": {"low", "high"}}) + eff, _ = objFieldString(t, out, "reasoning_effort") + if eff != "high" { + t.Errorf("supportedEfforts=[low high] 下默认档=%q want high (out=%s)", eff, out) + } + + // 模型只支持 minimal/low → 默认 high 降级到 low(≤high 的最高支持档)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[]}`), + false, map[string][]string{"deepseek-v4-flash": {"minimal", "low"}}) + eff, _ = objFieldString(t, out, "reasoning_effort") + if eff != "low" { + t.Errorf("supportedEfforts=[minimal low] 下默认档降级=%q want low (out=%s)", eff, out) + } + + // 显式 enabled + 缺 effort → 同样补默认档(官方 configure thinking 行为)。 + out = PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`), + false, nil) + if eff, _ = objFieldString(t, out, "reasoning_effort"); eff != "high" { + t.Errorf("显式 enabled 缺 effort 应补默认档, got %q (out=%s)", eff, out) + } +} + +// TestInjectThinkingEffortNotOverridden 已有显式 reasoning_effort(snake/camel) +// 一律不覆盖、不降级、不删除;降级由 normalizeReasoningEffort 单独负责。 +func TestInjectThinkingEffortNotOverridden(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"snake effort 原样保留", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"medium","messages":[]}`}, + {"camel effort 原样保留", + `{"model":"deepseek-v4-flash","reasoningEffort":"low","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, _ := getThinkingType(t, out) + if typ != "enabled" { + t.Fatalf("thinking.type=%q want enabled (out=%s)", typ, out) + } + // camel 分支:输入只有 camel,injectThinking 不得另加 snake 默认档。 + if strings.Contains(c.body, "reasoningEffort") { + if _, ok := objFieldString(t, out, "reasoning_effort"); ok { + t.Errorf("已有 reasoningEffort 却新增 reasoning_effort 默认档 (out=%s)", out) + } + } + }) + } + // 显式 effort + 无 thinking → 注入 enabled 但 effort 不覆盖。 + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","reasoning_effort":"medium","messages":[]}`), + false, nil) + if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != "medium" { + t.Errorf("显式 effort 被改写 %q (out=%s)", eff, out) + } +} + +// TestInjectThinkingDisabledNoDefaultEffort disabled 保持既有语义: +// thinking.type=disabled 尊重关闭意图;reasoning_effort 删除;不得再补默认档。 +func TestInjectThinkingDisabledNoDefaultEffort(t *testing.T) { + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`), + false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "disabled" { + t.Fatalf("thinking.type=%q present=%v want disabled (out=%s)", typ, present, out) + } + for _, k := range []string{"reasoning_effort", "reasoningEffort"} { + if _, ok := objFieldString(t, out, k); ok { + t.Errorf("%s 应被删除且不得补默认档(disabled 时)(out=%s)", k, out) + } + } +} + +// objFieldString 提取顶层字段 string 值。 +func objFieldString(t *testing.T, out []byte, key string) (string, bool) { + t.Helper() + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + s, ok := m[key].(string) + return s, ok +} + +// TestInjectThinkingDeepSeekExplicitControl 客户端显式控制时必须尊重: +// thinking.type 非空(enabled/disabled)都不得被覆盖。 +func TestInjectThinkingDeepSeekExplicitControl(t *testing.T) { + cases := []struct { + name string + body string + wantTyp string + wantEff string // 期望 reasoning_effort 值;"" 且 wantEffAbsent=true 表示应删除 + }{ + {"已有 enabled 不动", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"messages":[]}`, + "enabled", ""}, + {"已有 enabled 且带 reasoning_effort 保持(不删 effort)", + `{"model":"deepseek-v4-flash","thinking":{"type":"enabled"},"reasoning_effort":"high","messages":[]}`, + "enabled", "high"}, + {"已有 disabled 保留(尊重关闭意图)", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"messages":[]}`, + "disabled", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present { + t.Fatalf("thinking 字段缺失 (out=%s)", out) + } + if typ != c.wantTyp { + t.Errorf("thinking.type = %q want %q (out=%s)", typ, c.wantTyp, out) + } + if c.wantEff != "" { + if eff, _ := objFieldString(t, out, "reasoning_effort"); eff != c.wantEff { + t.Errorf("reasoning_effort = %q want %q (out=%s)", eff, c.wantEff, out) + } + } + }) + } +} + +// TestInjectThinkingDisabledDeletesEffort 显式 disabled 时 reasoning_effort 一并删除 +// (照抄官方客户端 case "deepseek" 行为:默认分支 delete reasoning_effort)。 +// snow/camel 双字段都删。 +func TestInjectThinkingDisabledDeletesEffort(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"disabled + snake effort 删除", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[]}`}, + {"disabled + camel effort 删除", + `{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"reasoningEffort":"high","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + typ, present := getThinkingType(t, out) + if !present || typ != "disabled" { + t.Fatalf("thinking.type = %q present=%v want disabled (out=%s)", typ, present, out) + } + for _, k := range []string{"reasoning_effort", "reasoningEffort"} { + if _, ok := objFieldString(t, out, k); ok { + t.Errorf("%s 应被删除(显式 disabled 时)(out=%s)", k, out) + } + } + }) + } +} + +// TestInjectThinkingSkipNonDeepSeek 非 deepseek 模型零改动:无 thinking 不得凭空添加, +// 已有 thinking 原样保留。 +func TestInjectThinkingSkipNonDeepSeek(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"glm 无 thinking 不注入", `{"model":"glm-5.2","messages":[]}`}, + {"glm 已有 thinking 保留", `{"model":"glm-5.2","thinking":{"type":"enabled"},"messages":[]}`}, + {"kimi 无 thinking 不注入", `{"model":"kimi-k2.5","messages":[]}`}, + {"qwen 系不注入", `{"model":"qwen2.5-coder-32b","messages":[]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 记录输入是否已带 thinking 及值,输出必须逐字节语义不变。 + var in map[string]any + if err := json.Unmarshal([]byte(c.body), &in); err != nil { + t.Fatalf("unmarshal input: %v", err) + } + inTh, inHadThink := in["thinking"] + out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil) + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal out: %v (out=%s)", err, out) + } + outTh, outHadThink := got["thinking"] + if inHadThink != outHadThink { + t.Errorf("thinking 出现性变化: in=%v(had=%v) out=%v(had=%v)", + inTh, inHadThink, outTh, outHadThink) + } + if inHadThink { + // 保留的 thinking 必须原值相等(JSON 语义级)。 + inJSON, _ := json.Marshal(inTh) + outJSON, _ := json.Marshal(outTh) + if string(inJSON) != string(outJSON) { + t.Errorf("thinking 被改动: in=%s out=%s", inJSON, outJSON) + } + } + // 非 deepseek 不得新增 thinking 相关字段;允许的既有新增字段: + // stream(强制流式)+ stream_options(D7 include_usage,CLI 流式必发)。 + allowedNew := map[string]bool{"stream": true, "stream_options": true} + if len(got) > len(in)+len(allowedNew) { + for k := range got { + if _, had := in[k]; !had && !allowedNew[k] { + t.Errorf("非 deepseek 新增字段 %q (out=%s)", k, out) + } + } + } + }) + } +} + +// TestInjectThinkingStringPreserved 注入不得破坏 model/messages 等既有字段。 +func TestInjectThinkingStringPreserved(t *testing.T) { + out := PrepareBodyOptWithEfforts( + []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0.7}`), + false, nil) + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["model"] != "deepseek-v4-flash" || m["temperature"] != 0.7 { + t.Errorf("既有字段被改动: %v", m) + } + if _, ok := m["messages"].([]any); !ok { + t.Errorf("messages 结构破坏: %v", m) + } + if !strings.Contains(string(out), `"stream":true`) { + t.Errorf("stream 未强制: %s", out) + } +} diff --git a/internal/upstream/tool_pairing.go b/internal/upstream/tool_pairing.go index d251c4427075dfbbd77a57a6437016ae8f1d89fd..8ba698fc74c2e92e9551d25023179fd71c63eca2 100644 --- a/internal/upstream/tool_pairing.go +++ b/internal/upstream/tool_pairing.go @@ -1,226 +1,347 @@ -// tool_pairing.go 出站请求体的孤儿 tool_call↔tool 配对清理 + tool 结果块重排 -// (吸收参考仓库 sse.ts:91-123 resolveToolPairing 语义,适配网关的 OpenAI wire 消息形态)。 -// -// 背景:OpenAI 兼容协议要求带 tool_calls 的 assistant 消息,其每一个 tool_call id -// 都必须有对应的一条 role:tool 结果消息;反之 role:tool 消息也必须有对应的前置 -// tool_call。缺任一侧,上游都会以 HTTP 400 拒绝整个请求。 -// -// 工具执行失败时(参数非法、超时、工具不存在……)客户端会把 assistant 的 tool_calls -// 持久化进会话历史,却写不回结果消息。这条坏历史随后被每次请求原样重放——上游对之后 -// 每一条用户消息都返回 400,整条会话报废。网关是最后一道防线:发出请求前剔除无法配对 -// 的条目让会话自愈,宁可丢一轮工具上下文,也好过整条会话死亡。 -package upstream - -// repackToolResultBlocks 把插在 assistant.tool_calls 与其 tool 结果之间的非 tool 消息 -// 挪到整组之后,保证同一批 tool_call 的结果在 wire 上连续。 -// -// 背景:Codex 的 image_resize_notice 特性会把 作为一条 -// developer/system 消息插在 tool 输出后面。并行调用时它插在两份 tool 结果中间: -// -// assistant tool_calls=[c00 c01] -// tool c00 -// developer <- 插在中间 -// tool c01 -// -// OpenAI 兼容协议要求 tool 结果紧跟 assistant,中间插任何消息都算配对断裂,上游判 -// 11148(tool_call_sequence_broken)并顶死整条会话。这里只调顺序、不改内容: -// -// assistant tool_calls=[c00 c01] | tool c00 | X | tool c01 -// → assistant tool_calls=[c00 c01] | tool c00 | tool c01 | X -// -// 结果顺序保持不变(同批 tool_call 的原相对顺序 = 结果顺序),不引入新的顺序敏感 -// 问题。无插入消息时零改动零分配(返回原 slice)。 -func repackToolResultBlocks(messages []any) ([]any, bool) { - if len(messages) < 3 { - return messages, false - } - out := make([]any, 0, len(messages)) - changed := false - i := 0 - for i < len(messages) { - m, ok := messages[i].(map[string]any) - if !ok || m["role"] != "assistant" { - out = append(out, messages[i]) - i++ - continue - } - tcs, hasCalls := m["tool_calls"].([]any) - if !hasCalls || len(tcs) == 0 { - out = append(out, messages[i]) - i++ - continue - } - want := map[string]bool{} - for _, tci := range tcs { - if tc, ok := tci.(map[string]any); ok { - if id, _ := tc["id"].(string); id != "" { - want[id] = true - } - } - } - // 收集紧随其后(允许被其他消息打断)的同批 tool 结果,按原相对顺序。 - out = append(out, messages[i]) - i++ - var results []any - var between []any - sawNonTool := false - for i < len(messages) { - mm, ok := messages[i].(map[string]any) - if !ok { - break - } - role, _ := mm["role"].(string) - if role == "tool" { - id, _ := mm["tool_call_id"].(string) - if !want[id] { - break - } - results = append(results, messages[i]) - if sawNonTool { - changed = true - } - i++ - continue - } - if len(results) == 0 { - break // assistant 后没有结果:交由 cleanupOrphanToolCalls 处理 - } - // 下一组 assistant.tool_calls 是新的组头,绝不能当插入物吞掉:一旦被收进 - // between,它永远不再被外层循环当作组头处理,它自己那批结果也就永远得不 - // 到重排(真实会话 msg[181] 正是这样漏掉的)。必须 break 交还外层循环。 - if role == "assistant" { - if next, _ := mm["tool_calls"].([]any); len(next) > 0 { - break - } - } - // 同批结果尚未收齐时,中间消息视为插入物,暂存待后移。 - between = append(between, messages[i]) - sawNonTool = true - i++ - } - out = append(out, results...) - out = append(out, between...) - } - if !changed { - return messages, false - } - return out, true -} - -// cleanupOrphanToolCalls 剔除无法配对的 tool_call 与 tool 结果(所有模型,独立于 -// deepseek-only 的 sanitize 开关)。语义对齐参考仓库 resolveToolPairing: -// -// - 收集全线 role:tool 消息的 tool_call_id(结果集)与 assistant.tool_calls[].id(调用集); -// - 一批 assistant.tool_calls 按 keepCalls 对称裁剪:只留有结果配对的调用(部分保留 -// 不会留下无结果的 tool_call),过滤后为空才删掉整个 tool_calls 键; -// - role:tool 只在对应 tool_call 被保留时才保留,否则删除整条消息; -// - 无任何工具流量 → 原 slice 原样返回,changed=false(零分配零改动)。 -// -// 这是「让请求通过」的安全网:只要存在合法配对就整段保留这些字段,绝不吞掉正确配对。 -// 返回清理后的 slice(无改动时等于原 slice,勿依赖其是否新分配)及是否发生删除。 -func cleanupOrphanToolCalls(messages []any) ([]any, bool) { - if len(messages) == 0 { - return messages, false - } - callIDs := map[string]bool{} - resultIDs := map[string]bool{} - hasTraffic := false - for _, m := range messages { - msg, ok := m.(map[string]any) - if !ok { - continue - } - switch msg["role"] { - case "tool": - if id, ok := msg["tool_call_id"].(string); ok && id != "" { - resultIDs[id] = true - hasTraffic = true - } - case "assistant": - if tcs, ok := msg["tool_calls"].([]any); ok { - for _, tci := range tcs { - tc, ok := tci.(map[string]any) - if !ok { - continue - } - if id, ok := tc["id"].(string); ok && id != "" { - callIDs[id] = true - hasTraffic = true - } - } - } - } - } - if !hasTraffic { - return messages, false - } - // keepCalls:调用 id 是否双侧齐全(调用存在且结果存在)。重复 id 与乱序均按集合处理。 - keepCalls := map[string]bool{} - for id := range callIDs { - if resultIDs[id] { - keepCalls[id] = true - } - } - changed := false - // 1) assistant.tool_calls:按 keepCalls 对称裁剪——只留有结果的调用,过滤后为空则删键。 - // - // 历史实现是「批内每个 id 都齐才整批保留,否则删掉整个 tool_calls 键」。那会留下 - // 无主结果:批 [c1,c2] 只回了 c1 时,调用侧整批被删,而 tool{c1} 仍按 id 命中 - // keepCalls 得以保留 —— 出站载荷于是变成「无 tool_calls 的 assistant + 孤儿 tool」, - // 上游判 11148(tool calls and tool results do not match)并顶死整条会话。 - // 现在两侧共用同一份 keepCalls 按 id 对称裁剪(与 2) 的删除侧同口径), - // 任何输入都不会再产生半截配对。 - for _, m := range messages { - msg, ok := m.(map[string]any) - if !ok { - continue - } - if role, _ := msg["role"].(string); role != "assistant" { - continue - } - tcs, ok := msg["tool_calls"].([]any) - if !ok || len(tcs) == 0 { - continue - } - keptCalls := make([]any, 0, len(tcs)) - for _, tci := range tcs { - tc, ok := tci.(map[string]any) - if !ok { - continue - } - if id, _ := tc["id"].(string); keepCalls[id] { - keptCalls = append(keptCalls, tc) - } - } - if len(keptCalls) == len(tcs) { - continue // 整批齐全:零改动 - } - changed = true - if len(keptCalls) == 0 { - delete(msg, "tool_calls") - continue - } - msg["tool_calls"] = keptCalls - } - // 2) role:tool 结果:只有对应 tool_call 被保留才保留;孤儿结果整条删除。 - kept := make([]any, 0, len(messages)) - for _, m := range messages { - msg, ok := m.(map[string]any) - if !ok { - kept = append(kept, m) - continue - } - if role, _ := msg["role"].(string); role == "tool" { - id, _ := msg["tool_call_id"].(string) - if !keepCalls[id] { - changed = true - continue - } - } - kept = append(kept, m) - } - if !changed { - return messages, false - } - return kept, true -} +// tool_pairing.go 出站请求体的孤儿 tool_call↔tool 配对清理 + tool 结果块重排 +// (吸收参考仓库 sse.ts:91-123 resolveToolPairing 语义,适配网关的 OpenAI wire 消息形态)。 +// +// 背景:OpenAI 兼容协议要求带 tool_calls 的 assistant 消息,其每一个 tool_call id +// 都必须有对应的一条 role:tool 结果消息;反之 role:tool 消息也必须有对应的前置 +// tool_call。缺任一侧,上游都会以 HTTP 400 拒绝整个请求。 +// +// 工具执行失败时(参数非法、超时、工具不存在……)客户端会把 assistant 的 tool_calls +// 持久化进会话历史,却写不回结果消息。这条坏历史随后被每次请求原样重放——上游对之后 +// 每一条用户消息都返回 400,整条会话报废。网关是最后一道防线:发出请求前剔除无法配对 +// 的条目让会话自愈,宁可丢一轮工具上下文,也好过整条会话死亡。 +package upstream + +// repackToolResultBlocks 把插在 assistant.tool_calls 与其 tool 结果之间的非 tool 消息 +// 挪到整组之后,保证同一批 tool_call 的结果在 wire 上连续。 +// +// 背景:Codex 的 image_resize_notice 特性会把 作为一条 +// developer/system 消息插在 tool 输出后面。并行调用时它插在两份 tool 结果中间: +// +// assistant tool_calls=[c00 c01] +// tool c00 +// developer <- 插在中间 +// tool c01 +// +// OpenAI 兼容协议要求 tool 结果紧跟 assistant,中间插任何消息都算配对断裂,上游判 +// 11148(tool_call_sequence_broken)并顶死整条会话。这里只调顺序、不改内容: +// +// assistant tool_calls=[c00 c01] | tool c00 | X | tool c01 +// → assistant tool_calls=[c00 c01] | tool c00 | tool c01 | X +// +// 结果顺序保持不变(同批 tool_call 的原相对顺序 = 结果顺序),不引入新的顺序敏感 +// 问题。无插入消息时零改动零分配(返回原 slice)。 +func repackToolResultBlocks(messages []any) ([]any, bool) { + if len(messages) < 3 { + return messages, false + } + out := make([]any, 0, len(messages)) + changed := false + i := 0 + for i < len(messages) { + m, ok := messages[i].(map[string]any) + if !ok || m["role"] != "assistant" { + out = append(out, messages[i]) + i++ + continue + } + tcs, hasCalls := m["tool_calls"].([]any) + if !hasCalls || len(tcs) == 0 { + out = append(out, messages[i]) + i++ + continue + } + want := map[string]bool{} + for _, tci := range tcs { + if tc, ok := tci.(map[string]any); ok { + if id, _ := tc["id"].(string); id != "" { + want[id] = true + } + } + } + // 收集紧随其后(允许被其他消息打断)的同批 tool 结果,按原相对顺序。 + out = append(out, messages[i]) + i++ + var results []any + var between []any + sawNonTool := false + for i < len(messages) { + mm, ok := messages[i].(map[string]any) + if !ok { + break + } + role, _ := mm["role"].(string) + if role == "tool" { + id, _ := mm["tool_call_id"].(string) + if !want[id] { + break + } + results = append(results, messages[i]) + if sawNonTool { + changed = true + } + i++ + continue + } + if len(results) == 0 { + break // assistant 后没有结果:交由 cleanupOrphanToolCalls 处理 + } + // 下一组 assistant.tool_calls 是新的组头,绝不能当插入物吞掉:一旦被收进 + // between,它永远不再被外层循环当作组头处理,它自己那批结果也就永远得不 + // 到重排(真实会话 msg[181] 正是这样漏掉的)。必须 break 交还外层循环。 + if role == "assistant" { + if next, _ := mm["tool_calls"].([]any); len(next) > 0 { + break + } + } + // 同批结果尚未收齐时,中间消息视为插入物,暂存待后移。 + between = append(between, messages[i]) + sawNonTool = true + i++ + } + out = append(out, results...) + out = append(out, between...) + } + if !changed { + return messages, false + } + return out, true +} + +// cleanupOrphanToolCalls 剔除无法配对的 tool_call 与 tool 结果(所有模型,独立于 +// deepseek-only 的 sanitize 开关)。语义对齐参考仓库 resolveToolPairing: +// +// - 收集全线 role:tool 消息的 tool_call_id(结果集)与 assistant.tool_calls[].id(调用集); +// - 一批 assistant.tool_calls 按 keepCalls 对称裁剪:只留有结果配对的调用(部分保留 +// 不会留下无结果的 tool_call),过滤后为空才删掉整个 tool_calls 键; +// - role:tool 只在对应 tool_call 被保留时才保留,否则删除整条消息; +// - 无任何工具流量 → 原 slice 原样返回,changed=false(零分配零改动)。 +// +// 这是「让请求通过」的安全网:只要存在合法配对就整段保留这些字段,绝不吞掉正确配对。 +// 返回清理后的 slice(无改动时等于原 slice,勿依赖其是否新分配)及是否发生删除。 +func cleanupOrphanToolCalls(messages []any) ([]any, bool) { + if len(messages) == 0 { + return messages, false + } + callIDs := map[string]bool{} + resultIDs := map[string]bool{} + hasTraffic := false + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + switch msg["role"] { + case "tool": + if id, ok := msg["tool_call_id"].(string); ok && id != "" { + resultIDs[id] = true + hasTraffic = true + } + case "assistant": + if tcs, ok := msg["tool_calls"].([]any); ok { + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + if id, ok := tc["id"].(string); ok && id != "" { + callIDs[id] = true + hasTraffic = true + } + } + } + } + } + if !hasTraffic { + return messages, false + } + // keepCalls:调用 id 是否双侧齐全(调用存在且结果存在)。重复 id 与乱序均按集合处理。 + keepCalls := map[string]bool{} + for id := range callIDs { + if resultIDs[id] { + keepCalls[id] = true + } + } + changed := false + // 1) assistant.tool_calls:按 keepCalls 对称裁剪——只留有结果的调用,过滤后为空则删键。 + // + // 历史实现是「批内每个 id 都齐才整批保留,否则删掉整个 tool_calls 键」。那会留下 + // 无主结果:批 [c1,c2] 只回了 c1 时,调用侧整批被删,而 tool{c1} 仍按 id 命中 + // keepCalls 得以保留 —— 出站载荷于是变成「无 tool_calls 的 assistant + 孤儿 tool」, + // 上游判 11148(tool calls and tool results do not match)并顶死整条会话。 + // 现在两侧共用同一份 keepCalls 按 id 对称裁剪(与 2) 的删除侧同口径), + // 任何输入都不会再产生半截配对。 + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, _ := msg["role"].(string); role != "assistant" { + continue + } + tcs, ok := msg["tool_calls"].([]any) + if !ok || len(tcs) == 0 { + continue + } + keptCalls := make([]any, 0, len(tcs)) + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + if id, _ := tc["id"].(string); keepCalls[id] { + keptCalls = append(keptCalls, tc) + } + } + if len(keptCalls) == len(tcs) { + continue // 整批齐全:零改动 + } + changed = true + if len(keptCalls) == 0 { + delete(msg, "tool_calls") + continue + } + msg["tool_calls"] = keptCalls + } + // 2) role:tool 结果:只有对应 tool_call 被保留才保留;孤儿结果整条删除。 + kept := make([]any, 0, len(messages)) + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + kept = append(kept, m) + continue + } + if role, _ := msg["role"].(string); role == "tool" { + id, _ := msg["tool_call_id"].(string) + if !keepCalls[id] { + changed = true + continue + } + } + kept = append(kept, m) + } + if !changed { + return messages, false + } + return kept, true +} + +// mergeAdjacentToolCalls 把**背靠背**的 assistant.tool_calls 消息合成一条(tool_calls 依原序拼接)。 +// +// 2026-09-20 实机定位并复现的 11148 事故根因:部分 OpenAI 兼容 agent 客户端回放历史时把 +// 同一批并行工具调用拆成多条紧邻的独立 assistant 消息,出站载荷长成: +// +// assistant tool_calls=[c00] +// assistant tool_calls=[c01] +// tool c00 +// tool c01 +// +// 上游要求「声明 tool_calls 的 assistant 之后必须紧跟它自己的结果」——紧随的若是另一条带 +// tool_calls 的 assistant,即返回 400 code=11148(extError tool_call_sequence_broken, +// "tool calls and tool results do not match, please start a new conversation and retry"), +// 整条会话报废:客户端每次重试重放同一条历史,池侧换号也无效(不是账号问题)。 +// +// 对照实验(2026-09-20 在线上网关实测,同一批调用): +// - 拆成两条 assistant(Codex Desktop 报文形状)+ deepseek-v4.1-flash → 503 / 11148 +// - 合成一条 assistant(=本函数产物) + deepseek-v4.1-flash → 200 +// - 拆成两条 assistant + glm-5.3-flash → 200(该模型宽容) +// +// 即:**行为本身合法**(上述第一种形态在 OpenAI 规范里也说得通),是上游 deepseek 系模型的 +// 校验更严。网关作为最后一道防线按最严口径归一,客户端不必感知。 +// +// 合并条件从严,避免引入新语义: +// - 两条消息**相邻**(中间隔着任何消息都不合并——隔着消息说明不是同一批声明, +// 凭猜测合并会改变语义,这类形态按其原样交给 repackToolResultBlocks 处理); +// - 后一条 content 为空(content 非空无法无损拼接,不猜语义); +// - 前一条本身必须是带 tool_calls 的 assistant(否则不合并,例如 assistant 文本 + 独立工具调用消息)。 +// +// reasoning_content(thinking.go 的多轮回填字段)不丢:后一条有则搬到合并结果,两边都有则换行拼接 +// (deepseek 多轮要求 assistant 带思维链回填,丢弃会换一个错误)。 +func mergeAdjacentToolCalls(messages []any) ([]any, bool) { + if len(messages) < 2 { + return messages, false + } + out := make([]any, 0, len(messages)) + changed := false + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + out = append(out, m) + continue + } + if role, _ := msg["role"].(string); role == "assistant" && len(out) > 0 { + // 形态一:本条是带 tool_calls 的 assistant 且没有正文 → 并入上一条 + // 同为 assistant 且带 tool_calls 的消息(背靠背的并行调用声明)。 + if tcs, ok := msg["tool_calls"].([]any); ok && len(tcs) > 0 && emptyContent(msg["content"]) { + if prev, ok := out[len(out)-1].(map[string]any); ok { + if prevRole, _ := prev["role"].(string); prevRole == "assistant" { + if prevCalls, ok := prev["tool_calls"].([]any); ok && len(prevCalls) > 0 { + prev["tool_calls"] = append(prevCalls, tcs...) + mergeReasoningContent(prev, msg) + changed = true + continue // 本条已并入上一条,不再单独出站 + } + } + } + } + // 形态二(反向):本条是纯正文 assistant,上一条是带 tool_calls 但没正文的 + // assistant → 把正文折进上一条,合成 assistant(正文 + tool_calls)。这样 + // "声明 tool_calls 的 assistant 紧跟它自己的结果"在两种拆分顺序下都成立。 + // + // 谁会产出这个顺序:部分 OpenAI 兼容 agent 客户端的回放顺序(正文与 + // 调用声明拆成两条独立 assistant)。 + // + // 条件同样从严:只认**字符串正文**(数组正文可能含多模态块,拼接会丢结构, + // 交给 repackToolResultBlocks 原样处理);上一条必须自身无正文。 + if _, hasCalls := msg["tool_calls"]; !hasCalls { + if txt, ok := msg["content"].(string); ok && txt != "" { + if prev, ok := out[len(out)-1].(map[string]any); ok { + if prevRole, _ := prev["role"].(string); prevRole == "assistant" { + if prevCalls, ok := prev["tool_calls"].([]any); ok && len(prevCalls) > 0 && emptyContent(prev["content"]) { + prev["content"] = txt + mergeReasoningContent(prev, msg) + changed = true + continue // 正文已折进上一条 + } + } + } + } + } + } + out = append(out, m) + } + if !changed { + return messages, false + } + return out, true +} + +// mergeReasoningContent 把 src 的 reasoning_content 并入 dst(两边都有则换行拼接)。 +// deepseek 多轮要求 assistant 带思维链回填,合并时丢弃会换一个错误。 +func mergeReasoningContent(dst, src map[string]any) { + rc, _ := src["reasoning_content"].(string) + if rc == "" { + return + } + if prevRC, _ := dst["reasoning_content"].(string); prevRC != "" { + dst["reasoning_content"] = prevRC + "\n" + rc + return + } + dst["reasoning_content"] = rc +} + +// emptyContent content 是否为空(缺失 / nil / 空串 / 空数组)。 +// 空数组也必须算空:有客户端把"没有正文"发成 `content: []` 而不是 null,此前按 +// "非字符串一律非空"处理 → 背靠背的两条 assistant(tool_calls) 不合并 → 上游 +// deepseek 系判 11148(正是 mergeAdjacentToolCalls 要挡的形态,条件漏了 [])。 +// 非空数组仍视为有内容:宁可漏合并,也不丢内容。 +func emptyContent(v any) bool { + switch c := v.(type) { + case nil: + return true + case string: + return c == "" + case []any: + return len(c) == 0 + } + return false +} diff --git a/internal/upstream/tool_pairing_test.go b/internal/upstream/tool_pairing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a31725d9c2420b2b0d07d5797b081475949e2b49 --- /dev/null +++ b/internal/upstream/tool_pairing_test.go @@ -0,0 +1,424 @@ +// tool_pairing_test.go 出站工具配对三步(merge/repack/cleanup)的行为钉桩。 +// +// 断言口径来自 2026-09-20 的线上实机定位:上游对「声明 tool_calls 的 assistant 之后必须 +// 紧跟它自己的结果」有硬校验,违反即 400 code=11148(tool_call_sequence_broken)并顶死整条 +// 会话(换号无效)。对照实验见 mergeAdjacentToolCalls 注释。 +package upstream + +import ( + "encoding/json" + "strings" + "testing" +) + +// msgs 解析测试用的 messages JSON 数组。 +func msgs(t *testing.T, s string) []any { + t.Helper() + var v []any + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("parse messages: %v", err) + } + return v +} + +// summarize 把消息序列压成可读摘要:assistant 带 tool_calls 记 "assistant(id1,id2)", +// 其余记 "role(-)"。用于逐条比对「合并后长什么样」。 +func summarize(messages []any) []string { + out := make([]string, 0, len(messages)) + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + out = append(out, "") + continue + } + role, _ := msg["role"].(string) + if role == "tool" { + id, _ := msg["tool_call_id"].(string) + out = append(out, "tool("+id+")") + continue + } + ids := []string{} + if tcs, ok := msg["tool_calls"].([]any); ok { + for _, tci := range tcs { + if tc, ok := tci.(map[string]any); ok { + id, _ := tc["id"].(string) + ids = append(ids, id) + } + } + } + if len(ids) > 0 { + out = append(out, role+"("+strings.Join(ids, ",")+")") + continue + } + out = append(out, role+"(-)") + } + return out +} + +func assertSummary(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("消息数不符:\n got %v\nwant %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("消息[%d] = %s want %s\n got %v\nwant %v", i, got[i], want[i], got, want) + } + } +} + +// TestMergeAdjacentToolCalls 背靠背的两条 assistant.tool_calls 必须合成一条。 +// +// 这是部分 OpenAI 兼容 agent 客户端回放并行工具调用的报文形状(同一批调用 +// 拆成多条独立 assistant 消息),上游 deepseek 系模型对它判 11148。 +func TestMergeAdjacentToolCalls(t *testing.T) { + const ( + aNil = `{"role":"assistant","content":null,"tool_calls":[{"id":"c00","type":"function","function":{"name":"f","arguments":"{}"}}]}` + aC01 = `{"role":"assistant","content":null,"tool_calls":[{"id":"c01","type":"function","function":{"name":"f","arguments":"{}"}}]}` + tC00 = `{"role":"tool","tool_call_id":"c00","content":"r0"}` + tC01 = `{"role":"tool","tool_call_id":"c01","content":"r1"}` + ) + + t.Run("并行两条合成一条(修复目标形态)", func(t *testing.T) { + // 输入即线上复现 11148 的报文:assistant(c00) 之后紧跟 assistant(c01)。 + in := msgs(t, "["+aNil+","+aC01+","+tC00+","+tC01+"]") + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("changed=false,期望发生合并") + } + assertSummary(t, summarize(out), []string{"assistant(c00,c01)", "tool(c00)", "tool(c01)"}) + // 拼接顺序必须是声明顺序(= 结果的 wire 顺序),不得反转。 + tcs := out[0].(map[string]any)["tool_calls"].([]any) + if id, _ := tcs[0].(map[string]any)["id"].(string); id != "c00" { + t.Errorf("合并后首个 tool_call = %q want c00", id) + } + }) + + t.Run("三条连续全并", func(t *testing.T) { + aC02 := `{"role":"assistant","content":null,"tool_calls":[{"id":"c02","type":"function","function":{"name":"f","arguments":"{}"}}]}` + in := msgs(t, "["+aNil+","+aC01+","+aC02+"]") + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("changed=false,期望发生合并") + } + assertSummary(t, summarize(out), []string{"assistant(c00,c01,c02)"}) + }) + + t.Run("已是规范形态原样返回且零改动", func(t *testing.T) { + // chat 规范形态(一条 assistant 带全部 tool_calls):不得改动,便于上层判断 no-op。 + merged := `{"role":"assistant","content":null,"tool_calls":[{"id":"c00"},{"id":"c01"}]}` + in := msgs(t, "["+merged+","+tC00+","+tC01+"]") + out, changed := mergeAdjacentToolCalls(in) + if changed { + t.Error("changed=true,规范形态不应被改动") + } + assertSummary(t, summarize(out), []string{"assistant(c00,c01)", "tool(c00)", "tool(c01)"}) + }) + + t.Run("中间隔着消息不合并(非背靠背)", func(t *testing.T) { + // 隔了 user 消息 = 两条独立声明,不是同一批;擅自合并会改变语义。 + in := msgs(t, "["+aNil+`,{"role":"user","content":"x"},`+aC01+","+tC00+","+tC01+"]") + out, changed := mergeAdjacentToolCalls(in) + if changed { + t.Error("changed=true,非相邻不应合并") + } + assertSummary(t, summarize(out), []string{"assistant(c00)", "user(-)", "assistant(c01)", "tool(c00)", "tool(c01)"}) + }) + + t.Run("后一条 content 非空不合并", func(t *testing.T) { + // content 非空无法无损拼接:不猜语义,原样交给上游。 + in := msgs(t, "["+aNil+`,{"role":"assistant","content":"text","tool_calls":[{"id":"c01"}]}`+"]") + out, changed := mergeAdjacentToolCalls(in) + if changed { + t.Error("changed=true,content 非空不应合并") + } + assertSummary(t, summarize(out), []string{"assistant(c00)", "assistant(c01)"}) + }) + + t.Run("前一条无 tool_calls 不合并", func(t *testing.T) { + in := msgs(t, `[{"role":"assistant","content":"hi"},`+aNil+"]") + out, changed := mergeAdjacentToolCalls(in) + if changed { + t.Error("changed=true,前一条无 tool_calls 不应合并") + } + assertSummary(t, summarize(out), []string{"assistant(-)", "assistant(c00)"}) + }) + + t.Run("reasoning_content 逐条保留(换行拼接)", func(t *testing.T) { + // deepseek 多轮要求 assistant 带回填思维链,丢弃会换一个错误,故必须搬过去。 + in := msgs(t, `[{"role":"assistant","content":null,"reasoning_content":"r1","tool_calls":[{"id":"c00"}]},{"role":"assistant","content":null,"reasoning_content":"r2","tool_calls":[{"id":"c01"}]}]`) + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("changed=false,期望合并") + } + rc, _ := out[0].(map[string]any)["reasoning_content"].(string) + if rc != "r1\nr2" { + t.Errorf("reasoning_content = %q want %q", rc, "r1\nr2") + } + }) + + t.Run("首条即 assistant.tool_calls 不越界", func(t *testing.T) { + out, changed := mergeAdjacentToolCalls(msgs(t, "["+aNil+"]")) + if changed { + t.Error("单条消息不应改动") + } + assertSummary(t, summarize(out), []string{"assistant(c00)"}) + }) + + t.Run("非对象元素原样保留且不阻断后续合并", func(t *testing.T) { + in := msgs(t, `["str",`+aNil+","+aC01+"]") + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("changed=false,期望 aNil/aC01 合并") + } + assertSummary(t, summarize(out), []string{"", "assistant(c00,c01)"}) + }) +} + +// TestPrepareBodyMergesSplitParallelToolCalls 全链路:出站管线必须把「拆开的并行调用」 +// 归一到上游认可的形态。 +// +// 断言的正是线上对照实验的结论:合成一条 assistant(本测试期望的输出形态)→ 200; +// 拆成两条 → deepseek 系模型 503/11148。 +func TestPrepareBodyMergesSplitParallelToolCalls(t *testing.T) { + body := `{"model":"deepseek-v4.1-flash","messages":[ + {"role":"user","content":"跑两个命令"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"call_00_a","type":"function","function":{"name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}]}, + {"role":"assistant","content":null,"tool_calls":[{"id":"call_01_b","type":"function","function":{"name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}]}, + {"role":"tool","tool_call_id":"call_00_a","content":"/opt"}, + {"role":"tool","tool_call_id":"call_01_b","content":"a b c"} + ]}` + out := PrepareBodyOptWithEfforts([]byte(body), false, nil) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("unmarshal 出站 body: %v", err) + } + messages, _ := obj["messages"].([]any) + assertSummary(t, summarize(messages), []string{ + "user(-)", "assistant(call_00_a,call_01_b)", "tool(call_00_a)", "tool(call_01_b)", + }) + // 逐条复核:不得出现「带 tool_calls 的 assistant 紧跟另一条带 tool_calls 的 assistant」。 + prevHadCalls := false + for i, m := range messages { + msg, _ := m.(map[string]any) + hasCalls := false + if tcs, ok := msg["tool_calls"].([]any); ok && len(tcs) > 0 { + hasCalls = true + } + if prevHadCalls && hasCalls { + t.Fatalf("消息[%d] 仍是两条相邻的 assistant.tool_calls(上游判 11148): %v", i, summarize(messages)) + } + prevHadCalls = hasCalls + } +} + +// TestRepackToolResultBlocks 结果之间的插入消息必须挪到整组之后(上游判配对断裂的另一形态)。 +func TestRepackToolResultBlocks(t *testing.T) { + t.Run("插在结果中间的消息后移", func(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c00"},{"id":"c01"}]}, + {"role":"tool","tool_call_id":"c00","content":"r0"}, + {"role":"system","content":""}, + {"role":"tool","tool_call_id":"c01","content":"r1"} + ]`) + out, changed := repackToolResultBlocks(in) + if !changed { + t.Fatal("changed=false,期望重排") + } + assertSummary(t, summarize(out), []string{"assistant(c00,c01)", "tool(c00)", "tool(c01)", "system(-)"}) + }) + + t.Run("下一组组头不被吞掉", func(t *testing.T) { + // 真实会话漏排的形态:第一组结果后跟着插入消息、再接第二组组头。组头必须交还外层 + // 循环当组头处理——否则它会被当成插入物挪走,它自己那批结果(t(c10) 与 t(c11) + // 之间夹着 system)永远得不到重排,上游判配对断裂。 + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c00"}]}, + {"role":"tool","tool_call_id":"c00","content":"r0"}, + {"role":"system","content":"notice1"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"c10"},{"id":"c11"}]}, + {"role":"tool","tool_call_id":"c10","content":"r10"}, + {"role":"system","content":"notice2"}, + {"role":"tool","tool_call_id":"c11","content":"r11"} + ]`) + out, changed := repackToolResultBlocks(in) + if !changed { + t.Fatal("changed=false,第二组的插入消息应被后移") + } + assertSummary(t, summarize(out), []string{ + "assistant(c00)", "tool(c00)", "system(-)", + "assistant(c10,c11)", "tool(c10)", "tool(c11)", "system(-)", + }) + }) + + t.Run("无可重排内容时零改动", func(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c00"}]}, + {"role":"tool","tool_call_id":"c00","content":"r0"} + ]`) + _, changed := repackToolResultBlocks(in) + if changed { + t.Error("changed=true,规范形态不应重排") + } + }) +} + +// TestCleanupOrphanToolCalls 缺一侧的配对必须两侧同口径剔除(否则残留半截配对 → 11148)。 +func TestCleanupOrphanToolCalls(t *testing.T) { + t.Run("批内部分缺结果:调用侧对称裁剪", func(t *testing.T) { + // 历史缺陷形态:批 [c1,c2] 只回了 c1 时整批删调用、却留下 tool{c1}, + // 出站变成「无 tool_calls 的 assistant + 孤儿 tool」→ 11148。 + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c1"},{"id":"c2"}]}, + {"role":"tool","tool_call_id":"c1","content":"r1"} + ]`) + out, changed := cleanupOrphanToolCalls(in) + if !changed { + t.Fatal("changed=false,期望裁剪") + } + assertSummary(t, summarize(out), []string{"assistant(c1)", "tool(c1)"}) + }) + + t.Run("调用无任何结果:删 tool_calls 键", func(t *testing.T) { + in := msgs(t, `[{"role":"assistant","content":"hi","tool_calls":[{"id":"c1"}]}]`) + out, changed := cleanupOrphanToolCalls(in) + if !changed { + t.Fatal("changed=false,期望删除 tool_calls") + } + msg := out[0].(map[string]any) + if _, has := msg["tool_calls"]; has { + t.Error("孤儿调用应删除整个 tool_calls 键") + } + if msg["content"] != "hi" { + t.Errorf("content 被误改: %v", msg["content"]) + } + }) + + t.Run("孤儿结果整条删除", func(t *testing.T) { + in := msgs(t, `[{"role":"user","content":"x"},{"role":"tool","tool_call_id":"c9","content":"r"}]`) + out, changed := cleanupOrphanToolCalls(in) + if !changed { + t.Fatal("changed=false,期望删除孤儿结果") + } + assertSummary(t, summarize(out), []string{"user(-)"}) + }) + + t.Run("完整配对零改动", func(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c1"},{"id":"c2"}]}, + {"role":"tool","tool_call_id":"c1","content":"r1"}, + {"role":"tool","tool_call_id":"c2","content":"r2"} + ]`) + out, changed := cleanupOrphanToolCalls(in) + if changed { + t.Error("changed=true,完整配对不应改动") + } + assertSummary(t, summarize(out), []string{"assistant(c1,c2)", "tool(c1)", "tool(c2)"}) + }) + + t.Run("无工具流量零改动", func(t *testing.T) { + if _, changed := cleanupOrphanToolCalls(msgs(t, `[{"role":"user","content":"x"}]`)); changed { + t.Error("changed=true,无工具流量不应改动") + } + }) + + t.Run("空 id 双侧不识别为工具流量", func(t *testing.T) { + // 已知边界:id 为空的配对无法按 id 判定,两侧都不计入工具流量 → 原样透传。 + // 这是刻意保留的行为(不猜哪条结果属于哪条调用),此处钉桩以免被误改。 + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":""}]}, + {"role":"tool","tool_call_id":"","content":"r"} + ]`) + if _, changed := cleanupOrphanToolCalls(in); changed { + t.Error("changed=true,空 id 形态当前按原样透传处理") + } + }) +} + +// TestMergeAdjacentToolCallsEmptyArrayContent 空数组 content 也必须触发合并: +// 有客户端把"没有正文"发成 `content: []`(而不是 null),此前 emptyContent 只认 +// nil / 空串 → 背靠背的 assistant(tool_calls) 不合并 → 上游 deepseek 系判 11148。 +func TestMergeAdjacentToolCallsEmptyArrayContent(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":[],"tool_calls":[{"id":"c1","type":"function","function":{"name":"A","arguments":"{}"}}]}, + {"role":"assistant","content":[],"tool_calls":[{"id":"c2","type":"function","function":{"name":"B","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"r1"}, + {"role":"tool","tool_call_id":"c2","content":"r2"} + ]`) + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("content:[] 的两条 assistant(tool_calls) 未合并(11148 残留形态)") + } + if len(out) != 3 { + t.Fatalf("合并后长度 = %d, want 3", len(out)) + } + tcs, _ := out[0].(map[string]any)["tool_calls"].([]any) + if len(tcs) != 2 { + t.Fatalf("首条 tool_calls = %d, want 2", len(tcs)) + } + + // 合并只以后一条(被并入方)的 content 是否为空为判据:并入不会覆盖前一条的 + // content,所以前一条是**非空数组**时同样应当合并,且内容必须原样保留。 + in2 := msgs(t, `[ + {"role":"assistant","content":[{"type":"text","text":"hi"}],"tool_calls":[{"id":"c1","type":"function","function":{"name":"A","arguments":"{}"}}]}, + {"role":"assistant","content":[],"tool_calls":[{"id":"c2","type":"function","function":{"name":"B","arguments":"{}"}}]} + ]`) + out2, changed2 := mergeAdjacentToolCalls(in2) + if !changed2 { + t.Fatal("后一条 content 为空数组时应合并") + } + first, _ := out2[0].(map[string]any) + if c, ok := first["content"].([]any); !ok || len(c) != 1 { + t.Fatalf("合并后前一条 content 丢失: %#v", first["content"]) + } + if tcs, _ := first["tool_calls"].([]any); len(tcs) != 2 { + t.Fatalf("合并后 tool_calls = %d, want 2", len(tcs)) + } +} + +// TestFoldTextIntoPrevToolCall 反向形态:前一条是带 tool_calls 但没正文的 assistant, +// 本条是纯正文 assistant(正文排在工具调用之后)——折进前一条,合成 +// assistant(正文 + tool_calls),让"工具调用紧跟自己的结果"在两种拆分顺序下都成立。 +func TestFoldTextIntoPrevToolCall(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"A","arguments":"{}"}}]}, + {"role":"assistant","content":"我来解释一下"}, + {"role":"tool","tool_call_id":"c1","content":"r1"} + ]`) + out, changed := mergeAdjacentToolCalls(in) + if !changed { + t.Fatal("正文未折进前一条工具调用") + } + if len(out) != 2 { + t.Fatalf("合并后长度 = %d, want 2", len(out)) + } + first, _ := out[0].(map[string]any) + if first["content"] != "我来解释一下" { + t.Fatalf("content = %#v, want 正文", first["content"]) + } + if tcs, _ := first["tool_calls"].([]any); len(tcs) != 1 { + t.Fatalf("tool_calls = %d, want 1", len(tcs)) + } + // 结果紧跟其后,顺序合法(上游 11148 校验的关键) + if role, _ := out[1].(map[string]any)["role"].(string); role != "tool" { + t.Fatalf("第二条应为 tool,实际 %q", role) + } + + // 反向守卫:前一条没有 tool_calls 时不得折叠(那是两条独立 assistant 轮次) + in2 := msgs(t, `[ + {"role":"assistant","content":"第一轮"}, + {"role":"assistant","content":"第二轮"} + ]`) + if _, changed2 := mergeAdjacentToolCalls(in2); changed2 { + t.Fatal("前一条无 tool_calls 时不该折叠") + } + + // 反向守卫:正文是数组(可能含多模态块)时不折叠,交给 repack 原样处理 + in3 := msgs(t, `[ + {"role":"assistant","content":[],"tool_calls":[{"id":"c1","type":"function","function":{"name":"A","arguments":"{}"}}]}, + {"role":"assistant","content":[{"type":"text","text":"hi"}]} + ]`) + if _, changed3 := mergeAdjacentToolCalls(in3); changed3 { + t.Fatal("数组正文不该被折叠(会丢结构)") + } +} diff --git a/internal/upstream/transport.go b/internal/upstream/transport.go index ac5920b03cee5d08090c8bc7dfdf1cf87910e4f2..adbbc992445f077a544231a9d0b3b9a5f25cdb9d 100644 --- a/internal/upstream/transport.go +++ b/internal/upstream/transport.go @@ -1,116 +1,116 @@ -// transport.go 出站 Transport 构造的单一事实来源(连接层加固,吸收 kongjianguan -// 4 连击实测经验的前三件,见 .claude/reports/fork-scan-absorb.md T-2): -// 真正禁 h2 / TLS 握手超时 / 短 keepalive 探测,全参数集中定义可测试可调整。 -// 第四件 DisableKeepAlives 按报告 trade-off 不吸收(每请求 TLS 握手开销与连接 -// 复用意图相反),分析见 .claude/reports/transport-hardening.md。 -package upstream - -import ( - "crypto/tls" - "net" - "net/http" - "time" -) - -// 连接层参数集中定义(与 server/backoff.go 同风格:一处定义,测试可回读断言)。 -const ( - // dialTimeout TCP 连接建立上限。半死连接的第一道闸:连不上就快速失败 - // 轮转换号,不再干等系统 TCP 重传窗口(kongjianguan 实测半开 TCP 单次 - // TTFB 卡 936s——默认 Dialer 无超时上限)。 - dialTimeout = 10 * time.Second - // dialKeepAlive TCP keepalive 探测周期。默认 Dialer 2h 才发首个探测—— - // NAT 黑洞里 2h 足够连接半死且被复用。15s 周期让死连接在 15~30s 内被 - // 内核掐掉(RST/ETIMEDOUT),复用侧立即感知而非卡到重传窗口。 - dialKeepAlive = 15 * time.Second - // tlsHandshakeTimeout TLS 握手上限。现役此前完全缺失——握手挂起时无任何 - // 层兜底(ResponseHeaderTimeout 只在请求写完后才计时),只能干等到 - // HTTP.Client.Timeout(120s)。 - tlsHandshakeTimeout = 10 * time.Second - // idleConnTimeout 空闲连接池保留时长。从 90s 收到 30s:WAF 风暴后上游 - // NGI 常态性掐闲置连接,90s 池里的连接多半已死(kongjianguan 同款取值); - // 复用侧仍有 15s keepalive 兜底识别。 - idleConnTimeout = 30 * time.Second - // responseHeaderTimeout 聊天 SSE 首字节前(响应头)硬上限。从 120s 收到 - // 60s:kongjianguan 笔记实录成功请求 TTFB 曾到 16s(慢模型冷启动), - // 60s ≈ 3.75× 观测最坏健康首包,留足慢冷启动余量;同时把半死连接场景的 - // 单请求卡死从 2 分钟压到 1 分钟(MaxRotate 默认 3 次的最坏轮转从 6 分钟 - // 压到 3 分钟)。不取 kongjianguan 的 20s:其 20s 是 DisableKeepAlives+ - // timedConn 20s 写超时组合的取值,我们保留连接复用,须按自身慢冷启动 - // 观测留余量。语义核对(任务书设计纪律):ResponseHeaderTimeout 只计响应 - // 头到达前的时长,头到达后 SSE 长流不受影响(流中空闲由 IdleTimeout 监控, - // 见 idle.go),不误杀长流——transport_test.go 有显式回归。 - // - // 注意:本常量只是 newTransport 的构造默认,main.go 会按 config - // upstream.header_timeout_seconds 无条件覆盖。因此生产生效值 = config - // 解析值(未配置时 normalize 回落 timeout_seconds,默认 120),本 60s 仅作 - // 「Config 未接线/测试裸用」时的安全网——与 config.example.json 的取值 - // 对齐避免三处口径漂移(transport 60 / config 回落 120 / example 60)。 - responseHeaderTimeout = 60 * time.Second -) - -// maxIdleConns / maxIdleConnsPerHost 连接池容量(既有值,一并集中定义)。 -const ( - maxIdleConns = 100 - maxIdleConnsPerHost = 20 -) - -// newDialer 构造出站拨号器(DialContext 的 Timeout/KeepAlive 参数集中于此, -// 供测试回读断言)。 -func newDialer() *net.Dialer { - return &net.Dialer{ - Timeout: dialTimeout, - KeepAlive: dialKeepAlive, - } -} - -// newTransport 构造共享出站 Transport(HTTP 与 ChatHTTP 同一实例,连接池不重复)。 -// 分两层防半死连接: -// - TLS 层:空 TLSNextProto 真正禁 h2(kongjianguan 二次修正的实证:ForceAttemptHTTP2=false -// 只对自定义 Dial 生效,默认 TLS 经 ALPN 仍协商出 h2,半死 h2 流复用表现为 -// "http2: timeout awaiting response headers"——唯一正确写法是置空映射,让 ALPN -// 完成后无 h2 协议可用,连接退回 HTTP/1.1)。 -// - TCP 层:DialContext 10s 建连上限 + 15s keepalive 探测,半开连接在建立期 -// 和复用期都能被快速识别(见 dialTimeout/dialKeepAlive 注释)。 -func newTransport() *http.Transport { - dialer := newDialer() - return &http.Transport{ - DialContext: dialer.DialContext, - // 空 TLSNextProto(非 nil)真正禁 h2:见函数注释。必须 make 而非 nil—— - // nil 表示「让标准库注入默认 h2 映射」(kongjianguan 实测:设 - // ForceAttemptHTTP2=false 后日志仍报 h2 timeout,正是这个陷阱)。 - TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), - TLSHandshakeTimeout: tlsHandshakeTimeout, - MaxIdleConns: maxIdleConns, - MaxIdleConnsPerHost: maxIdleConnsPerHost, - IdleConnTimeout: idleConnTimeout, - ResponseHeaderTimeout: responseHeaderTimeout, - } -} - -// closeIdler 实现该接口的 RoundTripper 支持清空空闲连接池(*http.Transport、 -// http2.Transport 等均满足;测试注入的自定义 RoundTripper 可选择性实现)。 -type closeIdler interface { - CloseIdleConnections() -} - -// roundTripCloseIdle 在传输层请求失败后清掉 rt 所属 Transport 的空闲连接池 -// (kongjianguan 第 4 件:失败连接可能仍留在空闲池里,等 IdleConnTimeout 才 -// 过期,下一个请求会继续捡到它)。 -// -// 挂载点(任务书「评估挂载点:错误分类处理处」的结论):错误分类(Classify) -// 只见业务信封——传输层失败根本没有 body 可分类(见 doJSON/ChatStreamContext -// 对 read body 失败的处理:不进 Classify、不罚号)。这类失败的正确处理正是 -// 连接层的池清理,故挂在与 Do 并列的传输层出口(ChatStreamContext 的 Do 错误 -// 分支),而非 applyErrorPolicy。 -// -// 关闭是 best-effort:rt 为 nil 或未实现 closeIdler(如测试注入的 rtFunc)时 -// 静默跳过。CloseIdleConnections 只关空闲连接,不影响在途请求;瞬时代价是 -// 下个请求多一次 TCP+TLS 握手,与半死连接被复用卡 60s 的风险完全不成比例。 -func roundTripCloseIdle(rt http.RoundTripper) { - if rt == nil { - return - } - if ci, ok := rt.(closeIdler); ok { - ci.CloseIdleConnections() - } -} +// transport.go 出站 Transport 构造的单一事实来源(连接层加固,吸收 kongjianguan +// 4 连击实测经验的前三件,见 .claude/reports/fork-scan-absorb.md T-2): +// 真正禁 h2 / TLS 握手超时 / 短 keepalive 探测,全参数集中定义可测试可调整。 +// 第四件 DisableKeepAlives 按报告 trade-off 不吸收(每请求 TLS 握手开销与连接 +// 复用意图相反),分析见 .claude/reports/transport-hardening.md。 +package upstream + +import ( + "crypto/tls" + "net" + "net/http" + "time" +) + +// 连接层参数集中定义(与 server/backoff.go 同风格:一处定义,测试可回读断言)。 +const ( + // dialTimeout TCP 连接建立上限。半死连接的第一道闸:连不上就快速失败 + // 轮转换号,不再干等系统 TCP 重传窗口(kongjianguan 实测半开 TCP 单次 + // TTFB 卡 936s——默认 Dialer 无超时上限)。 + dialTimeout = 10 * time.Second + // dialKeepAlive TCP keepalive 探测周期。默认 Dialer 2h 才发首个探测—— + // NAT 黑洞里 2h 足够连接半死且被复用。15s 周期让死连接在 15~30s 内被 + // 内核掐掉(RST/ETIMEDOUT),复用侧立即感知而非卡到重传窗口。 + dialKeepAlive = 15 * time.Second + // tlsHandshakeTimeout TLS 握手上限。现役此前完全缺失——握手挂起时无任何 + // 层兜底(ResponseHeaderTimeout 只在请求写完后才计时),只能干等到 + // HTTP.Client.Timeout(120s)。 + tlsHandshakeTimeout = 10 * time.Second + // idleConnTimeout 空闲连接池保留时长。从 90s 收到 30s:WAF 风暴后上游 + // NGI 常态性掐闲置连接,90s 池里的连接多半已死(kongjianguan 同款取值); + // 复用侧仍有 15s keepalive 兜底识别。 + idleConnTimeout = 30 * time.Second + // responseHeaderTimeout 聊天 SSE 首字节前(响应头)硬上限。从 120s 收到 + // 60s:kongjianguan 笔记实录成功请求 TTFB 曾到 16s(慢模型冷启动), + // 60s ≈ 3.75× 观测最坏健康首包,留足慢冷启动余量;同时把半死连接场景的 + // 单请求卡死从 2 分钟压到 1 分钟(MaxRotate 默认 3 次的最坏轮转从 6 分钟 + // 压到 3 分钟)。不取 kongjianguan 的 20s:其 20s 是 DisableKeepAlives+ + // timedConn 20s 写超时组合的取值,我们保留连接复用,须按自身慢冷启动 + // 观测留余量。语义核对(任务书设计纪律):ResponseHeaderTimeout 只计响应 + // 头到达前的时长,头到达后 SSE 长流不受影响(流中空闲由 IdleTimeout 监控, + // 见 idle.go),不误杀长流——transport_test.go 有显式回归。 + // + // 注意:本常量只是 newTransport 的构造默认,main.go 会按 config + // upstream.header_timeout_seconds 无条件覆盖。因此生产生效值 = config + // 解析值(未配置时 normalize 回落 timeout_seconds,默认 120),本 60s 仅作 + // 「Config 未接线/测试裸用」时的安全网——与 config.example.json 的取值 + // 对齐避免三处口径漂移(transport 60 / config 回落 120 / example 60)。 + responseHeaderTimeout = 60 * time.Second +) + +// maxIdleConns / maxIdleConnsPerHost 连接池容量(既有值,一并集中定义)。 +const ( + maxIdleConns = 100 + maxIdleConnsPerHost = 20 +) + +// newDialer 构造出站拨号器(DialContext 的 Timeout/KeepAlive 参数集中于此, +// 供测试回读断言)。 +func newDialer() *net.Dialer { + return &net.Dialer{ + Timeout: dialTimeout, + KeepAlive: dialKeepAlive, + } +} + +// newTransport 构造共享出站 Transport(HTTP 与 ChatHTTP 同一实例,连接池不重复)。 +// 分两层防半死连接: +// - TLS 层:空 TLSNextProto 真正禁 h2(kongjianguan 二次修正的实证:ForceAttemptHTTP2=false +// 只对自定义 Dial 生效,默认 TLS 经 ALPN 仍协商出 h2,半死 h2 流复用表现为 +// "http2: timeout awaiting response headers"——唯一正确写法是置空映射,让 ALPN +// 完成后无 h2 协议可用,连接退回 HTTP/1.1)。 +// - TCP 层:DialContext 10s 建连上限 + 15s keepalive 探测,半开连接在建立期 +// 和复用期都能被快速识别(见 dialTimeout/dialKeepAlive 注释)。 +func newTransport() *http.Transport { + dialer := newDialer() + return &http.Transport{ + DialContext: dialer.DialContext, + // 空 TLSNextProto(非 nil)真正禁 h2:见函数注释。必须 make 而非 nil—— + // nil 表示「让标准库注入默认 h2 映射」(kongjianguan 实测:设 + // ForceAttemptHTTP2=false 后日志仍报 h2 timeout,正是这个陷阱)。 + TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + TLSHandshakeTimeout: tlsHandshakeTimeout, + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + } +} + +// closeIdler 实现该接口的 RoundTripper 支持清空空闲连接池(*http.Transport、 +// http2.Transport 等均满足;测试注入的自定义 RoundTripper 可选择性实现)。 +type closeIdler interface { + CloseIdleConnections() +} + +// roundTripCloseIdle 在传输层请求失败后清掉 rt 所属 Transport 的空闲连接池 +// (kongjianguan 第 4 件:失败连接可能仍留在空闲池里,等 IdleConnTimeout 才 +// 过期,下一个请求会继续捡到它)。 +// +// 挂载点(任务书「评估挂载点:错误分类处理处」的结论):错误分类(Classify) +// 只见业务信封——传输层失败根本没有 body 可分类(见 doJSON/ChatStreamContext +// 对 read body 失败的处理:不进 Classify、不罚号)。这类失败的正确处理正是 +// 连接层的池清理,故挂在与 Do 并列的传输层出口(ChatStreamContext 的 Do 错误 +// 分支),而非 applyErrorPolicy。 +// +// 关闭是 best-effort:rt 为 nil 或未实现 closeIdler(如测试注入的 rtFunc)时 +// 静默跳过。CloseIdleConnections 只关空闲连接,不影响在途请求;瞬时代价是 +// 下个请求多一次 TCP+TLS 握手,与半死连接被复用卡 60s 的风险完全不成比例。 +func roundTripCloseIdle(rt http.RoundTripper) { + if rt == nil { + return + } + if ci, ok := rt.(closeIdler); ok { + ci.CloseIdleConnections() + } +} diff --git a/internal/upstream/travel.go b/internal/upstream/travel.go index 44fe98cff0f4d665a5e0865cd67e011948084ee2..c862f196bc9c5d53f20dad7eb5e501549227508e 100644 --- a/internal/upstream/travel.go +++ b/internal/upstream/travel.go @@ -1,166 +1,166 @@ -// travel.go growth 域「猫猫旅行」接口:状态查询 / 派出 / 领奖 / 领养 / 协议。 -// 全部走 chatBase(copilot.tencent.com,不带 /v2 前缀)+ BillingHeaders,信封同 doJSON。 -package upstream - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// growth 域路径(实测)。 -const ( - travelStatusPath = "/activity/growth/buddy/travel/status" - travelDepartPath = "/activity/growth/buddy/travel/depart" - travelClaimPath = "/activity/growth/buddy/travel/claim" - buddyInfoPath = "/activity/growth/buddy/info" - buddyFirstPath = "/activity/growth/buddy/first" - buddyAgreementPath = "/activity/growth/buddy/agreement" - streakPath = "/activity/growth/streak" -) - -// buddyTaskIncompleteMarker 领养门槛未达标的业务错误关键词(HTTP 400 时出现)。 -const buddyTaskIncompleteMarker = "first_buddy task not completed yet" - -// Buddy 账号当前猫档案;nil(data.buddy 为 null)表示无猫。 -type Buddy struct { - ID int64 `json:"id"` - Name string `json:"name"` -} - -// TravelState 猫猫旅行状态。 -type TravelState struct { - State string `json:"state"` // idle / traveling / arrived - DailyLimitReached bool `json:"daily_limit_reached"` // 今日已派出过(自然日 00:00 CST 重置) - RecordID int64 `json:"record_id"` // 在途/到站记录 id,claim 必带 - RewardCredit int64 `json:"reward_credit"` // 到站可领奖励积分 -} - -// growthJSON 发 growth 域请求并解信封;body 为 nil 时不带请求体。 -// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 -func (c *Client) growthJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { - var rdr io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) - if err != nil { - return nil, err - } - c.BillingHeaders(req, a) - return c.doJSON(req) -} - -// TravelStatus 查询猫猫旅行状态。 -func (c *Client) TravelStatus(a *auth.Auth) (*TravelState, error) { - data, err := c.growthJSON(a, http.MethodGet, travelStatusPath, nil) - if err != nil { - return nil, err - } - var st TravelState - if err := json.Unmarshal(data, &st); err != nil { - return nil, err - } - return &st, nil -} - -// TravelDepart 派出猫旅行;locationID 实测 1~4(收益/时长区间相同)。 -func (c *Client) TravelDepart(a *auth.Auth, locationID int) error { - _, err := c.growthJSON(a, http.MethodPost, travelDepartPath, map[string]any{"location_id": locationID}) - return err -} - -// TravelClaim 领取到站奖励,返回 reward_credit。 -func (c *Client) TravelClaim(a *auth.Auth, recordID int64) (int64, error) { - data, err := c.growthJSON(a, http.MethodPost, travelClaimPath, map[string]any{"record_id": recordID}) - if err != nil { - return 0, err - } - var resp struct { - RewardCredit int64 `json:"reward_credit"` - } - if len(data) > 0 { - // 奖励字段缺失不视为失败:调用方按 0 记日志即可。 - _ = json.Unmarshal(data, &resp) - } - return resp.RewardCredit, nil -} - -// BuddyInfo 查询当前猫档案;返回 (nil, nil) 表示无猫(data.buddy 为 null)。 -func (c *Client) BuddyInfo(a *auth.Auth) (*Buddy, error) { - data, err := c.growthJSON(a, http.MethodGet, buddyInfoPath, nil) - if err != nil { - return nil, err - } - var resp struct { - Buddy json.RawMessage `json:"buddy"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, err - } - // null / 缺字段 / 空对象都按无猫处理。 - trimmed := strings.TrimSpace(string(resp.Buddy)) - if trimmed == "" || trimmed == "null" { - return nil, nil - } - var b Buddy - if err := json.Unmarshal(resp.Buddy, &b); err != nil { - return nil, err - } - return &b, nil -} - -// BuddyFirst 领养第一只猫。无猫且已过 conversation 门槛时送 300 分。 -// 门槛未达标返回 HTTP 400(见 IsBuddyTaskIncomplete),属预期行为,调用方静默跳过。 -func (c *Client) BuddyFirst(a *auth.Auth) error { - _, err := c.growthJSON(a, http.MethodPost, buddyFirstPath, map[string]any{}) - return err -} - -// BuddyAgreement 同意协议(幂等,重复调用无副作用)。 -func (c *Client) BuddyAgreement(a *auth.Auth) error { - _, err := c.growthJSON(a, http.MethodPost, buddyAgreementPath, map[string]any{"agree": true}) - return err -} - -// GrowthStreak 查询连登天数(只读 oracle)。响应 `data.streak.days`(probe_active.py -// 实测口径:`(s.get("data", {}).get("streak", {}) or {}).get("days")`)。 -// GET 失败(HTTP 非 2xx / 业务 code != 0)返回 *Error;缺 streak/days 字段返回 0 -// (days==0 即活跃自检的「上报 200 但静默丢弃」告警信号)。 -func (c *Client) GrowthStreak(a *auth.Auth) (int, error) { - data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) - if err != nil { - return 0, err - } - var resp struct { - Streak struct { - Days int `json:"days"` - } `json:"streak"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return 0, err - } - return resp.Streak.Days, nil -} - -// IsBuddyTaskIncomplete 判定「领养门槛未达标」:HTTP 400 + first_buddy 关键词。 -// 该错误当日不应重试(避免对上游重试轰炸)。 -func IsBuddyTaskIncomplete(err error) bool { - if err == nil { - return false - } - var ue *Error - if !errors.As(err, &ue) || ue.Status != http.StatusBadRequest { - return false - } - return strings.Contains(strings.ToLower(ue.Msg), buddyTaskIncompleteMarker) -} +// travel.go growth 域「猫猫旅行」接口:状态查询 / 派出 / 领奖 / 领养 / 协议。 +// 全部走 chatBase(copilot.tencent.com,不带 /v2 前缀)+ BillingHeaders,信封同 doJSON。 +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// growth 域路径(实测)。 +const ( + travelStatusPath = "/activity/growth/buddy/travel/status" + travelDepartPath = "/activity/growth/buddy/travel/depart" + travelClaimPath = "/activity/growth/buddy/travel/claim" + buddyInfoPath = "/activity/growth/buddy/info" + buddyFirstPath = "/activity/growth/buddy/first" + buddyAgreementPath = "/activity/growth/buddy/agreement" + streakPath = "/activity/growth/streak" +) + +// buddyTaskIncompleteMarker 领养门槛未达标的业务错误关键词(HTTP 400 时出现)。 +const buddyTaskIncompleteMarker = "first_buddy task not completed yet" + +// Buddy 账号当前猫档案;nil(data.buddy 为 null)表示无猫。 +type Buddy struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// TravelState 猫猫旅行状态。 +type TravelState struct { + State string `json:"state"` // idle / traveling / arrived + DailyLimitReached bool `json:"daily_limit_reached"` // 今日已派出过(自然日 00:00 CST 重置) + RecordID int64 `json:"record_id"` // 在途/到站记录 id,claim 必带 + RewardCredit int64 `json:"reward_credit"` // 到站可领奖励积分 +} + +// growthJSON 发 growth 域请求并解信封;body 为 nil 时不带请求体。 +// 错误语义与 doJSON 一致:HTTP 非 2xx / 业务 code != 0 → *Error。 +func (c *Client) growthJSON(a *auth.Auth, method, path string, body any) (json.RawMessage, error) { + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.chatBase(a)+path, rdr) + if err != nil { + return nil, err + } + c.BillingHeaders(req, a) + return c.doJSON(req) +} + +// TravelStatus 查询猫猫旅行状态。 +func (c *Client) TravelStatus(a *auth.Auth) (*TravelState, error) { + data, err := c.growthJSON(a, http.MethodGet, travelStatusPath, nil) + if err != nil { + return nil, err + } + var st TravelState + if err := json.Unmarshal(data, &st); err != nil { + return nil, err + } + return &st, nil +} + +// TravelDepart 派出猫旅行;locationID 实测 1~4(收益/时长区间相同)。 +func (c *Client) TravelDepart(a *auth.Auth, locationID int) error { + _, err := c.growthJSON(a, http.MethodPost, travelDepartPath, map[string]any{"location_id": locationID}) + return err +} + +// TravelClaim 领取到站奖励,返回 reward_credit。 +func (c *Client) TravelClaim(a *auth.Auth, recordID int64) (int64, error) { + data, err := c.growthJSON(a, http.MethodPost, travelClaimPath, map[string]any{"record_id": recordID}) + if err != nil { + return 0, err + } + var resp struct { + RewardCredit int64 `json:"reward_credit"` + } + if len(data) > 0 { + // 奖励字段缺失不视为失败:调用方按 0 记日志即可。 + _ = json.Unmarshal(data, &resp) + } + return resp.RewardCredit, nil +} + +// BuddyInfo 查询当前猫档案;返回 (nil, nil) 表示无猫(data.buddy 为 null)。 +func (c *Client) BuddyInfo(a *auth.Auth) (*Buddy, error) { + data, err := c.growthJSON(a, http.MethodGet, buddyInfoPath, nil) + if err != nil { + return nil, err + } + var resp struct { + Buddy json.RawMessage `json:"buddy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + // null / 缺字段 / 空对象都按无猫处理。 + trimmed := strings.TrimSpace(string(resp.Buddy)) + if trimmed == "" || trimmed == "null" { + return nil, nil + } + var b Buddy + if err := json.Unmarshal(resp.Buddy, &b); err != nil { + return nil, err + } + return &b, nil +} + +// BuddyFirst 领养第一只猫。无猫且已过 conversation 门槛时送 300 分。 +// 门槛未达标返回 HTTP 400(见 IsBuddyTaskIncomplete),属预期行为,调用方静默跳过。 +func (c *Client) BuddyFirst(a *auth.Auth) error { + _, err := c.growthJSON(a, http.MethodPost, buddyFirstPath, map[string]any{}) + return err +} + +// BuddyAgreement 同意协议(幂等,重复调用无副作用)。 +func (c *Client) BuddyAgreement(a *auth.Auth) error { + _, err := c.growthJSON(a, http.MethodPost, buddyAgreementPath, map[string]any{"agree": true}) + return err +} + +// GrowthStreak 查询连登天数(只读 oracle)。响应 `data.streak.days`(probe_active.py +// 实测口径:`(s.get("data", {}).get("streak", {}) or {}).get("days")`)。 +// GET 失败(HTTP 非 2xx / 业务 code != 0)返回 *Error;缺 streak/days 字段返回 0 +// (days==0 即活跃自检的「上报 200 但静默丢弃」告警信号)。 +func (c *Client) GrowthStreak(a *auth.Auth) (int, error) { + data, err := c.growthJSON(a, http.MethodGet, streakPath, nil) + if err != nil { + return 0, err + } + var resp struct { + Streak struct { + Days int `json:"days"` + } `json:"streak"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, err + } + return resp.Streak.Days, nil +} + +// IsBuddyTaskIncomplete 判定「领养门槛未达标」:HTTP 400 + first_buddy 关键词。 +// 该错误当日不应重试(避免对上游重试轰炸)。 +func IsBuddyTaskIncomplete(err error) bool { + if err == nil { + return false + } + var ue *Error + if !errors.As(err, &ue) || ue.Status != http.StatusBadRequest { + return false + } + return strings.Contains(strings.ToLower(ue.Msg), buddyTaskIncompleteMarker) +} diff --git a/internal/upstream/travel_test.go b/internal/upstream/travel_test.go index f57620b334767140e7eb9ad4cba387e16df6b56b..b2c5e3f7e1e83c65029414dd679f2879c1e1e061 100644 --- a/internal/upstream/travel_test.go +++ b/internal/upstream/travel_test.go @@ -1,227 +1,227 @@ -package upstream - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// travelPath 断言请求打到 growth 域的正确路径(BASE 走 chatBase,无 /v2 前缀)。 -func travelPath(r *http.Request, want string) error { - if r.URL.Path != want { - return errors.New("wrong path: " + r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer at" { - return errors.New("missing Authorization") - } - if r.Header.Get("X-User-Id") != "u1" { - return errors.New("missing X-User-Id") - } - return nil -} - -func TestTravelStatusParsesFields(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodGet { - return nil, errors.New("want GET") - } - if err := travelPath(r, "/activity/growth/buddy/travel/status"); err != nil { - return nil, err - } - return jsonResp(200, `{"code":0,"msg":"ok","data":{"state":"arrived","daily_limit_reached":true,"record_id":42,"reward_credit":7}}`), nil - }) - st, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil { - t.Fatalf("travel status: %v", err) - } - if st.State != "arrived" || !st.DailyLimitReached || st.RecordID != 42 || st.RewardCredit != 7 { - t.Errorf("state=%+v", st) - } -} - -// TestTravelStatusBusinessError 上游 400 且 body 仍是 {code,msg,data} 信封时, -// 应被 doJSON 归一为 *Error(带 HTTP 状态码),而不是解析失败。 -func TestTravelStatusBusinessError(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(400, `{"code":400,"msg":"no active buddy","data":null}`), nil - }) - _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) - var ue *Error - if !errors.As(err, &ue) { - t.Fatalf("want *Error, got %T %v", err, err) - } - if ue.Status != 400 || ue.Kind != ErrClient { - t.Errorf("status=%d kind=%v", ue.Status, ue.Kind) - } -} - -func TestTravelDepartSendsLocationID(t *testing.T) { - var got []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodPost { - return nil, errors.New("want POST") - } - if err := travelPath(r, "/activity/growth/buddy/travel/depart"); err != nil { - return nil, err - } - got, _ = io.ReadAll(r.Body) - return jsonResp(200, `{"code":0,"msg":"ok","data":{}}`), nil - }) - if err := c.TravelDepart(&auth.Auth{AccessToken: "at", UID: "u1"}, 4); err != nil { - t.Fatalf("depart: %v", err) - } - var body map[string]any - if err := json.Unmarshal(got, &body); err != nil { - t.Fatalf("depart body: %v (%s)", err, got) - } - if n, _ := body["location_id"].(float64); int(n) != 4 { - t.Errorf("location_id=%v want 4 (body=%s)", body["location_id"], got) - } -} - -func TestTravelClaimReturnsReward(t *testing.T) { - var got []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodPost { - return nil, errors.New("want POST") - } - if err := travelPath(r, "/activity/growth/buddy/travel/claim"); err != nil { - return nil, err - } - got, _ = io.ReadAll(r.Body) - return jsonResp(200, `{"code":0,"msg":"ok","data":{"reward_credit":9}}`), nil - }) - reward, err := c.TravelClaim(&auth.Auth{AccessToken: "at", UID: "u1"}, 42) - if err != nil { - t.Fatalf("claim: %v", err) - } - if reward != 9 { - t.Errorf("reward=%d want 9", reward) - } - if !bytes.Contains(got, []byte(`"record_id":42`)) { - t.Errorf("claim body missing record_id: %s", got) - } -} - -// TestBuddyInfoNullMeansNoBuddy data.buddy 为 null → 返回 nil 表示无猫。 -func TestBuddyInfoNullMeansNoBuddy(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodGet { - return nil, errors.New("want GET") - } - if err := travelPath(r, "/activity/growth/buddy/info"); err != nil { - return nil, err - } - return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":null}}`), nil - }) - b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil { - t.Fatalf("buddy info: %v", err) - } - if b != nil { - t.Errorf("buddy=%+v want nil (无猫)", b) - } -} - -func TestBuddyInfoPresent(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":0,"data":{"buddy":{"id":7,"name":"档案喵 R"}}}`), nil - }) - b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err != nil || b == nil { - t.Fatalf("buddy=%+v err=%v", b, err) - } - if b.Name != "档案喵 R" || b.ID != 7 { - t.Errorf("buddy=%+v", b) - } -} - -func TestBuddyAgreementIdempotent(t *testing.T) { - var got []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodPost { - return nil, errors.New("want POST") - } - if err := travelPath(r, "/activity/growth/buddy/agreement"); err != nil { - return nil, err - } - got, _ = io.ReadAll(r.Body) - return jsonResp(200, `{"code":0,"msg":"ok","data":{"agreed":true}}`), nil - }) - if err := c.BuddyAgreement(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { - t.Fatalf("agreement: %v", err) - } - if !bytes.Contains(got, []byte(`"agree":true`)) { - t.Errorf("agreement body=%s", got) - } -} - -func TestBuddyFirstPath(t *testing.T) { - var got []byte - c := testClient(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodPost { - return nil, errors.New("want POST") - } - if err := travelPath(r, "/activity/growth/buddy/first"); err != nil { - return nil, err - } - got, _ = io.ReadAll(r.Body) - return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":{"id":1}}}`), nil - }) - if err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { - t.Fatalf("first: %v", err) - } - if string(bytes.TrimSpace(got)) != "{}" { - t.Errorf("first body=%q want {}", got) - } -} - -// TestIsBuddyTaskIncomplete conversation 门槛未达标:HTTP 400 + first_buddy 关键词。 -func TestIsBuddyTaskIncomplete(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(400, `{"code":400,"msg":"first_buddy task not completed yet"}`), nil - }) - err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}) - if err == nil { - t.Fatal("want error") - } - if !IsBuddyTaskIncomplete(err) { - t.Errorf("err=%v should be classified as 门槛未达标", err) - } -} - -func TestIsBuddyTaskIncompleteNegative(t *testing.T) { - cases := []struct { - name string - err error - want bool - }{ - {"nil", nil, false}, - {"普通错误", errors.New("boom"), false}, - {"400 其他业务错误", &Error{Kind: ErrClient, Status: 400, Msg: "no active buddy"}, false}, - {"500 含关键词也不认", &Error{Kind: ErrServer, Status: 500, Msg: "first_buddy task not completed yet"}, false}, - {"401 会话失效", &Error{Kind: ErrSessionDead, Status: 401, Msg: "Offline user session not found"}, false}, - } - for _, c := range cases { - if got := IsBuddyTaskIncomplete(c.err); got != c.want { - t.Errorf("%s: got %v want %v", c.name, got, c.want) - } - } -} - -// TestTravel401ClassifiedSessionDead 401 交由调用方跳过本轮(巡检不强刷 token)。 -func TestTravel401ClassifiedSessionDead(t *testing.T) { - c := testClient(func(r *http.Request) (*http.Response, error) { - return jsonResp(401, `{"code":12153,"msg":"Offline user session not found"}`), nil - }) - _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) - var ue *Error - if !errors.As(err, &ue) || ue.Kind != ErrSessionDead { - t.Fatalf("err=%v want ErrSessionDead", err) - } -} +package upstream + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// travelPath 断言请求打到 growth 域的正确路径(BASE 走 chatBase,无 /v2 前缀)。 +func travelPath(r *http.Request, want string) error { + if r.URL.Path != want { + return errors.New("wrong path: " + r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer at" { + return errors.New("missing Authorization") + } + if r.Header.Get("X-User-Id") != "u1" { + return errors.New("missing X-User-Id") + } + return nil +} + +func TestTravelStatusParsesFields(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet { + return nil, errors.New("want GET") + } + if err := travelPath(r, "/activity/growth/buddy/travel/status"); err != nil { + return nil, err + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"state":"arrived","daily_limit_reached":true,"record_id":42,"reward_credit":7}}`), nil + }) + st, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("travel status: %v", err) + } + if st.State != "arrived" || !st.DailyLimitReached || st.RecordID != 42 || st.RewardCredit != 7 { + t.Errorf("state=%+v", st) + } +} + +// TestTravelStatusBusinessError 上游 400 且 body 仍是 {code,msg,data} 信封时, +// 应被 doJSON 归一为 *Error(带 HTTP 状态码),而不是解析失败。 +func TestTravelStatusBusinessError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(400, `{"code":400,"msg":"no active buddy","data":null}`), nil + }) + _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + var ue *Error + if !errors.As(err, &ue) { + t.Fatalf("want *Error, got %T %v", err, err) + } + if ue.Status != 400 || ue.Kind != ErrClient { + t.Errorf("status=%d kind=%v", ue.Status, ue.Kind) + } +} + +func TestTravelDepartSendsLocationID(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/travel/depart"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{}}`), nil + }) + if err := c.TravelDepart(&auth.Auth{AccessToken: "at", UID: "u1"}, 4); err != nil { + t.Fatalf("depart: %v", err) + } + var body map[string]any + if err := json.Unmarshal(got, &body); err != nil { + t.Fatalf("depart body: %v (%s)", err, got) + } + if n, _ := body["location_id"].(float64); int(n) != 4 { + t.Errorf("location_id=%v want 4 (body=%s)", body["location_id"], got) + } +} + +func TestTravelClaimReturnsReward(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/travel/claim"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"reward_credit":9}}`), nil + }) + reward, err := c.TravelClaim(&auth.Auth{AccessToken: "at", UID: "u1"}, 42) + if err != nil { + t.Fatalf("claim: %v", err) + } + if reward != 9 { + t.Errorf("reward=%d want 9", reward) + } + if !bytes.Contains(got, []byte(`"record_id":42`)) { + t.Errorf("claim body missing record_id: %s", got) + } +} + +// TestBuddyInfoNullMeansNoBuddy data.buddy 为 null → 返回 nil 表示无猫。 +func TestBuddyInfoNullMeansNoBuddy(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet { + return nil, errors.New("want GET") + } + if err := travelPath(r, "/activity/growth/buddy/info"); err != nil { + return nil, err + } + return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":null}}`), nil + }) + b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("buddy info: %v", err) + } + if b != nil { + t.Errorf("buddy=%+v want nil (无猫)", b) + } +} + +func TestBuddyInfoPresent(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":0,"data":{"buddy":{"id":7,"name":"档案喵 R"}}}`), nil + }) + b, err := c.BuddyInfo(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil || b == nil { + t.Fatalf("buddy=%+v err=%v", b, err) + } + if b.Name != "档案喵 R" || b.ID != 7 { + t.Errorf("buddy=%+v", b) + } +} + +func TestBuddyAgreementIdempotent(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/agreement"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"agreed":true}}`), nil + }) + if err := c.BuddyAgreement(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { + t.Fatalf("agreement: %v", err) + } + if !bytes.Contains(got, []byte(`"agree":true`)) { + t.Errorf("agreement body=%s", got) + } +} + +func TestBuddyFirstPath(t *testing.T) { + var got []byte + c := testClient(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + return nil, errors.New("want POST") + } + if err := travelPath(r, "/activity/growth/buddy/first"); err != nil { + return nil, err + } + got, _ = io.ReadAll(r.Body) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"buddy":{"id":1}}}`), nil + }) + if err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}); err != nil { + t.Fatalf("first: %v", err) + } + if string(bytes.TrimSpace(got)) != "{}" { + t.Errorf("first body=%q want {}", got) + } +} + +// TestIsBuddyTaskIncomplete conversation 门槛未达标:HTTP 400 + first_buddy 关键词。 +func TestIsBuddyTaskIncomplete(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(400, `{"code":400,"msg":"first_buddy task not completed yet"}`), nil + }) + err := c.BuddyFirst(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err == nil { + t.Fatal("want error") + } + if !IsBuddyTaskIncomplete(err) { + t.Errorf("err=%v should be classified as 门槛未达标", err) + } +} + +func TestIsBuddyTaskIncompleteNegative(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"普通错误", errors.New("boom"), false}, + {"400 其他业务错误", &Error{Kind: ErrClient, Status: 400, Msg: "no active buddy"}, false}, + {"500 含关键词也不认", &Error{Kind: ErrServer, Status: 500, Msg: "first_buddy task not completed yet"}, false}, + {"401 会话失效", &Error{Kind: ErrSessionDead, Status: 401, Msg: "Offline user session not found"}, false}, + } + for _, c := range cases { + if got := IsBuddyTaskIncomplete(c.err); got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +// TestTravel401ClassifiedSessionDead 401 交由调用方跳过本轮(巡检不强刷 token)。 +func TestTravel401ClassifiedSessionDead(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(401, `{"code":12153,"msg":"Offline user session not found"}`), nil + }) + _, err := c.TravelStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + var ue *Error + if !errors.As(err, &ue) || ue.Kind != ErrSessionDead { + t.Fatalf("err=%v want ErrSessionDead", err) + } +} diff --git a/internal/upstream/trial.go b/internal/upstream/trial.go index 8959a9204e8e5ddcb621fa12d49c932a160c99a5..3c760ac954d71ff4cc82bb7338cba08d99a0e005 100644 --- a/internal/upstream/trial.go +++ b/internal/upstream/trial.go @@ -1,49 +1,49 @@ -// trial.go global 专属「一次性 trial 加油包」领取:POST {billingBase}/billing/ide/trial。 -// 仅 global 账号适用(CN 无此端点);幂等码 14051 = 已领过(视为正常,非错误)。 -// 这是 global 唯一天然的积分增益动作(无签到/任务中心,见 PLAN D4)。 -package upstream - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// trialPath global trial 加油包端点(Maquer/workbuddy-checkin 实测)。 -const trialPath = "/billing/ide/trial" - -// trialAlreadyMarkers 幂等码 14051「已领取过」的两种指纹: -// - "code=14051":doJSON 对 HTTP 200 + 业务 code 非 0 时拼出的 Msg 格式; -// - `"code":14051`:HTTP ≥400 时 doJSON 把原始 JSON body 直接塞进 Msg。 -var trialAlreadyMarkers = []string{"code=14051", `"code":14051`} - -// ClaimTrial 领取一次性 trial 加油包。仅 global 账号可调(CN 无此端点): -// 非 global → 直接报错(工具层还会再拦一道,这里是客户端侧防线)。 -// 返回 claimed:true=成功新领;false=已领过(幂等,不算失败)。 -func (c *Client) ClaimTrial(a *auth.Auth) (claimed bool, err error) { - if a == nil || a.Realm() != "global" { - return false, fmt.Errorf("claim trial: only global accounts") - } - _, err = c.billingJSON(a, http.MethodPost, trialPath, nil) - if err != nil { - var ue *Error - if errors.As(err, &ue) && trialAlreadyErr(ue.Msg) { - return false, nil // 已领过:幂等成功,非错误 - } - return false, err - } - return true, nil -} - -// trialAlreadyErr 判定错误 Msg 是否携带幂等码 14051(已领取过)。 -func trialAlreadyErr(msg string) bool { - for _, m := range trialAlreadyMarkers { - if strings.Contains(msg, m) { - return true - } - } - return false -} +// trial.go global 专属「一次性 trial 加油包」领取:POST {billingBase}/billing/ide/trial。 +// 仅 global 账号适用(CN 无此端点);幂等码 14051 = 已领过(视为正常,非错误)。 +// 这是 global 唯一天然的积分增益动作(无签到/任务中心,见 PLAN D4)。 +package upstream + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// trialPath global trial 加油包端点(Maquer/workbuddy-checkin 实测)。 +const trialPath = "/billing/ide/trial" + +// trialAlreadyMarkers 幂等码 14051「已领取过」的两种指纹: +// - "code=14051":doJSON 对 HTTP 200 + 业务 code 非 0 时拼出的 Msg 格式; +// - `"code":14051`:HTTP ≥400 时 doJSON 把原始 JSON body 直接塞进 Msg。 +var trialAlreadyMarkers = []string{"code=14051", `"code":14051`} + +// ClaimTrial 领取一次性 trial 加油包。仅 global 账号可调(CN 无此端点): +// 非 global → 直接报错(工具层还会再拦一道,这里是客户端侧防线)。 +// 返回 claimed:true=成功新领;false=已领过(幂等,不算失败)。 +func (c *Client) ClaimTrial(a *auth.Auth) (claimed bool, err error) { + if a == nil || a.Realm() != "global" { + return false, fmt.Errorf("claim trial: only global accounts") + } + _, err = c.billingJSON(a, http.MethodPost, trialPath, nil) + if err != nil { + var ue *Error + if errors.As(err, &ue) && trialAlreadyErr(ue.Msg) { + return false, nil // 已领过:幂等成功,非错误 + } + return false, err + } + return true, nil +} + +// trialAlreadyErr 判定错误 Msg 是否携带幂等码 14051(已领取过)。 +func trialAlreadyErr(msg string) bool { + for _, m := range trialAlreadyMarkers { + if strings.Contains(msg, m) { + return true + } + } + return false +} diff --git a/internal/upstream/truncation.go b/internal/upstream/truncation.go index 26268d4751eee27091e9988c7fcf416905702210..339da497da7e4cda3bfe9e07ca919626685370d7 100644 --- a/internal/upstream/truncation.go +++ b/internal/upstream/truncation.go @@ -1,47 +1,47 @@ -// truncation.go 工具调用的残缺参数检测(吸收参考仓库 sse.ts:158-167 -// isTruncatedArguments 语义)。 -// -// 背景:SSE 流被截断(连接中断 / finish_reason==length)时,工具调用的 arguments -// 会只剩半截 JSON。此时网关若把脏参数原样交给客户端,客户端解析会报非法 JSON 并卡死会话。 -// 参考仓库的处置是丢弃残缺调用并触发重试(报告 max-tokens),而非补成 {} 伪造合法外观。 -// -// 关键区分:只把「非空但无法解析」视为截断。空串是合法的无参数工具;能解析但类型不对 -// (标量 / 数组)属于模型输出错误,交给客户端 schema 校验回传即可,不在此判定。 -package upstream - -import ( - "encoding/json" - "strings" -) - -// isTruncatedArguments 判定工具参数字符串是否因分片丢失而残缺(区别于「该工具本就无参数」)。 -// - 空串 / 纯空白 → false(合法无参工具); -// - 非空但 JSON 解析失败 → true(截断); -// - 能解析(含 null/标量/数组等任何合法 JSON)→ false。 -func isTruncatedArguments(raw string) bool { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return false - } - var v any - return json.Unmarshal([]byte(trimmed), &v) != nil -} - -// dropTruncatedToolCalls 过滤出 arguments 完整的 tool_call(返回新 slice)。 -// 只依据 isTruncatedArguments 判定,不改动任何保留的调用(正例零改动)。 -func dropTruncatedToolCalls(calls []map[string]any) []map[string]any { - kept := make([]map[string]any, 0, len(calls)) - for _, call := range calls { - fn, _ := call["function"].(map[string]any) - if fn == nil { - kept = append(kept, call) - continue - } - args, _ := fn["arguments"].(string) - if isTruncatedArguments(args) { - continue - } - kept = append(kept, call) - } - return kept -} +// truncation.go 工具调用的残缺参数检测(吸收参考仓库 sse.ts:158-167 +// isTruncatedArguments 语义)。 +// +// 背景:SSE 流被截断(连接中断 / finish_reason==length)时,工具调用的 arguments +// 会只剩半截 JSON。此时网关若把脏参数原样交给客户端,客户端解析会报非法 JSON 并卡死会话。 +// 参考仓库的处置是丢弃残缺调用并触发重试(报告 max-tokens),而非补成 {} 伪造合法外观。 +// +// 关键区分:只把「非空但无法解析」视为截断。空串是合法的无参数工具;能解析但类型不对 +// (标量 / 数组)属于模型输出错误,交给客户端 schema 校验回传即可,不在此判定。 +package upstream + +import ( + "encoding/json" + "strings" +) + +// isTruncatedArguments 判定工具参数字符串是否因分片丢失而残缺(区别于「该工具本就无参数」)。 +// - 空串 / 纯空白 → false(合法无参工具); +// - 非空但 JSON 解析失败 → true(截断); +// - 能解析(含 null/标量/数组等任何合法 JSON)→ false。 +func isTruncatedArguments(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return false + } + var v any + return json.Unmarshal([]byte(trimmed), &v) != nil +} + +// dropTruncatedToolCalls 过滤出 arguments 完整的 tool_call(返回新 slice)。 +// 只依据 isTruncatedArguments 判定,不改动任何保留的调用(正例零改动)。 +func dropTruncatedToolCalls(calls []map[string]any) []map[string]any { + kept := make([]map[string]any, 0, len(calls)) + for _, call := range calls { + fn, _ := call["function"].(map[string]any) + if fn == nil { + kept = append(kept, call) + continue + } + args, _ := fn["arguments"].(string) + if isTruncatedArguments(args) { + continue + } + kept = append(kept, call) + } + return kept +} diff --git a/internal/upstream/usage.go b/internal/upstream/usage.go index 74a757fc4fa20fc9faef29dce8a4a0a99829a2e7..74c18da4d940a00258b4a2dad3001cd95a2bafb2 100644 --- a/internal/upstream/usage.go +++ b/internal/upstream/usage.go @@ -1,149 +1,149 @@ -package upstream - -import "encoding/json" - -// normalizeUsageCacheAliases keeps cache-hit aliases consistent before the -// response leaves the gateway. Some WorkBuddy responses carry the real hit in -// prompt_tokens_details.cached_tokens while also emitting -// cache_read_input_tokens: 0 and cached_tokens: 0 compatibility aliases. -// Strict downstream parsers may prefer those zero aliases and lose the hit. -func normalizeUsageCacheAliases(usage map[string]any) map[string]any { - best, ok := bestUsageCacheHitTokens(usage) - if !ok || best <= 0 { - return usage - } - - out := cloneUsageMap(usage) - out["cache_read_input_tokens"] = best - out["cached_tokens"] = best - out["prompt_cache_hit_tokens"] = best - - promptDetails := cloneUsageDetails(out, "prompt_tokens_details") - promptDetails["cached_tokens"] = best - out["prompt_tokens_details"] = promptDetails - - // Responses API consumers use this nested form. Preserve it when the - // upstream already supplies it, but do not invent it for Chat-only clients. - if _, exists := out["input_tokens_details"]; exists { - inputDetails := cloneUsageDetails(out, "input_tokens_details") - inputDetails["cached_tokens"] = best - out["input_tokens_details"] = inputDetails - } - - return out -} - -func bestUsageCacheHitTokens(usage map[string]any) (float64, bool) { - paths := []struct { - section string - key string - }{ - {"prompt_tokens_details", "cached_tokens"}, - {"", "prompt_cache_hit_tokens"}, - {"", "cache_read_input_tokens"}, - {"", "cached_tokens"}, - {"input_tokens_details", "cached_tokens"}, - } - - for _, path := range paths { - var value any - if path.section == "" { - value = usage[path.key] - } else if details, ok := usage[path.section].(map[string]any); ok { - value = details[path.key] - } - if tokens, ok := positiveUsageNumber(value); ok { - return tokens, true - } - } - return 0, false -} - -func positiveUsageNumber(value any) (float64, bool) { - switch n := value.(type) { - case float64: - return n, n > 0 - case float32: - value := float64(n) - return value, value > 0 - case int: - return float64(n), n > 0 - case int64: - return float64(n), n > 0 - case int32: - return float64(n), n > 0 - case uint: - return float64(n), n > 0 - case uint64: - return float64(n), n > 0 - case uint32: - return float64(n), n > 0 - default: - return 0, false - } -} - -func cloneUsageMap(usage map[string]any) map[string]any { - out := make(map[string]any, len(usage)) - for key, value := range usage { - out[key] = value - } - return out -} - -func cloneUsageDetails(usage map[string]any, key string) map[string]any { - out := make(map[string]any) - details, _ := usage[key].(map[string]any) - for detailKey, value := range details { - out[detailKey] = value - } - return out -} - -// UsageCacheHitTokens 返回 usage 里的缓存命中 token 数(多别名取最优,口径与 -// 回写给客户端的 normalizeUsageCacheAliases 一致)。供网关统计层(usage 桶 / -// reqlog)观测命中率使用;usage 缺失该维度时 ok=false。 -func UsageCacheHitTokens(usage map[string]any) (float64, bool) { - if usage == nil { - return 0, false - } - return bestUsageCacheHitTokens(usage) -} - -// UsageCacheMissTokens 返回 usage 里的缓存未命中 token 数:优先读上游显式的 -// prompt_cache_miss_tokens,缺失时按 prompt_tokens - 命中 推导(推导值为负时 -// 视为不可信,返回 ok=false)。 -func UsageCacheMissTokens(usage map[string]any) (float64, bool) { - if usage == nil { - return 0, false - } - if miss, ok := usageNumber(usage, "prompt_cache_miss_tokens"); ok { - return miss, true - } - prompt, okP := usageNumber(usage, "prompt_tokens") - hit, okH := UsageCacheHitTokens(usage) - if okP && okH && prompt-hit >= 0 { - return prompt - hit, true - } - return 0, false -} - -// usageNumber 从 usage 顶层取数值字段(JSON 数字可能是 float64 / json.Number 形态)。 -func usageNumber(usage map[string]any, key string) (float64, bool) { - v, ok := usage[key] - if !ok { - return 0, false - } - switch n := v.(type) { - case float64: - return n, true - case int: - return float64(n), true - case int64: - return float64(n), true - case json.Number: - f, err := n.Float64() - return f, err == nil - } - return 0, false -} +package upstream + +import "encoding/json" + +// normalizeUsageCacheAliases keeps cache-hit aliases consistent before the +// response leaves the gateway. Some WorkBuddy responses carry the real hit in +// prompt_tokens_details.cached_tokens while also emitting +// cache_read_input_tokens: 0 and cached_tokens: 0 compatibility aliases. +// Strict downstream parsers may prefer those zero aliases and lose the hit. +func normalizeUsageCacheAliases(usage map[string]any) map[string]any { + best, ok := bestUsageCacheHitTokens(usage) + if !ok || best <= 0 { + return usage + } + + out := cloneUsageMap(usage) + out["cache_read_input_tokens"] = best + out["cached_tokens"] = best + out["prompt_cache_hit_tokens"] = best + + promptDetails := cloneUsageDetails(out, "prompt_tokens_details") + promptDetails["cached_tokens"] = best + out["prompt_tokens_details"] = promptDetails + + // Responses API consumers use this nested form. Preserve it when the + // upstream already supplies it, but do not invent it for Chat-only clients. + if _, exists := out["input_tokens_details"]; exists { + inputDetails := cloneUsageDetails(out, "input_tokens_details") + inputDetails["cached_tokens"] = best + out["input_tokens_details"] = inputDetails + } + + return out +} + +func bestUsageCacheHitTokens(usage map[string]any) (float64, bool) { + paths := []struct { + section string + key string + }{ + {"prompt_tokens_details", "cached_tokens"}, + {"", "prompt_cache_hit_tokens"}, + {"", "cache_read_input_tokens"}, + {"", "cached_tokens"}, + {"input_tokens_details", "cached_tokens"}, + } + + for _, path := range paths { + var value any + if path.section == "" { + value = usage[path.key] + } else if details, ok := usage[path.section].(map[string]any); ok { + value = details[path.key] + } + if tokens, ok := positiveUsageNumber(value); ok { + return tokens, true + } + } + return 0, false +} + +func positiveUsageNumber(value any) (float64, bool) { + switch n := value.(type) { + case float64: + return n, n > 0 + case float32: + value := float64(n) + return value, value > 0 + case int: + return float64(n), n > 0 + case int64: + return float64(n), n > 0 + case int32: + return float64(n), n > 0 + case uint: + return float64(n), n > 0 + case uint64: + return float64(n), n > 0 + case uint32: + return float64(n), n > 0 + default: + return 0, false + } +} + +func cloneUsageMap(usage map[string]any) map[string]any { + out := make(map[string]any, len(usage)) + for key, value := range usage { + out[key] = value + } + return out +} + +func cloneUsageDetails(usage map[string]any, key string) map[string]any { + out := make(map[string]any) + details, _ := usage[key].(map[string]any) + for detailKey, value := range details { + out[detailKey] = value + } + return out +} + +// UsageCacheHitTokens 返回 usage 里的缓存命中 token 数(多别名取最优,口径与 +// 回写给客户端的 normalizeUsageCacheAliases 一致)。供网关统计层(usage 桶 / +// reqlog)观测命中率使用;usage 缺失该维度时 ok=false。 +func UsageCacheHitTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + return bestUsageCacheHitTokens(usage) +} + +// UsageCacheMissTokens 返回 usage 里的缓存未命中 token 数:优先读上游显式的 +// prompt_cache_miss_tokens,缺失时按 prompt_tokens - 命中 推导(推导值为负时 +// 视为不可信,返回 ok=false)。 +func UsageCacheMissTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + if miss, ok := usageNumber(usage, "prompt_cache_miss_tokens"); ok { + return miss, true + } + prompt, okP := usageNumber(usage, "prompt_tokens") + hit, okH := UsageCacheHitTokens(usage) + if okP && okH && prompt-hit >= 0 { + return prompt - hit, true + } + return 0, false +} + +// usageNumber 从 usage 顶层取数值字段(JSON 数字可能是 float64 / json.Number 形态)。 +func usageNumber(usage map[string]any, key string) (float64, bool) { + v, ok := usage[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + } + return 0, false +} diff --git a/internal/upstream/useragent_test.go b/internal/upstream/useragent_test.go index e583b1e2a970bf1aa98c78ec72b859a063a0ebe6..096eaffff8b2fc7c8800281d4cdcb170526be9f5 100644 --- a/internal/upstream/useragent_test.go +++ b/internal/upstream/useragent_test.go @@ -1,336 +1,336 @@ -package upstream - -import ( - "io" - "net/http" - "strings" - "testing" - - "github.com/linguo2625469/workbuddy2api-panel/internal/auth" -) - -// uaCaptureTransport 记录出站请求的 User-Agent。 -type uaCaptureTransport struct { - ua *string -} - -func (t uaCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) { - *t.ua = r.Header.Get("User-Agent") - return jsonResp(200, `{"code":0}`), nil -} - -// TestUserAgentDefaultEmptyKeepsClientUA 默认(UserAgent/client_name 空)行为: -// chat/refresh 路径 UA=默认 WorkBuddy 三段式;billing 路径(report/travel/balance) -// 单段 WorkBuddy/(对齐官方 banner 白名单头组,默认伪造桌面端指纹); -// 显式 client_name="SaaS" 才还原"billing 不设 UA"的旧行为。 -func TestUserAgentDefaultEmptyKeepsClientUA(t *testing.T) { - for _, tc := range []struct { - name string - call func(c *Client) error - wantUA string - }{ - { - name: "chat", - call: func(c *Client) error { - rc, status, _, err := c.ChatStream(&auth.Auth{AccessToken: "at", UID: "u1"}, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) - if status != 200 { - t.Fatalf("chat status=%d", status) - } - if rc != nil { - rc.Close() - } - return err - }, - wantUA: defaultUAString, - }, - { - name: "billing_report", - call: func(c *Client) error { - return c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") - }, - wantUA: "WorkBuddy/5.5.4", - }, - } { - t.Run(tc.name, func(t *testing.T) { - var ua string - c := &Client{ - HTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, - ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - } - if err := tc.call(c); err != nil { - t.Fatalf("call: %v", err) - } - if ua != tc.wantUA { - t.Errorf("UA = %q want %q", ua, tc.wantUA) - } - }) - } -} - -// TestUserAgentOverrideAllOutbound 显式设置后 chat/billing/refresh 全路径覆盖。 -// 用 env 别名直接验证 fields 传输到 headers 的行为。 -func TestUserAgentOverrideAllOutbound(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} - ua := "WorkBuddy/9.9.9" - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != ua { - t.Errorf("UA = %q want %q (path=%s)", got, ua, r.URL.Path) - } - return jsonResp(200, `{"code":0}`), nil - })}, - ChatHTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != ua { - t.Errorf("Chat UA = %q want %q", got, ua) - } - return jsonResp(200, `{"code":0}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - UserAgent: ua, - } - // chat - if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { - t.Errorf("chat: status=%d err=%v", status, err) - } else if rc != nil { - rc.Close() - } - // refresh(RefreshHeaders→CommonHeaders) - c.HTTP.Transport = rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != ua { - t.Errorf("Refresh UA = %q want %q", got, ua) - } - return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil - }) - if err := c.RefreshToken(a); err != nil { - t.Errorf("refresh: %v", err) - } -} - -// TestUserAgentOverrideBilling 余额/签到类 billing 请求同样覆盖。 -func TestUserAgentOverrideBilling(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != "CustomAgent/1" { - t.Errorf("Billing UA = %q want CustomAgent/1", got) - } - return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - UserAgent: "CustomAgent/1", - } - if _, _, err := c.UserResource(a); err != nil { - t.Errorf("userResource: %v", err) - } -} - -// TestFetchModelsUsesConfiguredUA FetchModels 手工 Set UA 也走覆盖。 -func TestFetchModelsUsesConfiguredUA(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - switch { - case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): - if got := r.Header.Get("User-Agent"); got != "FetchAgent/2" { - t.Errorf("personal/models UA = %q want FetchAgent/2", got) - } - case strings.HasSuffix(r.URL.Path, "/v3/config"): - if got := r.Header.Get("User-Agent"); got != codeBuddyIDEUA { - t.Errorf("v3/config UA = %q want %s", got, codeBuddyIDEUA) - } - return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil - default: - t.Errorf("path=%s", r.URL.Path) - } - return jsonResp(200, `{"code":0,"data":{"models":[{"id":"glm-5.2","name":"GLM","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":[]},"disabled":false}],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - UserAgent: "FetchAgent/2", - } - if _, err := c.FetchModels(a); err != nil { - t.Errorf("fetchModels: %v", err) - } -} - -// --- A 段:UA 对齐官方 WorkBuddy 三段式 --- - -const ( - defaultUAString = "WorkBuddy/5.5.4 WorkBuddy/5.5.4 CLI/2.137.1" - explicitString = "MyCustomAgent/3.1" - clientVerUAString = "WorkBuddy/6.0.0 WorkBuddy/6.0.0 CLI/2.137.1" - billingUAWorkBuddy = "WorkBuddy/5.5.4" - billingUACustomVer = "WorkBuddy/6.0.0" - billingUAAgentString = "BillingAgent/1" -) - -// TestUserAgentDefaultWorkBuddyShape 默认(无任何配置)聊天/刷新出站 UA = -// 官方 WorkBuddy 三段式,旧值 `CLI/2.63.2 CodeBuddy/2.63.2` 已被对齐替换。 -func TestUserAgentDefaultWorkBuddyShape(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - c := &Client{ - HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, - ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - } - rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) - if status != 200 || err != nil { - t.Fatalf("chat: status=%d err=%v", status, err) - } - if ua := c.chatLastUA(); ua != defaultUAString { - t.Errorf("chat UA = %q want %q", ua, defaultUAString) - } - if rc != nil { - rc.Close() - } -} - -// chatLastUA 从最近一次聊天请求捕获 UA(当前测试 Client 的 ChatHTTP transport 记录)。 -func (c *Client) chatLastUA() string { - if t, ok := c.ChatHTTP.Transport.(uaCaptureTransport); ok && t.ua != nil { - return *t.ua - } - return "" -} - -// TestUserAgentExplicitOverride config user_agent 非空时以用户显式值为准(兼容旧覆盖逻辑)。 -func TestUserAgentExplicitOverride(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != explicitString { - t.Errorf("UA = %q want %q (path=%s)", got, explicitString, r.URL.Path) - } - return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil - })}, - ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - UserAgent: explicitString, - } - if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { - t.Errorf("chat: status=%d err=%v", status, err) - } else if rc != nil { - rc.Close() - } - if got := c.chatLastUA(); got != explicitString { - t.Errorf("chat UA = %q want %q", got, explicitString) - } - if err := c.RefreshToken(a); err != nil { - t.Errorf("refresh: %v", err) - } -} - -// TestUserAgentClientVersionOverride config client_version 生效:UA 的 WorkBuddy 段跟随 -// 且成对相同(platform 段 = applicationName 段),CLI 段保持默认。 -func TestUserAgentClientVersionOverride(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - c := &Client{ - HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, - ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - ClientVersion: "6.0.0", - } - rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) - if status != 200 || err != nil { - t.Fatalf("chat: status=%d err=%v", status, err) - } - if got := c.chatLastUA(); got != clientVerUAString { - t.Errorf("UA = %q want %q", got, clientVerUAString) - } - if rc != nil { - rc.Close() - } -} - -// TestBillingUA_WhenClientNameSet billing/checkin 路径:client_name 非空时用单段 -// `WorkBuddy/`(不带 CLI 段,对齐官方 banner/check-in 显式头组)。 -func TestBillingUA_WhenClientNameSet(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - // 默认 client_version → WorkBuddy/5.5.4 - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != billingUAWorkBuddy { - t.Errorf("billing UA = %q want %q (path=%s)", got, billingUAWorkBuddy, r.URL.Path) - } - return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - ClientName: "WorkBuddy", - } - if _, _, err := c.UserResource(a); err != nil { - t.Errorf("userResource: %v", err) - } - // 自定义 client_version → WorkBuddy/6.0.0 - c2 := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != billingUACustomVer { - t.Errorf("billing UA = %q want %q", got, billingUACustomVer) - } - return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - ClientName: "WorkBuddy", - ClientVersion: "6.0.0", - } - if _, _, err := c2.UserResource(a); err != nil { - t.Errorf("userResource v2: %v", err) - } - // 显式 user_agent 仍优先于 billingUA - c3 := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - if got := r.Header.Get("User-Agent"); got != billingUAAgentString { - t.Errorf("billing UA = %q want %q", got, billingUAAgentString) - } - return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - ClientName: "WorkBuddy", - UserAgent: billingUAAgentString, - } - if _, _, err := c3.UserResource(a); err != nil { - t.Errorf("userResource v3: %v", err) - } -} - -// TestBillingUA_WhenClientNameEmpty client_name 空 = 默认对齐官方桌面端: -// billing UA 单段 WorkBuddy/;显式 client_name="SaaS" 才不设 UA。 -func TestBillingUA_WhenClientNameEmpty(t *testing.T) { - a := &auth.Auth{AccessToken: "at", UID: "u1"} - var ua string - const fullResp = `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}` - c := &Client{ - HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { - ua = r.Header.Get("User-Agent") - return jsonResp(200, fullResp), nil - })}, - ChatBaseCN: "https://chat.example", - BillingBaseCN: "https://billing.example", - ClientVersion: "6.0.0", - } - if _, _, err := c.UserResource(a); err != nil { - t.Errorf("userResource: %v", err) - } - if ua != "WorkBuddy/6.0.0" { - t.Errorf("billing UA = %q want WorkBuddy/6.0.0 (default desktop fingerprint)", ua) - } - if got := c.billingUA(); got != "WorkBuddy/6.0.0" { - t.Errorf("billingUA() = %q want WorkBuddy/6.0.0", got) - } - // 显式 SaaS 还原旧行为(不设 UA)。 - c.ClientName = "SaaS" - if got := c.billingUA(); got != "" { - t.Errorf("billingUA() SaaS = %q want empty", got) - } -} - -var _ = io.Discard +package upstream + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// uaCaptureTransport 记录出站请求的 User-Agent。 +type uaCaptureTransport struct { + ua *string +} + +func (t uaCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) { + *t.ua = r.Header.Get("User-Agent") + return jsonResp(200, `{"code":0}`), nil +} + +// TestUserAgentDefaultEmptyKeepsClientUA 默认(UserAgent/client_name 空)行为: +// chat/refresh 路径 UA=默认 WorkBuddy 三段式;billing 路径(report/travel/balance) +// 单段 WorkBuddy/(对齐官方 banner 白名单头组,默认伪造桌面端指纹); +// 显式 client_name="SaaS" 才还原"billing 不设 UA"的旧行为。 +func TestUserAgentDefaultEmptyKeepsClientUA(t *testing.T) { + for _, tc := range []struct { + name string + call func(c *Client) error + wantUA string + }{ + { + name: "chat", + call: func(c *Client) error { + rc, status, _, err := c.ChatStream(&auth.Auth{AccessToken: "at", UID: "u1"}, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) + if status != 200 { + t.Fatalf("chat status=%d", status) + } + if rc != nil { + rc.Close() + } + return err + }, + wantUA: defaultUAString, + }, + { + name: "billing_report", + call: func(c *Client) error { + return c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") + }, + wantUA: "WorkBuddy/5.5.4", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var ua string + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: &ua}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } + if err := tc.call(c); err != nil { + t.Fatalf("call: %v", err) + } + if ua != tc.wantUA { + t.Errorf("UA = %q want %q", ua, tc.wantUA) + } + }) + } +} + +// TestUserAgentOverrideAllOutbound 显式设置后 chat/billing/refresh 全路径覆盖。 +// 用 env 别名直接验证 fields 传输到 headers 的行为。 +func TestUserAgentOverrideAllOutbound(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} + ua := "WorkBuddy/9.9.9" + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("UA = %q want %q (path=%s)", got, ua, r.URL.Path) + } + return jsonResp(200, `{"code":0}`), nil + })}, + ChatHTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("Chat UA = %q want %q", got, ua) + } + return jsonResp(200, `{"code":0}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: ua, + } + // chat + if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { + t.Errorf("chat: status=%d err=%v", status, err) + } else if rc != nil { + rc.Close() + } + // refresh(RefreshHeaders→CommonHeaders) + c.HTTP.Transport = rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != ua { + t.Errorf("Refresh UA = %q want %q", got, ua) + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil + }) + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh: %v", err) + } +} + +// TestUserAgentOverrideBilling 余额/签到类 billing 请求同样覆盖。 +func TestUserAgentOverrideBilling(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != "CustomAgent/1" { + t.Errorf("Billing UA = %q want CustomAgent/1", got) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: "CustomAgent/1", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } +} + +// TestFetchModelsUsesConfiguredUA FetchModels 手工 Set UA 也走覆盖。 +func TestFetchModelsUsesConfiguredUA(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): + if got := r.Header.Get("User-Agent"); got != "FetchAgent/2" { + t.Errorf("personal/models UA = %q want FetchAgent/2", got) + } + case strings.HasSuffix(r.URL.Path, "/v3/config"): + if got := r.Header.Get("User-Agent"); got != codeBuddyIDEUA { + t.Errorf("v3/config UA = %q want %s", got, codeBuddyIDEUA) + } + return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil + default: + t.Errorf("path=%s", r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"models":[{"id":"glm-5.2","name":"GLM","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":[]},"disabled":false}],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: "FetchAgent/2", + } + if _, err := c.FetchModels(a); err != nil { + t.Errorf("fetchModels: %v", err) + } +} + +// --- A 段:UA 对齐官方 WorkBuddy 三段式 --- + +const ( + defaultUAString = "WorkBuddy/5.5.4 WorkBuddy/5.5.4 CLI/2.137.1" + explicitString = "MyCustomAgent/3.1" + clientVerUAString = "WorkBuddy/6.0.0 WorkBuddy/6.0.0 CLI/2.137.1" + billingUAWorkBuddy = "WorkBuddy/5.5.4" + billingUACustomVer = "WorkBuddy/6.0.0" + billingUAAgentString = "BillingAgent/1" +) + +// TestUserAgentDefaultWorkBuddyShape 默认(无任何配置)聊天/刷新出站 UA = +// 官方 WorkBuddy 三段式,旧值 `CLI/2.63.2 CodeBuddy/2.63.2` 已被对齐替换。 +func TestUserAgentDefaultWorkBuddyShape(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + } + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) + if status != 200 || err != nil { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if ua := c.chatLastUA(); ua != defaultUAString { + t.Errorf("chat UA = %q want %q", ua, defaultUAString) + } + if rc != nil { + rc.Close() + } +} + +// chatLastUA 从最近一次聊天请求捕获 UA(当前测试 Client 的 ChatHTTP transport 记录)。 +func (c *Client) chatLastUA() string { + if t, ok := c.ChatHTTP.Transport.(uaCaptureTransport); ok && t.ua != nil { + return *t.ua + } + return "" +} + +// TestUserAgentExplicitOverride config user_agent 非空时以用户显式值为准(兼容旧覆盖逻辑)。 +func TestUserAgentExplicitOverride(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1", RefreshToken: "rt"} + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != explicitString { + t.Errorf("UA = %q want %q (path=%s)", got, explicitString, r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"nat","refreshToken":"nrt"}}`), nil + })}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + UserAgent: explicitString, + } + if rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}); status != 200 || err != nil { + t.Errorf("chat: status=%d err=%v", status, err) + } else if rc != nil { + rc.Close() + } + if got := c.chatLastUA(); got != explicitString { + t.Errorf("chat UA = %q want %q", got, explicitString) + } + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh: %v", err) + } +} + +// TestUserAgentClientVersionOverride config client_version 生效:UA 的 WorkBuddy 段跟随 +// 且成对相同(platform 段 = applicationName 段),CLI 段保持默认。 +func TestUserAgentClientVersionOverride(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + c := &Client{ + HTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatHTTP: &http.Client{Transport: uaCaptureTransport{ua: new(string)}}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientVersion: "6.0.0", + } + rc, status, _, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4-flash","messages":[]}`), "", ChatMeta{}) + if status != 200 || err != nil { + t.Fatalf("chat: status=%d err=%v", status, err) + } + if got := c.chatLastUA(); got != clientVerUAString { + t.Errorf("UA = %q want %q", got, clientVerUAString) + } + if rc != nil { + rc.Close() + } +} + +// TestBillingUA_WhenClientNameSet billing/checkin 路径:client_name 非空时用单段 +// `WorkBuddy/`(不带 CLI 段,对齐官方 banner/check-in 显式头组)。 +func TestBillingUA_WhenClientNameSet(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + // 默认 client_version → WorkBuddy/5.5.4 + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUAWorkBuddy { + t.Errorf("billing UA = %q want %q (path=%s)", got, billingUAWorkBuddy, r.URL.Path) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } + // 自定义 client_version → WorkBuddy/6.0.0 + c2 := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUACustomVer { + t.Errorf("billing UA = %q want %q", got, billingUACustomVer) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + ClientVersion: "6.0.0", + } + if _, _, err := c2.UserResource(a); err != nil { + t.Errorf("userResource v2: %v", err) + } + // 显式 user_agent 仍优先于 billingUA + c3 := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("User-Agent"); got != billingUAAgentString { + t.Errorf("billing UA = %q want %q", got, billingUAAgentString) + } + return jsonResp(200, `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}`), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientName: "WorkBuddy", + UserAgent: billingUAAgentString, + } + if _, _, err := c3.UserResource(a); err != nil { + t.Errorf("userResource v3: %v", err) + } +} + +// TestBillingUA_WhenClientNameEmpty client_name 空 = 默认对齐官方桌面端: +// billing UA 单段 WorkBuddy/;显式 client_name="SaaS" 才不设 UA。 +func TestBillingUA_WhenClientNameEmpty(t *testing.T) { + a := &auth.Auth{AccessToken: "at", UID: "u1"} + var ua string + const fullResp = `{"code":0,"data":{"response":{"data":{"accounts":[{"PackageName":"x","CycleCapacitySize":100,"CycleCapacityUsed":0}]}}}}` + c := &Client{ + HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { + ua = r.Header.Get("User-Agent") + return jsonResp(200, fullResp), nil + })}, + ChatBaseCN: "https://chat.example", + BillingBaseCN: "https://billing.example", + ClientVersion: "6.0.0", + } + if _, _, err := c.UserResource(a); err != nil { + t.Errorf("userResource: %v", err) + } + if ua != "WorkBuddy/6.0.0" { + t.Errorf("billing UA = %q want WorkBuddy/6.0.0 (default desktop fingerprint)", ua) + } + if got := c.billingUA(); got != "WorkBuddy/6.0.0" { + t.Errorf("billingUA() = %q want WorkBuddy/6.0.0", got) + } + // 显式 SaaS 还原旧行为(不设 UA)。 + c.ClientName = "SaaS" + if got := c.billingUA(); got != "" { + t.Errorf("billingUA() SaaS = %q want empty", got) + } +} + +var _ = io.Discard diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 007af4582170625c2ac53bc926203baeba570a0c..40bd8213053aca0669091a658326eeb7e3cf9883 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -1,795 +1,795 @@ -// Package usage 记录并聚合逐请求 token 用量,供面板「用量」视图展示。 -// -// 与 internal/pool 的 TokenUsage 的区别: -// - pool 的 TokenUsage 是**每账号一个累计计数器**,只保留总量与「最近一次」, -// 没有时间维度,也无法按模型/时间下钻; -// - 本包按 (时间片, realm, uid, model, rate) 分桶累计,因此可以出「今天各模型各用了多少」 -// 「这一小时 prompt 涨得多快」这类问题,且能长期保留。 -// -// 保留策略(分片粒度自动降级,总量因此有界): -// - 近 hourlyKeep 小时内:小时桶(细粒度,看尖峰) -// - 更早:折叠为日桶,**永久保留**(看长期趋势) -// -// 落盘:data/usage.json,原子替换 + 防抖刷新(默认 30s),重启不丢。 -// 桶数上界 ≈ 账号数 × 模型数 × (hourlyKeep + 已过天数),实测单桶约 90 字节。 -package usage - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// hourlyKeep 小时桶的保留时长;超出后折叠为日桶。 -const hourlyKeep = 90 * 24 * time.Hour - -// flushInterval 防抖落盘间隔。 -const flushInterval = 30 * time.Second - -// maxBuckets 桶数硬上限。超过时立即触发一次折叠,避免异常流量把内存/文件撑爆。 -const maxBuckets = 400_000 - -// hourLayout / dayLayout 分片键的时间格式(本地时区,与用户直觉一致)。 -const ( - hourLayout = "2006-01-02T15" - dayLayout = "2006-01-02" -) - -// fileVersion 是 usage.json 的当前格式版本。版本 2 增加积分观测字段,版本 3 -// 增加模型生效倍率分区,版本 4 增加前缀缓存命中/未命中累计; -// 旧版本缺失字段按零值加载,旧数据不会丢弃。 -const fileVersion = 4 - -// bucket 一个 (时间片, realm, uid, model, rate) 的累计量。 -// JSON 字段名刻意取短,因为桶数量会随时间增长。 -type bucket struct { - Scope string `json:"s"` // "h:2006-01-02T15" 或 "d:2006-01-02" - Realm string `json:"r"` // cn / global - UID string `json:"u"` // 账号 uid - Model string `json:"m"` // 上游裸模型名 - Rate string `json:"x,omitempty"` // 请求时生效积分倍率(规范化数值;旧桶为空) - Req int64 `json:"q"` // 请求数(含失败) - Err int64 `json:"e"` // 失败数 - PT int64 `json:"p"` // prompt tokens - CT int64 `json:"c"` // completion tokens - TT int64 `json:"t"` // total tokens(上游给什么用什么的合计) - LatMs int64 `json:"l"` // 延迟累计(ms) - LatN int64 `json:"ln"` // 延迟样本数 - TPS float64 `json:"v"` // 吐字速率累计 - TPSN int64 `json:"vn"` // 速率样本数 - CR float64 `json:"cr,omitempty"` // usage.credit 累计(仅明确存在的观测) - CRN int64 `json:"cn,omitempty"` // usage.credit 样本数(区分缺字段与真实 0) - CRT int64 `json:"ct,omitempty"` // 同时具备 credit 与 token 的 Token 合计 - CH int64 `json:"ch,omitempty"` // 前缀缓存命中 token 累计(上游回该维度才累计) - CM int64 `json:"cm,omitempty"` // 前缀缓存未命中 token 累计 -} - -// file 落盘结构。 -type file struct { - Version int `json:"version"` - Saved string `json:"saved"` - Buckets []bucket `json:"buckets"` -} - -// Recorder 并发安全的用量记录器。 -type Recorder struct { - mu sync.Mutex - path string - buckets map[string]*bucket // key: scope|realm|uid|model|rate - dirty bool - started time.Time - - stopOnce sync.Once - stop chan struct{} - done chan struct{} -} - -// New 创建记录器。path 为空时禁用落盘(纯内存,测试用)。 -func New(path string) *Recorder { - r := &Recorder{ - path: path, - buckets: make(map[string]*bucket), - started: time.Now(), - stop: make(chan struct{}), - done: make(chan struct{}), - } - if path != "" { - if err := r.load(); err != nil { - log.Printf("[usage] 读取 %s 失败(从零开始): %v", path, err) - } - } - return r -} - -// Start 启动后台防抖落盘与折叠。Stop 前一直运行。 -func (r *Recorder) Start() { - go func() { - defer close(r.done) - t := time.NewTicker(flushInterval) - defer t.Stop() - for { - select { - case <-r.stop: - r.flush(true) - return - case <-t.C: - r.mu.Lock() - n := len(r.buckets) - r.mu.Unlock() - if n > maxBuckets { - r.Rollup(time.Now()) - } - r.flush(false) - } - } - }() -} - -// Stop 停止后台循环并做最后一次落盘。 -func (r *Recorder) Stop() { - r.stopOnce.Do(func() { close(r.stop) }) - <-r.done -} - -// Delta 一次请求尝试的用量增量(与 pool.TokenUsageDelta 同形,避免包间依赖)。 -type Delta struct { - PromptTokens int64 - HasPromptTokens bool - CompletionTokens int64 - HasCompletion bool - TotalTokens int64 - HasTotal bool - Credit float64 - HasCredit bool - ModelRate string - // CacheHitTokens / CacheMissTokens 前缀缓存命中/未命中观测(issue #92)。 - HasCacheTokens bool - CacheHitTokens int64 - CacheMissTokens int64 - LatencyMs int64 - HasLatency bool - TokensPerSecond float64 - HasTPS bool -} - -// Add 记录一次请求尝试。 -// -// ok=false 表示该次尝试失败(传输错误 / 上游 >=400 / 解析失败)。失败尝试通常 -// 没有 usage,但**仍要计入请求数与失败数**——重试放大正是靠这一列才看得出来。 -func (r *Recorder) Add(now time.Time, realm, uid, model string, d Delta, ok bool) { - if r == nil { - return - } - if realm == "" { - realm = "cn" - } - if model == "" { - model = "(unknown)" - } - scope := "h:" + now.Format(hourLayout) - key := scope + "|" + realm + "|" + uid + "|" + model + "|" + d.ModelRate - - r.mu.Lock() - defer r.mu.Unlock() - - b := r.buckets[key] - if b == nil { - b = &bucket{Scope: scope, Realm: realm, UID: uid, Model: model, Rate: d.ModelRate} - r.buckets[key] = b - } - b.Req++ - if !ok { - b.Err++ - } - if d.HasPromptTokens { - b.PT += d.PromptTokens - } - if d.HasCompletion { - b.CT += d.CompletionTokens - } - if d.HasTotal { - b.TT += d.TotalTokens - } else if d.HasPromptTokens || d.HasCompletion { - // 上游没给 total:用 pt+ct 兜底,保证总量口径连续。 - b.TT += d.PromptTokens + d.CompletionTokens - } - if d.HasCredit { - b.CR += d.Credit - b.CRN++ - // 比例只使用同一次请求同时具备 credit 与 token 的样本,避免把 - // 仅 token 的旧记录或仅 credit 的观测混进分母。 - if d.HasTotal { - b.CRT += d.TotalTokens - } else if d.HasPromptTokens || d.HasCompletion { - b.CRT += d.PromptTokens + d.CompletionTokens - } - } - if d.HasCacheTokens { - b.CH += d.CacheHitTokens - b.CM += d.CacheMissTokens - } - if d.HasLatency { - b.LatMs += d.LatencyMs - b.LatN++ - } - if d.HasTPS { - b.TPS += d.TokensPerSecond - b.TPSN++ - } - r.dirty = true -} - -// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶(按本地日历日)。 -// 幂等:同一小时反复折叠不会重复计数(先累加再删源桶)。 -func (r *Recorder) Rollup(now time.Time) { - if r == nil { - return - } - cutoff := now.Add(-hourlyKeep) - - r.mu.Lock() - defer r.mu.Unlock() - - type move struct{ from, to string } - var moves []move - for k, b := range r.buckets { - if !strings.HasPrefix(b.Scope, "h:") { - continue - } - ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(b.Scope, "h:"), time.Local) - if err != nil || !ts.Before(cutoff) { - continue - } - day := "d:" + ts.Format(dayLayout) - moves = append(moves, move{from: k, to: day + "|" + b.Realm + "|" + b.UID + "|" + b.Model + "|" + b.Rate}) - } - for _, m := range moves { - src := r.buckets[m.from] - if src == nil { - continue - } - dst := r.buckets[m.to] - if dst == nil { - cp := *src - cp.Scope = strings.SplitN(m.to, "|", 2)[0] - dst = &cp - r.buckets[m.to] = dst - } else { - dst.Req += src.Req - dst.Err += src.Err - dst.PT += src.PT - dst.CT += src.CT - dst.TT += src.TT - dst.LatMs += src.LatMs - dst.LatN += src.LatN - dst.TPS += src.TPS - dst.TPSN += src.TPSN - dst.CR += src.CR - dst.CRN += src.CRN - dst.CRT += src.CRT - dst.CH += src.CH - dst.CM += src.CM - } - delete(r.buckets, m.from) - } - if len(moves) > 0 { - r.dirty = true - log.Printf("[usage] 折叠 %d 个小时桶为日桶(保留 %v 细粒度)", len(moves), hourlyKeep) - } -} - -// ---------------------------------------------------------------- 持久化 ---- - -func (r *Recorder) load() error { - raw, err := os.ReadFile(r.path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - var f file - if err := json.Unmarshal(raw, &f); err != nil { - return err - } - for i := range f.Buckets { - b := f.Buckets[i] - r.buckets[b.Scope+"|"+b.Realm+"|"+b.UID+"|"+b.Model+"|"+b.Rate] = &b - } - log.Printf("[usage] 已恢复 %d 个用量桶(%s)", len(r.buckets), r.path) - return nil -} - -func (r *Recorder) flush(force bool) { - if r == nil || r.path == "" { - return - } - r.mu.Lock() - if !r.dirty && !force { - r.mu.Unlock() - return - } - snap := file{Version: fileVersion, Saved: time.Now().Format(time.RFC3339), Buckets: make([]bucket, 0, len(r.buckets))} - for _, b := range r.buckets { - snap.Buckets = append(snap.Buckets, *b) - } - r.dirty = false - r.mu.Unlock() - - raw, err := json.Marshal(snap) - if err != nil { - log.Printf("[usage] 序列化失败: %v", err) - return - } - if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { - log.Printf("[usage] 建目录失败: %v", err) - return - } - tmp := r.path + ".tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - log.Printf("[usage] 写临时文件失败: %v", err) - return - } - if err := os.Rename(tmp, r.path); err != nil { - log.Printf("[usage] 原子替换失败: %v", err) - } -} - -// Save 立即落盘(面板「刷新」或关闭前调用)。 -func (r *Recorder) Save() { r.flush(true) } - -// ---------------------------------------------------------------- 聚合 ---- - -// Agg 一组累计量。 -type Agg struct { - Requests int64 `json:"requests"` - Errors int64 `json:"errors"` - PromptTokens int64 `json:"prompt_tokens"` - CompletionTok int64 `json:"completion_tokens"` - TotalTokens int64 `json:"total_tokens"` - Credits float64 `json:"credits"` - CreditSamples int64 `json:"credit_samples"` - CreditTokens int64 `json:"credit_tokens"` - CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` - CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` - CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` - CacheHitRate float64 `json:"cache_hit_rate,omitempty"` - AvgLatencyMs float64 `json:"avg_latency_ms"` - AvgTPS float64 `json:"avg_tokens_per_second"` -} - -// aggAcc 是聚合过程中的累加器:Agg 只放已算好的结果,均值需要样本数才能 -// 正确加权(不能对每桶的均值再取平均),所以样本数留在这里。 -type aggAcc struct { - Agg - latSum int64 - latSamples int64 - tpsSum float64 - tpsSamples int64 -} - -func (g *aggAcc) add(b *bucket) { - g.Requests += b.Req - g.Errors += b.Err - g.PromptTokens += b.PT - g.CompletionTok += b.CT - g.TotalTokens += b.TT - g.Credits += b.CR - g.CreditSamples += b.CRN - g.CreditTokens += b.CRT - g.CacheHitTokens += b.CH - g.CacheMissTokens += b.CM - g.latSum += b.LatMs - g.latSamples += b.LatN - g.tpsSum += b.TPS - g.tpsSamples += b.TPSN -} - -func (g *aggAcc) finish() Agg { - a := g.Agg - if g.latSamples > 0 { - a.AvgLatencyMs = float64(g.latSum) / float64(g.latSamples) - } - if g.tpsSamples > 0 { - a.AvgTPS = g.tpsSum / float64(g.tpsSamples) - } - if g.CreditTokens > 0 { - a.CreditsPer1MTokens = g.Credits / float64(g.CreditTokens) * 1_000_000 - } - if total := g.CacheHitTokens + g.CacheMissTokens; total > 0 { - a.CacheHitRate = float64(g.CacheHitTokens) / float64(total) * 100 - } - return a -} - -// KeyedAgg 按某个维度聚合的一行。 -type KeyedAgg struct { - Key string `json:"key"` - Realm string `json:"realm,omitempty"` - Extra string `json:"extra,omitempty"` // 账号行放昵称 - Agg -} - -// Point 时序上的一个点。 -type Point struct { - T string `json:"t"` - Scope string `json:"scope"` // "hour" | "day" - Agg -} - -// CreditAgg 积分扣除统计的一行。Key 在账号维度是 UID,在模型维度是裸模型名; -// Rate 仅模型维度使用;比例分母只统计与 credit 同时存在的 Token 样本。 -type CreditAgg struct { - Key string `json:"key"` - Realm string `json:"realm,omitempty"` - Nickname string `json:"nickname,omitempty"` - Rate string `json:"rate,omitempty"` - Requests int64 `json:"requests"` - Credits float64 `json:"credits"` - CreditSamples int64 `json:"credit_samples"` - CreditTokens int64 `json:"credit_tokens"` - CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` - CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` - CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` - CacheHitRate float64 `json:"cache_hit_rate,omitempty"` -} - -type creditAcc struct { - CreditAgg -} - -func (a *creditAcc) add(b *bucket) { - a.Requests += b.Req - a.Credits += b.CR - a.CreditSamples += b.CRN - a.CreditTokens += b.CRT - a.CacheHitTokens += b.CH - a.CacheMissTokens += b.CM -} - -func (a *creditAcc) finish() CreditAgg { - out := a.CreditAgg - if a.CreditTokens > 0 { - out.CreditsPer1MTokens = a.Credits / float64(a.CreditTokens) * 1_000_000 - } - if total := a.CacheHitTokens + a.CacheMissTokens; total > 0 { - out.CacheHitRate = float64(a.CacheHitTokens) / float64(total) * 100 - } - return out -} - -// Snapshot 面板一次拉取的全部用量视图数据。 -type Snapshot struct { - Totals Agg `json:"totals"` - ByRealm []KeyedAgg `json:"by_realm"` - ByAccount []KeyedAgg `json:"by_account"` - ByModel []KeyedAgg `json:"by_model"` - Series []Point `json:"series"` - CreditByAccount []CreditAgg `json:"credit_by_account"` - CreditByModel []CreditAgg `json:"credit_by_model"` - Buckets int `json:"buckets"` - FileBytes int64 `json:"file_bytes"` - Since string `json:"since,omitempty"` - // WindowFrom/WindowTo 本次实际生效的统计区间(本地时间,RFC3339),供面板 - // 回显口径——「自定义」区间下用户必须能确认服务端到底按哪段算的。 - // 空串 = 该侧不设界(全部历史 / 到今天为止)。 - WindowFrom string `json:"window_from,omitempty"` - WindowTo string `json:"window_to,omitempty"` - Generated string `json:"generated"` -} - -// Window 用量统计窗口。三种口径按优先级解析(见 bounds): -// - From/To 任一非零 → 显式区间 [From, To](To 零值 = 不设上界) -// - 否则 Hours>0 → 滚动窗口:当前整点往回 Hours-1 小时 -// - 否则 → 全部历史 -// -// 为什么显式区间用「桶起点落在 [From, To] 内」判定而不是求交集:小时桶的粒度 -// 就是一小时,用户选到 14:00 时把 14:00 这一小时的桶算进来符合直觉;同时这也 -// 让 Hours 口径与历史行为逐位一致(原实现就是 ts.Before(from) 即跳过)。 -type Window struct { - Hours int - From time.Time - To time.Time -} - -// bounds 解析出实际生效的 [from, to];零值表示该侧不设界。 -func (w Window) bounds() (time.Time, time.Time) { - if !w.From.IsZero() || !w.To.IsZero() { - return w.From, w.To - } - if w.Hours <= 0 { - return time.Time{}, time.Time{} - } - h := w.Hours - if h > 24*60 { - h = 24 * 60 - } - return time.Now().Truncate(time.Hour).Add(-time.Duration(h-1) * time.Hour), time.Time{} -} - -// bucketTime 把桶 scope 解析成本地时间;脏 scope 返回 false(不进任何口径)。 -func bucketTime(scope string) (time.Time, bool) { - if strings.HasPrefix(scope, "h:") { - ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(scope, "h:"), time.Local) - return ts, err == nil - } - ts, err := time.ParseInLocation(dayLayout, strings.TrimPrefix(scope, "d:"), time.Local) - return ts, err == nil -} - -// Snapshot 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 -// -// hours>0:窗口 = [当前整点-(hours-1)小时, now],卡片汇总/按域/按账号/按模型/ -// 时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化(曾长期是"卡片为 -// 全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没生效",已废弃)。 -// 小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗,故小时窗口 -// 天然不含更早的日桶。 -// hours<=0:全部历史(含已折叠日桶),供「全部历史」选项看长期趋势。 -// -// nicks 是 uid→昵称映射,仅用于展示。 -func (r *Recorder) Snapshot(hours int, nicks map[string]string) Snapshot { - return r.SnapshotWithRates(hours, nicks, nil) -} - -// SnapshotWithRates 与 Snapshot 相同,但允许为缺少历史倍率的旧桶提供当前 -// 模型倍率回填。currentRate 返回空串时该行按“未知倍率”聚合,不伪造价格。 -func (r *Recorder) SnapshotWithRates(hours int, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { - return r.SnapshotWindow(Window{Hours: hours}, nicks, currentRate) -} - -// SnapshotWindow 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 -// -// 窗口语义见 Window:滚动窗口(Hours)/ 显式区间(From-To)/ 全部历史。卡片汇总、 -// 按域、按账号、按模型、时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化 -// (曾长期是"卡片为全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没 -// 生效",已废弃)。小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗, -// 故小时窗口天然不含更早的日桶。 -func (r *Recorder) SnapshotWindow(w Window, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { - if r == nil { - return Snapshot{Generated: time.Now().Format(time.RFC3339)} - } - from, to := w.bounds() - explicit := !w.From.IsZero() || !w.To.IsZero() - windowed := !from.IsZero() || !to.IsZero() - - r.mu.Lock() - bs := make([]bucket, 0, len(r.buckets)) - for _, b := range r.buckets { - bs = append(bs, *b) - } - r.mu.Unlock() - - var total aggAcc - realmAgg := map[string]*aggAcc{} - acctAgg := map[string]*aggAcc{} - acctRealm := map[string]string{} - modelAgg := map[string]*aggAcc{} - hourSeries := map[string]*aggAcc{} - daySeries := map[string]*aggAcc{} - creditAcctAgg := map[string]*creditAcc{} - creditModelAgg := map[string]*creditAcc{} - rateCache := map[string]string{} - - // 数据起点(全库最早分片):不受窗口影响,表示"记录自何时开始"。scope 字典序 - // 即时间序(同前缀内同格式排序;"d:" 恒早于 "h:"——日桶只来自 90 天前的小时折叠)。 - since := "" - matched := 0 - for i := range bs { - b := &bs[i] - if b.Scope < since || since == "" { - since = b.Scope - } - if windowed { - ts, ok := bucketTime(b.Scope) - // 解析失败的脏桶不进窗口聚合(也不该出现在任何口径里)。 - if !ok { - continue - } - if !from.IsZero() && ts.Before(from) { - continue - } - if !to.IsZero() && ts.After(to) { - continue - } - } - matched++ - total.add(b) - - if realmAgg[b.Realm] == nil { - realmAgg[b.Realm] = &aggAcc{} - } - realmAgg[b.Realm].add(b) - - if acctAgg[b.UID] == nil { - acctAgg[b.UID] = &aggAcc{} - } - acctAgg[b.UID].add(b) - // 一个账号只属于一个 realm,这里记下来供前端展示「域」列; - // keyed() 的 Realm 字段默认是空的(它按 key 分组,不知道 realm)。 - if acctRealm[b.UID] == "" { - acctRealm[b.UID] = b.Realm - } - - if modelAgg[b.Model] == nil { - modelAgg[b.Model] = &aggAcc{} - } - modelAgg[b.Model].add(b) - - if strings.HasPrefix(b.Scope, "h:") { - scope := strings.TrimPrefix(b.Scope, "h:") - if hourSeries[scope] == nil { - hourSeries[scope] = &aggAcc{} - } - hourSeries[scope].add(b) - } else { - scope := strings.TrimPrefix(b.Scope, "d:") - if daySeries[scope] == nil { - daySeries[scope] = &aggAcc{} - } - daySeries[scope].add(b) - } - if b.CRN > 0 { - ca := creditAcctAgg[b.UID] - if ca == nil { - ca = &creditAcc{CreditAgg: CreditAgg{ - Key: b.UID, - Realm: b.Realm, - Nickname: nicks[b.UID], - }} - creditAcctAgg[b.UID] = ca - } - ca.add(b) - - model := canonicalUsageModel(b.Model) - rate := b.Rate - if rate == "" && currentRate != nil { - cacheKey := b.Realm + "\x00" + model - if cached, ok := rateCache[cacheKey]; ok { - rate = cached - } else { - rate = currentRate(b.Realm, model) - rateCache[cacheKey] = rate - } - } - modelKey := model + "\x00" + rate - cm := creditModelAgg[modelKey] - if cm == nil { - cm = &creditAcc{CreditAgg: CreditAgg{Key: model, Rate: rate}} - creditModelAgg[modelKey] = cm - } - cm.add(b) - } - } - - snap := Snapshot{ - Totals: total.finish(), - ByRealm: keyed(realmAgg, func(k string) (string, string) { - return k, "" - }), - ByAccount: keyed(acctAgg, func(k string) (string, string) { - return k, nicks[k] - }), - ByModel: keyed(modelAgg, func(k string) (string, string) { return k, "" }), - CreditByAccount: creditKeyed(creditAcctAgg), - CreditByModel: creditKeyed(creditModelAgg), - Buckets: matched, - Generated: time.Now().Format(time.RFC3339), - } - for i := range snap.ByAccount { - snap.ByAccount[i].Realm = acctRealm[snap.ByAccount[i].Key] - } - - // 日点(升序)+ 小时点(升序)拼成一条连续时序。 - dayKeys := make([]string, 0, len(daySeries)) - for k := range daySeries { - dayKeys = append(dayKeys, k) - } - sort.Strings(dayKeys) - for _, k := range dayKeys { - snap.Series = append(snap.Series, Point{T: k, Scope: "day", Agg: daySeries[k].finish()}) - } - hourKeys := make([]string, 0, len(hourSeries)) - for k := range hourSeries { - hourKeys = append(hourKeys, k) - } - sort.Strings(hourKeys) - for _, k := range hourKeys { - snap.Series = append(snap.Series, Point{T: k, Scope: "hour", Agg: hourSeries[k].finish()}) - } - - if r.path != "" { - if fi, err := os.Stat(r.path); err == nil { - snap.FileBytes = fi.Size() - } - } - // since 去掉 scope 前缀("h:2026-09-16T13" → "2026-09-16T13")给前端展示; - // 无任何桶时保持空(无数据不伪造起点)。 - snap.Since = strings.TrimPrefix(strings.TrimPrefix(since, "h:"), "d:") - // 回显实际生效的区间:**仅显式区间口径**。滚动窗口由 hours 表达(前端自己 - // 知道选的是哪个预设),全部历史没有区间——两者回显都会变成噪音。 - if explicit { - if !w.From.IsZero() { - snap.WindowFrom = w.From.Format(time.RFC3339) - } - if !w.To.IsZero() { - snap.WindowTo = w.To.Format(time.RFC3339) - } - } - return snap -} - -func canonicalUsageModel(model string) string { - model = strings.TrimSpace(model) - for _, prefix := range []string{"cn:", "global:"} { - if strings.HasPrefix(model, prefix) { - model = strings.TrimPrefix(model, prefix) - break - } - } - if model == "" { - return "(unknown)" - } - return model -} - -func creditKeyed(m map[string]*creditAcc) []CreditAgg { - out := make([]CreditAgg, 0, len(m)) - for _, v := range m { - out = append(out, v.finish()) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Credits != out[j].Credits { - return out[i].Credits > out[j].Credits - } - if out[i].CreditTokens != out[j].CreditTokens { - return out[i].CreditTokens > out[j].CreditTokens - } - if out[i].Key != out[j].Key { - return out[i].Key < out[j].Key - } - return out[i].Rate < out[j].Rate - }) - return out -} - -func keyed(m map[string]*aggAcc, label func(string) (string, string)) []KeyedAgg { - out := make([]KeyedAgg, 0, len(m)) - for k, v := range m { - key, extra := label(k) - out = append(out, KeyedAgg{Key: key, Extra: extra, Agg: v.finish()}) - } - // 按总量降序;同量按 key 升序,保证输出稳定(前端 diff 不抖)。 - sort.Slice(out, func(i, j int) bool { - if out[i].TotalTokens != out[j].TotalTokens { - return out[i].TotalTokens > out[j].TotalTokens - } - if out[i].Requests != out[j].Requests { - return out[i].Requests > out[j].Requests - } - return out[i].Key < out[j].Key - }) - return out -} - -// Describe 返回一行人类可读的占用摘要(启动日志用)。 -func (r *Recorder) Describe() string { - if r == nil { - return "disabled" - } - r.mu.Lock() - n := len(r.buckets) - r.mu.Unlock() - var sz int64 - if r.path != "" { - if fi, err := os.Stat(r.path); err == nil { - sz = fi.Size() - } - } - return fmt.Sprintf("%d buckets, file %d bytes", n, sz) -} +// Package usage 记录并聚合逐请求 token 用量,供面板「用量」视图展示。 +// +// 与 internal/pool 的 TokenUsage 的区别: +// - pool 的 TokenUsage 是**每账号一个累计计数器**,只保留总量与「最近一次」, +// 没有时间维度,也无法按模型/时间下钻; +// - 本包按 (时间片, realm, uid, model, rate) 分桶累计,因此可以出「今天各模型各用了多少」 +// 「这一小时 prompt 涨得多快」这类问题,且能长期保留。 +// +// 保留策略(分片粒度自动降级,总量因此有界): +// - 近 hourlyKeep 小时内:小时桶(细粒度,看尖峰) +// - 更早:折叠为日桶,**永久保留**(看长期趋势) +// +// 落盘:data/usage.json,原子替换 + 防抖刷新(默认 30s),重启不丢。 +// 桶数上界 ≈ 账号数 × 模型数 × (hourlyKeep + 已过天数),实测单桶约 90 字节。 +package usage + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// hourlyKeep 小时桶的保留时长;超出后折叠为日桶。 +const hourlyKeep = 90 * 24 * time.Hour + +// flushInterval 防抖落盘间隔。 +const flushInterval = 30 * time.Second + +// maxBuckets 桶数硬上限。超过时立即触发一次折叠,避免异常流量把内存/文件撑爆。 +const maxBuckets = 400_000 + +// hourLayout / dayLayout 分片键的时间格式(本地时区,与用户直觉一致)。 +const ( + hourLayout = "2006-01-02T15" + dayLayout = "2006-01-02" +) + +// fileVersion 是 usage.json 的当前格式版本。版本 2 增加积分观测字段,版本 3 +// 增加模型生效倍率分区,版本 4 增加前缀缓存命中/未命中累计; +// 旧版本缺失字段按零值加载,旧数据不会丢弃。 +const fileVersion = 4 + +// bucket 一个 (时间片, realm, uid, model, rate) 的累计量。 +// JSON 字段名刻意取短,因为桶数量会随时间增长。 +type bucket struct { + Scope string `json:"s"` // "h:2006-01-02T15" 或 "d:2006-01-02" + Realm string `json:"r"` // cn / global + UID string `json:"u"` // 账号 uid + Model string `json:"m"` // 上游裸模型名 + Rate string `json:"x,omitempty"` // 请求时生效积分倍率(规范化数值;旧桶为空) + Req int64 `json:"q"` // 请求数(含失败) + Err int64 `json:"e"` // 失败数 + PT int64 `json:"p"` // prompt tokens + CT int64 `json:"c"` // completion tokens + TT int64 `json:"t"` // total tokens(上游给什么用什么的合计) + LatMs int64 `json:"l"` // 延迟累计(ms) + LatN int64 `json:"ln"` // 延迟样本数 + TPS float64 `json:"v"` // 吐字速率累计 + TPSN int64 `json:"vn"` // 速率样本数 + CR float64 `json:"cr,omitempty"` // usage.credit 累计(仅明确存在的观测) + CRN int64 `json:"cn,omitempty"` // usage.credit 样本数(区分缺字段与真实 0) + CRT int64 `json:"ct,omitempty"` // 同时具备 credit 与 token 的 Token 合计 + CH int64 `json:"ch,omitempty"` // 前缀缓存命中 token 累计(上游回该维度才累计) + CM int64 `json:"cm,omitempty"` // 前缀缓存未命中 token 累计 +} + +// file 落盘结构。 +type file struct { + Version int `json:"version"` + Saved string `json:"saved"` + Buckets []bucket `json:"buckets"` +} + +// Recorder 并发安全的用量记录器。 +type Recorder struct { + mu sync.Mutex + path string + buckets map[string]*bucket // key: scope|realm|uid|model|rate + dirty bool + started time.Time + + stopOnce sync.Once + stop chan struct{} + done chan struct{} +} + +// New 创建记录器。path 为空时禁用落盘(纯内存,测试用)。 +func New(path string) *Recorder { + r := &Recorder{ + path: path, + buckets: make(map[string]*bucket), + started: time.Now(), + stop: make(chan struct{}), + done: make(chan struct{}), + } + if path != "" { + if err := r.load(); err != nil { + log.Printf("[usage] 读取 %s 失败(从零开始): %v", path, err) + } + } + return r +} + +// Start 启动后台防抖落盘与折叠。Stop 前一直运行。 +func (r *Recorder) Start() { + go func() { + defer close(r.done) + t := time.NewTicker(flushInterval) + defer t.Stop() + for { + select { + case <-r.stop: + r.flush(true) + return + case <-t.C: + r.mu.Lock() + n := len(r.buckets) + r.mu.Unlock() + if n > maxBuckets { + r.Rollup(time.Now()) + } + r.flush(false) + } + } + }() +} + +// Stop 停止后台循环并做最后一次落盘。 +func (r *Recorder) Stop() { + r.stopOnce.Do(func() { close(r.stop) }) + <-r.done +} + +// Delta 一次请求尝试的用量增量(与 pool.TokenUsageDelta 同形,避免包间依赖)。 +type Delta struct { + PromptTokens int64 + HasPromptTokens bool + CompletionTokens int64 + HasCompletion bool + TotalTokens int64 + HasTotal bool + Credit float64 + HasCredit bool + ModelRate string + // CacheHitTokens / CacheMissTokens 前缀缓存命中/未命中观测(issue #92)。 + HasCacheTokens bool + CacheHitTokens int64 + CacheMissTokens int64 + LatencyMs int64 + HasLatency bool + TokensPerSecond float64 + HasTPS bool +} + +// Add 记录一次请求尝试。 +// +// ok=false 表示该次尝试失败(传输错误 / 上游 >=400 / 解析失败)。失败尝试通常 +// 没有 usage,但**仍要计入请求数与失败数**——重试放大正是靠这一列才看得出来。 +func (r *Recorder) Add(now time.Time, realm, uid, model string, d Delta, ok bool) { + if r == nil { + return + } + if realm == "" { + realm = "cn" + } + if model == "" { + model = "(unknown)" + } + scope := "h:" + now.Format(hourLayout) + key := scope + "|" + realm + "|" + uid + "|" + model + "|" + d.ModelRate + + r.mu.Lock() + defer r.mu.Unlock() + + b := r.buckets[key] + if b == nil { + b = &bucket{Scope: scope, Realm: realm, UID: uid, Model: model, Rate: d.ModelRate} + r.buckets[key] = b + } + b.Req++ + if !ok { + b.Err++ + } + if d.HasPromptTokens { + b.PT += d.PromptTokens + } + if d.HasCompletion { + b.CT += d.CompletionTokens + } + if d.HasTotal { + b.TT += d.TotalTokens + } else if d.HasPromptTokens || d.HasCompletion { + // 上游没给 total:用 pt+ct 兜底,保证总量口径连续。 + b.TT += d.PromptTokens + d.CompletionTokens + } + if d.HasCredit { + b.CR += d.Credit + b.CRN++ + // 比例只使用同一次请求同时具备 credit 与 token 的样本,避免把 + // 仅 token 的旧记录或仅 credit 的观测混进分母。 + if d.HasTotal { + b.CRT += d.TotalTokens + } else if d.HasPromptTokens || d.HasCompletion { + b.CRT += d.PromptTokens + d.CompletionTokens + } + } + if d.HasCacheTokens { + b.CH += d.CacheHitTokens + b.CM += d.CacheMissTokens + } + if d.HasLatency { + b.LatMs += d.LatencyMs + b.LatN++ + } + if d.HasTPS { + b.TPS += d.TokensPerSecond + b.TPSN++ + } + r.dirty = true +} + +// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶(按本地日历日)。 +// 幂等:同一小时反复折叠不会重复计数(先累加再删源桶)。 +func (r *Recorder) Rollup(now time.Time) { + if r == nil { + return + } + cutoff := now.Add(-hourlyKeep) + + r.mu.Lock() + defer r.mu.Unlock() + + type move struct{ from, to string } + var moves []move + for k, b := range r.buckets { + if !strings.HasPrefix(b.Scope, "h:") { + continue + } + ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(b.Scope, "h:"), time.Local) + if err != nil || !ts.Before(cutoff) { + continue + } + day := "d:" + ts.Format(dayLayout) + moves = append(moves, move{from: k, to: day + "|" + b.Realm + "|" + b.UID + "|" + b.Model + "|" + b.Rate}) + } + for _, m := range moves { + src := r.buckets[m.from] + if src == nil { + continue + } + dst := r.buckets[m.to] + if dst == nil { + cp := *src + cp.Scope = strings.SplitN(m.to, "|", 2)[0] + dst = &cp + r.buckets[m.to] = dst + } else { + dst.Req += src.Req + dst.Err += src.Err + dst.PT += src.PT + dst.CT += src.CT + dst.TT += src.TT + dst.LatMs += src.LatMs + dst.LatN += src.LatN + dst.TPS += src.TPS + dst.TPSN += src.TPSN + dst.CR += src.CR + dst.CRN += src.CRN + dst.CRT += src.CRT + dst.CH += src.CH + dst.CM += src.CM + } + delete(r.buckets, m.from) + } + if len(moves) > 0 { + r.dirty = true + log.Printf("[usage] 折叠 %d 个小时桶为日桶(保留 %v 细粒度)", len(moves), hourlyKeep) + } +} + +// ---------------------------------------------------------------- 持久化 ---- + +func (r *Recorder) load() error { + raw, err := os.ReadFile(r.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var f file + if err := json.Unmarshal(raw, &f); err != nil { + return err + } + for i := range f.Buckets { + b := f.Buckets[i] + r.buckets[b.Scope+"|"+b.Realm+"|"+b.UID+"|"+b.Model+"|"+b.Rate] = &b + } + log.Printf("[usage] 已恢复 %d 个用量桶(%s)", len(r.buckets), r.path) + return nil +} + +func (r *Recorder) flush(force bool) { + if r == nil || r.path == "" { + return + } + r.mu.Lock() + if !r.dirty && !force { + r.mu.Unlock() + return + } + snap := file{Version: fileVersion, Saved: time.Now().Format(time.RFC3339), Buckets: make([]bucket, 0, len(r.buckets))} + for _, b := range r.buckets { + snap.Buckets = append(snap.Buckets, *b) + } + r.dirty = false + r.mu.Unlock() + + raw, err := json.Marshal(snap) + if err != nil { + log.Printf("[usage] 序列化失败: %v", err) + return + } + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + log.Printf("[usage] 建目录失败: %v", err) + return + } + tmp := r.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + log.Printf("[usage] 写临时文件失败: %v", err) + return + } + if err := os.Rename(tmp, r.path); err != nil { + log.Printf("[usage] 原子替换失败: %v", err) + } +} + +// Save 立即落盘(面板「刷新」或关闭前调用)。 +func (r *Recorder) Save() { r.flush(true) } + +// ---------------------------------------------------------------- 聚合 ---- + +// Agg 一组累计量。 +type Agg struct { + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTok int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Credits float64 `json:"credits"` + CreditSamples int64 `json:"credit_samples"` + CreditTokens int64 `json:"credit_tokens"` + CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` + CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` + CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` + CacheHitRate float64 `json:"cache_hit_rate,omitempty"` + AvgLatencyMs float64 `json:"avg_latency_ms"` + AvgTPS float64 `json:"avg_tokens_per_second"` +} + +// aggAcc 是聚合过程中的累加器:Agg 只放已算好的结果,均值需要样本数才能 +// 正确加权(不能对每桶的均值再取平均),所以样本数留在这里。 +type aggAcc struct { + Agg + latSum int64 + latSamples int64 + tpsSum float64 + tpsSamples int64 +} + +func (g *aggAcc) add(b *bucket) { + g.Requests += b.Req + g.Errors += b.Err + g.PromptTokens += b.PT + g.CompletionTok += b.CT + g.TotalTokens += b.TT + g.Credits += b.CR + g.CreditSamples += b.CRN + g.CreditTokens += b.CRT + g.CacheHitTokens += b.CH + g.CacheMissTokens += b.CM + g.latSum += b.LatMs + g.latSamples += b.LatN + g.tpsSum += b.TPS + g.tpsSamples += b.TPSN +} + +func (g *aggAcc) finish() Agg { + a := g.Agg + if g.latSamples > 0 { + a.AvgLatencyMs = float64(g.latSum) / float64(g.latSamples) + } + if g.tpsSamples > 0 { + a.AvgTPS = g.tpsSum / float64(g.tpsSamples) + } + if g.CreditTokens > 0 { + a.CreditsPer1MTokens = g.Credits / float64(g.CreditTokens) * 1_000_000 + } + if total := g.CacheHitTokens + g.CacheMissTokens; total > 0 { + a.CacheHitRate = float64(g.CacheHitTokens) / float64(total) * 100 + } + return a +} + +// KeyedAgg 按某个维度聚合的一行。 +type KeyedAgg struct { + Key string `json:"key"` + Realm string `json:"realm,omitempty"` + Extra string `json:"extra,omitempty"` // 账号行放昵称 + Agg +} + +// Point 时序上的一个点。 +type Point struct { + T string `json:"t"` + Scope string `json:"scope"` // "hour" | "day" + Agg +} + +// CreditAgg 积分扣除统计的一行。Key 在账号维度是 UID,在模型维度是裸模型名; +// Rate 仅模型维度使用;比例分母只统计与 credit 同时存在的 Token 样本。 +type CreditAgg struct { + Key string `json:"key"` + Realm string `json:"realm,omitempty"` + Nickname string `json:"nickname,omitempty"` + Rate string `json:"rate,omitempty"` + Requests int64 `json:"requests"` + Credits float64 `json:"credits"` + CreditSamples int64 `json:"credit_samples"` + CreditTokens int64 `json:"credit_tokens"` + CreditsPer1MTokens float64 `json:"credits_per_1m_tokens"` + CacheHitTokens int64 `json:"cache_hit_tokens,omitempty"` + CacheMissTokens int64 `json:"cache_miss_tokens,omitempty"` + CacheHitRate float64 `json:"cache_hit_rate,omitempty"` +} + +type creditAcc struct { + CreditAgg +} + +func (a *creditAcc) add(b *bucket) { + a.Requests += b.Req + a.Credits += b.CR + a.CreditSamples += b.CRN + a.CreditTokens += b.CRT + a.CacheHitTokens += b.CH + a.CacheMissTokens += b.CM +} + +func (a *creditAcc) finish() CreditAgg { + out := a.CreditAgg + if a.CreditTokens > 0 { + out.CreditsPer1MTokens = a.Credits / float64(a.CreditTokens) * 1_000_000 + } + if total := a.CacheHitTokens + a.CacheMissTokens; total > 0 { + out.CacheHitRate = float64(a.CacheHitTokens) / float64(total) * 100 + } + return out +} + +// Snapshot 面板一次拉取的全部用量视图数据。 +type Snapshot struct { + Totals Agg `json:"totals"` + ByRealm []KeyedAgg `json:"by_realm"` + ByAccount []KeyedAgg `json:"by_account"` + ByModel []KeyedAgg `json:"by_model"` + Series []Point `json:"series"` + CreditByAccount []CreditAgg `json:"credit_by_account"` + CreditByModel []CreditAgg `json:"credit_by_model"` + Buckets int `json:"buckets"` + FileBytes int64 `json:"file_bytes"` + Since string `json:"since,omitempty"` + // WindowFrom/WindowTo 本次实际生效的统计区间(本地时间,RFC3339),供面板 + // 回显口径——「自定义」区间下用户必须能确认服务端到底按哪段算的。 + // 空串 = 该侧不设界(全部历史 / 到今天为止)。 + WindowFrom string `json:"window_from,omitempty"` + WindowTo string `json:"window_to,omitempty"` + Generated string `json:"generated"` +} + +// Window 用量统计窗口。三种口径按优先级解析(见 bounds): +// - From/To 任一非零 → 显式区间 [From, To](To 零值 = 不设上界) +// - 否则 Hours>0 → 滚动窗口:当前整点往回 Hours-1 小时 +// - 否则 → 全部历史 +// +// 为什么显式区间用「桶起点落在 [From, To] 内」判定而不是求交集:小时桶的粒度 +// 就是一小时,用户选到 14:00 时把 14:00 这一小时的桶算进来符合直觉;同时这也 +// 让 Hours 口径与历史行为逐位一致(原实现就是 ts.Before(from) 即跳过)。 +type Window struct { + Hours int + From time.Time + To time.Time +} + +// bounds 解析出实际生效的 [from, to];零值表示该侧不设界。 +func (w Window) bounds() (time.Time, time.Time) { + if !w.From.IsZero() || !w.To.IsZero() { + return w.From, w.To + } + if w.Hours <= 0 { + return time.Time{}, time.Time{} + } + h := w.Hours + if h > 24*60 { + h = 24 * 60 + } + return time.Now().Truncate(time.Hour).Add(-time.Duration(h-1) * time.Hour), time.Time{} +} + +// bucketTime 把桶 scope 解析成本地时间;脏 scope 返回 false(不进任何口径)。 +func bucketTime(scope string) (time.Time, bool) { + if strings.HasPrefix(scope, "h:") { + ts, err := time.ParseInLocation(hourLayout, strings.TrimPrefix(scope, "h:"), time.Local) + return ts, err == nil + } + ts, err := time.ParseInLocation(dayLayout, strings.TrimPrefix(scope, "d:"), time.Local) + return ts, err == nil +} + +// Snapshot 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 +// +// hours>0:窗口 = [当前整点-(hours-1)小时, now],卡片汇总/按域/按账号/按模型/ +// 时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化(曾长期是"卡片为 +// 全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没生效",已废弃)。 +// 小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗,故小时窗口 +// 天然不含更早的日桶。 +// hours<=0:全部历史(含已折叠日桶),供「全部历史」选项看长期趋势。 +// +// nicks 是 uid→昵称映射,仅用于展示。 +func (r *Recorder) Snapshot(hours int, nicks map[string]string) Snapshot { + return r.SnapshotWithRates(hours, nicks, nil) +} + +// SnapshotWithRates 与 Snapshot 相同,但允许为缺少历史倍率的旧桶提供当前 +// 模型倍率回填。currentRate 返回空串时该行按“未知倍率”聚合,不伪造价格。 +func (r *Recorder) SnapshotWithRates(hours int, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { + return r.SnapshotWindow(Window{Hours: hours}, nicks, currentRate) +} + +// SnapshotWindow 聚合**所选窗口内**的桶,产出面板一次拉取的全部用量视图数据。 +// +// 窗口语义见 Window:滚动窗口(Hours)/ 显式区间(From-To)/ 全部历史。卡片汇总、 +// 按域、按账号、按模型、时序**全部**按同一窗口口径统计——切窗口时所有数字随之变化 +// (曾长期是"卡片为全部历史累计、hours 只改时序分片"的口径,界面上被读成"筛选没 +// 生效",已废弃)。小时桶按整点入窗;日桶(Rollup 折叠出的长期数据)按日起点入窗, +// 故小时窗口天然不含更早的日桶。 +func (r *Recorder) SnapshotWindow(w Window, nicks map[string]string, currentRate func(realm, model string) string) Snapshot { + if r == nil { + return Snapshot{Generated: time.Now().Format(time.RFC3339)} + } + from, to := w.bounds() + explicit := !w.From.IsZero() || !w.To.IsZero() + windowed := !from.IsZero() || !to.IsZero() + + r.mu.Lock() + bs := make([]bucket, 0, len(r.buckets)) + for _, b := range r.buckets { + bs = append(bs, *b) + } + r.mu.Unlock() + + var total aggAcc + realmAgg := map[string]*aggAcc{} + acctAgg := map[string]*aggAcc{} + acctRealm := map[string]string{} + modelAgg := map[string]*aggAcc{} + hourSeries := map[string]*aggAcc{} + daySeries := map[string]*aggAcc{} + creditAcctAgg := map[string]*creditAcc{} + creditModelAgg := map[string]*creditAcc{} + rateCache := map[string]string{} + + // 数据起点(全库最早分片):不受窗口影响,表示"记录自何时开始"。scope 字典序 + // 即时间序(同前缀内同格式排序;"d:" 恒早于 "h:"——日桶只来自 90 天前的小时折叠)。 + since := "" + matched := 0 + for i := range bs { + b := &bs[i] + if b.Scope < since || since == "" { + since = b.Scope + } + if windowed { + ts, ok := bucketTime(b.Scope) + // 解析失败的脏桶不进窗口聚合(也不该出现在任何口径里)。 + if !ok { + continue + } + if !from.IsZero() && ts.Before(from) { + continue + } + if !to.IsZero() && ts.After(to) { + continue + } + } + matched++ + total.add(b) + + if realmAgg[b.Realm] == nil { + realmAgg[b.Realm] = &aggAcc{} + } + realmAgg[b.Realm].add(b) + + if acctAgg[b.UID] == nil { + acctAgg[b.UID] = &aggAcc{} + } + acctAgg[b.UID].add(b) + // 一个账号只属于一个 realm,这里记下来供前端展示「域」列; + // keyed() 的 Realm 字段默认是空的(它按 key 分组,不知道 realm)。 + if acctRealm[b.UID] == "" { + acctRealm[b.UID] = b.Realm + } + + if modelAgg[b.Model] == nil { + modelAgg[b.Model] = &aggAcc{} + } + modelAgg[b.Model].add(b) + + if strings.HasPrefix(b.Scope, "h:") { + scope := strings.TrimPrefix(b.Scope, "h:") + if hourSeries[scope] == nil { + hourSeries[scope] = &aggAcc{} + } + hourSeries[scope].add(b) + } else { + scope := strings.TrimPrefix(b.Scope, "d:") + if daySeries[scope] == nil { + daySeries[scope] = &aggAcc{} + } + daySeries[scope].add(b) + } + if b.CRN > 0 { + ca := creditAcctAgg[b.UID] + if ca == nil { + ca = &creditAcc{CreditAgg: CreditAgg{ + Key: b.UID, + Realm: b.Realm, + Nickname: nicks[b.UID], + }} + creditAcctAgg[b.UID] = ca + } + ca.add(b) + + model := canonicalUsageModel(b.Model) + rate := b.Rate + if rate == "" && currentRate != nil { + cacheKey := b.Realm + "\x00" + model + if cached, ok := rateCache[cacheKey]; ok { + rate = cached + } else { + rate = currentRate(b.Realm, model) + rateCache[cacheKey] = rate + } + } + modelKey := model + "\x00" + rate + cm := creditModelAgg[modelKey] + if cm == nil { + cm = &creditAcc{CreditAgg: CreditAgg{Key: model, Rate: rate}} + creditModelAgg[modelKey] = cm + } + cm.add(b) + } + } + + snap := Snapshot{ + Totals: total.finish(), + ByRealm: keyed(realmAgg, func(k string) (string, string) { + return k, "" + }), + ByAccount: keyed(acctAgg, func(k string) (string, string) { + return k, nicks[k] + }), + ByModel: keyed(modelAgg, func(k string) (string, string) { return k, "" }), + CreditByAccount: creditKeyed(creditAcctAgg), + CreditByModel: creditKeyed(creditModelAgg), + Buckets: matched, + Generated: time.Now().Format(time.RFC3339), + } + for i := range snap.ByAccount { + snap.ByAccount[i].Realm = acctRealm[snap.ByAccount[i].Key] + } + + // 日点(升序)+ 小时点(升序)拼成一条连续时序。 + dayKeys := make([]string, 0, len(daySeries)) + for k := range daySeries { + dayKeys = append(dayKeys, k) + } + sort.Strings(dayKeys) + for _, k := range dayKeys { + snap.Series = append(snap.Series, Point{T: k, Scope: "day", Agg: daySeries[k].finish()}) + } + hourKeys := make([]string, 0, len(hourSeries)) + for k := range hourSeries { + hourKeys = append(hourKeys, k) + } + sort.Strings(hourKeys) + for _, k := range hourKeys { + snap.Series = append(snap.Series, Point{T: k, Scope: "hour", Agg: hourSeries[k].finish()}) + } + + if r.path != "" { + if fi, err := os.Stat(r.path); err == nil { + snap.FileBytes = fi.Size() + } + } + // since 去掉 scope 前缀("h:2026-09-16T13" → "2026-09-16T13")给前端展示; + // 无任何桶时保持空(无数据不伪造起点)。 + snap.Since = strings.TrimPrefix(strings.TrimPrefix(since, "h:"), "d:") + // 回显实际生效的区间:**仅显式区间口径**。滚动窗口由 hours 表达(前端自己 + // 知道选的是哪个预设),全部历史没有区间——两者回显都会变成噪音。 + if explicit { + if !w.From.IsZero() { + snap.WindowFrom = w.From.Format(time.RFC3339) + } + if !w.To.IsZero() { + snap.WindowTo = w.To.Format(time.RFC3339) + } + } + return snap +} + +func canonicalUsageModel(model string) string { + model = strings.TrimSpace(model) + for _, prefix := range []string{"cn:", "global:"} { + if strings.HasPrefix(model, prefix) { + model = strings.TrimPrefix(model, prefix) + break + } + } + if model == "" { + return "(unknown)" + } + return model +} + +func creditKeyed(m map[string]*creditAcc) []CreditAgg { + out := make([]CreditAgg, 0, len(m)) + for _, v := range m { + out = append(out, v.finish()) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Credits != out[j].Credits { + return out[i].Credits > out[j].Credits + } + if out[i].CreditTokens != out[j].CreditTokens { + return out[i].CreditTokens > out[j].CreditTokens + } + if out[i].Key != out[j].Key { + return out[i].Key < out[j].Key + } + return out[i].Rate < out[j].Rate + }) + return out +} + +func keyed(m map[string]*aggAcc, label func(string) (string, string)) []KeyedAgg { + out := make([]KeyedAgg, 0, len(m)) + for k, v := range m { + key, extra := label(k) + out = append(out, KeyedAgg{Key: key, Extra: extra, Agg: v.finish()}) + } + // 按总量降序;同量按 key 升序,保证输出稳定(前端 diff 不抖)。 + sort.Slice(out, func(i, j int) bool { + if out[i].TotalTokens != out[j].TotalTokens { + return out[i].TotalTokens > out[j].TotalTokens + } + if out[i].Requests != out[j].Requests { + return out[i].Requests > out[j].Requests + } + return out[i].Key < out[j].Key + }) + return out +} + +// Describe 返回一行人类可读的占用摘要(启动日志用)。 +func (r *Recorder) Describe() string { + if r == nil { + return "disabled" + } + r.mu.Lock() + n := len(r.buckets) + r.mu.Unlock() + var sz int64 + if r.path != "" { + if fi, err := os.Stat(r.path); err == nil { + sz = fi.Size() + } + } + return fmt.Sprintf("%d buckets, file %d bytes", n, sz) +} diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go index bc872b5bf2a0e77e4d9dce42f8a8de93ff9652b3..50cfedec32aa57f115d41dce61e670c7406c3f07 100644 --- a/internal/usage/usage_test.go +++ b/internal/usage/usage_test.go @@ -1,355 +1,355 @@ -package usage - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" -) - -// 成功/失败尝试计数、total 的 pt+ct 兜底口径、按域/账号聚合。 -func TestAddAndTotals(t *testing.T) { - r := New("") - now := time.Now() - r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 100, HasPromptTokens: true, CompletionTokens: 50, HasCompletion: true, Credit: 1.5, HasCredit: true, ModelRate: "0.05", LatencyMs: 200, HasLatency: true}, true) - // 失败尝试:无 usage → 只计请求数与失败数,token 不加。 - r.Add(now, "global", "uid1", "claude-4.6", Delta{}, false) - // 上游没给 total 时用 pt+ct 兜底,保证总量口径连续。 - r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 10, HasPromptTokens: true, CompletionTokens: 5, HasCompletion: true}, true) - - s := r.Snapshot(24, nil) - if s.Totals.Requests != 3 || s.Totals.Errors != 1 { - t.Fatalf("requests/errors = %d/%d, want 3/1", s.Totals.Requests, s.Totals.Errors) - } - if s.Totals.PromptTokens != 110 || s.Totals.CompletionTok != 55 { - t.Fatalf("pt/ct = %d/%d, want 110/55", s.Totals.PromptTokens, s.Totals.CompletionTok) - } - if s.Totals.TotalTokens != 165 { - t.Fatalf("tt = %d, want 165(无 total 时按 pt+ct 兜底)", s.Totals.TotalTokens) - } - if s.Totals.Credits != 1.5 || s.Totals.CreditSamples != 1 || s.Totals.CreditTokens != 150 || s.Totals.CreditsPer1MTokens != 10000 { - t.Fatalf("credit totals = %+v, want credits=1.5 samples=1 tokens=150 ratio=10000", s.Totals) - } - if s.Totals.AvgLatencyMs != 200 { - t.Fatalf("avg latency = %v, want 200", s.Totals.AvgLatencyMs) - } - if len(s.ByRealm) != 2 { - t.Fatalf("by_realm = %d 项, want 2", len(s.ByRealm)) - } - if s.ByAccount[0].Realm == "" { - t.Fatal("by_account 行缺 realm 标注") - } - if len(s.CreditByAccount) != 1 || s.CreditByAccount[0].Key != "uid1" || - s.CreditByAccount[0].CreditSamples != 1 || s.CreditByAccount[0].CreditsPer1MTokens != 10000 { - t.Fatalf("credit_by_account = %+v, want one uid1 row", s.CreditByAccount) - } - if len(s.CreditByModel) != 1 || s.CreditByModel[0].Key != "glm-5.2" || - s.CreditByModel[0].Rate != "0.05" || s.CreditByModel[0].CreditsPer1MTokens != 10000 { - t.Fatalf("credit_by_model = %+v, want one glm-5.2 rate=0.05 row", s.CreditByModel) - } -} - -// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶,且幂等:重复折叠不重复计数。 -// 窗口口径:24h 窗口不含 100 天前的日桶;hours=0(全部历史)才含日点。 -func TestRollupIdempotent(t *testing.T) { - r := New("") - old := time.Now().AddDate(0, 0, -100) // 100 天前,超出 90 天小时保留 - r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) - r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) - r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 1, HasPromptTokens: true}, true) - - r.Rollup(time.Now()) - after := r.Snapshot(24, nil) - if after.Totals.Requests != 1 || after.Totals.PromptTokens != 1 { - t.Fatalf("24h 窗口 totals = %d/%d, want 1/1(窗口外日桶不进聚合)", after.Totals.Requests, after.Totals.PromptTokens) - } - if len(after.Series) != 1 || after.Series[0].Scope != "hour" { - t.Fatalf("series = %+v, want 仅当前小时 1 个点", after.Series) - } - - all := r.Snapshot(0, nil) - if all.Totals.Requests != 3 || all.Totals.PromptTokens != 15 { - t.Fatalf("全部历史 totals = %d/%d, want 3/15", all.Totals.Requests, all.Totals.PromptTokens) - } - if len(all.Series) != 2 || all.Series[0].Scope != "day" || all.Series[1].Scope != "hour" { - t.Fatalf("series = %+v, want 日点在前 + 小时点在后", all.Series) - } - - r.Rollup(time.Now()) - again := r.Snapshot(0, nil) - if again.Totals.Requests != 3 || again.Totals.PromptTokens != 15 { - t.Fatalf("二次折叠后 totals = %d/%d, want 3/15(幂等被破坏)", again.Totals.Requests, again.Totals.PromptTokens) - } -} - -// 落盘→新实例恢复,数据不丢;落盘结构带版本号。 -func TestFlushLoadRoundtrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "usage.json") - r1 := New(path) - r1.Add(time.Now(), "cn", "u1", "glm-5.2", Delta{PromptTokens: 42, HasPromptTokens: true, TotalTokens: 42, HasTotal: true}, true) - r1.Save() - - r2 := New(path) - s := r2.Snapshot(24, nil) - if s.Totals.Requests != 1 || s.Totals.TotalTokens != 42 { - t.Fatalf("恢复后 totals = %d/%d, want 1/42", s.Totals.Requests, s.Totals.TotalTokens) - } - raw, _ := os.ReadFile(path) - var f file - if err := json.Unmarshal(raw, &f); err != nil || f.Version != fileVersion || len(f.Buckets) != 1 { - t.Fatalf("落盘文件异常: err=%v buckets=%d", err, len(f.Buckets)) - } -} - -// 版本 1 文件没有积分字段:按零值恢复,旧 Token 数据保持可见且不产生伪比例。 -func TestLoadLegacyWithoutCredit(t *testing.T) { - path := filepath.Join(t.TempDir(), "usage.json") - legacy := `{"version":1,"saved":"2026-09-28T00:00:00+08:00","buckets":[{"s":"h:2026-09-28T10","r":"cn","u":"u1","m":"glm-5.2","q":1,"p":42,"t":42}]}` - if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { - t.Fatal(err) - } - r := New(path) - s := r.Snapshot(0, nil) - if s.Totals.TotalTokens != 42 || s.Totals.CreditSamples != 0 || s.Totals.CreditsPer1MTokens != 0 { - t.Fatalf("legacy totals = %+v, want token-only history", s.Totals) - } - if len(s.CreditByAccount) != 0 || len(s.CreditByModel) != 0 { - t.Fatalf("legacy credit dimensions = %+v / %+v, want none", s.CreditByAccount, s.CreditByModel) - } -} - -// 小时桶折叠为日桶时必须保留积分、样本数和匹配 Token,比例不能因 Rollup 漂移。 -func TestCreditSurvivesRollup(t *testing.T) { - r := New("") - old := time.Now().AddDate(0, 0, -100) - r.Add(old, "cn", "u", "m", Delta{PromptTokens: 100, HasPromptTokens: true, TotalTokens: 100, HasTotal: true, Credit: 1.25, HasCredit: true, ModelRate: "0.5"}, true) - r.Add(old.Add(2*time.Hour), "cn", "u", "m", Delta{PromptTokens: 300, HasPromptTokens: true, TotalTokens: 300, HasTotal: true, Credit: 3.75, HasCredit: true, ModelRate: "0.5"}, true) - r.Rollup(time.Now()) - s := r.Snapshot(0, nil) - if s.Totals.Credits != 5 || s.Totals.CreditSamples != 2 || s.Totals.CreditTokens != 400 || s.Totals.CreditsPer1MTokens != 12500 { - t.Fatalf("rolled credit totals = %+v, want credits=5 tokens=400 ratio=12500", s.Totals) - } - if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.5" || s.CreditByModel[0].CreditsPer1MTokens != 12500 { - t.Fatalf("rolled credit_by_model = %+v, want one rate-preserving row", s.CreditByModel) - } -} - -// 模型维度按“裸模型名 + 生效倍率”合并;同倍率跨账号/时间合并,不同倍率拆行。 -func TestCreditDimensionsRateGrouping(t *testing.T) { - r := New("") - now := time.Now() - r.Add(now, "cn", "u1", "cn:glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true, ModelRate: "0.5"}, true) - r.Add(now.Add(time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.5"}, true) - r.Add(now.Add(2*time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 300, HasTotal: true, Credit: 6, HasCredit: true, ModelRate: "0.8"}, true) - - s := r.Snapshot(24, nil) - if len(s.CreditByAccount) != 2 || len(s.CreditByModel) != 2 { - t.Fatalf("dimensions accounts=%+v models=%+v, want 2 accounts and 2 model-rate rows", s.CreditByAccount, s.CreditByModel) - } - if s.CreditByModel[0].Key != "glm-5.2" || s.CreditByModel[0].Rate != "0.8" || - s.CreditByModel[0].Credits != 6 || s.CreditByModel[0].CreditTokens != 300 { - t.Fatalf("first model row = %+v, want rate=0.8 credits=6 tokens=300", s.CreditByModel[0]) - } - if s.CreditByModel[1].Rate != "0.5" || s.CreditByModel[1].Credits != 3 || s.CreditByModel[1].CreditTokens != 300 { - t.Fatalf("merged model row = %+v, want rate=0.5 credits=3 tokens=300", s.CreditByModel[1]) - } -} - -// 旧桶缺倍率时由当前目录倍率回填,并与新桶同倍率记录合并;目录缺失时保留未知行。 -func TestCreditLegacyRateFallback(t *testing.T) { - r := New("") - now := time.Now() - r.Add(now, "cn", "u1", "glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true}, true) - r.Add(now.Add(time.Hour), "cn", "u1", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.79"}, true) - - s := r.SnapshotWithRates(24, nil, func(realm, model string) string { - if realm == "cn" && model == "glm-5.2" { - return "0.79" - } - return "" - }) - if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.79" || - s.CreditByModel[0].Credits != 3 || s.CreditByModel[0].CreditTokens != 300 { - t.Fatalf("fallback model rows = %+v, want legacy merged into rate=0.79", s.CreditByModel) - } -} - -// Snapshot 全口径窗口过滤:窗口外的数据不进**任何**聚合(卡片/表格/时序), -// 切窗口数字随之变化;hours=0 全部历史。Buckets 为窗口内命中的桶数。 -func TestSnapshotWindowFilter(t *testing.T) { - r := New("") - now := time.Now() - r.Add(now.Add(-48*time.Hour), "cn", "u", "m", Delta{PromptTokens: 5, HasPromptTokens: true}, true) // 窗口(24h)外 - r.Add(now, "cn", "u", "m", Delta{PromptTokens: 3, HasPromptTokens: true}, true) // 窗口内 - s := r.Snapshot(24, nil) - if s.Totals.Requests != 1 || s.Totals.PromptTokens != 3 { - t.Fatalf("24h 窗口 totals = %d/%d, want 1/3(48h 前的数据应被过滤)", s.Totals.Requests, s.Totals.PromptTokens) - } - if len(s.Series) != 1 || s.Series[0].Scope != "hour" || s.Series[0].PromptTokens != 3 { - t.Fatalf("series = %+v, want 仅窗口内 1 个小时点", s.Series) - } - if s.Buckets != 1 { - t.Fatalf("buckets = %d, want 1(窗口内命中桶数)", s.Buckets) - } - - all := r.Snapshot(0, nil) - if all.Totals.Requests != 2 || all.Totals.PromptTokens != 8 { - t.Fatalf("全部历史 totals = %d/%d, want 2/8", all.Totals.Requests, all.Totals.PromptTokens) - } - // since 是全库数据起点,不受窗口影响。 - if all.Since == "" || s.Since != all.Since { - t.Fatalf("since 应为全库起点且不随窗口变化: all=%q windowed=%q", all.Since, s.Since) - } -} - -// 显式区间(「今天」/「自定义」)与滚动窗口走同一套全口径过滤;区间是闭区间 -// (桶起点落在 [From, To] 内即命中),且 From/To 会回显给面板确认口径。 -func TestSnapshotExplicitWindow(t *testing.T) { - r := New("") - base := time.Now().Truncate(time.Hour).Add(-5 * time.Hour) - for i := 0; i < 6; i++ { - r.Add(base.Add(time.Duration(i)*time.Hour), "cn", "u", "m", - Delta{PromptTokens: 10, HasPromptTokens: true}, true) - } - // 只取中间两小时(base+2h、base+3h)。 - s := r.SnapshotWindow(Window{ - From: base.Add(2 * time.Hour), - To: base.Add(3 * time.Hour), - }, nil, nil) - if s.Totals.Requests != 2 || s.Totals.PromptTokens != 20 { - t.Fatalf("显式区间 totals = %d/%d, want 2/20", s.Totals.Requests, s.Totals.PromptTokens) - } - if len(s.Series) != 2 || s.Buckets != 2 { - t.Fatalf("显式区间 series/buckets = %d/%d, want 2/2", len(s.Series), s.Buckets) - } - if s.WindowFrom == "" || s.WindowTo == "" { - t.Fatalf("显式区间应回显 window_from/window_to: %+v", s) - } - if _, err := time.Parse(time.RFC3339, s.WindowFrom); err != nil { - t.Fatalf("window_from 不是 RFC3339: %q", s.WindowFrom) - } - - // 只有 From(「今天」的形态):从该点起到最新,全量命中。 - only := r.SnapshotWindow(Window{From: base.Add(4 * time.Hour)}, nil, nil) - if only.Totals.Requests != 2 { - t.Fatalf("仅 From 的 totals = %d, want 2", only.Totals.Requests) - } - if only.WindowFrom == "" || only.WindowTo != "" { - t.Fatalf("仅 From 时 window_to 应为空: %+v", only) - } - - // 空窗口(From/To 全零且 Hours<=0)= 全部历史,与 Snapshot(0) 等价。 - all := r.SnapshotWindow(Window{}, nil, nil) - if all.Totals.Requests != 6 { - t.Fatalf("全零窗口 totals = %d, want 6(全部历史)", all.Totals.Requests) - } - if all.WindowFrom != "" || all.WindowTo != "" { - t.Fatalf("全部历史不应回显区间: %+v", all) - } -} - -// 滚动窗口的上限仍是 60 天,且与显式区间互不干扰(From/To 优先)。 -func TestWindowBounds(t *testing.T) { - // From/To 优先于 Hours。 - from := time.Now().Add(-2 * time.Hour) - gotFrom, gotTo := Window{Hours: 720, From: from}.bounds() - if !gotFrom.Equal(from) || !gotTo.IsZero() { - t.Fatalf("From 应优先于 Hours: from=%v to=%v", gotFrom, gotTo) - } - // 只有 Hours:起点 = 当前整点往回 Hours-1 小时。 - f, to := Window{Hours: 24}.bounds() - want := time.Now().Truncate(time.Hour).Add(-23 * time.Hour) - if !f.Equal(want) || !to.IsZero() { - t.Fatalf("24h bounds = %v/%v, want %v/零值", f, to, want) - } - // Hours<=0 且无 From/To = 全部历史。 - if f, to := (Window{}).bounds(); !f.IsZero() || !to.IsZero() { - t.Fatalf("空窗口 bounds = %v/%v, want 零值/零值", f, to) - } - // 上限 60 天。 - f60, _ := Window{Hours: 100000}.bounds() - want60 := time.Now().Truncate(time.Hour).Add(-(24*60 - 1) * time.Hour) - if !f60.Equal(want60) { - t.Fatalf("超限 Hours 未被夹到 60 天: %v want %v", f60, want60) - } -} - -// 脏 scope(解析失败)不进任何口径,也不会让整次快照失败。 -func TestBucketTimeRejectsGarbage(t *testing.T) { - if _, ok := bucketTime("h:not-a-time"); ok { - t.Fatal("脏小时 scope 应判定失败") - } - if _, ok := bucketTime("d:2026-13-45"); ok { - t.Fatal("脏日 scope 应判定失败") - } - if ts, ok := bucketTime("h:2026-09-30T13"); !ok || ts.Hour() != 13 { - t.Fatalf("合法小时 scope 解析失败: %v %v", ts, ok) - } - if ts, ok := bucketTime("d:2026-09-30"); !ok || ts.Day() != 30 { - t.Fatalf("合法日 scope 解析失败: %v %v", ts, ok) - } -} - -// Stop 触发最终落盘(Start 后未到防抖间隔也要落)。 -func TestLifecycleFlush(t *testing.T) { - path := filepath.Join(t.TempDir(), "usage.json") - r := New(path) - r.Start() - r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 9, HasPromptTokens: true}, true) - r.Stop() - if _, err := os.Stat(path); err != nil { - t.Fatalf("Stop 后应有落盘文件: %v", err) - } -} - -// TestCacheHitRateAccumulation(issue #92):桶累计 hit/miss,credit_by_model -// 与 totals 输出命中率;无缓存观测的桶不参与(rate 零值省略)。 -func TestCacheHitRateAccumulation(t *testing.T) { - r := New(t.TempDir() + "/usage.json") - base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) - r.Add(base, "cn", "u1", "glm-5.3", Delta{ - PromptTokens: 1000, HasPromptTokens: true, - Credit: 1, HasCredit: true, TotalTokens: 1005, HasTotal: true, - HasCacheTokens: true, CacheHitTokens: 900, CacheMissTokens: 100, - }, true) - r.Add(base.Add(time.Minute), "cn", "u1", "glm-5.3", Delta{ - PromptTokens: 1000, HasPromptTokens: true, - Credit: 2, HasCredit: true, TotalTokens: 1004, HasTotal: true, - HasCacheTokens: true, CacheHitTokens: 100, CacheMissTokens: 900, - }, true) - snap := r.Snapshot(0, nil) - if got := snap.CreditByModel[0].CacheHitRate; got != 50 { - t.Fatalf("cache_hit_rate=%v want 50", got) - } - if snap.Totals.CacheHitRate != 50 { - t.Fatalf("totals cache_hit_rate=%v want 50", snap.Totals.CacheHitRate) - } - if snap.CreditByModel[0].CacheHitTokens != 1000 || snap.CreditByModel[0].CacheMissTokens != 1000 { - t.Fatalf("hit/miss 累计错误: %+v", snap.CreditByModel[0]) - } - - // 无缓存观测的桶:rate 零值。 - r2 := New(t.TempDir() + "/usage2.json") - r2.Add(base, "cn", "u1", "m", Delta{PromptTokens: 10, HasPromptTokens: true, Credit: 1, HasCredit: true, TotalTokens: 12, HasTotal: true}, true) - if got := r2.Snapshot(0, nil).CreditByModel[0].CacheHitRate; got != 0 { - t.Fatalf("无缓存观测应 rate=0, got %v", got) - } -} - -// TestCacheTokensPersistRoundtrip:ch/cm 落盘 v4 并无损恢复。 -func TestCacheTokensPersistRoundtrip(t *testing.T) { - dir := t.TempDir() - r := New(dir + "/usage.json") - base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) - r.Add(base, "cn", "u1", "m", Delta{ - Credit: 1, HasCredit: true, TotalTokens: 100, HasTotal: true, - HasCacheTokens: true, CacheHitTokens: 75, CacheMissTokens: 25, - }, true) - r.Save() - r2 := New(dir + "/usage.json") - snap := r2.Snapshot(0, nil) - if snap.CreditByModel[0].CacheHitRate != 75 { - t.Fatalf("恢复后 cache_hit_rate=%v want 75", snap.CreditByModel[0].CacheHitRate) - } -} +package usage + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// 成功/失败尝试计数、total 的 pt+ct 兜底口径、按域/账号聚合。 +func TestAddAndTotals(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 100, HasPromptTokens: true, CompletionTokens: 50, HasCompletion: true, Credit: 1.5, HasCredit: true, ModelRate: "0.05", LatencyMs: 200, HasLatency: true}, true) + // 失败尝试:无 usage → 只计请求数与失败数,token 不加。 + r.Add(now, "global", "uid1", "claude-4.6", Delta{}, false) + // 上游没给 total 时用 pt+ct 兜底,保证总量口径连续。 + r.Add(now, "cn", "uid1", "glm-5.2", Delta{PromptTokens: 10, HasPromptTokens: true, CompletionTokens: 5, HasCompletion: true}, true) + + s := r.Snapshot(24, nil) + if s.Totals.Requests != 3 || s.Totals.Errors != 1 { + t.Fatalf("requests/errors = %d/%d, want 3/1", s.Totals.Requests, s.Totals.Errors) + } + if s.Totals.PromptTokens != 110 || s.Totals.CompletionTok != 55 { + t.Fatalf("pt/ct = %d/%d, want 110/55", s.Totals.PromptTokens, s.Totals.CompletionTok) + } + if s.Totals.TotalTokens != 165 { + t.Fatalf("tt = %d, want 165(无 total 时按 pt+ct 兜底)", s.Totals.TotalTokens) + } + if s.Totals.Credits != 1.5 || s.Totals.CreditSamples != 1 || s.Totals.CreditTokens != 150 || s.Totals.CreditsPer1MTokens != 10000 { + t.Fatalf("credit totals = %+v, want credits=1.5 samples=1 tokens=150 ratio=10000", s.Totals) + } + if s.Totals.AvgLatencyMs != 200 { + t.Fatalf("avg latency = %v, want 200", s.Totals.AvgLatencyMs) + } + if len(s.ByRealm) != 2 { + t.Fatalf("by_realm = %d 项, want 2", len(s.ByRealm)) + } + if s.ByAccount[0].Realm == "" { + t.Fatal("by_account 行缺 realm 标注") + } + if len(s.CreditByAccount) != 1 || s.CreditByAccount[0].Key != "uid1" || + s.CreditByAccount[0].CreditSamples != 1 || s.CreditByAccount[0].CreditsPer1MTokens != 10000 { + t.Fatalf("credit_by_account = %+v, want one uid1 row", s.CreditByAccount) + } + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Key != "glm-5.2" || + s.CreditByModel[0].Rate != "0.05" || s.CreditByModel[0].CreditsPer1MTokens != 10000 { + t.Fatalf("credit_by_model = %+v, want one glm-5.2 rate=0.05 row", s.CreditByModel) + } +} + +// Rollup 把超出 hourlyKeep 的小时桶折叠为日桶,且幂等:重复折叠不重复计数。 +// 窗口口径:24h 窗口不含 100 天前的日桶;hours=0(全部历史)才含日点。 +func TestRollupIdempotent(t *testing.T) { + r := New("") + old := time.Now().AddDate(0, 0, -100) // 100 天前,超出 90 天小时保留 + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 7, HasPromptTokens: true}, true) + r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 1, HasPromptTokens: true}, true) + + r.Rollup(time.Now()) + after := r.Snapshot(24, nil) + if after.Totals.Requests != 1 || after.Totals.PromptTokens != 1 { + t.Fatalf("24h 窗口 totals = %d/%d, want 1/1(窗口外日桶不进聚合)", after.Totals.Requests, after.Totals.PromptTokens) + } + if len(after.Series) != 1 || after.Series[0].Scope != "hour" { + t.Fatalf("series = %+v, want 仅当前小时 1 个点", after.Series) + } + + all := r.Snapshot(0, nil) + if all.Totals.Requests != 3 || all.Totals.PromptTokens != 15 { + t.Fatalf("全部历史 totals = %d/%d, want 3/15", all.Totals.Requests, all.Totals.PromptTokens) + } + if len(all.Series) != 2 || all.Series[0].Scope != "day" || all.Series[1].Scope != "hour" { + t.Fatalf("series = %+v, want 日点在前 + 小时点在后", all.Series) + } + + r.Rollup(time.Now()) + again := r.Snapshot(0, nil) + if again.Totals.Requests != 3 || again.Totals.PromptTokens != 15 { + t.Fatalf("二次折叠后 totals = %d/%d, want 3/15(幂等被破坏)", again.Totals.Requests, again.Totals.PromptTokens) + } +} + +// 落盘→新实例恢复,数据不丢;落盘结构带版本号。 +func TestFlushLoadRoundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + r1 := New(path) + r1.Add(time.Now(), "cn", "u1", "glm-5.2", Delta{PromptTokens: 42, HasPromptTokens: true, TotalTokens: 42, HasTotal: true}, true) + r1.Save() + + r2 := New(path) + s := r2.Snapshot(24, nil) + if s.Totals.Requests != 1 || s.Totals.TotalTokens != 42 { + t.Fatalf("恢复后 totals = %d/%d, want 1/42", s.Totals.Requests, s.Totals.TotalTokens) + } + raw, _ := os.ReadFile(path) + var f file + if err := json.Unmarshal(raw, &f); err != nil || f.Version != fileVersion || len(f.Buckets) != 1 { + t.Fatalf("落盘文件异常: err=%v buckets=%d", err, len(f.Buckets)) + } +} + +// 版本 1 文件没有积分字段:按零值恢复,旧 Token 数据保持可见且不产生伪比例。 +func TestLoadLegacyWithoutCredit(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + legacy := `{"version":1,"saved":"2026-09-28T00:00:00+08:00","buckets":[{"s":"h:2026-09-28T10","r":"cn","u":"u1","m":"glm-5.2","q":1,"p":42,"t":42}]}` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + r := New(path) + s := r.Snapshot(0, nil) + if s.Totals.TotalTokens != 42 || s.Totals.CreditSamples != 0 || s.Totals.CreditsPer1MTokens != 0 { + t.Fatalf("legacy totals = %+v, want token-only history", s.Totals) + } + if len(s.CreditByAccount) != 0 || len(s.CreditByModel) != 0 { + t.Fatalf("legacy credit dimensions = %+v / %+v, want none", s.CreditByAccount, s.CreditByModel) + } +} + +// 小时桶折叠为日桶时必须保留积分、样本数和匹配 Token,比例不能因 Rollup 漂移。 +func TestCreditSurvivesRollup(t *testing.T) { + r := New("") + old := time.Now().AddDate(0, 0, -100) + r.Add(old, "cn", "u", "m", Delta{PromptTokens: 100, HasPromptTokens: true, TotalTokens: 100, HasTotal: true, Credit: 1.25, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(old.Add(2*time.Hour), "cn", "u", "m", Delta{PromptTokens: 300, HasPromptTokens: true, TotalTokens: 300, HasTotal: true, Credit: 3.75, HasCredit: true, ModelRate: "0.5"}, true) + r.Rollup(time.Now()) + s := r.Snapshot(0, nil) + if s.Totals.Credits != 5 || s.Totals.CreditSamples != 2 || s.Totals.CreditTokens != 400 || s.Totals.CreditsPer1MTokens != 12500 { + t.Fatalf("rolled credit totals = %+v, want credits=5 tokens=400 ratio=12500", s.Totals) + } + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.5" || s.CreditByModel[0].CreditsPer1MTokens != 12500 { + t.Fatalf("rolled credit_by_model = %+v, want one rate-preserving row", s.CreditByModel) + } +} + +// 模型维度按“裸模型名 + 生效倍率”合并;同倍率跨账号/时间合并,不同倍率拆行。 +func TestCreditDimensionsRateGrouping(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "u1", "cn:glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(now.Add(time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.5"}, true) + r.Add(now.Add(2*time.Hour), "cn", "u2", "glm-5.2", Delta{TotalTokens: 300, HasTotal: true, Credit: 6, HasCredit: true, ModelRate: "0.8"}, true) + + s := r.Snapshot(24, nil) + if len(s.CreditByAccount) != 2 || len(s.CreditByModel) != 2 { + t.Fatalf("dimensions accounts=%+v models=%+v, want 2 accounts and 2 model-rate rows", s.CreditByAccount, s.CreditByModel) + } + if s.CreditByModel[0].Key != "glm-5.2" || s.CreditByModel[0].Rate != "0.8" || + s.CreditByModel[0].Credits != 6 || s.CreditByModel[0].CreditTokens != 300 { + t.Fatalf("first model row = %+v, want rate=0.8 credits=6 tokens=300", s.CreditByModel[0]) + } + if s.CreditByModel[1].Rate != "0.5" || s.CreditByModel[1].Credits != 3 || s.CreditByModel[1].CreditTokens != 300 { + t.Fatalf("merged model row = %+v, want rate=0.5 credits=3 tokens=300", s.CreditByModel[1]) + } +} + +// 旧桶缺倍率时由当前目录倍率回填,并与新桶同倍率记录合并;目录缺失时保留未知行。 +func TestCreditLegacyRateFallback(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now, "cn", "u1", "glm-5.2", Delta{TotalTokens: 100, HasTotal: true, Credit: 1, HasCredit: true}, true) + r.Add(now.Add(time.Hour), "cn", "u1", "glm-5.2", Delta{TotalTokens: 200, HasTotal: true, Credit: 2, HasCredit: true, ModelRate: "0.79"}, true) + + s := r.SnapshotWithRates(24, nil, func(realm, model string) string { + if realm == "cn" && model == "glm-5.2" { + return "0.79" + } + return "" + }) + if len(s.CreditByModel) != 1 || s.CreditByModel[0].Rate != "0.79" || + s.CreditByModel[0].Credits != 3 || s.CreditByModel[0].CreditTokens != 300 { + t.Fatalf("fallback model rows = %+v, want legacy merged into rate=0.79", s.CreditByModel) + } +} + +// Snapshot 全口径窗口过滤:窗口外的数据不进**任何**聚合(卡片/表格/时序), +// 切窗口数字随之变化;hours=0 全部历史。Buckets 为窗口内命中的桶数。 +func TestSnapshotWindowFilter(t *testing.T) { + r := New("") + now := time.Now() + r.Add(now.Add(-48*time.Hour), "cn", "u", "m", Delta{PromptTokens: 5, HasPromptTokens: true}, true) // 窗口(24h)外 + r.Add(now, "cn", "u", "m", Delta{PromptTokens: 3, HasPromptTokens: true}, true) // 窗口内 + s := r.Snapshot(24, nil) + if s.Totals.Requests != 1 || s.Totals.PromptTokens != 3 { + t.Fatalf("24h 窗口 totals = %d/%d, want 1/3(48h 前的数据应被过滤)", s.Totals.Requests, s.Totals.PromptTokens) + } + if len(s.Series) != 1 || s.Series[0].Scope != "hour" || s.Series[0].PromptTokens != 3 { + t.Fatalf("series = %+v, want 仅窗口内 1 个小时点", s.Series) + } + if s.Buckets != 1 { + t.Fatalf("buckets = %d, want 1(窗口内命中桶数)", s.Buckets) + } + + all := r.Snapshot(0, nil) + if all.Totals.Requests != 2 || all.Totals.PromptTokens != 8 { + t.Fatalf("全部历史 totals = %d/%d, want 2/8", all.Totals.Requests, all.Totals.PromptTokens) + } + // since 是全库数据起点,不受窗口影响。 + if all.Since == "" || s.Since != all.Since { + t.Fatalf("since 应为全库起点且不随窗口变化: all=%q windowed=%q", all.Since, s.Since) + } +} + +// 显式区间(「今天」/「自定义」)与滚动窗口走同一套全口径过滤;区间是闭区间 +// (桶起点落在 [From, To] 内即命中),且 From/To 会回显给面板确认口径。 +func TestSnapshotExplicitWindow(t *testing.T) { + r := New("") + base := time.Now().Truncate(time.Hour).Add(-5 * time.Hour) + for i := 0; i < 6; i++ { + r.Add(base.Add(time.Duration(i)*time.Hour), "cn", "u", "m", + Delta{PromptTokens: 10, HasPromptTokens: true}, true) + } + // 只取中间两小时(base+2h、base+3h)。 + s := r.SnapshotWindow(Window{ + From: base.Add(2 * time.Hour), + To: base.Add(3 * time.Hour), + }, nil, nil) + if s.Totals.Requests != 2 || s.Totals.PromptTokens != 20 { + t.Fatalf("显式区间 totals = %d/%d, want 2/20", s.Totals.Requests, s.Totals.PromptTokens) + } + if len(s.Series) != 2 || s.Buckets != 2 { + t.Fatalf("显式区间 series/buckets = %d/%d, want 2/2", len(s.Series), s.Buckets) + } + if s.WindowFrom == "" || s.WindowTo == "" { + t.Fatalf("显式区间应回显 window_from/window_to: %+v", s) + } + if _, err := time.Parse(time.RFC3339, s.WindowFrom); err != nil { + t.Fatalf("window_from 不是 RFC3339: %q", s.WindowFrom) + } + + // 只有 From(「今天」的形态):从该点起到最新,全量命中。 + only := r.SnapshotWindow(Window{From: base.Add(4 * time.Hour)}, nil, nil) + if only.Totals.Requests != 2 { + t.Fatalf("仅 From 的 totals = %d, want 2", only.Totals.Requests) + } + if only.WindowFrom == "" || only.WindowTo != "" { + t.Fatalf("仅 From 时 window_to 应为空: %+v", only) + } + + // 空窗口(From/To 全零且 Hours<=0)= 全部历史,与 Snapshot(0) 等价。 + all := r.SnapshotWindow(Window{}, nil, nil) + if all.Totals.Requests != 6 { + t.Fatalf("全零窗口 totals = %d, want 6(全部历史)", all.Totals.Requests) + } + if all.WindowFrom != "" || all.WindowTo != "" { + t.Fatalf("全部历史不应回显区间: %+v", all) + } +} + +// 滚动窗口的上限仍是 60 天,且与显式区间互不干扰(From/To 优先)。 +func TestWindowBounds(t *testing.T) { + // From/To 优先于 Hours。 + from := time.Now().Add(-2 * time.Hour) + gotFrom, gotTo := Window{Hours: 720, From: from}.bounds() + if !gotFrom.Equal(from) || !gotTo.IsZero() { + t.Fatalf("From 应优先于 Hours: from=%v to=%v", gotFrom, gotTo) + } + // 只有 Hours:起点 = 当前整点往回 Hours-1 小时。 + f, to := Window{Hours: 24}.bounds() + want := time.Now().Truncate(time.Hour).Add(-23 * time.Hour) + if !f.Equal(want) || !to.IsZero() { + t.Fatalf("24h bounds = %v/%v, want %v/零值", f, to, want) + } + // Hours<=0 且无 From/To = 全部历史。 + if f, to := (Window{}).bounds(); !f.IsZero() || !to.IsZero() { + t.Fatalf("空窗口 bounds = %v/%v, want 零值/零值", f, to) + } + // 上限 60 天。 + f60, _ := Window{Hours: 100000}.bounds() + want60 := time.Now().Truncate(time.Hour).Add(-(24*60 - 1) * time.Hour) + if !f60.Equal(want60) { + t.Fatalf("超限 Hours 未被夹到 60 天: %v want %v", f60, want60) + } +} + +// 脏 scope(解析失败)不进任何口径,也不会让整次快照失败。 +func TestBucketTimeRejectsGarbage(t *testing.T) { + if _, ok := bucketTime("h:not-a-time"); ok { + t.Fatal("脏小时 scope 应判定失败") + } + if _, ok := bucketTime("d:2026-13-45"); ok { + t.Fatal("脏日 scope 应判定失败") + } + if ts, ok := bucketTime("h:2026-09-30T13"); !ok || ts.Hour() != 13 { + t.Fatalf("合法小时 scope 解析失败: %v %v", ts, ok) + } + if ts, ok := bucketTime("d:2026-09-30"); !ok || ts.Day() != 30 { + t.Fatalf("合法日 scope 解析失败: %v %v", ts, ok) + } +} + +// Stop 触发最终落盘(Start 后未到防抖间隔也要落)。 +func TestLifecycleFlush(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.json") + r := New(path) + r.Start() + r.Add(time.Now(), "cn", "u", "m", Delta{PromptTokens: 9, HasPromptTokens: true}, true) + r.Stop() + if _, err := os.Stat(path); err != nil { + t.Fatalf("Stop 后应有落盘文件: %v", err) + } +} + +// TestCacheHitRateAccumulation(issue #92):桶累计 hit/miss,credit_by_model +// 与 totals 输出命中率;无缓存观测的桶不参与(rate 零值省略)。 +func TestCacheHitRateAccumulation(t *testing.T) { + r := New(t.TempDir() + "/usage.json") + base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) + r.Add(base, "cn", "u1", "glm-5.3", Delta{ + PromptTokens: 1000, HasPromptTokens: true, + Credit: 1, HasCredit: true, TotalTokens: 1005, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 900, CacheMissTokens: 100, + }, true) + r.Add(base.Add(time.Minute), "cn", "u1", "glm-5.3", Delta{ + PromptTokens: 1000, HasPromptTokens: true, + Credit: 2, HasCredit: true, TotalTokens: 1004, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 100, CacheMissTokens: 900, + }, true) + snap := r.Snapshot(0, nil) + if got := snap.CreditByModel[0].CacheHitRate; got != 50 { + t.Fatalf("cache_hit_rate=%v want 50", got) + } + if snap.Totals.CacheHitRate != 50 { + t.Fatalf("totals cache_hit_rate=%v want 50", snap.Totals.CacheHitRate) + } + if snap.CreditByModel[0].CacheHitTokens != 1000 || snap.CreditByModel[0].CacheMissTokens != 1000 { + t.Fatalf("hit/miss 累计错误: %+v", snap.CreditByModel[0]) + } + + // 无缓存观测的桶:rate 零值。 + r2 := New(t.TempDir() + "/usage2.json") + r2.Add(base, "cn", "u1", "m", Delta{PromptTokens: 10, HasPromptTokens: true, Credit: 1, HasCredit: true, TotalTokens: 12, HasTotal: true}, true) + if got := r2.Snapshot(0, nil).CreditByModel[0].CacheHitRate; got != 0 { + t.Fatalf("无缓存观测应 rate=0, got %v", got) + } +} + +// TestCacheTokensPersistRoundtrip:ch/cm 落盘 v4 并无损恢复。 +func TestCacheTokensPersistRoundtrip(t *testing.T) { + dir := t.TempDir() + r := New(dir + "/usage.json") + base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) + r.Add(base, "cn", "u1", "m", Delta{ + Credit: 1, HasCredit: true, TotalTokens: 100, HasTotal: true, + HasCacheTokens: true, CacheHitTokens: 75, CacheMissTokens: 25, + }, true) + r.Save() + r2 := New(dir + "/usage.json") + snap := r2.Snapshot(0, nil) + if snap.CreditByModel[0].CacheHitRate != 75 { + t.Fatalf("恢复后 cache_hit_rate=%v want 75", snap.CreditByModel[0].CacheHitRate) + } +} diff --git a/login.sh b/login.sh index 6471869fd96973a35258b27abb08140e709a022f..94d81f9777a90d992b67634c41cf99977a66897f 100644 --- a/login.sh +++ b/login.sh @@ -1,182 +1,182 @@ -#!/usr/bin/env bash -# login.sh — WorkBuddy CN OAuth 登录 → 落盘 auth 文件 -# -# 用法: -# ./login.sh -# -# 流程: -# 1. POST /v2/plugin/auth/state 拿授权 URL(无 PKCE,state 由服务端签发) -# 2. 你在浏览器打开 URL 完成登录 -# 3. 回到这里按 y → poll 拿 token+uid+nickname → 签到 → 落盘 auths/workbuddy-.json -# 4. 重启 workbuddy2api 容器加载新账号 -set -euo pipefail - -cd "$(dirname "$0")" -AUTH_DIR="./auths" -CONTAINER="workbuddy2api" - -mkdir -p "$AUTH_DIR" - -# login 工具:不存在才编译(源码改动后手动 go build -o login ./cmd/login) -LOGIN_BIN="./login" -if [[ ! -x "$LOGIN_BIN" ]]; then - go build -o "$LOGIN_BIN" ./cmd/login -fi - -echo "============================================================" -echo " WorkBuddy OAuth 登录" -echo "============================================================" -echo "" - -AUTH_URL=$("$LOGIN_BIN" url) - -echo "请在浏览器中打开以下链接完成登录:" -echo "" -echo " $AUTH_URL" -echo "" - -if command -v xclip &>/dev/null; then - echo -n "$AUTH_URL" | xclip -selection clipboard 2>/dev/null && echo "(已复制到剪贴板)" -elif command -v xsel &>/dev/null; then - echo -n "$AUTH_URL" | xsel --clipboard 2>/dev/null && echo "(已复制到剪贴板)" -fi - -echo "" -read -rp "完成登录后按 y 继续: " ans -if [[ "$ans" != "y" && "$ans" != "Y" ]]; then - echo "已取消" - exit 1 -fi - -echo "" -echo "正在获取 token..." - -RESULT=$("$LOGIN_BIN" poll) || { - echo "" - echo "获取 token 失败。可能原因:" - echo " - 登录还没完成就按了 y(重新运行 ./login.sh 再试)" - echo " - 登录页报错(把报错截图发出来排查)" - exit 1 -} - -TOKEN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])") -REFRESH=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])") -EXPIRES_IN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['expires_in'])") -DOMAIN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))") -USER_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('uid',''))") -ENT_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('enterprise_id',''))") -NICKNAME=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('nickname',''))") - -if [[ -z "$USER_ID" ]]; then - echo "无法获取 uid,请检查 token 是否有效" - exit 1 -fi - -EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) - -# ─── 签到(CN:POST codebuddy.cn/v2/billing/meter/daily-checkin,幂等不阻塞)─── -# OAuth 返回字段一律通过环境变量传入 Python,并用带引号 heredoc: -# 昵称/domain/token 等值可含引号或换行,拼进 Python 源码会造成注入。 -WB2A_LOGIN_TOKEN="$TOKEN" \ -WB2A_LOGIN_USER_ID="$USER_ID" \ -WB2A_LOGIN_ENT_ID="$ENT_ID" \ -WB2A_LOGIN_DOMAIN="$DOMAIN" \ -python3 - <<'PYEOF' -import json, os, urllib.request, urllib.error - -token = os.environ["WB2A_LOGIN_TOKEN"] -user_id = os.environ["WB2A_LOGIN_USER_ID"] -ent_id = os.environ["WB2A_LOGIN_ENT_ID"] -domain = os.environ["WB2A_LOGIN_DOMAIN"] - -req = urllib.request.Request( - "https://www.codebuddy.cn/v2/billing/meter/daily-checkin", - method="POST", data=b"{}", - headers={ - "Authorization": "Bearer " + token, - "Accept": "application/json", - "Content-Type": "application/json", - "X-User-Id": user_id, - **({"X-Enterprise-Id": ent_id, "X-Tenant-Id": ent_id} if ent_id else {}), - **({"X-Domain": domain} if domain else {}), - }) -try: - with urllib.request.urlopen(req, timeout=15) as r: - body = json.loads(r.read().decode() or "{}") - if body.get("code") == 0: - data = body.get("data") or {} - print(f"签到: 成功 {json.dumps(data, ensure_ascii=False)[:150]}") - else: - print(f"签到: {body.get('msg', json.dumps(body)[:150])}") -except urllib.error.HTTPError as e: - # 已签到等业务错误也走 4xx(实测 code=10001 "今天已签到") - try: - body = json.loads(e.read().decode() or "{}") - print(f"签到: {body.get('msg', 'http %d' % e.code)}") - except Exception: - print(f"签到: http {e.code}") -except Exception as e: - print(f"签到: {e}") -PYEOF - -# ─── 落盘 auth 文件(与 internal/auth 读取格式一致)───────────────── -AUTH_FILE="$AUTH_DIR/workbuddy-${USER_ID}.json" -if [[ -f "$AUTH_FILE" ]]; then - echo "账号已存在(uid=${USER_ID}),将覆盖更新凭证" - ACTION="覆盖" -else - echo "新账号(uid=${USER_ID}),新增 auth 文件" - ACTION="新增" -fi -WB2A_LOGIN_TOKEN="$TOKEN" \ -WB2A_LOGIN_REFRESH="$REFRESH" \ -WB2A_LOGIN_EXPIRES_AT="$EXPIRES_AT" \ -WB2A_LOGIN_DOMAIN="$DOMAIN" \ -WB2A_LOGIN_USER_ID="$USER_ID" \ -WB2A_LOGIN_ENT_ID="$ENT_ID" \ -WB2A_LOGIN_NICKNAME="$NICKNAME" \ -WB2A_LOGIN_AUTH_FILE="$AUTH_FILE" \ -WB2A_LOGIN_ACTION="$ACTION" \ -python3 - <<'PYEOF' -import json, os - -auth = { - "account": { - "uid": os.environ["WB2A_LOGIN_USER_ID"], - "enterpriseId": os.environ["WB2A_LOGIN_ENT_ID"], - "nickname": os.environ["WB2A_LOGIN_NICKNAME"], - }, - "auth": { - "accessToken": os.environ["WB2A_LOGIN_TOKEN"], - "refreshToken": os.environ["WB2A_LOGIN_REFRESH"], - "expiresAt": int(os.environ["WB2A_LOGIN_EXPIRES_AT"]), - "domain": os.environ["WB2A_LOGIN_DOMAIN"], - }, -} -with open(os.environ["WB2A_LOGIN_AUTH_FILE"], "w") as f: - json.dump(auth, f, indent=1) -print(f"已保存({os.environ['WB2A_LOGIN_ACTION']}): {os.environ['WB2A_LOGIN_AUTH_FILE']}") -PYEOF - -# ─── 重启服务 ──────────────────────────────────────────── -echo "" -if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then - echo "重启 $CONTAINER 加载新账号..." - docker restart "$CONTAINER" >/dev/null - sleep 2 - # API_KEY 从 config.json 读取(该变量在脚本中未定义,fallback 仅为占位,不会通过鉴权) - API_KEY=$(python3 -c "import json; print(json.load(open('config.json')).get('api_key',''))" 2>/dev/null) - COUNT=$(curl -s http://127.0.0.1:7863/status -H "Authorization: Bearer ${API_KEY:-test_key}" 2>/dev/null | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('accounts',[])))" 2>/dev/null || echo "?") - echo "服务已重启,当前账号数: $COUNT" -else - echo "容器 $CONTAINER 未运行,auth 文件已保存,下次启动自动加载" -fi - -echo "" -echo "============================================================" -echo " 登录完成!" -echo " UID: $USER_ID" -echo " Nickname: ${NICKNAME:-(未获取到)}" -echo " Token: ${TOKEN:0:30}..." -echo " 有效期: $(date -d "@$EXPIRES_AT" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$EXPIRES_AT")" -echo "============================================================" +#!/usr/bin/env bash +# login.sh — WorkBuddy CN OAuth 登录 → 落盘 auth 文件 +# +# 用法: +# ./login.sh +# +# 流程: +# 1. POST /v2/plugin/auth/state 拿授权 URL(无 PKCE,state 由服务端签发) +# 2. 你在浏览器打开 URL 完成登录 +# 3. 回到这里按 y → poll 拿 token+uid+nickname → 签到 → 落盘 auths/workbuddy-.json +# 4. 重启 workbuddy2api 容器加载新账号 +set -euo pipefail + +cd "$(dirname "$0")" +AUTH_DIR="./auths" +CONTAINER="workbuddy2api" + +mkdir -p "$AUTH_DIR" + +# login 工具:不存在才编译(源码改动后手动 go build -o login ./cmd/login) +LOGIN_BIN="./login" +if [[ ! -x "$LOGIN_BIN" ]]; then + go build -o "$LOGIN_BIN" ./cmd/login +fi + +echo "============================================================" +echo " WorkBuddy OAuth 登录" +echo "============================================================" +echo "" + +AUTH_URL=$("$LOGIN_BIN" url) + +echo "请在浏览器中打开以下链接完成登录:" +echo "" +echo " $AUTH_URL" +echo "" + +if command -v xclip &>/dev/null; then + echo -n "$AUTH_URL" | xclip -selection clipboard 2>/dev/null && echo "(已复制到剪贴板)" +elif command -v xsel &>/dev/null; then + echo -n "$AUTH_URL" | xsel --clipboard 2>/dev/null && echo "(已复制到剪贴板)" +fi + +echo "" +read -rp "完成登录后按 y 继续: " ans +if [[ "$ans" != "y" && "$ans" != "Y" ]]; then + echo "已取消" + exit 1 +fi + +echo "" +echo "正在获取 token..." + +RESULT=$("$LOGIN_BIN" poll) || { + echo "" + echo "获取 token 失败。可能原因:" + echo " - 登录还没完成就按了 y(重新运行 ./login.sh 再试)" + echo " - 登录页报错(把报错截图发出来排查)" + exit 1 +} + +TOKEN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])") +REFRESH=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])") +EXPIRES_IN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['expires_in'])") +DOMAIN=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))") +USER_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('uid',''))") +ENT_ID=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('enterprise_id',''))") +NICKNAME=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('nickname',''))") + +if [[ -z "$USER_ID" ]]; then + echo "无法获取 uid,请检查 token 是否有效" + exit 1 +fi + +EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) + +# ─── 签到(CN:POST codebuddy.cn/v2/billing/meter/daily-checkin,幂等不阻塞)─── +# OAuth 返回字段一律通过环境变量传入 Python,并用带引号 heredoc: +# 昵称/domain/token 等值可含引号或换行,拼进 Python 源码会造成注入。 +WB2A_LOGIN_TOKEN="$TOKEN" \ +WB2A_LOGIN_USER_ID="$USER_ID" \ +WB2A_LOGIN_ENT_ID="$ENT_ID" \ +WB2A_LOGIN_DOMAIN="$DOMAIN" \ +python3 - <<'PYEOF' +import json, os, urllib.request, urllib.error + +token = os.environ["WB2A_LOGIN_TOKEN"] +user_id = os.environ["WB2A_LOGIN_USER_ID"] +ent_id = os.environ["WB2A_LOGIN_ENT_ID"] +domain = os.environ["WB2A_LOGIN_DOMAIN"] + +req = urllib.request.Request( + "https://www.codebuddy.cn/v2/billing/meter/daily-checkin", + method="POST", data=b"{}", + headers={ + "Authorization": "Bearer " + token, + "Accept": "application/json", + "Content-Type": "application/json", + "X-User-Id": user_id, + **({"X-Enterprise-Id": ent_id, "X-Tenant-Id": ent_id} if ent_id else {}), + **({"X-Domain": domain} if domain else {}), + }) +try: + with urllib.request.urlopen(req, timeout=15) as r: + body = json.loads(r.read().decode() or "{}") + if body.get("code") == 0: + data = body.get("data") or {} + print(f"签到: 成功 {json.dumps(data, ensure_ascii=False)[:150]}") + else: + print(f"签到: {body.get('msg', json.dumps(body)[:150])}") +except urllib.error.HTTPError as e: + # 已签到等业务错误也走 4xx(实测 code=10001 "今天已签到") + try: + body = json.loads(e.read().decode() or "{}") + print(f"签到: {body.get('msg', 'http %d' % e.code)}") + except Exception: + print(f"签到: http {e.code}") +except Exception as e: + print(f"签到: {e}") +PYEOF + +# ─── 落盘 auth 文件(与 internal/auth 读取格式一致)───────────────── +AUTH_FILE="$AUTH_DIR/workbuddy-${USER_ID}.json" +if [[ -f "$AUTH_FILE" ]]; then + echo "账号已存在(uid=${USER_ID}),将覆盖更新凭证" + ACTION="覆盖" +else + echo "新账号(uid=${USER_ID}),新增 auth 文件" + ACTION="新增" +fi +WB2A_LOGIN_TOKEN="$TOKEN" \ +WB2A_LOGIN_REFRESH="$REFRESH" \ +WB2A_LOGIN_EXPIRES_AT="$EXPIRES_AT" \ +WB2A_LOGIN_DOMAIN="$DOMAIN" \ +WB2A_LOGIN_USER_ID="$USER_ID" \ +WB2A_LOGIN_ENT_ID="$ENT_ID" \ +WB2A_LOGIN_NICKNAME="$NICKNAME" \ +WB2A_LOGIN_AUTH_FILE="$AUTH_FILE" \ +WB2A_LOGIN_ACTION="$ACTION" \ +python3 - <<'PYEOF' +import json, os + +auth = { + "account": { + "uid": os.environ["WB2A_LOGIN_USER_ID"], + "enterpriseId": os.environ["WB2A_LOGIN_ENT_ID"], + "nickname": os.environ["WB2A_LOGIN_NICKNAME"], + }, + "auth": { + "accessToken": os.environ["WB2A_LOGIN_TOKEN"], + "refreshToken": os.environ["WB2A_LOGIN_REFRESH"], + "expiresAt": int(os.environ["WB2A_LOGIN_EXPIRES_AT"]), + "domain": os.environ["WB2A_LOGIN_DOMAIN"], + }, +} +with open(os.environ["WB2A_LOGIN_AUTH_FILE"], "w") as f: + json.dump(auth, f, indent=1) +print(f"已保存({os.environ['WB2A_LOGIN_ACTION']}): {os.environ['WB2A_LOGIN_AUTH_FILE']}") +PYEOF + +# ─── 重启服务 ──────────────────────────────────────────── +echo "" +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + echo "重启 $CONTAINER 加载新账号..." + docker restart "$CONTAINER" >/dev/null + sleep 2 + # API_KEY 从 config.json 读取(该变量在脚本中未定义,fallback 仅为占位,不会通过鉴权) + API_KEY=$(python3 -c "import json; print(json.load(open('config.json')).get('api_key',''))" 2>/dev/null) + COUNT=$(curl -s http://127.0.0.1:7863/status -H "Authorization: Bearer ${API_KEY:-test_key}" 2>/dev/null | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('accounts',[])))" 2>/dev/null || echo "?") + echo "服务已重启,当前账号数: $COUNT" +else + echo "容器 $CONTAINER 未运行,auth 文件已保存,下次启动自动加载" +fi + +echo "" +echo "============================================================" +echo " 登录完成!" +echo " UID: $USER_ID" +echo " Nickname: ${NICKNAME:-(未获取到)}" +echo " Token: ${TOKEN:0:30}..." +echo " 有效期: $(date -d "@$EXPIRES_AT" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$EXPIRES_AT")" +echo "============================================================" diff --git a/scripts/probe_active.py b/scripts/probe_active.py index 09c365ed557937ccc8b878c83d46c563f9f0986b..cd9d9e634e80b8254e36bd51cd7df270768800cf 100644 --- a/scripts/probe_active.py +++ b/scripts/probe_active.py @@ -1,133 +1,133 @@ -#!/usr/bin/env python3 -"""WorkBuddy 活跃上报探针 / 解冻工具. - -用途 - probe 只读:查某账号 growth 任务状态 + streak - report 写:向 /v2/report 发一条 chat_request_send(点亮活跃/连登 + 解锁 first_buddy) - unlock 写:report -> agreement -> buddy/first(无猫号领免费猫) - -用法 - python3 probe_active.py probe 00e26541 - python3 probe_active.py report 0225284f - python3 probe_active.py unlock 0225284f --yes - python3 probe_active.py report ALL --yes --gap 1.05 # 全池 - -注意 - - 默认 dry-run:写动作必须显式加 --yes - - 单账号同接口间隔 >=1.05s(--gap) - - body 必须含 userId 字段,否则服务端 200 但静默丢弃 -""" -import json, os, sys, time, glob, argparse, urllib.request, urllib.error - -AUTHS = "/root/workbuddy2api/auths" -CHAT_BASE = "https://copilot.tencent.com" # growth / report -BILL_BASE = "https://www.codebuddy.cn" # billing / report - -def cred(prefix): - hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{prefix}*.json")) - if not hits: - raise SystemExit(f"no auth for {prefix}") - d = json.load(open(hits[0])) - a, acc = d["auth"], d["account"] - return {"token": a["accessToken"], "domain": a.get("domain") or "", - "uid": acc["uid"], "nick": acc.get("nickname", ""), - "file": os.path.basename(hits[0])} - -def call(c, method, path, body=None, host=CHAT_BASE, timeout=30): - url = path if path.startswith("http") else host + path - hdr = {"Authorization": "Bearer " + c["token"], "X-User-Id": c["uid"], - "X-Domain": c["domain"], "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "CLI/2.63.2 CodeBuddy/2.63.2", - "Origin": "https://www.codebuddy.cn", "Referer": "https://www.codebuddy.cn/"} - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request(url, data=data, headers=hdr, method=method) - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return r.status, json.loads(r.read().decode("utf-8", "replace")) - except urllib.error.HTTPError as e: - t = e.read().decode("utf-8", "replace") - try: return e.code, json.loads(t) - except Exception: return e.code, {"raw": t[:300]} - except Exception as e: - return -1, {"err": repr(e)} - -def chat_event(c, conversation_id=None): - """客户端 chat_request_send 事件形状(照抄 CLI,勿删 userId)。""" - now = int(time.time() * 1000) - cid = conversation_id or f"wb-active-{now}" - return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, - "mode": "craft", "conversationId": cid, "requestId": cid, - "inputLength": 12, "requestModelId": "deepseek-v4-flash", - "requestModelName": "DeepSeek V4 Flash", "isPlan": False, - "isAutoExecuteTerminal": False, "isAutoModify": False, - "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, - "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], - "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, - "command": "", "expertId": "", "recommendId": "", "skillId": "", - "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, - "traceId": "", "rootRequestId": cid, "parentConversationId": cid, - "agentName": "default", "agentType": "conversation", "userId": c["uid"]} - -def report_activity(c): - """点亮活跃/连登。返回 (status, code).""" - st, r = call(c, "POST", "/v2/report", [chat_event(c)], host=BILL_BASE) - return st, r.get("code") - -def tasks(c): - st, d = call(c, "GET", "/v2/activity/growth/tasks") - out = {} - for t in (d.get("data", {}).get("tasks") or []): - out[t["task_code"]] = (t["accept_status"], t.get("progress")) - return out - -def unlock(c, n_reports=6): - """无猫号解冻:report -> agreement -> buddy/first -> chat_5""" - st, code = report_activity(c) - print(f" report -> {st} code={code}") - time.sleep(2) - st, ag = call(c, "POST", "/activity/growth/buddy/agreement", {"agree": True}) - print(f" agreement -> {st} code={ag.get('code')}") - st, bf = call(c, "POST", "/activity/growth/buddy/first", {}) - d = bf.get("data") or {} - print(f" buddy/first -> {st} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") - return bf - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("action", choices=["probe", "report", "unlock"]) - ap.add_argument("account", help="uid 前缀,或 ALL") - ap.add_argument("--yes", action="store_true", help="确认执行写操作") - ap.add_argument("--gap", type=float, default=1.05) - a = ap.parse_args() - - if a.account.upper() == "ALL": - prefixes = [os.path.basename(p)[10:18] - for p in sorted(glob.glob(AUTHS + "/workbuddy-*.json"))] - else: - prefixes = [a.account] - - for pre in prefixes: - c = cred(pre) - print(f"== {c['uid'][:8]} ({c['nick']}) ==") - if a.action == "probe": - t = tasks(c) - st, s = call(c, "GET", "/activity/growth/streak") - print(" first_buddy:", t.get("first_buddy"), - "| RichMeow_Chat:", t.get("RichMeow_Chat"), - "| chat_5:", t.get("chat_5")) - print(" streak.days:", (s.get("data", {}).get("streak", {}) or {}).get("days")) - elif a.action == "report": - if not a.yes: - print(" [dry-run] 将上报 1 条 chat_request_send(加 --yes 执行)") - else: - print(" report ->", report_activity(c)) - else: # unlock - if not a.yes: - print(" [dry-run] 将 report + agreement + buddy/first(加 --yes 执行)") - else: - unlock(c) - time.sleep(a.gap) - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""WorkBuddy 活跃上报探针 / 解冻工具. + +用途 + probe 只读:查某账号 growth 任务状态 + streak + report 写:向 /v2/report 发一条 chat_request_send(点亮活跃/连登 + 解锁 first_buddy) + unlock 写:report -> agreement -> buddy/first(无猫号领免费猫) + +用法 + python3 probe_active.py probe 00e26541 + python3 probe_active.py report 0225284f + python3 probe_active.py unlock 0225284f --yes + python3 probe_active.py report ALL --yes --gap 1.05 # 全池 + +注意 + - 默认 dry-run:写动作必须显式加 --yes + - 单账号同接口间隔 >=1.05s(--gap) + - body 必须含 userId 字段,否则服务端 200 但静默丢弃 +""" +import json, os, sys, time, glob, argparse, urllib.request, urllib.error + +AUTHS = "/root/workbuddy2api/auths" +CHAT_BASE = "https://copilot.tencent.com" # growth / report +BILL_BASE = "https://www.codebuddy.cn" # billing / report + +def cred(prefix): + hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{prefix}*.json")) + if not hits: + raise SystemExit(f"no auth for {prefix}") + d = json.load(open(hits[0])) + a, acc = d["auth"], d["account"] + return {"token": a["accessToken"], "domain": a.get("domain") or "", + "uid": acc["uid"], "nick": acc.get("nickname", ""), + "file": os.path.basename(hits[0])} + +def call(c, method, path, body=None, host=CHAT_BASE, timeout=30): + url = path if path.startswith("http") else host + path + hdr = {"Authorization": "Bearer " + c["token"], "X-User-Id": c["uid"], + "X-Domain": c["domain"], "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "CLI/2.63.2 CodeBuddy/2.63.2", + "Origin": "https://www.codebuddy.cn", "Referer": "https://www.codebuddy.cn/"} + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=hdr, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + try: return e.code, json.loads(t) + except Exception: return e.code, {"raw": t[:300]} + except Exception as e: + return -1, {"err": repr(e)} + +def chat_event(c, conversation_id=None): + """客户端 chat_request_send 事件形状(照抄 CLI,勿删 userId)。""" + now = int(time.time() * 1000) + cid = conversation_id or f"wb-active-{now}" + return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, + "mode": "craft", "conversationId": cid, "requestId": cid, + "inputLength": 12, "requestModelId": "deepseek-v4-flash", + "requestModelName": "DeepSeek V4 Flash", "isPlan": False, + "isAutoExecuteTerminal": False, "isAutoModify": False, + "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, + "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], + "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, + "command": "", "expertId": "", "recommendId": "", "skillId": "", + "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, + "traceId": "", "rootRequestId": cid, "parentConversationId": cid, + "agentName": "default", "agentType": "conversation", "userId": c["uid"]} + +def report_activity(c): + """点亮活跃/连登。返回 (status, code).""" + st, r = call(c, "POST", "/v2/report", [chat_event(c)], host=BILL_BASE) + return st, r.get("code") + +def tasks(c): + st, d = call(c, "GET", "/v2/activity/growth/tasks") + out = {} + for t in (d.get("data", {}).get("tasks") or []): + out[t["task_code"]] = (t["accept_status"], t.get("progress")) + return out + +def unlock(c, n_reports=6): + """无猫号解冻:report -> agreement -> buddy/first -> chat_5""" + st, code = report_activity(c) + print(f" report -> {st} code={code}") + time.sleep(2) + st, ag = call(c, "POST", "/activity/growth/buddy/agreement", {"agree": True}) + print(f" agreement -> {st} code={ag.get('code')}") + st, bf = call(c, "POST", "/activity/growth/buddy/first", {}) + d = bf.get("data") or {} + print(f" buddy/first -> {st} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") + return bf + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("action", choices=["probe", "report", "unlock"]) + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--gap", type=float, default=1.05) + a = ap.parse_args() + + if a.account.upper() == "ALL": + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = cred(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + if a.action == "probe": + t = tasks(c) + st, s = call(c, "GET", "/activity/growth/streak") + print(" first_buddy:", t.get("first_buddy"), + "| RichMeow_Chat:", t.get("RichMeow_Chat"), + "| chat_5:", t.get("chat_5")) + print(" streak.days:", (s.get("data", {}).get("streak", {}) or {}).get("days")) + elif a.action == "report": + if not a.yes: + print(" [dry-run] 将上报 1 条 chat_request_send(加 --yes 执行)") + else: + print(" report ->", report_activity(c)) + else: # unlock + if not a.yes: + print(" [dry-run] 将 report + agreement + buddy/first(加 --yes 执行)") + else: + unlock(c) + time.sleep(a.gap) + +if __name__ == "__main__": + main() diff --git a/scripts/probe_max_tokens.py b/scripts/probe_max_tokens.py index 34eb64595a241486691fba4157845877bcd3cbe6..c7fb9c79a70ddb26ab3539d6344914d7c75435c7 100644 --- a/scripts/probe_max_tokens.py +++ b/scripts/probe_max_tokens.py @@ -1,425 +1,425 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -OpenAI 兼容端点的「真实输出上限」探测工具 -========================================== -背景:许多网关/中转会在 /v1/models 里透出 max_output_tokens,但它常常与实际能力不符 -(实测某网关对 deepseek-v4.1-flash 声称 393216,真实只有 32000)。更麻烦的是**静默钳制**: -请求 max_tokens 超过真实上限时不报错,而是内部截断——从 usage 只能看出"输出到某个值就停了"。 - -本工具用「强制长输出 + 阶梯上探」测出真实上限,关键判据区分两种停止: - - finish=length 且 输出 < 请求 → 上游截断,**输出值即真实上限**(可信,直接得出结果) - finish=length 且 输出 = 请求 → 支持至少这么多,继续上探更高阶梯 - finish=stop 且 输出 < 请求 → 模型自己不想写了,**不可作判据**,需强化提示重试 - -用法: - # 测某网关全部以给定前缀开头的模型(默认 cn:) - python probe_max_tokens.py --base http://211.154.25.123:7863/v1 --key sk-xxx - - # 指定模型 / 多个前缀 / 只看计划 - python probe_max_tokens.py --base ... --key ... --models flash,glm-5.3 - python probe_max_tokens.py --base ... --key ... --prefix cn: --prefix global: - python probe_max_tokens.py --base ... --key ... --dry-run - - # 控制成本与时长 - python probe_max_tokens.py --base ... --key ... --tiers 40000,100000 --retries 2 --timeout 900 - -结果: - - 控制台表格:模型 | 声称上限 | 实测上限 | 判据 | 耗时 - - JSONL 落盘(--out,默认 probe-max-tokens.jsonl),**支持 --resume 断点续测** - - --panel-out PATH 额外写面板契约文件(默认关闭;网关面板「模型与档位」的实测列读它): - {"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000, - "verdict":"clamped|at_least|inconclusive","note":"原始判据文本", - "tested_at":"...","source":"probe_max_tokens.py"}}} - 写入为合并语义:未重测的模型保留旧记录;文件放网关 state 文件同目录 - (默认部署即 data/output_probes.json),面板下次查询即展示,网关零重启。 - - --limit N 只测前 N 个模型(先小批量试跑) - -成本提示:一个模型若真有极大上限,上探到高档位会生成数十万 token,耗时长且消耗额度。 - 默认最高阶梯 40000、上限 160000 已足够识别常见的 32K/48K/64K 钳制。 -""" - -import argparse -import json -import os -import re -import socket -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path - -# 强制长输出的提示:目标值需与被测档位"量级相当"。 -# 实测教训:目标写得过大(数到 999999)模型会判断"不可能完成"而提前收尾(只输出 1~3k); -# 目标设为 12000~20000(能写满但看起来合理)时,模型会持续输出直到被硬截断,4/4 稳定命中。 -# 下面按档位缩放目标值:约 max_tokens/3 个数字(每数字行约 2~3 token,留余量防提前写完)。 -def long_prompt(max_tokens: int) -> str: - target = max(2000, min(200000, max_tokens // 3)) - return ( - f"请从 1 开始,每行输出一个数字,依次递增,一直数到 {target}。" - "严格只输出数字本身,每行一个;不要解释、不要总结、不要省略、不要合并、不要提前结束。" - ) - - -# 强化版(第一次被模型主动收尾时使用,仍保持量级匹配) -def long_prompt_hard(max_tokens: int) -> str: - target = max(2000, min(200000, max_tokens // 3)) - return ( - f"Count from 1 to {target}, one number per line, incrementing by 1. " - "Output digits only, one per line. Do not summarize, do not explain, " - "do not skip numbers, do not stop early — you must reach the target." - ) - - -def fetch_models(base: str, key: str, timeout: int = 20): - """取 /v1/models 列表:[{id, claimed_output, claimed_ctx}, ...]""" - url = base.rstrip("/") + "/models" - req = urllib.request.Request(url, headers={ - "Authorization": f"Bearer {key}", "x-api-key": key, "Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=timeout) as r: - data = json.loads(r.read().decode("utf-8")) - items = data.get("data") if isinstance(data, dict) else data - out = [] - for it in items or []: - if isinstance(it, str): - out.append({"id": it.strip(), "claimed_output": None, "claimed_ctx": None}) - continue - if not isinstance(it, dict): - continue - mid = (it.get("id") or it.get("name") or "").strip() - if not mid: - continue - top = it.get("top_provider") if isinstance(it.get("top_provider"), dict) else {} - out.append({ - "id": mid, - "claimed_output": it.get("max_output_tokens") or it.get("max_completion_tokens") - or top.get("max_completion_tokens"), - "claimed_ctx": it.get("context_length") or it.get("max_input_tokens"), - }) - return out - - -def probe_once(base: str, key: str, model: str, max_tokens: int, prompt: str, timeout: int): - """发一次强制长输出请求,返回 (out_tokens, reasoning_tokens, finish, err)。""" - body = json.dumps({ - "model": model, "stream": True, "max_tokens": max_tokens, - "messages": [{"role": "user", "content": prompt}], - }).encode() - req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body, headers={ - "Authorization": f"Bearer {key}", "x-api-key": key, - "Content-Type": "application/json", "Accept": "text/event-stream"}, method="POST") - out = None - reasoning = None - finish = None - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - buf = "" - while True: - piece = r.read(8192) - if not piece: - break - buf += piece.decode("utf-8", "replace") - while "\n" in buf: - line, buf = buf.split("\n", 1) - line = line.strip() - if not line.startswith("data:"): - continue - payload = line[5:].strip() - if not payload or payload == "[DONE]": - continue - try: - obj = json.loads(payload) - except Exception: - continue - u = obj.get("usage") - if isinstance(u, dict): - out = u.get("completion_tokens") - det = u.get("completion_tokens_details") - if isinstance(det, dict): - reasoning = det.get("reasoning_tokens") - for c in obj.get("choices") or []: - if isinstance(c, dict) and c.get("finish_reason"): - finish = c["finish_reason"] - except urllib.error.HTTPError as e: - detail = e.read().decode("utf-8", "replace")[:300] - return None, None, None, f"HTTP {e.code}: {detail}" - except Exception as e: - return None, None, None, f"{type(e).__name__}: {str(e)[:150]}" - return out, reasoning, finish, None - - -def probe_model(base: str, key: str, model: str, tiers: list[int], - retries: int, timeout: int, quiet: bool, budget: int = 0) -> dict: - """阶梯上探单个模型,返回结果字典。 - budget>0 时为单模型总时间预算(秒):超出即中止并记录"未测完",避免个别大上限模型 - 把整体拖到几十分钟(实测某模型生成 40000 token 需 720s,上探 100000 档要半小时)。""" - result = {"model": model, "measured": None, "verdict": None, "evidence": []} - started = time.time() - all_failed = True - for tier in tiers: - if budget and time.time() - started > budget: - result.update(verdict=f"未测完(超出时间预算 {budget}s;已知至少 >{result.get('max_full') or 0})") - result["elapsed"] = round(time.time() - started, 1) - return result - tier_ok = False - for attempt in range(1, retries + 1): - prompt = long_prompt(tier) if attempt == 1 else long_prompt_hard(tier) - # 单次请求也受剩余预算约束,防止单请求无上限地跑 - req_timeout = timeout - if budget: - remain = budget - (time.time() - started) - if remain <= 5: - break - req_timeout = int(min(timeout, remain)) - t0 = time.time() - out, reasoning, finish, err = probe_once(base, key, model, tier, prompt, req_timeout) - dt = time.time() - t0 - if err: - result["evidence"].append(f"tier={tier} 请求失败: {err[:120]}") - if not quiet: - print(f" [{model}] tier={tier}: 失败 {err[:70]}", flush=True) - break - tier_ok = True - all_failed = False - rs = f" 推理{reasoning}" if reasoning else "" - if not quiet: - print(f" [{model}] tier={tier} #{attempt}: 输出 {out} tok{rs} " - f"finish={finish} {dt:.0f}s", flush=True) - - if finish == "length" and out is not None: - if out < tier: - # 上游截断。注意:若上一档已满额通过(max_full),说明真实上限在 - # [max_full, out] 之间,语义是"≥out 附近被钳",用区间表达更准确。 - prev = result.get("max_full") - if prev and out < prev: - verdict = f"≈{out}(钳制;上一档满额 {prev},取值 {out})" - else: - verdict = "钳制" - result.update(measured=out, verdict=verdict, - evidence=result["evidence"] + [f"要 {tier} 只给 {out}(finish=length)"]) - result["elapsed"] = round(time.time() - started, 1) - return result - result["evidence"].append(f"tier={tier}: 输出满额 {out}(支持≥{tier})") - result["max_full"] = out - break - if finish == "stop": - result["evidence"].append(f"tier={tier} #{attempt}: finish=stop(模型主动收尾,输出 {out})") - if out is not None: - result["max_full"] = max(result.get("max_full") or 0, out) - if attempt == retries: - # 已满额通过的档位仍然有效:结论是"≥max_full",不是"无结论" - if result.get("max_full"): - result.update(measured=None, - verdict=f"≥{result['max_full']}(模型主动收尾,未测到钳制点;" - f"声称 {result.get('claimed_output') or '?'})") - else: - result.update(verdict="模型主动停止(未能测出上限,非钳制)") - result["elapsed"] = round(time.time() - started, 1) - return result - continue - result["evidence"].append(f"tier={tier}: 异常返回 out={out} finish={finish}") - break - - if all_failed: - result.update(measured=None, verdict="全部请求失败(模型/账号不可用,无结论)") - result["elapsed"] = round(time.time() - started, 1) - return result - if result.get("max_full"): - result.update(measured=result["max_full"], - verdict=f"≥{result['max_full']}(最高档位仍满额,未触顶)") - result["elapsed"] = round(time.time() - started, 1) - return result - - -def classify(res: dict): - """把一条探测记录映射为面板契约五态之三:clamped / at_least / inconclusive。 - - verdict 原文是自由中文文本,这里按前缀归类: - 钳制 / ≈N(钳制…) → clamped,measured = 截断值(可信) - ≥N(…)/ 未测完(…至少 >N)→ at_least,measured = 已知下界 - 其余(主动停止/全部失败) → inconclusive - 下界取值链:measured → max_full → 文本里的 ≥N / >N。""" - v = res.get("verdict") or "" - meas = res.get("measured") - if v.startswith("钳制") or v.startswith("≈"): - return "clamped", meas - floor = meas if isinstance(meas, int) else res.get("max_full") - if not floor: - m = re.search(r"[≥>]\s*(\d+)", v) - floor = int(m.group(1)) if m else None - if v.startswith("≥") or v.startswith("未测完"): - return ("at_least", floor) if floor else ("inconclusive", None) - return "inconclusive", None - - -def write_panel_out(path: str, results: list) -> None: - """写面板契约文件(合并语义:未重测的模型保留旧记录,上游改限后重跑即覆盖)。""" - p = Path(path) - contract = {"version": 1, "probes": {}} - if p.is_file(): - try: - old = json.loads(p.read_text(encoding="utf-8")) - if isinstance(old.get("probes"), dict): - contract["probes"].update(old["probes"]) - except Exception: - pass # 旧文件损坏则整体重建,不让坏文件卡住新结果 - for r in results: - verdict, meas = classify(r) - contract["probes"][r["model"]] = { - "claimed": r.get("claimed_output"), - "measured": meas, - "verdict": verdict, - "note": r.get("verdict") or "", - "tested_at": r.get("time") or "", - "source": "probe_max_tokens.py", - } - tmp = p.with_suffix(p.suffix + ".tmp") - tmp.parent.mkdir(parents=True, exist_ok=True) - tmp.write_text(json.dumps(contract, ensure_ascii=False, indent=1), encoding="utf-8") - tmp.replace(p) - print(f"面板探测数据已写入 {p}(共 {len(contract['probes'])} 条,合并保留未重测模型)") - - -def main() -> None: - ap = argparse.ArgumentParser( - description="探测 OpenAI 兼容端点的真实输出上限(区分静默钳制与模型主动停止)") - ap.add_argument("--base", required=True, help="接口基址,如 http://host:7863/v1") - ap.add_argument("--key", default=None, help="API Key(也可用环境变量 PROBE_API_KEY)") - ap.add_argument("--prefix", action="append", default=None, - help="只测以此前缀开头的模型(可多次;默认 cn:)") - ap.add_argument("--models", default=None, help="按子串匹配模型 id(逗号分隔,优先级高于 --prefix)") - ap.add_argument("--tiers", default="40000,100000", - help="阶梯请求值,逗号分隔(默认 40000,100000;越高越慢越费额度)") - ap.add_argument("--retries", type=int, default=3, help="finish=stop 时的重试次数(默认 3)") - ap.add_argument("--timeout", type=int, default=900, help="单次请求超时秒数(默认 900)") - ap.add_argument("--limit", type=int, default=0, help="只测前 N 个模型(0=全部)") - ap.add_argument("--out", default="probe-max-tokens.jsonl", help="结果 JSONL 路径") - ap.add_argument("--panel-out", default=None, - help="额外写网关面板契约文件(如 data/output_probes.json;默认关闭)。" - "合并写入:未重测的模型保留旧记录") - ap.add_argument("--resume", action="store_true", help="跳过已有结果的模型(断点续测)") - ap.add_argument("--jobs", type=int, default=4, - help="并行探测的模型数(默认 4;瓶颈是墙钟等待,并行可大幅缩短总时长)") - ap.add_argument("--budget", type=int, default=600, - help="单模型时间预算秒数(默认 600;超出记「未测完」,避免大上限模型拖垮整体)") - ap.add_argument("--dry-run", action="store_true", help="只列出将要测的模型与阶梯,不发请求") - ap.add_argument("--quiet", action="store_true", help="不打印每次尝试明细") - args = ap.parse_args() - - key = args.key or os.environ.get("PROBE_API_KEY") or "" - if not key and not args.dry_run: - raise SystemExit("[!] 需要 --key 或环境变量 PROBE_API_KEY") - - tiers = [int(x) for x in args.tiers.split(",") if x.strip()] - tiers.sort() - - socket.setdefaulttimeout(args.timeout) - - print(f"目标: {args.base}") - all_models = fetch_models(args.base, key) - if args.models: - kws = [k.strip().lower() for k in args.models.split(",") if k.strip()] - def _match(mid: str) -> int: - """返回匹配优先级:0=不匹配,3=带前缀的完整 id,2=后缀精确,1=子串。 - 关键词含 ':' 时按完整 id 比对(cn:xxx 只命中 cn:xxx,不含 global:xxx)。""" - low = mid.lower() - for k in kws: - if ":" in k and low == k: - return 3 - for k in kws: - if ":" not in k and (low == k or low.endswith(":" + k)): - return 2 - for k in kws: - if k in low: - return 1 - return 0 - scored = [(m, _match(m["id"])) for m in all_models] - best = max((s for _, s in scored), default=0) - targets = [m for m, s in scored if s == best and s > 0] - else: - prefixes = args.prefix or ["cn:"] - targets = [m for m in all_models if any(m["id"].startswith(p) for p in prefixes)] - if args.limit: - targets = targets[:args.limit] - if not targets: - raise SystemExit(f"[!] 没有匹配的模型(共 {len(all_models)} 个可测)") - - done = {} - out_path = Path(args.out) - if args.resume and out_path.is_file(): - for line in out_path.read_text(encoding="utf-8").splitlines(): - try: - rec = json.loads(line) - done[rec["model"]] = rec - except Exception: - pass - - print(f"待测模型 {len(targets)} 个 | 阶梯 {tiers} | 声称上限来自 /v1/models") - if args.dry_run: - print("\n[dry-run] 计划:") - for m in targets: - skip = " (已有结果,将跳过)" if m["id"] in done else "" - print(f" {m['id']:44} 声称 {str(m['claimed_output'] or '-'):>8}{skip}") - return - - print() - results = [] - todo = [m for m in targets if m["id"] not in done] - for m in targets: - if m["id"] in done: - results.append(done[m["id"]]) - - # 并行探测:墙钟时间是瓶颈(单模型生成 4 万 token 需数分钟),并发能有效压缩总时长。 - # 结果仍按模型去重,落盘用锁保护。 - import concurrent.futures as cf - import threading - write_lock = threading.Lock() - finished = {"n": 0} - - def _work(m): - res = probe_model(args.base, key, m["id"], tiers, args.retries, args.timeout, - args.quiet, budget=args.budget) - res["claimed_output"] = m["claimed_output"] - res["claimed_ctx"] = m["claimed_ctx"] - res["time"] = time.strftime("%Y-%m-%d %H:%M:%S") - with write_lock: - finished["n"] += 1 - n = finished["n"] - with out_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(res, ensure_ascii=False) + "\n") - measured = res["measured"] if res["measured"] is not None else "-" - print(f"[{n}/{len(todo)}] {m['id']}(声称 {m['claimed_output'] or '-'})" - f" → 实测 {measured} | {res['verdict']} | {res.get('elapsed','-')}s", flush=True) - return res - - if todo: - print(f"开始探测 {len(todo)} 个模型(并行 {args.jobs},单模型预算 {args.budget}s)...\n") - with cf.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex: - results.extend(ex.map(_work, todo)) - - # 汇总表 - print("=" * 96) - print(f"{'模型':44} {'声称':>9} {'实测':>9} {'判据':<28} {'耗时':>6}") - print("-" * 96) - for r in results: - claim = r.get("claimed_output") or "-" - meas = r.get("measured") if r.get("measured") is not None else "-" - verdict = (r.get("verdict") or "")[:26] - print(f"{r['model']:44} {str(claim):>9} {str(meas):>9} {verdict:<28} {str(r.get('elapsed','-')):>6}") - print("=" * 96) - - mism = [r for r in results if r.get("measured") and r.get("claimed_output") - and int(r["measured"]) < int(r["claimed_output"])] - if mism: - print(f"\n⚠ {len(mism)} 个模型的声称上限高于实测(静默钳制):") - for r in mism: - print(f" {r['model']}: 声称 {r['claimed_output']} → 实际 {r['measured']}") - print(f"\n结果已写入 {out_path}(--resume 可跳过已测模型继续)") - if args.panel_out: - write_panel_out(args.panel_out, results) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +OpenAI 兼容端点的「真实输出上限」探测工具 +========================================== +背景:许多网关/中转会在 /v1/models 里透出 max_output_tokens,但它常常与实际能力不符 +(实测某网关对 deepseek-v4.1-flash 声称 393216,真实只有 32000)。更麻烦的是**静默钳制**: +请求 max_tokens 超过真实上限时不报错,而是内部截断——从 usage 只能看出"输出到某个值就停了"。 + +本工具用「强制长输出 + 阶梯上探」测出真实上限,关键判据区分两种停止: + + finish=length 且 输出 < 请求 → 上游截断,**输出值即真实上限**(可信,直接得出结果) + finish=length 且 输出 = 请求 → 支持至少这么多,继续上探更高阶梯 + finish=stop 且 输出 < 请求 → 模型自己不想写了,**不可作判据**,需强化提示重试 + +用法: + # 测某网关全部以给定前缀开头的模型(默认 cn:) + python probe_max_tokens.py --base http://211.154.25.123:7863/v1 --key sk-xxx + + # 指定模型 / 多个前缀 / 只看计划 + python probe_max_tokens.py --base ... --key ... --models flash,glm-5.3 + python probe_max_tokens.py --base ... --key ... --prefix cn: --prefix global: + python probe_max_tokens.py --base ... --key ... --dry-run + + # 控制成本与时长 + python probe_max_tokens.py --base ... --key ... --tiers 40000,100000 --retries 2 --timeout 900 + +结果: + - 控制台表格:模型 | 声称上限 | 实测上限 | 判据 | 耗时 + - JSONL 落盘(--out,默认 probe-max-tokens.jsonl),**支持 --resume 断点续测** + - --panel-out PATH 额外写面板契约文件(默认关闭;网关面板「模型与档位」的实测列读它): + {"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000, + "verdict":"clamped|at_least|inconclusive","note":"原始判据文本", + "tested_at":"...","source":"probe_max_tokens.py"}}} + 写入为合并语义:未重测的模型保留旧记录;文件放网关 state 文件同目录 + (默认部署即 data/output_probes.json),面板下次查询即展示,网关零重启。 + - --limit N 只测前 N 个模型(先小批量试跑) + +成本提示:一个模型若真有极大上限,上探到高档位会生成数十万 token,耗时长且消耗额度。 + 默认最高阶梯 40000、上限 160000 已足够识别常见的 32K/48K/64K 钳制。 +""" + +import argparse +import json +import os +import re +import socket +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +# 强制长输出的提示:目标值需与被测档位"量级相当"。 +# 实测教训:目标写得过大(数到 999999)模型会判断"不可能完成"而提前收尾(只输出 1~3k); +# 目标设为 12000~20000(能写满但看起来合理)时,模型会持续输出直到被硬截断,4/4 稳定命中。 +# 下面按档位缩放目标值:约 max_tokens/3 个数字(每数字行约 2~3 token,留余量防提前写完)。 +def long_prompt(max_tokens: int) -> str: + target = max(2000, min(200000, max_tokens // 3)) + return ( + f"请从 1 开始,每行输出一个数字,依次递增,一直数到 {target}。" + "严格只输出数字本身,每行一个;不要解释、不要总结、不要省略、不要合并、不要提前结束。" + ) + + +# 强化版(第一次被模型主动收尾时使用,仍保持量级匹配) +def long_prompt_hard(max_tokens: int) -> str: + target = max(2000, min(200000, max_tokens // 3)) + return ( + f"Count from 1 to {target}, one number per line, incrementing by 1. " + "Output digits only, one per line. Do not summarize, do not explain, " + "do not skip numbers, do not stop early — you must reach the target." + ) + + +def fetch_models(base: str, key: str, timeout: int = 20): + """取 /v1/models 列表:[{id, claimed_output, claimed_ctx}, ...]""" + url = base.rstrip("/") + "/models" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {key}", "x-api-key": key, "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + data = json.loads(r.read().decode("utf-8")) + items = data.get("data") if isinstance(data, dict) else data + out = [] + for it in items or []: + if isinstance(it, str): + out.append({"id": it.strip(), "claimed_output": None, "claimed_ctx": None}) + continue + if not isinstance(it, dict): + continue + mid = (it.get("id") or it.get("name") or "").strip() + if not mid: + continue + top = it.get("top_provider") if isinstance(it.get("top_provider"), dict) else {} + out.append({ + "id": mid, + "claimed_output": it.get("max_output_tokens") or it.get("max_completion_tokens") + or top.get("max_completion_tokens"), + "claimed_ctx": it.get("context_length") or it.get("max_input_tokens"), + }) + return out + + +def probe_once(base: str, key: str, model: str, max_tokens: int, prompt: str, timeout: int): + """发一次强制长输出请求,返回 (out_tokens, reasoning_tokens, finish, err)。""" + body = json.dumps({ + "model": model, "stream": True, "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + }).encode() + req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body, headers={ + "Authorization": f"Bearer {key}", "x-api-key": key, + "Content-Type": "application/json", "Accept": "text/event-stream"}, method="POST") + out = None + reasoning = None + finish = None + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + buf = "" + while True: + piece = r.read(8192) + if not piece: + break + buf += piece.decode("utf-8", "replace") + while "\n" in buf: + line, buf = buf.split("\n", 1) + line = line.strip() + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if not payload or payload == "[DONE]": + continue + try: + obj = json.loads(payload) + except Exception: + continue + u = obj.get("usage") + if isinstance(u, dict): + out = u.get("completion_tokens") + det = u.get("completion_tokens_details") + if isinstance(det, dict): + reasoning = det.get("reasoning_tokens") + for c in obj.get("choices") or []: + if isinstance(c, dict) and c.get("finish_reason"): + finish = c["finish_reason"] + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", "replace")[:300] + return None, None, None, f"HTTP {e.code}: {detail}" + except Exception as e: + return None, None, None, f"{type(e).__name__}: {str(e)[:150]}" + return out, reasoning, finish, None + + +def probe_model(base: str, key: str, model: str, tiers: list[int], + retries: int, timeout: int, quiet: bool, budget: int = 0) -> dict: + """阶梯上探单个模型,返回结果字典。 + budget>0 时为单模型总时间预算(秒):超出即中止并记录"未测完",避免个别大上限模型 + 把整体拖到几十分钟(实测某模型生成 40000 token 需 720s,上探 100000 档要半小时)。""" + result = {"model": model, "measured": None, "verdict": None, "evidence": []} + started = time.time() + all_failed = True + for tier in tiers: + if budget and time.time() - started > budget: + result.update(verdict=f"未测完(超出时间预算 {budget}s;已知至少 >{result.get('max_full') or 0})") + result["elapsed"] = round(time.time() - started, 1) + return result + tier_ok = False + for attempt in range(1, retries + 1): + prompt = long_prompt(tier) if attempt == 1 else long_prompt_hard(tier) + # 单次请求也受剩余预算约束,防止单请求无上限地跑 + req_timeout = timeout + if budget: + remain = budget - (time.time() - started) + if remain <= 5: + break + req_timeout = int(min(timeout, remain)) + t0 = time.time() + out, reasoning, finish, err = probe_once(base, key, model, tier, prompt, req_timeout) + dt = time.time() - t0 + if err: + result["evidence"].append(f"tier={tier} 请求失败: {err[:120]}") + if not quiet: + print(f" [{model}] tier={tier}: 失败 {err[:70]}", flush=True) + break + tier_ok = True + all_failed = False + rs = f" 推理{reasoning}" if reasoning else "" + if not quiet: + print(f" [{model}] tier={tier} #{attempt}: 输出 {out} tok{rs} " + f"finish={finish} {dt:.0f}s", flush=True) + + if finish == "length" and out is not None: + if out < tier: + # 上游截断。注意:若上一档已满额通过(max_full),说明真实上限在 + # [max_full, out] 之间,语义是"≥out 附近被钳",用区间表达更准确。 + prev = result.get("max_full") + if prev and out < prev: + verdict = f"≈{out}(钳制;上一档满额 {prev},取值 {out})" + else: + verdict = "钳制" + result.update(measured=out, verdict=verdict, + evidence=result["evidence"] + [f"要 {tier} 只给 {out}(finish=length)"]) + result["elapsed"] = round(time.time() - started, 1) + return result + result["evidence"].append(f"tier={tier}: 输出满额 {out}(支持≥{tier})") + result["max_full"] = out + break + if finish == "stop": + result["evidence"].append(f"tier={tier} #{attempt}: finish=stop(模型主动收尾,输出 {out})") + if out is not None: + result["max_full"] = max(result.get("max_full") or 0, out) + if attempt == retries: + # 已满额通过的档位仍然有效:结论是"≥max_full",不是"无结论" + if result.get("max_full"): + result.update(measured=None, + verdict=f"≥{result['max_full']}(模型主动收尾,未测到钳制点;" + f"声称 {result.get('claimed_output') or '?'})") + else: + result.update(verdict="模型主动停止(未能测出上限,非钳制)") + result["elapsed"] = round(time.time() - started, 1) + return result + continue + result["evidence"].append(f"tier={tier}: 异常返回 out={out} finish={finish}") + break + + if all_failed: + result.update(measured=None, verdict="全部请求失败(模型/账号不可用,无结论)") + result["elapsed"] = round(time.time() - started, 1) + return result + if result.get("max_full"): + result.update(measured=result["max_full"], + verdict=f"≥{result['max_full']}(最高档位仍满额,未触顶)") + result["elapsed"] = round(time.time() - started, 1) + return result + + +def classify(res: dict): + """把一条探测记录映射为面板契约五态之三:clamped / at_least / inconclusive。 + + verdict 原文是自由中文文本,这里按前缀归类: + 钳制 / ≈N(钳制…) → clamped,measured = 截断值(可信) + ≥N(…)/ 未测完(…至少 >N)→ at_least,measured = 已知下界 + 其余(主动停止/全部失败) → inconclusive + 下界取值链:measured → max_full → 文本里的 ≥N / >N。""" + v = res.get("verdict") or "" + meas = res.get("measured") + if v.startswith("钳制") or v.startswith("≈"): + return "clamped", meas + floor = meas if isinstance(meas, int) else res.get("max_full") + if not floor: + m = re.search(r"[≥>]\s*(\d+)", v) + floor = int(m.group(1)) if m else None + if v.startswith("≥") or v.startswith("未测完"): + return ("at_least", floor) if floor else ("inconclusive", None) + return "inconclusive", None + + +def write_panel_out(path: str, results: list) -> None: + """写面板契约文件(合并语义:未重测的模型保留旧记录,上游改限后重跑即覆盖)。""" + p = Path(path) + contract = {"version": 1, "probes": {}} + if p.is_file(): + try: + old = json.loads(p.read_text(encoding="utf-8")) + if isinstance(old.get("probes"), dict): + contract["probes"].update(old["probes"]) + except Exception: + pass # 旧文件损坏则整体重建,不让坏文件卡住新结果 + for r in results: + verdict, meas = classify(r) + contract["probes"][r["model"]] = { + "claimed": r.get("claimed_output"), + "measured": meas, + "verdict": verdict, + "note": r.get("verdict") or "", + "tested_at": r.get("time") or "", + "source": "probe_max_tokens.py", + } + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(contract, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(p) + print(f"面板探测数据已写入 {p}(共 {len(contract['probes'])} 条,合并保留未重测模型)") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="探测 OpenAI 兼容端点的真实输出上限(区分静默钳制与模型主动停止)") + ap.add_argument("--base", required=True, help="接口基址,如 http://host:7863/v1") + ap.add_argument("--key", default=None, help="API Key(也可用环境变量 PROBE_API_KEY)") + ap.add_argument("--prefix", action="append", default=None, + help="只测以此前缀开头的模型(可多次;默认 cn:)") + ap.add_argument("--models", default=None, help="按子串匹配模型 id(逗号分隔,优先级高于 --prefix)") + ap.add_argument("--tiers", default="40000,100000", + help="阶梯请求值,逗号分隔(默认 40000,100000;越高越慢越费额度)") + ap.add_argument("--retries", type=int, default=3, help="finish=stop 时的重试次数(默认 3)") + ap.add_argument("--timeout", type=int, default=900, help="单次请求超时秒数(默认 900)") + ap.add_argument("--limit", type=int, default=0, help="只测前 N 个模型(0=全部)") + ap.add_argument("--out", default="probe-max-tokens.jsonl", help="结果 JSONL 路径") + ap.add_argument("--panel-out", default=None, + help="额外写网关面板契约文件(如 data/output_probes.json;默认关闭)。" + "合并写入:未重测的模型保留旧记录") + ap.add_argument("--resume", action="store_true", help="跳过已有结果的模型(断点续测)") + ap.add_argument("--jobs", type=int, default=4, + help="并行探测的模型数(默认 4;瓶颈是墙钟等待,并行可大幅缩短总时长)") + ap.add_argument("--budget", type=int, default=600, + help="单模型时间预算秒数(默认 600;超出记「未测完」,避免大上限模型拖垮整体)") + ap.add_argument("--dry-run", action="store_true", help="只列出将要测的模型与阶梯,不发请求") + ap.add_argument("--quiet", action="store_true", help="不打印每次尝试明细") + args = ap.parse_args() + + key = args.key or os.environ.get("PROBE_API_KEY") or "" + if not key and not args.dry_run: + raise SystemExit("[!] 需要 --key 或环境变量 PROBE_API_KEY") + + tiers = [int(x) for x in args.tiers.split(",") if x.strip()] + tiers.sort() + + socket.setdefaulttimeout(args.timeout) + + print(f"目标: {args.base}") + all_models = fetch_models(args.base, key) + if args.models: + kws = [k.strip().lower() for k in args.models.split(",") if k.strip()] + def _match(mid: str) -> int: + """返回匹配优先级:0=不匹配,3=带前缀的完整 id,2=后缀精确,1=子串。 + 关键词含 ':' 时按完整 id 比对(cn:xxx 只命中 cn:xxx,不含 global:xxx)。""" + low = mid.lower() + for k in kws: + if ":" in k and low == k: + return 3 + for k in kws: + if ":" not in k and (low == k or low.endswith(":" + k)): + return 2 + for k in kws: + if k in low: + return 1 + return 0 + scored = [(m, _match(m["id"])) for m in all_models] + best = max((s for _, s in scored), default=0) + targets = [m for m, s in scored if s == best and s > 0] + else: + prefixes = args.prefix or ["cn:"] + targets = [m for m in all_models if any(m["id"].startswith(p) for p in prefixes)] + if args.limit: + targets = targets[:args.limit] + if not targets: + raise SystemExit(f"[!] 没有匹配的模型(共 {len(all_models)} 个可测)") + + done = {} + out_path = Path(args.out) + if args.resume and out_path.is_file(): + for line in out_path.read_text(encoding="utf-8").splitlines(): + try: + rec = json.loads(line) + done[rec["model"]] = rec + except Exception: + pass + + print(f"待测模型 {len(targets)} 个 | 阶梯 {tiers} | 声称上限来自 /v1/models") + if args.dry_run: + print("\n[dry-run] 计划:") + for m in targets: + skip = " (已有结果,将跳过)" if m["id"] in done else "" + print(f" {m['id']:44} 声称 {str(m['claimed_output'] or '-'):>8}{skip}") + return + + print() + results = [] + todo = [m for m in targets if m["id"] not in done] + for m in targets: + if m["id"] in done: + results.append(done[m["id"]]) + + # 并行探测:墙钟时间是瓶颈(单模型生成 4 万 token 需数分钟),并发能有效压缩总时长。 + # 结果仍按模型去重,落盘用锁保护。 + import concurrent.futures as cf + import threading + write_lock = threading.Lock() + finished = {"n": 0} + + def _work(m): + res = probe_model(args.base, key, m["id"], tiers, args.retries, args.timeout, + args.quiet, budget=args.budget) + res["claimed_output"] = m["claimed_output"] + res["claimed_ctx"] = m["claimed_ctx"] + res["time"] = time.strftime("%Y-%m-%d %H:%M:%S") + with write_lock: + finished["n"] += 1 + n = finished["n"] + with out_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(res, ensure_ascii=False) + "\n") + measured = res["measured"] if res["measured"] is not None else "-" + print(f"[{n}/{len(todo)}] {m['id']}(声称 {m['claimed_output'] or '-'})" + f" → 实测 {measured} | {res['verdict']} | {res.get('elapsed','-')}s", flush=True) + return res + + if todo: + print(f"开始探测 {len(todo)} 个模型(并行 {args.jobs},单模型预算 {args.budget}s)...\n") + with cf.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex: + results.extend(ex.map(_work, todo)) + + # 汇总表 + print("=" * 96) + print(f"{'模型':44} {'声称':>9} {'实测':>9} {'判据':<28} {'耗时':>6}") + print("-" * 96) + for r in results: + claim = r.get("claimed_output") or "-" + meas = r.get("measured") if r.get("measured") is not None else "-" + verdict = (r.get("verdict") or "")[:26] + print(f"{r['model']:44} {str(claim):>9} {str(meas):>9} {verdict:<28} {str(r.get('elapsed','-')):>6}") + print("=" * 96) + + mism = [r for r in results if r.get("measured") and r.get("claimed_output") + and int(r["measured"]) < int(r["claimed_output"])] + if mism: + print(f"\n⚠ {len(mism)} 个模型的声称上限高于实测(静默钳制):") + for r in mism: + print(f" {r['model']}: 声称 {r['claimed_output']} → 实际 {r['measured']}") + print(f"\n结果已写入 {out_path}(--resume 可跳过已测模型继续)") + if args.panel_out: + write_panel_out(args.panel_out, results) + + +if __name__ == "__main__": + main() diff --git a/scripts/task_chat5.py b/scripts/task_chat5.py index 2672e3a340eef18d2d6a48de1183a38491ffdf6d..cc7024d416280fd606c68f64f6b1107fcdaa490e 100644 --- a/scripts/task_chat5.py +++ b/scripts/task_chat5.py @@ -1,76 +1,76 @@ -#!/usr/bin/env python3 -"""一次性任务脚本:chat_5(和 AI 聊天 5 次,+100 积分 +5 能量). - -通过 {billing}/v2/report 上报 chat_request_send 事件完成进度累计。 -照抄 report.go 的 chatRequestEvent 完整形状(含 userId,缺则静默丢弃)。 -默认 dry-run,--yes 才发真实上报;默认补到 5 次,支持 --count N。 - -用法 - python3 task_chat5.py # dry-run - python3 task_chat5.py --yes # 补齐到 5 次 - python3 task_chat5.py --yes --count 2 # 只补 2 次 -""" -import sys, os, argparse -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import task_common as tc - -TASK_CODE = "chat_5" -TARGET = 5 - - -def main(): - ap = argparse.ArgumentParser(description="chat_5 任务:上报 N 次对话完成 5/5") - ap.add_argument("account", help="uid 前缀,或 ALL") - ap.add_argument("--yes", action="store_true", help="确认执行写操作") - ap.add_argument("--count", type=int, default=0, - help="本次上报条数(默认自动补到 5 次)") - ap.add_argument("--gap", type=float, default=1.05) - a = ap.parse_args() - - prefixes = [] - if a.account.upper() == "ALL": - import glob - prefixes = [os.path.basename(p)[10:18] - for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] - else: - prefixes = [a.account] - - for pre in prefixes: - c = tc.load_auth(pre) - print(f"== {c['uid'][:8]} ({c['nick']}) ==") - try: - st = tc.task_status(c, TASK_CODE) - except Exception as e: - print(f" [skip] list_tasks 失败: {e}") - continue - if st is None: - print(f" [skip] 无 {TASK_CODE} 任务") - continue - ast = st.get("accept_status") - prog = (st.get("progress") or {}) - cur = prog.get("current", 0) - target = prog.get("target", TARGET) - if ast == "claimed" or cur >= target: - print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") - continue - need = max(0, target - cur) - if a.count > 0: - need = min(need, a.count) - if need <= 0: - print(" [skip] 无需上报") - continue - if not a.yes: - print(f" [dry-run] 将上报 {need} 条 chat_request_send " - f"(当前 {cur}/{target},加 --yes 生效)") - continue - results = tc.report_activity(c, count=need, gap=a.gap) - # 回读确认 - st2 = tc.task_status(c, TASK_CODE) - prog2 = (st2.get("progress") or {}) if st2 else {} - print(f" report x{need} -> {results}") - print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " - f"accept_status={st2.get('accept_status') if st2 else '?'}") - - -if __name__ == "__main__": +#!/usr/bin/env python3 +"""一次性任务脚本:chat_5(和 AI 聊天 5 次,+100 积分 +5 能量). + +通过 {billing}/v2/report 上报 chat_request_send 事件完成进度累计。 +照抄 report.go 的 chatRequestEvent 完整形状(含 userId,缺则静默丢弃)。 +默认 dry-run,--yes 才发真实上报;默认补到 5 次,支持 --count N。 + +用法 + python3 task_chat5.py # dry-run + python3 task_chat5.py --yes # 补齐到 5 次 + python3 task_chat5.py --yes --count 2 # 只补 2 次 +""" +import sys, os, argparse +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "chat_5" +TARGET = 5 + + +def main(): + ap = argparse.ArgumentParser(description="chat_5 任务:上报 N 次对话完成 5/5") + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--count", type=int, default=0, + help="本次上报条数(默认自动补到 5 次)") + ap.add_argument("--gap", type=float, default=1.05) + a = ap.parse_args() + + prefixes = [] + if a.account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + need = max(0, target - cur) + if a.count > 0: + need = min(need, a.count) + if need <= 0: + print(" [skip] 无需上报") + continue + if not a.yes: + print(f" [dry-run] 将上报 {need} 条 chat_request_send " + f"(当前 {cur}/{target},加 --yes 生效)") + continue + results = tc.report_activity(c, count=need, gap=a.gap) + # 回读确认 + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + print(f" report x{need} -> {results}") + print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " + f"accept_status={st2.get('accept_status') if st2 else '?'}") + + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/task_common.py b/scripts/task_common.py index 63f12b41e12d1cf4e09a36ac55c394391c6470f6..091c80c238e8fc7fbdabefa127156a83c7547b9d 100644 --- a/scripts/task_common.py +++ b/scripts/task_common.py @@ -1,223 +1,223 @@ -#!/usr/bin/env python3 -"""一次性积分任务脚本的共享函数库. - -与 probe_active.py 同风格:从 auths/ 读账号凭证,封装 growth 域 / report 域 -请求,供 task_*.py 复用。全部默认 dry-run(写动作由调用脚本显式 --yes 放行)。 - -端点权威来源(Go 代码实测 + 本次实测确认): - - chat 域(copilot.tencent.com):growth / tasks / buddy / streak / chat/completions - - billing 域(www.codebuddy.cn):/v2/report - - accept : POST /v2/activity/growth/tasks/accept {"task_codes":[code]} - - claim : POST /v2/activity/growth/tasks/reward/claim {"task_code":code} -""" -import json, os, time, glob, urllib.request, urllib.error - -AUTHS = "/root/workbuddy2api/auths" -CHAT_BASE = "https://copilot.tencent.com" # growth / tasks / buddy / streak / chat -BILL_BASE = "https://www.codebuddy.cn" # report / billing - -# growth 域常量(travel.go / report.go 与本次实测对齐) -PATH_LIST_TASKS = "/v2/activity/growth/tasks" -PATH_ACCEPT_TASKS = "/v2/activity/growth/tasks/accept" -PATH_CLAIM_REWARD = "/v2/activity/growth/tasks/reward/claim" -PATH_BUDDY_FIRST = "/activity/growth/buddy/first" -PATH_BUDDY_AGREEMENT = "/activity/growth/buddy/agreement" -PATH_STREAK = "/activity/growth/streak" -PATH_REPORT = "/v2/report" -PATH_CHAT = "/v2/chat/completions" - -CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2" - - -def load_auth(uid_or_file: str) -> dict: - """从 auths/ 加载账号凭证,uid_or_file 为 uid 前缀或 auths 文件名。 - - 返回 {token, uid, domain, nick, file} 五元组。 - """ - if os.path.sep in uid_or_file or uid_or_file.endswith(".json"): - p = uid_or_file - if not os.path.isabs(p): - p = os.path.join(AUTHS, p) - else: - pre = uid_or_file - hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{pre}*.json")) - if not hits: - raise SystemExit(f"no auth for {pre}") - p = hits[0] - # encoding 必须显式指定:Windows 上 open() 默认用 locale 代码页(中文系统为 - # GBK),而 auth 文件是 UTF-8,非 ASCII 昵称会 UnicodeDecodeError 中断全部任务。 - d = json.load(open(p, encoding="utf-8")) - a, acc = d["auth"], d["account"] - return {"token": a["accessToken"], "domain": a.get("domain") or "", - "uid": acc["uid"], "nick": acc.get("nickname", ""), - "file": os.path.basename(p)} - - -def chat_base(auth: dict) -> str: - return auth.get("chat_base") or CHAT_BASE - - -def billing_base(auth: dict) -> str: - return auth.get("billing_base") or BILL_BASE - - -def _headers(auth: dict) -> dict: - hdr = {"Authorization": "Bearer " + auth["token"], - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": CLIENT_UA, - "Origin": "https://www.codebuddy.cn", - "Referer": "https://www.codebuddy.cn/"} - if auth.get("uid"): - hdr["X-User-Id"] = auth["uid"] - if auth.get("domain"): - hdr["X-Domain"] = auth["domain"] - return hdr - - -def _request(auth, method, base, path, body=None, headers=None, timeout=30): - url = path if path.startswith("http") else base + path - hdr = _headers(auth) - if headers: - hdr.update(headers) - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request(url, data=data, headers=hdr, method=method) - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return r.status, json.loads(r.read().decode("utf-8", "replace")) - except urllib.error.HTTPError as e: - t = e.read().decode("utf-8", "replace") - try: - return e.code, json.loads(t) - except Exception: - return e.code, {"raw": t[:300]} - except Exception as e: - return -1, {"err": repr(e)} - - -def do_get(auth, base, path, headers=None) -> tuple: - """GET 读请求,返回 (status, dict)。""" - return _request(auth, "GET", base, path, None, headers) - - -def do_post(auth, base, path, body, headers=None) -> tuple: - """POST 写请求,返回 (status, dict)。body 为 dict。""" - return _request(auth, "POST", base, path, body, headers) - - -def list_tasks(auth) -> list: - """GET /v2/activity/growth/tasks 全量任务列表(元素为原始 dict)。""" - st, d = do_get(auth, chat_base(auth), PATH_LIST_TASKS) - if st != 200: - raise RuntimeError(f"list_tasks http={st}") - tasks = (d.get("data", {}) or {}).get("tasks") or [] - return tasks - - -def task_status(auth, task_code) -> dict | None: - """查单个任务当前状态;找不到返回 None。""" - for t in list_tasks(auth): - if t.get("task_code") == task_code: - return t - return None - - -def accept_tasks(auth, task_codes) -> tuple: - """POST accept 任务(not_accepted → accepted)。返回 (status, resp)。""" - return do_post(auth, chat_base(auth), PATH_ACCEPT_TASKS, - {"task_codes": task_codes}) - - -def claim_reward(auth, task_code) -> tuple: - """POST claim 领取奖励(任务已 complete 后可领)。重复领返回业务错误,安全。""" - return do_post(auth, chat_base(auth), PATH_CLAIM_REWARD, - {"task_code": task_code}) - - -def get_streak(auth) -> int: - """GET /activity/growth/streak 连登天数(只读 oracle)。失败返回 -1 记日志。""" - st, d = do_get(auth, chat_base(auth), PATH_STREAK) - if st != 200: - return -1 - return (d.get("data", {}).get("streak", {}) or {}).get("days", 0) - - -def chat_event(auth, conversation_id=None, model_id="deepseek-v4-flash", - model_name="DeepSeek V4 Flash", mode="craft"): - """客户端 chat_request_send 事件完整形状(照抄 report.go / probe_active.py)。 - - 必须带 userId(=账号 uid),缺失则服务端 200 但静默丢弃。 - model_id/name 可换(如 GLM-5.2),供 model_chat 对齐实际模型。 - """ - now = int(time.time() * 1000) - cid = conversation_id or f"task-{now}" - return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, - "mode": mode, "conversationId": cid, "requestId": cid, - "inputLength": 12, "requestModelId": model_id, - "requestModelName": model_name, "isPlan": False, - "isAutoExecuteTerminal": False, "isAutoModify": False, - "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, - "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], - "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, - "command": "", "expertId": "", "recommendId": "", "skillId": "", - "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, - "traceId": "", "rootRequestId": cid, "parentConversationId": cid, - "agentName": "default", "agentType": "conversation", "userId": auth["uid"]} - - -def report_activity(auth, count=1, gap=1.05, model_id="deepseek-v4-flash", - model_name="DeepSeek V4 Flash", mode="craft") -> list: - """向 {billing}/v2/report 上报 count 条 chat_request_send。 - - 每次间隔 >= gap 秒(默认 1.05,匹配 probe_active.py 的同接口限速口径)。 - 返回 [(status, code), ...] 汇总。 - """ - out = [] - for i in range(count): - ev = chat_event(auth, model_id=model_id, model_name=model_name, mode=mode) - st, r = do_post(auth, billing_base(auth), PATH_REPORT, [ev]) - out.append((st, r.get("code") if isinstance(r, dict) else None)) - if i < count - 1: - time.sleep(gap) - return out - - -def chat_completion(auth, model_id="glm-5.2", prompt="hi", max_tokens=32, - timeout=60) -> tuple: - """POST {chat}/v2/chat/completions 真实对话一次(stream:true)。 - - 服务端强制流式(payload.go 同款口径),这里逐行读 SSE 直到 done。 - 返回 (status, first_content)。用于 Model_chat_GLM5.2 的“真实对话一次”。 - """ - body = {"model": model_id, "messages": [{"role": "user", "content": prompt}], - "stream": True, "max_tokens": max_tokens} - hdr = {"Accept": "text/event-stream"} # SSE - url = chat_base(auth) + PATH_CHAT - req_headers = _headers(auth) - req_headers.update(hdr) - req = urllib.request.Request(url, data=json.dumps(body).encode(), - headers=req_headers, method="POST") - first = "" - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - status = r.status - for raw in r: - line = raw.decode("utf-8", "replace") - if line.startswith("data: "): - payload = line[6:].strip() - if payload in ("[DONE]", ""): - continue - try: - obj = json.loads(payload) - delta = (obj.get("choices") or [{}])[0].get("delta") or {} - content = delta.get("content") or "" - if content and not first: - first = content - except Exception: - pass - return status, first - except urllib.error.HTTPError as e: - t = e.read().decode("utf-8", "replace") - return e.code, t[:200] - except Exception as e: +#!/usr/bin/env python3 +"""一次性积分任务脚本的共享函数库. + +与 probe_active.py 同风格:从 auths/ 读账号凭证,封装 growth 域 / report 域 +请求,供 task_*.py 复用。全部默认 dry-run(写动作由调用脚本显式 --yes 放行)。 + +端点权威来源(Go 代码实测 + 本次实测确认): + - chat 域(copilot.tencent.com):growth / tasks / buddy / streak / chat/completions + - billing 域(www.codebuddy.cn):/v2/report + - accept : POST /v2/activity/growth/tasks/accept {"task_codes":[code]} + - claim : POST /v2/activity/growth/tasks/reward/claim {"task_code":code} +""" +import json, os, time, glob, urllib.request, urllib.error + +AUTHS = "/root/workbuddy2api/auths" +CHAT_BASE = "https://copilot.tencent.com" # growth / tasks / buddy / streak / chat +BILL_BASE = "https://www.codebuddy.cn" # report / billing + +# growth 域常量(travel.go / report.go 与本次实测对齐) +PATH_LIST_TASKS = "/v2/activity/growth/tasks" +PATH_ACCEPT_TASKS = "/v2/activity/growth/tasks/accept" +PATH_CLAIM_REWARD = "/v2/activity/growth/tasks/reward/claim" +PATH_BUDDY_FIRST = "/activity/growth/buddy/first" +PATH_BUDDY_AGREEMENT = "/activity/growth/buddy/agreement" +PATH_STREAK = "/activity/growth/streak" +PATH_REPORT = "/v2/report" +PATH_CHAT = "/v2/chat/completions" + +CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2" + + +def load_auth(uid_or_file: str) -> dict: + """从 auths/ 加载账号凭证,uid_or_file 为 uid 前缀或 auths 文件名。 + + 返回 {token, uid, domain, nick, file} 五元组。 + """ + if os.path.sep in uid_or_file or uid_or_file.endswith(".json"): + p = uid_or_file + if not os.path.isabs(p): + p = os.path.join(AUTHS, p) + else: + pre = uid_or_file + hits = glob.glob(os.path.join(AUTHS, f"workbuddy-{pre}*.json")) + if not hits: + raise SystemExit(f"no auth for {pre}") + p = hits[0] + # encoding 必须显式指定:Windows 上 open() 默认用 locale 代码页(中文系统为 + # GBK),而 auth 文件是 UTF-8,非 ASCII 昵称会 UnicodeDecodeError 中断全部任务。 + d = json.load(open(p, encoding="utf-8")) + a, acc = d["auth"], d["account"] + return {"token": a["accessToken"], "domain": a.get("domain") or "", + "uid": acc["uid"], "nick": acc.get("nickname", ""), + "file": os.path.basename(p)} + + +def chat_base(auth: dict) -> str: + return auth.get("chat_base") or CHAT_BASE + + +def billing_base(auth: dict) -> str: + return auth.get("billing_base") or BILL_BASE + + +def _headers(auth: dict) -> dict: + hdr = {"Authorization": "Bearer " + auth["token"], + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": CLIENT_UA, + "Origin": "https://www.codebuddy.cn", + "Referer": "https://www.codebuddy.cn/"} + if auth.get("uid"): + hdr["X-User-Id"] = auth["uid"] + if auth.get("domain"): + hdr["X-Domain"] = auth["domain"] + return hdr + + +def _request(auth, method, base, path, body=None, headers=None, timeout=30): + url = path if path.startswith("http") else base + path + hdr = _headers(auth) + if headers: + hdr.update(headers) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers=hdr, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + try: + return e.code, json.loads(t) + except Exception: + return e.code, {"raw": t[:300]} + except Exception as e: + return -1, {"err": repr(e)} + + +def do_get(auth, base, path, headers=None) -> tuple: + """GET 读请求,返回 (status, dict)。""" + return _request(auth, "GET", base, path, None, headers) + + +def do_post(auth, base, path, body, headers=None) -> tuple: + """POST 写请求,返回 (status, dict)。body 为 dict。""" + return _request(auth, "POST", base, path, body, headers) + + +def list_tasks(auth) -> list: + """GET /v2/activity/growth/tasks 全量任务列表(元素为原始 dict)。""" + st, d = do_get(auth, chat_base(auth), PATH_LIST_TASKS) + if st != 200: + raise RuntimeError(f"list_tasks http={st}") + tasks = (d.get("data", {}) or {}).get("tasks") or [] + return tasks + + +def task_status(auth, task_code) -> dict | None: + """查单个任务当前状态;找不到返回 None。""" + for t in list_tasks(auth): + if t.get("task_code") == task_code: + return t + return None + + +def accept_tasks(auth, task_codes) -> tuple: + """POST accept 任务(not_accepted → accepted)。返回 (status, resp)。""" + return do_post(auth, chat_base(auth), PATH_ACCEPT_TASKS, + {"task_codes": task_codes}) + + +def claim_reward(auth, task_code) -> tuple: + """POST claim 领取奖励(任务已 complete 后可领)。重复领返回业务错误,安全。""" + return do_post(auth, chat_base(auth), PATH_CLAIM_REWARD, + {"task_code": task_code}) + + +def get_streak(auth) -> int: + """GET /activity/growth/streak 连登天数(只读 oracle)。失败返回 -1 记日志。""" + st, d = do_get(auth, chat_base(auth), PATH_STREAK) + if st != 200: + return -1 + return (d.get("data", {}).get("streak", {}) or {}).get("days", 0) + + +def chat_event(auth, conversation_id=None, model_id="deepseek-v4-flash", + model_name="DeepSeek V4 Flash", mode="craft"): + """客户端 chat_request_send 事件完整形状(照抄 report.go / probe_active.py)。 + + 必须带 userId(=账号 uid),缺失则服务端 200 但静默丢弃。 + model_id/name 可换(如 GLM-5.2),供 model_chat 对齐实际模型。 + """ + now = int(time.time() * 1000) + cid = conversation_id or f"task-{now}" + return {"eventCode": "chat_request_send", "timestamp": now, "reportDelay": 0, + "mode": mode, "conversationId": cid, "requestId": cid, + "inputLength": 12, "requestModelId": model_id, + "requestModelName": model_name, "isPlan": False, + "isAutoExecuteTerminal": False, "isAutoModify": False, + "codebaseEnable": False, "maxToken": 0, "maxSteps": 0, "temperature": 0, + "maxRetries": 0, "mentionContexts": [], "knowledgeId": [], + "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, + "command": "", "expertId": "", "recommendId": "", "skillId": "", + "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, + "traceId": "", "rootRequestId": cid, "parentConversationId": cid, + "agentName": "default", "agentType": "conversation", "userId": auth["uid"]} + + +def report_activity(auth, count=1, gap=1.05, model_id="deepseek-v4-flash", + model_name="DeepSeek V4 Flash", mode="craft") -> list: + """向 {billing}/v2/report 上报 count 条 chat_request_send。 + + 每次间隔 >= gap 秒(默认 1.05,匹配 probe_active.py 的同接口限速口径)。 + 返回 [(status, code), ...] 汇总。 + """ + out = [] + for i in range(count): + ev = chat_event(auth, model_id=model_id, model_name=model_name, mode=mode) + st, r = do_post(auth, billing_base(auth), PATH_REPORT, [ev]) + out.append((st, r.get("code") if isinstance(r, dict) else None)) + if i < count - 1: + time.sleep(gap) + return out + + +def chat_completion(auth, model_id="glm-5.2", prompt="hi", max_tokens=32, + timeout=60) -> tuple: + """POST {chat}/v2/chat/completions 真实对话一次(stream:true)。 + + 服务端强制流式(payload.go 同款口径),这里逐行读 SSE 直到 done。 + 返回 (status, first_content)。用于 Model_chat_GLM5.2 的“真实对话一次”。 + """ + body = {"model": model_id, "messages": [{"role": "user", "content": prompt}], + "stream": True, "max_tokens": max_tokens} + hdr = {"Accept": "text/event-stream"} # SSE + url = chat_base(auth) + PATH_CHAT + req_headers = _headers(auth) + req_headers.update(hdr) + req = urllib.request.Request(url, data=json.dumps(body).encode(), + headers=req_headers, method="POST") + first = "" + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + status = r.status + for raw in r: + line = raw.decode("utf-8", "replace") + if line.startswith("data: "): + payload = line[6:].strip() + if payload in ("[DONE]", ""): + continue + try: + obj = json.loads(payload) + delta = (obj.get("choices") or [{}])[0].get("delta") or {} + content = delta.get("content") or "" + if content and not first: + first = content + except Exception: + pass + return status, first + except urllib.error.HTTPError as e: + t = e.read().decode("utf-8", "replace") + return e.code, t[:200] + except Exception as e: return -1, repr(e)[:200] \ No newline at end of file diff --git a/scripts/task_first_buddy.py b/scripts/task_first_buddy.py index 4be77016ceb4bdb0ad57e48e3443aa1fccf5bfcc..44cdeb68ff7eb4ad18767f14dc63f7a482f81aa5 100644 --- a/scripts/task_first_buddy.py +++ b/scripts/task_first_buddy.py @@ -1,68 +1,68 @@ -#!/usr/bin/env python3 -"""一次性任务脚本:first_buddy(领取一只 Buddy,+300 积分 +8 能量). - -链路(照抄 probe_active.py 的 unlock 模式,实测可行): - report(1 条 chat_request_send) -> buddy/agreement -> buddy/first -判据是服务端行为事件,不是 accept 状态。 - -用法 - python3 task_first_buddy.py # dry-run - python3 task_first_buddy.py --yes # 真正执行 - python3 task_first_buddy.py ALL --yes # 全池(跳过已 claimed) -""" -import sys, os, time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import task_common as tc - -TASK_CODE = "first_buddy" - - -def main(): - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) - account = sys.argv[1] - yes = "--yes" in sys.argv - gap = 1.05 - - prefixes = [] - if account.upper() == "ALL": - import glob - prefixes = [os.path.basename(p)[10:18] - for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] - else: - prefixes = [account] - - for pre in prefixes: - c = tc.load_auth(pre) - print(f"== {c['uid'][:8]} ({c['nick']}) ==") - try: - st = tc.task_status(c, TASK_CODE) - except Exception as e: - print(f" [skip] list_tasks 失败: {e}") - continue - if st is None: - print(f" [skip] 无 {TASK_CODE} 任务") - continue - ast = st.get("accept_status") - if ast == "claimed": - print(f" [skip] {TASK_CODE} 已 claimed,无需处理") - continue - if not yes: - print(f" [dry-run] 将执行 report + agreement + buddy/first (加 --yes 生效;当前 accept_status={ast})") - continue - - # 写链路 - st_code, code = tc.report_activity(c, count=1, gap=gap)[0] - print(f" report -> {st_code} code={code}") - time.sleep(2) - st2, ag = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_AGREEMENT, {"agree": True}) - print(f" agreement -> {st2} code={ag.get('code')}") - st3, bf = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_FIRST, {}) - d = bf.get("data") or {} - print(f" buddy/first -> {st3} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") - time.sleep(gap) - - -if __name__ == "__main__": +#!/usr/bin/env python3 +"""一次性任务脚本:first_buddy(领取一只 Buddy,+300 积分 +8 能量). + +链路(照抄 probe_active.py 的 unlock 模式,实测可行): + report(1 条 chat_request_send) -> buddy/agreement -> buddy/first +判据是服务端行为事件,不是 accept 状态。 + +用法 + python3 task_first_buddy.py # dry-run + python3 task_first_buddy.py --yes # 真正执行 + python3 task_first_buddy.py ALL --yes # 全池(跳过已 claimed) +""" +import sys, os, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "first_buddy" + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + account = sys.argv[1] + yes = "--yes" in sys.argv + gap = 1.05 + + prefixes = [] + if account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + if ast == "claimed": + print(f" [skip] {TASK_CODE} 已 claimed,无需处理") + continue + if not yes: + print(f" [dry-run] 将执行 report + agreement + buddy/first (加 --yes 生效;当前 accept_status={ast})") + continue + + # 写链路 + st_code, code = tc.report_activity(c, count=1, gap=gap)[0] + print(f" report -> {st_code} code={code}") + time.sleep(2) + st2, ag = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_AGREEMENT, {"agree": True}) + print(f" agreement -> {st2} code={ag.get('code')}") + st3, bf = tc.do_post(c, tc.chat_base(c), tc.PATH_BUDDY_FIRST, {}) + d = bf.get("data") or {} + print(f" buddy/first -> {st3} {bf.get('msg')} credit={d.get('credit')} energy={d.get('energy')}") + time.sleep(gap) + + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/task_model_chat.py b/scripts/task_model_chat.py index 48a0e1acaef0a9aa48a78943195d2dc55b1a2b84..d996b8fd2d7ef25c25456dfe3f4d6e4a0a522c49 100644 --- a/scripts/task_model_chat.py +++ b/scripts/task_model_chat.py @@ -1,91 +1,91 @@ -#!/usr/bin/env python3 -"""一次性任务脚本:Model_chat_GLM5.2(体验「GLM-5.2」模型对话 1 次,+100 积分 +5 能量). - -步骤(实测确认): - 1. accept:POST /v2/activity/growth/tasks/accept {"task_codes":["Model_chat_GLM5.2"]} - (不 accept 也会被行为事件点亮,但进度状态更规范,先 accept) - 2. 真实对话:POST {chat}/v2/chat/completions {model:"glm-5.2", stream:true} - —— 模型列表实测存在 glm-5.2,SSE 200 正常回包 - 3. 上报一条 chat_request_send(requestModelId=glm-5.2)触发 progress - —— 若任务判据靠事件上报,这条即可点亮;真实对话是「使用 GLM-5.2 成功对话」的最直接证据 - 4. 回读 progress;current>=target 后提示 claim - -用法 - python3 task_model_chat.py # dry-run - python3 task_model_chat.py --yes # accept + 真实对话 + 上报 -""" -import sys, os, time, argparse -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import task_common as tc - -TASK_CODE = "Model_chat_GLM5.2" -MODEL_ID = "glm-5.2" -MODEL_NAME = "GLM-5.2" -TARGET = 1 - - -def main(): - ap = argparse.ArgumentParser(description="Model_chat_GLM5.2 任务") - ap.add_argument("account", help="uid 前缀,或 ALL") - ap.add_argument("--yes", action="store_true", help="确认执行写操作") - ap.add_argument("--prompt", default="hi,请回复一句话", help="发给 GLM-5.2 的提示词") - a = ap.parse_args() - - prefixes = [] - if a.account.upper() == "ALL": - import glob - prefixes = [os.path.basename(p)[10:18] - for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] - else: - prefixes = [a.account] - - for pre in prefixes: - c = tc.load_auth(pre) - print(f"== {c['uid'][:8]} ({c['nick']}) ==") - try: - st = tc.task_status(c, TASK_CODE) - except Exception as e: - print(f" [skip] list_tasks 失败: {e}") - continue - if st is None: - print(f" [skip] 无 {TASK_CODE} 任务") - continue - ast = st.get("accept_status") - prog = (st.get("progress") or {}) - cur = prog.get("current", 0) - target = prog.get("target", TARGET) - if ast == "claimed" or cur >= target: - print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") - continue - if not a.yes: - print(f" [dry-run] 将 accept + GLM-5.2 真实对话一次 + 上报 chat_request_send " - f"(当前 {cur}/{target},加 --yes 生效)") - continue - - # 1. accept - st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) - print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") - time.sleep(1.05) - - # 2. 真实对话(GLM-5.2) - st_chat, first = tc.chat_completion(c, model_id=MODEL_ID, prompt=a.prompt) - print(f" chat glm-5.2 -> {st_chat} {first[:60]!r}") - - # 3. 上报一条 chat_request_send(模型字段对齐 glm-5.2) - results = tc.report_activity(c, count=1, gap=1.05, - model_id=MODEL_ID, model_name=MODEL_NAME) - print(f" report x1 -> {results}") - time.sleep(1.0) - - # 4. 回读 - st2 = tc.task_status(c, TASK_CODE) - prog2 = (st2.get("progress") or {}) if st2 else {} - cur2 = prog2.get("current", 0) - ast2 = st2.get("accept_status") if st2 else "?" - print(f" 回读 {TASK_CODE}: {cur2}/{prog2.get('target', target)} accept_status={ast2}") - if ast2 not in ("claimed",) and cur2 >= target: - print(" → 任务已满足,可手动或后续调用 claim_reward 领奖") - - -if __name__ == "__main__": +#!/usr/bin/env python3 +"""一次性任务脚本:Model_chat_GLM5.2(体验「GLM-5.2」模型对话 1 次,+100 积分 +5 能量). + +步骤(实测确认): + 1. accept:POST /v2/activity/growth/tasks/accept {"task_codes":["Model_chat_GLM5.2"]} + (不 accept 也会被行为事件点亮,但进度状态更规范,先 accept) + 2. 真实对话:POST {chat}/v2/chat/completions {model:"glm-5.2", stream:true} + —— 模型列表实测存在 glm-5.2,SSE 200 正常回包 + 3. 上报一条 chat_request_send(requestModelId=glm-5.2)触发 progress + —— 若任务判据靠事件上报,这条即可点亮;真实对话是「使用 GLM-5.2 成功对话」的最直接证据 + 4. 回读 progress;current>=target 后提示 claim + +用法 + python3 task_model_chat.py # dry-run + python3 task_model_chat.py --yes # accept + 真实对话 + 上报 +""" +import sys, os, time, argparse +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "Model_chat_GLM5.2" +MODEL_ID = "glm-5.2" +MODEL_NAME = "GLM-5.2" +TARGET = 1 + + +def main(): + ap = argparse.ArgumentParser(description="Model_chat_GLM5.2 任务") + ap.add_argument("account", help="uid 前缀,或 ALL") + ap.add_argument("--yes", action="store_true", help="确认执行写操作") + ap.add_argument("--prompt", default="hi,请回复一句话", help="发给 GLM-5.2 的提示词") + a = ap.parse_args() + + prefixes = [] + if a.account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [a.account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + if not a.yes: + print(f" [dry-run] 将 accept + GLM-5.2 真实对话一次 + 上报 chat_request_send " + f"(当前 {cur}/{target},加 --yes 生效)") + continue + + # 1. accept + st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) + print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") + time.sleep(1.05) + + # 2. 真实对话(GLM-5.2) + st_chat, first = tc.chat_completion(c, model_id=MODEL_ID, prompt=a.prompt) + print(f" chat glm-5.2 -> {st_chat} {first[:60]!r}") + + # 3. 上报一条 chat_request_send(模型字段对齐 glm-5.2) + results = tc.report_activity(c, count=1, gap=1.05, + model_id=MODEL_ID, model_name=MODEL_NAME) + print(f" report x1 -> {results}") + time.sleep(1.0) + + # 4. 回读 + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + cur2 = prog2.get("current", 0) + ast2 = st2.get("accept_status") if st2 else "?" + print(f" 回读 {TASK_CODE}: {cur2}/{prog2.get('target', target)} accept_status={ast2}") + if ast2 not in ("claimed",) and cur2 >= target: + print(" → 任务已满足,可手动或后续调用 claim_reward 领奖") + + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/task_richmeow.py b/scripts/task_richmeow.py index aee44d3fe804ca74bbbcd3bbd2ee3a7de384c805..334f4f60534d056c5fc597f6f38f7b0245c44a22 100644 --- a/scripts/task_richmeow.py +++ b/scripts/task_richmeow.py @@ -1,78 +1,78 @@ -#!/usr/bin/env python3 -"""一次性任务脚本:RichMeow_Chat(桌面端对话 1 次,+100 积分 +5 能量 +限定 Buddy 盲盒). - -任务书:先查 eventCode 是否与 chat_request_send 不同(在 probe_active.py / Go 代码 -搜 RichMeow 相关——未找到专属 eventCode,REPORT §3.2 也标注「桌面端专属(未破)」)。 - -实测结论(REPORT §3.2,2026-09-11): - - accept 后 3 个账号多次 chat_request_send 上报均不计数(progress 0/1 不动)。 - - 怀疑是桌面端变体的另一套上报通道:queuePendingGrowthTelemetry → 把 growthEvent - 挂在 chat 请求的 extra_vars 里随请求上行(REPORT §1.1),而非 /v2/report。 - - 本脚本默认上线尝试 1 次 chat_request_send(成本可忽略);若仍 0/1 → 判为不可脚本化。 - -用法 - python3 task_richmeow.py # dry-run - python3 task_richmeow.py --yes # 上报 1 次并回读 -""" -import sys, os, time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import task_common as tc - -TASK_CODE = "RichMeow_Chat" -TARGET = 1 - - -def main(): - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) - account = sys.argv[1] - yes = "--yes" in sys.argv - - prefixes = [] - if account.upper() == "ALL": - import glob - prefixes = [os.path.basename(p)[10:18] - for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] - else: - prefixes = [account] - - for pre in prefixes: - c = tc.load_auth(pre) - print(f"== {c['uid'][:8]} ({c['nick']}) ==") - try: - st = tc.task_status(c, TASK_CODE) - except Exception as e: - print(f" [skip] list_tasks 失败: {e}") - continue - if st is None: - print(f" [skip] 无 {TASK_CODE} 任务") - continue - ast = st.get("accept_status") - prog = (st.get("progress") or {}) - cur = prog.get("current", 0) - target = prog.get("target", TARGET) - if ast == "claimed" or cur >= target: - print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") - continue - # not_accepted 先 accept - if ast == "not_accepted" and yes: - st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) - print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") - time.sleep(1.05) - elif ast == "not_accepted": - print(f" [dry-run] 先 accept(当前 not_accepted),再上报 1 条") - if not yes: - print(f" [dry-run] 将上报 1 条 chat_request_send(当前 {cur}/{target},加 --yes 生效)") - continue - results = tc.report_activity(c, count=1, gap=1.05) - print(f" report x1 -> {results}") - time.sleep(1.0) - st2 = tc.task_status(c, TASK_CODE) - prog2 = (st2.get("progress") or {}) if st2 else {} - print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " - f"accept_status={st2.get('accept_status') if st2 else '?'}") - - -if __name__ == "__main__": +#!/usr/bin/env python3 +"""一次性任务脚本:RichMeow_Chat(桌面端对话 1 次,+100 积分 +5 能量 +限定 Buddy 盲盒). + +任务书:先查 eventCode 是否与 chat_request_send 不同(在 probe_active.py / Go 代码 +搜 RichMeow 相关——未找到专属 eventCode,REPORT §3.2 也标注「桌面端专属(未破)」)。 + +实测结论(REPORT §3.2,2026-09-11): + - accept 后 3 个账号多次 chat_request_send 上报均不计数(progress 0/1 不动)。 + - 怀疑是桌面端变体的另一套上报通道:queuePendingGrowthTelemetry → 把 growthEvent + 挂在 chat 请求的 extra_vars 里随请求上行(REPORT §1.1),而非 /v2/report。 + - 本脚本默认上线尝试 1 次 chat_request_send(成本可忽略);若仍 0/1 → 判为不可脚本化。 + +用法 + python3 task_richmeow.py # dry-run + python3 task_richmeow.py --yes # 上报 1 次并回读 +""" +import sys, os, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import task_common as tc + +TASK_CODE = "RichMeow_Chat" +TARGET = 1 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + account = sys.argv[1] + yes = "--yes" in sys.argv + + prefixes = [] + if account.upper() == "ALL": + import glob + prefixes = [os.path.basename(p)[10:18] + for p in sorted(glob.glob(tc.AUTHS + "/workbuddy-*.json"))] + else: + prefixes = [account] + + for pre in prefixes: + c = tc.load_auth(pre) + print(f"== {c['uid'][:8]} ({c['nick']}) ==") + try: + st = tc.task_status(c, TASK_CODE) + except Exception as e: + print(f" [skip] list_tasks 失败: {e}") + continue + if st is None: + print(f" [skip] 无 {TASK_CODE} 任务") + continue + ast = st.get("accept_status") + prog = (st.get("progress") or {}) + cur = prog.get("current", 0) + target = prog.get("target", TARGET) + if ast == "claimed" or cur >= target: + print(f" [skip] 已完成 {cur}/{target} (accept_status={ast})") + continue + # not_accepted 先 accept + if ast == "not_accepted" and yes: + st_a, r_a = tc.accept_tasks(c, [TASK_CODE]) + print(f" accept -> {st_a} {r_a.get('msg') if isinstance(r_a, dict) else r_a}") + time.sleep(1.05) + elif ast == "not_accepted": + print(f" [dry-run] 先 accept(当前 not_accepted),再上报 1 条") + if not yes: + print(f" [dry-run] 将上报 1 条 chat_request_send(当前 {cur}/{target},加 --yes 生效)") + continue + results = tc.report_activity(c, count=1, gap=1.05) + print(f" report x1 -> {results}") + time.sleep(1.0) + st2 = tc.task_status(c, TASK_CODE) + prog2 = (st2.get("progress") or {}) if st2 else {} + print(f" 回读 {TASK_CODE}: {prog2.get('current', 0)}/{prog2.get('target', target)} " + f"accept_status={st2.get('accept_status') if st2 else '?'}") + + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/signin.sh b/signin.sh index d469491c500c18b1cdae32bfcd593d0add4bcc99..9632561ea552c30de9a8606625f7da10a9aa9227 100644 --- a/signin.sh +++ b/signin.sh @@ -1,13 +1,13 @@ -#!/bin/bash -# 批量签到脚本:遍历 auths/ 下所有 workbuddy-*.json 账号 -# 用法: ./signin.sh [auths_dir] -set -e -cd "$(dirname "$0")" - -BIN=./signin_bin -if [ ! -x "$BIN" ]; then - echo "build signin_bin ..." - go build -o "$BIN" ./cmd/signin -fi - -exec "$BIN" "${1:-auths}" +#!/bin/bash +# 批量签到脚本:遍历 auths/ 下所有 workbuddy-*.json 账号 +# 用法: ./signin.sh [auths_dir] +set -e +cd "$(dirname "$0")" + +BIN=./signin_bin +if [ ! -x "$BIN" ]; then + echo "build signin_bin ..." + go build -o "$BIN" ./cmd/signin +fi + +exec "$BIN" "${1:-auths}" diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..9d5439ccd03b6c7346f50ee852f21098c503df62 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + WorkBuddy2API 引导脚本(Windows)。 + +.DESCRIPTION + 只做一件事:**没有二进制就编译,然后把控制权交给 wb2api-launcher.exe**。 + + 真正的启动逻辑(先同步凭证 → 再拉起网关 → 后台定时对账 → 转发中断信号) + 只有一份,在 cmd/launcher 里,不在脚本里重复一遍——两套实现迟早会漂移。 + + 不想依赖 Go?直接从发布页下载 `wb2api-launcher-windows-amd64.exe` 与 + `wb2api.exe` 放进同一目录,把启动器重命名为 wb2api-launcher.exe,然后双击即可。 + 本脚本检测到二进制已存在时会自动跳过编译。 + +.EXAMPLE + .\start.ps1 # 同步 + 启动网关 + 后台对账 + .\start.ps1 -status # 只看两边差异,不启动网关 + .\start.ps1 -no-sync # 离线启动 + .\start.ps1 -h # 看启动器全部选项 +#> +[CmdletBinding()] +param( + # 其余参数原样透传给 wb2api-launcher.exe + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Args +) + +$ErrorActionPreference = 'Stop' +$root = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +Set-Location $root + +# Windows 控制台默认代码页会把中文写成乱码(尤其在重定向日志时),显式切 UTF-8。 +try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 +} catch { + # 某些宿主不允许改编码,忽略即可,不影响功能。 +} + +$serverBin = Join-Path $root 'wb2api.exe' +$launcherBin = Join-Path $root 'wb2api-launcher.exe' + +function Need-Go([string]$what) { + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "找不到 go,无法编译 $what。请安装 Go,或直接下载预编译二进制放到 $root" + } +} + +if (-not (Test-Path $serverBin)) { + Need-Go 'wb2api' + Write-Host '[start] 编译网关 wb2api.exe ...' -ForegroundColor Cyan + & go build -o $serverBin ./cmd/server + if ($LASTEXITCODE -ne 0) { throw '编译 wb2api 失败' } +} + +if (-not (Test-Path $launcherBin)) { + Need-Go 'wb2api-launcher' + Write-Host '[start] 编译启动器 wb2api-launcher.exe ...' -ForegroundColor Cyan + & go build -o $launcherBin ./cmd/launcher + if ($LASTEXITCODE -ne 0) { throw '编译 wb2api-launcher 失败' } +} + +# 把控制权完全交给启动器;它的退出码原样透出。 +& $launcherBin @Args +exit $LASTEXITCODE diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..f44d42fe5713ddbe9cd62a0c1b6ff740539ee3d3 --- /dev/null +++ b/start.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# WorkBuddy2API 引导脚本(Linux / macOS / 容器内)。 +# +# 只做一件事:**没有二进制就编译,然后把控制权交给 wb2api-launcher**。 +# 真正的启动逻辑(同步凭证 → 拉起网关 → 后台对账 → 信号转发)只有一份, +# 在 cmd/launcher 里,不在 shell 里重复一遍——两套实现迟早会漂移。 +# +# 用法(所有参数原样透传给启动器): +# ./start.sh # 同步 + 启动网关 + 后台对账 +# ./start.sh -status # 只看两边差异,不启动网关 +# ./start.sh -no-sync # 离线启动 +# ./start.sh -h # 看启动器全部选项 +# +# 不想依赖 go?直接从发布页下载 wb2api-launcher-<平台> 与 wb2api 放到同一目录, +# 然后直接运行启动器即可(本脚本会被自动跳过编译)。 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +LAUNCHER="$ROOT/wb2api-launcher" +SERVER="$ROOT/wb2api" + +need_go() { + if ! command -v go >/dev/null 2>&1; then + echo "[start] 找不到 go,无法编译 $1" >&2 + echo "[start] 请安装 Go,或直接下载对应的预编译二进制放到 $ROOT" >&2 + exit 1 + fi +} + +if [[ ! -x "$SERVER" ]]; then + need_go wb2api + echo "[start] 编译网关 wb2api ..." + go build -o "$SERVER" ./cmd/server +fi + +if [[ ! -x "$LAUNCHER" ]]; then + need_go wb2api-launcher + echo "[start] 编译启动器 wb2api-launcher ..." + go build -o "$LAUNCHER" ./cmd/launcher +fi + +exec "$LAUNCHER" "$@"