fix(stargifts): sync correct channel gift notifications
This commit is contained in:
parent
9ac63f8006
commit
5433801380
19 changed files with 893 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import (
|
|||
)
|
||||
|
||||
// Star gift(payments.* 礼物 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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
234
internal/store/postgres/star_gift_channel_notifications.go
Normal file
234
internal/store/postgres/star_gift_channel_notifications.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(®ularLogs); 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(¬ificationMessageID, ¬ificationDeliveredAt, ¬ificationAttempts); 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(¬ificationPts, ¬ificationMediaJSON); 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(¬ificationEvents); 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(¬ificationOutbox); 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue