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
1a641392
Commit
1a641392
authored
Jan 10, 2026
by
cyhhao
Browse files
Merge up/main
parents
36b817d0
24d19a5f
Changes
174
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_multiplatform_test.go
View file @
1a641392
...
...
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
...
...
@@ -121,6 +122,9 @@ func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx cont
func
(
m
*
mockAccountRepoForGemini
)
SetRateLimited
(
ctx
context
.
Context
,
id
int64
,
resetAt
time
.
Time
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
SetAntigravityQuotaScopeLimit
(
ctx
context
.
Context
,
id
int64
,
scope
AntigravityQuotaScope
,
resetAt
time
.
Time
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
SetOverloaded
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
)
error
{
return
nil
}
...
...
@@ -131,6 +135,9 @@ func (m *mockAccountRepoForGemini) ClearTempUnschedulable(ctx context.Context, i
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ClearRateLimit
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ClearAntigravityQuotaScopes
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
UpdateSessionWindow
(
ctx
context
.
Context
,
id
int64
,
start
,
end
*
time
.
Time
,
status
string
)
error
{
return
nil
}
...
...
@@ -146,10 +153,21 @@ var _ AccountRepository = (*mockAccountRepoForGemini)(nil)
// mockGroupRepoForGemini Gemini 测试用的 group repo mock
type
mockGroupRepoForGemini
struct
{
groups
map
[
int64
]
*
Group
groups
map
[
int64
]
*
Group
getByIDCalls
int
getByIDLiteCalls
int
}
func
(
m
*
mockGroupRepoForGemini
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
{
m
.
getByIDCalls
++
if
g
,
ok
:=
m
.
groups
[
id
];
ok
{
return
g
,
nil
}
return
nil
,
errors
.
New
(
"group not found"
)
}
func
(
m
*
mockGroupRepoForGemini
)
GetByIDLite
(
ctx
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
{
m
.
getByIDLiteCalls
++
if
g
,
ok
:=
m
.
groups
[
id
];
ok
{
return
g
,
nil
}
...
...
@@ -166,7 +184,7 @@ func (m *mockGroupRepoForGemini) DeleteCascade(ctx context.Context, id int64) ([
func
(
m
*
mockGroupRepoForGemini
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
nil
}
func
(
m
*
mockGroupRepoForGemini
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
m
*
mockGroupRepoForGemini
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
nil
}
func
(
m
*
mockGroupRepoForGemini
)
ListActive
(
ctx
context
.
Context
)
([]
Group
,
error
)
{
return
nil
,
nil
}
...
...
@@ -242,6 +260,77 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiP
require
.
Equal
(
t
,
PlatformGemini
,
acc
.
Platform
,
"无分组时应只返回 gemini 平台账户"
)
}
func
TestGeminiMessagesCompatService_GroupResolution_ReusesContextGroup
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
groupID
:=
int64
(
7
)
group
:=
&
Group
{
ID
:
groupID
,
Platform
:
PlatformGemini
,
Status
:
StatusActive
,
Hydrated
:
true
,
}
ctx
=
context
.
WithValue
(
ctx
,
ctxkey
.
Group
,
group
)
repo
:=
&
mockAccountRepoForGemini
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformGemini
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForGemini
{}
groupRepo
:=
&
mockGroupRepoForGemini
{
groups
:
map
[
int64
]
*
Group
{}}
svc
:=
&
GeminiMessagesCompatService
{
accountRepo
:
repo
,
groupRepo
:
groupRepo
,
cache
:
cache
,
}
acc
,
err
:=
svc
.
SelectAccountForModelWithExclusions
(
ctx
,
&
groupID
,
""
,
"gemini-2.5-flash"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
acc
)
require
.
Equal
(
t
,
0
,
groupRepo
.
getByIDCalls
)
require
.
Equal
(
t
,
0
,
groupRepo
.
getByIDLiteCalls
)
}
func
TestGeminiMessagesCompatService_GroupResolution_UsesLiteFetch
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
groupID
:=
int64
(
7
)
repo
:=
&
mockAccountRepoForGemini
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformGemini
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForGemini
{}
groupRepo
:=
&
mockGroupRepoForGemini
{
groups
:
map
[
int64
]
*
Group
{
groupID
:
{
ID
:
groupID
,
Platform
:
PlatformGemini
},
},
}
svc
:=
&
GeminiMessagesCompatService
{
accountRepo
:
repo
,
groupRepo
:
groupRepo
,
cache
:
cache
,
}
acc
,
err
:=
svc
.
SelectAccountForModelWithExclusions
(
ctx
,
&
groupID
,
""
,
"gemini-2.5-flash"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
acc
)
require
.
Equal
(
t
,
0
,
groupRepo
.
getByIDCalls
)
require
.
Equal
(
t
,
1
,
groupRepo
.
getByIDLiteCalls
)
}
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup 测试 antigravity 分组
func
TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
...
...
backend/internal/service/group.go
View file @
1a641392
...
...
@@ -10,6 +10,7 @@ type Group struct {
RateMultiplier
float64
IsExclusive
bool
Status
string
Hydrated
bool
// indicates the group was loaded from a trusted repository source
SubscriptionType
string
DailyLimitUSD
*
float64
...
...
@@ -72,3 +73,20 @@ func (g *Group) GetImagePrice(imageSize string) *float64 {
return
g
.
ImagePrice2K
}
}
// IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
func
IsGroupContextValid
(
group
*
Group
)
bool
{
if
group
==
nil
{
return
false
}
if
group
.
ID
<=
0
{
return
false
}
if
!
group
.
Hydrated
{
return
false
}
if
group
.
Platform
==
""
||
group
.
Status
==
""
{
return
false
}
return
true
}
backend/internal/service/group_service.go
View file @
1a641392
...
...
@@ -16,12 +16,13 @@ var (
type
GroupRepository
interface
{
Create
(
ctx
context
.
Context
,
group
*
Group
)
error
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
GetByIDLite
(
ctx
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
Update
(
ctx
context
.
Context
,
group
*
Group
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
DeleteCascade
(
ctx
context
.
Context
,
id
int64
)
([]
int64
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
ListActive
(
ctx
context
.
Context
)
([]
Group
,
error
)
ListActiveByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Group
,
error
)
...
...
backend/internal/service/openai_codex_transform.go
View file @
1a641392
...
...
@@ -455,6 +455,10 @@ func getOpenCodeCodexHeader() string {
return
getOpenCodeCachedPrompt
(
opencodeCodexHeaderURL
,
"opencode-codex-header.txt"
,
"opencode-codex-header-meta.json"
)
}
func
GetOpenCodeInstructions
()
string
{
return
getOpenCodeCodexHeader
()
}
func
filterCodexInput
(
input
[]
any
)
[]
any
{
filtered
:=
make
([]
any
,
0
,
len
(
input
))
for
_
,
item
:=
range
input
{
...
...
backend/internal/service/openai_gateway_service.go
View file @
1a641392
...
...
@@ -1251,6 +1251,7 @@ type OpenAIRecordUsageInput struct {
Account
*
Account
Subscription
*
UserSubscription
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
}
// RecordUsage records usage and deducts balance
...
...
@@ -1325,6 +1326,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
usageLog
.
UserAgent
=
&
input
.
UserAgent
}
// 添加 IPAddress
if
input
.
IPAddress
!=
""
{
usageLog
.
IPAddress
=
&
input
.
IPAddress
}
if
apiKey
.
GroupID
!=
nil
{
usageLog
.
GroupID
=
apiKey
.
GroupID
}
...
...
backend/internal/service/promo_code.go
0 → 100644
View file @
1a641392
package
service
import
(
"time"
)
// PromoCode 注册优惠码
type
PromoCode
struct
{
ID
int64
Code
string
BonusAmount
float64
MaxUses
int
UsedCount
int
Status
string
ExpiresAt
*
time
.
Time
Notes
string
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
// 关联
UsageRecords
[]
PromoCodeUsage
}
// PromoCodeUsage 优惠码使用记录
type
PromoCodeUsage
struct
{
ID
int64
PromoCodeID
int64
UserID
int64
BonusAmount
float64
UsedAt
time
.
Time
// 关联
PromoCode
*
PromoCode
User
*
User
}
// CanUse 检查优惠码是否可用
func
(
p
*
PromoCode
)
CanUse
()
bool
{
if
p
.
Status
!=
PromoCodeStatusActive
{
return
false
}
if
p
.
ExpiresAt
!=
nil
&&
time
.
Now
()
.
After
(
*
p
.
ExpiresAt
)
{
return
false
}
if
p
.
MaxUses
>
0
&&
p
.
UsedCount
>=
p
.
MaxUses
{
return
false
}
return
true
}
// IsExpired 检查是否已过期
func
(
p
*
PromoCode
)
IsExpired
()
bool
{
return
p
.
ExpiresAt
!=
nil
&&
time
.
Now
()
.
After
(
*
p
.
ExpiresAt
)
}
// CreatePromoCodeInput 创建优惠码输入
type
CreatePromoCodeInput
struct
{
Code
string
BonusAmount
float64
MaxUses
int
ExpiresAt
*
time
.
Time
Notes
string
}
// UpdatePromoCodeInput 更新优惠码输入
type
UpdatePromoCodeInput
struct
{
Code
*
string
BonusAmount
*
float64
MaxUses
*
int
Status
*
string
ExpiresAt
*
time
.
Time
Notes
*
string
}
backend/internal/service/promo_code_repository.go
0 → 100644
View file @
1a641392
package
service
import
(
"context"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
// PromoCodeRepository 优惠码仓储接口
type
PromoCodeRepository
interface
{
// 基础 CRUD
Create
(
ctx
context
.
Context
,
code
*
PromoCode
)
error
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
PromoCode
,
error
)
GetByCode
(
ctx
context
.
Context
,
code
string
)
(
*
PromoCode
,
error
)
GetByCodeForUpdate
(
ctx
context
.
Context
,
code
string
)
(
*
PromoCode
,
error
)
// 带行锁的查询,用于并发控制
Update
(
ctx
context
.
Context
,
code
*
PromoCode
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
// 列表查询
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
PromoCode
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
search
string
)
([]
PromoCode
,
*
pagination
.
PaginationResult
,
error
)
// 使用记录
CreateUsage
(
ctx
context
.
Context
,
usage
*
PromoCodeUsage
)
error
GetUsageByPromoCodeAndUser
(
ctx
context
.
Context
,
promoCodeID
,
userID
int64
)
(
*
PromoCodeUsage
,
error
)
ListUsagesByPromoCode
(
ctx
context
.
Context
,
promoCodeID
int64
,
params
pagination
.
PaginationParams
)
([]
PromoCodeUsage
,
*
pagination
.
PaginationResult
,
error
)
// 计数操作
IncrementUsedCount
(
ctx
context
.
Context
,
id
int64
)
error
}
backend/internal/service/promo_service.go
0 → 100644
View file @
1a641392
package
service
import
(
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
var
(
ErrPromoCodeNotFound
=
infraerrors
.
NotFound
(
"PROMO_CODE_NOT_FOUND"
,
"promo code not found"
)
ErrPromoCodeExpired
=
infraerrors
.
BadRequest
(
"PROMO_CODE_EXPIRED"
,
"promo code has expired"
)
ErrPromoCodeDisabled
=
infraerrors
.
BadRequest
(
"PROMO_CODE_DISABLED"
,
"promo code is disabled"
)
ErrPromoCodeMaxUsed
=
infraerrors
.
BadRequest
(
"PROMO_CODE_MAX_USED"
,
"promo code has reached maximum uses"
)
ErrPromoCodeAlreadyUsed
=
infraerrors
.
Conflict
(
"PROMO_CODE_ALREADY_USED"
,
"you have already used this promo code"
)
ErrPromoCodeInvalid
=
infraerrors
.
BadRequest
(
"PROMO_CODE_INVALID"
,
"invalid promo code"
)
)
// PromoService 优惠码服务
type
PromoService
struct
{
promoRepo
PromoCodeRepository
userRepo
UserRepository
billingCacheService
*
BillingCacheService
entClient
*
dbent
.
Client
}
// NewPromoService 创建优惠码服务实例
func
NewPromoService
(
promoRepo
PromoCodeRepository
,
userRepo
UserRepository
,
billingCacheService
*
BillingCacheService
,
entClient
*
dbent
.
Client
,
)
*
PromoService
{
return
&
PromoService
{
promoRepo
:
promoRepo
,
userRepo
:
userRepo
,
billingCacheService
:
billingCacheService
,
entClient
:
entClient
,
}
}
// ValidatePromoCode 验证优惠码(注册前调用)
// 返回 nil, nil 表示空码(不报错)
func
(
s
*
PromoService
)
ValidatePromoCode
(
ctx
context
.
Context
,
code
string
)
(
*
PromoCode
,
error
)
{
code
=
strings
.
TrimSpace
(
code
)
if
code
==
""
{
return
nil
,
nil
// 空码不报错,直接返回
}
promoCode
,
err
:=
s
.
promoRepo
.
GetByCode
(
ctx
,
code
)
if
err
!=
nil
{
// 保留原始错误类型,不要统一映射为 NotFound
return
nil
,
err
}
if
err
:=
s
.
validatePromoCodeStatus
(
promoCode
);
err
!=
nil
{
return
nil
,
err
}
return
promoCode
,
nil
}
// validatePromoCodeStatus 验证优惠码状态
func
(
s
*
PromoService
)
validatePromoCodeStatus
(
promoCode
*
PromoCode
)
error
{
if
!
promoCode
.
CanUse
()
{
if
promoCode
.
IsExpired
()
{
return
ErrPromoCodeExpired
}
if
promoCode
.
Status
==
PromoCodeStatusDisabled
{
return
ErrPromoCodeDisabled
}
if
promoCode
.
MaxUses
>
0
&&
promoCode
.
UsedCount
>=
promoCode
.
MaxUses
{
return
ErrPromoCodeMaxUsed
}
return
ErrPromoCodeInvalid
}
return
nil
}
// ApplyPromoCode 应用优惠码(注册成功后调用)
// 使用事务和行锁确保并发安全
func
(
s
*
PromoService
)
ApplyPromoCode
(
ctx
context
.
Context
,
userID
int64
,
code
string
)
error
{
code
=
strings
.
TrimSpace
(
code
)
if
code
==
""
{
return
nil
}
// 开启事务
tx
,
err
:=
s
.
entClient
.
Tx
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"begin transaction: %w"
,
err
)
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
// 在事务中获取并锁定优惠码记录(FOR UPDATE)
promoCode
,
err
:=
s
.
promoRepo
.
GetByCodeForUpdate
(
txCtx
,
code
)
if
err
!=
nil
{
return
err
}
// 在事务中验证优惠码状态
if
err
:=
s
.
validatePromoCodeStatus
(
promoCode
);
err
!=
nil
{
return
err
}
// 在事务中检查用户是否已使用过此优惠码
existing
,
err
:=
s
.
promoRepo
.
GetUsageByPromoCodeAndUser
(
txCtx
,
promoCode
.
ID
,
userID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"check existing usage: %w"
,
err
)
}
if
existing
!=
nil
{
return
ErrPromoCodeAlreadyUsed
}
// 增加用户余额
if
err
:=
s
.
userRepo
.
UpdateBalance
(
txCtx
,
userID
,
promoCode
.
BonusAmount
);
err
!=
nil
{
return
fmt
.
Errorf
(
"update user balance: %w"
,
err
)
}
// 创建使用记录
usage
:=
&
PromoCodeUsage
{
PromoCodeID
:
promoCode
.
ID
,
UserID
:
userID
,
BonusAmount
:
promoCode
.
BonusAmount
,
UsedAt
:
time
.
Now
(),
}
if
err
:=
s
.
promoRepo
.
CreateUsage
(
txCtx
,
usage
);
err
!=
nil
{
return
fmt
.
Errorf
(
"create usage record: %w"
,
err
)
}
// 增加使用次数
if
err
:=
s
.
promoRepo
.
IncrementUsedCount
(
txCtx
,
promoCode
.
ID
);
err
!=
nil
{
return
fmt
.
Errorf
(
"increment used count: %w"
,
err
)
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
fmt
.
Errorf
(
"commit transaction: %w"
,
err
)
}
// 失效余额缓存
if
s
.
billingCacheService
!=
nil
{
go
func
()
{
cacheCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
_
=
s
.
billingCacheService
.
InvalidateUserBalance
(
cacheCtx
,
userID
)
}()
}
return
nil
}
// GenerateRandomCode 生成随机优惠码
func
(
s
*
PromoService
)
GenerateRandomCode
()
(
string
,
error
)
{
bytes
:=
make
([]
byte
,
8
)
if
_
,
err
:=
rand
.
Read
(
bytes
);
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"generate random bytes: %w"
,
err
)
}
return
strings
.
ToUpper
(
hex
.
EncodeToString
(
bytes
)),
nil
}
// Create 创建优惠码
func
(
s
*
PromoService
)
Create
(
ctx
context
.
Context
,
input
*
CreatePromoCodeInput
)
(
*
PromoCode
,
error
)
{
code
:=
strings
.
TrimSpace
(
input
.
Code
)
if
code
==
""
{
// 自动生成
var
err
error
code
,
err
=
s
.
GenerateRandomCode
()
if
err
!=
nil
{
return
nil
,
err
}
}
promoCode
:=
&
PromoCode
{
Code
:
strings
.
ToUpper
(
code
),
BonusAmount
:
input
.
BonusAmount
,
MaxUses
:
input
.
MaxUses
,
UsedCount
:
0
,
Status
:
PromoCodeStatusActive
,
ExpiresAt
:
input
.
ExpiresAt
,
Notes
:
input
.
Notes
,
}
if
err
:=
s
.
promoRepo
.
Create
(
ctx
,
promoCode
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create promo code: %w"
,
err
)
}
return
promoCode
,
nil
}
// GetByID 根据ID获取优惠码
func
(
s
*
PromoService
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
PromoCode
,
error
)
{
code
,
err
:=
s
.
promoRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
err
}
return
code
,
nil
}
// Update 更新优惠码
func
(
s
*
PromoService
)
Update
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdatePromoCodeInput
)
(
*
PromoCode
,
error
)
{
promoCode
,
err
:=
s
.
promoRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
err
}
if
input
.
Code
!=
nil
{
promoCode
.
Code
=
strings
.
ToUpper
(
strings
.
TrimSpace
(
*
input
.
Code
))
}
if
input
.
BonusAmount
!=
nil
{
promoCode
.
BonusAmount
=
*
input
.
BonusAmount
}
if
input
.
MaxUses
!=
nil
{
promoCode
.
MaxUses
=
*
input
.
MaxUses
}
if
input
.
Status
!=
nil
{
promoCode
.
Status
=
*
input
.
Status
}
if
input
.
ExpiresAt
!=
nil
{
promoCode
.
ExpiresAt
=
input
.
ExpiresAt
}
if
input
.
Notes
!=
nil
{
promoCode
.
Notes
=
*
input
.
Notes
}
if
err
:=
s
.
promoRepo
.
Update
(
ctx
,
promoCode
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update promo code: %w"
,
err
)
}
return
promoCode
,
nil
}
// Delete 删除优惠码
func
(
s
*
PromoService
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
if
err
:=
s
.
promoRepo
.
Delete
(
ctx
,
id
);
err
!=
nil
{
return
fmt
.
Errorf
(
"delete promo code: %w"
,
err
)
}
return
nil
}
// List 获取优惠码列表
func
(
s
*
PromoService
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
search
string
)
([]
PromoCode
,
*
pagination
.
PaginationResult
,
error
)
{
return
s
.
promoRepo
.
ListWithFilters
(
ctx
,
params
,
status
,
search
)
}
// ListUsages 获取使用记录
func
(
s
*
PromoService
)
ListUsages
(
ctx
context
.
Context
,
promoCodeID
int64
,
params
pagination
.
PaginationParams
)
([]
PromoCodeUsage
,
*
pagination
.
PaginationResult
,
error
)
{
return
s
.
promoRepo
.
ListUsagesByPromoCode
(
ctx
,
promoCodeID
,
params
)
}
backend/internal/service/ratelimit_service.go
View file @
1a641392
...
...
@@ -345,7 +345,7 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
// 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态
if
status
==
"allowed"
&&
account
.
IsRateLimited
()
{
if
err
:=
s
.
accountRepo
.
ClearRateLimit
(
ctx
,
account
.
ID
);
err
!=
nil
{
if
err
:=
s
.
ClearRateLimit
(
ctx
,
account
.
ID
);
err
!=
nil
{
log
.
Printf
(
"ClearRateLimit failed for account %d: %v"
,
account
.
ID
,
err
)
}
}
...
...
@@ -353,7 +353,10 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
// ClearRateLimit 清除账号的限流状态
func
(
s
*
RateLimitService
)
ClearRateLimit
(
ctx
context
.
Context
,
accountID
int64
)
error
{
return
s
.
accountRepo
.
ClearRateLimit
(
ctx
,
accountID
)
if
err
:=
s
.
accountRepo
.
ClearRateLimit
(
ctx
,
accountID
);
err
!=
nil
{
return
err
}
return
s
.
accountRepo
.
ClearAntigravityQuotaScopes
(
ctx
,
accountID
)
}
func
(
s
*
RateLimitService
)
ClearTempUnschedulable
(
ctx
context
.
Context
,
accountID
int64
)
error
{
...
...
backend/internal/service/setting_service.go
View file @
1a641392
...
...
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
...
...
@@ -64,6 +65,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAPIBaseURL
,
SettingKeyContactInfo
,
SettingKeyDocURL
,
SettingKeyLinuxDoConnectEnabled
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
...
...
@@ -71,6 +73,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
return
nil
,
fmt
.
Errorf
(
"get public settings: %w"
,
err
)
}
linuxDoEnabled
:=
false
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
linuxDoEnabled
=
raw
==
"true"
}
else
{
linuxDoEnabled
=
s
.
cfg
!=
nil
&&
s
.
cfg
.
LinuxDo
.
Enabled
}
return
&
PublicSettings
{
RegistrationEnabled
:
settings
[
SettingKeyRegistrationEnabled
]
==
"true"
,
EmailVerifyEnabled
:
settings
[
SettingKeyEmailVerifyEnabled
]
==
"true"
,
...
...
@@ -82,6 +91,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
APIBaseURL
:
settings
[
SettingKeyAPIBaseURL
],
ContactInfo
:
settings
[
SettingKeyContactInfo
],
DocURL
:
settings
[
SettingKeyDocURL
],
LinuxDoOAuthEnabled
:
linuxDoEnabled
,
},
nil
}
...
...
@@ -111,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyTurnstileSecretKey
]
=
settings
.
TurnstileSecretKey
}
// LinuxDo Connect OAuth 登录(终端用户 SSO)
updates
[
SettingKeyLinuxDoConnectEnabled
]
=
strconv
.
FormatBool
(
settings
.
LinuxDoConnectEnabled
)
updates
[
SettingKeyLinuxDoConnectClientID
]
=
settings
.
LinuxDoConnectClientID
updates
[
SettingKeyLinuxDoConnectRedirectURL
]
=
settings
.
LinuxDoConnectRedirectURL
if
settings
.
LinuxDoConnectClientSecret
!=
""
{
updates
[
SettingKeyLinuxDoConnectClientSecret
]
=
settings
.
LinuxDoConnectClientSecret
}
// OEM设置
updates
[
SettingKeySiteName
]
=
settings
.
SiteName
updates
[
SettingKeySiteLogo
]
=
settings
.
SiteLogo
...
...
@@ -141,8 +159,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
func
(
s
*
SettingService
)
IsRegistrationEnabled
(
ctx
context
.
Context
)
bool
{
value
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyRegistrationEnabled
)
if
err
!=
nil
{
//
默认开放
注册
return
tru
e
//
安全默认:如果设置不存在或查询出错,默认关闭
注册
return
fals
e
}
return
value
==
"true"
}
...
...
@@ -271,6 +289,38 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result
.
SMTPPassword
=
settings
[
SettingKeySMTPPassword
]
result
.
TurnstileSecretKey
=
settings
[
SettingKeyTurnstileSecretKey
]
// LinuxDo Connect 设置:
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
linuxDoBase
:=
config
.
LinuxDoConnectConfig
{}
if
s
.
cfg
!=
nil
{
linuxDoBase
=
s
.
cfg
.
LinuxDo
}
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
result
.
LinuxDoConnectEnabled
=
raw
==
"true"
}
else
{
result
.
LinuxDoConnectEnabled
=
linuxDoBase
.
Enabled
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
LinuxDoConnectClientID
=
linuxDoBase
.
ClientID
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
LinuxDoConnectRedirectURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
LinuxDoConnectRedirectURL
=
linuxDoBase
.
RedirectURL
}
result
.
LinuxDoConnectClientSecret
=
strings
.
TrimSpace
(
settings
[
SettingKeyLinuxDoConnectClientSecret
])
if
result
.
LinuxDoConnectClientSecret
==
""
{
result
.
LinuxDoConnectClientSecret
=
strings
.
TrimSpace
(
linuxDoBase
.
ClientSecret
)
}
result
.
LinuxDoConnectClientSecretConfigured
=
result
.
LinuxDoConnectClientSecret
!=
""
// Model fallback settings
result
.
EnableModelFallback
=
settings
[
SettingKeyEnableModelFallback
]
==
"true"
result
.
FallbackModelAnthropic
=
s
.
getStringOrDefault
(
settings
,
SettingKeyFallbackModelAnthropic
,
"claude-3-5-sonnet-20241022"
)
...
...
@@ -289,6 +339,99 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return
result
}
// GetLinuxDoConnectOAuthConfig 返回用于登录的“最终生效” LinuxDo Connect 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func
(
s
*
SettingService
)
GetLinuxDoConnectOAuthConfig
(
ctx
context
.
Context
)
(
config
.
LinuxDoConnectConfig
,
error
)
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
ServiceUnavailable
(
"CONFIG_NOT_READY"
,
"config not loaded"
)
}
effective
:=
s
.
cfg
.
LinuxDo
keys
:=
[]
string
{
SettingKeyLinuxDoConnectEnabled
,
SettingKeyLinuxDoConnectClientID
,
SettingKeyLinuxDoConnectClientSecret
,
SettingKeyLinuxDoConnectRedirectURL
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
if
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
fmt
.
Errorf
(
"get linuxdo connect settings: %w"
,
err
)
}
if
raw
,
ok
:=
settings
[
SettingKeyLinuxDoConnectEnabled
];
ok
{
effective
.
Enabled
=
raw
==
"true"
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientID
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectClientSecret
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientSecret
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyLinuxDoConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
RedirectURL
=
strings
.
TrimSpace
(
v
)
}
if
!
effective
.
Enabled
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
NotFound
(
"OAUTH_DISABLED"
,
"oauth login is disabled"
)
}
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
if
strings
.
TrimSpace
(
effective
.
ClientID
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client id not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
AuthorizeURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
TokenURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
UserInfoURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
RedirectURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
FrontendRedirectURL
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url not configured"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
AuthorizeURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
TokenURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
UserInfoURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
RedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url invalid"
)
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
effective
.
FrontendRedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url invalid"
)
}
method
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
effective
.
TokenAuthMethod
))
switch
method
{
case
""
,
"client_secret_post"
,
"client_secret_basic"
:
if
strings
.
TrimSpace
(
effective
.
ClientSecret
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client secret not configured"
)
}
case
"none"
:
if
!
effective
.
UsePKCE
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth pkce must be enabled when token_auth_method=none"
)
}
default
:
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token_auth_method invalid"
)
}
return
effective
,
nil
}
// getStringOrDefault 获取字符串值或默认值
func
(
s
*
SettingService
)
getStringOrDefault
(
settings
map
[
string
]
string
,
key
,
defaultValue
string
)
string
{
if
value
,
ok
:=
settings
[
key
];
ok
&&
value
!=
""
{
...
...
backend/internal/service/settings_view.go
View file @
1a641392
...
...
@@ -18,6 +18,13 @@ type SystemSettings struct {
TurnstileSecretKey
string
TurnstileSecretKeyConfigured
bool
// LinuxDo Connect OAuth 登录(终端用户 SSO)
LinuxDoConnectEnabled
bool
LinuxDoConnectClientID
string
LinuxDoConnectClientSecret
string
LinuxDoConnectClientSecretConfigured
bool
LinuxDoConnectRedirectURL
string
SiteName
string
SiteLogo
string
SiteSubtitle
string
...
...
@@ -51,5 +58,6 @@ type PublicSettings struct {
APIBaseURL
string
ContactInfo
string
DocURL
string
LinuxDoOAuthEnabled
bool
Version
string
}
backend/internal/service/usage_log.go
View file @
1a641392
...
...
@@ -39,6 +39,7 @@ type UsageLog struct {
DurationMs
*
int
FirstTokenMs
*
int
UserAgent
*
string
IPAddress
*
string
// 图片生成字段
ImageCount
int
...
...
backend/internal/service/wire.go
View file @
1a641392
...
...
@@ -87,6 +87,7 @@ var ProviderSet = wire.NewSet(
NewAccountService
,
NewProxyService
,
NewRedeemService
,
NewPromoService
,
NewUsageService
,
NewDashboardService
,
ProvidePricingService
,
...
...
backend/migrations/031_add_ip_address.sql
0 → 100644
View file @
1a641392
-- Add IP address field to usage_logs table for request tracking (admin-only visibility)
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
ip_address
VARCHAR
(
45
);
-- Create index for IP address queries
CREATE
INDEX
IF
NOT
EXISTS
idx_usage_logs_ip_address
ON
usage_logs
(
ip_address
);
backend/migrations/032_add_api_key_ip_restriction.sql
0 → 100644
View file @
1a641392
-- Add IP restriction fields to api_keys table
-- ip_whitelist: JSON array of allowed IPs/CIDRs (if set, only these IPs can use the key)
-- ip_blacklist: JSON array of blocked IPs/CIDRs (these IPs are always blocked)
ALTER
TABLE
api_keys
ADD
COLUMN
IF
NOT
EXISTS
ip_whitelist
JSONB
DEFAULT
NULL
;
ALTER
TABLE
api_keys
ADD
COLUMN
IF
NOT
EXISTS
ip_blacklist
JSONB
DEFAULT
NULL
;
COMMENT
ON
COLUMN
api_keys
.
ip_whitelist
IS
'JSON array of allowed IPs/CIDRs, e.g. ["192.168.1.100", "10.0.0.0/8"]'
;
COMMENT
ON
COLUMN
api_keys
.
ip_blacklist
IS
'JSON array of blocked IPs/CIDRs, e.g. ["1.2.3.4", "5.6.0.0/16"]'
;
backend/migrations/033_add_promo_codes.sql
0 → 100644
View file @
1a641392
-- 创建注册优惠码表
CREATE
TABLE
IF
NOT
EXISTS
promo_codes
(
id
BIGSERIAL
PRIMARY
KEY
,
code
VARCHAR
(
32
)
NOT
NULL
UNIQUE
,
bonus_amount
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
,
max_uses
INT
NOT
NULL
DEFAULT
0
,
used_count
INT
NOT
NULL
DEFAULT
0
,
status
VARCHAR
(
20
)
NOT
NULL
DEFAULT
'active'
,
expires_at
TIMESTAMPTZ
DEFAULT
NULL
,
notes
TEXT
DEFAULT
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
-- 创建优惠码使用记录表
CREATE
TABLE
IF
NOT
EXISTS
promo_code_usages
(
id
BIGSERIAL
PRIMARY
KEY
,
promo_code_id
BIGINT
NOT
NULL
REFERENCES
promo_codes
(
id
)
ON
DELETE
CASCADE
,
user_id
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
bonus_amount
DECIMAL
(
20
,
8
)
NOT
NULL
,
used_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
UNIQUE
(
promo_code_id
,
user_id
)
);
-- 索引
CREATE
INDEX
IF
NOT
EXISTS
idx_promo_codes_status
ON
promo_codes
(
status
);
CREATE
INDEX
IF
NOT
EXISTS
idx_promo_codes_expires_at
ON
promo_codes
(
expires_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_promo_code_usages_promo_code_id
ON
promo_code_usages
(
promo_code_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_promo_code_usages_user_id
ON
promo_code_usages
(
user_id
);
COMMENT
ON
TABLE
promo_codes
IS
'注册优惠码'
;
COMMENT
ON
TABLE
promo_code_usages
IS
'优惠码使用记录'
;
COMMENT
ON
COLUMN
promo_codes
.
max_uses
IS
'最大使用次数,0表示无限制'
;
COMMENT
ON
COLUMN
promo_codes
.
status
IS
'状态: active, disabled'
;
config.yaml
View file @
1a641392
...
...
@@ -154,9 +154,9 @@ gateway:
# Stream keepalive interval (seconds), 0=disable
# 流式 keepalive 间隔(秒),0=禁用
stream_keepalive_interval
:
10
# SSE max line size in bytes (default:
1
0MB)
# SSE 单行最大字节数(默认
1
0MB)
max_line_size
:
1048576
0
# SSE max line size in bytes (default:
4
0MB)
# SSE 单行最大字节数(默认
4
0MB)
max_line_size
:
4194304
0
# Log upstream error response body summary (safe/truncated; does not log request content)
# 记录上游错误响应体摘要(安全/截断;不记录请求内容)
log_upstream_error_body
:
false
...
...
deploy/config.example.yaml
View file @
1a641392
...
...
@@ -154,9 +154,9 @@ gateway:
# Stream keepalive interval (seconds), 0=disable
# 流式 keepalive 间隔(秒),0=禁用
stream_keepalive_interval
:
10
# SSE max line size in bytes (default:
1
0MB)
# SSE 单行最大字节数(默认
1
0MB)
max_line_size
:
1048576
0
# SSE max line size in bytes (default:
4
0MB)
# SSE 单行最大字节数(默认
4
0MB)
max_line_size
:
4194304
0
# Log upstream error response body summary (safe/truncated; does not log request content)
# 记录上游错误响应体摘要(安全/截断;不记录请求内容)
log_upstream_error_body
:
false
...
...
@@ -234,6 +234,31 @@ jwt:
# 令牌过期时间(小时,最大 24)
expire_hour
:
24
# =============================================================================
# LinuxDo Connect OAuth Login (SSO)
# LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
linuxdo_connect
:
enabled
:
false
client_id
:
"
"
client_secret
:
"
"
authorize_url
:
"
https://connect.linux.do/oauth2/authorize"
token_url
:
"
https://connect.linux.do/oauth2/token"
userinfo_url
:
"
https://connect.linux.do/api/user"
scopes
:
"
user"
# 示例: "https://your-domain.com/api/v1/auth/oauth/linuxdo/callback"
redirect_url
:
"
"
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url
:
"
/auth/linuxdo/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
false
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Default Settings
# 默认设置
...
...
deploy/docker-compose.standalone.yml
0 → 100644
View file @
1a641392
# =============================================================================
# Sub2API Docker Compose - Standalone Configuration
# =============================================================================
# This configuration runs only the Sub2API application.
# PostgreSQL and Redis must be provided externally.
#
# Usage:
# 1. Copy .env.example to .env and configure database/redis connection
# 2. docker-compose -f docker-compose.standalone.yml up -d
# 3. Access: http://localhost:8080
# =============================================================================
services
:
sub2api
:
image
:
weishaw/sub2api:latest
container_name
:
sub2api
restart
:
unless-stopped
ulimits
:
nofile
:
soft
:
100000
hard
:
100000
ports
:
-
"
${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080"
volumes
:
-
sub2api_data:/app/data
extra_hosts
:
-
"
host.docker.internal:host-gateway"
environment
:
# =======================================================================
# Auto Setup
# =======================================================================
-
AUTO_SETUP=true
# =======================================================================
# Server Configuration
# =======================================================================
-
SERVER_HOST=0.0.0.0
-
SERVER_PORT=8080
-
SERVER_MODE=${SERVER_MODE:-release}
-
RUN_MODE=${RUN_MODE:-standard}
# =======================================================================
# Database Configuration (PostgreSQL) - Required
# =======================================================================
-
DATABASE_HOST=${DATABASE_HOST:?DATABASE_HOST is required}
-
DATABASE_PORT=${DATABASE_PORT:-5432}
-
DATABASE_USER=${DATABASE_USER:-sub2api}
-
DATABASE_PASSWORD=${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
-
DATABASE_DBNAME=${DATABASE_DBNAME:-sub2api}
-
DATABASE_SSLMODE=${DATABASE_SSLMODE:-disable}
# =======================================================================
# Redis Configuration - Required
# =======================================================================
-
REDIS_HOST=${REDIS_HOST:?REDIS_HOST is required}
-
REDIS_PORT=${REDIS_PORT:-6379}
-
REDIS_PASSWORD=${REDIS_PASSWORD:-}
-
REDIS_DB=${REDIS_DB:-0}
# =======================================================================
# Admin Account (auto-created on first run)
# =======================================================================
-
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local}
-
ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
# =======================================================================
# JWT Configuration
# =======================================================================
-
JWT_SECRET=${JWT_SECRET:-}
-
JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
# =======================================================================
# Timezone Configuration
# =======================================================================
-
TZ=${TZ:-Asia/Shanghai}
# =======================================================================
# Gemini OAuth Configuration (optional)
# =======================================================================
-
GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
-
GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
-
GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
-
GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
healthcheck
:
test
:
[
"
CMD"
,
"
curl"
,
"
-f"
,
"
http://localhost:8080/health"
]
interval
:
30s
timeout
:
10s
retries
:
3
start_period
:
30s
volumes
:
sub2api_data
:
driver
:
local
deploy/docker-compose.yml
View file @
1a641392
...
...
@@ -173,11 +173,12 @@ services:
volumes
:
-
redis_data:/data
command
:
>
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
environment
:
-
TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
...
...
Prev
1
…
3
4
5
6
7
8
9
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