220 lines
8.1 KiB
Go
220 lines
8.1 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/postgres/sqlcgen"
|
||
)
|
||
|
||
// StarsStore 用 PostgreSQL 实现 store.StarsStore(Stars 本地账本)。
|
||
// 借记/贷记/授予各自在单事务内完成:余额与流水永不漂移。
|
||
type StarsStore struct {
|
||
db sqlcgen.DBTX
|
||
}
|
||
|
||
// NewStarsStore 基于 pgx 连接池(或事务)创建 StarsStore。
|
||
func NewStarsStore(db sqlcgen.DBTX) *StarsStore {
|
||
return &StarsStore{db: db}
|
||
}
|
||
|
||
func (s *StarsStore) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
|
||
if userID == 0 {
|
||
return domain.StarsBalance{}, nil
|
||
}
|
||
bal := domain.StarsBalance{UserID: userID}
|
||
err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, userID).
|
||
Scan(&bal.Balance, &bal.Granted)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.StarsBalance{UserID: userID}, nil
|
||
}
|
||
return domain.StarsBalance{}, fmt.Errorf("get stars balance: %w", err)
|
||
}
|
||
return bal, nil
|
||
}
|
||
|
||
func (s *StarsStore) EnsureGrant(ctx context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) {
|
||
if userID == 0 {
|
||
return domain.StarsBalance{}, false, nil
|
||
}
|
||
if amount <= 0 {
|
||
// 授予额为 0 时只确保有一行且 granted=true(幂等关闭授予)。
|
||
bal, err := s.GetBalance(ctx, userID)
|
||
return bal, false, err
|
||
}
|
||
out := domain.StarsBalance{UserID: userID}
|
||
applied := false
|
||
err := withTx(ctx, s.db, "ensure stars grant", func(tx pgx.Tx) error {
|
||
var balance int64
|
||
var granted bool
|
||
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).
|
||
Scan(&balance, &granted)
|
||
switch {
|
||
case errors.Is(err, pgx.ErrNoRows):
|
||
if _, err := tx.Exec(ctx, `INSERT INTO stars_balances (user_id, balance, granted, updated_at) VALUES ($1, $2, true, now())`,
|
||
userID, amount); err != nil {
|
||
return fmt.Errorf("insert stars balance grant: %w", err)
|
||
}
|
||
out.Balance, out.Granted, applied = amount, true, true
|
||
case err != nil:
|
||
return fmt.Errorf("select stars balance for grant: %w", err)
|
||
case granted:
|
||
out.Balance, out.Granted = balance, true
|
||
return nil
|
||
default:
|
||
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance + $2, granted = true, updated_at = now() WHERE user_id = $1 RETURNING balance`,
|
||
userID, amount).Scan(&out.Balance); err != nil {
|
||
return fmt.Errorf("update stars balance grant: %w", err)
|
||
}
|
||
out.Granted, applied = true, true
|
||
}
|
||
return insertStarsTxn(ctx, tx, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "")
|
||
})
|
||
if err != nil {
|
||
return domain.StarsBalance{}, false, err
|
||
}
|
||
return out, applied, nil
|
||
}
|
||
|
||
func (s *StarsStore) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||
if userID == 0 || amount <= 0 {
|
||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||
}
|
||
out := domain.StarsBalance{UserID: userID}
|
||
err := withTx(ctx, s.db, "credit stars", func(tx pgx.Tx) error {
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO stars_balances (user_id, balance, updated_at) VALUES ($1, $2, now())
|
||
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
|
||
RETURNING balance, granted`, userID, amount).Scan(&out.Balance, &out.Granted); err != nil {
|
||
return fmt.Errorf("credit stars balance: %w", err)
|
||
}
|
||
return insertStarsTxn(ctx, tx, userID, amount, reason, peer, date, title, desc)
|
||
})
|
||
if err != nil {
|
||
return domain.StarsBalance{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StarsStore) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||
if userID == 0 || amount <= 0 {
|
||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||
}
|
||
out := domain.StarsBalance{UserID: userID, Granted: true}
|
||
err := withTx(ctx, s.db, "debit stars", func(tx pgx.Tx) error {
|
||
var balance int64
|
||
var granted bool
|
||
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).
|
||
Scan(&balance, &granted)
|
||
if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) {
|
||
return domain.ErrStarsInsufficient
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("select stars balance for debit: %w", err)
|
||
}
|
||
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance - $2, updated_at = now() WHERE user_id = $1 RETURNING balance`,
|
||
userID, amount).Scan(&out.Balance); err != nil {
|
||
return fmt.Errorf("update stars balance debit: %w", err)
|
||
}
|
||
out.Granted = granted
|
||
return insertStarsTxn(ctx, tx, userID, -amount, reason, peer, date, title, desc)
|
||
})
|
||
if err != nil {
|
||
return domain.StarsBalance{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||
if userID == 0 {
|
||
return domain.StarsTransactionPage{}, nil
|
||
}
|
||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||
if err != nil {
|
||
return domain.StarsTransactionPage{}, err
|
||
}
|
||
bal, err := s.GetBalance(ctx, userID)
|
||
if err != nil {
|
||
return domain.StarsTransactionPage{}, err
|
||
}
|
||
page := domain.StarsTransactionPage{Balance: bal.Balance}
|
||
|
||
// 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 `+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, query.Limit+1)
|
||
for rows.Next() {
|
||
var (
|
||
t domain.StarsTransaction
|
||
peerType string
|
||
peerID int64
|
||
reason string
|
||
)
|
||
if err := rows.Scan(&t.ID, &peerType, &peerID, &t.Amount, &reason, &t.Title, &t.Description, &t.Date); err != nil {
|
||
return domain.StarsTransactionPage{}, fmt.Errorf("scan stars transaction: %w", err)
|
||
}
|
||
t.UserID = userID
|
||
t.Reason = domain.StarsTransactionReason(reason)
|
||
if peerType != "" {
|
||
t.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID}
|
||
}
|
||
txns = append(txns, t)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.StarsTransactionPage{}, fmt.Errorf("iterate stars transactions: %w", err)
|
||
}
|
||
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, `
|
||
INSERT INTO stars_transactions (user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||
userID, string(peer.Type), peer.ID, amount, string(reason), title, desc, date); err != nil {
|
||
return fmt.Errorf("insert stars transaction: %w", err)
|
||
}
|
||
return nil
|
||
}
|