mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a75d7d238 | ||
|
|
1c3f2810af | ||
|
|
7c48f9a85f | ||
|
|
980a46ffb0 | ||
|
|
824f763a77 | ||
|
|
9b3c96dc68 | ||
|
|
52f2bc69fb | ||
|
|
067cf8b46e | ||
|
|
d2b080e88b | ||
|
|
29e39b96e5 | ||
|
|
08e994ad86 | ||
|
|
bc2244c83f |
@@ -1 +1 @@
|
||||
0.1.157
|
||||
0.1.158
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -150,7 +149,7 @@ func (h *AuditLogHandler) Clear(c *gin.Context) {
|
||||
CredentialMasked: middleware.MaskedRequestCredential(c),
|
||||
Method: http.MethodPost,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: middleware.SecurityClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ func GetTrustedClientIP(c *gin.Context) string {
|
||||
return normalizeIP(c.ClientIP())
|
||||
}
|
||||
|
||||
// GetSecurityClientIP 返回安全敏感场景(API Key IP 限制、审计日志、会话 IP/UA 绑定)
|
||||
// 使用的客户端 IP。trustForwarded 对应系统设置「信任反代传递的客户端 IP」:
|
||||
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
|
||||
// 关闭时走 Gin trusted_proxies 解析链。
|
||||
func GetSecurityClientIP(c *gin.Context, trustForwarded bool) string {
|
||||
if trustForwarded {
|
||||
return GetClientIP(c)
|
||||
}
|
||||
return GetTrustedClientIP(c)
|
||||
}
|
||||
|
||||
// normalizeIP 规范化 IP 地址,去除端口号和空格。
|
||||
func normalizeIP(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
|
||||
@@ -94,3 +94,33 @@ func TestCheckIPRestrictionWithCompiledRules_InvalidWhitelistStillDenies(t *test
|
||||
require.False(t, allowed)
|
||||
require.Equal(t, "access denied", reason)
|
||||
}
|
||||
|
||||
func TestGetSecurityClientIPHonorsTrustToggle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
trustForwarded bool
|
||||
want string
|
||||
}{
|
||||
{name: "trust disabled uses trusted proxy chain", trustForwarded: false, want: "9.9.9.9"},
|
||||
{name: "trust enabled uses forwarded header", trustForwarded: true, want: "1.2.3.4"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
c.String(200, GetSecurityClientIP(c, tc.trustForwarded))
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "9.9.9.9:12345"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
require.Equal(t, tc.want, w.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,10 +97,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
// 检查 IP 限制(白名单/黑名单)
|
||||
// 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制
|
||||
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
|
||||
clientIP := ip.GetTrustedClientIP(c)
|
||||
if cfg.TrustForwardedIPForAPIKeyACL() {
|
||||
clientIP = ip.GetClientIP(c)
|
||||
}
|
||||
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
|
||||
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
|
||||
if !allowed {
|
||||
if clientIP == "" {
|
||||
|
||||
@@ -59,10 +59,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
|
||||
// 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。
|
||||
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
|
||||
clientIP := ip.GetTrustedClientIP(c)
|
||||
if cfg.TrustForwardedIPForAPIKeyACL() {
|
||||
clientIP = ip.GetClientIP(c)
|
||||
}
|
||||
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
|
||||
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
|
||||
if !allowed {
|
||||
if clientIP == "" {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -147,7 +146,7 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle
|
||||
Action: action,
|
||||
Method: c.Request.Method,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: SecurityClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestBody: bodyRedacted,
|
||||
StatusCode: status,
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SessionBindingContext 全局中间件:将请求的可信客户端 IP 与 User-Agent 注入
|
||||
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定。
|
||||
// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。
|
||||
func SessionBindingContext() gin.HandlerFunc {
|
||||
// SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入
|
||||
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定,
|
||||
// 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。
|
||||
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关:
|
||||
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
|
||||
// 关闭时走 trusted_proxies 解析链,避免不可信头伪造绕过绑定。
|
||||
func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
binding := &service.SessionBinding{
|
||||
IP: ip.GetTrustedClientIP(c),
|
||||
IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
|
||||
@@ -21,13 +27,27 @@ func SessionBindingContext() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// currentSessionBindingHash 计算当前请求的会话指纹哈希。
|
||||
func currentSessionBindingHash(c *gin.Context) string {
|
||||
binding := &service.SessionBinding{
|
||||
// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
|
||||
// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies
|
||||
// 链回退兜底(等价于开关关闭时的行为)。
|
||||
func requestSessionBinding(c *gin.Context) *service.SessionBinding {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil {
|
||||
return binding
|
||||
}
|
||||
return &service.SessionBinding{
|
||||
IP: ip.GetTrustedClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
return binding.Hash()
|
||||
}
|
||||
|
||||
// SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。
|
||||
// 与会话绑定、API Key IP 限制共用同一套「信任反代传递的客户端 IP」开关语义。
|
||||
func SecurityClientIP(c *gin.Context) string {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil &&
|
||||
strings.TrimSpace(binding.IP) != "" {
|
||||
return binding.IP
|
||||
}
|
||||
return ip.GetTrustedClientIP(c)
|
||||
}
|
||||
|
||||
// enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。
|
||||
@@ -49,7 +69,8 @@ func enforceSessionBinding(
|
||||
if claims == nil || claims.BindingHash == "" {
|
||||
return true
|
||||
}
|
||||
current := currentSessionBindingHash(c)
|
||||
binding := requestSessionBinding(c)
|
||||
current := binding.Hash()
|
||||
if current == "" || current == claims.BindingHash {
|
||||
return true
|
||||
}
|
||||
@@ -71,7 +92,7 @@ func enforceSessionBinding(
|
||||
Action: service.AuditActionSessionBindingMismatch,
|
||||
Method: c.Request.Method,
|
||||
Path: path,
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: binding.IP,
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: 401,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build unit
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 反代场景:RemoteAddr 为 127.0.0.1,真实客户端 IP 在 X-Real-IP 中。
|
||||
// 会话绑定注入与审计 IP 必须与 API Key IP 限制共用「信任反代传递的客户端 IP」开关语义。
|
||||
func TestSessionBindingContextHonorsTrustForwardedToggle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
trustForwarded bool
|
||||
wantIP string
|
||||
}{
|
||||
{name: "trust disabled records proxy address", trustForwarded: false, wantIP: "127.0.0.1"},
|
||||
{name: "trust enabled records forwarded client IP", trustForwarded: true, wantIP: "1.2.3.4"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.SetTrustForwardedIPForAPIKeyACL(tc.trustForwarded)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.Use(SessionBindingContext(cfg))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
binding := service.SessionBindingFromContext(c.Request.Context())
|
||||
require.NotNil(t, binding)
|
||||
require.Equal(t, tc.wantIP, binding.IP)
|
||||
require.Equal(t, "test-agent", binding.UserAgent)
|
||||
require.Equal(t, tc.wantIP, SecurityClientIP(c))
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未经过 SessionBindingContext 注入时(异常挂载顺序/单测直调),回退 trusted_proxies 链,
|
||||
// 等价于开关关闭时的历史行为。
|
||||
func TestSecurityClientIPFallsBackWithoutInjectedBinding(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
c.String(200, SecurityClientIP(c))
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "9.9.9.9:12345"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
require.Equal(t, "9.9.9.9", w.Body.String())
|
||||
}
|
||||
|
||||
// requestSessionBinding 优先取注入值:开关开启时校验哈希必须基于注入的转发 IP 计算,
|
||||
// 与 token 签发路径取值一致,否则同一客户端会被误判为指纹变化。
|
||||
func TestRequestSessionBindingPrefersInjectedBinding(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.SetTrustForwardedIPForAPIKeyACL(true)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.Use(SessionBindingContext(cfg))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
issued := &service.SessionBinding{IP: "1.2.3.4", UserAgent: "test-agent"}
|
||||
require.Equal(t, issued.Hash(), requestSessionBinding(c).Hash())
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
}
|
||||
@@ -54,8 +54,9 @@ func SetupRouter(
|
||||
|
||||
// 应用中间件
|
||||
r.Use(middleware2.RequestLogger())
|
||||
// 将可信客户端 IP + UA 注入 request context,供 token 签发路径写入会话绑定
|
||||
r.Use(middleware2.SessionBindingContext())
|
||||
// 将客户端 IP + UA 注入 request context,供 token 签发/会话绑定/审计日志统一读取。
|
||||
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关。
|
||||
r.Use(middleware2.SessionBindingContext(cfg))
|
||||
r.Use(middleware2.Logger())
|
||||
r.Use(middleware2.CORS(cfg.CORS))
|
||||
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string {
|
||||
|
||||
@@ -1425,10 +1425,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
// 配置集校验。
|
||||
capability = OpenAIEndpointCapabilityChatCompletions
|
||||
case OpenAIEndpointCapabilityAlphaSearch:
|
||||
// Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用
|
||||
// OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往
|
||||
// chatgpt.com/backend-api/codex/alpha/search 会稳定 401。
|
||||
if a.Type != AccountTypeOAuth {
|
||||
// alpha/search 的转发按账号类型分流:OAuth/PAT 走
|
||||
// chatgpt.com/backend-api/codex/alpha/search,API key 走
|
||||
// {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
|
||||
// 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
|
||||
if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
}
|
||||
case OpenAIEndpointCapabilityEmbeddings:
|
||||
|
||||
@@ -668,6 +668,53 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_ResponsesCapabilityExcl
|
||||
})
|
||||
}
|
||||
|
||||
// alpha/search 调度必须同时放行 OAuth 与 APIKey 账号:v0.1.157 曾因 OAuth-only
|
||||
// 门控把 APIKey 账号从候选池剔除,纯 APIKey 分组的独立搜索请求在选号阶段就
|
||||
// 报无可用账号,Codex 网页搜索整体失效(转发层其实一直支持 APIKey 路径)。
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_AlphaSearchAllowsAPIKeyAccount(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
ctx := context.Background()
|
||||
groupID := int64(10125)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 38001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, _, err := svc.SelectAccountWithSchedulerForCapability(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"",
|
||||
"gpt-5.6-sol",
|
||||
nil,
|
||||
OpenAIUpstreamTransportHTTPSSE,
|
||||
OpenAIEndpointCapabilityAlphaSearch,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(38001), selection.Account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsGrokChatAccount(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) {
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) ||
|
||||
isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
// alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
|
||||
// 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
|
||||
@@ -505,8 +506,29 @@ func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.C
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现
|
||||
// /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型
|
||||
// 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只
|
||||
// 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而
|
||||
// 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索,
|
||||
// 请求却可能死在先被选中的 API key 账号上。
|
||||
func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool {
|
||||
if account == nil || account.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
}
|
||||
return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
|
||||
}
|
||||
|
||||
func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
|
||||
return statusCode != http.StatusUnauthorized
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed:
|
||||
// 401:工具端点的 access enforcement 不代表凭据全局失效;
|
||||
// 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。
|
||||
// 两类都只换号,不写账号错误状态。
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
|
||||
|
||||
@@ -393,8 +393,101 @@ func TestForwardAlphaSearchPATResponsesFallbackUnauthorizedDoesNotMarkAccountErr
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
// API key 上游(官方平台或第三方中转)不提供 /v1/alpha/search 时返回的
|
||||
// 404/405 必须触发换号而不是把错误透传给客户端:混合分组里 OAuth 账号可以
|
||||
// 承接搜索,请求不能死在先被选中的 API key 账号上。端点缺失也不能写账号
|
||||
// 错误状态——账号本身是健康的。
|
||||
func TestForwardAlphaSearchAPIKeyEndpointNotFoundFailsOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Not Found"}}`)),
|
||||
}}
|
||||
repo := &alphaSearchAccountStateRepo{}
|
||||
cfg := &config.Config{}
|
||||
service := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 9,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://relay.example",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusNotFound, failoverErr.StatusCode)
|
||||
require.Zero(t, repo.setErrorCalls)
|
||||
require.Empty(t, repo.lastError)
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
// OAuth 账号的 chatgpt.com 端点固定存在,404 保持原有透传行为不变。
|
||||
func TestForwardAlphaSearchOAuthNotFoundPassesThrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstreamBody := `{"detail":"Not Found"}`
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 10,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
require.JSONEq(t, upstreamBody, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusUnauthorized))
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusNotFound))
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusMethodNotAllowed))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusForbidden))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
|
||||
}
|
||||
|
||||
func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) {
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusNotFound))
|
||||
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusMethodNotAllowed))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusBadRequest))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(oauth, http.StatusNotFound))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(nil, http.StatusNotFound))
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
// Free OAuth + client function tools: reuse Messages mixed-tools cache route
|
||||
// (append web_search/x_search so xAI does not force non-cacheable build-free).
|
||||
patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
}
|
||||
|
||||
token, _, err := s.getRequestCredential(ctx, c, account)
|
||||
if err != nil {
|
||||
|
||||
@@ -102,8 +102,8 @@ func isGrokRequestContext(c *gin.Context) bool {
|
||||
// Free OAuth requests without native search tools are routed by xAI to the
|
||||
// non-cacheable build-free model. For otherwise tool-free requests, add the
|
||||
// native tools with tool_choice=none: this selects the cache-capable tier
|
||||
// without allowing an actual search. Explicit client tools are handled by the
|
||||
// narrower Messages-only mixed-tools policy below.
|
||||
// without allowing an actual search. Explicit client function tools are handled by
|
||||
// applyGrokFreeMessagesFunctionToolCacheRoute (Messages bridge and native Responses).
|
||||
func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if identity == "" {
|
||||
@@ -263,17 +263,35 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
case "function":
|
||||
if !tool.IsObject() || strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if !tool.IsObject() || name == "" || tool.Get("function").Exists() {
|
||||
return body, nil
|
||||
}
|
||||
// Grok Build may declare search as function tools. Convert to native
|
||||
// entries so Free OAuth stays cache-capable without duplicate names.
|
||||
if name == "web_search" || name == "x_search" {
|
||||
if present[name] {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(map[string]string{"type": name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged = append(merged, raw)
|
||||
present[name] = true
|
||||
continue
|
||||
}
|
||||
hasFunction = true
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
case "web_search", "x_search":
|
||||
// Native tools may already be present when this helper is retried.
|
||||
if present[toolType] {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
present[toolType] = true
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
present[toolType] = true
|
||||
}
|
||||
if !hasFunction {
|
||||
return body, nil
|
||||
|
||||
@@ -475,7 +475,7 @@ func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(
|
||||
}
|
||||
|
||||
func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
t.Run("OpenAI APIKey 默认兼容 chat 和 embeddings", func(t *testing.T) {
|
||||
t.Run("OpenAI APIKey 默认兼容 chat、embeddings 和 alpha search", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
@@ -483,6 +483,7 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("OpenAI OAuth 默认仅兼容 chat", func(t *testing.T) {
|
||||
@@ -496,7 +497,9 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
t.Run("alpha search 仅允许 OpenAI OAuth/PAT 类账号", func(t *testing.T) {
|
||||
t.Run("alpha search 允许 OpenAI OAuth/PAT 与 APIKey 账号,拒绝 Grok", func(t *testing.T) {
|
||||
// OAuth/PAT 走 chatgpt.com Codex 端点,APIKey 走 {base_url}/v1/alpha/search,
|
||||
// 两类都能承接独立搜索(APIKey 被排除曾导致纯 APIKey 分组搜索失效的回归)。
|
||||
apiKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
@@ -505,9 +508,14 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
grok := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
|
||||
require.False(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.True(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.True(t, oauth.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, grok.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) {
|
||||
@@ -533,7 +541,8 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
// chat 能力隐含放行 alpha search(OAuth/APIKey 语义一致)。
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
|
||||
@@ -201,6 +201,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity)
|
||||
if err != nil {
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
}
|
||||
upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg)
|
||||
} else {
|
||||
upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
|
||||
|
||||
@@ -111,7 +111,7 @@ let elementsInstance: StripeElements | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const { loadStripe } = await import('@stripe/stripe-js/pure')
|
||||
const stripe = await loadStripe(props.publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
|
||||
@@ -868,6 +868,8 @@ export default {
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
||||
mobileRefreshTokenAuth: 'Manual Mobile RT Input',
|
||||
accessTokenAuth: 'Manual AT Input',
|
||||
codexSessionAuth: 'Codex OAuth auth.json / AT Import',
|
||||
codexSessionDesc: 'Paste a Codex OAuth auth.json or an accessToken. Accounts use the step 1 settings.',
|
||||
codexSessionInputLabel: 'Codex OAuth auth.json or accessToken',
|
||||
|
||||
@@ -146,10 +146,11 @@ export default {
|
||||
},
|
||||
apiKeyAcl: {
|
||||
title: 'API Key IP Access Control',
|
||||
description: 'Choose which client IP is used by API Key allowlists and denylists',
|
||||
description:
|
||||
'Choose which client IP is used by API Key allowlists/denylists, admin audit logs, and session IP/UA binding',
|
||||
trustForwardedIp: 'Trust forwarded client IP',
|
||||
trustForwardedIpHint:
|
||||
'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.'
|
||||
'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists/denylists, admin audit logs, and session IP/UA binding use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records. Toggling this switch changes the IP fingerprint of existing sessions; with session binding enabled they must sign in again.'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'LinuxDo Connect Login',
|
||||
|
||||
@@ -955,6 +955,8 @@ export default {
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||
mobileRefreshTokenAuth: '手动输入 Mobile RT',
|
||||
accessTokenAuth: '手动输入 AT',
|
||||
codexSessionAuth: 'Codex OAuth auth.json / AT 导入',
|
||||
codexSessionDesc: '粘贴 Codex OAuth auth.json 或 accessToken,按第一步配置创建账号。',
|
||||
codexSessionInputLabel: 'Codex OAuth auth.json 或 accessToken',
|
||||
|
||||
@@ -146,10 +146,10 @@ export default {
|
||||
},
|
||||
apiKeyAcl: {
|
||||
title: 'API Key IP 访问控制',
|
||||
description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断',
|
||||
description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断',
|
||||
trustForwardedIp: '信任反代传递的客户端 IP',
|
||||
trustForwardedIpHint:
|
||||
'默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。'
|
||||
'默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单、操作审计日志与会话 IP/UA 绑定会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。切换本开关会改变已登录会话的 IP 指纹,开启会话绑定时现有会话需重新登录。'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'LinuxDo Connect 登录',
|
||||
|
||||
@@ -252,7 +252,24 @@
|
||||
</template>
|
||||
<template #cell-name="{ row, value }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<HelpTooltip
|
||||
v-if="accountHomepageUrl(row)"
|
||||
:content="accountHomepageUrl(row)"
|
||||
width-class="w-max max-w-sm break-all"
|
||||
class="-ml-1 self-start"
|
||||
>
|
||||
<template #trigger>
|
||||
<a
|
||||
:href="accountHomepageUrl(row)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-b border-dotted border-gray-300 font-medium text-gray-900 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
{{ value }}
|
||||
</a>
|
||||
</template>
|
||||
</HelpTooltip>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<span
|
||||
v-if="accountDisplayEmail(row)"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
|
||||
@@ -523,6 +540,7 @@ import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -1295,6 +1313,12 @@ function accountDisplayEmail(row: any): string {
|
||||
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
|
||||
}
|
||||
|
||||
function accountHomepageUrl(row: Account): string {
|
||||
if (row.type !== 'apikey' || typeof row.credentials?.base_url !== 'string') return ''
|
||||
const baseUrl = sanitizeUrl(row.credentials.base_url)
|
||||
return baseUrl ? new URL(baseUrl).origin : ''
|
||||
}
|
||||
|
||||
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
|
||||
|
||||
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
|
||||
|
||||
@@ -5,6 +5,7 @@ import AccountsView from '../AccountsView.vue'
|
||||
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
|
||||
// 外审 F2:AccountActionMenu emit 'create-spark-shadow',但 AccountsView 此前未监听,
|
||||
// 导致按钮点击无效。本测试通过真实组件引用 emit 该事件,断言父页面接线调用 API。
|
||||
@@ -207,7 +208,7 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Task 6: 影子行 parent_* OR 兜底展示
|
||||
// 账号行展示
|
||||
const mountViewWithRow = () =>
|
||||
mount(AccountsView, {
|
||||
global: {
|
||||
@@ -255,7 +256,7 @@ const mountViewWithRow = () =>
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
|
||||
describe('admin AccountsView — 账号行展示', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
|
||||
@@ -304,6 +305,47 @@ describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('仅将具有安全 base_url 的 API Key 账号名称链接到站点主页', async () => {
|
||||
listAccounts.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 101, name: 'relay-account', platform: 'openai', type: 'apikey', credentials: { base_url: 'https://relay.example.com/api/v1/' } },
|
||||
{ id: 102, name: 'oauth-account', platform: 'openai', type: 'oauth', credentials: { base_url: 'https://oauth.example.com/v1' } },
|
||||
{ id: 103, name: 'invalid-url', platform: 'openai', type: 'apikey', credentials: { base_url: 'javascript:alert(1)' } },
|
||||
],
|
||||
total: 3,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
|
||||
const wrapper = mountViewWithRow()
|
||||
await flushPromises()
|
||||
|
||||
const links = wrapper.findAll('a')
|
||||
expect(links).toHaveLength(1)
|
||||
const [link] = links
|
||||
expect(link.text()).toBe('relay-account')
|
||||
expect(link.attributes()).toMatchObject({
|
||||
href: 'https://relay.example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})
|
||||
expect(link.classes()).toEqual(expect.arrayContaining([
|
||||
'border-dotted',
|
||||
'text-gray-900',
|
||||
'dark:text-white',
|
||||
]))
|
||||
expect(link.classes()).not.toContain('text-primary-600')
|
||||
const tooltip = wrapper.findComponent(HelpTooltip)
|
||||
expect(tooltip.props('content')).toBe('https://relay.example.com')
|
||||
expect(tooltip.props('widthClass')).toBe('w-max max-w-sm break-all')
|
||||
expect(tooltip.classes()).toEqual(expect.arrayContaining(['self-start']))
|
||||
expect(wrapper.text()).toContain('oauth-account')
|
||||
expect(wrapper.text()).toContain('invalid-url')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes fresh Grok billing and quota snapshots before stale credential fallbacks', async () => {
|
||||
const grokAccounts = [
|
||||
{
|
||||
|
||||
@@ -165,7 +165,7 @@ onMounted(async () => {
|
||||
const publishableKey = paymentStore.config?.stripe_publishable_key
|
||||
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
|
||||
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const { loadStripe } = await import('@stripe/stripe-js/pure')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const { loadStripe } = await import('@stripe/stripe-js/pure')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { error.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ vi.mock('@/api/payment', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@stripe/stripe-js', () => ({
|
||||
vi.mock('@stripe/stripe-js/pure', () => ({
|
||||
loadStripe,
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const frontendRoot = resolve(__dirname, '../../../..')
|
||||
const stripeConsumers = [
|
||||
'src/views/user/StripePaymentView.vue',
|
||||
'src/views/user/StripePopupView.vue',
|
||||
'src/components/payment/StripePaymentInline.vue',
|
||||
]
|
||||
|
||||
function readFrontendFile(path: string): string {
|
||||
return readFileSync(resolve(frontendRoot, path), 'utf8')
|
||||
}
|
||||
|
||||
describe('Stripe lazy-loading contract', () => {
|
||||
it.each(stripeConsumers)('%s uses the side-effect-free Stripe loader', (path) => {
|
||||
const source = readFrontendFile(path)
|
||||
|
||||
expect(source).toContain("await import('@stripe/stripe-js/pure')")
|
||||
expect(source).not.toMatch(/await import\(['"]@stripe\/stripe-js['"]\)/)
|
||||
})
|
||||
|
||||
it('keeps Stripe out of the shared vendor chunk', () => {
|
||||
const viteConfig = readFrontendFile('vite.config.ts')
|
||||
const stripeRule = viteConfig.indexOf("id.includes('/@stripe/stripe-js/')")
|
||||
const miscFallback = viteConfig.indexOf("return 'vendor-misc'")
|
||||
|
||||
expect(stripeRule).toBeGreaterThan(-1)
|
||||
expect(viteConfig.slice(stripeRule, miscFallback)).toContain("return 'vendor-stripe'")
|
||||
expect(stripeRule).toBeLessThan(miscFallback)
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,11 @@ export default defineConfig(({ mode }) => {
|
||||
return 'vendor-i18n'
|
||||
}
|
||||
|
||||
// Stripe 仅在支付流程中按需加载,避免进入首页公共依赖。
|
||||
if (id.includes('/@stripe/stripe-js/')) {
|
||||
return 'vendor-stripe'
|
||||
}
|
||||
|
||||
// 其他小型第三方库合并
|
||||
return 'vendor-misc'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user