fix: sync StarGift lifecycle hardening
This commit is contained in:
parent
bfdfe825f6
commit
d69a34a4a8
30 changed files with 1883 additions and 132 deletions
|
|
@ -0,0 +1,6 @@
|
|||
-- Durable edit events and repaired user gift projections are intentionally not
|
||||
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
|
||||
ALTER TABLE peer_star_gifts
|
||||
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
|
||||
|
||||
DROP TABLE IF EXISTS star_gift_user_message_refs;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
-- Official clients may continue lifecycle actions from a freshly emitted
|
||||
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
|
||||
-- durable references to the same saved gift aggregate.
|
||||
CREATE TABLE star_gift_user_message_refs (
|
||||
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
msg_id integer NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (owner_user_id, msg_id),
|
||||
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX star_gift_user_message_refs_saved_idx
|
||||
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
|
||||
|
||||
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
||||
SELECT box.owner_user_id, box.box_id, gift.id
|
||||
FROM message_boxes box
|
||||
JOIN unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN peer_star_gifts gift
|
||||
ON gift.id = unique_gift.source_saved_gift_id
|
||||
AND gift.owner_peer_type = 'user'
|
||||
AND gift.owner_peer_id = box.owner_user_id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.box_id <> gift.msg_id;
|
||||
|
||||
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
|
||||
-- also repairs historical gaps before the invariant is constrained.
|
||||
ALTER TABLE peer_star_gifts
|
||||
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
|
||||
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
|
||||
|
||||
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
|
||||
SELECT id,new_order
|
||||
FROM (
|
||||
SELECT id,
|
||||
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
|
||||
ORDER BY pinned_order,id)::integer AS new_order
|
||||
FROM peer_star_gifts
|
||||
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
|
||||
) ranked
|
||||
WHERE new_order<=6;
|
||||
|
||||
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
|
||||
|
||||
UPDATE peer_star_gifts gift
|
||||
SET pinned_order=repair.new_order
|
||||
FROM star_gift_pin_repairs repair
|
||||
WHERE gift.id=repair.id;
|
||||
|
||||
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
|
||||
-- projections set peer=user (and sometimes a user box id in saved_id), which
|
||||
-- made TDesktop select zero/stale ids instead of the emitted service message.
|
||||
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY(owner_user_id,box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
jsonb_set(
|
||||
box.media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
FROM message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
|
||||
FROM star_gift_user_unique_media_repairs
|
||||
ORDER BY owner_user_id,box_id
|
||||
LOOP
|
||||
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
|
||||
VALUES(repair.owner_user_id,0)
|
||||
ON CONFLICT(user_id) DO NOTHING;
|
||||
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts=contiguous_pts+1,updated_at=now()
|
||||
WHERE user_id=repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE message_boxes
|
||||
SET media=repair.repaired_media,pts=next_pts
|
||||
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
|
||||
|
||||
INSERT INTO user_update_events(
|
||||
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
|
||||
) VALUES(
|
||||
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO dispatch_outbox(
|
||||
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
|
||||
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE private_messages
|
||||
SET media=jsonb_set(
|
||||
media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Validation changes no data and the constraint belongs to migration 0126.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
|
||||
-- it could coexist with the deferrable unique-gift owner trigger in one
|
||||
-- migration transaction. Validate after that transaction has committed.
|
||||
ALTER TABLE peer_star_gifts
|
||||
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;
|
||||
38
deploy/migrations/0128_star_gift_craft_readiness.down.sql
Normal file
38
deploy/migrations/0128_star_gift_craft_readiness.down.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- Aggregate/message repairs and emitted edit events are authoritative business
|
||||
-- history and are intentionally not reversed. Restore only the pre-0128
|
||||
-- deferred owner guard shape.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id
|
||||
INTO saved_status, saved_owner_type, saved_owner_id
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
498
deploy/migrations/0128_star_gift_craft_readiness.up.sql
Normal file
498
deploy/migrations/0128_star_gift_craft_readiness.up.sql
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
-- Official Android clients use a positive can_craft_at both as the Craft
|
||||
-- capability marker and as the readiness boundary. Earlier zero-delay
|
||||
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
|
||||
-- could Craft the gift but Android hid the entry entirely.
|
||||
--
|
||||
-- Block concurrent lifecycle/message writers while aggregate facts, message
|
||||
-- snapshots and durable edit edges are repaired in this migration transaction.
|
||||
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
|
||||
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Craft capability remains an intrinsic collectible fact while ownership
|
||||
-- moves between users and channels. Terminal/external states cannot Craft.
|
||||
UPDATE public.unique_star_gifts unique_gift
|
||||
SET craft_chance_permille = 0,
|
||||
updated_at = now()
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
WHERE saved_gift.unique_gift_id = unique_gift.id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
);
|
||||
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = 0
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille = 0
|
||||
AND saved_gift.can_craft_at <> 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.owner_peer_type NOT IN ('user', 'channel')
|
||||
OR saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
|
||||
END IF;
|
||||
|
||||
-- created_at is the stable persisted proxy for the original upgrade
|
||||
-- transaction date on legacy rows. New writes use the exact request date.
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = GREATEST(
|
||||
1,
|
||||
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
|
||||
)
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND saved_gift.can_craft_at = 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE (unique_gift.craft_chance_permille > 0)
|
||||
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_craft_message_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
unique_gift_id bigint NOT NULL,
|
||||
desired_craft_chance integer NOT NULL,
|
||||
desired_can_craft_at integer NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- Adding capability is owner-scoped: repair only the current owner's
|
||||
-- authoritative unique action (upgrade_msg_id) and the other visible box of
|
||||
-- that same logical private message. Never add Craft back to an old owner's
|
||||
-- historical transfer/resale action.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes owner_box
|
||||
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed craft chance';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT visible_box.owner_user_id,
|
||||
visible_box.box_id,
|
||||
unique_gift.id,
|
||||
unique_gift.craft_chance_permille,
|
||||
saved_gift.can_craft_at
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
JOIN public.message_boxes owner_box
|
||||
ON owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = owner_box.message_sender_id
|
||||
AND visible_box.private_message_id = owner_box.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND (
|
||||
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
|
||||
IS DISTINCT FROM saved_gift.can_craft_at
|
||||
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
|
||||
IS DISTINCT FROM unique_gift.craft_chance_permille
|
||||
);
|
||||
|
||||
-- Only the current owner's authoritative logical message may expose Craft.
|
||||
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
|
||||
-- and old-owner historical actions must have both wire markers removed.
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
|
||||
FROM public.message_boxes box
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND (
|
||||
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes authority
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND authority.owner_user_id = saved_gift.owner_peer_id
|
||||
AND authority.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT authority.deleted
|
||||
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
AND authority.message_sender_id = box.message_sender_id
|
||||
AND authority.private_message_id = box.private_message_id
|
||||
)
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET unique_gift_id = EXCLUDED.unique_gift_id,
|
||||
desired_craft_chance = 0,
|
||||
desired_can_craft_at = 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE box.deleted
|
||||
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.private_messages private_message
|
||||
WHERE private_message.sender_user_id = box.message_sender_id
|
||||
AND private_message.id = box.private_message_id
|
||||
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
GROUP BY box.message_sender_id, box.private_message_id
|
||||
HAVING COUNT(DISTINCT (
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at
|
||||
)) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
|
||||
repaired_media jsonb;
|
||||
repaired_private_media jsonb;
|
||||
affected_rows bigint;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT target.owner_user_id,
|
||||
target.box_id,
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
box.media
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
AND NOT box.deleted
|
||||
ORDER BY target.owner_user_id, target.box_id
|
||||
FOR UPDATE OF box
|
||||
LOOP
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repair.media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
ELSE
|
||||
repaired_media := repair.media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
END IF;
|
||||
|
||||
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
|
||||
OR (
|
||||
repair.desired_craft_chance > 0
|
||||
AND (
|
||||
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
)
|
||||
)
|
||||
OR (
|
||||
repair.desired_craft_chance = 0
|
||||
AND (
|
||||
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (repair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair.owner_user_id
|
||||
AND box_id = repair.box_id
|
||||
AND NOT deleted;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
SELECT media
|
||||
INTO repaired_private_media
|
||||
FROM public.private_messages
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id
|
||||
FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_private_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repaired_private_media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
ELSE
|
||||
repaired_private_media := repaired_private_media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE public.private_messages
|
||||
SET media = repaired_private_media
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair.box_id, repair.peer_type, repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 'edit_message', 0, 0
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
|
||||
-- and export update the two tables in separate statements, so commit-time
|
||||
-- validation observes the final atomic state without a read fallback.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
gift_crafted boolean;
|
||||
gift_craft_chance integer;
|
||||
gift_revision_id bigint;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
saved_can_craft_at integer;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted,
|
||||
craft_chance_permille, collectible_revision_id
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
|
||||
gift_craft_chance, gift_revision_id
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at
|
||||
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
IF gift_craft_chance > 0 THEN
|
||||
IF saved_can_craft_at <= 0
|
||||
OR saved_status <> 'active'
|
||||
OR saved_owner_type NOT IN ('user', 'channel')
|
||||
OR gift_owner_address <> ''
|
||||
OR gift_burned
|
||||
OR gift_crafted THEN
|
||||
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = gift_revision_id
|
||||
AND model.crafted
|
||||
) THEN
|
||||
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
|
||||
END IF;
|
||||
ELSIF saved_can_craft_at <> 0 THEN
|
||||
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE public.star_gift_craft_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
|
||||
DROP COLUMN IF EXISTS output_fingerprint,
|
||||
DROP COLUMN IF EXISTS output_media;
|
||||
65
deploy/migrations/0129_star_gift_craft_output_receipt.up.sql
Normal file
65
deploy/migrations/0129_star_gift_craft_output_receipt.up.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- A successful Craft outcome and its self-service message are separated by a
|
||||
-- process boundary. Freeze the exact output intent in the outcome receipt so
|
||||
-- retries never rebuild a different message from mutable gift/profile state.
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD COLUMN output_media jsonb,
|
||||
ADD COLUMN output_fingerprint bytea;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_craft_commands command
|
||||
WHERE command.success
|
||||
AND 1 <> (
|
||||
SELECT COUNT(*)
|
||||
FROM public.private_messages message
|
||||
WHERE message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND octet_length(message.request_fingerprint) = 32
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
WITH outputs AS (
|
||||
SELECT command.user_id,
|
||||
command.command_key,
|
||||
message.sender_snapshot #> '{message,Media}' AS media,
|
||||
message.request_fingerprint
|
||||
FROM public.star_gift_craft_commands command
|
||||
JOIN public.private_messages message
|
||||
ON message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
WHERE command.success
|
||||
)
|
||||
UPDATE public.star_gift_craft_commands command
|
||||
SET output_media = output.media,
|
||||
output_fingerprint = output.request_fingerprint
|
||||
FROM outputs output
|
||||
WHERE command.user_id = output.user_id
|
||||
AND command.command_key = output.command_key;
|
||||
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
|
||||
(success
|
||||
AND result_unique_gift_id IS NOT NULL
|
||||
AND output_media IS NOT NULL
|
||||
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
|
||||
AND octet_length(output_fingerprint) = 32)
|
||||
OR
|
||||
(NOT success
|
||||
AND result_unique_gift_id IS NULL
|
||||
AND output_media IS NULL
|
||||
AND output_fingerprint IS NULL)
|
||||
);
|
||||
|
|
@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop"
|
|||
//
|
||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||
// so this compatibility key must always remain an array, even when it is empty.
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
|
||||
const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"boosts_channel_level_max": 100,
|
||||
"stargifts_pinned_to_top_limit": 6,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"dialogs_pinned_limit_default": 5,
|
||||
|
|
|
|||
|
|
@ -879,6 +879,9 @@ const (
|
|||
MaxStarGiftCollectionTitleRunes = 12
|
||||
MaxStarGiftCollectionsPerPeer = 100
|
||||
MaxStarGiftCollectionItems = 1000
|
||||
// MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to
|
||||
// official clients. Pin requests are complete replacement vectors.
|
||||
MaxPinnedStarGifts = 6
|
||||
)
|
||||
|
||||
// Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t
|
|||
if action.DropOriginalDetailsStars > 0 {
|
||||
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
|
||||
}
|
||||
if action.CanCraftAt > 0 {
|
||||
// Channel Craft is not executable yet. Gate on the authoritative gift owner
|
||||
// as a final wire boundary so historical JSON/admin-log actions or a future
|
||||
// constructor cannot accidentally expose Android's Craft entry marker.
|
||||
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
|
|
|
|||
|
|
@ -1007,9 +1007,11 @@ func starGiftLifecycleErr(err error) error {
|
|||
return tgerr.New(400, "STARGIFT_OWNER_INVALID")
|
||||
case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable):
|
||||
return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE")
|
||||
case errors.Is(err, domain.ErrStarGiftCraftUnavailable):
|
||||
return tgerr.New(400, "STARGIFT_CRAFT_UNAVAILABLE")
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable):
|
||||
return starGiftInvalidErr()
|
||||
|
|
|
|||
|
|
@ -1071,6 +1071,27 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
|||
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
|
||||
}
|
||||
}
|
||||
if g.CanExportAt > 0 {
|
||||
item.SetCanExportAt(g.CanExportAt)
|
||||
}
|
||||
if g.TransferStars > 0 {
|
||||
item.SetTransferStars(g.TransferStars)
|
||||
}
|
||||
if g.CanTransferAt > 0 {
|
||||
item.SetCanTransferAt(g.CanTransferAt)
|
||||
}
|
||||
if g.CanResellAt > 0 {
|
||||
item.SetCanResellAt(g.CanResellAt)
|
||||
}
|
||||
if g.DropOriginalDetailsStars > 0 {
|
||||
item.SetDropOriginalDetailsStars(g.DropOriginalDetailsStars)
|
||||
}
|
||||
// Channel Craft execution is not implemented yet. Android uses this field
|
||||
// as the entry/capability marker, so only advertise the currently
|
||||
// executable user-owned path while retaining the durable DB entitlement.
|
||||
if g.Owner.Type == domain.PeerTypeUser && g.CanCraftAt > 0 {
|
||||
item.SetCanCraftAt(g.CanCraftAt)
|
||||
}
|
||||
if g.PinnedOrder > 0 {
|
||||
item.PinnedToTop = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedSt
|
|||
}
|
||||
continue
|
||||
}
|
||||
if saved.MsgID == ref.MsgID {
|
||||
if saved.MsgID == ref.MsgID || saved.UpgradeMsgID == ref.MsgID {
|
||||
return saved, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -181,11 +181,12 @@ func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
|
|||
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
|
||||
}
|
||||
|
||||
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
updates, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
&tg.InputSavedStarGiftUser{MsgID: 116},
|
||||
}})
|
||||
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
|
||||
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall)
|
||||
if err != nil || updates == nil || service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50" ||
|
||||
len(service.craftReq.Refs) != 1 || service.craftReq.Refs[0].MsgID != 116 {
|
||||
t.Fatalf("upgrade message alias craft: updates=%T req=%+v err=%v calls=%d", updates, service.craftReq, err, service.craftCall)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +315,162 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
}
|
||||
}
|
||||
|
||||
func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
||||
const (
|
||||
giftID = int64(8001)
|
||||
revision = int64(9001)
|
||||
readyAt = 1_780_000_123
|
||||
exportAt = 1_780_000_200
|
||||
transferAt = 1_780_000_300
|
||||
resellAt = 1_780_000_400
|
||||
)
|
||||
unique := domain.UniqueStarGift{ID: 9901, GiftID: giftID, Title: "Craftable", Slug: "craftable-1", Num: 1,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, CraftChancePermille: 250}
|
||||
saved := domain.SavedStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 44, Date: 100, UniqueGiftID: unique.ID, Unique: &unique,
|
||||
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
|
||||
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
|
||||
}
|
||||
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("saved lifecycle projection count = %d", len(projected))
|
||||
}
|
||||
assertLifecycle := func(t *testing.T, item tg.SavedStarGift) {
|
||||
t.Helper()
|
||||
if value, ok := item.GetCanExportAt(); !ok || value != exportAt {
|
||||
t.Fatalf("can_export_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetTransferStars(); !ok || value != 25 {
|
||||
t.Fatalf("transfer_stars = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanTransferAt(); !ok || value != transferAt {
|
||||
t.Fatalf("can_transfer_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanResellAt(); !ok || value != resellAt {
|
||||
t.Fatalf("can_resell_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetDropOriginalDetailsStars(); !ok || value != 30 {
|
||||
t.Fatalf("drop_original_details_stars = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanCraftAt(); !ok || value != readyAt {
|
||||
t.Fatalf("can_craft_at = %d set=%v", value, ok)
|
||||
}
|
||||
}
|
||||
assertLifecycle(t, projected[0])
|
||||
zero := tgSavedStarGifts([]domain.SavedStarGift{{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
|
||||
}}, nil, nil)[0]
|
||||
if _, ok := zero.GetCanExportAt(); ok {
|
||||
t.Fatal("zero can_export_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetTransferStars(); ok {
|
||||
t.Fatal("zero transfer_stars must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanTransferAt(); ok {
|
||||
t.Fatal("zero can_transfer_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanResellAt(); ok {
|
||||
t.Fatal("zero can_resell_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetDropOriginalDetailsStars(); ok {
|
||||
t.Fatal("zero drop_original_details_stars must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanCraftAt(); ok {
|
||||
t.Fatal("zero can_craft_at must be absent")
|
||||
}
|
||||
channelSaved := saved
|
||||
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
|
||||
channelSaved.MsgID = 0
|
||||
channelSaved.SavedID = 51
|
||||
channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0]
|
||||
if _, ok := channelProjected.GetCanCraftAt(); ok {
|
||||
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
|
||||
}
|
||||
if value, ok := channelProjected.GetSavedID(); !ok || value != channelSaved.SavedID {
|
||||
t.Fatalf("channel saved_id = %d set=%v", value, ok)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||||
encoded := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, wire, encoded); err != nil {
|
||||
t.Fatalf("encode Layer %d saved lifecycle: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d saved lifecycle: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.PaymentsSavedStarGifts)
|
||||
if !ok || len(decoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d saved lifecycle type = %T", profile, decodedObject)
|
||||
}
|
||||
assertLifecycle(t, decoded.Gifts[0])
|
||||
|
||||
channelWire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: []tg.SavedStarGift{channelProjected}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||||
channelEncoded := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, channelWire, channelEncoded); err != nil {
|
||||
t.Fatalf("encode Layer %d channel saved lifecycle: %v", profile, err)
|
||||
}
|
||||
channelDecodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: channelEncoded.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d channel saved lifecycle: %v", profile, err)
|
||||
}
|
||||
channelDecoded, ok := channelDecodedObject.(*tg.PaymentsSavedStarGifts)
|
||||
if !ok || len(channelDecoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d channel saved lifecycle type = %T", profile, channelDecodedObject)
|
||||
}
|
||||
if _, ok := channelDecoded.Gifts[0].GetCanCraftAt(); ok {
|
||||
t.Fatalf("Layer %d channel saved gift exposed can_craft_at", profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelUniqueActionSuppressesCraftReadinessAcrossProfiles(t *testing.T) {
|
||||
const readyAt = 1_780_000_123
|
||||
unique := domain.UniqueStarGift{
|
||||
ID: 9902, GiftID: 8002, Title: "Channel Craftable", Slug: "channel-craftable-1", Num: 1,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}, CraftChancePermille: 250,
|
||||
}
|
||||
action := tgMessageActionStarGiftUnique(&domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, Peer: unique.Owner, SavedID: 52, Saved: true, CanCraftAt: readyAt,
|
||||
}).(*tg.MessageActionStarGiftUnique)
|
||||
if _, ok := action.GetCanCraftAt(); ok {
|
||||
t.Fatal("channel unique action must not expose can_craft_at")
|
||||
}
|
||||
projectedGift, ok := action.Gift.(*tg.StarGiftUnique)
|
||||
if !ok || projectedGift.CraftChancePermille != unique.CraftChancePermille {
|
||||
t.Fatalf("channel unique gift lost intrinsic Craft chance: %#v", action.Gift)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d channel unique action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d channel unique action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionStarGiftUnique)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d channel unique action type = %T", profile, decodedObject)
|
||||
}
|
||||
if _, ok := decoded.GetCanCraftAt(); ok {
|
||||
t.Fatalf("Layer %d channel unique action exposed can_craft_at", profile)
|
||||
}
|
||||
gift, ok := decoded.Gift.(*tg.StarGiftUnique)
|
||||
if !ok || gift.CraftChancePermille != unique.CraftChancePermille {
|
||||
t.Fatalf("Layer %d channel unique gift = %#v", profile, decoded.Gift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftLifecycleCraftUnavailableError(t *testing.T) {
|
||||
if err := starGiftLifecycleErr(domain.ErrStarGiftCraftUnavailable); !tgerr.Is(err, "STARGIFT_CRAFT_UNAVAILABLE") {
|
||||
t.Fatalf("craft unavailable mapping = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
|
||||
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
|
||||
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,
|
||||
|
|
|
|||
|
|
@ -530,6 +530,15 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
|
|||
for i := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
if unsaved && s.gifts[i].PinnedOrder > 0 {
|
||||
removedOrder := s.gifts[i].PinnedOrder
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for j := range s.gifts {
|
||||
if s.gifts[j].Owner == ref.Owner && s.gifts[j].PinnedOrder > removedOrder {
|
||||
s.gifts[j].PinnedOrder--
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -701,10 +710,16 @@ func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer,
|
|||
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(savedGiftIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
order := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
order[id] = i + 1
|
||||
|
|
@ -712,6 +727,9 @@ func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGif
|
|||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner == owner {
|
||||
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
|
||||
if s.gifts[i].PinnedOrder > 0 {
|
||||
s.gifts[i].Unsaved = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -47,6 +47,24 @@ func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
|
|||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("paged order = %v, want %v", got, want)
|
||||
}
|
||||
if ok, err := store.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}, true); err != nil || !ok {
|
||||
t.Fatalf("hide pinned gift = %v err %v", ok, err)
|
||||
}
|
||||
hidden, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || !hidden.Unsaved || hidden.PinnedOrder != 0 {
|
||||
t.Fatalf("hidden pinned gift = %+v found %v err %v", hidden, found, err)
|
||||
}
|
||||
remaining, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 102})
|
||||
if err != nil || !found || remaining.PinnedOrder != 1 {
|
||||
t.Fatalf("remaining pin = %+v found %v err %v", remaining, found, err)
|
||||
}
|
||||
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
|
||||
t.Fatalf("repin hidden gift: %v", err)
|
||||
}
|
||||
repinned, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
|
||||
t.Fatalf("repinned gift = %+v found %v err %v", repinned, found, err)
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, nil); err != nil {
|
||||
t.Fatalf("clear pinned: %v", err)
|
||||
|
|
|
|||
|
|
@ -649,11 +649,16 @@ LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
|
|||
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
|
||||
AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
|
||||
if owner.Type == domain.PeerTypeUser {
|
||||
query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id
|
||||
query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id
|
||||
FROM peer_star_gifts p
|
||||
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT p.msg_id::bigint AS msg_id
|
||||
UNION ALL
|
||||
SELECT r.msg_id::bigint FROM star_gift_user_message_refs r WHERE r.saved_gift_id=p.id
|
||||
) ref
|
||||
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
|
||||
AND (p.msg_id::bigint=ANY($3::bigint[])
|
||||
AND (ref.msg_id=ANY($3::bigint[])
|
||||
OR u.slug=ANY($4::text[]))`
|
||||
}
|
||||
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
|
||||
|
|
@ -744,15 +749,42 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
|
|||
if !ref.Valid() {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
args = append(args, unsaved)
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE peer_star_gifts SET unsaved = $4
|
||||
WHERE `+where+` AND lifecycle_status='active'`, args...)
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
var savedID int64
|
||||
var pinnedOrder int
|
||||
err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsaved && pinnedOrder > 0 {
|
||||
// The positive-order unique index is immediate. Move the bounded
|
||||
// vector one vacant slot at a time so no transient duplicate order
|
||||
// can be observed by PostgreSQL.
|
||||
for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set star gift unsaved: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
|
|
@ -836,7 +868,11 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
|
|||
return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
|
||||
default:
|
||||
args = append(args, ref.MsgID)
|
||||
return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args
|
||||
return `owner_peer_type = $1 AND owner_peer_id = $2 AND (
|
||||
msg_id = $3 OR EXISTS (
|
||||
SELECT 1 FROM star_gift_user_message_refs r
|
||||
WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3
|
||||
))`, args
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -690,6 +690,9 @@ func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Pee
|
|||
}
|
||||
|
||||
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
|
|
@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(savedGiftIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for order, id := range ids {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,13 +116,28 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
|
||||
}
|
||||
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
|
||||
if uniqueAction.SavedID != int64(saved.MsgID) {
|
||||
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID)
|
||||
if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
|
||||
t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
|
||||
}
|
||||
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
|
||||
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction)
|
||||
}
|
||||
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID {
|
||||
t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err)
|
||||
}
|
||||
var ownerAliasCount, senderAliasCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil {
|
||||
t.Fatalf("load owner upgrade output alias: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil {
|
||||
t.Fatalf("load sender upgrade output alias: %v", err)
|
||||
}
|
||||
if ownerAliasCount != 1 || senderAliasCount != 0 {
|
||||
t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount)
|
||||
}
|
||||
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
|
||||
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -18,6 +19,37 @@ const (
|
|||
maxStarGiftAuctionAcquired = 1000
|
||||
)
|
||||
|
||||
// starGiftCraftOutputIntent is the immutable message intent committed with a
|
||||
// successful craft outcome. The aggregate may subsequently be hidden, listed
|
||||
// or otherwise edited; an exact retry must still use the first intent and its
|
||||
// fingerprint instead of rebuilding a different message from mutable state.
|
||||
type starGiftCraftOutputIntent struct {
|
||||
Media *domain.MessageMedia
|
||||
Fingerprint []byte
|
||||
Date int
|
||||
SavedGiftID int64
|
||||
}
|
||||
|
||||
func starGiftCraftOutputMedia(userID int64, gift domain.UniqueStarGift, saved domain.SavedStarGift) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: gift, FromUserID: userID, Saved: !saved.Unsaved, Craft: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
|
||||
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
|
||||
CanCraftAt: saved.CanCraftAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func starGiftCraftOutputRequest(req domain.StarGiftCraftRequest, intent starGiftCraftOutputIntent) domain.SendPrivateTextRequest {
|
||||
return domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.UserID, RecipientUserID: req.UserID,
|
||||
RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: intent.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
|
||||
IdempotencyFingerprint: append([]byte(nil), intent.Fingerprint...), Media: intent.Media,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultStarGiftCraftDraw(upper int) (int, error) {
|
||||
if upper <= 0 {
|
||||
return 0, domain.ErrStarGiftCraftUnavailable
|
||||
|
|
@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID,
|
|||
}
|
||||
args := []any{userID, giftID}
|
||||
where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2
|
||||
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
|
||||
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL
|
||||
AND p.can_craft_at>0 AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
|
||||
AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0
|
||||
AND EXISTS (SELECT 1 FROM star_gift_collectible_models m
|
||||
WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)`
|
||||
|
|
@ -234,11 +267,11 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
// A committed failed craft has already moved every input out of the active
|
||||
// lifecycle. Consult the immutable receipt before active-gift resolution so
|
||||
// an exact transport retry can still replay the same terminal result.
|
||||
if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
|
||||
if replay, output, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
|
||||
if err != nil || !replay.Success {
|
||||
return replay, err
|
||||
}
|
||||
return s.deliverCraftSuccess(ctx, req, replay)
|
||||
return s.deliverCraftSuccess(ctx, req, replay, output)
|
||||
}
|
||||
savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs)
|
||||
if err != nil {
|
||||
|
|
@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
var result domain.StarGiftCraftResult
|
||||
var resultUniqueID int64
|
||||
var output starGiftCraftOutputIntent
|
||||
err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error {
|
||||
lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs))
|
||||
if err != nil {
|
||||
|
|
@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
chance := 0
|
||||
for i := range req.Refs {
|
||||
saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i])
|
||||
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date {
|
||||
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt <= 0 || saved.CanCraftAt > req.Date {
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
|
||||
|
|
@ -374,111 +407,164 @@ WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil {
|
|||
}
|
||||
result.SourceEdits = sourceEdits
|
||||
var resultID any
|
||||
var outputMediaJSON any
|
||||
var outputFingerprint any
|
||||
if result.Success {
|
||||
resultID = firstUniqueID
|
||||
resultUniqueID = firstUniqueID
|
||||
gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, firstUniqueID)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
saved, found, err := savedStarGiftByID(ctx, tx, firstSavedID)
|
||||
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != gift.ID ||
|
||||
!saved.LifecycleStatus.Live() || gift.Owner != owner || gift.Burned || !gift.Crafted {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
media := starGiftCraftOutputMedia(req.UserID, gift, saved)
|
||||
intent := starGiftCraftOutputIntent{Media: media, Date: req.Date, SavedGiftID: saved.ID}
|
||||
fingerprint, err := store.PrivateSendFingerprint(starGiftCraftOutputRequest(req, intent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fingerprint crafted gift output: %w", err)
|
||||
}
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode crafted gift output: %w", err)
|
||||
}
|
||||
intent.Fingerprint = fingerprint
|
||||
output = intent
|
||||
outputMediaJSON = mediaJSON
|
||||
outputFingerprint = fingerprint
|
||||
giftCopy := gift
|
||||
result.Gift = &giftCopy
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id,
|
||||
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID,
|
||||
strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS)
|
||||
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.UserID, strings.TrimSpace(req.CommandKey), uniqueIDs,
|
||||
giftID, result.Success, resultID, chance, req.Date, sourceEditPTS, outputMediaJSON, outputFingerprint)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
|
||||
if replay, replayOutput, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
|
||||
if replayErr == nil && found && replay.Success {
|
||||
return s.deliverCraftSuccess(ctx, req, replay, replayOutput)
|
||||
}
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
if result.Success {
|
||||
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID)
|
||||
if err != nil || !found {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Gift = &gift
|
||||
}
|
||||
if result.Success {
|
||||
return s.deliverCraftSuccess(ctx, req, result)
|
||||
return s.deliverCraftSuccess(ctx, req, result, output)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) {
|
||||
if result.Gift == nil || s.messages == nil {
|
||||
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult, output starGiftCraftOutputIntent) (domain.StarGiftCraftResult, error) {
|
||||
if result.Gift == nil || s.messages == nil || output.Media == nil || output.Date <= 0 || output.SavedGiftID <= 0 ||
|
||||
store.ValidateSendFingerprint(output.Fingerprint, "crafted gift output") != nil {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID)
|
||||
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID,
|
||||
RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
|
||||
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
|
||||
CanCraftAt: saved.CanCraftAt}}}})
|
||||
messageReq := starGiftCraftOutputRequest(req, output)
|
||||
hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID)
|
||||
}}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
registered, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, sent.SenderMessage.ID, output.SavedGiftID)
|
||||
if err != nil || !registered {
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Send = sent
|
||||
result.Duplicate = result.Duplicate || sent.Duplicate
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) {
|
||||
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, starGiftCraftOutputIntent, bool, error) {
|
||||
var success bool
|
||||
var resultID *int64
|
||||
var chance int
|
||||
var inputUniqueIDs []int64
|
||||
var sourceEditPTS []int32
|
||||
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts
|
||||
var createdAt int
|
||||
var outputMediaJSON, outputFingerprint []byte
|
||||
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts,
|
||||
created_at,output_media,output_fingerprint
|
||||
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
|
||||
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS)
|
||||
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS,
|
||||
&createdAt, &outputMediaJSON, &outputFingerprint)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftCraftResult{}, false, nil
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, false, err
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
savedIDs := make([]int64, 0, len(inputUniqueIDs))
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
|
||||
for i, uniqueID := range inputUniqueIDs {
|
||||
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID)
|
||||
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
ref := req.Refs[i]
|
||||
if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
if ref.Owner != owner {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
|
||||
if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
} else if ref.MsgID != saved.MsgID {
|
||||
matches, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, ref.MsgID, saved.ID)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
if !matches {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
}
|
||||
savedIDs = append(savedIDs, saved.ID)
|
||||
}
|
||||
sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, false, err
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true}
|
||||
if resultID != nil {
|
||||
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID)
|
||||
if err != nil || !found {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
if !success {
|
||||
if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Gift = &gift
|
||||
return result, starGiftCraftOutputIntent{}, true, nil
|
||||
}
|
||||
return result, true, nil
|
||||
if resultID == nil || createdAt <= 0 || store.ValidateSendFingerprint(outputFingerprint, "crafted gift replay") != nil {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
media, err := decodeMessageMedia(string(outputMediaJSON))
|
||||
if err != nil || media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
|
||||
!media.ServiceAction.StarGiftUnique.Craft || media.ServiceAction.StarGiftUnique.Gift.ID != *resultID {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
gift := media.ServiceAction.StarGiftUnique.Gift
|
||||
result.Gift = &gift
|
||||
output := starGiftCraftOutputIntent{Media: media, Fingerprint: append([]byte(nil), outputFingerprint...),
|
||||
Date: createdAt, SavedGiftID: savedIDs[0]}
|
||||
return result, output, true, nil
|
||||
}
|
||||
|
||||
func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
|
||||
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
|
||||
}
|
||||
// Hiding a pinned gift atomically unpins it and compacts the remaining
|
||||
// vector. Pinning it again makes it visible in the same owner transaction.
|
||||
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok {
|
||||
t.Fatalf("hide pinned gift = %v err %v", ok, err)
|
||||
}
|
||||
hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
|
||||
if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 {
|
||||
t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err)
|
||||
}
|
||||
remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102})
|
||||
if err != nil || !found || remainingPinned.PinnedOrder != 1 {
|
||||
t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err)
|
||||
}
|
||||
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
|
||||
t.Fatalf("repin hidden gift: %v", err)
|
||||
}
|
||||
repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
|
||||
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
|
||||
t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err)
|
||||
}
|
||||
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
|
||||
t.Fatalf("clear pinned profile order: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
}},
|
||||
}
|
||||
var result domain.StarGiftTransferResult
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date)
|
||||
|
|
@ -414,6 +415,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceSaved = saved
|
||||
unique.Owner = req.To
|
||||
saved.Owner = req.To
|
||||
result.Saved, result.Unique, result.Balance = saved, unique, balance
|
||||
|
|
@ -433,6 +435,9 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id,
|
||||
from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID,
|
||||
|
|
@ -441,6 +446,10 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
}
|
||||
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
|
||||
result.Saved.FromUserID = req.ActorUserID
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
}
|
||||
var result domain.StarGiftTransferResult
|
||||
var commissionAmount int64
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
var listingCurrency, sellerType string
|
||||
|
|
@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
gift.ResellAmount = nil
|
||||
gift.LastSaleDate = req.Date
|
||||
gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount}
|
||||
sourceSaved = saved
|
||||
saved.Owner = req.To
|
||||
if req.To.Type == domain.PeerTypeChannel {
|
||||
saved.MsgID, saved.SavedID = 0, saved.ID
|
||||
}
|
||||
result.Saved, result.Unique, result.Balance = saved, gift, balance
|
||||
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved)
|
||||
resaleAmount := req.Amount
|
||||
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
|
|
@ -578,6 +591,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.To.Type == domain.PeerTypeUser {
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, 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)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID,
|
||||
|
|
@ -601,6 +619,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
}
|
||||
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date
|
||||
result.Saved.FromUserID = messageSenderID
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID)
|
||||
},
|
||||
}
|
||||
|
|
@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
offer.Gift = gift
|
||||
actionKind := domain.MessageServiceActionStarGiftUnique
|
||||
action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID},
|
||||
Gift: gift, FromUserID: req.OwnerUserID,
|
||||
Transferred: true, FromOffer: true, Saved: true,
|
||||
}}
|
||||
if req.Decline {
|
||||
|
|
@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
var result domain.StarGiftOfferResult
|
||||
var commissionAmount int64
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,
|
||||
|
|
@ -874,10 +898,15 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
current.LastSaleDate = req.Date
|
||||
current.LastSaleAmount = &locked.Price
|
||||
locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current
|
||||
sourceSaved = saved
|
||||
saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}
|
||||
result.Offer = locked
|
||||
result.Unique = current
|
||||
result.Saved = saved
|
||||
send.Media.ServiceAction.StarGiftUnique.Gift = current
|
||||
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(current, req.OwnerUserID, saved.Owner, saved)
|
||||
send.Media.ServiceAction.StarGiftUnique.FromOffer = true
|
||||
resaleAmount := locked.Price
|
||||
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
|
|
@ -890,6 +919,10 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, result.Offer.BuyerUserID, msgID,
|
||||
result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID}
|
||||
result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
|
||||
|
|
@ -899,6 +932,9 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID)
|
||||
},
|
||||
}
|
||||
|
|
@ -1077,6 +1113,7 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
|
|||
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceSaved := saved
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
|
|||
return err
|
||||
}
|
||||
result.Saved, result.Unique, result.Balance = saved, unique, balance
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
|
|
@ -1148,17 +1190,23 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int
|
|||
}
|
||||
|
||||
func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction {
|
||||
peer := to
|
||||
savedID := saved.SavedID
|
||||
canCraftAt := saved.CanCraftAt
|
||||
if to.Type == domain.PeerTypeUser {
|
||||
// For a user-owned transferred gift the action message itself becomes
|
||||
// inputSavedStarGiftUser.msg_id. A channel saved_id belongs to a different
|
||||
// identity namespace and must never leak into the recipient's user view.
|
||||
// peer and saved_id are a shared channel-only TL flag. A user-owned
|
||||
// transferred gift is managed by this action message's id.
|
||||
peer = domain.Peer{}
|
||||
savedID = 0
|
||||
} else {
|
||||
// Preserve the durable entitlement for a future transfer back to a user,
|
||||
// but keep channel Craft hidden until its write/update path exists.
|
||||
canCraftAt = 0
|
||||
}
|
||||
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to,
|
||||
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: peer,
|
||||
SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
|
||||
TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt}
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt}
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount,
|
||||
|
|
@ -1468,15 +1516,22 @@ WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ow
|
|||
requestHash := sha256.Sum256([]byte(providerRequestID))
|
||||
giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8])
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL,
|
||||
owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
|
||||
owner_address=$2,gift_address=$3,craft_chance_permille=0,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0,can_craft_at=0 WHERE id=$1`, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil {
|
||||
return err
|
||||
}
|
||||
unique.Owner = domain.Peer{}
|
||||
unique.OwnerAddress = ownerAddress
|
||||
unique.GiftAddress = giftAddress
|
||||
unique.CraftChancePermille = 0
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, saved, unique, date); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, unique.GiftID)
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -140,14 +140,61 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessa
|
|||
t.Fatalf("upgrade prepaid gift: %v", err)
|
||||
}
|
||||
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
|
||||
upgraded.Saved.CanCraftAt != now+2 ||
|
||||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
|
||||
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
|
||||
}
|
||||
readinessTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin craft readiness guard probe: %v", err)
|
||||
}
|
||||
if _, err = readinessTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
t.Fatalf("stage mismatched craft readiness: %v", err)
|
||||
}
|
||||
if _, err = readinessTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
t.Fatal("deferred guard accepted positive craft chance with zero readiness")
|
||||
}
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
chanceTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin craft chance guard probe: %v", err)
|
||||
}
|
||||
if _, err = chanceTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
t.Fatalf("stage mismatched craft chance: %v", err)
|
||||
}
|
||||
if _, err = chanceTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
t.Fatal("deferred guard accepted positive readiness with zero craft chance")
|
||||
}
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
terminalTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin atomic craft terminal guard probe: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("stage terminal craft chance: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("stage terminal craft readiness: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("deferred guard rejected atomic craft terminal state: %v", err)
|
||||
}
|
||||
if err = terminalTx.Rollback(ctx); err != nil {
|
||||
t.Fatalf("rollback atomic craft terminal guard probe: %v", err)
|
||||
}
|
||||
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
|
||||
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
|
||||
senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
|
||||
if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 ||
|
||||
upgradeAction.CanCraftAt != now+2 || senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
|
||||
senderUpgradeAction.CanCraftAt != now+2 ||
|
||||
ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID ||
|
||||
|
|
@ -307,6 +354,37 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMess
|
|||
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
|
||||
t.Fatalf("paid transfer = %+v err %v", transferred, err)
|
||||
}
|
||||
var retiredSourceMediaJSON string
|
||||
var retiredSourcePTS int
|
||||
if err := pool.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, resaleBuyer.ID, resold.Saved.MsgID).
|
||||
Scan(&retiredSourceMediaJSON, &retiredSourcePTS); err != nil {
|
||||
t.Fatalf("load retired transfer source projection: %v", err)
|
||||
}
|
||||
retiredSourceMedia, err := decodeMessageMedia(retiredSourceMediaJSON)
|
||||
if err != nil || retiredSourceMedia == nil || retiredSourceMedia.ServiceAction == nil ||
|
||||
retiredSourceMedia.ServiceAction.StarGiftUnique == nil {
|
||||
t.Fatalf("decode retired transfer source projection: media=%+v err=%v", retiredSourceMedia, err)
|
||||
}
|
||||
retiredSourceAction := retiredSourceMedia.ServiceAction.StarGiftUnique
|
||||
if retiredSourceAction.Gift.Owner != ownerPeer || retiredSourceAction.Gift.CraftChancePermille != 0 ||
|
||||
!retiredSourceAction.Transferred || retiredSourceAction.Saved || retiredSourceAction.CanCraftAt != 0 ||
|
||||
retiredSourceAction.CanExportAt != 0 || retiredSourceAction.TransferStars != 0 ||
|
||||
retiredSourceAction.CanTransferAt != 0 || retiredSourceAction.CanResellAt != 0 ||
|
||||
retiredSourceAction.DropOriginalDetailsStars != 0 || retiredSourceAction.ResaleAmount != nil {
|
||||
t.Fatalf("retired transfer source remained actionable: %+v", retiredSourceAction)
|
||||
}
|
||||
var retiredEventCount, retiredOutboxCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, resaleBuyer.ID, retiredSourcePTS, resold.Saved.MsgID).
|
||||
Scan(&retiredEventCount); err != nil || retiredEventCount != 1 {
|
||||
t.Fatalf("retired transfer source event count=%d err=%v", retiredEventCount, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, resaleBuyer.ID, retiredSourcePTS).
|
||||
Scan(&retiredOutboxCount); err != nil || retiredOutboxCount != 1 {
|
||||
t.Fatalf("retired transfer source outbox count=%d err=%v", retiredOutboxCount, err)
|
||||
}
|
||||
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
|
||||
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
|
||||
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
|
||||
|
|
@ -381,13 +459,13 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err)
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("duplicate upgrade output/slug identities err = %v", err)
|
||||
}
|
||||
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
|
||||
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
|
||||
}); err != nil || found {
|
||||
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}); err != nil || !found || saved.ID != secondUpgrade.Saved.ID {
|
||||
t.Fatalf("upgrade output message id failed to resolve: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
|
||||
|
|
@ -406,6 +484,15 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 {
|
||||
t.Fatalf("craft result = %+v err %v", crafted, err)
|
||||
}
|
||||
craftOutputAction := crafted.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if craftOutputAction == nil || craftOutputAction.Peer.Type != "" || craftOutputAction.Peer.ID != 0 || craftOutputAction.SavedID != 0 || !craftOutputAction.Craft {
|
||||
t.Fatalf("craft output action leaked channel identity: %+v", craftOutputAction)
|
||||
}
|
||||
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
|
||||
Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID,
|
||||
}); err != nil || !found || byOutput.ID != transferred.Saved.ID || byOutput.UniqueGiftID != crafted.Gift.ID {
|
||||
t.Fatalf("craft output message ref = %+v found %v err %v", byOutput, found, err)
|
||||
}
|
||||
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
|
||||
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
|
||||
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
|
||||
|
|
@ -446,6 +533,40 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar
|
|||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
|
||||
}
|
||||
craftOutputRef := domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID}
|
||||
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, true); err != nil || !changed {
|
||||
t.Fatalf("hide crafted output before replay: changed=%v err=%v", changed, err)
|
||||
}
|
||||
hiddenReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
|
||||
if err != nil || !hiddenReplay.Duplicate || hiddenReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
|
||||
t.Fatalf("craft replay after hide = %+v err %v", hiddenReplay, err)
|
||||
}
|
||||
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, false); err != nil || !changed {
|
||||
t.Fatalf("restore crafted output before listing replay: changed=%v err=%v", changed, err)
|
||||
}
|
||||
listedCraftOutput, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
|
||||
Ref: craftOutputRef, Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 148,
|
||||
})
|
||||
if err != nil || listedCraftOutput.ResellAmount == nil || listedCraftOutput.ResellAmount.Amount != 125 {
|
||||
t.Fatalf("list crafted output before replay = %+v err %v", listedCraftOutput, err)
|
||||
}
|
||||
listedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
|
||||
if err != nil || !listedReplay.Duplicate || listedReplay.Gift == nil || listedReplay.Gift.ResellAmount != nil ||
|
||||
listedReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
|
||||
t.Fatalf("craft replay after listing did not use frozen output = %+v err %v", listedReplay, err)
|
||||
}
|
||||
if _, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
|
||||
Ref: craftOutputRef, Date: now + 149,
|
||||
}); err != nil {
|
||||
t.Fatalf("remove crafted output listing: %v", err)
|
||||
}
|
||||
var outputReceiptMedia string
|
||||
var outputReceiptFingerprint []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT output_media::text,output_fingerprint FROM star_gift_craft_commands
|
||||
WHERE user_id=$1 AND command_key=$2`, owner.ID, craftReq.CommandKey).Scan(&outputReceiptMedia, &outputReceiptFingerprint); err != nil ||
|
||||
outputReceiptMedia == "" || len(outputReceiptFingerprint) != 32 {
|
||||
t.Fatalf("craft immutable output receipt: media=%q fingerprint=%d err=%v", outputReceiptMedia, len(outputReceiptFingerprint), err)
|
||||
}
|
||||
var craftListings, resaleAvailability int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`,
|
||||
[]int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 {
|
||||
|
|
@ -487,7 +608,7 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar
|
|||
WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
|
||||
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
|
||||
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}},
|
||||
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.UpgradeMsgID}},
|
||||
CommandKey: "craft-fail-" + suffix, Date: now + 150,
|
||||
}
|
||||
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
|
||||
|
|
@ -526,6 +647,11 @@ can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHER
|
|||
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
|
||||
}
|
||||
wrongAliasReplay := failureReq
|
||||
wrongAliasReplay.Refs = []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}}
|
||||
if _, err := failingLifecycle.CraftStarGift(ctx, wrongAliasReplay); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
|
||||
t.Fatalf("craft replay accepted another aggregate alias: %v", err)
|
||||
}
|
||||
invalidRetry := failureReq
|
||||
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
|
||||
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
|
||||
|
|
@ -629,10 +755,14 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
|
|||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-crafted"), Animation: collectibleTestAnimationPtr("channel-crafted.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
|
|
@ -761,7 +891,9 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
|
|||
}
|
||||
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
if action == nil || action.FromUserID != domain.OfficialSystemUserID || 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 ||
|
||||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
|
||||
t.Fatalf("channel upgrade service action = %+v", action)
|
||||
}
|
||||
var ptsAfterUpgrade int
|
||||
|
|
@ -805,7 +937,9 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
|
|||
}
|
||||
resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
|
||||
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer ||
|
||||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 {
|
||||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 ||
|
||||
resold.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
resold.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
|
||||
}
|
||||
var channelTON, channelTONTxns, targetResaleLogs, commission int64
|
||||
|
|
@ -837,6 +971,36 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
|
|||
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
|
||||
t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err)
|
||||
}
|
||||
toUser, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
|
||||
ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{
|
||||
Owner: targetChannelPeer, SavedID: resold.Saved.SavedID,
|
||||
},
|
||||
To: domain.Peer{Type: domain.PeerTypeUser, ID: actor.ID}, ChargeStars: resold.Saved.TransferStars,
|
||||
CommandKey: "channel-craft-entitlement-to-user-" + suffix, Date: now + 8,
|
||||
})
|
||||
if err != nil || toUser.Saved.Owner.Type != domain.PeerTypeUser || toUser.Saved.Owner.ID != actor.ID ||
|
||||
toUser.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
toUser.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("channel-to-user Craft entitlement transfer = %+v err %v", toUser, err)
|
||||
}
|
||||
toUserAction := toUser.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if toUserAction == nil || toUserAction.CanCraftAt != upgraded.Saved.CanCraftAt {
|
||||
t.Fatalf("channel-to-user action did not restore Craft readiness: %+v", toUserAction)
|
||||
}
|
||||
backToChannel, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
|
||||
ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{
|
||||
Owner: toUser.Saved.Owner, MsgID: toUser.Saved.MsgID,
|
||||
},
|
||||
To: channelPeer, ChargeStars: toUser.Saved.TransferStars,
|
||||
CommandKey: "user-craft-entitlement-to-channel-" + suffix, Date: now + 9,
|
||||
})
|
||||
if err != nil || backToChannel.Saved.Owner != channelPeer ||
|
||||
backToChannel.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
backToChannel.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("user-to-channel Craft entitlement transfer = %+v err %v", backToChannel, err)
|
||||
}
|
||||
|
||||
var remainsBefore int
|
||||
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil {
|
||||
|
|
@ -890,6 +1054,119 @@ WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channe
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCraftFailureConsumesThreeInputsPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
users := NewUserStore(pool)
|
||||
buyer := createTestUser(t, ctx, users, "+1883"+suffix+"01", "CraftBuyer", "")
|
||||
owner := createTestUser(t, ctx, users, "+1883"+suffix+"02", "CraftOwner", "")
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
stars := NewStarsStore(pool)
|
||||
for _, userID := range []int64{buyer.ID, owner.ID} {
|
||||
if _, _, err := stars.EnsureGrant(ctx, userID, 10000, now); err != nil {
|
||||
t.Fatalf("grant craft stars to %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Three Input Craft " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "three-input.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "three-input"), Animation: collectibleTestAnimation("three-input.tgs"),
|
||||
Actor: "integration", CommandID: "three-input-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create three-input catalog: %v", err)
|
||||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "three-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "base.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "base"), Animation: collectibleTestAnimationPtr("base.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern",
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 88,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
Actor: "integration", CommandID: "three-input-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish three-input collectible: %v", err)
|
||||
}
|
||||
|
||||
messages := NewMessageStore(pool)
|
||||
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
|
||||
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
|
||||
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
|
||||
}))
|
||||
refs := make([]domain.SavedStarGiftRef, 0, 3)
|
||||
uniqueIDs := make([]int64, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
|
||||
BuyerUserID: buyer.ID, To: ownerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
|
||||
CommandKey: fmt.Sprintf("three-input-purchase-%s-%d", suffix, i), Date: now + i,
|
||||
})
|
||||
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase three-input gift %d: %v", i, err)
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true,
|
||||
CommandKey: fmt.Sprintf("three-input-upgrade-%s-%d", suffix, i), Date: now + 10 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade three-input gift %d: %v", i, err)
|
||||
}
|
||||
refs = append(refs, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: upgraded.Saved.UpgradeMsgID})
|
||||
uniqueIDs = append(uniqueIDs, upgraded.Unique.ID)
|
||||
}
|
||||
req := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: refs,
|
||||
CommandKey: "three-input-craft-fail-" + suffix, Date: now + 20}
|
||||
failed, err := lifecycle.CraftStarGift(ctx, req)
|
||||
if err != nil || failed.Success || failed.Chance != 750 || failed.Gift != nil {
|
||||
t.Fatalf("three-input craft failure = %+v err=%v", failed, err)
|
||||
}
|
||||
for _, uniqueID := range uniqueIDs {
|
||||
edit := craftedSourceEditForUserAndGift(failed, owner.ID, uniqueID)
|
||||
action := starGiftUniqueActionFromEdit(edit)
|
||||
if edit.Event.Pts <= 0 || action == nil || !action.Gift.Burned || action.Gift.CraftChancePermille != 0 ||
|
||||
action.Saved || action.CanCraftAt != 0 {
|
||||
t.Fatalf("three-input terminal edit for %d = %+v", uniqueID, edit)
|
||||
}
|
||||
var burned bool
|
||||
var status string
|
||||
if err := pool.QueryRow(ctx, `SELECT u.burned,p.lifecycle_status
|
||||
FROM unique_star_gifts u JOIN peer_star_gifts p ON p.unique_gift_id=u.id WHERE u.id=$1`, uniqueID).
|
||||
Scan(&burned, &status); err != nil || !burned || status != "burned" {
|
||||
t.Fatalf("three-input terminal aggregate %d burned=%v status=%q err=%v", uniqueID, burned, status, err)
|
||||
}
|
||||
}
|
||||
var sourcePTS []int32
|
||||
var outputMedia, outputFingerprint []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT source_edit_pts,output_media,output_fingerprint
|
||||
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, owner.ID, req.CommandKey).
|
||||
Scan(&sourcePTS, &outputMedia, &outputFingerprint); err != nil || len(sourcePTS) != 3 ||
|
||||
len(outputMedia) != 0 || len(outputFingerprint) != 0 {
|
||||
t.Fatalf("three-input failure receipt pts=%v media=%d fingerprint=%d err=%v", sourcePTS, len(outputMedia), len(outputFingerprint), err)
|
||||
}
|
||||
replay, err := lifecycle.CraftStarGift(ctx, req)
|
||||
if err != nil || !replay.Duplicate || replay.Success || replay.Chance != 750 {
|
||||
t.Fatalf("three-input failure replay = %+v err=%v", replay, err)
|
||||
}
|
||||
for i, uniqueID := range uniqueIDs {
|
||||
if edit := craftedSourceEditForUserAndGift(replay, owner.ID, uniqueID); edit.Event.Pts != int(sourcePTS[i]) {
|
||||
t.Fatalf("three-input replay pts for %d = %d want %d", uniqueID, edit.Event.Pts, sourcePTS[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
|
||||
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
|
||||
t.Helper()
|
||||
|
|
|
|||
|
|
@ -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 != 125 {
|
||||
t.Fatalf("migration status = %+v, want clean version 125", status)
|
||||
if status.Dirty || status.Empty || status.Version != 129 {
|
||||
t.Fatalf("migration status = %+v, want clean version 129", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
internal/store/postgres/star_gift_lifecycle_projection.go
Normal file
196
internal/store/postgres/star_gift_lifecycle_projection.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
|
||||
// emitted for the source ownership epoch. Ownership moves and terminal export
|
||||
// must not leave an older chat card with Craft/transfer/resale capabilities.
|
||||
// The aggregate mutation and all message edits share one transaction and each
|
||||
// visible box receives its own durable pts/event/outbox entry.
|
||||
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
source domain.SavedStarGift,
|
||||
current domain.UniqueStarGift,
|
||||
date int,
|
||||
) ([]domain.EditedMessageForUser, error) {
|
||||
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
|
||||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
|
||||
return nil, domain.ErrStarGiftTransferUnavailable
|
||||
}
|
||||
|
||||
messageIDs := map[int]struct{}{}
|
||||
if source.MsgID > 0 {
|
||||
messageIDs[source.MsgID] = struct{}{}
|
||||
}
|
||||
if source.UpgradeMsgID > 0 {
|
||||
messageIDs[source.UpgradeMsgID] = struct{}{}
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT msg_id FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND saved_gift_id=$2
|
||||
ORDER BY msg_id`, source.Owner.ID, source.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift message projections: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var msgID int
|
||||
if err := rows.Scan(&msgID); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan star gift message projection: %w", err)
|
||||
}
|
||||
if msgID > 0 {
|
||||
messageIDs[msgID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
ids := make([]int, 0, len(messageIDs))
|
||||
for msgID := range messageIDs {
|
||||
ids = append(ids, msgID)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
q := sqlcgen.New(tx)
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
|
||||
seenPrivateMessages := make(map[string]struct{}, len(ids))
|
||||
for _, msgID := range ids {
|
||||
var peerType string
|
||||
var peerID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT peer_type,peer_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
|
||||
FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock star gift message projection: %w", err)
|
||||
}
|
||||
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
|
||||
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
|
||||
}
|
||||
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
|
||||
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift message projection: %w", err)
|
||||
}
|
||||
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
|
||||
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
|
||||
continue
|
||||
}
|
||||
seenPrivateMessages[logicalKey] = struct{}{}
|
||||
|
||||
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID),
|
||||
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
|
||||
}
|
||||
var privateMediaJSON []byte
|
||||
matched := false
|
||||
for _, box := range boxes {
|
||||
media, err := decodeMessageMedia(box.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift message projection: %w", err)
|
||||
}
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
|
||||
media.ServiceAction.StarGiftUnique.Gift.ID != current.ID {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
action := media.ServiceAction.StarGiftUnique
|
||||
retiredGift := current
|
||||
retiredGift.CraftChancePermille = 0
|
||||
retiredGift.ResellAmount = nil
|
||||
action.Gift = retiredGift
|
||||
action.Peer = domain.Peer{}
|
||||
action.SavedID = 0
|
||||
action.Saved = false
|
||||
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
|
||||
action.Transferred = true
|
||||
}
|
||||
action.CanExportAt = 0
|
||||
action.TransferStars = 0
|
||||
action.ResaleAmount = nil
|
||||
action.CanTransferAt = 0
|
||||
action.CanResellAt = 0
|
||||
action.DropOriginalDetailsStars = 0
|
||||
action.CanCraftAt = 0
|
||||
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
|
||||
}
|
||||
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate retired star gift pts: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE message_boxes SET media=$3,pts=$4
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update retired star gift projection: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("update retired star gift projection lost row")
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Media = media
|
||||
msg.Pts = pts
|
||||
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
|
||||
Pts: pts, PtsCount: 1, Date: date, Message: msg}
|
||||
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
|
||||
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
|
||||
}
|
||||
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
|
||||
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("enqueue retired star gift edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if len(privateMediaJSON) == 0 {
|
||||
return nil, fmt.Errorf("retired star gift projection missing shared media")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE private_messages SET media=$3
|
||||
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
|
||||
return nil, fmt.Errorf("update retired star gift private media: %w", err)
|
||||
}
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
|
@ -7,19 +7,38 @@ import (
|
|||
)
|
||||
|
||||
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
|
||||
saved := domain.SavedStarGift{SavedID: 42}
|
||||
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
|
||||
unique := domain.UniqueStarGift{ID: 7}
|
||||
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
|
||||
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200}
|
||||
|
||||
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 {
|
||||
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt {
|
||||
t.Fatalf("user transfer action leaked channel saved_id: %+v", action)
|
||||
}
|
||||
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID {
|
||||
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 {
|
||||
t.Fatalf("channel transfer action lost channel saved_id: %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCraftReadyAt(t *testing.T) {
|
||||
const date = 1_780_000_000
|
||||
if got := starGiftCraftReadyAt(date, 0); got != date {
|
||||
t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(date, 60); got != date+60 {
|
||||
t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(0, 0); got != 0 {
|
||||
t.Fatalf("invalid-date craft ready_at = %d, want 0", got)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 {
|
||||
t.Fatalf("overflow craft ready_at = %d, want max int32", got)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 {
|
||||
t.Fatalf("oversized-date craft ready_at = %d, want max int32", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
|
||||
ordinary := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ import (
|
|||
|
||||
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
|
||||
// message identity only in the gift owner's message-box projection. Telegram
|
||||
// defines gift_msg_id as receiver-only, and TDesktop also treats user unique
|
||||
// saved_id as an inputSavedStarGiftUser identity. A non-owner counterpart box
|
||||
// id is therefore not a valid substitute: it could resolve to an unrelated
|
||||
// gift owned by that viewer. The shared private_messages row omits the local
|
||||
// reference for the same reason.
|
||||
// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
|
||||
// a valid substitute: it could resolve to an unrelated gift owned by that
|
||||
// viewer. User unique actions do not use channel-only peer/saved_id fields;
|
||||
// their owner-scoped message ids are registered separately at write time.
|
||||
func projectPrivateStarGiftSourceRef(
|
||||
_ context.Context,
|
||||
_ pgx.Tx,
|
||||
|
|
@ -60,25 +59,6 @@ func projectPrivateStarGiftSourceRef(
|
|||
} else {
|
||||
recipientAction.GiftMsgID = sourceOwnerBoxID
|
||||
}
|
||||
case privateStarGiftUniqueAction(shared) != nil:
|
||||
sharedAction := privateStarGiftUniqueAction(shared)
|
||||
senderAction := privateStarGiftUniqueAction(sender)
|
||||
recipientAction := privateStarGiftUniqueAction(recipient)
|
||||
if sharedAction.Peer.Type != domain.PeerTypeUser || sharedAction.Peer.ID != sourceOwnerUserID ||
|
||||
sharedAction.SavedID != int64(sourceOwnerBoxID) {
|
||||
return privateSendMediaProjection{}, fmt.Errorf(
|
||||
"project private unique star gift source: saved_id %d does not match owner box %d",
|
||||
sharedAction.SavedID, sourceOwnerBoxID,
|
||||
)
|
||||
}
|
||||
sharedAction.SavedID = 0
|
||||
senderAction.SavedID = 0
|
||||
recipientAction.SavedID = 0
|
||||
if req.SenderUserID == sourceOwnerUserID {
|
||||
senderAction.SavedID = int64(sourceOwnerBoxID)
|
||||
} else {
|
||||
recipientAction.SavedID = int64(sourceOwnerBoxID)
|
||||
}
|
||||
default:
|
||||
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,9 +122,15 @@ WHERE collectible_revision_id=$1 AND crafted
|
|||
}
|
||||
craftChancePermille := 0
|
||||
canCraftAt := 0
|
||||
// Keep the durable Craft entitlement attached to the collectible across
|
||||
// user/channel ownership moves. The RPC projection suppresses the
|
||||
// readiness marker for channel owners until channel Craft execution is
|
||||
// implemented, without destroying the official gift property.
|
||||
if craftable {
|
||||
craftChancePermille = s.lifecycle.CraftChancePermille
|
||||
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
if craftChancePermille > 0 {
|
||||
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
}
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
|
|
@ -230,12 +236,6 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
|
|||
}
|
||||
return nil
|
||||
},
|
||||
projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) {
|
||||
if result.Saved.Owner.Type != domain.PeerTypeUser {
|
||||
return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil
|
||||
}
|
||||
return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID)
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
ownerMessageID := sent.RecipientMessage.ID
|
||||
if saved.FromUserID == req.UserID {
|
||||
|
|
@ -251,6 +251,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
|
|||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
|
||||
}
|
||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, result.Saved.Owner.ID, ownerMessageID,
|
||||
result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.Saved.UpgradeMsgID = ownerMessageID
|
||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
||||
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
|
||||
|
|
@ -311,19 +317,27 @@ func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.Uniqu
|
|||
// peer plus action.peer=channel and action.saved_id.
|
||||
fromUserID = messageSenderID
|
||||
}
|
||||
peer := saved.Owner
|
||||
savedID := saved.SavedID
|
||||
canCraftAt := saved.CanCraftAt
|
||||
if saved.Owner.Type == domain.PeerTypeUser {
|
||||
// For user-owned gifts messageActionStarGiftUnique.saved_id is the
|
||||
// stable source gift message id. TDesktop uses this back-reference as
|
||||
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs.
|
||||
savedID = int64(saved.MsgID)
|
||||
// peer and saved_id share one TL flag and are defined for channel gifts.
|
||||
// For user gifts both must be absent; official clients use the emitted
|
||||
// service-message id (registered owner-locally by the send transaction).
|
||||
peer = domain.Peer{}
|
||||
savedID = 0
|
||||
} else {
|
||||
// The current Craft state machine is user-owned only. Android treats a
|
||||
// positive can_craft_at as the channel Craft entry marker, so do not
|
||||
// advertise a write path that the server cannot execute yet.
|
||||
canCraftAt = 0
|
||||
}
|
||||
return &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID,
|
||||
Gift: unique, FromUserID: fromUserID, Peer: peer, SavedID: savedID,
|
||||
Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
|
||||
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -469,6 +483,27 @@ func starGiftReadyAt(date, delaySeconds int) int {
|
|||
return date + delaySeconds
|
||||
}
|
||||
|
||||
// starGiftCraftReadyAt differs intentionally from the other lifecycle delay
|
||||
// fields. Official Android clients use a positive can_craft_at both as the
|
||||
// capability marker and as the readiness boundary, so an immediately
|
||||
// craftable gift must carry its upgrade date instead of omitting the field.
|
||||
func starGiftCraftReadyAt(date, delaySeconds int) int {
|
||||
if date <= 0 || delaySeconds < 0 {
|
||||
return 0
|
||||
}
|
||||
const maxProtocolDate = int(1<<31 - 1)
|
||||
if date >= maxProtocolDate {
|
||||
return maxProtocolDate
|
||||
}
|
||||
if delaySeconds == 0 {
|
||||
return date
|
||||
}
|
||||
if delaySeconds > maxProtocolDate-date {
|
||||
return maxProtocolDate
|
||||
}
|
||||
return date + delaySeconds
|
||||
}
|
||||
|
||||
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
return lockSavedStarGiftWhere(ctx, tx, where, args...)
|
||||
|
|
|
|||
59
internal/store/postgres/star_gift_user_message_ref.go
Normal file
59
internal/store/postgres/star_gift_user_message_ref.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
|
||||
// for a user-owned gift. Official clients may continue from a freshly emitted
|
||||
// messageActionStarGiftUnique and pass that message id to a lifecycle RPC,
|
||||
// while payments.getSavedStarGifts may still expose the original received gift
|
||||
// message as the aggregate's primary msg_id.
|
||||
func registerUserStarGiftMessageRef(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
ownerUserID int64,
|
||||
msgID int,
|
||||
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")
|
||||
}
|
||||
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 p.unique_gift_id=$4 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register user star gift message ref: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("register user star gift message ref: identity collision")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userStarGiftMessageRefMatches(
|
||||
ctx context.Context,
|
||||
db interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
},
|
||||
ownerUserID int64,
|
||||
msgID int,
|
||||
savedGiftID int64,
|
||||
) (bool, error) {
|
||||
var matches bool
|
||||
err := db.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3
|
||||
)`, ownerUserID, msgID, savedGiftID).Scan(&matches)
|
||||
return matches, err
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue