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

@ -21,7 +21,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script> <script type="module" crossorigin src="/assets/index-CsfWywUl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css"> <link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
</head> </head>
<body> <body>

View file

@ -135,7 +135,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="form-field"> <label className="form-field">
<span>{t("giveGift.message")}</span> <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>
<label className="gift-switch"> <label className="gift-switch">

View file

@ -1,7 +1,9 @@
ALTER TABLE public.channels ALTER TABLE public.channels
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam, DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake; DROP COLUMN IF EXISTS fake;
ALTER TABLE public.users ALTER TABLE public.users
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam, DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake; 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 scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake 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 ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake 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"` Message string `json:"message"`
Details map[string]any `json:"details,omitempty"` Details map[string]any `json:"details,omitempty"`
Error string `json:"error,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 { type ImportStarGiftRequest struct {
@ -343,14 +347,14 @@ type SetStarGiftSortOrderRequest struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
// GiveGiftRequest grants a catalog gift to a recipient (user or channel) from a // GiveGiftRequest grants a catalog gift to a recipient (user or channel) from
// sender account (defaults to the official system account 777000) at no charge. // the official system account 777000 at no charge.
// Exactly one of UserID / ChannelID identifies the recipient. // Exactly one of UserID / ChannelID identifies the recipient.
type GiveGiftRequest struct { type GiveGiftRequest struct {
CommandMeta CommandMeta
SenderUserID int64 `json:"sender_user_id"` SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"` ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id"` GiftID int64 `json:"gift_id"`
HideName bool `json:"hide_name"` HideName bool `json:"hide_name"`
Message string `json:"message"` Message string `json:"message"`
@ -870,10 +874,8 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
if s == nil || s.users == nil { if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured") return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
} }
// scam and fake are mutually exclusive (a peer is never both in Telegram). if req.Scam && req.Fake {
// scam takes precedence so the two never persist together. return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
if req.Scam {
req.Fake = false
} }
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID) u, found, err := s.users.AdminUser(ctx, req.UserID)
@ -945,9 +947,9 @@ func collectibleAttrPresent(attrs []domain.StarGiftCollectibleAttribute, id int6
return false return false
} }
// GiveGift grants a catalog gift to a recipient (user or channel) from a sender // GiveGift grants a catalog gift to a recipient (user or channel) from the
// account (defaults to the official system account 777000) without charging any // official system account 777000 without charging any Stars. Delivery reuses
// Stars. Delivery reuses the standard gift path via the GiftGranter dependency. // the standard gift path via the GiftGranter dependency.
func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandResult, error) { func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandResult, error) {
if req.GiftID <= 0 { if req.GiftID <= 0 {
return CommandResult{}, fmt.Errorf("gift_id is required") 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 { if sender <= 0 {
sender = domain.OfficialSystemUserID 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 var recipient domain.Peer
if req.ChannelID > 0 { if req.ChannelID > 0 {
recipient = domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} 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, "hide_name": req.HideName,
"upgrade": req.Upgrade, "upgrade": req.Upgrade,
} }
if strings.TrimSpace(req.Message) != "" { if req.Message != "" {
details["message"] = strings.TrimSpace(req.Message) details["message"] = req.Message
} }
if s.gifts != nil { if s.gifts != nil {
gift, found, err := s.gifts.GiftByID(ctx, req.GiftID) 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, Recipient: recipient,
GiftID: req.GiftID, GiftID: req.GiftID,
HideName: req.HideName, HideName: req.HideName,
Message: strings.TrimSpace(req.Message), Message: req.Message,
Upgrade: req.Upgrade, Upgrade: req.Upgrade,
CommandKey: "admin-gift:" + req.CommandID,
ModelAttributeID: req.ModelAttributeID, ModelAttributeID: req.ModelAttributeID,
PatternAttributeID: req.PatternAttributeID, PatternAttributeID: req.PatternAttributeID,
BackdropAttributeID: req.BackdropAttributeID, BackdropAttributeID: req.BackdropAttributeID,
@ -1173,14 +1183,14 @@ func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandR
return CommandResult{Details: details}, err return CommandResult{Details: details}, err
} }
details["bot_user_id"] = bot.ID 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 { if err := s.notifyUserChanged(ctx, bot); err != nil {
details["notify_error"] = err.Error() 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 { if s == nil || s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured") 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} target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelFlags, 0, target, req, func() (CommandResult, error) { return s.runCommand(ctx, req.CommandMeta, ActionSetChannelFlags, 0, target, req, func() (CommandResult, error) {
ch, err := s.channels.GetChannelByID(ctx, req.ChannelID) 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 { if marshalErr != nil {
return result, fmt.Errorf("marshal admin result: %w", marshalErr) 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 := "" errorText := ""
if opErr != nil { if opErr != nil {
errorText = opErr.Error() errorText = opErr.Error()
} }
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil { 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 { 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) { func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
now := fixedNow() now := fixedNow()
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{ 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 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 { type fakeRestrictionStore struct {
items map[int64]domain.AccountFreeze items map[int64]domain.AccountFreeze
setCalls int setCalls int

View file

@ -2,6 +2,7 @@ package bots
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "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) { func TestBotWriteAccessGrant(t *testing.T) {
svc, users, _, _ := newTestService(t) svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2012") owner := newOwner(t, users, "+2012")
@ -400,11 +433,12 @@ type captureRevoker struct {
botUserID int64 botUserID int64
pushedCommandsTo int64 pushedCommandsTo int64
pushedCommands []domain.BotCommand pushedCommands []domain.BotCommand
err error
} }
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error { func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
c.botUserID = botUserID c.botUserID = botUserID
return nil return c.err
} }
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) { 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 { if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store") 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 // Session revocation is part of the deletion invariant: a deleted bot must
// races the tombstone; DeleteBotAccount also revokes the authorization rows. // never retain an authenticated connection. Fail closed before tombstoning
if s.hooks != nil { // when the hook is unavailable or revocation fails.
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil { if s.hooks == nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err)) 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) u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil { if err != nil {

View file

@ -181,6 +181,7 @@ type countingBotStore struct {
*memory.BotStore *memory.BotStore
getBotCalls int getBotCalls int
getBotsCalls int getBotsCalls int
deleteCalls int
} }
func (s *countingBotStore) reset() { func (s *countingBotStore) reset() {
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
return s.BotStore.GetBots(ctx, botUserIDs) 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) { func TestBotFatherCancelAndUnknown(t *testing.T) {
svc, users, _, messages := newTestService(t) svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1001") 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 { if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid return domain.Channel{}, domain.ErrChannelInvalid
} }
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake) 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) 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) { func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil { if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable 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 { if userID == 0 {
return domain.User{}, ErrNotAuthorized return domain.User{}, ErrNotAuthorized
} }
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
u, found, err := s.users.ByID(ctx, userID) u, found, err := s.users.ByID(ctx, userID)
if err != nil { if err != nil {
return domain.User{}, err return domain.User{}, err

View file

@ -339,7 +339,7 @@ type StarGiftUpgradeRequest struct {
} }
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to // 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 // When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs pin specific model/pattern/backdrop (0 => random). The // attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically. // collectible number is always assigned automatically.
@ -350,11 +350,24 @@ type AdminStarGiftGrant struct {
HideName bool HideName bool
Message string Message string
Upgrade bool Upgrade bool
CommandKey string
Date int
RecipientBlocked bool
ModelAttributeID int64 ModelAttributeID int64
PatternAttributeID int64 PatternAttributeID int64
BackdropAttributeID 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 { type StarGiftPurchaseRequest struct {
BuyerUserID int64 BuyerUserID int64
BuyerPremium bool BuyerPremium bool

View file

@ -12,6 +12,9 @@ var (
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserFrozen = errors.New("user account frozen") ErrUserFrozen = errors.New("user account frozen")
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid") 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 表示该操作仅限有效会员(PREMIUM_ACCOUNT_REQUIRED)。
ErrPremiumRequired = errors.New("premium account required") ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。 // ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。

View file

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

View file

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

View file

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

View file

@ -201,6 +201,9 @@ func (s *ChannelStore) SetChannelScamFake(_ context.Context, channelID int64, sc
if channelID == 0 { if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid return domain.Channel{}, domain.ErrChannelInvalid
} }
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
channel, ok := s.channels[channelID] 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 语义一致)。 // SetScamFake 设置/取消用户的 scam 与 fake 标记(与 postgres 语义一致)。
func (s *UserStore) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) { 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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]

View file

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

View file

@ -119,7 +119,12 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
type privateSendTxHooks struct { type privateSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, 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 // 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) 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{ senderArg := sqlcgen.CreateMessageBoxParams{
OwnerUserID: req.SenderUserID, 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 { func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{ return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS, SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,

View file

@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@ -46,6 +47,296 @@ func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...St
return s 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) { 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() || 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) || (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 复用同一路径)。 // SetScamFake 设置/取消用户的 scam 与 fake 标记(bot 复用同一路径)。
func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) { 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{ row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
ID: userID, ID: userID,
Scam: scam, Scam: scam,

View file

@ -67,6 +67,7 @@ type StarGiftStore interface {
type StarGiftUpgradeStore interface { type StarGiftUpgradeStore interface {
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, 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, // StarGiftLifecycleStore owns transactions that span collectible ownership, listings,