Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
04e0184
新增配置脚本
MxyEI Jun 7, 2026
2d47d06
新增配置脚本
MxyEI Jun 7, 2026
235b751
新增配置脚本
MxyEI Jun 7, 2026
4da51c4
新增配置脚本
MxyEI Jun 7, 2026
3a993f1
修改配置脚本
MxyEI Jun 7, 2026
28c82ac
修改配置脚本
MxyEI Jun 7, 2026
f9ac0f7
修改配置脚本
MxyEI Jun 7, 2026
9514a5a
修改配置脚本
MxyEI Jun 7, 2026
ae311cc
修改配置脚本
MxyEI Jun 7, 2026
3cb85c3
Merge branch 'Wei-Shaw:main' into main
MxyEI Jun 8, 2026
eba25df
V0.1.136 合并版本
MxyEI Jun 10, 2026
f17c8ce
Revert "V0.1.136 合并版本"
MxyEI Jun 10, 2026
2ec13a9
Merge branch 'Wei-Shaw:main' into main
MxyEI Jun 10, 2026
0a50d8d
V0.1.136 合并版本 修复问题
MxyEI Jun 10, 2026
d283b50
feat(archive): 新增请求/响应全量异步落盘归档
liangcaijv Jun 16, 2026
0d2c795
V0.1.136 上传 hub
MxyEI Jun 16, 2026
6004494
Merge branch 'main' into feat/req-resp-archive
MxyEI Jun 21, 2026
6c935e9
V0.1.137 合并 解决冲突
MxyEI Jun 21, 2026
847abcd
V0.1.137
MxyEI Jun 21, 2026
9b513d1
Merge remote-tracking branch 'upstream/main' into feat/req-resp-archive
liangcaijv Jun 22, 2026
7c1e058
Merge branch 'Wei-Shaw:main' into feat/req-resp-archive
MxyEI Jun 22, 2026
3f7843f
V0.1.138
MxyEI Jun 22, 2026
70588e1
Merge branch 'main' into feat/req-resp-archive
MxyEI Jun 27, 2026
5e98f24
V0.1.139 修复重复函数导致的编译错误
MxyEI Jun 27, 2026
6060bbc
V0.1.139 修复重复函数导致的编译错误
MxyEI Jun 27, 2026
671333a
线上环境的 yml
MxyEI Jun 28, 2026
330c35c
Merge branch 'main' into feat/req-resp-archive
MxyEI Jun 30, 2026
8f34833
Merge branch 'main' into feat/req-resp-archive
MxyEI Jul 2, 2026
231a2c7
Merge branch 'main' into feat/req-resp-archive
MxyEI Jul 5, 2026
fe7bdc0
Merge branch 'main' into feat/req-resp-archive
MxyEI Jul 6, 2026
71cd953
Merge branch 'Wei-Shaw:main' into feat/req-resp-archive
MxyEI Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@
*.md
!deploy/DOCKER.md
docs/
# admin-compliance gate (LegalDocumentView.vue) build-time imports
# docs/legal/*.md?raw into the frontend bundle; keep that subtree in the
# Docker build context even though docs/ is otherwise excluded.

!docs/legal/
!docs/legal/*.md

Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Changelog

## Unreleased

### Fix: Docker 构建失败(docs/legal 文件缺失)

- **Dockerfile**: 添加 `COPY docs/legal/ /app/docs/legal/`,解决前端 `LegalDocumentView.vue` 通过相对路径引用 `docs/legal/admin-compliance.*.md` 在容器内找不到文件导致 vite build 失败的问题。
- **.dockerignore**: 添加 `!docs/legal/` 和 `!docs/legal/*.md` 例外规则,使 `docs/legal/` 目录不被全局的 `docs/` 和 `*.md` 排除规则过滤掉。
- **删除 `frontend/pnpm-workspace.yaml`**: 该文件由本地 pnpm v11 的 `approve-builds` 生成,与 Dockerfile 中 pnpm@9 不兼容(pnpm@9 要求 workspace 文件必须包含 `packages` 字段),导致 `pnpm install` 和 `pnpm run build` 报 "packages field missing or empty"。

- Added a config script download action to each API key row, matching the existing API key action area.
- Added a config script dropdown with Codex CLI, Claude Code, and OpenCode options, styled after the provided reference image.
- Added automatic OS detection so macOS downloads `.sh` scripts and Windows downloads `.bat` scripts.
- Added config script generation for Codex CLI, Claude Code, and OpenCode with the current API endpoint and API key injected into the generated files.
- Set the generated script site name to `look2eye`.
- Added Chinese and English i18n text for the config script button, menu, hints, and download states.
- Added focused unit coverage for config script generation and client availability rules.


本地 docker 部署
export http_proxy=http://127.0.0.1:7890
export https_proxy=http://127.0.0.1:7890
export HTTP_PROXY=http://127.0.0.1:7890
export HTTPS_PROXY=http://127.0.0.1:7890
docker compose -f docker-compose.dev.yml up --build -d

上传自己的 docker hub
export http_proxy=http://127.0.0.1:7890
export https_proxy=http://127.0.0.1:7890
export HTTP_PROXY=http://127.0.0.1:7890
export HTTPS_PROXY=http://127.0.0.1:7890
docker buildx build --platform linux/amd64 -t docker.io/doctor11ma/sub2api:latest -t docker.io/doctor11ma/sub2api:v0.1.138 --push .

feat
上传自己的 docker hub
export http_proxy=http://127.0.0.1:7890
export https_proxy=http://127.0.0.1:7890
export HTTP_PROXY=http://127.0.0.1:7890
export HTTPS_PROXY=http://127.0.0.1:7890
docker buildx build --platform linux/amd64 -t docker.io/doctor11ma/sub2api:v0.1.139feat --push .

7 changes: 7 additions & 0 deletions backend/cmd/server/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func provideCleanup(
emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
archiveService *service.ArchiveService,
subscriptionService *service.SubscriptionService,
oauth *service.OAuthService,
openaiOAuth *service.OpenAIOAuthService,
Expand Down Expand Up @@ -207,6 +208,12 @@ func provideCleanup(
}
return nil
}},
{"ArchiveService", func() error {
if archiveService != nil {
archiveService.Stop()
}
return nil
}},
{"OAuthService", func() error {
oauth.Stop()
return nil
Expand Down
15 changes: 12 additions & 3 deletions backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/cmd/server/wire_gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
emailQueueSvc,
billingCacheSvc,
&service.UsageRecordWorkerPool{},
&service.ArchiveService{},
&service.SubscriptionService{},
oauthSvc,
openAIOAuthSvc,
Expand Down
56 changes: 55 additions & 1 deletion backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type Config struct {
Gemini GeminiConfig `mapstructure:"gemini"`
Update UpdateConfig `mapstructure:"update"`
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
Archive ArchiveConfig `mapstructure:"archive"`
}

type LogConfig struct {
Expand Down Expand Up @@ -1017,7 +1018,31 @@ type GatewayUsageRecordConfig struct {
AutoScaleCooldownSeconds int `mapstructure:"auto_scale_cooldown_seconds"`
}

// TLSFingerprintConfig TLS指纹伪装配置
// ArchiveConfig 请求/响应全量落盘归档配置。
// 默认关闭(opt-in),开启后异步把每次请求体+响应体写入 zstd 压缩的 JSONL 分片。
type ArchiveConfig struct {
// Enabled: 是否启用归档(默认 false)
Enabled bool `mapstructure:"enabled"`
// Dir: 归档目录,空则默认 <DATA_DIR>/archive
Dir string `mapstructure:"dir"`
// MaxShardSizeMB: 单分片压缩后大小上限(MB),超过切片
MaxShardSizeMB int `mapstructure:"max_shard_size_mb"`
// QueueMaxItems: 有界队列最大条数
QueueMaxItems int `mapstructure:"queue_max_items"`
// QueueMaxBytes: 有界队列最大字节数(内存预算)
QueueMaxBytes int64 `mapstructure:"queue_max_bytes"`
// MaxResponseBytes: 单条记录响应体捕获上限(字节),超出截断
MaxResponseBytes int `mapstructure:"max_response_bytes"`
// CompressionLevel: zstd 级别 1-4(1 最快 / 3 默认 / 4 最高压缩比)
CompressionLevel int `mapstructure:"compression_level"`
// FlushIntervalMs: 周期 flush 间隔(毫秒)
FlushIntervalMs int `mapstructure:"flush_interval_ms"`
// MinFreeDiskGB: 归档分区剩余空间低于该值则停写(防写爆磁盘),0 关闭检查
MinFreeDiskGB int `mapstructure:"min_free_disk_gb"`
// IPHashSalt: 客户端 IP 哈希盐;空则在归档目录自动生成持久盐
IPHashSalt string `mapstructure:"ip_hash_salt"`
}

// 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端
type TLSFingerprintConfig struct {
// Enabled: 是否全局启用TLS指纹功能
Expand Down Expand Up @@ -1966,6 +1991,18 @@ func setDefaults() {
viper.SetDefault("gateway.usage_record.auto_scale_cooldown_seconds", 10)
viper.SetDefault("gateway.user_group_rate_cache_ttl_seconds", 30)
viper.SetDefault("gateway.models_list_cache_ttl_seconds", 15)

// 请求/响应归档(默认关闭,opt-in)
viper.SetDefault("archive.enabled", false)
viper.SetDefault("archive.dir", "")
viper.SetDefault("archive.max_shard_size_mb", 512)
viper.SetDefault("archive.queue_max_items", 4096)
viper.SetDefault("archive.queue_max_bytes", 256*1024*1024)
viper.SetDefault("archive.max_response_bytes", 16*1024*1024)
viper.SetDefault("archive.compression_level", 3)
viper.SetDefault("archive.flush_interval_ms", 1500)
viper.SetDefault("archive.min_free_disk_gb", 10)
viper.SetDefault("archive.ip_hash_salt", "")
// TLS指纹伪装配置(默认关闭,需要账号级别单独启用)
// 用户消息串行队列默认值
viper.SetDefault("gateway.user_message_queue.enabled", false)
Expand Down Expand Up @@ -2023,6 +2060,23 @@ func (c *Config) Validate() error {
default:
return fmt.Errorf("log.format must be one of: json/console")
}
if c.Archive.Enabled {
if c.Archive.MaxShardSizeMB <= 0 {
return fmt.Errorf("archive.max_shard_size_mb must be positive when archive is enabled")
}
if c.Archive.MaxResponseBytes <= 0 {
return fmt.Errorf("archive.max_response_bytes must be positive when archive is enabled")
}
if c.Archive.CompressionLevel < 1 || c.Archive.CompressionLevel > 4 {
return fmt.Errorf("archive.compression_level must be between 1 and 4")
}
if c.Archive.FlushIntervalMs <= 0 {
return fmt.Errorf("archive.flush_interval_ms must be positive")
}
if c.Archive.QueueMaxItems <= 0 {
return fmt.Errorf("archive.queue_max_items must be positive")
}
}
switch c.Log.StacktraceLevel {
case "none", "error", "fatal":
case "":
Expand Down
41 changes: 41 additions & 0 deletions backend/internal/handler/gateway_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type GatewayHandler struct {
usageService *service.UsageService
apiKeyService *service.APIKeyService
usageRecordWorkerPool *service.UsageRecordWorkerPool
archiveService *service.ArchiveService
errorPassthroughService *service.ErrorPassthroughService
contentModerationService *service.ContentModerationService
concurrencyHelper *ConcurrencyHelper
Expand All @@ -68,6 +69,7 @@ func NewGatewayHandler(
usageService *service.UsageService,
apiKeyService *service.APIKeyService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
archiveService *service.ArchiveService,
errorPassthroughService *service.ErrorPassthroughService,
contentModerationService *service.ContentModerationService,
userMsgQueueService *service.UserMessageQueueService,
Expand Down Expand Up @@ -102,6 +104,7 @@ func NewGatewayHandler(
usageService: usageService,
apiKeyService: apiKeyService,
usageRecordWorkerPool: usageRecordWorkerPool,
archiveService: archiveService,
errorPassthroughService: errorPassthroughService,
contentModerationService: contentModerationService,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
Expand Down Expand Up @@ -520,6 +523,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.archiveCapture(c, body, result, account, apiKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
Expand Down Expand Up @@ -950,6 +954,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
h.archiveCapture(c, attemptParsedReq.Body.Bytes(), result, account, currentAPIKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
Expand Down Expand Up @@ -2169,6 +2174,42 @@ func (h *GatewayHandler) maybeLogCompatibilityFallbackMetrics(reqLog *zap.Logger
)
}

// archiveCapture 在归档启用时构建并提交一条请求/响应归档记录(非阻塞、可丢弃)。
// 须在 Forward 完成、响应已写完后调用,此时捕获中间件已拿到完整响应体。
func (h *GatewayHandler) archiveCapture(c *gin.Context, body []byte, result *service.ForwardResult, account *service.Account, apiKey *service.APIKey) {
if h == nil || h.archiveService == nil || !h.archiveService.Enabled() || result == nil {
return
}
respBody, truncated, _ := middleware2.GetArchivedResponse(c)
var accountID int64
if account != nil {
accountID = account.ID
}
var userID, apiKeyID int64
if apiKey != nil {
userID = apiKey.UserID
apiKeyID = apiKey.ID
}
h.archiveService.Capture(service.ArchiveInput{
Body: body,
RespBody: respBody,
RespTruncated: truncated,
ReqHeaders: c.Request.Header,
RespHeaders: c.Writer.Header(),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
Model: result.Model,
InboundEndpoint: GetInboundEndpoint(c),
Stream: result.Stream,
Status: c.Writer.Status(),
DurationMs: result.Duration.Milliseconds(),
RequestID: result.RequestID,
ClientIP: ip.GetClientIP(c),
Usage: result.Usage,
})
}

func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task service.UsageRecordTask) {
if task == nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)

quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.archiveCapture(c, body, result, account, apiKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/gateway_handler_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)

quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.archiveCapture(c, body, result, account, apiKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/gemini_v1beta_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.archiveCapture(c, body, result, account, apiKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
Expand Down
Loading
Loading