fix: sync StarGift lifecycle hardening

This commit is contained in:
A 2026-07-21 15:47:55 +08:00
parent bfdfe825f6
commit d69a34a4a8
30 changed files with 1883 additions and 132 deletions

View file

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

View file

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

View file

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

View file

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