fix(stargifts): sync correct channel gift notifications

This commit is contained in:
iamxvbaba 2026-07-27 22:18:31 +08:00
parent 9ac63f8006
commit 5433801380
19 changed files with 893 additions and 26 deletions

View file

@ -735,6 +735,61 @@ WHERE `+where, args...)
return g, true, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.db == nil || viewerUserID <= 0 || msgID <= 0 {
return domain.SavedStarGiftRef{}, false, nil
}
var ownerType string
var ownerID, savedID int64
err := s.db.QueryRow(ctx, `
SELECT gift.owner_peer_type,gift.owner_peer_id,gift.saved_id
FROM star_gift_user_message_refs ref
JOIN peer_star_gifts gift ON gift.id=ref.saved_gift_id
JOIN message_boxes box
ON box.owner_user_id=ref.owner_user_id AND box.box_id=ref.msg_id
WHERE ref.owner_user_id=$1 AND ref.msg_id=$2
AND NOT box.deleted
AND gift.lifecycle_status='active'
AND (
(gift.owner_peer_type='user' AND gift.owner_peer_id=$1)
OR (
gift.owner_peer_type='channel'
AND (
(
box.media #>> '{service_action,kind}'='star_gift'
AND box.media #>> '{service_action,star_gift,peer_channel_id}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift,saved_id}'=gift.saved_id::text
)
OR (
box.media #>> '{service_action,kind}'='star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}'='channel'
AND box.media #>> '{service_action,star_gift_unique,peer,ID}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift_unique,saved_id}'=gift.saved_id::text
)
)
)
)`,
viewerUserID, msgID).Scan(&ownerType, &ownerID, &savedID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGiftRef{}, false, nil
}
if err != nil {
return domain.SavedStarGiftRef{}, false, fmt.Errorf("resolve star gift user message ref: %w", err)
}
owner := domain.Peer{Type: domain.PeerType(ownerType), ID: ownerID}
switch owner.Type {
case domain.PeerTypeUser:
return domain.SavedStarGiftRef{Owner: owner, MsgID: msgID}, true, nil
case domain.PeerTypeChannel:
if savedID <= 0 {
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
return domain.SavedStarGiftRef{Owner: owner, SavedID: savedID}, true, nil
default:
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
}
func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil

View file

@ -0,0 +1,234 @@
package postgres
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
const (
maxChannelStarGiftNotificationRecipients = 256
channelStarGiftNotificationLeaseSeconds = 60
)
type channelStarGiftNotificationJob struct {
SavedGiftID int64
TargetUserID int64
GiftDate int
Action domain.MessageStarGiftAction
Attempts int
}
func enqueueChannelStarGiftNotifications(
ctx context.Context,
tx pgx.Tx,
savedGiftID int64,
channelID int64,
giftDate int,
action *domain.MessageStarGiftAction,
) error {
if savedGiftID <= 0 || channelID <= 0 || giftDate <= 0 || action == nil ||
action.PeerChannelID != channelID || action.SavedID <= 0 {
return fmt.Errorf("enqueue channel star gift notifications: invalid intent")
}
actionJSON, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("encode channel star gift notification: %w", err)
}
_, err = tx.Exec(ctx, `
WITH candidates AS (
SELECT creator_user_id AS user_id
FROM channels
WHERE id=$2 AND NOT deleted
UNION
SELECT user_id
FROM channel_members
WHERE channel_id=$2 AND status='active'
AND (role='creator' OR (
role='admin'
AND COALESCE((admin_rights->>'PostMessages')::boolean,false)
))
), bounded AS (
SELECT user_id FROM candidates
WHERE user_id>0
ORDER BY user_id
LIMIT $5
)
INSERT INTO star_gift_channel_notification_jobs
(saved_gift_id,target_user_id,gift_date,action,next_attempt_at)
SELECT $1,bounded.user_id,$3,$4::jsonb,$3
FROM bounded
LEFT JOIN star_gift_notification_settings settings
ON settings.user_id=bounded.user_id AND settings.channel_id=$2
WHERE COALESCE(settings.enabled,TRUE)
ON CONFLICT(saved_gift_id,target_user_id) DO NOTHING`,
savedGiftID, channelID, giftDate, string(actionJSON), maxChannelStarGiftNotificationRecipients)
if err != nil {
return fmt.Errorf("enqueue channel star gift notifications: %w", err)
}
return nil
}
func (s *StarGiftLifecycleStore) dispatchChannelStarGiftNotifications(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) (int, error) {
if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 {
return 0, domain.ErrStarGiftUnavailable
}
if limit > maxChannelStarGiftNotificationRecipients {
limit = maxChannelStarGiftNotificationRecipients
}
jobs, err := s.claimChannelStarGiftNotificationJobs(ctx, now, limit, savedGiftID)
if err != nil {
return 0, err
}
var firstErr error
delivered := 0
for _, job := range jobs {
messageID, sendErr := s.deliverChannelStarGiftNotification(ctx, job)
if sendErr == nil {
tag, markErr := s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET delivered_at=$3,message_id=$4,lease_until=0,last_error='',updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, now, messageID)
if markErr == nil && tag.RowsAffected() == 1 {
delivered++
continue
}
if markErr == nil {
markErr = fmt.Errorf("channel star gift notification job disappeared")
}
sendErr = markErr
}
if firstErr == nil {
firstErr = sendErr
}
retryAt := now + channelStarGiftNotificationRetrySeconds(job.Attempts)
_, _ = s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET next_attempt_at=$3,lease_until=0,last_error=$4,updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, retryAt, truncateStarGiftNotificationError(sendErr))
}
return delivered, firstErr
}
func (s *StarGiftLifecycleStore) claimChannelStarGiftNotificationJobs(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) ([]channelStarGiftNotificationJob, error) {
jobs := make([]channelStarGiftNotificationJob, 0, limit)
err := withTx(ctx, s.db, "claim channel star gift notifications", func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
WITH picked AS (
SELECT saved_gift_id,target_user_id
FROM star_gift_channel_notification_jobs
WHERE delivered_at=0 AND next_attempt_at<=$1 AND lease_until<$1
AND ($3::bigint=0 OR saved_gift_id=$3)
ORDER BY next_attempt_at,saved_gift_id,target_user_id
FOR UPDATE SKIP LOCKED
LIMIT $2
)
UPDATE star_gift_channel_notification_jobs job
SET attempts=job.attempts+1,
lease_until=$1+$4,
updated_at=now()
FROM picked
WHERE job.saved_gift_id=picked.saved_gift_id
AND job.target_user_id=picked.target_user_id
RETURNING job.saved_gift_id,job.target_user_id,job.gift_date,job.action,job.attempts`,
now, limit, savedGiftID, channelStarGiftNotificationLeaseSeconds)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var job channelStarGiftNotificationJob
var actionJSON []byte
if err := rows.Scan(&job.SavedGiftID, &job.TargetUserID, &job.GiftDate, &actionJSON, &job.Attempts); err != nil {
return err
}
if err := json.Unmarshal(actionJSON, &job.Action); err != nil {
return fmt.Errorf("decode channel star gift notification: %w", err)
}
if job.SavedGiftID <= 0 || job.TargetUserID <= 0 || job.GiftDate <= 0 ||
job.Action.PeerChannelID <= 0 || job.Action.SavedID <= 0 {
return fmt.Errorf("decode channel star gift notification: invalid intent")
}
jobs = append(jobs, job)
}
return rows.Err()
})
return jobs, err
}
func (s *StarGiftLifecycleStore) deliverChannelStarGiftNotification(
ctx context.Context,
job channelStarGiftNotificationJob,
) (int, error) {
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
"telesrv:channel-star-gift-notification:v1:%d:%d",
job.SavedGiftID, job.TargetUserID,
)))
action := job.Action
request := domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: job.TargetUserID,
RandomID: lifecycleCommandRandomID("channel-star-gift-notification", job.SavedGiftID, job.TargetUserID),
Date: job.GiftDate,
IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &action,
}},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, request, privateSendTxHooks{
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if sent.RecipientMessage.ID <= 0 {
return fmt.Errorf("channel star gift notification missing recipient box")
}
return registerChannelNotificationMessageRef(ctx, tx, job.TargetUserID,
sent.RecipientMessage.ID, job.SavedGiftID)
},
})
if err != nil {
return 0, err
}
if sent.RecipientMessage.ID <= 0 {
return 0, fmt.Errorf("channel star gift notification replay missing recipient box")
}
return sent.RecipientMessage.ID, nil
}
func channelStarGiftNotificationRetrySeconds(attempt int) int {
if attempt < 1 {
attempt = 1
}
delay := attempt * attempt * 5
if delay > 3600 {
return 3600
}
return delay
}
func truncateStarGiftNotificationError(err error) string {
if err == nil {
return ""
}
value := strings.TrimSpace(err.Error())
runes := []rune(value)
if len(runes) > 1000 {
value = string(runes[:1000])
}
return value
}

View file

@ -181,7 +181,8 @@ WHERE saved_gift_id IS NULL ORDER BY gift_id LIMIT $1`, minAuctionInt(remaining,
}
}
}
return nil
_, err := s.dispatchChannelStarGiftNotifications(ctx, now, minAuctionInt(limit, 100), 0)
return err
}
func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {

View file

@ -147,6 +147,14 @@ VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.F
}
return registerUserStarGiftMessageRef(ctx, tx, req.Owner.ID, ownerMessageID, result.Saved.ID, 0)
}
notificationMessageID := sent.RecipientMessage.ID
if notificationMessageID <= 0 {
return fmt.Errorf("prepaid channel gift notification missing recipient box")
}
if err := registerViewerStarGiftMessageRef(ctx, tx, req.PayerUserID, notificationMessageID,
result.Saved.ID, req.Owner, 0); err != nil {
return err
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})

View file

@ -598,6 +598,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
} else {
notificationMessageID := sent.RecipientMessage.ID
if notificationMessageID <= 0 {
return fmt.Errorf("channel resale notification missing buyer box")
}
if err := registerViewerStarGiftMessageRef(ctx, tx, req.BuyerUserID, notificationMessageID,
result.Saved.ID, req.To, result.Unique.ID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key)
@ -1399,6 +1408,18 @@ ON CONFLICT(user_id,channel_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_a
return err
}
func (s *StarGiftLifecycleStore) StarGiftNotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
if s == nil || s.db == nil || userID <= 0 || channelID <= 0 {
return false, domain.ErrStarGiftOwnerInvalid
}
var enabled bool
err := s.db.QueryRow(ctx, `SELECT COALESCE((
SELECT enabled FROM star_gift_notification_settings
WHERE user_id=$1 AND channel_id=$2
),TRUE)`, userID, channelID).Scan(&enabled)
return enabled, err
}
func (s *StarGiftLifecycleStore) RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) {
if req.UserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || expiresAt <= req.Date || strings.TrimSpace(provider) == "" || strings.TrimSpace(providerRequestID) == "" || strings.TrimSpace(url) == "" {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable

View file

@ -811,17 +811,37 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
now := int(time.Now().Unix())
users := NewUserStore(pool)
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
notifyAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ChannelGiftNotifyAdmin", "")
mutedAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"03", "ChannelGiftMutedAdmin", "")
noPostAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"04", "ChannelGiftNoPostAdmin", "")
ordinaryMember := createTestUser(t, ctx, users, "+1882"+suffix+"05", "ChannelGiftMember", "")
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
t.Fatalf("grant actor stars: %v", err)
}
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
channelStore := NewChannelStore(pool)
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
MemberUserIDs: []int64{notifyAdmin.ID, mutedAdmin.ID, noPostAdmin.ID, ordinaryMember.ID},
})
if err != nil {
t.Fatalf("create gift channel: %v", err)
}
for _, admin := range []domain.User{notifyAdmin, mutedAdmin} {
if _, err := channelStore.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: actor.ID, ChannelID: created.Channel.ID, MemberID: admin.ID,
AdminRights: domain.ChannelAdminRights{PostMessages: true}, Date: now,
}); err != nil {
t.Fatalf("grant channel gift PostMessages admin %d: %v", admin.ID, err)
}
}
if _, err := channelStore.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: actor.ID, ChannelID: created.Channel.ID, MemberID: noPostAdmin.ID,
AdminRights: domain.ChannelAdminRights{ChangeInfo: true}, Date: now,
}); err != nil {
t.Fatalf("grant non-posting channel admin: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
createdTarget, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
@ -867,9 +887,16 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
if err := lifecycle.SetStarGiftNotifications(ctx, mutedAdmin.ID, created.Channel.ID, false); err != nil {
t.Fatalf("disable muted admin channel gift notifications: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
var channelPtsBeforePurchase int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&channelPtsBeforePurchase); err != nil {
t.Fatalf("load channel pts before gift purchase: %v", err)
}
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
@ -881,6 +908,107 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err)
}
var notificationRecipients []int64
rows, err := pool.Query(ctx, `SELECT target_user_id FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 ORDER BY target_user_id`, purchased.Saved.ID)
if err != nil {
t.Fatalf("list channel gift notification recipients: %v", err)
}
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
t.Fatalf("scan channel gift notification recipient: %v", err)
}
notificationRecipients = append(notificationRecipients, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("iterate channel gift notification recipients: %v", err)
}
rows.Close()
wantRecipients := []int64{actor.ID, notifyAdmin.ID}
if fmt.Sprint(notificationRecipients) != fmt.Sprint(wantRecipients) {
t.Fatalf("channel gift notification recipients=%v want=%v (muted/no-post/member excluded)",
notificationRecipients, wantRecipients)
}
var notificationMessageID, notificationDeliveredAt, notificationAttempts int
if err := pool.QueryRow(ctx, `SELECT message_id,delivered_at,attempts
FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID).
Scan(&notificationMessageID, &notificationDeliveredAt, &notificationAttempts); err != nil ||
notificationMessageID <= 0 || notificationDeliveredAt <= 0 || notificationAttempts != 1 {
t.Fatalf("channel gift notification job message=%d delivered=%d attempts=%d err=%v",
notificationMessageID, notificationDeliveredAt, notificationAttempts, err)
}
notificationRef, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, notificationMessageID)
if err != nil || !found || notificationRef.Owner != channelPeer ||
notificationRef.SavedID != purchased.Saved.SavedID {
t.Fatalf("channel gift notification alias = %+v found=%v err=%v", notificationRef, found, err)
}
var notificationPts, notificationEvents, notificationOutbox, channelPtsAfterPurchase int
var notificationMediaJSON string
if err := pool.QueryRow(ctx, `SELECT pts,media::text FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2`, actor.ID, notificationMessageID).
Scan(&notificationPts, &notificationMediaJSON); err != nil {
t.Fatalf("load channel gift notification message: %v", err)
}
notificationMedia, err := decodeMessageMedia(notificationMediaJSON)
if err != nil || notificationMedia == nil || notificationMedia.ServiceAction == nil ||
notificationMedia.ServiceAction.StarGift == nil ||
notificationMedia.ServiceAction.StarGift.PeerChannelID != created.Channel.ID ||
notificationMedia.ServiceAction.StarGift.SavedID != purchased.Saved.SavedID {
t.Fatalf("channel gift notification media = %+v err=%v", notificationMedia, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='new_message'`, actor.ID, notificationPts).Scan(&notificationEvents); err != nil ||
notificationEvents != 1 {
t.Fatalf("channel gift notification events=%d err=%v", notificationEvents, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2`, actor.ID, notificationPts).Scan(&notificationOutbox); err != nil ||
notificationOutbox != 1 {
t.Fatalf("channel gift notification outbox=%d err=%v", notificationOutbox, err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&channelPtsAfterPurchase); err != nil ||
channelPtsAfterPurchase != channelPtsBeforePurchase {
t.Fatalf("channel gift notification changed channel pts: before=%d after=%d err=%v",
channelPtsBeforePurchase, channelPtsAfterPurchase, err)
}
// Simulate a process stopping after the private message committed but before
// the job completion update. The next claim must replay the same message and
// must not allocate a second account PTS/event/outbox row.
if _, err := pool.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET delivered_at=0,message_id=0,next_attempt_at=$3,lease_until=0
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID, now+1); err != nil {
t.Fatalf("reset notification job for replay probe: %v", err)
}
if delivered, err := lifecycle.dispatchChannelStarGiftNotifications(ctx, now+2, 1, purchased.Saved.ID); err != nil || delivered != 1 {
t.Fatalf("replay channel gift notification delivered=%d err=%v", delivered, err)
}
var replayMessageID, replayEventCount int
if err := pool.QueryRow(ctx, `SELECT message_id FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID).Scan(&replayMessageID); err != nil ||
replayMessageID != notificationMessageID {
t.Fatalf("channel gift notification replay message=%d want=%d err=%v", replayMessageID, notificationMessageID, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='new_message'`, actor.ID, notificationPts).Scan(&replayEventCount); err != nil ||
replayEventCount != 1 {
t.Fatalf("channel gift notification replay events=%d err=%v", replayEventCount, err)
}
if enabled, err := lifecycle.StarGiftNotificationsEnabled(ctx, actor.ID, created.Channel.ID); err != nil || !enabled {
t.Fatalf("default channel gift notification setting enabled=%v err=%v", enabled, err)
}
if err := lifecycle.SetStarGiftNotifications(ctx, actor.ID, created.Channel.ID, false); err != nil {
t.Fatalf("disable channel gift notifications: %v", err)
}
if enabled, err := lifecycle.StarGiftNotificationsEnabled(ctx, actor.ID, created.Channel.ID); err != nil || enabled {
t.Fatalf("disabled channel gift notification setting enabled=%v err=%v", enabled, err)
}
if err := lifecycle.SetStarGiftNotifications(ctx, actor.ID, created.Channel.ID, true); err != nil {
t.Fatalf("re-enable channel gift notifications: %v", err)
}
var channelPrice string
var channelPrepaidAmount any
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
@ -949,6 +1077,11 @@ WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
}
prepayAlias, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, channelPrepay.Send.RecipientMessage.ID)
if err != nil || !found || prepayAlias.Owner != channelPeer ||
prepayAlias.SavedID != channelPrepay.Saved.SavedID {
t.Fatalf("channel prepaid notification alias = %+v found=%v err=%v", prepayAlias, found, err)
}
var prepayLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
@ -983,12 +1116,17 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
}
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
if action == nil || action.FromUserID != actor.ID || action.Peer != channelPeer ||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 ||
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
t.Fatalf("channel upgrade service action = %+v", action)
}
upgradeAlias, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, upgraded.Send.RecipientMessage.ID)
if err != nil || !found || upgradeAlias.Owner != channelPeer ||
upgradeAlias.SavedID != upgraded.Saved.SavedID {
t.Fatalf("channel upgrade notification alias = %+v found=%v err=%v", upgradeAlias, found, err)
}
var ptsAfterUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err)

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 149 {
t.Fatalf("migration status = %+v, want clean version 149", status)
if status.Dirty || status.Empty || status.Version != 150 {
t.Fatalf("migration status = %+v, want clean version 150", status)
}
}

View file

@ -188,6 +188,9 @@ func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context,
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
return err
}
if err := enqueueChannelStarGiftNotifications(ctx, tx, id, req.To.ID, req.Date, action.StarGift); err != nil {
return err
}
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
return err
}
@ -202,6 +205,9 @@ func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context,
}
return domain.StarGiftPurchaseResult{}, err
}
// The purchase remains successful once its transaction has committed. Any
// immediate delivery failure leaves a durable job for the lifecycle sweeper.
_, _ = s.dispatchChannelStarGiftNotifications(ctx, req.Date, maxChannelStarGiftNotificationRecipients, result.Saved.ID)
return result, nil
}

View file

@ -518,7 +518,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
locked.CanCraftAt = canCraftAt
locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
action := starGiftUpgradeUniqueAction(locked, unique, req)
messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -530,7 +530,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
ownerMessageID := sent.RecipientMessage.ID
if saved.FromUserID == req.UserID {
if result.Saved.Owner.Type == domain.PeerTypeUser && saved.FromUserID == req.UserID {
ownerMessageID = sent.SenderMessage.ID
}
if ownerMessageID <= 0 {
@ -548,6 +548,9 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
result.Saved.ID, result.Unique.ID); err != nil {
return err
}
} else if err := registerViewerStarGiftMessageRef(ctx, tx, req.UserID, ownerMessageID,
result.Saved.ID, result.Saved.Owner, result.Unique.ID); err != nil {
return err
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
@ -576,7 +579,7 @@ WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
return fmt.Errorf("save star gift source edit pts lost command row")
}
} else {
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID)
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req)
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
@ -599,15 +602,16 @@ WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
return result, nil
}
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction {
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest) *domain.MessageStarGiftUniqueAction {
fromUserID := saved.FromUserID
if saved.NameHidden {
fromUserID = 0
}
if saved.Owner.Type == domain.PeerTypeChannel {
// TDesktop recognizes a channel-owned upgrade from the official service
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
// The private envelope is sent by 777000, while action.from_id identifies
// the administrator who performed the upgrade. TDesktop uses that
// distinction to render "upgraded" instead of an unknown transfer.
fromUserID = req.UserID
}
peer := saved.Owner
savedID := saved.SavedID

View file

@ -5,6 +5,8 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
@ -22,24 +24,71 @@ func registerUserStarGiftMessageRef(
savedGiftID int64,
uniqueGiftID int64,
) error {
if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID < 0 {
return fmt.Errorf("register user star gift message ref: invalid identity")
return registerViewerStarGiftMessageRef(ctx, tx, ownerUserID, msgID, savedGiftID,
domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}, uniqueGiftID)
}
// registerViewerStarGiftMessageRef binds one viewer-local private message to
// the aggregate owner explicitly named by the action. The alias does not grant
// ownership: RPC callers resolve the real owner and authorize it again.
func registerViewerStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
expectedOwner domain.Peer,
uniqueGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || !validLifecyclePeer(expectedOwner) || uniqueGiftID < 0 {
return fmt.Errorf("register star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,p.id
FROM peer_star_gifts p
WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1
AND (($4::bigint=0 AND p.unique_gift_id IS NULL) OR ($4::bigint>0 AND p.unique_gift_id=$4::bigint))
WHERE p.id=$3 AND p.owner_peer_type=$4 AND p.owner_peer_id=$5
AND (($6::bigint=0 AND p.unique_gift_id IS NULL) OR ($6::bigint>0 AND p.unique_gift_id=$6::bigint))
AND p.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`, ownerUserID, msgID, savedGiftID, uniqueGiftID)
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID, string(expectedOwner.Type), expectedOwner.ID, uniqueGiftID)
if err != nil {
return fmt.Errorf("register user star gift message ref: %w", err)
return fmt.Errorf("register star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register user star gift message ref: identity collision")
return fmt.Errorf("register star gift message ref: identity collision")
}
return nil
}
func registerChannelNotificationMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 {
return fmt.Errorf("register channel notification star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,gift.id
FROM star_gift_channel_notification_jobs job
JOIN peer_star_gifts gift ON gift.id=job.saved_gift_id
WHERE job.saved_gift_id=$3 AND job.target_user_id=$1
AND gift.owner_peer_type='channel' AND gift.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID)
if err != nil {
return fmt.Errorf("register channel notification star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register channel notification star gift message ref: identity collision")
}
return nil
}