admin: gift granting, collectible attribute/number control, and Layer 228 moderation tools

Admin console additions (Layer 228):
- Give Gifts: dedicated tab with sorted Lottie/TGS gift picker + inline form; grant any catalog gift to a user/channel from 777000 (no charge)
- Upgraded/collectible delivery: mint a unique gift with admin-selected model/pattern/backdrop and custom number, or random/auto (DB FK + UNIQUE(gift_id,num) enforce invariants)
- SCAM/FAKE flags for users/channels (migration 0136) with configurable profile warning (TELESRV_SCAM_WARNING/TELESRV_FAKE_WARNING)
- Support toggle, force channel settings incl. gigagroup (migration 0137), username management, cosmetic color/emoji-status
- Emoji admin tab (custom emoji list + document IDs + Lottie/TGS preview)
- Bot management; soft UI / dark theme
Wired through Router -> admin.Service -> adminapi -> BFF -> React panel (en/zh/ru).
This commit is contained in:
epilepticseizureee 2026-07-23 04:00:29 +03:00
parent 9e45da69ef
commit 313624eab2
63 changed files with 3650 additions and 71 deletions

View file

@ -500,6 +500,46 @@ func (s *Service) SetVerified(ctx context.Context, channelID int64, verified boo
return s.channels.SetChannelVerified(ctx, channelID, verified)
}
// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
}
// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
}
// AdminSetUsername force-sets or clears a channel username through the admin path.
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
}
// AdminSetColor force-sets a channel name/profile color through the admin path.
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
}
// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
}
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {

View file

@ -0,0 +1,79 @@
package files
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"telesrv/internal/domain"
)
const maxEmojiAnimationBytes = 2 << 20
// DocumentAnimationJSON returns the Lottie JSON for an animated custom-emoji
// document, decompressing TGS (gzip) transparently. Non-emoji documents and
// documents without a stored blob return found=false. It backs the admin emoji
// browser preview and reuses the existing file-blob storage (doc:<id> key).
func (s *Service) DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error) {
if s == nil || s.media == nil || s.blobs == nil || documentID <= 0 {
return nil, false, nil
}
doc, found, err := s.GetDocument(ctx, documentID)
if err != nil {
return nil, false, err
}
if !found || !documentIsCustomEmoji(doc) {
return nil, false, nil
}
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", documentID))
if err != nil {
return nil, false, err
}
if !found || blob.Size <= 0 || blob.Size > maxEmojiAnimationBytes {
return nil, false, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
return nil, false, err
}
if int64(len(data)) != total {
return nil, false, nil
}
out, err := gunzipIfNeeded(data)
if err != nil {
return nil, false, err
}
return out, true, nil
}
func documentIsCustomEmoji(doc domain.Document) bool {
for _, a := range doc.Attributes {
if a.Kind == domain.DocAttrCustomEmoji {
return true
}
}
return false
}
// gunzipIfNeeded transparently decompresses TGS (gzip-wrapped Lottie); raw JSON
// (non-gzip) is returned unchanged.
func gunzipIfNeeded(data []byte) ([]byte, error) {
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
return data, nil
}
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("open tgs gzip: %w", err)
}
defer gz.Close()
out, err := io.ReadAll(io.LimitReader(gz, maxEmojiAnimationBytes+1))
if err != nil {
return nil, fmt.Errorf("decompress tgs: %w", err)
}
if len(out) > maxEmojiAnimationBytes {
return nil, fmt.Errorf("decompressed tgs too large")
}
return out, nil
}

View file

@ -365,6 +365,53 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
return updated, nil
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径。scam/fake
// 是账号基础事实,所有 user 投影统一消费;写后刷新基础缓存以便投影即时可见。
func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Scam == scam && u.Fake == fake {
return u, nil
}
updated, err := s.users.SetScamFake(ctx, userID, scam, fake)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Support == support {
return u, nil
}
updated, err := s.users.SetSupport(ctx, userID, support)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// SweepExpiredPremium 清理到期会员store 把过期行清 NULL并失效用户缓存
// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发
// 正确性由读取路径即时派生保证,这里只做收尾与通知。