diff --git a/deploy/migrations/0106_star_gift_profile_pin_order.down.sql b/deploy/migrations/0106_star_gift_profile_pin_order.down.sql new file mode 100644 index 00000000..5a0023dc --- /dev/null +++ b/deploy/migrations/0106_star_gift_profile_pin_order.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.peer_star_gifts_owner_profile_order_idx; +DROP INDEX IF EXISTS public.peer_star_gifts_owner_pinned_order_uniq; diff --git a/deploy/migrations/0106_star_gift_profile_pin_order.up.sql b/deploy/migrations/0106_star_gift_profile_pin_order.up.sql new file mode 100644 index 00000000..df76e99e --- /dev/null +++ b/deploy/migrations/0106_star_gift_profile_pin_order.up.sql @@ -0,0 +1,13 @@ +CREATE UNIQUE INDEX peer_star_gifts_owner_pinned_order_uniq + ON public.peer_star_gifts(owner_peer_type, owner_peer_id, pinned_order) + WHERE pinned_order > 0; + +CREATE INDEX peer_star_gifts_owner_profile_order_idx + ON public.peer_star_gifts( + owner_peer_type, + owner_peer_id, + (pinned_order = 0), + pinned_order, + id DESC + ) + WHERE lifecycle_status = 'active'; diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go index 4a1ec093..52a5b0c1 100644 --- a/internal/domain/star_gift.go +++ b/internal/domain/star_gift.go @@ -807,6 +807,14 @@ type SavedStarGiftPage struct { Count int // 总数(未转换、按 excludeUnsaved 过滤后) } +// SavedStarGiftListCursor is the composite keyset cursor for the profile gift +// order: pinned gifts first by PinnedOrder, then unpinned gifts by ID DESC. +// PinnedOrder == 0 identifies the unpinned segment. +type SavedStarGiftListCursor struct { + PinnedOrder int + ID int64 +} + // SavedStarGiftFilter describes the client-visible filters supported by // payments.getSavedStarGifts. CollectionID is the collection membership filter; // zero means all collections. The current catalog is used only to decide whether @@ -1013,7 +1021,44 @@ func StarGiftCollectionHash(title string, giftIDs []int64) int64 { return int64(h & 0x7fffffffffffffff) } -// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id)。 +// EncodeSavedStarGiftListCursor encodes the exact profile-order key of the last +// visible gift. The version prefix keeps this cursor distinct from other star +// gift lists that are ordered only by instance ID. +func EncodeSavedStarGiftListCursor(pinnedOrder int, id int64) string { + if pinnedOrder < 0 || id <= 0 { + return "" + } + raw := "v1:" + strconv.Itoa(pinnedOrder) + ":" + strconv.FormatInt(id, 10) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +// DecodeSavedStarGiftListCursor decodes a profile gift list cursor. Invalid or +// obsolete cursor shapes are rejected instead of being normalized on read. +func DecodeSavedStarGiftListCursor(s string) (SavedStarGiftListCursor, bool) { + if s == "" { + return SavedStarGiftListCursor{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return SavedStarGiftListCursor{}, false + } + parts := strings.Split(string(raw), ":") + if len(parts) != 3 || parts[0] != "v1" { + return SavedStarGiftListCursor{}, false + } + order, err := strconv.ParseInt(parts[1], 10, 32) + if err != nil || order < 0 { + return SavedStarGiftListCursor{}, false + } + id, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil || id <= 0 { + return SavedStarGiftListCursor{}, false + } + return SavedStarGiftListCursor{PinnedOrder: int(order), ID: id}, true +} + +// EncodeStarGiftCursor / DecodeStarGiftCursor are simple instance-ID cursors +// used by star gift lists whose order is strictly ID DESC (for example craft). func EncodeStarGiftCursor(id int64) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) } diff --git a/internal/domain/star_gift_cursor_test.go b/internal/domain/star_gift_cursor_test.go new file mode 100644 index 00000000..9a0bcc21 --- /dev/null +++ b/internal/domain/star_gift_cursor_test.go @@ -0,0 +1,31 @@ +package domain + +import "testing" + +func TestSavedStarGiftListCursorRoundTrip(t *testing.T) { + want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000} + encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID) + got, ok := DecodeSavedStarGiftListCursor(encoded) + if !ok || got != want { + t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want) + } + + unpinned := SavedStarGiftListCursor{ID: 42} + got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID)) + if !ok || got != unpinned { + t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned) + } +} + +func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) { + for _, cursor := range []string{ + "not-base64!", + EncodeStarGiftCursor(42), + EncodeSavedStarGiftListCursor(-1, 42), + EncodeSavedStarGiftListCursor(1, 0), + } { + if got, ok := DecodeSavedStarGiftListCursor(cursor); ok { + t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got) + } + } +} diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index 348ee1ef..958fd34f 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -392,28 +392,51 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav } matched = append(matched, g) } - sort.Slice(matched, func(i, j int) bool { return matched[i].ID > matched[j].ID }) + profileOrder := filter.CollectionID == 0 + sort.Slice(matched, func(i, j int) bool { + if profileOrder { + iPinned := matched[i].PinnedOrder > 0 + jPinned := matched[j].PinnedOrder > 0 + if iPinned != jPinned { + return iPinned + } + if iPinned && matched[i].PinnedOrder != matched[j].PinnedOrder { + return matched[i].PinnedOrder < matched[j].PinnedOrder + } + } + return matched[i].ID > matched[j].ID + }) page := domain.SavedStarGiftPage{Count: len(matched)} - cursor, hasCursor := domain.DecodeStarGiftCursor(offset) - out := make([]domain.SavedStarGift, 0, limit) + cursor, hasCursor := domain.DecodeSavedStarGiftListCursor(offset) + out := make([]domain.SavedStarGift, 0, limit+1) for _, g := range matched { - if hasCursor && g.ID >= cursor { - continue + if hasCursor { + if profileOrder { + if cursor.PinnedOrder > 0 { + if g.PinnedOrder > 0 && (g.PinnedOrder < cursor.PinnedOrder || + g.PinnedOrder == cursor.PinnedOrder && g.ID >= cursor.ID) { + continue + } + } else if g.PinnedOrder > 0 || g.ID >= cursor.ID { + continue + } + } else if g.ID >= cursor.ID { + continue + } } out = append(out, g) - if len(out) == limit { + if len(out) == limit+1 { break } } - if len(out) == limit { - // 还有更早的则给下一页游标。 - last := out[len(out)-1].ID - for _, g := range matched { - if g.ID < last { - page.NextOffset = domain.EncodeStarGiftCursor(last) - break - } + if len(out) > limit { + out = out[:limit] + last := out[len(out)-1] + pinnedOrder := 0 + if profileOrder { + pinnedOrder = last.PinnedOrder } + page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID) } page.Gifts = out return page, nil diff --git a/internal/store/memory/star_gift_profile_order_test.go b/internal/store/memory/star_gift_profile_order_test.go new file mode 100644 index 00000000..e7e008e0 --- /dev/null +++ b/internal/store/memory/star_gift_profile_order_test.go @@ -0,0 +1,69 @@ +package memory + +import ( + "context" + "slices" + "testing" + + "telesrv/internal/domain" +) + +func TestStarGiftProfilePinOrderAndPagination(t *testing.T) { + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + store := NewStarGiftStore() + ids := make([]int64, 4) + for i := range ids { + id, err := store.Create(ctx, domain.SavedStarGift{ + Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 100 + i, Date: 1700000000 + i, + }) + if err != nil { + t.Fatalf("create gift %d: %v", i, err) + } + ids[i] = id + } + + if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil { + t.Fatalf("set pinned: %v", err) + } + + want := []int64{ids[0], ids[2], ids[3], ids[1]} + var got []int64 + offset := "" + for pageNumber := 0; ; pageNumber++ { + page, err := store.ListByOwner(ctx, owner, false, offset, 1) + if err != nil { + t.Fatalf("list page %d: %v", pageNumber, err) + } + if page.Count != len(ids) || len(page.Gifts) != 1 { + t.Fatalf("page %d = %+v, want count=%d and one gift", pageNumber, page, len(ids)) + } + got = append(got, page.Gifts[0].ID) + if page.NextOffset == "" { + break + } + offset = page.NextOffset + } + if !slices.Equal(got, want) { + t.Fatalf("paged order = %v, want %v", got, want) + } + + if err := store.SetPinned(ctx, owner, nil); err != nil { + t.Fatalf("clear pinned: %v", err) + } + page, err := store.ListByOwner(ctx, owner, false, "", 10) + if err != nil { + t.Fatalf("list after clear: %v", err) + } + want = []int64{ids[3], ids[2], ids[1], ids[0]} + got = got[:0] + for _, gift := range page.Gifts { + got = append(got, gift.ID) + if gift.PinnedOrder != 0 { + t.Fatalf("gift %d pinned_order=%d after clear", gift.ID, gift.PinnedOrder) + } + } + if !slices.Equal(got, want) { + t.Fatalf("order after clear = %v, want %v", got, want) + } +} diff --git a/internal/store/postgres/star_gift.go b/internal/store/postgres/star_gift.go index 2e65a3bb..7f83fdb3 100644 --- a/internal/store/postgres/star_gift.go +++ b/internal/store/postgres/star_gift.go @@ -535,9 +535,27 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d } page := domain.SavedStarGiftPage{Count: total} - if cursor, ok := domain.DecodeStarGiftCursor(offset); ok { - args = append(args, cursor) - where += fmt.Sprintf(" AND p.id < $%d", len(args)) + profileOrder := filter.CollectionID == 0 + if cursor, ok := domain.DecodeSavedStarGiftListCursor(offset); ok { + if profileOrder && cursor.PinnedOrder > 0 { + args = append(args, cursor.PinnedOrder, cursor.ID) + where += fmt.Sprintf(` AND ( + p.pinned_order = 0 + OR p.pinned_order > $%d + OR (p.pinned_order = $%d AND p.id < $%d) +)`, len(args)-1, len(args)-1, len(args)) + } else { + args = append(args, cursor.ID) + if profileOrder { + where += fmt.Sprintf(" AND p.pinned_order = 0 AND p.id < $%d", len(args)) + } else { + where += fmt.Sprintf(" AND p.id < $%d", len(args)) + } + } + } + orderBy := "ORDER BY p.id DESC" + if profileOrder { + orderBy = "ORDER BY (p.pinned_order = 0), p.pinned_order, p.id DESC" } args = append(args, limit+1) limitPlaceholder := len(args) @@ -553,7 +571,7 @@ SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.ca WHERE i.saved_gift_id=p.id), ARRAY[]::integer[]) FROM peer_star_gifts p `+joins+` WHERE `+where+` -ORDER BY p.id DESC +`+orderBy+` LIMIT $`+fmt.Sprint(limitPlaceholder), args...) if err != nil { return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err) @@ -572,7 +590,12 @@ LIMIT $`+fmt.Sprint(limitPlaceholder), args...) } if len(gifts) > limit { gifts = gifts[:limit] - page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID) + last := gifts[len(gifts)-1] + pinnedOrder := 0 + if profileOrder { + pinnedOrder = last.PinnedOrder + } + page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID) } page.Gifts = gifts return page, nil diff --git a/internal/store/postgres/star_gift_integration_test.go b/internal/store/postgres/star_gift_integration_test.go index 778a013b..17ba3ce1 100644 --- a/internal/store/postgres/star_gift_integration_test.go +++ b/internal/store/postgres/star_gift_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "testing" "time" @@ -57,13 +58,16 @@ func TestStarGiftStorePostgres(t *testing.T) { }) // 创建三份礼物(msg_id 递增)。 + savedIDs := make([]int64, 3) for i := 0; i < 3; i++ { - if _, err := st.Create(ctx, domain.SavedStarGift{ + savedID, err := st.Create(ctx, domain.SavedStarGift{ Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 50, - }); err != nil { + }) + if err != nil { t.Fatalf("create gift #%d: %v", i, err) } + savedIDs[i] = savedID } // active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。 @@ -113,6 +117,35 @@ func TestStarGiftStorePostgres(t *testing.T) { t.Fatalf("page2 = %d next %q, want 1 + empty (terminal)", len(page2.Gifts), page2.NextOffset) } + // 资料页顺序:完整 pin vector 的顺序必须成为列表前缀;游标即使切在 + // pinned block 内或 pinned/unpinned 边界,也不能重复或漏项。 + if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil { + t.Fatalf("set pinned profile order: %v", err) + } + wantMsgIDs := []int{100, 102, 101} + gotMsgIDs := make([]int, 0, len(wantMsgIDs)) + offset := "" + for pageNumber := 0; ; pageNumber++ { + page, err := st.ListByOwner(ctx, ownerPeer, false, offset, 1) + if err != nil { + t.Fatalf("list pinned page %d: %v", pageNumber, err) + } + if page.Count != 3 || len(page.Gifts) != 1 { + t.Fatalf("pinned page %d = %+v, want count=3 and one gift", pageNumber, page) + } + gotMsgIDs = append(gotMsgIDs, page.Gifts[0].MsgID) + if page.NextOffset == "" { + break + } + offset = page.NextOffset + } + if !slices.Equal(gotMsgIDs, wantMsgIDs) { + t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs) + } + if err := st.SetPinned(ctx, ownerPeer, nil); err != nil { + t.Fatalf("clear pinned profile order: %v", err) + } + // 隐藏 msg_id=101 → excludeUnsaved 列表少一份。 if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 101}, true); err != nil || !ok { t.Fatalf("set unsaved = %v err %v", ok, err) diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index ddff2764..de34283e 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -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 != 105 { - t.Fatalf("migration status = %+v, want clean version 105", status) + if status.Dirty || status.Empty || status.Version != 106 { + t.Fatalf("migration status = %+v, want clean version 106", status) } }