- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
400 lines
15 KiB
Go
400 lines
15 KiB
Go
//go:build integration
|
|
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func querySingleFloat(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) float64 {
|
|
t.Helper()
|
|
rows, err := client.QueryContext(ctx, query, args...)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
require.True(t, rows.Next(), "expected one row")
|
|
var value float64
|
|
require.NoError(t, rows.Scan(&value))
|
|
require.NoError(t, rows.Err())
|
|
return value
|
|
}
|
|
|
|
func querySingleInt(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) int {
|
|
t.Helper()
|
|
rows, err := client.QueryContext(ctx, query, args...)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
require.True(t, rows.Next(), "expected one row")
|
|
var value int
|
|
require.NoError(t, rows.Scan(&value))
|
|
require.NoError(t, rows.Err())
|
|
return value
|
|
}
|
|
|
|
func TestAffiliateRepository_TransferQuotaToBalance_UsesClaimedQuotaBeforeClear(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
u := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-transfer-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
Balance: 5.5,
|
|
Concurrency: 5,
|
|
})
|
|
|
|
affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
|
|
_, err := client.ExecContext(txCtx, `
|
|
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
|
|
require.NoError(t, err)
|
|
|
|
transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 12.34, transferred, 1e-9)
|
|
require.InDelta(t, 17.84, balance, 1e-9)
|
|
|
|
affQuota := querySingleFloat(t, txCtx, client,
|
|
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID)
|
|
require.InDelta(t, 0.0, affQuota, 1e-9)
|
|
|
|
persistedBalance := querySingleFloat(t, txCtx, client,
|
|
"SELECT balance::double precision FROM users WHERE id = $1", u.ID)
|
|
require.InDelta(t, 17.84, persistedBalance, 1e-9)
|
|
|
|
ledgerCount := querySingleInt(t, txCtx, client,
|
|
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
|
|
require.Equal(t, 1, ledgerCount)
|
|
}
|
|
|
|
// TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
|
|
// cross-layer tx propagation invariant: when AccrueQuota is called with a ctx
|
|
// that already carries a transaction (via dbent.NewTxContext), repo.withTx
|
|
// must reuse that tx rather than opening a nested one. If this invariant
|
|
// breaks, AccrueQuota would commit independently and survive a rollback of
|
|
// the outer tx, which would violate payment_fulfillment's all-or-nothing
|
|
// semantics.
|
|
func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
outerTx, err := integrationEntClient.Tx(ctx)
|
|
require.NoError(t, err, "begin outer tx")
|
|
// Defensive cleanup: if any require.* below fires before the explicit
|
|
// Rollback, this prevents the tx from leaking until container teardown.
|
|
// Rollback is idempotent at the driver level (extra rollback returns an
|
|
// error we ignore).
|
|
t.Cleanup(func() { _ = outerTx.Rollback() })
|
|
client := outerTx.Client()
|
|
txCtx := dbent.NewTxContext(ctx, outerTx)
|
|
|
|
inviter := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-inviter-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
Concurrency: 5,
|
|
})
|
|
invitee := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-invitee-%d@example.com", time.Now().UnixNano()+1),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
Concurrency: 5,
|
|
})
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
_, err = repo.EnsureUserAffiliate(txCtx, inviter.ID)
|
|
require.NoError(t, err)
|
|
_, err = repo.EnsureUserAffiliate(txCtx, invitee.ID)
|
|
require.NoError(t, err)
|
|
|
|
bound, err := repo.BindInviter(txCtx, invitee.ID, inviter.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, bound, "invitee must bind to inviter")
|
|
|
|
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0)
|
|
require.NoError(t, err)
|
|
require.True(t, applied, "AccrueQuota must report applied=true")
|
|
|
|
// Visible inside the outer tx.
|
|
innerQuota := querySingleFloat(t, txCtx, client,
|
|
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", inviter.ID)
|
|
require.InDelta(t, 3.5, innerQuota, 1e-9)
|
|
|
|
// Roll back the outer tx; if AccrueQuota had opened its own inner tx and
|
|
// committed it, the rows would still be visible to the global client.
|
|
require.NoError(t, outerTx.Rollback())
|
|
|
|
rows, err := integrationEntClient.QueryContext(ctx,
|
|
"SELECT COUNT(*) FROM user_affiliates WHERE user_id IN ($1, $2)",
|
|
inviter.ID, invitee.ID)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
require.True(t, rows.Next())
|
|
var postRollbackCount int
|
|
require.NoError(t, rows.Scan(&postRollbackCount))
|
|
require.Equal(t, 0, postRollbackCount,
|
|
"AccrueQuota must propagate the outer tx — found persisted rows after rollback")
|
|
}
|
|
|
|
func TestAffiliateRepository_TransferQuotaToBalance_EmptyQuota(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
u := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-empty-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
Balance: 3.21,
|
|
Concurrency: 5,
|
|
})
|
|
|
|
affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
|
|
_, err := client.ExecContext(txCtx, `
|
|
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
|
|
VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
|
|
require.NoError(t, err)
|
|
|
|
transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
|
|
require.ErrorIs(t, err, service.ErrAffiliateQuotaEmpty)
|
|
require.InDelta(t, 0.0, transferred, 1e-9)
|
|
require.InDelta(t, 0.0, balance, 1e-9)
|
|
|
|
persistedBalance := querySingleFloat(t, txCtx, client,
|
|
"SELECT balance::double precision FROM users WHERE id = $1", u.ID)
|
|
require.InDelta(t, 3.21, persistedBalance, 1e-9)
|
|
}
|
|
|
|
// TestAffiliateRepository_AdminCustomCode covers the success path of admin
|
|
// invite-code rewrite + reset within a shared test transaction:
|
|
// - UpdateUserAffCode replaces aff_code, sets aff_code_custom=true, lookup works
|
|
// - the old code can no longer be found
|
|
// - ResetUserAffCode reverts aff_code_custom and assigns a new system-format code
|
|
//
|
|
// The conflict path (duplicate code → ErrAffiliateCodeTaken) lives in its own
|
|
// test because a unique-violation aborts the surrounding Postgres tx, which
|
|
// would poison subsequent assertions in the same transaction.
|
|
func TestAffiliateRepository_AdminCustomCode(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
u := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-custom-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
})
|
|
|
|
original, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, original.AffCodeCustom, "system-generated codes start as non-custom")
|
|
originalCode := original.AffCode
|
|
|
|
// Rewrite to a custom code
|
|
customCode := fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)
|
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, u.ID, customCode))
|
|
|
|
updated, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, customCode, updated.AffCode)
|
|
require.True(t, updated.AffCodeCustom)
|
|
|
|
// Lookup by new custom code finds the user
|
|
byCode, err := repo.GetAffiliateByCode(txCtx, customCode)
|
|
require.NoError(t, err)
|
|
require.Equal(t, u.ID, byCode.UserID)
|
|
|
|
// Old system code should no longer match
|
|
_, err = repo.GetAffiliateByCode(txCtx, originalCode)
|
|
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
|
|
|
|
// Reset back to a fresh system code, clears custom flag
|
|
newSysCode, err := repo.ResetUserAffCode(txCtx, u.ID)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, customCode, newSysCode)
|
|
|
|
reset, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, newSysCode, reset.AffCode)
|
|
require.False(t, reset.AffCodeCustom)
|
|
|
|
// The old custom code is now free again
|
|
_, err = repo.GetAffiliateByCode(txCtx, customCode)
|
|
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
|
|
}
|
|
|
|
// TestAffiliateRepository_AdminCustomCode_Conflict isolates the unique-violation
|
|
// path. PostgreSQL aborts the enclosing tx when a unique constraint fires, so
|
|
// this test must be the only assertion and run in its own tx — production
|
|
// callers each have their own outer tx, so this matches real behavior.
|
|
func TestAffiliateRepository_AdminCustomCode_Conflict(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
taker := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-conflict-taker-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser, Status: service.StatusActive,
|
|
})
|
|
requester := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-conflict-req-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser, Status: service.StatusActive,
|
|
})
|
|
|
|
takenCode := fmt.Sprintf("HOT%09d", time.Now().UnixNano()%1_000_000_000)
|
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, taker.ID, takenCode))
|
|
|
|
// Now requester tries to grab the same code → conflict.
|
|
err := repo.UpdateUserAffCode(txCtx, requester.ID, takenCode)
|
|
require.ErrorIs(t, err, service.ErrAffiliateCodeTaken)
|
|
}
|
|
|
|
// TestAffiliateRepository_AdminRebateRate covers per-user exclusive rate
|
|
// set/clear and the Batch variant including NULL semantics.
|
|
func TestAffiliateRepository_AdminRebateRate(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
u1 := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-rate-%d-a@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
})
|
|
u2 := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-rate-%d-b@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser,
|
|
Status: service.StatusActive,
|
|
})
|
|
|
|
// Set exclusive rate for u1
|
|
rate := 42.5
|
|
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, &rate))
|
|
|
|
got, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.AffRebateRatePercent)
|
|
require.InDelta(t, 42.5, *got.AffRebateRatePercent, 1e-9)
|
|
|
|
// Clear exclusive rate
|
|
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, nil))
|
|
cleared, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, cleared.AffRebateRatePercent)
|
|
|
|
// Batch set both users
|
|
batchRate := 15.0
|
|
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, &batchRate))
|
|
|
|
for _, uid := range []int64{u1.ID, u2.ID} {
|
|
v, err := repo.EnsureUserAffiliate(txCtx, uid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, v.AffRebateRatePercent)
|
|
require.InDelta(t, 15.0, *v.AffRebateRatePercent, 1e-9)
|
|
}
|
|
|
|
// Batch clear
|
|
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, nil))
|
|
for _, uid := range []int64{u1.ID, u2.ID} {
|
|
v, err := repo.EnsureUserAffiliate(txCtx, uid)
|
|
require.NoError(t, err)
|
|
require.Nil(t, v.AffRebateRatePercent)
|
|
}
|
|
}
|
|
|
|
// TestAffiliateRepository_ListUsersWithCustomSettings verifies the admin list
|
|
// only includes users with at least one override applied.
|
|
func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
tx := testEntTx(t)
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
client := tx.Client()
|
|
|
|
repo := NewAffiliateRepository(client, integrationDB)
|
|
|
|
// User without any custom config — should NOT appear in the list.
|
|
plainEmail := fmt.Sprintf("affiliate-plain-%d@example.com", time.Now().UnixNano())
|
|
uPlain := mustCreateUser(t, client, &service.User{
|
|
Email: plainEmail, PasswordHash: "hash",
|
|
Role: service.RoleUser, Status: service.StatusActive,
|
|
})
|
|
_, err := repo.EnsureUserAffiliate(txCtx, uPlain.ID)
|
|
require.NoError(t, err)
|
|
|
|
// User with a custom code — should appear.
|
|
uCode := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-codeonly-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser, Status: service.StatusActive,
|
|
})
|
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, uCode.ID, fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)))
|
|
|
|
// User with only an exclusive rate — should appear.
|
|
uRate := mustCreateUser(t, client, &service.User{
|
|
Email: fmt.Sprintf("affiliate-rateonly-%d@example.com", time.Now().UnixNano()),
|
|
PasswordHash: "hash",
|
|
Role: service.RoleUser, Status: service.StatusActive,
|
|
})
|
|
r := 33.3
|
|
require.NoError(t, repo.SetUserRebateRate(txCtx, uRate.ID, &r))
|
|
|
|
entries, total, err := repo.ListUsersWithCustomSettings(txCtx, service.AffiliateAdminFilter{
|
|
Page: 1, PageSize: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Build a quick lookup to assert per-user attributes (other tests may have
|
|
// inserted custom rows in the same DB; we only care about our 3).
|
|
byUserID := make(map[int64]service.AffiliateAdminEntry, len(entries))
|
|
for _, e := range entries {
|
|
byUserID[e.UserID] = e
|
|
}
|
|
|
|
require.NotContains(t, byUserID, uPlain.ID, "users without overrides must not appear")
|
|
|
|
codeEntry, ok := byUserID[uCode.ID]
|
|
require.True(t, ok, "custom-code user missing from list")
|
|
require.True(t, codeEntry.AffCodeCustom)
|
|
require.Nil(t, codeEntry.AffRebateRatePercent)
|
|
|
|
rateEntry, ok := byUserID[uRate.ID]
|
|
require.True(t, ok, "custom-rate user missing from list")
|
|
require.False(t, rateEntry.AffCodeCustom)
|
|
require.NotNil(t, rateEntry.AffRebateRatePercent)
|
|
require.InDelta(t, 33.3, *rateEntry.AffRebateRatePercent, 1e-9)
|
|
|
|
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
|
|
}
|