feat: sync admin stars grant support
This commit is contained in:
parent
23fa1cad21
commit
e0cabb4930
14 changed files with 286 additions and 11 deletions
|
|
@ -14,6 +14,7 @@ import (
|
|||
const (
|
||||
ActionSetSendFrozen = "account.set_send_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
ActionRevokeSessions = "account.revoke_sessions"
|
||||
|
|
@ -25,6 +26,7 @@ const (
|
|||
maxReasonLength = 1000
|
||||
maxHistoryBatches = 100
|
||||
maxPremiumMonths = 120
|
||||
maxStarsGrant = 1_000_000_000
|
||||
)
|
||||
|
||||
type CommandRepository interface {
|
||||
|
|
@ -54,6 +56,14 @@ type UsersService interface {
|
|||
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
|
||||
}
|
||||
|
||||
type StarsService interface {
|
||||
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
||||
}
|
||||
|
||||
type StarsNotifier interface {
|
||||
NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error
|
||||
}
|
||||
|
||||
type UserNotifier interface {
|
||||
NotifyUserChanged(ctx context.Context, u domain.User) error
|
||||
}
|
||||
|
|
@ -80,6 +90,8 @@ type Dependencies struct {
|
|||
Auth AuthService
|
||||
Revoker AuthKeyRevoker
|
||||
Users UsersService
|
||||
Stars StarsService
|
||||
StarsNotifier StarsNotifier
|
||||
UserNotifier UserNotifier
|
||||
Channels ChannelsService
|
||||
ChannelNotifier ChannelNotifier
|
||||
|
|
@ -93,6 +105,8 @@ type Service struct {
|
|||
auth AuthService
|
||||
revoker AuthKeyRevoker
|
||||
users UsersService
|
||||
stars StarsService
|
||||
starsNotifier StarsNotifier
|
||||
userNotifier UserNotifier
|
||||
channels ChannelsService
|
||||
channelNotifier ChannelNotifier
|
||||
|
|
@ -121,6 +135,12 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Users != nil {
|
||||
s.users = deps.Users
|
||||
}
|
||||
if deps.Stars != nil {
|
||||
s.stars = deps.Stars
|
||||
}
|
||||
if deps.StarsNotifier != nil {
|
||||
s.starsNotifier = deps.StarsNotifier
|
||||
}
|
||||
if deps.UserNotifier != nil {
|
||||
s.userNotifier = deps.UserNotifier
|
||||
}
|
||||
|
|
@ -174,6 +194,12 @@ type GrantPremiumRequest struct {
|
|||
Months int `json:"months"`
|
||||
}
|
||||
|
||||
type GrantStarsRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
type SetVerifiedRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
|
|
@ -305,6 +331,46 @@ func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (Co
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) GrantStars(ctx context.Context, req GrantStarsRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if req.Amount <= 0 || req.Amount > maxStarsGrant {
|
||||
return CommandResult{}, fmt.Errorf("amount must be between 1 and %d", maxStarsGrant)
|
||||
}
|
||||
if s == nil || s.users == nil || s.stars == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin stars dependencies are not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGrantStars, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{
|
||||
"amount": req.Amount,
|
||||
"username": u.Username,
|
||||
"phone": u.Phone,
|
||||
"would_credit": true,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
balance, err := s.stars.Credit(ctx, req.UserID, req.Amount, domain.StarsReasonAdjust, domain.Peer{}, "Admin Stars grant", req.Reason)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_balance"] = balance.Balance
|
||||
details["starting_grant_applied"] = balance.Granted
|
||||
if err := s.notifyStarsBalanceChanged(ctx, balance); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "stars granted", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
|
|
@ -654,6 +720,13 @@ func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error {
|
|||
return s.userNotifier.NotifyUserChanged(ctx, u)
|
||||
}
|
||||
|
||||
func (s *Service) notifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
|
||||
if s == nil || s.starsNotifier == nil {
|
||||
return nil
|
||||
}
|
||||
return s.starsNotifier.NotifyStarsBalanceChanged(ctx, balance)
|
||||
}
|
||||
|
||||
func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error {
|
||||
if s == nil || s.channelNotifier == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -103,6 +103,59 @@ func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGrantStarsDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, Phone: "1001", Username: "alice", FirstName: "Alice"},
|
||||
}}
|
||||
stars := &fakeStarsService{balances: map[int64]domain.StarsBalance{
|
||||
1001: {UserID: 1001, Balance: 1000, Granted: true},
|
||||
}}
|
||||
notifier := &fakeStarsNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
Stars: stars,
|
||||
StarsNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.GrantStars(ctx, GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-stars", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run stars: %v", err)
|
||||
}
|
||||
if !dry.DryRun || stars.creditCalls != 0 || len(notifier.balances) != 0 {
|
||||
t.Fatalf("dry=%+v creditCalls=%d notified=%v, want no mutation", dry, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
|
||||
req := GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-stars", Actor: "ops", Reason: "ops grant"},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
}
|
||||
exec, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute stars: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || stars.creditCalls != 1 || stars.lastAmount != 250 || stars.lastReason != domain.StarsReasonAdjust || len(notifier.balances) != 1 {
|
||||
t.Fatalf("exec=%+v creditCalls=%d amount=%d reason=%s notified=%v", exec, stars.creditCalls, stars.lastAmount, stars.lastReason, notifier.balances)
|
||||
}
|
||||
if exec.Details["updated_balance"] != int64(1250) {
|
||||
t.Fatalf("updated_balance=%v, want 1250", exec.Details["updated_balance"])
|
||||
}
|
||||
again, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate stars: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || stars.creditCalls != 1 || len(notifier.balances) != 1 {
|
||||
t.Fatalf("again=%+v creditCalls=%d notified=%v, want idempotent replay", again, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
|
|
@ -467,6 +520,47 @@ func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified
|
|||
return u, nil
|
||||
}
|
||||
|
||||
type fakeStarsService struct {
|
||||
balances map[int64]domain.StarsBalance
|
||||
creditCalls int
|
||||
lastUserID int64
|
||||
lastAmount int64
|
||||
lastReason domain.StarsTransactionReason
|
||||
lastPeer domain.Peer
|
||||
lastTitle string
|
||||
lastDesc string
|
||||
}
|
||||
|
||||
func (f *fakeStarsService) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
f.creditCalls++
|
||||
f.lastUserID = userID
|
||||
f.lastAmount = amount
|
||||
f.lastReason = reason
|
||||
f.lastPeer = peer
|
||||
f.lastTitle = title
|
||||
f.lastDesc = desc
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
if f.balances == nil {
|
||||
f.balances = map[int64]domain.StarsBalance{}
|
||||
}
|
||||
balance := f.balances[userID]
|
||||
balance.UserID = userID
|
||||
balance.Balance += amount
|
||||
f.balances[userID] = balance
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
type fakeStarsNotifier struct {
|
||||
balances []domain.StarsBalance
|
||||
}
|
||||
|
||||
func (f *fakeStarsNotifier) NotifyStarsBalanceChanged(_ context.Context, balance domain.StarsBalance) error {
|
||||
f.balances = append(f.balances, balance)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUserNotifier struct {
|
||||
users []int64
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue