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
c229f33e
Commit
c229f33e
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(review): harden payment, oauth, and migration paths
parent
7fbd5177
Changes
33
Show whitespace changes
Inline
Side-by-side
backend/ent/migrate/schema.go
View file @
c229f33e
...
@@ -361,7 +361,7 @@ var (
...
@@ -361,7 +361,7 @@ var (
Symbol
:
"auth_identities_users_auth_identities"
,
Symbol
:
"auth_identities_users_auth_identities"
,
Columns
:
[]
*
schema
.
Column
{
AuthIdentitiesColumns
[
9
]},
Columns
:
[]
*
schema
.
Column
{
AuthIdentitiesColumns
[
9
]},
RefColumns
:
[]
*
schema
.
Column
{
UsersColumns
[
0
]},
RefColumns
:
[]
*
schema
.
Column
{
UsersColumns
[
0
]},
OnDelete
:
schema
.
NoAction
,
OnDelete
:
schema
.
Cascade
,
},
},
},
},
Indexes
:
[]
*
schema
.
Index
{
Indexes
:
[]
*
schema
.
Index
{
...
@@ -405,7 +405,7 @@ var (
...
@@ -405,7 +405,7 @@ var (
Symbol
:
"auth_identity_channels_auth_identities_channels"
,
Symbol
:
"auth_identity_channels_auth_identities_channels"
,
Columns
:
[]
*
schema
.
Column
{
AuthIdentityChannelsColumns
[
9
]},
Columns
:
[]
*
schema
.
Column
{
AuthIdentityChannelsColumns
[
9
]},
RefColumns
:
[]
*
schema
.
Column
{
AuthIdentitiesColumns
[
0
]},
RefColumns
:
[]
*
schema
.
Column
{
AuthIdentitiesColumns
[
0
]},
OnDelete
:
schema
.
NoAction
,
OnDelete
:
schema
.
Cascade
,
},
},
},
},
Indexes
:
[]
*
schema
.
Index
{
Indexes
:
[]
*
schema
.
Index
{
...
@@ -595,7 +595,7 @@ var (
...
@@ -595,7 +595,7 @@ var (
Symbol
:
"identity_adoption_decisions_pending_auth_sessions_adoption_decision"
,
Symbol
:
"identity_adoption_decisions_pending_auth_sessions_adoption_decision"
,
Columns
:
[]
*
schema
.
Column
{
IdentityAdoptionDecisionsColumns
[
7
]},
Columns
:
[]
*
schema
.
Column
{
IdentityAdoptionDecisionsColumns
[
7
]},
RefColumns
:
[]
*
schema
.
Column
{
PendingAuthSessionsColumns
[
0
]},
RefColumns
:
[]
*
schema
.
Column
{
PendingAuthSessionsColumns
[
0
]},
OnDelete
:
schema
.
NoAction
,
OnDelete
:
schema
.
Cascade
,
},
},
},
},
Indexes
:
[]
*
schema
.
Index
{
Indexes
:
[]
*
schema
.
Index
{
...
@@ -692,8 +692,11 @@ var (
...
@@ -692,8 +692,11 @@ var (
Indexes
:
[]
*
schema
.
Index
{
Indexes
:
[]
*
schema
.
Index
{
{
{
Name
:
"paymentorder_out_trade_no"
,
Name
:
"paymentorder_out_trade_no"
,
Unique
:
fals
e
,
Unique
:
tru
e
,
Columns
:
[]
*
schema
.
Column
{
PaymentOrdersColumns
[
8
]},
Columns
:
[]
*
schema
.
Column
{
PaymentOrdersColumns
[
8
]},
Annotation
:
&
entsql
.
IndexAnnotation
{
Where
:
"out_trade_no <> ''"
,
},
},
},
{
{
Name
:
"paymentorder_user_id"
,
Name
:
"paymentorder_user_id"
,
...
...
backend/ent/schema/auth_identity.go
View file @
c229f33e
...
@@ -79,7 +79,8 @@ func (AuthIdentity) Edges() []ent.Edge {
...
@@ -79,7 +79,8 @@ func (AuthIdentity) Edges() []ent.Edge {
Field
(
"user_id"
)
.
Field
(
"user_id"
)
.
Required
()
.
Required
()
.
Unique
(),
Unique
(),
edge
.
To
(
"channels"
,
AuthIdentityChannel
.
Type
),
edge
.
To
(
"channels"
,
AuthIdentityChannel
.
Type
)
.
Annotations
(
entsql
.
OnDelete
(
entsql
.
Cascade
)),
edge
.
To
(
"adoption_decisions"
,
IdentityAdoptionDecision
.
Type
),
edge
.
To
(
"adoption_decisions"
,
IdentityAdoptionDecision
.
Type
),
}
}
}
}
...
...
backend/ent/schema/payment_order.go
View file @
c229f33e
...
@@ -185,7 +185,9 @@ func (PaymentOrder) Edges() []ent.Edge {
...
@@ -185,7 +185,9 @@ func (PaymentOrder) Edges() []ent.Edge {
func
(
PaymentOrder
)
Indexes
()
[]
ent
.
Index
{
func
(
PaymentOrder
)
Indexes
()
[]
ent
.
Index
{
return
[]
ent
.
Index
{
return
[]
ent
.
Index
{
index
.
Fields
(
"out_trade_no"
),
index
.
Fields
(
"out_trade_no"
)
.
Unique
()
.
Annotations
(
entsql
.
IndexWhere
(
"out_trade_no <> ''"
)),
index
.
Fields
(
"user_id"
),
index
.
Fields
(
"user_id"
),
index
.
Fields
(
"status"
),
index
.
Fields
(
"status"
),
index
.
Fields
(
"expires_at"
),
index
.
Fields
(
"expires_at"
),
...
...
backend/ent/schema/pending_auth_session.go
View file @
c229f33e
...
@@ -119,6 +119,7 @@ func (PendingAuthSession) Edges() []ent.Edge {
...
@@ -119,6 +119,7 @@ func (PendingAuthSession) Edges() []ent.Edge {
Field
(
"target_user_id"
)
.
Field
(
"target_user_id"
)
.
Unique
(),
Unique
(),
edge
.
To
(
"adoption_decision"
,
IdentityAdoptionDecision
.
Type
)
.
edge
.
To
(
"adoption_decision"
,
IdentityAdoptionDecision
.
Type
)
.
Annotations
(
entsql
.
OnDelete
(
entsql
.
Cascade
))
.
Unique
(),
Unique
(),
}
}
}
}
...
...
backend/ent/schema/user.go
View file @
c229f33e
...
@@ -115,7 +115,8 @@ func (User) Edges() []ent.Edge {
...
@@ -115,7 +115,8 @@ func (User) Edges() []ent.Edge {
edge
.
To
(
"attribute_values"
,
UserAttributeValue
.
Type
),
edge
.
To
(
"attribute_values"
,
UserAttributeValue
.
Type
),
edge
.
To
(
"promo_code_usages"
,
PromoCodeUsage
.
Type
),
edge
.
To
(
"promo_code_usages"
,
PromoCodeUsage
.
Type
),
edge
.
To
(
"payment_orders"
,
PaymentOrder
.
Type
),
edge
.
To
(
"payment_orders"
,
PaymentOrder
.
Type
),
edge
.
To
(
"auth_identities"
,
AuthIdentity
.
Type
),
edge
.
To
(
"auth_identities"
,
AuthIdentity
.
Type
)
.
Annotations
(
entsql
.
OnDelete
(
entsql
.
Cascade
)),
edge
.
To
(
"pending_auth_sessions"
,
PendingAuthSession
.
Type
),
edge
.
To
(
"pending_auth_sessions"
,
PendingAuthSession
.
Type
),
}
}
}
}
...
...
backend/internal/config/config.go
View file @
c229f33e
...
@@ -1202,7 +1202,7 @@ func setDefaults() {
...
@@ -1202,7 +1202,7 @@ func setDefaults() {
viper
.
SetDefault
(
"linuxdo_connect.redirect_url"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.redirect_url"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.frontend_redirect_url"
,
"/auth/linuxdo/callback"
)
viper
.
SetDefault
(
"linuxdo_connect.frontend_redirect_url"
,
"/auth/linuxdo/callback"
)
viper
.
SetDefault
(
"linuxdo_connect.token_auth_method"
,
"client_secret_post"
)
viper
.
SetDefault
(
"linuxdo_connect.token_auth_method"
,
"client_secret_post"
)
viper
.
SetDefault
(
"linuxdo_connect.use_pkce"
,
fals
e
)
viper
.
SetDefault
(
"linuxdo_connect.use_pkce"
,
tru
e
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_email_path"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_email_path"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_id_path"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_id_path"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_username_path"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.userinfo_username_path"
,
""
)
...
@@ -1222,7 +1222,7 @@ func setDefaults() {
...
@@ -1222,7 +1222,7 @@ func setDefaults() {
viper
.
SetDefault
(
"oidc_connect.redirect_url"
,
""
)
viper
.
SetDefault
(
"oidc_connect.redirect_url"
,
""
)
viper
.
SetDefault
(
"oidc_connect.frontend_redirect_url"
,
"/auth/oidc/callback"
)
viper
.
SetDefault
(
"oidc_connect.frontend_redirect_url"
,
"/auth/oidc/callback"
)
viper
.
SetDefault
(
"oidc_connect.token_auth_method"
,
"client_secret_post"
)
viper
.
SetDefault
(
"oidc_connect.token_auth_method"
,
"client_secret_post"
)
viper
.
SetDefault
(
"oidc_connect.use_pkce"
,
fals
e
)
viper
.
SetDefault
(
"oidc_connect.use_pkce"
,
tru
e
)
viper
.
SetDefault
(
"oidc_connect.validate_id_token"
,
true
)
viper
.
SetDefault
(
"oidc_connect.validate_id_token"
,
true
)
viper
.
SetDefault
(
"oidc_connect.allowed_signing_algs"
,
"RS256,ES256,PS256"
)
viper
.
SetDefault
(
"oidc_connect.allowed_signing_algs"
,
"RS256,ES256,PS256"
)
viper
.
SetDefault
(
"oidc_connect.clock_skew_seconds"
,
120
)
viper
.
SetDefault
(
"oidc_connect.clock_skew_seconds"
,
120
)
...
...
backend/internal/handler/auth_linuxdo_oauth.go
View file @
c229f33e
...
@@ -937,7 +937,19 @@ func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
...
@@ -937,7 +937,19 @@ func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
Value
:
""
,
Value
:
""
,
Path
:
oauthBindAccessTokenCookiePath
,
Path
:
oauthBindAccessTokenCookiePath
,
MaxAge
:
-
1
,
MaxAge
:
-
1
,
HttpOnly
:
false
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
setOAuthBindAccessTokenCookie
(
c
*
gin
.
Context
,
token
string
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthBindAccessTokenCookieName
,
Value
:
url
.
QueryEscape
(
strings
.
TrimSpace
(
token
)),
Path
:
oauthBindAccessTokenCookiePath
,
MaxAge
:
linuxDoOAuthCookieMaxAgeSec
,
HttpOnly
:
true
,
Secure
:
secure
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
SameSite
:
http
.
SameSiteLaxMode
,
})
})
...
@@ -1021,6 +1033,26 @@ func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (strin
...
@@ -1021,6 +1033,26 @@ func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (strin
return
buildOAuthBindUserCookieValue
(
*
userID
,
h
.
oauthBindCookieSecret
())
return
buildOAuthBindUserCookieValue
(
*
userID
,
h
.
oauthBindCookieSecret
())
}
}
func
(
h
*
AuthHandler
)
PrepareOAuthBindAccessTokenCookie
(
c
*
gin
.
Context
)
{
const
bearerPrefix
=
"Bearer "
authHeader
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"Authorization"
))
if
!
strings
.
HasPrefix
(
strings
.
ToLower
(
authHeader
),
strings
.
ToLower
(
bearerPrefix
))
{
response
.
ErrorFrom
(
c
,
infraerrors
.
Unauthorized
(
"UNAUTHORIZED"
,
"authentication required"
))
return
}
token
:=
strings
.
TrimSpace
(
authHeader
[
len
(
bearerPrefix
)
:
])
if
token
==
""
{
response
.
ErrorFrom
(
c
,
infraerrors
.
Unauthorized
(
"UNAUTHORIZED"
,
"authentication required"
))
return
}
setOAuthBindAccessTokenCookie
(
c
,
token
,
isRequestHTTPS
(
c
))
c
.
Status
(
http
.
StatusNoContent
)
c
.
Writer
.
WriteHeaderNow
()
}
func
(
h
*
AuthHandler
)
resolveOAuthBindTargetUserID
(
c
*
gin
.
Context
)
(
*
int64
,
error
)
{
func
(
h
*
AuthHandler
)
resolveOAuthBindTargetUserID
(
c
*
gin
.
Context
)
(
*
int64
,
error
)
{
if
subject
,
ok
:=
servermiddleware
.
GetAuthSubjectFromContext
(
c
);
ok
&&
subject
.
UserID
>
0
{
if
subject
,
ok
:=
servermiddleware
.
GetAuthSubjectFromContext
(
c
);
ok
&&
subject
.
UserID
>
0
{
return
&
subject
.
UserID
,
nil
return
&
subject
.
UserID
,
nil
...
...
backend/internal/handler/auth_linuxdo_oauth_test.go
View file @
c229f33e
...
@@ -5,6 +5,7 @@ import (
...
@@ -5,6 +5,7 @@ import (
"context"
"context"
"net/http"
"net/http"
"net/http/httptest"
"net/http/httptest"
"net/url"
"strings"
"strings"
"testing"
"testing"
"time"
"time"
...
@@ -226,6 +227,27 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
...
@@ -226,6 +227,27 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
require
.
Equal
(
t
,
-
1
,
accessTokenCookie
.
MaxAge
)
require
.
Equal
(
t
,
-
1
,
accessTokenCookie
.
MaxAge
)
}
}
func
TestPrepareOAuthBindAccessTokenCookieSetsHttpOnlyCookie
(
t
*
testing
.
T
)
{
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/bind-token"
,
nil
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer access-token-value"
)
c
.
Request
=
req
handler
.
PrepareOAuthBindAccessTokenCookie
(
c
)
require
.
Equal
(
t
,
http
.
StatusNoContent
,
recorder
.
Code
)
accessTokenCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthBindAccessTokenCookieName
)
require
.
NotNil
(
t
,
accessTokenCookie
)
require
.
Equal
(
t
,
oauthBindAccessTokenCookiePath
,
accessTokenCookie
.
Path
)
require
.
Equal
(
t
,
linuxDoOAuthCookieMaxAgeSec
,
accessTokenCookie
.
MaxAge
)
require
.
True
(
t
,
accessTokenCookie
.
HttpOnly
)
require
.
Equal
(
t
,
url
.
QueryEscape
(
"access-token-value"
),
accessTokenCookie
.
Value
)
}
func
TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser
(
t
*
testing
.
T
)
{
func
TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
switch
r
.
URL
.
Path
{
...
...
backend/internal/payment/wire.go
View file @
c229f33e
...
@@ -4,6 +4,7 @@ import (
...
@@ -4,6 +4,7 @@ import (
"encoding/hex"
"encoding/hex"
"fmt"
"fmt"
"log/slog"
"log/slog"
"strings"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
...
@@ -19,11 +20,22 @@ type EncryptionKey []byte
...
@@ -19,11 +20,22 @@ type EncryptionKey []byte
// When the key is non-empty but invalid (bad hex or wrong length), an error is returned
// When the key is non-empty but invalid (bad hex or wrong length), an error is returned
// to prevent startup with a misconfigured encryption key.
// to prevent startup with a misconfigured encryption key.
func
ProvideEncryptionKey
(
cfg
*
config
.
Config
)
(
EncryptionKey
,
error
)
{
func
ProvideEncryptionKey
(
cfg
*
config
.
Config
)
(
EncryptionKey
,
error
)
{
if
cfg
.
Totp
.
EncryptionKey
==
""
{
if
cfg
==
nil
{
slog
.
Warn
(
"payment encryption key not configured — encrypted payment config and resume signing will be unavailable"
)
return
nil
,
nil
}
keyHex
:=
strings
.
TrimSpace
(
cfg
.
Totp
.
EncryptionKey
)
if
keyHex
==
""
{
slog
.
Warn
(
"payment encryption key not configured — encrypted payment config will be unavailable"
)
slog
.
Warn
(
"payment encryption key not configured — encrypted payment config will be unavailable"
)
return
nil
,
nil
return
nil
,
nil
}
}
key
,
err
:=
hex
.
DecodeString
(
cfg
.
Totp
.
EncryptionKey
)
// Reject auto-generated TOTP keys for payment signing.
// They change across restarts/instances and can silently break resume-token flows.
if
!
cfg
.
Totp
.
EncryptionKeyConfigured
{
slog
.
Warn
(
"payment encryption/signing key is not explicitly configured; set TOTP_ENCRYPTION_KEY to enable payment resume tokens"
)
return
nil
,
nil
}
key
,
err
:=
hex
.
DecodeString
(
keyHex
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid payment encryption key (hex decode): %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"invalid payment encryption key (hex decode): %w"
,
err
)
}
}
...
...
backend/internal/payment/wire_test.go
0 → 100644
View file @
c229f33e
package
payment
import
(
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
)
func
TestProvideEncryptionKeySkipsAutoGeneratedTotpKey
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
&
config
.
Config
{
Totp
:
config
.
TotpConfig
{
EncryptionKey
:
strings
.
Repeat
(
"a"
,
64
),
EncryptionKeyConfigured
:
false
,
},
}
key
,
err
:=
ProvideEncryptionKey
(
cfg
)
if
err
!=
nil
{
t
.
Fatalf
(
"ProvideEncryptionKey returned error: %v"
,
err
)
}
if
len
(
key
)
!=
0
{
t
.
Fatalf
(
"encryption key len = %d, want 0"
,
len
(
key
))
}
}
func
TestProvideEncryptionKeyUsesConfiguredTotpKey
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
&
config
.
Config
{
Totp
:
config
.
TotpConfig
{
EncryptionKey
:
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
,
EncryptionKeyConfigured
:
true
,
},
}
key
,
err
:=
ProvideEncryptionKey
(
cfg
)
if
err
!=
nil
{
t
.
Fatalf
(
"ProvideEncryptionKey returned error: %v"
,
err
)
}
if
len
(
key
)
!=
32
{
t
.
Fatalf
(
"encryption key len = %d, want 32"
,
len
(
key
))
}
}
func
TestProvideEncryptionKeyRejectsConfiguredInvalidLength
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
&
config
.
Config
{
Totp
:
config
.
TotpConfig
{
EncryptionKey
:
"abcd"
,
EncryptionKeyConfigured
:
true
,
},
}
_
,
err
:=
ProvideEncryptionKey
(
cfg
)
if
err
==
nil
{
t
.
Fatal
(
"expected error for invalid key length"
)
}
}
backend/internal/server/routes/auth.go
View file @
c229f33e
...
@@ -164,6 +164,7 @@ func RegisterAuthRoutes(
...
@@ -164,6 +164,7 @@ func RegisterAuthRoutes(
authenticated
.
GET
(
"/auth/me"
,
h
.
Auth
.
GetCurrentUser
)
authenticated
.
GET
(
"/auth/me"
,
h
.
Auth
.
GetCurrentUser
)
// 撤销所有会话(需要认证)
// 撤销所有会话(需要认证)
authenticated
.
POST
(
"/auth/revoke-all-sessions"
,
h
.
Auth
.
RevokeAllSessions
)
authenticated
.
POST
(
"/auth/revoke-all-sessions"
,
h
.
Auth
.
RevokeAllSessions
)
authenticated
.
POST
(
"/auth/oauth/bind-token"
,
h
.
Auth
.
PrepareOAuthBindAccessTokenCookie
)
authenticated
.
GET
(
"/auth/oauth/linuxdo/bind/start"
,
func
(
c
*
gin
.
Context
)
{
authenticated
.
GET
(
"/auth/oauth/linuxdo/bind/start"
,
func
(
c
*
gin
.
Context
)
{
query
:=
c
.
Request
.
URL
.
Query
()
query
:=
c
.
Request
.
URL
.
Query
()
query
.
Set
(
"intent"
,
"bind_current_user"
)
query
.
Set
(
"intent"
,
"bind_current_user"
)
...
...
backend/internal/service/payment_fulfillment.go
View file @
c229f33e
...
@@ -80,21 +80,25 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
...
@@ -80,21 +80,25 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
})
})
return
err
return
err
}
}
// Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount).
if
!
isValidProviderAmount
(
paid
)
{
// Also skip if paid is NaN/Inf (malformed provider data).
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"PAYMENT_INVALID_AMOUNT"
,
pk
,
map
[
string
]
any
{
if
paid
>
0
&&
!
math
.
IsNaN
(
paid
)
&&
!
math
.
IsInf
(
paid
,
0
)
{
"expected"
:
o
.
PayAmount
,
"paid"
:
paid
,
"tradeNo"
:
tradeNo
,
})
return
fmt
.
Errorf
(
"invalid paid amount from provider: %v"
,
paid
)
}
if
math
.
Abs
(
paid
-
o
.
PayAmount
)
>
amountToleranceCNY
{
if
math
.
Abs
(
paid
-
o
.
PayAmount
)
>
amountToleranceCNY
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"PAYMENT_AMOUNT_MISMATCH"
,
pk
,
map
[
string
]
any
{
"expected"
:
o
.
PayAmount
,
"paid"
:
paid
,
"tradeNo"
:
tradeNo
})
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"PAYMENT_AMOUNT_MISMATCH"
,
pk
,
map
[
string
]
any
{
"expected"
:
o
.
PayAmount
,
"paid"
:
paid
,
"tradeNo"
:
tradeNo
})
return
fmt
.
Errorf
(
"amount mismatch: expected %.2f, got %.2f"
,
o
.
PayAmount
,
paid
)
return
fmt
.
Errorf
(
"amount mismatch: expected %.2f, got %.2f"
,
o
.
PayAmount
,
paid
)
}
}
}
// Use order's expected amount when provider didn't report one
if
paid
<=
0
||
math
.
IsNaN
(
paid
)
||
math
.
IsInf
(
paid
,
0
)
{
paid
=
o
.
PayAmount
}
return
s
.
toPaid
(
ctx
,
o
,
tradeNo
,
paid
,
pk
)
return
s
.
toPaid
(
ctx
,
o
,
tradeNo
,
paid
,
pk
)
}
}
func
isValidProviderAmount
(
amount
float64
)
bool
{
return
amount
>
0
&&
!
math
.
IsNaN
(
amount
)
&&
!
math
.
IsInf
(
amount
,
0
)
}
func
validateProviderNotificationMetadata
(
order
*
dbent
.
PaymentOrder
,
providerKey
string
,
metadata
map
[
string
]
string
)
error
{
func
validateProviderNotificationMetadata
(
order
*
dbent
.
PaymentOrder
,
providerKey
string
,
metadata
map
[
string
]
string
)
error
{
return
validateProviderSnapshotMetadata
(
order
,
providerKey
,
metadata
)
return
validateProviderSnapshotMetadata
(
order
,
providerKey
,
metadata
)
}
}
...
...
backend/internal/service/payment_fulfillment_test.go
View file @
c229f33e
...
@@ -5,6 +5,7 @@ package service
...
@@ -5,6 +5,7 @@ package service
import
(
import
(
"context"
"context"
"errors"
"errors"
"math"
"testing"
"testing"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
...
@@ -322,6 +323,16 @@ func TestParseLegacyPaymentOrderID(t *testing.T) {
...
@@ -322,6 +323,16 @@ func TestParseLegacyPaymentOrderID(t *testing.T) {
assert
.
False
(
t
,
ok
)
assert
.
False
(
t
,
ok
)
}
}
func
TestIsValidProviderAmount
(
t
*
testing
.
T
)
{
t
.
Parallel
()
assert
.
True
(
t
,
isValidProviderAmount
(
0.01
))
assert
.
False
(
t
,
isValidProviderAmount
(
0
))
assert
.
False
(
t
,
isValidProviderAmount
(
-
1
))
assert
.
False
(
t
,
isValidProviderAmount
(
math
.
NaN
()))
assert
.
False
(
t
,
isValidProviderAmount
(
math
.
Inf
(
1
)))
}
func
TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch
(
t
*
testing
.
T
)
{
func
TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Parallel
()
...
...
backend/internal/service/payment_order.go
View file @
c229f33e
...
@@ -139,6 +139,10 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
...
@@ -139,6 +139,10 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
tm
=
defaultOrderTimeoutMin
tm
=
defaultOrderTimeoutMin
}
}
exp
:=
time
.
Now
()
.
Add
(
time
.
Duration
(
tm
)
*
time
.
Minute
)
exp
:=
time
.
Now
()
.
Add
(
time
.
Duration
(
tm
)
*
time
.
Minute
)
outTradeNo
,
err
:=
s
.
allocateOutTradeNo
(
ctx
,
tx
)
if
err
!=
nil
{
return
nil
,
err
}
providerSnapshot
:=
buildPaymentOrderProviderSnapshot
(
sel
,
req
)
providerSnapshot
:=
buildPaymentOrderProviderSnapshot
(
sel
,
req
)
selectedInstanceID
:=
""
selectedInstanceID
:=
""
selectedProviderKey
:=
""
selectedProviderKey
:=
""
...
@@ -155,7 +159,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
...
@@ -155,7 +159,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
SetPayAmount
(
payAmount
)
.
SetPayAmount
(
payAmount
)
.
SetFeeRate
(
feeRate
)
.
SetFeeRate
(
feeRate
)
.
SetRechargeCode
(
""
)
.
SetRechargeCode
(
""
)
.
SetOutTradeNo
(
generateO
utTradeNo
()
)
.
SetOutTradeNo
(
o
utTradeNo
)
.
SetPaymentType
(
req
.
PaymentType
)
.
SetPaymentType
(
req
.
PaymentType
)
.
SetPaymentTradeNo
(
""
)
.
SetPaymentTradeNo
(
""
)
.
SetOrderType
(
req
.
OrderType
)
.
SetOrderType
(
req
.
OrderType
)
.
...
@@ -193,6 +197,21 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
...
@@ -193,6 +197,21 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
return
order
,
nil
return
order
,
nil
}
}
func
(
s
*
PaymentService
)
allocateOutTradeNo
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
)
(
string
,
error
)
{
const
maxAttempts
=
5
for
attempt
:=
0
;
attempt
<
maxAttempts
;
attempt
++
{
candidate
:=
generateOutTradeNo
()
exists
,
err
:=
tx
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
OutTradeNo
(
candidate
))
.
Exist
(
ctx
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"check out_trade_no uniqueness: %w"
,
err
)
}
if
!
exists
{
return
candidate
,
nil
}
}
return
""
,
fmt
.
Errorf
(
"generate unique out_trade_no: exhausted %d attempts"
,
maxAttempts
)
}
func
(
s
*
PaymentService
)
checkPendingLimit
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
userID
int64
,
max
int
)
error
{
func
(
s
*
PaymentService
)
checkPendingLimit
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
userID
int64
,
max
int
)
error
{
if
max
<=
0
{
if
max
<=
0
{
max
=
defaultMaxPendingOrders
max
=
defaultMaxPendingOrders
...
@@ -366,7 +385,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
...
@@ -366,7 +385,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
}
resumeToken
:=
""
resumeToken
:=
""
if
resume
:=
s
.
paymentResume
();
resume
!=
nil
{
if
resume
:=
s
.
paymentResume
();
resume
!=
nil
{
if
resume
.
isSigningConfigured
()
{
if
canonicalReturnURL
!=
""
{
if
err
:=
resume
.
ensureSigningKey
();
err
!=
nil
{
return
nil
,
err
}
resumeToken
,
err
=
resume
.
CreateToken
(
ResumeTokenClaims
{
resumeToken
,
err
=
resume
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
OrderID
:
order
.
ID
,
UserID
:
order
.
UserID
,
UserID
:
order
.
UserID
,
...
@@ -482,6 +504,9 @@ func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, r
...
@@ -482,6 +504,9 @@ func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, r
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
if
err
:=
s
.
paymentResume
()
.
ensureSigningKey
();
err
!=
nil
{
return
nil
,
err
}
authorizeURL
,
err
:=
buildWeChatPaymentOAuthStartURL
(
req
,
"snsapi_base"
)
authorizeURL
,
err
:=
buildWeChatPaymentOAuthStartURL
(
req
,
"snsapi_base"
)
if
err
!=
nil
{
if
err
!=
nil
{
...
...
backend/internal/service/payment_order_lifecycle.go
View file @
c229f33e
...
@@ -150,6 +150,16 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
...
@@ -150,6 +150,16 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
return
""
return
""
}
}
if
resp
.
Status
==
payment
.
ProviderStatusPaid
{
if
resp
.
Status
==
payment
.
ProviderStatusPaid
{
if
!
isValidProviderAmount
(
resp
.
Amount
)
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"PAYMENT_INVALID_AMOUNT"
,
prov
.
ProviderKey
(),
map
[
string
]
any
{
"expected"
:
o
.
PayAmount
,
"paid"
:
resp
.
Amount
,
"tradeNo"
:
resp
.
TradeNo
,
"queryRef"
:
queryRef
,
})
slog
.
Warn
(
"query upstream returned invalid paid amount"
,
"orderID"
,
o
.
ID
,
"queryRef"
,
queryRef
,
"paid"
,
resp
.
Amount
)
return
""
}
notificationTradeNo
:=
o
.
PaymentTradeNo
notificationTradeNo
:=
o
.
PaymentTradeNo
if
upstreamTradeNo
:=
strings
.
TrimSpace
(
resp
.
TradeNo
);
paymentOrderShouldPersistUpstreamTradeNo
(
queryRef
,
upstreamTradeNo
,
notificationTradeNo
)
{
if
upstreamTradeNo
:=
strings
.
TrimSpace
(
resp
.
TradeNo
);
paymentOrderShouldPersistUpstreamTradeNo
(
queryRef
,
upstreamTradeNo
,
notificationTradeNo
)
{
if
_
,
updateErr
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
if
_
,
updateErr
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
...
...
backend/internal/service/payment_order_lifecycle_test.go
View file @
c229f33e
...
@@ -234,6 +234,97 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
...
@@ -234,6 +234,97 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
require
.
Equal
(
t
,
user
.
ID
,
redeemRepo
.
useCalls
[
0
]
.
userID
)
require
.
Equal
(
t
,
user
.
ID
,
redeemRepo
.
useCalls
[
0
]
.
userID
)
}
}
func
TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
client
:=
newPaymentOrderLifecycleTestClient
(
t
)
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"checkpaid-zero-amount@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"checkpaid-zero-amount-user"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
88
)
.
SetFeeRate
(
0
)
.
SetRechargeCode
(
"CHECKPAID-ZERO-AMOUNT"
)
.
SetOutTradeNo
(
"sub2_checkpaid_zero_amount"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
""
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
userRepo
:=
&
mockUserRepo
{
getByIDUser
:
&
User
{
ID
:
user
.
ID
,
Email
:
user
.
Email
,
Username
:
user
.
Username
,
Balance
:
0
,
},
}
redeemRepo
:=
&
paymentOrderLifecycleRedeemRepo
{
codesByCode
:
map
[
string
]
*
RedeemCode
{
order
.
RechargeCode
:
{
ID
:
1
,
Code
:
order
.
RechargeCode
,
Type
:
RedeemTypeBalance
,
Value
:
order
.
Amount
,
Status
:
StatusUnused
,
},
},
}
redeemService
:=
NewRedeemService
(
redeemRepo
,
userRepo
,
nil
,
nil
,
nil
,
client
,
nil
,
)
registry
:=
payment
.
NewRegistry
()
provider
:=
&
paymentOrderLifecycleQueryProvider
{
resp
:
&
payment
.
QueryOrderResponse
{
TradeNo
:
"upstream-trade-zero"
,
Status
:
payment
.
ProviderStatusPaid
,
Amount
:
0
,
},
}
registry
.
Register
(
provider
)
svc
:=
&
PaymentService
{
entClient
:
client
,
registry
:
registry
,
redeemService
:
redeemService
,
userRepo
:
userRepo
,
providersLoaded
:
true
,
}
got
,
err
:=
svc
.
VerifyOrderByOutTradeNo
(
ctx
,
order
.
OutTradeNo
,
user
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
order
.
OutTradeNo
,
provider
.
lastQueryTradeNo
)
require
.
Equal
(
t
,
OrderStatusPending
,
got
.
Status
)
require
.
Empty
(
t
,
got
.
PaymentTradeNo
)
reloaded
,
err
:=
client
.
PaymentOrder
.
Get
(
ctx
,
order
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
OrderStatusPending
,
reloaded
.
Status
)
require
.
Empty
(
t
,
reloaded
.
PaymentTradeNo
)
require
.
Equal
(
t
,
0.0
,
userRepo
.
getByIDUser
.
Balance
)
require
.
Empty
(
t
,
redeemRepo
.
useCalls
)
}
func
TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay
(
t
*
testing
.
T
)
{
func
TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
ctx
:=
context
.
Background
()
client
:=
newPaymentOrderLifecycleTestClient
(
t
)
client
:=
newPaymentOrderLifecycleTestClient
(
t
)
...
...
backend/internal/service/payment_order_result_test.go
View file @
c229f33e
...
@@ -159,6 +159,45 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin
...
@@ -159,6 +159,45 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin
}
}
}
}
func
TestMaybeBuildWeChatOAuthRequiredResponseRequiresResumeSigningKey
(
t
*
testing
.
T
)
{
t
.
Parallel
()
svc
:=
&
PaymentService
{
configService
:
&
PaymentConfigService
{
settingRepo
:
&
paymentConfigSettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyWeChatConnectEnabled
:
"true"
,
SettingKeyWeChatConnectAppID
:
"wx123456"
,
SettingKeyWeChatConnectAppSecret
:
"wechat-secret"
,
SettingKeyWeChatConnectMode
:
"mp"
,
SettingKeyWeChatConnectScopes
:
"snsapi_base"
,
SettingKeyWeChatConnectRedirectURL
:
"https://api.example.com/api/v1/auth/oauth/wechat/callback"
,
SettingKeyWeChatConnectFrontendRedirectURL
:
"/auth/wechat/callback"
,
}},
// Intentionally missing payment resume signing key.
encryptionKey
:
nil
,
},
}
resp
,
err
:=
svc
.
maybeBuildWeChatOAuthRequiredResponse
(
context
.
Background
(),
CreateOrderRequest
{
Amount
:
12.5
,
PaymentType
:
payment
.
TypeWxpay
,
IsWeChatBrowser
:
true
,
SrcURL
:
"https://merchant.example/payment?from=wechat"
,
OrderType
:
payment
.
OrderTypeBalance
,
},
12.5
,
12.88
,
0.03
)
if
resp
!=
nil
{
t
.
Fatalf
(
"expected nil response, got %+v"
,
resp
)
}
if
err
==
nil
{
t
.
Fatal
(
"expected error, got nil"
)
}
appErr
:=
infraerrors
.
FromError
(
err
)
if
appErr
.
Reason
!=
"PAYMENT_RESUME_NOT_CONFIGURED"
{
t
.
Fatalf
(
"reason = %q, want %q"
,
appErr
.
Reason
,
"PAYMENT_RESUME_NOT_CONFIGURED"
)
}
}
func
TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider
(
t
*
testing
.
T
)
{
func
TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider
(
t
*
testing
.
T
)
{
svc
:=
newWeChatPaymentOAuthTestService
(
map
[
string
]
string
{
svc
:=
newWeChatPaymentOAuthTestService
(
map
[
string
]
string
{
SettingKeyWeChatConnectEnabled
:
"true"
,
SettingKeyWeChatConnectEnabled
:
"true"
,
...
@@ -190,6 +229,7 @@ func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService
...
@@ -190,6 +229,7 @@ func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService
return
&
PaymentService
{
return
&
PaymentService
{
configService
:
&
PaymentConfigService
{
configService
:
&
PaymentConfigService
{
settingRepo
:
&
paymentConfigSettingRepoStub
{
values
:
values
},
settingRepo
:
&
paymentConfigSettingRepoStub
{
values
:
values
},
encryptionKey
:
[]
byte
(
"0123456789abcdef0123456789abcdef"
),
},
},
}
}
}
}
backend/migrations/112_add_payment_order_provider_key_snapshot.sql
View file @
c229f33e
ALTER
TABLE
payment_orders
ADD
COLUMN
provider_key
VARCHAR
(
30
);
ALTER
TABLE
payment_orders
ADD
COLUMN
IF
NOT
EXISTS
provider_key
VARCHAR
(
30
);
UPDATE
payment_orders
UPDATE
payment_orders
SET
provider_key
=
(
SET
provider_key
=
(
...
...
backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql
View file @
c229f33e
...
@@ -21,12 +21,3 @@ VALUES
...
@@ -21,12 +21,3 @@ VALUES
(
'auth_source_default_oidc_grant_on_signup'
,
'false'
),
(
'auth_source_default_oidc_grant_on_signup'
,
'false'
),
(
'auth_source_default_wechat_grant_on_signup'
,
'false'
)
(
'auth_source_default_wechat_grant_on_signup'
,
'false'
)
ON
CONFLICT
(
key
)
DO
NOTHING
;
ON
CONFLICT
(
key
)
DO
NOTHING
;
UPDATE
settings
SET
value
=
'false'
WHERE
key
IN
(
'auth_source_default_email_grant_on_signup'
,
'auth_source_default_linuxdo_grant_on_signup'
,
'auth_source_default_oidc_grant_on_signup'
,
'auth_source_default_wechat_grant_on_signup'
);
backend/migrations/119_enforce_payment_orders_out_trade_no_unique.sql
0 → 100644
View file @
c229f33e
-- Replace the legacy non-unique index with a partial unique index.
-- Keep empty-string legacy rows compatible while enforcing uniqueness for real order IDs.
DROP
INDEX
IF
EXISTS
paymentorder_out_trade_no
;
CREATE
UNIQUE
INDEX
IF
NOT
EXISTS
paymentorder_out_trade_no
ON
payment_orders
(
out_trade_no
)
WHERE
out_trade_no
<>
''
;
Prev
1
2
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