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
1ef3782d
Unverified
Commit
1ef3782d
authored
Apr 11, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 11, 2026
Browse files
Merge pull request #1538 from IanShaw027/fix/bug-cleanup-main
fix: 修复多个 UI 和功能问题 - 表格排序搜索、导出逻辑、分页配置和状态筛选
parents
00c08c57
f480e573
Changes
117
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/dto/settings.go
View file @
1ef3782d
...
@@ -84,6 +84,8 @@ type SystemSettings struct {
...
@@ -84,6 +84,8 @@ type SystemSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
...
@@ -148,6 +150,8 @@ type PublicSettings struct {
...
@@ -148,6 +150,8 @@ type PublicSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
...
...
backend/internal/handler/setting_handler.go
View file @
1ef3782d
...
@@ -51,6 +51,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
...
@@ -51,6 +51,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
CustomMenuItems
:
dto
.
ParseUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
CustomMenuItems
:
dto
.
ParseUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
...
...
backend/internal/handler/usage_handler.go
View file @
1ef3782d
...
@@ -119,7 +119,12 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -119,7 +119,12 @@ func (h *UsageHandler) List(c *gin.Context) {
endTime
=
&
t
endTime
=
&
t
}
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
SortBy
:
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
),
SortOrder
:
c
.
DefaultQuery
(
"sort_order"
,
"desc"
),
}
filters
:=
usagestats
.
UsageLogFilters
{
filters
:=
usagestats
.
UsageLogFilters
{
UserID
:
subject
.
UserID
,
// Always filter by current user for security
UserID
:
subject
.
UserID
,
// Always filter by current user for security
APIKeyID
:
apiKeyID
,
APIKeyID
:
apiKeyID
,
...
...
backend/internal/handler/usage_handler_request_type_test.go
View file @
1ef3782d
...
@@ -16,10 +16,12 @@ import (
...
@@ -16,10 +16,12 @@ import (
type
userUsageRepoCapture
struct
{
type
userUsageRepoCapture
struct
{
service
.
UsageLogRepository
service
.
UsageLogRepository
listParams
pagination
.
PaginationParams
listFilters
usagestats
.
UsageLogFilters
listFilters
usagestats
.
UsageLogFilters
}
}
func
(
s
*
userUsageRepoCapture
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
s
*
userUsageRepoCapture
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
listParams
=
params
s
.
listFilters
=
filters
s
.
listFilters
=
filters
return
[]
service
.
UsageLog
{},
&
pagination
.
PaginationResult
{
return
[]
service
.
UsageLog
{},
&
pagination
.
PaginationResult
{
Total
:
0
,
Total
:
0
,
...
...
backend/internal/handler/usage_handler_sort_test.go
0 → 100644
View file @
1ef3782d
package
handler
import
(
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func
TestUserUsageListSortParams
(
t
*
testing
.
T
)
{
repo
:=
&
userUsageRepoCapture
{}
router
:=
newUserUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/usage?sort_by=model&sort_order=ASC"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"model"
,
repo
.
listParams
.
SortBy
)
require
.
Equal
(
t
,
"ASC"
,
repo
.
listParams
.
SortOrder
)
}
func
TestUserUsageListSortDefaults
(
t
*
testing
.
T
)
{
repo
:=
&
userUsageRepoCapture
{}
router
:=
newUserUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/usage"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"created_at"
,
repo
.
listParams
.
SortBy
)
require
.
Equal
(
t
,
"desc"
,
repo
.
listParams
.
SortOrder
)
}
backend/internal/pkg/pagination/pagination.go
View file @
1ef3782d
// Package pagination provides types and helpers for paginated responses.
// Package pagination provides types and helpers for paginated responses.
package
pagination
package
pagination
import
"strings"
const
(
SortOrderAsc
=
"asc"
SortOrderDesc
=
"desc"
)
// PaginationParams 分页参数
// PaginationParams 分页参数
type
PaginationParams
struct
{
type
PaginationParams
struct
{
Page
int
Page
int
PageSize
int
PageSize
int
SortBy
string
SortOrder
string
}
}
// PaginationResult 分页结果
// PaginationResult 分页结果
...
@@ -20,6 +29,7 @@ func DefaultPagination() PaginationParams {
...
@@ -20,6 +29,7 @@ func DefaultPagination() PaginationParams {
return
PaginationParams
{
return
PaginationParams
{
Page
:
1
,
Page
:
1
,
PageSize
:
20
,
PageSize
:
20
,
SortOrder
:
SortOrderDesc
,
}
}
}
}
...
@@ -36,8 +46,32 @@ func (p PaginationParams) Limit() int {
...
@@ -36,8 +46,32 @@ func (p PaginationParams) Limit() int {
if
p
.
PageSize
<
1
{
if
p
.
PageSize
<
1
{
return
20
return
20
}
}
if
p
.
PageSize
>
100
{
if
p
.
PageSize
>
100
0
{
return
100
return
100
0
}
}
return
p
.
PageSize
return
p
.
PageSize
}
}
// NormalizeSortOrder normalizes sort order to asc/desc and falls back to defaultOrder.
func
NormalizeSortOrder
(
order
string
,
defaultOrder
string
)
string
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
defaultOrder
))
{
case
SortOrderAsc
:
defaultOrder
=
SortOrderAsc
default
:
defaultOrder
=
SortOrderDesc
}
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
order
))
{
case
SortOrderAsc
:
return
SortOrderAsc
case
SortOrderDesc
:
return
SortOrderDesc
default
:
return
defaultOrder
}
}
// NormalizedSortOrder returns the normalized sort order using defaultOrder as fallback.
func
(
p
PaginationParams
)
NormalizedSortOrder
(
defaultOrder
string
)
string
{
return
NormalizeSortOrder
(
p
.
SortOrder
,
defaultOrder
)
}
backend/internal/pkg/pagination/pagination_test.go
0 → 100644
View file @
1ef3782d
package
pagination
import
"testing"
func
TestNormalizeSortOrder
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
input
string
defaultOrder
string
want
string
}{
{
name
:
"asc"
,
input
:
"asc"
,
defaultOrder
:
"desc"
,
want
:
"asc"
},
{
name
:
"uppercase asc"
,
input
:
"ASC"
,
defaultOrder
:
"desc"
,
want
:
"asc"
},
{
name
:
"desc"
,
input
:
"desc"
,
defaultOrder
:
"asc"
,
want
:
"desc"
},
{
name
:
"trim spaces"
,
input
:
" desc "
,
defaultOrder
:
"asc"
,
want
:
"desc"
},
{
name
:
"invalid falls back"
,
input
:
"sideways"
,
defaultOrder
:
"asc"
,
want
:
"asc"
},
{
name
:
"empty falls back"
,
input
:
""
,
defaultOrder
:
"desc"
,
want
:
"desc"
},
{
name
:
"invalid default falls back to desc"
,
input
:
""
,
defaultOrder
:
"wat"
,
want
:
"desc"
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
if
got
:=
NormalizeSortOrder
(
tt
.
input
,
tt
.
defaultOrder
);
got
!=
tt
.
want
{
t
.
Fatalf
(
"NormalizeSortOrder(%q, %q) = %q, want %q"
,
tt
.
input
,
tt
.
defaultOrder
,
got
,
tt
.
want
)
}
})
}
}
func
TestPaginationParamsNormalizedSortOrder
(
t
*
testing
.
T
)
{
t
.
Parallel
()
params
:=
PaginationParams
{
SortOrder
:
"ASC"
}
if
got
:=
params
.
NormalizedSortOrder
(
"desc"
);
got
!=
"asc"
{
t
.
Fatalf
(
"NormalizedSortOrder = %q, want asc"
,
got
)
}
params
=
PaginationParams
{
SortOrder
:
"bad"
}
if
got
:=
params
.
NormalizedSortOrder
(
"asc"
);
got
!=
"asc"
{
t
.
Fatalf
(
"NormalizedSortOrder invalid fallback = %q, want asc"
,
got
)
}
}
func
TestPaginationParamsLimit
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
pageSize
int
want
int
}{
{
name
:
"non-positive falls back to default"
,
pageSize
:
0
,
want
:
20
},
{
name
:
"negative falls back to default"
,
pageSize
:
-
1
,
want
:
20
},
{
name
:
"normal value keeps"
,
pageSize
:
50
,
want
:
50
},
{
name
:
"max value keeps"
,
pageSize
:
1000
,
want
:
1000
},
{
name
:
"beyond max clamps to 1000"
,
pageSize
:
1500
,
want
:
1000
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
p
:=
PaginationParams
{
PageSize
:
tt
.
pageSize
}
if
got
:=
p
.
Limit
();
got
!=
tt
.
want
{
t
.
Fatalf
(
"Limit() for PageSize=%d = %d, want %d"
,
tt
.
pageSize
,
got
,
tt
.
want
)
}
})
}
}
backend/internal/repository/account_repo.go
View file @
1ef3782d
...
@@ -471,21 +471,58 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
...
@@ -471,21 +471,58 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
case
service
.
StatusActive
:
case
service
.
StatusActive
:
q
=
q
.
Where
(
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
status
),
dbaccount
.
StatusEQ
(
status
),
dbaccount
.
SchedulableEQ
(
true
),
dbaccount
.
Or
(
dbaccount
.
Or
(
dbaccount
.
RateLimitResetAtIsNil
(),
dbaccount
.
RateLimitResetAtIsNil
(),
dbaccount
.
RateLimitResetAtLTE
(
time
.
Now
()),
dbaccount
.
RateLimitResetAtLTE
(
time
.
Now
()),
),
),
dbpredicate
.
Account
(
func
(
s
*
entsql
.
Selector
)
{
col
:=
s
.
C
(
"temp_unschedulable_until"
)
s
.
Where
(
entsql
.
Or
(
entsql
.
IsNull
(
col
),
entsql
.
LTE
(
col
,
entsql
.
Expr
(
"NOW()"
)),
))
}),
)
)
case
"rate_limited"
:
case
"rate_limited"
:
q
=
q
.
Where
(
dbaccount
.
RateLimitResetAtGT
(
time
.
Now
()))
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
service
.
StatusActive
),
dbaccount
.
RateLimitResetAtGT
(
time
.
Now
()),
dbpredicate
.
Account
(
func
(
s
*
entsql
.
Selector
)
{
col
:=
s
.
C
(
"temp_unschedulable_until"
)
s
.
Where
(
entsql
.
Or
(
entsql
.
IsNull
(
col
),
entsql
.
LTE
(
col
,
entsql
.
Expr
(
"NOW()"
)),
))
}),
)
case
"temp_unschedulable"
:
case
"temp_unschedulable"
:
q
=
q
.
Where
(
dbpredicate
.
Account
(
func
(
s
*
entsql
.
Selector
)
{
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
service
.
StatusActive
),
dbpredicate
.
Account
(
func
(
s
*
entsql
.
Selector
)
{
col
:=
s
.
C
(
"temp_unschedulable_until"
)
col
:=
s
.
C
(
"temp_unschedulable_until"
)
s
.
Where
(
entsql
.
And
(
s
.
Where
(
entsql
.
And
(
entsql
.
Not
(
entsql
.
IsNull
(
col
)),
entsql
.
Not
(
entsql
.
IsNull
(
col
)),
entsql
.
GT
(
col
,
entsql
.
Expr
(
"NOW()"
)),
entsql
.
GT
(
col
,
entsql
.
Expr
(
"NOW()"
)),
))
))
}))
}),
)
case
"unschedulable"
:
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
service
.
StatusActive
),
dbaccount
.
SchedulableEQ
(
false
),
dbaccount
.
Or
(
dbaccount
.
RateLimitResetAtIsNil
(),
dbaccount
.
RateLimitResetAtLTE
(
time
.
Now
()),
),
dbpredicate
.
Account
(
func
(
s
*
entsql
.
Selector
)
{
col
:=
s
.
C
(
"temp_unschedulable_until"
)
s
.
Where
(
entsql
.
Or
(
entsql
.
IsNull
(
col
),
entsql
.
LTE
(
col
,
entsql
.
Expr
(
"NOW()"
)),
))
}),
)
default
:
default
:
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
status
))
q
=
q
.
Where
(
dbaccount
.
StatusEQ
(
status
))
}
}
...
@@ -518,11 +555,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
...
@@ -518,11 +555,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
accounts
,
er
r
:=
q
.
accounts
Qu
er
y
:=
q
.
Offset
(
params
.
Offset
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Limit
(
params
.
Limit
())
Order
(
dbent
.
Desc
(
dbaccount
.
FieldID
))
.
for
_
,
order
:=
range
accountListOrder
(
params
)
{
All
(
ctx
)
accountsQuery
=
accountsQuery
.
Order
(
order
)
}
accounts
,
err
:=
accountsQuery
.
All
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
...
@@ -534,6 +574,50 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
...
@@ -534,6 +574,50 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
return
outAccounts
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
return
outAccounts
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
}
func
accountListOrder
(
params
pagination
.
PaginationParams
)
[]
func
(
*
entsql
.
Selector
)
{
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderAsc
)
field
:=
dbaccount
.
FieldName
defaultOrder
:=
true
switch
sortBy
{
case
""
,
"name"
:
field
=
dbaccount
.
FieldName
case
"id"
:
field
=
dbaccount
.
FieldID
defaultOrder
=
false
case
"status"
:
field
=
dbaccount
.
FieldStatus
defaultOrder
=
false
case
"schedulable"
:
field
=
dbaccount
.
FieldSchedulable
defaultOrder
=
false
case
"priority"
:
field
=
dbaccount
.
FieldPriority
defaultOrder
=
false
case
"rate_multiplier"
:
field
=
dbaccount
.
FieldRateMultiplier
defaultOrder
=
false
case
"last_used_at"
:
field
=
dbaccount
.
FieldLastUsedAt
defaultOrder
=
false
case
"expires_at"
:
field
=
dbaccount
.
FieldExpiresAt
defaultOrder
=
false
case
"created_at"
:
field
=
dbaccount
.
FieldCreatedAt
defaultOrder
=
false
}
if
sortOrder
==
pagination
.
SortOrderDesc
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
dbent
.
Desc
(
dbaccount
.
FieldID
)}
}
if
defaultOrder
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
dbaccount
.
FieldName
),
dbent
.
Asc
(
dbaccount
.
FieldID
)}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
),
dbent
.
Asc
(
dbaccount
.
FieldID
)}
}
func
(
r
*
accountRepository
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
service
.
Account
,
error
)
{
func
(
r
*
accountRepository
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
service
.
Account
,
error
)
{
accounts
,
err
:=
r
.
queryAccountsByGroup
(
ctx
,
groupID
,
accountGroupQueryOptions
{
accounts
,
err
:=
r
.
queryAccountsByGroup
(
ctx
,
groupID
,
accountGroupQueryOptions
{
status
:
service
.
StatusActive
,
status
:
service
.
StatusActive
,
...
...
backend/internal/repository/account_repo_integration_test.go
View file @
1ef3782d
...
@@ -256,7 +256,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
...
@@ -256,7 +256,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
},
},
},
},
{
{
name
:
"filter_by_status_active_excludes_r
ate_limited
"
,
name
:
"filter_by_status_active_excludes_r
untime_blocked_accounts
"
,
setup
:
func
(
client
*
dbent
.
Client
)
{
setup
:
func
(
client
*
dbent
.
Client
)
{
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-normal"
,
Status
:
service
.
StatusActive
})
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-normal"
,
Status
:
service
.
StatusActive
})
rateLimited
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-rate-limited"
,
Status
:
service
.
StatusActive
})
rateLimited
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-rate-limited"
,
Status
:
service
.
StatusActive
})
...
@@ -264,6 +264,16 @@ func (s *AccountRepoSuite) TestListWithFilters() {
...
@@ -264,6 +264,16 @@ func (s *AccountRepoSuite) TestListWithFilters() {
SetRateLimitResetAt
(
time
.
Now
()
.
Add
(
10
*
time
.
Minute
))
.
SetRateLimitResetAt
(
time
.
Now
()
.
Add
(
10
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
tempUnsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-temp-unsched"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
tempUnsched
.
ID
)
.
SetTempUnschedulableUntil
(
time
.
Now
()
.
Add
(
15
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
unsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-unsched"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
unsched
.
ID
)
.
SetSchedulable
(
false
)
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
},
},
status
:
service
.
StatusActive
,
status
:
service
.
StatusActive
,
wantCount
:
1
,
wantCount
:
1
,
...
@@ -271,6 +281,75 @@ func (s *AccountRepoSuite) TestListWithFilters() {
...
@@ -271,6 +281,75 @@ func (s *AccountRepoSuite) TestListWithFilters() {
s
.
Require
()
.
Equal
(
"active-normal"
,
accounts
[
0
]
.
Name
)
s
.
Require
()
.
Equal
(
"active-normal"
,
accounts
[
0
]
.
Name
)
},
},
},
},
{
name
:
"filter_by_status_unschedulable_excludes_rate_limited_and_temp_unschedulable"
,
setup
:
func
(
client
*
dbent
.
Client
)
{
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-normal"
,
Status
:
service
.
StatusActive
,
Schedulable
:
true
})
unsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-unsched"
,
Status
:
service
.
StatusActive
})
err
:=
client
.
Account
.
UpdateOneID
(
unsched
.
ID
)
.
SetSchedulable
(
false
)
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
rateLimited
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-rate-limited"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
rateLimited
.
ID
)
.
SetSchedulable
(
false
)
.
SetRateLimitResetAt
(
time
.
Now
()
.
Add
(
10
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
tempUnsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-temp-unsched"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
tempUnsched
.
ID
)
.
SetSchedulable
(
false
)
.
SetTempUnschedulableUntil
(
time
.
Now
()
.
Add
(
15
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
},
status
:
"unschedulable"
,
wantCount
:
1
,
validate
:
func
(
accounts
[]
service
.
Account
)
{
s
.
Require
()
.
Equal
(
"active-unsched"
,
accounts
[
0
]
.
Name
)
},
},
{
name
:
"filter_by_status_rate_limited_excludes_temp_unschedulable"
,
setup
:
func
(
client
*
dbent
.
Client
)
{
rateLimited
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-rate-limited"
,
Status
:
service
.
StatusActive
})
err
:=
client
.
Account
.
UpdateOneID
(
rateLimited
.
ID
)
.
SetRateLimitResetAt
(
time
.
Now
()
.
Add
(
10
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
tempUnsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-temp-unsched"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
tempUnsched
.
ID
)
.
SetRateLimitResetAt
(
time
.
Now
()
.
Add
(
20
*
time
.
Minute
))
.
SetTempUnschedulableUntil
(
time
.
Now
()
.
Add
(
15
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
},
status
:
"rate_limited"
,
wantCount
:
1
,
validate
:
func
(
accounts
[]
service
.
Account
)
{
s
.
Require
()
.
Equal
(
"active-rate-limited"
,
accounts
[
0
]
.
Name
)
},
},
{
name
:
"filter_by_status_temp_unschedulable_excludes_manually_unschedulable"
,
setup
:
func
(
client
*
dbent
.
Client
)
{
tempUnsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-temp-unsched"
,
Status
:
service
.
StatusActive
,
Schedulable
:
true
})
err
:=
client
.
Account
.
UpdateOneID
(
tempUnsched
.
ID
)
.
SetTempUnschedulableUntil
(
time
.
Now
()
.
Add
(
15
*
time
.
Minute
))
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
unsched
:=
mustCreateAccount
(
s
.
T
(),
client
,
&
service
.
Account
{
Name
:
"active-unsched"
,
Status
:
service
.
StatusActive
})
err
=
client
.
Account
.
UpdateOneID
(
unsched
.
ID
)
.
SetSchedulable
(
false
)
.
Exec
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
},
status
:
"temp_unschedulable"
,
wantCount
:
1
,
validate
:
func
(
accounts
[]
service
.
Account
)
{
s
.
Require
()
.
Equal
(
"active-temp-unsched"
,
accounts
[
0
]
.
Name
)
},
},
{
{
name
:
"filter_by_search"
,
name
:
"filter_by_search"
,
setup
:
func
(
client
*
dbent
.
Client
)
{
setup
:
func
(
client
*
dbent
.
Client
)
{
...
...
backend/internal/repository/account_repo_sort_integration_test.go
0 → 100644
View file @
1ef3782d
//go:build integration
package
repository
import
(
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func
(
s
*
AccountRepoSuite
)
TestList_DefaultSortByNameAsc
()
{
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"z-account"
})
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"a-account"
})
accounts
,
_
,
err
:=
s
.
repo
.
List
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
})
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
accounts
,
2
)
s
.
Require
()
.
Equal
(
"a-account"
,
accounts
[
0
]
.
Name
)
s
.
Require
()
.
Equal
(
"z-account"
,
accounts
[
1
]
.
Name
)
}
func
(
s
*
AccountRepoSuite
)
TestListWithFilters_SortByPriorityDesc
()
{
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"low-priority"
,
Priority
:
10
})
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"high-priority"
,
Priority
:
90
})
accounts
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
,
SortBy
:
"priority"
,
SortOrder
:
"desc"
,
},
""
,
""
,
""
,
""
,
0
,
""
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
accounts
,
2
)
s
.
Require
()
.
Equal
(
"high-priority"
,
accounts
[
0
]
.
Name
)
s
.
Require
()
.
Equal
(
"low-priority"
,
accounts
[
1
]
.
Name
)
}
backend/internal/repository/announcement_repo.go
View file @
1ef3782d
...
@@ -2,12 +2,15 @@ package repository
...
@@ -2,12 +2,15 @@ package repository
import
(
import
(
"context"
"context"
"strings"
"time"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/announcement"
"github.com/Wei-Shaw/sub2api/ent/announcement"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
entsql
"entgo.io/ent/dialect/sql"
)
)
type
announcementRepository
struct
{
type
announcementRepository
struct
{
...
@@ -128,11 +131,14 @@ func (r *announcementRepository) List(
...
@@ -128,11 +131,14 @@ func (r *announcementRepository) List(
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
items
,
er
r
:=
q
.
items
Qu
er
y
:=
q
.
Offset
(
params
.
Offset
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Limit
(
params
.
Limit
())
Order
(
dbent
.
Desc
(
announcement
.
FieldID
))
.
for
_
,
order
:=
range
announcementListOrders
(
params
)
{
All
(
ctx
)
itemsQuery
=
itemsQuery
.
Order
(
order
)
}
items
,
err
:=
itemsQuery
.
All
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
...
@@ -141,6 +147,56 @@ func (r *announcementRepository) List(
...
@@ -141,6 +147,56 @@ func (r *announcementRepository) List(
return
out
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
return
out
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
}
func
announcementListOrder
(
params
pagination
.
PaginationParams
)
(
string
,
string
)
{
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderDesc
)
switch
sortBy
{
case
"title"
:
return
announcement
.
FieldTitle
,
sortOrder
case
"status"
:
return
announcement
.
FieldStatus
,
sortOrder
case
"notify_mode"
:
return
announcement
.
FieldNotifyMode
,
sortOrder
case
"starts_at"
:
return
announcement
.
FieldStartsAt
,
sortOrder
case
"ends_at"
:
return
announcement
.
FieldEndsAt
,
sortOrder
case
"id"
:
return
announcement
.
FieldID
,
sortOrder
case
""
,
"created_at"
:
return
announcement
.
FieldCreatedAt
,
sortOrder
default
:
return
announcement
.
FieldCreatedAt
,
pagination
.
SortOrderDesc
}
}
func
announcementListOrders
(
params
pagination
.
PaginationParams
)
[]
func
(
*
entsql
.
Selector
)
{
field
,
sortOrder
:=
announcementListOrder
(
params
)
if
sortOrder
==
pagination
.
SortOrderAsc
{
if
field
==
announcement
.
FieldID
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
),
}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
),
dbent
.
Asc
(
announcement
.
FieldID
),
}
}
if
field
==
announcement
.
FieldID
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
dbent
.
Desc
(
announcement
.
FieldID
),
}
}
func
(
r
*
announcementRepository
)
ListActive
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
service
.
Announcement
,
error
)
{
func
(
r
*
announcementRepository
)
ListActive
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
service
.
Announcement
,
error
)
{
q
:=
r
.
client
.
Announcement
.
Query
()
.
q
:=
r
.
client
.
Announcement
.
Query
()
.
Where
(
Where
(
...
...
backend/internal/repository/announcement_repo_sort_test.go
0 → 100644
View file @
1ef3782d
package
repository
import
(
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
func
TestAnnouncementListOrder
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
params
pagination
.
PaginationParams
wantBy
string
want
string
}{
{
name
:
"default created_at desc"
,
params
:
pagination
.
PaginationParams
{},
wantBy
:
"created_at"
,
want
:
"desc"
,
},
{
name
:
"title asc"
,
params
:
pagination
.
PaginationParams
{
SortBy
:
"title"
,
SortOrder
:
"ASC"
,
},
wantBy
:
"title"
,
want
:
"asc"
,
},
{
name
:
"status desc"
,
params
:
pagination
.
PaginationParams
{
SortBy
:
"status"
,
SortOrder
:
"desc"
,
},
wantBy
:
"status"
,
want
:
"desc"
,
},
{
name
:
"invalid falls back"
,
params
:
pagination
.
PaginationParams
{
SortBy
:
"sideways"
,
SortOrder
:
"wat"
,
},
wantBy
:
"created_at"
,
want
:
"desc"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gotBy
,
gotOrder
:=
announcementListOrder
(
tt
.
params
)
if
gotBy
!=
tt
.
wantBy
||
gotOrder
!=
tt
.
want
{
t
.
Fatalf
(
"announcementListOrder(%+v) = (%q, %q), want (%q, %q)"
,
tt
.
params
,
gotBy
,
gotOrder
,
tt
.
wantBy
,
tt
.
want
)
}
})
}
}
backend/internal/repository/api_key_repo.go
View file @
1ef3782d
...
@@ -4,6 +4,7 @@ import (
...
@@ -4,6 +4,7 @@ import (
"context"
"context"
"database/sql"
"database/sql"
"fmt"
"fmt"
"strings"
"time"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
...
@@ -14,6 +15,8 @@ import (
...
@@ -14,6 +15,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
entsql
"entgo.io/ent/dialect/sql"
)
)
type
apiKeyRepository
struct
{
type
apiKeyRepository
struct
{
...
@@ -164,6 +167,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
...
@@ -164,6 +167,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group
.
FieldSupportedModelScopes
,
group
.
FieldSupportedModelScopes
,
group
.
FieldAllowMessagesDispatch
,
group
.
FieldAllowMessagesDispatch
,
group
.
FieldDefaultMappedModel
,
group
.
FieldDefaultMappedModel
,
group
.
FieldMessagesDispatchModelConfig
,
)
)
})
.
})
.
Only
(
ctx
)
Only
(
ctx
)
...
@@ -309,12 +313,15 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
...
@@ -309,12 +313,15 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
keys
,
er
r
:=
q
.
keys
Qu
er
y
:=
q
.
WithGroup
()
.
WithGroup
()
.
Offset
(
params
.
Offset
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Limit
(
params
.
Limit
())
Order
(
dbent
.
Desc
(
apikey
.
FieldID
))
.
for
_
,
order
:=
range
apiKeyListOrder
(
params
)
{
All
(
ctx
)
keysQuery
=
keysQuery
.
Order
(
order
)
}
keys
,
err
:=
keysQuery
.
All
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
...
@@ -359,12 +366,15 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
...
@@ -359,12 +366,15 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
keys
,
er
r
:=
q
.
keys
Qu
er
y
:=
q
.
WithUser
()
.
WithUser
()
.
Offset
(
params
.
Offset
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Limit
(
params
.
Limit
())
Order
(
dbent
.
Desc
(
apikey
.
FieldID
))
.
for
_
,
order
:=
range
apiKeyListOrder
(
params
)
{
All
(
ctx
)
keysQuery
=
keysQuery
.
Order
(
order
)
}
keys
,
err
:=
keysQuery
.
All
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
...
@@ -377,6 +387,32 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
...
@@ -377,6 +387,32 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par
return
outKeys
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
return
outKeys
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
}
func
apiKeyListOrder
(
params
pagination
.
PaginationParams
)
[]
func
(
*
entsql
.
Selector
)
{
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderDesc
)
var
field
string
switch
sortBy
{
case
"name"
:
field
=
apikey
.
FieldName
case
"status"
:
field
=
apikey
.
FieldStatus
case
"expires_at"
:
field
=
apikey
.
FieldExpiresAt
case
"last_used_at"
:
field
=
apikey
.
FieldLastUsedAt
case
"created_at"
:
field
=
apikey
.
FieldCreatedAt
default
:
field
=
apikey
.
FieldID
}
if
sortOrder
==
pagination
.
SortOrderAsc
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
),
dbent
.
Asc
(
apikey
.
FieldID
)}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
dbent
.
Desc
(
apikey
.
FieldID
)}
}
// SearchAPIKeys searches API keys by user ID and/or keyword (name)
// SearchAPIKeys searches API keys by user ID and/or keyword (name)
func
(
r
*
apiKeyRepository
)
SearchAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
keyword
string
,
limit
int
)
([]
service
.
APIKey
,
error
)
{
func
(
r
*
apiKeyRepository
)
SearchAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
keyword
string
,
limit
int
)
([]
service
.
APIKey
,
error
)
{
q
:=
r
.
activeQuery
()
q
:=
r
.
activeQuery
()
...
@@ -654,6 +690,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
...
@@ -654,6 +690,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
RequireOAuthOnly
:
g
.
RequireOauthOnly
,
RequireOAuthOnly
:
g
.
RequireOauthOnly
,
RequirePrivacySet
:
g
.
RequirePrivacySet
,
RequirePrivacySet
:
g
.
RequirePrivacySet
,
DefaultMappedModel
:
g
.
DefaultMappedModel
,
DefaultMappedModel
:
g
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
g
.
MessagesDispatchModelConfig
,
CreatedAt
:
g
.
CreatedAt
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
}
...
...
backend/internal/repository/api_key_repo_integration_test.go
View file @
1ef3782d
...
@@ -86,6 +86,45 @@ func (s *APIKeyRepoSuite) TestGetByKey_NotFound() {
...
@@ -86,6 +86,45 @@ func (s *APIKeyRepoSuite) TestGetByKey_NotFound() {
s
.
Require
()
.
Error
(
err
,
"expected error for non-existent key"
)
s
.
Require
()
.
Error
(
err
,
"expected error for non-existent key"
)
}
}
func
(
s
*
APIKeyRepoSuite
)
TestGetByKeyForAuth_PreservesMessagesDispatchModelConfig
()
{
user
:=
s
.
mustCreateUser
(
"getbykey-auth-dispatch@test.com"
)
group
,
err
:=
s
.
client
.
Group
.
Create
()
.
SetName
(
"g-auth-dispatch"
)
.
SetPlatform
(
service
.
PlatformOpenAI
)
.
SetStatus
(
service
.
StatusActive
)
.
SetSubscriptionType
(
service
.
SubscriptionTypeStandard
)
.
SetRateMultiplier
(
1
)
.
SetAllowMessagesDispatch
(
true
)
.
SetDefaultMappedModel
(
"gpt-5.4"
)
.
SetMessagesDispatchModelConfig
(
service
.
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4-nano"
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
"gpt-5.4-mini"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4.5"
:
"gpt-5.4-nano"
,
},
})
.
Save
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
key
:=
&
service
.
APIKey
{
UserID
:
user
.
ID
,
Key
:
"sk-getbykey-auth-dispatch"
,
Name
:
"Dispatch Key"
,
GroupID
:
&
group
.
ID
,
Status
:
service
.
StatusActive
,
}
s
.
Require
()
.
NoError
(
s
.
repo
.
Create
(
s
.
ctx
,
key
))
got
,
err
:=
s
.
repo
.
GetByKeyForAuth
(
s
.
ctx
,
key
.
Key
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NotNil
(
got
.
Group
)
s
.
Require
()
.
True
(
got
.
Group
.
AllowMessagesDispatch
)
s
.
Require
()
.
Equal
(
"gpt-5.4"
,
got
.
Group
.
DefaultMappedModel
)
s
.
Require
()
.
Equal
(
"gpt-5.4-nano"
,
got
.
Group
.
MessagesDispatchModelConfig
.
OpusMappedModel
)
s
.
Require
()
.
Equal
(
"gpt-5.4-nano"
,
got
.
Group
.
MessagesDispatchModelConfig
.
ExactModelMappings
[
"claude-sonnet-4.5"
])
}
// --- Update ---
// --- Update ---
func
(
s
*
APIKeyRepoSuite
)
TestUpdate
()
{
func
(
s
*
APIKeyRepoSuite
)
TestUpdate
()
{
...
...
backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go
0 → 100644
View file @
1ef3782d
package
repository
import
(
"context"
"testing"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func
TestGroupEntityToService_PreservesMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
group
:=
&
dbent
.
Group
{
ID
:
1
,
Name
:
"openai-dispatch"
,
Platform
:
service
.
PlatformOpenAI
,
Status
:
service
.
StatusActive
,
SubscriptionType
:
service
.
SubscriptionTypeStandard
,
RateMultiplier
:
1
,
AllowMessagesDispatch
:
true
,
DefaultMappedModel
:
"gpt-5.4"
,
MessagesDispatchModelConfig
:
service
.
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4-nano"
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
"gpt-5.4-mini"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4.5"
:
"gpt-5.4-nano"
,
},
},
}
got
:=
groupEntityToService
(
group
)
require
.
NotNil
(
t
,
got
)
require
.
Equal
(
t
,
group
.
MessagesDispatchModelConfig
,
got
.
MessagesDispatchModelConfig
)
}
func
TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_SQLite
(
t
*
testing
.
T
)
{
repo
,
client
:=
newAPIKeyRepoSQLite
(
t
)
ctx
:=
context
.
Background
()
user
:=
mustCreateAPIKeyRepoUser
(
t
,
ctx
,
client
,
"getbykey-auth-dispatch-unit@test.com"
)
group
,
err
:=
client
.
Group
.
Create
()
.
SetName
(
"g-auth-dispatch-unit"
)
.
SetPlatform
(
service
.
PlatformOpenAI
)
.
SetStatus
(
service
.
StatusActive
)
.
SetSubscriptionType
(
service
.
SubscriptionTypeStandard
)
.
SetRateMultiplier
(
1
)
.
SetAllowMessagesDispatch
(
true
)
.
SetDefaultMappedModel
(
"gpt-5.4"
)
.
SetMessagesDispatchModelConfig
(
service
.
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4-nano"
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
"gpt-5.4-mini"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4.5"
:
"gpt-5.4-nano"
,
},
})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
key
:=
&
service
.
APIKey
{
UserID
:
user
.
ID
,
Key
:
"sk-getbykey-auth-dispatch-unit"
,
Name
:
"Dispatch Key Unit"
,
GroupID
:
&
group
.
ID
,
Status
:
service
.
StatusActive
,
}
require
.
NoError
(
t
,
repo
.
Create
(
ctx
,
key
))
got
,
err
:=
repo
.
GetByKeyForAuth
(
ctx
,
key
.
Key
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
got
.
Group
)
require
.
Equal
(
t
,
group
.
MessagesDispatchModelConfig
,
got
.
Group
.
MessagesDispatchModelConfig
)
}
backend/internal/repository/api_key_repo_sort_integration_test.go
0 → 100644
View file @
1ef3782d
//go:build integration
package
repository
import
(
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func
(
s
*
APIKeyRepoSuite
)
TestListByUserID_SortByNameAsc
()
{
user
:=
s
.
mustCreateUser
(
"sort-name@example.com"
)
s
.
mustCreateApiKey
(
user
.
ID
,
"sk-z"
,
"z-key"
,
nil
)
s
.
mustCreateApiKey
(
user
.
ID
,
"sk-a"
,
"a-key"
,
nil
)
keys
,
_
,
err
:=
s
.
repo
.
ListByUserID
(
s
.
ctx
,
user
.
ID
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
,
SortBy
:
"name"
,
SortOrder
:
"asc"
,
},
service
.
APIKeyListFilters
{})
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
keys
,
2
)
s
.
Require
()
.
Equal
(
"a-key"
,
keys
[
0
]
.
Name
)
s
.
Require
()
.
Equal
(
"z-key"
,
keys
[
1
]
.
Name
)
}
backend/internal/repository/channel_repo.go
View file @
1ef3782d
...
@@ -188,8 +188,8 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
...
@@ -188,8 +188,8 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
// 查询 channel 列表
// 查询 channel 列表
dataQuery
:=
fmt
.
Sprintf
(
dataQuery
:=
fmt
.
Sprintf
(
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at
FROM channels c WHERE %s ORDER BY
c.id ASC
LIMIT $%d OFFSET $%d`
,
FROM channels c WHERE %s ORDER BY
%s
LIMIT $%d OFFSET $%d`
,
whereClause
,
argIdx
,
argIdx
+
1
,
whereClause
,
channelListOrderBy
(
params
),
argIdx
,
argIdx
+
1
,
)
)
args
=
append
(
args
,
pageSize
,
offset
)
args
=
append
(
args
,
pageSize
,
offset
)
...
@@ -246,6 +246,31 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
...
@@ -246,6 +246,31 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
return
channels
,
paginationResult
,
nil
return
channels
,
paginationResult
,
nil
}
}
func
channelListOrderBy
(
params
pagination
.
PaginationParams
)
string
{
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
strings
.
ToUpper
(
params
.
NormalizedSortOrder
(
pagination
.
SortOrderAsc
))
var
column
string
switch
sortBy
{
case
""
:
column
=
"c.id"
sortOrder
=
"ASC"
case
"id"
:
column
=
"c.id"
case
"name"
:
column
=
"c.name"
case
"status"
:
column
=
"c.status"
case
"created_at"
:
column
=
"c.created_at"
default
:
column
=
"c.id"
sortOrder
=
"ASC"
}
return
fmt
.
Sprintf
(
"%s %s, c.id %s"
,
column
,
sortOrder
,
sortOrder
)
}
func
(
r
*
channelRepository
)
ListAll
(
ctx
context
.
Context
)
([]
service
.
Channel
,
error
)
{
func
(
r
*
channelRepository
)
ListAll
(
ctx
context
.
Context
)
([]
service
.
Channel
,
error
)
{
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`
,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`
,
...
...
backend/internal/repository/channel_repo_test.go
View file @
1ef3782d
...
@@ -8,6 +8,7 @@ import (
...
@@ -8,6 +8,7 @@ import (
"fmt"
"fmt"
"testing"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/lib/pq"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
)
...
@@ -225,3 +226,12 @@ func TestIsUniqueViolation(t *testing.T) {
...
@@ -225,3 +226,12 @@ func TestIsUniqueViolation(t *testing.T) {
})
})
}
}
}
}
func
TestChannelListOrderBy_AllowsDescendingIDSort
(
t
*
testing
.
T
)
{
params
:=
pagination
.
PaginationParams
{
SortBy
:
"id"
,
SortOrder
:
"desc"
,
}
require
.
Equal
(
t
,
"c.id DESC, c.id DESC"
,
channelListOrderBy
(
params
))
}
backend/internal/repository/group_repo.go
View file @
1ef3782d
...
@@ -5,6 +5,7 @@ import (
...
@@ -5,6 +5,7 @@ import (
"database/sql"
"database/sql"
"errors"
"errors"
"fmt"
"fmt"
"sort"
"strings"
"strings"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
...
@@ -14,6 +15,8 @@ import (
...
@@ -14,6 +15,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
"github.com/lib/pq"
entsql
"entgo.io/ent/dialect/sql"
)
)
type
sqlExecutor
interface
{
type
sqlExecutor
interface
{
...
@@ -40,6 +43,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
...
@@ -40,6 +43,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetDescription
(
groupIn
.
Description
)
.
SetDescription
(
groupIn
.
Description
)
.
SetPlatform
(
groupIn
.
Platform
)
.
SetPlatform
(
groupIn
.
Platform
)
.
SetRateMultiplier
(
groupIn
.
RateMultiplier
)
.
SetRateMultiplier
(
groupIn
.
RateMultiplier
)
.
SetSortOrder
(
groupIn
.
SortOrder
)
.
SetIsExclusive
(
groupIn
.
IsExclusive
)
.
SetIsExclusive
(
groupIn
.
IsExclusive
)
.
SetStatus
(
groupIn
.
Status
)
.
SetStatus
(
groupIn
.
Status
)
.
SetSubscriptionType
(
groupIn
.
SubscriptionType
)
.
SetSubscriptionType
(
groupIn
.
SubscriptionType
)
.
...
@@ -233,11 +237,18 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
...
@@ -233,11 +237,18 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
groups
,
err
:=
q
.
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
params
.
SortBy
),
"account_count"
)
{
return
r
.
listWithAccountCountSort
(
ctx
,
q
,
params
,
total
)
}
groupsQuery
:=
q
.
Offset
(
params
.
Offset
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Limit
(
params
.
Limit
())
Order
(
dbent
.
Asc
(
group
.
FieldSortOrder
),
dbent
.
Asc
(
group
.
FieldID
))
.
for
_
,
order
:=
range
groupListOrder
(
params
)
{
All
(
ctx
)
groupsQuery
=
groupsQuery
.
Order
(
order
)
}
groups
,
err
:=
groupsQuery
.
All
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
...
@@ -263,6 +274,104 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
...
@@ -263,6 +274,104 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
return
outGroups
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
return
outGroups
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
}
func
(
r
*
groupRepository
)
listWithAccountCountSort
(
ctx
context
.
Context
,
q
*
dbent
.
GroupQuery
,
params
pagination
.
PaginationParams
,
total
int
)
([]
service
.
Group
,
*
pagination
.
PaginationResult
,
error
)
{
groups
,
err
:=
q
.
Order
(
dbent
.
Asc
(
group
.
FieldSortOrder
),
dbent
.
Asc
(
group
.
FieldID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
groupIDs
:=
make
([]
int64
,
0
,
len
(
groups
))
outGroups
:=
make
([]
service
.
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
g
:=
groupEntityToService
(
groups
[
i
])
outGroups
=
append
(
outGroups
,
*
g
)
groupIDs
=
append
(
groupIDs
,
g
.
ID
)
}
counts
,
err
:=
r
.
loadAccountCounts
(
ctx
,
groupIDs
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
for
i
:=
range
outGroups
{
c
:=
counts
[
outGroups
[
i
]
.
ID
]
outGroups
[
i
]
.
AccountCount
=
c
.
Total
outGroups
[
i
]
.
ActiveAccountCount
=
c
.
Active
outGroups
[
i
]
.
RateLimitedAccountCount
=
c
.
RateLimited
}
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderDesc
)
sort
.
SliceStable
(
outGroups
,
func
(
i
,
j
int
)
bool
{
if
outGroups
[
i
]
.
AccountCount
==
outGroups
[
j
]
.
AccountCount
{
if
outGroups
[
i
]
.
SortOrder
==
outGroups
[
j
]
.
SortOrder
{
return
outGroups
[
i
]
.
ID
<
outGroups
[
j
]
.
ID
}
return
outGroups
[
i
]
.
SortOrder
<
outGroups
[
j
]
.
SortOrder
}
if
sortOrder
==
pagination
.
SortOrderAsc
{
return
outGroups
[
i
]
.
AccountCount
<
outGroups
[
j
]
.
AccountCount
}
return
outGroups
[
i
]
.
AccountCount
>
outGroups
[
j
]
.
AccountCount
})
return
paginateSlice
(
outGroups
,
params
),
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
func
groupListOrder
(
params
pagination
.
PaginationParams
)
[]
func
(
*
entsql
.
Selector
)
{
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderAsc
)
var
field
string
tieField
:=
group
.
FieldID
defaultOrder
:=
true
switch
sortBy
{
case
""
,
"sort_order"
:
field
=
group
.
FieldSortOrder
case
"name"
:
field
=
group
.
FieldName
defaultOrder
=
false
case
"platform"
:
field
=
group
.
FieldPlatform
defaultOrder
=
false
case
"billing_type"
,
"subscription_type"
:
field
=
group
.
FieldSubscriptionType
defaultOrder
=
false
case
"rate_multiplier"
:
field
=
group
.
FieldRateMultiplier
defaultOrder
=
false
case
"is_exclusive"
:
field
=
group
.
FieldIsExclusive
defaultOrder
=
false
case
"status"
:
field
=
group
.
FieldStatus
defaultOrder
=
false
case
"created_at"
:
field
=
group
.
FieldCreatedAt
defaultOrder
=
false
case
"id"
:
field
=
group
.
FieldID
defaultOrder
=
false
tieField
=
""
default
:
field
=
group
.
FieldSortOrder
}
if
sortOrder
==
pagination
.
SortOrderDesc
&&
sortBy
!=
""
{
if
tieField
==
""
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
)}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
dbent
.
Desc
(
tieField
)}
}
if
defaultOrder
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
group
.
FieldSortOrder
),
dbent
.
Asc
(
group
.
FieldID
)}
}
if
tieField
==
""
{
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
)}
}
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Asc
(
field
),
dbent
.
Asc
(
tieField
)}
}
func
(
r
*
groupRepository
)
ListActive
(
ctx
context
.
Context
)
([]
service
.
Group
,
error
)
{
func
(
r
*
groupRepository
)
ListActive
(
ctx
context
.
Context
)
([]
service
.
Group
,
error
)
{
groups
,
err
:=
r
.
client
.
Group
.
Query
()
.
groups
,
err
:=
r
.
client
.
Group
.
Query
()
.
Where
(
group
.
StatusEQ
(
service
.
StatusActive
))
.
Where
(
group
.
StatusEQ
(
service
.
StatusActive
))
.
...
...
backend/internal/repository/group_repo_integration_test.go
View file @
1ef3782d
...
@@ -113,6 +113,33 @@ func (s *GroupRepoSuite) TestUpdate() {
...
@@ -113,6 +113,33 @@ func (s *GroupRepoSuite) TestUpdate() {
s
.
Require
()
.
Equal
(
"updated"
,
got
.
Name
)
s
.
Require
()
.
Equal
(
"updated"
,
got
.
Name
)
}
}
func
(
s
*
GroupRepoSuite
)
TestGetByID_PreservesMessagesDispatchModelConfig
()
{
group
:=
&
service
.
Group
{
Name
:
"openai-dispatch"
,
Platform
:
service
.
PlatformOpenAI
,
RateMultiplier
:
1.0
,
IsExclusive
:
false
,
Status
:
service
.
StatusActive
,
SubscriptionType
:
service
.
SubscriptionTypeStandard
,
AllowMessagesDispatch
:
true
,
DefaultMappedModel
:
"gpt-5.4"
,
MessagesDispatchModelConfig
:
service
.
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4"
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
"gpt-5.4-mini"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4.5"
:
"gpt-5.4-nano"
,
},
},
}
s
.
Require
()
.
NoError
(
s
.
repo
.
Create
(
s
.
ctx
,
group
))
got
,
err
:=
s
.
repo
.
GetByID
(
s
.
ctx
,
group
.
ID
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Equal
(
group
.
MessagesDispatchModelConfig
,
got
.
MessagesDispatchModelConfig
)
}
func
(
s
*
GroupRepoSuite
)
TestDelete
()
{
func
(
s
*
GroupRepoSuite
)
TestDelete
()
{
group
:=
&
service
.
Group
{
group
:=
&
service
.
Group
{
Name
:
"to-delete"
,
Name
:
"to-delete"
,
...
...
Prev
1
2
3
4
5
6
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