fix(admin): close PR review blockers

Keep bot credentials out of durable command results, fail bot deletion closed when session revocation fails, reject invalid scam/fake states at every write boundary, and make direct collectible grants a single replayable PostgreSQL aggregate.

Also lock admin gift sender/message limits and add regression coverage for rollback, replay, moderation constraints, and credential redaction.
This commit is contained in:
iamxvbaba 2026-07-23 13:29:04 +08:00
parent 90792cdfab
commit 234061ef83
30 changed files with 859 additions and 93 deletions

View file

@ -1,30 +1,30 @@
<!doctype html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script>
(function () {
try {
var stored = localStorage.getItem("telesrv.admin.theme");
var theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script>
<!doctype html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script>
(function () {
try {
var stored = localStorage.getItem("telesrv.admin.theme");
var theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CsfWywUl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -135,7 +135,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="form-field">
<span>{t("giveGift.message")}</span>
<textarea value={message} rows={2} maxLength={255} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
</label>
<label className="gift-switch">

View file

@ -1,7 +1,9 @@
ALTER TABLE public.channels
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;
ALTER TABLE public.users
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;

View file

@ -4,6 +4,16 @@ ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
UPDATE public.users SET fake = false WHERE scam AND fake;
ALTER TABLE public.users
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
ADD CONSTRAINT users_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
UPDATE public.channels SET fake = false WHERE scam AND fake;
ALTER TABLE public.channels
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
ADD CONSTRAINT channels_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;

View file

@ -0,0 +1,22 @@
-- Direct admin collectible grants are one idempotent aggregate: unique
-- issuance, saved ownership, private message, pts/outbox and this receipt.
CREATE TABLE public.star_gift_admin_grant_commands (
recipient_user_id bigint NOT NULL,
command_key text NOT NULL,
request_fingerprint bytea NOT NULL,
sender_user_id bigint NOT NULL,
gift_id bigint NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_admin_grant_commands_pkey PRIMARY KEY (recipient_user_id, command_key),
CONSTRAINT star_gift_admin_grant_command_saved_uniq UNIQUE (saved_gift_id),
CONSTRAINT star_gift_admin_grant_command_unique_uniq UNIQUE (unique_gift_id),
CONSTRAINT star_gift_admin_grant_command_shape_check CHECK (
recipient_user_id > 0
AND sender_user_id = 777000
AND gift_id > 0
AND char_length(command_key) BETWEEN 1 AND 256
AND octet_length(request_fingerprint) = 32
)
);

View file

@ -299,6 +299,10 @@ type CommandResult struct {
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
Error string `json:"error,omitempty"`
// transientDetails are returned to the initiating caller only. They are
// deliberately excluded from JSON so credentials can never enter command
// replay or audit storage.
transientDetails map[string]any
}
type ImportStarGiftRequest struct {
@ -343,14 +347,14 @@ type SetStarGiftSortOrderRequest struct {
SortOrder int `json:"sort_order"`
}
// GiveGiftRequest grants a catalog gift to a recipient (user or channel) from a
// sender account (defaults to the official system account 777000) at no charge.
// GiveGiftRequest grants a catalog gift to a recipient (user or channel) from
// the official system account 777000 at no charge.
// Exactly one of UserID / ChannelID identifies the recipient.
type GiveGiftRequest struct {
CommandMeta
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
@ -870,10 +874,8 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
// scam and fake are mutually exclusive (a peer is never both in Telegram).
// scam takes precedence so the two never persist together.
if req.Scam {
req.Fake = false
if req.Scam && req.Fake {
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
@ -945,9 +947,9 @@ func collectibleAttrPresent(attrs []domain.StarGiftCollectibleAttribute, id int6
return false
}
// GiveGift grants a catalog gift to a recipient (user or channel) from a sender
// account (defaults to the official system account 777000) without charging any
// Stars. Delivery reuses the standard gift path via the GiftGranter dependency.
// GiveGift grants a catalog gift to a recipient (user or channel) from the
// official system account 777000 without charging any Stars. Delivery reuses
// the standard gift path via the GiftGranter dependency.
func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandResult, error) {
if req.GiftID <= 0 {
return CommandResult{}, fmt.Errorf("gift_id is required")
@ -962,6 +964,13 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if sender <= 0 {
sender = domain.OfficialSystemUserID
}
if sender != domain.OfficialSystemUserID {
return CommandResult{}, fmt.Errorf("gift sender must be the official system account")
}
req.Message = strings.TrimSpace(req.Message)
if len([]rune(req.Message)) > 128 {
return CommandResult{}, fmt.Errorf("gift message must be <= 128 characters")
}
var recipient domain.Peer
if req.ChannelID > 0 {
recipient = domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
@ -983,8 +992,8 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
"hide_name": req.HideName,
"upgrade": req.Upgrade,
}
if strings.TrimSpace(req.Message) != "" {
details["message"] = strings.TrimSpace(req.Message)
if req.Message != "" {
details["message"] = req.Message
}
if s.gifts != nil {
gift, found, err := s.gifts.GiftByID(ctx, req.GiftID)
@ -1037,8 +1046,9 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
Recipient: recipient,
GiftID: req.GiftID,
HideName: req.HideName,
Message: strings.TrimSpace(req.Message),
Message: req.Message,
Upgrade: req.Upgrade,
CommandKey: "admin-gift:" + req.CommandID,
ModelAttributeID: req.ModelAttributeID,
PatternAttributeID: req.PatternAttributeID,
BackdropAttributeID: req.BackdropAttributeID,
@ -1173,14 +1183,14 @@ func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandR
return CommandResult{Details: details}, err
}
details["bot_user_id"] = bot.ID
// The token is a credential. It is surfaced once so the operator can copy
// it; it is also persisted in the audit result, so treat admin audit logs
// as sensitive.
details["token"] = token
if err := s.notifyUserChanged(ctx, bot); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "bot created", Details: details}, nil
return CommandResult{
Message: "bot created",
Details: details,
transientDetails: map[string]any{"token": token},
}, nil
})
}
@ -1274,6 +1284,9 @@ func (s *Service) SetChannelFlags(ctx context.Context, req SetChannelFlagsReques
if s == nil || s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
}
if req.Scam && req.Fake {
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelFlags, 0, target, req, func() (CommandResult, error) {
ch, err := s.channels.GetChannelByID(ctx, req.ChannelID)
@ -2092,14 +2105,24 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
if marshalErr != nil {
return result, fmt.Errorf("marshal admin result: %w", marshalErr)
}
response := result
if len(result.transientDetails) > 0 {
response.Details = make(map[string]any, len(result.Details)+len(result.transientDetails))
for key, value := range result.Details {
response.Details[key] = value
}
for key, value := range result.transientDetails {
response.Details[key] = value
}
}
errorText := ""
if opErr != nil {
errorText = opErr.Error()
}
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
return result, err
return response, err
}
return result, opErr
return response, opErr
}
func sameJSON(a, b []byte) bool {

View file

@ -80,6 +80,69 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
}
}
func TestCreateBotReturnsTokenOnceWithoutPersistingCredential(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
bots := &fakeBotService{token: "test-one-time-bot-credential"}
svc := NewService(Dependencies{Commands: repo, Bots: bots, Now: fixedNow})
req := CreateBotRequest{
CommandMeta: CommandMeta{CommandID: "create-bot-once", Actor: "ops", Reason: "requested"},
OwnerUserID: 1001,
Name: "Audit Safe Bot",
Username: "audit_safe_bot",
}
first, err := svc.CreateBot(ctx, req)
if err != nil {
t.Fatalf("CreateBot: %v", err)
}
if first.Details["token"] != bots.token || bots.createCalls != 1 {
t.Fatalf("first result=%+v createCalls=%d", first, bots.createCalls)
}
stored := repo.items[req.CommandID].ResultJSON
if bytes.Contains(stored, []byte(bots.token)) || bytes.Contains(stored, []byte(`"token"`)) {
t.Fatalf("persisted admin result contains bot credential: %s", stored)
}
replay, err := svc.CreateBot(ctx, req)
if err != nil {
t.Fatalf("CreateBot replay: %v", err)
}
if !replay.AlreadyExecuted || bots.createCalls != 1 {
t.Fatalf("replay=%+v createCalls=%d", replay, bots.createCalls)
}
if _, leaked := replay.Details["token"]; leaked {
t.Fatalf("replayed command exposed one-time bot token: %+v", replay)
}
}
func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
users := &fakeUsersService{users: map[int64]domain.User{1001: {ID: 1001}}}
channels := &fakeChannelsService{channels: map[int64]domain.Channel{2001: {
ID: 2001, Megagroup: true,
}}}
svc := NewService(Dependencies{Commands: repo, Users: users, Channels: channels, Now: fixedNow})
meta := CommandMeta{CommandID: "invalid-user-flags", Actor: "ops", Reason: "test"}
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
CommandMeta: meta, UserID: 1001, Scam: true, Fake: true,
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("SetUserFlags error=%v", err)
}
meta.CommandID = "invalid-channel-flags"
if _, err := svc.SetChannelFlags(ctx, SetChannelFlagsRequest{
CommandMeta: meta, ChannelID: 2001, Scam: true, Fake: true,
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("SetChannelFlags error=%v", err)
}
if len(repo.items) != 0 || users.users[1001].Scam || users.users[1001].Fake ||
channels.channels[2001].Scam || channels.channels[2001].Fake {
t.Fatalf("invalid moderation state reached command/store boundary: commands=%d user=%+v channel=%+v",
len(repo.items), users.users[1001], channels.channels[2001])
}
}
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
now := fixedNow()
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
@ -505,6 +568,22 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
return cmd, nil
}
type fakeBotService struct {
token string
createCalls int
deleteCalls int
}
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) {
f.createCalls++
return domain.User{ID: 2001, FirstName: name, Username: username, Bot: true}, f.token, nil
}
func (f *fakeBotService) DeleteBot(_ context.Context, botUserID int64) (domain.User, error) {
f.deleteCalls++
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountFreeze
setCalls int

View file

@ -2,6 +2,7 @@ package bots
import (
"context"
"errors"
"strings"
"testing"
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
}
}
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
users := memory.NewUserStore()
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
revocationErr := errors.New("authorization store unavailable")
rev := &captureRevoker{err: revocationErr}
svc := NewService(users, botStore, messages)
svc.SetRouterHooks(rev)
owner := newOwner(t, users, "+2099")
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
}
if botStore.deleteCalls != 0 {
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
}
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
}
rev.err = nil
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
if err != nil {
t.Fatalf("DeleteBot after revocation recovery: %v", err)
}
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
}
}
func TestBotWriteAccessGrant(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2012")
@ -400,11 +433,12 @@ type captureRevoker struct {
botUserID int64
pushedCommandsTo int64
pushedCommands []domain.BotCommand
err error
}
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
c.botUserID = botUserID
return nil
return c.err
}
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {

View file

@ -469,12 +469,15 @@ func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User,
if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
}
// Drop live sessions up front so the token stops working even if a caller
// races the tombstone; DeleteBotAccount also revokes the authorization rows.
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
// Session revocation is part of the deletion invariant: a deleted bot must
// never retain an authenticated connection. Fail closed before tombstoning
// when the hook is unavailable or revocation fails.
if s.hooks == nil {
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil {

View file

@ -181,6 +181,7 @@ type countingBotStore struct {
*memory.BotStore
getBotCalls int
getBotsCalls int
deleteCalls int
}
func (s *countingBotStore) reset() {
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
return s.BotStore.GetBots(ctx, botUserIDs)
}
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
s.deleteCalls++
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
}
func TestBotFatherCancelAndUnknown(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1001")

View file

@ -505,6 +505,9 @@ func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake b
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
}

View file

@ -517,6 +517,18 @@ func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey s
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
}
// GrantUnique atomically assigns a freshly minted collectible to a user.
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
if s == nil || s.upgrades == nil {
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable

View file

@ -371,6 +371,9 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err

View file

@ -339,7 +339,7 @@ type StarGiftUpgradeRequest struct {
}
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from Sender (0 => official system account 777000) at no charge.
// Recipient from the official system account 777000 at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically.
@ -350,11 +350,24 @@ type AdminStarGiftGrant struct {
HideName bool
Message string
Upgrade bool
CommandKey string
Date int
RecipientBlocked bool
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
}
// AdminStarGiftGrantResult is the committed direct collectible assignment.
// The saved gift, unique issuance, private message and replay receipt are one
// aggregate transaction.
type AdminStarGiftGrantResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftPurchaseRequest struct {
BuyerUserID int64
BuyerPremium bool

View file

@ -12,6 +12,9 @@ var (
ErrUserNotFound = errors.New("user not found")
ErrUserFrozen = errors.New("user account frozen")
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
// ErrPeerModerationFlagsInvalid rejects the impossible scam+fake state at
// every write boundary shared by user, bot and channel projections.
ErrPeerModerationFlagsInvalid = errors.New("peer moderation flags invalid")
// ErrPremiumRequired 表示该操作仅限有效会员PREMIUM_ACCOUNT_REQUIRED
ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。

View file

@ -451,9 +451,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Verified: ch.Verified,
Scam: ch.Scam,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: ch.Fake && !ch.Scam,
Fake: ch.Fake,
Gigagroup: ch.Gigagroup,
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,

View file

@ -53,9 +53,7 @@ func tgUser(u domain.User) *tg.User {
Phone: u.Phone,
Verified: u.Verified,
Scam: u.Scam,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: u.Fake && !u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -7,24 +7,30 @@ import (
"telesrv/internal/domain"
)
type adminUniqueStarGiftGranter interface {
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
}
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
// grant.SenderID without charging any Stars. It powers the admin console "Give
// gift" action: the gift is loaded from the catalog and delivered through the
// exact same path a paid send uses (messageActionStarGift service message for
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
//
// When SenderID is zero the official system account (777000, the telesrv
// service account) is used as the sender. When Upgrade is true the granted gift
// is immediately upgraded to a genuine collectible (unique) gift. The optional
// ModelAttributeID / PatternAttributeID / BackdropAttributeID / Num pin specific
// collectible facts (0 => random model/pattern/backdrop, auto sequential
// number); the DB constraints remain the source of truth. Upgraded delivery is
// supported for user recipients only.
// SenderID must be zero or the official system account (777000). When Upgrade
// is true, the store assigns a genuine collectible directly in the same
// transaction as its service message and durable updates. The optional
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
// collectible facts (0 => random; number is always sequential). Upgraded
// delivery is supported for user recipients only.
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
senderID := grant.SenderID
if senderID <= 0 {
senderID = domain.OfficialSystemUserID
}
if senderID != domain.OfficialSystemUserID {
return fmt.Errorf("gift sender must be the official system account")
}
if grant.GiftID <= 0 {
return fmt.Errorf("gift_id is required")
}
@ -56,10 +62,9 @@ func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarG
}
}
// adminGrantUpgradedStarGift grants a base gift carrying a prepaid upgrade
// entitlement and then mints the collectible via the standard zero-charge
// prepaid upgrade path, so the recipient ends up owning a real unique gift with
// the requested (or random) attributes and number.
// adminGrantUpgradedStarGift assigns a collectible through the atomic store
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
// outbox event behind.
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
if grant.Recipient.Type != domain.PeerTypeUser {
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
@ -74,23 +79,18 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
if preview.Issued >= preview.SupplyTotal {
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
}
// Grant the base gift with a prepaid-upgrade entitlement so the upgrade
// below runs on the zero-charge RequirePrepaid path.
ref, _, err := r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, preview.UpgradeStars)
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
if !ok {
return fmt.Errorf("atomic collectible grant is not configured")
}
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
if err != nil {
return err
}
commandKey := fmt.Sprintf("admin-grant-upgrade:%d:%d:%d", grant.Recipient.ID, gift.ID, ref.MsgID)
if _, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: grant.Recipient.ID,
Ref: ref,
RequirePrepaid: true,
CommandKey: commandKey,
Date: int(r.clock.Now().Unix()),
ModelAttributeID: grant.ModelAttributeID,
PatternAttributeID: grant.PatternAttributeID,
BackdropAttributeID: grant.BackdropAttributeID,
}); err != nil {
grant.SenderID = senderID
grant.Date = int(r.clock.Now().Unix())
grant.RecipientBlocked = recipientBlocked
if _, err := granter.GrantUnique(ctx, grant); err != nil {
return err
}
r.invalidateStarGiftOwnerProjection(grant.Recipient)

View file

@ -201,6 +201,9 @@ func (s *ChannelStore) SetChannelScamFake(_ context.Context, channelID int64, sc
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]

View file

@ -0,0 +1,40 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestModerationStoresRejectScamAndFakeTogether(t *testing.T) {
ctx := context.Background()
users := NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "+15550009999", FirstName: "Flag"})
if err != nil {
t.Fatal(err)
}
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("user SetScamFake error=%v", err)
}
gotUser, found, err := users.ByID(ctx, user.ID)
if err != nil || !found || gotUser.Scam || gotUser.Fake {
t.Fatalf("user after rejected flags=%+v found=%v err=%v", gotUser, found, err)
}
channels := NewChannelStore()
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: user.ID, Title: "Flags", Megagroup: true,
})
if err != nil {
t.Fatal(err)
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("channel SetChannelScamFake error=%v", err)
}
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
if err != nil || gotChannel.Scam || gotChannel.Fake {
t.Fatalf("channel after rejected flags=%+v err=%v", gotChannel, err)
}
}

View file

@ -298,6 +298,9 @@ func (s *UserStore) SetSupport(_ context.Context, userID int64, support bool) (d
// SetScamFake 设置/取消用户的 scam 与 fake 标记(与 postgres 语义一致)。
func (s *UserStore) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]

View file

@ -289,6 +289,9 @@ func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64,
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err

View file

@ -119,7 +119,12 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
type privateSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, error)
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
// afterAllocate runs after the immutable logical message and both box IDs
// exist, but before either box, update event or replay snapshot is written.
// It may finalize req.Media using those IDs; all of its writes remain in the
// same private-send transaction.
afterAllocate func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest, int, int) error
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
}
// privateSendMediaProjection separates the logical private-message payload
@ -325,6 +330,44 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err)
}
}
if hooks.afterAllocate != nil {
// A callback may replace the media after the ordinary request
// fingerprint was computed. Requiring a complete caller-owned
// fingerprint keeps random_id replay bound to the final aggregate intent.
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "after-allocate private send"); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := hooks.afterAllocate(ctx, tx, &req, senderBoxID, recipientBoxID); err != nil {
return domain.SendPrivateTextResult{}, err
}
media = privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
if hooks.projectMedia != nil {
media, err = hooks.projectMedia(ctx, tx, &req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
}
sharedMediaJSON, err = encodeMessageMedia(media.Shared)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
senderMediaJSON, err = encodeMessageMedia(media.Sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
recipientMediaJSON, err = encodeMessageMedia(media.Recipient)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
tag, err := tx.Exec(ctx, `UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
if err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: logical message disappeared")
}
}
senderArg := sqlcgen.CreateMessageBoxParams{
OwnerUserID: req.SenderUserID,

View file

@ -0,0 +1,50 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
user := createTestUser(t, ctx, users, "+1781"+suffix+"71", "ModerationFlags", "")
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("user store error=%v", err)
}
if _, err := pool.Exec(ctx, `UPDATE users SET scam=true,fake=true WHERE id=$1`, user.ID); err == nil {
t.Fatal("users CHECK constraint accepted scam=true,fake=true")
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: user.ID,
Title: "Moderation " + suffix,
Megagroup: true,
Date: 1700002000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
t.Fatalf("channel store error=%v", err)
}
if _, err := pool.Exec(ctx, `UPDATE channels SET scam=true,fake=true WHERE id=$1`, created.Channel.ID); err == nil {
t.Fatal("channels CHECK constraint accepted scam=true,fake=true")
}
gotUser, found, err := users.ByID(ctx, user.ID)
if err != nil || !found || gotUser.Scam || gotUser.Fake {
t.Fatalf("user after rejected writes=%+v found=%v err=%v", gotUser, found, err)
}
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
if err != nil || gotChannel.Scam || gotChannel.Fake {
t.Fatalf("channel after rejected writes=%+v err=%v", gotChannel, err)
}
}

View file

@ -603,6 +603,125 @@ FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p
}
}
func TestAdminUniqueStarGiftGrantIsAtomicAndReplayable(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
recipient := createTestUser(t, ctx, NewUserStore(pool), "+1780"+suffix+"61", "AdminGiftRecipient", "")
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Admin Grant " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "admin-grant-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "admin-grant-gift"), Animation: collectibleTestAnimation("admin-grant-gift.tgs"),
Actor: "integration", CommandID: "admin-grant-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create admin grant catalog gift: %v", err)
}
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 2, SlugPrefix: "admin-grant-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Model One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "admin-model-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "admin-model-one"), Animation: collectibleTestAnimationPtr("admin-model-one.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "admin-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "admin-model-two"), Animation: collectibleTestAnimationPtr("admin-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "admin-pattern-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "admin-pattern-one"), Animation: collectibleTestAnimationPtr("admin-pattern-one.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "admin-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "admin-pattern-two"), Animation: collectibleTestAnimationPtr("admin-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop One", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "admin-grant-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish admin grant collectible pool: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, NewMessageStore(pool))
invalid := domain.AdminStarGiftGrant{
SenderID: domain.OfficialSystemUserID, Recipient: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
GiftID: entry.Gift.ID, Upgrade: true, CommandKey: "admin-invalid-" + suffix, Date: now,
ModelAttributeID: revision.Models[0].ID + 9_999_999,
}
if _, err := upgrades.GrantUniqueStarGift(ctx, invalid); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("invalid admin grant error=%v", err)
}
var issued, messageCount, savedCount, uniqueCount, commandCount int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
invalidRandomID := lifecycleCommandRandomID("admin-collectible-grant", recipient.ID, invalid.CommandKey)
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM private_messages WHERE sender_user_id=$1 AND random_id=$2`,
domain.OfficialSystemUserID, invalidRandomID).Scan(&messageCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type='user' AND owner_peer_id=$1 AND gift_id=$2`,
recipient.ID, entry.Gift.ID).Scan(&savedCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE gift_id=$1`, entry.Gift.ID).Scan(&uniqueCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 0 || messageCount != 0 || savedCount != 0 || uniqueCount != 0 || commandCount != 0 {
t.Fatalf("failed grant leaked state: issued=%d messages=%d saved=%d unique=%d commands=%d",
issued, messageCount, savedCount, uniqueCount, commandCount)
}
req := invalid
req.CommandKey = "admin-success-" + suffix
req.Message = "atomic collectible"
req.ModelAttributeID = revision.Models[0].ID
req.PatternAttributeID = revision.Patterns[0].ID
req.BackdropAttributeID = revision.Backdrops[0].ID
granted, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("grant admin unique gift: %v", err)
}
action := granted.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if granted.Duplicate || granted.Saved.MsgID <= 0 || granted.Saved.MsgID != granted.Saved.UpgradeMsgID ||
granted.Saved.UniqueGiftID != granted.Unique.ID || granted.Unique.Num != 1 ||
action == nil || !action.Assigned || !action.Saved || action.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant result=%+v action=%+v", granted, action)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, granted.Send.RecipientMessage.Pts-1, 1)
if err != nil || len(events) != 1 || events[0].Message.Media == nil ||
events[0].Message.Media.ServiceAction == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant durable update=%+v err=%v", events, err)
}
replay, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("replay admin unique gift: %v", err)
}
if !replay.Duplicate || replay.Saved.ID != granted.Saved.ID || replay.Unique.ID != granted.Unique.ID ||
replay.Send.RecipientMessage.ID != granted.Send.RecipientMessage.ID {
t.Fatalf("admin grant replay=%+v want saved=%d unique=%d msg=%d",
replay, granted.Saved.ID, granted.Unique.ID, granted.Send.RecipientMessage.ID)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 1 || commandCount != 1 {
t.Fatalf("replay duplicated aggregate: issued=%d commands=%d", issued, commandCount)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,

View file

@ -1,6 +1,7 @@
package postgres
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@ -46,6 +47,296 @@ func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...St
return s
}
// GrantUniqueStarGift atomically assigns a newly minted collectible from the
// official system account. The saved gift, unique issuance, service message,
// pts/outbox and immutable command receipt share MessageStore's transaction.
func (s *StarGiftUpgradeStore) GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
req.Message = strings.TrimSpace(req.Message)
if s == nil || s.db == nil || s.messages == nil || req.SenderID != domain.OfficialSystemUserID ||
req.Recipient.Type != domain.PeerTypeUser || req.Recipient.ID <= 0 || req.GiftID <= 0 || !req.Upgrade ||
req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 ||
req.ModelAttributeID < 0 || req.PatternAttributeID < 0 || req.BackdropAttributeID < 0 {
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
}
fingerprint := adminStarGiftGrantFingerprint(req)
if replay, found, err := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Assigned: true,
Saved: true,
},
}}
messageReq := domain.SendPrivateTextRequest{
SenderUserID: req.SenderID,
RecipientUserID: req.Recipient.ID,
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
Date: req.Date,
OriginUserID: req.SenderID,
RecipientBlocked: req.RecipientBlocked,
IdempotencyFingerprint: fingerprint[:],
Media: placeholder,
}
var result domain.AdminStarGiftGrantResult
hooks := privateSendTxHooks{
afterAllocate: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest, senderBoxID, recipientBoxID int) error {
ownerMessageID := recipientBoxID
if req.SenderID == req.Recipient.ID {
ownerMessageID = senderBoxID
}
if ownerMessageID <= 0 {
return fmt.Errorf("admin collectible grant missing owner message id")
}
var revisionID int64
var enabled bool
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled
FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).Scan(&revisionID, &enabled); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock admin collectible catalog gift: %w", err)
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
if err != nil {
return err
}
if !found || !enabled || gift.ID != req.GiftID {
return domain.ErrStarGiftNotFound
}
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
if err != nil {
return err
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
}
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
if err != nil {
return err
}
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
if err != nil {
return err
}
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
if err != nil {
return err
}
var craftable bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM star_gift_collectible_models
WHERE collectible_revision_id=$1 AND crafted
)`, revision.ID).Scan(&craftable); err != nil {
return fmt.Errorf("load admin collectible craft capability: %w", err)
}
craftChancePermille, canCraftAt := 0, 0
if craftable {
craftChancePermille = s.lifecycle.CraftChancePermille
if craftChancePermille > 0 {
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
}
}
saved := domain.SavedStarGift{
Owner: req.Recipient,
FromUserID: req.SenderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: ownerMessageID,
Date: req.Date,
NameHidden: req.HideName,
LifecycleStatus: domain.StarGiftLifecycleActive,
Message: req.Message,
TransferStars: s.lifecycle.TransferStars,
CanExportAt: starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
CanTransferAt: starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds),
CanResellAt: starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
DropOriginalDetailsStars: s.lifecycle.DropOriginalDetailsStars,
CanCraftAt: canCraftAt,
UpgradeMsgID: ownerMessageID,
}
savedID, err := NewStarGiftStore(tx).Create(ctx, saved)
if err != nil {
return err
}
saved.ID = savedID
num := revision.Issued + 1
var uniqueID int64
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
return fmt.Errorf("allocate admin unique star gift id: %w", err)
}
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
if _, err := tx.Exec(ctx, `
INSERT INTO unique_star_gifts
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
craft_chance_permille, offer_min_stars)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,true,$13,$14,$15,$16)`,
uniqueID, gift.ID, revision.ID, savedID, gift.Title, slug, num,
string(req.Recipient.Type), req.Recipient.ID, modelID, patternID, backdropID,
string(req.Recipient.Type), req.Recipient.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
return fmt.Errorf("insert admin unique star gift: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
return fmt.Errorf("increment admin collectible issuance: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE peer_star_gifts
SET unique_gift_id=$2,upgrade_msg_id=$3,convert_stars=0,prepaid_upgrade_stars=0,prepaid_upgrade_hash='',
transfer_stars=$4,can_export_at=$5,can_transfer_at=$6,can_resell_at=$7,
drop_original_details_stars=$8,can_craft_at=$9
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`,
savedID, uniqueID, ownerMessageID, s.lifecycle.TransferStars, saved.CanExportAt,
saved.CanTransferAt, saved.CanResellAt, s.lifecycle.DropOriginalDetailsStars, canCraftAt)
if err != nil {
return fmt.Errorf("link admin unique star gift: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("link admin unique star gift lost aggregate row")
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_admin_grant_commands
(recipient_user_id,command_key,request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,to_timestamp($8))`,
req.Recipient.ID, req.CommandKey, fingerprint[:], req.SenderID, gift.ID, savedID, uniqueID, req.Date); err != nil {
return fmt.Errorf("insert admin collectible grant command: %w", err)
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("new admin unique star gift %d disappeared", uniqueID)
}
saved.UniqueGiftID = uniqueID
saved.Unique = &unique
if err := registerUserStarGiftMessageRef(ctx, tx, req.Recipient.ID, ownerMessageID, savedID, uniqueID); err != nil {
return err
}
result.Saved, result.Unique = saved, unique
send.Media = adminStarGiftUniqueMedia(saved, unique)
return nil
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.AdminStarGiftGrantResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent)
if replayErr != nil {
return domain.AdminStarGiftGrantResult{}, replayErr
}
if !found {
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
}
return replay, nil
}
return result, nil
}
func adminStarGiftUniqueMedia(saved domain.SavedStarGift, unique domain.UniqueStarGift) *domain.MessageMedia {
fromUserID := saved.FromUserID
if saved.NameHidden {
fromUserID = 0
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: fromUserID, Assigned: true, Saved: true,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
},
}}
}
func adminStarGiftGrantFingerprint(req domain.AdminStarGiftGrant) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf(
"telesrv:admin-star-gift-grant:v1:%d:%s:%d:%d:%t:%q:%d:%d:%d",
req.SenderID, req.Recipient.Type, req.Recipient.ID, req.GiftID, req.HideName, req.Message,
req.ModelAttributeID, req.PatternAttributeID, req.BackdropAttributeID,
)))
}
func (s *StarGiftUpgradeStore) loadAdminStarGiftGrantReplay(
ctx context.Context,
req domain.AdminStarGiftGrant,
fingerprint [32]byte,
sent domain.SendPrivateTextResult,
) (domain.AdminStarGiftGrantResult, bool, error) {
var storedFingerprint []byte
var senderID, giftID, savedID, uniqueID int64
err := s.db.QueryRow(ctx, `
SELECT request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id
FROM star_gift_admin_grant_commands
WHERE recipient_user_id=$1 AND command_key=$2`, req.Recipient.ID, req.CommandKey).Scan(
&storedFingerprint, &senderID, &giftID, &savedID, &uniqueID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AdminStarGiftGrantResult{}, false, nil
}
if err != nil {
return domain.AdminStarGiftGrantResult{}, false, err
}
if senderID != req.SenderID || giftID != req.GiftID || !bytes.Equal(storedFingerprint, fingerprint[:]) {
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
if err == nil {
err = domain.ErrStarGiftCollectibleInvalid
}
return domain.AdminStarGiftGrantResult{}, false, err
}
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found {
if err == nil {
err = domain.ErrStarGiftCollectibleInvalid
}
return domain.AdminStarGiftGrantResult{}, false, err
}
if saved.Owner != req.Recipient || saved.FromUserID != req.SenderID || saved.GiftID != req.GiftID ||
saved.UniqueGiftID != uniqueID || saved.MsgID <= 0 || saved.UpgradeMsgID != saved.MsgID ||
unique.SourceSavedGiftID != savedID || unique.Owner != req.Recipient {
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
}
if sent.SenderMessage.ID == 0 {
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.SenderID, RecipientUserID: req.Recipient.ID,
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
IdempotencyFingerprint: fingerprint[:],
})
if replayErr != nil {
return domain.AdminStarGiftGrantResult{}, false, replayErr
}
if !replayFound {
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
}
sent = replay
}
uniqueCopy := unique
saved.Unique = &uniqueCopy
return domain.AdminStarGiftGrantResult{
Saved: saved, Unique: unique, Send: sent, Duplicate: true,
}, true, nil
}
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||

View file

@ -338,6 +338,9 @@ func (s *UserStore) SetSupport(ctx context.Context, userID int64, support bool)
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径)。
func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
ID: userID,
Scam: scam,

View file

@ -67,6 +67,7 @@ type StarGiftStore interface {
type StarGiftUpgradeStore interface {
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
}
// StarGiftLifecycleStore owns transactions that span collectible ownership, listings,