mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on every call, regardless of which fields the caller meant to change. Several columns on those tables are maintained by dedicated atomic paths (balance deduction, quota and rate-limit counters, limit adjustments, activity timestamps), so a caller holding a slightly older snapshot could silently roll them back - a lost update. Both methods now take an explicit column mask and persist only the columns the caller declares; everything else keeps its current database value. - All user and API-key call sites declare exactly what they mutate, which turns admin edits and profile saves into genuine partial updates. - Email uniqueness locking/lookup and allowed_groups sync only run when those fields are part of the update. - UserUpdateFields deliberately has no balance/total_recharged members, so Update cannot touch them. New AdjustBalance/SetBalance apply the change in a single statement and return before/after values; admin balance adjustment uses them instead of read-modify-write. - promo_codes.used_count is no longer written by Update; it is only ever incremented by the redemption path. - The billing hot path that marks an API key quota-exhausted writes only status. - Dropped a no-op row write in RevokeAllUserTokens: users has no token_version column, so it persisted nothing while still overwriting concurrently-updated columns. Adds integration coverage that a stale snapshot cannot revert concurrent atomic writes, and unit coverage pinning the column set each entry point declares.
264 lines
8.2 KiB
Go
264 lines
8.2 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
type balanceUserRepoStub struct {
|
||
*userRepoStub
|
||
adjustErr error
|
||
// changes 记录每次原子余额变更,顺序与调用顺序一致。
|
||
changes []BalanceChange
|
||
}
|
||
|
||
func (s *balanceUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
||
return s.apply(func(current float64) float64 { return current + delta })
|
||
}
|
||
|
||
func (s *balanceUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
||
return s.apply(func(float64) float64 { return value })
|
||
}
|
||
|
||
func (s *balanceUserRepoStub) apply(next func(current float64) float64) (BalanceChange, error) {
|
||
if s.adjustErr != nil {
|
||
return BalanceChange{}, s.adjustErr
|
||
}
|
||
if s.userRepoStub == nil || s.userRepoStub.user == nil {
|
||
return BalanceChange{}, ErrUserNotFound
|
||
}
|
||
change := BalanceChange{Old: s.userRepoStub.user.Balance}
|
||
change.New = next(change.Old)
|
||
if change.New < 0 {
|
||
return change, ErrBalanceNegative
|
||
}
|
||
s.userRepoStub.user.Balance = change.New
|
||
s.changes = append(s.changes, change)
|
||
return change, nil
|
||
}
|
||
|
||
type balanceRedeemRepoStub struct {
|
||
*redeemRepoStub
|
||
created []*RedeemCode
|
||
}
|
||
|
||
func (s *balanceRedeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
||
if code == nil {
|
||
return nil
|
||
}
|
||
clone := *code
|
||
s.created = append(s.created, &clone)
|
||
return nil
|
||
}
|
||
|
||
type authCacheInvalidatorStub struct {
|
||
userIDs []int64
|
||
groupIDs []int64
|
||
keys []string
|
||
}
|
||
|
||
type adminRechargeAffiliateAccruerStub struct {
|
||
calls []adminRechargeAffiliateAccrual
|
||
rebate float64
|
||
err error
|
||
}
|
||
|
||
type adminRechargeAffiliateAccrual struct {
|
||
userID int64
|
||
amount float64
|
||
}
|
||
|
||
func (s *adminRechargeAffiliateAccruerStub) AccrueInviteRebate(_ context.Context, userID int64, amount float64) (float64, error) {
|
||
s.calls = append(s.calls, adminRechargeAffiliateAccrual{userID: userID, amount: amount})
|
||
return s.rebate, s.err
|
||
}
|
||
|
||
func adminRechargeSettingService(enabled bool) *SettingService {
|
||
values := map[string]string{}
|
||
if enabled {
|
||
values[SettingKeyAffiliateAdminRechargeEnabled] = "true"
|
||
}
|
||
return NewSettingService(&settingRepoStub{values: values}, nil)
|
||
}
|
||
|
||
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByKey(ctx context.Context, key string) {
|
||
s.keys = append(s.keys, key)
|
||
}
|
||
|
||
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
|
||
s.userIDs = append(s.userIDs, userID)
|
||
}
|
||
|
||
func (s *authCacheInvalidatorStub) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) {
|
||
s.groupIDs = append(s.groupIDs, groupID)
|
||
}
|
||
|
||
// 管理员调账必须走原子的 AdjustBalance/SetBalance,而不是"读余额→算新值→整行写回",
|
||
// 后者会把并发的计费扣款覆盖掉。userRepoStub.Update 对未预期的调用会 panic,
|
||
// 因此这里同时证明它没被走到。
|
||
func TestAdminService_UpdateUserBalance_UsesAtomicPrimitives(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
operation string
|
||
amount float64
|
||
want BalanceChange
|
||
}{
|
||
{name: "add", operation: "add", amount: 5, want: BalanceChange{Old: 10, New: 15}},
|
||
{name: "subtract", operation: "subtract", amount: 4, want: BalanceChange{Old: 10, New: 6}},
|
||
{name: "set", operation: "set", amount: 2, want: BalanceChange{Old: 10, New: 2}},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||
}
|
||
|
||
user, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
|
||
require.NoError(t, err)
|
||
require.Equal(t, []BalanceChange{tt.want}, repo.changes)
|
||
require.Equal(t, tt.want.New, user.Balance)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_RejectsNegativeResult(t *testing.T) {
|
||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 3}}}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||
}
|
||
|
||
_, err := svc.UpdateUserBalance(context.Background(), 7, 4, "subtract", "")
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "balance cannot be negative")
|
||
require.Empty(t, repo.changes, "refused adjustment must not be applied")
|
||
require.Equal(t, 3.0, repo.userRepoStub.user.Balance)
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_RejectsUnknownOperation(t *testing.T) {
|
||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||
}
|
||
|
||
_, err := svc.UpdateUserBalance(context.Background(), 7, 1, "multiply", "")
|
||
require.Error(t, err)
|
||
require.Empty(t, repo.changes)
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
|
||
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
||
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
||
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
||
invalidator := &authCacheInvalidatorStub{}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: redeemRepo,
|
||
authCacheInvalidator: invalidator,
|
||
}
|
||
|
||
_, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
|
||
require.NoError(t, err)
|
||
require.Equal(t, []int64{7}, invalidator.userIDs)
|
||
require.Len(t, redeemRepo.created, 1)
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_NoChangeNoInvalidate(t *testing.T) {
|
||
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
||
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
||
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
||
invalidator := &authCacheInvalidatorStub{}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: redeemRepo,
|
||
authCacheInvalidator: invalidator,
|
||
}
|
||
|
||
_, err := svc.UpdateUserBalance(context.Background(), 7, 10, "set", "")
|
||
require.NoError(t, err)
|
||
require.Empty(t, invalidator.userIDs)
|
||
require.Empty(t, redeemRepo.created)
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_AdminRechargeAffiliateRebate(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
enabled bool
|
||
operation string
|
||
amount float64
|
||
wantCalls []adminRechargeAffiliateAccrual
|
||
}{
|
||
{
|
||
name: "disabled by default",
|
||
operation: "add",
|
||
amount: 5,
|
||
},
|
||
{
|
||
name: "enabled add",
|
||
enabled: true,
|
||
operation: "add",
|
||
amount: 0.1,
|
||
wantCalls: []adminRechargeAffiliateAccrual{{userID: 7, amount: 0.1}},
|
||
},
|
||
{
|
||
name: "enabled set increase",
|
||
enabled: true,
|
||
operation: "set",
|
||
amount: 15,
|
||
},
|
||
{
|
||
name: "enabled subtract",
|
||
enabled: true,
|
||
operation: "subtract",
|
||
amount: 5,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
||
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
||
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
||
affiliate := &adminRechargeAffiliateAccruerStub{}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: redeemRepo,
|
||
settingService: adminRechargeSettingService(tt.enabled),
|
||
affiliateService: affiliate,
|
||
}
|
||
|
||
_, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
|
||
require.NoError(t, err)
|
||
require.Equal(t, tt.wantCalls, affiliate.calls)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAdminService_UpdateUserBalance_AffiliateFailureDoesNotRollbackRecharge(t *testing.T) {
|
||
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
||
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
||
redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
|
||
affiliate := &adminRechargeAffiliateAccruerStub{err: errors.New("affiliate unavailable")}
|
||
svc := &adminServiceImpl{
|
||
userRepo: repo,
|
||
redeemCodeRepo: redeemRepo,
|
||
settingService: adminRechargeSettingService(true),
|
||
affiliateService: affiliate,
|
||
}
|
||
|
||
user, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
|
||
require.NoError(t, err)
|
||
require.Equal(t, 15.0, user.Balance)
|
||
require.Equal(t, []adminRechargeAffiliateAccrual{{userID: 7, amount: 5}}, affiliate.calls)
|
||
require.Len(t, redeemRepo.created, 1)
|
||
}
|