feat: sync admin stars grant support

This commit is contained in:
A 2026-07-08 02:30:01 +08:00
parent 23fa1cad21
commit e0cabb4930
14 changed files with 286 additions and 11 deletions

View file

@ -53,6 +53,8 @@ type AccountDetail struct {
Verified bool Verified bool
Support bool Support bool
Bot bool Bot bool
StarsBalance int64
StarsGranted bool
Restriction RestrictionRow Restriction RestrictionRow
HasRestriction bool HasRestriction bool
Authorizations []AuthorizationRow Authorizations []AuthorizationRow
@ -359,14 +361,16 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
u.about, u.last_seen_at, u.verified, u.support, u.is_bot, u.about, u.last_seen_at, u.verified, u.support, u.is_bot,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), COALESCE(r.frozen, false), COALESCE(r.reason, ''),
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN stars_balances sb ON sb.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, userID).Scan( WHERE u.id = $1`, userID).Scan(
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName, &out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot, &out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot,
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username, &out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.StarsBalance, &out.StarsGranted, &out.Account.Username,
) )
if err != nil { if err != nil {
return out, fmt.Errorf("get account: %w", err) return out, fmt.Errorf("get account: %w", err)

View file

@ -58,6 +58,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI))) mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI))) mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI))) mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI))) mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI))) mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
@ -431,6 +432,28 @@ func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err) writeCommandResultAPI(w, result, err)
} }
type grantStarsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Amount int64 `json:"amount"`
}
func (s *server) handleGrantStarsAPI(w http.ResponseWriter, r *http.Request) {
var body grantStarsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GrantStarsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-stars"),
UserID: body.UserID,
Amount: body.Amount,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-stars", req)
writeCommandResultAPI(w, result, err)
}
type setVerifiedAPIRequest struct { type setVerifiedAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title> <title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-DLK0oYaN.js"></script> <script type="module" crossorigin src="/assets/index-BHnkZ_za.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css"> <link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
</head> </head>
<body> <body>

View file

@ -113,6 +113,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.notVerified": "Not verified", "account.notVerified": "Not verified",
"account.notPremium": "Not premium", "account.notPremium": "Not premium",
"account.premiumUntil": "Premium expires", "account.premiumUntil": "Premium expires",
"account.starsBalance": "Stars balance",
"account.startingGrantApplied": "initial grant applied",
"account.startingGrantPending": "initial grant pending",
"account.activeSessions": "Authorized devices", "account.activeSessions": "Authorized devices",
"account.accountFlags": "Account flags", "account.accountFlags": "Account flags",
"account.restriction": "Restriction", "account.restriction": "Restriction",
@ -137,6 +140,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.premiumMonthsAria": "Set premium duration in months", "account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set premium", "account.setPremium": "Set premium",
"account.clearPremium": "Clear premium", "account.clearPremium": "Clear premium",
"account.starsAmount": "Stars to grant",
"account.starsAmountAria": "Set Stars amount to grant",
"account.grantStars": "Grant Stars",
"account.setVerified": "Set verified", "account.setVerified": "Set verified",
"account.clearVerified": "Clear verified", "account.clearVerified": "Clear verified",
"channel.pageTitle": "Supergroups and Channels", "channel.pageTitle": "Supergroups and Channels",
@ -372,6 +378,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.notVerified": "未认证", "account.notVerified": "未认证",
"account.notPremium": "非会员", "account.notPremium": "非会员",
"account.premiumUntil": "会员到期", "account.premiumUntil": "会员到期",
"account.starsBalance": "Stars 余额",
"account.startingGrantApplied": "初始赠送已发放",
"account.startingGrantPending": "初始赠送未触发",
"account.activeSessions": "授权设备", "account.activeSessions": "授权设备",
"account.accountFlags": "账号标记", "account.accountFlags": "账号标记",
"account.restriction": "限制状态", "account.restriction": "限制状态",
@ -396,6 +405,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.premiumMonthsAria": "设置会员时长,单位月", "account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员", "account.setPremium": "设置会员",
"account.clearPremium": "取消会员", "account.clearPremium": "取消会员",
"account.starsAmount": "赠送 Stars 数量",
"account.starsAmountAria": "设置要赠送的 Stars 数量",
"account.grantStars": "赠送 Stars",
"account.setVerified": "设置认证", "account.setVerified": "设置认证",
"account.clearVerified": "取消认证", "account.clearVerified": "取消认证",
"channel.pageTitle": "超级群与频道", "channel.pageTitle": "超级群与频道",

View file

@ -1,4 +1,4 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react"; import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles, Star } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
@ -15,6 +15,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1"); const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000");
async function load() { async function load() {
setBusy(true); setBusy(true);
@ -64,6 +65,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<Summary label={t("account.userID")} value={String(account.ID)} mono /> <Summary label={t("account.userID")} value={String(account.ID)} mono />
<Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} /> <Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} /> <Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} />
<Summary label={t("account.starsBalance")} value={`${detail.StarsBalance} / ${detail.StarsGranted ? t("account.startingGrantApplied") : t("account.startingGrantPending")}`} />
<Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} /> <Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} /> <Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} /> <Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
@ -119,6 +121,25 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
payload={() => ({ user_id: account.ID, months: 0 })} payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load} onDone={load}
/> />
<label className="duration-field">
<span>{t("account.starsAmount")}</span>
<input
aria-label={t("account.starsAmountAria")}
value={starsAmount}
onChange={(event) => setStarsAmount(event.target.value)}
type="number"
min="1"
max="1000000000"
/>
</label>
<ActionButton
label={t("account.grantStars")}
icon={<Star size={15} />}
tone="warn"
path="/api/actions/grant-stars"
payload={() => ({ user_id: account.ID, amount: toInt(starsAmount) })}
onDone={load}
/>
<ActionButton <ActionButton
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")} label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}

View file

@ -57,6 +57,8 @@ export type AccountDetail = {
Verified: boolean; Verified: boolean;
Support: boolean; Support: boolean;
Bot: boolean; Bot: boolean;
StarsBalance: number;
StarsGranted: boolean;
Restriction: RestrictionRow; Restriction: RestrictionRow;
HasRestriction: boolean; HasRestriction: boolean;
Authorizations: AuthorizationRow[]; Authorizations: AuthorizationRow[];

View file

@ -689,6 +689,8 @@ func run(logger *zap.Logger) error {
Auth: authService, Auth: authService,
Revoker: router, Revoker: router,
Users: usersService, Users: usersService,
Stars: starsService,
StarsNotifier: router,
UserNotifier: router, UserNotifier: router,
Channels: channelsService, Channels: channelsService,
ChannelNotifier: router, ChannelNotifier: router,

View file

@ -14,6 +14,7 @@ import (
const ( const (
ActionSetSendFrozen = "account.set_send_frozen" ActionSetSendFrozen = "account.set_send_frozen"
ActionGrantPremium = "account.grant_premium" ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified" ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified" ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions" ActionRevokeSessions = "account.revoke_sessions"
@ -25,6 +26,7 @@ const (
maxReasonLength = 1000 maxReasonLength = 1000
maxHistoryBatches = 100 maxHistoryBatches = 100
maxPremiumMonths = 120 maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
) )
type CommandRepository interface { type CommandRepository interface {
@ -54,6 +56,14 @@ type UsersService interface {
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) 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 { type UserNotifier interface {
NotifyUserChanged(ctx context.Context, u domain.User) error NotifyUserChanged(ctx context.Context, u domain.User) error
} }
@ -80,6 +90,8 @@ type Dependencies struct {
Auth AuthService Auth AuthService
Revoker AuthKeyRevoker Revoker AuthKeyRevoker
Users UsersService Users UsersService
Stars StarsService
StarsNotifier StarsNotifier
UserNotifier UserNotifier UserNotifier UserNotifier
Channels ChannelsService Channels ChannelsService
ChannelNotifier ChannelNotifier ChannelNotifier ChannelNotifier
@ -93,6 +105,8 @@ type Service struct {
auth AuthService auth AuthService
revoker AuthKeyRevoker revoker AuthKeyRevoker
users UsersService users UsersService
stars StarsService
starsNotifier StarsNotifier
userNotifier UserNotifier userNotifier UserNotifier
channels ChannelsService channels ChannelsService
channelNotifier ChannelNotifier channelNotifier ChannelNotifier
@ -121,6 +135,12 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Users != nil { if deps.Users != nil {
s.users = deps.Users s.users = deps.Users
} }
if deps.Stars != nil {
s.stars = deps.Stars
}
if deps.StarsNotifier != nil {
s.starsNotifier = deps.StarsNotifier
}
if deps.UserNotifier != nil { if deps.UserNotifier != nil {
s.userNotifier = deps.UserNotifier s.userNotifier = deps.UserNotifier
} }
@ -174,6 +194,12 @@ type GrantPremiumRequest struct {
Months int `json:"months"` Months int `json:"months"`
} }
type GrantStarsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Amount int64 `json:"amount"`
}
type SetVerifiedRequest struct { type SetVerifiedRequest struct {
CommandMeta CommandMeta
UserID int64 `json:"user_id"` 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) { func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) {
if req.UserID <= 0 { if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required") 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) 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 { func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error {
if s == nil || s.channelNotifier == nil { if s == nil || s.channelNotifier == nil {
return nil return nil

View file

@ -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) { func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background() ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{ users := &fakeUsersService{users: map[int64]domain.User{
@ -467,6 +520,47 @@ func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified
return u, nil 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 { type fakeUserNotifier struct {
users []int64 users []int64
} }

View file

@ -22,6 +22,7 @@ type Config struct {
type Service interface { type Service interface {
SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error) SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error)
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error) GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error) SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error) RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
@ -77,6 +78,7 @@ func (s *Server) routes() http.Handler {
}) })
mux.HandleFunc("POST /v1/accounts/freeze-send", s.authenticated(s.handleFreezeSend)) mux.HandleFunc("POST /v1/accounts/freeze-send", s.authenticated(s.handleFreezeSend))
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium)) mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified)) mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions)) mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified)) mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
@ -114,6 +116,15 @@ func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err) writeCommandResult(w, result, err)
} }
func (s *Server) handleGrantStars(w http.ResponseWriter, r *http.Request) {
var req admin.GrantStarsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.GrantStars(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetVerified(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetVerified(w http.ResponseWriter, r *http.Request) {
var req admin.SetVerifiedRequest var req admin.SetVerifiedRequest
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {

View file

@ -48,6 +48,20 @@ func TestAdminAPISetVerified(t *testing.T) {
} }
} }
func TestAdminAPIGrantStars(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/grant-stars", strings.NewReader(`{"command_id":"c-stars","actor":"ops","reason":"manual grant","dry_run":true,"user_id":1001,"amount":500}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"command_id":"c-stars"`) {
t.Fatalf("body=%s", rec.Body.String())
}
}
func TestAdminAPISetChannelVerified(t *testing.T) { func TestAdminAPISetChannelVerified(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}} srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/channels/set-verified", strings.NewReader(`{"command_id":"c3","actor":"ops","reason":"official","dry_run":true,"channel_id":2001,"verified":true}`)) req := httptest.NewRequest(http.MethodPost, "/v1/channels/set-verified", strings.NewReader(`{"command_id":"c3","actor":"ops","reason":"official","dry_run":true,"channel_id":2001,"verified":true}`))
@ -72,6 +86,10 @@ func (fakeService) GrantPremium(_ context.Context, req admin.GrantPremiumRequest
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }
func (fakeService) GrantStars(_ context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetVerified(_ context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) { func (fakeService) SetVerified(_ context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }

View file

@ -3,6 +3,8 @@ package rpc
import ( import (
"context" "context"
"github.com/gotd/td/tg"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -31,3 +33,16 @@ func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) er
r.channelStateMutationUpdates(ctx, ch.CreatorUserID, ch) r.channelStateMutationUpdates(ctx, ch.CreatorUserID, ch)
return nil return nil
} }
// NotifyStarsBalanceChanged is the domain-only hook used by the internal Admin
// API after the local Stars ledger balance has changed outside a client RPC.
func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
if r == nil || balance.UserID == 0 {
return nil
}
r.pushUserUpdates(ctx, balance.UserID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
Date: int(r.clock.Now().Unix()),
})
return nil
}