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
0170d19f
Commit
0170d19f
authored
Feb 02, 2026
by
song
Browse files
merge upstream main
parent
7ade9baa
Changes
319
Hide whitespace changes
Inline
Side-by-side
backend/internal/domain/announcement.go
0 → 100644
View file @
0170d19f
package
domain
import
(
"strings"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const
(
AnnouncementStatusDraft
=
"draft"
AnnouncementStatusActive
=
"active"
AnnouncementStatusArchived
=
"archived"
)
const
(
AnnouncementConditionTypeSubscription
=
"subscription"
AnnouncementConditionTypeBalance
=
"balance"
)
const
(
AnnouncementOperatorIn
=
"in"
AnnouncementOperatorGT
=
"gt"
AnnouncementOperatorGTE
=
"gte"
AnnouncementOperatorLT
=
"lt"
AnnouncementOperatorLTE
=
"lte"
AnnouncementOperatorEQ
=
"eq"
)
var
(
ErrAnnouncementNotFound
=
infraerrors
.
NotFound
(
"ANNOUNCEMENT_NOT_FOUND"
,
"announcement not found"
)
ErrAnnouncementInvalidTarget
=
infraerrors
.
BadRequest
(
"ANNOUNCEMENT_INVALID_TARGET"
,
"invalid announcement targeting rules"
)
)
type
AnnouncementTargeting
struct
{
// AnyOf 表示 OR:任意一个条件组满足即可展示。
AnyOf
[]
AnnouncementConditionGroup
`json:"any_of,omitempty"`
}
type
AnnouncementConditionGroup
struct
{
// AllOf 表示 AND:组内所有条件都满足才算命中该组。
AllOf
[]
AnnouncementCondition
`json:"all_of,omitempty"`
}
type
AnnouncementCondition
struct
{
// Type: subscription | balance
Type
string
`json:"type"`
// Operator:
// - subscription: in
// - balance: gt/gte/lt/lte/eq
Operator
string
`json:"operator"`
// subscription 条件:匹配的订阅套餐(group_id)
GroupIDs
[]
int64
`json:"group_ids,omitempty"`
// balance 条件:比较阈值
Value
float64
`json:"value,omitempty"`
}
func
(
t
AnnouncementTargeting
)
Matches
(
balance
float64
,
activeSubscriptionGroupIDs
map
[
int64
]
struct
{})
bool
{
// 空规则:展示给所有用户
if
len
(
t
.
AnyOf
)
==
0
{
return
true
}
for
_
,
group
:=
range
t
.
AnyOf
{
if
len
(
group
.
AllOf
)
==
0
{
// 空条件组不命中(避免 OR 中出现无条件 “全命中”)
continue
}
allMatched
:=
true
for
_
,
cond
:=
range
group
.
AllOf
{
if
!
cond
.
Matches
(
balance
,
activeSubscriptionGroupIDs
)
{
allMatched
=
false
break
}
}
if
allMatched
{
return
true
}
}
return
false
}
func
(
c
AnnouncementCondition
)
Matches
(
balance
float64
,
activeSubscriptionGroupIDs
map
[
int64
]
struct
{})
bool
{
switch
c
.
Type
{
case
AnnouncementConditionTypeSubscription
:
if
c
.
Operator
!=
AnnouncementOperatorIn
{
return
false
}
if
len
(
c
.
GroupIDs
)
==
0
{
return
false
}
if
len
(
activeSubscriptionGroupIDs
)
==
0
{
return
false
}
for
_
,
gid
:=
range
c
.
GroupIDs
{
if
_
,
ok
:=
activeSubscriptionGroupIDs
[
gid
];
ok
{
return
true
}
}
return
false
case
AnnouncementConditionTypeBalance
:
switch
c
.
Operator
{
case
AnnouncementOperatorGT
:
return
balance
>
c
.
Value
case
AnnouncementOperatorGTE
:
return
balance
>=
c
.
Value
case
AnnouncementOperatorLT
:
return
balance
<
c
.
Value
case
AnnouncementOperatorLTE
:
return
balance
<=
c
.
Value
case
AnnouncementOperatorEQ
:
return
balance
==
c
.
Value
default
:
return
false
}
default
:
return
false
}
}
func
(
t
AnnouncementTargeting
)
NormalizeAndValidate
()
(
AnnouncementTargeting
,
error
)
{
normalized
:=
AnnouncementTargeting
{
AnyOf
:
make
([]
AnnouncementConditionGroup
,
0
,
len
(
t
.
AnyOf
))}
// 允许空 targeting(展示给所有用户)
if
len
(
t
.
AnyOf
)
==
0
{
return
normalized
,
nil
}
if
len
(
t
.
AnyOf
)
>
50
{
return
AnnouncementTargeting
{},
ErrAnnouncementInvalidTarget
}
for
_
,
g
:=
range
t
.
AnyOf
{
if
len
(
g
.
AllOf
)
==
0
{
return
AnnouncementTargeting
{},
ErrAnnouncementInvalidTarget
}
if
len
(
g
.
AllOf
)
>
50
{
return
AnnouncementTargeting
{},
ErrAnnouncementInvalidTarget
}
group
:=
AnnouncementConditionGroup
{
AllOf
:
make
([]
AnnouncementCondition
,
0
,
len
(
g
.
AllOf
))}
for
_
,
c
:=
range
g
.
AllOf
{
cond
:=
AnnouncementCondition
{
Type
:
strings
.
TrimSpace
(
c
.
Type
),
Operator
:
strings
.
TrimSpace
(
c
.
Operator
),
Value
:
c
.
Value
,
}
for
_
,
gid
:=
range
c
.
GroupIDs
{
if
gid
<=
0
{
return
AnnouncementTargeting
{},
ErrAnnouncementInvalidTarget
}
cond
.
GroupIDs
=
append
(
cond
.
GroupIDs
,
gid
)
}
if
err
:=
cond
.
validate
();
err
!=
nil
{
return
AnnouncementTargeting
{},
err
}
group
.
AllOf
=
append
(
group
.
AllOf
,
cond
)
}
normalized
.
AnyOf
=
append
(
normalized
.
AnyOf
,
group
)
}
return
normalized
,
nil
}
func
(
c
AnnouncementCondition
)
validate
()
error
{
switch
c
.
Type
{
case
AnnouncementConditionTypeSubscription
:
if
c
.
Operator
!=
AnnouncementOperatorIn
{
return
ErrAnnouncementInvalidTarget
}
if
len
(
c
.
GroupIDs
)
==
0
{
return
ErrAnnouncementInvalidTarget
}
return
nil
case
AnnouncementConditionTypeBalance
:
switch
c
.
Operator
{
case
AnnouncementOperatorGT
,
AnnouncementOperatorGTE
,
AnnouncementOperatorLT
,
AnnouncementOperatorLTE
,
AnnouncementOperatorEQ
:
return
nil
default
:
return
ErrAnnouncementInvalidTarget
}
default
:
return
ErrAnnouncementInvalidTarget
}
}
type
Announcement
struct
{
ID
int64
Title
string
Content
string
Status
string
Targeting
AnnouncementTargeting
StartsAt
*
time
.
Time
EndsAt
*
time
.
Time
CreatedBy
*
int64
UpdatedBy
*
int64
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
}
func
(
a
*
Announcement
)
IsActiveAt
(
now
time
.
Time
)
bool
{
if
a
==
nil
{
return
false
}
if
a
.
Status
!=
AnnouncementStatusActive
{
return
false
}
if
a
.
StartsAt
!=
nil
&&
now
.
Before
(
*
a
.
StartsAt
)
{
return
false
}
if
a
.
EndsAt
!=
nil
&&
!
now
.
Before
(
*
a
.
EndsAt
)
{
// ends_at 语义:到点即下线
return
false
}
return
true
}
backend/internal/domain/constants.go
0 → 100644
View file @
0170d19f
package
domain
// Status constants
const
(
StatusActive
=
"active"
StatusDisabled
=
"disabled"
StatusError
=
"error"
StatusUnused
=
"unused"
StatusUsed
=
"used"
StatusExpired
=
"expired"
)
// Role constants
const
(
RoleAdmin
=
"admin"
RoleUser
=
"user"
)
// Platform constants
const
(
PlatformAnthropic
=
"anthropic"
PlatformOpenAI
=
"openai"
PlatformGemini
=
"gemini"
PlatformAntigravity
=
"antigravity"
)
// Account type constants
const
(
AccountTypeOAuth
=
"oauth"
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
"setup-token"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
)
// Redeem type constants
const
(
RedeemTypeBalance
=
"balance"
RedeemTypeConcurrency
=
"concurrency"
RedeemTypeSubscription
=
"subscription"
)
// PromoCode status constants
const
(
PromoCodeStatusActive
=
"active"
PromoCodeStatusDisabled
=
"disabled"
)
// Admin adjustment type constants
const
(
AdjustmentTypeAdminBalance
=
"admin_balance"
// 管理员调整余额
AdjustmentTypeAdminConcurrency
=
"admin_concurrency"
// 管理员调整并发数
)
// Group subscription type constants
const
(
SubscriptionTypeStandard
=
"standard"
// 标准计费模式(按余额扣费)
SubscriptionTypeSubscription
=
"subscription"
// 订阅模式(按限额控制)
)
// Subscription status constants
const
(
SubscriptionStatusActive
=
"active"
SubscriptionStatusExpired
=
"expired"
SubscriptionStatusSuspended
=
"suspended"
)
backend/internal/handler/admin/account_handler.go
View file @
0170d19f
...
...
@@ -45,6 +45,7 @@ type AccountHandler struct {
concurrencyService
*
service
.
ConcurrencyService
crsSyncService
*
service
.
CRSSyncService
sessionLimitCache
service
.
SessionLimitCache
tokenCacheInvalidator
service
.
TokenCacheInvalidator
}
// NewAccountHandler creates a new admin account handler
...
...
@@ -60,6 +61,7 @@ func NewAccountHandler(
concurrencyService
*
service
.
ConcurrencyService
,
crsSyncService
*
service
.
CRSSyncService
,
sessionLimitCache
service
.
SessionLimitCache
,
tokenCacheInvalidator
service
.
TokenCacheInvalidator
,
)
*
AccountHandler
{
return
&
AccountHandler
{
adminService
:
adminService
,
...
...
@@ -73,6 +75,7 @@ func NewAccountHandler(
concurrencyService
:
concurrencyService
,
crsSyncService
:
crsSyncService
,
sessionLimitCache
:
sessionLimitCache
,
tokenCacheInvalidator
:
tokenCacheInvalidator
,
}
}
...
...
@@ -129,13 +132,6 @@ type BulkUpdateAccountsRequest struct {
ConfirmMixedChannelRisk
*
bool
`json:"confirm_mixed_channel_risk"`
// 用户确认混合渠道风险
}
// AccountLookupRequest 用于凭证身份信息查找账号
type
AccountLookupRequest
struct
{
Platform
string
`json:"platform" binding:"required"`
Emails
[]
string
`json:"emails" binding:"required,min=1"`
IdentityType
string
`json:"identity_type"`
}
// AccountWithConcurrency extends Account with real-time concurrency info
type
AccountWithConcurrency
struct
{
*
dto
.
Account
...
...
@@ -180,6 +176,7 @@ func (h *AccountHandler) List(c *gin.Context) {
// 识别需要查询窗口费用和会话数的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
windowCostAccountIDs
:=
make
([]
int64
,
0
)
sessionLimitAccountIDs
:=
make
([]
int64
,
0
)
sessionIdleTimeouts
:=
make
(
map
[
int64
]
time
.
Duration
)
// 各账号的会话空闲超时配置
for
i
:=
range
accounts
{
acc
:=
&
accounts
[
i
]
if
acc
.
IsAnthropicOAuthOrSetupToken
()
{
...
...
@@ -188,6 +185,7 @@ func (h *AccountHandler) List(c *gin.Context) {
}
if
acc
.
GetMaxSessions
()
>
0
{
sessionLimitAccountIDs
=
append
(
sessionLimitAccountIDs
,
acc
.
ID
)
sessionIdleTimeouts
[
acc
.
ID
]
=
time
.
Duration
(
acc
.
GetSessionIdleTimeoutMinutes
())
*
time
.
Minute
}
}
}
...
...
@@ -196,9 +194,9 @@ func (h *AccountHandler) List(c *gin.Context) {
var
windowCosts
map
[
int64
]
float64
var
activeSessions
map
[
int64
]
int
// 获取活跃会话数(批量查询)
// 获取活跃会话数(批量查询
,传入各账号的 idleTimeout 配置
)
if
len
(
sessionLimitAccountIDs
)
>
0
&&
h
.
sessionLimitCache
!=
nil
{
activeSessions
,
_
=
h
.
sessionLimitCache
.
GetActiveSessionCountBatch
(
c
.
Request
.
Context
(),
sessionLimitAccountIDs
)
activeSessions
,
_
=
h
.
sessionLimitCache
.
GetActiveSessionCountBatch
(
c
.
Request
.
Context
(),
sessionLimitAccountIDs
,
sessionIdleTimeouts
)
if
activeSessions
==
nil
{
activeSessions
=
make
(
map
[
int64
]
int
)
}
...
...
@@ -218,12 +216,8 @@ func (h *AccountHandler) List(c *gin.Context) {
}
accCopy
:=
acc
// 闭包捕获
g
.
Go
(
func
()
error
{
var
startTime
time
.
Time
if
accCopy
.
SessionWindowStart
!=
nil
{
startTime
=
*
accCopy
.
SessionWindowStart
}
else
{
startTime
=
time
.
Now
()
.
Add
(
-
5
*
time
.
Hour
)
}
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
startTime
:=
accCopy
.
GetCurrentWindowStartTime
()
stats
,
err
:=
h
.
accountUsageService
.
GetAccountWindowStats
(
gctx
,
accCopy
.
ID
,
startTime
)
if
err
==
nil
&&
stats
!=
nil
{
mu
.
Lock
()
...
...
@@ -265,87 +259,6 @@ func (h *AccountHandler) List(c *gin.Context) {
response
.
Paginated
(
c
,
result
,
total
,
page
,
pageSize
)
}
// Lookup 根据凭证身份信息查找账号
// POST /api/v1/admin/accounts/lookup
func
(
h
*
AccountHandler
)
Lookup
(
c
*
gin
.
Context
)
{
var
req
AccountLookupRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
identityType
:=
strings
.
TrimSpace
(
req
.
IdentityType
)
if
identityType
==
""
{
identityType
=
"credential_email"
}
if
identityType
!=
"credential_email"
{
response
.
BadRequest
(
c
,
"Unsupported identity_type"
)
return
}
platform
:=
strings
.
TrimSpace
(
req
.
Platform
)
if
platform
==
""
{
response
.
BadRequest
(
c
,
"Platform is required"
)
return
}
normalized
:=
make
([]
string
,
0
,
len
(
req
.
Emails
))
seen
:=
make
(
map
[
string
]
struct
{})
for
_
,
email
:=
range
req
.
Emails
{
cleaned
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
if
cleaned
==
""
{
continue
}
if
_
,
ok
:=
seen
[
cleaned
];
ok
{
continue
}
seen
[
cleaned
]
=
struct
{}{}
normalized
=
append
(
normalized
,
cleaned
)
}
if
len
(
normalized
)
==
0
{
response
.
BadRequest
(
c
,
"Emails is required"
)
return
}
accounts
,
err
:=
h
.
adminService
.
LookupAccountsByCredentialEmail
(
c
.
Request
.
Context
(),
platform
,
normalized
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
matchedMap
:=
make
(
map
[
string
]
service
.
Account
)
for
_
,
account
:=
range
accounts
{
email
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
account
.
GetCredential
(
"email"
)))
if
email
==
""
{
continue
}
if
_
,
ok
:=
matchedMap
[
email
];
ok
{
continue
}
matchedMap
[
email
]
=
account
}
matched
:=
make
([]
gin
.
H
,
0
,
len
(
matchedMap
))
missing
:=
make
([]
string
,
0
)
for
_
,
email
:=
range
normalized
{
if
account
,
ok
:=
matchedMap
[
email
];
ok
{
matched
=
append
(
matched
,
gin
.
H
{
"email"
:
email
,
"account_id"
:
account
.
ID
,
"platform"
:
account
.
Platform
,
"name"
:
account
.
Name
,
})
continue
}
missing
=
append
(
missing
,
email
)
}
response
.
Success
(
c
,
gin
.
H
{
"matched"
:
matched
,
"missing"
:
missing
,
})
}
// GetByID handles getting an account by ID
// GET /api/v1/admin/accounts/:id
func
(
h
*
AccountHandler
)
GetByID
(
c
*
gin
.
Context
)
{
...
...
@@ -634,9 +547,18 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
}
}
// 如果 project_id 获取失败,先更新凭证,再标记账户为 error
// 特殊处理 project_id:如果新值为空但旧值非空,保留旧值
// 这确保了即使 LoadCodeAssist 失败,project_id 也不会丢失
if
newProjectID
,
_
:=
newCredentials
[
"project_id"
]
.
(
string
);
newProjectID
==
""
{
if
oldProjectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
));
oldProjectID
!=
""
{
newCredentials
[
"project_id"
]
=
oldProjectID
}
}
// 如果 project_id 获取失败,更新凭证但不标记为 error
// LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试
if
tokenInfo
.
ProjectIDMissing
{
// 先更新凭证
// 先更新凭证
(token 本身刷新成功了)
_
,
updateErr
:=
h
.
adminService
.
UpdateAccount
(
c
.
Request
.
Context
(),
accountID
,
&
service
.
UpdateAccountInput
{
Credentials
:
newCredentials
,
})
...
...
@@ -644,14 +566,10 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
response
.
InternalError
(
c
,
"Failed to update credentials: "
+
updateErr
.
Error
())
return
}
// 标记账户为 error
if
setErr
:=
h
.
adminService
.
SetAccountError
(
c
.
Request
.
Context
(),
accountID
,
"missing_project_id: 账户缺少project id,可能无法使用Antigravity"
);
setErr
!=
nil
{
response
.
InternalError
(
c
,
"Failed to set account error: "
+
setErr
.
Error
())
return
}
// 不标记为 error,只返回警告信息
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"Token refreshed but project_id
is missing, account marked as error
"
,
"warning"
:
"missing_project_id"
,
"message"
:
"Token refreshed
successfully,
but project_id
could not be retrieved (will retry automatically)
"
,
"warning"
:
"missing_project_id
_temporary
"
,
})
return
}
...
...
@@ -698,6 +616,14 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
return
}
// 刷新成功后,清除 token 缓存,确保下次请求使用新 token
if
h
.
tokenCacheInvalidator
!=
nil
{
if
invalidateErr
:=
h
.
tokenCacheInvalidator
.
InvalidateToken
(
c
.
Request
.
Context
(),
updatedAccount
);
invalidateErr
!=
nil
{
// 缓存失效失败只记录日志,不影响主流程
_
=
c
.
Error
(
invalidateErr
)
}
}
response
.
Success
(
c
,
dto
.
AccountFromService
(
updatedAccount
))
}
...
...
@@ -747,6 +673,15 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
return
}
// 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token(触发刷新或从 DB 读取)
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
if
h
.
tokenCacheInvalidator
!=
nil
&&
account
.
IsOAuth
()
{
if
invalidateErr
:=
h
.
tokenCacheInvalidator
.
InvalidateToken
(
c
.
Request
.
Context
(),
account
);
invalidateErr
!=
nil
{
// 缓存失效失败只记录日志,不影响主流程
_
=
c
.
Error
(
invalidateErr
)
}
}
response
.
Success
(
c
,
dto
.
AccountFromService
(
account
))
}
...
...
backend/internal/handler/admin/admin_basic_handlers_test.go
0 → 100644
View file @
0170d19f
package
admin
import
(
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
setupAdminRouter
()
(
*
gin
.
Engine
,
*
stubAdminService
)
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
adminSvc
:=
newStubAdminService
()
userHandler
:=
NewUserHandler
(
adminSvc
)
groupHandler
:=
NewGroupHandler
(
adminSvc
)
proxyHandler
:=
NewProxyHandler
(
adminSvc
)
redeemHandler
:=
NewRedeemHandler
(
adminSvc
)
router
.
GET
(
"/api/v1/admin/users"
,
userHandler
.
List
)
router
.
GET
(
"/api/v1/admin/users/:id"
,
userHandler
.
GetByID
)
router
.
POST
(
"/api/v1/admin/users"
,
userHandler
.
Create
)
router
.
PUT
(
"/api/v1/admin/users/:id"
,
userHandler
.
Update
)
router
.
DELETE
(
"/api/v1/admin/users/:id"
,
userHandler
.
Delete
)
router
.
POST
(
"/api/v1/admin/users/:id/balance"
,
userHandler
.
UpdateBalance
)
router
.
GET
(
"/api/v1/admin/users/:id/api-keys"
,
userHandler
.
GetUserAPIKeys
)
router
.
GET
(
"/api/v1/admin/users/:id/usage"
,
userHandler
.
GetUserUsage
)
router
.
GET
(
"/api/v1/admin/groups"
,
groupHandler
.
List
)
router
.
GET
(
"/api/v1/admin/groups/all"
,
groupHandler
.
GetAll
)
router
.
GET
(
"/api/v1/admin/groups/:id"
,
groupHandler
.
GetByID
)
router
.
POST
(
"/api/v1/admin/groups"
,
groupHandler
.
Create
)
router
.
PUT
(
"/api/v1/admin/groups/:id"
,
groupHandler
.
Update
)
router
.
DELETE
(
"/api/v1/admin/groups/:id"
,
groupHandler
.
Delete
)
router
.
GET
(
"/api/v1/admin/groups/:id/stats"
,
groupHandler
.
GetStats
)
router
.
GET
(
"/api/v1/admin/groups/:id/api-keys"
,
groupHandler
.
GetGroupAPIKeys
)
router
.
GET
(
"/api/v1/admin/proxies"
,
proxyHandler
.
List
)
router
.
GET
(
"/api/v1/admin/proxies/all"
,
proxyHandler
.
GetAll
)
router
.
GET
(
"/api/v1/admin/proxies/:id"
,
proxyHandler
.
GetByID
)
router
.
POST
(
"/api/v1/admin/proxies"
,
proxyHandler
.
Create
)
router
.
PUT
(
"/api/v1/admin/proxies/:id"
,
proxyHandler
.
Update
)
router
.
DELETE
(
"/api/v1/admin/proxies/:id"
,
proxyHandler
.
Delete
)
router
.
POST
(
"/api/v1/admin/proxies/batch-delete"
,
proxyHandler
.
BatchDelete
)
router
.
POST
(
"/api/v1/admin/proxies/:id/test"
,
proxyHandler
.
Test
)
router
.
GET
(
"/api/v1/admin/proxies/:id/stats"
,
proxyHandler
.
GetStats
)
router
.
GET
(
"/api/v1/admin/proxies/:id/accounts"
,
proxyHandler
.
GetProxyAccounts
)
router
.
GET
(
"/api/v1/admin/redeem-codes"
,
redeemHandler
.
List
)
router
.
GET
(
"/api/v1/admin/redeem-codes/:id"
,
redeemHandler
.
GetByID
)
router
.
POST
(
"/api/v1/admin/redeem-codes"
,
redeemHandler
.
Generate
)
router
.
DELETE
(
"/api/v1/admin/redeem-codes/:id"
,
redeemHandler
.
Delete
)
router
.
POST
(
"/api/v1/admin/redeem-codes/batch-delete"
,
redeemHandler
.
BatchDelete
)
router
.
POST
(
"/api/v1/admin/redeem-codes/:id/expire"
,
redeemHandler
.
Expire
)
router
.
GET
(
"/api/v1/admin/redeem-codes/:id/stats"
,
redeemHandler
.
GetStats
)
return
router
,
adminSvc
}
func
TestUserHandlerEndpoints
(
t
*
testing
.
T
)
{
router
,
_
:=
setupAdminRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users?page=1&page_size=20"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users/1"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
createBody
:=
map
[
string
]
any
{
"email"
:
"new@example.com"
,
"password"
:
"pass123"
,
"balance"
:
1
,
"concurrency"
:
2
}
body
,
_
:=
json
.
Marshal
(
createBody
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/users"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
updateBody
:=
map
[
string
]
any
{
"email"
:
"updated@example.com"
}
body
,
_
=
json
.
Marshal
(
updateBody
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/users/1"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodDelete
,
"/api/v1/admin/users/1"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/users/1/balance"
,
bytes
.
NewBufferString
(
`{"balance":1,"operation":"add"}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users/1/api-keys"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users/1/usage?period=today"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
func
TestGroupHandlerEndpoints
(
t
*
testing
.
T
)
{
router
,
_
:=
setupAdminRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/groups"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/groups/all"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/groups/2"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
body
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"name"
:
"new"
,
"platform"
:
"anthropic"
,
"subscription_type"
:
"standard"
})
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/groups"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
body
,
_
=
json
.
Marshal
(
map
[
string
]
any
{
"name"
:
"update"
})
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/groups/2"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodDelete
,
"/api/v1/admin/groups/2"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/groups/2/stats"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/groups/2/api-keys"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
func
TestProxyHandlerEndpoints
(
t
*
testing
.
T
)
{
router
,
_
:=
setupAdminRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/all"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/4"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
body
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"name"
:
"proxy"
,
"protocol"
:
"http"
,
"host"
:
"localhost"
,
"port"
:
8080
})
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/proxies"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
body
,
_
=
json
.
Marshal
(
map
[
string
]
any
{
"name"
:
"proxy2"
})
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/proxies/4"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodDelete
,
"/api/v1/admin/proxies/4"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/proxies/batch-delete"
,
bytes
.
NewBufferString
(
`{"ids":[1,2]}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/proxies/4/test"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/4/stats"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/4/accounts"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
func
TestRedeemHandlerEndpoints
(
t
*
testing
.
T
)
{
router
,
_
:=
setupAdminRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/redeem-codes"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/redeem-codes/5"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
body
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"count"
:
1
,
"type"
:
"balance"
,
"value"
:
10
})
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/redeem-codes"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodDelete
,
"/api/v1/admin/redeem-codes/5"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/redeem-codes/batch-delete"
,
bytes
.
NewBufferString
(
`{"ids":[1,2]}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/redeem-codes/5/expire"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
rec
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/redeem-codes/5/stats"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
backend/internal/handler/admin/admin_helpers_test.go
0 → 100644
View file @
0170d19f
package
admin
import
(
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestParseTimeRange
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?start_date=2024-01-01&end_date=2024-01-02&timezone=UTC"
,
nil
)
c
.
Request
=
req
start
,
end
:=
parseTimeRange
(
c
)
require
.
Equal
(
t
,
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
),
start
)
require
.
Equal
(
t
,
time
.
Date
(
2024
,
1
,
3
,
0
,
0
,
0
,
0
,
time
.
UTC
),
end
)
req
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?start_date=bad&timezone=UTC"
,
nil
)
c
.
Request
=
req
start
,
end
=
parseTimeRange
(
c
)
require
.
False
(
t
,
start
.
IsZero
())
require
.
False
(
t
,
end
.
IsZero
())
}
func
TestParseOpsViewParam
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?view=excluded"
,
nil
)
require
.
Equal
(
t
,
opsListViewExcluded
,
parseOpsViewParam
(
c
))
c2
,
_
:=
gin
.
CreateTestContext
(
w
)
c2
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?view=all"
,
nil
)
require
.
Equal
(
t
,
opsListViewAll
,
parseOpsViewParam
(
c2
))
c3
,
_
:=
gin
.
CreateTestContext
(
w
)
c3
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?view=unknown"
,
nil
)
require
.
Equal
(
t
,
opsListViewErrors
,
parseOpsViewParam
(
c3
))
require
.
Equal
(
t
,
""
,
parseOpsViewParam
(
nil
))
}
func
TestParseOpsDuration
(
t
*
testing
.
T
)
{
dur
,
ok
:=
parseOpsDuration
(
"1h"
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
time
.
Hour
,
dur
)
_
,
ok
=
parseOpsDuration
(
"invalid"
)
require
.
False
(
t
,
ok
)
}
func
TestParseOpsTimeRange
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
now
:=
time
.
Now
()
.
UTC
()
startStr
:=
now
.
Add
(
-
time
.
Hour
)
.
Format
(
time
.
RFC3339
)
endStr
:=
now
.
Format
(
time
.
RFC3339
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?start_time="
+
startStr
+
"&end_time="
+
endStr
,
nil
)
start
,
end
,
err
:=
parseOpsTimeRange
(
c
,
"1h"
)
require
.
NoError
(
t
,
err
)
require
.
True
(
t
,
start
.
Before
(
end
))
c2
,
_
:=
gin
.
CreateTestContext
(
w
)
c2
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?start_time=bad"
,
nil
)
_
,
_
,
err
=
parseOpsTimeRange
(
c2
,
"1h"
)
require
.
Error
(
t
,
err
)
}
func
TestParseOpsRealtimeWindow
(
t
*
testing
.
T
)
{
dur
,
label
,
ok
:=
parseOpsRealtimeWindow
(
"5m"
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
5
*
time
.
Minute
,
dur
)
require
.
Equal
(
t
,
"5min"
,
label
)
_
,
_
,
ok
=
parseOpsRealtimeWindow
(
"invalid"
)
require
.
False
(
t
,
ok
)
}
func
TestPickThroughputBucketSeconds
(
t
*
testing
.
T
)
{
require
.
Equal
(
t
,
60
,
pickThroughputBucketSeconds
(
30
*
time
.
Minute
))
require
.
Equal
(
t
,
300
,
pickThroughputBucketSeconds
(
6
*
time
.
Hour
))
require
.
Equal
(
t
,
3600
,
pickThroughputBucketSeconds
(
48
*
time
.
Hour
))
}
func
TestParseOpsQueryMode
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/?mode=raw"
,
nil
)
require
.
Equal
(
t
,
service
.
ParseOpsQueryMode
(
"raw"
),
parseOpsQueryMode
(
c
))
require
.
Equal
(
t
,
service
.
OpsQueryMode
(
""
),
parseOpsQueryMode
(
nil
))
}
func
TestOpsAlertRuleValidation
(
t
*
testing
.
T
)
{
raw
:=
map
[
string
]
json
.
RawMessage
{
"name"
:
json
.
RawMessage
(
`"High error rate"`
),
"metric_type"
:
json
.
RawMessage
(
`"error_rate"`
),
"operator"
:
json
.
RawMessage
(
`">"`
),
"threshold"
:
json
.
RawMessage
(
`90`
),
}
validated
,
err
:=
validateOpsAlertRulePayload
(
raw
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"High error rate"
,
validated
.
Name
)
_
,
err
=
validateOpsAlertRulePayload
(
map
[
string
]
json
.
RawMessage
{})
require
.
Error
(
t
,
err
)
require
.
True
(
t
,
isPercentOrRateMetric
(
"error_rate"
))
require
.
False
(
t
,
isPercentOrRateMetric
(
"concurrency_queue_depth"
))
}
func
TestOpsWSHelpers
(
t
*
testing
.
T
)
{
prefixes
,
invalid
:=
parseTrustedProxyList
(
"10.0.0.0/8,invalid"
)
require
.
Len
(
t
,
prefixes
,
1
)
require
.
Len
(
t
,
invalid
,
1
)
host
:=
hostWithoutPort
(
"example.com:443"
)
require
.
Equal
(
t
,
"example.com"
,
host
)
addr
:=
netip
.
MustParseAddr
(
"10.0.0.1"
)
require
.
True
(
t
,
isAddrInTrustedProxies
(
addr
,
prefixes
))
require
.
False
(
t
,
isAddrInTrustedProxies
(
netip
.
MustParseAddr
(
"192.168.0.1"
),
prefixes
))
}
backend/internal/handler/admin/admin_service_stub_test.go
0 → 100644
View file @
0170d19f
package
admin
import
(
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
stubAdminService
struct
{
users
[]
service
.
User
apiKeys
[]
service
.
APIKey
groups
[]
service
.
Group
accounts
[]
service
.
Account
proxies
[]
service
.
Proxy
proxyCounts
[]
service
.
ProxyWithAccountCount
redeems
[]
service
.
RedeemCode
}
func
newStubAdminService
()
*
stubAdminService
{
now
:=
time
.
Now
()
.
UTC
()
user
:=
service
.
User
{
ID
:
1
,
Email
:
"user@example.com"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
apiKey
:=
service
.
APIKey
{
ID
:
10
,
UserID
:
user
.
ID
,
Key
:
"sk-test"
,
Name
:
"test"
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
group
:=
service
.
Group
{
ID
:
2
,
Name
:
"group"
,
Platform
:
service
.
PlatformAnthropic
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
account
:=
service
.
Account
{
ID
:
3
,
Name
:
"account"
,
Platform
:
service
.
PlatformAnthropic
,
Type
:
service
.
AccountTypeOAuth
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
proxy
:=
service
.
Proxy
{
ID
:
4
,
Name
:
"proxy"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
redeem
:=
service
.
RedeemCode
{
ID
:
5
,
Code
:
"R-TEST"
,
Type
:
service
.
RedeemTypeBalance
,
Value
:
10
,
Status
:
service
.
StatusUnused
,
CreatedAt
:
now
,
}
return
&
stubAdminService
{
users
:
[]
service
.
User
{
user
},
apiKeys
:
[]
service
.
APIKey
{
apiKey
},
groups
:
[]
service
.
Group
{
group
},
accounts
:
[]
service
.
Account
{
account
},
proxies
:
[]
service
.
Proxy
{
proxy
},
proxyCounts
:
[]
service
.
ProxyWithAccountCount
{{
Proxy
:
proxy
,
AccountCount
:
1
}},
redeems
:
[]
service
.
RedeemCode
{
redeem
},
}
}
func
(
s
*
stubAdminService
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
filters
service
.
UserListFilters
)
([]
service
.
User
,
int64
,
error
)
{
return
s
.
users
,
int64
(
len
(
s
.
users
)),
nil
}
func
(
s
*
stubAdminService
)
GetUser
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
User
,
error
)
{
for
i
:=
range
s
.
users
{
if
s
.
users
[
i
]
.
ID
==
id
{
return
&
s
.
users
[
i
],
nil
}
}
user
:=
service
.
User
{
ID
:
id
,
Email
:
"user@example.com"
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
CreateUser
(
ctx
context
.
Context
,
input
*
service
.
CreateUserInput
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
100
,
Email
:
input
.
Email
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
UpdateUser
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateUserInput
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
id
,
Email
:
"updated@example.com"
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
DeleteUser
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
UpdateUserBalance
(
ctx
context
.
Context
,
userID
int64
,
balance
float64
,
operation
string
,
notes
string
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
userID
,
Balance
:
balance
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
GetUserAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
)
([]
service
.
APIKey
,
int64
,
error
)
{
return
s
.
apiKeys
,
int64
(
len
(
s
.
apiKeys
)),
nil
}
func
(
s
*
stubAdminService
)
GetUserUsageStats
(
ctx
context
.
Context
,
userID
int64
,
period
string
)
(
any
,
error
)
{
return
map
[
string
]
any
{
"user_id"
:
userID
},
nil
}
func
(
s
*
stubAdminService
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
service
.
Group
,
int64
,
error
)
{
return
s
.
groups
,
int64
(
len
(
s
.
groups
)),
nil
}
func
(
s
*
stubAdminService
)
GetAllGroups
(
ctx
context
.
Context
)
([]
service
.
Group
,
error
)
{
return
s
.
groups
,
nil
}
func
(
s
*
stubAdminService
)
GetAllGroupsByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
service
.
Group
,
error
)
{
return
s
.
groups
,
nil
}
func
(
s
*
stubAdminService
)
GetGroup
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
id
,
Name
:
"group"
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
CreateGroup
(
ctx
context
.
Context
,
input
*
service
.
CreateGroupInput
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
200
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
UpdateGroup
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateGroupInput
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
DeleteGroup
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
GetGroupAPIKeys
(
ctx
context
.
Context
,
groupID
int64
,
page
,
pageSize
int
)
([]
service
.
APIKey
,
int64
,
error
)
{
return
s
.
apiKeys
,
int64
(
len
(
s
.
apiKeys
)),
nil
}
func
(
s
*
stubAdminService
)
ListAccounts
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
int64
,
error
)
{
return
s
.
accounts
,
int64
(
len
(
s
.
accounts
)),
nil
}
func
(
s
*
stubAdminService
)
GetAccount
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
GetAccountsByIDs
(
ctx
context
.
Context
,
ids
[]
int64
)
([]
*
service
.
Account
,
error
)
{
out
:=
make
([]
*
service
.
Account
,
0
,
len
(
ids
))
for
_
,
id
:=
range
ids
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
out
=
append
(
out
,
&
account
)
}
return
out
,
nil
}
func
(
s
*
stubAdminService
)
CreateAccount
(
ctx
context
.
Context
,
input
*
service
.
CreateAccountInput
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
300
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
UpdateAccount
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateAccountInput
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
DeleteAccount
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
RefreshAccountCredentials
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
ClearAccountError
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
SetAccountError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
SetAccountSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
,
Schedulable
:
schedulable
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
service
.
BulkUpdateAccountsInput
)
(
*
service
.
BulkUpdateAccountsResult
,
error
)
{
return
&
service
.
BulkUpdateAccountsResult
{
Success
:
1
,
Failed
:
0
,
SuccessIDs
:
[]
int64
{
1
}},
nil
}
func
(
s
*
stubAdminService
)
ListProxies
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
int64
,
error
)
{
return
s
.
proxies
,
int64
(
len
(
s
.
proxies
)),
nil
}
func
(
s
*
stubAdminService
)
ListProxiesWithAccountCount
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
ProxyWithAccountCount
,
int64
,
error
)
{
return
s
.
proxyCounts
,
int64
(
len
(
s
.
proxyCounts
)),
nil
}
func
(
s
*
stubAdminService
)
GetAllProxies
(
ctx
context
.
Context
)
([]
service
.
Proxy
,
error
)
{
return
s
.
proxies
,
nil
}
func
(
s
*
stubAdminService
)
GetAllProxiesWithAccountCount
(
ctx
context
.
Context
)
([]
service
.
ProxyWithAccountCount
,
error
)
{
return
s
.
proxyCounts
,
nil
}
func
(
s
*
stubAdminService
)
GetProxy
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
id
,
Name
:
"proxy"
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
CreateProxy
(
ctx
context
.
Context
,
input
*
service
.
CreateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
400
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
UpdateProxy
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
DeleteProxy
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
BatchDeleteProxies
(
ctx
context
.
Context
,
ids
[]
int64
)
(
*
service
.
ProxyBatchDeleteResult
,
error
)
{
return
&
service
.
ProxyBatchDeleteResult
{
DeletedIDs
:
ids
},
nil
}
func
(
s
*
stubAdminService
)
GetProxyAccounts
(
ctx
context
.
Context
,
proxyID
int64
)
([]
service
.
ProxyAccountSummary
,
error
)
{
return
[]
service
.
ProxyAccountSummary
{{
ID
:
1
,
Name
:
"account"
}},
nil
}
func
(
s
*
stubAdminService
)
CheckProxyExists
(
ctx
context
.
Context
,
host
string
,
port
int
,
username
,
password
string
)
(
bool
,
error
)
{
return
false
,
nil
}
func
(
s
*
stubAdminService
)
TestProxy
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
ProxyTestResult
,
error
)
{
return
&
service
.
ProxyTestResult
{
Success
:
true
,
Message
:
"ok"
},
nil
}
func
(
s
*
stubAdminService
)
ListRedeemCodes
(
ctx
context
.
Context
,
page
,
pageSize
int
,
codeType
,
status
,
search
string
)
([]
service
.
RedeemCode
,
int64
,
error
)
{
return
s
.
redeems
,
int64
(
len
(
s
.
redeems
)),
nil
}
func
(
s
*
stubAdminService
)
GetRedeemCode
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
RedeemCode
,
error
)
{
code
:=
service
.
RedeemCode
{
ID
:
id
,
Code
:
"R-TEST"
,
Status
:
service
.
StatusUnused
}
return
&
code
,
nil
}
func
(
s
*
stubAdminService
)
GenerateRedeemCodes
(
ctx
context
.
Context
,
input
*
service
.
GenerateRedeemCodesInput
)
([]
service
.
RedeemCode
,
error
)
{
return
s
.
redeems
,
nil
}
func
(
s
*
stubAdminService
)
DeleteRedeemCode
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
BatchDeleteRedeemCodes
(
ctx
context
.
Context
,
ids
[]
int64
)
(
int64
,
error
)
{
return
int64
(
len
(
ids
)),
nil
}
func
(
s
*
stubAdminService
)
ExpireRedeemCode
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
RedeemCode
,
error
)
{
code
:=
service
.
RedeemCode
{
ID
:
id
,
Code
:
"R-TEST"
,
Status
:
service
.
StatusUsed
}
return
&
code
,
nil
}
// Ensure stub implements interface.
var
_
service
.
AdminService
=
(
*
stubAdminService
)(
nil
)
backend/internal/handler/admin/announcement_handler.go
0 → 100644
View file @
0170d19f
package
admin
import
(
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementHandler handles admin announcement management
type
AnnouncementHandler
struct
{
announcementService
*
service
.
AnnouncementService
}
// NewAnnouncementHandler creates a new admin announcement handler
func
NewAnnouncementHandler
(
announcementService
*
service
.
AnnouncementService
)
*
AnnouncementHandler
{
return
&
AnnouncementHandler
{
announcementService
:
announcementService
,
}
}
type
CreateAnnouncementRequest
struct
{
Title
string
`json:"title" binding:"required"`
Content
string
`json:"content" binding:"required"`
Status
string
`json:"status" binding:"omitempty,oneof=draft active archived"`
Targeting
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
int64
`json:"starts_at"`
// Unix seconds, 0/empty = immediate
EndsAt
*
int64
`json:"ends_at"`
// Unix seconds, 0/empty = never
}
type
UpdateAnnouncementRequest
struct
{
Title
*
string
`json:"title"`
Content
*
string
`json:"content"`
Status
*
string
`json:"status" binding:"omitempty,oneof=draft active archived"`
Targeting
*
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
int64
`json:"starts_at"`
// Unix seconds, 0 = clear
EndsAt
*
int64
`json:"ends_at"`
// Unix seconds, 0 = clear
}
// List handles listing announcements with filters
// GET /api/v1/admin/announcements
func
(
h
*
AnnouncementHandler
)
List
(
c
*
gin
.
Context
)
{
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
status
:=
strings
.
TrimSpace
(
c
.
Query
(
"status"
))
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
200
{
search
=
search
[
:
200
]
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
}
items
,
paginationResult
,
err
:=
h
.
announcementService
.
List
(
c
.
Request
.
Context
(),
params
,
service
.
AnnouncementListFilters
{
Status
:
status
,
Search
:
search
},
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
Announcement
,
0
,
len
(
items
))
for
i
:=
range
items
{
out
=
append
(
out
,
*
dto
.
AnnouncementFromService
(
&
items
[
i
]))
}
response
.
Paginated
(
c
,
out
,
paginationResult
.
Total
,
page
,
pageSize
)
}
// GetByID handles getting an announcement by ID
// GET /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
GetByID
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
item
,
err
:=
h
.
announcementService
.
GetByID
(
c
.
Request
.
Context
(),
announcementID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
item
))
}
// Create handles creating a new announcement
// POST /api/v1/admin/announcements
func
(
h
*
AnnouncementHandler
)
Create
(
c
*
gin
.
Context
)
{
var
req
CreateAnnouncementRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
input
:=
&
service
.
CreateAnnouncementInput
{
Title
:
req
.
Title
,
Content
:
req
.
Content
,
Status
:
req
.
Status
,
Targeting
:
req
.
Targeting
,
ActorID
:
&
subject
.
UserID
,
}
if
req
.
StartsAt
!=
nil
&&
*
req
.
StartsAt
>
0
{
t
:=
time
.
Unix
(
*
req
.
StartsAt
,
0
)
input
.
StartsAt
=
&
t
}
if
req
.
EndsAt
!=
nil
&&
*
req
.
EndsAt
>
0
{
t
:=
time
.
Unix
(
*
req
.
EndsAt
,
0
)
input
.
EndsAt
=
&
t
}
created
,
err
:=
h
.
announcementService
.
Create
(
c
.
Request
.
Context
(),
input
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
created
))
}
// Update handles updating an announcement
// PUT /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
Update
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
var
req
UpdateAnnouncementRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
input
:=
&
service
.
UpdateAnnouncementInput
{
Title
:
req
.
Title
,
Content
:
req
.
Content
,
Status
:
req
.
Status
,
Targeting
:
req
.
Targeting
,
ActorID
:
&
subject
.
UserID
,
}
if
req
.
StartsAt
!=
nil
{
if
*
req
.
StartsAt
==
0
{
var
cleared
*
time
.
Time
=
nil
input
.
StartsAt
=
&
cleared
}
else
{
t
:=
time
.
Unix
(
*
req
.
StartsAt
,
0
)
ptr
:=
&
t
input
.
StartsAt
=
&
ptr
}
}
if
req
.
EndsAt
!=
nil
{
if
*
req
.
EndsAt
==
0
{
var
cleared
*
time
.
Time
=
nil
input
.
EndsAt
=
&
cleared
}
else
{
t
:=
time
.
Unix
(
*
req
.
EndsAt
,
0
)
ptr
:=
&
t
input
.
EndsAt
=
&
ptr
}
}
updated
,
err
:=
h
.
announcementService
.
Update
(
c
.
Request
.
Context
(),
announcementID
,
input
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
updated
))
}
// Delete handles deleting an announcement
// DELETE /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
Delete
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
if
err
:=
h
.
announcementService
.
Delete
(
c
.
Request
.
Context
(),
announcementID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"Announcement deleted successfully"
})
}
// ListReadStatus handles listing users read status for an announcement
// GET /api/v1/admin/announcements/:id/read-status
func
(
h
*
AnnouncementHandler
)
ListReadStatus
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
}
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
200
{
search
=
search
[
:
200
]
}
items
,
paginationResult
,
err
:=
h
.
announcementService
.
ListUserReadStatus
(
c
.
Request
.
Context
(),
announcementID
,
params
,
search
,
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
items
,
paginationResult
.
Total
,
page
,
pageSize
)
}
backend/internal/handler/admin/dashboard_handler.go
View file @
0170d19f
...
...
@@ -186,7 +186,7 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
// GetUsageTrend handles getting usage trend data
// GET /api/v1/admin/dashboard/trend
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, stream
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, stream
, billing_type
func
(
h
*
DashboardHandler
)
GetUsageTrend
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
granularity
:=
c
.
DefaultQuery
(
"granularity"
,
"day"
)
...
...
@@ -195,6 +195,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
model
string
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
...
@@ -224,8 +225,17 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
stream
=
&
streamVal
}
}
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
if
v
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
);
err
==
nil
{
bt
:=
int8
(
v
)
billingType
=
&
bt
}
else
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
}
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
)
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
,
billingType
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get usage trend"
)
return
...
...
@@ -241,13 +251,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
// GetModelStats handles getting model usage statistics
// GET /api/v1/admin/dashboard/models
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, stream
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, stream
, billing_type
func
(
h
*
DashboardHandler
)
GetModelStats
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
// Parse optional filter params
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
...
@@ -274,8 +285,17 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
stream
=
&
streamVal
}
}
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
if
v
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
);
err
==
nil
{
bt
:=
int8
(
v
)
billingType
=
&
bt
}
else
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
}
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
,
accountID
,
groupID
,
stream
)
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
,
accountID
,
groupID
,
stream
,
billingType
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get model statistics"
)
return
...
...
backend/internal/handler/admin/group_handler.go
View file @
0170d19f
...
...
@@ -98,9 +98,9 @@ func (h *GroupHandler) List(c *gin.Context) {
return
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
(
&
groups
[
i
]))
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
Admin
(
&
groups
[
i
]))
}
response
.
Paginated
(
c
,
outGroups
,
total
,
page
,
pageSize
)
}
...
...
@@ -124,9 +124,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
(
&
groups
[
i
]))
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
Admin
(
&
groups
[
i
]))
}
response
.
Success
(
c
,
outGroups
)
}
...
...
@@ -146,7 +146,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
// Create handles creating a new group
...
...
@@ -183,7 +183,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
// Update handles updating a group
...
...
@@ -227,7 +227,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
// Delete handles deleting a group
...
...
backend/internal/handler/admin/redeem_handler.go
View file @
0170d19f
...
...
@@ -54,9 +54,9 @@ func (h *RedeemHandler) List(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
(
&
codes
[
i
]))
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
Admin
(
&
codes
[
i
]))
}
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
}
...
...
@@ -76,7 +76,7 @@ func (h *RedeemHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
// Generate handles generating new redeem codes
...
...
@@ -100,9 +100,9 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
(
&
codes
[
i
]))
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
Admin
(
&
codes
[
i
]))
}
response
.
Success
(
c
,
out
)
}
...
...
@@ -163,7 +163,7 @@ func (h *RedeemHandler) Expire(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
// GetStats handles getting redeem code statistics
...
...
backend/internal/handler/admin/setting_handler.go
View file @
0170d19f
...
...
@@ -47,6 +47,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
settings
.
SMTPHost
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPUsername
:
settings
.
SMTPUsername
,
...
...
@@ -68,6 +72,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
...
...
@@ -87,8 +94,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求
type
UpdateSettingsRequest
struct
{
// 注册设置
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
// 邮件服务设置
SMTPHost
string
`json:"smtp_host"`
...
...
@@ -111,13 +121,16 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// OEM设置
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
...
...
@@ -194,6 +207,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// TOTP 双因素认证参数验证
// 只有手动配置了加密密钥才允许启用 TOTP 功能
if
req
.
TotpEnabled
&&
!
previousSettings
.
TotpEnabled
{
// 尝试启用 TOTP,检查加密密钥是否已手动配置
if
!
h
.
settingService
.
IsTotpEncryptionKeyConfigured
()
{
response
.
BadRequest
(
c
,
"Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment."
)
return
}
}
// LinuxDo Connect 参数验证
if
req
.
LinuxDoConnectEnabled
{
req
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
req
.
LinuxDoConnectClientID
)
...
...
@@ -223,6 +246,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// “购买订阅”页面配置验证
purchaseEnabled
:=
previousSettings
.
PurchaseSubscriptionEnabled
if
req
.
PurchaseSubscriptionEnabled
!=
nil
{
purchaseEnabled
=
*
req
.
PurchaseSubscriptionEnabled
}
purchaseURL
:=
previousSettings
.
PurchaseSubscriptionURL
if
req
.
PurchaseSubscriptionURL
!=
nil
{
purchaseURL
=
strings
.
TrimSpace
(
*
req
.
PurchaseSubscriptionURL
)
}
// - 启用时要求 URL 合法且非空
// - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置
if
purchaseEnabled
{
if
purchaseURL
==
""
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL is required when enabled"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
purchaseURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL must be an absolute http(s) URL"
)
return
}
}
else
if
purchaseURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
purchaseURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL must be an absolute http(s) URL"
)
return
}
}
// Ops metrics collector interval validation (seconds).
if
req
.
OpsMetricsIntervalSeconds
!=
nil
{
v
:=
*
req
.
OpsMetricsIntervalSeconds
...
...
@@ -236,38 +287,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
settings
:=
&
service
.
SystemSettings
{
RegistrationEnabled
:
req
.
RegistrationEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
SMTPHost
:
req
.
SMTPHost
,
SMTPPort
:
req
.
SMTPPort
,
SMTPUsername
:
req
.
SMTPUsername
,
SMTPPassword
:
req
.
SMTPPassword
,
SMTPFrom
:
req
.
SMTPFrom
,
SMTPFromName
:
req
.
SMTPFromName
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
SiteName
:
req
.
SiteName
,
SiteLogo
:
req
.
SiteLogo
,
SiteSubtitle
:
req
.
SiteSubtitle
,
APIBaseURL
:
req
.
APIBaseURL
,
ContactInfo
:
req
.
ContactInfo
,
DocURL
:
req
.
DocURL
,
HomeContent
:
req
.
HomeContent
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
RegistrationEnabled
:
req
.
RegistrationEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
req
.
PromoCodeEnabled
,
PasswordResetEnabled
:
req
.
PasswordResetEnabled
,
TotpEnabled
:
req
.
TotpEnabled
,
SMTPHost
:
req
.
SMTPHost
,
SMTPPort
:
req
.
SMTPPort
,
SMTPUsername
:
req
.
SMTPUsername
,
SMTPPassword
:
req
.
SMTPPassword
,
SMTPFrom
:
req
.
SMTPFrom
,
SMTPFromName
:
req
.
SMTPFromName
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
SiteName
:
req
.
SiteName
,
SiteLogo
:
req
.
SiteLogo
,
SiteSubtitle
:
req
.
SiteSubtitle
,
APIBaseURL
:
req
.
APIBaseURL
,
ContactInfo
:
req
.
ContactInfo
,
DocURL
:
req
.
DocURL
,
HomeContent
:
req
.
HomeContent
,
HideCcsImportButton
:
req
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
OpsMonitoringEnabled
:
func
()
bool
{
if
req
.
OpsMonitoringEnabled
!=
nil
{
return
*
req
.
OpsMonitoringEnabled
...
...
@@ -311,6 +368,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
updatedSettings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
updatedSettings
.
PasswordResetEnabled
,
TotpEnabled
:
updatedSettings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
...
...
@@ -332,6 +393,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ContactInfo
:
updatedSettings
.
ContactInfo
,
DocURL
:
updatedSettings
.
DocURL
,
HomeContent
:
updatedSettings
.
HomeContent
,
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
...
...
@@ -376,6 +440,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
EmailVerifyEnabled
!=
after
.
EmailVerifyEnabled
{
changed
=
append
(
changed
,
"email_verify_enabled"
)
}
if
before
.
PasswordResetEnabled
!=
after
.
PasswordResetEnabled
{
changed
=
append
(
changed
,
"password_reset_enabled"
)
}
if
before
.
TotpEnabled
!=
after
.
TotpEnabled
{
changed
=
append
(
changed
,
"totp_enabled"
)
}
if
before
.
SMTPHost
!=
after
.
SMTPHost
{
changed
=
append
(
changed
,
"smtp_host"
)
}
...
...
@@ -439,6 +509,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
HomeContent
!=
after
.
HomeContent
{
changed
=
append
(
changed
,
"home_content"
)
}
if
before
.
HideCcsImportButton
!=
after
.
HideCcsImportButton
{
changed
=
append
(
changed
,
"hide_ccs_import_button"
)
}
if
before
.
DefaultConcurrency
!=
after
.
DefaultConcurrency
{
changed
=
append
(
changed
,
"default_concurrency"
)
}
...
...
backend/internal/handler/admin/subscription_handler.go
View file @
0170d19f
...
...
@@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct {
Notes
string
`json:"notes"`
}
//
Extend
SubscriptionRequest represents
extend
subscription request
type
Extend
SubscriptionRequest
struct
{
Days
int
`json:"days" binding:"required,min=
1
,max=36500"`
//
max 100 years
//
Adjust
SubscriptionRequest represents
adjust
subscription request
(extend or shorten)
type
Adjust
SubscriptionRequest
struct
{
Days
int
`json:"days" binding:"required,min=
-36500
,max=36500"`
//
negative to shorten, positive to extend
}
// List handles listing all subscriptions with pagination and filters
...
...
@@ -77,15 +77,19 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
}
status
:=
c
.
Query
(
"status"
)
subscriptions
,
pagination
,
err
:=
h
.
subscriptionService
.
List
(
c
.
Request
.
Context
(),
page
,
pageSize
,
userID
,
groupID
,
status
)
// Parse sorting parameters
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
subscriptions
,
pagination
,
err
:=
h
.
subscriptionService
.
List
(
c
.
Request
.
Context
(),
page
,
pageSize
,
userID
,
groupID
,
status
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
}
...
...
@@ -105,7 +109,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// GetProgress handles getting subscription usage progress
...
...
@@ -150,7 +154,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// BulkAssign handles bulk assigning subscriptions to multiple users
...
...
@@ -180,7 +184,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
response
.
Success
(
c
,
dto
.
BulkAssignResultFromService
(
result
))
}
// Extend handles
extend
ing a subscription
// Extend handles
adjust
ing a subscription
(extend or shorten)
// POST /api/v1/admin/subscriptions/:id/extend
func
(
h
*
SubscriptionHandler
)
Extend
(
c
*
gin
.
Context
)
{
subscriptionID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
...
...
@@ -189,7 +193,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
}
var
req
Extend
SubscriptionRequest
var
req
Adjust
SubscriptionRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
...
...
@@ -201,7 +205,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// Revoke handles revoking a subscription
...
...
@@ -239,9 +243,9 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
}
...
...
@@ -261,9 +265,9 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
response
.
Success
(
c
,
out
)
}
...
...
backend/internal/handler/admin/usage_cleanup_handler_test.go
0 → 100644
View file @
0170d19f
package
admin
import
(
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
cleanupRepoStub
struct
{
mu
sync
.
Mutex
created
[]
*
service
.
UsageCleanupTask
listTasks
[]
service
.
UsageCleanupTask
listResult
*
pagination
.
PaginationResult
listErr
error
statusByID
map
[
int64
]
string
}
func
(
s
*
cleanupRepoStub
)
CreateTask
(
ctx
context
.
Context
,
task
*
service
.
UsageCleanupTask
)
error
{
if
task
==
nil
{
return
nil
}
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
task
.
ID
==
0
{
task
.
ID
=
int64
(
len
(
s
.
created
)
+
1
)
}
if
task
.
CreatedAt
.
IsZero
()
{
task
.
CreatedAt
=
time
.
Now
()
.
UTC
()
}
task
.
UpdatedAt
=
task
.
CreatedAt
clone
:=
*
task
s
.
created
=
append
(
s
.
created
,
&
clone
)
return
nil
}
func
(
s
*
cleanupRepoStub
)
ListTasks
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
UsageCleanupTask
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
return
s
.
listTasks
,
s
.
listResult
,
s
.
listErr
}
func
(
s
*
cleanupRepoStub
)
ClaimNextPendingTask
(
ctx
context
.
Context
,
staleRunningAfterSeconds
int64
)
(
*
service
.
UsageCleanupTask
,
error
)
{
return
nil
,
nil
}
func
(
s
*
cleanupRepoStub
)
GetTaskStatus
(
ctx
context
.
Context
,
taskID
int64
)
(
string
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
statusByID
==
nil
{
return
""
,
sql
.
ErrNoRows
}
status
,
ok
:=
s
.
statusByID
[
taskID
]
if
!
ok
{
return
""
,
sql
.
ErrNoRows
}
return
status
,
nil
}
func
(
s
*
cleanupRepoStub
)
UpdateTaskProgress
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
CancelTask
(
ctx
context
.
Context
,
taskID
int64
,
canceledBy
int64
)
(
bool
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
status
:=
s
.
statusByID
[
taskID
]
if
status
!=
service
.
UsageCleanupStatusPending
&&
status
!=
service
.
UsageCleanupStatusRunning
{
return
false
,
nil
}
s
.
statusByID
[
taskID
]
=
service
.
UsageCleanupStatusCanceled
return
true
,
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskSucceeded
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskFailed
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
,
errorMsg
string
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
DeleteUsageLogsBatch
(
ctx
context
.
Context
,
filters
service
.
UsageCleanupFilters
,
limit
int
)
(
int64
,
error
)
{
return
0
,
nil
}
var
_
service
.
UsageCleanupRepository
=
(
*
cleanupRepoStub
)(
nil
)
func
setupCleanupRouter
(
cleanupService
*
service
.
UsageCleanupService
,
userID
int64
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
if
userID
>
0
{
router
.
Use
(
func
(
c
*
gin
.
Context
)
{
c
.
Set
(
string
(
middleware
.
ContextKeyUser
),
middleware
.
AuthSubject
{
UserID
:
userID
})
c
.
Next
()
})
}
handler
:=
NewUsageHandler
(
nil
,
nil
,
nil
,
cleanupService
)
router
.
POST
(
"/api/v1/admin/usage/cleanup-tasks"
,
handler
.
CreateCleanupTask
)
router
.
GET
(
"/api/v1/admin/usage/cleanup-tasks"
,
handler
.
ListCleanupTasks
)
router
.
POST
(
"/api/v1/admin/usage/cleanup-tasks/:id/cancel"
,
handler
.
CancelCleanupTask
)
return
router
}
func
TestUsageHandlerCreateCleanupTaskUnauthorized
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
`{}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusUnauthorized
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskUnavailable
(
t
*
testing
.
T
)
{
router
:=
setupCleanupRouter
(
nil
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
`{}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskBindError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
"{bad-json"
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskMissingRange
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskInvalidDate
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-13-01"
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskInvalidEndDate
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"end_date"
:
"2024-02-40"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
99
)
payload
:=
map
[
string
]
any
{
"start_date"
:
" 2024-01-01 "
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
"model"
:
"gpt-4"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
created
,
1
)
created
:=
repo
.
created
[
0
]
require
.
Equal
(
t
,
int64
(
99
),
created
.
CreatedBy
)
require
.
NotNil
(
t
,
created
.
Filters
.
Model
)
require
.
Equal
(
t
,
"gpt-4"
,
*
created
.
Filters
.
Model
)
start
:=
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
time
.
Date
(
2024
,
1
,
2
,
0
,
0
,
0
,
0
,
time
.
UTC
)
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
require
.
True
(
t
,
created
.
Filters
.
StartTime
.
Equal
(
start
))
require
.
True
(
t
,
created
.
Filters
.
EndTime
.
Equal
(
end
))
}
func
TestUsageHandlerListCleanupTasksUnavailable
(
t
*
testing
.
T
)
{
router
:=
setupCleanupRouter
(
nil
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
recorder
.
Code
)
}
func
TestUsageHandlerListCleanupTasksSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
repo
.
listTasks
=
[]
service
.
UsageCleanupTask
{
{
ID
:
7
,
Status
:
service
.
UsageCleanupStatusSucceeded
,
CreatedBy
:
4
,
},
}
repo
.
listResult
=
&
pagination
.
PaginationResult
{
Total
:
1
,
Page
:
1
,
PageSize
:
20
,
Pages
:
1
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
Items
[]
dto
.
UsageCleanupTask
`json:"items"`
Total
int64
`json:"total"`
Page
int
`json:"page"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Items
,
1
)
require
.
Equal
(
t
,
int64
(
7
),
resp
.
Data
.
Items
[
0
]
.
ID
)
require
.
Equal
(
t
,
int64
(
1
),
resp
.
Data
.
Total
)
require
.
Equal
(
t
,
1
,
resp
.
Data
.
Page
)
}
func
TestUsageHandlerListCleanupTasksError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
listErr
:
errors
.
New
(
"boom"
)}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusInternalServerError
,
recorder
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskUnauthorized
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/1/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusUnauthorized
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskNotFound
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/999/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusNotFound
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskConflict
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
2
:
service
.
UsageCleanupStatusSucceeded
}}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/2/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
3
:
service
.
UsageCleanupStatusPending
}}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/3/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
backend/internal/handler/admin/usage_handler.go
View file @
0170d19f
package
admin
import
(
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
...
...
@@ -9,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
...
...
@@ -16,9 +20,10 @@ import (
// UsageHandler handles admin usage-related requests
type
UsageHandler
struct
{
usageService
*
service
.
UsageService
apiKeyService
*
service
.
APIKeyService
adminService
service
.
AdminService
usageService
*
service
.
UsageService
apiKeyService
*
service
.
APIKeyService
adminService
service
.
AdminService
cleanupService
*
service
.
UsageCleanupService
}
// NewUsageHandler creates a new admin usage handler
...
...
@@ -26,14 +31,30 @@ func NewUsageHandler(
usageService
*
service
.
UsageService
,
apiKeyService
*
service
.
APIKeyService
,
adminService
service
.
AdminService
,
cleanupService
*
service
.
UsageCleanupService
,
)
*
UsageHandler
{
return
&
UsageHandler
{
usageService
:
usageService
,
apiKeyService
:
apiKeyService
,
adminService
:
adminService
,
usageService
:
usageService
,
apiKeyService
:
apiKeyService
,
adminService
:
adminService
,
cleanupService
:
cleanupService
,
}
}
// CreateUsageCleanupTaskRequest represents cleanup task creation request
type
CreateUsageCleanupTaskRequest
struct
{
StartDate
string
`json:"start_date"`
EndDate
string
`json:"end_date"`
UserID
*
int64
`json:"user_id"`
APIKeyID
*
int64
`json:"api_key_id"`
AccountID
*
int64
`json:"account_id"`
GroupID
*
int64
`json:"group_id"`
Model
*
string
`json:"model"`
Stream
*
bool
`json:"stream"`
BillingType
*
int8
`json:"billing_type"`
Timezone
string
`json:"timezone"`
}
// List handles listing all usage records with filters
// GET /api/v1/admin/usage
func
(
h
*
UsageHandler
)
List
(
c
*
gin
.
Context
)
{
...
...
@@ -142,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UsageLog
,
0
,
len
(
records
))
out
:=
make
([]
dto
.
Admin
UsageLog
,
0
,
len
(
records
))
for
i
:=
range
records
{
out
=
append
(
out
,
*
dto
.
UsageLogFromServiceAdmin
(
&
records
[
i
]))
}
...
...
@@ -344,3 +365,162 @@ func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
response
.
Success
(
c
,
result
)
}
// ListCleanupTasks handles listing usage cleanup tasks
// GET /api/v1/admin/usage/cleanup-tasks
func
(
h
*
UsageHandler
)
ListCleanupTasks
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
operator
:=
int64
(
0
)
if
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
);
ok
{
operator
=
subject
.
UserID
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
log
.
Printf
(
"[UsageCleanup] 请求清理任务列表: operator=%d page=%d page_size=%d"
,
operator
,
page
,
pageSize
)
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
tasks
,
result
,
err
:=
h
.
cleanupService
.
ListTasks
(
c
.
Request
.
Context
(),
params
)
if
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 查询清理任务列表失败: operator=%d page=%d page_size=%d err=%v"
,
operator
,
page
,
pageSize
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UsageCleanupTask
,
0
,
len
(
tasks
))
for
i
:=
range
tasks
{
out
=
append
(
out
,
*
dto
.
UsageCleanupTaskFromService
(
&
tasks
[
i
]))
}
log
.
Printf
(
"[UsageCleanup] 返回清理任务列表: operator=%d total=%d items=%d page=%d page_size=%d"
,
operator
,
result
.
Total
,
len
(
out
),
page
,
pageSize
)
response
.
Paginated
(
c
,
out
,
result
.
Total
,
page
,
pageSize
)
}
// CreateCleanupTask handles creating a usage cleanup task
// POST /api/v1/admin/usage/cleanup-tasks
func
(
h
*
UsageHandler
)
CreateCleanupTask
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Unauthorized
(
c
,
"Unauthorized"
)
return
}
var
req
CreateUsageCleanupTaskRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
req
.
StartDate
=
strings
.
TrimSpace
(
req
.
StartDate
)
req
.
EndDate
=
strings
.
TrimSpace
(
req
.
EndDate
)
if
req
.
StartDate
==
""
||
req
.
EndDate
==
""
{
response
.
BadRequest
(
c
,
"start_date and end_date are required"
)
return
}
startTime
,
err
:=
timezone
.
ParseInUserLocation
(
"2006-01-02"
,
req
.
StartDate
,
req
.
Timezone
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid start_date format, use YYYY-MM-DD"
)
return
}
endTime
,
err
:=
timezone
.
ParseInUserLocation
(
"2006-01-02"
,
req
.
EndDate
,
req
.
Timezone
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid end_date format, use YYYY-MM-DD"
)
return
}
endTime
=
endTime
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
filters
:=
service
.
UsageCleanupFilters
{
StartTime
:
startTime
,
EndTime
:
endTime
,
UserID
:
req
.
UserID
,
APIKeyID
:
req
.
APIKeyID
,
AccountID
:
req
.
AccountID
,
GroupID
:
req
.
GroupID
,
Model
:
req
.
Model
,
Stream
:
req
.
Stream
,
BillingType
:
req
.
BillingType
,
}
var
userID
any
if
filters
.
UserID
!=
nil
{
userID
=
*
filters
.
UserID
}
var
apiKeyID
any
if
filters
.
APIKeyID
!=
nil
{
apiKeyID
=
*
filters
.
APIKeyID
}
var
accountID
any
if
filters
.
AccountID
!=
nil
{
accountID
=
*
filters
.
AccountID
}
var
groupID
any
if
filters
.
GroupID
!=
nil
{
groupID
=
*
filters
.
GroupID
}
var
model
any
if
filters
.
Model
!=
nil
{
model
=
*
filters
.
Model
}
var
stream
any
if
filters
.
Stream
!=
nil
{
stream
=
*
filters
.
Stream
}
var
billingType
any
if
filters
.
BillingType
!=
nil
{
billingType
=
*
filters
.
BillingType
}
log
.
Printf
(
"[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v stream=%v billing_type=%v tz=%q"
,
subject
.
UserID
,
filters
.
StartTime
.
Format
(
time
.
RFC3339
),
filters
.
EndTime
.
Format
(
time
.
RFC3339
),
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
,
billingType
,
req
.
Timezone
,
)
task
,
err
:=
h
.
cleanupService
.
CreateTask
(
c
.
Request
.
Context
(),
filters
,
subject
.
UserID
)
if
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 创建清理任务失败: operator=%d err=%v"
,
subject
.
UserID
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
log
.
Printf
(
"[UsageCleanup] 清理任务已创建: task=%d operator=%d status=%s"
,
task
.
ID
,
subject
.
UserID
,
task
.
Status
)
response
.
Success
(
c
,
dto
.
UsageCleanupTaskFromService
(
task
))
}
// CancelCleanupTask handles canceling a usage cleanup task
// POST /api/v1/admin/usage/cleanup-tasks/:id/cancel
func
(
h
*
UsageHandler
)
CancelCleanupTask
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Unauthorized
(
c
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
taskID
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
taskID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid task id"
)
return
}
log
.
Printf
(
"[UsageCleanup] 请求取消清理任务: task=%d operator=%d"
,
taskID
,
subject
.
UserID
)
if
err
:=
h
.
cleanupService
.
CancelTask
(
c
.
Request
.
Context
(),
taskID
,
subject
.
UserID
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 取消清理任务失败: task=%d operator=%d err=%v"
,
taskID
,
subject
.
UserID
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
log
.
Printf
(
"[UsageCleanup] 清理任务已取消: task=%d operator=%d"
,
taskID
,
subject
.
UserID
)
response
.
Success
(
c
,
gin
.
H
{
"id"
:
taskID
,
"status"
:
service
.
UsageCleanupStatusCanceled
})
}
backend/internal/handler/admin/user_handler.go
View file @
0170d19f
...
...
@@ -84,9 +84,9 @@ func (h *UserHandler) List(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
User
,
0
,
len
(
users
))
out
:=
make
([]
dto
.
Admin
User
,
0
,
len
(
users
))
for
i
:=
range
users
{
out
=
append
(
out
,
*
dto
.
UserFromService
(
&
users
[
i
]))
out
=
append
(
out
,
*
dto
.
UserFromService
Admin
(
&
users
[
i
]))
}
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
}
...
...
@@ -129,7 +129,7 @@ func (h *UserHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Create handles creating a new user
...
...
@@ -155,7 +155,7 @@ func (h *UserHandler) Create(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Update handles updating a user
...
...
@@ -189,7 +189,7 @@ func (h *UserHandler) Update(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Delete handles deleting a user
...
...
@@ -231,7 +231,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// GetUserAPIKeys handles getting user's API keys
...
...
backend/internal/handler/announcement_handler.go
0 → 100644
View file @
0170d19f
package
handler
import
(
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementHandler handles user announcement operations
type
AnnouncementHandler
struct
{
announcementService
*
service
.
AnnouncementService
}
// NewAnnouncementHandler creates a new user announcement handler
func
NewAnnouncementHandler
(
announcementService
*
service
.
AnnouncementService
)
*
AnnouncementHandler
{
return
&
AnnouncementHandler
{
announcementService
:
announcementService
,
}
}
// List handles listing announcements visible to current user
// GET /api/v1/announcements
func
(
h
*
AnnouncementHandler
)
List
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
unreadOnly
:=
parseBoolQuery
(
c
.
Query
(
"unread_only"
))
items
,
err
:=
h
.
announcementService
.
ListForUser
(
c
.
Request
.
Context
(),
subject
.
UserID
,
unreadOnly
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UserAnnouncement
,
0
,
len
(
items
))
for
i
:=
range
items
{
out
=
append
(
out
,
*
dto
.
UserAnnouncementFromService
(
&
items
[
i
]))
}
response
.
Success
(
c
,
out
)
}
// MarkRead marks an announcement as read for current user
// POST /api/v1/announcements/:id/read
func
(
h
*
AnnouncementHandler
)
MarkRead
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
if
err
:=
h
.
announcementService
.
MarkRead
(
c
.
Request
.
Context
(),
subject
.
UserID
,
announcementID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"ok"
})
}
func
parseBoolQuery
(
v
string
)
bool
{
switch
strings
.
TrimSpace
(
strings
.
ToLower
(
v
))
{
case
"1"
,
"true"
,
"yes"
,
"y"
,
"on"
:
return
true
default
:
return
false
}
}
backend/internal/handler/auth_handler.go
View file @
0170d19f
package
handler
import
(
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
...
...
@@ -18,16 +20,18 @@ type AuthHandler struct {
userService
*
service
.
UserService
settingSvc
*
service
.
SettingService
promoService
*
service
.
PromoService
totpService
*
service
.
TotpService
}
// NewAuthHandler creates a new AuthHandler
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
)
*
AuthHandler
{
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
,
totpService
*
service
.
TotpService
)
*
AuthHandler
{
return
&
AuthHandler
{
cfg
:
cfg
,
authService
:
authService
,
userService
:
userService
,
settingSvc
:
settingService
,
promoService
:
promoService
,
totpService
:
totpService
,
}
}
...
...
@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Check if TOTP 2FA is enabled for this user
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
// Create a temporary login session for 2FA
tempToken
,
err
:=
h
.
totpService
.
CreateLoginSession
(
c
.
Request
.
Context
(),
user
.
ID
,
user
.
Email
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to create 2FA session"
)
return
}
response
.
Success
(
c
,
TotpLoginResponse
{
Requires2FA
:
true
,
TempToken
:
tempToken
,
UserEmailMasked
:
service
.
MaskEmail
(
user
.
Email
),
})
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
User
:
dto
.
UserFromService
(
user
),
})
}
// TotpLoginResponse represents the response when 2FA is required
type
TotpLoginResponse
struct
{
Requires2FA
bool
`json:"requires_2fa"`
TempToken
string
`json:"temp_token,omitempty"`
UserEmailMasked
string
`json:"user_email_masked,omitempty"`
}
// Login2FARequest represents the 2FA login request
type
Login2FARequest
struct
{
TempToken
string
`json:"temp_token" binding:"required"`
TotpCode
string
`json:"totp_code" binding:"required,len=6"`
}
// Login2FA completes the login with 2FA verification
// POST /api/v1/auth/login/2fa
func
(
h
*
AuthHandler
)
Login2FA
(
c
*
gin
.
Context
)
{
var
req
Login2FARequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
slog
.
Debug
(
"login_2fa_request"
,
"temp_token_len"
,
len
(
req
.
TempToken
),
"totp_code_len"
,
len
(
req
.
TotpCode
))
// Get the login session
session
,
err
:=
h
.
totpService
.
GetLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
if
err
!=
nil
||
session
==
nil
{
tokenPrefix
:=
""
if
len
(
req
.
TempToken
)
>=
8
{
tokenPrefix
=
req
.
TempToken
[
:
8
]
}
slog
.
Debug
(
"login_2fa_session_invalid"
,
"temp_token_prefix"
,
tokenPrefix
,
"error"
,
err
)
response
.
BadRequest
(
c
,
"Invalid or expired 2FA session"
)
return
}
slog
.
Debug
(
"login_2fa_session_found"
,
"user_id"
,
session
.
UserID
,
"email"
,
session
.
Email
)
// Verify the TOTP code
if
err
:=
h
.
totpService
.
VerifyCode
(
c
.
Request
.
Context
(),
session
.
UserID
,
req
.
TotpCode
);
err
!=
nil
{
slog
.
Debug
(
"login_2fa_verify_failed"
,
"user_id"
,
session
.
UserID
,
"error"
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
// Delete the login session
_
=
h
.
totpService
.
DeleteLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
// Get the user
user
,
err
:=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
session
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Generate the JWT token
token
,
err
:=
h
.
authService
.
GenerateToken
(
user
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to generate token"
)
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
...
...
@@ -195,6 +293,15 @@ type ValidatePromoCodeResponse struct {
// ValidatePromoCode 验证优惠码(公开接口,注册前调用)
// POST /api/v1/auth/validate-promo-code
func
(
h
*
AuthHandler
)
ValidatePromoCode
(
c
*
gin
.
Context
)
{
// 检查优惠码功能是否启用
if
h
.
settingSvc
!=
nil
&&
!
h
.
settingSvc
.
IsPromoCodeEnabled
(
c
.
Request
.
Context
())
{
response
.
Success
(
c
,
ValidatePromoCodeResponse
{
Valid
:
false
,
ErrorCode
:
"PROMO_CODE_DISABLED"
,
})
return
}
var
req
ValidatePromoCodeRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
...
...
@@ -238,3 +345,85 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
BonusAmount
:
promoCode
.
BonusAmount
,
})
}
// ForgotPasswordRequest 忘记密码请求
type
ForgotPasswordRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
TurnstileToken
string
`json:"turnstile_token"`
}
// ForgotPasswordResponse 忘记密码响应
type
ForgotPasswordResponse
struct
{
Message
string
`json:"message"`
}
// ForgotPassword 请求密码重置
// POST /api/v1/auth/forgot-password
func
(
h
*
AuthHandler
)
ForgotPassword
(
c
*
gin
.
Context
)
{
var
req
ForgotPasswordRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
// Turnstile 验证
if
err
:=
h
.
authService
.
VerifyTurnstile
(
c
.
Request
.
Context
(),
req
.
TurnstileToken
,
ip
.
GetClientIP
(
c
));
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Build frontend base URL from request
scheme
:=
"https"
if
c
.
Request
.
TLS
==
nil
{
// Check X-Forwarded-Proto header (common in reverse proxy setups)
if
proto
:=
c
.
GetHeader
(
"X-Forwarded-Proto"
);
proto
!=
""
{
scheme
=
proto
}
else
{
scheme
=
"http"
}
}
frontendBaseURL
:=
scheme
+
"://"
+
c
.
Request
.
Host
// Request password reset (async)
// Note: This returns success even if email doesn't exist (to prevent enumeration)
if
err
:=
h
.
authService
.
RequestPasswordResetAsync
(
c
.
Request
.
Context
(),
req
.
Email
,
frontendBaseURL
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
ForgotPasswordResponse
{
Message
:
"If your email is registered, you will receive a password reset link shortly."
,
})
}
// ResetPasswordRequest 重置密码请求
type
ResetPasswordRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
Token
string
`json:"token" binding:"required"`
NewPassword
string
`json:"new_password" binding:"required,min=6"`
}
// ResetPasswordResponse 重置密码响应
type
ResetPasswordResponse
struct
{
Message
string
`json:"message"`
}
// ResetPassword 重置密码
// POST /api/v1/auth/reset-password
func
(
h
*
AuthHandler
)
ResetPassword
(
c
*
gin
.
Context
)
{
var
req
ResetPasswordRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
// Reset password
if
err
:=
h
.
authService
.
ResetPassword
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Token
,
req
.
NewPassword
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
ResetPasswordResponse
{
Message
:
"Your password has been reset successfully. You can now log in with your new password."
,
})
}
backend/internal/handler/dto/announcement.go
0 → 100644
View file @
0170d19f
package
dto
import
(
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
Announcement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
Status
string
`json:"status"`
Targeting
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
CreatedBy
*
int64
`json:"created_by,omitempty"`
UpdatedBy
*
int64
`json:"updated_by,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
type
UserAnnouncement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
ReadAt
*
time
.
Time
`json:"read_at,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
func
AnnouncementFromService
(
a
*
service
.
Announcement
)
*
Announcement
{
if
a
==
nil
{
return
nil
}
return
&
Announcement
{
ID
:
a
.
ID
,
Title
:
a
.
Title
,
Content
:
a
.
Content
,
Status
:
a
.
Status
,
Targeting
:
a
.
Targeting
,
StartsAt
:
a
.
StartsAt
,
EndsAt
:
a
.
EndsAt
,
CreatedBy
:
a
.
CreatedBy
,
UpdatedBy
:
a
.
UpdatedBy
,
CreatedAt
:
a
.
CreatedAt
,
UpdatedAt
:
a
.
UpdatedAt
,
}
}
func
UserAnnouncementFromService
(
a
*
service
.
UserAnnouncement
)
*
UserAnnouncement
{
if
a
==
nil
{
return
nil
}
return
&
UserAnnouncement
{
ID
:
a
.
Announcement
.
ID
,
Title
:
a
.
Announcement
.
Title
,
Content
:
a
.
Announcement
.
Content
,
StartsAt
:
a
.
Announcement
.
StartsAt
,
EndsAt
:
a
.
Announcement
.
EndsAt
,
ReadAt
:
a
.
ReadAt
,
CreatedAt
:
a
.
Announcement
.
CreatedAt
,
UpdatedAt
:
a
.
Announcement
.
UpdatedAt
,
}
}
backend/internal/handler/dto/mappers.go
View file @
0170d19f
...
...
@@ -15,7 +15,6 @@ func UserFromServiceShallow(u *service.User) *User {
ID
:
u
.
ID
,
Email
:
u
.
Email
,
Username
:
u
.
Username
,
Notes
:
u
.
Notes
,
Role
:
u
.
Role
,
Balance
:
u
.
Balance
,
Concurrency
:
u
.
Concurrency
,
...
...
@@ -48,6 +47,22 @@ func UserFromService(u *service.User) *User {
return
out
}
// UserFromServiceAdmin converts a service User to DTO for admin users.
// It includes notes - user-facing endpoints must not use this.
func
UserFromServiceAdmin
(
u
*
service
.
User
)
*
AdminUser
{
if
u
==
nil
{
return
nil
}
base
:=
UserFromService
(
u
)
if
base
==
nil
{
return
nil
}
return
&
AdminUser
{
User
:
*
base
,
Notes
:
u
.
Notes
,
}
}
func
APIKeyFromService
(
k
*
service
.
APIKey
)
*
APIKey
{
if
k
==
nil
{
return
nil
...
...
@@ -72,38 +87,31 @@ func GroupFromServiceShallow(g *service.Group) *Group {
if
g
==
nil
{
return
nil
}
return
&
Group
{
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
FallbackGroupIDOnInvalidRequest
:
g
.
FallbackGroupIDOnInvalidRequest
,
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
MCPXMLInject
:
g
.
MCPXMLInject
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
AccountCount
:
g
.
AccountCount
,
}
out
:=
groupFromServiceBase
(
g
)
return
&
out
}
func
GroupFromService
(
g
*
service
.
Group
)
*
Group
{
if
g
==
nil
{
return
nil
}
out
:=
GroupFromServiceShallow
(
g
)
return
GroupFromServiceShallow
(
g
)
}
// GroupFromServiceAdmin converts a service Group to DTO for admin users.
// It includes internal fields like model_routing and account_count.
func
GroupFromServiceAdmin
(
g
*
service
.
Group
)
*
AdminGroup
{
if
g
==
nil
{
return
nil
}
out
:=
&
AdminGroup
{
Group
:
groupFromServiceBase
(
g
),
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
MCPXMLInject
:
g
.
MCPXMLInject
,
SupportedModelScopes
:
g
.
SupportedModelScopes
,
AccountCount
:
g
.
AccountCount
,
}
if
len
(
g
.
AccountGroups
)
>
0
{
out
.
AccountGroups
=
make
([]
AccountGroup
,
0
,
len
(
g
.
AccountGroups
))
for
i
:=
range
g
.
AccountGroups
{
...
...
@@ -114,6 +122,31 @@ func GroupFromService(g *service.Group) *Group {
return
out
}
func
groupFromServiceBase
(
g
*
service
.
Group
)
Group
{
return
Group
{
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
// 无效请求兜底分组
FallbackGroupIDOnInvalidRequest
:
g
.
FallbackGroupIDOnInvalidRequest
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
}
func
AccountFromServiceShallow
(
a
*
service
.
Account
)
*
Account
{
if
a
==
nil
{
return
nil
...
...
@@ -163,6 +196,16 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
}
// TLS指纹伪装开关
if
a
.
IsTLSFingerprintEnabled
()
{
enabled
:=
true
out
.
EnableTLSFingerprint
=
&
enabled
}
// 会话ID伪装开关
if
a
.
IsSessionIDMaskingEnabled
()
{
enabled
:=
true
out
.
EnableSessionIDMasking
=
&
enabled
}
}
if
scopeLimits
:=
a
.
GetAntigravityScopeRateLimits
();
len
(
scopeLimits
)
>
0
{
...
...
@@ -276,7 +319,24 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
if
rc
==
nil
{
return
nil
}
return
&
RedeemCode
{
out
:=
redeemCodeFromServiceBase
(
rc
)
return
&
out
}
// RedeemCodeFromServiceAdmin converts a service RedeemCode to DTO for admin users.
// It includes notes - user-facing endpoints must not use this.
func
RedeemCodeFromServiceAdmin
(
rc
*
service
.
RedeemCode
)
*
AdminRedeemCode
{
if
rc
==
nil
{
return
nil
}
return
&
AdminRedeemCode
{
RedeemCode
:
redeemCodeFromServiceBase
(
rc
),
Notes
:
rc
.
Notes
,
}
}
func
redeemCodeFromServiceBase
(
rc
*
service
.
RedeemCode
)
RedeemCode
{
out
:=
RedeemCode
{
ID
:
rc
.
ID
,
Code
:
rc
.
Code
,
Type
:
rc
.
Type
,
...
...
@@ -284,13 +344,20 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
Status
:
rc
.
Status
,
UsedBy
:
rc
.
UsedBy
,
UsedAt
:
rc
.
UsedAt
,
Notes
:
rc
.
Notes
,
CreatedAt
:
rc
.
CreatedAt
,
GroupID
:
rc
.
GroupID
,
ValidityDays
:
rc
.
ValidityDays
,
User
:
UserFromServiceShallow
(
rc
.
User
),
Group
:
GroupFromServiceShallow
(
rc
.
Group
),
}
// For admin_balance/admin_concurrency types, include notes so users can see
// why they were charged or credited by admin
if
(
rc
.
Type
==
"admin_balance"
||
rc
.
Type
==
"admin_concurrency"
)
&&
rc
.
Notes
!=
""
{
out
.
Notes
=
&
rc
.
Notes
}
return
out
}
// AccountSummaryFromService returns a minimal AccountSummary for usage log display.
...
...
@@ -305,14 +372,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
}
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
// The account parameter allows caller to control what Account info is included.
// The includeIPAddress parameter controls whether to include the IP address (admin-only).
func
usageLogFromServiceBase
(
l
*
service
.
UsageLog
,
account
*
AccountSummary
,
includeIPAddress
bool
)
*
UsageLog
{
if
l
==
nil
{
return
nil
}
result
:=
&
UsageLog
{
func
usageLogFromServiceUser
(
l
*
service
.
UsageLog
)
UsageLog
{
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
return
UsageLog
{
ID
:
l
.
ID
,
UserID
:
l
.
UserID
,
APIKeyID
:
l
.
APIKeyID
,
...
...
@@ -334,7 +396,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
TotalCost
:
l
.
TotalCost
,
ActualCost
:
l
.
ActualCost
,
RateMultiplier
:
l
.
RateMultiplier
,
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
BillingType
:
l
.
BillingType
,
Stream
:
l
.
Stream
,
DurationMs
:
l
.
DurationMs
,
...
...
@@ -345,30 +406,63 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
CreatedAt
:
l
.
CreatedAt
,
User
:
UserFromServiceShallow
(
l
.
User
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
Account
:
account
,
Group
:
GroupFromServiceShallow
(
l
.
Group
),
Subscription
:
UserSubscriptionFromService
(
l
.
Subscription
),
}
// IP 地址仅对管理员可见
if
includeIPAddress
{
result
.
IPAddress
=
l
.
IPAddress
}
return
result
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
func
UsageLogFromService
(
l
*
service
.
UsageLog
)
*
UsageLog
{
return
usageLogFromServiceBase
(
l
,
nil
,
false
)
if
l
==
nil
{
return
nil
}
u
:=
usageLogFromServiceUser
(
l
)
return
&
u
}
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
// It includes minimal Account info (ID, Name only) and IP address.
func
UsageLogFromServiceAdmin
(
l
*
service
.
UsageLog
)
*
UsageLog
{
func
UsageLogFromServiceAdmin
(
l
*
service
.
UsageLog
)
*
Admin
UsageLog
{
if
l
==
nil
{
return
nil
}
return
usageLogFromServiceBase
(
l
,
AccountSummaryFromService
(
l
.
Account
),
true
)
return
&
AdminUsageLog
{
UsageLog
:
usageLogFromServiceUser
(
l
),
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
IPAddress
:
l
.
IPAddress
,
Account
:
AccountSummaryFromService
(
l
.
Account
),
}
}
func
UsageCleanupTaskFromService
(
task
*
service
.
UsageCleanupTask
)
*
UsageCleanupTask
{
if
task
==
nil
{
return
nil
}
return
&
UsageCleanupTask
{
ID
:
task
.
ID
,
Status
:
task
.
Status
,
Filters
:
UsageCleanupFilters
{
StartTime
:
task
.
Filters
.
StartTime
,
EndTime
:
task
.
Filters
.
EndTime
,
UserID
:
task
.
Filters
.
UserID
,
APIKeyID
:
task
.
Filters
.
APIKeyID
,
AccountID
:
task
.
Filters
.
AccountID
,
GroupID
:
task
.
Filters
.
GroupID
,
Model
:
task
.
Filters
.
Model
,
Stream
:
task
.
Filters
.
Stream
,
BillingType
:
task
.
Filters
.
BillingType
,
},
CreatedBy
:
task
.
CreatedBy
,
DeletedRows
:
task
.
DeletedRows
,
ErrorMessage
:
task
.
ErrorMsg
,
CanceledBy
:
task
.
CanceledBy
,
CanceledAt
:
task
.
CanceledAt
,
StartedAt
:
task
.
StartedAt
,
FinishedAt
:
task
.
FinishedAt
,
CreatedAt
:
task
.
CreatedAt
,
UpdatedAt
:
task
.
UpdatedAt
,
}
}
func
SettingFromService
(
s
*
service
.
Setting
)
*
Setting
{
...
...
@@ -387,7 +481,27 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
if
sub
==
nil
{
return
nil
}
return
&
UserSubscription
{
out
:=
userSubscriptionFromServiceBase
(
sub
)
return
&
out
}
// UserSubscriptionFromServiceAdmin converts a service UserSubscription to DTO for admin users.
// It includes assignment metadata and notes.
func
UserSubscriptionFromServiceAdmin
(
sub
*
service
.
UserSubscription
)
*
AdminUserSubscription
{
if
sub
==
nil
{
return
nil
}
return
&
AdminUserSubscription
{
UserSubscription
:
userSubscriptionFromServiceBase
(
sub
),
AssignedBy
:
sub
.
AssignedBy
,
AssignedAt
:
sub
.
AssignedAt
,
Notes
:
sub
.
Notes
,
AssignedByUser
:
UserFromServiceShallow
(
sub
.
AssignedByUser
),
}
}
func
userSubscriptionFromServiceBase
(
sub
*
service
.
UserSubscription
)
UserSubscription
{
return
UserSubscription
{
ID
:
sub
.
ID
,
UserID
:
sub
.
UserID
,
GroupID
:
sub
.
GroupID
,
...
...
@@ -400,14 +514,10 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
DailyUsageUSD
:
sub
.
DailyUsageUSD
,
WeeklyUsageUSD
:
sub
.
WeeklyUsageUSD
,
MonthlyUsageUSD
:
sub
.
MonthlyUsageUSD
,
AssignedBy
:
sub
.
AssignedBy
,
AssignedAt
:
sub
.
AssignedAt
,
Notes
:
sub
.
Notes
,
CreatedAt
:
sub
.
CreatedAt
,
UpdatedAt
:
sub
.
UpdatedAt
,
User
:
UserFromServiceShallow
(
sub
.
User
),
Group
:
GroupFromServiceShallow
(
sub
.
Group
),
AssignedByUser
:
UserFromServiceShallow
(
sub
.
AssignedByUser
),
}
}
...
...
@@ -415,9 +525,9 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult
if
r
==
nil
{
return
nil
}
subs
:=
make
([]
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
subs
:=
make
([]
Admin
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
for
i
:=
range
r
.
Subscriptions
{
subs
=
append
(
subs
,
*
UserSubscriptionFromService
(
&
r
.
Subscriptions
[
i
]))
subs
=
append
(
subs
,
*
UserSubscriptionFromService
Admin
(
&
r
.
Subscriptions
[
i
]))
}
return
&
BulkAssignResult
{
SuccessCount
:
r
.
SuccessCount
,
...
...
backend/internal/handler/dto/settings.go
View file @
0170d19f
...
...
@@ -2,8 +2,12 @@ package dto
// SystemSettings represents the admin settings API response payload.
type
SystemSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TotpEncryptionKeyConfigured
bool
`json:"totp_encryption_key_configured"`
// TOTP 加密密钥是否已配置
SMTPHost
string
`json:"smtp_host"`
SMTPPort
int
`json:"smtp_port"`
...
...
@@ -22,13 +26,16 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
...
...
@@ -52,19 +59,25 @@ type SystemSettings struct {
}
type
PublicSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version"`
}
// StreamTimeoutSettings 流超时处理配置 DTO
...
...
Prev
1
2
3
4
5
6
7
8
…
16
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