feat: sync admin stars grant support
This commit is contained in:
parent
23fa1cad21
commit
e0cabb4930
14 changed files with 286 additions and 11 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
8
cmd/telesrv-admin/web/dist/assets/index-BHnkZ_za.js
vendored
Normal file
8
cmd/telesrv-admin/web/dist/assets/index-BHnkZ_za.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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": "超级群与频道",
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue