Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
4cce21b1
Unverified
Commit
4cce21b1
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge branch 'main' into main
parents
0707f3d9
c0c9c984
Changes
52
Show whitespace changes
Inline
Side-by-side
backend/internal/repository/proxy_probe_service.go
View file @
4cce21b1
...
@@ -28,7 +28,6 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
...
@@ -28,7 +28,6 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
log
.
Printf
(
"[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure."
)
log
.
Printf
(
"[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure."
)
}
}
return
&
proxyProbeService
{
return
&
proxyProbeService
{
ipInfoURL
:
defaultIPInfoURL
,
insecureSkipVerify
:
insecure
,
insecureSkipVerify
:
insecure
,
allowPrivateHosts
:
allowPrivate
,
allowPrivateHosts
:
allowPrivate
,
validateResolvedIP
:
validateResolvedIP
,
validateResolvedIP
:
validateResolvedIP
,
...
@@ -36,12 +35,20 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
...
@@ -36,12 +35,20 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
}
}
const
(
const
(
defaultIPInfoURL
=
"http://ip-api.com/json/?lang=zh-CN"
defaultProxyProbeTimeout
=
30
*
time
.
Second
defaultProxyProbeTimeout
=
30
*
time
.
Second
)
)
// probeURLs 按优先级排列的探测 URL 列表
// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选
var
probeURLs
=
[]
struct
{
url
string
parser
string
// "ip-api" or "httpbin"
}{
{
"http://ip-api.com/json/?lang=zh-CN"
,
"ip-api"
},
{
"http://httpbin.org/ip"
,
"httpbin"
},
}
type
proxyProbeService
struct
{
type
proxyProbeService
struct
{
ipInfoURL
string
insecureSkipVerify
bool
insecureSkipVerify
bool
allowPrivateHosts
bool
allowPrivateHosts
bool
validateResolvedIP
bool
validateResolvedIP
bool
...
@@ -60,8 +67,21 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
...
@@ -60,8 +67,21 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
return
nil
,
0
,
fmt
.
Errorf
(
"failed to create proxy client: %w"
,
err
)
return
nil
,
0
,
fmt
.
Errorf
(
"failed to create proxy client: %w"
,
err
)
}
}
var
lastErr
error
for
_
,
probe
:=
range
probeURLs
{
exitInfo
,
latencyMs
,
err
:=
s
.
probeWithURL
(
ctx
,
client
,
probe
.
url
,
probe
.
parser
)
if
err
==
nil
{
return
exitInfo
,
latencyMs
,
nil
}
lastErr
=
err
}
return
nil
,
0
,
fmt
.
Errorf
(
"all probe URLs failed, last error: %w"
,
lastErr
)
}
func
(
s
*
proxyProbeService
)
probeWithURL
(
ctx
context
.
Context
,
client
*
http
.
Client
,
url
string
,
parser
string
)
(
*
service
.
ProxyExitInfo
,
int64
,
error
)
{
startTime
:=
time
.
Now
()
startTime
:=
time
.
Now
()
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
s
.
ipInfoURL
,
nil
)
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
url
,
nil
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
0
,
fmt
.
Errorf
(
"failed to create request: %w"
,
err
)
return
nil
,
0
,
fmt
.
Errorf
(
"failed to create request: %w"
,
err
)
}
}
...
@@ -78,6 +98,22 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
...
@@ -78,6 +98,22 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"request failed with status: %d"
,
resp
.
StatusCode
)
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"request failed with status: %d"
,
resp
.
StatusCode
)
}
}
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"failed to read response: %w"
,
err
)
}
switch
parser
{
case
"ip-api"
:
return
s
.
parseIPAPI
(
body
,
latencyMs
)
case
"httpbin"
:
return
s
.
parseHTTPBin
(
body
,
latencyMs
)
default
:
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"unknown parser: %s"
,
parser
)
}
}
func
(
s
*
proxyProbeService
)
parseIPAPI
(
body
[]
byte
,
latencyMs
int64
)
(
*
service
.
ProxyExitInfo
,
int64
,
error
)
{
var
ipInfo
struct
{
var
ipInfo
struct
{
Status
string
`json:"status"`
Status
string
`json:"status"`
Message
string
`json:"message"`
Message
string
`json:"message"`
...
@@ -89,13 +125,12 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
...
@@ -89,13 +125,12 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
CountryCode
string
`json:"countryCode"`
CountryCode
string
`json:"countryCode"`
}
}
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"failed to read response: %w"
,
err
)
}
if
err
:=
json
.
Unmarshal
(
body
,
&
ipInfo
);
err
!=
nil
{
if
err
:=
json
.
Unmarshal
(
body
,
&
ipInfo
);
err
!=
nil
{
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"failed to parse response: %w"
,
err
)
preview
:=
string
(
body
)
if
len
(
preview
)
>
200
{
preview
=
preview
[
:
200
]
+
"..."
}
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"failed to parse response: %w (body: %s)"
,
err
,
preview
)
}
}
if
strings
.
ToLower
(
ipInfo
.
Status
)
!=
"success"
{
if
strings
.
ToLower
(
ipInfo
.
Status
)
!=
"success"
{
if
ipInfo
.
Message
==
""
{
if
ipInfo
.
Message
==
""
{
...
@@ -116,3 +151,19 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
...
@@ -116,3 +151,19 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
CountryCode
:
ipInfo
.
CountryCode
,
CountryCode
:
ipInfo
.
CountryCode
,
},
latencyMs
,
nil
},
latencyMs
,
nil
}
}
func
(
s
*
proxyProbeService
)
parseHTTPBin
(
body
[]
byte
,
latencyMs
int64
)
(
*
service
.
ProxyExitInfo
,
int64
,
error
)
{
// httpbin.org/ip 返回格式: {"origin": "1.2.3.4"}
var
result
struct
{
Origin
string
`json:"origin"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
result
);
err
!=
nil
{
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"failed to parse httpbin response: %w"
,
err
)
}
if
result
.
Origin
==
""
{
return
nil
,
latencyMs
,
fmt
.
Errorf
(
"httpbin: no IP found in response"
)
}
return
&
service
.
ProxyExitInfo
{
IP
:
result
.
Origin
,
},
latencyMs
,
nil
}
backend/internal/repository/proxy_probe_service_test.go
View file @
4cce21b1
...
@@ -5,6 +5,7 @@ import (
...
@@ -5,6 +5,7 @@ import (
"io"
"io"
"net/http"
"net/http"
"net/http/httptest"
"net/http/httptest"
"strings"
"testing"
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
...
@@ -21,7 +22,6 @@ type ProxyProbeServiceSuite struct {
...
@@ -21,7 +22,6 @@ type ProxyProbeServiceSuite struct {
func
(
s
*
ProxyProbeServiceSuite
)
SetupTest
()
{
func
(
s
*
ProxyProbeServiceSuite
)
SetupTest
()
{
s
.
ctx
=
context
.
Background
()
s
.
ctx
=
context
.
Background
()
s
.
prober
=
&
proxyProbeService
{
s
.
prober
=
&
proxyProbeService
{
ipInfoURL
:
"http://ip-api.test/json/?lang=zh-CN"
,
allowPrivateHosts
:
true
,
allowPrivateHosts
:
true
,
}
}
}
}
...
@@ -49,12 +49,16 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_UnsupportedProxyScheme() {
...
@@ -49,12 +49,16 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_UnsupportedProxyScheme() {
require
.
ErrorContains
(
s
.
T
(),
err
,
"failed to create proxy client"
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"failed to create proxy client"
)
}
}
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_Success
()
{
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_Success_IPAPI
()
{
seen
:=
make
(
chan
string
,
1
)
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
seen
<-
r
.
RequestURI
// 检查是否是 ip-api 请求
if
strings
.
Contains
(
r
.
RequestURI
,
"ip-api.com"
)
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
`{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`
)
_
,
_
=
io
.
WriteString
(
w
,
`{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`
)
return
}
// 其他请求返回错误
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
}))
}))
info
,
latencyMs
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
info
,
latencyMs
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
...
@@ -65,45 +69,59 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() {
...
@@ -65,45 +69,59 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() {
require
.
Equal
(
s
.
T
(),
"r"
,
info
.
Region
)
require
.
Equal
(
s
.
T
(),
"r"
,
info
.
Region
)
require
.
Equal
(
s
.
T
(),
"cc"
,
info
.
Country
)
require
.
Equal
(
s
.
T
(),
"cc"
,
info
.
Country
)
require
.
Equal
(
s
.
T
(),
"CC"
,
info
.
CountryCode
)
require
.
Equal
(
s
.
T
(),
"CC"
,
info
.
CountryCode
)
}
// Verify proxy received the request
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_Success_HTTPBinFallback
()
{
s
elect
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
case
uri
:=
<-
seen
:
// ip-api 失败
require
.
Contains
(
s
.
T
(),
uri
,
"ip-api.test"
,
"expected request to go through proxy"
)
if
strings
.
Contains
(
r
.
RequestURI
,
"ip-api.com"
)
{
default
:
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
re
quire
.
Fail
(
s
.
T
(),
"expected proxy to receive request"
)
re
turn
}
}
// httpbin 成功
if
strings
.
Contains
(
r
.
RequestURI
,
"httpbin.org"
)
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
`{"origin": "5.6.7.8"}`
)
return
}
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
}))
info
,
latencyMs
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
require
.
NoError
(
s
.
T
(),
err
,
"ProbeProxy should fallback to httpbin"
)
require
.
GreaterOrEqual
(
s
.
T
(),
latencyMs
,
int64
(
0
),
"unexpected latency"
)
require
.
Equal
(
s
.
T
(),
"5.6.7.8"
,
info
.
IP
)
}
}
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_
NonOKStatus
()
{
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_
AllFailed
()
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
}))
}))
_
,
_
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
_
,
_
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
require
.
Error
(
s
.
T
(),
err
)
require
.
Error
(
s
.
T
(),
err
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"
status: 503
"
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"
all probe URLs failed
"
)
}
}
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_InvalidJSON
()
{
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_InvalidJSON
()
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
strings
.
Contains
(
r
.
RequestURI
,
"ip-api.com"
)
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
"not-json"
)
return
}
// httpbin 也返回无效响应
if
strings
.
Contains
(
r
.
RequestURI
,
"httpbin.org"
)
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
"not-json"
)
_
,
_
=
io
.
WriteString
(
w
,
"not-json"
)
return
}
w
.
WriteHeader
(
http
.
StatusServiceUnavailable
)
}))
}))
_
,
_
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
_
,
_
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
require
.
Error
(
s
.
T
(),
err
)
require
.
Error
(
s
.
T
(),
err
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"failed to parse response"
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"all probe URLs failed"
)
}
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_InvalidIPInfoURL
()
{
s
.
prober
.
ipInfoURL
=
"://invalid-url"
s
.
setupProxyServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
w
.
WriteHeader
(
http
.
StatusOK
)
}))
_
,
_
,
err
:=
s
.
prober
.
ProbeProxy
(
s
.
ctx
,
s
.
proxySrv
.
URL
)
require
.
Error
(
s
.
T
(),
err
,
"expected error for invalid ipInfoURL"
)
}
}
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_ProxyServerClosed
()
{
func
(
s
*
ProxyProbeServiceSuite
)
TestProbeProxy_ProxyServerClosed
()
{
...
@@ -114,6 +132,40 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() {
...
@@ -114,6 +132,40 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() {
require
.
Error
(
s
.
T
(),
err
,
"expected error when proxy server is closed"
)
require
.
Error
(
s
.
T
(),
err
,
"expected error when proxy server is closed"
)
}
}
func
(
s
*
ProxyProbeServiceSuite
)
TestParseIPAPI_Success
()
{
body
:=
[]
byte
(
`{"status":"success","query":"1.2.3.4","city":"Beijing","regionName":"Beijing","country":"China","countryCode":"CN"}`
)
info
,
latencyMs
,
err
:=
s
.
prober
.
parseIPAPI
(
body
,
100
)
require
.
NoError
(
s
.
T
(),
err
)
require
.
Equal
(
s
.
T
(),
int64
(
100
),
latencyMs
)
require
.
Equal
(
s
.
T
(),
"1.2.3.4"
,
info
.
IP
)
require
.
Equal
(
s
.
T
(),
"Beijing"
,
info
.
City
)
require
.
Equal
(
s
.
T
(),
"Beijing"
,
info
.
Region
)
require
.
Equal
(
s
.
T
(),
"China"
,
info
.
Country
)
require
.
Equal
(
s
.
T
(),
"CN"
,
info
.
CountryCode
)
}
func
(
s
*
ProxyProbeServiceSuite
)
TestParseIPAPI_Failure
()
{
body
:=
[]
byte
(
`{"status":"fail","message":"rate limited"}`
)
_
,
_
,
err
:=
s
.
prober
.
parseIPAPI
(
body
,
100
)
require
.
Error
(
s
.
T
(),
err
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"rate limited"
)
}
func
(
s
*
ProxyProbeServiceSuite
)
TestParseHTTPBin_Success
()
{
body
:=
[]
byte
(
`{"origin": "9.8.7.6"}`
)
info
,
latencyMs
,
err
:=
s
.
prober
.
parseHTTPBin
(
body
,
50
)
require
.
NoError
(
s
.
T
(),
err
)
require
.
Equal
(
s
.
T
(),
int64
(
50
),
latencyMs
)
require
.
Equal
(
s
.
T
(),
"9.8.7.6"
,
info
.
IP
)
}
func
(
s
*
ProxyProbeServiceSuite
)
TestParseHTTPBin_NoIP
()
{
body
:=
[]
byte
(
`{"origin": ""}`
)
_
,
_
,
err
:=
s
.
prober
.
parseHTTPBin
(
body
,
50
)
require
.
Error
(
s
.
T
(),
err
)
require
.
ErrorContains
(
s
.
T
(),
err
,
"no IP found"
)
}
func
TestProxyProbeServiceSuite
(
t
*
testing
.
T
)
{
func
TestProxyProbeServiceSuite
(
t
*
testing
.
T
)
{
suite
.
Run
(
t
,
new
(
ProxyProbeServiceSuite
))
suite
.
Run
(
t
,
new
(
ProxyProbeServiceSuite
))
}
}
backend/internal/repository/redeem_code_repo.go
View file @
4cce21b1
...
@@ -202,6 +202,57 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
...
@@ -202,6 +202,57 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
return
redeemCodeEntitiesToService
(
codes
),
nil
return
redeemCodeEntitiesToService
(
codes
),
nil
}
}
// ListByUserPaginated returns paginated balance/concurrency history for a user.
// Supports optional type filter (e.g. "balance", "admin_balance", "concurrency", "admin_concurrency", "subscription").
func
(
r
*
redeemCodeRepository
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
service
.
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
q
:=
r
.
client
.
RedeemCode
.
Query
()
.
Where
(
redeemcode
.
UsedByEQ
(
userID
))
// Optional type filter
if
codeType
!=
""
{
q
=
q
.
Where
(
redeemcode
.
TypeEQ
(
codeType
))
}
total
,
err
:=
q
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
codes
,
err
:=
q
.
WithGroup
()
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Order
(
dbent
.
Desc
(
redeemcode
.
FieldUsedAt
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
return
redeemCodeEntitiesToService
(
codes
),
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
// SumPositiveBalanceByUser returns total recharged amount (sum of value > 0 where type is balance/admin_balance).
func
(
r
*
redeemCodeRepository
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
var
result
[]
struct
{
Sum
float64
`json:"sum"`
}
err
:=
r
.
client
.
RedeemCode
.
Query
()
.
Where
(
redeemcode
.
UsedByEQ
(
userID
),
redeemcode
.
ValueGT
(
0
),
redeemcode
.
TypeIn
(
"balance"
,
"admin_balance"
),
)
.
Aggregate
(
dbent
.
As
(
dbent
.
Sum
(
redeemcode
.
FieldValue
),
"sum"
))
.
Scan
(
ctx
,
&
result
)
if
err
!=
nil
{
return
0
,
err
}
if
len
(
result
)
==
0
{
return
0
,
nil
}
return
result
[
0
]
.
Sum
,
nil
}
func
redeemCodeEntityToService
(
m
*
dbent
.
RedeemCode
)
*
service
.
RedeemCode
{
func
redeemCodeEntityToService
(
m
*
dbent
.
RedeemCode
)
*
service
.
RedeemCode
{
if
m
==
nil
{
if
m
==
nil
{
return
nil
return
nil
...
...
backend/internal/server/api_contract_test.go
View file @
4cce21b1
...
@@ -83,6 +83,9 @@ func TestAPIContracts(t *testing.T) {
...
@@ -83,6 +83,9 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"status": "active",
"ip_whitelist": null,
"ip_whitelist": null,
"ip_blacklist": null,
"ip_blacklist": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
"created_at": "2025-01-02T03:04:05Z",
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
"updated_at": "2025-01-02T03:04:05Z"
}
}
...
@@ -119,6 +122,9 @@ func TestAPIContracts(t *testing.T) {
...
@@ -119,6 +122,9 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"status": "active",
"ip_whitelist": null,
"ip_whitelist": null,
"ip_blacklist": null,
"ip_blacklist": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
"created_at": "2025-01-02T03:04:05Z",
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
"updated_at": "2025-01-02T03:04:05Z"
}
}
...
@@ -1151,6 +1157,14 @@ func (r *stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit
...
@@ -1151,6 +1157,14 @@ func (r *stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit
return
append
([]
service
.
RedeemCode
(
nil
),
codes
...
),
nil
return
append
([]
service
.
RedeemCode
(
nil
),
codes
...
),
nil
}
}
func
(
stubRedeemCodeRepo
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
service
.
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
stubRedeemCodeRepo
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
stubUserSubscriptionRepo
struct
{
type
stubUserSubscriptionRepo
struct
{
byUser
map
[
int64
][]
service
.
UserSubscription
byUser
map
[
int64
][]
service
.
UserSubscription
activeByUser
map
[
int64
][]
service
.
UserSubscription
activeByUser
map
[
int64
][]
service
.
UserSubscription
...
@@ -1435,6 +1449,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
...
@@ -1435,6 +1449,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
r
*
stubApiKeyRepo
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
stubUsageLogRepo
struct
{
type
stubUsageLogRepo
struct
{
userLogs
map
[
int64
][]
service
.
UsageLog
userLogs
map
[
int64
][]
service
.
UsageLog
}
}
...
...
backend/internal/server/middleware/api_key_auth.go
View file @
4cce21b1
...
@@ -70,7 +70,27 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
...
@@ -70,7 +70,27 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
// 检查API key是否激活
// 检查API key是否激活
if
!
apiKey
.
IsActive
()
{
if
!
apiKey
.
IsActive
()
{
// Provide more specific error message based on status
switch
apiKey
.
Status
{
case
service
.
StatusAPIKeyQuotaExhausted
:
AbortWithError
(
c
,
429
,
"API_KEY_QUOTA_EXHAUSTED"
,
"API key 额度已用完"
)
case
service
.
StatusAPIKeyExpired
:
AbortWithError
(
c
,
403
,
"API_KEY_EXPIRED"
,
"API key 已过期"
)
default
:
AbortWithError
(
c
,
401
,
"API_KEY_DISABLED"
,
"API key is disabled"
)
AbortWithError
(
c
,
401
,
"API_KEY_DISABLED"
,
"API key is disabled"
)
}
return
}
// 检查API Key是否过期(即使状态是active,也要检查时间)
if
apiKey
.
IsExpired
()
{
AbortWithError
(
c
,
403
,
"API_KEY_EXPIRED"
,
"API key 已过期"
)
return
}
// 检查API Key配额是否耗尽
if
apiKey
.
IsQuotaExhausted
()
{
AbortWithError
(
c
,
429
,
"API_KEY_QUOTA_EXHAUSTED"
,
"API key 额度已用完"
)
return
return
}
}
...
...
backend/internal/server/middleware/api_key_auth_google_test.go
View file @
4cce21b1
...
@@ -75,6 +75,9 @@ func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]s
...
@@ -75,6 +75,9 @@ func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]s
func
(
f
fakeAPIKeyRepo
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
{
func
(
f
fakeAPIKeyRepo
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
f
fakeAPIKeyRepo
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
googleErrorResponse
struct
{
type
googleErrorResponse
struct
{
Error
struct
{
Error
struct
{
...
...
backend/internal/server/middleware/api_key_auth_test.go
View file @
4cce21b1
...
@@ -319,6 +319,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
...
@@ -319,6 +319,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
r
*
stubApiKeyRepo
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
stubUserSubscriptionRepo
struct
{
type
stubUserSubscriptionRepo
struct
{
getActive
func
(
ctx
context
.
Context
,
userID
,
groupID
int64
)
(
*
service
.
UserSubscription
,
error
)
getActive
func
(
ctx
context
.
Context
,
userID
,
groupID
int64
)
(
*
service
.
UserSubscription
,
error
)
updateStatus
func
(
ctx
context
.
Context
,
subscriptionID
int64
,
status
string
)
error
updateStatus
func
(
ctx
context
.
Context
,
subscriptionID
int64
,
status
string
)
error
...
...
backend/internal/server/routes/admin.go
View file @
4cce21b1
...
@@ -175,6 +175,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -175,6 +175,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users
.
POST
(
"/:id/balance"
,
h
.
Admin
.
User
.
UpdateBalance
)
users
.
POST
(
"/:id/balance"
,
h
.
Admin
.
User
.
UpdateBalance
)
users
.
GET
(
"/:id/api-keys"
,
h
.
Admin
.
User
.
GetUserAPIKeys
)
users
.
GET
(
"/:id/api-keys"
,
h
.
Admin
.
User
.
GetUserAPIKeys
)
users
.
GET
(
"/:id/usage"
,
h
.
Admin
.
User
.
GetUserUsage
)
users
.
GET
(
"/:id/usage"
,
h
.
Admin
.
User
.
GetUserUsage
)
users
.
GET
(
"/:id/balance-history"
,
h
.
Admin
.
User
.
GetBalanceHistory
)
// User attribute values
// User attribute values
users
.
GET
(
"/:id/attributes"
,
h
.
Admin
.
UserAttribute
.
GetUserAttributes
)
users
.
GET
(
"/:id/attributes"
,
h
.
Admin
.
UserAttribute
.
GetUserAttributes
)
...
...
backend/internal/service/admin_service.go
View file @
4cce21b1
...
@@ -22,6 +22,10 @@ type AdminService interface {
...
@@ -22,6 +22,10 @@ type AdminService interface {
UpdateUserBalance
(
ctx
context
.
Context
,
userID
int64
,
balance
float64
,
operation
string
,
notes
string
)
(
*
User
,
error
)
UpdateUserBalance
(
ctx
context
.
Context
,
userID
int64
,
balance
float64
,
operation
string
,
notes
string
)
(
*
User
,
error
)
GetUserAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
)
([]
APIKey
,
int64
,
error
)
GetUserAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
)
([]
APIKey
,
int64
,
error
)
GetUserUsageStats
(
ctx
context
.
Context
,
userID
int64
,
period
string
)
(
any
,
error
)
GetUserUsageStats
(
ctx
context
.
Context
,
userID
int64
,
period
string
)
(
any
,
error
)
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
// codeType is optional - pass empty string to return all types.
// Also returns totalRecharged (sum of all positive balance top-ups).
GetUserBalanceHistory
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
,
codeType
string
)
([]
RedeemCode
,
int64
,
float64
,
error
)
// Group management
// Group management
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
...
@@ -536,6 +540,21 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64,
...
@@ -536,6 +540,21 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64,
},
nil
},
nil
}
}
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
func
(
s
*
adminServiceImpl
)
GetUserBalanceHistory
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
,
codeType
string
)
([]
RedeemCode
,
int64
,
float64
,
error
)
{
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
codes
,
result
,
err
:=
s
.
redeemCodeRepo
.
ListByUserPaginated
(
ctx
,
userID
,
params
,
codeType
)
if
err
!=
nil
{
return
nil
,
0
,
0
,
err
}
// Aggregate total recharged amount (only once, regardless of type filter)
totalRecharged
,
err
:=
s
.
redeemCodeRepo
.
SumPositiveBalanceByUser
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
0
,
0
,
err
}
return
codes
,
result
.
Total
,
totalRecharged
,
nil
}
// Group management implementations
// Group management implementations
func
(
s
*
adminServiceImpl
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
{
func
(
s
*
adminServiceImpl
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
{
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
...
...
backend/internal/service/admin_service_delete_test.go
View file @
4cce21b1
...
@@ -282,6 +282,14 @@ func (s *redeemRepoStub) ListByUser(ctx context.Context, userID int64, limit int
...
@@ -282,6 +282,14 @@ func (s *redeemRepoStub) ListByUser(ctx context.Context, userID int64, limit int
panic
(
"unexpected ListByUser call"
)
panic
(
"unexpected ListByUser call"
)
}
}
func
(
s
*
redeemRepoStub
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListByUserPaginated call"
)
}
func
(
s
*
redeemRepoStub
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
panic
(
"unexpected SumPositiveBalanceByUser call"
)
}
type
subscriptionInvalidateCall
struct
{
type
subscriptionInvalidateCall
struct
{
userID
int64
userID
int64
groupID
int64
groupID
int64
...
...
backend/internal/service/admin_service_search_test.go
View file @
4cce21b1
...
@@ -152,6 +152,14 @@ func (s *redeemRepoStubForAdminList) ListWithFilters(_ context.Context, params p
...
@@ -152,6 +152,14 @@ func (s *redeemRepoStubForAdminList) ListWithFilters(_ context.Context, params p
return
s
.
listWithFiltersCodes
,
result
,
nil
return
s
.
listWithFiltersCodes
,
result
,
nil
}
}
func
(
s
*
redeemRepoStubForAdminList
)
ListByUserPaginated
(
_
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListByUserPaginated call"
)
}
func
(
s
*
redeemRepoStubForAdminList
)
SumPositiveBalanceByUser
(
_
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
panic
(
"unexpected SumPositiveBalanceByUser call"
)
}
func
TestAdminService_ListAccounts_WithSearch
(
t
*
testing
.
T
)
{
func
TestAdminService_ListAccounts_WithSearch
(
t
*
testing
.
T
)
{
t
.
Run
(
"search 参数正常传递到 repository 层"
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
"search 参数正常传递到 repository 层"
,
func
(
t
*
testing
.
T
)
{
repo
:=
&
accountRepoStubForAdminList
{
repo
:=
&
accountRepoStubForAdminList
{
...
...
backend/internal/service/api_key.go
View file @
4cce21b1
...
@@ -2,6 +2,14 @@ package service
...
@@ -2,6 +2,14 @@ package service
import
"time"
import
"time"
// API Key status constants
const
(
StatusAPIKeyActive
=
"active"
StatusAPIKeyDisabled
=
"disabled"
StatusAPIKeyQuotaExhausted
=
"quota_exhausted"
StatusAPIKeyExpired
=
"expired"
)
type
APIKey
struct
{
type
APIKey
struct
{
ID
int64
ID
int64
UserID
int64
UserID
int64
...
@@ -15,8 +23,53 @@ type APIKey struct {
...
@@ -15,8 +23,53 @@ type APIKey struct {
UpdatedAt
time
.
Time
UpdatedAt
time
.
Time
User
*
User
User
*
User
Group
*
Group
Group
*
Group
// Quota fields
Quota
float64
// Quota limit in USD (0 = unlimited)
QuotaUsed
float64
// Used quota amount
ExpiresAt
*
time
.
Time
// Expiration time (nil = never expires)
}
}
func
(
k
*
APIKey
)
IsActive
()
bool
{
func
(
k
*
APIKey
)
IsActive
()
bool
{
return
k
.
Status
==
StatusActive
return
k
.
Status
==
StatusActive
}
}
// IsExpired checks if the API key has expired
func
(
k
*
APIKey
)
IsExpired
()
bool
{
if
k
.
ExpiresAt
==
nil
{
return
false
}
return
time
.
Now
()
.
After
(
*
k
.
ExpiresAt
)
}
// IsQuotaExhausted checks if the API key quota is exhausted
func
(
k
*
APIKey
)
IsQuotaExhausted
()
bool
{
if
k
.
Quota
<=
0
{
return
false
// unlimited
}
return
k
.
QuotaUsed
>=
k
.
Quota
}
// GetQuotaRemaining returns remaining quota (-1 for unlimited)
func
(
k
*
APIKey
)
GetQuotaRemaining
()
float64
{
if
k
.
Quota
<=
0
{
return
-
1
// unlimited
}
remaining
:=
k
.
Quota
-
k
.
QuotaUsed
if
remaining
<
0
{
return
0
}
return
remaining
}
// GetDaysUntilExpiry returns days until expiry (-1 for never expires)
func
(
k
*
APIKey
)
GetDaysUntilExpiry
()
int
{
if
k
.
ExpiresAt
==
nil
{
return
-
1
// never expires
}
duration
:=
time
.
Until
(
*
k
.
ExpiresAt
)
if
duration
<
0
{
return
0
}
return
int
(
duration
.
Hours
()
/
24
)
}
backend/internal/service/api_key_auth_cache.go
View file @
4cce21b1
package
service
package
service
import
"time"
// APIKeyAuthSnapshot API Key 认证缓存快照(仅包含认证所需字段)
// APIKeyAuthSnapshot API Key 认证缓存快照(仅包含认证所需字段)
type
APIKeyAuthSnapshot
struct
{
type
APIKeyAuthSnapshot
struct
{
APIKeyID
int64
`json:"api_key_id"`
APIKeyID
int64
`json:"api_key_id"`
...
@@ -10,6 +12,13 @@ type APIKeyAuthSnapshot struct {
...
@@ -10,6 +12,13 @@ type APIKeyAuthSnapshot struct {
IPBlacklist
[]
string
`json:"ip_blacklist,omitempty"`
IPBlacklist
[]
string
`json:"ip_blacklist,omitempty"`
User
APIKeyAuthUserSnapshot
`json:"user"`
User
APIKeyAuthUserSnapshot
`json:"user"`
Group
*
APIKeyAuthGroupSnapshot
`json:"group,omitempty"`
Group
*
APIKeyAuthGroupSnapshot
`json:"group,omitempty"`
// Quota fields for API Key independent quota feature
Quota
float64
`json:"quota"`
// Quota limit in USD (0 = unlimited)
QuotaUsed
float64
`json:"quota_used"`
// Used quota amount
// Expiration field for API Key expiration feature
ExpiresAt
*
time
.
Time
`json:"expires_at,omitempty"`
// Expiration time (nil = never expires)
}
}
// APIKeyAuthUserSnapshot 用户快照
// APIKeyAuthUserSnapshot 用户快照
...
...
backend/internal/service/api_key_auth_cache_impl.go
View file @
4cce21b1
...
@@ -213,6 +213,9 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
...
@@ -213,6 +213,9 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
Status
:
apiKey
.
Status
,
Status
:
apiKey
.
Status
,
IPWhitelist
:
apiKey
.
IPWhitelist
,
IPWhitelist
:
apiKey
.
IPWhitelist
,
IPBlacklist
:
apiKey
.
IPBlacklist
,
IPBlacklist
:
apiKey
.
IPBlacklist
,
Quota
:
apiKey
.
Quota
,
QuotaUsed
:
apiKey
.
QuotaUsed
,
ExpiresAt
:
apiKey
.
ExpiresAt
,
User
:
APIKeyAuthUserSnapshot
{
User
:
APIKeyAuthUserSnapshot
{
ID
:
apiKey
.
User
.
ID
,
ID
:
apiKey
.
User
.
ID
,
Status
:
apiKey
.
User
.
Status
,
Status
:
apiKey
.
User
.
Status
,
...
@@ -259,6 +262,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
...
@@ -259,6 +262,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
Status
:
snapshot
.
Status
,
Status
:
snapshot
.
Status
,
IPWhitelist
:
snapshot
.
IPWhitelist
,
IPWhitelist
:
snapshot
.
IPWhitelist
,
IPBlacklist
:
snapshot
.
IPBlacklist
,
IPBlacklist
:
snapshot
.
IPBlacklist
,
Quota
:
snapshot
.
Quota
,
QuotaUsed
:
snapshot
.
QuotaUsed
,
ExpiresAt
:
snapshot
.
ExpiresAt
,
User
:
&
User
{
User
:
&
User
{
ID
:
snapshot
.
User
.
ID
,
ID
:
snapshot
.
User
.
ID
,
Status
:
snapshot
.
User
.
Status
,
Status
:
snapshot
.
User
.
Status
,
...
...
backend/internal/service/api_key_service.go
View file @
4cce21b1
...
@@ -24,6 +24,10 @@ var (
...
@@ -24,6 +24,10 @@ var (
ErrAPIKeyInvalidChars
=
infraerrors
.
BadRequest
(
"API_KEY_INVALID_CHARS"
,
"api key can only contain letters, numbers, underscores, and hyphens"
)
ErrAPIKeyInvalidChars
=
infraerrors
.
BadRequest
(
"API_KEY_INVALID_CHARS"
,
"api key can only contain letters, numbers, underscores, and hyphens"
)
ErrAPIKeyRateLimited
=
infraerrors
.
TooManyRequests
(
"API_KEY_RATE_LIMITED"
,
"too many failed attempts, please try again later"
)
ErrAPIKeyRateLimited
=
infraerrors
.
TooManyRequests
(
"API_KEY_RATE_LIMITED"
,
"too many failed attempts, please try again later"
)
ErrInvalidIPPattern
=
infraerrors
.
BadRequest
(
"INVALID_IP_PATTERN"
,
"invalid IP or CIDR pattern"
)
ErrInvalidIPPattern
=
infraerrors
.
BadRequest
(
"INVALID_IP_PATTERN"
,
"invalid IP or CIDR pattern"
)
// ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key has expired")
ErrAPIKeyExpired
=
infraerrors
.
Forbidden
(
"API_KEY_EXPIRED"
,
"api key 已过期"
)
// ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key quota exhausted")
ErrAPIKeyQuotaExhausted
=
infraerrors
.
TooManyRequests
(
"API_KEY_QUOTA_EXHAUSTED"
,
"api key 额度已用完"
)
)
)
const
(
const
(
...
@@ -51,6 +55,9 @@ type APIKeyRepository interface {
...
@@ -51,6 +55,9 @@ type APIKeyRepository interface {
CountByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
(
int64
,
error
)
CountByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
(
int64
,
error
)
ListKeysByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
string
,
error
)
ListKeysByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
string
,
error
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
// Quota methods
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
}
}
// APIKeyCache defines cache operations for API key service
// APIKeyCache defines cache operations for API key service
...
@@ -85,6 +92,10 @@ type CreateAPIKeyRequest struct {
...
@@ -85,6 +92,10 @@ type CreateAPIKeyRequest struct {
CustomKey
*
string
`json:"custom_key"`
// 可选的自定义key
CustomKey
*
string
`json:"custom_key"`
// 可选的自定义key
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单
// Quota fields
Quota
float64
`json:"quota"`
// Quota limit in USD (0 = unlimited)
ExpiresInDays
*
int
`json:"expires_in_days"`
// Days until expiry (nil = never expires)
}
}
// UpdateAPIKeyRequest 更新API Key请求
// UpdateAPIKeyRequest 更新API Key请求
...
@@ -94,6 +105,12 @@ type UpdateAPIKeyRequest struct {
...
@@ -94,6 +105,12 @@ type UpdateAPIKeyRequest struct {
Status
*
string
`json:"status"`
Status
*
string
`json:"status"`
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单(空数组清空)
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单(空数组清空)
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单(空数组清空)
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单(空数组清空)
// Quota fields
Quota
*
float64
`json:"quota"`
// Quota limit in USD (nil = no change, 0 = unlimited)
ExpiresAt
*
time
.
Time
`json:"expires_at"`
// Expiration time (nil = no change)
ClearExpiration
bool
`json:"-"`
// Clear expiration (internal use)
ResetQuota
*
bool
`json:"reset_quota"`
// Reset quota_used to 0
}
}
// APIKeyService API Key服务
// APIKeyService API Key服务
...
@@ -289,6 +306,14 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
...
@@ -289,6 +306,14 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
Status
:
StatusActive
,
Status
:
StatusActive
,
IPWhitelist
:
req
.
IPWhitelist
,
IPWhitelist
:
req
.
IPWhitelist
,
IPBlacklist
:
req
.
IPBlacklist
,
IPBlacklist
:
req
.
IPBlacklist
,
Quota
:
req
.
Quota
,
QuotaUsed
:
0
,
}
// Set expiration time if specified
if
req
.
ExpiresInDays
!=
nil
&&
*
req
.
ExpiresInDays
>
0
{
expiresAt
:=
time
.
Now
()
.
AddDate
(
0
,
0
,
*
req
.
ExpiresInDays
)
apiKey
.
ExpiresAt
=
&
expiresAt
}
}
if
err
:=
s
.
apiKeyRepo
.
Create
(
ctx
,
apiKey
);
err
!=
nil
{
if
err
:=
s
.
apiKeyRepo
.
Create
(
ctx
,
apiKey
);
err
!=
nil
{
...
@@ -436,6 +461,35 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
...
@@ -436,6 +461,35 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
}
}
}
// Update quota fields
if
req
.
Quota
!=
nil
{
apiKey
.
Quota
=
*
req
.
Quota
// If quota is increased and status was quota_exhausted, reactivate
if
apiKey
.
Status
==
StatusAPIKeyQuotaExhausted
&&
*
req
.
Quota
>
apiKey
.
QuotaUsed
{
apiKey
.
Status
=
StatusActive
}
}
if
req
.
ResetQuota
!=
nil
&&
*
req
.
ResetQuota
{
apiKey
.
QuotaUsed
=
0
// If resetting quota and status was quota_exhausted, reactivate
if
apiKey
.
Status
==
StatusAPIKeyQuotaExhausted
{
apiKey
.
Status
=
StatusActive
}
}
if
req
.
ClearExpiration
{
apiKey
.
ExpiresAt
=
nil
// If clearing expiry and status was expired, reactivate
if
apiKey
.
Status
==
StatusAPIKeyExpired
{
apiKey
.
Status
=
StatusActive
}
}
else
if
req
.
ExpiresAt
!=
nil
{
apiKey
.
ExpiresAt
=
req
.
ExpiresAt
// If extending expiry and status was expired, reactivate
if
apiKey
.
Status
==
StatusAPIKeyExpired
&&
time
.
Now
()
.
Before
(
*
req
.
ExpiresAt
)
{
apiKey
.
Status
=
StatusActive
}
}
// 更新 IP 限制(空数组会清空设置)
// 更新 IP 限制(空数组会清空设置)
apiKey
.
IPWhitelist
=
req
.
IPWhitelist
apiKey
.
IPWhitelist
=
req
.
IPWhitelist
apiKey
.
IPBlacklist
=
req
.
IPBlacklist
apiKey
.
IPBlacklist
=
req
.
IPBlacklist
...
@@ -572,3 +626,51 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
...
@@ -572,3 +626,51 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
}
}
return
keys
,
nil
return
keys
,
nil
}
}
// CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted)
// Returns nil if valid, error if invalid
func
(
s
*
APIKeyService
)
CheckAPIKeyQuotaAndExpiry
(
apiKey
*
APIKey
)
error
{
// Check expiration
if
apiKey
.
IsExpired
()
{
return
ErrAPIKeyExpired
}
// Check quota
if
apiKey
.
IsQuotaExhausted
()
{
return
ErrAPIKeyQuotaExhausted
}
return
nil
}
// UpdateQuotaUsed updates the quota_used field after a request
// Also checks if quota is exhausted and updates status accordingly
func
(
s
*
APIKeyService
)
UpdateQuotaUsed
(
ctx
context
.
Context
,
apiKeyID
int64
,
cost
float64
)
error
{
if
cost
<=
0
{
return
nil
}
// Use repository to atomically increment quota_used
newQuotaUsed
,
err
:=
s
.
apiKeyRepo
.
IncrementQuotaUsed
(
ctx
,
apiKeyID
,
cost
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"increment quota used: %w"
,
err
)
}
// Check if quota is now exhausted and update status if needed
apiKey
,
err
:=
s
.
apiKeyRepo
.
GetByID
(
ctx
,
apiKeyID
)
if
err
!=
nil
{
return
nil
// Don't fail the request, just log
}
// If quota is set and now exhausted, update status
if
apiKey
.
Quota
>
0
&&
newQuotaUsed
>=
apiKey
.
Quota
{
apiKey
.
Status
=
StatusAPIKeyQuotaExhausted
if
err
:=
s
.
apiKeyRepo
.
Update
(
ctx
,
apiKey
);
err
!=
nil
{
return
nil
// Don't fail the request
}
// Invalidate cache so next request sees the new status
s
.
InvalidateAuthCacheByKey
(
ctx
,
apiKey
.
Key
)
}
return
nil
}
backend/internal/service/api_key_service_cache_test.go
View file @
4cce21b1
...
@@ -99,6 +99,10 @@ func (s *authRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]
...
@@ -99,6 +99,10 @@ func (s *authRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]
return
s
.
listKeysByGroupID
(
ctx
,
groupID
)
return
s
.
listKeysByGroupID
(
ctx
,
groupID
)
}
}
func
(
s
*
authRepoStub
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
panic
(
"unexpected IncrementQuotaUsed call"
)
}
type
authCacheStub
struct
{
type
authCacheStub
struct
{
getAuthCache
func
(
ctx
context
.
Context
,
key
string
)
(
*
APIKeyAuthCacheEntry
,
error
)
getAuthCache
func
(
ctx
context
.
Context
,
key
string
)
(
*
APIKeyAuthCacheEntry
,
error
)
setAuthKeys
[]
string
setAuthKeys
[]
string
...
...
backend/internal/service/api_key_service_delete_test.go
View file @
4cce21b1
...
@@ -118,6 +118,10 @@ func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) (
...
@@ -118,6 +118,10 @@ func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) (
panic
(
"unexpected ListKeysByGroupID call"
)
panic
(
"unexpected ListKeysByGroupID call"
)
}
}
func
(
s
*
apiKeyRepoStub
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
panic
(
"unexpected IncrementQuotaUsed call"
)
}
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
//
//
...
...
backend/internal/service/gateway_service.go
View file @
4cce21b1
...
@@ -4547,6 +4547,12 @@ type RecordUsageInput struct {
...
@@ -4547,6 +4547,12 @@ type RecordUsageInput struct {
Subscription
*
UserSubscription
// 可选:订阅信息
Subscription
*
UserSubscription
// 可选:订阅信息
UserAgent
string
// 请求的 User-Agent
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
IPAddress
string
// 请求的客户端 IP 地址
APIKeyService
APIKeyQuotaUpdater
// 可选:用于更新API Key配额
}
// APIKeyQuotaUpdater defines the interface for updating API Key quota
type
APIKeyQuotaUpdater
interface
{
UpdateQuotaUsed
(
ctx
context
.
Context
,
apiKeyID
int64
,
cost
float64
)
error
}
}
// RecordUsage 记录使用量并扣费(或更新订阅用量)
// RecordUsage 记录使用量并扣费(或更新订阅用量)
...
@@ -4686,6 +4692,13 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
...
@@ -4686,6 +4692,13 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
}
}
}
}
// 更新 API Key 配额(如果设置了配额限制)
if
shouldBill
&&
cost
.
ActualCost
>
0
&&
apiKey
.
Quota
>
0
&&
input
.
APIKeyService
!=
nil
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Update API key quota failed: %v"
,
err
)
}
}
// Schedule batch update for account last_used_at
// Schedule batch update for account last_used_at
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
...
@@ -4703,6 +4716,7 @@ type RecordUsageLongContextInput struct {
...
@@ -4703,6 +4716,7 @@ type RecordUsageLongContextInput struct {
IPAddress
string
// 请求的客户端 IP 地址
IPAddress
string
// 请求的客户端 IP 地址
LongContextThreshold
int
// 长上下文阈值(如 200000)
LongContextThreshold
int
// 长上下文阈值(如 200000)
LongContextMultiplier
float64
// 超出阈值部分的倍率(如 2.0)
LongContextMultiplier
float64
// 超出阈值部分的倍率(如 2.0)
APIKeyService
*
APIKeyService
// API Key 配额服务(可选)
}
}
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
...
@@ -4839,6 +4853,12 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
...
@@ -4839,6 +4853,12 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
}
}
// 异步更新余额缓存
// 异步更新余额缓存
s
.
billingCacheService
.
QueueDeductBalance
(
user
.
ID
,
cost
.
ActualCost
)
s
.
billingCacheService
.
QueueDeductBalance
(
user
.
ID
,
cost
.
ActualCost
)
// API Key 独立配额扣费
if
input
.
APIKeyService
!=
nil
&&
apiKey
.
Quota
>
0
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Add API key quota used failed: %v"
,
err
)
}
}
}
}
}
}
...
...
backend/internal/service/openai_gateway_service.go
View file @
4cce21b1
...
@@ -1688,6 +1688,7 @@ type OpenAIRecordUsageInput struct {
...
@@ -1688,6 +1688,7 @@ type OpenAIRecordUsageInput struct {
Subscription
*
UserSubscription
Subscription
*
UserSubscription
UserAgent
string
// 请求的 User-Agent
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
IPAddress
string
// 请求的客户端 IP 地址
APIKeyService
APIKeyQuotaUpdater
}
}
// RecordUsage records usage and deducts balance
// RecordUsage records usage and deducts balance
...
@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
...
@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
}
}
}
// Update API key quota if applicable (only for balance mode with quota set)
if
shouldBill
&&
cost
.
ActualCost
>
0
&&
apiKey
.
Quota
>
0
&&
input
.
APIKeyService
!=
nil
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Update API key quota failed: %v"
,
err
)
}
}
// Schedule batch update for account last_used_at
// Schedule batch update for account last_used_at
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
...
...
backend/internal/service/redeem_service.go
View file @
4cce21b1
...
@@ -49,6 +49,11 @@ type RedeemCodeRepository interface {
...
@@ -49,6 +49,11 @@ type RedeemCodeRepository interface {
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
codeType
,
status
,
search
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
codeType
,
status
,
search
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
ListByUser
(
ctx
context
.
Context
,
userID
int64
,
limit
int
)
([]
RedeemCode
,
error
)
ListByUser
(
ctx
context
.
Context
,
userID
int64
,
limit
int
)
([]
RedeemCode
,
error
)
// ListByUserPaginated returns paginated balance/concurrency history for a specific user.
// codeType filter is optional - pass empty string to return all types.
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
// SumPositiveBalanceByUser returns the total recharged amount (sum of positive balance values) for a user.
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
}
}
// GenerateCodesRequest 生成兑换码请求
// GenerateCodesRequest 生成兑换码请求
...
...
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment