From 5806924e39277e892fd6d73dde33607dcaca065e Mon Sep 17 00:00:00 2001 From: lujunjie Date: Thu, 6 Aug 2026 12:44:37 +0800 Subject: [PATCH] fix(gift): stop streak gifts being dropped and double counted Streak state was keyed on the user alone, so two concurrent streaks from the same user overwrote each other. Finishing one streak then called clear() on the whole map, wiping the state of every other user in the room. A repeated finishing frame raised onGift a second time and the gift was counted twice. TikTok resends the finishing frame of a streak under a fresh msgId, sometimes within milliseconds, so the message id alone does not identify it. A streak that never received a finishing frame never raised onGift at all, and the gift was lost entirely. - key streaks on user + gift + group instead of user alone - remove only the finished streak rather than clearing the whole map - ignore finishing frames repeated within 60s of a streak being closed - close streaks left idle for 5 minutes using their last active frame The idle window is measured from the last frame of a streak rather than its start, so a long streak keeps extending it. It is deliberately generous: closing a streak early would split the gift in two and the real finishing frame would then be discarded as a duplicate. --- .../handlers/TikTokGiftEventHandler.java | 125 ++++++++++++++---- .../events/TikTokGiftEventHandlerTest.java | 109 ++++++++++++++- 2 files changed, 202 insertions(+), 32 deletions(-) diff --git a/Client/src/main/java/io/github/jwdeveloper/tiktok/mappers/handlers/TikTokGiftEventHandler.java b/Client/src/main/java/io/github/jwdeveloper/tiktok/mappers/handlers/TikTokGiftEventHandler.java index 7f3140a6..2c72b5a9 100644 --- a/Client/src/main/java/io/github/jwdeveloper/tiktok/mappers/handlers/TikTokGiftEventHandler.java +++ b/Client/src/main/java/io/github/jwdeveloper/tiktok/mappers/handlers/TikTokGiftEventHandler.java @@ -34,16 +34,48 @@ import lombok.SneakyThrows; import java.util.*; +import java.util.function.LongSupplier; public class TikTokGiftEventHandler { - private final Map giftsMessages; + /** + * A streak that has not been finished within this window is closed by its last active frame. + * TikTok does not guarantee a finishing frame (sendType 0) for every streak, and without this + * fallback such a gift would never raise onGift at all. + *

+ * Measured from the last frame of the streak rather than its start, so an ongoing streak keeps + * extending it. Deliberately generous: streaks can run into the thousands and a lull between + * frames must not close one early, or the gift is split in half and the real finishing frame + * is then discarded as a duplicate. + */ + private static final long DEFAULT_COMBO_TIMEOUT_MS = 300_000; + + /** + * TikTok repeats the finishing frame of a streak under a fresh msgId within milliseconds. + * Finishing frames arriving inside this window after a streak was closed are ignored. + */ + private static final long FINALIZED_RETENTION_MS = 60_000; + + private final Map activeCombos; + private final Map finalizedCombos; private final TikTokRoomInfo tikTokRoomInfo; private final GiftsManager giftsManager; + private final long comboTimeoutMillis; + private final LongSupplier clock; public TikTokGiftEventHandler(GiftsManager giftsManager, TikTokRoomInfo tikTokRoomInfo) { - giftsMessages = new HashMap<>(); + this(giftsManager, tikTokRoomInfo, DEFAULT_COMBO_TIMEOUT_MS, System::currentTimeMillis); + } + + public TikTokGiftEventHandler(GiftsManager giftsManager, + TikTokRoomInfo tikTokRoomInfo, + long comboTimeoutMillis, + LongSupplier clock) { + this.activeCombos = new HashMap<>(); + this.finalizedCombos = new HashMap<>(); this.tikTokRoomInfo = tikTokRoomInfo; this.giftsManager = giftsManager; + this.comboTimeoutMillis = comboTimeoutMillis; + this.clock = clock; } @SneakyThrows @@ -54,43 +86,80 @@ public MappingResult handleGifts(byte[] msg, String name, LiveMapperHelper helpe } public List handleGift(WebcastGiftMessage currentMessage) { + var now = clock.getAsLong(); + var events = new ArrayList<>(flushTimedOutCombos(now)); + //If gift is not streakable just return onGift event if (currentMessage.getGift().getType() != 1) { - var comboEvent = getGiftComboEvent(currentMessage, GiftComboStateType.Finished); - var giftEvent = getGiftEvent(currentMessage); - return List.of(comboEvent, giftEvent); + events.add(getGiftComboEvent(currentMessage, GiftComboStateType.Finished)); + events.add(getGiftEvent(currentMessage)); + return events; } - var userId = currentMessage.getUser().getId(); + var key = comboKey(currentMessage); var currentType = GiftComboStateType.fromNumber(currentMessage.getSendType()); - var previousMessage = giftsMessages.get(userId); - - if (previousMessage == null) { - if (currentType == GiftComboStateType.Finished) { - return List.of(getGiftEvent(currentMessage)); - } else { - giftsMessages.put(userId, currentMessage); - return List.of(getGiftComboEvent(currentMessage, GiftComboStateType.Begin)); - } - } - var previousType = GiftComboStateType.fromNumber(previousMessage.getSendType()); - if (currentType == GiftComboStateType.Active && - previousType == GiftComboStateType.Active) { - giftsMessages.put(userId, currentMessage); - return List.of(getGiftComboEvent(currentMessage, GiftComboStateType.Active)); + if (currentType == GiftComboStateType.Active) { + var previous = activeCombos.put(key, new ComboState(currentMessage, now)); + events.add(getGiftComboEvent(currentMessage, + previous == null ? GiftComboStateType.Begin : GiftComboStateType.Active)); + return events; } + //TikTok may repeat the finishing frame of a streak under a fresh msgId. Without this guard + //every repeat raises another onGift and the gift gets counted twice. + var finalizedUntil = finalizedCombos.get(key); + if (finalizedUntil != null && finalizedUntil > now) + return events; + + var previous = activeCombos.remove(key); + finalizedCombos.put(key, now + FINALIZED_RETENTION_MS); + if (previous != null) + events.add(getGiftComboEvent(currentMessage, GiftComboStateType.Finished)); + events.add(getGiftEvent(currentMessage)); + return events; + } - if (currentType == GiftComboStateType.Finished && - previousType == GiftComboStateType.Active) { - giftsMessages.clear(); - return List.of( - getGiftComboEvent(currentMessage, GiftComboStateType.Finished), - getGiftEvent(currentMessage)); + /** + * Closes streaks that timed out waiting for their finishing frame. Runs lazily on every + * incoming gift, so a streak left open at the very end of a live may still be missed. + */ + private List flushTimedOutCombos(long now) { + finalizedCombos.values().removeIf(expiresAt -> expiresAt <= now); + if (activeCombos.isEmpty()) + return List.of(); + + var events = new ArrayList(); + var iterator = activeCombos.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + var state = entry.getValue(); + if (now - state.updatedAt() < comboTimeoutMillis) + continue; + + iterator.remove(); + finalizedCombos.put(entry.getKey(), now + FINALIZED_RETENTION_MS); + events.add(getGiftComboEvent(state.message(), GiftComboStateType.Finished)); + events.add(getGiftEvent(state.message())); } + return events; + } + + /** + * Identifies a single streak. Keying on the user alone lets concurrent streaks of the same + * user overwrite each other, and made one finishing frame wipe the state of every other user. + */ + private String comboKey(WebcastGiftMessage message) { + var groupId = message.getGroupId() != 0 + ? Long.toString(message.getGroupId()) + : message.getOrderId(); + if (groupId == null || groupId.isEmpty()) + groupId = Long.toString(message.getCommon().getMsgId()); + + return message.getUser().getId() + ":" + message.getGiftId() + ":" + groupId; + } - return List.of(); + private record ComboState(WebcastGiftMessage message, long updatedAt) { } diff --git a/Client/src/test/java/io/github/jwdeveloper/tiktok/handlers/events/TikTokGiftEventHandlerTest.java b/Client/src/test/java/io/github/jwdeveloper/tiktok/handlers/events/TikTokGiftEventHandlerTest.java index 68fdfae0..2f661a94 100644 --- a/Client/src/test/java/io/github/jwdeveloper/tiktok/handlers/events/TikTokGiftEventHandlerTest.java +++ b/Client/src/test/java/io/github/jwdeveloper/tiktok/handlers/events/TikTokGiftEventHandlerTest.java @@ -34,25 +34,30 @@ import io.github.jwdeveloper.tiktok.messages.data.User; import io.github.jwdeveloper.tiktok.messages.webcast.WebcastGiftMessage; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; @TestInstance(TestInstance.Lifecycle.PER_CLASS) class TikTokGiftEventHandlerTest { - public static TikTokGiftEventHandler handler; + private static final long COMBO_TIMEOUT_MS = 30_000; - @BeforeAll + public TikTokGiftEventHandler handler; + private AtomicLong clock; + + @BeforeEach public void before() { var manager = new TikTokGiftsManager(List.of()); var info = new TikTokRoomInfo(); info.setHost(new io.github.jwdeveloper.tiktok.data.models.users.User(123L, "test", new Picture(""))); manager.attachGift(new Gift(123, "example", 123, "image.webp")); - handler = new TikTokGiftEventHandler(manager, info); + clock = new AtomicLong(1_000L); + handler = new TikTokGiftEventHandler(manager, info, COMBO_TIMEOUT_MS, clock::get); } @Test @@ -103,12 +108,107 @@ void shouldHandleStrike() { } + @Test + void shouldKeepConcurrentStreaksOfSameUserApart() { + var streakA = getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 111); + var streakB = getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 222); + + Assertions.assertEquals(GiftComboStateType.Begin, comboStateOf(handler.handleGift(streakA))); + Assertions.assertEquals(GiftComboStateType.Begin, comboStateOf(handler.handleGift(streakB))); + + var finishA = getGiftMessage("example-new-name", 123, "image-new.png", 0, 1, true, 111); + var finishB = getGiftMessage("example-new-name", 123, "image-new.png", 0, 1, true, 222); + + //Both streaks must finish on their own, keying on the user alone used to drop one of them + Assertions.assertEquals(1, countGiftEvents(handler.handleGift(finishA))); + Assertions.assertEquals(1, countGiftEvents(handler.handleGift(finishB))); + } + + @Test + void shouldNotDropStreakOfOtherUserWhenOneFinishes() { + var userOneActive = getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 111); + var userTwoActive = getGiftMessage("example-new-name", 123, "image-new.png", 1, 2, true, 222); + handler.handleGift(userOneActive); + handler.handleGift(userTwoActive); + + //Finishing user two used to clear() the whole map and reset user one back to Begin + handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 0, 2, true, 222)); + + var next = handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 111)); + Assertions.assertEquals(GiftComboStateType.Active, comboStateOf(next)); + } + + @Test + void shouldIgnoreRepeatedFinishFrame() { + handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 111)); + + var first = handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 0, 1, true, 111)); + Assertions.assertEquals(1, countGiftEvents(first)); + + //TikTok resends the finishing frame under a fresh msgId; it must not raise onGift again + clock.addAndGet(150); + var repeated = handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 0, 1, true, 111)); + Assertions.assertEquals(0, countGiftEvents(repeated)); + } + + @Test + void shouldNotCutLongRunningStreak() { + //A streak of 1000 keeps sending frames; the timeout is measured from the last one, so it + //must never fire mid-streak and split the gift in two + for (var i = 1; i <= 1000; i++) { + clock.addAndGet(COMBO_TIMEOUT_MS / 2); + var frame = handler.handleGift( + getGiftMessage("example-new-name", 123, "image-new.png", 1, 1, true, 111)); + Assertions.assertEquals(0, countGiftEvents(frame), "streak was cut at frame " + i); + } + + //Only the real finishing frame closes it, carrying the full repeat count + var finish = handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 0, 1, true, 111)); + Assertions.assertEquals(1, countGiftEvents(finish)); + } + + @Test + void shouldCloseStreakThatNeverReceivedFinishFrame() { + handler.handleGift(getGiftMessage("example-new-name", 123, "image-new.png", 4, 1, true, 111)); + + //Any later gift drives the lazy sweep; the abandoned streak is closed by its last frame + clock.addAndGet(COMBO_TIMEOUT_MS + 1); + var unrelated = getGiftMessage("example-new-name", 123, "image-new.png", 0, 9, false, 999); + var result = handler.handleGift(unrelated); + + //One for the timed out streak, one for the gift that triggered the sweep + Assertions.assertEquals(2, countGiftEvents(result)); + } + + private GiftComboStateType comboStateOf(List events) { + return events.stream() + .filter(TikTokGiftComboEvent.class::isInstance) + .map(TikTokGiftComboEvent.class::cast) + .findFirst() + .orElseThrow() + .getComboState(); + } + + private long countGiftEvents(List events) { + return events.stream().filter(e -> !(e instanceof TikTokGiftComboEvent)).count(); + } + public WebcastGiftMessage getGiftMessage(String giftName, int giftId, String giftImage, int sendType, int userId, boolean streakable) { + return getGiftMessage(giftName, giftId, giftImage, sendType, userId, streakable, 0); + } + + public WebcastGiftMessage getGiftMessage(String giftName, + int giftId, + String giftImage, + int sendType, + int userId, + boolean streakable, + long groupId) { var builder = WebcastGiftMessage.newBuilder(); var giftBuilder = io.github.jwdeveloper.tiktok.messages.data.Gift.newBuilder(); var userBuilder = User.newBuilder(); @@ -123,6 +223,7 @@ public WebcastGiftMessage getGiftMessage(String giftName, builder.setGiftId(giftId); builder.setUser(userBuilder); builder.setSendType(sendType); + builder.setGroupId(groupId); builder.setGift(giftBuilder); return builder.build(); }