fix: sync star gift profile pin order

Sync telesrv 6fcc6f0 (fix(stargifts): honor profile pin order).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 13:20:36 +08:00
parent 14bf7d1e20
commit f88aa16a49
9 changed files with 263 additions and 24 deletions

View file

@ -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;

View file

@ -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';

View file

@ -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)))
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)

View file

@ -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)
}
}