119 lines
4.8 KiB
Go
119 lines
4.8 KiB
Go
// 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
|
||
giftStore store.StarsGiftPurchaseStore
|
||
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 }
|
||
}
|
||
|
||
// WithGiftPurchaseStore enables the atomic fiat Stars-gift checkout aggregate.
|
||
func WithGiftPurchaseStore(st store.StarsGiftPurchaseStore) Option {
|
||
return func(s *Service) { s.giftStore = st }
|
||
}
|
||
|
||
// 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, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||
if err != nil {
|
||
return domain.StarsTransactionPage{}, err
|
||
}
|
||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||
return domain.StarsTransactionPage{}, err
|
||
}
|
||
return s.store.ListTransactions(ctx, userID, query)
|
||
}
|
||
|
||
// IssueGiftPurchaseForm persists a short-lived, exact checkout intent.
|
||
func (s *Service) IssueGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
|
||
if s.giftStore == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 ||
|
||
form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 ||
|
||
form.Currency == "" || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
|
||
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid
|
||
}
|
||
return s.giftStore.IssueStarsGiftPurchaseForm(ctx, form)
|
||
}
|
||
|
||
// PurchaseGift settles one exact persisted form. Package validation remains at
|
||
// the RPC boundary as well, while the store revalidates the persisted tuple
|
||
// under lock before performing any write.
|
||
func (s *Service) PurchaseGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
|
||
if s.giftStore == nil || req.FormID == 0 || req.BuyerUserID <= 0 || req.RecipientUserID <= 0 ||
|
||
req.BuyerUserID == req.RecipientUserID || req.Stars <= 0 || req.Amount <= 0 ||
|
||
req.Currency == "" || req.Date <= 0 {
|
||
return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid
|
||
}
|
||
return s.giftStore.PurchaseStarsGift(ctx, req)
|
||
}
|