@@ -11,7 +11,6 @@ import (
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
@@ -52,7 +51,6 @@ func TestAPIContracts(t *testing.T) {
"id": 1,
"email": "alice@example.com",
"username": "alice",
"notes": "hello",
"role": "user",
"balance": 12.5,
"concurrency": 5,
@@ -132,6 +130,153 @@ func TestAPIContracts(t *testing.T) {
}
} ` ,
} ,
{
name : "GET /api/v1/groups/available" ,
setup : func ( t * testing . T , deps * contractDeps ) {
t . Helper ( )
// 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count) 。
deps . groupRepo . SetActive ( [ ] service . Group {
{
ID : 10 ,
Name : "Group One" ,
Description : "desc" ,
Platform : service . PlatformAnthropic ,
RateMultiplier : 1.5 ,
IsExclusive : false ,
Status : service . StatusActive ,
SubscriptionType : service . SubscriptionTypeStandard ,
ModelRoutingEnabled : true ,
ModelRouting : map [ string ] [ ] int64 {
"claude-3-*" : [ ] int64 { 101 , 102 } ,
} ,
AccountCount : 2 ,
CreatedAt : deps . now ,
UpdatedAt : deps . now ,
} ,
} )
deps . userSubRepo . SetActiveByUserID ( 1 , nil )
} ,
method : http . MethodGet ,
path : "/api/v1/groups/available" ,
wantStatus : http . StatusOK ,
wantJSON : ` {
"code": 0,
"message": "success",
"data": [
{
"id": 10,
"name": "Group One",
"description": "desc",
"platform": "anthropic",
"rate_multiplier": 1.5,
"is_exclusive": false,
"status": "active",
"subscription_type": "standard",
"daily_limit_usd": null,
"weekly_limit_usd": null,
"monthly_limit_usd": null,
"image_price_1k": null,
"image_price_2k": null,
"image_price_4k": null,
"claude_code_only": false,
"fallback_group_id": null,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
}
]
} ` ,
} ,
{
name : "GET /api/v1/subscriptions" ,
setup : func ( t * testing . T , deps * contractDeps ) {
t . Helper ( )
// 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。
deps . userSubRepo . SetByUserID ( 1 , [ ] service . UserSubscription {
{
ID : 501 ,
UserID : 1 ,
GroupID : 10 ,
StartsAt : deps . now ,
ExpiresAt : time . Date ( 2099 , 1 , 2 , 3 , 4 , 5 , 0 , time . UTC ) , // 使用未来日期避免 normalizeSubscriptionStatus 标记为过期
Status : service . SubscriptionStatusActive ,
DailyUsageUSD : 1.23 ,
WeeklyUsageUSD : 2.34 ,
MonthlyUsageUSD : 3.45 ,
AssignedBy : ptr ( int64 ( 999 ) ) ,
AssignedAt : deps . now ,
Notes : "admin-note" ,
CreatedAt : deps . now ,
UpdatedAt : deps . now ,
} ,
} )
} ,
method : http . MethodGet ,
path : "/api/v1/subscriptions" ,
wantStatus : http . StatusOK ,
wantJSON : ` {
"code": 0,
"message": "success",
"data": [
{
"id": 501,
"user_id": 1,
"group_id": 10,
"starts_at": "2025-01-02T03:04:05Z",
"expires_at": "2099-01-02T03:04:05Z",
"status": "active",
"daily_window_start": null,
"weekly_window_start": null,
"monthly_window_start": null,
"daily_usage_usd": 1.23,
"weekly_usage_usd": 2.34,
"monthly_usage_usd": 3.45,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
}
]
} ` ,
} ,
{
name : "GET /api/v1/redeem/history" ,
setup : func ( t * testing . T , deps * contractDeps ) {
t . Helper ( )
// 普通用户兑换历史不应包含 notes 等内部字段。
deps . redeemRepo . SetByUser ( 1 , [ ] service . RedeemCode {
{
ID : 900 ,
Code : "CODE-123" ,
Type : service . RedeemTypeBalance ,
Value : 1.25 ,
Status : service . StatusUsed ,
UsedBy : ptr ( int64 ( 1 ) ) ,
UsedAt : ptr ( deps . now ) ,
Notes : "internal-note" ,
CreatedAt : deps . now ,
} ,
} )
} ,
method : http . MethodGet ,
path : "/api/v1/redeem/history" ,
wantStatus : http . StatusOK ,
wantJSON : ` {
"code": 0,
"message": "success",
"data": [
{
"id": 900,
"code": "CODE-123",
"type": "balance",
"value": 1.25,
"status": "used",
"used_by": 1,
"used_at": "2025-01-02T03:04:05Z",
"created_at": "2025-01-02T03:04:05Z",
"group_id": null,
"validity_days": 0
}
]
} ` ,
} ,
{
name : "GET /api/v1/usage/stats" ,
setup : func ( t * testing . T , deps * contractDeps ) {
@@ -191,24 +336,25 @@ func TestAPIContracts(t *testing.T) {
t . Helper ( )
deps . usageRepo . SetUserLogs ( 1 , [ ] service . UsageLog {
{
ID : 1 ,
UserID : 1 ,
APIKeyID : 100 ,
AccountID : 200 ,
RequestID : "req_123" ,
Model : "claude- 3" ,
InputTokens : 10 ,
Out putTokens: 2 0,
CacheCreationTokens : 1 ,
CacheRead Tokens : 2 ,
TotalCost : 0.5 ,
Actu alCost: 0.5 ,
RateMultiplier : 1 ,
BillingType : service . BillingTypeBalance ,
Stream : tru e ,
DurationMs : ptr ( 100 ) ,
FirstToke nMs: ptr ( 5 0) ,
CreatedAt : deps . now ,
ID : 1 ,
UserID : 1 ,
APIKeyID : 100 ,
AccountID : 200 ,
AccountRateMultiplier : ptr ( 0.5 ) ,
RequestID : "req_12 3",
Model : "claude-3" ,
In putTokens: 1 0 ,
OutputTokens : 20 ,
CacheCreation Tokens : 1 ,
CacheReadTokens : 2 ,
Tot alCost: 0.5 ,
ActualCost : 0.5 ,
RateMultiplier : 1 ,
BillingType : service . BillingTypeBalanc e,
Stream : true ,
Duratio nMs: ptr ( 10 0) ,
FirstTokenMs : ptr ( 50 ) ,
CreatedAt : deps . now ,
} ,
} )
} ,
@@ -239,10 +385,9 @@ func TestAPIContracts(t *testing.T) {
"output_cost": 0,
"cache_creation_cost": 0,
"cache_read_cost": 0,
"total_cost": 0.5,
"total_cost": 0.5,
"actual_cost": 0.5,
"rate_multiplier": 1,
"account_rate_multiplier": null,
"billing_type": 0,
"stream": true,
"duration_ms": 100,
@@ -267,6 +412,7 @@ func TestAPIContracts(t *testing.T) {
deps . settingRepo . SetAll ( map [ string ] string {
service . SettingKeyRegistrationEnabled : "true" ,
service . SettingKeyEmailVerifyEnabled : "false" ,
service . SettingKeyPromoCodeEnabled : "true" ,
service . SettingKeySMTPHost : "smtp.example.com" ,
service . SettingKeySMTPPort : "587" ,
@@ -305,6 +451,10 @@ func TestAPIContracts(t *testing.T) {
"data": {
"registration_enabled": true,
"email_verify_enabled": false,
"promo_code_enabled": true,
"password_reset_enabled": false,
"totp_enabled": false,
"totp_encryption_key_configured": false,
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_username": "user",
@@ -338,45 +488,10 @@ func TestAPIContracts(t *testing.T) {
"fallback_model_openai": "gpt-4o",
"enable_identity_patch": true,
"identity_patch_prompt": "",
"home_content": ""
}
} ` ,
} ,
{
name : "POST /api/v1/admin/accounts/lookup" ,
setup : func ( t * testing . T , deps * contractDeps ) {
t . Helper ( )
deps . accountRepo . lookupAccounts = [ ] service . Account {
{
ID : 101 ,
Name : "Alice Account" ,
Platform : "antigravity" ,
Credentials : map [ string ] any {
"email" : "alice@example.com" ,
} ,
} ,
}
} ,
method : http . MethodPost ,
path : "/api/v1/admin/accounts/lookup" ,
body : ` { "platform":"antigravity","emails":["Alice@Example.com","bob@example.com"]} ` ,
headers : map [ string ] string {
"Content-Type" : "application/json" ,
} ,
wantStatus : http . StatusOK ,
wantJSON : ` {
"code": 0,
"message": "success",
"data": {
"matched": [
{
"email": "alice@example.com",
"account_id": 101,
"platform": "antigravity",
"name": "Alice Account"
}
],
"missing": ["bob@example.com"]
"home_content": "",
"hide_ccs_import_button": false,
"purchase_subscription_enabled": false,
"purchase_subscription_url": ""
}
} ` ,
} ,
@@ -424,9 +539,11 @@ type contractDeps struct {
now time . Time
router http . Handler
apiKeyRepo * stubApiKeyRepo
groupRepo * stubGroupRepo
userSubRepo * stubUserSubscriptionRepo
usageRepo * stubUsageLogRepo
settingRepo * stubSettingRepo
account Repo * stubAccount Repo
redeem Repo * stubRedeemCode Repo
}
func newContractDeps ( t * testing . T ) * contractDeps {
@@ -454,11 +571,11 @@ func newContractDeps(t *testing.T) *contractDeps {
apiKeyRepo := newStubApiKeyRepo ( now )
apiKeyCache := stubApiKeyCache { }
groupRepo := stubGroupRepo { }
userSubRepo := stubUserSubscriptionRepo { }
groupRepo := & stubGroupRepo { }
userSubRepo := & stubUserSubscriptionRepo { }
accountRepo := stubAccountRepo { }
proxyRepo := stubProxyRepo { }
redeemRepo := stubRedeemCodeRepo { }
redeemRepo := & stubRedeemCodeRepo { }
cfg := & config . Config {
Default : config . DefaultConfig {
@@ -473,15 +590,21 @@ func newContractDeps(t *testing.T) *contractDeps {
usageRepo := newStubUsageLogRepo ( )
usageService := service . NewUsageService ( usageRepo , userRepo , nil , nil )
subscriptionService := service . NewSubscriptionService ( groupRepo , userSubRepo , nil )
subscriptionHandler := handler . NewSubscriptionHandler ( subscriptionService )
redeemService := service . NewRedeemService ( redeemRepo , userRepo , subscriptionService , nil , nil , nil , nil )
redeemHandler := handler . NewRedeemHandler ( redeemService )
settingRepo := newStubSettingRepo ( )
settingService := service . NewSettingService ( settingRepo , cfg )
adminService := service . NewAdminService ( userRepo , groupRepo , & accountRepo , proxyRepo , apiKeyRepo , redeemRepo , nil , nil , nil , nil )
authHandler := handler . NewAuthHandler ( cfg , nil , userService , settingService , nil )
authHandler := handler . NewAuthHandler ( cfg , nil , userService , settingService , nil , nil )
apiKeyHandler := handler . NewAPIKeyHandler ( apiKeyService )
usageHandler := handler . NewUsageHandler ( usageService , apiKeyService )
adminSettingHandler := adminhandler . NewSettingHandler ( settingService , nil , nil , nil )
adminAccountHandler := adminhandler . NewAccountHandler ( adminService , nil , nil , nil , nil , nil , nil , nil , nil , nil , nil )
adminAccountHandler := adminhandler . NewAccountHandler ( adminService , nil , nil , nil , nil , nil , nil , nil , nil , nil , nil , nil )
jwtAuth := func ( c * gin . Context ) {
c . Set ( string ( middleware . ContextKeyUser ) , middleware . AuthSubject {
@@ -512,25 +635,35 @@ func newContractDeps(t *testing.T) *contractDeps {
v1Keys . Use ( jwtAuth )
v1Keys . GET ( "/keys" , apiKeyHandler . List )
v1Keys . POST ( "/keys" , apiKeyHandler . Create )
v1Keys . GET ( "/groups/available" , apiKeyHandler . GetAvailableGroups )
v1Usage := v1 . Group ( "" )
v1Usage . Use ( jwtAuth )
v1Usage . GET ( "/usage" , usageHandler . List )
v1Usage . GET ( "/usage/stats" , usageHandler . Stats )
v1Subs := v1 . Group ( "" )
v1Subs . Use ( jwtAuth )
v1Subs . GET ( "/subscriptions" , subscriptionHandler . List )
v1Redeem := v1 . Group ( "" )
v1Redeem . Use ( jwtAuth )
v1Redeem . GET ( "/redeem/history" , redeemHandler . GetHistory )
v1Admin := v1 . Group ( "/admin" )
v1Admin . Use ( adminAuth )
v1Admin . GET ( "/settings" , adminSettingHandler . GetSettings )
v1Admin . POST ( "/accounts/bulk-update" , adminAccountHandler . BulkUpdate )
v1Admin . POST ( "/accounts/lookup" , adminAccountHandler . Lookup )
return & contractDeps {
now : now ,
router : r ,
apiKeyRepo : apiKeyRepo ,
groupRepo : groupRepo ,
userSubRepo : userSubRepo ,
usageRepo : usageRepo ,
settingRepo : settingRepo ,
account Repo: & account Repo,
redeem Repo: redeem Repo ,
}
}
@@ -626,6 +759,18 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
return 0 , errors . New ( "not implemented" )
}
func ( r * stubUserRepo ) UpdateTotpSecret ( ctx context . Context , userID int64 , encryptedSecret * string ) error {
return errors . New ( "not implemented" )
}
func ( r * stubUserRepo ) EnableTotp ( ctx context . Context , userID int64 ) error {
return errors . New ( "not implemented" )
}
func ( r * stubUserRepo ) DisableTotp ( ctx context . Context , userID int64 ) error {
return errors . New ( "not implemented" )
}
type stubApiKeyCache struct { }
func ( stubApiKeyCache ) GetCreateAttemptCount ( ctx context . Context , userID int64 ) ( int , error ) {
@@ -660,7 +805,21 @@ func (stubApiKeyCache) DeleteAuthCache(ctx context.Context, key string) error {
return nil
}
type stubGroupRepo struct { }
func ( stubApiKeyCache ) PublishAuthCacheInvalidation ( ctx context . Context , cacheKey string ) error {
return nil
}
func ( stubApiKeyCache ) SubscribeAuthCacheInvalidation ( ctx context . Context , handler func ( cacheKey string ) ) error {
return nil
}
type stubGroupRepo struct {
active [ ] service . Group
}
func ( r * stubGroupRepo ) SetActive ( groups [ ] service . Group ) {
r . active = append ( [ ] service . Group ( nil ) , groups ... )
}
func ( stubGroupRepo ) Create ( ctx context . Context , group * service . Group ) error {
return errors . New ( "not implemented" )
@@ -694,12 +853,19 @@ func (stubGroupRepo) ListWithFilters(ctx context.Context, params pagination.Pagi
return nil , nil , errors . New ( "not implemented" )
}
func ( stubGroupRepo ) ListActive ( ctx context . Context ) ( [ ] service . Group , error ) {
return nil , errors . New ( "not implemented" )
func ( r * stubGroupRepo) ListActive ( ctx context . Context ) ( [ ] service . Group , error ) {
return append ( [ ] service . Group ( nil ) , r . active ... ) , nil
}
func ( stubGroupRepo ) ListActiveByPlatform ( ctx context . Context , platform string ) ( [ ] service . Group , error ) {
return nil , errors . New ( "not implemented" )
func ( r * stubGroupRepo) ListActiveByPlatform ( ctx context . Context , platform string ) ( [ ] service . Group , error ) {
out := make ( [ ] service . Group , 0 , len ( r . active ) )
for i := range r . active {
g := r . active [ i ]
if g . Platform == platform {
out = append ( out , g )
}
}
return out , nil
}
func ( stubGroupRepo ) ExistsByName ( ctx context . Context , name string ) ( bool , error ) {
@@ -715,8 +881,7 @@ func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID i
}
type stubAccountRepo struct {
bulkUpdateIDs [ ] int64
lookupAccounts [ ] service . Account
bulkUpdateIDs [ ] int64
}
func ( s * stubAccountRepo ) Create ( ctx context . Context , account * service . Account ) error {
@@ -767,36 +932,6 @@ func (s *stubAccountRepo) ListByPlatform(ctx context.Context, platform string) (
return nil , errors . New ( "not implemented" )
}
func ( s * stubAccountRepo ) ListByPlatformAndCredentialEmails ( ctx context . Context , platform string , emails [ ] string ) ( [ ] service . Account , error ) {
if len ( s . lookupAccounts ) == 0 {
return nil , nil
}
emailSet := make ( map [ string ] struct { } , len ( emails ) )
for _ , email := range emails {
normalized := strings . ToLower ( strings . TrimSpace ( email ) )
if normalized == "" {
continue
}
emailSet [ normalized ] = struct { } { }
}
var matches [ ] service . Account
for i := range s . lookupAccounts {
account := & s . lookupAccounts [ i ]
if account . Platform != platform {
continue
}
accountEmail := strings . ToLower ( strings . TrimSpace ( account . GetCredential ( "email" ) ) )
if accountEmail == "" {
continue
}
if _ , ok := emailSet [ accountEmail ] ; ! ok {
continue
}
matches = append ( matches , * account )
}
return matches , nil
}
func ( s * stubAccountRepo ) UpdateLastUsed ( ctx context . Context , id int64 ) error {
return errors . New ( "not implemented" )
}
@@ -948,7 +1083,16 @@ func (stubProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID
return nil , errors . New ( "not implemented" )
}
type stubRedeemCodeRepo struct { }
type stubRedeemCodeRepo struct {
byUser map [ int64 ] [ ] service . RedeemCode
}
func ( r * stubRedeemCodeRepo ) SetByUser ( userID int64 , codes [ ] service . RedeemCode ) {
if r . byUser == nil {
r . byUser = make ( map [ int64 ] [ ] service . RedeemCode )
}
r . byUser [ userID ] = append ( [ ] service . RedeemCode ( nil ) , codes ... )
}
func ( stubRedeemCodeRepo ) Create ( ctx context . Context , code * service . RedeemCode ) error {
return errors . New ( "not implemented" )
@@ -986,11 +1130,35 @@ func (stubRedeemCodeRepo) ListWithFilters(ctx context.Context, params pagination
return nil , nil , errors . New ( "not implemented" )
}
func ( stubRedeemCodeRepo ) ListByUser ( ctx context . Context , userID int64 , limit int ) ( [ ] service . RedeemCode , error ) {
return nil , errors . New ( "not implemented" )
func ( r * stubRedeemCodeRepo) ListByUser ( ctx context . Context , userID int64 , limit int ) ( [ ] service . RedeemCode , error ) {
if r . byUser == nil {
return nil , nil
}
codes := r . byUser [ userID ]
if limit > 0 && len ( codes ) > limit {
codes = codes [ : limit ]
}
return append ( [ ] service . RedeemCode ( nil ) , codes ... ) , nil
}
type stubUserSubscriptionRepo struct { }
type stubUserSubscriptionRepo struct {
byUser map [ int64 ] [ ] service . UserSubscription
activeByUser map [ int64 ] [ ] service . UserSubscription
}
func ( r * stubUserSubscriptionRepo ) SetByUserID ( userID int64 , subs [ ] service . UserSubscription ) {
if r . byUser == nil {
r . byUser = make ( map [ int64 ] [ ] service . UserSubscription )
}
r . byUser [ userID ] = append ( [ ] service . UserSubscription ( nil ) , subs ... )
}
func ( r * stubUserSubscriptionRepo ) SetActiveByUserID ( userID int64 , subs [ ] service . UserSubscription ) {
if r . activeByUser == nil {
r . activeByUser = make ( map [ int64 ] [ ] service . UserSubscription )
}
r . activeByUser [ userID ] = append ( [ ] service . UserSubscription ( nil ) , subs ... )
}
func ( stubUserSubscriptionRepo ) Create ( ctx context . Context , sub * service . UserSubscription ) error {
return errors . New ( "not implemented" )
@@ -1010,16 +1178,22 @@ func (stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSub
func ( stubUserSubscriptionRepo ) Delete ( ctx context . Context , id int64 ) error {
return errors . New ( "not implemented" )
}
func ( stubUserSubscriptionRepo ) ListByUserID ( ctx context . Context , userID int64 ) ( [ ] service . UserSubscription , error ) {
return nil , errors . New ( "not implemented" )
func ( r * stubUserSubscriptionRepo) ListByUserID ( ctx context . Context , userID int64 ) ( [ ] service . UserSubscription , error ) {
if r . byUser == nil {
return nil , nil
}
return append ( [ ] service . UserSubscription ( nil ) , r . byUser [ userID ] ... ) , nil
}
func ( stubUserSubscriptionRepo ) ListActiveByUserID ( ctx context . Context , userID int64 ) ( [ ] service . UserSubscription , error ) {
return nil , errors . New ( "not implemented" )
func ( r * stubUserSubscriptionRepo) ListActiveByUserID ( ctx context . Context , userID int64 ) ( [ ] service . UserSubscription , error ) {
if r . activeByUser == nil {
return nil , nil
}
return append ( [ ] service . UserSubscription ( nil ) , r . activeByUser [ userID ] ... ) , nil
}
func ( stubUserSubscriptionRepo ) ListByGroupID ( ctx context . Context , groupID int64 , params pagination . PaginationParams ) ( [ ] service . UserSubscription , * pagination . PaginationResult , error ) {
return nil , nil , errors . New ( "not implemented" )
}
func ( stubUserSubscriptionRepo ) List ( ctx context . Context , params pagination . PaginationParams , userID , groupID * int64 , status string ) ( [ ] service . UserSubscription , * pagination . PaginationResult , error ) {
func ( stubUserSubscriptionRepo ) List ( ctx context . Context , params pagination . PaginationParams , userID , groupID * int64 , status , sortBy , sortOrder string ) ( [ ] service . UserSubscription , * pagination . PaginationResult , error ) {
return nil , nil , errors . New ( "not implemented" )
}
func ( stubUserSubscriptionRepo ) ExistsByUserIDAndGroupID ( ctx context . Context , userID , groupID int64 ) ( bool , error ) {
@@ -1319,11 +1493,11 @@ func (r *stubUsageLogRepo) GetDashboardStats(ctx context.Context) (*usagestats.D
return nil , errors . New ( "not implemented" )
}
func ( r * stubUsageLogRepo ) GetUsageTrendWithFilters ( ctx context . Context , startTime , endTime time . Time , granularity string , userID , apiKeyID , accountID , groupID int64 , model string , stream * bool ) ( [ ] usagestats . TrendDataPoint , error ) {
func ( r * stubUsageLogRepo ) GetUsageTrendWithFilters ( ctx context . Context , startTime , endTime time . Time , granularity string , userID , apiKeyID , accountID , groupID int64 , model string , stream * bool , billingType * int8 ) ( [ ] usagestats . TrendDataPoint , error ) {
return nil , errors . New ( "not implemented" )
}
func ( r * stubUsageLogRepo ) GetModelStatsWithFilters ( ctx context . Context , startTime , endTime time . Time , userID , apiKeyID , accountID , groupID int64 , stream * bool ) ( [ ] usagestats . ModelStat , error ) {
func ( r * stubUsageLogRepo ) GetModelStatsWithFilters ( ctx context . Context , startTime , endTime time . Time , userID , apiKeyID , accountID , groupID int64 , stream * bool , billingType * int8 ) ( [ ] usagestats . ModelStat , error ) {
return nil , errors . New ( "not implemented" )
}