chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,93 @@
// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页,
// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、
// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。
package stars
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 是 Stars 账本应用服务。
type Service struct {
store store.StarsStore
grantAmount int64
now func() time.Time
}
// Option 配置 Service。
type Option func(*Service)
// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。
func WithStartingGrant(amount int64) Option {
return func(s *Service) { s.grantAmount = amount }
}
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。
func NewService(st store.StarsStore, opts ...Option) *Service {
s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now}
for _, opt := range opts {
opt(s)
}
return s
}
// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。
func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) {
if s.grantAmount > 0 {
bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix()))
return bal, err
}
return s.store.GetBalance(ctx, userID)
}
// GetBalance 返回账号余额,首读时惰性授予起始余额。
func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
return s.ensureGranted(ctx, userID)
}
// Credit 为账号入账(amount>0)。
func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。
// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。
func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsBalance{}, err
}
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// ListTransactions 按 keyset 分页返回流水 + 当前余额,首读时惰性授予。
func (s *Service) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsTransactionPage{}, err
}
return s.store.ListTransactions(ctx, userID, offset, limit)
}

View file

@ -0,0 +1,137 @@
package stars
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newTestService(grant int64) *Service {
return NewService(memory.NewStarsStore(), WithStartingGrant(grant))
}
// 起始授予幂等:多次 GetBalance 只授予一次。
func TestStartingGrantOnce(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
bal, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 1000 || !bal.Granted {
t.Fatalf("first balance = %+v, want 1000 granted", bal)
}
// 再读不应重复授予。
bal2, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance#2: %v", err)
}
if bal2.Balance != 1000 {
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
}
// 流水里应恰有一条 grant。
page, err := svc.ListTransactions(ctx, 7, "", 100)
if err != nil {
t.Fatalf("ListTransactions: %v", err)
}
if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 {
t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions)
}
}
// 关闭授予(grant=0)时余额为 0、无 grant 流水。
func TestGrantDisabled(t *testing.T) {
svc := newTestService(0)
bal, err := svc.GetBalance(context.Background(), 9)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 0 || bal.Granted {
t.Fatalf("balance = %+v, want 0 not granted", bal)
}
}
// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。
func TestDebitAndInsufficient(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555}
bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "")
if err != nil {
t.Fatalf("Debit: %v", err)
}
if bal.Balance != 700 {
t.Fatalf("after debit = %d, want 700", bal.Balance)
}
// 余额不足。
if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
}
// 余额未被改动。
after, _ := svc.GetBalance(ctx, 7)
if after.Balance != 700 {
t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance)
}
// 非法金额。
if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) {
t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err)
}
}
// 贷记增加余额并写正流水。
func TestCredit(t *testing.T) {
svc := newTestService(0) // 关闭起始授予,单测贷记
ctx := context.Background()
bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "")
if err != nil {
t.Fatalf("Credit: %v", err)
}
if bal.Balance != 250 {
t.Fatalf("after credit = %d, want 250", bal.Balance)
}
}
// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。
func TestListTransactionsPagination(t *testing.T) {
svc := newTestService(0)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("Credit#%d: %v", i, err)
}
}
page1, err := svc.ListTransactions(ctx, 7, "", 2)
if err != nil {
t.Fatalf("page1: %v", err)
}
if len(page1.Transactions) != 2 || page1.NextOffset == "" {
t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset)
}
// 倒序:最新(id 最大,amount=14)在前。
if page1.Transactions[0].Amount != 14 {
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
}
page2, err := svc.ListTransactions(ctx, 7, page1.NextOffset, 2)
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2.Transactions) != 2 {
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
}
page3, err := svc.ListTransactions(ctx, 7, page2.NextOffset, 2)
if err != nil {
t.Fatalf("page3: %v", err)
}
if len(page3.Transactions) != 1 {
t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions))
}
if page3.NextOffset != "" {
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
}
}