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

@ -740,6 +740,25 @@ func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64,
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
}
func (s *Service) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
if s == nil {
return false, domain.ErrStarGiftUnavailable
}
if s.lifecycle == nil {
// Isolated memory/RPC adapters have no settings table; production's
// persisted default is enabled, so preserve that wire behavior.
return true, nil
}
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
}
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.store == nil {
return domain.SavedStarGiftRef{}, false, nil
}
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
}
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable

View file

@ -17,7 +17,8 @@ import (
)
// Star giftpayments.* 礼物 RPC目录 / 购买表单 / 发送 / 收礼列表 / 展示切换 / 转换回 Stars。
// 扣费经 r.deps.Stars 账本;用户礼物走私聊服务消息,频道礼物只落 saved gifts + admin log。
// 扣费经原子礼物聚合账本;用户礼物走私聊服务消息,频道礼物落 saved gift/admin log
// 并由持久 notification job 向启用通知的礼物管理员投递私聊 service message。
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
@ -560,6 +561,15 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
if err != nil {
return nil, internalErr()
}
if settings, ok := r.deps.Gifts.(interface {
NotificationsEnabled(context.Context, int64, int64) (bool, error)
}); ok && owner.Type == domain.PeerTypeChannel && r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil {
enabled, settingsErr := settings.NotificationsEnabled(ctx, userID, owner.ID)
if settingsErr != nil {
return nil, internalErr()
}
response.SetChatNotificationsEnabled(enabled)
}
return response, nil
}
@ -753,6 +763,17 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
if v == nil || v.MsgID <= 0 {
return domain.SavedStarGiftRef{}, false, nil
}
if resolver, ok := r.deps.Gifts.(interface {
ResolveUserMessageRef(context.Context, int64, int) (domain.SavedStarGiftRef, bool, error)
}); ok {
resolved, found, err := resolver.ResolveUserMessageRef(ctx, userID, v.MsgID)
if err != nil {
return domain.SavedStarGiftRef{}, false, internalErr()
}
if found {
return resolved, resolved.Valid(), nil
}
}
return domain.SavedStarGiftRef{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MsgID: v.MsgID,

View file

@ -93,6 +93,48 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
return s.unique, slug == s.unique.Slug, nil
}
type starGiftMessageAliasRPCService struct {
GiftsService
viewerUserID int64
msgID int
ref domain.SavedStarGiftRef
}
func (s *starGiftMessageAliasRPCService) ResolveUserMessageRef(_ context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if viewerUserID == s.viewerUserID && msgID == s.msgID {
return s.ref, true, nil
}
return domain.SavedStarGiftRef{}, false, nil
}
func TestStarGiftUserInputResolvesOnlyExplicitChannelNotificationAlias(t *testing.T) {
channelRef := domain.SavedStarGiftRef{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 8801},
SavedID: 91,
}
service := &starGiftMessageAliasRPCService{viewerUserID: 7102, msgID: 44, ref: channelRef}
r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System)
resolved, ok, err := r.starGiftRefFromInput(context.Background(), 7102, &tg.InputSavedStarGiftUser{MsgID: 44})
if err != nil || !ok || resolved != channelRef {
t.Fatalf("channel notification alias = %+v ok=%v err=%v", resolved, ok, err)
}
fallback, ok, err := r.starGiftRefFromInput(context.Background(), 7102, &tg.InputSavedStarGiftUser{MsgID: 45})
wantFallback := domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, MsgID: 45}
if err != nil || !ok || fallback != wantFallback {
t.Fatalf("unknown notification alias = %+v ok=%v err=%v, want user fallback %+v", fallback, ok, err, wantFallback)
}
}
type starGiftNotificationSettingsRPCService struct {
GiftsService
enabled bool
}
func (s *starGiftNotificationSettingsRPCService) NotificationsEnabled(context.Context, int64, int64) (bool, error) {
return s.enabled, nil
}
type craftStarGiftRPCService struct {
GiftsService
uniques map[string]domain.UniqueStarGift
@ -1154,10 +1196,41 @@ func TestStarGiftChannelSaga(t *testing.T) {
if !savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
}
ownerSavedRes, err := r.onPaymentsGetSavedStarGifts(ownerCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer})
if err != nil {
t.Fatalf("getSavedStarGifts(channel owner): %v", err)
}
if enabled, ok := ownerSavedRes.GetChatNotificationsEnabled(); !ok || !enabled {
t.Fatalf("channel owner notification setting enabled=%v ok=%v, want default true", enabled, ok)
}
baseGifts := r.deps.Gifts
r.deps.Gifts = &starGiftNotificationSettingsRPCService{GiftsService: baseGifts, enabled: false}
disabledSavedRes, err := r.onPaymentsGetSavedStarGifts(ownerCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer})
r.deps.Gifts = baseGifts
if err != nil {
t.Fatalf("getSavedStarGifts(channel notifications disabled): %v", err)
}
if enabled, ok := disabledSavedRes.GetChatNotificationsEnabled(); !ok || enabled {
t.Fatalf("disabled channel notification setting enabled=%v ok=%v, want false/present", enabled, ok)
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok)
}
baseGifts = r.deps.Gifts
r.deps.Gifts = &starGiftMessageAliasRPCService{
GiftsService: baseGifts,
viewerUserID: sender.ID,
msgID: 777,
ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedID: savedID},
}
_, aliasPermissionErr := r.onPaymentsSaveStarGift(senderCtx, &tg.PaymentsSaveStarGiftRequest{
Stargift: &tg.InputSavedStarGiftUser{MsgID: 777},
})
r.deps.Gifts = baseGifts
if !tgerr.Is(aliasPermissionErr, "CHAT_ADMIN_REQUIRED") {
t.Fatalf("non-admin channel notification alias err=%v, want CHAT_ADMIN_REQUIRED", aliasPermissionErr)
}
if _, ok := savedRes.Gifts[0].GetMsgID(); ok {
t.Fatalf("channel saved gift should not expose inputSavedStarGiftUser.msg_id")
}

View file

@ -234,11 +234,7 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
userIDs[msg.Media.Contact.UserID] = struct{}{}
}
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
}
}
collectServiceActionPeerRefs(msg.Media, currentChannelID, userIDs, channelIDs)
collectPollMediaUserRefs(msg.Media, userIDs)
collectTodoMediaUserRefs(msg.Media, userIDs)
if msg.Reactions != nil {
@ -332,6 +328,7 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
channelIDs[id] = struct{}{}
}
}
collectStarGiftUniquePeerRefs(msg.Action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
}
if msg.Reactions != nil {
for _, reaction := range msg.Reactions.Recent {
@ -342,6 +339,49 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
}
}
func collectServiceActionPeerRefs(media *domain.MessageMedia, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
if media == nil || media.ServiceAction == nil {
return
}
action := media.ServiceAction
if action.RequestedPeer != nil {
for _, peer := range action.RequestedPeer.Peers {
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
}
}
if gift := action.StarGift; gift != nil {
if gift.FromUserID != 0 && !gift.NameHidden {
userIDs[gift.FromUserID] = struct{}{}
}
if gift.PeerUserID != 0 {
userIDs[gift.PeerUserID] = struct{}{}
}
if gift.PeerChannelID != 0 && gift.PeerChannelID != currentChannelID {
channelIDs[gift.PeerChannelID] = struct{}{}
}
addDomainPeerRef(gift.To, currentChannelID, userIDs, channelIDs)
}
collectStarGiftUniquePeerRefs(action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
}
func collectStarGiftUniquePeerRefs(action *domain.MessageStarGiftUniqueAction, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
if action == nil {
return
}
if action.FromUserID != 0 {
userIDs[action.FromUserID] = struct{}{}
}
addDomainPeerRef(action.Peer, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.Owner, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.OriginalOwner, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.ReleasedBy, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.ThemePeer, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.Host, currentChannelID, userIDs, channelIDs)
if action.Gift.OriginalFromUserID != 0 && !action.Gift.OriginalNameHidden {
userIDs[action.Gift.OriginalFromUserID] = struct{}{}
}
}
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
switch peer.Type {
case domain.PeerTypeUser:

View file

@ -28,3 +28,96 @@ func TestRemoveKnownChannelRefs(t *testing.T) {
}
}
}
func TestCollectMessagePeerRefsIncludesStarGiftServiceActions(t *testing.T) {
users := map[int64]struct{}{}
channels := map[int64]struct{}{}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
FromUserID: 1001, PeerChannelID: 55,
},
},
}}, 0, users, channels)
if _, ok := users[1001]; !ok {
t.Fatalf("ordinary star-gift user refs=%v, missing sender", users)
}
if _, ok := channels[55]; !ok {
t.Fatalf("ordinary star-gift channel refs=%v", channels)
}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{PeerUserID: 1002},
},
}}, 0, users, channels)
if _, ok := users[1002]; !ok {
t.Fatalf("ordinary star-gift recipient refs=%v", users)
}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
FromUserID: 2001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
Gift: domain.UniqueStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
OriginalFromUserID: 2002,
OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
ReleasedBy: domain.Peer{Type: domain.PeerTypeUser, ID: 2004},
ThemePeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 57},
Host: domain.Peer{Type: domain.PeerTypeUser, ID: 2005},
},
},
},
}}, 0, users, channels)
for _, id := range []int64{2001, 2002, 2003, 2004, 2005} {
if _, ok := users[id]; !ok {
t.Fatalf("unique star-gift user refs=%v, missing %d", users, id)
}
}
for _, id := range []int64{56, 57} {
if _, ok := channels[id]; !ok {
t.Fatalf("unique star-gift channel refs=%v, missing %d", channels, id)
}
}
}
func TestCollectMessagePeerRefsHidesStarGiftSenderDetails(t *testing.T) {
users := map[int64]struct{}{}
channels := map[int64]struct{}{}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
FromUserID: 1001, NameHidden: true, PeerChannelID: 55,
},
},
}}, 0, users, channels)
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: domain.UniqueStarGift{
OriginalFromUserID: 2001,
OriginalNameHidden: true,
},
},
},
}}, 0, users, channels)
for _, id := range []int64{1001, 2001} {
if _, ok := users[id]; ok {
t.Fatalf("hidden star-gift sender %d leaked into refs=%v", id, users)
}
}
if _, ok := channels[55]; !ok {
t.Fatalf("hidden ordinary gift lost recipient channel ref=%v", channels)
}
}

View file

@ -506,6 +506,10 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
return domain.SavedStarGift{}, false, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(_ context.Context, _ int64, _ int) (domain.SavedStarGiftRef, bool, error) {
return domain.SavedStarGiftRef{}, false, nil
}
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil

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
}

View file

@ -42,6 +42,10 @@ type StarGiftStore interface {
ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
// GetByRef 按协议引用取礼物实例:用户用 msg_id频道用 saved_id。
GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
// ResolveUserMessageRef resolves only an explicit viewer-local service-message
// alias. The returned ref retains the saved gift's authoritative user/channel
// owner; callers must still authorize that owner.
ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error)
// ResolveSavedIDs resolves an ordered batch of protocol references without
// per-gift round trips. Every ref must belong to owner and resolve to a live gift.
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
@ -95,6 +99,7 @@ type StarGiftLifecycleStore interface {
PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
StarGiftNotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error)
RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error)
ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)