mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
OpenCode Go 上游(https://opencode.ai/zen/go/v1)官方提供用量端点 GET /zen/go/v1/usage(Bearer 复用消费 key),返回 rolling(5h)/weekly/monthly 三窗口的 percent 与 resetsAt。本提交为 openai apikey 账号(base_url 为 opencode.ai/zen/go/v1)接入该用量窗口: - 后端:OpenCodeGoUsageService——手动刷新(每账号 10s 节流)、 周期自动刷新(全局开关 + 账号开关双开启,interval 5-1440 分钟, leader lock + 每轮 20 账号 + 并发 4)、失败指数退避(上限 24h)、 Retry-After 尊重;401→unauthorized、403→failed;snapshot 持久化于 accounts.extra(单账号写入,无分组 CAS、无会话加密) - admin API:settings GET/PUT、/:id/opencode-go-usage GET、 auto-refresh PUT、refresh POST(与 Ollama Cloud usage 同构) - 前端:账号列表行内三窗口进度条(复用 UsageProgressBar)、 编辑弹窗内配置面板(详情+手动刷新+auto-refresh)、全局设置卡片、 en/zh i18n 验证: - 上游端点已用生产真实 Go key 实测 200,响应 {"usage":{"rolling":{"status":"ok","percent":6,...},...}}(2026-08-13) - go build ./... OK;service/handler/repo/dto/cmd 全量回归 OK - 新增单测 20 例(解析 401/403/畸形 JSON、退避、节流等)全绿 - frontend vitest 新增 7 例 + Ollama 回归 4 例全绿;vue-tsc 零错误
171 lines
6.6 KiB
Go
171 lines
6.6 KiB
Go
package admin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type openCodeGoUsageHandlerTestRepo struct {
|
|
service.AccountRepository
|
|
account *service.Account
|
|
accounts []*service.Account
|
|
}
|
|
|
|
func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
|
|
if r.account != nil && r.account.ID == id {
|
|
return r.account, nil
|
|
}
|
|
for _, account := range r.accounts {
|
|
if account.ID == id {
|
|
return account, nil
|
|
}
|
|
}
|
|
return nil, service.ErrAccountNotFound
|
|
}
|
|
|
|
func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error {
|
|
return nil
|
|
}
|
|
func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error {
|
|
return nil
|
|
}
|
|
func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newOpenCodeGoUsageHandlerTestService(t *testing.T) *service.OpenCodeGoUsageService {
|
|
t.Helper()
|
|
svc := service.NewOpenCodeGoUsageService(nil, nil, nil)
|
|
t.Cleanup(svc.Stop)
|
|
return svc
|
|
}
|
|
|
|
func newOpenCodeGoUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
ctx, _ := gin.CreateTestContext(recorder)
|
|
ctx.Request = request
|
|
if id != "" {
|
|
ctx.Params = gin.Params{{Key: "id", Value: id}}
|
|
}
|
|
return ctx, recorder
|
|
}
|
|
|
|
func TestOpenCodeGoUsageHandlersValidateRequestsAndDependencies(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
svc := newOpenCodeGoUsageHandlerTestService(t)
|
|
|
|
t.Run("invalid account id", func(t *testing.T) {
|
|
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/opencode-go-usage", "", "not-an-id")
|
|
(&AccountHandler{opencodeGoUsage: svc}).GetOpenCodeGoUsage(ctx)
|
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
})
|
|
|
|
t.Run("missing enabled", func(t *testing.T) {
|
|
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodPut, "/admin/accounts/7/opencode-go-usage/auto-refresh", `{}`, "7")
|
|
(&AccountHandler{opencodeGoUsage: svc}).SetOpenCodeGoUsageAutoRefresh(ctx)
|
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
})
|
|
|
|
t.Run("service unavailable", func(t *testing.T) {
|
|
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/7/opencode-go-usage", "", "7")
|
|
(&AccountHandler{}).GetOpenCodeGoUsage(ctx)
|
|
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
|
require.Contains(t, recorder.Body.String(), "OPENCODE_GO_USAGE_UNAVAILABLE")
|
|
})
|
|
}
|
|
|
|
func TestGetOpenCodeGoUsageSettingsHandlerSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/opencode-go-usage/settings", "", "")
|
|
handler := &AccountHandler{opencodeGoUsage: newOpenCodeGoUsageHandlerTestService(t)}
|
|
|
|
handler.GetOpenCodeGoUsageSettings(ctx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
require.Contains(t, recorder.Body.String(), `"enabled":false`)
|
|
require.Contains(t, recorder.Body.String(), `"interval_minutes":15`)
|
|
}
|
|
|
|
func TestOpenCodeGoUsageStateEmbeddedInListAndDetail(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
now := time.Now().UTC()
|
|
account := &service.Account{
|
|
ID: 7, Name: "opencode", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"},
|
|
Extra: map[string]any{
|
|
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
|
service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{
|
|
Status: service.OpenCodeGoUsageStatusOK, Data: &service.OpenCodeGoUsageData{
|
|
Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6},
|
|
},
|
|
LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
|
|
},
|
|
},
|
|
Status: service.StatusActive,
|
|
}
|
|
repo := &openCodeGoUsageHandlerTestRepo{account: account}
|
|
adminService := newStubAdminService()
|
|
adminService.accounts = []service.Account{*account}
|
|
adminService.getAccountResult = account
|
|
usageService := service.NewOpenCodeGoUsageService(repo, nil, nil)
|
|
t.Cleanup(usageService.Stop)
|
|
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
|
handler.SetOpenCodeGoUsageService(usageService)
|
|
router := gin.New()
|
|
router.GET("/accounts", handler.List)
|
|
router.GET("/accounts/:id", handler.GetByID)
|
|
router.GET("/accounts/:id/opencode-go-usage", handler.GetOpenCodeGoUsage)
|
|
|
|
listRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
|
|
require.Equal(t, http.StatusOK, listRecorder.Code)
|
|
var listPayload struct {
|
|
Data struct {
|
|
Items []struct {
|
|
OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"`
|
|
} `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
|
|
require.Len(t, listPayload.Data.Items, 1)
|
|
require.NotNil(t, listPayload.Data.Items[0].OpenCodeGoUsage)
|
|
require.True(t, listPayload.Data.Items[0].OpenCodeGoUsage.AutoRefreshEnabled)
|
|
require.Equal(t, 6.0, listPayload.Data.Items[0].OpenCodeGoUsage.Snapshot.Data.Rolling.Percent)
|
|
|
|
detailRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil))
|
|
require.Equal(t, http.StatusOK, detailRecorder.Code)
|
|
var detailPayload struct {
|
|
Data struct {
|
|
OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
|
|
require.NotNil(t, detailPayload.Data.OpenCodeGoUsage)
|
|
require.Equal(t, 6.0, detailPayload.Data.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent)
|
|
|
|
stateRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/opencode-go-usage", nil))
|
|
require.Equal(t, http.StatusOK, stateRecorder.Code)
|
|
var statePayload struct {
|
|
Data service.OpenCodeGoUsageState `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
|
|
require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OpenCodeGoUsage.Snapshot)
|
|
|
|
for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} {
|
|
require.NotContains(t, body, "test-key")
|
|
}
|
|
}
|