fix(stars): sync filter transaction history by direction

This commit is contained in:
iamxvbaba 2026-08-02 01:51:02 +08:00
parent 94f3843d23
commit 3a123f38db
19 changed files with 559 additions and 135 deletions

View file

@ -1611,27 +1611,25 @@ VALUES($1,$2,$3,$4)`, userID, s.tonStartingGrant, string(domain.StarsReasonGrant
return balance, nil
}
func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if userID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if userID <= 0 {
return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
if _, err := s.TonBalance(ctx, userID); err != nil {
return domain.TonTransactionPage{}, err
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{userID, limit + 1}
where := "user_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
}
where, order, args := starsTransactionQueryParts("user_id", "amount_nanoton", userID, query)
rows, err := s.db.Query(ctx, `SELECT id,user_id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),
amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.TonTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.TonTransaction, 0, limit+1)
items := make([]domain.TonTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.TonTransaction
var peerType string
@ -1645,8 +1643,8 @@ amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DES
return domain.TonTransactionPage{}, err
}
page := domain.TonTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items
@ -1668,24 +1666,22 @@ func (s *StarGiftLifecycleStore) ChannelStarsBalance(ctx context.Context, channe
return balance, err
}
func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if channelID <= 0 {
return domain.StarsTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{channelID, limit + 1}
where := "channel_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
where, order, args := starsTransactionQueryParts("channel_id", "amount", channelID, query)
rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),amount,date,reason
FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
FROM channel_stars_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.StarsTransaction, 0, limit+1)
items := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.StarsTransaction
var peerType string
@ -1699,8 +1695,8 @@ FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args
return domain.StarsTransactionPage{}, err
}
page := domain.StarsTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items
@ -1717,24 +1713,22 @@ func (s *StarGiftLifecycleStore) ChannelTonBalance(ctx context.Context, channelI
return balance, err
}
func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if channelID <= 0 {
return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{channelID, limit + 1}
where := "channel_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
where, order, args := starsTransactionQueryParts("channel_id", "amount_nanoton", channelID, query)
rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),amount_nanoton,date,reason
FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
FROM channel_ton_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.TonTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.TonTransaction, 0, limit+1)
items := make([]domain.TonTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.TonTransaction
var peerType string
@ -1748,8 +1742,8 @@ FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args..
return domain.TonTransactionPage{}, err
}
page := domain.TonTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items

View file

@ -426,7 +426,7 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMess
Scan(&resaleCommission); err != nil || resaleCommission != 100 {
t.Fatalf("TON resale commission = %d err %v", resaleCommission, err)
}
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20)
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 {
t.Fatalf("TON ledger page = %+v err %v", tonPage, err)
}
@ -1050,7 +1050,7 @@ WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_
if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 {
t.Fatalf("channel stars balance projection = %d err %v", balance, err)
}
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20)
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 ||
starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift {
t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err)
@ -1188,7 +1188,7 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 {
t.Fatalf("channel ton balance projection = %d err %v", balance, err)
}
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20)
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 ||
tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale {
t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err)

View file

@ -0,0 +1,117 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftLedgerTransactionDirectionsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
ownerID := (time.Now().UnixNano() & 0x1fffffffffffffff) + 3_000_000_000
channelID := ownerID + 1
lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_balances WHERE channel_id=$1`, channelID)
})
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,70,true)`, ownerID); err != nil {
t.Fatalf("insert ton balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel ton balance: %v", err)
}
for i, amount := range []int64{100, -40, 20, -10} {
date := 1_800_000_000 + i
if _, err := pool.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,date) VALUES($1,$2,'adjust',$3)`, ownerID, amount, date); err != nil {
t.Fatalf("insert ton transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel stars transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel ton transaction %d: %v", i, err)
}
}
tonIncoming, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("personal ton incoming: %v", err)
}
assertTonTransactionAmounts(t, tonIncoming.Transactions, []int64{20, 100})
tonOutgoing, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
})
if err != nil {
t.Fatalf("personal ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, tonOutgoing.Transactions, []int64{-40, -10})
channelIncoming1, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page1: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming1.Transactions, []int64{20})
if channelIncoming1.NextOffset == "" {
t.Fatal("channel stars incoming page1 missing next offset")
}
channelIncoming2, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Offset: channelIncoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page2: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming2.Transactions, []int64{100})
if channelIncoming2.NextOffset != "" {
t.Fatalf("channel stars terminal next offset = %q", channelIncoming2.NextOffset)
}
channelTonOutgoing, err := lifecycle.ChannelTonTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil {
t.Fatalf("channel ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, channelTonOutgoing.Transactions, []int64{-10, -40})
}
func assertPostgresStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("stars transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("stars transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}
func assertTonTransactionAmounts(t *testing.T, transactions []domain.TonTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("ton transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("ton transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}

View file

@ -130,12 +130,13 @@ func (s *StarsStore) Debit(ctx context.Context, userID, amount int64, reason dom
return out, nil
}
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if userID == 0 {
return domain.StarsTransactionPage{}, nil
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
bal, err := s.GetBalance(ctx, userID)
if err != nil {
@ -143,29 +144,19 @@ func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, offset
}
page := domain.StarsTransactionPage{Balance: bal.Balance}
// keyset多取一条以探测是否还有下一页。
args := []any{userID, limit + 1}
query := `
// keyset方向过滤先于 LIMIT多取一条以探测同一视图是否还有下一页。
where, order, args := starsTransactionQueryParts("user_id", "amount", userID, query)
rows, err := s.db.Query(ctx, `
SELECT id, peer_type, peer_id, amount, reason, title, description, date
FROM stars_transactions
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`
if cursor, ok := domain.DecodeStarsCursor(offset); ok {
query = `
SELECT id, peer_type, peer_id, amount, reason, title, description, date
FROM stars_transactions
WHERE user_id = $1 AND id < $3
ORDER BY id DESC
LIMIT $2`
args = append(args, cursor)
}
rows, err := s.db.Query(ctx, query, args...)
WHERE `+where+`
ORDER BY id `+order+`
LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("list stars transactions: %w", err)
}
defer rows.Close()
txns := make([]domain.StarsTransaction, 0, limit)
txns := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var (
t domain.StarsTransaction
@ -186,14 +177,37 @@ LIMIT $2`
if err := rows.Err(); err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("iterate stars transactions: %w", err)
}
if len(txns) > limit {
txns = txns[:limit]
if len(txns) > query.Limit {
txns = txns[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(txns[len(txns)-1].ID)
}
page.Transactions = txns
return page, nil
}
// starsTransactionQueryParts centralizes the sign predicate and keyset
// direction for personal/channel Stars and TON ledgers. Column names are only
// package-owned constants; client values remain bind parameters.
func starsTransactionQueryParts(ownerColumn, amountColumn string, ownerID int64, query domain.StarsTransactionQuery) (string, string, []any) {
where := ownerColumn + "=$1"
switch query.Direction {
case domain.StarsTransactionDirectionIncoming:
where += " AND " + amountColumn + ">0"
case domain.StarsTransactionDirectionOutgoing:
where += " AND " + amountColumn + "<0"
}
order, comparator := "DESC", "<"
if query.Ascending {
order, comparator = "ASC", ">"
}
args := []any{ownerID, query.Limit + 1}
if cursor, ok := domain.DecodeStarsCursor(query.Offset); ok {
where += " AND id" + comparator + "$3"
args = append(args, cursor)
}
return where, order, args
}
// insertStarsTxn 在事务内写一条流水amount 带符号)。
func insertStarsTxn(ctx context.Context, tx pgx.Tx, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) error {
if _, err := tx.Exec(ctx, `

View file

@ -65,7 +65,7 @@ func TestStarsLedgerPostgres(t *testing.T) {
}
// 流水grant(+1000) / debit(-300) / credit(+50) 共 3 条,倒序最新在前。
page, err := st.ListTransactions(ctx, u.ID, "", 2)
page, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 2})
if err != nil {
t.Fatalf("list page1: %v", err)
}
@ -78,7 +78,7 @@ func TestStarsLedgerPostgres(t *testing.T) {
if page.Balance != 750 {
t.Fatalf("page balance = %d, want 750", page.Balance)
}
page2, err := st.ListTransactions(ctx, u.ID, page.NextOffset, 2)
page2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Offset: page.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("list page2: %v", err)
}
@ -88,4 +88,41 @@ func TestStarsLedgerPostgres(t *testing.T) {
if page2.Transactions[0].Reason != domain.StarsReasonGrant || page2.Transactions[0].Amount != 1000 {
t.Fatalf("page2[0] = %+v, want +1000 grant (oldest)", page2.Transactions[0])
}
incoming1, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page1: %v", err)
}
if len(incoming1.Transactions) != 1 || incoming1.Transactions[0].Amount != 50 || incoming1.NextOffset == "" {
t.Fatalf("incoming page1 = %+v next=%q, want +50 and next", incoming1.Transactions, incoming1.NextOffset)
}
incoming2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page2: %v", err)
}
if len(incoming2.Transactions) != 1 || incoming2.Transactions[0].Amount != 1000 || incoming2.NextOffset != "" {
t.Fatalf("incoming page2 = %+v next=%q, want +1000 terminal", incoming2.Transactions, incoming2.NextOffset)
}
outgoing, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil || len(outgoing.Transactions) != 1 || outgoing.Transactions[0].Amount != -300 {
t.Fatalf("outgoing = %+v err=%v, want only -300", outgoing.Transactions, err)
}
ascending, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 10, Ascending: true})
if err != nil || len(ascending.Transactions) != 3 {
t.Fatalf("ascending = %+v err=%v", ascending.Transactions, err)
}
wantAscending := []int64{1000, -300, 50}
for i, amount := range wantAscending {
if ascending.Transactions[i].Amount != amount {
t.Fatalf("ascending[%d].amount = %d, want %d", i, ascending.Transactions[i].Amount, amount)
}
}
}