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
- .dockerignore +32 -0
- .gitattributes +3 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +118 -0
- .github/ISSUE_TEMPLATE/config.yml +1 -0
- .github/ISSUE_TEMPLATE/documentation.yml +95 -0
- .github/ISSUE_TEMPLATE/feature_request.yml +114 -0
- .github/workflows/codeql.yml +49 -0
- .github/workflows/ghcr-image.yml +219 -0
- .github/workflows/stale.yml +30 -0
- .gitignore +45 -0
- Dockerfile +73 -0
- LICENSE +21 -0
- Makefile +14 -0
- README.md +34 -0
- README.zh-CN.md +359 -0
- VERSION +1 -0
- backend/Makefile +5 -0
- backend/README.md +82 -0
- backend/cmd/grok2api/main.go +24 -0
- backend/docs/docs.go +809 -0
- backend/docs/swagger.json +784 -0
- backend/docs/swagger.yaml +514 -0
- backend/go.mod +87 -0
- backend/go.sum +255 -0
- backend/internal/app/application.go +764 -0
- backend/internal/app/console_routes_test.go +32 -0
- backend/internal/app/startup.go +444 -0
- backend/internal/app/startup_test.go +144 -0
- backend/internal/app/topology.go +72 -0
- backend/internal/app/topology_test.go +32 -0
- backend/internal/application/account/account_cleanup_test.go +157 -0
- backend/internal/application/account/auto_clean.go +265 -0
- backend/internal/application/account/auto_clean_test.go +528 -0
- backend/internal/application/account/batch_quota_test.go +93 -0
- backend/internal/application/account/batch_update_test.go +79 -0
- backend/internal/application/account/build_super_entitlement_test.go +189 -0
- backend/internal/application/account/console_quota_test.go +25 -0
- backend/internal/application/account/conversion_test.go +278 -0
- backend/internal/application/account/credential_refresh_test.go +706 -0
- backend/internal/application/account/credential_scheduler.go +183 -0
- backend/internal/application/account/export_test.go +288 -0
- backend/internal/application/account/linked_delete_test.go +264 -0
- backend/internal/application/account/list_filter_test.go +64 -0
- backend/internal/application/account/paid_quota_recovery_test.go +57 -0
- backend/internal/application/account/provider_links.go +85 -0
- backend/internal/application/account/provider_links_test.go +172 -0
- backend/internal/application/account/quota_refresh_redis_integration_test.go +147 -0
- backend/internal/application/account/quota_refresh_test.go +620 -0
- backend/internal/application/account/quota_test.go +256 -0
- 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&utm_medium=badge&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 := "aCountingAdapter{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 := "aCountingAdapter{
|
| 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 := "aCountingAdapter{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 := "aCountingAdapter{}
|
| 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 := "aCountingAdapter{}
|
| 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 := "aCountingAdapter{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 := "aResetRepository{}
|
| 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
|
|
|