fix(admin): resolve moderation, gift, and admin-panel bugs from QA

- scam/fake: enforce mutual exclusivity (scam precedence) at TL conversion, admin command, and UI toggles; both flags could render as neither
- scam/fake flicker: persist scam/fake in the Redis user base cache so cache hits no longer drop the flags
- getSavedStarGifts: hidden (unsaved) gifts are now force-excluded for non-owner viewers; same guard for get-by-ref
- convertStarGift: map already-upgraded/owner-invalid/unavailable to a clean client error instead of 500
- channel force-settings: send only changed fields (partial patch) and re-sync toggles after reload, so one setting no longer resets another
- give gifts: removed custom collectible numbers (auto sequential only); locked sender to the system account 777000
- give gifts UI: fixed picker card spacing/right gap; static (hover-play) Lottie previews on emoji + picker to stop render lag; fixed emoji ID field overflow
This commit is contained in:
epilepticseizureee 2026-07-23 06:24:53 +03:00
parent 313624eab2
commit 90792cdfab
22 changed files with 214 additions and 135 deletions

View file

@ -358,7 +358,6 @@ type GiveGiftRequest struct {
ModelAttributeID int64 `json:"model_attribute_id"`
PatternAttributeID int64 `json:"pattern_attribute_id"`
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
Num int `json:"num"`
}
type StarGiftCollectibleAnimationUpload struct {
@ -871,6 +870,11 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
// scam and fake are mutually exclusive (a peer is never both in Telegram).
// scam takes precedence so the two never persist together.
if req.Scam {
req.Fake = false
}
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -967,8 +971,8 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
return CommandResult{}, fmt.Errorf("upgraded gift delivery is supported for user recipients only")
}
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0 || req.Num > 0) {
return CommandResult{}, fmt.Errorf("collectible attributes and number require upgrade")
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0) {
return CommandResult{}, fmt.Errorf("collectible attributes require upgrade")
}
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
details := map[string]any{
@ -1012,9 +1016,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.BackdropAttributeID > 0 && !collectibleAttrPresent(preview.Backdrops, req.BackdropAttributeID) {
return CommandResult{}, fmt.Errorf("backdrop attribute %d is not part of gift %d", req.BackdropAttributeID, req.GiftID)
}
if req.Num > 0 && req.Num > preview.SupplyTotal {
return CommandResult{}, fmt.Errorf("number %d exceeds collectible supply %d", req.Num, preview.SupplyTotal)
}
details["collectible_supply_total"] = preview.SupplyTotal
details["collectible_issued"] = preview.Issued
if req.ModelAttributeID > 0 {
@ -1026,9 +1027,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.BackdropAttributeID > 0 {
details["backdrop_attribute_id"] = req.BackdropAttributeID
}
if req.Num > 0 {
details["num"] = req.Num
}
}
}
if req.DryRun {
@ -1044,7 +1042,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
ModelAttributeID: req.ModelAttributeID,
PatternAttributeID: req.PatternAttributeID,
BackdropAttributeID: req.BackdropAttributeID,
Num: req.Num,
}); err != nil {
return CommandResult{}, err
}

View file

@ -328,21 +328,21 @@ type StarGiftUpgradeRequest struct {
OriginAuthKeyID [8]byte
OriginSessionID int64
// Admin-controlled minting overrides. When non-zero these pin the specific
// collectible attributes / number instead of the random pool draw and the
// sequential issued+1 number. They are only honoured on the admin grant path;
// the DB FK (attribute must belong to the revision) and UNIQUE(gift_id,num)
// constraints remain the source of truth.
// Admin-controlled attribute overrides. When non-zero these pin the specific
// collectible model/pattern/backdrop instead of the random pool draw. They
// are only honoured on the admin grant path; the DB FK (attribute must belong
// to the revision) remains the source of truth. The collectible number is
// always assigned automatically (sequential).
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from Sender (0 => official system account 777000) at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs / Num pin specific collectible facts (0 => random/auto).
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically.
type AdminStarGiftGrant struct {
SenderID int64
Recipient Peer
@ -353,7 +353,6 @@ type AdminStarGiftGrant struct {
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
type StarGiftPurchaseRequest struct {
@ -927,7 +926,6 @@ var (
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectibleNumberTaken = errors.New("stargift: collectible number already taken")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
ErrStarGiftUnavailable = errors.New("stargift: unavailable")

View file

@ -451,7 +451,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Verified: ch.Verified,
Scam: ch.Scam,
Fake: ch.Fake,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: ch.Fake && !ch.Scam,
Gigagroup: ch.Gigagroup,
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,

View file

@ -53,7 +53,9 @@ func tgUser(u domain.User) *tg.User {
Phone: u.Phone,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: u.Fake && !u.Scam,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -523,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
if r.deps.Gifts == nil {
return emptySavedStarGifts(), nil
}
// Gifts hidden from the profile (unsaved) are visible only to the owner (or a
// channel admin). Never trust the client's exclude_unsaved flag for other
// viewers: force-exclude hidden gifts unless the requester manages the owner.
excludeUnsaved := req.ExcludeUnsaved
if r.ensureCanManageStarGiftOwner(ctx, userID, owner) != nil {
excludeUnsaved = true
}
collectionID, _ := req.GetCollectionID()
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner,
ExcludeUnsaved: req.ExcludeUnsaved,
ExcludeUnsaved: excludeUnsaved,
ExcludeSaved: req.ExcludeSaved,
ExcludeUnlimited: req.ExcludeUnlimited,
ExcludeUnique: req.ExcludeUnique,
@ -556,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
return emptySavedStarGifts(), nil
}
gifts := make([]domain.SavedStarGift, 0, len(refs))
// A gift hidden from the profile (unsaved) is visible only to the owner or a
// channel admin. Memoize the manage check per owner to avoid repeat lookups.
manageCache := make(map[domain.Peer]bool)
canManageOwner := func(owner domain.Peer) bool {
if v, ok := manageCache[owner]; ok {
return v
}
v := r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil
manageCache[owner] = v
return v
}
for _, ref := range refs {
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
if err != nil {
@ -569,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
return nil, internalErr()
}
if found && !g.Converted {
if g.Unsaved && !canManageOwner(g.Owner) {
continue
}
gifts = append(gifts, g)
}
}
@ -669,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
})
if err != nil {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound):
return false, starGiftInvalidErr()
case errors.Is(err, domain.ErrStarGiftAlreadyConverted):
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
errors.Is(err, domain.ErrStarGiftOwnerInvalid),
errors.Is(err, domain.ErrStarGiftUnavailable):
// These are known business conditions (e.g. converting an already
// upgraded/unique gift). Surface a clean client error instead of a
// 500 INTERNAL_SERVER_ERROR.
return false, starGiftInvalidErr()
default:
return false, internalErr()

View file

@ -90,7 +90,6 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
ModelAttributeID: grant.ModelAttributeID,
PatternAttributeID: grant.PatternAttributeID,
BackdropAttributeID: grant.BackdropAttributeID,
Num: grant.Num,
}); err != nil {
return err
}

View file

@ -164,19 +164,6 @@ WHERE collectible_revision_id=$1 AND crafted
}
num := revision.Issued + 1
if req.Num > 0 {
if req.Num > revision.SupplyTotal {
return domain.ErrStarGiftCollectibleInvalid
}
var numTaken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM unique_star_gifts WHERE gift_id=$1 AND num=$2)`, locked.GiftID, req.Num).Scan(&numTaken); err != nil {
return fmt.Errorf("check collectible number availability: %w", err)
}
if numTaken {
return domain.ErrStarGiftCollectibleNumberTaken
}
num = req.Num
}
var uniqueID int64
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
return fmt.Errorf("allocate unique star gift id: %w", err)

View file

@ -41,6 +41,10 @@ type userBaseValue struct {
CountryCode string `json:"country_code"`
Verified bool `json:"verified"`
Support bool `json:"support"`
// scam / fake 同理必须随缓存往返:丢失会让缓存命中路径把带标记的账号输出成
// 普通账号,导致资料页 SCAM/FAKE 标记随缓存命中/未命中间歇性消失(与 bot 列同坑)。
Scam bool `json:"scam,omitempty"`
Fake bool `json:"fake,omitempty"`
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
// 污染客户端本地缓存TDesktop 的 bot 标记不可逆)。
Bot bool `json:"bot,omitempty"`
@ -78,6 +82,8 @@ func baseValueFromUser(u domain.User) userBaseValue {
CountryCode: u.CountryCode,
Verified: u.Verified,
Support: u.Support,
Scam: u.Scam,
Fake: u.Fake,
Bot: u.Bot,
BotInfoVersion: u.BotInfoVersion,
PremiumUntil: u.PremiumUntil,
@ -110,6 +116,8 @@ func (v userBaseValue) user() domain.User {
CountryCode: v.CountryCode,
Verified: v.Verified,
Support: v.Support,
Scam: v.Scam,
Fake: v.Fake,
Bot: v.Bot,
BotInfoVersion: v.BotInfoVersion,
PremiumUntil: v.PremiumUntil,