owpengram-server/internal/app/stars/service.go

150 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
purchaseStore store.StarsPurchaseStore
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 }
}
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
return func(s *Service) { s.purchaseStore = 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)
}
// IssuePurchaseForm persists a short-lived, exact checkout intent.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s.purchaseStore == nil || !validPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
}
// Purchase 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) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.PurchaseStars(ctx, req)
}
// GetGiveawayInfo resolves one launch card from the same aggregate that
// persisted it. date is supplied by the RPC clock for deterministic tests.
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
}
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
}
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
return false
}
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
case domain.StarsPurchaseGift:
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
case domain.StarsPurchaseGiveaway:
g := form.Giveaway
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
default:
return false
}
}