FUCAT commited on
Commit
cdc337a
·
0 Parent(s):

Update to v3.0.11 with HF deployment fixes

Browse files

- Full upstream v3.0.11 source (frontend/backend/docker now included, fixes BUILD_ERROR)
- Bake config.yaml into image (/run/grok2api/config.yaml) so entrypoint passes
- app_port: 8000 in README so HF routes to the app's listen port
- Data paths moved to /data (HF persistent storage)
- entrypoint ensures /data writable by app user
- Clean out obsolete Python-version files

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +32 -0
  2. .gitattributes +3 -0
  3. .github/ISSUE_TEMPLATE/bug_report.yml +118 -0
  4. .github/ISSUE_TEMPLATE/config.yml +1 -0
  5. .github/ISSUE_TEMPLATE/documentation.yml +95 -0
  6. .github/ISSUE_TEMPLATE/feature_request.yml +114 -0
  7. .github/workflows/codeql.yml +49 -0
  8. .github/workflows/ghcr-image.yml +219 -0
  9. .github/workflows/stale.yml +30 -0
  10. .gitignore +45 -0
  11. Dockerfile +73 -0
  12. LICENSE +21 -0
  13. Makefile +14 -0
  14. README.md +34 -0
  15. README.zh-CN.md +359 -0
  16. VERSION +1 -0
  17. backend/Makefile +5 -0
  18. backend/README.md +82 -0
  19. backend/cmd/grok2api/main.go +24 -0
  20. backend/docs/docs.go +809 -0
  21. backend/docs/swagger.json +784 -0
  22. backend/docs/swagger.yaml +514 -0
  23. backend/go.mod +87 -0
  24. backend/go.sum +255 -0
  25. backend/internal/app/application.go +764 -0
  26. backend/internal/app/console_routes_test.go +32 -0
  27. backend/internal/app/startup.go +444 -0
  28. backend/internal/app/startup_test.go +144 -0
  29. backend/internal/app/topology.go +72 -0
  30. backend/internal/app/topology_test.go +32 -0
  31. backend/internal/application/account/account_cleanup_test.go +157 -0
  32. backend/internal/application/account/auto_clean.go +265 -0
  33. backend/internal/application/account/auto_clean_test.go +528 -0
  34. backend/internal/application/account/batch_quota_test.go +93 -0
  35. backend/internal/application/account/batch_update_test.go +79 -0
  36. backend/internal/application/account/build_super_entitlement_test.go +189 -0
  37. backend/internal/application/account/console_quota_test.go +25 -0
  38. backend/internal/application/account/conversion_test.go +278 -0
  39. backend/internal/application/account/credential_refresh_test.go +706 -0
  40. backend/internal/application/account/credential_scheduler.go +183 -0
  41. backend/internal/application/account/export_test.go +288 -0
  42. backend/internal/application/account/linked_delete_test.go +264 -0
  43. backend/internal/application/account/list_filter_test.go +64 -0
  44. backend/internal/application/account/paid_quota_recovery_test.go +57 -0
  45. backend/internal/application/account/provider_links.go +85 -0
  46. backend/internal/application/account/provider_links_test.go +172 -0
  47. backend/internal/application/account/quota_refresh_redis_integration_test.go +147 -0
  48. backend/internal/application/account/quota_refresh_test.go +620 -0
  49. backend/internal/application/account/quota_test.go +256 -0
  50. backend/internal/application/account/service.go +0 -0
.dockerignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **
2
+
3
+ !VERSION
4
+
5
+ !config.yaml
6
+
7
+ !backend/
8
+ !backend/go.mod
9
+ !backend/go.sum
10
+ !backend/cmd/
11
+ !backend/cmd/**
12
+ !backend/internal/
13
+ !backend/internal/**
14
+ backend/**/*_test.go
15
+ !backend/docs/
16
+ !backend/docs/docs.go
17
+
18
+ !frontend/
19
+ !frontend/package.json
20
+ !frontend/pnpm-lock.yaml
21
+ !frontend/index.html
22
+ !frontend/vite.config.ts
23
+ !frontend/tsconfig.json
24
+ !frontend/tsconfig.app.json
25
+ !frontend/tsconfig.node.json
26
+ !frontend/public/
27
+ !frontend/public/**
28
+ !frontend/src/
29
+ !frontend/src/**
30
+
31
+ !docker/
32
+ !docker/entrypoint.sh
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text
2
+ frontend/public/**/*.jpg filter=lfs diff=lfs merge=lfs -text
3
+ frontend/public/**/*.ico filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bug 报告
2
+ description: 报告可稳定复现的功能、兼容性或部署问题。
3
+ title: "[Bug]: "
4
+ labels:
5
+ - bug
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ 感谢反馈问题。请尽量提供最小复现步骤和经过脱敏的日志、请求或响应。
11
+
12
+ 请勿提交 Access Token、Refresh Token、Cookie、API Key、账号密码或其他敏感信息。安全漏洞请不要通过公开 Issue 披露。
13
+
14
+ - type: dropdown
15
+ id: area
16
+ attributes:
17
+ label: 问题模块
18
+ description: 请选择问题主要出现在哪个模块。
19
+ options:
20
+ - OpenAI API 兼容层(Responses / Chat Completions)
21
+ - 模型同步、映射、路由与定价
22
+ - 账号、Provider、OAuth 与凭据
23
+ - 额度、计费与请求审计
24
+ - 图片、视频与媒体处理
25
+ - 管理端前端界面
26
+ - SQLite / PostgreSQL 数据库
27
+ - Docker、配置、部署与网络出口
28
+ - 其他
29
+ validations:
30
+ required: true
31
+
32
+ - type: dropdown
33
+ id: provider
34
+ attributes:
35
+ label: 相关 Provider
36
+ description: 请选择受影响的上游渠道。
37
+ options:
38
+ - Grok Build
39
+ - Grok Web
40
+ - Grok Console
41
+ - 多个 Provider
42
+ - 与 Provider 无关
43
+ validations:
44
+ required: true
45
+
46
+ - type: input
47
+ id: version
48
+ attributes:
49
+ label: 版本信息
50
+ description: 请提供版本号、Docker 镜像标签或 Commit SHA。
51
+ placeholder: "v0.2.99、ghcr.io/chenyme/grok2api:latest、main@abcdef1"
52
+ validations:
53
+ required: true
54
+
55
+ - type: textarea
56
+ id: environment
57
+ attributes:
58
+ label: 部署与运行环境
59
+ description: 请填写与问题相关的部署方式、系统、架构、数据库和网络环境。
60
+ placeholder: |
61
+ 部署方式:Docker Compose / Docker Run / 源码运行
62
+ 操作系统:Debian 13 / Ubuntu 24.04 / macOS ...
63
+ CPU 架构:amd64 / arm64
64
+ 数据库:SQLite / PostgreSQL
65
+ 反向代理:Nginx / Nginx Proxy Manager / 无
66
+ 出口网络:直连 / HTTP Proxy / SOCKS5 / WARP
67
+ validations:
68
+ required: true
69
+
70
+ - type: textarea
71
+ id: steps
72
+ attributes:
73
+ label: 复现步骤
74
+ description: 请提供能够稳定复现问题的最小操作步骤。
75
+ placeholder: |
76
+ 1. 使用配置……启动服务
77
+ 2. 导入或选择……账号
78
+ 3. 请求……接口
79
+ 4. 出现……错误
80
+ validations:
81
+ required: true
82
+
83
+ - type: textarea
84
+ id: expected
85
+ attributes:
86
+ label: 预期行为
87
+ description: 正常情况下应该发生什么?
88
+ validations:
89
+ required: true
90
+
91
+ - type: textarea
92
+ id: actual
93
+ attributes:
94
+ label: 实际行为
95
+ description: 实际发生了什么?请包含完整错误信息。
96
+ validations:
97
+ required: true
98
+
99
+ - type: textarea
100
+ id: evidence
101
+ attributes:
102
+ label: 请求、响应、日志与截图
103
+ description: 请提供脱敏后的最小请求、状态码、响应体、容器日志、请求 ID 或截图。长日志请仅保留问题前后的关键部分。
104
+ placeholder: 可粘贴内容或直接拖放截图、日志文件;提交前请再次确认内容已经脱敏。
105
+ validations:
106
+ required: false
107
+
108
+ - type: checkboxes
109
+ id: checklist
110
+ attributes:
111
+ label: 提交前检查
112
+ options:
113
+ - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同问题。
114
+ required: true
115
+ - label: 我已经删除 Token、Cookie、API Key、账号凭据和个人信息。
116
+ required: true
117
+ - label: 这不是需要私下披露的安全漏洞。
118
+ required: true
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ blank_issues_enabled: false
.github/ISSUE_TEMPLATE/documentation.yml ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 文档改进
2
+ description: 报告缺失、错误、过时或难以理解的文档内容。
3
+ title: "[Docs]: "
4
+ labels:
5
+ - documentation
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ 请指出具体文档位置,并说明当前内容的问题和建议修改方向。
11
+
12
+ - type: dropdown
13
+ id: area
14
+ attributes:
15
+ label: 文档范围
16
+ options:
17
+ - README
18
+ - 配置文件与 config.example.yaml
19
+ - Docker 与部署说明
20
+ - Grok Build / Web / Console 账号接入
21
+ - OpenAI API 兼容与 Swagger 契约
22
+ - 模型同步、映射、路由与定价
23
+ - 故障排查与常见问题
24
+ - 开发规范与项目架构
25
+ - 管理端界面文案
26
+ - 其他
27
+ validations:
28
+ required: true
29
+
30
+ - type: dropdown
31
+ id: issue_type
32
+ attributes:
33
+ label: 问题类型
34
+ options:
35
+ - 文档缺失
36
+ - 内容错误
37
+ - 内容已过时
38
+ - 描述不清晰
39
+ - 缺少示例
40
+ - 链接失效
41
+ - 格式或用词不统一
42
+ - 其他
43
+ validations:
44
+ required: true
45
+
46
+ - type: input
47
+ id: location
48
+ attributes:
49
+ label: 文档位置
50
+ description: 请提供文件路径、页面链接、章节名称或 API 路径。
51
+ placeholder: "README.md#docker、config.example.yaml、/v1/responses"
52
+ validations:
53
+ required: true
54
+
55
+ - type: textarea
56
+ id: issue
57
+ attributes:
58
+ label: 当前问题
59
+ description: 当前文档缺少什么、哪里错误或为什么难以理解?
60
+ validations:
61
+ required: true
62
+
63
+ - type: textarea
64
+ id: expected
65
+ attributes:
66
+ label: 建议修改
67
+ description: 请描述建议补充、修正或重写的内容;如有条件可提供示例文本。
68
+ validations:
69
+ required: false
70
+
71
+ - type: dropdown
72
+ id: audience
73
+ attributes:
74
+ label: 主要受众
75
+ options:
76
+ - 新用户
77
+ - API 调用方
78
+ - Docker 部署者
79
+ - 系统管理员
80
+ - 前端开发者
81
+ - 后端开发者
82
+ - 项目贡献者
83
+ - 其他
84
+ validations:
85
+ required: true
86
+
87
+ - type: checkboxes
88
+ id: checklist
89
+ attributes:
90
+ label: 提交前检查
91
+ options:
92
+ - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同文档问题。
93
+ required: true
94
+ - label: 我已经提供尽可能具体的文档位置。
95
+ required: true
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 功能建议
2
+ description: 提议新的功能、兼容能力或工程改进。
3
+ title: "[Feature]: "
4
+ labels:
5
+ - enhancement
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ 请围绕一个明确的问题或使用场景提出建议,并说明预期行为、兼容性和部署影响。
11
+
12
+ - type: dropdown
13
+ id: area
14
+ attributes:
15
+ label: 功能模块
16
+ description: 请选择建议主要影响的模块。
17
+ options:
18
+ - OpenAI API 兼容层(Responses / Chat Completions)
19
+ - 模型同步、映射、路由与定价
20
+ - 账号、Provider、OAuth 与凭据
21
+ - 额度、计费与请求审计
22
+ - 图片、视频与媒体处理
23
+ - 管理端前端界面
24
+ - SQLite / PostgreSQL 数据库
25
+ - Docker、配置、部署与网络出口
26
+ - 开发体验与工程质量
27
+ - 其他
28
+ validations:
29
+ required: true
30
+
31
+ - type: dropdown
32
+ id: provider
33
+ attributes:
34
+ label: 相关 Provider
35
+ options:
36
+ - Grok Build
37
+ - Grok Web
38
+ - Grok Console
39
+ - 多个 Provider
40
+ - 新 Provider
41
+ - 与 Provider 无关
42
+ validations:
43
+ required: true
44
+
45
+ - type: textarea
46
+ id: problem
47
+ attributes:
48
+ label: 问题与使用场景
49
+ description: 当前存在什么限制?谁会受到影响?为什么需要解决?
50
+ placeholder: 请描述实际使用场景,而不仅是功能名称。
51
+ validations:
52
+ required: true
53
+
54
+ - type: textarea
55
+ id: proposal
56
+ attributes:
57
+ label: 建议方案
58
+ description: 请描述期望的交互、API、配置或运行行为。
59
+ validations:
60
+ required: true
61
+
62
+ - type: textarea
63
+ id: acceptance
64
+ attributes:
65
+ label: 验收标准
66
+ description: 满足哪些可验证条件时,可以认为该功能已经完成?
67
+ placeholder: |
68
+ - 当……时,系统应……
69
+ - API 应返回……
70
+ - 现有配置和客户端应继续兼容……
71
+ validations:
72
+ required: true
73
+
74
+ - type: textarea
75
+ id: api_example
76
+ attributes:
77
+ label: API、配置或界面示例
78
+ description: 如适用,请提供期望的请求响应、配置片段或界面流程。
79
+ validations:
80
+ required: false
81
+
82
+ - type: textarea
83
+ id: alternatives
84
+ attributes:
85
+ label: 替代方案
86
+ description: 请描述目前的解决办法、绕过方式或考虑过的其他设计。
87
+ validations:
88
+ required: false
89
+
90
+ - type: textarea
91
+ id: compatibility
92
+ attributes:
93
+ label: 兼容性、部署与迁移影响
94
+ description: 请说明是否影响 API 协议、模型命名、配置、数据库、Docker 部署或现有客户端。
95
+ placeholder: |
96
+ API 兼容性:
97
+ 配置变更:
98
+ 数据库迁移:
99
+ Docker / 网络变更:
100
+ 向后兼容性:
101
+ validations:
102
+ required: false
103
+
104
+ - type: checkboxes
105
+ id: checklist
106
+ attributes:
107
+ label: 提交前检查
108
+ options:
109
+ - label: 我已经搜索现有 Issue 和 Pull Request,确认没有相同建议。
110
+ required: true
111
+ - label: 该 Issue 只聚焦一个功能或改进方向。
112
+ required: true
113
+ - label: 我已经说明用户价值和可验证的验收标准。
114
+ required: true
.github/workflows/codeql.yml ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CodeQL Advanced
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+ schedule:
11
+ - cron: "0 0 * * *"
12
+
13
+ concurrency:
14
+ group: codeql-${{ github.workflow }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ analyze:
19
+ name: Analyze (${{ matrix.language }})
20
+ runs-on: ubuntu-latest
21
+ permissions:
22
+ security-events: write
23
+ packages: read
24
+ actions: read
25
+ contents: read
26
+ strategy:
27
+ fail-fast: false
28
+ matrix:
29
+ include:
30
+ - language: actions
31
+ build-mode: none
32
+ - language: go
33
+ build-mode: autobuild
34
+ - language: javascript-typescript
35
+ build-mode: none
36
+ steps:
37
+ - name: Checkout repository
38
+ uses: actions/checkout@v4
39
+
40
+ - name: Initialize CodeQL
41
+ uses: github/codeql-action/init@v4
42
+ with:
43
+ languages: ${{ matrix.language }}
44
+ build-mode: ${{ matrix.build-mode }}
45
+
46
+ - name: Perform CodeQL Analysis
47
+ uses: github/codeql-action/analyze@v4
48
+ with:
49
+ category: /language:${{ matrix.language }}
.github/workflows/ghcr-image.yml ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: GHCR Image
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ tags:
8
+ - "v*.*.*"
9
+ pull_request:
10
+ branches:
11
+ - main
12
+ workflow_dispatch:
13
+
14
+ concurrency:
15
+ group: ghcr-${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+ permissions:
19
+ contents: read
20
+
21
+ jobs:
22
+ verify:
23
+ name: Verify
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v4
28
+
29
+ - name: Set up Go
30
+ uses: actions/setup-go@v5
31
+ with:
32
+ go-version-file: backend/go.mod
33
+ cache-dependency-path: backend/go.sum
34
+
35
+ - name: Test backend
36
+ working-directory: backend
37
+ run: go test ./...
38
+
39
+ - name: Vet backend
40
+ working-directory: backend
41
+ run: go vet ./...
42
+
43
+ - name: Verify Swagger document
44
+ run: |
45
+ make swagger
46
+ git diff --exit-code -- backend/docs/docs.go backend/docs/swagger.json backend/docs/swagger.yaml
47
+
48
+ - name: Set up pnpm
49
+ uses: pnpm/action-setup@v4
50
+ with:
51
+ version: 11.5.2
52
+
53
+ - name: Set up Node.js
54
+ uses: actions/setup-node@v4
55
+ with:
56
+ node-version: 22
57
+ cache: pnpm
58
+ cache-dependency-path: frontend/pnpm-lock.yaml
59
+
60
+ - name: Install frontend dependencies
61
+ working-directory: frontend
62
+ run: pnpm install --frozen-lockfile
63
+
64
+ - name: Lint frontend
65
+ working-directory: frontend
66
+ run: pnpm lint
67
+
68
+ - name: Build frontend
69
+ working-directory: frontend
70
+ run: pnpm build
71
+
72
+ check_ghcr_image:
73
+ name: Build image (${{ matrix.arch }})
74
+ if: github.event_name != 'push'
75
+ needs: verify
76
+ strategy:
77
+ fail-fast: false
78
+ matrix:
79
+ include:
80
+ - arch: amd64
81
+ platform: linux/amd64
82
+ runner: ubuntu-24.04
83
+ - arch: arm64
84
+ platform: linux/arm64
85
+ runner: ubuntu-24.04-arm
86
+ runs-on: ${{ matrix.runner }}
87
+
88
+ steps:
89
+ - name: Checkout
90
+ uses: actions/checkout@v4
91
+
92
+ - name: Set up Buildx
93
+ uses: docker/setup-buildx-action@v3
94
+
95
+ - name: Build
96
+ uses: docker/build-push-action@v6
97
+ with:
98
+ context: .
99
+ file: ./Dockerfile
100
+ platforms: ${{ matrix.platform }}
101
+ push: false
102
+ cache-from: type=gha,scope=${{ matrix.arch }}
103
+ provenance: false
104
+ sbom: false
105
+
106
+ build_ghcr_image:
107
+ name: Publish image (${{ matrix.arch }})
108
+ if: github.event_name == 'push'
109
+ needs: verify
110
+ permissions:
111
+ contents: read
112
+ packages: write
113
+ strategy:
114
+ fail-fast: false
115
+ matrix:
116
+ include:
117
+ - arch: amd64
118
+ platform: linux/amd64
119
+ runner: ubuntu-24.04
120
+ - arch: arm64
121
+ platform: linux/arm64
122
+ runner: ubuntu-24.04-arm
123
+ runs-on: ${{ matrix.runner }}
124
+
125
+ steps:
126
+ - name: Checkout
127
+ uses: actions/checkout@v4
128
+
129
+ - name: Set up Buildx
130
+ uses: docker/setup-buildx-action@v3
131
+
132
+ - name: Normalize image name
133
+ id: image
134
+ shell: bash
135
+ run: |
136
+ image_name=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')
137
+ echo "name=ghcr.io/$image_name" >> "$GITHUB_OUTPUT"
138
+
139
+ - name: Docker metadata
140
+ id: meta
141
+ uses: docker/metadata-action@v5
142
+ with:
143
+ images: ${{ steps.image.outputs.name }}
144
+ tags: |
145
+ type=ref,event=branch,suffix=-${{ matrix.arch }}
146
+ type=ref,event=tag,suffix=-${{ matrix.arch }}
147
+ type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }}
148
+
149
+ - name: Login to GHCR
150
+ uses: docker/login-action@v4
151
+ with:
152
+ registry: ghcr.io
153
+ username: ${{ github.actor }}
154
+ password: ${{ secrets.GITHUB_TOKEN }}
155
+
156
+ - name: Build and publish
157
+ uses: docker/build-push-action@v6
158
+ with:
159
+ context: .
160
+ file: ./Dockerfile
161
+ platforms: ${{ matrix.platform }}
162
+ push: true
163
+ tags: ${{ steps.meta.outputs.tags }}
164
+ labels: ${{ steps.meta.outputs.labels }}
165
+ cache-from: type=gha,scope=${{ matrix.arch }}
166
+ cache-to: type=gha,scope=${{ matrix.arch }},mode=max
167
+ provenance: mode=max
168
+ sbom: true
169
+
170
+ merge:
171
+ name: Merge image
172
+ if: github.event_name == 'push'
173
+ needs:
174
+ - build_ghcr_image
175
+ runs-on: ubuntu-latest
176
+ permissions:
177
+ contents: read
178
+ packages: write
179
+
180
+ steps:
181
+ - name: Set up Buildx
182
+ uses: docker/setup-buildx-action@v3
183
+
184
+ - name: Normalize image name
185
+ id: image
186
+ shell: bash
187
+ run: |
188
+ image_name=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')
189
+ echo "name=ghcr.io/$image_name" >> "$GITHUB_OUTPUT"
190
+
191
+ - name: Docker metadata
192
+ id: meta
193
+ uses: docker/metadata-action@v5
194
+ with:
195
+ images: ${{ steps.image.outputs.name }}
196
+ tags: |
197
+ type=ref,event=branch
198
+ type=ref,event=tag
199
+ type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
200
+
201
+ - name: Login to GHCR
202
+ uses: docker/login-action@v4
203
+ with:
204
+ registry: ghcr.io
205
+ username: ${{ github.actor }}
206
+ password: ${{ secrets.GITHUB_TOKEN }}
207
+
208
+ - name: Create manifest list
209
+ shell: bash
210
+ run: |
211
+ jq -cr '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON" | while read -r tag; do
212
+ docker buildx imagetools create \
213
+ -t "$tag" \
214
+ "${tag}-amd64" \
215
+ "${tag}-arm64"
216
+ done
217
+
218
+ - name: Inspect image
219
+ run: docker buildx imagetools inspect "${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}"
.github/workflows/stale.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Close inactive issues and PRs
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "30 1 * * *"
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ issues: write
10
+ pull-requests: write
11
+
12
+ jobs:
13
+ stale:
14
+ name: Mark and close inactive issues and PRs
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - name: Process stale issues and PRs
19
+ uses: actions/stale@v10
20
+ with:
21
+ days-before-issue-stale: 7
22
+ days-before-pr-stale: 7
23
+ days-before-issue-close: 7
24
+ days-before-pr-close: 7
25
+ stale-issue-label: stale
26
+ stale-pr-label: stale
27
+ stale-issue-message: This issue has had no activity for 7 days. It will be closed in 7 days if there is no further activity.
28
+ stale-pr-message: This pull request has had no activity for 7 days. It will be closed in 7 days if there is no further activity.
29
+ close-issue-message: This issue was closed because it had no activity for 7 days after being marked stale.
30
+ close-pr-message: This pull request was closed because it had no activity for 7 days after being marked stale.
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OS and editors
2
+ .DS_Store
3
+ Thumbs.db
4
+ .idea/
5
+ .vscode/
6
+ *.swp
7
+ *.swo
8
+ *~
9
+
10
+ # Local environment and secrets
11
+ # 注意: HF 部署需要 config.yaml 入库(打进镜像),所以不忽略它
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ /docs/*
16
+ /docs/rules/*
17
+ /backend/docs/goal/
18
+ *.har
19
+ *.pcap
20
+ *.pcapng
21
+
22
+ # Runtime data
23
+ /data/
24
+ /backend/data/
25
+ *.log
26
+
27
+ # Go
28
+ /.gocache/
29
+ /.gocache-*/
30
+ /backend/grok2api
31
+ /backend/coverage.out
32
+ *.coverprofile
33
+ *.test
34
+
35
+ # Frontend
36
+ /.pnpm-store/
37
+ /frontend/node_modules/
38
+ /frontend/dist/
39
+ /frontend/.cache/
40
+ /frontend/*.tsbuildinfo
41
+
42
+ # Temporary files
43
+ /release/
44
+ /.tmp/
45
+ /tmp/
Dockerfile ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG NODE_VERSION=22
2
+ ARG GO_VERSION=1.26
3
+ ARG ALPINE_VERSION=3.23
4
+
5
+ FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS frontend-builder
6
+
7
+ WORKDIR /src/frontend
8
+ RUN corepack enable
9
+
10
+ COPY frontend/package.json frontend/pnpm-lock.yaml ./
11
+ RUN --mount=type=cache,id=grok2api-pnpm,target=/pnpm/store \
12
+ pnpm config set store-dir /pnpm/store && \
13
+ pnpm fetch --frozen-lockfile
14
+
15
+ RUN --mount=type=cache,id=grok2api-pnpm,target=/pnpm/store \
16
+ pnpm config set store-dir /pnpm/store && \
17
+ pnpm install --offline --frozen-lockfile
18
+
19
+ COPY frontend/index.html frontend/vite.config.ts frontend/tsconfig.json frontend/tsconfig.app.json frontend/tsconfig.node.json ./
20
+ COPY frontend/public ./public
21
+ COPY frontend/src ./src
22
+ RUN --mount=type=cache,id=grok2api-tsc,target=/src/frontend/.cache,sharing=locked \
23
+ pnpm build
24
+
25
+
26
+ FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS backend-builder
27
+
28
+ ARG TARGETOS
29
+ ARG TARGETARCH
30
+
31
+ WORKDIR /src/backend
32
+ RUN apk add --no-cache ca-certificates git
33
+
34
+ COPY backend/go.mod backend/go.sum ./
35
+ RUN --mount=type=cache,id=grok2api-go-mod,target=/go/pkg/mod,sharing=locked \
36
+ go mod download
37
+
38
+ COPY backend/cmd ./cmd
39
+ COPY backend/internal ./internal
40
+ COPY backend/docs/docs.go ./docs/docs.go
41
+ RUN --mount=type=cache,id=grok2api-go-mod,target=/go/pkg/mod,sharing=locked \
42
+ --mount=type=cache,id=grok2api-go-build,target=/root/.cache/go-build,sharing=locked \
43
+ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
44
+ go build -buildvcs=false -trimpath -ldflags="-s -w" -o /out/grok2api ./cmd/grok2api
45
+
46
+
47
+ FROM alpine:${ALPINE_VERSION}
48
+
49
+ ENV TZ=Asia/Shanghai \
50
+ GROK2API_CONFIG_SOURCE=/run/grok2api/config.yaml
51
+
52
+ RUN apk add --no-cache ca-certificates su-exec tzdata openssl && \
53
+ addgroup -S -g 10001 grok2api && \
54
+ adduser -S -D -H -u 10001 -G grok2api grok2api && \
55
+ mkdir -p /app /run/grok2api && \
56
+ chown -R grok2api:grok2api /app /run/grok2api
57
+
58
+ WORKDIR /app
59
+
60
+ COPY --from=backend-builder --chmod=0755 /out/grok2api /app/grok2api
61
+ COPY --from=frontend-builder /src/frontend/dist /app/frontend/dist
62
+ COPY VERSION /app/VERSION
63
+ COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/grok2api-entrypoint
64
+ # Hugging Face Spaces: 把配置打进镜像(HF 无卷挂载,靠 entrypoint 落盘)
65
+ COPY config.yaml /run/grok2api/config.yaml
66
+
67
+ EXPOSE 8000
68
+
69
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
70
+ CMD wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1
71
+
72
+ ENTRYPOINT ["/usr/local/bin/grok2api-entrypoint"]
73
+ CMD ["/app/grok2api", "--config", "/app/config.yaml", "--listen", "0.0.0.0:8000"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chenyme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: run swagger
2
+
3
+ CONFIG ?= $(CURDIR)/config.yaml
4
+
5
+ run:
6
+ cd backend && GOCACHE=$(CURDIR)/.gocache go run ./cmd/grok2api --config "$(abspath $(CONFIG))" $(RUN_ARGS)
7
+
8
+ swagger:
9
+ cd backend && GOCACHE=$(CURDIR)/.gocache go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \
10
+ -g main.go \
11
+ -d cmd/grok2api,internal/transport/http \
12
+ --parseInternal \
13
+ --output docs \
14
+ --outputTypes go,json,yaml
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Grok2API
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 8000
8
+ pinned: false
9
+ ---
10
+
11
+ # Grok2API - Hugging Face Spaces 部署版 (v3.0.11)
12
+
13
+ Grok Build / Grok Web / Grok Console 多账号 API 网关,OpenAI / Anthropic 兼容接口。
14
+ 本仓库为 [chenyme/grok2api](https://github.com/chenyme/grok2api) v3.0.11 的 HF Spaces 适配版。
15
+
16
+ ## 部署要点
17
+
18
+ 1. **端口**:`app_port: 8000`(上方 YAML 头块),与容器内监听端口一致。
19
+ 2. **配置**:`config.yaml` 已打进 Docker 镜像(`/run/grok2api/config.yaml`),
20
+ 密钥/管理员密码直接写在文件里。**公开空间下这些值可见,建议把 Space 设为 Private。**
21
+ 3. **数据持久化**:数据库与媒体文件存放在 `/data`(HF Persistent Storage 挂载点)。
22
+ 请在 Space Settings 中开启 Persistent Storage,否则重启后数据丢失。
23
+ 4. **管理员登录**:部署完成后访问本 Space 根路径进入管理后台,
24
+ 用户名 `admin`,密码见 `config.yaml` 的 `bootstrapAdmin.password`,
25
+ 首次登录后请在管理端修改密码。
26
+
27
+ ## 环境变量
28
+
29
+ 本版 Go 服务**不读取**旧版 Python 时代的环境变量(`APP_KEY`、`API_KEY`、
30
+ `BOOTSTRAP_ADMIN_PASSWORD` 等均无效),所有配置都在 `config.yaml` 中完成。
31
+
32
+ ## 许可证
33
+
34
+ MIT License
README.zh-CN.md ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <img alt="Grok2API" src="./frontend/public/grok2api.png" width="720" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>面向 Grok Build、Grok Web 与 Grok Console 的多账号 API 网关</strong>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="./README.md">English</a> | 简体中文
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="./backend/go.mod"><img alt="Go" src="https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white" /></a>
15
+ <a href="./frontend/package.json"><img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111827" /></a>
16
+ <a href="https://github.com/chenyme/grok2api/pkgs/container/grok2api"><img alt="Docker" src="https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white" /></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="https://trendshift.io/repositories/19868?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-19868" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/19868" alt="chenyme%2Fgrok2api | Trendshift" width="250" height="55"/></a>
21
+ </p>
22
+
23
+ > [!TIP]
24
+ > 推荐个人新项目 [DEEIX-AI / DEEIX-Chat](https://github.com/DEEIX-AI/DEEIX-Chat):面向多模型路由、对话、文件、工具、计费与运维的一体化轻量 AI 平台。
25
+
26
+ > [!NOTE]
27
+ > 本项目仅供技术研究与学习交流。使用时请务必遵循 Grok 官方的使用条款及当地法律法规,否则一切后果自负!
28
+
29
+ ## 赞助商
30
+
31
+ > [希望赞助这个项目?](mailto:chenyme03@gmail.com)
32
+
33
+ <table>
34
+ <tr>
35
+ <td width="200" align="center" valign="middle"><a href="https://www.krill-ai.com/register?invite=KJ2VGIRVAE"><img src="https://raw.githubusercontent.com/Krill-ai-org/krill-ai-static/refs/heads/main/krill-logo/Eng/250x150.png" alt="Krill AI" width="160"></a></td>
36
+ <td valign="middle">感谢 Krill AI 赞助了本项目!Krill 提供 GPT / Claude / Gemini / 多款国产模型的官方稳定极速的 API 中转服务,支持企业级定制、报销开票、7×16h 专属技术支持。更有独家适配的 WebSocket 连接,畅享极速首字速度。Krill 为本项目提供了特别优惠,使用<a href="https://www.krill-ai.com/register?invite=KJ2VGIRVAE">此链接</a>注册并在下订单时填写「grok2api」优惠码,首购套餐可享 Codex 77 折优惠!</td>
37
+ </tr>
38
+ <tr>
39
+ <td width="200" align="center" valign="middle"><a href="https://github.com/DEEIX-AI/DEEIX-Chat"><img src="frontend/public/sponner/deeix-chat_deeix-ai.png" alt="DEEIX AI / DEEIX Chat" width="160"></a></td>
40
+ <td valign="middle">DEEIX-Chat 是一款开源可部署的 AI Chat 平台,面向需要长期、稳定、统一使用多模型能力的个人、团队与企业,将模型、对话、文件、工具调用与后台管理整合为一套可部署、可扩展的系统。点击 <a href="https://github.com/DEEIX-AI/DEEIX-Chat">此处</a> 开始部署!</td>
41
+ </tr>
42
+ <tr>
43
+ <td width="200" align="center" valign="middle"><a href="https://www.right.codes/register"><img src="frontend/public/sponner/rightcode.jpg" alt="RightCode" width="160"></a></td>
44
+ <td valign="middle">Right Code 是一个企业级 AI Agent 分发平台,主要提供稳定的 Claude Code、Codex、Gemini 等模型的中转服务。充值即可开票,企业、团队用户一对一对接。感谢 Right Code 提供的 Tokens 支持,点击 <a href="https://www.right.codes/register">此处</a> 注册并开始使用!</td>
45
+ </tr>
46
+ </table>
47
+
48
+ <br>
49
+
50
+ ## 项目简介
51
+
52
+ Grok2API 是一个内置 React 管理端的 Go 网关。它分别管理 Grok Build、Grok Web 和 Grok Console 账号池,并对外提供统一的 OpenAI 与 Anthropic 兼容接口。
53
+
54
+ ### 项目架构
55
+
56
+ ```mermaid
57
+ flowchart LR
58
+ %% 颜色定义
59
+ classDef access fill:#e1f5fe,stroke:#01579b
60
+ classDef core fill:#fff3e0,stroke:#e65100
61
+ classDef providers fill:#f3e5f5,stroke:#4a148c
62
+ classDef infra fill:#e8f5e9,stroke:#1b5e20
63
+ classDef upstream fill:#fce4ec,stroke:#880e4f
64
+
65
+ subgraph Access["接入域"]
66
+ direction LR
67
+ Clients["API 客户端"]
68
+ Admin["React 管理端"]
69
+ end
70
+
71
+ subgraph Core["网关核心域"]
72
+ direction LR
73
+ Management["管理服务<br/>账号 · 模型 · 密钥 · 设置"]
74
+ Sync["账号同步<br/>凭据 · 额度 · 模型"]
75
+ Gateway["网关服务<br/>协议 · 路由 · 选号 · 重试"]
76
+ Audit["审计服务<br/>用量 · 客户端计费"]
77
+ Management --> Sync
78
+ Gateway -.-> Audit
79
+ end
80
+
81
+ subgraph Providers["Provider 渠道域"]
82
+ direction LR
83
+ Registry["Provider 注册表"]
84
+ Build["Grok Build<br/>OAuth · 动态模型 · Billing"]
85
+ Web["Grok Web<br/>SSO · 远端额度 · 媒体"]
86
+ Console["Grok Console<br/>SSO · 本地窗口 · 无状态"]
87
+ Registry --> Build
88
+ Registry --> Web
89
+ Registry --> Console
90
+ end
91
+
92
+ subgraph Infra["共享基础设施域"]
93
+ direction LR
94
+ Egress["出口管理器<br/>作用域 · 代理池 · ���退 · Clearance"]
95
+ Database[("SQLite / PostgreSQL")]
96
+ Runtime[("Memory / Redis")]
97
+ end
98
+
99
+ Upstream["🌐 Grok 上游"]
100
+
101
+ %% 跨域调用
102
+ Clients --> Gateway
103
+ Admin --> Management
104
+ Gateway --> Registry
105
+ Sync --> Registry
106
+ Build -->|grok_build| Egress
107
+ Web -->|grok_web / asset| Egress
108
+ Console -->|grok_console| Egress
109
+ Egress --> Upstream
110
+ Management --> Database
111
+ Audit --> Database
112
+ Gateway <--> Runtime
113
+
114
+ %% 应用样式
115
+ class Clients,Admin access
116
+ class Management,Sync,Gateway,Audit core
117
+ class Registry,Build,Web,Console providers
118
+ class Egress,Database,Runtime infra
119
+ class Upstream upstream
120
+ ```
121
+
122
+ 网关通过 Provider 注册表分发请求,账号同步负责刷新凭据、额度和模型。三个渠道独立维护账号状态并使用隔离的出口作用域;请求结束后统一结算用量、审计和客户端计费。
123
+
124
+ ### 核心能力
125
+
126
+ | 模块 | 能力 |
127
+ | :-- | :-- |
128
+ | 接口 | Responses、Chat Completions、Anthropic Messages、Images 与异步 Videos |
129
+ | 客户端 | Codex、Claude Code,以及 OpenAI/Anthropic 兼容 SDK |
130
+ | 账号 | 批量导入导出、额度同步、凭据续期、转换、账号工具与清理 |
131
+ | 路由 | 模型发现、Provider 限定、会话粘滞、额度/并发门禁和有界切换 |
132
+ | 会话 | stored response、compact、Prompt Cache 亲和与可选 reasoning replay |
133
+ | 媒体 | 图片生成与编辑、视频任务、本地归档及 URL/Base64/SSE 输出 |
134
+ | 出口 | HTTP/SOCKS/Resin、订阅、探测、代理池、调配、回退与 FlareSolverr |
135
+ | 运维 | Dashboard、模型路由、客户端密钥、审计、运行设置和媒体库 |
136
+
137
+ ### Provider 边界
138
+
139
+ | Provider | 认证 | 模型 | 主要能力 |
140
+ | :-- | :-- | :-- | :-- |
141
+ | Grok Build | OAuth / 设备授权 | 按账号动态发现 | Responses、Chat、Messages、compact、stored response、视频 |
142
+ | Grok Web | SSO | 内置并按等级过滤 | Responses、Chat、Messages、图片、图片编辑、视频 |
143
+ | Grok Console | SSO | 内置 | 无状态 Responses、Chat、Messages |
144
+
145
+ 三个 Provider 独立维护凭据、额度、健康、冷却、并发与模型能力。故障切换不会跨 Provider 混用账号状态。
146
+
147
+ ## 快速部署
148
+
149
+ 官方镜像支持 `linux/amd64` 和 `linux/arm64`。
150
+
151
+ ```bash
152
+ git clone https://github.com/chenyme/grok2api.git
153
+ cd grok2api
154
+ cp config.example.yaml config.yaml
155
+ ```
156
+
157
+ 生成密钥并写入 `config.yaml`:
158
+
159
+ ```bash
160
+ openssl rand -hex 32
161
+ openssl rand -base64 32
162
+ ```
163
+
164
+ ```yaml
165
+ secrets:
166
+ jwtSecret: "替换为生成的 Hex 密钥"
167
+ credentialEncryptionKey: "替换为生成的 Base64 密钥"
168
+
169
+ bootstrapAdmin:
170
+ username: "admin"
171
+ password: "替换为强密码"
172
+ ```
173
+
174
+ 启动服务:
175
+
176
+ ```bash
177
+ docker compose pull
178
+ docker compose up -d
179
+ docker compose logs -f grok2api
180
+ ```
181
+
182
+ 访问 `http://127.0.0.1:8000`。镜像已包含前端,SQLite 数据库与本地媒体保存在 Compose 数据卷中。
183
+
184
+ ### 源码运行
185
+
186
+ ```bash
187
+ cp config.example.yaml config.yaml
188
+ make run
189
+ ```
190
+
191
+ 单独运行前端开发服务:
192
+
193
+ ```bash
194
+ cd frontend
195
+ pnpm install
196
+ pnpm dev
197
+ ```
198
+
199
+ ## 初始化网关
200
+
201
+ 1. 使用初始管理员登录。
202
+ 2. 接入 Build、Web 或 Console 账号。
203
+ 3. 等待额度和模型能力同步完成。
204
+ 4. 在“模型路由”中确认公开模型。
205
+ 5. 在“客户端密钥”中创建密钥。
206
+ 6. 使用该密钥调用 `/v1/*`。
207
+
208
+ 首次登录后请修改管理员密码,并从配置中删除 `bootstrapAdmin`。账号写入后不要更换 `credentialEncryptionKey`。
209
+
210
+ ### 账号操作
211
+
212
+ | Provider | 接入或导入 | 导出 |
213
+ | :-- | :-- | :-- |
214
+ | Build | 设备授权、JSON/JSONL | 可重新导入的账号文件 |
215
+ | Web | 粘贴/TXT SSO、JSON/JSONL | 可重新导入的账号文件 |
216
+ | Console | 粘贴/TXT SSO、JSON/JSONL | 可重新导入的账号文件 |
217
+
218
+ 导入兼容 UTF-8 BOM。批量额度同步、Build 凭据续期、Web→Build/Console 转换、账号工具和账号清理均显示实时进度。
219
+
220
+ Web 账号工具支持接受协议、设置对应 20–40 岁的随机生日和开启 NSFW;已完成步骤会记录并在后续执行时跳过。
221
+
222
+ 系统支持自动删除长期处于 `reauthRequired` 的账号,默认关闭;存在活动推理租约或视频任务的账号不会被删除。
223
+
224
+ > [!TIP]
225
+ > 从 Python 版迁移时,请将 Grok Web SSO 导出为 TXT,再导入“Grok Web”。旧数据库和号池元数据不兼容。
226
+
227
+ ## 模型与路由
228
+
229
+ Build 模型根据账号能力动态发现;Web、Console 使用内置目录。请以模型页面或 `GET /v1/models` 为准,README 不再维护容易过期的静态模型清单。
230
+
231
+ 公开模型名通常不带 Provider。内部路由使用 `Build/`、`Web/` 或 `Console/` 前缀;带前缀名称可显式限定来源。
232
+
233
+ Web 可与对应的 Build、Console 建立一对一弱关联。关联只共享匿名出口身份和来源展示,不合并凭据、额度、健康、冷却、并发、模型能力或计费。
234
+
235
+ ### Codex、Claude Code 与 Prompt Cache
236
+
237
+ Responses 与 Messages 支持流式、工具、推理、多轮会话和 compact。客户端会话信号会保持稳定,用于 Grok Build Prompt Cache 亲和;实际命中仍要求上游账号兼容且请求前缀未变化。
238
+
239
+ Responses 与 Chat Completions 按 OpenAI 语义报告输入总量;Messages 按 Anthropic 语义分开报告未缓存输入和缓存读取。审计保留输入总量与缓存部分,用于计费对账。
240
+
241
+ ## API
242
+
243
+ 推理接口使用客户端密钥:
244
+
245
+ ```http
246
+ Authorization: Bearer g2a_xxx_xxx
247
+ ```
248
+
249
+ | 方法 | 路径 | 用途 |
250
+ | :-- | :-- | :-- |
251
+ | `GET` | `/healthz`、`/readyz` | 存活与就绪检查 |
252
+ | `GET` | `/v1/models` | 当前可服务模型 |
253
+ | `POST` | `/v1/responses` | Responses JSON/SSE |
254
+ | `POST` | `/v1/responses/compact` | 压缩支持的 Response 会话 |
255
+ | `GET`、`DELETE` | `/v1/responses/{id}` | 查询或删除 stored response |
256
+ | `POST` | `/v1/chat/completions` | Chat Completions JSON/SSE |
257
+ | `POST` | `/v1/messages` | Anthropic Messages JSON/SSE |
258
+ | `POST` | `/v1/images/generations`、`/v1/images/edits` | 生成或编辑图片 |
259
+ | `POST`、`GET` | `/v1/videos/*` | 创建和查询视频任务 |
260
+ | `GET` | `/v1/media/images/{asset_id}`、`/v1/media/videos/{asset_id}` | 读取归档媒体 |
261
+
262
+ stored response 和 compact 取决于最终 Provider。登录管理端后可在 `/docs` 查看当前模型与调用示例;仅在 `server.swaggerEnabled: true` 时提供 Swagger。
263
+
264
+ 客户端密钥支持模型白名单,以及可选的 RPM、并发、用量和截止日期限制。
265
+
266
+ ```bash
267
+ curl http://127.0.0.1:8000/v1/responses \
268
+ -H "Authorization: Bearer g2a_xxx_xxx" \
269
+ -H "Content-Type: application/json" \
270
+ -d '{
271
+ "model": "your-model",
272
+ "input": "用三句话解释量子隧穿。",
273
+ "stream": true
274
+ }'
275
+ ```
276
+
277
+ ## 出口与 Cloudflare
278
+
279
+ 出口节点按 Build、Web、Console 或 Web 资源隔离。管理端支持:
280
+
281
+ - HTTP、HTTPS、SOCKS4/4A、SOCKS5/5H 与 Resin
282
+ - 订阅和文本/Base64 导入
283
+ - 批量探测、筛选、删除、分配与均衡
284
+ - 按作用域配置无回退、直连或固定节点
285
+ - 代理池模式,单次连接失败不会触发全局冷却
286
+
287
+ Resin 用户名支持 `{account}`:
288
+
289
+ ```text
290
+ socks5h://Default.{account}:RESIN_PROXY_TOKEN@resin:2260
291
+ ```
292
+
293
+ 占位符会替换为稳定的匿名身份。已关联的 Web、Build、Console 可共享该身份,不直接使用 Token 或 Email。
294
+
295
+ 如需自动维护 Web/Console Cloudflare Clearance:
296
+
297
+ ```bash
298
+ docker compose --profile flaresolverr up -d
299
+ ```
300
+
301
+ 随后在 **运行设置 → 媒体与网络 → Clearance** 选择 `FlareSolverr`,地址填写 `http://flaresolverr:8191`。
302
+
303
+ 出口层只重试可以确认发生在请求提交前的连接故障,不会重放已经提交的生成请求、认证失败、额度耗尽或上游限流。
304
+
305
+ ## 配置与部署
306
+
307
+ `config.yaml` 保存启动配置;Provider 和运维参数由管理端维护,未标记“重启生效”的设置支持热加载。
308
+
309
+ | 场景 | 数据库 | 运行态 | 媒体 |
310
+ | :-- | :-- | :-- | :-- |
311
+ | 单实例 | SQLite | Memory | 本地目录 |
312
+ | 多实例 | PostgreSQL | Redis | 共享且可读写的目录 |
313
+
314
+ 多实例需要为每个副本设置唯一的 `deployment.instanceID`,统一使用同一个 `clusterID`;只有媒体目录已正确共享时才设置 `sharedMedia: true`。
315
+
316
+ 重要的可选设置:
317
+
318
+ - `audit.ledgerMode`:`observe` 仅报告账本故障;`enforce` 可暂停新推理以保护计费准确性。
319
+ - `routing.segmentedSelectorEnabled`:用于大型账号池,同时保留完整选号回退与原子门禁。
320
+ - Build 响应头超时和精确匹配的 403 失效规则支持热加载。
321
+ - “同步最新版本”可应用已验证的 Grok Build 客户端版本和 User-Agent。
322
+
323
+ ## 生产检查
324
+
325
+ - 使用 HTTPS,并启用 `auth.secureCookies`。
326
+ - 公网部署保持 Swagger 关闭。
327
+ - 使用强密钥并妥善备份;不要提交凭据、Cookie、账号导出或数据库。
328
+ - 备份 `config.yaml`、数据库和媒体目录。
329
+ - 多实例同时使用 PostgreSQL、Redis 与共享媒体。
330
+ - 公网服务前置反向代理与访问控制。
331
+
332
+ ## 开发验证
333
+
334
+ ```bash
335
+ cd backend
336
+ go test ./...
337
+ go test -race ./...
338
+ go vet ./...
339
+ go build ./cmd/grok2api
340
+ ```
341
+
342
+ ```bash
343
+ cd frontend
344
+ pnpm install --frozen-lockfile
345
+ pnpm lint
346
+ pnpm build
347
+ ```
348
+
349
+ 修改公开 API 注释后重新生成 Swagger:
350
+
351
+ ```bash
352
+ make swagger
353
+ ```
354
+
355
+ ## 相关文档
356
+
357
+ - [English README](./README.md)
358
+ - [后端说明](./backend/README.md)
359
+ - [前端说明](./frontend/README.md)
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ v3.0.11
backend/Makefile ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .PHONY: run
2
+
3
+ # 在 backend/ 目录复用项目根目录的启动入口和配置路径。
4
+ run:
5
+ $(MAKE) -C .. run
backend/README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Grok2API Backend
2
+
3
+ Grok2API 的 Go 后端,负责上游账号调度、协议转换、额度管理、请求审计和管理 API,并可直接托管前端构建产物。
4
+
5
+ ## 技术栈
6
+
7
+ - Go 1.26、Gin、GORM
8
+ - SQLite / PostgreSQL
9
+ - Memory / Redis
10
+ - Grok Build OAuth、Grok Web SSO 与 Grok Console SSO Provider
11
+
12
+ ## 本地运行
13
+
14
+ 配置文件位于仓库根目录。首次运行前创建本地配置并设置安全密钥:
15
+
16
+ ```bash
17
+ cp config.example.yaml config.yaml
18
+ openssl rand -hex 32
19
+ openssl rand -base64 32
20
+ ```
21
+
22
+ 将生成值写入 `config.yaml` 的 `secrets`,并修改 `bootstrapAdmin` 初始密码,然后启动:
23
+
24
+ ```bash
25
+ cd backend
26
+ go run ./cmd/grok2api
27
+ ```
28
+
29
+ 服务默认监听 `http://127.0.0.1:8000`。也可以显式指定配置文件或监听地址:
30
+
31
+ ```bash
32
+ go run ./cmd/grok2api --config /path/to/config.yaml --listen 0.0.0.0:8000
33
+ ```
34
+
35
+ ## 配置与存储
36
+
37
+ 启动配置统一由根目录 `config.yaml` 管理,启动阶段字段见 [`config.example.yaml`](../config.example.yaml)。Provider、服务容量、批量任务、路由、媒体、审计和客户端密钥默认限制由管理端设置页持久化;除页面明确标记“重启生效”的字段外均会热加载。
38
+
39
+ | 场景 | 数据库 | 运行态存储 |
40
+ | --- | --- | --- |
41
+ | 本地开发 / 单实例 | SQLite | Memory |
42
+ | 多实例部署 | PostgreSQL | Redis |
43
+
44
+ 关系型数据库保存账号、凭据、模型、额度、客户端密钥、审计和媒体任务;Redis 仅承载限流、并发租约、粘滞路由、分布式锁和事件通知。敏感凭据使用 `credentialEncryptionKey` 加密,该密钥必须长期保留且不得提交到版本库。
45
+
46
+ 运行设置与代理参数由管理端设置页维护;数据库驱动、监听地址、Redis、JWT 与加密密钥仍通过 YAML 配置并在启动时生效。
47
+
48
+ ## 服务入口
49
+
50
+ - `/v1/*`:兼容 API
51
+ - `/api/admin/v1/*`:管理 API
52
+ - `/healthz`、`/readyz`:健康与就绪探针
53
+ - `/swagger/index.html`:公开 API Swagger,仅在 `server.swaggerEnabled: true` 时注册
54
+ - `frontend.staticPath`:前端静态目录,默认 `./frontend/dist`
55
+
56
+ 详细协议说明见 [`docs`](./docs)。
57
+
58
+ 修改公开接口注释后,在仓库根目录执行 `make swagger` 更新 `backend/docs/docs.go`、`swagger.json` 与 `swagger.yaml`。生产配置应保持 `server.swaggerEnabled: false`。
59
+
60
+ ## 代码结构
61
+
62
+ ```text
63
+ cmd/grok2api/ 进程入口
64
+ internal/domain/ 领域模型与规则
65
+ internal/application/ 应用服务与用例
66
+ internal/infra/ 数据库、Provider、运行态与安全实现
67
+ internal/transport/ HTTP 路由、鉴权与协议适配
68
+ internal/repository/ 持久化接口
69
+ ```
70
+
71
+ 依赖方向保持为 Transport → Application → Domain,基础设施通过接口接入,不在领域层依赖 HTTP、数据库或具体 Provider。
72
+
73
+ 三个 Provider 通过声明式 Definition 和小型能力接口注册:Build 使用远程模型目录与 Billing,Web 使用按等级过滤的静态目录与上游额度窗口,Console 使用静态目录和无状态 Responses。Gateway 只根据能力声明完成路由与调度,不在通用 Handler 中拼装 Provider 私有请求。
74
+
75
+ ## 验证
76
+
77
+ ```bash
78
+ go test ./...
79
+ go test -race ./...
80
+ go vet ./...
81
+ go build ./cmd/grok2api
82
+ ```
backend/cmd/grok2api/main.go ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ // @title Grok2API
4
+ // @version 1.0
5
+ // @description Grok Build 与 Grok Web 多账号 API 网关。
6
+ // @BasePath /
7
+ // @securityDefinitions.apikey BearerAuth
8
+ // @in header
9
+ // @name Authorization
10
+ // @description 使用 "Bearer g2a_xxx_xxx"。
11
+
12
+ import (
13
+ "fmt"
14
+ "os"
15
+
16
+ "github.com/chenyme/grok2api/backend/internal/cli"
17
+ )
18
+
19
+ func main() {
20
+ if err := cli.Run(os.Args[1:]); err != nil {
21
+ _, _ = fmt.Fprintln(os.Stderr, err)
22
+ os.Exit(1)
23
+ }
24
+ }
backend/docs/docs.go ADDED
@@ -0,0 +1,809 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package docs Code generated by swaggo/swag. DO NOT EDIT
2
+ package docs
3
+
4
+ import "github.com/swaggo/swag"
5
+
6
+ const docTemplate = `{
7
+ "schemes": {{ marshal .Schemes }},
8
+ "swagger": "2.0",
9
+ "info": {
10
+ "description": "{{escape .Description}}",
11
+ "title": "{{.Title}}",
12
+ "contact": {},
13
+ "version": "{{.Version}}"
14
+ },
15
+ "host": "{{.Host}}",
16
+ "basePath": "{{.BasePath}}",
17
+ "paths": {
18
+ "/healthz": {
19
+ "get": {
20
+ "produces": [
21
+ "application/json"
22
+ ],
23
+ "tags": [
24
+ "System"
25
+ ],
26
+ "summary": "存活检查",
27
+ "responses": {
28
+ "200": {
29
+ "description": "OK",
30
+ "schema": {
31
+ "type": "object",
32
+ "additionalProperties": {
33
+ "type": "boolean"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ },
40
+ "/readyz": {
41
+ "get": {
42
+ "produces": [
43
+ "application/json"
44
+ ],
45
+ "tags": [
46
+ "System"
47
+ ],
48
+ "summary": "就绪检查",
49
+ "responses": {
50
+ "200": {
51
+ "description": "OK",
52
+ "schema": {
53
+ "type": "object",
54
+ "additionalProperties": {
55
+ "type": "boolean"
56
+ }
57
+ }
58
+ },
59
+ "503": {
60
+ "description": "Service Unavailable",
61
+ "schema": {
62
+ "type": "object",
63
+ "additionalProperties": {
64
+ "type": "boolean"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "/v1/chat/completions": {
72
+ "post": {
73
+ "security": [
74
+ {
75
+ "BearerAuth": []
76
+ }
77
+ ],
78
+ "description": "支持 JSON 与 SSE、图片输入和函数工具。",
79
+ "consumes": [
80
+ "application/json"
81
+ ],
82
+ "produces": [
83
+ "application/json"
84
+ ],
85
+ "tags": [
86
+ "Chat"
87
+ ],
88
+ "summary": "创建 Chat Completion",
89
+ "parameters": [
90
+ {
91
+ "description": "请求",
92
+ "name": "request",
93
+ "in": "body",
94
+ "required": true,
95
+ "schema": {
96
+ "$ref": "#/definitions/httpserver.SwaggerChatRequest"
97
+ }
98
+ }
99
+ ],
100
+ "responses": {
101
+ "200": {
102
+ "description": "OK",
103
+ "schema": {
104
+ "type": "object",
105
+ "additionalProperties": true
106
+ }
107
+ },
108
+ "400": {
109
+ "description": "Bad Request",
110
+ "schema": {
111
+ "type": "object",
112
+ "additionalProperties": true
113
+ }
114
+ }
115
+ }
116
+ }
117
+ },
118
+ "/v1/images/edits": {
119
+ "post": {
120
+ "security": [
121
+ {
122
+ "BearerAuth": []
123
+ }
124
+ ],
125
+ "consumes": [
126
+ "application/json"
127
+ ],
128
+ "produces": [
129
+ "application/json"
130
+ ],
131
+ "tags": [
132
+ "Images"
133
+ ],
134
+ "summary": "编辑图片",
135
+ "parameters": [
136
+ {
137
+ "description": "请求",
138
+ "name": "request",
139
+ "in": "body",
140
+ "required": true,
141
+ "schema": {
142
+ "$ref": "#/definitions/httpserver.SwaggerImageEditRequest"
143
+ }
144
+ }
145
+ ],
146
+ "responses": {
147
+ "200": {
148
+ "description": "OK",
149
+ "schema": {
150
+ "type": "object",
151
+ "additionalProperties": true
152
+ }
153
+ },
154
+ "400": {
155
+ "description": "Bad Request",
156
+ "schema": {
157
+ "type": "object",
158
+ "additionalProperties": true
159
+ }
160
+ }
161
+ }
162
+ }
163
+ },
164
+ "/v1/images/generations": {
165
+ "post": {
166
+ "security": [
167
+ {
168
+ "BearerAuth": []
169
+ }
170
+ ],
171
+ "consumes": [
172
+ "application/json"
173
+ ],
174
+ "produces": [
175
+ "application/json"
176
+ ],
177
+ "tags": [
178
+ "Images"
179
+ ],
180
+ "summary": "生成图片",
181
+ "parameters": [
182
+ {
183
+ "description": "请求",
184
+ "name": "request",
185
+ "in": "body",
186
+ "required": true,
187
+ "schema": {
188
+ "$ref": "#/definitions/httpserver.SwaggerImageGenerationRequest"
189
+ }
190
+ }
191
+ ],
192
+ "responses": {
193
+ "200": {
194
+ "description": "OK",
195
+ "schema": {
196
+ "type": "object",
197
+ "additionalProperties": true
198
+ }
199
+ },
200
+ "400": {
201
+ "description": "Bad Request",
202
+ "schema": {
203
+ "type": "object",
204
+ "additionalProperties": true
205
+ }
206
+ }
207
+ }
208
+ }
209
+ },
210
+ "/v1/media/images/{asset_id}": {
211
+ "get": {
212
+ "produces": [
213
+ "image/png"
214
+ ],
215
+ "tags": [
216
+ "Images"
217
+ ],
218
+ "summary": "获取归档图片",
219
+ "parameters": [
220
+ {
221
+ "type": "string",
222
+ "description": "Asset ID",
223
+ "name": "asset_id",
224
+ "in": "path",
225
+ "required": true
226
+ }
227
+ ],
228
+ "responses": {
229
+ "200": {
230
+ "description": "OK",
231
+ "schema": {
232
+ "type": "file"
233
+ }
234
+ },
235
+ "404": {
236
+ "description": "Not Found"
237
+ }
238
+ }
239
+ }
240
+ },
241
+ "/v1/messages": {
242
+ "post": {
243
+ "security": [
244
+ {
245
+ "BearerAuth": []
246
+ }
247
+ ],
248
+ "consumes": [
249
+ "application/json"
250
+ ],
251
+ "produces": [
252
+ "application/json"
253
+ ],
254
+ "tags": [
255
+ "Messages"
256
+ ],
257
+ "summary": "创建 Anthropic Message",
258
+ "parameters": [
259
+ {
260
+ "type": "string",
261
+ "default": "2023-06-01",
262
+ "description": "Anthropic API version",
263
+ "name": "anthropic-version",
264
+ "in": "header",
265
+ "required": true
266
+ },
267
+ {
268
+ "description": "请求",
269
+ "name": "request",
270
+ "in": "body",
271
+ "required": true,
272
+ "schema": {
273
+ "$ref": "#/definitions/httpserver.SwaggerMessagesRequest"
274
+ }
275
+ }
276
+ ],
277
+ "responses": {
278
+ "200": {
279
+ "description": "OK",
280
+ "schema": {
281
+ "type": "object",
282
+ "additionalProperties": true
283
+ }
284
+ },
285
+ "400": {
286
+ "description": "Bad Request",
287
+ "schema": {
288
+ "type": "object",
289
+ "additionalProperties": true
290
+ }
291
+ }
292
+ }
293
+ }
294
+ },
295
+ "/v1/models": {
296
+ "get": {
297
+ "security": [
298
+ {
299
+ "BearerAuth": []
300
+ }
301
+ ],
302
+ "produces": [
303
+ "application/json"
304
+ ],
305
+ "tags": [
306
+ "Models"
307
+ ],
308
+ "summary": "获取可用模型",
309
+ "responses": {
310
+ "200": {
311
+ "description": "OK",
312
+ "schema": {
313
+ "type": "object",
314
+ "additionalProperties": true
315
+ }
316
+ },
317
+ "401": {
318
+ "description": "Unauthorized",
319
+ "schema": {
320
+ "type": "object",
321
+ "additionalProperties": true
322
+ }
323
+ }
324
+ }
325
+ }
326
+ },
327
+ "/v1/responses": {
328
+ "post": {
329
+ "security": [
330
+ {
331
+ "BearerAuth": []
332
+ }
333
+ ],
334
+ "description": "支持 JSON 与 SSE;stream=true 时返回 text/event-stream。",
335
+ "consumes": [
336
+ "application/json"
337
+ ],
338
+ "produces": [
339
+ "application/json"
340
+ ],
341
+ "tags": [
342
+ "Responses"
343
+ ],
344
+ "summary": "创建 Response",
345
+ "parameters": [
346
+ {
347
+ "description": "请求",
348
+ "name": "request",
349
+ "in": "body",
350
+ "required": true,
351
+ "schema": {
352
+ "$ref": "#/definitions/httpserver.SwaggerResponsesRequest"
353
+ }
354
+ }
355
+ ],
356
+ "responses": {
357
+ "200": {
358
+ "description": "OK",
359
+ "schema": {
360
+ "type": "object",
361
+ "additionalProperties": true
362
+ }
363
+ },
364
+ "400": {
365
+ "description": "Bad Request",
366
+ "schema": {
367
+ "type": "object",
368
+ "additionalProperties": true
369
+ }
370
+ },
371
+ "401": {
372
+ "description": "Unauthorized",
373
+ "schema": {
374
+ "type": "object",
375
+ "additionalProperties": true
376
+ }
377
+ }
378
+ }
379
+ }
380
+ },
381
+ "/v1/responses/compact": {
382
+ "post": {
383
+ "security": [
384
+ {
385
+ "BearerAuth": []
386
+ }
387
+ ],
388
+ "consumes": [
389
+ "application/json"
390
+ ],
391
+ "produces": [
392
+ "application/json"
393
+ ],
394
+ "tags": [
395
+ "Responses"
396
+ ],
397
+ "summary": "压缩 Response 上下文",
398
+ "parameters": [
399
+ {
400
+ "description": "请求",
401
+ "name": "request",
402
+ "in": "body",
403
+ "required": true,
404
+ "schema": {
405
+ "$ref": "#/definitions/httpserver.SwaggerResponsesRequest"
406
+ }
407
+ }
408
+ ],
409
+ "responses": {
410
+ "200": {
411
+ "description": "OK",
412
+ "schema": {
413
+ "type": "object",
414
+ "additionalProperties": true
415
+ }
416
+ }
417
+ }
418
+ }
419
+ },
420
+ "/v1/responses/{response_id}": {
421
+ "get": {
422
+ "security": [
423
+ {
424
+ "BearerAuth": []
425
+ }
426
+ ],
427
+ "produces": [
428
+ "application/json"
429
+ ],
430
+ "tags": [
431
+ "Responses"
432
+ ],
433
+ "summary": "查询 Response",
434
+ "parameters": [
435
+ {
436
+ "type": "string",
437
+ "description": "Response ID",
438
+ "name": "response_id",
439
+ "in": "path",
440
+ "required": true
441
+ }
442
+ ],
443
+ "responses": {
444
+ "200": {
445
+ "description": "OK",
446
+ "schema": {
447
+ "type": "object",
448
+ "additionalProperties": true
449
+ }
450
+ },
451
+ "404": {
452
+ "description": "Not Found",
453
+ "schema": {
454
+ "type": "object",
455
+ "additionalProperties": true
456
+ }
457
+ }
458
+ }
459
+ },
460
+ "delete": {
461
+ "security": [
462
+ {
463
+ "BearerAuth": []
464
+ }
465
+ ],
466
+ "produces": [
467
+ "application/json"
468
+ ],
469
+ "tags": [
470
+ "Responses"
471
+ ],
472
+ "summary": "删除 Response",
473
+ "parameters": [
474
+ {
475
+ "type": "string",
476
+ "description": "Response ID",
477
+ "name": "response_id",
478
+ "in": "path",
479
+ "required": true
480
+ }
481
+ ],
482
+ "responses": {
483
+ "200": {
484
+ "description": "OK",
485
+ "schema": {
486
+ "type": "object",
487
+ "additionalProperties": true
488
+ }
489
+ },
490
+ "404": {
491
+ "description": "Not Found",
492
+ "schema": {
493
+ "type": "object",
494
+ "additionalProperties": true
495
+ }
496
+ }
497
+ }
498
+ }
499
+ },
500
+ "/v1/videos/generations": {
501
+ "post": {
502
+ "security": [
503
+ {
504
+ "BearerAuth": []
505
+ }
506
+ ],
507
+ "consumes": [
508
+ "application/json"
509
+ ],
510
+ "produces": [
511
+ "application/json"
512
+ ],
513
+ "tags": [
514
+ "Videos"
515
+ ],
516
+ "summary": "创建异步视频任务",
517
+ "parameters": [
518
+ {
519
+ "description": "请求",
520
+ "name": "request",
521
+ "in": "body",
522
+ "required": true,
523
+ "schema": {
524
+ "$ref": "#/definitions/httpserver.SwaggerVideoGenerationRequest"
525
+ }
526
+ }
527
+ ],
528
+ "responses": {
529
+ "200": {
530
+ "description": "OK",
531
+ "schema": {
532
+ "type": "object",
533
+ "additionalProperties": {
534
+ "type": "string"
535
+ }
536
+ }
537
+ },
538
+ "400": {
539
+ "description": "Bad Request",
540
+ "schema": {
541
+ "type": "object",
542
+ "additionalProperties": true
543
+ }
544
+ }
545
+ }
546
+ }
547
+ },
548
+ "/v1/videos/{request_id}": {
549
+ "get": {
550
+ "security": [
551
+ {
552
+ "BearerAuth": []
553
+ }
554
+ ],
555
+ "produces": [
556
+ "application/json"
557
+ ],
558
+ "tags": [
559
+ "Videos"
560
+ ],
561
+ "summary": "查询异步视频任务",
562
+ "parameters": [
563
+ {
564
+ "type": "string",
565
+ "description": "Request ID",
566
+ "name": "request_id",
567
+ "in": "path",
568
+ "required": true
569
+ }
570
+ ],
571
+ "responses": {
572
+ "200": {
573
+ "description": "OK",
574
+ "schema": {
575
+ "type": "object",
576
+ "additionalProperties": true
577
+ }
578
+ },
579
+ "404": {
580
+ "description": "Not Found",
581
+ "schema": {
582
+ "type": "object",
583
+ "additionalProperties": true
584
+ }
585
+ }
586
+ }
587
+ }
588
+ }
589
+ },
590
+ "definitions": {
591
+ "httpserver.SwaggerChatRequest": {
592
+ "type": "object",
593
+ "properties": {
594
+ "messages": {
595
+ "type": "array",
596
+ "items": {
597
+ "$ref": "#/definitions/httpserver.SwaggerMessage"
598
+ }
599
+ },
600
+ "model": {
601
+ "type": "string",
602
+ "example": "grok-chat-fast"
603
+ },
604
+ "stream": {
605
+ "type": "boolean",
606
+ "example": false
607
+ }
608
+ }
609
+ },
610
+ "httpserver.SwaggerImageEditRequest": {
611
+ "type": "object",
612
+ "properties": {
613
+ "aspect_ratio": {
614
+ "type": "string",
615
+ "example": "1:1"
616
+ },
617
+ "image": {
618
+ "$ref": "#/definitions/httpserver.SwaggerImageReference"
619
+ },
620
+ "model": {
621
+ "type": "string",
622
+ "example": "grok-imagine-image-edit"
623
+ },
624
+ "n": {
625
+ "type": "integer",
626
+ "example": 1
627
+ },
628
+ "partial_images": {
629
+ "type": "integer",
630
+ "example": 0
631
+ },
632
+ "prompt": {
633
+ "type": "string",
634
+ "example": "Change the background to black"
635
+ },
636
+ "resolution": {
637
+ "type": "string",
638
+ "example": "1k"
639
+ },
640
+ "response_format": {
641
+ "type": "string",
642
+ "example": "url"
643
+ },
644
+ "size": {
645
+ "type": "string",
646
+ "example": "1024x1024"
647
+ },
648
+ "stream": {
649
+ "type": "boolean",
650
+ "example": false
651
+ }
652
+ }
653
+ },
654
+ "httpserver.SwaggerImageGenerationRequest": {
655
+ "type": "object",
656
+ "properties": {
657
+ "aspect_ratio": {
658
+ "type": "string",
659
+ "example": "16:9"
660
+ },
661
+ "model": {
662
+ "type": "string",
663
+ "example": "grok-imagine-image-quality"
664
+ },
665
+ "n": {
666
+ "type": "integer",
667
+ "example": 1
668
+ },
669
+ "partial_images": {
670
+ "type": "integer",
671
+ "example": 0
672
+ },
673
+ "prompt": {
674
+ "type": "string",
675
+ "example": "A cinematic city at night"
676
+ },
677
+ "resolution": {
678
+ "type": "string",
679
+ "example": "2k"
680
+ },
681
+ "response_format": {
682
+ "type": "string",
683
+ "example": "url"
684
+ },
685
+ "stream": {
686
+ "type": "boolean",
687
+ "example": false
688
+ }
689
+ }
690
+ },
691
+ "httpserver.SwaggerImageReference": {
692
+ "type": "object",
693
+ "properties": {
694
+ "url": {
695
+ "type": "string",
696
+ "example": "https://example.com/source.png"
697
+ }
698
+ }
699
+ },
700
+ "httpserver.SwaggerMessage": {
701
+ "type": "object",
702
+ "properties": {
703
+ "content": {},
704
+ "role": {
705
+ "type": "string",
706
+ "example": "user"
707
+ }
708
+ }
709
+ },
710
+ "httpserver.SwaggerMessagesRequest": {
711
+ "type": "object",
712
+ "properties": {
713
+ "max_tokens": {
714
+ "type": "integer",
715
+ "example": 1024
716
+ },
717
+ "messages": {
718
+ "type": "array",
719
+ "items": {
720
+ "$ref": "#/definitions/httpserver.SwaggerMessage"
721
+ }
722
+ },
723
+ "model": {
724
+ "type": "string",
725
+ "example": "grok-chat-expert"
726
+ },
727
+ "stream": {
728
+ "type": "boolean",
729
+ "example": false
730
+ }
731
+ }
732
+ },
733
+ "httpserver.SwaggerResponsesRequest": {
734
+ "type": "object",
735
+ "properties": {
736
+ "input": {},
737
+ "model": {
738
+ "type": "string",
739
+ "example": "grok-chat-auto"
740
+ },
741
+ "previous_response_id": {
742
+ "type": "string"
743
+ },
744
+ "prompt_cache_key": {
745
+ "type": "string"
746
+ },
747
+ "store": {
748
+ "type": "boolean",
749
+ "example": false
750
+ },
751
+ "stream": {
752
+ "type": "boolean",
753
+ "example": false
754
+ }
755
+ }
756
+ },
757
+ "httpserver.SwaggerVideoGenerationRequest": {
758
+ "type": "object",
759
+ "properties": {
760
+ "aspect_ratio": {
761
+ "type": "string",
762
+ "example": "16:9"
763
+ },
764
+ "duration": {
765
+ "type": "integer",
766
+ "example": 8
767
+ },
768
+ "model": {
769
+ "type": "string",
770
+ "example": "grok-imagine-video"
771
+ },
772
+ "prompt": {
773
+ "type": "string",
774
+ "example": "A cinematic tracking shot in the rain"
775
+ },
776
+ "resolution": {
777
+ "type": "string",
778
+ "example": "720p"
779
+ }
780
+ }
781
+ }
782
+ },
783
+ "securityDefinitions": {
784
+ "BearerAuth": {
785
+ "description": "使用 \"Bearer g2a_xxx_xxx\"。",
786
+ "type": "apiKey",
787
+ "name": "Authorization",
788
+ "in": "header"
789
+ }
790
+ }
791
+ }`
792
+
793
+ // SwaggerInfo holds exported Swagger Info so clients can modify it
794
+ var SwaggerInfo = &swag.Spec{
795
+ Version: "1.0",
796
+ Host: "",
797
+ BasePath: "/",
798
+ Schemes: []string{},
799
+ Title: "Grok2API",
800
+ Description: "Grok Build 与 Grok Web 多账号 API 网关。",
801
+ InfoInstanceName: "swagger",
802
+ SwaggerTemplate: docTemplate,
803
+ LeftDelim: "{{",
804
+ RightDelim: "}}",
805
+ }
806
+
807
+ func init() {
808
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
809
+ }
backend/docs/swagger.json ADDED
@@ -0,0 +1,784 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "swagger": "2.0",
3
+ "info": {
4
+ "description": "Grok Build 与 Grok Web 多账号 API 网关。",
5
+ "title": "Grok2API",
6
+ "contact": {},
7
+ "version": "1.0"
8
+ },
9
+ "basePath": "/",
10
+ "paths": {
11
+ "/healthz": {
12
+ "get": {
13
+ "produces": [
14
+ "application/json"
15
+ ],
16
+ "tags": [
17
+ "System"
18
+ ],
19
+ "summary": "存活检查",
20
+ "responses": {
21
+ "200": {
22
+ "description": "OK",
23
+ "schema": {
24
+ "type": "object",
25
+ "additionalProperties": {
26
+ "type": "boolean"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ },
33
+ "/readyz": {
34
+ "get": {
35
+ "produces": [
36
+ "application/json"
37
+ ],
38
+ "tags": [
39
+ "System"
40
+ ],
41
+ "summary": "就绪检查",
42
+ "responses": {
43
+ "200": {
44
+ "description": "OK",
45
+ "schema": {
46
+ "type": "object",
47
+ "additionalProperties": {
48
+ "type": "boolean"
49
+ }
50
+ }
51
+ },
52
+ "503": {
53
+ "description": "Service Unavailable",
54
+ "schema": {
55
+ "type": "object",
56
+ "additionalProperties": {
57
+ "type": "boolean"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "/v1/chat/completions": {
65
+ "post": {
66
+ "security": [
67
+ {
68
+ "BearerAuth": []
69
+ }
70
+ ],
71
+ "description": "支持 JSON 与 SSE、图片输入和函数工具。",
72
+ "consumes": [
73
+ "application/json"
74
+ ],
75
+ "produces": [
76
+ "application/json"
77
+ ],
78
+ "tags": [
79
+ "Chat"
80
+ ],
81
+ "summary": "创建 Chat Completion",
82
+ "parameters": [
83
+ {
84
+ "description": "请求",
85
+ "name": "request",
86
+ "in": "body",
87
+ "required": true,
88
+ "schema": {
89
+ "$ref": "#/definitions/httpserver.SwaggerChatRequest"
90
+ }
91
+ }
92
+ ],
93
+ "responses": {
94
+ "200": {
95
+ "description": "OK",
96
+ "schema": {
97
+ "type": "object",
98
+ "additionalProperties": true
99
+ }
100
+ },
101
+ "400": {
102
+ "description": "Bad Request",
103
+ "schema": {
104
+ "type": "object",
105
+ "additionalProperties": true
106
+ }
107
+ }
108
+ }
109
+ }
110
+ },
111
+ "/v1/images/edits": {
112
+ "post": {
113
+ "security": [
114
+ {
115
+ "BearerAuth": []
116
+ }
117
+ ],
118
+ "consumes": [
119
+ "application/json"
120
+ ],
121
+ "produces": [
122
+ "application/json"
123
+ ],
124
+ "tags": [
125
+ "Images"
126
+ ],
127
+ "summary": "编辑图片",
128
+ "parameters": [
129
+ {
130
+ "description": "请求",
131
+ "name": "request",
132
+ "in": "body",
133
+ "required": true,
134
+ "schema": {
135
+ "$ref": "#/definitions/httpserver.SwaggerImageEditRequest"
136
+ }
137
+ }
138
+ ],
139
+ "responses": {
140
+ "200": {
141
+ "description": "OK",
142
+ "schema": {
143
+ "type": "object",
144
+ "additionalProperties": true
145
+ }
146
+ },
147
+ "400": {
148
+ "description": "Bad Request",
149
+ "schema": {
150
+ "type": "object",
151
+ "additionalProperties": true
152
+ }
153
+ }
154
+ }
155
+ }
156
+ },
157
+ "/v1/images/generations": {
158
+ "post": {
159
+ "security": [
160
+ {
161
+ "BearerAuth": []
162
+ }
163
+ ],
164
+ "consumes": [
165
+ "application/json"
166
+ ],
167
+ "produces": [
168
+ "application/json"
169
+ ],
170
+ "tags": [
171
+ "Images"
172
+ ],
173
+ "summary": "生成图片",
174
+ "parameters": [
175
+ {
176
+ "description": "请求",
177
+ "name": "request",
178
+ "in": "body",
179
+ "required": true,
180
+ "schema": {
181
+ "$ref": "#/definitions/httpserver.SwaggerImageGenerationRequest"
182
+ }
183
+ }
184
+ ],
185
+ "responses": {
186
+ "200": {
187
+ "description": "OK",
188
+ "schema": {
189
+ "type": "object",
190
+ "additionalProperties": true
191
+ }
192
+ },
193
+ "400": {
194
+ "description": "Bad Request",
195
+ "schema": {
196
+ "type": "object",
197
+ "additionalProperties": true
198
+ }
199
+ }
200
+ }
201
+ }
202
+ },
203
+ "/v1/media/images/{asset_id}": {
204
+ "get": {
205
+ "produces": [
206
+ "image/png"
207
+ ],
208
+ "tags": [
209
+ "Images"
210
+ ],
211
+ "summary": "获取归档图片",
212
+ "parameters": [
213
+ {
214
+ "type": "string",
215
+ "description": "Asset ID",
216
+ "name": "asset_id",
217
+ "in": "path",
218
+ "required": true
219
+ }
220
+ ],
221
+ "responses": {
222
+ "200": {
223
+ "description": "OK",
224
+ "schema": {
225
+ "type": "file"
226
+ }
227
+ },
228
+ "404": {
229
+ "description": "Not Found"
230
+ }
231
+ }
232
+ }
233
+ },
234
+ "/v1/messages": {
235
+ "post": {
236
+ "security": [
237
+ {
238
+ "BearerAuth": []
239
+ }
240
+ ],
241
+ "consumes": [
242
+ "application/json"
243
+ ],
244
+ "produces": [
245
+ "application/json"
246
+ ],
247
+ "tags": [
248
+ "Messages"
249
+ ],
250
+ "summary": "创建 Anthropic Message",
251
+ "parameters": [
252
+ {
253
+ "type": "string",
254
+ "default": "2023-06-01",
255
+ "description": "Anthropic API version",
256
+ "name": "anthropic-version",
257
+ "in": "header",
258
+ "required": true
259
+ },
260
+ {
261
+ "description": "请求",
262
+ "name": "request",
263
+ "in": "body",
264
+ "required": true,
265
+ "schema": {
266
+ "$ref": "#/definitions/httpserver.SwaggerMessagesRequest"
267
+ }
268
+ }
269
+ ],
270
+ "responses": {
271
+ "200": {
272
+ "description": "OK",
273
+ "schema": {
274
+ "type": "object",
275
+ "additionalProperties": true
276
+ }
277
+ },
278
+ "400": {
279
+ "description": "Bad Request",
280
+ "schema": {
281
+ "type": "object",
282
+ "additionalProperties": true
283
+ }
284
+ }
285
+ }
286
+ }
287
+ },
288
+ "/v1/models": {
289
+ "get": {
290
+ "security": [
291
+ {
292
+ "BearerAuth": []
293
+ }
294
+ ],
295
+ "produces": [
296
+ "application/json"
297
+ ],
298
+ "tags": [
299
+ "Models"
300
+ ],
301
+ "summary": "获取可用模型",
302
+ "responses": {
303
+ "200": {
304
+ "description": "OK",
305
+ "schema": {
306
+ "type": "object",
307
+ "additionalProperties": true
308
+ }
309
+ },
310
+ "401": {
311
+ "description": "Unauthorized",
312
+ "schema": {
313
+ "type": "object",
314
+ "additionalProperties": true
315
+ }
316
+ }
317
+ }
318
+ }
319
+ },
320
+ "/v1/responses": {
321
+ "post": {
322
+ "security": [
323
+ {
324
+ "BearerAuth": []
325
+ }
326
+ ],
327
+ "description": "支持 JSON 与 SSE;stream=true 时返回 text/event-stream。",
328
+ "consumes": [
329
+ "application/json"
330
+ ],
331
+ "produces": [
332
+ "application/json"
333
+ ],
334
+ "tags": [
335
+ "Responses"
336
+ ],
337
+ "summary": "创建 Response",
338
+ "parameters": [
339
+ {
340
+ "description": "请求",
341
+ "name": "request",
342
+ "in": "body",
343
+ "required": true,
344
+ "schema": {
345
+ "$ref": "#/definitions/httpserver.SwaggerResponsesRequest"
346
+ }
347
+ }
348
+ ],
349
+ "responses": {
350
+ "200": {
351
+ "description": "OK",
352
+ "schema": {
353
+ "type": "object",
354
+ "additionalProperties": true
355
+ }
356
+ },
357
+ "400": {
358
+ "description": "Bad Request",
359
+ "schema": {
360
+ "type": "object",
361
+ "additionalProperties": true
362
+ }
363
+ },
364
+ "401": {
365
+ "description": "Unauthorized",
366
+ "schema": {
367
+ "type": "object",
368
+ "additionalProperties": true
369
+ }
370
+ }
371
+ }
372
+ }
373
+ },
374
+ "/v1/responses/compact": {
375
+ "post": {
376
+ "security": [
377
+ {
378
+ "BearerAuth": []
379
+ }
380
+ ],
381
+ "consumes": [
382
+ "application/json"
383
+ ],
384
+ "produces": [
385
+ "application/json"
386
+ ],
387
+ "tags": [
388
+ "Responses"
389
+ ],
390
+ "summary": "压缩 Response 上下文",
391
+ "parameters": [
392
+ {
393
+ "description": "请求",
394
+ "name": "request",
395
+ "in": "body",
396
+ "required": true,
397
+ "schema": {
398
+ "$ref": "#/definitions/httpserver.SwaggerResponsesRequest"
399
+ }
400
+ }
401
+ ],
402
+ "responses": {
403
+ "200": {
404
+ "description": "OK",
405
+ "schema": {
406
+ "type": "object",
407
+ "additionalProperties": true
408
+ }
409
+ }
410
+ }
411
+ }
412
+ },
413
+ "/v1/responses/{response_id}": {
414
+ "get": {
415
+ "security": [
416
+ {
417
+ "BearerAuth": []
418
+ }
419
+ ],
420
+ "produces": [
421
+ "application/json"
422
+ ],
423
+ "tags": [
424
+ "Responses"
425
+ ],
426
+ "summary": "查询 Response",
427
+ "parameters": [
428
+ {
429
+ "type": "string",
430
+ "description": "Response ID",
431
+ "name": "response_id",
432
+ "in": "path",
433
+ "required": true
434
+ }
435
+ ],
436
+ "responses": {
437
+ "200": {
438
+ "description": "OK",
439
+ "schema": {
440
+ "type": "object",
441
+ "additionalProperties": true
442
+ }
443
+ },
444
+ "404": {
445
+ "description": "Not Found",
446
+ "schema": {
447
+ "type": "object",
448
+ "additionalProperties": true
449
+ }
450
+ }
451
+ }
452
+ },
453
+ "delete": {
454
+ "security": [
455
+ {
456
+ "BearerAuth": []
457
+ }
458
+ ],
459
+ "produces": [
460
+ "application/json"
461
+ ],
462
+ "tags": [
463
+ "Responses"
464
+ ],
465
+ "summary": "删除 Response",
466
+ "parameters": [
467
+ {
468
+ "type": "string",
469
+ "description": "Response ID",
470
+ "name": "response_id",
471
+ "in": "path",
472
+ "required": true
473
+ }
474
+ ],
475
+ "responses": {
476
+ "200": {
477
+ "description": "OK",
478
+ "schema": {
479
+ "type": "object",
480
+ "additionalProperties": true
481
+ }
482
+ },
483
+ "404": {
484
+ "description": "Not Found",
485
+ "schema": {
486
+ "type": "object",
487
+ "additionalProperties": true
488
+ }
489
+ }
490
+ }
491
+ }
492
+ },
493
+ "/v1/videos/generations": {
494
+ "post": {
495
+ "security": [
496
+ {
497
+ "BearerAuth": []
498
+ }
499
+ ],
500
+ "consumes": [
501
+ "application/json"
502
+ ],
503
+ "produces": [
504
+ "application/json"
505
+ ],
506
+ "tags": [
507
+ "Videos"
508
+ ],
509
+ "summary": "创建异步视频任务",
510
+ "parameters": [
511
+ {
512
+ "description": "请求",
513
+ "name": "request",
514
+ "in": "body",
515
+ "required": true,
516
+ "schema": {
517
+ "$ref": "#/definitions/httpserver.SwaggerVideoGenerationRequest"
518
+ }
519
+ }
520
+ ],
521
+ "responses": {
522
+ "200": {
523
+ "description": "OK",
524
+ "schema": {
525
+ "type": "object",
526
+ "additionalProperties": {
527
+ "type": "string"
528
+ }
529
+ }
530
+ },
531
+ "400": {
532
+ "description": "Bad Request",
533
+ "schema": {
534
+ "type": "object",
535
+ "additionalProperties": true
536
+ }
537
+ }
538
+ }
539
+ }
540
+ },
541
+ "/v1/videos/{request_id}": {
542
+ "get": {
543
+ "security": [
544
+ {
545
+ "BearerAuth": []
546
+ }
547
+ ],
548
+ "produces": [
549
+ "application/json"
550
+ ],
551
+ "tags": [
552
+ "Videos"
553
+ ],
554
+ "summary": "查询异步视频任务",
555
+ "parameters": [
556
+ {
557
+ "type": "string",
558
+ "description": "Request ID",
559
+ "name": "request_id",
560
+ "in": "path",
561
+ "required": true
562
+ }
563
+ ],
564
+ "responses": {
565
+ "200": {
566
+ "description": "OK",
567
+ "schema": {
568
+ "type": "object",
569
+ "additionalProperties": true
570
+ }
571
+ },
572
+ "404": {
573
+ "description": "Not Found",
574
+ "schema": {
575
+ "type": "object",
576
+ "additionalProperties": true
577
+ }
578
+ }
579
+ }
580
+ }
581
+ }
582
+ },
583
+ "definitions": {
584
+ "httpserver.SwaggerChatRequest": {
585
+ "type": "object",
586
+ "properties": {
587
+ "messages": {
588
+ "type": "array",
589
+ "items": {
590
+ "$ref": "#/definitions/httpserver.SwaggerMessage"
591
+ }
592
+ },
593
+ "model": {
594
+ "type": "string",
595
+ "example": "grok-chat-fast"
596
+ },
597
+ "stream": {
598
+ "type": "boolean",
599
+ "example": false
600
+ }
601
+ }
602
+ },
603
+ "httpserver.SwaggerImageEditRequest": {
604
+ "type": "object",
605
+ "properties": {
606
+ "aspect_ratio": {
607
+ "type": "string",
608
+ "example": "1:1"
609
+ },
610
+ "image": {
611
+ "$ref": "#/definitions/httpserver.SwaggerImageReference"
612
+ },
613
+ "model": {
614
+ "type": "string",
615
+ "example": "grok-imagine-image-edit"
616
+ },
617
+ "n": {
618
+ "type": "integer",
619
+ "example": 1
620
+ },
621
+ "partial_images": {
622
+ "type": "integer",
623
+ "example": 0
624
+ },
625
+ "prompt": {
626
+ "type": "string",
627
+ "example": "Change the background to black"
628
+ },
629
+ "resolution": {
630
+ "type": "string",
631
+ "example": "1k"
632
+ },
633
+ "response_format": {
634
+ "type": "string",
635
+ "example": "url"
636
+ },
637
+ "size": {
638
+ "type": "string",
639
+ "example": "1024x1024"
640
+ },
641
+ "stream": {
642
+ "type": "boolean",
643
+ "example": false
644
+ }
645
+ }
646
+ },
647
+ "httpserver.SwaggerImageGenerationRequest": {
648
+ "type": "object",
649
+ "properties": {
650
+ "aspect_ratio": {
651
+ "type": "string",
652
+ "example": "16:9"
653
+ },
654
+ "model": {
655
+ "type": "string",
656
+ "example": "grok-imagine-image-quality"
657
+ },
658
+ "n": {
659
+ "type": "integer",
660
+ "example": 1
661
+ },
662
+ "partial_images": {
663
+ "type": "integer",
664
+ "example": 0
665
+ },
666
+ "prompt": {
667
+ "type": "string",
668
+ "example": "A cinematic city at night"
669
+ },
670
+ "resolution": {
671
+ "type": "string",
672
+ "example": "2k"
673
+ },
674
+ "response_format": {
675
+ "type": "string",
676
+ "example": "url"
677
+ },
678
+ "stream": {
679
+ "type": "boolean",
680
+ "example": false
681
+ }
682
+ }
683
+ },
684
+ "httpserver.SwaggerImageReference": {
685
+ "type": "object",
686
+ "properties": {
687
+ "url": {
688
+ "type": "string",
689
+ "example": "https://example.com/source.png"
690
+ }
691
+ }
692
+ },
693
+ "httpserver.SwaggerMessage": {
694
+ "type": "object",
695
+ "properties": {
696
+ "content": {},
697
+ "role": {
698
+ "type": "string",
699
+ "example": "user"
700
+ }
701
+ }
702
+ },
703
+ "httpserver.SwaggerMessagesRequest": {
704
+ "type": "object",
705
+ "properties": {
706
+ "max_tokens": {
707
+ "type": "integer",
708
+ "example": 1024
709
+ },
710
+ "messages": {
711
+ "type": "array",
712
+ "items": {
713
+ "$ref": "#/definitions/httpserver.SwaggerMessage"
714
+ }
715
+ },
716
+ "model": {
717
+ "type": "string",
718
+ "example": "grok-chat-expert"
719
+ },
720
+ "stream": {
721
+ "type": "boolean",
722
+ "example": false
723
+ }
724
+ }
725
+ },
726
+ "httpserver.SwaggerResponsesRequest": {
727
+ "type": "object",
728
+ "properties": {
729
+ "input": {},
730
+ "model": {
731
+ "type": "string",
732
+ "example": "grok-chat-auto"
733
+ },
734
+ "previous_response_id": {
735
+ "type": "string"
736
+ },
737
+ "prompt_cache_key": {
738
+ "type": "string"
739
+ },
740
+ "store": {
741
+ "type": "boolean",
742
+ "example": false
743
+ },
744
+ "stream": {
745
+ "type": "boolean",
746
+ "example": false
747
+ }
748
+ }
749
+ },
750
+ "httpserver.SwaggerVideoGenerationRequest": {
751
+ "type": "object",
752
+ "properties": {
753
+ "aspect_ratio": {
754
+ "type": "string",
755
+ "example": "16:9"
756
+ },
757
+ "duration": {
758
+ "type": "integer",
759
+ "example": 8
760
+ },
761
+ "model": {
762
+ "type": "string",
763
+ "example": "grok-imagine-video"
764
+ },
765
+ "prompt": {
766
+ "type": "string",
767
+ "example": "A cinematic tracking shot in the rain"
768
+ },
769
+ "resolution": {
770
+ "type": "string",
771
+ "example": "720p"
772
+ }
773
+ }
774
+ }
775
+ },
776
+ "securityDefinitions": {
777
+ "BearerAuth": {
778
+ "description": "使用 \"Bearer g2a_xxx_xxx\"。",
779
+ "type": "apiKey",
780
+ "name": "Authorization",
781
+ "in": "header"
782
+ }
783
+ }
784
+ }
backend/docs/swagger.yaml ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ basePath: /
2
+ definitions:
3
+ httpserver.SwaggerChatRequest:
4
+ properties:
5
+ messages:
6
+ items:
7
+ $ref: '#/definitions/httpserver.SwaggerMessage'
8
+ type: array
9
+ model:
10
+ example: grok-chat-fast
11
+ type: string
12
+ stream:
13
+ example: false
14
+ type: boolean
15
+ type: object
16
+ httpserver.SwaggerImageEditRequest:
17
+ properties:
18
+ aspect_ratio:
19
+ example: "1:1"
20
+ type: string
21
+ image:
22
+ $ref: '#/definitions/httpserver.SwaggerImageReference'
23
+ model:
24
+ example: grok-imagine-image-edit
25
+ type: string
26
+ "n":
27
+ example: 1
28
+ type: integer
29
+ partial_images:
30
+ example: 0
31
+ type: integer
32
+ prompt:
33
+ example: Change the background to black
34
+ type: string
35
+ resolution:
36
+ example: 1k
37
+ type: string
38
+ response_format:
39
+ example: url
40
+ type: string
41
+ size:
42
+ example: 1024x1024
43
+ type: string
44
+ stream:
45
+ example: false
46
+ type: boolean
47
+ type: object
48
+ httpserver.SwaggerImageGenerationRequest:
49
+ properties:
50
+ aspect_ratio:
51
+ example: "16:9"
52
+ type: string
53
+ model:
54
+ example: grok-imagine-image-quality
55
+ type: string
56
+ "n":
57
+ example: 1
58
+ type: integer
59
+ partial_images:
60
+ example: 0
61
+ type: integer
62
+ prompt:
63
+ example: A cinematic city at night
64
+ type: string
65
+ resolution:
66
+ example: 2k
67
+ type: string
68
+ response_format:
69
+ example: url
70
+ type: string
71
+ stream:
72
+ example: false
73
+ type: boolean
74
+ type: object
75
+ httpserver.SwaggerImageReference:
76
+ properties:
77
+ url:
78
+ example: https://example.com/source.png
79
+ type: string
80
+ type: object
81
+ httpserver.SwaggerMessage:
82
+ properties:
83
+ content: {}
84
+ role:
85
+ example: user
86
+ type: string
87
+ type: object
88
+ httpserver.SwaggerMessagesRequest:
89
+ properties:
90
+ max_tokens:
91
+ example: 1024
92
+ type: integer
93
+ messages:
94
+ items:
95
+ $ref: '#/definitions/httpserver.SwaggerMessage'
96
+ type: array
97
+ model:
98
+ example: grok-chat-expert
99
+ type: string
100
+ stream:
101
+ example: false
102
+ type: boolean
103
+ type: object
104
+ httpserver.SwaggerResponsesRequest:
105
+ properties:
106
+ input: {}
107
+ model:
108
+ example: grok-chat-auto
109
+ type: string
110
+ previous_response_id:
111
+ type: string
112
+ prompt_cache_key:
113
+ type: string
114
+ store:
115
+ example: false
116
+ type: boolean
117
+ stream:
118
+ example: false
119
+ type: boolean
120
+ type: object
121
+ httpserver.SwaggerVideoGenerationRequest:
122
+ properties:
123
+ aspect_ratio:
124
+ example: "16:9"
125
+ type: string
126
+ duration:
127
+ example: 8
128
+ type: integer
129
+ model:
130
+ example: grok-imagine-video
131
+ type: string
132
+ prompt:
133
+ example: A cinematic tracking shot in the rain
134
+ type: string
135
+ resolution:
136
+ example: 720p
137
+ type: string
138
+ type: object
139
+ info:
140
+ contact: {}
141
+ description: Grok Build 与 Grok Web 多账号 API 网关。
142
+ title: Grok2API
143
+ version: "1.0"
144
+ paths:
145
+ /healthz:
146
+ get:
147
+ produces:
148
+ - application/json
149
+ responses:
150
+ "200":
151
+ description: OK
152
+ schema:
153
+ additionalProperties:
154
+ type: boolean
155
+ type: object
156
+ summary: 存活检查
157
+ tags:
158
+ - System
159
+ /readyz:
160
+ get:
161
+ produces:
162
+ - application/json
163
+ responses:
164
+ "200":
165
+ description: OK
166
+ schema:
167
+ additionalProperties:
168
+ type: boolean
169
+ type: object
170
+ "503":
171
+ description: Service Unavailable
172
+ schema:
173
+ additionalProperties:
174
+ type: boolean
175
+ type: object
176
+ summary: 就绪检查
177
+ tags:
178
+ - System
179
+ /v1/chat/completions:
180
+ post:
181
+ consumes:
182
+ - application/json
183
+ description: 支持 JSON 与 SSE、图片输入和函数工具。
184
+ parameters:
185
+ - description: 请求
186
+ in: body
187
+ name: request
188
+ required: true
189
+ schema:
190
+ $ref: '#/definitions/httpserver.SwaggerChatRequest'
191
+ produces:
192
+ - application/json
193
+ responses:
194
+ "200":
195
+ description: OK
196
+ schema:
197
+ additionalProperties: true
198
+ type: object
199
+ "400":
200
+ description: Bad Request
201
+ schema:
202
+ additionalProperties: true
203
+ type: object
204
+ security:
205
+ - BearerAuth: []
206
+ summary: 创建 Chat Completion
207
+ tags:
208
+ - Chat
209
+ /v1/images/edits:
210
+ post:
211
+ consumes:
212
+ - application/json
213
+ parameters:
214
+ - description: 请求
215
+ in: body
216
+ name: request
217
+ required: true
218
+ schema:
219
+ $ref: '#/definitions/httpserver.SwaggerImageEditRequest'
220
+ produces:
221
+ - application/json
222
+ responses:
223
+ "200":
224
+ description: OK
225
+ schema:
226
+ additionalProperties: true
227
+ type: object
228
+ "400":
229
+ description: Bad Request
230
+ schema:
231
+ additionalProperties: true
232
+ type: object
233
+ security:
234
+ - BearerAuth: []
235
+ summary: 编辑图片
236
+ tags:
237
+ - Images
238
+ /v1/images/generations:
239
+ post:
240
+ consumes:
241
+ - application/json
242
+ parameters:
243
+ - description: 请求
244
+ in: body
245
+ name: request
246
+ required: true
247
+ schema:
248
+ $ref: '#/definitions/httpserver.SwaggerImageGenerationRequest'
249
+ produces:
250
+ - application/json
251
+ responses:
252
+ "200":
253
+ description: OK
254
+ schema:
255
+ additionalProperties: true
256
+ type: object
257
+ "400":
258
+ description: Bad Request
259
+ schema:
260
+ additionalProperties: true
261
+ type: object
262
+ security:
263
+ - BearerAuth: []
264
+ summary: 生成图片
265
+ tags:
266
+ - Images
267
+ /v1/media/images/{asset_id}:
268
+ get:
269
+ parameters:
270
+ - description: Asset ID
271
+ in: path
272
+ name: asset_id
273
+ required: true
274
+ type: string
275
+ produces:
276
+ - image/png
277
+ responses:
278
+ "200":
279
+ description: OK
280
+ schema:
281
+ type: file
282
+ "404":
283
+ description: Not Found
284
+ summary: 获取归档图片
285
+ tags:
286
+ - Images
287
+ /v1/messages:
288
+ post:
289
+ consumes:
290
+ - application/json
291
+ parameters:
292
+ - default: "2023-06-01"
293
+ description: Anthropic API version
294
+ in: header
295
+ name: anthropic-version
296
+ required: true
297
+ type: string
298
+ - description: 请求
299
+ in: body
300
+ name: request
301
+ required: true
302
+ schema:
303
+ $ref: '#/definitions/httpserver.SwaggerMessagesRequest'
304
+ produces:
305
+ - application/json
306
+ responses:
307
+ "200":
308
+ description: OK
309
+ schema:
310
+ additionalProperties: true
311
+ type: object
312
+ "400":
313
+ description: Bad Request
314
+ schema:
315
+ additionalProperties: true
316
+ type: object
317
+ security:
318
+ - BearerAuth: []
319
+ summary: 创建 Anthropic Message
320
+ tags:
321
+ - Messages
322
+ /v1/models:
323
+ get:
324
+ produces:
325
+ - application/json
326
+ responses:
327
+ "200":
328
+ description: OK
329
+ schema:
330
+ additionalProperties: true
331
+ type: object
332
+ "401":
333
+ description: Unauthorized
334
+ schema:
335
+ additionalProperties: true
336
+ type: object
337
+ security:
338
+ - BearerAuth: []
339
+ summary: 获取可用模型
340
+ tags:
341
+ - Models
342
+ /v1/responses:
343
+ post:
344
+ consumes:
345
+ - application/json
346
+ description: 支持 JSON 与 SSE;stream=true 时返回 text/event-stream。
347
+ parameters:
348
+ - description: 请求
349
+ in: body
350
+ name: request
351
+ required: true
352
+ schema:
353
+ $ref: '#/definitions/httpserver.SwaggerResponsesRequest'
354
+ produces:
355
+ - application/json
356
+ responses:
357
+ "200":
358
+ description: OK
359
+ schema:
360
+ additionalProperties: true
361
+ type: object
362
+ "400":
363
+ description: Bad Request
364
+ schema:
365
+ additionalProperties: true
366
+ type: object
367
+ "401":
368
+ description: Unauthorized
369
+ schema:
370
+ additionalProperties: true
371
+ type: object
372
+ security:
373
+ - BearerAuth: []
374
+ summary: 创建 Response
375
+ tags:
376
+ - Responses
377
+ /v1/responses/{response_id}:
378
+ delete:
379
+ parameters:
380
+ - description: Response ID
381
+ in: path
382
+ name: response_id
383
+ required: true
384
+ type: string
385
+ produces:
386
+ - application/json
387
+ responses:
388
+ "200":
389
+ description: OK
390
+ schema:
391
+ additionalProperties: true
392
+ type: object
393
+ "404":
394
+ description: Not Found
395
+ schema:
396
+ additionalProperties: true
397
+ type: object
398
+ security:
399
+ - BearerAuth: []
400
+ summary: 删除 Response
401
+ tags:
402
+ - Responses
403
+ get:
404
+ parameters:
405
+ - description: Response ID
406
+ in: path
407
+ name: response_id
408
+ required: true
409
+ type: string
410
+ produces:
411
+ - application/json
412
+ responses:
413
+ "200":
414
+ description: OK
415
+ schema:
416
+ additionalProperties: true
417
+ type: object
418
+ "404":
419
+ description: Not Found
420
+ schema:
421
+ additionalProperties: true
422
+ type: object
423
+ security:
424
+ - BearerAuth: []
425
+ summary: 查询 Response
426
+ tags:
427
+ - Responses
428
+ /v1/responses/compact:
429
+ post:
430
+ consumes:
431
+ - application/json
432
+ parameters:
433
+ - description: 请求
434
+ in: body
435
+ name: request
436
+ required: true
437
+ schema:
438
+ $ref: '#/definitions/httpserver.SwaggerResponsesRequest'
439
+ produces:
440
+ - application/json
441
+ responses:
442
+ "200":
443
+ description: OK
444
+ schema:
445
+ additionalProperties: true
446
+ type: object
447
+ security:
448
+ - BearerAuth: []
449
+ summary: 压缩 Response 上下文
450
+ tags:
451
+ - Responses
452
+ /v1/videos/{request_id}:
453
+ get:
454
+ parameters:
455
+ - description: Request ID
456
+ in: path
457
+ name: request_id
458
+ required: true
459
+ type: string
460
+ produces:
461
+ - application/json
462
+ responses:
463
+ "200":
464
+ description: OK
465
+ schema:
466
+ additionalProperties: true
467
+ type: object
468
+ "404":
469
+ description: Not Found
470
+ schema:
471
+ additionalProperties: true
472
+ type: object
473
+ security:
474
+ - BearerAuth: []
475
+ summary: 查询异步视频任务
476
+ tags:
477
+ - Videos
478
+ /v1/videos/generations:
479
+ post:
480
+ consumes:
481
+ - application/json
482
+ parameters:
483
+ - description: 请求
484
+ in: body
485
+ name: request
486
+ required: true
487
+ schema:
488
+ $ref: '#/definitions/httpserver.SwaggerVideoGenerationRequest'
489
+ produces:
490
+ - application/json
491
+ responses:
492
+ "200":
493
+ description: OK
494
+ schema:
495
+ additionalProperties:
496
+ type: string
497
+ type: object
498
+ "400":
499
+ description: Bad Request
500
+ schema:
501
+ additionalProperties: true
502
+ type: object
503
+ security:
504
+ - BearerAuth: []
505
+ summary: 创建异步视频任务
506
+ tags:
507
+ - Videos
508
+ securityDefinitions:
509
+ BearerAuth:
510
+ description: 使用 "Bearer g2a_xxx_xxx"。
511
+ in: header
512
+ name: Authorization
513
+ type: apiKey
514
+ swagger: "2.0"
backend/go.mod ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module github.com/chenyme/grok2api/backend
2
+
3
+ go 1.26
4
+
5
+ require (
6
+ github.com/bdandy/go-socks4 v1.2.3
7
+ github.com/bogdanfinn/fhttp v0.6.8
8
+ github.com/bogdanfinn/tls-client v1.15.1
9
+ github.com/bogdanfinn/websocket v1.5.5-barnius
10
+ github.com/gin-gonic/gin v1.12.0
11
+ github.com/glebarez/sqlite v1.11.0
12
+ github.com/golang-jwt/jwt/v5 v5.3.1
13
+ github.com/google/uuid v1.6.0
14
+ github.com/redis/go-redis/v9 v9.21.0
15
+ github.com/swaggo/files v1.0.1
16
+ github.com/swaggo/gin-swagger v1.6.1
17
+ github.com/swaggo/swag v1.16.6
18
+ golang.org/x/crypto v0.54.0
19
+ golang.org/x/net v0.56.0
20
+ golang.org/x/sync v0.22.0
21
+ google.golang.org/protobuf v1.36.10
22
+ gopkg.in/yaml.v3 v3.0.1
23
+ gorm.io/driver/postgres v1.6.0
24
+ gorm.io/gorm v1.31.2
25
+ )
26
+
27
+ require (
28
+ github.com/KyleBanks/depth v1.2.1 // indirect
29
+ github.com/PuerkitoBio/purell v1.1.1 // indirect
30
+ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
31
+ github.com/andybalholm/brotli v1.2.0 // indirect
32
+ github.com/bdandy/go-errors v1.2.2 // indirect
33
+ github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
34
+ github.com/bogdanfinn/utls v1.7.7-barnius // indirect
35
+ github.com/bytedance/gopkg v0.1.3 // indirect
36
+ github.com/bytedance/sonic v1.15.0 // indirect
37
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
38
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
39
+ github.com/cloudwego/base64x v0.1.6 // indirect
40
+ github.com/dustin/go-humanize v1.0.1 // indirect
41
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
42
+ github.com/gin-contrib/sse v1.1.0 // indirect
43
+ github.com/glebarez/go-sqlite v1.21.2 // indirect
44
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
45
+ github.com/go-openapi/jsonreference v0.19.6 // indirect
46
+ github.com/go-openapi/spec v0.20.4 // indirect
47
+ github.com/go-openapi/swag v0.19.15 // indirect
48
+ github.com/go-playground/locales v0.14.1 // indirect
49
+ github.com/go-playground/universal-translator v0.18.1 // indirect
50
+ github.com/go-playground/validator/v10 v10.30.1 // indirect
51
+ github.com/goccy/go-json v0.10.5 // indirect
52
+ github.com/goccy/go-yaml v1.19.2 // indirect
53
+ github.com/jackc/pgpassfile v1.0.0 // indirect
54
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
55
+ github.com/jackc/pgx/v5 v5.6.0 // indirect
56
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
57
+ github.com/jinzhu/inflection v1.0.0 // indirect
58
+ github.com/jinzhu/now v1.1.5 // indirect
59
+ github.com/josharian/intern v1.0.0 // indirect
60
+ github.com/json-iterator/go v1.1.12 // indirect
61
+ github.com/klauspost/compress v1.18.2 // indirect
62
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
63
+ github.com/leodido/go-urn v1.4.0 // indirect
64
+ github.com/mailru/easyjson v0.7.6 // indirect
65
+ github.com/mattn/go-isatty v0.0.20 // indirect
66
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
67
+ github.com/modern-go/reflect2 v1.0.2 // indirect
68
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
69
+ github.com/quic-go/qpack v0.6.0 // indirect
70
+ github.com/quic-go/quic-go v0.59.0 // indirect
71
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
72
+ github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
73
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
74
+ github.com/ugorji/go/codec v1.3.1 // indirect
75
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
76
+ go.uber.org/atomic v1.11.0 // indirect
77
+ golang.org/x/arch v0.22.0 // indirect
78
+ golang.org/x/mod v0.37.0 // indirect
79
+ golang.org/x/sys v0.47.0 // indirect
80
+ golang.org/x/text v0.40.0 // indirect
81
+ golang.org/x/tools v0.47.0 // indirect
82
+ gopkg.in/yaml.v2 v2.4.0 // indirect
83
+ modernc.org/libc v1.22.5 // indirect
84
+ modernc.org/mathutil v1.5.0 // indirect
85
+ modernc.org/memory v1.5.0 // indirect
86
+ modernc.org/sqlite v1.23.1 // indirect
87
+ )
backend/go.sum ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
2
+ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
3
+ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
4
+ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
5
+ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
6
+ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
7
+ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
8
+ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
9
+ github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
10
+ github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
11
+ github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
12
+ github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
13
+ github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
14
+ github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
15
+ github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
16
+ github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
17
+ github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
18
+ github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
19
+ github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
20
+ github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
21
+ github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
22
+ github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
23
+ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
24
+ github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
25
+ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
26
+ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
27
+ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
28
+ github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
29
+ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
30
+ github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
31
+ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
32
+ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
33
+ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
34
+ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
35
+ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
36
+ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
37
+ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
38
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
39
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
40
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
41
+ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
42
+ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
43
+ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
44
+ github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
45
+ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
46
+ github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
47
+ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
48
+ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
49
+ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
50
+ github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
51
+ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
52
+ github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
53
+ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
54
+ github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
55
+ github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
56
+ github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
57
+ github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
58
+ github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
59
+ github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
60
+ github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
61
+ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
62
+ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
63
+ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
64
+ github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
65
+ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
66
+ github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
67
+ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
68
+ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
69
+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
70
+ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
71
+ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
72
+ github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
73
+ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
74
+ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
75
+ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
76
+ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
77
+ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
78
+ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
79
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
80
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
81
+ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
82
+ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
83
+ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
84
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
85
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
86
+ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
87
+ github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
88
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
89
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
90
+ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
91
+ github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
92
+ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
93
+ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
94
+ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
95
+ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
96
+ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
97
+ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
98
+ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
99
+ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
100
+ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
101
+ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
102
+ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
103
+ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
104
+ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
105
+ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
106
+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
107
+ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
108
+ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
109
+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
110
+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
111
+ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
112
+ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
113
+ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
114
+ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
115
+ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
116
+ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
117
+ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
118
+ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
119
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
120
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
121
+ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
122
+ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
123
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
124
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
125
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
126
+ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
127
+ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
128
+ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
129
+ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
130
+ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
131
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
132
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
133
+ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
134
+ github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
135
+ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
136
+ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
137
+ github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
138
+ github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
139
+ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
140
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
141
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
142
+ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
143
+ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
144
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
145
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
146
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
147
+ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
148
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
149
+ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
150
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
151
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
152
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
153
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
154
+ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
155
+ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
156
+ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
157
+ github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
158
+ github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
159
+ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
160
+ github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
161
+ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
162
+ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
163
+ github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
164
+ github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
165
+ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
166
+ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
167
+ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
168
+ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
169
+ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
170
+ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
171
+ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
172
+ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
173
+ github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
174
+ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
175
+ go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
176
+ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
177
+ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
178
+ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
179
+ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
180
+ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
181
+ golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
182
+ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
183
+ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
184
+ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
185
+ golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
186
+ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
187
+ golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
188
+ golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
189
+ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
190
+ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
191
+ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
192
+ golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
193
+ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
194
+ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
195
+ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
196
+ golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
197
+ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
198
+ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
199
+ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
200
+ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
201
+ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
202
+ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
203
+ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
204
+ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
205
+ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
206
+ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
207
+ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
208
+ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
209
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
210
+ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
211
+ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
212
+ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
213
+ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
214
+ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
215
+ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
216
+ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
217
+ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
218
+ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
219
+ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
220
+ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
221
+ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
222
+ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
223
+ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
224
+ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
225
+ golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
226
+ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
227
+ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
228
+ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
229
+ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
230
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
231
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
232
+ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
233
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
234
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
235
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
236
+ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
237
+ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
238
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
239
+ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
240
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
241
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
242
+ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
243
+ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
244
+ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
245
+ gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
246
+ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
247
+ gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
248
+ modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
249
+ modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
250
+ modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
251
+ modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
252
+ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
253
+ modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
254
+ modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
255
+ modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
backend/internal/app/application.go ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "log/slog"
9
+ "net/http"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ accountapp "github.com/chenyme/grok2api/backend/internal/application/account"
15
+ accountsyncapp "github.com/chenyme/grok2api/backend/internal/application/accountsync"
16
+ "github.com/chenyme/grok2api/backend/internal/application/adminauth"
17
+ auditapp "github.com/chenyme/grok2api/backend/internal/application/audit"
18
+ clientkeyapp "github.com/chenyme/grok2api/backend/internal/application/clientkey"
19
+ dashboardapp "github.com/chenyme/grok2api/backend/internal/application/dashboard"
20
+ egressapp "github.com/chenyme/grok2api/backend/internal/application/egress"
21
+ "github.com/chenyme/grok2api/backend/internal/application/gateway"
22
+ invalidationapp "github.com/chenyme/grok2api/backend/internal/application/invalidation"
23
+ mediaapp "github.com/chenyme/grok2api/backend/internal/application/media"
24
+ modelapp "github.com/chenyme/grok2api/backend/internal/application/model"
25
+ quotarecoveryapp "github.com/chenyme/grok2api/backend/internal/application/quotarecovery"
26
+ settingsapp "github.com/chenyme/grok2api/backend/internal/application/settings"
27
+ updatecheckapp "github.com/chenyme/grok2api/backend/internal/application/updatecheck"
28
+ "github.com/chenyme/grok2api/backend/internal/buildinfo"
29
+ "github.com/chenyme/grok2api/backend/internal/domain/account"
30
+ "github.com/chenyme/grok2api/backend/internal/infra/config"
31
+ infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress"
32
+ inframedia "github.com/chenyme/grok2api/backend/internal/infra/media"
33
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
34
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
35
+ cliprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/cli"
36
+ consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console"
37
+ webprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/web"
38
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
39
+ redisruntime "github.com/chenyme/grok2api/backend/internal/infra/runtime/redis"
40
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
41
+ "github.com/chenyme/grok2api/backend/internal/pkg/batch"
42
+ "github.com/chenyme/grok2api/backend/internal/pkg/perfmetrics"
43
+ "github.com/chenyme/grok2api/backend/internal/pkg/reasoningreplay"
44
+ "github.com/chenyme/grok2api/backend/internal/repository"
45
+ httpserver "github.com/chenyme/grok2api/backend/internal/transport/http"
46
+ httpmiddleware "github.com/chenyme/grok2api/backend/internal/transport/http/middleware"
47
+ )
48
+
49
+ const (
50
+ responseOwnershipCleanupBatchSize = 1000
51
+ webResponseStateCleanupBatchSize = 50
52
+ responseCleanupMaxBatches = 100
53
+ responseCleanupInterval = 5 * time.Minute
54
+ responseCleanupBudget = 30 * time.Second
55
+ responseCleanupLockTTL = 2 * time.Minute
56
+ )
57
+
58
+ // Application 管理后端进程生命周期和本地后台任务。
59
+ type Application struct {
60
+ logger *slog.Logger
61
+ database *relational.Database
62
+ server *http.Server
63
+ audits *auditapp.Service
64
+ responses repository.ResponseRepository
65
+ cleanupLock repository.DistributedLock
66
+ runtime io.Closer
67
+ settingsBus repository.SettingsChangeBus
68
+ invalidationBus repository.InvalidationBus
69
+ settings *settingsapp.Service
70
+ gateway *gateway.Service
71
+ media *mediaapp.Service
72
+ quotaRecovery *quotarecoveryapp.Service
73
+ accounts *accountapp.Service
74
+ models *modelapp.Service
75
+ clientKeys *clientkeyapp.Service
76
+ updates *updatecheckapp.Service
77
+ invalidations *invalidationapp.Service
78
+ accountRepo repository.AccountRepository
79
+ modelRepo repository.ModelRepository
80
+ providers *provider.Registry
81
+ web *webprovider.Adapter
82
+ egress *infraegress.Manager
83
+ egressOps *egressapp.Service
84
+ startup *startupState
85
+ }
86
+
87
+ // New 完成数据库、Provider、应用服务和 HTTP 路由装配。
88
+ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Application, error) {
89
+ var database *relational.Database
90
+ var err error
91
+ switch cfg.Database.Driver {
92
+ case "sqlite":
93
+ database, err = relational.OpenSQLite(ctx, cfg.Database.SQLite.Path)
94
+ case "postgres":
95
+ database, err = relational.OpenPostgres(ctx, cfg.Database.Postgres.DSN, cfg.Database.Postgres.MaxOpenConns, cfg.Database.Postgres.MaxIdleConns)
96
+ default:
97
+ return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Database.Driver)
98
+ }
99
+ if err != nil {
100
+ return nil, err
101
+ }
102
+ if err := database.InitializeSchema(ctx); err != nil {
103
+ database.Close()
104
+ return nil, err
105
+ }
106
+ cipher, err := security.NewCipher(cfg.Secrets.CredentialEncryptionKey)
107
+ if err != nil {
108
+ database.Close()
109
+ return nil, err
110
+ }
111
+
112
+ adminRepo := relational.NewAdminRepository(database)
113
+ sessionRepo := relational.NewAdminSessionRepository(database)
114
+ accountRepo := relational.NewAccountRepository(database)
115
+ modelRepo := relational.NewModelRepository(database)
116
+ clientKeyRepo := relational.NewClientKeyRepository(database)
117
+ auditRepo := relational.NewAuditRepository(database)
118
+ responseRepo := relational.NewResponseRepository(database)
119
+ dashboardRepo := relational.NewDashboardRepository(database)
120
+ runtimeSettingsRepo := relational.NewRuntimeSettingsRepository(database, cipher)
121
+ egressRepo := relational.NewEgressRepository(database)
122
+ mediaJobRepo := relational.NewMediaJobRepository(database)
123
+ mediaAssetRepo := relational.NewMediaAssetRepository(database)
124
+ mediaUploadTicketRepo := relational.NewMediaUploadTicketRepository(database)
125
+ loadedConfig, settingsUpdatedAt, settingsRevision, err := settingsapp.LoadPersisted(ctx, cfg, runtimeSettingsRepo)
126
+ if err != nil {
127
+ database.Close()
128
+ return nil, err
129
+ }
130
+ cfg = loadedConfig
131
+ localMediaStore, err := inframedia.NewLocalStore(cfg.Media.Local.Path)
132
+ if err != nil {
133
+ database.Close()
134
+ return nil, err
135
+ }
136
+ if err := preflightDeployment(cfg); err != nil {
137
+ database.Close()
138
+ return nil, err
139
+ }
140
+ var rateLimiter repository.RateLimiter
141
+ var concurrency repository.ConcurrencyLimiter
142
+ var sticky repository.StickySessionRepository
143
+ var reasoningReplayStore repository.ReasoningReplayRepository
144
+ var deviceSessions repository.DeviceSessionRepository
145
+ var refreshLock repository.DistributedLock
146
+ var settingsBus repository.SettingsChangeBus
147
+ var quotaQueue repository.QuotaRecoveryQueue
148
+ var quotaRefreshState repository.QuotaRefreshCoordinator
149
+ var observedModelStore repository.ObservedModelStateRepository
150
+ var invalidationBus repository.InvalidationBus
151
+ var runtimeStore io.Closer
152
+ runtimeHealth := func(context.Context) error { return nil }
153
+ switch cfg.RuntimeStore.Driver {
154
+ case "redis":
155
+ redisStore, openErr := redisruntime.Open(ctx, redisruntime.Config{
156
+ Address: cfg.RuntimeStore.Redis.Address, Username: cfg.RuntimeStore.Redis.Username,
157
+ Password: cfg.RuntimeStore.Redis.Password, Database: cfg.RuntimeStore.Redis.Database,
158
+ KeyPrefix: cfg.RuntimeStore.Redis.KeyPrefix, TLS: cfg.RuntimeStore.Redis.TLS,
159
+ ConcurrencyLease: cfg.Server.RequestTimeout.Value() + time.Minute,
160
+ })
161
+ if openErr != nil {
162
+ database.Close()
163
+ return nil, openErr
164
+ }
165
+ runtimeStore = redisStore
166
+ invalidationBus = redisStore
167
+ runtimeHealth = redisStore.Ping
168
+ rateLimiter = redisStore
169
+ concurrency = redisruntime.NewConcurrencyLimiter(redisStore)
170
+ sticky = redisStore
171
+ reasoningReplayStore = redisruntime.NewReasoningReplayStore(redisStore)
172
+ deviceSessions = redisruntime.NewDeviceSessionStore(redisStore)
173
+ refreshLock = redisruntime.NewLockStore(redisStore)
174
+ settingsBus = redisStore
175
+ quotaQueue = redisStore
176
+ quotaRefreshState = redisStore
177
+ observedModelStore = redisStore
178
+ case "memory":
179
+ rateLimiter = memory.NewRateLimiter()
180
+ concurrency = memory.NewConcurrencyLimiter()
181
+ sticky = memory.NewStickyStore()
182
+ reasoningReplayStore = memory.NewReasoningReplayStore(cfg.Routing.ReasoningReplayMaxEntries)
183
+ deviceSessions = memory.NewDeviceSessionStore()
184
+ refreshLock = memory.NewLockStore()
185
+ quotaQueue = memory.NewQuotaRecoveryQueue()
186
+ quotaRefreshState = memory.NewQuotaRefreshCoordinator()
187
+ default:
188
+ database.Close()
189
+ return nil, fmt.Errorf("不支持的运行态驱动: %s", cfg.RuntimeStore.Driver)
190
+ }
191
+ logger.Info("deployment_topology", "replicas", cfg.Deployment.Replicas, "instance_id", cfg.Deployment.InstanceID, "cluster_id", cfg.Deployment.ClusterID, "database", cfg.Database.Driver, "runtime_store", cfg.RuntimeStore.Driver, "media_driver", cfg.Media.Driver, "shared_media", cfg.Deployment.SharedMedia)
192
+ mediaService := mediaapp.NewServiceWithTickets(mediaAssetRepo, mediaJobRepo, mediaUploadTicketRepo, localMediaStore, refreshLock, mediaConfig(cfg))
193
+
194
+ egressManager := infraegress.NewManager(egressRepo, cipher)
195
+ egressManager.SetLogger(logger)
196
+ egressManager.SetClearanceLock(refreshLock)
197
+ egressManager.UpdateClearanceConfig(clearanceConfig(cfg))
198
+ egressManager.UpdateBuildResponseHeaderTimeout(cfg.Provider.Build.ResponseHeaderTimeout.Value())
199
+ cliAdapter := cliprovider.NewAdapter(cliprovider.Config{
200
+ BaseURL: cfg.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(cfg.Provider.Build.FallbackBaseURL),
201
+ ClientVersion: cfg.Provider.Build.ClientVersion, ClientIdentifier: cfg.Provider.Build.ClientIdentifier,
202
+ TokenAuth: cfg.Provider.Build.TokenAuth, UserAgent: cfg.Provider.Build.UserAgent,
203
+ ResponseHeaderTimeout: cfg.Provider.Build.ResponseHeaderTimeout.Value(),
204
+ }, cipher)
205
+ cliAdapter.SetLogger(logger)
206
+ cliAdapter.SetEgress(egressManager)
207
+ cliAdapter.SetVideoUploadIssuer(mediaService)
208
+ reasoningReplay := reasoningreplay.New(reasoningReplayStore, reasoningreplay.Config{
209
+ Enabled: cfg.Routing.ReasoningReplayEnabled,
210
+ TTL: cfg.Routing.ReasoningReplayTTL.Value(),
211
+ }, logger)
212
+ cliAdapter.SetReasoningReplay(reasoningReplay)
213
+ webAdapter := webprovider.NewAdapter(webProviderConfig(cfg), egressManager, cipher, responseRepo, mediaService)
214
+ webAdapter.SetLogger(logger)
215
+ consoleAdapter := consoleprovider.NewAdapter(consoleProviderConfig(cfg), egressManager, cipher)
216
+ providers := provider.NewRegistry(cliAdapter, webAdapter, consoleAdapter)
217
+ if err := providers.Validate(); err != nil {
218
+ if runtimeStore != nil {
219
+ _ = runtimeStore.Close()
220
+ }
221
+ database.Close()
222
+ return nil, fmt.Errorf("校验 Provider 注册表: %w", err)
223
+ }
224
+ adminService := adminauth.NewService(adminRepo, sessionRepo, security.NewTokenService(cfg.Secrets.JWTSecret), cfg.Auth.AccessTokenTTL.Value(), cfg.Auth.RefreshTokenTTL.Value())
225
+ adminService.SetLoginRateLimiter(rateLimiter)
226
+ if err := adminService.Bootstrap(ctx, cfg.BootstrapAdmin.Username, cfg.BootstrapAdmin.Password); err != nil {
227
+ if runtimeStore != nil {
228
+ _ = runtimeStore.Close()
229
+ }
230
+ database.Close()
231
+ return nil, err
232
+ }
233
+ bulkPool := batch.NewSharedPool(maxBatchConcurrency(cfg.Batch), concurrency, "bulk:upstream")
234
+ importPool := batch.NewSharedChildPool(cfg.Batch.ImportConcurrency, concurrency, "bulk:import", bulkPool)
235
+ conversionPool := batch.NewSharedChildPool(cfg.Batch.ConversionConcurrency, concurrency, "bulk:conversion", bulkPool)
236
+ syncPool := batch.NewSharedChildPool(cfg.Batch.SyncConcurrency, concurrency, "bulk:sync", bulkPool)
237
+ refreshPool := batch.NewSharedChildPool(cfg.Batch.RefreshConcurrency, concurrency, "bulk:refresh", bulkPool)
238
+ for _, pool := range []*batch.Pool{importPool, conversionPool, syncPool, refreshPool} {
239
+ pool.UpdateJitter(cfg.Batch.RandomDelay.Value())
240
+ }
241
+ accountService := accountapp.NewService(accountRepo, auditRepo, deviceSessions, sticky, providers, cipher, refreshLock)
242
+ cliAdapter.SetFallbackMarker(accountService)
243
+ accountService.SetLogger(logger)
244
+ accountService.UpdateAutoCleanConfig(accountAutoCleanConfig(cfg.Accounts))
245
+ accountService.SetConcurrencyLimiter(concurrency)
246
+ accountService.SetQuotaRecoveryQueue(quotaQueue)
247
+ accountService.SetQuotaRefreshCoordinator(quotaRefreshState)
248
+ accountService.SetObservedModelStore(observedModelStore)
249
+ accountService.SetTaskPools(conversionPool, syncPool, refreshPool)
250
+ windows, err := accountRepo.ListQuotaRecoveryWindows(ctx, 100000)
251
+ if err != nil {
252
+ if runtimeStore != nil {
253
+ _ = runtimeStore.Close()
254
+ }
255
+ database.Close()
256
+ return nil, fmt.Errorf("加载 Web 额度恢复事件: %w", err)
257
+ }
258
+ for _, window := range windows {
259
+ if window.ResetAt != nil {
260
+ if err := quotaQueue.ScheduleQuotaRecovery(ctx, account.QuotaRecoveryEvent{AccountID: window.AccountID, Mode: window.Mode, DueAt: *window.ResetAt}); err != nil {
261
+ if runtimeStore != nil {
262
+ _ = runtimeStore.Close()
263
+ }
264
+ database.Close()
265
+ return nil, fmt.Errorf("恢复 Web 额度事件: %w", err)
266
+ }
267
+ }
268
+ }
269
+ modelService := modelapp.NewService(modelRepo, accountRepo, accountService, providers)
270
+ modelService.SetBulkPool(syncPool)
271
+ modelService.SetLogger(logger)
272
+ if err := modelRepo.ReplaceProviderRoutes(ctx, account.ProviderWeb, webprovider.Routes()); err != nil {
273
+ if runtimeStore != nil {
274
+ _ = runtimeStore.Close()
275
+ }
276
+ database.Close()
277
+ return nil, fmt.Errorf("初始化 Grok Web 模型目录: %w", err)
278
+ }
279
+ if err := modelRepo.ReplaceProviderRoutes(ctx, account.ProviderConsole, consoleprovider.Routes()); err != nil {
280
+ if runtimeStore != nil {
281
+ _ = runtimeStore.Close()
282
+ }
283
+ database.Close()
284
+ return nil, fmt.Errorf("初始化 Grok Console 模型目录: %w", err)
285
+ }
286
+ accountSyncService := accountsyncapp.NewService(logger, accountService, accountService, accountService, modelService)
287
+ accountSyncService.SetBulkPool(importPool)
288
+ accountSyncService.UpdateConcurrency(cfg.Batch.ImportConcurrency)
289
+ egressService := egressapp.NewService(egressRepo, cipher, infraegress.DefaultUserAgent, accountRepo)
290
+ egressService.SetClearanceManager(egressManager)
291
+ egressService.SetNodeProber(egressManager)
292
+ egressService.SetOperationsConfigInvalidator(egressManager)
293
+ clientKeyService := clientkeyapp.NewService(clientKeyRepo, rateLimiter, concurrency, cfg.ClientKeyDefaults.RPMLimit, cfg.ClientKeyDefaults.MaxConcurrent, cipher)
294
+ auditService := auditapp.NewService(auditRepo, logger, cfg.Audit.BufferSize, cfg.Audit.BatchSize, cfg.Audit.FlushInterval.Value())
295
+ auditService.UpdateWriterConfig(cfg.Audit.BatchSize, cfg.Audit.FlushInterval.Value(), cfg.Audit.CommitDelay.Value())
296
+ auditService.UpdateLedgerConfig(auditLedgerConfig(cfg.Audit))
297
+ auditService.SetCommitObserver(clientKeyService.CompleteBillingBatch)
298
+ auditService.SetDropObserver(clientKeyService.ReleaseBillingProtectionBatch)
299
+ dashboardService := dashboardapp.NewService(dashboardRepo)
300
+ selector := gateway.NewSelector(accountRepo, concurrency, sticky, providers, cfg.Routing.StickyTTL.Value(), cfg.Routing.CooldownBase.Value(), cfg.Routing.CooldownMax.Value(), cfg.Routing.CapacityWait.Value())
301
+ selector.UpdatePreferFreeBuild(cfg.Routing.PreferFreeBuild)
302
+ selector.UpdateSegmentedSelector(cfg.Routing.SegmentedSelectorEnabled, cfg.Routing.SegmentedMinCandidates, cfg.Routing.SegmentedWindowSize)
303
+ invalidationService := invalidationapp.NewService(invalidationBus, invalidationSourceInstance(cfg), func(event repository.InvalidationEvent) {
304
+ selector.ApplyInvalidation(event)
305
+ clientKeyService.ApplyInvalidation(event)
306
+ }, logger)
307
+ accountRepo.SetInvalidationObserver(invalidationService.Notify)
308
+ modelRepo.SetInvalidationObserver(invalidationService.Notify)
309
+ clientKeyRepo.SetInvalidationObserver(invalidationService.Notify)
310
+ gatewayService := gateway.NewService(modelService, auditService, accountService, clientKeyService, providers, selector, responseRepo, cfg.Routing.MaxAttempts)
311
+ gatewayService.SetLogger(logger)
312
+ gatewayService.UpdateBuildForbiddenReauthPolicy(cfg.Accounts.MarkBuildForbiddenReauth, cfg.Accounts.BuildForbiddenReauthCodes)
313
+ gatewayService.UpdateRequestTimeout(cfg.Server.RequestTimeout.Value())
314
+ gatewayService.ConfigureMedia(mediaJobRepo, cfg.Provider.Web.MediaConcurrency)
315
+ gatewayService.ConfigureMediaAssets(mediaService)
316
+ quotaRecoveryService := quotarecoveryapp.NewService(logger, quotaQueue, accountService, cfg.Provider.Web.RecoveryBackoffBase.Value(), cfg.Provider.Web.RecoveryBackoffMax.Value())
317
+ quotaRecoveryService.SetBulkPool(syncPool)
318
+ inferenceConcurrency := httpmiddleware.NewConcurrencyGate(cfg.Server.MaxConcurrentRequests)
319
+ var notifySettings func(context.Context)
320
+ if settingsBus != nil {
321
+ notifySettings = func(notifyCtx context.Context) {
322
+ publishCtx, cancel := context.WithTimeout(context.WithoutCancel(notifyCtx), 3*time.Second)
323
+ defer cancel()
324
+ if err := settingsBus.PublishSettingsChanged(publishCtx); err != nil {
325
+ logger.Warn("settings_change_publish_failed", "error", err)
326
+ }
327
+ }
328
+ }
329
+ settingsService := settingsapp.NewService(cfg, settingsUpdatedAt, settingsRevision, runtimeSettingsRepo, notifySettings, func(next config.Config) {
330
+ inferenceConcurrency.UpdateLimit(next.Server.MaxConcurrentRequests)
331
+ bulkPool.UpdateLimit(maxBatchConcurrency(next.Batch))
332
+ importPool.UpdateLimit(next.Batch.ImportConcurrency)
333
+ conversionPool.UpdateLimit(next.Batch.ConversionConcurrency)
334
+ syncPool.UpdateLimit(next.Batch.SyncConcurrency)
335
+ refreshPool.UpdateLimit(next.Batch.RefreshConcurrency)
336
+ for _, pool := range []*batch.Pool{importPool, conversionPool, syncPool, refreshPool} {
337
+ pool.UpdateJitter(next.Batch.RandomDelay.Value())
338
+ }
339
+ cliAdapter.UpdateConfig(cliprovider.Config{
340
+ BaseURL: next.Provider.Build.BaseURL, FallbackBaseURL: config.NormalizeBuildFallbackBaseURL(next.Provider.Build.FallbackBaseURL),
341
+ ClientVersion: next.Provider.Build.ClientVersion, ClientIdentifier: next.Provider.Build.ClientIdentifier,
342
+ TokenAuth: next.Provider.Build.TokenAuth, UserAgent: next.Provider.Build.UserAgent,
343
+ ResponseHeaderTimeout: next.Provider.Build.ResponseHeaderTimeout.Value(),
344
+ })
345
+ egressManager.UpdateBuildResponseHeaderTimeout(next.Provider.Build.ResponseHeaderTimeout.Value())
346
+ webAdapter.UpdateConfig(webProviderConfig(next))
347
+ egressManager.UpdateClearanceConfig(clearanceConfig(next))
348
+ consoleAdapter.UpdateConfig(consoleProviderConfig(next))
349
+ mediaService.UpdateConfig(mediaConfig(next))
350
+ quotaRecoveryService.UpdateConfig(next.Provider.Web.RecoveryBackoffBase.Value(), next.Provider.Web.RecoveryBackoffMax.Value())
351
+ accountSyncService.UpdateConcurrency(next.Batch.ImportConcurrency)
352
+ selector.UpdateConfig(next.Routing.StickyTTL.Value(), next.Routing.CooldownBase.Value(), next.Routing.CooldownMax.Value(), next.Routing.CapacityWait.Value())
353
+ selector.UpdatePreferFreeBuild(next.Routing.PreferFreeBuild)
354
+ selector.UpdateSegmentedSelector(next.Routing.SegmentedSelectorEnabled, next.Routing.SegmentedMinCandidates, next.Routing.SegmentedWindowSize)
355
+ reasoningReplay.UpdateConfig(reasoningreplay.Config{Enabled: next.Routing.ReasoningReplayEnabled, TTL: next.Routing.ReasoningReplayTTL.Value()})
356
+ gatewayService.UpdateMaxAttempts(next.Routing.MaxAttempts)
357
+ gatewayService.UpdateBuildForbiddenReauthPolicy(next.Accounts.MarkBuildForbiddenReauth, next.Accounts.BuildForbiddenReauthCodes)
358
+ auditService.UpdateWriterConfig(next.Audit.BatchSize, next.Audit.FlushInterval.Value(), next.Audit.CommitDelay.Value())
359
+ auditService.UpdateLedgerConfig(auditLedgerConfig(next.Audit))
360
+ clientKeyService.UpdateDefaults(next.ClientKeyDefaults.RPMLimit, next.ClientKeyDefaults.MaxConcurrent)
361
+ accountService.UpdateAutoCleanConfig(accountAutoCleanConfig(next.Accounts))
362
+ })
363
+ updateService := updatecheckapp.NewService(buildinfo.CurrentVersion(), nil)
364
+
365
+ startup := newStartupState(len(windows))
366
+ readiness := func(readyCtx context.Context) httpserver.ReadinessSnapshot {
367
+ return readinessSnapshot(readyCtx, startup, runtimeHealth, modelRepo, accountRepo, providers, auditService)
368
+ }
369
+ router := httpserver.New(httpserver.Dependencies{Logger: logger, RequestTimeout: cfg.Server.RequestTimeout.Value(), MaxBodyBytes: cfg.Server.MaxBodyBytes, ConcurrencyGate: inferenceConcurrency, SecureCookies: cfg.Auth.SecureCookies, SwaggerEnabled: cfg.Server.SwaggerEnabled, PublicAPIBaseURL: cfg.Frontend.EffectivePublicAPIBaseURL(), FrontendStaticPath: cfg.Frontend.StaticPath, Readiness: readiness, TrafficReady: startup.acceptsTraffic, AdminAuth: adminService, Accounts: accountService, AccountSync: accountSyncService, Models: modelService, ClientKeys: clientKeyService, Audits: auditService, Dashboard: dashboardService, Gateway: gatewayService, Media: mediaService, Settings: settingsService, Egress: egressService, Updates: updateService})
370
+ server := &http.Server{Addr: cfg.Server.Listen, Handler: router, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: cfg.Server.ReadTimeout.Value(), IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 64 << 10}
371
+ return &Application{
372
+ logger: logger, database: database, server: server,
373
+ audits: auditService, responses: responseRepo, cleanupLock: refreshLock, runtime: runtimeStore,
374
+ settingsBus: settingsBus, invalidationBus: invalidationBus, settings: settingsService, gateway: gatewayService, media: mediaService, quotaRecovery: quotaRecoveryService, accounts: accountService, models: modelService, clientKeys: clientKeyService, updates: updateService, invalidations: invalidationService,
375
+ accountRepo: accountRepo, modelRepo: modelRepo, providers: providers, web: webAdapter, egress: egressManager, egressOps: egressService, startup: startup,
376
+ }, nil
377
+ }
378
+
379
+ func invalidationSourceInstance(cfg config.Config) string {
380
+ if value := strings.TrimSpace(cfg.Deployment.InstanceID); value != "" {
381
+ return value
382
+ }
383
+ return fmt.Sprintf("process-%d", time.Now().UnixNano())
384
+ }
385
+
386
+ func maxBatchConcurrency(value config.BatchConfig) int {
387
+ return max(value.ImportConcurrency, value.ConversionConcurrency, value.SyncConcurrency, value.RefreshConcurrency)
388
+ }
389
+
390
+ func webProviderConfig(cfg config.Config) webprovider.Config {
391
+ return webprovider.Config{
392
+ BaseURL: cfg.Provider.Web.BaseURL, QuotaTimeoutSeconds: int(cfg.Provider.Web.QuotaTimeout.Value().Seconds()),
393
+ StatsigMode: cfg.Provider.Web.StatsigMode, StatsigManualValue: cfg.Provider.Web.StatsigManualValue,
394
+ StatsigSignerURL: cfg.Provider.Web.StatsigSignerURL,
395
+ ChatTimeoutSeconds: int(cfg.Provider.Web.ChatTimeout.Value().Seconds()), ImageTimeoutSeconds: int(cfg.Provider.Web.ImageTimeout.Value().Seconds()),
396
+ VideoTimeoutSeconds: int(cfg.Provider.Web.VideoTimeout.Value().Seconds()), MaxInputImageBytes: cfg.Media.MaxImageBytes,
397
+ AllowNSFW: cfg.Provider.Web.AllowNSFW,
398
+ }
399
+ }
400
+
401
+ func clearanceConfig(cfg config.Config) infraegress.ClearanceConfig {
402
+ return infraegress.ClearanceConfig{
403
+ Mode: cfg.Provider.Web.ClearanceMode, FlareSolverrURL: cfg.Provider.Web.FlareSolverrURL,
404
+ TargetURL: cfg.Provider.Web.BaseURL, Timeout: cfg.Provider.Web.ClearanceTimeout.Value(),
405
+ RefreshInterval: cfg.Provider.Web.ClearanceRefresh.Value(),
406
+ }
407
+ }
408
+
409
+ func consoleProviderConfig(cfg config.Config) consoleprovider.Config {
410
+ return consoleprovider.Config{
411
+ BaseURL: cfg.Provider.Console.BaseURL, SessionBaseURL: cfg.Provider.Web.BaseURL,
412
+ TimeoutSeconds: int(cfg.Provider.Console.ChatTimeout.Value().Seconds()),
413
+ }
414
+ }
415
+
416
+ func accountAutoCleanConfig(value config.AccountsConfig) accountapp.AutoCleanConfig {
417
+ return accountapp.AutoCleanConfig{
418
+ Enabled: value.AutoCleanReauthEnabled,
419
+ Interval: value.AutoCleanReauthInterval.Value(),
420
+ MinAge: value.AutoCleanReauthMinAge.Value(),
421
+ IncludeDisabled: value.AutoCleanIncludeDisabled,
422
+ }
423
+ }
424
+
425
+ func auditLedgerConfig(value config.AuditConfig) auditapp.LedgerConfig {
426
+ return auditapp.LedgerConfig{
427
+ Mode: auditapp.LedgerMode(value.LedgerMode),
428
+ FailureThreshold: value.LedgerFailureThreshold,
429
+ UnhealthyGrace: value.LedgerUnhealthyGrace.Value(),
430
+ QueueHighWatermarkPercent: value.LedgerQueueHighWatermarkPct,
431
+ }
432
+ }
433
+
434
+ func mediaConfig(cfg config.Config) mediaapp.Config {
435
+ return mediaapp.Config{
436
+ PublicBaseURL: cfg.Frontend.EffectivePublicAPIBaseURL(),
437
+ MaxImageBytes: cfg.Media.MaxImageBytes, MaxTotalBytes: cfg.Media.MaxTotalBytes,
438
+ CleanupThresholdPercent: cfg.Media.CleanupThresholdPercent, CleanupInterval: cfg.Media.CleanupInterval.Value(),
439
+ }
440
+ }
441
+
442
+ // Run 启动 HTTP 服务和本地后台维护任务。
443
+ func (a *Application) Run(ctx context.Context) error {
444
+ a.audits.Start()
445
+ defer func() {
446
+ closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
447
+ defer cancel()
448
+ if err := a.audits.Close(closeCtx); err != nil {
449
+ a.logger.Warn("audit_shutdown_failed", "error", err)
450
+ }
451
+ }()
452
+ runCtx, cancelBackground := context.WithCancel(ctx)
453
+ var background sync.WaitGroup
454
+ defer func() {
455
+ cancelBackground()
456
+ background.Wait()
457
+ }()
458
+ errCh := make(chan error, 1)
459
+ go func() {
460
+ a.logger.Info("server_started", "listen", a.server.Addr)
461
+ errCh <- a.server.ListenAndServe()
462
+ }()
463
+ a.reconcileStartup(runCtx)
464
+ startBackground := func(name string, task func(context.Context) error) {
465
+ background.Add(1)
466
+ go func() {
467
+ defer background.Done()
468
+ a.runSupervisedTask(runCtx, name, task)
469
+ }()
470
+ }
471
+ if a.invalidationBus != nil {
472
+ startBackground("invalidation_publisher", a.invalidations.RunPublisher)
473
+ startBackground("invalidation_subscriber", a.invalidations.RunSubscriber)
474
+ }
475
+ startBackground("settings_reconcile", func(taskCtx context.Context) error {
476
+ a.runPeriodicTask(taskCtx, 30*time.Second, "settings_reconcile", func(runCtx context.Context) error {
477
+ return a.settings.ReloadPersisted(runCtx)
478
+ })
479
+ return nil
480
+ })
481
+ startBackground("performance_metrics", func(taskCtx context.Context) error {
482
+ a.runPeriodicTask(taskCtx, time.Minute, "performance_metrics", func(context.Context) error {
483
+ a.logPerformanceMetrics()
484
+ return nil
485
+ })
486
+ return nil
487
+ })
488
+ startBackground("release_check", func(taskCtx context.Context) error {
489
+ a.updates.Check(taskCtx)
490
+ a.runPeriodicTask(taskCtx, 24*time.Hour, "release_check", func(checkCtx context.Context) error {
491
+ a.updates.Check(checkCtx)
492
+ return nil
493
+ })
494
+ return nil
495
+ })
496
+ startBackground("billing_reservation_cleanup", func(taskCtx context.Context) error {
497
+ a.runPeriodicTask(taskCtx, 10*time.Minute, "billing_reservation_cleanup", func(runCtx context.Context) error {
498
+ _, err := a.clientKeys.CleanupExpiredBilling(runCtx, 1000)
499
+ return err
500
+ })
501
+ return nil
502
+ })
503
+ startBackground("model_cooldown_cleanup", func(taskCtx context.Context) error {
504
+ a.runPeriodicTask(taskCtx, 10*time.Minute, "model_cooldown_cleanup", func(runCtx context.Context) error {
505
+ _, err := a.accountRepo.PruneExpiredModelQuotaBlocks(runCtx, time.Now().UTC(), 1000)
506
+ return err
507
+ })
508
+ return nil
509
+ })
510
+ startBackground("response_ownership_cleanup", func(taskCtx context.Context) error {
511
+ a.runPeriodicTask(taskCtx, responseCleanupInterval, "response_ownership_cleanup", func(runCtx context.Context) error {
512
+ return a.cleanupExpiredResponses(runCtx, time.Now().UTC())
513
+ })
514
+ return nil
515
+ })
516
+ startBackground("quota_recovery", func(taskCtx context.Context) error {
517
+ a.quotaRecovery.Run(taskCtx)
518
+ return nil
519
+ })
520
+ startBackground("web_quota_refresh", func(taskCtx context.Context) error {
521
+ a.accounts.RunWebQuotaRefresh(taskCtx)
522
+ return nil
523
+ })
524
+ startBackground("credential_refresh", func(taskCtx context.Context) error {
525
+ a.accounts.RunCredentialRefresh(taskCtx)
526
+ return nil
527
+ })
528
+ startBackground("account_auto_clean", func(taskCtx context.Context) error {
529
+ a.accounts.RunAccountAutoClean(taskCtx)
530
+ return nil
531
+ })
532
+ startBackground("statsig_warmup", func(taskCtx context.Context) error {
533
+ a.runStatsigWarmup(taskCtx)
534
+ return nil
535
+ })
536
+ startBackground("web_quota_startup_catchup", func(taskCtx context.Context) error {
537
+ a.runWebQuotaCatchup(taskCtx)
538
+ return nil
539
+ })
540
+ startBackground("model_catalog_startup_catchup", func(taskCtx context.Context) error {
541
+ a.runModelCatalogCatchup(taskCtx)
542
+ return nil
543
+ })
544
+ startBackground("video_recovery", func(taskCtx context.Context) error {
545
+ a.gateway.RunVideoRecovery(taskCtx)
546
+ return nil
547
+ })
548
+ startBackground("video_workers", func(taskCtx context.Context) error {
549
+ a.gateway.RunVideoWorkers(taskCtx)
550
+ return nil
551
+ })
552
+ startBackground("media_cleanup", func(taskCtx context.Context) error {
553
+ a.media.RunCleanup(taskCtx, func(err error) {
554
+ a.logger.Warn("media_cleanup_failed", "error", err)
555
+ })
556
+ return nil
557
+ })
558
+ startBackground("clearance_refresh", func(taskCtx context.Context) error {
559
+ if err := a.egress.RefreshDueClearances(taskCtx, false); err != nil {
560
+ a.logger.Warn("clearance_initial_refresh_failed", "error", err)
561
+ }
562
+ a.runPeriodicTask(taskCtx, time.Minute, "clearance_refresh", func(runCtx context.Context) error {
563
+ if err := a.egress.RefreshDueClearances(runCtx, false); err != nil {
564
+ a.logger.Warn("clearance_refresh_failed", "error", err)
565
+ }
566
+ return nil
567
+ })
568
+ return nil
569
+ })
570
+ startBackground("egress_operations", func(taskCtx context.Context) error {
571
+ if err := a.egressOps.RunMaintenance(taskCtx); err != nil {
572
+ a.logger.Warn("egress_operations_initial_run_failed", "error", err)
573
+ }
574
+ a.runPeriodicTask(taskCtx, time.Minute, "egress_operations", a.egressOps.RunMaintenance)
575
+ return nil
576
+ })
577
+ if a.settingsBus != nil {
578
+ startBackground("settings_change_listener", func(taskCtx context.Context) error {
579
+ return a.settingsBus.ListenSettingsChanges(taskCtx, func(eventCtx context.Context) error {
580
+ reloadCtx, cancel := context.WithTimeout(eventCtx, 5*time.Second)
581
+ defer cancel()
582
+ if err := a.settings.ReloadPersisted(reloadCtx); err != nil {
583
+ a.logger.Warn("settings_reload_failed", "error", err)
584
+ }
585
+ return nil
586
+ })
587
+ })
588
+ }
589
+ a.queueDueWebQuotaRefresh(runCtx)
590
+ select {
591
+ case <-ctx.Done():
592
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
593
+ defer cancel()
594
+ if err := a.server.Shutdown(shutdownCtx); err != nil {
595
+ return fmt.Errorf("关闭 HTTP 服务: %w", err)
596
+ }
597
+ return nil
598
+ case err := <-errCh:
599
+ if errors.Is(err, http.ErrServerClosed) {
600
+ return nil
601
+ }
602
+ return err
603
+ }
604
+ }
605
+
606
+ func (a *Application) cleanupExpiredResponses(ctx context.Context, now time.Time) error {
607
+ cleanupCtx, cancel := context.WithTimeout(ctx, responseCleanupBudget)
608
+ defer cancel()
609
+ if a.cleanupLock != nil {
610
+ release, acquired, err := a.cleanupLock.Acquire(cleanupCtx, "response-ownership-cleanup", responseCleanupLockTTL)
611
+ if err != nil {
612
+ return err
613
+ }
614
+ if !acquired {
615
+ return nil
616
+ }
617
+ defer release()
618
+ }
619
+ var totalOwnership, totalWebState int64
620
+ for range responseCleanupMaxBatches {
621
+ if err := cleanupCtx.Err(); err != nil {
622
+ if ctx.Err() != nil {
623
+ return ctx.Err()
624
+ }
625
+ a.recordResponseCleanup(totalOwnership, totalWebState, true)
626
+ return nil
627
+ }
628
+ result, err := a.responses.DeleteExpired(cleanupCtx, now, responseOwnershipCleanupBatchSize, webResponseStateCleanupBatchSize)
629
+ if err != nil {
630
+ if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
631
+ a.recordResponseCleanup(totalOwnership, totalWebState, true)
632
+ return nil
633
+ }
634
+ return err
635
+ }
636
+ totalOwnership += result.OwnershipDeleted
637
+ totalWebState += result.WebStateDeleted
638
+ if !result.HasMore {
639
+ a.recordResponseCleanup(totalOwnership, totalWebState, false)
640
+ return nil
641
+ }
642
+ }
643
+ a.recordResponseCleanup(totalOwnership, totalWebState, true)
644
+ return nil
645
+ }
646
+
647
+ func (a *Application) recordResponseCleanup(ownershipDeleted, webStateDeleted int64, backlog bool) {
648
+ outcome := "complete"
649
+ if backlog {
650
+ outcome = "backlog"
651
+ a.logger.Warn("response_cleanup_backlog", "ownership_deleted", ownershipDeleted, "web_state_deleted", webStateDeleted)
652
+ }
653
+ labels := perfmetrics.Labels{Subsystem: "response", Operation: "cleanup", Outcome: outcome}
654
+ perfmetrics.Default.Add("response_cleanup_ownership_rows", labels, ownershipDeleted)
655
+ perfmetrics.Default.Add("response_cleanup_web_state_rows", labels, webStateDeleted)
656
+ }
657
+
658
+ func (a *Application) logPerformanceMetrics() {
659
+ stats := a.database.Stats()
660
+ databaseLabels := perfmetrics.Labels{Subsystem: "database", Operation: a.database.Dialect()}
661
+ perfmetrics.Default.SetGauge("db_open_connections", databaseLabels, int64(stats.OpenConnections))
662
+ perfmetrics.Default.SetGauge("db_in_use_connections", databaseLabels, int64(stats.InUse))
663
+ perfmetrics.Default.SetGauge("db_idle_connections", databaseLabels, int64(stats.Idle))
664
+ perfmetrics.Default.SetGauge("db_wait_count", databaseLabels, stats.WaitCount)
665
+ perfmetrics.Default.SetGauge("db_wait_duration_us", databaseLabels, stats.WaitDuration.Microseconds())
666
+ if a.audits != nil {
667
+ a.audits.LedgerSnapshot()
668
+ }
669
+ if a.accounts != nil {
670
+ quota := a.accounts.QuotaRefreshStats()
671
+ labels := perfmetrics.Labels{Subsystem: "quota", Operation: "refresh"}
672
+ perfmetrics.Default.SetGauge("quota_refresh_pending", labels, int64(quota.Pending))
673
+ perfmetrics.Default.SetGauge("quota_refresh_queued", labels, int64(quota.Queued))
674
+ perfmetrics.Default.SetGauge("quota_refresh_running", labels, int64(quota.Running))
675
+ }
676
+ for _, sample := range perfmetrics.Default.CollectAndReset() {
677
+ a.logger.Info("performance_metric",
678
+ "name", sample.Name,
679
+ "subsystem", sample.Labels.Subsystem,
680
+ "operation", sample.Labels.Operation,
681
+ "provider", sample.Labels.Provider,
682
+ "plane", sample.Labels.Plane,
683
+ "stage", sample.Labels.Stage,
684
+ "ordinal", sample.Labels.Ordinal,
685
+ "outcome", sample.Labels.Outcome,
686
+ "count", sample.Count,
687
+ "total", sample.Total,
688
+ "maximum", sample.Maximum,
689
+ "gauge", sample.Gauge,
690
+ "has_gauge", sample.HasGauge,
691
+ )
692
+ }
693
+ }
694
+
695
+ func (a *Application) Close() error {
696
+ var runtimeErr error
697
+ if a.runtime != nil {
698
+ runtimeErr = a.runtime.Close()
699
+ }
700
+ return errors.Join(runtimeErr, a.database.Close())
701
+ }
702
+
703
+ func (a *Application) runPeriodicTask(ctx context.Context, interval time.Duration, name string, task func(context.Context) error) {
704
+ timer := time.NewTimer(interval)
705
+ defer timer.Stop()
706
+ for {
707
+ select {
708
+ case <-ctx.Done():
709
+ return
710
+ case <-timer.C:
711
+ runCtx, cancel := context.WithTimeout(ctx, minDuration(interval, 5*time.Minute))
712
+ err := task(runCtx)
713
+ cancel()
714
+ if err != nil {
715
+ a.logger.Warn(name+"_failed", "error", err)
716
+ }
717
+ resetTimer(timer, interval)
718
+ }
719
+ }
720
+ }
721
+
722
+ func (a *Application) runSupervisedTask(ctx context.Context, name string, task func(context.Context) error) {
723
+ backoff := time.Second
724
+ for {
725
+ err := batch.Do(ctx, task)
726
+ if ctx.Err() != nil {
727
+ return
728
+ }
729
+ if err == nil {
730
+ err = errors.New("后台任务意外退出")
731
+ }
732
+ var panicErr *batch.PanicError
733
+ if errors.As(err, &panicErr) {
734
+ a.logger.Error("background_task_restarting", "task", name, "backoff", backoff, "error", panicErr, "stack", string(panicErr.Stack))
735
+ } else {
736
+ a.logger.Error("background_task_restarting", "task", name, "backoff", backoff, "error", err)
737
+ }
738
+ timer := time.NewTimer(backoff)
739
+ select {
740
+ case <-ctx.Done():
741
+ timer.Stop()
742
+ return
743
+ case <-timer.C:
744
+ }
745
+ backoff = min(backoff*2, 30*time.Second)
746
+ }
747
+ }
748
+
749
+ func resetTimer(timer *time.Timer, interval time.Duration) {
750
+ if !timer.Stop() {
751
+ select {
752
+ case <-timer.C:
753
+ default:
754
+ }
755
+ }
756
+ timer.Reset(interval)
757
+ }
758
+
759
+ func minDuration(left, right time.Duration) time.Duration {
760
+ if left < right {
761
+ return left
762
+ }
763
+ return right
764
+ }
backend/internal/app/console_routes_test.go ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/chenyme/grok2api/backend/internal/domain/account"
8
+ consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console"
9
+ )
10
+
11
+ func TestConsoleRoutesUseStableProviderNamespace(t *testing.T) {
12
+ routes := consoleprovider.Routes()
13
+ if len(routes) == 0 {
14
+ t.Fatal("console catalog is empty")
15
+ }
16
+ seen := make(map[string]bool, len(routes))
17
+ for _, route := range routes {
18
+ if route.Provider != account.ProviderConsole || !strings.HasPrefix(route.PublicID, "Console/") {
19
+ t.Fatalf("non-canonical console route = %#v", route)
20
+ }
21
+ if seen[route.PublicID] {
22
+ t.Fatalf("duplicate console public id %q", route.PublicID)
23
+ }
24
+ seen[route.PublicID] = true
25
+ }
26
+ if seen["Console/grok-4.3-console"] {
27
+ t.Fatal("legacy conflict suffix leaked into canonical Console model IDs")
28
+ }
29
+ if !seen["Console/grok-4.3"] {
30
+ t.Fatal("canonical Console/grok-4.3 route is missing")
31
+ }
32
+ }
backend/internal/app/startup.go ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "sync"
8
+ "time"
9
+
10
+ accountapp "github.com/chenyme/grok2api/backend/internal/application/account"
11
+ auditapp "github.com/chenyme/grok2api/backend/internal/application/audit"
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
14
+ "github.com/chenyme/grok2api/backend/internal/repository"
15
+ httpserver "github.com/chenyme/grok2api/backend/internal/transport/http"
16
+ )
17
+
18
+ const (
19
+ startupRecoveryBudget = 20 * time.Second
20
+ startupCriticalWindow = 2 * time.Minute
21
+ startupCriticalLimit = 100
22
+ statsigWarmupInterval = 15 * time.Minute
23
+ webQuotaStaleAfter = 30 * time.Minute
24
+ webQuotaCatchupEvery = 30 * time.Minute
25
+ modelCatalogStaleAfter = 24 * time.Hour
26
+ modelCatalogCatchupEvery = 6 * time.Hour
27
+ )
28
+
29
+ type startupReport struct {
30
+ StartedAt time.Time
31
+ CompletedAt *time.Time
32
+ Credentials accountapp.CredentialStartupReport
33
+ CooldownsRestored int
34
+ QuotaRecoveriesRestored int
35
+ DueWebQuotasQueued int
36
+ StatsigKeysWarmed int
37
+ StaleWebQuotasFound int
38
+ StaleWebQuotasSynced int
39
+ StaleModelCatalogsFound int
40
+ StaleModelCatalogsSynced int
41
+ ErrorCount int
42
+ }
43
+
44
+ type startupState struct {
45
+ mu sync.RWMutex
46
+ phase string
47
+ updatedAt time.Time
48
+ report startupReport
49
+ statsig httpserver.ReadinessComponent
50
+ }
51
+
52
+ func newStartupState(restoredQuotaRecoveries int) *startupState {
53
+ now := time.Now().UTC()
54
+ return &startupState{
55
+ phase: "booting",
56
+ updatedAt: now,
57
+ report: startupReport{
58
+ StartedAt: now,
59
+ QuotaRecoveriesRestored: restoredQuotaRecoveries,
60
+ },
61
+ statsig: httpserver.ReadinessComponent{State: "cold"},
62
+ }
63
+ }
64
+
65
+ func (s *startupState) setPhase(phase string) {
66
+ s.mu.Lock()
67
+ s.phase = phase
68
+ s.updatedAt = time.Now().UTC()
69
+ if phase == "running" {
70
+ completed := s.updatedAt
71
+ s.report.CompletedAt = &completed
72
+ }
73
+ s.mu.Unlock()
74
+ }
75
+
76
+ func (s *startupState) updateReport(update func(*startupReport)) {
77
+ s.mu.Lock()
78
+ update(&s.report)
79
+ s.updatedAt = time.Now().UTC()
80
+ s.mu.Unlock()
81
+ }
82
+
83
+ func (s *startupState) recordError(err error) {
84
+ if err == nil {
85
+ return
86
+ }
87
+ s.updateReport(func(report *startupReport) {
88
+ report.ErrorCount++
89
+ })
90
+ }
91
+
92
+ func (s *startupState) setStatsig(state, detail string, warmed int) {
93
+ s.mu.Lock()
94
+ s.statsig = httpserver.ReadinessComponent{State: state, Detail: detail}
95
+ if warmed > 0 {
96
+ s.report.StatsigKeysWarmed = warmed
97
+ }
98
+ s.updatedAt = time.Now().UTC()
99
+ s.mu.Unlock()
100
+ }
101
+
102
+ func (s *startupState) snapshot() (string, time.Time, startupReport, httpserver.ReadinessComponent) {
103
+ s.mu.RLock()
104
+ defer s.mu.RUnlock()
105
+ return s.phase, s.updatedAt, s.report, s.statsig
106
+ }
107
+
108
+ func (s *startupState) acceptsTraffic() bool {
109
+ s.mu.RLock()
110
+ defer s.mu.RUnlock()
111
+ return s.phase == "running"
112
+ }
113
+
114
+ func readinessSnapshot(
115
+ ctx context.Context,
116
+ state *startupState,
117
+ runtimeHealth func(context.Context) error,
118
+ models repository.ModelRepository,
119
+ accounts repository.AccountRepository,
120
+ providers *provider.Registry,
121
+ ledger *auditapp.Service,
122
+ ) httpserver.ReadinessSnapshot {
123
+ phase, updatedAt, report, statsig := state.snapshot()
124
+ snapshot := httpserver.ReadinessSnapshot{
125
+ Ready: false, State: phase, UpdatedAt: updatedAt, Startup: newReadinessStartupReport(report),
126
+ Components: map[string]httpserver.ReadinessComponent{
127
+ "runtime_store": {State: "unknown"},
128
+ "billing_ledger": {State: "unknown"},
129
+ "grok_build": {State: "unknown"},
130
+ "grok_web": {State: "unknown"},
131
+ "statsig": statsig,
132
+ },
133
+ }
134
+ if phase != "running" {
135
+ return snapshot
136
+ }
137
+ ledgerDegraded := false
138
+ healthCtx, cancel := context.WithTimeout(ctx, time.Second)
139
+ err := runtimeHealth(healthCtx)
140
+ cancel()
141
+ if err != nil {
142
+ snapshot.State = "not_ready"
143
+ snapshot.Components["runtime_store"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "运行态存储不可用"}
144
+ return snapshot
145
+ }
146
+ snapshot.Components["runtime_store"] = httpserver.ReadinessComponent{State: "ready"}
147
+ if ledger != nil {
148
+ ledgerState := ledger.LedgerSnapshot()
149
+ if ledgerState.Ready {
150
+ snapshot.Components["billing_ledger"] = httpserver.ReadinessComponent{State: "ready"}
151
+ } else {
152
+ detail := fmt.Sprintf("审计账本不可用;连续失败 %d 次,丢失 %d 条,队列 %d/%d", ledgerState.ConsecutiveFailures, ledgerState.Dropped, ledgerState.QueueDepth, ledgerState.QueueCapacity)
153
+ snapshot.Components["billing_ledger"] = httpserver.ReadinessComponent{State: "degraded", Detail: detail}
154
+ if ledgerState.Irrecoverable || ledgerState.Mode == auditapp.LedgerModeEnforce {
155
+ snapshot.State = "not_ready"
156
+ return snapshot
157
+ }
158
+ ledgerDegraded = true
159
+ }
160
+ }
161
+
162
+ routes, err := models.ListConfiguredEnabled(ctx)
163
+ if err != nil {
164
+ snapshot.State = "not_ready"
165
+ snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "模���路由读取失败"}
166
+ return snapshot
167
+ }
168
+ if len(routes) == 0 {
169
+ snapshot.State = "not_ready"
170
+ snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "unavailable", Detail: "没有启用的模型路由"}
171
+ return snapshot
172
+ }
173
+ snapshot.Components["model_routes"] = httpserver.ReadinessComponent{State: "ready", Detail: fmt.Sprintf("%d 条已启用路由", len(routes))}
174
+
175
+ required := make(map[accountdomain.Provider]bool, 3)
176
+ usable := make(map[accountdomain.Provider]bool, 3)
177
+ providerErrors := make(map[accountdomain.Provider]bool, 3)
178
+ now := time.Now().UTC()
179
+ for _, route := range routes {
180
+ required[route.Provider] = true
181
+ if usable[route.Provider] || route.SupportedAccounts == 0 {
182
+ continue
183
+ }
184
+ candidates, listErr := accounts.ListRoutingCandidates(ctx, route.Provider, route.ID, route.UpstreamModel, providers.QuotaMode(route.Provider, route.UpstreamModel))
185
+ if listErr != nil {
186
+ providerErrors[route.Provider] = true
187
+ continue
188
+ }
189
+ for _, candidate := range candidates {
190
+ if !startupCandidateUsable(candidate, now, providers) {
191
+ continue
192
+ }
193
+ material, materialErr := accounts.GetCredentialMaterial(ctx, candidate.Credential.ID, candidate.Credential.Provider)
194
+ if materialErr != nil {
195
+ if !errors.Is(materialErr, repository.ErrNotFound) {
196
+ providerErrors[route.Provider] = true
197
+ }
198
+ continue
199
+ }
200
+ if material.EncryptedAccessToken != "" {
201
+ usable[route.Provider] = true
202
+ break
203
+ }
204
+ }
205
+ }
206
+
207
+ readyProviders := 0
208
+ unavailableProviders := 0
209
+ for _, providerValue := range accountdomain.Providers() {
210
+ name := string(providerValue)
211
+ if !required[providerValue] {
212
+ snapshot.Components[name] = httpserver.ReadinessComponent{State: "disabled"}
213
+ continue
214
+ }
215
+ if usable[providerValue] {
216
+ readyProviders++
217
+ snapshot.Components[name] = httpserver.ReadinessComponent{State: "ready"}
218
+ continue
219
+ }
220
+ unavailableProviders++
221
+ detail := "当前没有可用于已启用路由的账号"
222
+ if providerErrors[providerValue] {
223
+ detail = "账号候选状态读取失败"
224
+ }
225
+ snapshot.Components[name] = httpserver.ReadinessComponent{State: "unavailable", Detail: detail}
226
+ }
227
+ if required[accountdomain.ProviderWeb] && usable[accountdomain.ProviderWeb] && statsig.State != "warm" {
228
+ component := snapshot.Components[string(accountdomain.ProviderWeb)]
229
+ if statsig.State == "warming" || statsig.State == "cold" {
230
+ component.State = "warming"
231
+ } else {
232
+ component.State = "degraded"
233
+ }
234
+ component.Detail = "Statsig 尚未完成预热;请求仍可按需刷新"
235
+ snapshot.Components[string(accountdomain.ProviderWeb)] = component
236
+ unavailableProviders++
237
+ }
238
+ if readyProviders == 0 {
239
+ snapshot.State = "not_ready"
240
+ return snapshot
241
+ }
242
+ snapshot.Ready = true
243
+ if unavailableProviders > 0 || ledgerDegraded {
244
+ snapshot.State = "degraded"
245
+ } else {
246
+ snapshot.State = "ready"
247
+ }
248
+ return snapshot
249
+ }
250
+
251
+ // newReadinessStartupReport 只公开稳定统计,不把启动错误原文暴露到无鉴权就绪端点。
252
+ func newReadinessStartupReport(report startupReport) *httpserver.ReadinessStartupReport {
253
+ return &httpserver.ReadinessStartupReport{
254
+ StartedAt: report.StartedAt,
255
+ CompletedAt: report.CompletedAt,
256
+ Credentials: httpserver.ReadinessCredentialReport{
257
+ SchedulesBackfilled: report.Credentials.SchedulesBackfilled,
258
+ CriticalFound: report.Credentials.CriticalFound,
259
+ Refreshed: report.Credentials.Refreshed,
260
+ Failed: report.Credentials.Failed,
261
+ },
262
+ CooldownsRestored: report.CooldownsRestored,
263
+ QuotaRecoveriesRestored: report.QuotaRecoveriesRestored,
264
+ DueWebQuotasQueued: report.DueWebQuotasQueued,
265
+ StatsigKeysWarmed: report.StatsigKeysWarmed,
266
+ StaleWebQuotasFound: report.StaleWebQuotasFound,
267
+ StaleWebQuotasSynced: report.StaleWebQuotasSynced,
268
+ StaleModelCatalogsFound: report.StaleModelCatalogsFound,
269
+ StaleModelCatalogsSynced: report.StaleModelCatalogsSynced,
270
+ ErrorCount: report.ErrorCount,
271
+ }
272
+ }
273
+
274
+ func startupCandidateUsable(candidate accountdomain.RoutingCandidate, now time.Time, providers *provider.Registry) bool {
275
+ credential := candidate.Credential
276
+ if credential.AuthType == "" || credential.AuthStatus != accountdomain.AuthStatusActive {
277
+ return false
278
+ }
279
+ refreshable := credential.AuthType == accountdomain.AuthTypeOAuth
280
+ if providers != nil {
281
+ refreshable = providers.SupportsCredentialRefresh(credential.Provider)
282
+ }
283
+ if refreshable && !credential.ExpiresAt.IsZero() && !now.Before(credential.ExpiresAt) {
284
+ return false
285
+ }
286
+ if credential.CooldownUntil != nil && now.Before(*credential.CooldownUntil) {
287
+ return false
288
+ }
289
+ if candidate.ModelCapabilityKnown && !candidate.SupportsModel {
290
+ return false
291
+ }
292
+ if candidate.ModelQuotaBlock != nil && now.Before(candidate.ModelQuotaBlock.CooldownUntil) {
293
+ return false
294
+ }
295
+ if candidate.QuotaRecovery != nil && candidate.QuotaRecovery.Status != accountdomain.QuotaRecoveryStatusActive {
296
+ return false
297
+ }
298
+ if candidate.Billing != nil && candidate.Billing.IsExhausted(credential.MinimumRemaining) {
299
+ return false
300
+ }
301
+ return candidate.QuotaWindow == nil || candidate.QuotaWindow.Remaining > 0
302
+ }
303
+
304
+ func (a *Application) reconcileStartup(ctx context.Context) {
305
+ a.startup.setPhase("reconciling")
306
+ recoveryCtx, cancel := context.WithTimeout(ctx, startupRecoveryBudget)
307
+ defer cancel()
308
+
309
+ if _, err := a.clientKeys.CleanupExpiredBilling(recoveryCtx, 1000); err != nil {
310
+ a.logger.Warn("billing_reservation_cleanup_failed", "error", err)
311
+ a.startup.recordError(err)
312
+ }
313
+ if err := a.gateway.RecoverVideoJobs(recoveryCtx); err != nil {
314
+ a.logger.Warn("video_job_recovery_failed", "error", err)
315
+ a.startup.recordError(err)
316
+ }
317
+ if _, err := a.accountRepo.PruneExpiredModelQuotaBlocks(recoveryCtx, time.Now().UTC(), 1000); err != nil {
318
+ a.logger.Warn("model_cooldown_cleanup_failed", "error", err)
319
+ a.startup.recordError(err)
320
+ }
321
+ for _, providerValue := range accountdomain.Providers() {
322
+ values, err := a.accountRepo.ListEnabled(recoveryCtx, providerValue)
323
+ if err != nil {
324
+ a.startup.recordError(err)
325
+ continue
326
+ }
327
+ now := time.Now().UTC()
328
+ a.startup.updateReport(func(report *startupReport) {
329
+ for _, value := range values {
330
+ if value.CooldownUntil != nil && now.Before(*value.CooldownUntil) {
331
+ report.CooldownsRestored++
332
+ }
333
+ }
334
+ })
335
+ }
336
+ report, err := a.accounts.RecoverCriticalCredentials(recoveryCtx, startupCriticalWindow, startupCriticalLimit)
337
+ a.startup.updateReport(func(startup *startupReport) { startup.Credentials = report })
338
+ if err != nil && ctx.Err() == nil {
339
+ a.logger.Warn("credential_startup_recovery_incomplete", "error", err, "found", report.CriticalFound, "refreshed", report.Refreshed, "failed", report.Failed)
340
+ a.startup.recordError(err)
341
+ }
342
+ a.startup.setPhase("running")
343
+ a.logger.Info("startup_reconciliation_completed", "credentials_backfilled", report.SchedulesBackfilled, "critical_found", report.CriticalFound, "credentials_refreshed", report.Refreshed, "credentials_failed", report.Failed)
344
+ }
345
+
346
+ func (a *Application) runStatsigWarmup(ctx context.Context) {
347
+ timer := time.NewTimer(0)
348
+ defer timer.Stop()
349
+ for {
350
+ select {
351
+ case <-ctx.Done():
352
+ return
353
+ case <-timer.C:
354
+ }
355
+ a.startup.setStatsig("warming", "正在预热共享签名", 0)
356
+ values, err := a.accountRepo.ListEnabled(ctx, accountdomain.ProviderWeb)
357
+ if err == nil && len(values) == 0 {
358
+ a.startup.setStatsig("disabled", "没有启用的 Grok Web 账号", 0)
359
+ } else if err == nil {
360
+ warmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
361
+ var warmed int
362
+ warmed, err = a.web.WarmStatsig(warmCtx, values[0])
363
+ cancel()
364
+ if err == nil {
365
+ a.startup.setStatsig("warm", "共享签名已预热", warmed)
366
+ }
367
+ }
368
+ if err != nil && ctx.Err() == nil {
369
+ a.logger.Warn("web_statsig_warmup_failed", "error", err)
370
+ a.startup.setStatsig("unavailable", "预热失败,将由请求按需重试", 0)
371
+ }
372
+ resetTimer(timer, statsigWarmupInterval)
373
+ }
374
+ }
375
+
376
+ func (a *Application) queueDueWebQuotaRefresh(ctx context.Context) {
377
+ windows, err := a.accounts.ListDueWebQuotaWindows(ctx, time.Now().UTC(), 1000)
378
+ if err != nil {
379
+ a.logger.Warn("web_quota_startup_catchup_failed", "error", err)
380
+ a.startup.recordError(err)
381
+ return
382
+ }
383
+ for _, window := range windows {
384
+ a.accounts.QueueWebQuotaRefresh(window.AccountID, window.Mode)
385
+ }
386
+ a.startup.updateReport(func(report *startupReport) { report.DueWebQuotasQueued = len(windows) })
387
+ if len(windows) > 0 {
388
+ a.logger.Info("web_quota_startup_catchup_queued", "count", len(windows))
389
+ }
390
+ }
391
+
392
+ func (a *Application) runWebQuotaCatchup(ctx context.Context) {
393
+ timer := time.NewTimer(5 * time.Second)
394
+ defer timer.Stop()
395
+ for {
396
+ select {
397
+ case <-ctx.Done():
398
+ return
399
+ case <-timer.C:
400
+ }
401
+ ids, err := a.accountRepo.ListStaleWebQuotaAccountIDs(ctx, time.Now().UTC().Add(-webQuotaStaleAfter), 100)
402
+ if err == nil && len(ids) > 0 {
403
+ runCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
404
+ var succeeded int
405
+ succeeded, _, err = a.accounts.SyncWebQuotaAccounts(runCtx, ids)
406
+ cancel()
407
+ a.startup.updateReport(func(report *startupReport) {
408
+ report.StaleWebQuotasFound = len(ids)
409
+ report.StaleWebQuotasSynced = succeeded
410
+ })
411
+ }
412
+ if err != nil && ctx.Err() == nil {
413
+ a.logger.Warn("web_quota_stale_catchup_failed", "error", err)
414
+ }
415
+ resetTimer(timer, webQuotaCatchupEvery)
416
+ }
417
+ }
418
+
419
+ func (a *Application) runModelCatalogCatchup(ctx context.Context) {
420
+ timer := time.NewTimer(20 * time.Second)
421
+ defer timer.Stop()
422
+ for {
423
+ select {
424
+ case <-ctx.Done():
425
+ return
426
+ case <-timer.C:
427
+ }
428
+ ids, err := a.modelRepo.ListStaleAccountSyncIDs(ctx, time.Now().UTC().Add(-modelCatalogStaleAfter), 100)
429
+ if err == nil && len(ids) > 0 {
430
+ runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
431
+ var succeeded int
432
+ succeeded, _, err = a.models.SyncAccounts(runCtx, ids)
433
+ cancel()
434
+ a.startup.updateReport(func(report *startupReport) {
435
+ report.StaleModelCatalogsFound = len(ids)
436
+ report.StaleModelCatalogsSynced = succeeded
437
+ })
438
+ }
439
+ if err != nil && ctx.Err() == nil {
440
+ a.logger.Warn("model_catalog_stale_catchup_failed", "error", err)
441
+ }
442
+ resetTimer(timer, modelCatalogCatchupEvery)
443
+ }
444
+ }
backend/internal/app/startup_test.go ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ modeldomain "github.com/chenyme/grok2api/backend/internal/domain/model"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
16
+ )
17
+
18
+ func TestReadinessStartupReportDoesNotExposeInternalErrors(t *testing.T) {
19
+ state := newStartupState(0)
20
+ state.recordError(errors.New("postgres://private-host/internal"))
21
+ _, _, report, _ := state.snapshot()
22
+ payload, err := json.Marshal(newReadinessStartupReport(report))
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if strings.Contains(string(payload), "private-host") || !strings.Contains(string(payload), `"errorCount":1`) {
27
+ t.Fatalf("public readiness leaked internal error: %s", payload)
28
+ }
29
+ }
30
+
31
+ func TestReadinessKeepsBuildReadyWhenWebIsUnavailable(t *testing.T) {
32
+ ctx := context.Background()
33
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "readiness.db"))
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ defer database.Close()
38
+ if err := database.InitializeSchema(ctx); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ accounts := relational.NewAccountRepository(database)
42
+ models := relational.NewModelRepository(database)
43
+ now := time.Now().UTC()
44
+ build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
45
+ Provider: accountdomain.ProviderBuild, Name: "build-ready", SourceKey: "build-ready",
46
+ EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Hour),
47
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1,
48
+ })
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if err := models.UpsertRoutes(ctx, []modeldomain.Route{
53
+ {PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true},
54
+ {PublicID: "web-model", Provider: accountdomain.ProviderWeb, UpstreamModel: "web-model", Capability: modeldomain.CapabilityChat, Enabled: true},
55
+ }); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ state := newStartupState(0)
62
+ state.setPhase("running")
63
+ state.setStatsig("unavailable", "test", 0)
64
+ snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil)
65
+ if !snapshot.Ready || snapshot.State != "degraded" {
66
+ t.Fatalf("snapshot = %#v", snapshot)
67
+ }
68
+ if snapshot.Components["grok_build"].State != "ready" || snapshot.Components["grok_web"].State != "unavailable" {
69
+ t.Fatalf("components = %#v", snapshot.Components)
70
+ }
71
+ }
72
+
73
+ func TestReadinessRejectsAccountWithoutAccessToken(t *testing.T) {
74
+ ctx := context.Background()
75
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "readiness-missing-access.db"))
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ defer database.Close()
80
+ if err := database.InitializeSchema(ctx); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ accounts := relational.NewAccountRepository(database)
84
+ models := relational.NewModelRepository(database)
85
+ now := time.Now().UTC()
86
+ build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
87
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth,
88
+ Name: "refresh-only", SourceKey: "refresh-only", EncryptedRefreshToken: "refresh",
89
+ ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1,
90
+ })
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ if err := models.UpsertRoutes(ctx, []modeldomain.Route{{
95
+ PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true,
96
+ }}); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ state := newStartupState(0)
103
+ state.setPhase("running")
104
+ snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil)
105
+ if snapshot.Ready || snapshot.Components["grok_build"].State != "unavailable" {
106
+ t.Fatalf("snapshot = %#v", snapshot)
107
+ }
108
+ }
109
+
110
+ func TestReadinessRestoresPersistedCooldownWithoutUpstreamProbe(t *testing.T) {
111
+ ctx := context.Background()
112
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "cooldown-readiness.db"))
113
+ if err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ defer database.Close()
117
+ if err := database.InitializeSchema(ctx); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ accounts := relational.NewAccountRepository(database)
121
+ models := relational.NewModelRepository(database)
122
+ now := time.Now().UTC()
123
+ cooldownUntil := now.Add(10 * time.Minute)
124
+ build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
125
+ Provider: accountdomain.ProviderBuild, Name: "cooling", SourceKey: "cooling",
126
+ EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Hour),
127
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1, CooldownUntil: &cooldownUntil,
128
+ })
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ if err := models.UpsertRoutes(ctx, []modeldomain.Route{{PublicID: "build-model", Provider: accountdomain.ProviderBuild, UpstreamModel: "build-model", Capability: modeldomain.CapabilityResponses, Enabled: true}}); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if err := models.ReplaceAccountCapabilities(ctx, build.ID, []string{"build-model"}, now); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ state := newStartupState(0)
139
+ state.setPhase("running")
140
+ snapshot := readinessSnapshot(ctx, state, func(context.Context) error { return nil }, models, accounts, provider.NewRegistry(), nil)
141
+ if snapshot.Ready || snapshot.State != "not_ready" || snapshot.Components["grok_build"].State != "unavailable" {
142
+ t.Fatalf("snapshot = %#v", snapshot)
143
+ }
144
+ }
backend/internal/app/topology.go ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+
10
+ "github.com/chenyme/grok2api/backend/internal/infra/config"
11
+ )
12
+
13
+ const sharedMediaMarkerName = ".grok2api-cluster"
14
+
15
+ // preflightDeployment validates the operator-declared shared media mount with a stable cluster marker and a read/write probe.
16
+ func preflightDeployment(cfg config.Config) error {
17
+ if cfg.Deployment.Replicas <= 1 {
18
+ return nil
19
+ }
20
+ directory := cfg.Media.Local.Path
21
+ markerPath := filepath.Join(directory, sharedMediaMarkerName)
22
+ want := strings.TrimSpace(cfg.Deployment.ClusterID) + "\n"
23
+ current, err := os.ReadFile(markerPath)
24
+ if errors.Is(err, os.ErrNotExist) {
25
+ file, createErr := os.OpenFile(markerPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
26
+ if createErr == nil {
27
+ if _, writeErr := file.WriteString(want); writeErr != nil {
28
+ _ = file.Close()
29
+ return fmt.Errorf("write shared media cluster marker: %w", writeErr)
30
+ }
31
+ if closeErr := file.Close(); closeErr != nil {
32
+ return fmt.Errorf("close shared media cluster marker: %w", closeErr)
33
+ }
34
+ current = []byte(want)
35
+ } else if errors.Is(createErr, os.ErrExist) {
36
+ current, err = os.ReadFile(markerPath)
37
+ if err != nil {
38
+ return fmt.Errorf("read shared media cluster marker: %w", err)
39
+ }
40
+ } else {
41
+ return fmt.Errorf("create shared media cluster marker: %w", createErr)
42
+ }
43
+ } else if err != nil {
44
+ return fmt.Errorf("read shared media cluster marker: %w", err)
45
+ }
46
+ if string(current) != want {
47
+ return fmt.Errorf("shared media cluster marker mismatch: configured cluster %q does not own %s", cfg.Deployment.ClusterID, markerPath)
48
+ }
49
+
50
+ probe, err := os.CreateTemp(directory, ".grok2api-preflight-")
51
+ if err != nil {
52
+ return fmt.Errorf("create shared media preflight file: %w", err)
53
+ }
54
+ probePath := probe.Name()
55
+ defer os.Remove(probePath)
56
+ payload := []byte(strings.TrimSpace(cfg.Deployment.InstanceID))
57
+ if _, err := probe.Write(payload); err != nil {
58
+ _ = probe.Close()
59
+ return fmt.Errorf("write shared media preflight file: %w", err)
60
+ }
61
+ if err := probe.Close(); err != nil {
62
+ return fmt.Errorf("close shared media preflight file: %w", err)
63
+ }
64
+ readBack, err := os.ReadFile(probePath)
65
+ if err != nil {
66
+ return fmt.Errorf("read shared media preflight file: %w", err)
67
+ }
68
+ if string(readBack) != string(payload) {
69
+ return errors.New("shared media preflight read-back mismatch")
70
+ }
71
+ return nil
72
+ }
backend/internal/app/topology_test.go ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+
8
+ "github.com/chenyme/grok2api/backend/internal/infra/config"
9
+ )
10
+
11
+ func TestPreflightDeploymentCreatesAndValidatesSharedMediaMarker(t *testing.T) {
12
+ directory := t.TempDir()
13
+ cfg := config.Config{
14
+ Deployment: config.DeploymentConfig{Replicas: 2, InstanceID: "replica-a", ClusterID: "cluster-a", SharedMedia: true},
15
+ Media: config.MediaConfig{Local: config.LocalMediaConfig{Path: directory}},
16
+ }
17
+ if err := preflightDeployment(cfg); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ marker, err := os.ReadFile(filepath.Join(directory, sharedMediaMarkerName))
21
+ if err != nil || string(marker) != "cluster-a\n" {
22
+ t.Fatalf("marker = %q, err = %v", marker, err)
23
+ }
24
+ cfg.Deployment.InstanceID = "replica-b"
25
+ if err := preflightDeployment(cfg); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ cfg.Deployment.ClusterID = "cluster-b"
29
+ if err := preflightDeployment(cfg); err == nil {
30
+ t.Fatal("expected cluster marker mismatch")
31
+ }
32
+ }
backend/internal/application/account/account_cleanup_test.go ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "fmt"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
16
+ )
17
+
18
+ func TestCleanupAccountsDeletesOnlySelectedCurrentStatuses(t *testing.T) {
19
+ ctx := context.Background()
20
+ now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
21
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-cleanup.db"))
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ t.Cleanup(func() { _ = database.Close() })
26
+ if err := database.InitializeSchema(ctx); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ token, err := cipher.Encrypt("cleanup-token")
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ repo := relational.NewAccountRepository(database)
38
+ service := NewService(repo, nil, nil, memory.NewStickyStore(), nil, cipher, nil)
39
+ service.now = func() time.Time { return now }
40
+
41
+ create := func(name string, providerValue accountdomain.Provider, mutate func(*accountdomain.Credential)) uint64 {
42
+ t.Helper()
43
+ value, _, createErr := repo.UpsertByIdentity(ctx, accountdomain.Credential{
44
+ Provider: providerValue, AuthType: accountdomain.AuthTypeSSO, Name: name, SourceKey: fmt.Sprintf("cleanup-%s", name),
45
+ EncryptedAccessToken: token, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
46
+ })
47
+ if createErr != nil {
48
+ t.Fatal(createErr)
49
+ }
50
+ if mutate != nil {
51
+ mutate(&value)
52
+ value, createErr = repo.Update(ctx, value)
53
+ if createErr != nil {
54
+ t.Fatal(createErr)
55
+ }
56
+ }
57
+ return value.ID
58
+ }
59
+
60
+ normalID := create("normal", accountdomain.ProviderBuild, nil)
61
+ disabledID := create("disabled", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { value.Enabled = false })
62
+ invalidID := create("invalid", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { value.AuthStatus = accountdomain.AuthStatusReauthRequired })
63
+ coolingID := create("cooling", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { until := now.Add(time.Hour); value.CooldownUntil = &until })
64
+ expiredCooldownID := create("expired-cooldown", accountdomain.ProviderBuild, func(value *accountdomain.Credential) { until := now.Add(-time.Hour); value.CooldownUntil = &until })
65
+ otherProviderID := create("web-disabled", accountdomain.ProviderWeb, func(value *accountdomain.Credential) { value.Enabled = false })
66
+
67
+ result, err := service.CleanupAccounts(ctx, accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled, CleanupStatusReauthRequired, CleanupStatusCooldown, CleanupStatusDisabled}, nil)
68
+ if err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ if result.Deleted != 3 || result.RootsDeleted != 3 || result.LinkedDeleted != 0 || result.Skipped != 0 {
72
+ t.Fatalf("result = %#v", result)
73
+ }
74
+ for _, id := range []uint64{disabledID, invalidID, coolingID} {
75
+ if _, err := repo.Get(ctx, id); err == nil {
76
+ t.Fatalf("account %d was not deleted", id)
77
+ }
78
+ }
79
+ for _, id := range []uint64{normalID, expiredCooldownID, otherProviderID} {
80
+ if _, err := repo.Get(ctx, id); err != nil {
81
+ t.Fatalf("account %d should remain: %v", id, err)
82
+ }
83
+ }
84
+ }
85
+
86
+ func TestCleanupAccountsRequiresStatus(t *testing.T) {
87
+ service := NewService(nil, nil, nil, nil, nil, nil, nil)
88
+ if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, nil, nil); err == nil {
89
+ t.Fatal("empty cleanup status unexpectedly succeeded")
90
+ }
91
+ // Linked targets cannot include the root provider or an invalid provider.
92
+ if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled}, []accountdomain.Provider{accountdomain.ProviderBuild}); err == nil {
93
+ t.Fatal("self-target cleanup unexpectedly succeeded")
94
+ }
95
+ if _, err := service.CleanupAccounts(context.Background(), accountdomain.ProviderBuild, []CleanupStatus{CleanupStatusDisabled}, []accountdomain.Provider{accountdomain.Provider("nope")}); err == nil {
96
+ t.Fatal("invalid target cleanup unexpectedly succeeded")
97
+ }
98
+ }
99
+
100
+ // Cleanup with linked targets removes peers regardless of peer state and reports exact counts.
101
+ func TestCleanupAccountsWithLinkedTargets(t *testing.T) {
102
+ ctx := context.Background()
103
+ repo, service := newLinkedDeleteTestService(t, "svc-cleanup-linked.db")
104
+ now := time.Now().UTC()
105
+ service.now = func() time.Time { return now }
106
+
107
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("7", 64), "u-cleanup")
108
+ // Mark the Web root invalid while keeping active peers to verify peer state is ignored.
109
+ web.AuthStatus = accountdomain.AuthStatusReauthRequired
110
+ if _, err := repo.Update(ctx, web); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ healthyWeb := mustUpsertLinked(t, repo, accountdomain.Credential{
114
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "healthy", SourceKey: "sso:" + strings.Repeat("8", 64),
115
+ })
116
+
117
+ result, err := service.CleanupAccounts(ctx, accountdomain.ProviderWeb, []CleanupStatus{CleanupStatusReauthRequired}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole})
118
+ if err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ if result.Deleted != 3 || result.RootsDeleted != 1 || result.LinkedDeleted != 2 || result.Skipped != 0 {
122
+ t.Fatalf("result = %#v", result)
123
+ }
124
+ assertAccountMissing(t, repo, web.ID)
125
+ assertAccountMissing(t, repo, build.ID)
126
+ assertAccountMissing(t, repo, console.ID)
127
+ assertAccountPresent(t, repo, healthyWeb.ID)
128
+ }
129
+
130
+ // Cleanup preview counts roots and linked targets without deleting rows.
131
+ func TestPreviewCleanupCountsWithoutDeleting(t *testing.T) {
132
+ ctx := context.Background()
133
+ repo, service := newLinkedDeleteTestService(t, "svc-cleanup-preview.db")
134
+ now := time.Now().UTC()
135
+ service.now = func() time.Time { return now }
136
+
137
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("9", 64), "u-preview")
138
+ web.Enabled = false
139
+ if _, err := repo.Update(ctx, web); err != nil {
140
+ t.Fatal(err)
141
+ }
142
+
143
+ preview, err := service.PreviewCleanup(ctx, accountdomain.ProviderWeb, []CleanupStatus{CleanupStatusDisabled, CleanupStatusCooldown}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole})
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if preview.RootsByStatus["disabled"] != 1 || preview.RootsByStatus["cooldown"] != 0 || preview.RootCount != 1 || preview.Total != 3 {
148
+ t.Fatalf("preview = %#v", preview)
149
+ }
150
+ assertAccountPresent(t, repo, web.ID)
151
+ assertAccountPresent(t, repo, build.ID)
152
+ assertAccountPresent(t, repo, console.ID)
153
+
154
+ if _, err := service.PreviewCleanup(ctx, accountdomain.ProviderWeb, nil, nil); err == nil {
155
+ t.Fatal("empty statuses preview unexpectedly succeeded")
156
+ }
157
+ }
backend/internal/application/account/auto_clean.go ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "github.com/chenyme/grok2api/backend/internal/repository"
9
+ )
10
+
11
+ // AutoCleanConfig 是账号自动清理策略;由 app 层从运行设置映射,不依赖 infra/config。
12
+ type AutoCleanConfig struct {
13
+ Enabled bool
14
+ Interval time.Duration
15
+ MinAge time.Duration
16
+ IncludeDisabled bool
17
+ }
18
+
19
+ const (
20
+ autoCleanReauthBatchSize = 100
21
+ autoCleanReauthMaxScans = 50
22
+ autoCleanReauthMaxDeletes = 10
23
+ autoCleanReauthLockKey = "account-auto-clean:reauth"
24
+ autoCleanReauthLockTTL = 5 * time.Minute
25
+ autoCleanReauthRunTimeout = 4 * time.Minute
26
+ autoCleanRuntimeWriteLimit = 3 * time.Second
27
+ )
28
+
29
+ // UpdateAutoCleanConfig 热更新账号自动清理策略。
30
+ // 仅在策略实际变化时唤醒调度器;唤醒只重排 timer,不会直接硬删。
31
+ func (s *Service) UpdateAutoCleanConfig(value AutoCleanConfig) {
32
+ value = normalizeAutoCleanConfig(value)
33
+ s.autoCleanMu.Lock()
34
+ if s.autoClean == value {
35
+ s.autoCleanMu.Unlock()
36
+ return
37
+ }
38
+ s.autoClean = value
39
+ s.autoCleanRevision++
40
+ s.autoCleanMu.Unlock()
41
+ select {
42
+ case s.autoCleanWake <- struct{}{}:
43
+ default:
44
+ }
45
+ }
46
+
47
+ func normalizeAutoCleanConfig(value AutoCleanConfig) AutoCleanConfig {
48
+ if value.Interval < time.Minute {
49
+ value.Interval = time.Minute
50
+ }
51
+ if value.Interval > time.Hour {
52
+ value.Interval = time.Hour
53
+ }
54
+ if value.MinAge < time.Minute {
55
+ value.MinAge = time.Minute
56
+ }
57
+ if value.MinAge > 30*24*time.Hour {
58
+ value.MinAge = 30 * 24 * time.Hour
59
+ }
60
+ return value
61
+ }
62
+
63
+ func (s *Service) autoCleanSnapshot() (AutoCleanConfig, uint64) {
64
+ s.autoCleanMu.RLock()
65
+ defer s.autoCleanMu.RUnlock()
66
+ return s.autoClean, s.autoCleanRevision
67
+ }
68
+
69
+ func (s *Service) autoCleanConfig() AutoCleanConfig {
70
+ value, _ := s.autoCleanSnapshot()
71
+ return value
72
+ }
73
+
74
+ func (s *Service) autoCleanRevisionCurrent(expected uint64, cfg AutoCleanConfig) bool {
75
+ current, revision := s.autoCleanSnapshot()
76
+ return revision == expected && current == cfg
77
+ }
78
+
79
+ func autoCleanInterval(cfg AutoCleanConfig) time.Duration {
80
+ if !cfg.Enabled {
81
+ return time.Hour
82
+ }
83
+ return normalizeAutoCleanConfig(cfg).Interval
84
+ }
85
+
86
+ // RunAccountAutoClean 在启用时周期性删除过期的 reauthRequired 账号;默认关闭。
87
+ // timer 绑定配置 revision,旧 timer 即使与热更新同时就绪也只能重排,不能使用新配置执行删除。
88
+ func (s *Service) RunAccountAutoClean(ctx context.Context) {
89
+ // NewService / 启动接线可能已向 wake 写入;启动时统一按最新快照排程。
90
+ select {
91
+ case <-s.autoCleanWake:
92
+ default:
93
+ }
94
+ cfg, scheduledRevision := s.autoCleanSnapshot()
95
+ timer := time.NewTimer(autoCleanInterval(cfg))
96
+ defer timer.Stop()
97
+ for {
98
+ select {
99
+ case <-ctx.Done():
100
+ return
101
+ case <-s.autoCleanWake:
102
+ cfg, scheduledRevision = s.autoCleanSnapshot()
103
+ resetCredentialRefreshTimer(timer, autoCleanInterval(cfg))
104
+ case <-timer.C:
105
+ current, revision := s.autoCleanSnapshot()
106
+ if revision == scheduledRevision && current.Enabled {
107
+ if err := s.runAutoCleanReauthRevision(ctx, current, revision); err != nil && ctx.Err() == nil {
108
+ s.logger.Warn("account_auto_clean_failed", "error", err)
109
+ }
110
+ }
111
+ cfg, scheduledRevision = s.autoCleanSnapshot()
112
+ resetCredentialRefreshTimer(timer, autoCleanInterval(cfg))
113
+ }
114
+ }
115
+ }
116
+
117
+ // runAutoCleanReauth 保留给同包测试与维护调用;生产调度使用带 revision 的实现。
118
+ func (s *Service) runAutoCleanReauth(ctx context.Context, cfg AutoCleanConfig) error {
119
+ _, revision := s.autoCleanSnapshot()
120
+ return s.runAutoCleanReauthRevision(ctx, cfg, revision)
121
+ }
122
+
123
+ func (s *Service) runAutoCleanReauthRevision(ctx context.Context, cfg AutoCleanConfig, revision uint64) error {
124
+ if !cfg.Enabled || !s.autoCleanRevisionCurrent(revision, cfg) {
125
+ return nil
126
+ }
127
+ runCtx, cancel := context.WithTimeout(ctx, autoCleanReauthRunTimeout)
128
+ defer cancel()
129
+ if s.refreshLock != nil {
130
+ release, acquired, err := s.refreshLock.Acquire(runCtx, autoCleanReauthLockKey, autoCleanReauthLockTTL)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ if !acquired {
135
+ s.logger.Debug("account_auto_clean_skipped", "reason", "lock_contended")
136
+ return nil
137
+ }
138
+ if release != nil {
139
+ defer release()
140
+ }
141
+ }
142
+
143
+ markedBefore := s.now().Add(-cfg.MinAge)
144
+ var afterID uint64
145
+ scanned := 0
146
+ deleted := 0
147
+ skipped := 0
148
+ activeSkipped := 0
149
+ scanBatches := 0
150
+ deleteBatches := 0
151
+ limitReached := false
152
+ exhausted := false
153
+ for scanBatches < autoCleanReauthMaxScans && deleteBatches < autoCleanReauthMaxDeletes {
154
+ if !s.autoCleanRevisionCurrent(revision, cfg) {
155
+ break
156
+ }
157
+ candidates, err := s.accounts.ListAutoCleanReauthCandidates(runCtx, markedBefore, cfg.IncludeDisabled, afterID, autoCleanReauthBatchSize)
158
+ if err != nil {
159
+ if scanned > 0 || deleted > 0 || skipped > 0 {
160
+ s.logger.Warn("auto_clean_reauth_partial", "deleted", deleted, "scanned", scanned, "skipped", skipped, "error", err)
161
+ }
162
+ return err
163
+ }
164
+ if len(candidates) == 0 {
165
+ exhausted = true
166
+ break
167
+ }
168
+ scanBatches++
169
+ scanned += len(candidates)
170
+ afterID = candidates[len(candidates)-1]
171
+ deletable, active, err := s.excludeAccountsWithActiveLeases(runCtx, candidates)
172
+ if err != nil {
173
+ return err
174
+ }
175
+ activeSkipped += active
176
+ skipped += active
177
+ if !s.autoCleanRevisionCurrent(revision, cfg) {
178
+ break
179
+ }
180
+ if len(deletable) == 0 {
181
+ if len(candidates) < autoCleanReauthBatchSize {
182
+ exhausted = true
183
+ break
184
+ }
185
+ continue
186
+ }
187
+ deleteBatches++
188
+ ids, err := s.accounts.DeleteAutoCleanReauthCandidates(runCtx, markedBefore, cfg.IncludeDisabled, deletable)
189
+ if err != nil {
190
+ if scanned > 0 || deleted > 0 || skipped > 0 {
191
+ s.logger.Warn("auto_clean_reauth_partial", "deleted", deleted, "scanned", scanned, "skipped", skipped, "error", err)
192
+ }
193
+ return err
194
+ }
195
+ deleted += len(ids)
196
+ skipped += len(deletable) - len(ids)
197
+ if len(ids) > 0 {
198
+ s.logger.Debug("auto_clean_reauth_batch_deleted", "account_ids", ids)
199
+ if failures, cleanupErr := s.clearDeletedAccountRuntimeState(runCtx, ids); cleanupErr != nil {
200
+ s.logger.Warn("auto_clean_reauth_runtime_cleanup_failed", "failures", failures, "error", cleanupErr)
201
+ }
202
+ }
203
+ if len(candidates) < autoCleanReauthBatchSize {
204
+ exhausted = true
205
+ break
206
+ }
207
+ }
208
+ limitReached = !exhausted && (scanBatches == autoCleanReauthMaxScans || deleteBatches == autoCleanReauthMaxDeletes)
209
+ if deleted > 0 {
210
+ s.invalidateBuildBotFlagCache()
211
+ }
212
+ if scanned > 0 || deleted > 0 || skipped > 0 {
213
+ s.logger.Info("auto_clean_reauth", "deleted", deleted, "scanned", scanned, "skipped", skipped, "active_skipped", activeSkipped, "scan_batches", scanBatches, "delete_batches", deleteBatches, "limit_reached", limitReached, "min_age", cfg.MinAge.String(), "include_disabled", cfg.IncludeDisabled)
214
+ }
215
+ return nil
216
+ }
217
+
218
+ func (s *Service) excludeAccountsWithActiveLeases(ctx context.Context, ids []uint64) ([]uint64, int, error) {
219
+ if len(ids) == 0 || s.concurrency == nil {
220
+ return append([]uint64(nil), ids...), 0, nil
221
+ }
222
+ keys := make([]string, len(ids))
223
+ for index, id := range ids {
224
+ keys[index] = repository.AccountConcurrencyKey(id)
225
+ }
226
+ values := make(map[string]int, len(keys))
227
+ if reader, ok := s.concurrency.(repository.ConcurrencySnapshotReader); ok {
228
+ current, err := reader.CurrentMany(ctx, keys)
229
+ if err != nil {
230
+ return nil, 0, err
231
+ }
232
+ values = current
233
+ } else {
234
+ for _, key := range keys {
235
+ current, err := s.concurrency.Current(ctx, key)
236
+ if err != nil {
237
+ return nil, 0, err
238
+ }
239
+ values[key] = current
240
+ }
241
+ }
242
+ deletable := make([]uint64, 0, len(ids))
243
+ active := 0
244
+ for index, id := range ids {
245
+ if values[keys[index]] > 0 {
246
+ active++
247
+ continue
248
+ }
249
+ deletable = append(deletable, id)
250
+ }
251
+ return deletable, active, nil
252
+ }
253
+
254
+ func (s *Service) clearDeletedAccountRuntimeState(ctx context.Context, ids []uint64) (int, error) {
255
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), autoCleanRuntimeWriteLimit)
256
+ defer cancel()
257
+ failures, firstErr := s.deleteStickyAccounts(cleanupCtx, ids)
258
+ for _, id := range ids {
259
+ s.clearRefreshState(id)
260
+ }
261
+ if failures == 0 {
262
+ return 0, nil
263
+ }
264
+ return failures, fmt.Errorf("清理已删除账号的会话粘滞状态失败: %w", firstErr)
265
+ }
backend/internal/application/account/auto_clean_test.go ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+
10
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
11
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
12
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
13
+ "github.com/chenyme/grok2api/backend/internal/repository"
14
+ )
15
+
16
+ func TestAutoCleanReauthRespectsMinAgeAndIncludeDisabled(t *testing.T) {
17
+ ctx := context.Background()
18
+ now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
19
+ service, repo := newAutoCleanTestService(t, now)
20
+
21
+ aged := mustUpsert(t, repo, accountdomain.Credential{
22
+ Provider: accountdomain.ProviderBuild, Name: "aged-reauth", SourceKey: "aged-reauth",
23
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
24
+ ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)),
25
+ })
26
+ fresh := mustUpsert(t, repo, accountdomain.Credential{
27
+ Provider: accountdomain.ProviderBuild, Name: "fresh-reauth", SourceKey: "fresh-reauth",
28
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
29
+ ReauthMarkedAt: ptrTime(now.Add(-10 * time.Minute)),
30
+ })
31
+ activePermanent := mustUpsert(t, repo, accountdomain.Credential{
32
+ Provider: accountdomain.ProviderBuild, Name: "active-permanent", SourceKey: "active-permanent",
33
+ EncryptedAccessToken: "x", EncryptedRefreshToken: "r", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
34
+ RefreshPermanent: true, ExpiresAt: now.Add(time.Hour),
35
+ })
36
+ cooldownUntil := now.Add(time.Hour)
37
+ cooldown := mustUpsert(t, repo, accountdomain.Credential{
38
+ Provider: accountdomain.ProviderBuild, Name: "cooldown", SourceKey: "cooldown",
39
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
40
+ CooldownUntil: &cooldownUntil,
41
+ })
42
+ disabledAged := mustUpsert(t, repo, accountdomain.Credential{
43
+ Provider: accountdomain.ProviderBuild, Name: "disabled-aged", SourceKey: "disabled-aged",
44
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
45
+ ReauthMarkedAt: ptrTime(now.Add(-3 * time.Hour)),
46
+ })
47
+ disabledAged.Enabled = false
48
+ var err error
49
+ disabledAged, err = repo.Update(ctx, disabledAged)
50
+ if err != nil {
51
+ t.Fatal(err)
52
+ }
53
+
54
+ // Flag off is a no-op.
55
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
56
+ Enabled: false, Interval: 10 * time.Minute, MinAge: time.Hour,
57
+ })
58
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ assertPresent(t, repo, aged.ID)
62
+ assertPresent(t, repo, fresh.ID)
63
+ assertPresent(t, repo, disabledAged.ID)
64
+
65
+ // Enabled without include-disabled: only aged enabled reauth is deleted.
66
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
67
+ Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour, IncludeDisabled: false,
68
+ })
69
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ assertMissing(t, repo, aged.ID)
73
+ assertPresent(t, repo, fresh.ID)
74
+ assertPresent(t, repo, activePermanent.ID)
75
+ assertPresent(t, repo, cooldown.ID)
76
+ assertPresent(t, repo, disabledAged.ID)
77
+
78
+ // Include disabled: aged disabled reauth is deleted; fresh remains.
79
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
80
+ Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour, IncludeDisabled: true,
81
+ })
82
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ assertMissing(t, repo, disabledAged.ID)
86
+ assertPresent(t, repo, fresh.ID)
87
+ assertPresent(t, repo, activePermanent.ID)
88
+ assertPresent(t, repo, cooldown.ID)
89
+
90
+ // Advance clock past minAge for the remaining fresh reauth.
91
+ service.now = func() time.Time { return now.Add(2 * time.Hour) }
92
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ assertMissing(t, repo, fresh.ID)
96
+ assertPresent(t, repo, activePermanent.ID)
97
+ assertPresent(t, repo, cooldown.ID)
98
+ }
99
+
100
+ func TestMarkReauthRequiredSetsAnchorAndEditDoesNotReset(t *testing.T) {
101
+ ctx := context.Background()
102
+ now := time.Date(2026, 7, 20, 15, 0, 0, 0, time.UTC)
103
+ service, repo := newAutoCleanTestService(t, now)
104
+
105
+ value := mustUpsert(t, repo, accountdomain.Credential{
106
+ Provider: accountdomain.ProviderBuild, Name: "anchor", SourceKey: "anchor",
107
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
108
+ })
109
+ if err := service.MarkReauthRequired(ctx, value.ID, "token rejected"); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ marked, err := repo.Get(ctx, value.ID)
113
+ if err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ if marked.AuthStatus != accountdomain.AuthStatusReauthRequired || marked.ReauthMarkedAt == nil {
117
+ t.Fatalf("expected reauth anchor, got %#v", marked)
118
+ }
119
+ anchor := *marked.ReauthMarkedAt
120
+
121
+ // Ordinary edit must not reset reauth_marked_at.
122
+ marked.Name = "anchor-renamed"
123
+ if _, err := repo.Update(ctx, marked); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ afterEdit, err := repo.Get(ctx, value.ID)
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if afterEdit.ReauthMarkedAt == nil || !afterEdit.ReauthMarkedAt.Equal(anchor) {
131
+ t.Fatalf("reauth_marked_at reset by edit: before=%s after=%v", anchor, afterEdit.ReauthMarkedAt)
132
+ }
133
+ }
134
+
135
+ func TestAutoCleanReauthMultiBatch(t *testing.T) {
136
+ ctx := context.Background()
137
+ now := time.Date(2026, 7, 20, 18, 0, 0, 0, time.UTC)
138
+ service, repo := newAutoCleanTestService(t, now)
139
+
140
+ const total = 105
141
+ ids := make([]uint64, 0, total)
142
+ for i := 0; i < total; i++ {
143
+ value := mustUpsert(t, repo, accountdomain.Credential{
144
+ Provider: accountdomain.ProviderBuild, Name: "batch-" + itoa(i), SourceKey: "batch-" + itoa(i),
145
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
146
+ ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)),
147
+ })
148
+ ids = append(ids, value.ID)
149
+ }
150
+
151
+ // 直接验证 repo 分批:第一批最多 100,且 nextAfter 前进。
152
+ candidates, err := repo.ListAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, 0, 100)
153
+ if err != nil {
154
+ t.Fatal(err)
155
+ }
156
+ deleted, err := repo.DeleteAutoCleanReauthCandidates(ctx, now.Add(-time.Hour), false, candidates)
157
+ if err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ if len(candidates) != 100 || len(deleted) != 100 || candidates[len(candidates)-1] == 0 {
161
+ t.Fatalf("first batch candidates=%d deleted=%d nextAfter=%d", len(candidates), len(deleted), candidates[len(candidates)-1])
162
+ }
163
+ remaining := 0
164
+ for _, id := range ids {
165
+ if _, getErr := repo.Get(ctx, id); getErr == nil {
166
+ remaining++
167
+ }
168
+ }
169
+ if remaining != 5 {
170
+ t.Fatalf("remaining after first batch = %d", remaining)
171
+ }
172
+
173
+ // 应用层应扫完全部剩余。
174
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
175
+ Enabled: true, Interval: 10 * time.Minute, MinAge: time.Hour,
176
+ })
177
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ for _, id := range ids {
181
+ assertMissing(t, repo, id)
182
+ }
183
+ }
184
+
185
+ func TestSecondMarkReauthKeepsOriginalAnchor(t *testing.T) {
186
+ ctx := context.Background()
187
+ now := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC)
188
+ service, repo := newAutoCleanTestService(t, now)
189
+ value := mustUpsert(t, repo, accountdomain.Credential{
190
+ Provider: accountdomain.ProviderBuild, Name: "second-mark", SourceKey: "second-mark",
191
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
192
+ })
193
+ if err := service.MarkReauthRequired(ctx, value.ID, "first"); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ first, err := repo.Get(ctx, value.ID)
197
+ if err != nil || first.ReauthMarkedAt == nil {
198
+ t.Fatalf("first mark = %#v err=%v", first, err)
199
+ }
200
+ anchor := *first.ReauthMarkedAt
201
+ time.Sleep(5 * time.Millisecond)
202
+ if err := service.MarkReauthRequired(ctx, value.ID, "second"); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ second, err := repo.Get(ctx, value.ID)
206
+ if err != nil || second.ReauthMarkedAt == nil || !second.ReauthMarkedAt.Equal(anchor) {
207
+ t.Fatalf("anchor reset: first=%s second=%v", anchor, second.ReauthMarkedAt)
208
+ }
209
+ }
210
+
211
+ func TestRunAccountAutoCleanDoesNotDeleteOnEnableOrWake(t *testing.T) {
212
+ now := time.Date(2026, 7, 20, 19, 0, 0, 0, time.UTC)
213
+ service, repo := newAutoCleanTestService(t, now)
214
+ aged := mustUpsert(t, repo, accountdomain.Credential{
215
+ Provider: accountdomain.ProviderBuild, Name: "wake-aged", SourceKey: "wake-aged",
216
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
217
+ ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)),
218
+ })
219
+
220
+ // 启用只写入配置并唤醒;本身不删除。
221
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
222
+ Enabled: true, Interval: time.Minute, MinAge: time.Hour,
223
+ })
224
+ assertPresent(t, repo, aged.ID)
225
+
226
+ ctx, cancel := context.WithCancel(context.Background())
227
+ done := make(chan struct{})
228
+ go func() {
229
+ defer close(done)
230
+ service.RunAccountAutoClean(ctx)
231
+ }()
232
+
233
+ // 启动时会 drain 启动前 wake 并 arm timer;热更 wake 只重排 timer。
234
+ time.Sleep(150 * time.Millisecond)
235
+ assertPresent(t, repo, aged.ID)
236
+
237
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
238
+ Enabled: true, Interval: time.Minute, MinAge: time.Hour, IncludeDisabled: true,
239
+ })
240
+ time.Sleep(150 * time.Millisecond)
241
+ assertPresent(t, repo, aged.ID)
242
+
243
+ cancel()
244
+ select {
245
+ case <-done:
246
+ case <-time.After(2 * time.Second):
247
+ t.Fatal("auto-clean scheduler did not stop")
248
+ }
249
+ assertPresent(t, repo, aged.ID)
250
+ }
251
+
252
+ func newAutoCleanTestService(t *testing.T, now time.Time) (*Service, *relational.AccountRepository) {
253
+ t.Helper()
254
+ ctx := context.Background()
255
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "auto-clean.db"))
256
+ if err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ t.Cleanup(func() { _ = database.Close() })
260
+ if err := database.InitializeSchema(ctx); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ repo := relational.NewAccountRepository(database)
264
+ service := NewService(repo, nil, nil, memory.NewStickyStore(), nil, nil, nil)
265
+ service.now = func() time.Time { return now }
266
+ return service, repo
267
+ }
268
+
269
+ func mustUpsert(t *testing.T, repo *relational.AccountRepository, value accountdomain.Credential) accountdomain.Credential {
270
+ t.Helper()
271
+ out, _, err := repo.UpsertByIdentity(context.Background(), value)
272
+ if err != nil {
273
+ t.Fatal(err)
274
+ }
275
+ return out
276
+ }
277
+
278
+ func assertMissing(t *testing.T, repo *relational.AccountRepository, id uint64) {
279
+ t.Helper()
280
+ if _, err := repo.Get(context.Background(), id); err == nil {
281
+ t.Fatalf("account %d still present", id)
282
+ } else if !errors.Is(err, repository.ErrNotFound) {
283
+ t.Fatalf("account %d get error: %v", id, err)
284
+ }
285
+ }
286
+
287
+ func assertPresent(t *testing.T, repo *relational.AccountRepository, id uint64) {
288
+ t.Helper()
289
+ if _, err := repo.Get(context.Background(), id); err != nil {
290
+ t.Fatalf("account %d missing or error: %v", id, err)
291
+ }
292
+ }
293
+
294
+ func ptrTime(value time.Time) *time.Time { return &value }
295
+
296
+ func itoa(value int) string {
297
+ if value == 0 {
298
+ return "0"
299
+ }
300
+ var buf [12]byte
301
+ i := len(buf)
302
+ for value > 0 {
303
+ i--
304
+ buf[i] = byte('0' + value%10)
305
+ value /= 10
306
+ }
307
+ return string(buf[i:])
308
+ }
309
+
310
+ func TestUpdateAutoCleanConfigClamps(t *testing.T) {
311
+ service, _ := newAutoCleanTestService(t, time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC))
312
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
313
+ Enabled: true, Interval: 30 * time.Second, MinAge: 10 * time.Second, IncludeDisabled: true,
314
+ })
315
+ cfg := service.autoCleanConfig()
316
+ if cfg.Interval != time.Minute || cfg.MinAge != time.Minute || !cfg.IncludeDisabled || !cfg.Enabled {
317
+ t.Fatalf("low clamp = %#v", cfg)
318
+ }
319
+ service.UpdateAutoCleanConfig(AutoCleanConfig{
320
+ Enabled: false, Interval: 2 * time.Hour, MinAge: 40 * 24 * time.Hour,
321
+ })
322
+ cfg = service.autoCleanConfig()
323
+ if cfg.Interval != time.Hour || cfg.MinAge != 30*24*time.Hour || cfg.Enabled {
324
+ t.Fatalf("high clamp = %#v", cfg)
325
+ }
326
+ if got := autoCleanInterval(AutoCleanConfig{Enabled: false, Interval: time.Minute}); got != time.Hour {
327
+ t.Fatalf("disabled interval = %s", got)
328
+ }
329
+ if got := autoCleanInterval(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute}); got != 5*time.Minute {
330
+ t.Fatalf("enabled interval = %s", got)
331
+ }
332
+ }
333
+
334
+ func TestAutoCleanSkipsActiveInferenceLease(t *testing.T) {
335
+ ctx := context.Background()
336
+ now := time.Date(2026, 7, 20, 22, 0, 0, 0, time.UTC)
337
+ service, repo := newAutoCleanTestService(t, now)
338
+ limiter := memory.NewConcurrencyLimiter()
339
+ service.SetConcurrencyLimiter(limiter)
340
+ value := mustUpsert(t, repo, accountdomain.Credential{
341
+ Provider: accountdomain.ProviderBuild, Name: "active-lease", SourceKey: "active-lease",
342
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
343
+ ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)),
344
+ })
345
+ release, acquired, err := limiter.Acquire(ctx, repository.AccountConcurrencyKey(value.ID), 1)
346
+ if err != nil || !acquired {
347
+ t.Fatalf("acquire lease: acquired=%v err=%v", acquired, err)
348
+ }
349
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour})
350
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ assertPresent(t, repo, value.ID)
354
+ release()
355
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
356
+ t.Fatal(err)
357
+ }
358
+ assertMissing(t, repo, value.ID)
359
+ }
360
+
361
+ func TestAutoCleanConfigRevisionRejectsOldTimerAndUnchangedUpdateDoesNotWake(t *testing.T) {
362
+ service, _ := newAutoCleanTestService(t, time.Date(2026, 7, 20, 23, 0, 0, 0, time.UTC))
363
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: time.Hour})
364
+ select {
365
+ case <-service.autoCleanWake:
366
+ default:
367
+ t.Fatal("initial config update did not wake scheduler")
368
+ }
369
+ cfg, revision := service.autoCleanSnapshot()
370
+ service.UpdateAutoCleanConfig(cfg)
371
+ select {
372
+ case <-service.autoCleanWake:
373
+ t.Fatal("unchanged config woke scheduler")
374
+ default:
375
+ }
376
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: 2 * time.Hour})
377
+ if service.autoCleanRevisionCurrent(revision, cfg) {
378
+ t.Fatal("old timer revision remained executable after config update")
379
+ }
380
+ }
381
+
382
+ type deniedAutoCleanLock struct{}
383
+
384
+ func (deniedAutoCleanLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) {
385
+ return nil, false, nil
386
+ }
387
+
388
+ func TestAutoCleanSkipsWhenDistributedLockIsHeld(t *testing.T) {
389
+ ctx := context.Background()
390
+ now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
391
+ service, repo := newAutoCleanTestService(t, now)
392
+ service.refreshLock = deniedAutoCleanLock{}
393
+ value := mustUpsert(t, repo, accountdomain.Credential{
394
+ Provider: accountdomain.ProviderBuild, Name: "locked", SourceKey: "locked",
395
+ EncryptedAccessToken: "x", Enabled: true, AuthStatus: accountdomain.AuthStatusReauthRequired,
396
+ ReauthMarkedAt: ptrTime(now.Add(-2 * time.Hour)),
397
+ })
398
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour})
399
+ if err := service.runAutoCleanReauth(ctx, service.autoCleanConfig()); err != nil {
400
+ t.Fatal(err)
401
+ }
402
+ assertPresent(t, repo, value.ID)
403
+ }
404
+
405
+ type endlessAutoCleanRepository struct {
406
+ repository.AccountRepository
407
+ listCalls int
408
+ deleteCalls int
409
+ deletedIDs []uint64
410
+ }
411
+
412
+ func (r *endlessAutoCleanRepository) ListAutoCleanReauthCandidates(_ context.Context, _ time.Time, _ bool, afterID uint64, _ int) ([]uint64, error) {
413
+ r.listCalls++
414
+ ids := make([]uint64, autoCleanReauthBatchSize)
415
+ for index := range ids {
416
+ ids[index] = afterID + uint64(index) + 1
417
+ }
418
+ return ids, nil
419
+ }
420
+
421
+ func (r *endlessAutoCleanRepository) DeleteAutoCleanReauthCandidates(_ context.Context, _ time.Time, _ bool, ids []uint64) ([]uint64, error) {
422
+ r.deleteCalls++
423
+ r.deletedIDs = append(r.deletedIDs, ids...)
424
+ return append([]uint64(nil), ids...), nil
425
+ }
426
+
427
+ func TestAutoCleanLimitsWorkPerTick(t *testing.T) {
428
+ repo := &endlessAutoCleanRepository{}
429
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
430
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour})
431
+ if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil {
432
+ t.Fatal(err)
433
+ }
434
+ if repo.listCalls != autoCleanReauthMaxDeletes || repo.deleteCalls != autoCleanReauthMaxDeletes {
435
+ t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls)
436
+ }
437
+ }
438
+
439
+ type activeKeyConcurrency struct {
440
+ active map[string]struct{}
441
+ all bool
442
+ }
443
+
444
+ func (*activeKeyConcurrency) Acquire(context.Context, string, int) (func(), bool, error) {
445
+ return func() {}, true, nil
446
+ }
447
+
448
+ func (l *activeKeyConcurrency) Current(_ context.Context, key string) (int, error) {
449
+ if l.all {
450
+ return 1, nil
451
+ }
452
+ if _, ok := l.active[key]; ok {
453
+ return 1, nil
454
+ }
455
+ return 0, nil
456
+ }
457
+
458
+ func (l *activeKeyConcurrency) CurrentMany(_ context.Context, keys []string) (map[string]int, error) {
459
+ values := make(map[string]int, len(keys))
460
+ for _, key := range keys {
461
+ current, _ := l.Current(context.Background(), key)
462
+ values[key] = current
463
+ }
464
+ return values, nil
465
+ }
466
+
467
+ func TestAutoCleanActiveOnlyPagesDoNotConsumeDeleteBudget(t *testing.T) {
468
+ repo := &endlessAutoCleanRepository{}
469
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
470
+ active := make(map[string]struct{}, 2*autoCleanReauthBatchSize)
471
+ for id := uint64(1); id <= 2*autoCleanReauthBatchSize; id++ {
472
+ active[repository.AccountConcurrencyKey(id)] = struct{}{}
473
+ }
474
+ service.SetConcurrencyLimiter(&activeKeyConcurrency{active: active})
475
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour})
476
+ if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil {
477
+ t.Fatal(err)
478
+ }
479
+ if repo.listCalls != autoCleanReauthMaxDeletes+2 || repo.deleteCalls != autoCleanReauthMaxDeletes {
480
+ t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls)
481
+ }
482
+ if len(repo.deletedIDs) == 0 || repo.deletedIDs[0] != 2*autoCleanReauthBatchSize+1 {
483
+ t.Fatalf("first deleted id=%v", repo.deletedIDs)
484
+ }
485
+ }
486
+
487
+ func TestAutoCleanActiveOnlySourceIsBoundedByScanBudget(t *testing.T) {
488
+ repo := &endlessAutoCleanRepository{}
489
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
490
+ service.SetConcurrencyLimiter(&activeKeyConcurrency{all: true})
491
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: time.Minute, MinAge: time.Hour})
492
+ if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil {
493
+ t.Fatal(err)
494
+ }
495
+ if repo.listCalls != autoCleanReauthMaxScans || repo.deleteCalls != 0 {
496
+ t.Fatalf("calls list=%d delete=%d", repo.listCalls, repo.deleteCalls)
497
+ }
498
+ }
499
+
500
+ type configChangingConcurrency struct {
501
+ service *Service
502
+ once bool
503
+ }
504
+
505
+ func (c *configChangingConcurrency) Acquire(context.Context, string, int) (func(), bool, error) {
506
+ return func() {}, true, nil
507
+ }
508
+
509
+ func (c *configChangingConcurrency) Current(context.Context, string) (int, error) {
510
+ if !c.once {
511
+ c.once = true
512
+ c.service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: 2 * time.Hour})
513
+ }
514
+ return 0, nil
515
+ }
516
+
517
+ func TestAutoCleanPolicyChangeAbortsBeforeDelete(t *testing.T) {
518
+ repo := &endlessAutoCleanRepository{}
519
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
520
+ service.SetConcurrencyLimiter(&configChangingConcurrency{service: service})
521
+ service.UpdateAutoCleanConfig(AutoCleanConfig{Enabled: true, Interval: 5 * time.Minute, MinAge: time.Hour})
522
+ if err := service.runAutoCleanReauth(context.Background(), service.autoCleanConfig()); err != nil {
523
+ t.Fatal(err)
524
+ }
525
+ if repo.deleteCalls != 0 {
526
+ t.Fatalf("delete calls after policy change=%d", repo.deleteCalls)
527
+ }
528
+ }
backend/internal/application/account/batch_quota_test.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "fmt"
7
+ "path/filepath"
8
+ "sync/atomic"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
16
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
17
+ )
18
+
19
+ func TestBatchRefreshQuotaSupportsWebAndConsole(t *testing.T) {
20
+ for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} {
21
+ t.Run(string(providerValue), func(t *testing.T) {
22
+ ctx := context.Background()
23
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota.db"))
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ t.Cleanup(func() { _ = database.Close() })
28
+ if err := database.InitializeSchema(ctx); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ encryptedToken, err := cipher.Encrypt("test-token")
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ repository := relational.NewAccountRepository(database)
40
+ ids := make([]uint64, 0, 2)
41
+ for index := 1; index <= 2; index++ {
42
+ value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{
43
+ Provider: providerValue, AuthType: accountdomain.AuthTypeSSO,
44
+ Name: fmt.Sprintf("account-%d", index), SourceKey: fmt.Sprintf("source-%d", index),
45
+ EncryptedAccessToken: encryptedToken, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
46
+ })
47
+ if createErr != nil {
48
+ t.Fatal(createErr)
49
+ }
50
+ ids = append(ids, value.ID)
51
+ }
52
+ adapter := &selectedQuotaAdapter{providerValue: providerValue}
53
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore())
54
+
55
+ succeeded, failed, err := service.BatchRefreshQuota(ctx, ids)
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ if succeeded != 2 || failed != 0 || adapter.calls.Load() != 2 {
60
+ t.Fatalf("succeeded=%d failed=%d calls=%d", succeeded, failed, adapter.calls.Load())
61
+ }
62
+ windows, err := repository.GetQuotaWindows(ctx, ids)
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ for _, id := range ids {
67
+ if len(windows[id]) != 1 || windows[id][0].Remaining != 7 {
68
+ t.Fatalf("account %d windows = %#v", id, windows[id])
69
+ }
70
+ }
71
+ })
72
+ }
73
+ }
74
+
75
+ type selectedQuotaAdapter struct {
76
+ providerValue accountdomain.Provider
77
+ calls atomic.Int64
78
+ }
79
+
80
+ func (a *selectedQuotaAdapter) Provider() accountdomain.Provider { return a.providerValue }
81
+
82
+ func (a *selectedQuotaAdapter) SyncQuota(_ context.Context, _ accountdomain.Credential) (provider.QuotaSnapshot, error) {
83
+ a.calls.Add(1)
84
+ now := time.Now().UTC()
85
+ return provider.QuotaSnapshot{
86
+ Tier: accountdomain.WebTierSuper, SyncedAt: now,
87
+ Windows: []accountdomain.QuotaWindow{{Mode: "default", Remaining: 7, Total: 10, SyncedAt: &now, UpdatedAt: now}},
88
+ }, nil
89
+ }
90
+
91
+ func (a *selectedQuotaAdapter) SyncQuotaMode(_ context.Context, _ accountdomain.Credential, mode string) (accountdomain.QuotaWindow, error) {
92
+ return accountdomain.QuotaWindow{Mode: mode}, nil
93
+ }
backend/internal/application/account/batch_update_test.go ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "slices"
7
+ "testing"
8
+
9
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
10
+ "github.com/chenyme/grok2api/backend/internal/repository"
11
+ )
12
+
13
+ type batchUpdateRepository struct {
14
+ repository.AccountRepository
15
+ updateErr error
16
+ updatedIDs []uint64
17
+ }
18
+
19
+ func (r *batchUpdateRepository) UpdateMany(_ context.Context, providerValue accountdomain.Provider, ids []uint64, _ repository.AccountUpdates) (int64, error) {
20
+ if providerValue != accountdomain.ProviderBuild {
21
+ return 0, errors.New("unexpected provider")
22
+ }
23
+ r.updatedIDs = append([]uint64(nil), ids...)
24
+ if r.updateErr != nil {
25
+ return 0, r.updateErr
26
+ }
27
+ return int64(len(ids)), nil
28
+ }
29
+
30
+ func TestBatchUpdateSupportsMoreThanAdminPageLimit(t *testing.T) {
31
+ ids := make([]uint64, 2501)
32
+ for index := range ids {
33
+ ids[index] = uint64(index + 1)
34
+ }
35
+ repo := &batchUpdateRepository{}
36
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
37
+ maxConcurrent := 3
38
+
39
+ updated, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent})
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if updated != int64(len(ids)) || !slices.Equal(repo.updatedIDs, ids) {
44
+ t.Fatalf("updated = %d, ids = %d", updated, len(repo.updatedIDs))
45
+ }
46
+ }
47
+
48
+ func TestBatchUpdatePreservesProviderMismatchSemantics(t *testing.T) {
49
+ ids := make([]uint64, 501)
50
+ for index := range ids {
51
+ ids[index] = uint64(index + 1)
52
+ }
53
+ repo := &batchUpdateRepository{updateErr: repository.ErrAccountPoolMismatch}
54
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
55
+ maxConcurrent := 3
56
+
57
+ _, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent})
58
+ if !errors.Is(err, ErrAccountPoolMismatch) {
59
+ t.Fatalf("error = %v, want account pool mismatch", err)
60
+ }
61
+ }
62
+
63
+ func TestBatchUpdateRetainsBoundedRequestSize(t *testing.T) {
64
+ ids := make([]uint64, maxBatchUpdateAccounts+1)
65
+ for index := range ids {
66
+ ids[index] = uint64(index + 1)
67
+ }
68
+ repo := &batchUpdateRepository{}
69
+ service := NewService(repo, nil, nil, nil, nil, nil, nil)
70
+ maxConcurrent := 3
71
+
72
+ _, err := service.BatchUpdate(context.Background(), accountdomain.ProviderBuild, ids, UpdateInput{MaxConcurrent: &maxConcurrent})
73
+ if !errors.Is(err, ErrInvalidInput) {
74
+ t.Fatalf("error = %v, want invalid input", err)
75
+ }
76
+ if len(repo.updatedIDs) != 0 {
77
+ t.Fatal("oversized update reached repository")
78
+ }
79
+ }
backend/internal/application/account/build_super_entitlement_test.go ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "errors"
7
+ "path/filepath"
8
+ "sync/atomic"
9
+ "testing"
10
+
11
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
12
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
15
+ "github.com/chenyme/grok2api/backend/internal/repository"
16
+ )
17
+
18
+ func openAccountService(t *testing.T) (*Service, repository.AccountRepository) {
19
+ t.Helper()
20
+ ctx := context.Background()
21
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "build-super.db"))
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ t.Cleanup(func() { _ = database.Close() })
26
+ if err := database.InitializeSchema(ctx); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ accounts := relational.NewAccountRepository(database)
30
+ audits := relational.NewAuditRepository(database)
31
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ service := NewService(accounts, audits, nil, nil, nil, cipher, nil)
36
+ return service, accounts
37
+ }
38
+
39
+ type credentialMetadataAdapterStub struct {
40
+ calls *atomic.Int32
41
+ }
42
+
43
+ func (credentialMetadataAdapterStub) Provider() accountdomain.Provider {
44
+ return accountdomain.ProviderBuild
45
+ }
46
+
47
+ func (s credentialMetadataAdapterStub) CredentialMetadata(credential accountdomain.Credential) provider.CredentialMetadata {
48
+ if s.calls != nil {
49
+ s.calls.Add(1)
50
+ }
51
+ return provider.CredentialMetadata{BuildBotFlagged: credential.ID == 1}
52
+ }
53
+
54
+ func TestBuildBotFlagSummaryUsesShortLivedCache(t *testing.T) {
55
+ ctx := context.Background()
56
+ service, accounts := openAccountService(t)
57
+ var calls atomic.Int32
58
+ service.providers = provider.NewRegistry(credentialMetadataAdapterStub{calls: &calls})
59
+ if _, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
60
+ Provider: accountdomain.ProviderBuild, Name: "flagged", SourceKey: "cached-build-bot-flag",
61
+ EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true,
62
+ }); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+
66
+ if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ if got := calls.Load(); got != 1 {
73
+ t.Fatalf("metadata inspections = %d, want 1", got)
74
+ }
75
+ service.invalidateBuildBotFlagCache()
76
+ if _, err := service.buildBotFlaggedAccountIDs(ctx); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if got := calls.Load(); got != 2 {
80
+ t.Fatalf("metadata inspections after invalidation = %d, want 2", got)
81
+ }
82
+ }
83
+
84
+ func TestAccountViewsIncludeBuildBotFlagMetadata(t *testing.T) {
85
+ ctx := context.Background()
86
+ service, accounts := openAccountService(t)
87
+ service.providers = provider.NewRegistry(credentialMetadataAdapterStub{})
88
+ build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
89
+ Provider: accountdomain.ProviderBuild, Name: "flagged", SourceKey: "build-bot-flag",
90
+ EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true,
91
+ })
92
+ if err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ view, err := service.Get(ctx, build.ID)
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if !view.BuildBotFlagged {
100
+ t.Fatal("single account view did not include bot flag metadata")
101
+ }
102
+ views, _, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild)})
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ if len(views) != 1 || !views[0].BuildBotFlagged {
107
+ t.Fatalf("list views = %#v", views)
108
+ }
109
+ normal, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
110
+ Provider: accountdomain.ProviderBuild, Name: "normal", SourceKey: "build-normal",
111
+ EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true,
112
+ })
113
+ if err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ views, total, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild), Risk: "flagged"})
117
+ if err != nil || total != 1 || len(views) != 1 || views[0].Credential.ID != build.ID {
118
+ t.Fatalf("flagged views=%#v total=%d err=%v", views, total, err)
119
+ }
120
+ views, total, err = service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderBuild), Risk: "normal"})
121
+ if err != nil || total != 1 || len(views) != 1 || views[0].Credential.ID != normal.ID {
122
+ t.Fatalf("normal views=%#v total=%d err=%v", views, total, err)
123
+ }
124
+ if _, _, err := service.List(ctx, 1, 20, "", ListFilter{Provider: string(accountdomain.ProviderWeb), Risk: "flagged"}); !errors.Is(err, ErrInvalidFilter) {
125
+ t.Fatalf("non-Build risk filter err = %v", err)
126
+ }
127
+ summary, err := service.Summary(ctx)
128
+ if err != nil || summary.Risk != 1 {
129
+ t.Fatalf("summary=%#v err=%v", summary, err)
130
+ }
131
+ }
132
+
133
+ func TestUpdateBuildSuperEntitledBuildOnly(t *testing.T) {
134
+ ctx := context.Background()
135
+ service, accounts := openAccountService(t)
136
+ build, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
137
+ Provider: accountdomain.ProviderBuild, Name: "build", SourceKey: "build-super-patch",
138
+ EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true,
139
+ })
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ web, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
144
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-super-patch",
145
+ EncryptedAccessToken: "enc", AuthStatus: accountdomain.AuthStatusActive, Enabled: true,
146
+ })
147
+ if err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ trueVal := true
151
+ view, err := service.Update(ctx, build.ID, UpdateInput{BuildSuperEntitled: &trueVal})
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ if !view.Credential.BuildSuperEntitled {
156
+ t.Fatalf("build entitlement not set: %#v", view.Credential)
157
+ }
158
+ if view.Quota.Type != QuotaTypePaid || view.Quota.Source != "buildSuperEntitlement" || view.Quota.Confidence != "confirmed" {
159
+ t.Fatalf("quota = %#v", view.Quota)
160
+ }
161
+ // 零 Billing 数值保持未知/零。
162
+ if view.Quota.LimitKnown || view.Quota.Used != 0 || view.Quota.Limit != 0 {
163
+ t.Fatalf("must not fabricate limits: %#v", view.Quota)
164
+ }
165
+ if _, err := service.Update(ctx, web.ID, UpdateInput{BuildSuperEntitled: &trueVal}); !errors.Is(err, ErrInvalidInput) {
166
+ t.Fatalf("web update err = %v", err)
167
+ }
168
+ xaiMode := accountdomain.BuildRouteXAI
169
+ view, err = service.Update(ctx, build.ID, UpdateInput{BuildRouteMode: &xaiMode})
170
+ if err != nil || view.Credential.BuildRouteMode != accountdomain.BuildRouteXAI {
171
+ t.Fatalf("route update view=%#v err=%v", view.Credential, err)
172
+ }
173
+ if _, err := service.Update(ctx, web.ID, UpdateInput{BuildRouteMode: &xaiMode}); !errors.Is(err, ErrInvalidInput) {
174
+ t.Fatalf("web route update err = %v", err)
175
+ }
176
+ invalidMode := accountdomain.BuildRouteMode("invalid")
177
+ if _, err := service.Update(ctx, build.ID, UpdateInput{BuildRouteMode: &invalidMode}); !errors.Is(err, ErrInvalidInput) {
178
+ t.Fatalf("invalid route update err = %v", err)
179
+ }
180
+ // 清除 entitlement
181
+ falseVal := false
182
+ view, err = service.Update(ctx, build.ID, UpdateInput{BuildSuperEntitled: &falseVal})
183
+ if err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ if view.Credential.BuildSuperEntitled || view.Quota.Type != QuotaTypeUnknown {
187
+ t.Fatalf("cleared view = %#v quota=%#v", view.Credential, view.Quota)
188
+ }
189
+ }
backend/internal/application/account/console_quota_test.go ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
8
+ )
9
+
10
+ func TestPreserveActiveQuotaWindowsUntilReset(t *testing.T) {
11
+ now := time.Now().UTC()
12
+ future := now.Add(time.Hour)
13
+ past := now.Add(-time.Second)
14
+ incoming := []accountdomain.QuotaWindow{{Mode: "console", Remaining: 20, Total: 20}}
15
+
16
+ active := preserveActiveQuotaWindows([]accountdomain.QuotaWindow{{Mode: "console", Remaining: 7, Total: 20, ResetAt: &future}}, incoming, now)
17
+ if len(active) != 1 || active[0].Remaining != 7 {
18
+ t.Fatalf("active window = %#v", active)
19
+ }
20
+
21
+ expired := preserveActiveQuotaWindows([]accountdomain.QuotaWindow{{Mode: "console", Remaining: 0, Total: 20, ResetAt: &past}}, incoming, now)
22
+ if len(expired) != 1 || expired[0].Remaining != 20 {
23
+ t.Fatalf("expired window = %#v", expired)
24
+ }
25
+ }
backend/internal/application/account/conversion_test.go ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "fmt"
7
+ "path/filepath"
8
+ "sync/atomic"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
16
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
17
+ "github.com/chenyme/grok2api/backend/internal/repository"
18
+ )
19
+
20
+ func TestConvertWebAccountsToBuildIsIdempotent(t *testing.T) {
21
+ ctx := context.Background()
22
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion.db"))
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ t.Cleanup(func() { _ = database.Close() })
27
+ if err := database.InitializeSchema(ctx); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ encryptedSSO, err := cipher.Encrypt("test-sso")
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ repository := relational.NewAccountRepository(database)
39
+ webAccount, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
40
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-source",
41
+ EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
42
+ })
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ adapter := &buildConversionAdapter{}
47
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore())
48
+ first, err := service.ConvertWebAccountsToBuild(ctx, []uint64{webAccount.ID})
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if first.Created != 1 || first.Linked != 0 || first.Skipped != 0 || first.Failed != 0 || len(first.BuildAccountIDs) != 1 {
53
+ t.Fatalf("first conversion = %#v", first)
54
+ }
55
+ var secondProgress [][2]int
56
+ second, err := service.ConvertWebAccountsToBuildWithProgress(ctx, []uint64{webAccount.ID}, nil, func(completed, total int) error {
57
+ secondProgress = append(secondProgress, [2]int{completed, total})
58
+ return nil
59
+ })
60
+ if err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if second.Created != 0 || second.Linked != 0 || second.Skipped != 1 || second.Failed != 0 || adapter.calls.Load() != 1 {
64
+ t.Fatalf("second conversion = %#v, calls = %d", second, adapter.calls.Load())
65
+ }
66
+ if len(secondProgress) != 1 || secondProgress[0] != [2]int{0, 0} {
67
+ t.Fatalf("second progress = %#v", secondProgress)
68
+ }
69
+ linkedWeb, err := repository.Get(ctx, webAccount.ID)
70
+ if err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if linkedWeb.LinkedAccountID != first.BuildAccountIDs[0] || linkedWeb.LinkedProvider != accountdomain.ProviderBuild {
74
+ t.Fatalf("linked web account = %#v", linkedWeb)
75
+ }
76
+ }
77
+
78
+ func TestConvertWebAccountsToBuildAllRefreshesLinkedCredential(t *testing.T) {
79
+ ctx := context.Background()
80
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion-refresh.db"))
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ t.Cleanup(func() { _ = database.Close() })
85
+ if err := database.InitializeSchema(ctx); err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
89
+ if err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ encryptedSSO, err := cipher.Encrypt("test-sso")
93
+ if err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ repository := relational.NewAccountRepository(database)
97
+ webAccount, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
98
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "web-source",
99
+ EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
100
+ })
101
+ if err != nil {
102
+ t.Fatal(err)
103
+ }
104
+ adapter := &buildConversionAdapter{}
105
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore())
106
+ first, err := service.ConvertWebAccountsToBuild(ctx, []uint64{webAccount.ID})
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ refreshed, err := service.ConvertWebAccountsToBuildWithStrategy(ctx, []uint64{webAccount.ID}, BuildConversionAll, nil, nil)
111
+ if err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ if refreshed.Created != 0 || refreshed.Linked != 1 || refreshed.Skipped != 0 || refreshed.Failed != 0 || len(refreshed.BuildAccountIDs) != 1 {
115
+ t.Fatalf("refreshed conversion = %#v", refreshed)
116
+ }
117
+ if refreshed.BuildAccountIDs[0] != first.BuildAccountIDs[0] || adapter.calls.Load() != 2 {
118
+ t.Fatalf("build ids first=%v refreshed=%v calls=%d", first.BuildAccountIDs, refreshed.BuildAccountIDs, adapter.calls.Load())
119
+ }
120
+ buildAccount, err := repository.Get(ctx, first.BuildAccountIDs[0])
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ accessToken, err := cipher.Decrypt(buildAccount.EncryptedAccessToken)
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ if accessToken != "access-2" {
129
+ t.Fatalf("access token = %q", accessToken)
130
+ }
131
+ }
132
+
133
+ func TestConvertAllWebAccountsToBuildUsesUnlinkedPool(t *testing.T) {
134
+ ctx := context.Background()
135
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conversion-all.db"))
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ t.Cleanup(func() { _ = database.Close() })
140
+ if err := database.InitializeSchema(ctx); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ encryptedSSO, err := cipher.Encrypt("test-sso")
148
+ if err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ repository := relational.NewAccountRepository(database)
152
+ createWeb := func(name, source string) accountdomain.Credential {
153
+ value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{
154
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: name, SourceKey: source,
155
+ EncryptedAccessToken: encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
156
+ })
157
+ if createErr != nil {
158
+ t.Fatal(createErr)
159
+ }
160
+ return value
161
+ }
162
+ firstWeb := createWeb("web-1", "web-source-1")
163
+ createWeb("web-2", "web-source-2")
164
+ adapter := &buildConversionAdapter{}
165
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore())
166
+ if _, err := service.ConvertWebAccountsToBuild(ctx, []uint64{firstWeb.ID}); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ observed := make([]uint64, 0, 1)
170
+ progress := make([][2]int, 0, 2)
171
+ result, err := service.ConvertAllWebAccountsToBuildWithProgress(ctx, func(accountID uint64) error {
172
+ observed = append(observed, accountID)
173
+ return nil
174
+ }, func(completed, total int) error {
175
+ progress = append(progress, [2]int{completed, total})
176
+ return nil
177
+ })
178
+ if err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ if result.Created != 1 || result.Linked != 0 || result.Skipped != 0 || result.Failed != 0 || adapter.calls.Load() != 2 || len(observed) != 1 || observed[0] != result.BuildAccountIDs[0] {
182
+ t.Fatalf("all conversion = %#v, calls = %d", result, adapter.calls.Load())
183
+ }
184
+ if len(progress) != 2 || progress[0] != [2]int{0, 1} || progress[1] != [2]int{1, 1} {
185
+ t.Fatalf("progress = %#v", progress)
186
+ }
187
+ empty, err := service.ConvertAllWebAccountsToBuild(ctx)
188
+ if err != nil {
189
+ t.Fatal(err)
190
+ }
191
+ if empty.Created != 0 || empty.Linked != 0 || empty.Skipped != 0 || empty.Failed != 0 || len(empty.BuildAccountIDs) != 0 || adapter.calls.Load() != 2 {
192
+ t.Fatalf("empty conversion = %#v, calls = %d", empty, adapter.calls.Load())
193
+ }
194
+ resynced, err := service.ConvertAllWebAccountsToBuildWithStrategy(ctx, BuildConversionAll, nil, nil)
195
+ if err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ if resynced.Created != 0 || resynced.Linked != 2 || resynced.Skipped != 0 || resynced.Failed != 0 || len(resynced.BuildAccountIDs) != 2 || adapter.calls.Load() != 4 {
199
+ t.Fatalf("resynced conversion = %#v, calls = %d", resynced, adapter.calls.Load())
200
+ }
201
+ }
202
+
203
+ func TestConvertAllWebAccountsToBuildProcessesMoreThanLegacyLimitInBatches(t *testing.T) {
204
+ const totalAccounts = maxBuildConversionAccounts + 1
205
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
206
+ if err != nil {
207
+ t.Fatal(err)
208
+ }
209
+ encryptedSSO, err := cipher.Encrypt("test-sso")
210
+ if err != nil {
211
+ t.Fatal(err)
212
+ }
213
+ repository := &conversionBatchRepository{total: totalAccounts, encryptedSSO: encryptedSSO}
214
+ adapter := &buildConversionAdapter{}
215
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, memory.NewLockStore())
216
+ progress := make([][2]int, 0, totalAccounts+1)
217
+ result, err := service.ConvertAllWebAccountsToBuildWithProgress(context.Background(), nil, func(completed, total int) error {
218
+ progress = append(progress, [2]int{completed, total})
219
+ return nil
220
+ })
221
+ if err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ if result.Created != totalAccounts || result.Linked != 0 || result.Skipped != 0 || result.Failed != 0 || len(result.BuildAccountIDs) != totalAccounts {
225
+ t.Fatalf("conversion result = %#v", result)
226
+ }
227
+ if adapter.calls.Load() != totalAccounts || repository.listCalls != 2 {
228
+ t.Fatalf("adapter calls = %d, repository batches = %d", adapter.calls.Load(), repository.listCalls)
229
+ }
230
+ if len(progress) != totalAccounts+1 || progress[0] != [2]int{0, totalAccounts} || progress[len(progress)-1] != [2]int{totalAccounts, totalAccounts} {
231
+ t.Fatalf("progress first=%v last=%v count=%d", progress[0], progress[len(progress)-1], len(progress))
232
+ }
233
+ }
234
+
235
+ type conversionBatchRepository struct {
236
+ repository.AccountRepository
237
+ total int
238
+ encryptedSSO string
239
+ listCalls int
240
+ nextBuildID atomic.Uint64
241
+ }
242
+
243
+ func (r *conversionBatchRepository) ListUnlinkedWebAccountIDs(_ context.Context, afterID uint64, limit int) ([]uint64, int64, error) {
244
+ r.listCalls++
245
+ ids := make([]uint64, 0, limit)
246
+ for id := afterID + 1; id <= uint64(r.total) && len(ids) < limit; id++ {
247
+ ids = append(ids, id)
248
+ }
249
+ return ids, int64(r.total), nil
250
+ }
251
+
252
+ func (r *conversionBatchRepository) Get(_ context.Context, id uint64) (accountdomain.Credential, error) {
253
+ return accountdomain.Credential{
254
+ ID: id, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
255
+ Name: fmt.Sprintf("web-%d", id), SourceKey: fmt.Sprintf("web-source-%d", id),
256
+ EncryptedAccessToken: r.encryptedSSO, Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
257
+ }, nil
258
+ }
259
+
260
+ func (r *conversionBatchRepository) UpsertByIdentity(_ context.Context, value accountdomain.Credential) (accountdomain.Credential, bool, error) {
261
+ value.ID = 10_000 + r.nextBuildID.Add(1)
262
+ return value, true, nil
263
+ }
264
+
265
+ func (r *conversionBatchRepository) LinkWebToBuild(context.Context, uint64, uint64) error { return nil }
266
+
267
+ type buildConversionAdapter struct{ calls atomic.Int64 }
268
+
269
+ func (a *buildConversionAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb }
270
+
271
+ func (a *buildConversionAdapter) ConvertToBuild(_ context.Context, credential accountdomain.Credential) (provider.CredentialSeed, error) {
272
+ call := a.calls.Add(1)
273
+ return provider.CredentialSeed{
274
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", UserID: credential.SourceKey,
275
+ SourceKey: fmt.Sprintf("converted:%s:%d", credential.SourceKey, call), OIDCClientID: "client",
276
+ AccessToken: fmt.Sprintf("access-%d", call), RefreshToken: fmt.Sprintf("refresh-%d", call), ExpiresAt: time.Now().UTC().Add(time.Hour),
277
+ }, nil
278
+ }
backend/internal/application/account/credential_refresh_test.go ADDED
@@ -0,0 +1,706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "path/filepath"
8
+ "sync"
9
+ "sync/atomic"
10
+ "testing"
11
+ "time"
12
+
13
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
16
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
17
+ "github.com/chenyme/grok2api/backend/internal/repository"
18
+ )
19
+
20
+ func TestEnsureCredentialReusesRotatedTokenAndThrottlesForcedRefresh(t *testing.T) {
21
+ ctx := context.Background()
22
+ now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
23
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
24
+ service.now = func() time.Time { return now }
25
+
26
+ first, err := service.EnsureCredential(ctx, credential, true)
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if adapter.refreshCount.Load() != 1 || first.EncryptedAccessToken != "access-1" {
31
+ t.Fatalf("first refresh = %#v, count = %d", first, adapter.refreshCount.Load())
32
+ }
33
+
34
+ fromStaleRequest, err := service.EnsureCredential(ctx, credential, true)
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ if adapter.refreshCount.Load() != 1 || fromStaleRequest.EncryptedAccessToken != first.EncryptedAccessToken {
39
+ t.Fatalf("stale request caused another refresh: count = %d", adapter.refreshCount.Load())
40
+ }
41
+
42
+ duringCooldown, err := service.EnsureCredential(ctx, first, true)
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if adapter.refreshCount.Load() != 1 || duringCooldown.EncryptedAccessToken != first.EncryptedAccessToken {
47
+ t.Fatalf("forced refresh cooldown failed: count = %d", adapter.refreshCount.Load())
48
+ }
49
+
50
+ now = now.Add(forcedRefreshMinInterval + time.Second)
51
+ afterCooldown, err := service.EnsureCredential(ctx, first, true)
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if adapter.refreshCount.Load() != 2 || afterCooldown.EncryptedAccessToken != "access-2" {
56
+ t.Fatalf("refresh after cooldown = %#v, count = %d", afterCooldown, adapter.refreshCount.Load())
57
+ }
58
+
59
+ manual, err := service.ensureCredential(ctx, afterCooldown, ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true})
60
+ if err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if adapter.refreshCount.Load() != 3 || manual.EncryptedAccessToken != "access-3" {
64
+ t.Fatalf("manual refresh did not bypass cooldown: count = %d", adapter.refreshCount.Load())
65
+ }
66
+ }
67
+
68
+ func TestEnsureCredentialCollapsesConcurrentForcedRefreshes(t *testing.T) {
69
+ ctx := context.Background()
70
+ now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
71
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
72
+ service.now = func() time.Time { return now }
73
+ adapter.delay = 30 * time.Millisecond
74
+
75
+ const callers = 20
76
+ start := make(chan struct{})
77
+ errors := make(chan error, callers)
78
+ var workers sync.WaitGroup
79
+ workers.Add(callers)
80
+ for range callers {
81
+ go func() {
82
+ defer workers.Done()
83
+ <-start
84
+ value, err := service.EnsureCredential(ctx, credential, true)
85
+ if err == nil && value.EncryptedAccessToken != "access-1" {
86
+ err = fmt.Errorf("access token = %q", value.EncryptedAccessToken)
87
+ }
88
+ errors <- err
89
+ }()
90
+ }
91
+ close(start)
92
+ workers.Wait()
93
+ close(errors)
94
+ for err := range errors {
95
+ if err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ }
99
+ if adapter.refreshCount.Load() != 1 {
100
+ t.Fatalf("refresh count = %d", adapter.refreshCount.Load())
101
+ }
102
+ }
103
+
104
+ func TestEnsureCredentialCollapsesRefreshAcrossServiceInstances(t *testing.T) {
105
+ ctx := context.Background()
106
+ now := time.Now().UTC()
107
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "credential-refresh-multi-instance.db"))
108
+ if err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ defer database.Close()
112
+ if err := database.InitializeSchema(ctx); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ repository := relational.NewAccountRepository(database)
116
+ credential, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
117
+ Provider: accountdomain.ProviderBuild, Name: "multi-instance", SourceKey: "multi-instance",
118
+ EncryptedAccessToken: "access-0", EncryptedRefreshToken: "refresh-0", ExpiresAt: now.Add(time.Hour),
119
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1,
120
+ })
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ adapter := &credentialRefreshAdapter{delay: 40 * time.Millisecond}
125
+ registry := provider.NewRegistry(adapter)
126
+ lock := memory.NewLockStore()
127
+ first := NewService(repository, nil, nil, nil, registry, nil, lock)
128
+ second := NewService(repository, nil, nil, nil, registry, nil, lock)
129
+ start := make(chan struct{})
130
+ errors := make(chan error, 2)
131
+ for _, service := range []*Service{first, second} {
132
+ go func(service *Service) {
133
+ <-start
134
+ _, refreshErr := service.EnsureCredential(ctx, credential, true)
135
+ errors <- refreshErr
136
+ }(service)
137
+ }
138
+ close(start)
139
+ for range 2 {
140
+ if err := <-errors; err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ }
144
+ if adapter.refreshCount.Load() != 1 {
145
+ t.Fatalf("refresh count = %d", adapter.refreshCount.Load())
146
+ }
147
+ }
148
+
149
+ func TestEnsureCredentialRefreshesWhenAccessTokenIsMissing(t *testing.T) {
150
+ ctx := context.Background()
151
+ now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
152
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
153
+ service.now = func() time.Time { return now }
154
+ credential, err := service.accounts.UpdateTokens(ctx, credential.ID, "", "refresh-only", now.Add(time.Hour))
155
+ if err != nil {
156
+ t.Fatal(err)
157
+ }
158
+
159
+ refreshed, err := service.EnsureCredential(ctx, credential, false)
160
+ if err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ if adapter.refreshCount.Load() != 1 || refreshed.EncryptedAccessToken != "access-1" {
164
+ t.Fatalf("refresh-only credential was not refreshed: %#v, count = %d", refreshed, adapter.refreshCount.Load())
165
+ }
166
+ }
167
+
168
+ func TestCredentialRefreshSchedulerRefreshesOnlyDueAccounts(t *testing.T) {
169
+ ctx := context.Background()
170
+ now := time.Now().UTC()
171
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
172
+ service.now = func() time.Time { return time.Now().UTC() }
173
+ dueAt := now.Add(-time.Minute)
174
+ credential.RefreshDueAt = &dueAt
175
+ credential, err := service.accounts.Update(ctx, credential)
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ far, _, err := service.accounts.UpsertByIdentity(ctx, accountdomain.Credential{
180
+ Provider: accountdomain.ProviderBuild, Name: "far", SourceKey: "far",
181
+ EncryptedAccessToken: "far-access", EncryptedRefreshToken: "far-refresh", ExpiresAt: now.Add(6 * time.Hour),
182
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1,
183
+ })
184
+ if err != nil {
185
+ t.Fatal(err)
186
+ }
187
+
188
+ runCtx, cancel := context.WithCancel(context.Background())
189
+ done := make(chan struct{})
190
+ go func() {
191
+ service.RunCredentialRefresh(runCtx)
192
+ close(done)
193
+ }()
194
+ t.Cleanup(func() {
195
+ cancel()
196
+ select {
197
+ case <-done:
198
+ case <-time.After(time.Second):
199
+ t.Fatal("credential refresh scheduler did not stop")
200
+ }
201
+ })
202
+
203
+ deadline := time.Now().Add(2 * time.Second)
204
+ var updated accountdomain.Credential
205
+ for time.Now().Before(deadline) {
206
+ updated, err = service.accounts.Get(ctx, credential.ID)
207
+ if err == nil && adapter.refreshCount.Load() == 1 && updated.LastRefreshAt != nil && updated.RefreshFailureCount == 0 {
208
+ break
209
+ }
210
+ time.Sleep(10 * time.Millisecond)
211
+ }
212
+ if adapter.refreshCount.Load() != 1 {
213
+ t.Fatalf("refresh count = %d", adapter.refreshCount.Load())
214
+ }
215
+ if err != nil {
216
+ t.Fatal(err)
217
+ }
218
+ if updated.RefreshDueAt == nil || !updated.RefreshDueAt.After(time.Now()) || updated.LastRefreshAt == nil || updated.RefreshFailureCount != 0 {
219
+ t.Fatalf("updated credential = %#v", updated)
220
+ }
221
+ farUpdated, err := service.accounts.Get(ctx, far.ID)
222
+ if err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ if farUpdated.EncryptedAccessToken != "far-access" || farUpdated.LastRefreshAt != nil {
226
+ t.Fatalf("far credential was refreshed: %#v", farUpdated)
227
+ }
228
+ }
229
+
230
+ func TestStartupRecoveryPreservesFutureRefreshSchedule(t *testing.T) {
231
+ now := time.Now().UTC()
232
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
233
+ service.now = func() time.Time { return now }
234
+ originalDue := credential.RefreshDueAt
235
+
236
+ report, err := service.RecoverCriticalCredentials(context.Background(), 2*time.Minute, 100)
237
+ if err != nil {
238
+ t.Fatal(err)
239
+ }
240
+ if report.CriticalFound != 0 || adapter.refreshCount.Load() != 0 {
241
+ t.Fatalf("report=%#v refreshes=%d", report, adapter.refreshCount.Load())
242
+ }
243
+ stored, err := service.accounts.Get(context.Background(), credential.ID)
244
+ if err != nil {
245
+ t.Fatal(err)
246
+ }
247
+ if originalDue == nil || stored.RefreshDueAt == nil || !stored.RefreshDueAt.Equal(*originalDue) {
248
+ t.Fatalf("refresh due changed: before=%v after=%v", originalDue, stored.RefreshDueAt)
249
+ }
250
+ }
251
+
252
+ func TestStartupRecoveryRefreshesExpiredCredential(t *testing.T) {
253
+ now := time.Now().UTC()
254
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
255
+ service.now = func() time.Time { return now }
256
+ expired, err := service.accounts.UpdateTokens(context.Background(), credential.ID, credential.EncryptedAccessToken, credential.EncryptedRefreshToken, now.Add(-time.Minute))
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if expired.RefreshDueAt == nil || expired.RefreshDueAt.After(now) {
261
+ t.Fatalf("expired refresh due = %v", expired.RefreshDueAt)
262
+ }
263
+
264
+ report, err := service.RecoverCriticalCredentials(context.Background(), 2*time.Minute, 100)
265
+ if err != nil {
266
+ t.Fatal(err)
267
+ }
268
+ if report.CriticalFound != 1 || report.Refreshed != 1 || report.Failed != 0 || adapter.refreshCount.Load() != 1 {
269
+ t.Fatalf("report=%#v refreshes=%d", report, adapter.refreshCount.Load())
270
+ }
271
+ }
272
+
273
+ func TestStartupRecoveryRespectsContextBudget(t *testing.T) {
274
+ now := time.Now().UTC()
275
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
276
+ service.now = func() time.Time { return now }
277
+ if _, err := service.accounts.UpdateTokens(context.Background(), credential.ID, credential.EncryptedAccessToken, credential.EncryptedRefreshToken, now.Add(-time.Minute)); err != nil {
278
+ t.Fatal(err)
279
+ }
280
+ adapter.delay = time.Second
281
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
282
+ defer cancel()
283
+ started := time.Now()
284
+ _, err := service.RecoverCriticalCredentials(ctx, 2*time.Minute, 100)
285
+ if !errors.Is(err, context.DeadlineExceeded) {
286
+ t.Fatalf("err = %v", err)
287
+ }
288
+ if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
289
+ t.Fatalf("startup recovery exceeded budget: %s", elapsed)
290
+ }
291
+ }
292
+
293
+ func TestCredentialRefreshDueQueryStaysBoundedForLargePool(t *testing.T) {
294
+ ctx := context.Background()
295
+ now := time.Now().UTC()
296
+ service, _, _ := newCredentialRefreshTestService(t, now)
297
+ values := make([]accountdomain.Credential, 0, 1000)
298
+ for index := range 1000 {
299
+ name := fmt.Sprintf("large-%04d", index)
300
+ values = append(values, accountdomain.Credential{
301
+ Provider: accountdomain.ProviderBuild, Name: name, SourceKey: name,
302
+ EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", ExpiresAt: now.Add(time.Minute),
303
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, MaxConcurrent: 1,
304
+ })
305
+ }
306
+ if _, err := service.accounts.UpsertManyByIdentity(ctx, values); err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ ids, err := service.accounts.ListDueCredentialRefreshIDs(ctx, now, credentialRefreshBatchSize)
310
+ if err != nil {
311
+ t.Fatal(err)
312
+ }
313
+ if len(ids) != credentialRefreshBatchSize {
314
+ t.Fatalf("due batch size = %d", len(ids))
315
+ }
316
+ next, err := service.accounts.NextCredentialRefreshDueAt(ctx)
317
+ if err != nil || next == nil || next.After(now) {
318
+ t.Fatalf("next due = %v, err = %v", next, err)
319
+ }
320
+ }
321
+
322
+ func TestCredentialRefreshFailureDistinguishesTransientAndPermanent(t *testing.T) {
323
+ ctx := context.Background()
324
+ now := time.Now().UTC()
325
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
326
+ service.now = func() time.Time { return now }
327
+
328
+ adapter.refreshErr = &provider.CredentialRefreshError{Status: 503, Code: "oauth_unavailable", Message: "Please retry later", Response: `{"error":"oauth_unavailable","message":"Please retry later"}`}
329
+ if _, err := service.EnsureCredential(ctx, credential, true); err == nil {
330
+ t.Fatal("transient refresh unexpectedly succeeded")
331
+ }
332
+ transient, err := service.accounts.Get(ctx, credential.ID)
333
+ if err != nil {
334
+ t.Fatal(err)
335
+ }
336
+ if transient.AuthStatus != accountdomain.AuthStatusActive || transient.RefreshFailureCount != 1 || transient.LastRefreshErrorStatus != 503 || transient.LastRefreshErrorCode != "oauth_unavailable" || transient.LastRefreshErrorMessage != "Please retry later" || transient.LastRefreshErrorResponse == "" || transient.RefreshPermanent || transient.RefreshDueAt == nil || !transient.RefreshDueAt.After(now) {
337
+ t.Fatalf("transient state = %#v", transient)
338
+ }
339
+
340
+ service.clearRefreshState(credential.ID)
341
+ adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Message: "Refresh token has expired", Response: `{"error":"invalid_grant","error_description":"Refresh token has expired"}`, Permanent: true}
342
+ if _, err := service.EnsureCredential(ctx, transient, true); err == nil {
343
+ t.Fatal("permanent refresh unexpectedly succeeded")
344
+ }
345
+ permanent, err := service.accounts.Get(ctx, credential.ID)
346
+ if err != nil {
347
+ t.Fatal(err)
348
+ }
349
+ if permanent.AuthStatus != accountdomain.AuthStatusActive || permanent.RefreshFailureCount != 2 || permanent.LastRefreshErrorStatus != 400 || permanent.LastRefreshErrorCode != "invalid_grant" || permanent.LastRefreshErrorMessage != "Refresh token has expired" || permanent.LastRefreshErrorResponse == "" || !permanent.RefreshPermanent || permanent.RefreshDueAt == nil || !permanent.RefreshDueAt.Equal(permanent.ExpiresAt) {
350
+ t.Fatalf("permanent with valid token should stay active: %#v", permanent)
351
+ }
352
+ dueIDs, err := service.accounts.ListDueCredentialRefreshIDs(ctx, now, credentialRefreshBatchSize)
353
+ if err != nil {
354
+ t.Fatal(err)
355
+ }
356
+ if len(dueIDs) != 0 {
357
+ t.Fatalf("permanent refresh failure remained immediately due: %#v", dueIDs)
358
+ }
359
+ dueAtExpiry, err := service.accounts.ListDueCredentialRefreshIDs(ctx, permanent.ExpiresAt, credentialRefreshBatchSize)
360
+ if err != nil {
361
+ t.Fatal(err)
362
+ }
363
+ if len(dueAtExpiry) != 1 || dueAtExpiry[0] != credential.ID {
364
+ t.Fatalf("permanent refresh failure was not scheduled at expiry: %#v", dueAtExpiry)
365
+ }
366
+ refreshCount := adapter.refreshCount.Load()
367
+ service.now = func() time.Time { return permanent.ExpiresAt.Add(-time.Minute) }
368
+ usable, err := service.EnsureCredential(ctx, permanent, false)
369
+ if err != nil {
370
+ t.Fatalf("valid access token was rejected after permanent refresh failure: %v", err)
371
+ }
372
+ if usable.EncryptedAccessToken != permanent.EncryptedAccessToken || adapter.refreshCount.Load() != refreshCount {
373
+ t.Fatalf("usable token = %#v, refresh count = %d", usable, adapter.refreshCount.Load())
374
+ }
375
+ service.now = func() time.Time { return now }
376
+ if _, err := service.EnsureCredential(ctx, permanent, true); err == nil {
377
+ t.Fatal("forced retry after permanent failure unexpectedly succeeded")
378
+ }
379
+ permanent, err = service.accounts.Get(ctx, credential.ID)
380
+ if err != nil {
381
+ t.Fatal(err)
382
+ }
383
+ if !permanent.RefreshPermanent || permanent.RefreshDueAt == nil || !permanent.RefreshDueAt.Equal(permanent.ExpiresAt) || adapter.refreshCount.Load() != refreshCount {
384
+ t.Fatalf("permanent refresh state retried or changed: %#v, refresh count = %d", permanent, adapter.refreshCount.Load())
385
+ }
386
+
387
+ service.clearRefreshState(credential.ID)
388
+ expiredCredential := permanent
389
+ expiredCredential.ExpiresAt = now.Add(-time.Minute)
390
+ if _, err := service.accounts.UpdateTokens(ctx, permanent.ID, permanent.EncryptedAccessToken, permanent.EncryptedRefreshToken, expiredCredential.ExpiresAt); err != nil {
391
+ t.Fatal(err)
392
+ }
393
+ adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Permanent: true}
394
+ expiredState, _ := service.accounts.Get(ctx, credential.ID)
395
+ if expiredState.RefreshPermanent {
396
+ t.Fatalf("token update did not clear permanent refresh failure: %#v", expiredState)
397
+ }
398
+ if _, err := service.EnsureCredential(ctx, expiredState, true); err == nil {
399
+ t.Fatal("permanent refresh with expired token unexpectedly succeeded")
400
+ }
401
+ finalState, err := service.accounts.Get(ctx, credential.ID)
402
+ if err != nil {
403
+ t.Fatal(err)
404
+ }
405
+ if finalState.AuthStatus != accountdomain.AuthStatusReauthRequired {
406
+ t.Fatalf("permanent with expired token should be reauthRequired: %#v", finalState)
407
+ }
408
+ manualCount := adapter.refreshCount.Load()
409
+ manualOptions := ensureCredentialOptions{force: true, bypassCooldown: true, retryPermanentOnce: true}
410
+ if _, err := service.ensureCredential(ctx, finalState, manualOptions); err == nil {
411
+ t.Fatal("manual retry should surface the repeated invalid_grant")
412
+ }
413
+ if adapter.refreshCount.Load() != manualCount+1 {
414
+ t.Fatalf("manual retry did not issue exactly one oauth request: before=%d after=%d", manualCount, adapter.refreshCount.Load())
415
+ }
416
+ adapter.refreshErr = nil
417
+ latest, err := service.accounts.Get(ctx, finalState.ID)
418
+ if err != nil {
419
+ t.Fatal(err)
420
+ }
421
+ recovered, err := service.ensureCredential(ctx, latest, manualOptions)
422
+ if err != nil {
423
+ t.Fatalf("manual retry did not recover credential: %v", err)
424
+ }
425
+ if recovered.AuthStatus != accountdomain.AuthStatusActive || recovered.RefreshPermanent || recovered.LastRefreshErrorCode != "" {
426
+ t.Fatalf("manual recovery state = %#v", recovered)
427
+ }
428
+ }
429
+
430
+ func TestCredentialDecryptFailedAllowsRetryAfterKeyRecovery(t *testing.T) {
431
+ ctx := context.Background()
432
+ now := time.Now().UTC()
433
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
434
+ service.now = func() time.Time { return now }
435
+
436
+ // 旧行为会把 decrypt_failed 标 permanent;模拟已落库的 permanent 状态。
437
+ if err := service.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, repository.CredentialRefreshFailure{Count: 1, RetryAt: now.Add(time.Hour), Code: "credential_decrypt_failed", Message: "Stored credential could not be decrypted", Permanent: true}); err != nil {
438
+ t.Fatal(err)
439
+ }
440
+ stuck, err := service.accounts.Get(ctx, credential.ID)
441
+ if err != nil || !stuck.RefreshPermanent || stuck.LastRefreshErrorCode != "credential_decrypt_failed" {
442
+ t.Fatalf("setup stuck state = %#v err=%v", stuck, err)
443
+ }
444
+
445
+ // 密钥恢复后:手动 force 必须能再次发起刷新。
446
+ adapter.refreshErr = nil
447
+ service.clearRefreshState(credential.ID)
448
+ recovered, err := service.EnsureCredential(ctx, stuck, true)
449
+ if err != nil {
450
+ t.Fatalf("force refresh after decrypt_failed should retry: %v", err)
451
+ }
452
+ if recovered.RefreshPermanent || recovered.LastRefreshErrorStatus != 0 || recovered.LastRefreshErrorCode != "" || recovered.LastRefreshErrorMessage != "" || recovered.LastRefreshErrorResponse != "" || adapter.refreshCount.Load() < 1 {
453
+ t.Fatalf("decrypt_failed was not cleared after successful refresh: %#v count=%d", recovered, adapter.refreshCount.Load())
454
+ }
455
+
456
+ // invalid_grant 仍须保持永久阻断。
457
+ service.clearRefreshState(credential.ID)
458
+ adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Permanent: true}
459
+ if _, err := service.EnsureCredential(ctx, recovered, true); err == nil {
460
+ t.Fatal("invalid_grant should fail")
461
+ }
462
+ blocked, err := service.accounts.Get(ctx, credential.ID)
463
+ if err != nil || !blocked.RefreshPermanent || blocked.LastRefreshErrorCode != "invalid_grant" {
464
+ t.Fatalf("invalid_grant permanent state = %#v err=%v", blocked, err)
465
+ }
466
+ // force 也不得再打 OAuth(真正永久)
467
+ count := adapter.refreshCount.Load()
468
+ if _, err := service.EnsureCredential(ctx, blocked, true); err == nil {
469
+ t.Fatal("invalid_grant force should still be blocked")
470
+ }
471
+ if adapter.refreshCount.Load() != count {
472
+ t.Fatalf("invalid_grant forced another oauth call: before=%d after=%d", count, adapter.refreshCount.Load())
473
+ }
474
+ }
475
+
476
+ func TestRefreshAllTokensSkipsUnrefreshableAccounts(t *testing.T) {
477
+ ctx := context.Background()
478
+ now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
479
+ service, _, adapter := newCredentialRefreshTestService(t, now)
480
+ service.now = func() time.Time { return now }
481
+ for _, value := range []accountdomain.Credential{
482
+ {Provider: accountdomain.ProviderBuild, Name: "refreshable-2", SourceKey: "refreshable-2", EncryptedAccessToken: "access-2", EncryptedRefreshToken: "refresh-2", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive},
483
+ {Provider: accountdomain.ProviderBuild, Name: "not-refreshable", SourceKey: "not-refreshable", EncryptedAccessToken: "access-3", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive},
484
+ } {
485
+ if _, _, err := service.accounts.UpsertByIdentity(ctx, value); err != nil {
486
+ t.Fatal(err)
487
+ }
488
+ }
489
+ invalid, _, err := service.accounts.UpsertByIdentity(ctx, accountdomain.Credential{
490
+ Provider: accountdomain.ProviderBuild, Name: "invalid-refreshable", SourceKey: "invalid-refreshable",
491
+ EncryptedAccessToken: "access-invalid", EncryptedRefreshToken: "refresh-invalid", ExpiresAt: now.Add(time.Hour), Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
492
+ })
493
+ if err != nil {
494
+ t.Fatal(err)
495
+ }
496
+ invalid.AuthStatus = accountdomain.AuthStatusReauthRequired
497
+ if _, err := service.accounts.Update(ctx, invalid); err != nil {
498
+ t.Fatal(err)
499
+ }
500
+
501
+ progress := make([][2]int, 0, 4)
502
+ succeeded, failed, skipped, err := service.RefreshAllTokensWithProgress(ctx, func(completed, total int) error {
503
+ progress = append(progress, [2]int{completed, total})
504
+ return nil
505
+ })
506
+ if err != nil {
507
+ t.Fatal(err)
508
+ }
509
+ if succeeded != 3 || failed != 0 || skipped != 1 || adapter.refreshCount.Load() != 3 {
510
+ t.Fatalf("result = %d/%d/%d, refresh count = %d", succeeded, failed, skipped, adapter.refreshCount.Load())
511
+ }
512
+ if len(progress) != 4 || progress[0] != [2]int{0, 3} || progress[1] != [2]int{1, 3} || progress[2] != [2]int{2, 3} || progress[3] != [2]int{3, 3} {
513
+ t.Fatalf("progress = %#v", progress)
514
+ }
515
+ recovered, err := service.accounts.Get(ctx, invalid.ID)
516
+ if err != nil || recovered.AuthStatus != accountdomain.AuthStatusActive {
517
+ t.Fatalf("invalid account was not recovered: %#v err=%v", recovered, err)
518
+ }
519
+ }
520
+
521
+ func TestBatchRefreshTokensRefreshesOnlySelectedEligibleAccounts(t *testing.T) {
522
+ ctx := context.Background()
523
+ now := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
524
+ service, refreshable, adapter := newCredentialRefreshTestService(t, now)
525
+ service.now = func() time.Time { return now }
526
+ selected := []uint64{refreshable.ID}
527
+ var invalidID uint64
528
+ for _, value := range []accountdomain.Credential{
529
+ {Provider: accountdomain.ProviderBuild, Name: "missing-refresh", SourceKey: "missing-refresh", EncryptedAccessToken: "access", Enabled: true, AuthStatus: accountdomain.AuthStatusActive},
530
+ {Provider: accountdomain.ProviderBuild, Name: "disabled", SourceKey: "disabled", EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", Enabled: true, AuthStatus: accountdomain.AuthStatusActive},
531
+ {Provider: accountdomain.ProviderBuild, Name: "invalid", SourceKey: "invalid", EncryptedAccessToken: "access", EncryptedRefreshToken: "refresh", Enabled: true, AuthStatus: accountdomain.AuthStatusActive},
532
+ } {
533
+ created, _, err := service.accounts.UpsertByIdentity(ctx, value)
534
+ if err != nil {
535
+ t.Fatal(err)
536
+ }
537
+ needsUpdate := false
538
+ if value.Name == "disabled" {
539
+ created.Enabled = false
540
+ needsUpdate = true
541
+ }
542
+ if value.Name == "invalid" {
543
+ created.AuthStatus = accountdomain.AuthStatusReauthRequired
544
+ invalidID = created.ID
545
+ needsUpdate = true
546
+ }
547
+ if needsUpdate {
548
+ created, err = service.accounts.Update(ctx, created)
549
+ if err != nil {
550
+ t.Fatal(err)
551
+ }
552
+ }
553
+ selected = append(selected, created.ID)
554
+ }
555
+
556
+ succeeded, failed, skipped, err := service.BatchRefreshTokens(ctx, append(selected, refreshable.ID))
557
+ if err != nil {
558
+ t.Fatal(err)
559
+ }
560
+ if succeeded != 2 || failed != 0 || skipped != 2 || adapter.refreshCount.Load() != 2 {
561
+ t.Fatalf("result = %d/%d/%d, refresh count = %d", succeeded, failed, skipped, adapter.refreshCount.Load())
562
+ }
563
+ updated, err := service.accounts.Get(ctx, refreshable.ID)
564
+ if err != nil {
565
+ t.Fatal(err)
566
+ }
567
+ if updated.EncryptedAccessToken == "" || updated.EncryptedAccessToken == "access-0" {
568
+ t.Fatalf("refreshed access token = %q", updated.EncryptedAccessToken)
569
+ }
570
+ recovered, err := service.accounts.Get(ctx, invalidID)
571
+ if err != nil || recovered.AuthStatus != accountdomain.AuthStatusActive {
572
+ t.Fatalf("selected invalid account was not recovered: %#v err=%v", recovered, err)
573
+ }
574
+ }
575
+
576
+ func TestRefreshBillingCollapsesConcurrentRequests(t *testing.T) {
577
+ ctx := context.Background()
578
+ now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
579
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
580
+ adapter.billingDelay = 30 * time.Millisecond
581
+ const callers = 20
582
+ start := make(chan struct{})
583
+ errors := make(chan error, callers)
584
+ var workers sync.WaitGroup
585
+ workers.Add(callers)
586
+ for range callers {
587
+ go func() {
588
+ defer workers.Done()
589
+ <-start
590
+ _, err := service.RefreshBilling(ctx, credential.ID)
591
+ errors <- err
592
+ }()
593
+ }
594
+ close(start)
595
+ workers.Wait()
596
+ close(errors)
597
+ for err := range errors {
598
+ if err != nil {
599
+ t.Fatal(err)
600
+ }
601
+ }
602
+ if adapter.billingCount.Load() != 1 {
603
+ t.Fatalf("billing count = %d", adapter.billingCount.Load())
604
+ }
605
+ }
606
+
607
+ func newCredentialRefreshTestService(t *testing.T, now time.Time) (*Service, accountdomain.Credential, *credentialRefreshAdapter) {
608
+ t.Helper()
609
+ ctx := context.Background()
610
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "credential-refresh.db"))
611
+ if err != nil {
612
+ t.Fatal(err)
613
+ }
614
+ t.Cleanup(func() { _ = database.Close() })
615
+ if err := database.InitializeSchema(ctx); err != nil {
616
+ t.Fatal(err)
617
+ }
618
+ repository := relational.NewAccountRepository(database)
619
+ credential, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
620
+ Provider: accountdomain.ProviderBuild,
621
+ Name: "refresh-test",
622
+ SourceKey: "refresh-test",
623
+ EncryptedAccessToken: "access-0",
624
+ EncryptedRefreshToken: "refresh-0",
625
+ ExpiresAt: now.Add(time.Hour),
626
+ Enabled: true,
627
+ AuthStatus: accountdomain.AuthStatusActive,
628
+ })
629
+ if err != nil {
630
+ t.Fatal(err)
631
+ }
632
+ adapter := &credentialRefreshAdapter{}
633
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
634
+ return service, credential, adapter
635
+ }
636
+
637
+ type credentialRefreshAdapter struct {
638
+ refreshCount atomic.Int64
639
+ billingCount atomic.Int64
640
+ delay time.Duration
641
+ billingDelay time.Duration
642
+ billing accountdomain.Billing
643
+ billingErr error
644
+ refreshErr error
645
+ }
646
+
647
+ func (a *credentialRefreshAdapter) Provider() accountdomain.Provider {
648
+ return accountdomain.ProviderBuild
649
+ }
650
+
651
+ func (a *credentialRefreshAdapter) Definition() provider.Definition {
652
+ return provider.Definition{
653
+ Provider: accountdomain.ProviderBuild, Quota: provider.QuotaBilling,
654
+ Credential: provider.CredentialSurface{
655
+ Refresh: true,
656
+ },
657
+ }
658
+ }
659
+
660
+ func (a *credentialRefreshAdapter) RefreshCredential(ctx context.Context, _ accountdomain.Credential) (provider.RefreshedCredential, error) {
661
+ if a.delay > 0 {
662
+ timer := time.NewTimer(a.delay)
663
+ defer timer.Stop()
664
+ select {
665
+ case <-ctx.Done():
666
+ return provider.RefreshedCredential{}, ctx.Err()
667
+ case <-timer.C:
668
+ }
669
+ }
670
+ count := a.refreshCount.Add(1)
671
+ if a.refreshErr != nil {
672
+ return provider.RefreshedCredential{}, a.refreshErr
673
+ }
674
+ return provider.RefreshedCredential{EncryptedAccessToken: fmt.Sprintf("access-%d", count), EncryptedRefreshToken: fmt.Sprintf("refresh-%d", count), ExpiresAt: time.Now().UTC().Add(time.Hour)}, nil
675
+ }
676
+
677
+ func (a *credentialRefreshAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) {
678
+ return nil, nil
679
+ }
680
+
681
+ func (a *credentialRefreshAdapter) ListModels(context.Context, accountdomain.Credential) ([]string, error) {
682
+ return nil, nil
683
+ }
684
+
685
+ func (a *credentialRefreshAdapter) GetBilling(context.Context, accountdomain.Credential) (accountdomain.Billing, error) {
686
+ if a.billingDelay > 0 {
687
+ time.Sleep(a.billingDelay)
688
+ }
689
+ a.billingCount.Add(1)
690
+ return a.billing, a.billingErr
691
+ }
692
+
693
+ func (a *credentialRefreshAdapter) StartDeviceAuthorization(context.Context) (provider.DeviceAuthorization, error) {
694
+ return provider.DeviceAuthorization{}, nil
695
+ }
696
+
697
+ func (a *credentialRefreshAdapter) PollDeviceAuthorization(context.Context, string) (provider.CredentialSeed, error) {
698
+ return provider.CredentialSeed{}, nil
699
+ }
700
+
701
+ func (a *credentialRefreshAdapter) ParseImportedCredentials([]byte) ([]provider.CredentialSeed, error) {
702
+ return nil, nil
703
+ }
704
+ func (a *credentialRefreshAdapter) MarshalCredentials([]provider.CredentialSeed) ([]byte, error) {
705
+ return nil, nil
706
+ }
backend/internal/application/account/credential_scheduler.go ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "time"
8
+
9
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
10
+ )
11
+
12
+ // CredentialStartupReport 汇总启动阶段的凭据调度与恢复结果。
13
+ type CredentialStartupReport struct {
14
+ SchedulesBackfilled int
15
+ CriticalFound int
16
+ Refreshed int
17
+ Failed int
18
+ }
19
+
20
+ // ReconcileCredentialSchedules 为升级前账号补齐持久化调度,不解密凭据,也不访问上游。
21
+ func (s *Service) ReconcileCredentialSchedules(ctx context.Context) (int, error) {
22
+ total := 0
23
+ for {
24
+ count, err := s.accounts.BackfillCredentialRefreshSchedules(ctx, s.now(), credentialRefreshBatchSize)
25
+ total += count
26
+ if err != nil || count < credentialRefreshBatchSize {
27
+ return total, err
28
+ }
29
+ }
30
+ }
31
+
32
+ // RecoverCriticalCredentials 在启动预算内仅恢复缺失、已过期、两分钟内到期或失败重试到期的凭据。
33
+ func (s *Service) RecoverCriticalCredentials(ctx context.Context, expiresWithin time.Duration, limit int) (CredentialStartupReport, error) {
34
+ report := CredentialStartupReport{}
35
+ backfilled, err := s.ReconcileCredentialSchedules(ctx)
36
+ report.SchedulesBackfilled = backfilled
37
+ if err != nil {
38
+ return report, err
39
+ }
40
+ if limit < 1 || limit > credentialRefreshBatchSize {
41
+ limit = credentialRefreshBatchSize
42
+ }
43
+ now := s.now()
44
+ ids, err := s.accounts.ListCriticalCredentialRefreshIDs(ctx, now, now.Add(expiresWithin), limit)
45
+ if err != nil {
46
+ return report, err
47
+ }
48
+ report.CriticalFound = len(ids)
49
+ if len(ids) == 0 {
50
+ return report, nil
51
+ }
52
+ report.Refreshed, report.Failed, err = s.runAccountBatch(ctx, "credential_startup_recovery", ids, s.refreshPool, nil, func(workCtx context.Context, id uint64) error {
53
+ taskCtx, cancel := context.WithTimeout(workCtx, credentialRefreshTimeout)
54
+ defer cancel()
55
+ credential, getErr := s.accounts.Get(taskCtx, id)
56
+ if getErr != nil {
57
+ return getErr
58
+ }
59
+ if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) {
60
+ if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) {
61
+ return nil
62
+ }
63
+ return s.MarkReauthRequired(taskCtx, id, permanentRefreshExpiredReason)
64
+ }
65
+ // 临界凭据不受进程内强制刷新节流影响;分布式账号锁和旋转 Token 比对仍避免重复 OAuth。
66
+ _, refreshErr := s.ensureCredential(taskCtx, credential, ensureCredentialOptions{force: true, bypassCooldown: true})
67
+ return refreshErr
68
+ })
69
+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
70
+ return report, err
71
+ }
72
+ return report, err
73
+ }
74
+
75
+ // WakeCredentialRefresh 合并调度唤醒;导入、手动刷新和失败退避更新不会阻塞调用方。
76
+ func (s *Service) WakeCredentialRefresh() {
77
+ select {
78
+ case s.credentialRefreshWake <- struct{}{}:
79
+ default:
80
+ }
81
+ }
82
+
83
+ // RunCredentialRefresh 使用单个 Timer 和数据库到期索引驱动刷新,内存占用与账号总数无关。
84
+ func (s *Service) RunCredentialRefresh(ctx context.Context) {
85
+ timer := time.NewTimer(0)
86
+ defer timer.Stop()
87
+ for {
88
+ select {
89
+ case <-ctx.Done():
90
+ return
91
+ case <-s.credentialRefreshWake:
92
+ case <-timer.C:
93
+ }
94
+ runFailed := false
95
+ if err := s.refreshDueCredentials(ctx); err != nil && ctx.Err() == nil {
96
+ s.logger.Warn("credential_refresh_scheduler_failed", "error", err)
97
+ runFailed = true
98
+ }
99
+ delay, err := s.nextCredentialRefreshDelay(ctx)
100
+ if err != nil && ctx.Err() == nil {
101
+ s.logger.Warn("credential_refresh_schedule_read_failed", "error", err)
102
+ delay = credentialRefreshSafetyPoll
103
+ }
104
+ if runFailed && delay < 30*time.Second {
105
+ delay = 30 * time.Second
106
+ }
107
+ resetCredentialRefreshTimer(timer, delay)
108
+ }
109
+ }
110
+
111
+ func (s *Service) refreshDueCredentials(ctx context.Context) error {
112
+ if _, err := s.ReconcileCredentialSchedules(ctx); err != nil {
113
+ return err
114
+ }
115
+ for {
116
+ ids, err := s.accounts.ListDueCredentialRefreshIDs(ctx, s.now(), credentialRefreshBatchSize)
117
+ if err != nil {
118
+ return err
119
+ }
120
+ if len(ids) == 0 {
121
+ return nil
122
+ }
123
+ _, failed, batchErr := s.runAccountBatch(ctx, "credential_auto_refresh", ids, s.refreshPool, nil, func(workCtx context.Context, id uint64) error {
124
+ taskCtx, cancel := context.WithTimeout(workCtx, credentialRefreshTimeout)
125
+ defer cancel()
126
+ credential, err := s.accounts.Get(taskCtx, id)
127
+ if err != nil {
128
+ return err
129
+ }
130
+ if !credential.Enabled || credential.AuthStatus != accountdomain.AuthStatusActive || s.providers == nil || !s.providers.SupportsCredentialRefresh(credential.Provider) || credential.EncryptedRefreshToken == "" {
131
+ return nil
132
+ }
133
+ if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) {
134
+ if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) {
135
+ return nil
136
+ }
137
+ return s.MarkReauthRequired(taskCtx, id, permanentRefreshExpiredReason)
138
+ }
139
+ if credential.RefreshDueAt != nil && credential.RefreshDueAt.After(s.now()) {
140
+ return nil
141
+ }
142
+ _, err = s.ensureCredential(taskCtx, credential, ensureCredentialOptions{force: true, respectSchedule: true})
143
+ return err
144
+ })
145
+ if batchErr != nil {
146
+ return fmt.Errorf("自动刷新批次执行失败: %w", batchErr)
147
+ }
148
+ if failed > 0 {
149
+ return fmt.Errorf("自动刷新批次失败 %d/%d", failed, len(ids))
150
+ }
151
+ if len(ids) < credentialRefreshBatchSize {
152
+ return nil
153
+ }
154
+ }
155
+ }
156
+
157
+ func (s *Service) nextCredentialRefreshDelay(ctx context.Context) (time.Duration, error) {
158
+ next, err := s.accounts.NextCredentialRefreshDueAt(ctx)
159
+ if err != nil {
160
+ return 0, err
161
+ }
162
+ delay := credentialRefreshSafetyPoll
163
+ if next != nil {
164
+ until := next.Sub(s.now())
165
+ if until < delay {
166
+ delay = until
167
+ }
168
+ }
169
+ if delay < 100*time.Millisecond {
170
+ delay = 100 * time.Millisecond
171
+ }
172
+ return delay, nil
173
+ }
174
+
175
+ func resetCredentialRefreshTimer(timer *time.Timer, delay time.Duration) {
176
+ if !timer.Stop() {
177
+ select {
178
+ case <-timer.C:
179
+ default:
180
+ }
181
+ }
182
+ timer.Reset(delay)
183
+ }
backend/internal/application/account/export_test.go ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+
10
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
11
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
12
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
13
+ cliprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/cli"
14
+ consoleprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/console"
15
+ webprovider "github.com/chenyme/grok2api/backend/internal/infra/provider/web"
16
+ "github.com/chenyme/grok2api/backend/internal/infra/security"
17
+ )
18
+
19
+ func TestExportCredentialsRoundTripsImportFormat(t *testing.T) {
20
+ ctx := context.Background()
21
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "export.db"))
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ defer database.Close()
26
+ if err := database.InitializeSchema(ctx); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ accessToken, err := cipher.Encrypt("access-token")
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ refreshToken, err := cipher.Encrypt("refresh-token")
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ expiresAt := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
42
+ repository := relational.NewAccountRepository(database)
43
+ created, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
44
+ Provider: accountdomain.ProviderBuild, Name: "primary", Email: "user@example.com", UserID: "user-1",
45
+ SourceKey: "export-test", OIDCClientID: "client-1", EncryptedAccessToken: accessToken,
46
+ EncryptedRefreshToken: refreshToken, ExpiresAt: expiresAt, Enabled: false,
47
+ AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8,
48
+ })
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ adapter := cliprovider.NewAdapter(cliprovider.Config{}, cipher)
53
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, nil)
54
+
55
+ result, err := service.ExportCredentials(ctx)
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ selectedResult, err := service.ExportProviderCredentialsByIDs(ctx, accountdomain.ProviderBuild, []uint64{created.ID})
60
+ if err != nil || selectedResult.Count != 1 {
61
+ t.Fatalf("selected export result = %#v, error = %v", selectedResult, err)
62
+ }
63
+ if _, err := service.ExportProviderCredentialsByIDs(ctx, accountdomain.ProviderWeb, []uint64{created.ID}); err == nil {
64
+ t.Fatal("expected cross-provider selected export to fail")
65
+ }
66
+ if _, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, 0, 0, maxCredentialExportAccounts+1); err == nil {
67
+ t.Fatal("expected oversized export page to fail")
68
+ }
69
+ values, err := adapter.ParseImportedCredentials(result.Data)
70
+ if err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if result.Count != 1 || len(values) != 1 {
74
+ t.Fatalf("export count = %d, imported values = %d", result.Count, len(values))
75
+ }
76
+ value := values[0]
77
+ if value.Name != "primary" || value.Email != "user@example.com" || value.UserID != "user-1" || value.OIDCClientID != "client-1" || value.AccessToken != "access-token" || value.RefreshToken != "refresh-token" || !value.ExpiresAt.Equal(expiresAt) {
78
+ t.Fatalf("round-trip credential = %#v", value)
79
+ }
80
+ progress := make([][2]int, 0, 2)
81
+ if _, err := service.ImportCredentialsWithProgress(ctx, result.Data, nil, func(completed, total int) error {
82
+ progress = append(progress, [2]int{completed, total})
83
+ return nil
84
+ }); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ if len(progress) != 2 || progress[0] != [2]int{0, 1} || progress[1] != [2]int{1, 1} {
88
+ t.Fatalf("import progress = %#v", progress)
89
+ }
90
+
91
+ multiProgress := make([][2]int, 0, 3)
92
+ multiResult, err := service.ImportCredentialDocumentsWithProgress(ctx, [][]byte{
93
+ result.Data,
94
+ result.Data,
95
+ []byte(`{"provider":"grok_build","name":"secondary","access_token":"second-access","refresh_token":"second-refresh","user_id":"user-2"}`),
96
+ }, nil, func(completed, total int) error {
97
+ multiProgress = append(multiProgress, [2]int{completed, total})
98
+ return nil
99
+ })
100
+ if err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if multiResult.Created != 1 || multiResult.Updated != 1 {
104
+ t.Fatalf("multi-file import result = %#v", multiResult)
105
+ }
106
+ if len(multiProgress) != 3 || multiProgress[0] != [2]int{0, 2} || multiProgress[2] != [2]int{2, 2} {
107
+ t.Fatalf("multi-file import progress = %#v", multiProgress)
108
+ }
109
+ }
110
+
111
+ func TestExportProviderCredentialsCursorKeepsStableSnapshot(t *testing.T) {
112
+ ctx := context.Background()
113
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "cursor-export.db"))
114
+ if err != nil {
115
+ t.Fatal(err)
116
+ }
117
+ t.Cleanup(func() { _ = database.Close() })
118
+ if err := database.InitializeSchema(ctx); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
122
+ if err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ accessToken, err := cipher.Encrypt("access-token")
126
+ if err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ repository := relational.NewAccountRepository(database)
130
+ createAccount := func(name string) accountdomain.Credential {
131
+ t.Helper()
132
+ value, _, createErr := repository.UpsertByIdentity(ctx, accountdomain.Credential{
133
+ Provider: accountdomain.ProviderBuild, Name: name, SourceKey: "cursor-" + name,
134
+ UserID: name, EncryptedAccessToken: accessToken, Enabled: true,
135
+ AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8,
136
+ })
137
+ if createErr != nil {
138
+ t.Fatal(createErr)
139
+ }
140
+ return value
141
+ }
142
+ first := createAccount("first")
143
+ second := createAccount("second")
144
+ third := createAccount("third")
145
+ adapter := cliprovider.NewAdapter(cliprovider.Config{}, cipher)
146
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(adapter), cipher, nil)
147
+
148
+ pageOne, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, 0, 0, 2)
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if pageOne.Count != 2 || !pageOne.HasMore || pageOne.NextID != second.ID || pageOne.SnapshotMaxID != third.ID {
153
+ t.Fatalf("first cursor page = %#v", pageOne)
154
+ }
155
+ createAccount("new-after-snapshot")
156
+ pageTwo, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, pageOne.NextID, pageOne.SnapshotMaxID, 2)
157
+ if err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ values, err := adapter.ParseImportedCredentials(pageTwo.Data)
161
+ if err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ if pageTwo.Count != 1 || pageTwo.HasMore || pageTwo.NextID != third.ID || len(values) != 1 || values[0].Name != "third" {
165
+ t.Fatalf("second cursor page = %#v, values = %#v", pageTwo, values)
166
+ }
167
+ if first.ID >= second.ID || second.ID >= third.ID {
168
+ t.Fatalf("test account IDs are not monotonic: %d, %d, %d", first.ID, second.ID, third.ID)
169
+ }
170
+ if _, err := service.ExportProviderCredentialsCursor(ctx, accountdomain.ProviderBuild, second.ID, 0, 2); err == nil {
171
+ t.Fatal("expected continuation without snapshot boundary to fail")
172
+ }
173
+ }
174
+
175
+ func TestValidateCredentialExportCountRejectsPartialReads(t *testing.T) {
176
+ for _, test := range []struct {
177
+ name string
178
+ expected int
179
+ total int64
180
+ actual int
181
+ wantErr bool
182
+ }{
183
+ {name: "exact", expected: 2, total: 2, actual: 2},
184
+ {name: "missing at count", expected: 2, total: 1, actual: 1, wantErr: true},
185
+ {name: "deleted after count", expected: 2, total: 2, actual: 1, wantErr: true},
186
+ } {
187
+ t.Run(test.name, func(t *testing.T) {
188
+ err := validateCredentialExportCount(test.expected, test.total, test.actual)
189
+ if (err != nil) != test.wantErr {
190
+ t.Fatalf("validateCredentialExportCount() error = %v, wantErr %v", err, test.wantErr)
191
+ }
192
+ })
193
+ }
194
+ }
195
+
196
+ func TestExportProviderCredentialsRoundTripsSSOProviders(t *testing.T) {
197
+ for _, test := range []struct {
198
+ name string
199
+ providerValue accountdomain.Provider
200
+ adapter provider.Adapter
201
+ webTier accountdomain.WebTier
202
+ }{
203
+ {name: "web", providerValue: accountdomain.ProviderWeb, adapter: webprovider.NewAdapter(webprovider.Config{}, nil, nil, nil, nil), webTier: accountdomain.WebTierSuper},
204
+ {name: "console", providerValue: accountdomain.ProviderConsole, adapter: consoleprovider.NewAdapter(consoleprovider.Config{}, nil, nil)},
205
+ } {
206
+ t.Run(test.name, func(t *testing.T) {
207
+ ctx := context.Background()
208
+ nsfwAt := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
209
+ tosAt := nsfwAt.Add(-time.Hour)
210
+ birthDateAt := tosAt.Add(-time.Hour)
211
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "export-sso.db"))
212
+ if err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ t.Cleanup(func() { _ = database.Close() })
216
+ if err := database.InitializeSchema(ctx); err != nil {
217
+ t.Fatal(err)
218
+ }
219
+ cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32)))
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ token, err := cipher.Encrypt("sso-token")
224
+ if err != nil {
225
+ t.Fatal(err)
226
+ }
227
+ cookies, err := cipher.Encrypt("cf_clearance=clearance-token")
228
+ if err != nil {
229
+ t.Fatal(err)
230
+ }
231
+ repository := relational.NewAccountRepository(database)
232
+ created, _, err := repository.UpsertByIdentity(ctx, accountdomain.Credential{
233
+ Provider: test.providerValue, AuthType: accountdomain.AuthTypeSSO, WebTier: test.webTier,
234
+ Name: test.name + "-account", SourceKey: test.name + "-export-test",
235
+ EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies,
236
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
237
+ WebNSFWEnabledAt: &nsfwAt, WebTermsAcceptedAt: &tosAt,
238
+ WebTermsAcceptedVersion: accountdomain.CurrentWebTermsVersion, WebBirthDateSetAt: &birthDateAt,
239
+ })
240
+ if err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ // 模拟旧账号先按 SSO 来源创建,后续身份同步再补齐邮箱与 user_id;
244
+ // 回导必须命中原账号,不能因新身份字段生成重复记录。
245
+ created.Email = test.name + "@example.com"
246
+ created.UserID = test.name + "-user-id"
247
+ if _, err := repository.Update(ctx, created); err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ service := NewService(repository, nil, nil, nil, provider.NewRegistry(test.adapter), cipher, nil)
251
+
252
+ result, err := service.ExportProviderCredentials(ctx, test.providerValue)
253
+ if err != nil {
254
+ t.Fatal(err)
255
+ }
256
+ codec, ok := test.adapter.(provider.CredentialCodecAdapter)
257
+ if !ok {
258
+ t.Fatal("adapter does not implement credential codec")
259
+ }
260
+ values, err := codec.ParseImportedCredentials(result.Data)
261
+ if err != nil {
262
+ t.Fatal(err)
263
+ }
264
+ if result.Count != 1 || len(values) != 1 || values[0].Provider != test.providerValue || values[0].AccessToken != "sso-token" || values[0].CloudflareCookies != "cf_clearance=clearance-token" || values[0].Email != test.name+"@example.com" || values[0].UserID != test.name+"-user-id" {
265
+ t.Fatalf("round-trip result = %#v, values = %#v", result, values)
266
+ }
267
+ if test.providerValue == accountdomain.ProviderWeb && (values[0].WebTier != accountdomain.WebTierSuper || values[0].WebNSFWEnabledAt == nil || !values[0].WebNSFWEnabledAt.Equal(nsfwAt) || values[0].WebTermsAcceptedAt == nil || !values[0].WebTermsAcceptedAt.Equal(tosAt) || values[0].WebTermsAcceptedVersion != accountdomain.CurrentWebTermsVersion || values[0].WebBirthDateSetAt == nil || !values[0].WebBirthDateSetAt.Equal(birthDateAt)) {
268
+ t.Fatalf("web metadata = %#v", values[0])
269
+ }
270
+ var imported ImportResult
271
+ if test.providerValue == accountdomain.ProviderWeb {
272
+ imported, err = service.ImportWebCredentialsWithProgress(ctx, result.Data, nil, nil)
273
+ } else {
274
+ imported, err = service.ImportConsoleCredentialsWithProgress(ctx, result.Data, nil, nil)
275
+ }
276
+ if err != nil || len(imported.AccountIDs) != 1 {
277
+ t.Fatalf("reimport result = %#v, error = %v", imported, err)
278
+ }
279
+ stored, err := repository.Get(ctx, imported.AccountIDs[0])
280
+ if err != nil || stored.Email != test.name+"@example.com" || stored.UserID != test.name+"-user-id" {
281
+ t.Fatalf("reimported account = %#v, error = %v", stored, err)
282
+ }
283
+ if test.providerValue == accountdomain.ProviderWeb && (stored.WebNSFWEnabledAt == nil || !stored.WebNSFWEnabledAt.Equal(nsfwAt) || stored.WebTermsAcceptedAt == nil || !stored.WebTermsAcceptedAt.Equal(tosAt) || stored.WebTermsAcceptedVersion != accountdomain.CurrentWebTermsVersion || stored.WebBirthDateSetAt == nil || !stored.WebBirthDateSetAt.Equal(birthDateAt)) {
284
+ t.Fatalf("reimported web metadata = %#v", stored)
285
+ }
286
+ })
287
+ }
288
+ }
backend/internal/application/account/linked_delete_test.go ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
12
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
13
+ "github.com/chenyme/grok2api/backend/internal/repository"
14
+ )
15
+
16
+ func TestBatchDeleteWithLinkedRemovesPeersAndKeepsUntargetedWeb(t *testing.T) {
17
+ t.Parallel()
18
+ ctx := context.Background()
19
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-delete.db")
20
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("1", 64), "u1")
21
+
22
+ result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderBuild, []uint64{build.ID}, []accountdomain.Provider{accountdomain.ProviderConsole})
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if result.Deleted != 2 || result.LinkedDeleted != 1 {
27
+ t.Fatalf("result = %#v", result)
28
+ }
29
+ assertAccountMissing(t, repo, build.ID)
30
+ assertAccountMissing(t, repo, console.ID)
31
+ assertAccountPresent(t, repo, web.ID)
32
+
33
+ web2 := mustUpsertLinked(t, repo, accountdomain.Credential{
34
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web2", SourceKey: "sso:" + strings.Repeat("2", 64),
35
+ })
36
+ deleted, err := service.BatchDelete(ctx, []uint64{web2.ID})
37
+ if err != nil || deleted != 1 {
38
+ t.Fatalf("legacy batch delete deleted=%d err=%v", deleted, err)
39
+ }
40
+ }
41
+
42
+ func TestBatchDeleteWithLinkedWebDeletesBuildAndConsole(t *testing.T) {
43
+ t.Parallel()
44
+ ctx := context.Background()
45
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-web.db")
46
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("a", 64), "u-web")
47
+
48
+ result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole})
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if result.Deleted != 3 || result.RootsDeleted != 1 || result.LinkedDeleted != 2 {
53
+ t.Fatalf("result = %#v", result)
54
+ }
55
+ assertAccountMissing(t, repo, web.ID)
56
+ assertAccountMissing(t, repo, build.ID)
57
+ assertAccountMissing(t, repo, console.ID)
58
+ }
59
+
60
+ func TestBatchDeleteWithLinkedMixedBatchOnlyExpandsLinkedRoots(t *testing.T) {
61
+ t.Parallel()
62
+ ctx := context.Background()
63
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-mix.db")
64
+ webLinked, _, console := seedLinkedTrio(t, repo, strings.Repeat("b", 64), "u-mix")
65
+ webOnly := mustUpsertLinked(t, repo, accountdomain.Credential{
66
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web-only", SourceKey: "sso:" + strings.Repeat("c", 64),
67
+ })
68
+
69
+ result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{webLinked.ID, webOnly.ID}, []accountdomain.Provider{accountdomain.ProviderConsole})
70
+ if err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if result.Deleted != 3 {
74
+ t.Fatalf("result = %#v", result)
75
+ }
76
+ assertAccountMissing(t, repo, webLinked.ID)
77
+ assertAccountMissing(t, repo, webOnly.ID)
78
+ assertAccountMissing(t, repo, console.ID)
79
+ }
80
+
81
+ func TestPreviewLinkedDeleteCountsWithoutDeleting(t *testing.T) {
82
+ t.Parallel()
83
+ ctx := context.Background()
84
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-preview.db")
85
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("d", 64), "u-prev")
86
+
87
+ res, err := service.PreviewLinkedDelete(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole})
88
+ if err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ if len(res.RootIDs) != 1 || res.LinkedByProvider[accountdomain.ProviderBuild] != 1 || res.LinkedByProvider[accountdomain.ProviderConsole] != 1 || len(res.FinalIDs) != 3 {
92
+ t.Fatalf("preview = %#v", res)
93
+ }
94
+ assertAccountPresent(t, repo, web.ID)
95
+ assertAccountPresent(t, repo, build.ID)
96
+ assertAccountPresent(t, repo, console.ID)
97
+ }
98
+
99
+ func TestBatchDeleteWithLinkedEmptyTargetsDeletesRootOnly(t *testing.T) {
100
+ t.Parallel()
101
+ ctx := context.Background()
102
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-empty-targets.db")
103
+ web, build, console := seedLinkedTrio(t, repo, strings.Repeat("e", 64), "u-empty")
104
+
105
+ result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, nil)
106
+ if err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ if result.Deleted != 1 || result.LinkedDeleted != 0 {
110
+ t.Fatalf("result = %#v", result)
111
+ }
112
+ assertAccountMissing(t, repo, web.ID)
113
+ assertAccountPresent(t, repo, build.ID)
114
+ assertAccountPresent(t, repo, console.ID)
115
+ }
116
+
117
+ func TestBatchDeleteWithLinkedInvalidTarget(t *testing.T) {
118
+ t.Parallel()
119
+ ctx := context.Background()
120
+ repo, service := newLinkedDeleteTestService(t, "svc-linked-bad-target.db")
121
+ web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("f", 64), "u-bad")
122
+
123
+ if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.Provider("nope")}); err == nil {
124
+ t.Fatal("expected invalid target error")
125
+ }
126
+ if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderWeb}); err == nil {
127
+ t.Fatal("expected self-target error")
128
+ }
129
+ assertAccountPresent(t, repo, web.ID)
130
+ }
131
+
132
+ func TestDeleteMissingAccountReturnsNotFound(t *testing.T) {
133
+ t.Parallel()
134
+ ctx := context.Background()
135
+ _, service := newLinkedDeleteTestService(t, "svc-delete-missing.db")
136
+
137
+ if err := service.Delete(ctx, 9_999_999); !errors.Is(err, ErrNotFound) {
138
+ t.Fatalf("Delete missing: err=%v", err)
139
+ }
140
+ if _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderWeb, 9_999_999, []accountdomain.Provider{accountdomain.ProviderBuild}); !errors.Is(err, ErrNotFound) {
141
+ t.Fatalf("DeleteWithLinked missing: err=%v", err)
142
+ }
143
+ }
144
+
145
+ func TestDeleteWithLinkedRejectsRootFromAnotherProvider(t *testing.T) {
146
+ t.Parallel()
147
+ ctx := context.Background()
148
+ repo, service := newLinkedDeleteTestService(t, "svc-delete-provider-mismatch.db")
149
+ web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("9", 64), "u-provider-mismatch")
150
+
151
+ _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderBuild, web.ID, []accountdomain.Provider{accountdomain.ProviderConsole})
152
+ if !errors.Is(err, ErrConflict) {
153
+ t.Fatalf("provider mismatch error = %v", err)
154
+ }
155
+ assertAccountPresent(t, repo, web.ID)
156
+ }
157
+
158
+ func TestFinishLinkedDeleteUsesBatchStickyCleanup(t *testing.T) {
159
+ sticky := &stickyBatchStub{}
160
+ service := &Service{sticky: sticky}
161
+ service.finishLinkedDelete(context.Background(), []uint64{3, 5, 8})
162
+ if sticky.singleCalls != 0 {
163
+ t.Fatalf("single-account cleanup calls = %d", sticky.singleCalls)
164
+ }
165
+ if len(sticky.batchCalls) != 1 || len(sticky.batchCalls[0]) != 3 || sticky.batchCalls[0][0] != 3 || sticky.batchCalls[0][2] != 8 {
166
+ t.Fatalf("batch cleanup calls = %#v", sticky.batchCalls)
167
+ }
168
+ }
169
+
170
+ func newLinkedDeleteTestService(t *testing.T, dbName string) (*relational.AccountRepository, *Service) {
171
+ t.Helper()
172
+ ctx := context.Background()
173
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), dbName))
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ t.Cleanup(func() { _ = database.Close() })
178
+ if err := database.InitializeSchema(ctx); err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ repo := relational.NewAccountRepository(database)
182
+ return repo, &Service{accounts: repo, sticky: stickyStub{}, logger: nil}
183
+ }
184
+
185
+ func seedLinkedTrio(t *testing.T, repo *relational.AccountRepository, digest, userID string) (web, build, console accountdomain.Credential) {
186
+ t.Helper()
187
+ ctx := context.Background()
188
+ web = mustUpsertLinked(t, repo, accountdomain.Credential{
189
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + digest, UserID: userID,
190
+ })
191
+ build = mustUpsertLinked(t, repo, accountdomain.Credential{
192
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build-" + digest[:8], UserID: userID,
193
+ })
194
+ console = mustUpsertLinked(t, repo, accountdomain.Credential{
195
+ Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console", SourceKey: "console-sso:" + digest, UserID: userID,
196
+ })
197
+ if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ if err := repo.ReconcileProviderLinks(ctx, web.ID); err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ return web, build, console
204
+ }
205
+
206
+ func mustUpsertLinked(t *testing.T, repo *relational.AccountRepository, value accountdomain.Credential) accountdomain.Credential {
207
+ t.Helper()
208
+ value.EncryptedAccessToken = "encrypted"
209
+ value.Enabled = true
210
+ value.AuthStatus = accountdomain.AuthStatusActive
211
+ value.Priority = accountdomain.DefaultPriority
212
+ value.MaxConcurrent = accountdomain.DefaultMaxConcurrent
213
+ stored, _, err := repo.UpsertByIdentity(context.Background(), value)
214
+ if err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ return stored
218
+ }
219
+
220
+ func assertAccountMissing(t *testing.T, repo *relational.AccountRepository, id uint64) {
221
+ t.Helper()
222
+ if _, err := repo.Get(context.Background(), id); !errors.Is(err, repository.ErrNotFound) {
223
+ t.Fatalf("account %d should be missing, err=%v", id, err)
224
+ }
225
+ }
226
+
227
+ func assertAccountPresent(t *testing.T, repo *relational.AccountRepository, id uint64) {
228
+ t.Helper()
229
+ if _, err := repo.Get(context.Background(), id); err != nil {
230
+ t.Fatalf("account %d should remain: %v", id, err)
231
+ }
232
+ }
233
+
234
+ type stickyStub struct{}
235
+
236
+ func (stickyStub) Get(context.Context, string, time.Time) (uint64, bool, error) {
237
+ return 0, false, nil
238
+ }
239
+ func (stickyStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) {
240
+ return 0, nil
241
+ }
242
+ func (stickyStub) Set(context.Context, string, uint64, time.Time) error { return nil }
243
+ func (stickyStub) DeleteByAccount(context.Context, uint64) error { return nil }
244
+
245
+ type stickyBatchStub struct {
246
+ singleCalls int
247
+ batchCalls [][]uint64
248
+ }
249
+
250
+ func (*stickyBatchStub) Get(context.Context, string, time.Time) (uint64, bool, error) {
251
+ return 0, false, nil
252
+ }
253
+ func (*stickyBatchStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) {
254
+ return 0, nil
255
+ }
256
+ func (*stickyBatchStub) Set(context.Context, string, uint64, time.Time) error { return nil }
257
+ func (s *stickyBatchStub) DeleteByAccount(context.Context, uint64) error {
258
+ s.singleCalls++
259
+ return nil
260
+ }
261
+ func (s *stickyBatchStub) DeleteByAccounts(_ context.Context, ids []uint64) error {
262
+ s.batchCalls = append(s.batchCalls, append([]uint64(nil), ids...))
263
+ return nil
264
+ }
backend/internal/application/account/list_filter_test.go ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+
8
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
9
+ )
10
+
11
+ func TestListRejectsInvalidWebFilters(t *testing.T) {
12
+ tests := []struct {
13
+ name string
14
+ filter ListFilter
15
+ }{
16
+ {name: "agreement on non-Web provider", filter: ListFilter{Provider: string(accountdomain.ProviderBuild), Agreement: "nsfwEnabled"}},
17
+ {name: "web association value on Build provider", filter: ListFilter{Provider: string(accountdomain.ProviderBuild), Association: "buildLinked"}},
18
+ {name: "web association value on Console provider", filter: ListFilter{Provider: string(accountdomain.ProviderConsole), Association: "allLinked"}},
19
+ {name: "webLinked on Web provider", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Association: "webLinked"}},
20
+ {name: "association without provider", filter: ListFilter{Association: "webLinked"}},
21
+ {name: "invalid agreement", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Agreement: "invalid"}},
22
+ {name: "invalid association", filter: ListFilter{Provider: string(accountdomain.ProviderWeb), Association: "invalid"}},
23
+ }
24
+
25
+ service := &Service{}
26
+ for _, test := range tests {
27
+ t.Run(test.name, func(t *testing.T) {
28
+ if _, _, err := service.List(context.Background(), 1, 20, "", test.filter); !errors.Is(err, ErrInvalidFilter) {
29
+ t.Fatalf("List() error = %v, want %v", err, ErrInvalidFilter)
30
+ }
31
+ })
32
+ }
33
+ }
34
+
35
+ // Validate provider-specific association filters: Web supports six values; Build and Console support Web links only.
36
+ func TestValidAssociationFilterPerProvider(t *testing.T) {
37
+ web := string(accountdomain.ProviderWeb)
38
+ build := string(accountdomain.ProviderBuild)
39
+ console := string(accountdomain.ProviderConsole)
40
+ tests := []struct {
41
+ provider string
42
+ association string
43
+ want bool
44
+ }{
45
+ {web, "", true},
46
+ {build, "", true},
47
+ {"", "", true},
48
+ {web, "buildLinked", true},
49
+ {web, "allUnlinked", true},
50
+ {web, "webLinked", false},
51
+ {build, "webLinked", true},
52
+ {build, "webUnlinked", true},
53
+ {build, "consoleLinked", false},
54
+ {console, "webLinked", true},
55
+ {console, "webUnlinked", true},
56
+ {console, "buildLinked", false},
57
+ {"", "webLinked", false},
58
+ }
59
+ for _, test := range tests {
60
+ if got := validAssociationFilter(test.provider, test.association); got != test.want {
61
+ t.Fatalf("validAssociationFilter(%q, %q) = %v, want %v", test.provider, test.association, got, test.want)
62
+ }
63
+ }
64
+ }
backend/internal/application/account/paid_quota_recovery_test.go ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
10
+ "github.com/chenyme/grok2api/backend/internal/repository"
11
+ )
12
+
13
+ func TestPaidQuotaCreatesPeriodProbeAndClearsAfterRecovery(t *testing.T) {
14
+ now := time.Now().UTC()
15
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
16
+ periodEnd := now.Add(time.Hour).Format(time.RFC3339)
17
+ adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 100, BillingPeriodEnd: periodEnd}
18
+ if _, err := service.RefreshBilling(context.Background(), credential.ID); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ recovery, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID)
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ if recovery.Kind != accountdomain.QuotaRecoveryKindPaid || recovery.Status != accountdomain.QuotaRecoveryStatusExhausted || recovery.NextProbeAt == nil || recovery.NextProbeAt.Format(time.RFC3339) != periodEnd {
26
+ t.Fatalf("recovery = %#v", recovery)
27
+ }
28
+
29
+ adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 0, BillingPeriodEnd: now.Add(31 * 24 * time.Hour).Format(time.RFC3339)}
30
+ recovered, err := service.ProbePaidQuota(context.Background(), credential)
31
+ if err != nil || !recovered {
32
+ t.Fatalf("recovered = %v, err = %v", recovered, err)
33
+ }
34
+ if _, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID); !errors.Is(err, repository.ErrNotFound) {
35
+ t.Fatalf("paid recovery state should be cleared, err = %v", err)
36
+ }
37
+ if adapter.billingCount.Load() != 2 {
38
+ t.Fatalf("billing probes = %d", adapter.billingCount.Load())
39
+ }
40
+ }
41
+
42
+ func TestPaidQuotaStillExhaustedBacksOffAfterDueProbe(t *testing.T) {
43
+ now := time.Now().UTC()
44
+ service, credential, adapter := newCredentialRefreshTestService(t, now)
45
+ adapter.billing = accountdomain.Billing{MonthlyLimit: 100, Used: 100, BillingPeriodEnd: now.Add(-time.Minute).Format(time.RFC3339)}
46
+ recovered, err := service.ProbePaidQuota(context.Background(), credential)
47
+ if err != nil || recovered {
48
+ t.Fatalf("recovered = %v, err = %v", recovered, err)
49
+ }
50
+ recovery, err := service.accounts.GetQuotaRecovery(context.Background(), credential.ID)
51
+ if err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if recovery.NextProbeAt == nil || recovery.NextProbeAt.Before(time.Now().UTC().Add(14*time.Minute)) {
55
+ t.Fatalf("next probe should be backed off, recovery = %#v", recovery)
56
+ }
57
+ }
backend/internal/application/account/provider_links.go ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "strings"
8
+
9
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
10
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
11
+ )
12
+
13
+ type providerLinkRepository interface {
14
+ ReconcileProviderLinks(ctx context.Context, accountID uint64) error
15
+ UpdateIdentityMetadata(ctx context.Context, accountID uint64, email, userID, teamID string) error
16
+ }
17
+
18
+ // SyncAccountIdentity best-effort fills stable Web/Console identity metadata and reconciles trusted links.
19
+ // Definitive unauthorized signals mark the current Provider account as reauthRequired and remove it from scheduling;
20
+ // other synchronization failures do not affect account health.
21
+ func (s *Service) SyncAccountIdentity(ctx context.Context, id uint64) error {
22
+ _, err, _ := s.identitySyncs.Do(fmt.Sprintf("%d", id), func() (any, error) {
23
+ return nil, s.syncAccountIdentity(ctx, id)
24
+ })
25
+ return err
26
+ }
27
+
28
+ func (s *Service) syncAccountIdentity(ctx context.Context, id uint64) error {
29
+ links, ok := s.accounts.(providerLinkRepository)
30
+ if !ok {
31
+ return nil
32
+ }
33
+ value, err := s.accounts.Get(ctx, id)
34
+ if err != nil {
35
+ return mapRepositoryError(err)
36
+ }
37
+ if value.Provider != accountdomain.ProviderWeb && value.Provider != accountdomain.ProviderConsole {
38
+ return nil
39
+ }
40
+ // Session 身份只需成功补充一次;已有 user_id 或 email 时仅做本地关联协调,
41
+ // 不再重复访问上游。没有任何身份数据的失败账号会在后续同步时重试。
42
+ if strings.TrimSpace(value.UserID) != "" || strings.TrimSpace(value.Email) != "" {
43
+ return mapRepositoryError(links.ReconcileProviderLinks(ctx, id))
44
+ }
45
+ if s.providers == nil {
46
+ return fmt.Errorf("Provider 注册表未初始化")
47
+ }
48
+ adapter, ok := s.providers.AccountIdentity(value.Provider)
49
+ if !ok {
50
+ return nil
51
+ }
52
+ identity, err := adapter.SyncAccountIdentity(ctx, value)
53
+ if err != nil {
54
+ if errors.Is(err, provider.ErrUnauthorized) {
55
+ markErr := s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider))
56
+ return errors.Join(err, markErr)
57
+ }
58
+ return err
59
+ }
60
+ if len(identity.Email) > 255 || len(identity.UserID) > 255 || len(identity.TeamID) > 255 {
61
+ return fmt.Errorf("Grok Web Session 身份字段超过安全上限")
62
+ }
63
+ if err := links.UpdateIdentityMetadata(ctx, id, identity.Email, identity.UserID, identity.TeamID); err != nil {
64
+ return mapRepositoryError(err)
65
+ }
66
+ return mapRepositoryError(links.ReconcileProviderLinks(ctx, id))
67
+ }
68
+
69
+ func (s *Service) reconcileProviderLinksBestEffort(ctx context.Context, id uint64) {
70
+ links, ok := s.accounts.(providerLinkRepository)
71
+ if !ok {
72
+ return
73
+ }
74
+ if err := links.ReconcileProviderLinks(ctx, id); err != nil {
75
+ s.logger.Warn("account_provider_link_reconcile_failed", "account_id", id, "error", err)
76
+ }
77
+ }
78
+
79
+ func (s *Service) syncAccountIdentityBestEffort(ctx context.Context, id uint64) error {
80
+ if err := s.SyncAccountIdentity(ctx, id); err != nil {
81
+ s.logger.Warn("account_identity_sync_failed", "account_id", id, "error", err)
82
+ return err
83
+ }
84
+ return nil
85
+ }
backend/internal/application/account/provider_links_test.go ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
10
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
11
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
12
+ )
13
+
14
+ func TestSyncAccountIdentityLinksUniqueBuildWithoutSharingState(t *testing.T) {
15
+ t.Parallel()
16
+ ctx := context.Background()
17
+ service, repo, adapter := newWebAccountSettingsTestService(t)
18
+ web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
19
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
20
+ EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 7, MaxConcurrent: 3,
21
+ })
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ build, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
26
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build", UserID: "user-1",
27
+ EncryptedAccessToken: "encrypted", Enabled: false, AuthStatus: accountdomain.AuthStatusReauthRequired, Priority: 1, MaxConcurrent: 8,
28
+ })
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ build.Enabled = false
33
+ build, err = repo.Update(ctx, build)
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ adapter.identity = provider.AccountIdentity{UserID: "user-1", Email: "user@example.com"}
38
+ if err := service.SyncAccountIdentity(ctx, web.ID); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ web, err = repo.Get(ctx, web.ID)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ build, err = repo.Get(ctx, build.ID)
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ if web.UserID != "user-1" || web.Email != "user@example.com" || len(web.LinkedAccounts) != 1 || web.LinkedAccounts[0].ID != build.ID {
50
+ t.Fatalf("web = %#v", web)
51
+ }
52
+ if !web.Enabled || web.AuthStatus != accountdomain.AuthStatusActive || web.Priority != 7 || web.MaxConcurrent != 3 {
53
+ t.Fatalf("web operational state changed: %#v", web)
54
+ }
55
+ if build.Enabled || build.AuthStatus != accountdomain.AuthStatusReauthRequired {
56
+ t.Fatalf("build operational state changed: %#v", build)
57
+ }
58
+ if err := service.SyncAccountIdentity(ctx, web.ID); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if adapter.identityCalls != 1 {
62
+ t.Fatalf("identity calls = %d", adapter.identityCalls)
63
+ }
64
+ }
65
+
66
+ func TestSyncAccountIdentityUnauthorizedInvalidatesCurrentProviderAccount(t *testing.T) {
67
+ t.Parallel()
68
+ ctx := context.Background()
69
+ service, repo, adapter := newWebAccountSettingsTestService(t)
70
+ web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
71
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
72
+ EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8,
73
+ })
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ adapter.identityErr = provider.ErrUnauthorized
78
+ if err := service.SyncAccountIdentity(ctx, web.ID); !errors.Is(err, provider.ErrUnauthorized) {
79
+ t.Fatalf("err = %v", err)
80
+ }
81
+ web, err = repo.Get(ctx, web.ID)
82
+ if err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ if web.AuthStatus != accountdomain.AuthStatusReauthRequired || !web.Enabled || web.FailureCount != 0 {
86
+ t.Fatalf("identity unauthorized state = %#v", web)
87
+ }
88
+ }
89
+
90
+ func TestSyncAccountIdentityDoesNotRepeatWhenEmailIsKnown(t *testing.T) {
91
+ t.Parallel()
92
+ ctx := context.Background()
93
+ service, repo, adapter := newWebAccountSettingsTestService(t)
94
+ web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
95
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", Email: "known@example.com",
96
+ SourceKey: "sso:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
97
+ EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
98
+ })
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ adapter.identity = provider.AccountIdentity{UserID: "stable-user", Email: "known@example.com"}
103
+ if err := service.SyncAccountIdentity(ctx, web.ID); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ web, err = repo.Get(ctx, web.ID)
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ if web.UserID != "" || web.Email != "known@example.com" || adapter.identityCalls != 0 {
111
+ t.Fatalf("identity was fetched again: user_id=%q email=%q calls=%d", web.UserID, web.Email, adapter.identityCalls)
112
+ }
113
+ }
114
+
115
+ func TestSyncConsoleAccountIdentityLinksUniqueWebAccountOnce(t *testing.T) {
116
+ t.Parallel()
117
+ ctx := context.Background()
118
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "console-identity.db"))
119
+ if err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ t.Cleanup(func() { _ = database.Close() })
123
+ if err := database.InitializeSchema(ctx); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ repo := relational.NewAccountRepository(database)
127
+ web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
128
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web",
129
+ SourceKey: "sso:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
130
+ UserID: "same-user", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
131
+ })
132
+ if err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ console, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{
136
+ Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console",
137
+ SourceKey: "console-sso:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
138
+ EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
139
+ })
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ adapter := &consoleIdentityAdapterStub{identity: provider.AccountIdentity{UserID: "same-user", Email: "same@example.com"}}
144
+ service := NewService(repo, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
145
+ if err := service.SyncAccountIdentity(ctx, console.ID); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if err := service.SyncAccountIdentity(ctx, console.ID); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ console, err = repo.Get(ctx, console.ID)
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ if adapter.calls != 1 || console.UserID != "same-user" || len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID {
156
+ t.Fatalf("calls=%d console=%#v", adapter.calls, console)
157
+ }
158
+ }
159
+
160
+ type consoleIdentityAdapterStub struct {
161
+ identity provider.AccountIdentity
162
+ calls int
163
+ }
164
+
165
+ func (*consoleIdentityAdapterStub) Provider() accountdomain.Provider {
166
+ return accountdomain.ProviderConsole
167
+ }
168
+
169
+ func (a *consoleIdentityAdapterStub) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) {
170
+ a.calls++
171
+ return a.identity, nil
172
+ }
backend/internal/application/account/quota_refresh_redis_integration_test.go ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strconv"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
15
+ redisruntime "github.com/chenyme/grok2api/backend/internal/infra/runtime/redis"
16
+ redisclient "github.com/redis/go-redis/v9"
17
+ )
18
+
19
+ func TestRedisQuotaRefreshCrossInstanceTrailing(t *testing.T) {
20
+ address := os.Getenv("TEST_REDIS_ADDRESS")
21
+ if address == "" {
22
+ t.Skip("TEST_REDIS_ADDRESS is not configured")
23
+ }
24
+ databaseNumber, err := redisTestDatabaseNumber()
25
+ if err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ ctx := context.Background()
29
+ cleanup := redisclient.NewClient(&redisclient.Options{
30
+ Addr: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), DB: databaseNumber,
31
+ })
32
+ defer cleanup.Close()
33
+ if err := cleanup.FlushDB(ctx).Err(); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ defer func() {
37
+ if err := cleanup.FlushDB(ctx).Err(); err != nil {
38
+ t.Errorf("flush Redis test database: %v", err)
39
+ }
40
+ }()
41
+
42
+ prefix := "grok2api:quota-refresh-integration:" + time.Now().UTC().Format("20060102150405.000000000") + ":"
43
+ config := redisruntime.Config{
44
+ Address: address, Username: os.Getenv("TEST_REDIS_USERNAME"), Password: os.Getenv("TEST_REDIS_PASSWORD"), Database: databaseNumber,
45
+ KeyPrefix: prefix, ConcurrencyLease: time.Minute,
46
+ }
47
+ firstRuntime, err := redisruntime.Open(ctx, config)
48
+ if err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ defer firstRuntime.Close()
52
+ secondRuntime, err := redisruntime.Open(ctx, config)
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ defer secondRuntime.Close()
57
+
58
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-refresh-redis-integration.db"))
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ defer database.Close()
63
+ if err := database.InitializeSchema(ctx); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ accounts := relational.NewAccountRepository(database)
67
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
68
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
69
+ Name: "redis-cross-instance", SourceKey: "redis-cross-instance", EncryptedAccessToken: "encrypted",
70
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper,
71
+ })
72
+ if err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ adapter := &quotaCountingAdapter{modeStarted: make(chan struct{}, 4), modeRelease: make(chan struct{}, 4)}
76
+ registry := provider.NewRegistry(adapter)
77
+ first := NewService(accounts, nil, nil, nil, registry, nil, redisruntime.NewLockStore(firstRuntime))
78
+ second := NewService(accounts, nil, nil, nil, registry, nil, redisruntime.NewLockStore(secondRuntime))
79
+ first.SetQuotaRefreshCoordinator(firstRuntime)
80
+ second.SetQuotaRefreshCoordinator(secondRuntime)
81
+
82
+ runCtx, cancel := context.WithCancel(ctx)
83
+ done := make(chan struct{}, 2)
84
+ go func() { first.RunWebQuotaRefresh(runCtx); done <- struct{}{} }()
85
+ go func() { second.RunWebQuotaRefresh(runCtx); done <- struct{}{} }()
86
+ t.Cleanup(func() {
87
+ for range 4 {
88
+ adapter.modeRelease <- struct{}{}
89
+ }
90
+ cancel()
91
+ <-done
92
+ <-done
93
+ })
94
+
95
+ first.QueueQuotaRefresh(credential.ID, "weekly")
96
+ select {
97
+ case <-adapter.modeStarted:
98
+ case <-time.After(3 * time.Second):
99
+ t.Fatal("first Redis-backed refresh did not start")
100
+ }
101
+ second.QueueQuotaRefresh(credential.ID, "weekly")
102
+ deadline := time.Now().Add(3 * time.Second)
103
+ for {
104
+ generation, dirty, generationErr := secondRuntime.QuotaRefreshGeneration(ctx, credential.ID, "weekly")
105
+ if generationErr != nil {
106
+ t.Fatal(generationErr)
107
+ }
108
+ if generation >= 2 && dirty {
109
+ break
110
+ }
111
+ if time.Now().After(deadline) {
112
+ t.Fatalf("shared Redis generation = %d, dirty = %v", generation, dirty)
113
+ }
114
+ time.Sleep(10 * time.Millisecond)
115
+ }
116
+ adapter.modeRelease <- struct{}{}
117
+ select {
118
+ case <-adapter.modeStarted:
119
+ case <-time.After(3 * time.Second):
120
+ t.Fatal("Redis-backed trailing refresh did not start")
121
+ }
122
+ adapter.modeRelease <- struct{}{}
123
+
124
+ deadline = time.Now().Add(3 * time.Second)
125
+ for adapter.modeCalls.Load() != 2 && time.Now().Before(deadline) {
126
+ time.Sleep(10 * time.Millisecond)
127
+ }
128
+ if calls := adapter.modeCalls.Load(); calls != 2 {
129
+ t.Fatalf("Redis-backed refresh calls = %d, want 2", calls)
130
+ }
131
+ time.Sleep(2 * webQuotaRefreshRetryInterval)
132
+ if calls := adapter.modeCalls.Load(); calls != 2 {
133
+ t.Fatalf("Redis-backed losing instance performed duplicate refresh: %d", calls)
134
+ }
135
+ }
136
+
137
+ func redisTestDatabaseNumber() (int, error) {
138
+ raw := os.Getenv("TEST_REDIS_DATABASE")
139
+ if raw == "" {
140
+ return 0, nil
141
+ }
142
+ value, err := strconv.Atoi(raw)
143
+ if err != nil || value < 0 {
144
+ return 0, fmt.Errorf("TEST_REDIS_DATABASE = %q", raw)
145
+ }
146
+ return value, nil
147
+ }
backend/internal/application/account/quota_refresh_test.go ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "sync"
8
+ "sync/atomic"
9
+ "testing"
10
+ "time"
11
+
12
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
13
+ "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational"
14
+ "github.com/chenyme/grok2api/backend/internal/infra/provider"
15
+ "github.com/chenyme/grok2api/backend/internal/infra/runtime/memory"
16
+ "github.com/chenyme/grok2api/backend/internal/repository"
17
+ )
18
+
19
+ func TestWebQuotaRefreshDeduplicatesPerMode(t *testing.T) {
20
+ service := NewService(nil, nil, nil, nil, nil, nil, nil)
21
+ service.QueueWebQuotaRefresh(42, "fast")
22
+ service.QueueWebQuotaRefresh(42, "expert")
23
+ service.QueueWebQuotaRefresh(42, "fast")
24
+
25
+ service.quotaRefreshMu.Lock()
26
+ defer service.quotaRefreshMu.Unlock()
27
+ if len(service.quotaRefreshes) != 2 {
28
+ t.Fatalf("refresh states = %#v", service.quotaRefreshes)
29
+ }
30
+ if service.quotaRefreshes["42:fast"].generation != 2 || !service.quotaRefreshes["42:fast"].queued {
31
+ t.Fatal("duplicate fast refresh was not coalesced into the queued generation")
32
+ }
33
+ if service.quotaRefreshes["42:expert"].generation != 1 || !service.quotaRefreshes["42:expert"].queued {
34
+ t.Fatal("independent expert refresh state is invalid")
35
+ }
36
+ if len(service.quotaRefreshQueue) != 2 {
37
+ t.Fatalf("queued refreshes = %d", len(service.quotaRefreshQueue))
38
+ }
39
+ }
40
+
41
+ func TestWeeklyQuotaRefreshPreservesTrailingSnapshot(t *testing.T) {
42
+ ctx := context.Background()
43
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "weekly-quota-refresh.db"))
44
+ if err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ t.Cleanup(func() { _ = database.Close() })
48
+ if err := database.InitializeSchema(ctx); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ accounts := relational.NewAccountRepository(database)
52
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
53
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
54
+ Name: "weekly", SourceKey: "weekly", EncryptedAccessToken: "encrypted",
55
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper,
56
+ })
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ adapter := &quotaCountingAdapter{
61
+ modeStarted: make(chan struct{}, 2),
62
+ modeRelease: make(chan struct{}, 2),
63
+ }
64
+ service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
65
+
66
+ service.QueueQuotaRefresh(credential.ID, "weekly")
67
+ request := <-service.quotaRefreshQueue
68
+ done := make(chan struct{})
69
+ go func() {
70
+ service.runWebQuotaRefresh(ctx, request)
71
+ close(done)
72
+ }()
73
+
74
+ select {
75
+ case <-adapter.modeStarted:
76
+ case <-time.After(time.Second):
77
+ t.Fatal("first weekly refresh did not start")
78
+ }
79
+ service.QueueQuotaRefresh(credential.ID, "weekly")
80
+ adapter.modeRelease <- struct{}{}
81
+
82
+ select {
83
+ case <-adapter.modeStarted:
84
+ case <-time.After(time.Second):
85
+ t.Fatal("trailing weekly refresh did not start")
86
+ }
87
+ adapter.modeRelease <- struct{}{}
88
+
89
+ select {
90
+ case <-done:
91
+ case <-time.After(time.Second):
92
+ t.Fatal("weekly refresh did not finish")
93
+ }
94
+ if adapter.modeCalls.Load() != 2 {
95
+ t.Fatalf("weekly refresh calls = %d, want 2", adapter.modeCalls.Load())
96
+ }
97
+ service.quotaRefreshMu.Lock()
98
+ _, exists := service.quotaRefreshes[request.key]
99
+ service.quotaRefreshMu.Unlock()
100
+ if exists {
101
+ t.Fatal("completed weekly refresh retained queue state")
102
+ }
103
+ }
104
+
105
+ func TestQuotaRefreshQueueOverflowRetainsDirtyState(t *testing.T) {
106
+ service := NewService(nil, nil, nil, nil, nil, nil, nil)
107
+ service.quotaRefreshQueue = make(chan webQuotaRefreshRequest, 1)
108
+ service.QueueQuotaRefresh(1, "fast")
109
+ service.QueueQuotaRefresh(2, "fast")
110
+
111
+ service.quotaRefreshMu.Lock()
112
+ state := service.quotaRefreshes["2:fast"]
113
+ if state == nil || state.generation != 1 || state.queued || state.running {
114
+ service.quotaRefreshMu.Unlock()
115
+ t.Fatalf("overflow state = %#v", state)
116
+ }
117
+ service.quotaRefreshMu.Unlock()
118
+
119
+ first := <-service.quotaRefreshQueue
120
+ if first.accountID != 1 {
121
+ t.Fatalf("first queued account = %d", first.accountID)
122
+ }
123
+ service.requeueQuotaRefreshes()
124
+ second := <-service.quotaRefreshQueue
125
+ if second.accountID != 2 || second.mode != "fast" {
126
+ t.Fatalf("recovered request = %#v", second)
127
+ }
128
+ service.quotaRefreshMu.Lock()
129
+ recovered := service.quotaRefreshes["2:fast"]
130
+ service.quotaRefreshMu.Unlock()
131
+ if recovered == nil || !recovered.queued || recovered.running {
132
+ t.Fatalf("recovered state = %#v", recovered)
133
+ }
134
+ }
135
+
136
+ func TestQuotaRefreshCrossInstanceGenerationTriggersSingleTrailingRefresh(t *testing.T) {
137
+ ctx := context.Background()
138
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-cross-instance.db"))
139
+ if err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ t.Cleanup(func() { _ = database.Close() })
143
+ if err := database.InitializeSchema(ctx); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ accounts := relational.NewAccountRepository(database)
147
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
148
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
149
+ Name: "cross-instance", SourceKey: "cross-instance", EncryptedAccessToken: "encrypted",
150
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierSuper,
151
+ })
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ adapter := &quotaCountingAdapter{modeStarted: make(chan struct{}, 4), modeRelease: make(chan struct{}, 4)}
156
+ registry := provider.NewRegistry(adapter)
157
+ coordinator := memory.NewQuotaRefreshCoordinator()
158
+ lock := memory.NewLockStore()
159
+ first := NewService(accounts, nil, nil, nil, registry, nil, lock)
160
+ second := NewService(accounts, nil, nil, nil, registry, nil, lock)
161
+ first.SetQuotaRefreshCoordinator(coordinator)
162
+ second.SetQuotaRefreshCoordinator(coordinator)
163
+ runCtx, cancel := context.WithCancel(ctx)
164
+ done := make(chan struct{}, 2)
165
+ go func() { first.RunWebQuotaRefresh(runCtx); done <- struct{}{} }()
166
+ go func() { second.RunWebQuotaRefresh(runCtx); done <- struct{}{} }()
167
+ t.Cleanup(func() {
168
+ for range 4 {
169
+ adapter.modeRelease <- struct{}{}
170
+ }
171
+ cancel()
172
+ <-done
173
+ <-done
174
+ })
175
+
176
+ first.QueueQuotaRefresh(credential.ID, "weekly")
177
+ select {
178
+ case <-adapter.modeStarted:
179
+ case <-time.After(2 * time.Second):
180
+ t.Fatal("first refresh did not start")
181
+ }
182
+ second.QueueQuotaRefresh(credential.ID, "weekly")
183
+ deadline := time.Now().Add(2 * time.Second)
184
+ for {
185
+ generation, dirty, generationErr := coordinator.QuotaRefreshGeneration(ctx, credential.ID, "weekly")
186
+ if generationErr != nil {
187
+ t.Fatal(generationErr)
188
+ }
189
+ if generation >= 2 && dirty {
190
+ break
191
+ }
192
+ if time.Now().After(deadline) {
193
+ t.Fatalf("shared generation = %d, dirty = %v", generation, dirty)
194
+ }
195
+ time.Sleep(10 * time.Millisecond)
196
+ }
197
+ adapter.modeRelease <- struct{}{}
198
+ select {
199
+ case <-adapter.modeStarted:
200
+ case <-time.After(2 * time.Second):
201
+ t.Fatal("trailing refresh did not start")
202
+ }
203
+ adapter.modeRelease <- struct{}{}
204
+
205
+ deadline = time.Now().Add(2 * time.Second)
206
+ for adapter.modeCalls.Load() != 2 && time.Now().Before(deadline) {
207
+ time.Sleep(10 * time.Millisecond)
208
+ }
209
+ if calls := adapter.modeCalls.Load(); calls != 2 {
210
+ t.Fatalf("refresh calls = %d, want 2", calls)
211
+ }
212
+ time.Sleep(2 * webQuotaRefreshRetryInterval)
213
+ if calls := adapter.modeCalls.Load(); calls != 2 {
214
+ t.Fatalf("losing instance performed duplicate refresh: %d", calls)
215
+ }
216
+ }
217
+
218
+ func TestRefreshQuotaModeDoesNotTriggerFullProviderSyncForAutoTier(t *testing.T) {
219
+ ctx := context.Background()
220
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-mode.db"))
221
+ if err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ t.Cleanup(func() { _ = database.Close() })
225
+ if err := database.InitializeSchema(ctx); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ accounts := relational.NewAccountRepository(database)
229
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
230
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
231
+ Name: "web-auto", SourceKey: "web-auto", EncryptedAccessToken: "encrypted",
232
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierAuto,
233
+ })
234
+ if err != nil {
235
+ t.Fatal(err)
236
+ }
237
+ adapter := &quotaCountingAdapter{}
238
+ service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
239
+ window, err := service.RefreshQuotaMode(ctx, credential.ID, "fast")
240
+ if err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ if window.Mode != "fast" || adapter.modeCalls.Load() != 1 || adapter.fullCalls.Load() != 0 {
244
+ t.Fatalf("window = %#v, mode calls = %d, full calls = %d", window, adapter.modeCalls.Load(), adapter.fullCalls.Load())
245
+ }
246
+ stored, err := accounts.Get(ctx, credential.ID)
247
+ if err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ if stored.WebTier != accountdomain.WebTierAuto {
251
+ t.Fatalf("single-mode sync changed tier to %q", stored.WebTier)
252
+ }
253
+
254
+ service.QueueQuotaRefresh(credential.ID, "fast")
255
+ service.QueueQuotaRefresh(credential.ID, "fast")
256
+ request := <-service.quotaRefreshQueue
257
+ service.runWebQuotaRefresh(ctx, request)
258
+ if adapter.modeCalls.Load() != 2 || adapter.fullCalls.Load() != 0 {
259
+ t.Fatalf("coalesced mode calls = %d, full calls = %d", adapter.modeCalls.Load(), adapter.fullCalls.Load())
260
+ }
261
+ service.quotaRefreshMu.Lock()
262
+ _, queued := service.quotaRefreshes[request.key]
263
+ service.quotaRefreshMu.Unlock()
264
+ if queued {
265
+ t.Fatal("completed coalesced refresh retained queue state")
266
+ }
267
+
268
+ service.refreshLock = deniedQuotaRefreshLock{}
269
+ service.QueueQuotaRefresh(credential.ID, "fast")
270
+ request = <-service.quotaRefreshQueue
271
+ service.runWebQuotaRefresh(ctx, request)
272
+ if adapter.modeCalls.Load() != 2 {
273
+ t.Fatalf("worker without distributed lease made %d mode calls", adapter.modeCalls.Load())
274
+ }
275
+ }
276
+
277
+ func TestObserveResponseModelCoalescesUnchangedValues(t *testing.T) {
278
+ ctx := context.Background()
279
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "observed-model.db"))
280
+ if err != nil {
281
+ t.Fatal(err)
282
+ }
283
+ t.Cleanup(func() { _ = database.Close() })
284
+ if err := database.InitializeSchema(ctx); err != nil {
285
+ t.Fatal(err)
286
+ }
287
+ base := relational.NewAccountRepository(database)
288
+ credential, _, err := base.UpsertByIdentity(ctx, accountdomain.Credential{
289
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth,
290
+ Name: "observed", SourceKey: "observed", EncryptedAccessToken: "encrypted",
291
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
292
+ })
293
+ if err != nil {
294
+ t.Fatal(err)
295
+ }
296
+ accounts := &observedModelCountingRepository{AccountRepository: base}
297
+ service := NewService(accounts, nil, nil, nil, nil, nil, nil)
298
+ now := time.Now().UTC()
299
+ service.now = func() time.Time { return now }
300
+
301
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
302
+ t.Fatal(err)
303
+ }
304
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
305
+ t.Fatal(err)
306
+ }
307
+ now = now.Add(observedModelPersistInterval - time.Second)
308
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ if calls := accounts.calls.Load(); calls != 1 {
312
+ t.Fatalf("unchanged model writes = %d, want 1", calls)
313
+ }
314
+
315
+ now = now.Add(2 * time.Second)
316
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
317
+ t.Fatal(err)
318
+ }
319
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5-mini"); err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ if calls := accounts.calls.Load(); calls != 3 {
323
+ t.Fatalf("interval and model transition writes = %d, want 3", calls)
324
+ }
325
+ }
326
+
327
+ func TestObserveResponseModelRefreshesAfterCrossInstanceStateChange(t *testing.T) {
328
+ ctx := context.Background()
329
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "observed-model-shared.db"))
330
+ if err != nil {
331
+ t.Fatal(err)
332
+ }
333
+ t.Cleanup(func() { _ = database.Close() })
334
+ if err := database.InitializeSchema(ctx); err != nil {
335
+ t.Fatal(err)
336
+ }
337
+ base := relational.NewAccountRepository(database)
338
+ credential, _, err := base.UpsertByIdentity(ctx, accountdomain.Credential{
339
+ Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth,
340
+ Name: "observed-shared", SourceKey: "observed-shared", EncryptedAccessToken: "encrypted",
341
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
342
+ })
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ accounts := &observedModelCountingRepository{AccountRepository: base}
347
+ shared := &observedModelTestStore{values: make(map[uint64]repository.ObservedModelState)}
348
+ service := NewService(accounts, nil, nil, nil, nil, nil, nil)
349
+ service.SetObservedModelStore(shared)
350
+ now := time.Now().UTC()
351
+ service.now = func() time.Time { return now }
352
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
353
+ t.Fatal(err)
354
+ }
355
+ shared.mu.Lock()
356
+ shared.values[credential.ID] = repository.ObservedModelState{Model: "grok-4.5-build-free", ObservedAt: now}
357
+ shared.mu.Unlock()
358
+ now = now.Add(observedModelLocalCacheTTL + time.Second)
359
+ if err := service.ObserveResponseModel(ctx, credential.ID, "grok-4.5"); err != nil {
360
+ t.Fatal(err)
361
+ }
362
+ if calls := accounts.calls.Load(); calls != 2 {
363
+ t.Fatalf("cross-instance model change was suppressed, writes = %d", calls)
364
+ }
365
+ }
366
+
367
+ func TestObserveResponseModelCoalescesConcurrentFirstWrite(t *testing.T) {
368
+ accounts := &observedModelBlockingRepository{
369
+ started: make(chan struct{}),
370
+ release: make(chan struct{}),
371
+ }
372
+ service := NewService(accounts, nil, nil, nil, nil, nil, nil)
373
+ const workers = 32
374
+ start := make(chan struct{})
375
+ var launched sync.WaitGroup
376
+ launched.Add(workers)
377
+ errorsCh := make(chan error, workers)
378
+ var wait sync.WaitGroup
379
+ wait.Add(workers)
380
+ for range workers {
381
+ go func() {
382
+ defer wait.Done()
383
+ launched.Done()
384
+ <-start
385
+ errorsCh <- service.ObserveResponseModel(context.Background(), 42, "grok-4.5")
386
+ }()
387
+ }
388
+ launched.Wait()
389
+ close(start)
390
+ <-accounts.started
391
+ time.Sleep(25 * time.Millisecond)
392
+ close(accounts.release)
393
+ wait.Wait()
394
+ close(errorsCh)
395
+ for err := range errorsCh {
396
+ if err != nil {
397
+ t.Fatal(err)
398
+ }
399
+ }
400
+ if calls := accounts.calls.Load(); calls != 1 {
401
+ t.Fatalf("concurrent unchanged model writes = %d, want 1", calls)
402
+ }
403
+ }
404
+
405
+ func TestObserveResponseModelKeepsNewerLocalStateAfterOutOfOrderCompletion(t *testing.T) {
406
+ accounts := &observedModelOrderingRepository{
407
+ olderStarted: make(chan struct{}),
408
+ olderRelease: make(chan struct{}),
409
+ }
410
+ service := NewService(accounts, nil, nil, nil, nil, nil, nil)
411
+ current := time.Now().UTC()
412
+ var nowMu sync.RWMutex
413
+ service.now = func() time.Time {
414
+ nowMu.RLock()
415
+ defer nowMu.RUnlock()
416
+ return current
417
+ }
418
+ olderDone := make(chan error, 1)
419
+ go func() {
420
+ olderDone <- service.ObserveResponseModel(context.Background(), 42, "grok-older")
421
+ }()
422
+ <-accounts.olderStarted
423
+ nowMu.Lock()
424
+ current = current.Add(time.Minute)
425
+ nowMu.Unlock()
426
+ if err := service.ObserveResponseModel(context.Background(), 42, "grok-newer"); err != nil {
427
+ t.Fatal(err)
428
+ }
429
+ close(accounts.olderRelease)
430
+ if err := <-olderDone; err != nil {
431
+ t.Fatal(err)
432
+ }
433
+ if err := service.ObserveResponseModel(context.Background(), 42, "grok-newer"); err != nil {
434
+ t.Fatal(err)
435
+ }
436
+ if calls := accounts.calls.Load(); calls != 2 {
437
+ t.Fatalf("out-of-order writes = %d, want 2", calls)
438
+ }
439
+ }
440
+
441
+ type observedModelCountingRepository struct {
442
+ repository.AccountRepository
443
+ calls atomic.Int64
444
+ }
445
+
446
+ type observedModelBlockingRepository struct {
447
+ repository.AccountRepository
448
+ calls atomic.Int64
449
+ started chan struct{}
450
+ release chan struct{}
451
+ once sync.Once
452
+ }
453
+
454
+ type observedModelOrderingRepository struct {
455
+ repository.AccountRepository
456
+ calls atomic.Int64
457
+ olderStarted chan struct{}
458
+ olderRelease chan struct{}
459
+ }
460
+
461
+ type observedModelTestStore struct {
462
+ mu sync.Mutex
463
+ values map[uint64]repository.ObservedModelState
464
+ }
465
+
466
+ func (s *observedModelTestStore) GetObservedModelState(_ context.Context, accountID uint64) (repository.ObservedModelState, bool, error) {
467
+ s.mu.Lock()
468
+ defer s.mu.Unlock()
469
+ value, ok := s.values[accountID]
470
+ return value, ok, nil
471
+ }
472
+
473
+ func (s *observedModelTestStore) SetObservedModelState(_ context.Context, accountID uint64, value repository.ObservedModelState, _ time.Duration) error {
474
+ s.mu.Lock()
475
+ s.values[accountID] = value
476
+ s.mu.Unlock()
477
+ return nil
478
+ }
479
+
480
+ func (r *observedModelCountingRepository) UpdateObservedModel(ctx context.Context, id uint64, model string, observedAt time.Time) error {
481
+ r.calls.Add(1)
482
+ return r.AccountRepository.UpdateObservedModel(ctx, id, model, observedAt)
483
+ }
484
+
485
+ func (r *observedModelBlockingRepository) UpdateObservedModel(context.Context, uint64, string, time.Time) error {
486
+ r.calls.Add(1)
487
+ r.once.Do(func() { close(r.started) })
488
+ <-r.release
489
+ return nil
490
+ }
491
+
492
+ func (r *observedModelOrderingRepository) UpdateObservedModel(_ context.Context, _ uint64, model string, _ time.Time) error {
493
+ r.calls.Add(1)
494
+ if model == "grok-older" {
495
+ close(r.olderStarted)
496
+ <-r.olderRelease
497
+ }
498
+ return nil
499
+ }
500
+
501
+ func TestRefreshQuotaFetchesWebIdentityOnlyUntilDataExists(t *testing.T) {
502
+ ctx := context.Background()
503
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-identity.db"))
504
+ if err != nil {
505
+ t.Fatal(err)
506
+ }
507
+ t.Cleanup(func() { _ = database.Close() })
508
+ if err := database.InitializeSchema(ctx); err != nil {
509
+ t.Fatal(err)
510
+ }
511
+ accounts := relational.NewAccountRepository(database)
512
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
513
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
514
+ Name: "web-identity", SourceKey: "web-identity", EncryptedAccessToken: "encrypted",
515
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierAuto,
516
+ })
517
+ if err != nil {
518
+ t.Fatal(err)
519
+ }
520
+ adapter := &quotaCountingAdapter{}
521
+ service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
522
+ for range 2 {
523
+ if _, err := service.RefreshQuota(ctx, credential.ID); err != nil {
524
+ t.Fatal(err)
525
+ }
526
+ }
527
+ if adapter.fullCalls.Load() != 2 || adapter.identityCalls.Load() != 1 {
528
+ t.Fatalf("quota calls=%d identity calls=%d", adapter.fullCalls.Load(), adapter.identityCalls.Load())
529
+ }
530
+ stored, err := accounts.Get(ctx, credential.ID)
531
+ if err != nil {
532
+ t.Fatal(err)
533
+ }
534
+ if stored.Email != "identity@example.com" {
535
+ t.Fatalf("email = %q", stored.Email)
536
+ }
537
+ }
538
+
539
+ func TestRefreshQuotaUnauthorizedMarksWebAccountInvalid(t *testing.T) {
540
+ ctx := context.Background()
541
+ database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-unauthorized.db"))
542
+ if err != nil {
543
+ t.Fatal(err)
544
+ }
545
+ t.Cleanup(func() { _ = database.Close() })
546
+ if err := database.InitializeSchema(ctx); err != nil {
547
+ t.Fatal(err)
548
+ }
549
+ accounts := relational.NewAccountRepository(database)
550
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
551
+ Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
552
+ Name: "web-unauthorized", SourceKey: "web-unauthorized", EncryptedAccessToken: "encrypted",
553
+ Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
554
+ })
555
+ if err != nil {
556
+ t.Fatal(err)
557
+ }
558
+ adapter := &quotaCountingAdapter{fullErr: provider.ErrUnauthorized}
559
+ service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
560
+ if _, err := service.RefreshQuota(ctx, credential.ID); !errors.Is(err, provider.ErrUnauthorized) {
561
+ t.Fatalf("err = %v", err)
562
+ }
563
+ stored, err := accounts.Get(ctx, credential.ID)
564
+ if err != nil {
565
+ t.Fatal(err)
566
+ }
567
+ if stored.AuthStatus != accountdomain.AuthStatusReauthRequired || !stored.Enabled {
568
+ t.Fatalf("account state = %#v", stored)
569
+ }
570
+ }
571
+
572
+ type deniedQuotaRefreshLock struct{}
573
+
574
+ func (deniedQuotaRefreshLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) {
575
+ return nil, false, nil
576
+ }
577
+
578
+ type quotaCountingAdapter struct {
579
+ modeCalls atomic.Int64
580
+ fullCalls atomic.Int64
581
+ identityCalls atomic.Int64
582
+ fullErr error
583
+ modeStarted chan struct{}
584
+ modeRelease chan struct{}
585
+ }
586
+
587
+ func (a *quotaCountingAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb }
588
+
589
+ func (a *quotaCountingAdapter) Definition() provider.Definition {
590
+ return provider.Definition{
591
+ Provider: accountdomain.ProviderWeb, ModelNamespace: accountdomain.ProviderWeb.ModelNamespace(),
592
+ Quota: provider.QuotaRemoteWindow, Credential: provider.CredentialSurface{AuthType: accountdomain.AuthTypeSSO},
593
+ }
594
+ }
595
+
596
+ func (a *quotaCountingAdapter) SyncQuota(context.Context, accountdomain.Credential) (provider.QuotaSnapshot, error) {
597
+ a.fullCalls.Add(1)
598
+ return provider.QuotaSnapshot{}, a.fullErr
599
+ }
600
+
601
+ func (a *quotaCountingAdapter) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) {
602
+ a.identityCalls.Add(1)
603
+ return provider.AccountIdentity{Email: "identity@example.com"}, nil
604
+ }
605
+
606
+ func (a *quotaCountingAdapter) SyncQuotaMode(_ context.Context, credential accountdomain.Credential, mode string) (accountdomain.QuotaWindow, error) {
607
+ a.modeCalls.Add(1)
608
+ if a.modeStarted != nil {
609
+ a.modeStarted <- struct{}{}
610
+ }
611
+ if a.modeRelease != nil {
612
+ <-a.modeRelease
613
+ }
614
+ now := time.Now().UTC()
615
+ resetAt := now.Add(time.Hour)
616
+ return accountdomain.QuotaWindow{
617
+ AccountID: credential.ID, Mode: mode, Remaining: 0, Total: 30,
618
+ WindowSeconds: 3600, ResetAt: &resetAt, SyncedAt: &now, Source: accountdomain.QuotaSourceUpstream, UpdatedAt: now,
619
+ }, nil
620
+ }
backend/internal/application/account/quota_test.go ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package account
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "slices"
7
+ "testing"
8
+ "time"
9
+
10
+ accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
11
+ "github.com/chenyme/grok2api/backend/internal/repository"
12
+ )
13
+
14
+ type quotaResetRepository struct {
15
+ repository.AccountRepository
16
+ countBatchSizes []int
17
+ resetBatchSizes []int
18
+ }
19
+
20
+ func (r *quotaResetRepository) CountProviderAccountsByIDs(_ context.Context, provider accountdomain.Provider, ids []uint64) (int64, error) {
21
+ if provider != accountdomain.ProviderBuild {
22
+ return 0, nil
23
+ }
24
+ r.countBatchSizes = append(r.countBatchSizes, len(ids))
25
+ return int64(len(ids)), nil
26
+ }
27
+
28
+ func (r *quotaResetRepository) ResetQuotaState(_ context.Context, provider accountdomain.Provider, ids []uint64) error {
29
+ if provider != accountdomain.ProviderBuild {
30
+ return errors.New("unexpected provider")
31
+ }
32
+ r.resetBatchSizes = append(r.resetBatchSizes, len(ids))
33
+ return nil
34
+ }
35
+
36
+ func TestNewQuotaViewFreeUsesObservedRollingTokens(t *testing.T) {
37
+ quota := newQuotaView(&accountdomain.Billing{IsUnifiedBillingUser: true}, 250_000, nil, "grok-4.5-build-free", false)
38
+ if quota.Type != QuotaTypeFree || quota.Unit != "tokens" || quota.Limit != 500_000 || quota.LimitKnown || quota.Confidence != "observed" {
39
+ t.Fatalf("quota = %#v", quota)
40
+ }
41
+ if quota.Used != 250_000 || quota.Remaining != 250_000 || quota.UsagePercent != 50 || quota.WindowHours != 24 || !quota.Observed {
42
+ t.Fatalf("quota = %#v", quota)
43
+ }
44
+ }
45
+
46
+ func TestNewQuotaViewPaidUsesMonthlyBilling(t *testing.T) {
47
+ quota := newQuotaView(&accountdomain.Billing{MonthlyLimit: 200, Used: 50, BillingPeriodStart: "start", BillingPeriodEnd: "end"}, 900_000, nil, "", false)
48
+ if quota.Type != QuotaTypePaid || quota.Unit != "credits" || quota.Limit != 200 {
49
+ t.Fatalf("quota = %#v", quota)
50
+ }
51
+ if quota.Used != 50 || quota.Remaining != 150 || quota.UsagePercent != 25 || quota.Observed || !quota.LimitKnown {
52
+ t.Fatalf("quota = %#v", quota)
53
+ }
54
+ }
55
+
56
+ func TestNewQuotaViewPaidShowsBillingProbeState(t *testing.T) {
57
+ now := time.Now().UTC()
58
+ next := now.Add(time.Hour)
59
+ quota := newQuotaView(&accountdomain.Billing{MonthlyLimit: 100, Used: 100}, 0, &accountdomain.QuotaRecovery{
60
+ Kind: accountdomain.QuotaRecoveryKindPaid, Status: accountdomain.QuotaRecoveryStatusExhausted,
61
+ ExhaustedAt: &now, NextProbeAt: &next,
62
+ }, "", false)
63
+ if quota.Type != QuotaTypePaid || quota.Status != QuotaStatusWaitingReset || quota.NextProbeAt == nil {
64
+ t.Fatalf("quota = %#v", quota)
65
+ }
66
+ }
67
+
68
+ func TestNewQuotaViewUnknownWithoutBillingSnapshot(t *testing.T) {
69
+ quota := newQuotaView(nil, 100, nil, "", false)
70
+ if quota.Type != QuotaTypeUnknown {
71
+ t.Fatalf("quota = %#v", quota)
72
+ }
73
+ }
74
+
75
+ func TestNewQuotaViewEstimatesFreeFromObservedZeroBillingProfile(t *testing.T) {
76
+ quota := newQuotaView(&accountdomain.Billing{PlanName: "Free", IsUnifiedBillingUser: true, TopUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD"}, 100, nil, "", false)
77
+ if quota.Type != QuotaTypeFree || quota.Source != "billingProfile" || quota.Confidence != "estimated" || quota.Limit != 500_000 || quota.LimitKnown {
78
+ t.Fatalf("quota = %#v", quota)
79
+ }
80
+ }
81
+
82
+ func TestNewQuotaViewInfersFreeFromSuccessfulZeroBillingSnapshot(t *testing.T) {
83
+ quota := newQuotaView(&accountdomain.Billing{
84
+ IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: time.Now().UTC(),
85
+ }, 100, nil, "", false)
86
+ if quota.Type != QuotaTypeFree || quota.Source != "billingProfile" || quota.Confidence != "estimated" {
87
+ t.Fatalf("quota = %#v", quota)
88
+ }
89
+ }
90
+
91
+ func TestNewQuotaViewTreatsZeroUsageSuperGrokAsPaid(t *testing.T) {
92
+ quota := newQuotaView(&accountdomain.Billing{
93
+ PlanName: "SuperGrok", IsUnifiedBillingUser: true,
94
+ UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", CreditUsagePercent: 0,
95
+ }, 0, nil, "", false)
96
+ if quota.Type != QuotaTypePaid || quota.Unit != "percent" || quota.Limit != 100 || quota.Remaining != 100 || quota.Confidence != "observed" {
97
+ t.Fatalf("quota = %#v", quota)
98
+ }
99
+ }
100
+
101
+ func TestNewQuotaViewUsesConfirmedExhaustion(t *testing.T) {
102
+ now := time.Now().UTC()
103
+ next := now.Add(24 * time.Hour)
104
+ quota := newQuotaView(&accountdomain.Billing{}, 250_000, &accountdomain.QuotaRecovery{
105
+ Status: accountdomain.QuotaRecoveryStatusExhausted, ConfirmedUsed: 1_065_387,
106
+ ConfirmedLimit: 1_000_000, ExhaustedAt: &now, NextProbeAt: &next, LastConfirmedAt: &now,
107
+ }, "", false)
108
+ if quota.Type != QuotaTypeFree || quota.Status != QuotaStatusWaitingReset || !quota.Confirmed {
109
+ t.Fatalf("quota = %#v", quota)
110
+ }
111
+ if quota.Used != 1_065_387 || quota.Limit != 1_000_000 || quota.Remaining != 0 || quota.NextProbeAt == nil || !quota.LimitKnown {
112
+ t.Fatalf("quota = %#v", quota)
113
+ }
114
+ }
115
+
116
+ func TestNewQuotaViewBuildSuperEntitlementOverridesFreeSignals(t *testing.T) {
117
+ now := time.Now().UTC()
118
+ next := now.Add(time.Hour)
119
+ // 零 Billing profile + entitlement → paid/Super,不伪造额度。
120
+ quota := newQuotaView(&accountdomain.Billing{IsUnifiedBillingUser: true, TopUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD"}, 100, nil, "grok-4.5-build-free", true)
121
+ if quota.Type != QuotaTypePaid || quota.Source != "buildSuperEntitlement" || quota.Confidence != "confirmed" || !quota.Confirmed {
122
+ t.Fatalf("entitlement quota = %#v", quota)
123
+ }
124
+ if quota.LimitKnown || quota.Used != 0 || quota.Limit != 0 || quota.Unit != "" {
125
+ t.Fatalf("must not fabricate limits: %#v", quota)
126
+ }
127
+ // Free recovery 不得把 entitlement 账号降回 Free。
128
+ quota = newQuotaView(&accountdomain.Billing{}, 250_000, &accountdomain.QuotaRecovery{
129
+ Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted,
130
+ ConfirmedUsed: 1_000_000, ConfirmedLimit: 1_000_000, ExhaustedAt: &now, NextProbeAt: &next,
131
+ }, "", true)
132
+ if quota.Type != QuotaTypePaid || quota.Source != "buildSuperEntitlement" {
133
+ t.Fatalf("entitlement must override free recovery: %#v", quota)
134
+ }
135
+ // Billing paid 仍优先展示真实额度。
136
+ quota = newQuotaView(&accountdomain.Billing{MonthlyLimit: 200, Used: 50}, 0, nil, "", true)
137
+ if quota.Type != QuotaTypePaid || quota.Source != "upstreamBilling" || quota.Limit != 200 {
138
+ t.Fatalf("paid billing must win: %#v", quota)
139
+ }
140
+ }
141
+
142
+ func TestBatchResetQuotaStatePreservesBillingAndClearsLocalBlocks(t *testing.T) {
143
+ ctx := context.Background()
144
+ service, accounts := openAccountService(t)
145
+ credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
146
+ Provider: accountdomain.ProviderBuild, Name: "build", SourceKey: "quota-reset-build",
147
+ EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
148
+ })
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ now := time.Now().UTC()
153
+ next := now.Add(24 * time.Hour)
154
+ if err := accounts.SaveBilling(ctx, accountdomain.Billing{AccountID: credential.ID, PlanName: "free", Used: 42, SyncedAt: now}); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if err := accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{
158
+ AccountID: credential.ID, Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted,
159
+ ExhaustedAt: &now, NextProbeAt: &next, UpdatedAt: now,
160
+ }); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ if err := accounts.UpsertModelQuotaBlock(ctx, accountdomain.ModelQuotaBlock{
164
+ AccountID: credential.ID, UpstreamModel: "grok-test", Reason: "model_quota_depleted", CooldownUntil: next,
165
+ }); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ if err := accounts.UpsertModelQuotaBlock(ctx, accountdomain.ModelQuotaBlock{
169
+ AccountID: credential.ID, UpstreamModel: "grok-denied", Reason: "model_access_denied", CooldownUntil: next,
170
+ }); err != nil {
171
+ t.Fatal(err)
172
+ }
173
+
174
+ reset, err := service.BatchResetQuotaState(ctx, []uint64{credential.ID})
175
+ if err != nil || reset != 1 {
176
+ t.Fatalf("reset = %d, err = %v", reset, err)
177
+ }
178
+ if _, err := accounts.GetQuotaRecovery(ctx, credential.ID); !errors.Is(err, repository.ErrNotFound) {
179
+ t.Fatalf("quota recovery should be cleared, err = %v", err)
180
+ }
181
+ billing, err := accounts.GetBilling(ctx, credential.ID)
182
+ if err != nil || billing.Used != 42 {
183
+ t.Fatalf("billing = %#v, err = %v", billing, err)
184
+ }
185
+ candidates, err := accounts.ListRoutingCandidates(ctx, accountdomain.ProviderBuild, 0, "grok-test", "")
186
+ if err != nil || len(candidates) != 1 || candidates[0].ModelQuotaBlock != nil {
187
+ t.Fatalf("candidates = %#v, err = %v", candidates, err)
188
+ }
189
+ candidates, err = accounts.ListRoutingCandidates(ctx, accountdomain.ProviderBuild, 0, "grok-denied", "")
190
+ if err != nil || len(candidates) != 1 || candidates[0].ModelQuotaBlock == nil || candidates[0].ModelQuotaBlock.Reason != "model_access_denied" {
191
+ t.Fatalf("access denial must be preserved, candidates = %#v, err = %v", candidates, err)
192
+ }
193
+ }
194
+
195
+ func TestResetAllBuildQuotaStateOnlyProcessesEnabledBuildAccounts(t *testing.T) {
196
+ ctx := context.Background()
197
+ service, accounts := openAccountService(t)
198
+ create := func(providerValue accountdomain.Provider, sourceKey string, enabled bool) accountdomain.Credential {
199
+ value, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
200
+ Provider: providerValue, Name: sourceKey, SourceKey: sourceKey,
201
+ EncryptedAccessToken: "encrypted", Enabled: enabled, AuthStatus: accountdomain.AuthStatusActive,
202
+ })
203
+ if err != nil {
204
+ t.Fatal(err)
205
+ }
206
+ return value
207
+ }
208
+ enabledBuild := create(accountdomain.ProviderBuild, "reset-all-enabled", true)
209
+ disabledBuild := create(accountdomain.ProviderBuild, "reset-all-disabled", false)
210
+ web := create(accountdomain.ProviderWeb, "reset-all-web", true)
211
+ disabledBuild.Enabled = false
212
+ if _, err := accounts.Update(ctx, disabledBuild); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ now := time.Now().UTC()
216
+ next := now.Add(24 * time.Hour)
217
+ for _, value := range []accountdomain.Credential{enabledBuild, disabledBuild, web} {
218
+ if err := accounts.SaveQuotaRecovery(ctx, accountdomain.QuotaRecovery{
219
+ AccountID: value.ID, Kind: accountdomain.QuotaRecoveryKindFree, Status: accountdomain.QuotaRecoveryStatusExhausted,
220
+ ExhaustedAt: &now, NextProbeAt: &next, UpdatedAt: now,
221
+ }); err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ }
225
+
226
+ reset, err := service.ResetAllBuildQuotaState(ctx)
227
+ if err != nil || reset != 1 {
228
+ t.Fatalf("reset = %d, err = %v", reset, err)
229
+ }
230
+ if _, err := accounts.GetQuotaRecovery(ctx, enabledBuild.ID); !errors.Is(err, repository.ErrNotFound) {
231
+ t.Fatalf("enabled Build recovery should be cleared, err = %v", err)
232
+ }
233
+ for _, value := range []accountdomain.Credential{disabledBuild, web} {
234
+ if _, err := accounts.GetQuotaRecovery(ctx, value.ID); err != nil {
235
+ t.Fatalf("recovery for account %d should remain, err = %v", value.ID, err)
236
+ }
237
+ }
238
+ }
239
+
240
+ func TestBatchResetQuotaStateChunksMoreThanAdminPageLimit(t *testing.T) {
241
+ repo := &quotaResetRepository{}
242
+ service := &Service{accounts: repo}
243
+ ids := make([]uint64, 2501)
244
+ for index := range ids {
245
+ ids[index] = uint64(index + 1)
246
+ }
247
+
248
+ reset, err := service.BatchResetQuotaState(context.Background(), ids)
249
+ if err != nil || reset != len(ids) {
250
+ t.Fatalf("reset = %d, err = %v", reset, err)
251
+ }
252
+ want := []int{500, 500, 500, 500, 500, 1}
253
+ if !slices.Equal(repo.countBatchSizes, want) || !slices.Equal(repo.resetBatchSizes, want) {
254
+ t.Fatalf("count batches = %v, reset batches = %v, want %v", repo.countBatchSizes, repo.resetBatchSizes, want)
255
+ }
256
+ }
backend/internal/application/account/service.go ADDED
The diff for this file is too large to render. See raw diff