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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue