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:
parent
90792cdfab
commit
234061ef83
30 changed files with 859 additions and 93 deletions
File diff suppressed because one or more lines are too long
58
cmd/telesrv-admin/web/dist/index.html
vendored
58
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
1
deploy/migrations/0138_star_gift_admin_grants.down.sql
Normal file
1
deploy/migrations/0138_star_gift_admin_grants.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;
|
||||
22
deploy/migrations/0138_star_gift_admin_grants.up.sql
Normal file
22
deploy/migrations/0138_star_gift_admin_grants.up.sql
Normal 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
|
||||
)
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 账号不可被授予会员(官方语义)。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
40
internal/store/memory/moderation_flags_test.go
Normal file
40
internal/store/memory/moderation_flags_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
50
internal/store/postgres/moderation_flags_integration_test.go
Normal file
50
internal/store/postgres/moderation_flags_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue