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
Support bool
Bot bool
StarsBalance int64
StarsGranted bool
Restriction RestrictionRow
HasRestriction bool
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,
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
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
FROM users u
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
WHERE u.id = $1`, userID).Scan(
&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.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 {
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("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-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-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
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)
}
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 {
CommandID string `json:"command_id"`
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>

View file

@ -113,6 +113,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.notVerified": "Not verified",
"account.notPremium": "Not premium",
"account.premiumUntil": "Premium expires",
"account.starsBalance": "Stars balance",
"account.startingGrantApplied": "initial grant applied",
"account.startingGrantPending": "initial grant pending",
"account.activeSessions": "Authorized devices",
"account.accountFlags": "Account flags",
"account.restriction": "Restriction",
@ -137,6 +140,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set 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.clearVerified": "Clear verified",
"channel.pageTitle": "Supergroups and Channels",
@ -372,6 +378,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.notVerified": "未认证",
"account.notPremium": "非会员",
"account.premiumUntil": "会员到期",
"account.starsBalance": "Stars 余额",
"account.startingGrantApplied": "初始赠送已发放",
"account.startingGrantPending": "初始赠送未触发",
"account.activeSessions": "授权设备",
"account.accountFlags": "账号标记",
"account.restriction": "限制状态",
@ -396,6 +405,9 @@ const translations: Record<Language, Record<string, string>> = {
"account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员",
"account.clearPremium": "取消会员",
"account.starsAmount": "赠送 Stars 数量",
"account.starsAmountAria": "设置要赠送的 Stars 数量",
"account.grantStars": "赠送 Stars",
"account.setVerified": "设置认证",
"account.clearVerified": "取消认证",
"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 { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
@ -15,6 +15,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000");
async function load() {
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.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
<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("account.activeSessions")} value={String(detail.Authorizations.length)} />
<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 })}
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
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />}

View file

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

View file

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

View file

@ -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

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) {
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
}

View file

@ -22,6 +22,7 @@ type Config struct {
type Service interface {
SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (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)
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (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/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/revoke-sessions", s.authenticated(s.handleRevokeSessions))
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)
}
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) {
var req admin.SetVerifiedRequest
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) {
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}`))
@ -72,6 +86,10 @@ func (fakeService) GrantPremium(_ context.Context, req admin.GrantPremiumRequest
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) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -3,6 +3,8 @@ package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
@ -31,3 +33,16 @@ func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) er
r.channelStateMutationUpdates(ctx, ch.CreatorUserID, ch)
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
}