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
a641d4a1
"git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "0b8fea4cb4931b362bbc8c49b8e95b40ccb0afe4"
Commit
a641d4a1
authored
Dec 30, 2025
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
0ea373d9
809ea235
Changes
9
Hide whitespace changes
Inline
Side-by-side
.gitignore
View file @
a641d4a1
...
...
@@ -113,4 +113,4 @@ scripts
.code-review-state
openspec/
docs/
code-reviews/
\ No newline at end of file
code-reviews/
backend/internal/handler/gateway_handler.go
View file @
a641d4a1
...
...
@@ -216,7 +216,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
const
maxAccountSwitches
=
3
const
maxAccountSwitches
=
10
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
...
...
backend/internal/server/middleware/jwt_auth.go
View file @
a641d4a1
...
...
@@ -61,6 +61,13 @@ func jwtAuth(authService *service.AuthService, userService *service.UserService)
return
}
// Security: Validate TokenVersion to ensure token hasn't been invalidated
// This check ensures tokens issued before a password change are rejected
if
claims
.
TokenVersion
!=
user
.
TokenVersion
{
AbortWithError
(
c
,
401
,
"TOKEN_REVOKED"
,
"Token has been revoked (password changed)"
)
return
}
c
.
Set
(
string
(
ContextKeyUser
),
AuthSubject
{
UserID
:
user
.
ID
,
Concurrency
:
user
.
Concurrency
,
...
...
backend/internal/service/auth_service.go
View file @
a641d4a1
...
...
@@ -20,6 +20,7 @@ var (
ErrEmailExists
=
infraerrors
.
Conflict
(
"EMAIL_EXISTS"
,
"email already exists"
)
ErrInvalidToken
=
infraerrors
.
Unauthorized
(
"INVALID_TOKEN"
,
"invalid token"
)
ErrTokenExpired
=
infraerrors
.
Unauthorized
(
"TOKEN_EXPIRED"
,
"token has expired"
)
ErrTokenRevoked
=
infraerrors
.
Unauthorized
(
"TOKEN_REVOKED"
,
"token has been revoked"
)
ErrEmailVerifyRequired
=
infraerrors
.
BadRequest
(
"EMAIL_VERIFY_REQUIRED"
,
"email verification is required"
)
ErrRegDisabled
=
infraerrors
.
Forbidden
(
"REGISTRATION_DISABLED"
,
"registration is currently disabled"
)
ErrServiceUnavailable
=
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"service temporarily unavailable"
)
...
...
@@ -27,9 +28,10 @@ var (
// JWTClaims JWT载荷数据
type
JWTClaims
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Role
string
`json:"role"`
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Role
string
`json:"role"`
TokenVersion
int64
`json:"token_version"`
// Used to invalidate tokens on password change
jwt
.
RegisteredClaims
}
...
...
@@ -311,9 +313,10 @@ func (s *AuthService) GenerateToken(user *User) (string, error) {
expiresAt
:=
now
.
Add
(
time
.
Duration
(
s
.
cfg
.
JWT
.
ExpireHour
)
*
time
.
Hour
)
claims
:=
&
JWTClaims
{
UserID
:
user
.
ID
,
Email
:
user
.
Email
,
Role
:
user
.
Role
,
UserID
:
user
.
ID
,
Email
:
user
.
Email
,
Role
:
user
.
Role
,
TokenVersion
:
user
.
TokenVersion
,
RegisteredClaims
:
jwt
.
RegisteredClaims
{
ExpiresAt
:
jwt
.
NewNumericDate
(
expiresAt
),
IssuedAt
:
jwt
.
NewNumericDate
(
now
),
...
...
@@ -368,6 +371,12 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) (
return
""
,
ErrUserNotActive
}
// Security: Check TokenVersion to prevent refreshing revoked tokens
// This ensures tokens issued before a password change cannot be refreshed
if
claims
.
TokenVersion
!=
user
.
TokenVersion
{
return
""
,
ErrTokenRevoked
}
// 生成新token
return
s
.
GenerateToken
(
user
)
}
backend/internal/service/gateway_service.go
View file @
a641d4a1
...
...
@@ -695,6 +695,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if
req
.
Stream
{
streamResult
,
err
:=
s
.
handleStreamingResponse
(
ctx
,
resp
,
c
,
account
,
startTime
,
originalModel
,
req
.
Model
)
if
err
!=
nil
{
if
err
.
Error
()
==
"have error in stream"
{
return
nil
,
&
UpstreamFailoverError
{
StatusCode
:
403
,
}
}
return
nil
,
err
}
usage
=
streamResult
.
usage
...
...
@@ -969,6 +974,9 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
for
scanner
.
Scan
()
{
line
:=
scanner
.
Text
()
if
line
==
"event: error"
{
return
nil
,
errors
.
New
(
"have error in stream"
)
}
// Extract data from SSE line (supports both "data: " and "data:" formats)
if
sseDataRe
.
MatchString
(
line
)
{
...
...
backend/internal/service/user.go
View file @
a641d4a1
...
...
@@ -18,6 +18,7 @@ type User struct {
Concurrency
int
Status
string
AllowedGroups
[]
int64
TokenVersion
int64
// Incremented on password change to invalidate existing tokens
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
backend/internal/service/user_service.go
View file @
a641d4a1
...
...
@@ -116,6 +116,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
}
// ChangePassword 修改密码
// Security: Increments TokenVersion to invalidate all existing JWT tokens
func
(
s
*
UserService
)
ChangePassword
(
ctx
context
.
Context
,
userID
int64
,
req
ChangePasswordRequest
)
error
{
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
userID
)
if
err
!=
nil
{
...
...
@@ -131,6 +132,10 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, req Chan
return
fmt
.
Errorf
(
"set password: %w"
,
err
)
}
// Increment TokenVersion to invalidate all existing tokens
// This ensures that any tokens issued before the password change become invalid
user
.
TokenVersion
++
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
return
fmt
.
Errorf
(
"update user: %w"
,
err
)
}
...
...
backend
/Makefile
→
deploy
/Makefile
View file @
a641d4a1
.PHONY
:
wire build build-embed test-unit test-integration test-e2e test-cover-integration
clean-coverage
.PHONY
:
wire build build-embed test-unit test-integration test-e2e test-cover-integration
wire
:
@
echo
"生成 Wire 代码..."
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
a641d4a1
...
...
@@ -335,12 +335,59 @@
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.user
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
assignForm.user_id
"
:
options
=
"
userOptions
"
:
placeholder
=
"
t('admin.subscriptions.selectUser')
"
searchable
/>
<
div
class
=
"
relative
"
>
<
input
v
-
model
=
"
userSearchKeyword
"
type
=
"
text
"
class
=
"
input pr-8
"
:
placeholder
=
"
t('admin.usage.searchUserPlaceholder')
"
@
input
=
"
debounceSearchUsers
"
@
focus
=
"
showUserDropdown = true
"
/>
<
button
v
-
if
=
"
selectedUser
"
@
click
=
"
clearUserSelection
"
type
=
"
button
"
class
=
"
absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
/svg
>
<
/button
>
<!--
User
Dropdown
-->
<
div
v
-
if
=
"
showUserDropdown && (userSearchResults.length > 0 || userSearchKeyword)
"
class
=
"
absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800
"
>
<
div
v
-
if
=
"
userSearchLoading
"
class
=
"
px-4 py-3 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.loading
'
)
}}
<
/div
>
<
div
v
-
else
-
if
=
"
userSearchResults.length === 0 && userSearchKeyword
"
class
=
"
px-4 py-3 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.noOptionsFound
'
)
}}
<
/div
>
<
button
v
-
for
=
"
user in userSearchResults
"
:
key
=
"
user.id
"
type
=
"
button
"
@
click
=
"
selectUser(user)
"
class
=
"
w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700
"
>
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
user
.
email
}}
<
/span
>
<
span
class
=
"
ml-2 text-gray-500 dark:text-gray-400
"
>
#
{{
user
.
id
}}
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.group
'
)
}}
<
/label
>
...
...
@@ -462,11 +509,12 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserSubscription
,
Group
,
User
}
from
'
@/types
'
import
type
{
UserSubscription
,
Group
}
from
'
@/types
'
import
type
{
SimpleUser
}
from
'
@/api/admin/usage
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateOnly
}
from
'
@/utils/format
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
...
...
@@ -501,9 +549,17 @@ const statusOptions = computed(() => [
const
subscriptions
=
ref
<
UserSubscription
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
users
=
ref
<
User
[]
>
([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
// User search state
const
userSearchKeyword
=
ref
(
''
)
const
userSearchResults
=
ref
<
SimpleUser
[]
>
([])
const
userSearchLoading
=
ref
(
false
)
const
showUserDropdown
=
ref
(
false
)
const
selectedUser
=
ref
<
SimpleUser
|
null
>
(
null
)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
filters
=
reactive
({
status
:
''
,
group_id
:
''
...
...
@@ -545,9 +601,6 @@ const subscriptionGroupOptions = computed(() =>
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
))
)
// User options for assign
const
userOptions
=
computed
(()
=>
users
.
value
.
map
((
u
)
=>
({
value
:
u
.
id
,
label
:
u
.
email
}
)))
const
loadSubscriptions
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
@@ -590,15 +643,53 @@ const loadGroups = async () => {
}
}
const
loadUsers
=
async
()
=>
{
// User search with debounce
const
debounceSearchUsers
=
()
=>
{
if
(
userSearchTimeout
)
{
clearTimeout
(
userSearchTimeout
)
}
userSearchTimeout
=
setTimeout
(
searchUsers
,
300
)
}
const
searchUsers
=
async
()
=>
{
const
keyword
=
userSearchKeyword
.
value
.
trim
()
// Clear selection if user modified the search keyword
if
(
selectedUser
.
value
&&
keyword
!==
selectedUser
.
value
.
email
)
{
selectedUser
.
value
=
null
assignForm
.
user_id
=
null
}
if
(
!
keyword
)
{
userSearchResults
.
value
=
[]
return
}
userSearchLoading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
users
.
list
(
1
,
1000
)
users
.
value
=
response
.
items
userSearchResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
keyword
)
}
catch
(
error
)
{
console
.
error
(
'
Error loading users:
'
,
error
)
console
.
error
(
'
Failed to search users:
'
,
error
)
userSearchResults
.
value
=
[]
}
finally
{
userSearchLoading
.
value
=
false
}
}
const
selectUser
=
(
user
:
SimpleUser
)
=>
{
selectedUser
.
value
=
user
userSearchKeyword
.
value
=
user
.
email
showUserDropdown
.
value
=
false
assignForm
.
user_id
=
user
.
id
}
const
clearUserSelection
=
()
=>
{
selectedUser
.
value
=
null
userSearchKeyword
.
value
=
''
userSearchResults
.
value
=
[]
assignForm
.
user_id
=
null
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadSubscriptions
()
...
...
@@ -615,6 +706,11 @@ const closeAssignModal = () => {
assignForm
.
user_id
=
null
assignForm
.
group_id
=
null
assignForm
.
validity_days
=
30
// Clear user search state
selectedUser
.
value
=
null
userSearchKeyword
.
value
=
''
userSearchResults
.
value
=
[]
showUserDropdown
.
value
=
false
}
const
handleAssignSubscription
=
async
()
=>
{
...
...
@@ -754,10 +850,25 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
}
}
// Handle click outside to close user dropdown
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.relative
'
))
{
showUserDropdown
.
value
=
false
}
}
onMounted
(()
=>
{
loadSubscriptions
()
loadGroups
()
loadUsers
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
}
)
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
if
(
userSearchTimeout
)
{
clearTimeout
(
userSearchTimeout
)
}
}
)
<
/script
>
...
...
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