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
a04ae28a
Commit
a04ae28a
authored
Apr 13, 2026
by
陈曦
Browse files
merge v0.1.111
parents
68f67198
ad64190b
Changes
302
Show whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
302 of 302+
files are displayed.
Plain diff
Email patch
backend/internal/service/openai_messages_dispatch_test.go
0 → 100644
View file @
a04ae28a
package
service
import
"testing"
import
"github.com/stretchr/testify/require"
func
TestNormalizeOpenAIMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
normalizeOpenAIMessagesDispatchModelConfig
(
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
" gpt-5.4-high "
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
" gpt-5.4-mini-medium "
,
ExactModelMappings
:
map
[
string
]
string
{
" claude-sonnet-4-5-20250929 "
:
" gpt-5.2-high "
,
""
:
"gpt-5.4"
,
"claude-opus-4-6"
:
" "
,
},
})
require
.
Equal
(
t
,
"gpt-5.4"
,
cfg
.
OpusMappedModel
)
require
.
Equal
(
t
,
"gpt-5.3-codex"
,
cfg
.
SonnetMappedModel
)
require
.
Equal
(
t
,
"gpt-5.4-mini"
,
cfg
.
HaikuMappedModel
)
require
.
Equal
(
t
,
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"gpt-5.2"
,
},
cfg
.
ExactModelMappings
)
}
backend/internal/service/openai_ws_ratelimit_signal_test.go
View file @
a04ae28a
...
...
@@ -492,7 +492,7 @@ func TestAdminService_ListAccounts_ExhaustedCodexExtraReturnsRateLimitedAccount(
}
svc
:=
&
adminServiceImpl
{
accountRepo
:
repo
}
accounts
,
total
,
err
:=
svc
.
ListAccounts
(
context
.
Background
(),
1
,
20
,
PlatformOpenAI
,
AccountTypeOAuth
,
""
,
""
,
0
,
""
)
accounts
,
total
,
err
:=
svc
.
ListAccounts
(
context
.
Background
(),
1
,
20
,
PlatformOpenAI
,
AccountTypeOAuth
,
""
,
""
,
0
,
""
,
""
,
""
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
int64
(
1
),
total
)
require
.
Len
(
t
,
accounts
,
1
)
...
...
backend/internal/service/ops_service.go
View file @
a04ae28a
...
...
@@ -16,7 +16,7 @@ import (
var
ErrOpsDisabled
=
infraerrors
.
NotFound
(
"OPS_DISABLED"
,
"Ops monitoring is disabled"
)
const
(
opsMaxStoredRequestBodyBytes
=
10
*
1024
opsMaxStoredRequestBodyBytes
=
256
*
1024
opsMaxStoredErrorBodyBytes
=
20
*
1024
)
...
...
backend/internal/service/payment_config_limits.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"encoding/json"
"fmt"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// GetAvailableMethodLimits collects all payment types from enabled provider
// instances and returns limits for each, plus the global widest range.
// Stripe sub-types (card, link) are aggregated under "stripe".
func
(
s
*
PaymentConfigService
)
GetAvailableMethodLimits
(
ctx
context
.
Context
)
(
*
MethodLimitsResponse
,
error
)
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Where
(
paymentproviderinstance
.
EnabledEQ
(
true
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query provider instances: %w"
,
err
)
}
typeInstances
:=
pcGroupByPaymentType
(
instances
)
resp
:=
&
MethodLimitsResponse
{
Methods
:
make
(
map
[
string
]
MethodLimits
,
len
(
typeInstances
)),
}
for
pt
,
insts
:=
range
typeInstances
{
ml
:=
pcAggregateMethodLimits
(
pt
,
insts
)
resp
.
Methods
[
ml
.
PaymentType
]
=
ml
}
resp
.
GlobalMin
,
resp
.
GlobalMax
=
pcComputeGlobalRange
(
resp
.
Methods
)
return
resp
,
nil
}
// GetMethodLimits returns per-payment-type limits from enabled provider instances.
func
(
s
*
PaymentConfigService
)
GetMethodLimits
(
ctx
context
.
Context
,
types
[]
string
)
([]
MethodLimits
,
error
)
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Where
(
paymentproviderinstance
.
EnabledEQ
(
true
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query provider instances: %w"
,
err
)
}
result
:=
make
([]
MethodLimits
,
0
,
len
(
types
))
for
_
,
pt
:=
range
types
{
var
matching
[]
*
dbent
.
PaymentProviderInstance
for
_
,
inst
:=
range
instances
{
if
payment
.
InstanceSupportsType
(
inst
.
SupportedTypes
,
pt
)
{
matching
=
append
(
matching
,
inst
)
}
}
result
=
append
(
result
,
pcAggregateMethodLimits
(
pt
,
matching
))
}
return
result
,
nil
}
// pcGroupByPaymentType groups instances by user-facing payment type.
// For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe"
// because the user sees a single "Stripe" button, not individual sub-methods.
// Uses a seen set to avoid counting one instance twice.
func
pcGroupByPaymentType
(
instances
[]
*
dbent
.
PaymentProviderInstance
)
map
[
string
][]
*
dbent
.
PaymentProviderInstance
{
typeInstances
:=
make
(
map
[
string
][]
*
dbent
.
PaymentProviderInstance
)
seen
:=
make
(
map
[
string
]
map
[
int64
]
bool
)
add
:=
func
(
key
string
,
inst
*
dbent
.
PaymentProviderInstance
)
{
if
seen
[
key
]
==
nil
{
seen
[
key
]
=
make
(
map
[
int64
]
bool
)
}
if
!
seen
[
key
][
int64
(
inst
.
ID
)]
{
seen
[
key
][
int64
(
inst
.
ID
)]
=
true
typeInstances
[
key
]
=
append
(
typeInstances
[
key
],
inst
)
}
}
for
_
,
inst
:=
range
instances
{
// Stripe provider: all sub-types → single "stripe" group
if
inst
.
ProviderKey
==
payment
.
TypeStripe
{
add
(
payment
.
TypeStripe
,
inst
)
continue
}
for
_
,
t
:=
range
splitTypes
(
inst
.
SupportedTypes
)
{
add
(
t
,
inst
)
}
}
return
typeInstances
}
// pcInstanceTypeLimits extracts per-type limits from a provider instance.
// Returns (limits, true) if configured; (zero, false) if unlimited.
// For Stripe instances, limits are stored under "stripe" key regardless of sub-types.
func
pcInstanceTypeLimits
(
inst
*
dbent
.
PaymentProviderInstance
,
pt
string
)
(
payment
.
ChannelLimits
,
bool
)
{
if
inst
.
Limits
==
""
{
return
payment
.
ChannelLimits
{},
false
}
var
limits
payment
.
InstanceLimits
if
err
:=
json
.
Unmarshal
([]
byte
(
inst
.
Limits
),
&
limits
);
err
!=
nil
{
return
payment
.
ChannelLimits
{},
false
}
cl
,
ok
:=
limits
[
pt
]
return
cl
,
ok
}
// unionFloat merges a single limit value into the aggregate using UNION semantics.
// - For "min" fields (wantMin=true): keeps the lowest non-zero value
// - For "max"/"cap" fields (wantMin=false): keeps the highest non-zero value
// - If any value is 0 (unlimited), the result is unlimited.
//
// Returns (aggregated value, still limited).
func
unionFloat
(
agg
float64
,
limited
bool
,
val
float64
,
wantMin
bool
)
(
float64
,
bool
)
{
if
val
==
0
{
return
agg
,
false
}
if
!
limited
{
return
agg
,
false
}
if
agg
==
0
{
return
val
,
true
}
if
wantMin
&&
val
<
agg
{
return
val
,
true
}
if
!
wantMin
&&
val
>
agg
{
return
val
,
true
}
return
agg
,
true
}
// pcAggregateMethodLimits computes the UNION (least restrictive) of limits
// across all provider instances for a given payment type.
//
// Since the load balancer can route an order to any available instance,
// the user should see the widest possible range:
// - SingleMin: lowest floor across instances; 0 if any is unlimited
// - SingleMax: highest ceiling across instances; 0 if any is unlimited
// - DailyLimit: highest cap across instances; 0 if any is unlimited
func
pcAggregateMethodLimits
(
pt
string
,
instances
[]
*
dbent
.
PaymentProviderInstance
)
MethodLimits
{
ml
:=
MethodLimits
{
PaymentType
:
pt
}
minLimited
,
maxLimited
,
dailyLimited
:=
true
,
true
,
true
for
_
,
inst
:=
range
instances
{
cl
,
hasLimits
:=
pcInstanceTypeLimits
(
inst
,
pt
)
if
!
hasLimits
{
return
MethodLimits
{
PaymentType
:
pt
}
// any unlimited instance → all zeros
}
ml
.
SingleMin
,
minLimited
=
unionFloat
(
ml
.
SingleMin
,
minLimited
,
cl
.
SingleMin
,
true
)
ml
.
SingleMax
,
maxLimited
=
unionFloat
(
ml
.
SingleMax
,
maxLimited
,
cl
.
SingleMax
,
false
)
ml
.
DailyLimit
,
dailyLimited
=
unionFloat
(
ml
.
DailyLimit
,
dailyLimited
,
cl
.
DailyLimit
,
false
)
}
if
!
minLimited
{
ml
.
SingleMin
=
0
}
if
!
maxLimited
{
ml
.
SingleMax
=
0
}
if
!
dailyLimited
{
ml
.
DailyLimit
=
0
}
return
ml
}
// pcComputeGlobalRange computes the widest [min, max] across all methods.
// Uses the same union logic: lowest min, highest max, 0 if any is unlimited.
func
pcComputeGlobalRange
(
methods
map
[
string
]
MethodLimits
)
(
globalMin
,
globalMax
float64
)
{
minLimited
,
maxLimited
:=
true
,
true
for
_
,
ml
:=
range
methods
{
globalMin
,
minLimited
=
unionFloat
(
globalMin
,
minLimited
,
ml
.
SingleMin
,
true
)
globalMax
,
maxLimited
=
unionFloat
(
globalMax
,
maxLimited
,
ml
.
SingleMax
,
false
)
}
if
!
minLimited
{
globalMin
=
0
}
if
!
maxLimited
{
globalMax
=
0
}
return
globalMin
,
globalMax
}
backend/internal/service/payment_config_limits_test.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"testing"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
func
TestUnionFloat
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
agg
float64
limited
bool
val
float64
wantMin
bool
wantAgg
float64
wantLimited
bool
}{
{
"first non-zero value"
,
0
,
true
,
5
,
true
,
5
,
true
},
{
"lower min replaces"
,
10
,
true
,
3
,
true
,
3
,
true
},
{
"higher min does not replace"
,
3
,
true
,
10
,
true
,
3
,
true
},
{
"higher max replaces"
,
10
,
true
,
20
,
false
,
20
,
true
},
{
"lower max does not replace"
,
20
,
true
,
10
,
false
,
20
,
true
},
{
"zero value makes unlimited"
,
5
,
true
,
0
,
true
,
5
,
false
},
{
"already unlimited stays unlimited"
,
5
,
false
,
10
,
true
,
5
,
false
},
{
"zero on first call"
,
0
,
true
,
0
,
true
,
0
,
false
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gotAgg
,
gotLimited
:=
unionFloat
(
tt
.
agg
,
tt
.
limited
,
tt
.
val
,
tt
.
wantMin
)
if
gotAgg
!=
tt
.
wantAgg
||
gotLimited
!=
tt
.
wantLimited
{
t
.
Fatalf
(
"unionFloat(%v, %v, %v, %v) = (%v, %v), want (%v, %v)"
,
tt
.
agg
,
tt
.
limited
,
tt
.
val
,
tt
.
wantMin
,
gotAgg
,
gotLimited
,
tt
.
wantAgg
,
tt
.
wantLimited
)
}
})
}
}
func
makeInstance
(
id
int64
,
providerKey
,
supportedTypes
,
limits
string
)
*
dbent
.
PaymentProviderInstance
{
return
&
dbent
.
PaymentProviderInstance
{
ID
:
id
,
ProviderKey
:
providerKey
,
SupportedTypes
:
supportedTypes
,
Limits
:
limits
,
Enabled
:
true
,
}
}
func
TestPcAggregateMethodLimits
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Run
(
"single instance with limits"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay,wxpay"
,
`{"alipay":{"singleMin":2,"singleMax":14},"wxpay":{"singleMin":1,"singleMax":12}}`
)
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst
})
if
ml
.
SingleMin
!=
2
||
ml
.
SingleMax
!=
14
{
t
.
Fatalf
(
"alipay limits = min:%v max:%v, want min:2 max:14"
,
ml
.
SingleMin
,
ml
.
SingleMax
)
}
})
t
.
Run
(
"two instances union takes widest range"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst1
:=
makeInstance
(
1
,
"easypay"
,
"alipay,wxpay"
,
`{"alipay":{"singleMin":5,"singleMax":100}}`
)
inst2
:=
makeInstance
(
2
,
"easypay"
,
"alipay,wxpay"
,
`{"alipay":{"singleMin":2,"singleMax":200}}`
)
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst1
,
inst2
})
if
ml
.
SingleMin
!=
2
{
t
.
Fatalf
(
"SingleMin = %v, want 2 (lowest floor)"
,
ml
.
SingleMin
)
}
if
ml
.
SingleMax
!=
200
{
t
.
Fatalf
(
"SingleMax = %v, want 200 (highest ceiling)"
,
ml
.
SingleMax
)
}
})
t
.
Run
(
"one instance unlimited makes aggregate unlimited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst1
:=
makeInstance
(
1
,
"easypay"
,
"wxpay"
,
`{"wxpay":{"singleMin":3,"singleMax":10}}`
)
inst2
:=
makeInstance
(
2
,
"easypay"
,
"wxpay"
,
""
)
// no limits = unlimited
ml
:=
pcAggregateMethodLimits
(
"wxpay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst1
,
inst2
})
if
ml
.
SingleMin
!=
0
||
ml
.
SingleMax
!=
0
{
t
.
Fatalf
(
"limits = min:%v max:%v, want min:0 max:0 (unlimited)"
,
ml
.
SingleMin
,
ml
.
SingleMax
)
}
})
t
.
Run
(
"one field unlimited others limited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst1
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{"alipay":{"singleMin":5,"singleMax":100}}`
)
inst2
:=
makeInstance
(
2
,
"easypay"
,
"alipay"
,
`{"alipay":{"singleMin":3,"singleMax":0}}`
)
// singleMax=0 = unlimited
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst1
,
inst2
})
if
ml
.
SingleMin
!=
3
{
t
.
Fatalf
(
"SingleMin = %v, want 3 (lowest floor)"
,
ml
.
SingleMin
)
}
if
ml
.
SingleMax
!=
0
{
t
.
Fatalf
(
"SingleMax = %v, want 0 (unlimited)"
,
ml
.
SingleMax
)
}
})
t
.
Run
(
"empty instances returns zeros"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
nil
)
if
ml
.
SingleMin
!=
0
||
ml
.
SingleMax
!=
0
||
ml
.
DailyLimit
!=
0
{
t
.
Fatalf
(
"empty instances should return all zeros, got %+v"
,
ml
)
}
})
t
.
Run
(
"invalid JSON treated as unlimited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{invalid json}`
)
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst
})
if
ml
.
SingleMin
!=
0
||
ml
.
SingleMax
!=
0
{
t
.
Fatalf
(
"invalid JSON should be treated as unlimited, got %+v"
,
ml
)
}
})
t
.
Run
(
"type not in limits JSON treated as unlimited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay,wxpay"
,
`{"wxpay":{"singleMin":1,"singleMax":10}}`
)
// only wxpay, no alipay
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst
})
if
ml
.
SingleMin
!=
0
||
ml
.
SingleMax
!=
0
{
t
.
Fatalf
(
"missing type should be treated as unlimited, got %+v"
,
ml
)
}
})
t
.
Run
(
"daily limit aggregation"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst1
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{"alipay":{"singleMin":1,"singleMax":100,"dailyLimit":500}}`
)
inst2
:=
makeInstance
(
2
,
"easypay"
,
"alipay"
,
`{"alipay":{"singleMin":2,"singleMax":200,"dailyLimit":1000}}`
)
ml
:=
pcAggregateMethodLimits
(
"alipay"
,
[]
*
dbent
.
PaymentProviderInstance
{
inst1
,
inst2
})
if
ml
.
DailyLimit
!=
1000
{
t
.
Fatalf
(
"DailyLimit = %v, want 1000 (highest cap)"
,
ml
.
DailyLimit
)
}
})
}
func
TestPcGroupByPaymentType
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Run
(
"stripe instance maps all types to stripe group"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
stripe
:=
makeInstance
(
1
,
payment
.
TypeStripe
,
"card,alipay,link,wxpay"
,
""
)
easypay
:=
makeInstance
(
2
,
payment
.
TypeEasyPay
,
"alipay,wxpay"
,
""
)
groups
:=
pcGroupByPaymentType
([]
*
dbent
.
PaymentProviderInstance
{
stripe
,
easypay
})
// Stripe instance should only be in "stripe" group
if
len
(
groups
[
payment
.
TypeStripe
])
!=
1
||
groups
[
payment
.
TypeStripe
][
0
]
.
ID
!=
1
{
t
.
Fatalf
(
"stripe group should contain only stripe instance, got %v"
,
groups
[
payment
.
TypeStripe
])
}
// alipay group should only contain easypay, NOT stripe
if
len
(
groups
[
payment
.
TypeAlipay
])
!=
1
||
groups
[
payment
.
TypeAlipay
][
0
]
.
ID
!=
2
{
t
.
Fatalf
(
"alipay group should contain only easypay instance, got %v"
,
groups
[
payment
.
TypeAlipay
])
}
// wxpay group should only contain easypay, NOT stripe
if
len
(
groups
[
payment
.
TypeWxpay
])
!=
1
||
groups
[
payment
.
TypeWxpay
][
0
]
.
ID
!=
2
{
t
.
Fatalf
(
"wxpay group should contain only easypay instance, got %v"
,
groups
[
payment
.
TypeWxpay
])
}
})
t
.
Run
(
"multiple easypay instances in same groups"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
ep1
:=
makeInstance
(
1
,
payment
.
TypeEasyPay
,
"alipay,wxpay"
,
""
)
ep2
:=
makeInstance
(
2
,
payment
.
TypeEasyPay
,
"alipay,wxpay"
,
""
)
groups
:=
pcGroupByPaymentType
([]
*
dbent
.
PaymentProviderInstance
{
ep1
,
ep2
})
if
len
(
groups
[
payment
.
TypeAlipay
])
!=
2
{
t
.
Fatalf
(
"alipay group should have 2 instances, got %d"
,
len
(
groups
[
payment
.
TypeAlipay
]))
}
if
len
(
groups
[
payment
.
TypeWxpay
])
!=
2
{
t
.
Fatalf
(
"wxpay group should have 2 instances, got %d"
,
len
(
groups
[
payment
.
TypeWxpay
]))
}
})
t
.
Run
(
"stripe with no supported types still in stripe group"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
stripe
:=
makeInstance
(
1
,
payment
.
TypeStripe
,
""
,
""
)
groups
:=
pcGroupByPaymentType
([]
*
dbent
.
PaymentProviderInstance
{
stripe
})
if
len
(
groups
[
payment
.
TypeStripe
])
!=
1
{
t
.
Fatalf
(
"stripe with empty types should still be in stripe group, got %v"
,
groups
)
}
})
}
func
TestPcComputeGlobalRange
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Run
(
"all methods have limits"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
methods
:=
map
[
string
]
MethodLimits
{
"alipay"
:
{
SingleMin
:
2
,
SingleMax
:
14
},
"wxpay"
:
{
SingleMin
:
1
,
SingleMax
:
12
},
"stripe"
:
{
SingleMin
:
5
,
SingleMax
:
100
},
}
gMin
,
gMax
:=
pcComputeGlobalRange
(
methods
)
if
gMin
!=
1
{
t
.
Fatalf
(
"global min = %v, want 1 (lowest floor)"
,
gMin
)
}
if
gMax
!=
100
{
t
.
Fatalf
(
"global max = %v, want 100 (highest ceiling)"
,
gMax
)
}
})
t
.
Run
(
"one method unlimited makes global unlimited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
methods
:=
map
[
string
]
MethodLimits
{
"alipay"
:
{
SingleMin
:
2
,
SingleMax
:
14
},
"stripe"
:
{
SingleMin
:
0
,
SingleMax
:
0
},
// unlimited
}
gMin
,
gMax
:=
pcComputeGlobalRange
(
methods
)
if
gMin
!=
0
{
t
.
Fatalf
(
"global min = %v, want 0 (unlimited)"
,
gMin
)
}
if
gMax
!=
0
{
t
.
Fatalf
(
"global max = %v, want 0 (unlimited)"
,
gMax
)
}
})
t
.
Run
(
"empty methods returns zeros"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gMin
,
gMax
:=
pcComputeGlobalRange
(
map
[
string
]
MethodLimits
{})
if
gMin
!=
0
||
gMax
!=
0
{
t
.
Fatalf
(
"empty methods should return (0, 0), got (%v, %v)"
,
gMin
,
gMax
)
}
})
t
.
Run
(
"only min unlimited"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
methods
:=
map
[
string
]
MethodLimits
{
"alipay"
:
{
SingleMin
:
0
,
SingleMax
:
100
},
"wxpay"
:
{
SingleMin
:
5
,
SingleMax
:
50
},
}
gMin
,
gMax
:=
pcComputeGlobalRange
(
methods
)
if
gMin
!=
0
{
t
.
Fatalf
(
"global min = %v, want 0 (unlimited)"
,
gMin
)
}
if
gMax
!=
100
{
t
.
Fatalf
(
"global max = %v, want 100"
,
gMax
)
}
})
}
func
TestPcInstanceTypeLimits
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Run
(
"empty limits string returns false"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
""
)
_
,
ok
:=
pcInstanceTypeLimits
(
inst
,
"alipay"
)
if
ok
{
t
.
Fatal
(
"expected ok=false for empty limits"
)
}
})
t
.
Run
(
"type found returns correct values"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{"alipay":{"singleMin":2,"singleMax":14,"dailyLimit":500}}`
)
cl
,
ok
:=
pcInstanceTypeLimits
(
inst
,
"alipay"
)
if
!
ok
{
t
.
Fatal
(
"expected ok=true"
)
}
if
cl
.
SingleMin
!=
2
||
cl
.
SingleMax
!=
14
||
cl
.
DailyLimit
!=
500
{
t
.
Fatalf
(
"limits = %+v, want min:2 max:14 daily:500"
,
cl
)
}
})
t
.
Run
(
"type not found returns false"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{"wxpay":{"singleMin":1}}`
)
_
,
ok
:=
pcInstanceTypeLimits
(
inst
,
"alipay"
)
if
ok
{
t
.
Fatal
(
"expected ok=false for missing type"
)
}
})
t
.
Run
(
"invalid JSON returns false"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
inst
:=
makeInstance
(
1
,
"easypay"
,
"alipay"
,
`{bad json}`
)
_
,
ok
:=
pcInstanceTypeLimits
(
inst
,
"alipay"
)
if
ok
{
t
.
Fatal
(
"expected ok=false for invalid JSON"
)
}
})
}
backend/internal/service/payment_config_plans.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Plan CRUD ---
// PlanGroupInfo holds the group details needed for subscription plan display.
type
PlanGroupInfo
struct
{
Platform
string
`json:"platform"`
Name
string
`json:"name"`
RateMultiplier
float64
`json:"rate_multiplier"`
DailyLimitUSD
*
float64
`json:"daily_limit_usd"`
WeeklyLimitUSD
*
float64
`json:"weekly_limit_usd"`
MonthlyLimitUSD
*
float64
`json:"monthly_limit_usd"`
ModelScopes
[]
string
`json:"supported_model_scopes"`
}
// GetGroupPlatformMap returns a map of group_id → platform for the given plans.
func
(
s
*
PaymentConfigService
)
GetGroupPlatformMap
(
ctx
context
.
Context
,
plans
[]
*
dbent
.
SubscriptionPlan
)
map
[
int64
]
string
{
info
:=
s
.
GetGroupInfoMap
(
ctx
,
plans
)
m
:=
make
(
map
[
int64
]
string
,
len
(
info
))
for
id
,
gi
:=
range
info
{
m
[
id
]
=
gi
.
Platform
}
return
m
}
// GetGroupInfoMap returns a map of group_id → PlanGroupInfo for the given plans.
func
(
s
*
PaymentConfigService
)
GetGroupInfoMap
(
ctx
context
.
Context
,
plans
[]
*
dbent
.
SubscriptionPlan
)
map
[
int64
]
PlanGroupInfo
{
ids
:=
make
([]
int64
,
0
,
len
(
plans
))
seen
:=
make
(
map
[
int64
]
bool
)
for
_
,
p
:=
range
plans
{
if
!
seen
[
p
.
GroupID
]
{
seen
[
p
.
GroupID
]
=
true
ids
=
append
(
ids
,
p
.
GroupID
)
}
}
if
len
(
ids
)
==
0
{
return
nil
}
groups
,
err
:=
s
.
entClient
.
Group
.
Query
()
.
Where
(
group
.
IDIn
(
ids
...
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
}
m
:=
make
(
map
[
int64
]
PlanGroupInfo
,
len
(
groups
))
for
_
,
g
:=
range
groups
{
m
[
int64
(
g
.
ID
)]
=
PlanGroupInfo
{
Platform
:
g
.
Platform
,
Name
:
g
.
Name
,
RateMultiplier
:
g
.
RateMultiplier
,
DailyLimitUSD
:
g
.
DailyLimitUsd
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUsd
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUsd
,
ModelScopes
:
g
.
SupportedModelScopes
,
}
}
return
m
}
func
(
s
*
PaymentConfigService
)
ListPlans
(
ctx
context
.
Context
)
([]
*
dbent
.
SubscriptionPlan
,
error
)
{
return
s
.
entClient
.
SubscriptionPlan
.
Query
()
.
Order
(
subscriptionplan
.
BySortOrder
())
.
All
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
ListPlansForSale
(
ctx
context
.
Context
)
([]
*
dbent
.
SubscriptionPlan
,
error
)
{
return
s
.
entClient
.
SubscriptionPlan
.
Query
()
.
Where
(
subscriptionplan
.
ForSaleEQ
(
true
))
.
Order
(
subscriptionplan
.
BySortOrder
())
.
All
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
CreatePlan
(
ctx
context
.
Context
,
req
CreatePlanRequest
)
(
*
dbent
.
SubscriptionPlan
,
error
)
{
b
:=
s
.
entClient
.
SubscriptionPlan
.
Create
()
.
SetGroupID
(
req
.
GroupID
)
.
SetName
(
req
.
Name
)
.
SetDescription
(
req
.
Description
)
.
SetPrice
(
req
.
Price
)
.
SetValidityDays
(
req
.
ValidityDays
)
.
SetValidityUnit
(
req
.
ValidityUnit
)
.
SetFeatures
(
req
.
Features
)
.
SetProductName
(
req
.
ProductName
)
.
SetForSale
(
req
.
ForSale
)
.
SetSortOrder
(
req
.
SortOrder
)
if
req
.
OriginalPrice
!=
nil
{
b
.
SetOriginalPrice
(
*
req
.
OriginalPrice
)
}
return
b
.
Save
(
ctx
)
}
// UpdatePlan updates a subscription plan by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update boilerplate.
func
(
s
*
PaymentConfigService
)
UpdatePlan
(
ctx
context
.
Context
,
id
int64
,
req
UpdatePlanRequest
)
(
*
dbent
.
SubscriptionPlan
,
error
)
{
u
:=
s
.
entClient
.
SubscriptionPlan
.
UpdateOneID
(
id
)
if
req
.
GroupID
!=
nil
{
u
.
SetGroupID
(
*
req
.
GroupID
)
}
if
req
.
Name
!=
nil
{
u
.
SetName
(
*
req
.
Name
)
}
if
req
.
Description
!=
nil
{
u
.
SetDescription
(
*
req
.
Description
)
}
if
req
.
Price
!=
nil
{
u
.
SetPrice
(
*
req
.
Price
)
}
if
req
.
OriginalPrice
!=
nil
{
u
.
SetOriginalPrice
(
*
req
.
OriginalPrice
)
}
if
req
.
ValidityDays
!=
nil
{
u
.
SetValidityDays
(
*
req
.
ValidityDays
)
}
if
req
.
ValidityUnit
!=
nil
{
u
.
SetValidityUnit
(
*
req
.
ValidityUnit
)
}
if
req
.
Features
!=
nil
{
u
.
SetFeatures
(
*
req
.
Features
)
}
if
req
.
ProductName
!=
nil
{
u
.
SetProductName
(
*
req
.
ProductName
)
}
if
req
.
ForSale
!=
nil
{
u
.
SetForSale
(
*
req
.
ForSale
)
}
if
req
.
SortOrder
!=
nil
{
u
.
SetSortOrder
(
*
req
.
SortOrder
)
}
return
u
.
Save
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
DeletePlan
(
ctx
context
.
Context
,
id
int64
)
error
{
count
,
err
:=
s
.
countPendingOrdersByPlan
(
ctx
,
id
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
if
count
>
0
{
return
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
fmt
.
Sprintf
(
"this plan has %d in-progress orders and cannot be deleted — wait for orders to complete first"
,
count
))
}
return
s
.
entClient
.
SubscriptionPlan
.
DeleteOneID
(
id
)
.
Exec
(
ctx
)
}
// GetPlan returns a subscription plan by ID.
func
(
s
*
PaymentConfigService
)
GetPlan
(
ctx
context
.
Context
,
id
int64
)
(
*
dbent
.
SubscriptionPlan
,
error
)
{
plan
,
err
:=
s
.
entClient
.
SubscriptionPlan
.
Get
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"PLAN_NOT_FOUND"
,
"subscription plan not found"
)
}
return
plan
,
nil
}
backend/internal/service/payment_config_providers.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Provider Instance CRUD ---
func
(
s
*
PaymentConfigService
)
ListProviderInstances
(
ctx
context
.
Context
)
([]
*
dbent
.
PaymentProviderInstance
,
error
)
{
return
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Order
(
paymentproviderinstance
.
BySortOrder
())
.
All
(
ctx
)
}
// ProviderInstanceResponse is the API response for a provider instance.
type
ProviderInstanceResponse
struct
{
ID
int64
`json:"id"`
ProviderKey
string
`json:"provider_key"`
Name
string
`json:"name"`
Config
map
[
string
]
string
`json:"config"`
SupportedTypes
[]
string
`json:"supported_types"`
Limits
string
`json:"limits"`
Enabled
bool
`json:"enabled"`
RefundEnabled
bool
`json:"refund_enabled"`
SortOrder
int
`json:"sort_order"`
PaymentMode
string
`json:"payment_mode"`
}
// ListProviderInstancesWithConfig returns provider instances with decrypted config.
func
(
s
*
PaymentConfigService
)
ListProviderInstancesWithConfig
(
ctx
context
.
Context
)
([]
ProviderInstanceResponse
,
error
)
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Order
(
paymentproviderinstance
.
BySortOrder
())
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
ProviderInstanceResponse
,
0
,
len
(
instances
))
for
_
,
inst
:=
range
instances
{
resp
:=
ProviderInstanceResponse
{
ID
:
int64
(
inst
.
ID
),
ProviderKey
:
inst
.
ProviderKey
,
Name
:
inst
.
Name
,
SupportedTypes
:
splitTypes
(
inst
.
SupportedTypes
),
Limits
:
inst
.
Limits
,
Enabled
:
inst
.
Enabled
,
RefundEnabled
:
inst
.
RefundEnabled
,
SortOrder
:
inst
.
SortOrder
,
PaymentMode
:
inst
.
PaymentMode
,
}
resp
.
Config
,
err
=
s
.
decryptAndMaskConfig
(
inst
.
Config
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decrypt config for instance %d: %w"
,
inst
.
ID
,
err
)
}
result
=
append
(
result
,
resp
)
}
return
result
,
nil
}
func
(
s
*
PaymentConfigService
)
decryptAndMaskConfig
(
encrypted
string
)
(
map
[
string
]
string
,
error
)
{
return
s
.
decryptConfig
(
encrypted
)
}
// pendingOrderStatuses are order statuses considered "in progress".
var
pendingOrderStatuses
=
[]
string
{
payment
.
OrderStatusPending
,
payment
.
OrderStatusPaid
,
payment
.
OrderStatusRecharging
,
}
var
sensitiveConfigPatterns
=
[]
string
{
"key"
,
"pkey"
,
"secret"
,
"private"
,
"password"
}
func
isSensitiveConfigField
(
fieldName
string
)
bool
{
lower
:=
strings
.
ToLower
(
fieldName
)
for
_
,
p
:=
range
sensitiveConfigPatterns
{
if
strings
.
Contains
(
lower
,
p
)
{
return
true
}
}
return
false
}
func
(
s
*
PaymentConfigService
)
countPendingOrders
(
ctx
context
.
Context
,
providerInstanceID
int64
)
(
int
,
error
)
{
return
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
ProviderInstanceIDEQ
(
strconv
.
FormatInt
(
providerInstanceID
,
10
)),
paymentorder
.
StatusIn
(
pendingOrderStatuses
...
),
)
.
Count
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
countPendingOrdersByPlan
(
ctx
context
.
Context
,
planID
int64
)
(
int
,
error
)
{
return
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
PlanIDEQ
(
planID
),
paymentorder
.
StatusIn
(
pendingOrderStatuses
...
),
)
.
Count
(
ctx
)
}
var
validProviderKeys
=
map
[
string
]
bool
{
payment
.
TypeEasyPay
:
true
,
payment
.
TypeAlipay
:
true
,
payment
.
TypeWxpay
:
true
,
payment
.
TypeStripe
:
true
,
}
func
(
s
*
PaymentConfigService
)
CreateProviderInstance
(
ctx
context
.
Context
,
req
CreateProviderInstanceRequest
)
(
*
dbent
.
PaymentProviderInstance
,
error
)
{
typesStr
:=
joinTypes
(
req
.
SupportedTypes
)
if
err
:=
validateProviderRequest
(
req
.
ProviderKey
,
req
.
Name
,
typesStr
);
err
!=
nil
{
return
nil
,
err
}
enc
,
err
:=
s
.
encryptConfig
(
req
.
Config
)
if
err
!=
nil
{
return
nil
,
err
}
return
s
.
entClient
.
PaymentProviderInstance
.
Create
()
.
SetProviderKey
(
req
.
ProviderKey
)
.
SetName
(
req
.
Name
)
.
SetConfig
(
enc
)
.
SetSupportedTypes
(
typesStr
)
.
SetEnabled
(
req
.
Enabled
)
.
SetPaymentMode
(
req
.
PaymentMode
)
.
SetSortOrder
(
req
.
SortOrder
)
.
SetLimits
(
req
.
Limits
)
.
SetRefundEnabled
(
req
.
RefundEnabled
)
.
Save
(
ctx
)
}
func
validateProviderRequest
(
providerKey
,
name
,
supportedTypes
string
)
error
{
if
strings
.
TrimSpace
(
name
)
==
""
{
return
infraerrors
.
BadRequest
(
"VALIDATION_ERROR"
,
"provider name is required"
)
}
if
!
validProviderKeys
[
providerKey
]
{
return
infraerrors
.
BadRequest
(
"VALIDATION_ERROR"
,
fmt
.
Sprintf
(
"invalid provider key: %s"
,
providerKey
))
}
// supported_types can be empty (provider accepts no payment types until configured)
return
nil
}
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
// boilerplate and pending-order safety checks.
func
(
s
*
PaymentConfigService
)
UpdateProviderInstance
(
ctx
context
.
Context
,
id
int64
,
req
UpdateProviderInstanceRequest
)
(
*
dbent
.
PaymentProviderInstance
,
error
)
{
if
req
.
Config
!=
nil
{
hasSensitive
:=
false
for
k
:=
range
req
.
Config
{
if
isSensitiveConfigField
(
k
)
&&
req
.
Config
[
k
]
!=
""
{
hasSensitive
=
true
break
}
}
if
hasSensitive
{
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
if
count
>
0
{
return
nil
,
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
"instance has pending orders"
)
.
WithMetadata
(
map
[
string
]
string
{
"count"
:
strconv
.
Itoa
(
count
)})
}
}
}
if
req
.
Enabled
!=
nil
&&
!*
req
.
Enabled
{
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
if
count
>
0
{
return
nil
,
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
"instance has pending orders"
)
.
WithMetadata
(
map
[
string
]
string
{
"count"
:
strconv
.
Itoa
(
count
)})
}
}
u
:=
s
.
entClient
.
PaymentProviderInstance
.
UpdateOneID
(
id
)
if
req
.
Name
!=
nil
{
u
.
SetName
(
*
req
.
Name
)
}
if
req
.
Config
!=
nil
{
merged
,
err
:=
s
.
mergeConfig
(
ctx
,
id
,
req
.
Config
)
if
err
!=
nil
{
return
nil
,
err
}
enc
,
err
:=
s
.
encryptConfig
(
merged
)
if
err
!=
nil
{
return
nil
,
err
}
u
.
SetConfig
(
enc
)
}
if
req
.
SupportedTypes
!=
nil
{
// Check pending orders before removing payment types
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
if
count
>
0
{
// Load current instance to compare types
inst
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Get
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"load provider instance: %w"
,
err
)
}
oldTypes
:=
strings
.
Split
(
inst
.
SupportedTypes
,
","
)
newTypes
:=
req
.
SupportedTypes
for
_
,
ot
:=
range
oldTypes
{
ot
=
strings
.
TrimSpace
(
ot
)
if
ot
==
""
{
continue
}
found
:=
false
for
_
,
nt
:=
range
newTypes
{
if
strings
.
TrimSpace
(
nt
)
==
ot
{
found
=
true
break
}
}
if
!
found
{
return
nil
,
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
"cannot remove payment types while instance has pending orders"
)
.
WithMetadata
(
map
[
string
]
string
{
"count"
:
strconv
.
Itoa
(
count
)})
}
}
}
u
.
SetSupportedTypes
(
joinTypes
(
req
.
SupportedTypes
))
}
if
req
.
Enabled
!=
nil
{
u
.
SetEnabled
(
*
req
.
Enabled
)
}
if
req
.
SortOrder
!=
nil
{
u
.
SetSortOrder
(
*
req
.
SortOrder
)
}
if
req
.
Limits
!=
nil
{
u
.
SetLimits
(
*
req
.
Limits
)
}
if
req
.
RefundEnabled
!=
nil
{
u
.
SetRefundEnabled
(
*
req
.
RefundEnabled
)
}
if
req
.
PaymentMode
!=
nil
{
u
.
SetPaymentMode
(
*
req
.
PaymentMode
)
}
return
u
.
Save
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
mergeConfig
(
ctx
context
.
Context
,
id
int64
,
newConfig
map
[
string
]
string
)
(
map
[
string
]
string
,
error
)
{
inst
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Get
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"load existing provider: %w"
,
err
)
}
existing
,
err
:=
s
.
decryptConfig
(
inst
.
Config
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decrypt existing config for instance %d: %w"
,
id
,
err
)
}
if
existing
==
nil
{
return
newConfig
,
nil
}
for
k
,
v
:=
range
newConfig
{
existing
[
k
]
=
v
}
return
existing
,
nil
}
func
(
s
*
PaymentConfigService
)
decryptConfig
(
encrypted
string
)
(
map
[
string
]
string
,
error
)
{
if
encrypted
==
""
{
return
nil
,
nil
}
decrypted
,
err
:=
payment
.
Decrypt
(
encrypted
,
s
.
encryptionKey
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decrypt config: %w"
,
err
)
}
var
raw
map
[
string
]
string
if
err
:=
json
.
Unmarshal
([]
byte
(
decrypted
),
&
raw
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"unmarshal decrypted config: %w"
,
err
)
}
return
raw
,
nil
}
func
(
s
*
PaymentConfigService
)
DeleteProviderInstance
(
ctx
context
.
Context
,
id
int64
)
error
{
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
if
count
>
0
{
return
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
fmt
.
Sprintf
(
"this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first"
,
count
))
}
return
s
.
entClient
.
PaymentProviderInstance
.
DeleteOneID
(
id
)
.
Exec
(
ctx
)
}
func
(
s
*
PaymentConfigService
)
encryptConfig
(
cfg
map
[
string
]
string
)
(
string
,
error
)
{
data
,
err
:=
json
.
Marshal
(
cfg
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"marshal config: %w"
,
err
)
}
enc
,
err
:=
payment
.
Encrypt
(
string
(
data
),
s
.
encryptionKey
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"encrypt config: %w"
,
err
)
}
return
enc
,
nil
}
backend/internal/service/payment_config_providers_test.go
0 → 100644
View file @
a04ae28a
//go:build unit
package
service
import
(
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func
TestValidateProviderRequest
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
providerKey
string
providerName
string
supportedTypes
string
wantErr
bool
errContains
string
}{
{
name
:
"valid easypay with types"
,
providerKey
:
"easypay"
,
providerName
:
"MyProvider"
,
supportedTypes
:
"alipay,wxpay"
,
wantErr
:
false
,
},
{
name
:
"valid stripe with empty types"
,
providerKey
:
"stripe"
,
providerName
:
"Stripe Provider"
,
supportedTypes
:
""
,
wantErr
:
false
,
},
{
name
:
"valid alipay provider"
,
providerKey
:
"alipay"
,
providerName
:
"Alipay Direct"
,
supportedTypes
:
"alipay"
,
wantErr
:
false
,
},
{
name
:
"valid wxpay provider"
,
providerKey
:
"wxpay"
,
providerName
:
"WeChat Pay"
,
supportedTypes
:
"wxpay"
,
wantErr
:
false
,
},
{
name
:
"invalid provider key"
,
providerKey
:
"invalid"
,
providerName
:
"Name"
,
supportedTypes
:
"alipay"
,
wantErr
:
true
,
errContains
:
"invalid provider key"
,
},
{
name
:
"empty name"
,
providerKey
:
"easypay"
,
providerName
:
""
,
supportedTypes
:
"alipay"
,
wantErr
:
true
,
errContains
:
"provider name is required"
,
},
{
name
:
"whitespace-only name"
,
providerKey
:
"easypay"
,
providerName
:
" "
,
supportedTypes
:
"alipay"
,
wantErr
:
true
,
errContains
:
"provider name is required"
,
},
{
name
:
"tab-only name"
,
providerKey
:
"easypay"
,
providerName
:
"
\t
"
,
supportedTypes
:
"alipay"
,
wantErr
:
true
,
errContains
:
"provider name is required"
,
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
err
:=
validateProviderRequest
(
tc
.
providerKey
,
tc
.
providerName
,
tc
.
supportedTypes
)
if
tc
.
wantErr
{
require
.
Error
(
t
,
err
)
assert
.
Contains
(
t
,
err
.
Error
(),
tc
.
errContains
)
}
else
{
require
.
NoError
(
t
,
err
)
}
})
}
}
func
TestIsSensitiveConfigField
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
field
string
wantSen
bool
}{
// Sensitive fields (contain key/secret/private/password/pkey patterns)
{
"secretKey"
,
true
},
{
"apiSecret"
,
true
},
{
"pkey"
,
true
},
{
"privateKey"
,
true
},
{
"apiPassword"
,
true
},
{
"appKey"
,
true
},
{
"SECRET_TOKEN"
,
true
},
{
"PrivateData"
,
true
},
{
"PASSWORD"
,
true
},
{
"mySecretValue"
,
true
},
// Non-sensitive fields
{
"appId"
,
false
},
{
"mchId"
,
false
},
{
"apiBase"
,
false
},
{
"endpoint"
,
false
},
{
"merchantNo"
,
false
},
{
"paymentMode"
,
false
},
{
"notifyUrl"
,
false
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
field
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
isSensitiveConfigField
(
tc
.
field
)
assert
.
Equal
(
t
,
tc
.
wantSen
,
got
,
"isSensitiveConfigField(%q)"
,
tc
.
field
)
})
}
}
func
TestJoinTypes
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
input
[]
string
want
string
}{
{
name
:
"multiple types"
,
input
:
[]
string
{
"alipay"
,
"wxpay"
},
want
:
"alipay,wxpay"
,
},
{
name
:
"single type"
,
input
:
[]
string
{
"stripe"
},
want
:
"stripe"
,
},
{
name
:
"empty slice"
,
input
:
[]
string
{},
want
:
""
,
},
{
name
:
"nil slice"
,
input
:
nil
,
want
:
""
,
},
{
name
:
"three types"
,
input
:
[]
string
{
"alipay"
,
"wxpay"
,
"stripe"
},
want
:
"alipay,wxpay,stripe"
,
},
{
name
:
"types with spaces are not trimmed"
,
input
:
[]
string
{
" alipay "
,
" wxpay "
},
want
:
" alipay , wxpay "
,
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
joinTypes
(
tc
.
input
)
assert
.
Equal
(
t
,
tc
.
want
,
got
)
})
}
}
backend/internal/service/payment_config_service.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"strconv"
"strings"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
const
(
SettingPaymentEnabled
=
"payment_enabled"
SettingMinRechargeAmount
=
"MIN_RECHARGE_AMOUNT"
SettingMaxRechargeAmount
=
"MAX_RECHARGE_AMOUNT"
SettingDailyRechargeLimit
=
"DAILY_RECHARGE_LIMIT"
SettingOrderTimeoutMinutes
=
"ORDER_TIMEOUT_MINUTES"
SettingMaxPendingOrders
=
"MAX_PENDING_ORDERS"
SettingEnabledPaymentTypes
=
"ENABLED_PAYMENT_TYPES"
SettingLoadBalanceStrategy
=
"LOAD_BALANCE_STRATEGY"
SettingBalancePayDisabled
=
"BALANCE_PAYMENT_DISABLED"
SettingProductNamePrefix
=
"PRODUCT_NAME_PREFIX"
SettingProductNameSuffix
=
"PRODUCT_NAME_SUFFIX"
SettingHelpImageURL
=
"PAYMENT_HELP_IMAGE_URL"
SettingHelpText
=
"PAYMENT_HELP_TEXT"
SettingCancelRateLimitOn
=
"CANCEL_RATE_LIMIT_ENABLED"
SettingCancelRateLimitMax
=
"CANCEL_RATE_LIMIT_MAX"
SettingCancelWindowSize
=
"CANCEL_RATE_LIMIT_WINDOW"
SettingCancelWindowUnit
=
"CANCEL_RATE_LIMIT_UNIT"
SettingCancelWindowMode
=
"CANCEL_RATE_LIMIT_WINDOW_MODE"
)
// Default values for payment configuration settings.
const
(
defaultOrderTimeoutMin
=
30
defaultMaxPendingOrders
=
3
)
// PaymentConfig holds the payment system configuration.
type
PaymentConfig
struct
{
Enabled
bool
`json:"enabled"`
MinAmount
float64
`json:"min_amount"`
MaxAmount
float64
`json:"max_amount"`
DailyLimit
float64
`json:"daily_limit"`
OrderTimeoutMin
int
`json:"order_timeout_minutes"`
MaxPendingOrders
int
`json:"max_pending_orders"`
EnabledTypes
[]
string
`json:"enabled_payment_types"`
BalanceDisabled
bool
`json:"balance_disabled"`
LoadBalanceStrategy
string
`json:"load_balance_strategy"`
ProductNamePrefix
string
`json:"product_name_prefix"`
ProductNameSuffix
string
`json:"product_name_suffix"`
HelpImageURL
string
`json:"help_image_url"`
HelpText
string
`json:"help_text"`
StripePublishableKey
string
`json:"stripe_publishable_key,omitempty"`
// Cancel rate limit settings
CancelRateLimitEnabled
bool
`json:"cancel_rate_limit_enabled"`
CancelRateLimitMax
int
`json:"cancel_rate_limit_max"`
CancelRateLimitWindow
int
`json:"cancel_rate_limit_window"`
CancelRateLimitUnit
string
`json:"cancel_rate_limit_unit"`
CancelRateLimitMode
string
`json:"cancel_rate_limit_window_mode"`
}
// UpdatePaymentConfigRequest contains fields to update payment configuration.
type
UpdatePaymentConfigRequest
struct
{
Enabled
*
bool
`json:"enabled"`
MinAmount
*
float64
`json:"min_amount"`
MaxAmount
*
float64
`json:"max_amount"`
DailyLimit
*
float64
`json:"daily_limit"`
OrderTimeoutMin
*
int
`json:"order_timeout_minutes"`
MaxPendingOrders
*
int
`json:"max_pending_orders"`
EnabledTypes
[]
string
`json:"enabled_payment_types"`
BalanceDisabled
*
bool
`json:"balance_disabled"`
LoadBalanceStrategy
*
string
`json:"load_balance_strategy"`
ProductNamePrefix
*
string
`json:"product_name_prefix"`
ProductNameSuffix
*
string
`json:"product_name_suffix"`
HelpImageURL
*
string
`json:"help_image_url"`
HelpText
*
string
`json:"help_text"`
// Cancel rate limit settings
CancelRateLimitEnabled
*
bool
`json:"cancel_rate_limit_enabled"`
CancelRateLimitMax
*
int
`json:"cancel_rate_limit_max"`
CancelRateLimitWindow
*
int
`json:"cancel_rate_limit_window"`
CancelRateLimitUnit
*
string
`json:"cancel_rate_limit_unit"`
CancelRateLimitMode
*
string
`json:"cancel_rate_limit_window_mode"`
}
// MethodLimits holds per-payment-type limits.
type
MethodLimits
struct
{
PaymentType
string
`json:"payment_type"`
FeeRate
float64
`json:"fee_rate"`
DailyLimit
float64
`json:"daily_limit"`
SingleMin
float64
`json:"single_min"`
SingleMax
float64
`json:"single_max"`
}
// MethodLimitsResponse is the full response for the user-facing /limits API.
// It includes per-method limits and the global widest range (union of all methods).
type
MethodLimitsResponse
struct
{
Methods
map
[
string
]
MethodLimits
`json:"methods"`
GlobalMin
float64
`json:"global_min"`
// 0 = no minimum
GlobalMax
float64
`json:"global_max"`
// 0 = no maximum
}
type
CreateProviderInstanceRequest
struct
{
ProviderKey
string
`json:"provider_key"`
Name
string
`json:"name"`
Config
map
[
string
]
string
`json:"config"`
SupportedTypes
[]
string
`json:"supported_types"`
Enabled
bool
`json:"enabled"`
PaymentMode
string
`json:"payment_mode"`
SortOrder
int
`json:"sort_order"`
Limits
string
`json:"limits"`
RefundEnabled
bool
`json:"refund_enabled"`
}
type
UpdateProviderInstanceRequest
struct
{
Name
*
string
`json:"name"`
Config
map
[
string
]
string
`json:"config"`
SupportedTypes
[]
string
`json:"supported_types"`
Enabled
*
bool
`json:"enabled"`
PaymentMode
*
string
`json:"payment_mode"`
SortOrder
*
int
`json:"sort_order"`
Limits
*
string
`json:"limits"`
RefundEnabled
*
bool
`json:"refund_enabled"`
}
type
CreatePlanRequest
struct
{
GroupID
int64
`json:"group_id"`
Name
string
`json:"name"`
Description
string
`json:"description"`
Price
float64
`json:"price"`
OriginalPrice
*
float64
`json:"original_price"`
ValidityDays
int
`json:"validity_days"`
ValidityUnit
string
`json:"validity_unit"`
Features
string
`json:"features"`
ProductName
string
`json:"product_name"`
ForSale
bool
`json:"for_sale"`
SortOrder
int
`json:"sort_order"`
}
type
UpdatePlanRequest
struct
{
GroupID
*
int64
`json:"group_id"`
Name
*
string
`json:"name"`
Description
*
string
`json:"description"`
Price
*
float64
`json:"price"`
OriginalPrice
*
float64
`json:"original_price"`
ValidityDays
*
int
`json:"validity_days"`
ValidityUnit
*
string
`json:"validity_unit"`
Features
*
string
`json:"features"`
ProductName
*
string
`json:"product_name"`
ForSale
*
bool
`json:"for_sale"`
SortOrder
*
int
`json:"sort_order"`
}
// PaymentConfigService manages payment configuration and CRUD for
// provider instances, channels, and subscription plans.
type
PaymentConfigService
struct
{
entClient
*
dbent
.
Client
settingRepo
SettingRepository
encryptionKey
[]
byte
}
// NewPaymentConfigService creates a new PaymentConfigService.
func
NewPaymentConfigService
(
entClient
*
dbent
.
Client
,
settingRepo
SettingRepository
,
encryptionKey
[]
byte
)
*
PaymentConfigService
{
return
&
PaymentConfigService
{
entClient
:
entClient
,
settingRepo
:
settingRepo
,
encryptionKey
:
encryptionKey
}
}
// IsPaymentEnabled returns whether the payment system is enabled.
func
(
s
*
PaymentConfigService
)
IsPaymentEnabled
(
ctx
context
.
Context
)
bool
{
val
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingPaymentEnabled
)
if
err
!=
nil
{
return
false
}
return
val
==
"true"
}
// GetPaymentConfig returns the full payment configuration.
func
(
s
*
PaymentConfigService
)
GetPaymentConfig
(
ctx
context
.
Context
)
(
*
PaymentConfig
,
error
)
{
keys
:=
[]
string
{
SettingPaymentEnabled
,
SettingMinRechargeAmount
,
SettingMaxRechargeAmount
,
SettingDailyRechargeLimit
,
SettingOrderTimeoutMinutes
,
SettingMaxPendingOrders
,
SettingEnabledPaymentTypes
,
SettingBalancePayDisabled
,
SettingLoadBalanceStrategy
,
SettingProductNamePrefix
,
SettingProductNameSuffix
,
SettingHelpImageURL
,
SettingHelpText
,
SettingCancelRateLimitOn
,
SettingCancelRateLimitMax
,
SettingCancelWindowSize
,
SettingCancelWindowUnit
,
SettingCancelWindowMode
,
}
vals
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get payment config settings: %w"
,
err
)
}
cfg
:=
s
.
parsePaymentConfig
(
vals
)
// Load Stripe publishable key from the first enabled Stripe provider instance
cfg
.
StripePublishableKey
=
s
.
getStripePublishableKey
(
ctx
)
return
cfg
,
nil
}
func
(
s
*
PaymentConfigService
)
parsePaymentConfig
(
vals
map
[
string
]
string
)
*
PaymentConfig
{
cfg
:=
&
PaymentConfig
{
Enabled
:
vals
[
SettingPaymentEnabled
]
==
"true"
,
MinAmount
:
pcParseFloat
(
vals
[
SettingMinRechargeAmount
],
1
),
MaxAmount
:
pcParseFloat
(
vals
[
SettingMaxRechargeAmount
],
0
),
DailyLimit
:
pcParseFloat
(
vals
[
SettingDailyRechargeLimit
],
0
),
OrderTimeoutMin
:
pcParseInt
(
vals
[
SettingOrderTimeoutMinutes
],
defaultOrderTimeoutMin
),
MaxPendingOrders
:
pcParseInt
(
vals
[
SettingMaxPendingOrders
],
defaultMaxPendingOrders
),
BalanceDisabled
:
vals
[
SettingBalancePayDisabled
]
==
"true"
,
LoadBalanceStrategy
:
vals
[
SettingLoadBalanceStrategy
],
ProductNamePrefix
:
vals
[
SettingProductNamePrefix
],
ProductNameSuffix
:
vals
[
SettingProductNameSuffix
],
HelpImageURL
:
vals
[
SettingHelpImageURL
],
HelpText
:
vals
[
SettingHelpText
],
CancelRateLimitEnabled
:
vals
[
SettingCancelRateLimitOn
]
==
"true"
,
CancelRateLimitMax
:
pcParseInt
(
vals
[
SettingCancelRateLimitMax
],
10
),
CancelRateLimitWindow
:
pcParseInt
(
vals
[
SettingCancelWindowSize
],
1
),
CancelRateLimitUnit
:
vals
[
SettingCancelWindowUnit
],
CancelRateLimitMode
:
vals
[
SettingCancelWindowMode
],
}
if
cfg
.
LoadBalanceStrategy
==
""
{
cfg
.
LoadBalanceStrategy
=
payment
.
DefaultLoadBalanceStrategy
}
if
raw
:=
vals
[
SettingEnabledPaymentTypes
];
raw
!=
""
{
for
_
,
t
:=
range
strings
.
Split
(
raw
,
","
)
{
t
=
strings
.
TrimSpace
(
t
)
if
t
!=
""
{
cfg
.
EnabledTypes
=
append
(
cfg
.
EnabledTypes
,
t
)
}
}
}
return
cfg
}
// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
func
(
s
*
PaymentConfigService
)
getStripePublishableKey
(
ctx
context
.
Context
)
string
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Where
(
paymentproviderinstance
.
EnabledEQ
(
true
),
paymentproviderinstance
.
ProviderKeyEQ
(
payment
.
TypeStripe
),
)
.
Limit
(
1
)
.
All
(
ctx
)
if
err
!=
nil
||
len
(
instances
)
==
0
{
return
""
}
cfg
,
err
:=
s
.
decryptConfig
(
instances
[
0
]
.
Config
)
if
err
!=
nil
||
cfg
==
nil
{
return
""
}
return
cfg
[
payment
.
ConfigKeyPublishableKey
]
}
// UpdatePaymentConfig updates the payment configuration settings.
// NOTE: This function exceeds 30 lines because each field requires an independent
// nil-check before serialisation — this is inherent to patch-style update patterns
// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func
(
s
*
PaymentConfigService
)
UpdatePaymentConfig
(
ctx
context
.
Context
,
req
UpdatePaymentConfigRequest
)
error
{
m
:=
map
[
string
]
string
{
SettingPaymentEnabled
:
formatBoolOrEmpty
(
req
.
Enabled
),
SettingMinRechargeAmount
:
formatPositiveFloat
(
req
.
MinAmount
),
SettingMaxRechargeAmount
:
formatPositiveFloat
(
req
.
MaxAmount
),
SettingDailyRechargeLimit
:
formatPositiveFloat
(
req
.
DailyLimit
),
SettingOrderTimeoutMinutes
:
formatPositiveInt
(
req
.
OrderTimeoutMin
),
SettingMaxPendingOrders
:
formatPositiveInt
(
req
.
MaxPendingOrders
),
SettingBalancePayDisabled
:
formatBoolOrEmpty
(
req
.
BalanceDisabled
),
SettingLoadBalanceStrategy
:
derefStr
(
req
.
LoadBalanceStrategy
),
SettingProductNamePrefix
:
derefStr
(
req
.
ProductNamePrefix
),
SettingProductNameSuffix
:
derefStr
(
req
.
ProductNameSuffix
),
SettingHelpImageURL
:
derefStr
(
req
.
HelpImageURL
),
SettingHelpText
:
derefStr
(
req
.
HelpText
),
SettingCancelRateLimitOn
:
formatBoolOrEmpty
(
req
.
CancelRateLimitEnabled
),
SettingCancelRateLimitMax
:
formatPositiveInt
(
req
.
CancelRateLimitMax
),
SettingCancelWindowSize
:
formatPositiveInt
(
req
.
CancelRateLimitWindow
),
SettingCancelWindowUnit
:
derefStr
(
req
.
CancelRateLimitUnit
),
SettingCancelWindowMode
:
derefStr
(
req
.
CancelRateLimitMode
),
}
if
req
.
EnabledTypes
!=
nil
{
m
[
SettingEnabledPaymentTypes
]
=
strings
.
Join
(
req
.
EnabledTypes
,
","
)
}
else
{
m
[
SettingEnabledPaymentTypes
]
=
""
}
return
s
.
settingRepo
.
SetMultiple
(
ctx
,
m
)
}
func
formatBoolOrEmpty
(
v
*
bool
)
string
{
if
v
==
nil
{
return
""
}
return
strconv
.
FormatBool
(
*
v
)
}
func
formatPositiveFloat
(
v
*
float64
)
string
{
if
v
==
nil
||
*
v
<=
0
{
return
""
// empty → parsePaymentConfig uses default
}
return
strconv
.
FormatFloat
(
*
v
,
'f'
,
2
,
64
)
}
func
formatPositiveInt
(
v
*
int
)
string
{
if
v
==
nil
||
*
v
<=
0
{
return
""
}
return
strconv
.
Itoa
(
*
v
)
}
func
derefStr
(
v
*
string
)
string
{
if
v
==
nil
{
return
""
}
return
*
v
}
func
splitTypes
(
s
string
)
[]
string
{
if
s
==
""
{
return
nil
}
parts
:=
strings
.
Split
(
s
,
","
)
result
:=
make
([]
string
,
0
,
len
(
parts
))
for
_
,
p
:=
range
parts
{
p
=
strings
.
TrimSpace
(
p
)
if
p
!=
""
{
result
=
append
(
result
,
p
)
}
}
return
result
}
func
joinTypes
(
types
[]
string
)
string
{
return
strings
.
Join
(
types
,
","
)
}
func
pcParseFloat
(
s
string
,
defaultVal
float64
)
float64
{
if
s
==
""
{
return
defaultVal
}
v
,
err
:=
strconv
.
ParseFloat
(
s
,
64
)
if
err
!=
nil
{
return
defaultVal
}
return
v
}
func
pcParseInt
(
s
string
,
defaultVal
int
)
int
{
if
s
==
""
{
return
defaultVal
}
v
,
err
:=
strconv
.
Atoi
(
s
)
if
err
!=
nil
{
return
defaultVal
}
return
v
}
backend/internal/service/payment_config_service_test.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
func
TestPcParseFloat
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
input
string
defaultVal
float64
expected
float64
}{
{
"empty string returns default"
,
""
,
1.0
,
1.0
},
{
"valid float"
,
"3.14"
,
0
,
3.14
},
{
"valid integer as float"
,
"42"
,
0
,
42.0
},
{
"invalid string returns default"
,
"notanumber"
,
9.99
,
9.99
},
{
"zero value"
,
"0"
,
5.0
,
0
},
{
"negative value"
,
"-10.5"
,
0
,
-
10.5
},
{
"very large value"
,
"99999999.99"
,
0
,
99999999.99
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
pcParseFloat
(
tt
.
input
,
tt
.
defaultVal
)
if
got
!=
tt
.
expected
{
t
.
Fatalf
(
"pcParseFloat(%q, %v) = %v, want %v"
,
tt
.
input
,
tt
.
defaultVal
,
got
,
tt
.
expected
)
}
})
}
}
func
TestPcParseInt
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
input
string
defaultVal
int
expected
int
}{
{
"empty string returns default"
,
""
,
30
,
30
},
{
"valid int"
,
"10"
,
0
,
10
},
{
"invalid string returns default"
,
"abc"
,
5
,
5
},
{
"float string returns default"
,
"3.14"
,
0
,
0
},
{
"zero value"
,
"0"
,
99
,
0
},
{
"negative value"
,
"-1"
,
0
,
-
1
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
pcParseInt
(
tt
.
input
,
tt
.
defaultVal
)
if
got
!=
tt
.
expected
{
t
.
Fatalf
(
"pcParseInt(%q, %v) = %v, want %v"
,
tt
.
input
,
tt
.
defaultVal
,
got
,
tt
.
expected
)
}
})
}
}
func
TestParsePaymentConfig
(
t
*
testing
.
T
)
{
t
.
Parallel
()
svc
:=
&
PaymentConfigService
{}
t
.
Run
(
"empty vals uses defaults"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
svc
.
parsePaymentConfig
(
map
[
string
]
string
{})
if
cfg
.
Enabled
{
t
.
Fatal
(
"expected Enabled=false by default"
)
}
if
cfg
.
MinAmount
!=
1
{
t
.
Fatalf
(
"expected MinAmount=1, got %v"
,
cfg
.
MinAmount
)
}
if
cfg
.
MaxAmount
!=
0
{
t
.
Fatalf
(
"expected MaxAmount=0 (no limit), got %v"
,
cfg
.
MaxAmount
)
}
if
cfg
.
OrderTimeoutMin
!=
30
{
t
.
Fatalf
(
"expected OrderTimeoutMin=30, got %v"
,
cfg
.
OrderTimeoutMin
)
}
if
cfg
.
MaxPendingOrders
!=
3
{
t
.
Fatalf
(
"expected MaxPendingOrders=3, got %v"
,
cfg
.
MaxPendingOrders
)
}
if
cfg
.
LoadBalanceStrategy
!=
payment
.
DefaultLoadBalanceStrategy
{
t
.
Fatalf
(
"expected LoadBalanceStrategy=%s, got %q"
,
payment
.
DefaultLoadBalanceStrategy
,
cfg
.
LoadBalanceStrategy
)
}
if
len
(
cfg
.
EnabledTypes
)
!=
0
{
t
.
Fatalf
(
"expected empty EnabledTypes, got %v"
,
cfg
.
EnabledTypes
)
}
})
t
.
Run
(
"all values populated"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
vals
:=
map
[
string
]
string
{
SettingPaymentEnabled
:
"true"
,
SettingMinRechargeAmount
:
"5.00"
,
SettingMaxRechargeAmount
:
"1000.00"
,
SettingDailyRechargeLimit
:
"5000.00"
,
SettingOrderTimeoutMinutes
:
"15"
,
SettingMaxPendingOrders
:
"5"
,
SettingEnabledPaymentTypes
:
"alipay,wxpay,stripe"
,
SettingBalancePayDisabled
:
"true"
,
SettingLoadBalanceStrategy
:
"least_amount"
,
SettingProductNamePrefix
:
"PRE"
,
SettingProductNameSuffix
:
"SUF"
,
}
cfg
:=
svc
.
parsePaymentConfig
(
vals
)
if
!
cfg
.
Enabled
{
t
.
Fatal
(
"expected Enabled=true"
)
}
if
cfg
.
MinAmount
!=
5
{
t
.
Fatalf
(
"MinAmount = %v, want 5"
,
cfg
.
MinAmount
)
}
if
cfg
.
MaxAmount
!=
1000
{
t
.
Fatalf
(
"MaxAmount = %v, want 1000"
,
cfg
.
MaxAmount
)
}
if
cfg
.
DailyLimit
!=
5000
{
t
.
Fatalf
(
"DailyLimit = %v, want 5000"
,
cfg
.
DailyLimit
)
}
if
cfg
.
OrderTimeoutMin
!=
15
{
t
.
Fatalf
(
"OrderTimeoutMin = %v, want 15"
,
cfg
.
OrderTimeoutMin
)
}
if
cfg
.
MaxPendingOrders
!=
5
{
t
.
Fatalf
(
"MaxPendingOrders = %v, want 5"
,
cfg
.
MaxPendingOrders
)
}
if
len
(
cfg
.
EnabledTypes
)
!=
3
{
t
.
Fatalf
(
"EnabledTypes len = %d, want 3"
,
len
(
cfg
.
EnabledTypes
))
}
if
cfg
.
EnabledTypes
[
0
]
!=
"alipay"
||
cfg
.
EnabledTypes
[
1
]
!=
"wxpay"
||
cfg
.
EnabledTypes
[
2
]
!=
"stripe"
{
t
.
Fatalf
(
"EnabledTypes = %v, want [alipay wxpay stripe]"
,
cfg
.
EnabledTypes
)
}
if
!
cfg
.
BalanceDisabled
{
t
.
Fatal
(
"expected BalanceDisabled=true"
)
}
if
cfg
.
LoadBalanceStrategy
!=
"least_amount"
{
t
.
Fatalf
(
"LoadBalanceStrategy = %q, want %q"
,
cfg
.
LoadBalanceStrategy
,
"least_amount"
)
}
if
cfg
.
ProductNamePrefix
!=
"PRE"
{
t
.
Fatalf
(
"ProductNamePrefix = %q, want %q"
,
cfg
.
ProductNamePrefix
,
"PRE"
)
}
if
cfg
.
ProductNameSuffix
!=
"SUF"
{
t
.
Fatalf
(
"ProductNameSuffix = %q, want %q"
,
cfg
.
ProductNameSuffix
,
"SUF"
)
}
})
t
.
Run
(
"enabled types with spaces are trimmed"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
vals
:=
map
[
string
]
string
{
SettingEnabledPaymentTypes
:
" alipay , wxpay "
,
}
cfg
:=
svc
.
parsePaymentConfig
(
vals
)
if
len
(
cfg
.
EnabledTypes
)
!=
2
{
t
.
Fatalf
(
"EnabledTypes len = %d, want 2"
,
len
(
cfg
.
EnabledTypes
))
}
if
cfg
.
EnabledTypes
[
0
]
!=
"alipay"
||
cfg
.
EnabledTypes
[
1
]
!=
"wxpay"
{
t
.
Fatalf
(
"EnabledTypes = %v, want [alipay wxpay]"
,
cfg
.
EnabledTypes
)
}
})
t
.
Run
(
"empty enabled types string"
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
vals
:=
map
[
string
]
string
{
SettingEnabledPaymentTypes
:
""
,
}
cfg
:=
svc
.
parsePaymentConfig
(
vals
)
if
len
(
cfg
.
EnabledTypes
)
!=
0
{
t
.
Fatalf
(
"expected empty EnabledTypes for empty string, got %v"
,
cfg
.
EnabledTypes
)
}
})
}
func
TestGetBasePaymentType
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
input
string
expected
string
}{
{
payment
.
TypeEasyPay
,
payment
.
TypeEasyPay
},
{
payment
.
TypeStripe
,
payment
.
TypeStripe
},
{
payment
.
TypeCard
,
payment
.
TypeStripe
},
{
payment
.
TypeLink
,
payment
.
TypeStripe
},
{
payment
.
TypeAlipay
,
payment
.
TypeAlipay
},
{
payment
.
TypeAlipayDirect
,
payment
.
TypeAlipay
},
{
payment
.
TypeWxpay
,
payment
.
TypeWxpay
},
{
payment
.
TypeWxpayDirect
,
payment
.
TypeWxpay
},
{
"unknown"
,
"unknown"
},
{
""
,
""
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
input
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
payment
.
GetBasePaymentType
(
tt
.
input
)
if
got
!=
tt
.
expected
{
t
.
Fatalf
(
"GetBasePaymentType(%q) = %q, want %q"
,
tt
.
input
,
got
,
tt
.
expected
)
}
})
}
}
backend/internal/service/payment_fulfillment.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Payment Notification & Fulfillment ---
func
(
s
*
PaymentService
)
HandlePaymentNotification
(
ctx
context
.
Context
,
n
*
payment
.
PaymentNotification
,
pk
string
)
error
{
if
n
.
Status
!=
payment
.
NotificationStatusSuccess
{
return
nil
}
// Look up order by out_trade_no (the external order ID we sent to the provider)
order
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
OutTradeNo
(
n
.
OrderID
))
.
Only
(
ctx
)
if
err
!=
nil
{
// Fallback: try legacy format (sub2_N where N is DB ID)
trimmed
:=
strings
.
TrimPrefix
(
n
.
OrderID
,
orderIDPrefix
)
if
oid
,
parseErr
:=
strconv
.
ParseInt
(
trimmed
,
10
,
64
);
parseErr
==
nil
{
return
s
.
confirmPayment
(
ctx
,
oid
,
n
.
TradeNo
,
n
.
Amount
,
pk
)
}
return
fmt
.
Errorf
(
"order not found for out_trade_no: %s"
,
n
.
OrderID
)
}
return
s
.
confirmPayment
(
ctx
,
order
.
ID
,
n
.
TradeNo
,
n
.
Amount
,
pk
)
}
func
(
s
*
PaymentService
)
confirmPayment
(
ctx
context
.
Context
,
oid
int64
,
tradeNo
string
,
paid
float64
,
pk
string
)
error
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
slog
.
Error
(
"order not found"
,
"orderID"
,
oid
)
return
nil
}
// Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount).
// Also skip if paid is NaN/Inf (malformed provider data).
if
paid
>
0
&&
!
math
.
IsNaN
(
paid
)
&&
!
math
.
IsInf
(
paid
,
0
)
{
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
})
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
)
}
func
(
s
*
PaymentService
)
toPaid
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
,
tradeNo
string
,
paid
float64
,
pk
string
)
error
{
previousStatus
:=
o
.
Status
now
:=
time
.
Now
()
grace
:=
now
.
Add
(
-
paymentGraceMinutes
*
time
.
Minute
)
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
o
.
ID
),
paymentorder
.
Or
(
paymentorder
.
StatusEQ
(
OrderStatusPending
),
paymentorder
.
StatusEQ
(
OrderStatusCancelled
),
paymentorder
.
And
(
paymentorder
.
StatusEQ
(
OrderStatusExpired
),
paymentorder
.
UpdatedAtGTE
(
grace
),
),
),
)
.
SetStatus
(
OrderStatusPaid
)
.
SetPayAmount
(
paid
)
.
SetPaymentTradeNo
(
tradeNo
)
.
SetPaidAt
(
now
)
.
ClearFailedAt
()
.
ClearFailedReason
()
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"update to PAID: %w"
,
err
)
}
if
c
==
0
{
return
s
.
alreadyProcessed
(
ctx
,
o
)
}
if
previousStatus
==
OrderStatusCancelled
||
previousStatus
==
OrderStatusExpired
{
slog
.
Info
(
"order recovered from webhook payment success"
,
"orderID"
,
o
.
ID
,
"previousStatus"
,
previousStatus
,
"tradeNo"
,
tradeNo
,
"provider"
,
pk
,
)
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"ORDER_RECOVERED"
,
pk
,
map
[
string
]
any
{
"previous_status"
:
previousStatus
,
"tradeNo"
:
tradeNo
,
"paidAmount"
:
paid
,
"reason"
:
"webhook payment success received after order "
+
previousStatus
,
})
}
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"ORDER_PAID"
,
pk
,
map
[
string
]
any
{
"tradeNo"
:
tradeNo
,
"paidAmount"
:
paid
})
return
s
.
executeFulfillment
(
ctx
,
o
.
ID
)
}
func
(
s
*
PaymentService
)
alreadyProcessed
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
error
{
cur
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
o
.
ID
)
if
err
!=
nil
{
return
nil
}
switch
cur
.
Status
{
case
OrderStatusCompleted
,
OrderStatusRefunded
:
return
nil
case
OrderStatusFailed
:
return
s
.
executeFulfillment
(
ctx
,
o
.
ID
)
case
OrderStatusPaid
,
OrderStatusRecharging
:
return
fmt
.
Errorf
(
"order %d is being processed"
,
o
.
ID
)
case
OrderStatusExpired
:
slog
.
Warn
(
"webhook payment success for expired order beyond grace period"
,
"orderID"
,
o
.
ID
,
"status"
,
cur
.
Status
,
"updatedAt"
,
cur
.
UpdatedAt
,
)
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"PAYMENT_AFTER_EXPIRY"
,
"system"
,
map
[
string
]
any
{
"status"
:
cur
.
Status
,
"updatedAt"
:
cur
.
UpdatedAt
,
"reason"
:
"payment arrived after expiry grace period"
,
})
return
nil
default
:
return
nil
}
}
func
(
s
*
PaymentService
)
executeFulfillment
(
ctx
context
.
Context
,
oid
int64
)
error
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"get order: %w"
,
err
)
}
if
o
.
OrderType
==
payment
.
OrderTypeSubscription
{
return
s
.
ExecuteSubscriptionFulfillment
(
ctx
,
oid
)
}
return
s
.
ExecuteBalanceFulfillment
(
ctx
,
oid
)
}
func
(
s
*
PaymentService
)
ExecuteBalanceFulfillment
(
ctx
context
.
Context
,
oid
int64
)
error
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
Status
==
OrderStatusCompleted
{
return
nil
}
if
psIsRefundStatus
(
o
.
Status
)
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"refund-related order cannot fulfill"
)
}
if
o
.
Status
!=
OrderStatusPaid
&&
o
.
Status
!=
OrderStatusFailed
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order cannot fulfill in status "
+
o
.
Status
)
}
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
oid
),
paymentorder
.
StatusIn
(
OrderStatusPaid
,
OrderStatusFailed
))
.
SetStatus
(
OrderStatusRecharging
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"lock: %w"
,
err
)
}
if
c
==
0
{
return
nil
}
if
err
:=
s
.
doBalance
(
ctx
,
o
);
err
!=
nil
{
s
.
markFailed
(
ctx
,
oid
,
err
)
return
err
}
return
nil
}
// redeemAction represents the idempotency decision for balance fulfillment.
type
redeemAction
int
const
(
// redeemActionCreate: code does not exist — create it, then redeem.
redeemActionCreate
redeemAction
=
iota
// redeemActionRedeem: code exists but is unused — skip creation, redeem only.
redeemActionRedeem
// redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
redeemActionSkipCompleted
)
// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
// existing is the result of GetByCode; lookupErr is the error from that call.
func
resolveRedeemAction
(
existing
*
RedeemCode
,
lookupErr
error
)
redeemAction
{
if
existing
==
nil
||
lookupErr
!=
nil
{
return
redeemActionCreate
}
if
existing
.
IsUsed
()
{
return
redeemActionSkipCompleted
}
return
redeemActionRedeem
}
func
(
s
*
PaymentService
)
doBalance
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
error
{
// Idempotency: check if redeem code already exists (from a previous partial run)
existing
,
lookupErr
:=
s
.
redeemService
.
GetByCode
(
ctx
,
o
.
RechargeCode
)
action
:=
resolveRedeemAction
(
existing
,
lookupErr
)
switch
action
{
case
redeemActionSkipCompleted
:
// Code already created and redeemed — just mark completed
return
s
.
markCompleted
(
ctx
,
o
,
"RECHARGE_SUCCESS"
)
case
redeemActionCreate
:
rc
:=
&
RedeemCode
{
Code
:
o
.
RechargeCode
,
Type
:
RedeemTypeBalance
,
Value
:
o
.
Amount
,
Status
:
StatusUnused
}
if
err
:=
s
.
redeemService
.
CreateCode
(
ctx
,
rc
);
err
!=
nil
{
return
fmt
.
Errorf
(
"create redeem code: %w"
,
err
)
}
case
redeemActionRedeem
:
// Code exists but unused — skip creation, proceed to redeem
}
if
_
,
err
:=
s
.
redeemService
.
Redeem
(
ctx
,
o
.
UserID
,
o
.
RechargeCode
);
err
!=
nil
{
return
fmt
.
Errorf
(
"redeem balance: %w"
,
err
)
}
return
s
.
markCompleted
(
ctx
,
o
,
"RECHARGE_SUCCESS"
)
}
func
(
s
*
PaymentService
)
markCompleted
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
,
auditAction
string
)
error
{
now
:=
time
.
Now
()
_
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
o
.
ID
),
paymentorder
.
StatusEQ
(
OrderStatusRecharging
))
.
SetStatus
(
OrderStatusCompleted
)
.
SetCompletedAt
(
now
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"mark completed: %w"
,
err
)
}
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
auditAction
,
"system"
,
map
[
string
]
any
{
"rechargeCode"
:
o
.
RechargeCode
,
"amount"
:
o
.
Amount
})
return
nil
}
func
(
s
*
PaymentService
)
ExecuteSubscriptionFulfillment
(
ctx
context
.
Context
,
oid
int64
)
error
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
Status
==
OrderStatusCompleted
{
return
nil
}
if
psIsRefundStatus
(
o
.
Status
)
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"refund-related order cannot fulfill"
)
}
if
o
.
Status
!=
OrderStatusPaid
&&
o
.
Status
!=
OrderStatusFailed
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order cannot fulfill in status "
+
o
.
Status
)
}
if
o
.
SubscriptionGroupID
==
nil
||
o
.
SubscriptionDays
==
nil
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"missing subscription info"
)
}
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
oid
),
paymentorder
.
StatusIn
(
OrderStatusPaid
,
OrderStatusFailed
))
.
SetStatus
(
OrderStatusRecharging
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"lock: %w"
,
err
)
}
if
c
==
0
{
return
nil
}
if
err
:=
s
.
doSub
(
ctx
,
o
);
err
!=
nil
{
s
.
markFailed
(
ctx
,
oid
,
err
)
return
err
}
return
nil
}
func
(
s
*
PaymentService
)
doSub
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
error
{
gid
:=
*
o
.
SubscriptionGroupID
days
:=
*
o
.
SubscriptionDays
g
,
err
:=
s
.
groupRepo
.
GetByID
(
ctx
,
gid
)
if
err
!=
nil
||
g
.
Status
!=
payment
.
EntityStatusActive
{
return
fmt
.
Errorf
(
"group %d no longer exists or inactive"
,
gid
)
}
// Idempotency: check audit log to see if subscription was already assigned.
// Prevents double-extension on retry after markCompleted fails.
if
s
.
hasAuditLog
(
ctx
,
o
.
ID
,
"SUBSCRIPTION_SUCCESS"
)
{
slog
.
Info
(
"subscription already assigned for order, skipping"
,
"orderID"
,
o
.
ID
,
"groupID"
,
gid
)
return
s
.
markCompleted
(
ctx
,
o
,
"SUBSCRIPTION_SUCCESS"
)
}
orderNote
:=
fmt
.
Sprintf
(
"payment order %d"
,
o
.
ID
)
_
,
_
,
err
=
s
.
subscriptionSvc
.
AssignOrExtendSubscription
(
ctx
,
&
AssignSubscriptionInput
{
UserID
:
o
.
UserID
,
GroupID
:
gid
,
ValidityDays
:
days
,
AssignedBy
:
0
,
Notes
:
orderNote
})
if
err
!=
nil
{
return
fmt
.
Errorf
(
"assign subscription: %w"
,
err
)
}
return
s
.
markCompleted
(
ctx
,
o
,
"SUBSCRIPTION_SUCCESS"
)
}
func
(
s
*
PaymentService
)
hasAuditLog
(
ctx
context
.
Context
,
orderID
int64
,
action
string
)
bool
{
oid
:=
strconv
.
FormatInt
(
orderID
,
10
)
c
,
_
:=
s
.
entClient
.
PaymentAuditLog
.
Query
()
.
Where
(
paymentauditlog
.
OrderIDEQ
(
oid
),
paymentauditlog
.
ActionEQ
(
action
))
.
Limit
(
1
)
.
Count
(
ctx
)
return
c
>
0
}
func
(
s
*
PaymentService
)
markFailed
(
ctx
context
.
Context
,
oid
int64
,
cause
error
)
{
now
:=
time
.
Now
()
r
:=
psErrMsg
(
cause
)
// Only mark FAILED if still in RECHARGING state — prevents overwriting
// a COMPLETED order when markCompleted failed but fulfillment succeeded.
c
,
e
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
oid
),
paymentorder
.
StatusEQ
(
OrderStatusRecharging
))
.
SetStatus
(
OrderStatusFailed
)
.
SetFailedAt
(
now
)
.
SetFailedReason
(
r
)
.
Save
(
ctx
)
if
e
!=
nil
{
slog
.
Error
(
"mark FAILED"
,
"orderID"
,
oid
,
"error"
,
e
)
}
if
c
>
0
{
s
.
writeAuditLog
(
ctx
,
oid
,
"FULFILLMENT_FAILED"
,
"system"
,
map
[
string
]
any
{
"reason"
:
r
})
}
}
func
(
s
*
PaymentService
)
RetryFulfillment
(
ctx
context
.
Context
,
oid
int64
)
error
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
PaidAt
==
nil
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order is not paid"
)
}
if
psIsRefundStatus
(
o
.
Status
)
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"refund-related order cannot retry"
)
}
if
o
.
Status
==
OrderStatusRecharging
{
return
infraerrors
.
Conflict
(
"CONFLICT"
,
"order is being processed"
)
}
if
o
.
Status
==
OrderStatusCompleted
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order already completed"
)
}
if
o
.
Status
!=
OrderStatusFailed
&&
o
.
Status
!=
OrderStatusPaid
{
return
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"only paid and failed orders can retry"
)
}
_
,
err
=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
oid
),
paymentorder
.
StatusIn
(
OrderStatusFailed
,
OrderStatusPaid
))
.
SetStatus
(
OrderStatusPaid
)
.
ClearFailedAt
()
.
ClearFailedReason
()
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"reset for retry: %w"
,
err
)
}
s
.
writeAuditLog
(
ctx
,
oid
,
"RECHARGE_RETRY"
,
"admin"
,
map
[
string
]
any
{
"detail"
:
"admin manual retry"
})
return
s
.
executeFulfillment
(
ctx
,
oid
)
}
backend/internal/service/payment_fulfillment_test.go
0 → 100644
View file @
a04ae28a
//go:build unit
package
service
import
(
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
// ---------------------------------------------------------------------------
// resolveRedeemAction — pure idempotency decision logic
// ---------------------------------------------------------------------------
func
TestResolveRedeemAction_CodeNotFound
(
t
*
testing
.
T
)
{
t
.
Parallel
()
action
:=
resolveRedeemAction
(
nil
,
nil
)
assert
.
Equal
(
t
,
redeemActionCreate
,
action
,
"nil code with nil error should create"
)
}
func
TestResolveRedeemAction_LookupError
(
t
*
testing
.
T
)
{
t
.
Parallel
()
action
:=
resolveRedeemAction
(
nil
,
errors
.
New
(
"db connection lost"
))
assert
.
Equal
(
t
,
redeemActionCreate
,
action
,
"lookup error should fall back to create"
)
}
func
TestResolveRedeemAction_LookupErrorWithNonNilCode
(
t
*
testing
.
T
)
{
t
.
Parallel
()
// Edge case: both code and error are non-nil (shouldn't happen in practice,
// but the function should still treat error as authoritative)
code
:=
&
RedeemCode
{
Status
:
StatusUnused
}
action
:=
resolveRedeemAction
(
code
,
errors
.
New
(
"partial error"
))
assert
.
Equal
(
t
,
redeemActionCreate
,
action
,
"non-nil error should always result in create regardless of code"
)
}
func
TestResolveRedeemAction_CodeExistsAndUsed
(
t
*
testing
.
T
)
{
t
.
Parallel
()
code
:=
&
RedeemCode
{
Code
:
"test-code-123"
,
Status
:
StatusUsed
,
Type
:
RedeemTypeBalance
,
Value
:
10.0
,
}
action
:=
resolveRedeemAction
(
code
,
nil
)
assert
.
Equal
(
t
,
redeemActionSkipCompleted
,
action
,
"used code should skip to completed"
)
}
func
TestResolveRedeemAction_CodeExistsAndUnused
(
t
*
testing
.
T
)
{
t
.
Parallel
()
code
:=
&
RedeemCode
{
Code
:
"test-code-456"
,
Status
:
StatusUnused
,
Type
:
RedeemTypeBalance
,
Value
:
25.0
,
}
action
:=
resolveRedeemAction
(
code
,
nil
)
assert
.
Equal
(
t
,
redeemActionRedeem
,
action
,
"unused code should skip creation and proceed to redeem"
)
}
func
TestResolveRedeemAction_CodeExistsWithExpiredStatus
(
t
*
testing
.
T
)
{
t
.
Parallel
()
// A code with a non-standard status (neither "unused" nor "used")
// should NOT be treated as used, so it falls through to redeemActionRedeem.
code
:=
&
RedeemCode
{
Code
:
"expired-code"
,
Status
:
StatusExpired
,
}
action
:=
resolveRedeemAction
(
code
,
nil
)
assert
.
Equal
(
t
,
redeemActionRedeem
,
action
,
"expired-status code is not IsUsed(), should redeem"
)
}
// ---------------------------------------------------------------------------
// Table-driven comprehensive test
// ---------------------------------------------------------------------------
func
TestResolveRedeemAction_Table
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
code
*
RedeemCode
err
error
expected
redeemAction
}{
{
name
:
"nil code, nil error — first run"
,
code
:
nil
,
err
:
nil
,
expected
:
redeemActionCreate
,
},
{
name
:
"nil code, lookup error — treat as not found"
,
code
:
nil
,
err
:
ErrRedeemCodeNotFound
,
expected
:
redeemActionCreate
,
},
{
name
:
"nil code, generic DB error — treat as not found"
,
code
:
nil
,
err
:
errors
.
New
(
"connection refused"
),
expected
:
redeemActionCreate
,
},
{
name
:
"code exists, used — previous run completed redeem"
,
code
:
&
RedeemCode
{
Status
:
StatusUsed
},
err
:
nil
,
expected
:
redeemActionSkipCompleted
,
},
{
name
:
"code exists, unused — previous run created code but crashed before redeem"
,
code
:
&
RedeemCode
{
Status
:
StatusUnused
},
err
:
nil
,
expected
:
redeemActionRedeem
,
},
{
name
:
"code exists but error also set — error takes precedence"
,
code
:
&
RedeemCode
{
Status
:
StatusUsed
},
err
:
errors
.
New
(
"unexpected"
),
expected
:
redeemActionCreate
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
resolveRedeemAction
(
tt
.
code
,
tt
.
err
)
assert
.
Equal
(
t
,
tt
.
expected
,
got
)
})
}
}
// ---------------------------------------------------------------------------
// redeemAction enum value sanity
// ---------------------------------------------------------------------------
func
TestRedeemAction_DistinctValues
(
t
*
testing
.
T
)
{
t
.
Parallel
()
// Ensure the three actions have distinct values (iota correctness)
assert
.
NotEqual
(
t
,
redeemActionCreate
,
redeemActionRedeem
)
assert
.
NotEqual
(
t
,
redeemActionCreate
,
redeemActionSkipCompleted
)
assert
.
NotEqual
(
t
,
redeemActionRedeem
,
redeemActionSkipCompleted
)
}
// ---------------------------------------------------------------------------
// RedeemCode.IsUsed / CanUse interaction with resolveRedeemAction
// ---------------------------------------------------------------------------
func
TestResolveRedeemAction_IsUsedCanUseConsistency
(
t
*
testing
.
T
)
{
t
.
Parallel
()
usedCode
:=
&
RedeemCode
{
Status
:
StatusUsed
}
unusedCode
:=
&
RedeemCode
{
Status
:
StatusUnused
}
// Verify our decision function is consistent with the domain model methods
assert
.
True
(
t
,
usedCode
.
IsUsed
())
assert
.
False
(
t
,
usedCode
.
CanUse
())
assert
.
Equal
(
t
,
redeemActionSkipCompleted
,
resolveRedeemAction
(
usedCode
,
nil
))
assert
.
False
(
t
,
unusedCode
.
IsUsed
())
assert
.
True
(
t
,
unusedCode
.
CanUse
())
assert
.
Equal
(
t
,
redeemActionRedeem
,
resolveRedeemAction
(
unusedCode
,
nil
))
}
backend/internal/service/payment_order.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Order Creation ---
func
(
s
*
PaymentService
)
CreateOrder
(
ctx
context
.
Context
,
req
CreateOrderRequest
)
(
*
CreateOrderResponse
,
error
)
{
if
req
.
OrderType
==
""
{
req
.
OrderType
=
payment
.
OrderTypeBalance
}
cfg
,
err
:=
s
.
configService
.
GetPaymentConfig
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get payment config: %w"
,
err
)
}
if
!
cfg
.
Enabled
{
return
nil
,
infraerrors
.
Forbidden
(
"PAYMENT_DISABLED"
,
"payment system is disabled"
)
}
plan
,
err
:=
s
.
validateOrderInput
(
ctx
,
req
,
cfg
)
if
err
!=
nil
{
return
nil
,
err
}
if
err
:=
s
.
checkCancelRateLimit
(
ctx
,
req
.
UserID
,
cfg
);
err
!=
nil
{
return
nil
,
err
}
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
req
.
UserID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
if
user
.
Status
!=
payment
.
EntityStatusActive
{
return
nil
,
infraerrors
.
Forbidden
(
"USER_INACTIVE"
,
"user account is disabled"
)
}
amount
:=
req
.
Amount
if
plan
!=
nil
{
amount
=
plan
.
Price
}
feeRate
:=
s
.
getFeeRate
(
req
.
PaymentType
)
payAmountStr
:=
payment
.
CalculatePayAmount
(
amount
,
feeRate
)
payAmount
,
_
:=
strconv
.
ParseFloat
(
payAmountStr
,
64
)
order
,
err
:=
s
.
createOrderInTx
(
ctx
,
req
,
user
,
plan
,
cfg
,
amount
,
feeRate
,
payAmount
)
if
err
!=
nil
{
return
nil
,
err
}
resp
,
err
:=
s
.
invokeProvider
(
ctx
,
order
,
req
,
cfg
,
payAmountStr
,
payAmount
,
plan
)
if
err
!=
nil
{
_
,
_
=
s
.
entClient
.
PaymentOrder
.
UpdateOneID
(
order
.
ID
)
.
SetStatus
(
OrderStatusFailed
)
.
Save
(
ctx
)
return
nil
,
err
}
return
resp
,
nil
}
func
(
s
*
PaymentService
)
validateOrderInput
(
ctx
context
.
Context
,
req
CreateOrderRequest
,
cfg
*
PaymentConfig
)
(
*
dbent
.
SubscriptionPlan
,
error
)
{
if
req
.
OrderType
==
payment
.
OrderTypeBalance
&&
cfg
.
BalanceDisabled
{
return
nil
,
infraerrors
.
Forbidden
(
"BALANCE_PAYMENT_DISABLED"
,
"balance recharge has been disabled"
)
}
if
req
.
OrderType
==
payment
.
OrderTypeSubscription
{
return
s
.
validateSubOrder
(
ctx
,
req
)
}
if
math
.
IsNaN
(
req
.
Amount
)
||
math
.
IsInf
(
req
.
Amount
,
0
)
||
req
.
Amount
<=
0
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_AMOUNT"
,
"amount must be a positive number"
)
}
if
(
cfg
.
MinAmount
>
0
&&
req
.
Amount
<
cfg
.
MinAmount
)
||
(
cfg
.
MaxAmount
>
0
&&
req
.
Amount
>
cfg
.
MaxAmount
)
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_AMOUNT"
,
"amount out of range"
)
.
WithMetadata
(
map
[
string
]
string
{
"min"
:
fmt
.
Sprintf
(
"%.2f"
,
cfg
.
MinAmount
),
"max"
:
fmt
.
Sprintf
(
"%.2f"
,
cfg
.
MaxAmount
)})
}
return
nil
,
nil
}
func
(
s
*
PaymentService
)
validateSubOrder
(
ctx
context
.
Context
,
req
CreateOrderRequest
)
(
*
dbent
.
SubscriptionPlan
,
error
)
{
if
req
.
PlanID
==
0
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_INPUT"
,
"subscription order requires a plan"
)
}
plan
,
err
:=
s
.
configService
.
GetPlan
(
ctx
,
req
.
PlanID
)
if
err
!=
nil
||
!
plan
.
ForSale
{
return
nil
,
infraerrors
.
NotFound
(
"PLAN_NOT_AVAILABLE"
,
"plan not found or not for sale"
)
}
group
,
err
:=
s
.
groupRepo
.
GetByID
(
ctx
,
plan
.
GroupID
)
if
err
!=
nil
||
group
.
Status
!=
payment
.
EntityStatusActive
{
return
nil
,
infraerrors
.
NotFound
(
"GROUP_NOT_FOUND"
,
"subscription group is no longer available"
)
}
if
!
group
.
IsSubscriptionType
()
{
return
nil
,
infraerrors
.
BadRequest
(
"GROUP_TYPE_MISMATCH"
,
"group is not a subscription type"
)
}
return
plan
,
nil
}
func
(
s
*
PaymentService
)
createOrderInTx
(
ctx
context
.
Context
,
req
CreateOrderRequest
,
user
*
User
,
plan
*
dbent
.
SubscriptionPlan
,
cfg
*
PaymentConfig
,
amount
,
feeRate
,
payAmount
float64
)
(
*
dbent
.
PaymentOrder
,
error
)
{
tx
,
err
:=
s
.
entClient
.
Tx
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"begin transaction: %w"
,
err
)
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
if
err
:=
s
.
checkPendingLimit
(
ctx
,
tx
,
req
.
UserID
,
cfg
.
MaxPendingOrders
);
err
!=
nil
{
return
nil
,
err
}
if
err
:=
s
.
checkDailyLimit
(
ctx
,
tx
,
req
.
UserID
,
amount
,
cfg
.
DailyLimit
);
err
!=
nil
{
return
nil
,
err
}
tm
:=
cfg
.
OrderTimeoutMin
if
tm
<=
0
{
tm
=
defaultOrderTimeoutMin
}
exp
:=
time
.
Now
()
.
Add
(
time
.
Duration
(
tm
)
*
time
.
Minute
)
b
:=
tx
.
PaymentOrder
.
Create
()
.
SetUserID
(
req
.
UserID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetNillableUserNotes
(
psNilIfEmpty
(
user
.
Notes
))
.
SetAmount
(
amount
)
.
SetPayAmount
(
payAmount
)
.
SetFeeRate
(
feeRate
)
.
SetRechargeCode
(
""
)
.
SetOutTradeNo
(
generateOutTradeNo
())
.
SetPaymentType
(
req
.
PaymentType
)
.
SetPaymentTradeNo
(
""
)
.
SetOrderType
(
req
.
OrderType
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
exp
)
.
SetClientIP
(
req
.
ClientIP
)
.
SetSrcHost
(
req
.
SrcHost
)
if
req
.
SrcURL
!=
""
{
b
.
SetSrcURL
(
req
.
SrcURL
)
}
if
plan
!=
nil
{
b
.
SetPlanID
(
plan
.
ID
)
.
SetSubscriptionGroupID
(
plan
.
GroupID
)
.
SetSubscriptionDays
(
psComputeValidityDays
(
plan
.
ValidityDays
,
plan
.
ValidityUnit
))
}
order
,
err
:=
b
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create order: %w"
,
err
)
}
code
:=
fmt
.
Sprintf
(
"PAY-%d-%d"
,
order
.
ID
,
time
.
Now
()
.
UnixNano
()
%
100000
)
order
,
err
=
tx
.
PaymentOrder
.
UpdateOneID
(
order
.
ID
)
.
SetRechargeCode
(
code
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"set recharge code: %w"
,
err
)
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"commit order transaction: %w"
,
err
)
}
return
order
,
nil
}
func
(
s
*
PaymentService
)
checkPendingLimit
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
userID
int64
,
max
int
)
error
{
if
max
<=
0
{
max
=
defaultMaxPendingOrders
}
c
,
err
:=
tx
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
UserIDEQ
(
userID
),
paymentorder
.
StatusEQ
(
OrderStatusPending
))
.
Count
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"count pending orders: %w"
,
err
)
}
if
c
>=
max
{
return
infraerrors
.
TooManyRequests
(
"TOO_MANY_PENDING"
,
fmt
.
Sprintf
(
"too many pending orders (max %d)"
,
max
))
.
WithMetadata
(
map
[
string
]
string
{
"max"
:
strconv
.
Itoa
(
max
)})
}
return
nil
}
func
(
s
*
PaymentService
)
checkDailyLimit
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
userID
int64
,
amount
,
limit
float64
)
error
{
if
limit
<=
0
{
return
nil
}
ts
:=
psStartOfDayUTC
(
time
.
Now
())
orders
,
err
:=
tx
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
UserIDEQ
(
userID
),
paymentorder
.
StatusIn
(
OrderStatusPaid
,
OrderStatusRecharging
,
OrderStatusCompleted
),
paymentorder
.
PaidAtGTE
(
ts
))
.
All
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"query daily usage: %w"
,
err
)
}
var
used
float64
for
_
,
o
:=
range
orders
{
used
+=
o
.
Amount
}
if
used
+
amount
>
limit
{
return
infraerrors
.
TooManyRequests
(
"DAILY_LIMIT_EXCEEDED"
,
fmt
.
Sprintf
(
"daily recharge limit reached, remaining: %.2f"
,
math
.
Max
(
0
,
limit
-
used
)))
}
return
nil
}
func
(
s
*
PaymentService
)
invokeProvider
(
ctx
context
.
Context
,
order
*
dbent
.
PaymentOrder
,
req
CreateOrderRequest
,
cfg
*
PaymentConfig
,
payAmountStr
string
,
payAmount
float64
,
plan
*
dbent
.
SubscriptionPlan
)
(
*
CreateOrderResponse
,
error
)
{
s
.
EnsureProviders
(
ctx
)
providerKey
:=
s
.
registry
.
GetProviderKey
(
req
.
PaymentType
)
if
providerKey
==
""
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PAYMENT_GATEWAY_ERROR"
,
fmt
.
Sprintf
(
"payment method (%s) is not configured"
,
req
.
PaymentType
))
}
sel
,
err
:=
s
.
loadBalancer
.
SelectInstance
(
ctx
,
providerKey
,
req
.
PaymentType
,
payment
.
Strategy
(
cfg
.
LoadBalanceStrategy
),
payAmount
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"select provider instance: %w"
,
err
)
}
if
sel
==
nil
{
return
nil
,
infraerrors
.
TooManyRequests
(
"NO_AVAILABLE_INSTANCE"
,
"no available payment instance"
)
}
prov
,
err
:=
provider
.
CreateProvider
(
providerKey
,
sel
.
InstanceID
,
sel
.
Config
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PAYMENT_GATEWAY_ERROR"
,
"payment method is temporarily unavailable"
)
}
subject
:=
s
.
buildPaymentSubject
(
plan
,
payAmountStr
,
cfg
)
outTradeNo
:=
order
.
OutTradeNo
pr
,
err
:=
prov
.
CreatePayment
(
ctx
,
payment
.
CreatePaymentRequest
{
OrderID
:
outTradeNo
,
Amount
:
payAmountStr
,
PaymentType
:
req
.
PaymentType
,
Subject
:
subject
,
ClientIP
:
req
.
ClientIP
,
IsMobile
:
req
.
IsMobile
,
InstanceSubMethods
:
sel
.
SupportedTypes
})
if
err
!=
nil
{
slog
.
Error
(
"[PaymentService] CreatePayment failed"
,
"provider"
,
providerKey
,
"instance"
,
sel
.
InstanceID
,
"error"
,
err
)
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PAYMENT_GATEWAY_ERROR"
,
fmt
.
Sprintf
(
"payment gateway error: %s"
,
err
.
Error
()))
}
_
,
err
=
s
.
entClient
.
PaymentOrder
.
UpdateOneID
(
order
.
ID
)
.
SetNillablePaymentTradeNo
(
psNilIfEmpty
(
pr
.
TradeNo
))
.
SetNillablePayURL
(
psNilIfEmpty
(
pr
.
PayURL
))
.
SetNillableQrCode
(
psNilIfEmpty
(
pr
.
QRCode
))
.
SetNillableProviderInstanceID
(
psNilIfEmpty
(
sel
.
InstanceID
))
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update order with payment details: %w"
,
err
)
}
s
.
writeAuditLog
(
ctx
,
order
.
ID
,
"ORDER_CREATED"
,
fmt
.
Sprintf
(
"user:%d"
,
req
.
UserID
),
map
[
string
]
any
{
"amount"
:
req
.
Amount
,
"paymentType"
:
req
.
PaymentType
,
"orderType"
:
req
.
OrderType
})
return
&
CreateOrderResponse
{
OrderID
:
order
.
ID
,
Amount
:
order
.
Amount
,
PayAmount
:
payAmount
,
FeeRate
:
order
.
FeeRate
,
Status
:
OrderStatusPending
,
PaymentType
:
req
.
PaymentType
,
PayURL
:
pr
.
PayURL
,
QRCode
:
pr
.
QRCode
,
ClientSecret
:
pr
.
ClientSecret
,
ExpiresAt
:
order
.
ExpiresAt
,
PaymentMode
:
sel
.
PaymentMode
},
nil
}
func
(
s
*
PaymentService
)
buildPaymentSubject
(
plan
*
dbent
.
SubscriptionPlan
,
payAmountStr
string
,
cfg
*
PaymentConfig
)
string
{
if
plan
!=
nil
{
if
plan
.
ProductName
!=
""
{
return
plan
.
ProductName
}
return
"Sub2API Subscription "
+
plan
.
Name
}
pf
:=
strings
.
TrimSpace
(
cfg
.
ProductNamePrefix
)
sf
:=
strings
.
TrimSpace
(
cfg
.
ProductNameSuffix
)
if
pf
!=
""
||
sf
!=
""
{
return
strings
.
TrimSpace
(
pf
+
" "
+
payAmountStr
+
" "
+
sf
)
}
return
"Sub2API "
+
payAmountStr
+
" CNY"
}
// --- Order Queries ---
func
(
s
*
PaymentService
)
GetOrder
(
ctx
context
.
Context
,
orderID
,
userID
int64
)
(
*
dbent
.
PaymentOrder
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
orderID
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
UserID
!=
userID
{
return
nil
,
infraerrors
.
Forbidden
(
"FORBIDDEN"
,
"no permission for this order"
)
}
return
o
,
nil
}
func
(
s
*
PaymentService
)
GetOrderByID
(
ctx
context
.
Context
,
orderID
int64
)
(
*
dbent
.
PaymentOrder
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
orderID
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
return
o
,
nil
}
func
(
s
*
PaymentService
)
GetUserOrders
(
ctx
context
.
Context
,
userID
int64
,
p
OrderListParams
)
([]
*
dbent
.
PaymentOrder
,
int
,
error
)
{
q
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
UserIDEQ
(
userID
))
if
p
.
Status
!=
""
{
q
=
q
.
Where
(
paymentorder
.
StatusEQ
(
p
.
Status
))
}
if
p
.
OrderType
!=
""
{
q
=
q
.
Where
(
paymentorder
.
OrderTypeEQ
(
p
.
OrderType
))
}
if
p
.
PaymentType
!=
""
{
q
=
q
.
Where
(
paymentorder
.
PaymentTypeEQ
(
p
.
PaymentType
))
}
total
,
err
:=
q
.
Clone
()
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
0
,
fmt
.
Errorf
(
"count user orders: %w"
,
err
)
}
ps
,
pg
:=
applyPagination
(
p
.
PageSize
,
p
.
Page
)
orders
,
err
:=
q
.
Order
(
dbent
.
Desc
(
paymentorder
.
FieldCreatedAt
))
.
Limit
(
ps
)
.
Offset
((
pg
-
1
)
*
ps
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
0
,
fmt
.
Errorf
(
"query user orders: %w"
,
err
)
}
return
orders
,
total
,
nil
}
// AdminListOrders returns a paginated list of orders. If userID > 0, filters by user.
func
(
s
*
PaymentService
)
AdminListOrders
(
ctx
context
.
Context
,
userID
int64
,
p
OrderListParams
)
([]
*
dbent
.
PaymentOrder
,
int
,
error
)
{
q
:=
s
.
entClient
.
PaymentOrder
.
Query
()
if
userID
>
0
{
q
=
q
.
Where
(
paymentorder
.
UserIDEQ
(
userID
))
}
if
p
.
Status
!=
""
{
q
=
q
.
Where
(
paymentorder
.
StatusEQ
(
p
.
Status
))
}
if
p
.
OrderType
!=
""
{
q
=
q
.
Where
(
paymentorder
.
OrderTypeEQ
(
p
.
OrderType
))
}
if
p
.
PaymentType
!=
""
{
q
=
q
.
Where
(
paymentorder
.
PaymentTypeEQ
(
p
.
PaymentType
))
}
if
p
.
Keyword
!=
""
{
q
=
q
.
Where
(
paymentorder
.
Or
(
paymentorder
.
OutTradeNoContainsFold
(
p
.
Keyword
),
paymentorder
.
UserEmailContainsFold
(
p
.
Keyword
),
paymentorder
.
UserNameContainsFold
(
p
.
Keyword
),
))
}
total
,
err
:=
q
.
Clone
()
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
0
,
fmt
.
Errorf
(
"count admin orders: %w"
,
err
)
}
ps
,
pg
:=
applyPagination
(
p
.
PageSize
,
p
.
Page
)
orders
,
err
:=
q
.
Order
(
dbent
.
Desc
(
paymentorder
.
FieldCreatedAt
))
.
Limit
(
ps
)
.
Offset
((
pg
-
1
)
*
ps
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
0
,
fmt
.
Errorf
(
"query admin orders: %w"
,
err
)
}
return
orders
,
total
,
nil
}
backend/internal/service/payment_order_expiry_service.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"log/slog"
"sync"
"time"
)
const
expiryCheckTimeout
=
30
*
time
.
Second
// PaymentOrderExpiryService periodically expires timed-out payment orders.
type
PaymentOrderExpiryService
struct
{
paymentSvc
*
PaymentService
interval
time
.
Duration
stopCh
chan
struct
{}
stopOnce
sync
.
Once
wg
sync
.
WaitGroup
}
func
NewPaymentOrderExpiryService
(
paymentSvc
*
PaymentService
,
interval
time
.
Duration
)
*
PaymentOrderExpiryService
{
return
&
PaymentOrderExpiryService
{
paymentSvc
:
paymentSvc
,
interval
:
interval
,
stopCh
:
make
(
chan
struct
{}),
}
}
func
(
s
*
PaymentOrderExpiryService
)
Start
()
{
if
s
==
nil
||
s
.
paymentSvc
==
nil
||
s
.
interval
<=
0
{
return
}
s
.
wg
.
Add
(
1
)
go
func
()
{
defer
s
.
wg
.
Done
()
ticker
:=
time
.
NewTicker
(
s
.
interval
)
defer
ticker
.
Stop
()
s
.
runOnce
()
for
{
select
{
case
<-
ticker
.
C
:
s
.
runOnce
()
case
<-
s
.
stopCh
:
return
}
}
}()
}
func
(
s
*
PaymentOrderExpiryService
)
Stop
()
{
if
s
==
nil
{
return
}
s
.
stopOnce
.
Do
(
func
()
{
close
(
s
.
stopCh
)
})
s
.
wg
.
Wait
()
}
func
(
s
*
PaymentOrderExpiryService
)
runOnce
()
{
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
expiryCheckTimeout
)
defer
cancel
()
expired
,
err
:=
s
.
paymentSvc
.
ExpireTimedOutOrders
(
ctx
)
if
err
!=
nil
{
slog
.
Error
(
"[PaymentOrderExpiry] failed to expire orders"
,
"error"
,
err
)
return
}
if
expired
>
0
{
slog
.
Info
(
"[PaymentOrderExpiry] expired timed-out orders"
,
"count"
,
expired
)
}
}
backend/internal/service/payment_order_lifecycle.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"log/slog"
"strconv"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Cancel & Expire ---
// Cancel rate limit configuration constants.
const
(
rateLimitUnitDay
=
"day"
rateLimitUnitMinute
=
"minute"
rateLimitUnitHour
=
"hour"
rateLimitModeFixed
=
"fixed"
checkPaidResultAlreadyPaid
=
"already_paid"
checkPaidResultCancelled
=
"cancelled"
)
func
(
s
*
PaymentService
)
checkCancelRateLimit
(
ctx
context
.
Context
,
userID
int64
,
cfg
*
PaymentConfig
)
error
{
if
!
cfg
.
CancelRateLimitEnabled
||
cfg
.
CancelRateLimitMax
<=
0
{
return
nil
}
windowStart
:=
cancelRateLimitWindowStart
(
cfg
)
operator
:=
fmt
.
Sprintf
(
"user:%d"
,
userID
)
count
,
err
:=
s
.
entClient
.
PaymentAuditLog
.
Query
()
.
Where
(
paymentauditlog
.
ActionEQ
(
"ORDER_CANCELLED"
),
paymentauditlog
.
OperatorEQ
(
operator
),
paymentauditlog
.
CreatedAtGTE
(
windowStart
),
)
.
Count
(
ctx
)
if
err
!=
nil
{
slog
.
Error
(
"check cancel rate limit failed"
,
"userID"
,
userID
,
"error"
,
err
)
return
nil
// fail open
}
if
count
>=
cfg
.
CancelRateLimitMax
{
return
infraerrors
.
TooManyRequests
(
"CANCEL_RATE_LIMITED"
,
"cancel rate limited"
)
.
WithMetadata
(
map
[
string
]
string
{
"max"
:
strconv
.
Itoa
(
cfg
.
CancelRateLimitMax
),
"window"
:
strconv
.
Itoa
(
cfg
.
CancelRateLimitWindow
),
"unit"
:
cfg
.
CancelRateLimitUnit
,
})
}
return
nil
}
func
cancelRateLimitWindowStart
(
cfg
*
PaymentConfig
)
time
.
Time
{
now
:=
time
.
Now
()
w
:=
cfg
.
CancelRateLimitWindow
if
w
<=
0
{
w
=
1
}
unit
:=
cfg
.
CancelRateLimitUnit
if
unit
==
""
{
unit
=
rateLimitUnitDay
}
if
cfg
.
CancelRateLimitMode
==
rateLimitModeFixed
{
switch
unit
{
case
rateLimitUnitMinute
:
t
:=
now
.
Truncate
(
time
.
Minute
)
return
t
.
Add
(
-
time
.
Duration
(
w
-
1
)
*
time
.
Minute
)
case
rateLimitUnitDay
:
y
,
m
,
d
:=
now
.
Date
()
t
:=
time
.
Date
(
y
,
m
,
d
,
0
,
0
,
0
,
0
,
now
.
Location
())
return
t
.
AddDate
(
0
,
0
,
-
(
w
-
1
))
default
:
// hour
t
:=
now
.
Truncate
(
time
.
Hour
)
return
t
.
Add
(
-
time
.
Duration
(
w
-
1
)
*
time
.
Hour
)
}
}
// rolling window
switch
unit
{
case
rateLimitUnitMinute
:
return
now
.
Add
(
-
time
.
Duration
(
w
)
*
time
.
Minute
)
case
rateLimitUnitDay
:
return
now
.
AddDate
(
0
,
0
,
-
w
)
default
:
// hour
return
now
.
Add
(
-
time
.
Duration
(
w
)
*
time
.
Hour
)
}
}
func
(
s
*
PaymentService
)
CancelOrder
(
ctx
context
.
Context
,
orderID
,
userID
int64
)
(
string
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
orderID
)
if
err
!=
nil
{
return
""
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
UserID
!=
userID
{
return
""
,
infraerrors
.
Forbidden
(
"FORBIDDEN"
,
"no permission for this order"
)
}
if
o
.
Status
!=
OrderStatusPending
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order cannot be cancelled in current status"
)
}
return
s
.
cancelCore
(
ctx
,
o
,
OrderStatusCancelled
,
fmt
.
Sprintf
(
"user:%d"
,
userID
),
"user cancelled order"
)
}
func
(
s
*
PaymentService
)
AdminCancelOrder
(
ctx
context
.
Context
,
orderID
int64
)
(
string
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
orderID
)
if
err
!=
nil
{
return
""
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
Status
!=
OrderStatusPending
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order cannot be cancelled in current status"
)
}
return
s
.
cancelCore
(
ctx
,
o
,
OrderStatusCancelled
,
"admin"
,
"admin cancelled order"
)
}
func
(
s
*
PaymentService
)
cancelCore
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
,
fs
,
op
,
ad
string
)
(
string
,
error
)
{
if
o
.
PaymentTradeNo
!=
""
||
o
.
PaymentType
!=
""
{
if
s
.
checkPaid
(
ctx
,
o
)
==
checkPaidResultAlreadyPaid
{
return
checkPaidResultAlreadyPaid
,
nil
}
}
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
o
.
ID
),
paymentorder
.
StatusEQ
(
OrderStatusPending
))
.
SetStatus
(
fs
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"update order status: %w"
,
err
)
}
if
c
>
0
{
auditAction
:=
"ORDER_CANCELLED"
if
fs
==
OrderStatusExpired
{
auditAction
=
"ORDER_EXPIRED"
}
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
auditAction
,
op
,
map
[
string
]
any
{
"detail"
:
ad
})
}
return
checkPaidResultCancelled
,
nil
}
func
(
s
*
PaymentService
)
checkPaid
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
string
{
prov
,
err
:=
s
.
getOrderProvider
(
ctx
,
o
)
if
err
!=
nil
{
return
""
}
// Use OutTradeNo as fallback when PaymentTradeNo is empty
// (e.g. EasyPay popup mode where trade_no arrives only via notify callback)
tradeNo
:=
o
.
PaymentTradeNo
if
tradeNo
==
""
{
tradeNo
=
o
.
OutTradeNo
}
resp
,
err
:=
prov
.
QueryOrder
(
ctx
,
tradeNo
)
if
err
!=
nil
{
slog
.
Warn
(
"query upstream failed"
,
"orderID"
,
o
.
ID
,
"error"
,
err
)
return
""
}
if
resp
.
Status
==
payment
.
ProviderStatusPaid
{
if
err
:=
s
.
HandlePaymentNotification
(
ctx
,
&
payment
.
PaymentNotification
{
TradeNo
:
o
.
PaymentTradeNo
,
OrderID
:
o
.
OutTradeNo
,
Amount
:
resp
.
Amount
,
Status
:
payment
.
ProviderStatusSuccess
},
prov
.
ProviderKey
());
err
!=
nil
{
slog
.
Error
(
"fulfillment failed during checkPaid"
,
"orderID"
,
o
.
ID
,
"error"
,
err
)
// Still return already_paid — order was paid, fulfillment can be retried
}
return
checkPaidResultAlreadyPaid
}
if
cp
,
ok
:=
prov
.
(
payment
.
CancelableProvider
);
ok
{
_
=
cp
.
CancelPayment
(
ctx
,
tradeNo
)
}
return
""
}
// VerifyOrderByOutTradeNo actively queries the upstream provider to check
// if a payment was made, and processes it if so. This handles the case where
// the provider's notify callback was missed (e.g. EasyPay popup mode).
func
(
s
*
PaymentService
)
VerifyOrderByOutTradeNo
(
ctx
context
.
Context
,
outTradeNo
string
,
userID
int64
)
(
*
dbent
.
PaymentOrder
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
OutTradeNo
(
outTradeNo
))
.
Only
(
ctx
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
UserID
!=
userID
{
return
nil
,
infraerrors
.
Forbidden
(
"FORBIDDEN"
,
"no permission for this order"
)
}
// Only verify orders that are still pending or recently expired
if
o
.
Status
==
OrderStatusPending
||
o
.
Status
==
OrderStatusExpired
{
result
:=
s
.
checkPaid
(
ctx
,
o
)
if
result
==
checkPaidResultAlreadyPaid
{
// Reload order to get updated status
o
,
err
=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
o
.
ID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"reload order: %w"
,
err
)
}
}
}
return
o
,
nil
}
// VerifyOrderPublic verifies payment status without user authentication.
// Used by the payment result page when the user's session has expired.
func
(
s
*
PaymentService
)
VerifyOrderPublic
(
ctx
context
.
Context
,
outTradeNo
string
)
(
*
dbent
.
PaymentOrder
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
OutTradeNo
(
outTradeNo
))
.
Only
(
ctx
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
Status
==
OrderStatusPending
||
o
.
Status
==
OrderStatusExpired
{
result
:=
s
.
checkPaid
(
ctx
,
o
)
if
result
==
checkPaidResultAlreadyPaid
{
o
,
err
=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
o
.
ID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"reload order: %w"
,
err
)
}
}
}
return
o
,
nil
}
func
(
s
*
PaymentService
)
ExpireTimedOutOrders
(
ctx
context
.
Context
)
(
int
,
error
)
{
now
:=
time
.
Now
()
orders
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
StatusEQ
(
OrderStatusPending
),
paymentorder
.
ExpiresAtLTE
(
now
))
.
All
(
ctx
)
if
err
!=
nil
{
return
0
,
fmt
.
Errorf
(
"query expired: %w"
,
err
)
}
n
:=
0
for
_
,
o
:=
range
orders
{
// Check upstream payment status before expiring — the user may have
// paid just before timeout and the webhook hasn't arrived yet.
outcome
,
_
:=
s
.
cancelCore
(
ctx
,
o
,
OrderStatusExpired
,
"system"
,
"order expired"
)
if
outcome
==
checkPaidResultAlreadyPaid
{
slog
.
Info
(
"order was paid during expiry"
,
"orderID"
,
o
.
ID
)
continue
}
if
outcome
!=
""
{
n
++
}
}
return
n
,
nil
}
// getOrderProvider creates a provider using the order's original instance config.
// Falls back to registry lookup if instance ID is missing (legacy orders).
func
(
s
*
PaymentService
)
getOrderProvider
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
(
payment
.
Provider
,
error
)
{
if
o
.
ProviderInstanceID
!=
nil
&&
*
o
.
ProviderInstanceID
!=
""
{
instID
,
err
:=
strconv
.
ParseInt
(
*
o
.
ProviderInstanceID
,
10
,
64
)
if
err
==
nil
{
cfg
,
err
:=
s
.
loadBalancer
.
GetInstanceConfig
(
ctx
,
instID
)
if
err
==
nil
{
providerKey
:=
s
.
registry
.
GetProviderKey
(
o
.
PaymentType
)
if
providerKey
==
""
{
providerKey
=
o
.
PaymentType
}
p
,
err
:=
provider
.
CreateProvider
(
providerKey
,
*
o
.
ProviderInstanceID
,
cfg
)
if
err
==
nil
{
return
p
,
nil
}
}
}
}
s
.
EnsureProviders
(
ctx
)
return
s
.
registry
.
GetProvider
(
o
.
PaymentType
)
}
backend/internal/service/payment_refund.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Refund Flow ---
func
(
s
*
PaymentService
)
RequestRefund
(
ctx
context
.
Context
,
oid
,
uid
int64
,
reason
string
)
error
{
o
,
err
:=
s
.
validateRefundRequest
(
ctx
,
oid
,
uid
)
if
err
!=
nil
{
return
err
}
u
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
o
.
UserID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
if
u
.
Balance
<
o
.
Amount
{
return
infraerrors
.
BadRequest
(
"BALANCE_NOT_ENOUGH"
,
"refund amount exceeds balance"
)
}
nr
:=
strings
.
TrimSpace
(
reason
)
now
:=
time
.
Now
()
by
:=
fmt
.
Sprintf
(
"%d"
,
uid
)
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
oid
),
paymentorder
.
UserIDEQ
(
uid
),
paymentorder
.
StatusEQ
(
OrderStatusCompleted
),
paymentorder
.
OrderTypeEQ
(
payment
.
OrderTypeBalance
))
.
SetStatus
(
OrderStatusRefundRequested
)
.
SetRefundRequestedAt
(
now
)
.
SetRefundRequestReason
(
nr
)
.
SetRefundRequestedBy
(
by
)
.
SetRefundAmount
(
o
.
Amount
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"update: %w"
,
err
)
}
if
c
==
0
{
return
infraerrors
.
Conflict
(
"CONFLICT"
,
"order status changed"
)
}
s
.
writeAuditLog
(
ctx
,
oid
,
"REFUND_REQUESTED"
,
fmt
.
Sprintf
(
"user:%d"
,
uid
),
map
[
string
]
any
{
"amount"
:
o
.
Amount
,
"reason"
:
nr
})
return
nil
}
func
(
s
*
PaymentService
)
validateRefundRequest
(
ctx
context
.
Context
,
oid
,
uid
int64
)
(
*
dbent
.
PaymentOrder
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
if
o
.
UserID
!=
uid
{
return
nil
,
infraerrors
.
Forbidden
(
"FORBIDDEN"
,
"no permission"
)
}
if
o
.
OrderType
!=
payment
.
OrderTypeBalance
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_ORDER_TYPE"
,
"only balance orders can request refund"
)
}
if
o
.
Status
!=
OrderStatusCompleted
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"only completed orders can request refund"
)
}
return
o
,
nil
}
func
(
s
*
PaymentService
)
PrepareRefund
(
ctx
context
.
Context
,
oid
int64
,
amt
float64
,
reason
string
,
force
,
deduct
bool
)
(
*
RefundPlan
,
*
RefundResult
,
error
)
{
o
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
oid
)
if
err
!=
nil
{
return
nil
,
nil
,
infraerrors
.
NotFound
(
"NOT_FOUND"
,
"order not found"
)
}
ok
:=
[]
string
{
OrderStatusCompleted
,
OrderStatusRefundRequested
,
OrderStatusRefundFailed
}
if
!
psSliceContains
(
ok
,
o
.
Status
)
{
return
nil
,
nil
,
infraerrors
.
BadRequest
(
"INVALID_STATUS"
,
"order status does not allow refund"
)
}
if
math
.
IsNaN
(
amt
)
||
math
.
IsInf
(
amt
,
0
)
{
return
nil
,
nil
,
infraerrors
.
BadRequest
(
"INVALID_AMOUNT"
,
"invalid refund amount"
)
}
if
amt
<=
0
{
amt
=
o
.
Amount
}
if
amt
-
o
.
Amount
>
amountToleranceCNY
{
return
nil
,
nil
,
infraerrors
.
BadRequest
(
"REFUND_AMOUNT_EXCEEDED"
,
"refund amount exceeds recharge"
)
}
// Full refund: use actual pay_amount for gateway (includes fees)
ga
:=
amt
if
math
.
Abs
(
amt
-
o
.
Amount
)
<=
amountToleranceCNY
{
ga
=
o
.
PayAmount
}
rr
:=
strings
.
TrimSpace
(
reason
)
if
rr
==
""
&&
o
.
RefundRequestReason
!=
nil
{
rr
=
*
o
.
RefundRequestReason
}
if
rr
==
""
{
rr
=
fmt
.
Sprintf
(
"refund order:%d"
,
o
.
ID
)
}
p
:=
&
RefundPlan
{
OrderID
:
oid
,
Order
:
o
,
RefundAmount
:
amt
,
GatewayAmount
:
ga
,
Reason
:
rr
,
Force
:
force
,
DeductBalance
:
deduct
,
DeductionType
:
payment
.
DeductionTypeNone
}
if
deduct
{
if
er
:=
s
.
prepDeduct
(
ctx
,
o
,
p
,
force
);
er
!=
nil
{
return
nil
,
er
,
nil
}
}
return
p
,
nil
,
nil
}
func
(
s
*
PaymentService
)
prepDeduct
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
,
p
*
RefundPlan
,
force
bool
)
*
RefundResult
{
if
o
.
OrderType
==
payment
.
OrderTypeSubscription
{
p
.
DeductionType
=
payment
.
DeductionTypeSubscription
if
o
.
SubscriptionGroupID
!=
nil
&&
o
.
SubscriptionDays
!=
nil
{
p
.
SubDaysToDeduct
=
*
o
.
SubscriptionDays
sub
,
err
:=
s
.
subscriptionSvc
.
GetActiveSubscription
(
ctx
,
o
.
UserID
,
*
o
.
SubscriptionGroupID
)
if
err
==
nil
&&
sub
!=
nil
{
p
.
SubscriptionID
=
sub
.
ID
}
else
if
!
force
{
return
&
RefundResult
{
Success
:
false
,
Warning
:
"cannot find active subscription for deduction, use force"
,
RequireForce
:
true
}
}
}
return
nil
}
u
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
o
.
UserID
)
if
err
!=
nil
{
if
!
force
{
return
&
RefundResult
{
Success
:
false
,
Warning
:
"cannot fetch user balance, use force"
,
RequireForce
:
true
}
}
return
nil
}
p
.
DeductionType
=
payment
.
DeductionTypeBalance
p
.
BalanceToDeduct
=
math
.
Min
(
p
.
RefundAmount
,
u
.
Balance
)
return
nil
}
func
(
s
*
PaymentService
)
ExecuteRefund
(
ctx
context
.
Context
,
p
*
RefundPlan
)
(
*
RefundResult
,
error
)
{
c
,
err
:=
s
.
entClient
.
PaymentOrder
.
Update
()
.
Where
(
paymentorder
.
IDEQ
(
p
.
OrderID
),
paymentorder
.
StatusIn
(
OrderStatusCompleted
,
OrderStatusRefundRequested
,
OrderStatusRefundFailed
))
.
SetStatus
(
OrderStatusRefunding
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"lock: %w"
,
err
)
}
if
c
==
0
{
return
nil
,
infraerrors
.
Conflict
(
"CONFLICT"
,
"order status changed"
)
}
if
p
.
DeductionType
==
payment
.
DeductionTypeBalance
&&
p
.
BalanceToDeduct
>
0
{
// Skip balance deduction on retry if previous attempt already deducted
// but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
if
!
s
.
hasAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_ROLLBACK_FAILED"
)
{
if
err
:=
s
.
userRepo
.
DeductBalance
(
ctx
,
p
.
Order
.
UserID
,
p
.
BalanceToDeduct
);
err
!=
nil
{
s
.
restoreStatus
(
ctx
,
p
)
return
nil
,
fmt
.
Errorf
(
"deduction: %w"
,
err
)
}
}
else
{
slog
.
Warn
(
"skipping balance deduction on retry (previous rollback failed)"
,
"orderID"
,
p
.
OrderID
)
p
.
BalanceToDeduct
=
0
}
}
if
p
.
DeductionType
==
payment
.
DeductionTypeSubscription
&&
p
.
SubDaysToDeduct
>
0
&&
p
.
SubscriptionID
>
0
{
if
!
s
.
hasAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_ROLLBACK_FAILED"
)
{
_
,
err
:=
s
.
subscriptionSvc
.
ExtendSubscription
(
ctx
,
p
.
SubscriptionID
,
-
p
.
SubDaysToDeduct
)
if
err
!=
nil
{
// If deducting would expire the subscription, revoke it entirely
slog
.
Info
(
"subscription deduction would expire, revoking"
,
"orderID"
,
p
.
OrderID
,
"subID"
,
p
.
SubscriptionID
,
"days"
,
p
.
SubDaysToDeduct
)
if
revokeErr
:=
s
.
subscriptionSvc
.
RevokeSubscription
(
ctx
,
p
.
SubscriptionID
);
revokeErr
!=
nil
{
s
.
restoreStatus
(
ctx
,
p
)
return
nil
,
fmt
.
Errorf
(
"revoke subscription: %w"
,
revokeErr
)
}
}
}
else
{
slog
.
Warn
(
"skipping subscription deduction on retry (previous rollback failed)"
,
"orderID"
,
p
.
OrderID
)
p
.
SubDaysToDeduct
=
0
}
}
if
err
:=
s
.
gwRefund
(
ctx
,
p
);
err
!=
nil
{
return
s
.
handleGwFail
(
ctx
,
p
,
err
)
}
return
s
.
markRefundOk
(
ctx
,
p
)
}
func
(
s
*
PaymentService
)
gwRefund
(
ctx
context
.
Context
,
p
*
RefundPlan
)
error
{
if
p
.
Order
.
PaymentTradeNo
==
""
{
s
.
writeAuditLog
(
ctx
,
p
.
Order
.
ID
,
"REFUND_NO_TRADE_NO"
,
"admin"
,
map
[
string
]
any
{
"detail"
:
"skipped"
})
return
nil
}
// Use the exact provider instance that created this order, not a random one
// from the registry. Each instance has its own merchant credentials.
prov
,
err
:=
s
.
getRefundProvider
(
ctx
,
p
.
Order
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"get refund provider: %w"
,
err
)
}
_
,
err
=
prov
.
Refund
(
ctx
,
payment
.
RefundRequest
{
TradeNo
:
p
.
Order
.
PaymentTradeNo
,
OrderID
:
p
.
Order
.
OutTradeNo
,
Amount
:
strconv
.
FormatFloat
(
p
.
GatewayAmount
,
'f'
,
2
,
64
),
Reason
:
p
.
Reason
,
})
return
err
}
// getRefundProvider creates a provider using the order's original instance config.
// Delegates to getOrderProvider which handles instance lookup and fallback.
func
(
s
*
PaymentService
)
getRefundProvider
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
(
payment
.
Provider
,
error
)
{
return
s
.
getOrderProvider
(
ctx
,
o
)
}
func
(
s
*
PaymentService
)
handleGwFail
(
ctx
context
.
Context
,
p
*
RefundPlan
,
gErr
error
)
(
*
RefundResult
,
error
)
{
if
s
.
RollbackRefund
(
ctx
,
p
,
gErr
)
{
s
.
restoreStatus
(
ctx
,
p
)
s
.
writeAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_GATEWAY_FAILED"
,
"admin"
,
map
[
string
]
any
{
"detail"
:
psErrMsg
(
gErr
)})
return
&
RefundResult
{
Success
:
false
,
Warning
:
"gateway failed: "
+
psErrMsg
(
gErr
)
+
", rolled back"
},
nil
}
now
:=
time
.
Now
()
_
,
_
=
s
.
entClient
.
PaymentOrder
.
UpdateOneID
(
p
.
OrderID
)
.
SetStatus
(
OrderStatusRefundFailed
)
.
SetFailedAt
(
now
)
.
SetFailedReason
(
psErrMsg
(
gErr
))
.
Save
(
ctx
)
s
.
writeAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_FAILED"
,
"admin"
,
map
[
string
]
any
{
"detail"
:
psErrMsg
(
gErr
)})
return
nil
,
infraerrors
.
InternalServer
(
"REFUND_FAILED"
,
psErrMsg
(
gErr
))
}
func
(
s
*
PaymentService
)
markRefundOk
(
ctx
context
.
Context
,
p
*
RefundPlan
)
(
*
RefundResult
,
error
)
{
fs
:=
OrderStatusRefunded
if
p
.
RefundAmount
<
p
.
Order
.
Amount
{
fs
=
OrderStatusPartiallyRefunded
}
now
:=
time
.
Now
()
_
,
err
:=
s
.
entClient
.
PaymentOrder
.
UpdateOneID
(
p
.
OrderID
)
.
SetStatus
(
fs
)
.
SetRefundAmount
(
p
.
RefundAmount
)
.
SetRefundReason
(
p
.
Reason
)
.
SetRefundAt
(
now
)
.
SetForceRefund
(
p
.
Force
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"mark refund: %w"
,
err
)
}
s
.
writeAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_SUCCESS"
,
"admin"
,
map
[
string
]
any
{
"refundAmount"
:
p
.
RefundAmount
,
"reason"
:
p
.
Reason
,
"balanceDeducted"
:
p
.
BalanceToDeduct
,
"force"
:
p
.
Force
})
return
&
RefundResult
{
Success
:
true
,
BalanceDeducted
:
p
.
BalanceToDeduct
,
SubDaysDeducted
:
p
.
SubDaysToDeduct
},
nil
}
func
(
s
*
PaymentService
)
RollbackRefund
(
ctx
context
.
Context
,
p
*
RefundPlan
,
gErr
error
)
bool
{
if
p
.
DeductionType
==
payment
.
DeductionTypeBalance
&&
p
.
BalanceToDeduct
>
0
{
if
err
:=
s
.
userRepo
.
UpdateBalance
(
ctx
,
p
.
Order
.
UserID
,
p
.
BalanceToDeduct
);
err
!=
nil
{
slog
.
Error
(
"[CRITICAL] rollback failed"
,
"orderID"
,
p
.
OrderID
,
"amount"
,
p
.
BalanceToDeduct
,
"error"
,
err
)
s
.
writeAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_ROLLBACK_FAILED"
,
"admin"
,
map
[
string
]
any
{
"gatewayError"
:
psErrMsg
(
gErr
),
"rollbackError"
:
psErrMsg
(
err
),
"balanceDeducted"
:
p
.
BalanceToDeduct
})
return
false
}
}
if
p
.
DeductionType
==
payment
.
DeductionTypeSubscription
&&
p
.
SubDaysToDeduct
>
0
&&
p
.
SubscriptionID
>
0
{
if
_
,
err
:=
s
.
subscriptionSvc
.
ExtendSubscription
(
ctx
,
p
.
SubscriptionID
,
p
.
SubDaysToDeduct
);
err
!=
nil
{
slog
.
Error
(
"[CRITICAL] subscription rollback failed"
,
"orderID"
,
p
.
OrderID
,
"subID"
,
p
.
SubscriptionID
,
"days"
,
p
.
SubDaysToDeduct
,
"error"
,
err
)
s
.
writeAuditLog
(
ctx
,
p
.
OrderID
,
"REFUND_ROLLBACK_FAILED"
,
"admin"
,
map
[
string
]
any
{
"gatewayError"
:
psErrMsg
(
gErr
),
"rollbackError"
:
psErrMsg
(
err
),
"subDaysDeducted"
:
p
.
SubDaysToDeduct
})
return
false
}
}
return
true
}
func
(
s
*
PaymentService
)
restoreStatus
(
ctx
context
.
Context
,
p
*
RefundPlan
)
{
rs
:=
OrderStatusCompleted
if
p
.
Order
.
Status
==
OrderStatusRefundRequested
{
rs
=
OrderStatusRefundRequested
}
_
,
_
=
s
.
entClient
.
PaymentOrder
.
UpdateOneID
(
p
.
OrderID
)
.
SetStatus
(
rs
)
.
Save
(
ctx
)
}
backend/internal/service/payment_service.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"fmt"
"log/slog"
"math/rand/v2"
"sync"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
)
// --- Order Status Constants ---
const
(
OrderStatusPending
=
payment
.
OrderStatusPending
OrderStatusPaid
=
payment
.
OrderStatusPaid
OrderStatusRecharging
=
payment
.
OrderStatusRecharging
OrderStatusCompleted
=
payment
.
OrderStatusCompleted
OrderStatusExpired
=
payment
.
OrderStatusExpired
OrderStatusCancelled
=
payment
.
OrderStatusCancelled
OrderStatusFailed
=
payment
.
OrderStatusFailed
OrderStatusRefundRequested
=
payment
.
OrderStatusRefundRequested
OrderStatusRefunding
=
payment
.
OrderStatusRefunding
OrderStatusPartiallyRefunded
=
payment
.
OrderStatusPartiallyRefunded
OrderStatusRefunded
=
payment
.
OrderStatusRefunded
OrderStatusRefundFailed
=
payment
.
OrderStatusRefundFailed
)
const
(
// defaultMaxPendingOrders and defaultOrderTimeoutMin are defined in
// payment_config_service.go alongside other payment configuration defaults.
paymentGraceMinutes
=
5
defaultPageSize
=
20
maxPageSize
=
100
topUsersLimit
=
10
amountToleranceCNY
=
0.01
orderIDPrefix
=
"sub2_"
)
// --- Types ---
// generateOutTradeNo creates a unique external order ID for payment providers.
// Format: sub2_20250409aB3kX9mQ (prefix + date + 8-char random)
func
generateOutTradeNo
()
string
{
date
:=
time
.
Now
()
.
Format
(
"20060102"
)
rnd
:=
generateRandomString
(
8
)
return
orderIDPrefix
+
date
+
rnd
}
func
generateRandomString
(
n
int
)
string
{
const
charset
=
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b
:=
make
([]
byte
,
n
)
for
i
:=
range
b
{
b
[
i
]
=
charset
[
rand
.
IntN
(
len
(
charset
))]
}
return
string
(
b
)
}
type
CreateOrderRequest
struct
{
UserID
int64
Amount
float64
PaymentType
string
ClientIP
string
IsMobile
bool
SrcHost
string
SrcURL
string
OrderType
string
PlanID
int64
}
type
CreateOrderResponse
struct
{
OrderID
int64
`json:"order_id"`
Amount
float64
`json:"amount"`
PayAmount
float64
`json:"pay_amount"`
FeeRate
float64
`json:"fee_rate"`
Status
string
`json:"status"`
PaymentType
string
`json:"payment_type"`
PayURL
string
`json:"pay_url,omitempty"`
QRCode
string
`json:"qr_code,omitempty"`
ClientSecret
string
`json:"client_secret,omitempty"`
ExpiresAt
time
.
Time
`json:"expires_at"`
PaymentMode
string
`json:"payment_mode,omitempty"`
}
type
OrderListParams
struct
{
Page
int
PageSize
int
Status
string
OrderType
string
PaymentType
string
Keyword
string
}
type
RefundPlan
struct
{
OrderID
int64
Order
*
dbent
.
PaymentOrder
RefundAmount
float64
GatewayAmount
float64
Reason
string
Force
bool
DeductBalance
bool
DeductionType
string
BalanceToDeduct
float64
SubDaysToDeduct
int
SubscriptionID
int64
}
type
RefundResult
struct
{
Success
bool
`json:"success"`
Warning
string
`json:"warning,omitempty"`
RequireForce
bool
`json:"require_force,omitempty"`
BalanceDeducted
float64
`json:"balance_deducted,omitempty"`
SubDaysDeducted
int
`json:"subscription_days_deducted,omitempty"`
}
type
DashboardStats
struct
{
TodayAmount
float64
`json:"today_amount"`
TotalAmount
float64
`json:"total_amount"`
TodayCount
int
`json:"today_count"`
TotalCount
int
`json:"total_count"`
AvgAmount
float64
`json:"avg_amount"`
PendingOrders
int
`json:"pending_orders"`
DailySeries
[]
DailyStats
`json:"daily_series"`
PaymentMethods
[]
PaymentMethodStat
`json:"payment_methods"`
TopUsers
[]
TopUserStat
`json:"top_users"`
}
type
DailyStats
struct
{
Date
string
`json:"date"`
Amount
float64
`json:"amount"`
Count
int
`json:"count"`
}
type
PaymentMethodStat
struct
{
Type
string
`json:"type"`
Amount
float64
`json:"amount"`
Count
int
`json:"count"`
}
type
TopUserStat
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Amount
float64
`json:"amount"`
}
// --- Service ---
type
PaymentService
struct
{
providerMu
sync
.
Mutex
providersLoaded
bool
entClient
*
dbent
.
Client
registry
*
payment
.
Registry
loadBalancer
payment
.
LoadBalancer
redeemService
*
RedeemService
subscriptionSvc
*
SubscriptionService
configService
*
PaymentConfigService
userRepo
UserRepository
groupRepo
GroupRepository
}
func
NewPaymentService
(
entClient
*
dbent
.
Client
,
registry
*
payment
.
Registry
,
loadBalancer
payment
.
LoadBalancer
,
redeemService
*
RedeemService
,
subscriptionSvc
*
SubscriptionService
,
configService
*
PaymentConfigService
,
userRepo
UserRepository
,
groupRepo
GroupRepository
)
*
PaymentService
{
return
&
PaymentService
{
entClient
:
entClient
,
registry
:
registry
,
loadBalancer
:
loadBalancer
,
redeemService
:
redeemService
,
subscriptionSvc
:
subscriptionSvc
,
configService
:
configService
,
userRepo
:
userRepo
,
groupRepo
:
groupRepo
}
}
// --- Provider Registry ---
// EnsureProviders lazily initializes the provider registry on first call.
func
(
s
*
PaymentService
)
EnsureProviders
(
ctx
context
.
Context
)
{
s
.
providerMu
.
Lock
()
defer
s
.
providerMu
.
Unlock
()
if
!
s
.
providersLoaded
{
s
.
loadProviders
(
ctx
)
s
.
providersLoaded
=
true
}
}
// RefreshProviders clears and re-registers all providers from the database.
func
(
s
*
PaymentService
)
RefreshProviders
(
ctx
context
.
Context
)
{
s
.
providerMu
.
Lock
()
defer
s
.
providerMu
.
Unlock
()
s
.
registry
.
Clear
()
s
.
loadProviders
(
ctx
)
s
.
providersLoaded
=
true
}
func
(
s
*
PaymentService
)
loadProviders
(
ctx
context
.
Context
)
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
Where
(
paymentproviderinstance
.
EnabledEQ
(
true
))
.
All
(
ctx
)
if
err
!=
nil
{
slog
.
Error
(
"[PaymentService] failed to query provider instances"
,
"error"
,
err
)
return
}
for
_
,
inst
:=
range
instances
{
cfg
,
err
:=
s
.
loadBalancer
.
GetInstanceConfig
(
ctx
,
int64
(
inst
.
ID
))
if
err
!=
nil
{
slog
.
Warn
(
"[PaymentService] failed to decrypt config for instance"
,
"instanceID"
,
inst
.
ID
,
"error"
,
err
)
continue
}
if
inst
.
PaymentMode
!=
""
{
cfg
[
"paymentMode"
]
=
inst
.
PaymentMode
}
instID
:=
fmt
.
Sprintf
(
"%d"
,
inst
.
ID
)
p
,
err
:=
provider
.
CreateProvider
(
inst
.
ProviderKey
,
instID
,
cfg
)
if
err
!=
nil
{
slog
.
Warn
(
"[PaymentService] failed to create provider for instance"
,
"instanceID"
,
inst
.
ID
,
"key"
,
inst
.
ProviderKey
,
"error"
,
err
)
continue
}
s
.
registry
.
Register
(
p
)
}
}
// GetWebhookProvider returns the provider instance that should verify a webhook.
// It extracts out_trade_no from the raw body, looks up the order to find the
// original provider instance, and creates a provider with that instance's credentials.
// Falls back to the registry provider when the order cannot be found.
func
(
s
*
PaymentService
)
GetWebhookProvider
(
ctx
context
.
Context
,
providerKey
,
outTradeNo
string
)
(
payment
.
Provider
,
error
)
{
if
outTradeNo
!=
""
{
order
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
OutTradeNo
(
outTradeNo
))
.
Only
(
ctx
)
if
err
==
nil
{
p
,
pErr
:=
s
.
getOrderProvider
(
ctx
,
order
)
if
pErr
==
nil
{
return
p
,
nil
}
slog
.
Warn
(
"[Webhook] order provider creation failed, falling back to registry"
,
"outTradeNo"
,
outTradeNo
,
"error"
,
pErr
)
}
}
s
.
EnsureProviders
(
ctx
)
return
s
.
registry
.
GetProviderByKey
(
providerKey
)
}
// --- Helpers ---
func
psIsRefundStatus
(
s
string
)
bool
{
switch
s
{
case
OrderStatusRefundRequested
,
OrderStatusRefunding
,
OrderStatusPartiallyRefunded
,
OrderStatusRefunded
,
OrderStatusRefundFailed
:
return
true
}
return
false
}
func
psErrMsg
(
err
error
)
string
{
if
err
==
nil
{
return
""
}
return
err
.
Error
()
}
func
psNilIfEmpty
(
s
string
)
*
string
{
if
s
==
""
{
return
nil
}
return
&
s
}
func
psSliceContains
(
sl
[]
string
,
s
string
)
bool
{
for
_
,
v
:=
range
sl
{
if
v
==
s
{
return
true
}
}
return
false
}
// Subscription validity period unit constants.
const
(
validityUnitWeek
=
"week"
validityUnitMonth
=
"month"
)
func
psComputeValidityDays
(
days
int
,
unit
string
)
int
{
switch
unit
{
case
validityUnitWeek
:
return
days
*
7
case
validityUnitMonth
:
return
days
*
30
default
:
return
days
}
}
func
(
s
*
PaymentService
)
getFeeRate
(
_
string
)
float64
{
return
0
}
func
psStartOfDayUTC
(
t
time
.
Time
)
time
.
Time
{
y
,
m
,
d
:=
t
.
UTC
()
.
Date
()
return
time
.
Date
(
y
,
m
,
d
,
0
,
0
,
0
,
0
,
time
.
UTC
)
}
func
applyPagination
(
pageSize
,
page
int
)
(
size
,
pg
int
)
{
size
=
pageSize
if
size
<=
0
{
size
=
defaultPageSize
}
if
size
>
maxPageSize
{
size
=
maxPageSize
}
pg
=
page
if
pg
<
1
{
pg
=
1
}
return
size
,
pg
}
backend/internal/service/payment_stats.go
0 → 100644
View file @
a04ae28a
package
service
import
(
"context"
"encoding/json"
"log/slog"
"math"
"sort"
"strconv"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
)
// --- Dashboard & Analytics ---
func
(
s
*
PaymentService
)
GetDashboardStats
(
ctx
context
.
Context
,
days
int
)
(
*
DashboardStats
,
error
)
{
if
days
<=
0
{
days
=
30
}
now
:=
time
.
Now
()
since
:=
now
.
AddDate
(
0
,
0
,
-
days
)
todayStart
:=
time
.
Date
(
now
.
Year
(),
now
.
Month
(),
now
.
Day
(),
0
,
0
,
0
,
0
,
now
.
Location
())
paidStatuses
:=
[]
string
{
OrderStatusCompleted
,
OrderStatusPaid
,
OrderStatusRecharging
}
orders
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
StatusIn
(
paidStatuses
...
),
paymentorder
.
PaidAtGTE
(
since
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
st
:=
&
DashboardStats
{}
computeBasicStats
(
st
,
orders
,
todayStart
)
st
.
PendingOrders
,
err
=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
StatusEQ
(
OrderStatusPending
))
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
st
.
DailySeries
=
buildDailySeries
(
orders
,
since
,
days
)
st
.
PaymentMethods
=
buildMethodDistribution
(
orders
)
st
.
TopUsers
=
buildTopUsers
(
orders
)
return
st
,
nil
}
func
computeBasicStats
(
st
*
DashboardStats
,
orders
[]
*
dbent
.
PaymentOrder
,
todayStart
time
.
Time
)
{
var
totalAmount
,
todayAmount
float64
var
todayCount
int
for
_
,
o
:=
range
orders
{
totalAmount
+=
o
.
PayAmount
if
o
.
PaidAt
!=
nil
&&
!
o
.
PaidAt
.
Before
(
todayStart
)
{
todayAmount
+=
o
.
PayAmount
todayCount
++
}
}
st
.
TotalAmount
=
math
.
Round
(
totalAmount
*
100
)
/
100
st
.
TodayAmount
=
math
.
Round
(
todayAmount
*
100
)
/
100
st
.
TotalCount
=
len
(
orders
)
st
.
TodayCount
=
todayCount
if
st
.
TotalCount
>
0
{
st
.
AvgAmount
=
math
.
Round
(
totalAmount
/
float64
(
st
.
TotalCount
)
*
100
)
/
100
}
}
func
buildDailySeries
(
orders
[]
*
dbent
.
PaymentOrder
,
since
time
.
Time
,
days
int
)
[]
DailyStats
{
dailyMap
:=
make
(
map
[
string
]
*
DailyStats
)
for
_
,
o
:=
range
orders
{
if
o
.
PaidAt
==
nil
{
continue
}
date
:=
o
.
PaidAt
.
Format
(
"2006-01-02"
)
ds
,
ok
:=
dailyMap
[
date
]
if
!
ok
{
ds
=
&
DailyStats
{
Date
:
date
}
dailyMap
[
date
]
=
ds
}
ds
.
Amount
+=
o
.
PayAmount
ds
.
Count
++
}
series
:=
make
([]
DailyStats
,
0
,
days
)
for
i
:=
0
;
i
<
days
;
i
++
{
date
:=
since
.
AddDate
(
0
,
0
,
i
+
1
)
.
Format
(
"2006-01-02"
)
if
ds
,
ok
:=
dailyMap
[
date
];
ok
{
ds
.
Amount
=
math
.
Round
(
ds
.
Amount
*
100
)
/
100
series
=
append
(
series
,
*
ds
)
}
else
{
series
=
append
(
series
,
DailyStats
{
Date
:
date
})
}
}
return
series
}
func
buildMethodDistribution
(
orders
[]
*
dbent
.
PaymentOrder
)
[]
PaymentMethodStat
{
methodMap
:=
make
(
map
[
string
]
*
PaymentMethodStat
)
for
_
,
o
:=
range
orders
{
ms
,
ok
:=
methodMap
[
o
.
PaymentType
]
if
!
ok
{
ms
=
&
PaymentMethodStat
{
Type
:
o
.
PaymentType
}
methodMap
[
o
.
PaymentType
]
=
ms
}
ms
.
Amount
+=
o
.
PayAmount
ms
.
Count
++
}
methods
:=
make
([]
PaymentMethodStat
,
0
,
len
(
methodMap
))
for
_
,
ms
:=
range
methodMap
{
ms
.
Amount
=
math
.
Round
(
ms
.
Amount
*
100
)
/
100
methods
=
append
(
methods
,
*
ms
)
}
return
methods
}
func
buildTopUsers
(
orders
[]
*
dbent
.
PaymentOrder
)
[]
TopUserStat
{
userMap
:=
make
(
map
[
int64
]
*
TopUserStat
)
for
_
,
o
:=
range
orders
{
us
,
ok
:=
userMap
[
o
.
UserID
]
if
!
ok
{
us
=
&
TopUserStat
{
UserID
:
o
.
UserID
,
Email
:
o
.
UserEmail
}
userMap
[
o
.
UserID
]
=
us
}
us
.
Amount
+=
o
.
PayAmount
}
userList
:=
make
([]
*
TopUserStat
,
0
,
len
(
userMap
))
for
_
,
us
:=
range
userMap
{
us
.
Amount
=
math
.
Round
(
us
.
Amount
*
100
)
/
100
userList
=
append
(
userList
,
us
)
}
sort
.
Slice
(
userList
,
func
(
i
,
j
int
)
bool
{
return
userList
[
i
]
.
Amount
>
userList
[
j
]
.
Amount
})
limit
:=
topUsersLimit
if
len
(
userList
)
<
limit
{
limit
=
len
(
userList
)
}
result
:=
make
([]
TopUserStat
,
0
,
limit
)
for
i
:=
0
;
i
<
limit
;
i
++
{
result
=
append
(
result
,
*
userList
[
i
])
}
return
result
}
// --- Audit Logs ---
func
(
s
*
PaymentService
)
writeAuditLog
(
ctx
context
.
Context
,
oid
int64
,
action
,
op
string
,
detail
map
[
string
]
any
)
{
dj
,
_
:=
json
.
Marshal
(
detail
)
_
,
err
:=
s
.
entClient
.
PaymentAuditLog
.
Create
()
.
SetOrderID
(
strconv
.
FormatInt
(
oid
,
10
))
.
SetAction
(
action
)
.
SetDetail
(
string
(
dj
))
.
SetOperator
(
op
)
.
Save
(
ctx
)
if
err
!=
nil
{
slog
.
Error
(
"audit log failed"
,
"orderID"
,
oid
,
"action"
,
action
,
"error"
,
err
)
}
}
func
(
s
*
PaymentService
)
GetOrderAuditLogs
(
ctx
context
.
Context
,
oid
int64
)
([]
*
dbent
.
PaymentAuditLog
,
error
)
{
return
s
.
entClient
.
PaymentAuditLog
.
Query
()
.
Where
(
paymentauditlog
.
OrderIDEQ
(
strconv
.
FormatInt
(
oid
,
10
)))
.
Order
(
paymentauditlog
.
ByCreatedAt
())
.
All
(
ctx
)
}
backend/internal/service/ratelimit_service.go
View file @
a04ae28a
...
...
@@ -142,11 +142,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
switch
statusCode
{
case
400
:
//
只有当错误信息包含
"organization has been disabled"
时才
禁用
// "organization has been disabled"
→ 永久
禁用
if
strings
.
Contains
(
strings
.
ToLower
(
upstreamMsg
),
"organization has been disabled"
)
{
msg
:=
"Organization disabled (400): "
+
upstreamMsg
s
.
handleAuthError
(
ctx
,
account
,
msg
)
shouldDisable
=
true
}
else
if
account
.
Platform
==
PlatformAnthropic
&&
strings
.
Contains
(
strings
.
ToLower
(
upstreamMsg
),
"credit balance"
)
{
// Anthropic API key 余额不足(语义等同 402),停止调度
msg
:=
"Credit balance exhausted (400): "
+
upstreamMsg
s
.
handleAuthError
(
ctx
,
account
,
msg
)
shouldDisable
=
true
}
// 其他 400 错误(如参数问题)不处理,不禁用账号
case
401
:
...
...
backend/internal/service/scheduler_snapshot_hydration_test.go
0 → 100644
View file @
a04ae28a
//go:build unit
package
service
import
(
"context"
"testing"
"time"
)
type
snapshotHydrationCache
struct
{
snapshot
[]
*
Account
accounts
map
[
int64
]
*
Account
}
func
(
c
*
snapshotHydrationCache
)
GetSnapshot
(
ctx
context
.
Context
,
bucket
SchedulerBucket
)
([]
*
Account
,
bool
,
error
)
{
return
c
.
snapshot
,
true
,
nil
}
func
(
c
*
snapshotHydrationCache
)
SetSnapshot
(
ctx
context
.
Context
,
bucket
SchedulerBucket
,
accounts
[]
Account
)
error
{
return
nil
}
func
(
c
*
snapshotHydrationCache
)
GetAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
if
c
.
accounts
==
nil
{
return
nil
,
nil
}
return
c
.
accounts
[
accountID
],
nil
}
func
(
c
*
snapshotHydrationCache
)
SetAccount
(
ctx
context
.
Context
,
account
*
Account
)
error
{
return
nil
}
func
(
c
*
snapshotHydrationCache
)
DeleteAccount
(
ctx
context
.
Context
,
accountID
int64
)
error
{
return
nil
}
func
(
c
*
snapshotHydrationCache
)
UpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
{
return
nil
}
func
(
c
*
snapshotHydrationCache
)
TryLockBucket
(
ctx
context
.
Context
,
bucket
SchedulerBucket
,
ttl
time
.
Duration
)
(
bool
,
error
)
{
return
true
,
nil
}
func
(
c
*
snapshotHydrationCache
)
ListBuckets
(
ctx
context
.
Context
)
([]
SchedulerBucket
,
error
)
{
return
nil
,
nil
}
func
(
c
*
snapshotHydrationCache
)
GetOutboxWatermark
(
ctx
context
.
Context
)
(
int64
,
error
)
{
return
0
,
nil
}
func
(
c
*
snapshotHydrationCache
)
SetOutboxWatermark
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
TestOpenAISelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot
(
t
*
testing
.
T
)
{
cache
:=
&
snapshotHydrationCache
{
snapshot
:
[]
*
Account
{
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
Credentials
:
map
[
string
]
any
{
"model_mapping"
:
map
[
string
]
any
{
"gpt-4"
:
"gpt-4"
,
},
},
},
},
accounts
:
map
[
int64
]
*
Account
{
1
:
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
Credentials
:
map
[
string
]
any
{
"api_key"
:
"sk-live"
,
"model_mapping"
:
map
[
string
]
any
{
"gpt-4"
:
"gpt-4"
},
},
},
},
}
schedulerSnapshot
:=
NewSchedulerSnapshotService
(
cache
,
nil
,
nil
,
nil
,
nil
)
groupID
:=
int64
(
2
)
svc
:=
&
OpenAIGatewayService
{
schedulerSnapshot
:
schedulerSnapshot
,
cache
:
&
stubGatewayCache
{},
}
selection
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
&
groupID
,
""
,
"gpt-4"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
t
.
Fatalf
(
"expected selected account"
)
}
if
got
:=
selection
.
Account
.
GetOpenAIApiKey
();
got
!=
"sk-live"
{
t
.
Fatalf
(
"expected hydrated api key, got %q"
,
got
)
}
}
func
TestGatewaySelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot
(
t
*
testing
.
T
)
{
cache
:=
&
snapshotHydrationCache
{
snapshot
:
[]
*
Account
{
{
ID
:
9
,
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
},
},
accounts
:
map
[
int64
]
*
Account
{
9
:
{
ID
:
9
,
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
Credentials
:
map
[
string
]
any
{
"api_key"
:
"anthropic-live-key"
,
},
},
},
}
schedulerSnapshot
:=
NewSchedulerSnapshotService
(
cache
,
nil
,
nil
,
nil
,
nil
)
svc
:=
&
GatewayService
{
schedulerSnapshot
:
schedulerSnapshot
,
cache
:
&
mockGatewayCacheForPlatform
{},
cfg
:
testConfig
(),
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
,
0
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
result
==
nil
||
result
.
Account
==
nil
{
t
.
Fatalf
(
"expected selected account"
)
}
if
got
:=
result
.
Account
.
GetCredential
(
"api_key"
);
got
!=
"anthropic-live-key"
{
t
.
Fatalf
(
"expected hydrated api key, got %q"
,
got
)
}
}
Prev
1
…
6
7
8
9
10
11
12
13
14
…
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