Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
50e5377e
authored
Aug 10, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(topup): settle recharge orders atomically
parent
d7992672
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
364 additions
and
79 deletions
+364
-79
common/quota_math.go
+6
-0
controller/topup.go
+27
-38
model/payment_method_guard_test.go
+137
-1
model/quota_reserve.go
+68
-0
model/redemption.go
+1
-0
model/topup.go
+108
-38
model/user_cache.go
+17
-2
No files found.
common/quota_math.go
View file @
50e5377e
...
...
@@ -146,3 +146,9 @@ func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
f
,
_
:=
d
.
Round
(
0
)
.
Float64
()
return
saturateQuota
(
f
,
"QuotaFromDecimal"
)
}
// QuotaFromDecimalStrict converts an in-range decimal quota and rejects a
// value that would otherwise be saturated at the database's int32 boundary.
func
QuotaFromDecimalStrict
(
d
decimal
.
Decimal
)
(
int
,
error
)
{
return
strictQuota
(
QuotaFromDecimalChecked
(
d
))
}
controller/topup.go
View file @
50e5377e
package
controller
import
(
"errors"
"fmt"
"net/http"
"net/url"
...
...
@@ -181,7 +182,7 @@ func getMinTopup() int64 {
if
operation_setting
.
GetQuotaDisplayType
()
==
operation_setting
.
QuotaDisplayTypeTokens
{
dMinTopup
:=
decimal
.
NewFromInt
(
int64
(
minTopup
))
dQuotaPerUnit
:=
decimal
.
NewFromFloat
(
common
.
QuotaPerUnit
)
minTopup
=
int
(
dMinTopup
.
Mul
(
dQuotaPerUnit
)
.
IntPart
(
))
minTopup
=
common
.
QuotaFromDecimal
(
dMinTopup
.
Mul
(
dQuotaPerUnit
))
}
return
int64
(
minTopup
)
}
...
...
@@ -351,16 +352,9 @@ func EpayNotify(c *gin.Context) {
return
}
verifyInfo
,
err
:=
client
.
Verify
(
params
)
if
err
==
nil
&&
verifyInfo
.
VerifyStatus
{
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
verifyInfo
.
TradeStatus
,
c
.
ClientIP
(),
common
.
GetJsonString
(
verifyInfo
)))
_
,
err
:=
c
.
Writer
.
Write
([]
byte
(
"success"
))
if
err
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q"
,
verifyInfo
.
ServiceTradeNo
,
c
.
ClientIP
(),
err
.
Error
()))
}
}
else
{
_
,
err
:=
c
.
Writer
.
Write
([]
byte
(
"fail"
))
if
err
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q"
,
c
.
Request
.
RequestURI
,
c
.
ClientIP
(),
err
.
Error
()))
if
err
!=
nil
||
!
verifyInfo
.
VerifyStatus
{
if
_
,
writeErr
:=
c
.
Writer
.
Write
([]
byte
(
"fail"
));
writeErr
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q"
,
c
.
Request
.
RequestURI
,
c
.
ClientIP
(),
writeErr
.
Error
()))
}
if
err
!=
nil
{
logger
.
LogWarn
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q"
,
c
.
Request
.
RequestURI
,
c
.
ClientIP
(),
err
.
Error
()))
...
...
@@ -369,46 +363,41 @@ func EpayNotify(c *gin.Context) {
}
return
}
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
verifyInfo
.
TradeStatus
,
c
.
ClientIP
(),
common
.
GetJsonString
(
verifyInfo
)))
if
verifyInfo
.
TradeStatus
==
epay
.
StatusTradeSuccess
{
// 进程内锁只是优化;重复/并发回调的正确性由 RechargeEpay 的
// 数据库行锁 + 事务内状态校验保证(多实例部署下同样安全)。
LockOrder
(
verifyInfo
.
ServiceTradeNo
)
defer
UnlockOrder
(
verifyInfo
.
ServiceTradeNo
)
topUp
:=
model
.
GetTopUpByTradeNo
(
verifyInfo
.
ServiceTradeNo
)
if
topUp
==
nil
{
alreadyDone
,
err
:=
model
.
RechargeEpay
(
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
())
if
err
!=
nil
{
switch
{
case
errors
.
Is
(
err
,
model
.
ErrTopUpNotFound
)
:
logger
.
LogWarn
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
(),
common
.
GetJsonString
(
verifyInfo
)))
return
case
errors
.
Is
(
err
,
model
.
ErrPaymentMethodMismatch
)
:
logger
.
LogWarn
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
case
errors
.
Is
(
err
,
model
.
ErrTopUpStatusInvalid
)
:
logger
.
LogWarn
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
default
:
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 充值处理失败 trade_no=%s client_ip=%s error=%q"
,
verifyInfo
.
ServiceTradeNo
,
c
.
ClientIP
(),
err
.
Error
()))
}
if
topUp
.
PaymentProvider
!=
model
.
PaymentProviderEpay
{
logger
.
LogWarn
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 订单支付网关不匹配 trade_no=%s order_provider=%s callback_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
topUp
.
PaymentProvider
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
return
}
if
topUp
.
Status
==
common
.
TopUpStatusPending
{
if
topUp
.
PaymentMethod
!=
verifyInfo
.
Type
{
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 实际支付方式与订单不同 trade_no=%s order_payment_method=%s actual_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
topUp
.
PaymentMethod
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
topUp
.
PaymentMethod
=
verifyInfo
.
Type
if
_
,
writeErr
:=
c
.
Writer
.
Write
([]
byte
(
"fail"
));
writeErr
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q"
,
verifyInfo
.
ServiceTradeNo
,
c
.
ClientIP
(),
writeErr
.
Error
()))
}
topUp
.
Status
=
common
.
TopUpStatusSuccess
err
:=
topUp
.
Update
()
if
err
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q"
,
topUp
.
TradeNo
,
topUp
.
UserId
,
c
.
ClientIP
(),
err
.
Error
(),
common
.
GetJsonString
(
topUp
)))
return
}
//user, _ := model.GetUserById(topUp.UserId, false)
//user.Quota += topUp.Amount * 500000
dAmount
:=
decimal
.
NewFromInt
(
int64
(
topUp
.
Amount
))
dQuotaPerUnit
:=
decimal
.
NewFromFloat
(
common
.
QuotaPerUnit
)
quotaToAdd
:=
int
(
dAmount
.
Mul
(
dQuotaPerUnit
)
.
IntPart
())
err
=
model
.
IncreaseUserQuota
(
topUp
.
UserId
,
quotaToAdd
,
true
)
if
err
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q"
,
topUp
.
TradeNo
,
topUp
.
UserId
,
c
.
ClientIP
(),
quotaToAdd
,
err
.
Error
(),
common
.
GetJsonString
(
topUp
)))
return
}
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q"
,
topUp
.
TradeNo
,
topUp
.
UserId
,
c
.
ClientIP
(),
quotaToAdd
,
topUp
.
Money
,
common
.
GetJsonString
(
topUp
)))
model
.
RecordTopupLog
(
topUp
.
UserId
,
fmt
.
Sprintf
(
"使用在线充值成功,充值金额: %v,支付金额:%f"
,
logger
.
LogQuota
(
quotaToAdd
),
topUp
.
Money
),
c
.
ClientIP
(),
topUp
.
PaymentMethod
,
"epay"
)
if
alreadyDone
{
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
}
else
{
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
c
.
ClientIP
()))
}
}
else
{
logger
.
LogInfo
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q"
,
verifyInfo
.
ServiceTradeNo
,
verifyInfo
.
Type
,
verifyInfo
.
TradeStatus
,
c
.
ClientIP
(),
common
.
GetJsonString
(
verifyInfo
)))
}
if
_
,
writeErr
:=
c
.
Writer
.
Write
([]
byte
(
"success"
));
writeErr
!=
nil
{
logger
.
LogError
(
c
.
Request
.
Context
(),
fmt
.
Sprintf
(
"易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q"
,
verifyInfo
.
ServiceTradeNo
,
c
.
ClientIP
(),
writeErr
.
Error
()))
}
}
func
RequestAmount
(
c
*
gin
.
Context
)
{
...
...
model/payment_method_guard_test.go
View file @
50e5377e
...
...
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func
insertUserForPaymentGuardTest
(
t
*
testing
.
T
,
id
int
,
quota
int
)
{
func
insertUserForPaymentGuardTest
(
t
*
testing
.
T
,
id
int
,
quota
int
)
*
User
{
t
.
Helper
()
user
:=
&
User
{
Id
:
id
,
...
...
@@ -18,6 +18,7 @@ func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
Quota
:
quota
,
}
require
.
NoError
(
t
,
DB
.
Create
(
user
)
.
Error
)
return
user
}
func
insertSubscriptionPlanForPaymentGuardTest
(
t
*
testing
.
T
,
id
int
)
*
SubscriptionPlan
{
...
...
@@ -172,3 +173,138 @@ func TestExpireSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T)
require
.
NotNil
(
t
,
order
)
assert
.
Equal
(
t
,
common
.
TopUpStatusPending
,
order
.
Status
)
}
func
createEpayTestOrder
(
t
*
testing
.
T
,
userId
int
,
tradeNo
string
,
provider
string
,
status
string
)
TopUp
{
t
.
Helper
()
topUp
:=
TopUp
{
UserId
:
userId
,
Amount
:
2
,
Money
:
10.0
,
TradeNo
:
tradeNo
,
PaymentMethod
:
"alipay"
,
PaymentProvider
:
provider
,
CreateTime
:
common
.
GetTimestamp
(),
Status
:
status
,
}
require
.
NoError
(
t
,
DB
.
Create
(
&
topUp
)
.
Error
)
return
topUp
}
func
TestRechargeEpayCreditsQuotaExactlyOnce
(
t
*
testing
.
T
)
{
truncateTables
(
t
)
oldQuotaPerUnit
:=
common
.
QuotaPerUnit
common
.
QuotaPerUnit
=
500000
t
.
Cleanup
(
func
()
{
common
.
QuotaPerUnit
=
oldQuotaPerUnit
})
user
:=
insertUserForPaymentGuardTest
(
t
,
501
,
0
)
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTONCE"
,
PaymentProviderEpay
,
common
.
TopUpStatusPending
)
alreadyDone
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
alreadyDone
)
assert
.
Equal
(
t
,
2
*
500000
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
reloaded
:=
GetTopUpByTradeNo
(
order
.
TradeNo
)
require
.
NotNil
(
t
,
reloaded
)
assert
.
Equal
(
t
,
common
.
TopUpStatusSuccess
,
reloaded
.
Status
)
assert
.
NotZero
(
t
,
reloaded
.
CompleteTime
)
alreadyDone
,
err
=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
require
.
NoError
(
t
,
err
)
assert
.
True
(
t
,
alreadyDone
)
assert
.
Equal
(
t
,
2
*
500000
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
}
func
TestRechargeEpayKeepsRedisAndDatabaseCreditInSync
(
t
*
testing
.
T
)
{
truncateTables
(
t
)
useUserCacheMiniRedis
(
t
)
oldQuotaPerUnit
:=
common
.
QuotaPerUnit
common
.
QuotaPerUnit
=
5
t
.
Cleanup
(
func
()
{
common
.
QuotaPerUnit
=
oldQuotaPerUnit
})
user
:=
insertUserForPaymentGuardTest
(
t
,
502
,
7
)
require
.
NoError
(
t
,
populateUserCache
(
*
user
))
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTREDISSYNC"
,
PaymentProviderEpay
,
common
.
TopUpStatusPending
)
alreadyDone
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
alreadyDone
)
assert
.
Equal
(
t
,
17
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
cached
,
err
:=
cacheGetUserBase
(
user
.
Id
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
17
,
cached
.
Quota
)
alreadyDone
,
err
=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
require
.
NoError
(
t
,
err
)
assert
.
True
(
t
,
alreadyDone
)
cached
,
err
=
cacheGetUserBase
(
user
.
Id
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
17
,
cached
.
Quota
)
}
func
TestRechargeEpayUpdatesPaymentMethodToActual
(
t
*
testing
.
T
)
{
truncateTables
(
t
)
oldQuotaPerUnit
:=
common
.
QuotaPerUnit
common
.
QuotaPerUnit
=
500000
t
.
Cleanup
(
func
()
{
common
.
QuotaPerUnit
=
oldQuotaPerUnit
})
user
:=
insertUserForPaymentGuardTest
(
t
,
503
,
0
)
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTMETHOD"
,
PaymentProviderEpay
,
common
.
TopUpStatusPending
)
alreadyDone
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"wxpay"
,
"127.0.0.1"
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
alreadyDone
)
reloaded
:=
GetTopUpByTradeNo
(
order
.
TradeNo
)
require
.
NotNil
(
t
,
reloaded
)
assert
.
Equal
(
t
,
"wxpay"
,
reloaded
.
PaymentMethod
)
assert
.
Equal
(
t
,
2
*
500000
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
}
func
TestRechargeEpayRejectsForeignAndNonPendingOrders
(
t
*
testing
.
T
)
{
truncateTables
(
t
)
oldQuotaPerUnit
:=
common
.
QuotaPerUnit
common
.
QuotaPerUnit
=
500000
t
.
Cleanup
(
func
()
{
common
.
QuotaPerUnit
=
oldQuotaPerUnit
})
user
:=
insertUserForPaymentGuardTest
(
t
,
504
,
7
)
t
.
Run
(
"order from another payment provider"
,
func
(
t
*
testing
.
T
)
{
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTSTRIPE"
,
PaymentProviderStripe
,
common
.
TopUpStatusPending
)
_
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
assert
.
ErrorIs
(
t
,
err
,
ErrPaymentMethodMismatch
)
assert
.
Equal
(
t
,
7
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
})
t
.
Run
(
"order that is not pending"
,
func
(
t
*
testing
.
T
)
{
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTEXPIRED"
,
PaymentProviderEpay
,
common
.
TopUpStatusExpired
)
_
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
assert
.
ErrorIs
(
t
,
err
,
ErrTopUpStatusInvalid
)
assert
.
Equal
(
t
,
7
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
})
t
.
Run
(
"missing order"
,
func
(
t
*
testing
.
T
)
{
_
,
err
:=
RechargeEpay
(
"EPAYTESTMISSING"
,
"alipay"
,
"127.0.0.1"
)
assert
.
ErrorIs
(
t
,
err
,
ErrTopUpNotFound
)
})
}
func
TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder
(
t
*
testing
.
T
)
{
truncateTables
(
t
)
oldQuotaPerUnit
:=
common
.
QuotaPerUnit
common
.
QuotaPerUnit
=
float64
(
common
.
MaxQuota
)
t
.
Cleanup
(
func
()
{
common
.
QuotaPerUnit
=
oldQuotaPerUnit
})
user
:=
insertUserForPaymentGuardTest
(
t
,
505
,
3
)
order
:=
createEpayTestOrder
(
t
,
user
.
Id
,
"EPAYTESTOVERFLOW"
,
PaymentProviderEpay
,
common
.
TopUpStatusPending
)
_
,
err
:=
RechargeEpay
(
order
.
TradeNo
,
"alipay"
,
"127.0.0.1"
)
require
.
Error
(
t
,
err
)
assert
.
Equal
(
t
,
3
,
getUserQuotaForPaymentGuardTest
(
t
,
user
.
Id
))
assert
.
Equal
(
t
,
common
.
TopUpStatusPending
,
getTopUpStatusForPaymentGuardTest
(
t
,
order
.
TradeNo
))
}
model/quota_reserve.go
0 → 100644
View file @
50e5377e
package
model
import
(
"context"
"github.com/QuantumNous/new-api/common"
)
type
cacheQuotaResult
int
const
(
cacheQuotaInsufficient
cacheQuotaResult
=
iota
cacheQuotaOK
cacheQuotaMiss
)
// 下列脚本都是守卫式的:只在完整哈希(Id 匹配且配额字段存在)上操作,
// 哈希缺失时返回 miss 而不是创建残缺哈希。脚本不修改 TTL(HINCRBY 天然保留
// 水合时设置的 TTL),因此即使某个写库路径绕过了缓存,偏差也会在一个 TTL
// 窗口内随缓存过期而自愈。
const
userQuotaReserveScript
=
`
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
local quota = tonumber(redis.call('HGET', KEYS[1], 'Quota'))
if quota == nil or quota < tonumber(ARGV[1]) then
return 0
end
redis.call('HINCRBY', KEYS[1], 'Quota', -tonumber(ARGV[1]))
return 1`
const
userQuotaDeltaScript
=
`
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'Quota', tonumber(ARGV[1]))
return 1`
func
quotaResultFromLua
(
result
int
,
err
error
)
(
cacheQuotaResult
,
error
)
{
if
err
!=
nil
{
return
cacheQuotaMiss
,
err
}
switch
result
{
case
1
:
return
cacheQuotaOK
,
nil
case
0
:
return
cacheQuotaInsufficient
,
nil
default
:
return
cacheQuotaMiss
,
nil
}
}
func
cacheTryReserveUserQuota
(
userID
int
,
amount
int64
)
(
cacheQuotaResult
,
error
)
{
result
,
err
:=
common
.
RDB
.
Eval
(
context
.
Background
(),
userQuotaReserveScript
,
[]
string
{
getUserCacheKey
(
userID
)},
amount
,
userID
,
userCacheSchemaVersion
)
.
Int
()
return
quotaResultFromLua
(
result
,
err
)
}
func
cacheApplyUserQuotaDelta
(
userID
int
,
delta
int64
)
(
cacheQuotaResult
,
error
)
{
result
,
err
:=
common
.
RDB
.
Eval
(
context
.
Background
(),
userQuotaDeltaScript
,
[]
string
{
getUserCacheKey
(
userID
)},
delta
,
userID
,
userCacheSchemaVersion
)
.
Int
()
return
quotaResultFromLua
(
result
,
err
)
}
model/redemption.go
View file @
50e5377e
...
...
@@ -181,6 +181,7 @@ func Redeem(key string, userId int) (quota int, err error) {
common
.
SysError
(
"redemption failed: "
+
err
.
Error
())
return
0
,
ErrRedeemFailed
}
syncCreditUserQuotaCache
(
userId
,
redemption
.
Quota
,
"redemption"
)
RecordLog
(
userId
,
LogTypeTopup
,
fmt
.
Sprintf
(
"通过兑换码充值 %s,兑换码ID %d"
,
logger
.
LogQuota
(
redemption
.
Quota
),
redemption
.
Id
))
return
redemption
.
Quota
,
nil
}
...
...
model/topup.go
View file @
50e5377e
This diff is collapsed.
Click to expand it.
model/user_cache.go
View file @
50e5377e
...
...
@@ -143,18 +143,33 @@ func cacheGetUserBase(userId int) (*UserBase, error) {
return
&
userCache
,
nil
}
// Add atomic quota operations using hash fields
// Add atomic quota operations using hash fields.
// 通过守卫式 Lua 脚本执行:哈希不存在时直接跳过(下次读取会从数据库水合),
// 不会像裸 HINCRBY 那样创建只含 Quota 字段的残缺哈希。
func
cacheIncrUserQuota
(
userId
int
,
delta
int64
)
error
{
if
!
common
.
RedisEnabled
{
return
nil
}
return
common
.
RedisHIncrBy
(
getUserCacheKey
(
userId
),
"Quota"
,
delta
)
_
,
err
:=
cacheApplyUserQuotaDelta
(
userId
,
delta
)
return
err
}
func
cacheDecrUserQuota
(
userId
int
,
delta
int64
)
error
{
return
cacheIncrUserQuota
(
userId
,
-
delta
)
}
// syncCreditUserQuotaCache 在授信事务(充值/兑换等)提交后同步把增量补进缓存
// 余额。预扣以缓存值为准(存在期间),授信不能绕过它,否则新到账的额度在
// 缓存过期前不可用;缓存未命中无需处理,下次读取会从已提交的数据库余额水合。
func
syncCreditUserQuotaCache
(
userId
int
,
quota
int
,
operation
string
)
{
if
quota
<=
0
{
return
}
if
err
:=
cacheIncrUserQuota
(
userId
,
int64
(
quota
));
err
!=
nil
{
common
.
SysLog
(
fmt
.
Sprintf
(
"failed to sync %s credit to user quota cache: %s"
,
operation
,
err
.
Error
()))
}
}
// Helper functions to get individual fields if needed
func
getUserGroupCache
(
userId
int
)
(
string
,
error
)
{
cache
,
err
:=
GetUserCache
(
userId
)
...
...
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