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
|
|
@ -0,0 +1,9 @@
|
||||||
|
DELETE FROM star_gift_user_message_refs refs
|
||||||
|
USING peer_star_gifts gift
|
||||||
|
WHERE gift.id = refs.saved_gift_id
|
||||||
|
AND gift.owner_peer_type = 'channel';
|
||||||
|
|
||||||
|
COMMENT ON TABLE star_gift_user_message_refs IS
|
||||||
|
'Owner-local private service-message aliases to user-owned saved gift aggregates.';
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS star_gift_channel_notification_jobs;
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
-- Channel gift purchases commit a durable recipient snapshot before any
|
||||||
|
-- private notification is attempted. Delivery is idempotent and restart-safe:
|
||||||
|
-- the deterministic private-send replay is authoritative if a process stops
|
||||||
|
-- after writing the message but before marking this job delivered.
|
||||||
|
CREATE TABLE star_gift_channel_notification_jobs (
|
||||||
|
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
|
||||||
|
target_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
gift_date integer NOT NULL,
|
||||||
|
action jsonb NOT NULL,
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at integer NOT NULL,
|
||||||
|
lease_until integer NOT NULL DEFAULT 0,
|
||||||
|
delivered_at integer NOT NULL DEFAULT 0,
|
||||||
|
message_id integer NOT NULL DEFAULT 0,
|
||||||
|
last_error text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (saved_gift_id, target_user_id),
|
||||||
|
CONSTRAINT star_gift_channel_notification_jobs_state_check CHECK (
|
||||||
|
saved_gift_id > 0
|
||||||
|
AND target_user_id > 0
|
||||||
|
AND gift_date > 0
|
||||||
|
AND attempts >= 0
|
||||||
|
AND next_attempt_at > 0
|
||||||
|
AND lease_until >= 0
|
||||||
|
AND delivered_at >= 0
|
||||||
|
AND message_id >= 0
|
||||||
|
AND ((delivered_at = 0 AND message_id = 0) OR (delivered_at > 0 AND message_id > 0))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX star_gift_channel_notification_jobs_pending_idx
|
||||||
|
ON star_gift_channel_notification_jobs(next_attempt_at, lease_until, saved_gift_id, target_user_id)
|
||||||
|
WHERE delivered_at = 0;
|
||||||
|
|
||||||
|
COMMENT ON TABLE star_gift_channel_notification_jobs IS
|
||||||
|
'Purchase-time snapshot of per-admin channel gift notification intents; delivery uses deterministic private-message replay.';
|
||||||
|
|
||||||
|
COMMENT ON TABLE star_gift_user_message_refs IS
|
||||||
|
'Viewer-local private service-message aliases to saved gift aggregates; the saved gift owner may be that user or an authorized channel.';
|
||||||
|
|
||||||
|
-- Existing testserver/users already have channel prepaid/upgrade notifications.
|
||||||
|
-- Backfill only aliases that are cryptographically unnecessary to guess: the
|
||||||
|
-- persisted action itself names a channel and saved_id, and that exact pair
|
||||||
|
-- must resolve to one saved gift. This changes no message, PTS or outbox row.
|
||||||
|
WITH ordinary AS (
|
||||||
|
SELECT box.owner_user_id,
|
||||||
|
box.box_id,
|
||||||
|
gift.id AS saved_gift_id
|
||||||
|
FROM message_boxes box
|
||||||
|
JOIN peer_star_gifts gift
|
||||||
|
ON gift.owner_peer_type = 'channel'
|
||||||
|
AND (box.media #>> '{service_action,star_gift,peer_channel_id}') ~ '^[1-9][0-9]*$'
|
||||||
|
AND gift.owner_peer_id = (box.media #>> '{service_action,star_gift,peer_channel_id}')::bigint
|
||||||
|
AND (box.media #>> '{service_action,star_gift,saved_id}') ~ '^[1-9][0-9]*$'
|
||||||
|
AND gift.saved_id = (box.media #>> '{service_action,star_gift,saved_id}')::bigint
|
||||||
|
WHERE NOT box.deleted
|
||||||
|
AND box.media #>> '{service_action,kind}' = 'star_gift'
|
||||||
|
AND gift.lifecycle_status = 'active'
|
||||||
|
), collectible AS (
|
||||||
|
SELECT box.owner_user_id,
|
||||||
|
box.box_id,
|
||||||
|
gift.id AS saved_gift_id
|
||||||
|
FROM message_boxes box
|
||||||
|
JOIN peer_star_gifts gift
|
||||||
|
ON gift.owner_peer_type = 'channel'
|
||||||
|
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'channel'
|
||||||
|
AND (box.media #>> '{service_action,star_gift_unique,peer,ID}') ~ '^[1-9][0-9]*$'
|
||||||
|
AND gift.owner_peer_id = (box.media #>> '{service_action,star_gift_unique,peer,ID}')::bigint
|
||||||
|
AND (box.media #>> '{service_action,star_gift_unique,saved_id}') ~ '^[1-9][0-9]*$'
|
||||||
|
AND gift.saved_id = (box.media #>> '{service_action,star_gift_unique,saved_id}')::bigint
|
||||||
|
AND (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[1-9][0-9]*$'
|
||||||
|
AND gift.unique_gift_id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||||
|
WHERE NOT box.deleted
|
||||||
|
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||||
|
AND gift.lifecycle_status = 'active'
|
||||||
|
), aliases AS (
|
||||||
|
SELECT * FROM ordinary
|
||||||
|
UNION
|
||||||
|
SELECT * FROM collectible
|
||||||
|
)
|
||||||
|
INSERT INTO star_gift_user_message_refs(owner_user_id, msg_id, saved_gift_id)
|
||||||
|
SELECT owner_user_id, box_id, saved_gift_id
|
||||||
|
FROM aliases
|
||||||
|
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;
|
||||||
|
|
@ -740,6 +740,25 @@ func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64,
|
||||||
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
|
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) {
|
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
|
||||||
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
|
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
|
||||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Star gift(payments.* 礼物 RPC):目录 / 购买表单 / 发送 / 收礼列表 / 展示切换 / 转换回 Stars。
|
// 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") }
|
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 {
|
if err != nil {
|
||||||
return nil, internalErr()
|
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
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,6 +763,17 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
|
||||||
if v == nil || v.MsgID <= 0 {
|
if v == nil || v.MsgID <= 0 {
|
||||||
return domain.SavedStarGiftRef{}, false, nil
|
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{
|
return domain.SavedStarGiftRef{
|
||||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||||
MsgID: v.MsgID,
|
MsgID: v.MsgID,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,48 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
|
||||||
return s.unique, slug == s.unique.Slug, nil
|
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 {
|
type craftStarGiftRPCService struct {
|
||||||
GiftsService
|
GiftsService
|
||||||
uniques map[string]domain.UniqueStarGift
|
uniques map[string]domain.UniqueStarGift
|
||||||
|
|
@ -1154,10 +1196,41 @@ func TestStarGiftChannelSaga(t *testing.T) {
|
||||||
if !savedRes.Gifts[0].CanUpgrade {
|
if !savedRes.Gifts[0].CanUpgrade {
|
||||||
t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
|
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()
|
savedID, ok := savedRes.Gifts[0].GetSavedID()
|
||||||
if !ok || savedID <= 0 {
|
if !ok || savedID <= 0 {
|
||||||
t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok)
|
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 {
|
if _, ok := savedRes.Gifts[0].GetMsgID(); ok {
|
||||||
t.Fatalf("channel saved gift should not expose inputSavedStarGiftUser.msg_id")
|
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 {
|
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||||
}
|
}
|
||||||
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
|
collectServiceActionPeerRefs(msg.Media, currentChannelID, userIDs, channelIDs)
|
||||||
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
|
|
||||||
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
collectPollMediaUserRefs(msg.Media, userIDs)
|
collectPollMediaUserRefs(msg.Media, userIDs)
|
||||||
collectTodoMediaUserRefs(msg.Media, userIDs)
|
collectTodoMediaUserRefs(msg.Media, userIDs)
|
||||||
if msg.Reactions != nil {
|
if msg.Reactions != nil {
|
||||||
|
|
@ -332,6 +328,7 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
|
||||||
channelIDs[id] = struct{}{}
|
channelIDs[id] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
collectStarGiftUniquePeerRefs(msg.Action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
|
||||||
}
|
}
|
||||||
if msg.Reactions != nil {
|
if msg.Reactions != nil {
|
||||||
for _, reaction := range msg.Reactions.Recent {
|
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{}) {
|
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
|
||||||
switch peer.Type {
|
switch peer.Type {
|
||||||
case domain.PeerTypeUser:
|
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
|
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) {
|
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
|
||||||
if !validStarGiftOwner(owner) {
|
if !validStarGiftOwner(owner) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|
|
||||||
|
|
@ -735,6 +735,61 @@ WHERE `+where, args...)
|
||||||
return g, true, nil
|
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) {
|
func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) {
|
||||||
if !validStarGiftOwner(owner) {
|
if !validStarGiftOwner(owner) {
|
||||||
return 0, nil
|
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) {
|
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)
|
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
|
action := messageReq.Media.ServiceAction.StarGift
|
||||||
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
|
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
|
||||||
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})
|
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 {
|
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
|
||||||
return err
|
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,
|
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)
|
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
|
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) {
|
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) == "" {
|
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
|
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||||
|
|
|
||||||
|
|
@ -811,17 +811,37 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
|
||||||
now := int(time.Now().Unix())
|
now := int(time.Now().Unix())
|
||||||
users := NewUserStore(pool)
|
users := NewUserStore(pool)
|
||||||
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
|
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 {
|
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
|
||||||
t.Fatalf("grant actor stars: %v", err)
|
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,
|
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
|
||||||
|
MemberUserIDs: []int64{notifyAdmin.ID, mutedAdmin.ID, noPostAdmin.ID, ordinaryMember.ID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create gift channel: %v", err)
|
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}
|
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,
|
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -867,9 +887,16 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
|
||||||
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
||||||
StarsProceedsPermille: 900, TONProceedsPermille: 900,
|
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{
|
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||||
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
|
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,
|
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
|
||||||
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
|
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
|
||||||
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
|
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 {
|
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)
|
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 channelPrice string
|
||||||
var channelPrepaidAmount any
|
var channelPrepaidAmount any
|
||||||
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
|
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 {
|
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
|
||||||
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
|
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
|
var prepayLogs int
|
||||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
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 {
|
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)
|
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
|
||||||
}
|
}
|
||||||
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
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.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 ||
|
||||||
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
|
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
|
||||||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
|
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
|
||||||
t.Fatalf("channel upgrade service action = %+v", action)
|
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
|
var ptsAfterUpgrade int
|
||||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||||
}
|
}
|
||||||
if status.Dirty || status.Empty || status.Version != 149 {
|
if status.Dirty || status.Empty || status.Version != 150 {
|
||||||
t.Fatalf("migration status = %+v, want clean version 149", status)
|
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 {
|
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
|
||||||
return err
|
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 {
|
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +205,9 @@ func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context,
|
||||||
}
|
}
|
||||||
return domain.StarGiftPurchaseResult{}, err
|
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
|
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.CanCraftAt = canCraftAt
|
||||||
locked.Unique = &unique
|
locked.Unique = &unique
|
||||||
result.Saved, result.Unique, result.Balance = locked, unique, balance
|
result.Saved, result.Unique, result.Balance = locked, unique, balance
|
||||||
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
|
action := starGiftUpgradeUniqueAction(locked, unique, req)
|
||||||
messageReq.Media = &domain.MessageMedia{
|
messageReq.Media = &domain.MessageMedia{
|
||||||
Kind: domain.MessageMediaKindService,
|
Kind: domain.MessageMediaKindService,
|
||||||
ServiceAction: &domain.MessageServiceAction{
|
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 {
|
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||||
ownerMessageID := sent.RecipientMessage.ID
|
ownerMessageID := sent.RecipientMessage.ID
|
||||||
if saved.FromUserID == req.UserID {
|
if result.Saved.Owner.Type == domain.PeerTypeUser && saved.FromUserID == req.UserID {
|
||||||
ownerMessageID = sent.SenderMessage.ID
|
ownerMessageID = sent.SenderMessage.ID
|
||||||
}
|
}
|
||||||
if ownerMessageID <= 0 {
|
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 {
|
result.Saved.ID, result.Unique.ID); err != nil {
|
||||||
return err
|
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
|
result.Saved.UpgradeMsgID = ownerMessageID
|
||||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
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")
|
return fmt.Errorf("save star gift source edit pts lost command row")
|
||||||
}
|
}
|
||||||
} else {
|
} 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,
|
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
|
||||||
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
|
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
|
||||||
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
|
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
|
||||||
|
|
@ -599,15 +602,16 @@ WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
|
||||||
return result, nil
|
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
|
fromUserID := saved.FromUserID
|
||||||
if saved.NameHidden {
|
if saved.NameHidden {
|
||||||
fromUserID = 0
|
fromUserID = 0
|
||||||
}
|
}
|
||||||
if saved.Owner.Type == domain.PeerTypeChannel {
|
if saved.Owner.Type == domain.PeerTypeChannel {
|
||||||
// TDesktop recognizes a channel-owned upgrade from the official service
|
// The private envelope is sent by 777000, while action.from_id identifies
|
||||||
// peer plus action.peer=channel and action.saved_id.
|
// the administrator who performed the upgrade. TDesktop uses that
|
||||||
fromUserID = messageSenderID
|
// distinction to render "upgraded" instead of an unknown transfer.
|
||||||
|
fromUserID = req.UserID
|
||||||
}
|
}
|
||||||
peer := saved.Owner
|
peer := saved.Owner
|
||||||
savedID := saved.SavedID
|
savedID := saved.SavedID
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
|
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
|
||||||
|
|
@ -22,24 +24,71 @@ func registerUserStarGiftMessageRef(
|
||||||
savedGiftID int64,
|
savedGiftID int64,
|
||||||
uniqueGiftID int64,
|
uniqueGiftID int64,
|
||||||
) error {
|
) error {
|
||||||
if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID < 0 {
|
return registerViewerStarGiftMessageRef(ctx, tx, ownerUserID, msgID, savedGiftID,
|
||||||
return fmt.Errorf("register user star gift message ref: invalid identity")
|
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, `
|
tag, err := tx.Exec(ctx, `
|
||||||
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
||||||
SELECT $1,$2,p.id
|
SELECT $1,$2,p.id
|
||||||
FROM peer_star_gifts p
|
FROM peer_star_gifts p
|
||||||
WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1
|
WHERE p.id=$3 AND p.owner_peer_type=$4 AND p.owner_peer_id=$5
|
||||||
AND (($4::bigint=0 AND p.unique_gift_id IS NULL) OR ($4::bigint>0 AND p.unique_gift_id=$4::bigint))
|
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'
|
AND p.lifecycle_status='active'
|
||||||
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
|
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
|
||||||
SET saved_gift_id=EXCLUDED.saved_gift_id
|
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 {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ type StarGiftStore interface {
|
||||||
ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
|
ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
|
||||||
// GetByRef 按协议引用取礼物实例:用户用 msg_id,频道用 saved_id。
|
// GetByRef 按协议引用取礼物实例:用户用 msg_id,频道用 saved_id。
|
||||||
GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
|
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
|
// 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.
|
// 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)
|
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)
|
PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
|
||||||
DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
|
DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
|
||||||
SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) 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)
|
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)
|
ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
|
||||||
CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
|
CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue