feat(premium): sync promo video catalog seed
This commit is contained in:
parent
c3c079edf3
commit
3369d0bd5c
14 changed files with 1137 additions and 10 deletions
|
|
@ -167,6 +167,9 @@ TELESRV_STARGIFT_CRAFT_DELAY=0s
|
|||
TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250
|
||||
TELESRV_BLOB_DIR=data/blobs
|
||||
TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
||||
# Optional Premium feature-preview media export. Missing directory keeps the
|
||||
# no-video fallback; an existing but incomplete/invalid directory fails startup.
|
||||
TELESRV_PREMIUM_PROMO_SEED_DIR=data/premium-promo
|
||||
|
||||
# Optional public-link Web listener for /<username>, profile avatars,
|
||||
# sticker/custom emoji sets, and shared folders. This is a host:port bind
|
||||
|
|
|
|||
|
|
@ -494,6 +494,15 @@ func run(logger *zap.Logger) error {
|
|||
zap.Int("blobs", stats.Blobs),
|
||||
)
|
||||
}
|
||||
if stats, err := filesService.SeedPremiumPromo(ctx, cfg.PremiumPromoSeedDir); err != nil {
|
||||
return fmt.Errorf("seed premium promo: %w", err)
|
||||
} else if !stats.Skipped {
|
||||
logger.Info("Premium promo 视频种子导入完成",
|
||||
zap.String("dir", cfg.PremiumPromoSeedDir),
|
||||
zap.Int("videos", stats.Videos),
|
||||
zap.Int("blobs", stats.Blobs),
|
||||
)
|
||||
}
|
||||
if stats, err := filesService.SeedAppearance(ctx); err != nil {
|
||||
return fmt.Errorf("seed appearance: %w", err)
|
||||
} else if !stats.Skipped {
|
||||
|
|
@ -901,6 +910,7 @@ func run(logger *zap.Logger) error {
|
|||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ key rings independently on different instances.
|
|||
| `TELESRV_BLOB_DIR` | path / `data/blobs` | Local development blob-backend root for media bytes. |
|
||||
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. |
|
||||
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. |
|
||||
| `TELESRV_PREMIUM_PROMO_SEED_DIR` | path / `data/premium-promo` | Exported `help.getPremiumPromo` manifest, MP4 videos, and JPEG thumbnails. A missing directory keeps the no-video fallback; an existing invalid/incomplete directory fails startup. |
|
||||
|
||||
The language-pack file manifest is authoritative. To add a language, place `data/langpack/<pack>/<pack>_<lang>_v<version>.strings` and restart `telesrv`. The `pack` must match its first-level directory and may use the letters, digits, `-`, and `_` already used by Telegram (for example, `android_x`); `lang` is canonicalized to lowercase with hyphens (`pt_BR` becomes `pt-br`). Only the highest file version for each language is loaded. Effective content changes require a version bump; same-version effective mutations and version rollbacks stop startup. Removing a language file or an entire pack subdirectory atomically removes its database catalog and strings on the next restart. Startup streams a source-file SHA-256 first: unchanged files reuse the last atomic manifest without parsing strings or writing the database, while only new or changed files are parsed and replaced through PostgreSQL `COPY`.
|
||||
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别
|
|||
| `TELESRV_BLOB_DIR` | path / `data/blobs` | 本地开发 blob backend 的媒体字节根目录。 |
|
||||
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 |
|
||||
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 |
|
||||
| `TELESRV_PREMIUM_PROMO_SEED_DIR` | path / `data/premium-promo` | `help.getPremiumPromo` 导出的 manifest、MP4 视频与 JPEG 缩略图目录。目录缺失时保留无视频兼容响应;目录存在但内容非法或不完整时启动失败。 |
|
||||
|
||||
语言包 seed 以文件 manifest 为事实源。新增语言时放入 `data/langpack/<pack>/<pack>_<lang>_v<version>.strings` 并重启 `telesrv`;`pack` 必须与所在一级目录一致,允许 Telegram 已使用的字母、数字、`-` 与 `_`(例如 `android_x`),`lang` 会统一为小写、连字符形式(例如 `pt_BR` 归一为 `pt-br`)。同一语言存在多个文件时只读取最高版本。修改已有语言的有效内容必须提高版本;同版本有效内容变化或版本倒退会阻止启动。删除语言文件或整个 pack 子目录后,下次重启会原子移除对应数据库目录和字符串。启动先流式计算源文件 SHA-256;未变化文件复用上次原子 manifest,不解析字符串也不写库,只有新增或变化文件才解析并通过 PostgreSQL `COPY` 整包替换。
|
||||
|
||||
|
|
|
|||
575
internal/app/files/premium_promo_seed.go
Normal file
575
internal/app/files/premium_promo_seed.go
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
premiumPromoSeedStateKey = "files.premium_promo"
|
||||
premiumPromoSeedStateVersion = "premium-promo-v1"
|
||||
|
||||
premiumPromoManifestName = "premium_promo.json"
|
||||
premiumPromoMaxVideos = 128
|
||||
premiumPromoMaxVideoSize = int64(64 << 20)
|
||||
premiumPromoMaxThumbSize = int64(4 << 20)
|
||||
premiumPromoMaxTotalSize = int64(512 << 20)
|
||||
)
|
||||
|
||||
// PremiumPromoSeedStats reports the startup import outcome. Videos is the
|
||||
// number of usable catalog entries; Blobs counts main/thumbnail blobs written
|
||||
// during this run.
|
||||
type PremiumPromoSeedStats struct {
|
||||
Videos int
|
||||
Blobs int
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
type premiumPromoSeedJSON struct {
|
||||
APICall string `json:"api_call"`
|
||||
StatusText string `json:"status_text"`
|
||||
VideoSections []string `json:"video_sections"`
|
||||
Videos []seedDocumentJSON `json:"videos"`
|
||||
PeriodOptions []json.RawMessage `json:"period_options"`
|
||||
}
|
||||
|
||||
type premiumPromoSeedVideo struct {
|
||||
section string
|
||||
document domain.Document
|
||||
mainPath string
|
||||
thumbPath string
|
||||
thumbType string
|
||||
}
|
||||
|
||||
// SeedPremiumPromo imports the exported promo videos into the ordinary
|
||||
// document/file_blob storage. A missing root is an optional-resource fallback;
|
||||
// once the directory exists, malformed or incomplete data is a startup error.
|
||||
func (s *Service) SeedPremiumPromo(ctx context.Context, root string) (PremiumPromoSeedStats, error) {
|
||||
var stats PremiumPromoSeedStats
|
||||
if root == "" {
|
||||
s.clearPremiumPromo()
|
||||
stats.Skipped = true
|
||||
s.warnPremiumPromoMissing(root, errors.New("seed dir is empty"))
|
||||
return stats, nil
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.clearPremiumPromo()
|
||||
stats.Skipped = true
|
||||
s.warnPremiumPromoMissing(root, err)
|
||||
return stats, nil
|
||||
}
|
||||
return stats, fmt.Errorf("stat premium promo seed dir %q: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return stats, fmt.Errorf("premium promo seed path %q is not a directory", root)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(root, premiumPromoManifestName)
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo manifest %q: %w", manifestPath, err)
|
||||
}
|
||||
videos, err := parsePremiumPromoSeed(root, raw)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("validate premium promo seed: %w", err)
|
||||
}
|
||||
for i := range videos {
|
||||
videos[i].document.DCID = s.dc
|
||||
}
|
||||
stats.Videos = len(videos)
|
||||
|
||||
stateHash, err := premiumPromoSeedHash(raw, videos, s.dc)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("hash premium promo seed: %w", err)
|
||||
}
|
||||
stateMatches, err := s.seedStateMatches(ctx, premiumPromoSeedStateKey, stateHash)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo seed state: %w", err)
|
||||
}
|
||||
if stateMatches {
|
||||
if catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos); err != nil {
|
||||
return stats, fmt.Errorf("verify premium promo catalog: %w", err)
|
||||
} else if ready {
|
||||
s.setPremiumPromoCatalog(catalog)
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, video := range videos {
|
||||
existing, found, err := s.media.GetDocument(ctx, video.document.ID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("read premium promo document %d: %w", video.document.ID, err)
|
||||
}
|
||||
if found && existing.AccessHash != video.document.AccessHash {
|
||||
return stats, fmt.Errorf(
|
||||
"premium promo document %d collides with access_hash %d (seed has %d)",
|
||||
video.document.ID,
|
||||
existing.AccessHash,
|
||||
video.document.AccessHash,
|
||||
)
|
||||
}
|
||||
|
||||
forceBlobWrite := !stateMatches
|
||||
if wrote, err := s.putPremiumPromoBlob(
|
||||
ctx,
|
||||
fmt.Sprintf("doc:%d", video.document.ID),
|
||||
video.mainPath,
|
||||
video.document.MimeType,
|
||||
video.document.Size,
|
||||
forceBlobWrite,
|
||||
); err != nil {
|
||||
return stats, fmt.Errorf("import premium promo video %d: %w", video.document.ID, err)
|
||||
} else if wrote {
|
||||
stats.Blobs++
|
||||
}
|
||||
thumb := video.document.Thumbs[0]
|
||||
if wrote, err := s.putPremiumPromoBlob(
|
||||
ctx,
|
||||
fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType),
|
||||
video.thumbPath,
|
||||
"image/jpeg",
|
||||
int64(thumb.Size),
|
||||
forceBlobWrite,
|
||||
); err != nil {
|
||||
return stats, fmt.Errorf("import premium promo thumbnail %d: %w", video.document.ID, err)
|
||||
} else if wrote {
|
||||
stats.Blobs++
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, video.document); err != nil {
|
||||
return stats, fmt.Errorf("store premium promo document %d: %w", video.document.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("verify imported premium promo catalog: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return stats, errors.New("premium promo catalog is incomplete after import")
|
||||
}
|
||||
if err := s.putSeedState(ctx, premiumPromoSeedStateKey, stateHash); err != nil {
|
||||
return stats, fmt.Errorf("record premium promo seed state: %w", err)
|
||||
}
|
||||
s.setPremiumPromoCatalog(catalog)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// PremiumPromo returns a deep copy so callers cannot mutate the startup
|
||||
// catalog or another request's response.
|
||||
func (s *Service) PremiumPromo(_ context.Context) (domain.PremiumPromoCatalog, bool, error) {
|
||||
s.premiumPromoMu.RLock()
|
||||
defer s.premiumPromoMu.RUnlock()
|
||||
if !s.premiumPromoReady {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
return domain.PremiumPromoCatalog{
|
||||
VideoSections: append([]string(nil), s.premiumPromo.VideoSections...),
|
||||
Videos: copyDocuments(s.premiumPromo.Videos),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) setPremiumPromoCatalog(catalog domain.PremiumPromoCatalog) {
|
||||
s.premiumPromoMu.Lock()
|
||||
defer s.premiumPromoMu.Unlock()
|
||||
s.premiumPromo = domain.PremiumPromoCatalog{
|
||||
VideoSections: append([]string(nil), catalog.VideoSections...),
|
||||
Videos: copyDocuments(catalog.Videos),
|
||||
}
|
||||
s.premiumPromoReady = true
|
||||
}
|
||||
|
||||
func (s *Service) clearPremiumPromo() {
|
||||
s.premiumPromoMu.Lock()
|
||||
defer s.premiumPromoMu.Unlock()
|
||||
s.premiumPromo = domain.PremiumPromoCatalog{}
|
||||
s.premiumPromoReady = false
|
||||
}
|
||||
|
||||
func (s *Service) warnPremiumPromoMissing(root string, err error) {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn(
|
||||
"Premium promo seed 目录不存在,help.getPremiumPromo 将返回无视频兼容响应",
|
||||
zap.String("dir", root),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
func parsePremiumPromoSeed(root string, raw []byte) ([]premiumPromoSeedVideo, error) {
|
||||
var parsed premiumPromoSeedJSON
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", premiumPromoManifestName, err)
|
||||
}
|
||||
if parsed.APICall != "help.getPremiumPromo" {
|
||||
return nil, fmt.Errorf("api_call = %q, want help.getPremiumPromo", parsed.APICall)
|
||||
}
|
||||
if len(parsed.VideoSections) == 0 || len(parsed.VideoSections) > premiumPromoMaxVideos {
|
||||
return nil, fmt.Errorf("video_sections count %d is outside 1..%d", len(parsed.VideoSections), premiumPromoMaxVideos)
|
||||
}
|
||||
if len(parsed.VideoSections) != len(parsed.Videos) {
|
||||
return nil, fmt.Errorf("video_sections count %d does not match videos count %d", len(parsed.VideoSections), len(parsed.Videos))
|
||||
}
|
||||
|
||||
seenSections := make(map[string]struct{}, len(parsed.VideoSections))
|
||||
seenDocuments := make(map[int64]struct{}, len(parsed.Videos))
|
||||
out := make([]premiumPromoSeedVideo, 0, len(parsed.Videos))
|
||||
var totalSize int64
|
||||
for i, dj := range parsed.Videos {
|
||||
section := parsed.VideoSections[i]
|
||||
if !validPremiumPromoSection(section) {
|
||||
return nil, fmt.Errorf("video_sections[%d] %q is invalid", i, section)
|
||||
}
|
||||
if _, exists := seenSections[section]; exists {
|
||||
return nil, fmt.Errorf("duplicate video section %q", section)
|
||||
}
|
||||
seenSections[section] = struct{}{}
|
||||
|
||||
if dj.ID <= 0 {
|
||||
return nil, fmt.Errorf("videos[%d].id must be positive", i)
|
||||
}
|
||||
if _, exists := seenDocuments[dj.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate video document id %d", dj.ID)
|
||||
}
|
||||
seenDocuments[dj.ID] = struct{}{}
|
||||
if dj.AccessHash == 0 {
|
||||
return nil, fmt.Errorf("videos[%d].access_hash must be non-zero", i)
|
||||
}
|
||||
fileReference, err := hex.DecodeString(dj.FileReference)
|
||||
if err != nil || len(fileReference) == 0 {
|
||||
return nil, fmt.Errorf("videos[%d].file_reference is not non-empty hex", i)
|
||||
}
|
||||
date, err := time.Parse(time.RFC3339, dj.Date)
|
||||
if err != nil || date.Unix() < 0 || date.Unix() > 1<<31-1 {
|
||||
return nil, fmt.Errorf("videos[%d].date %q is outside TL int date range", i, dj.Date)
|
||||
}
|
||||
if dj.MimeType != "video/mp4" {
|
||||
return nil, fmt.Errorf("videos[%d].mime_type = %q, want video/mp4", i, dj.MimeType)
|
||||
}
|
||||
if dj.Size <= 0 || dj.Size > premiumPromoMaxVideoSize {
|
||||
return nil, fmt.Errorf("videos[%d].size %d is outside 1..%d", i, dj.Size, premiumPromoMaxVideoSize)
|
||||
}
|
||||
if err := validatePremiumPromoAttributes(i, dj.Attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", dj.ID))
|
||||
mainInfo, err := regularFileInfo(mainPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
|
||||
}
|
||||
if mainInfo.Size() != dj.Size {
|
||||
return nil, fmt.Errorf("videos[%d] main file size %d does not match manifest %d", i, mainInfo.Size(), dj.Size)
|
||||
}
|
||||
if err := validateMP4Header(mainPath); err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", dj.ID))
|
||||
thumbInfo, err := regularFileInfo(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
|
||||
}
|
||||
if thumbInfo.Size() <= 0 || thumbInfo.Size() > premiumPromoMaxThumbSize {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail size %d is outside 1..%d", i, thumbInfo.Size(), premiumPromoMaxThumbSize)
|
||||
}
|
||||
w, h, err := jpegDimensions(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
|
||||
}
|
||||
thumbType := premiumPromoThumbType(w, h)
|
||||
attributes := seedDocumentAttributes(dj.Attributes)
|
||||
document := domain.Document{
|
||||
ID: dj.ID,
|
||||
AccessHash: dj.AccessHash,
|
||||
FileReference: fileReference,
|
||||
Date: int(date.Unix()),
|
||||
MimeType: dj.MimeType,
|
||||
Size: dj.Size,
|
||||
DCID: 0, // overwritten with the canonical server DC by the caller
|
||||
Attributes: attributes,
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault,
|
||||
Type: thumbType,
|
||||
W: w,
|
||||
H: h,
|
||||
Size: int(thumbInfo.Size()),
|
||||
}},
|
||||
}
|
||||
out = append(out, premiumPromoSeedVideo{
|
||||
section: section,
|
||||
document: document,
|
||||
mainPath: mainPath,
|
||||
thumbPath: thumbPath,
|
||||
thumbType: thumbType,
|
||||
})
|
||||
totalSize += mainInfo.Size() + thumbInfo.Size()
|
||||
if totalSize > premiumPromoMaxTotalSize {
|
||||
return nil, fmt.Errorf("premium promo source bytes %d exceed limit %d", totalSize, premiumPromoMaxTotalSize)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePremiumPromoAttributes(index int, attrs []seedAttrJSON) error {
|
||||
var filename, video, animated int
|
||||
for j, attr := range attrs {
|
||||
switch attr.Type {
|
||||
case "DocumentAttributeFilename":
|
||||
filename++
|
||||
if strings.TrimSpace(attr.FileName) == "" {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has empty file_name", index, j)
|
||||
}
|
||||
case "DocumentAttributeVideo":
|
||||
video++
|
||||
if attr.W <= 0 || attr.W > 16384 || attr.H <= 0 || attr.H > 16384 {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has invalid video dimensions %dx%d", index, j, attr.W, attr.H)
|
||||
}
|
||||
if attr.Duration <= 0 || attr.Duration > 3600 {
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has invalid duration %v", index, j, attr.Duration)
|
||||
}
|
||||
case "DocumentAttributeAnimated":
|
||||
animated++
|
||||
default:
|
||||
return fmt.Errorf("videos[%d].attributes[%d] has unsupported type %q", index, j, attr.Type)
|
||||
}
|
||||
}
|
||||
if filename != 1 || video != 1 || animated > 1 {
|
||||
return fmt.Errorf("videos[%d] must contain exactly one filename/video and at most one animated attribute", index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPremiumPromoSection(section string) bool {
|
||||
if section == "" || len(section) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range section {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func regularFileInfo(path string) (os.FileInfo, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%q is not a regular file", path)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func validateMP4Header(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
header := make([]byte, 12)
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return fmt.Errorf("read MP4 header: %w", err)
|
||||
}
|
||||
if string(header[4:8]) != "ftyp" {
|
||||
return errors.New("missing ISO BMFF ftyp header")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jpegDimensions(path string) (int, int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, err := jpeg.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("decode JPEG config: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Width > 16384 || cfg.Height <= 0 || cfg.Height > 16384 {
|
||||
return 0, 0, fmt.Errorf("invalid JPEG dimensions %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func premiumPromoThumbType(w, h int) string {
|
||||
maxDimension := w
|
||||
if h > maxDimension {
|
||||
maxDimension = h
|
||||
}
|
||||
switch {
|
||||
case maxDimension <= 100:
|
||||
return "s"
|
||||
case maxDimension <= 320:
|
||||
return "m"
|
||||
case maxDimension <= 800:
|
||||
return "x"
|
||||
case maxDimension <= 1280:
|
||||
return "y"
|
||||
default:
|
||||
return "w"
|
||||
}
|
||||
}
|
||||
|
||||
func premiumPromoSeedHash(raw []byte, videos []premiumPromoSeedVideo, dc int) (string, error) {
|
||||
return seedStateHash(func(h hash.Hash) error {
|
||||
writeSeedStateHeader(h, premiumPromoSeedStateVersion, dc)
|
||||
if _, err := h.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
paths := make([]string, 0, len(videos)*2)
|
||||
for _, video := range videos {
|
||||
paths = append(paths, video.mainPath, video.thumbPath)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
info, err := regularFileInfo(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path))
|
||||
_, _ = fmt.Fprintf(h, "\nfile=%s\x00size=%d\x00mtime=%d", filepath.ToSlash(rel), info.Size(), info.ModTime().UnixNano())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadPremiumPromoCatalog(ctx context.Context, videos []premiumPromoSeedVideo) (domain.PremiumPromoCatalog, bool, error) {
|
||||
ids := make([]int64, 0, len(videos))
|
||||
locationKeys := make([]string, 0, len(videos)*2)
|
||||
for i := range videos {
|
||||
videos[i].document.DCID = s.dc
|
||||
ids = append(ids, videos[i].document.ID)
|
||||
locationKeys = append(
|
||||
locationKeys,
|
||||
fmt.Sprintf("doc:%d", videos[i].document.ID),
|
||||
fmt.Sprintf("doc:%d:%s", videos[i].document.ID, videos[i].thumbType),
|
||||
)
|
||||
}
|
||||
stored, err := s.media.GetDocuments(ctx, ids)
|
||||
if err != nil {
|
||||
return domain.PremiumPromoCatalog{}, false, err
|
||||
}
|
||||
if len(stored) != len(videos) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
byID := make(map[int64]domain.Document, len(stored))
|
||||
for _, doc := range stored {
|
||||
byID[doc.ID] = doc
|
||||
}
|
||||
blobs, err := s.media.GetFileBlobs(ctx, locationKeys)
|
||||
if err != nil {
|
||||
return domain.PremiumPromoCatalog{}, false, err
|
||||
}
|
||||
|
||||
catalog := domain.PremiumPromoCatalog{
|
||||
VideoSections: make([]string, 0, len(videos)),
|
||||
Videos: make([]domain.Document, 0, len(videos)),
|
||||
}
|
||||
for _, video := range videos {
|
||||
storedDoc, ok := byID[video.document.ID]
|
||||
if !ok || !premiumPromoDocumentEqual(storedDoc, video.document) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
mainKey := fmt.Sprintf("doc:%d", video.document.ID)
|
||||
thumbKey := fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType)
|
||||
if !s.premiumPromoBlobReady(ctx, blobs[mainKey], mainKey, video.document.Size, video.document.MimeType) {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
if !s.premiumPromoBlobReady(ctx, blobs[thumbKey], thumbKey, int64(video.document.Thumbs[0].Size), "image/jpeg") {
|
||||
return domain.PremiumPromoCatalog{}, false, nil
|
||||
}
|
||||
catalog.VideoSections = append(catalog.VideoSections, video.section)
|
||||
catalog.Videos = append(catalog.Videos, storedDoc)
|
||||
}
|
||||
return catalog, true, nil
|
||||
}
|
||||
|
||||
func premiumPromoDocumentEqual(got, want domain.Document) bool {
|
||||
return got.ID == want.ID &&
|
||||
got.AccessHash == want.AccessHash &&
|
||||
bytes.Equal(got.FileReference, want.FileReference) &&
|
||||
got.Date == want.Date &&
|
||||
got.MimeType == want.MimeType &&
|
||||
got.Size == want.Size &&
|
||||
got.DCID == want.DCID &&
|
||||
reflect.DeepEqual(got.Attributes, want.Attributes) &&
|
||||
reflect.DeepEqual(got.Thumbs, want.Thumbs)
|
||||
}
|
||||
|
||||
func (s *Service) premiumPromoBlobReady(ctx context.Context, blob domain.FileBlob, locationKey string, size int64, mimeType string) bool {
|
||||
if blob.LocationKey != locationKey ||
|
||||
blob.Backend != domain.MediaBackend(s.blobs.Name()) ||
|
||||
blob.ObjectKey == "" ||
|
||||
blob.Size != size ||
|
||||
blob.MimeType != mimeType {
|
||||
return false
|
||||
}
|
||||
_, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, 1)
|
||||
return err == nil && total == size
|
||||
}
|
||||
|
||||
func (s *Service) putPremiumPromoBlob(
|
||||
ctx context.Context,
|
||||
locationKey string,
|
||||
path string,
|
||||
mimeType string,
|
||||
wantSize int64,
|
||||
force bool,
|
||||
) (bool, error) {
|
||||
if !force {
|
||||
if blob, found, err := s.media.GetFileBlob(ctx, locationKey); err != nil {
|
||||
return false, err
|
||||
} else if found && s.premiumPromoBlobReady(ctx, blob, locationKey, wantSize, mimeType) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
objectKey, size, sum, err := s.blobs.PutReader(ctx, f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if size != wantSize {
|
||||
return false, fmt.Errorf("streamed size %d does not match validated size %d", size, wantSize)
|
||||
}
|
||||
blob := domain.FileBlob{
|
||||
LocationKey: locationKey,
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: size,
|
||||
SHA256: append([]byte(nil), sum...),
|
||||
MimeType: mimeType,
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, blob); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.blobCache.put(locationKey, blob)
|
||||
return true, nil
|
||||
}
|
||||
292
internal/app/files/premium_promo_seed_test.go
Normal file
292
internal/app/files/premium_promo_seed_test.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSeedPremiumPromoImportsDownloadsSkipsAndRepairs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root, videoBytes, thumbBytes := writePremiumPromoFixture(t)
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 7, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
|
||||
first, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo first: %v", err)
|
||||
}
|
||||
if first.Skipped || first.Videos != 1 || first.Blobs != 2 {
|
||||
t.Fatalf("first stats = %+v, want one video and two blobs", first)
|
||||
}
|
||||
catalog, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
|
||||
}
|
||||
if len(catalog.VideoSections) != 1 || catalog.VideoSections[0] != "no_ads" || len(catalog.Videos) != 1 {
|
||||
t.Fatalf("catalog = %+v", catalog)
|
||||
}
|
||||
doc := catalog.Videos[0]
|
||||
if doc.DCID != 7 || doc.MimeType != "video/mp4" || len(doc.Thumbs) != 1 || doc.Thumbs[0].Type != "m" {
|
||||
t.Fatalf("document = %+v", doc)
|
||||
}
|
||||
|
||||
main, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("doc:%d", doc.ID),
|
||||
Limit: len(videoBytes) + 1,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("download main ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !bytes.Equal(main.Bytes, videoBytes) {
|
||||
t.Fatalf("downloaded main = %x, want %x", main.Bytes, videoBytes)
|
||||
}
|
||||
thumb, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, doc.Thumbs[0].Type),
|
||||
Limit: len(thumbBytes) + 1,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("download thumb ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !bytes.Equal(thumb.Bytes, thumbBytes) {
|
||||
t.Fatalf("downloaded thumb differs: got %d bytes, want %d", len(thumb.Bytes), len(thumbBytes))
|
||||
}
|
||||
|
||||
// Returned values are request-owned: mutating one response must not corrupt
|
||||
// the immutable catalog seen by later/concurrent requests.
|
||||
catalog.VideoSections[0] = "mutated"
|
||||
catalog.Videos[0].FileReference[0] ^= 0xff
|
||||
catalog.Videos[0].Thumbs[0].Type = "z"
|
||||
again, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo again found=%v err=%v", found, err)
|
||||
}
|
||||
if again.VideoSections[0] != "no_ads" || again.Videos[0].Thumbs[0].Type != "m" || again.Videos[0].FileReference[0] != 0 {
|
||||
t.Fatalf("catalog was mutated through returned value: %+v", again)
|
||||
}
|
||||
|
||||
var readers sync.WaitGroup
|
||||
readerErrors := make(chan error, 32)
|
||||
for i := 0; i < 32; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
got, found, err := svc.PremiumPromo(ctx)
|
||||
if err != nil || !found || len(got.Videos) != 1 {
|
||||
readerErrors <- fmt.Errorf("found=%v videos=%d err=%v", found, len(got.Videos), err)
|
||||
return
|
||||
}
|
||||
got.VideoSections[0] = "request-owned"
|
||||
got.Videos[0].FileReference[0] = 0x7f
|
||||
}()
|
||||
}
|
||||
readers.Wait()
|
||||
close(readerErrors)
|
||||
for err := range readerErrors {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
second, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo unchanged: %v", err)
|
||||
}
|
||||
if !second.Skipped || second.Videos != 1 || second.Blobs != 0 {
|
||||
t.Fatalf("unchanged stats = %+v, want skipped catalog", second)
|
||||
}
|
||||
|
||||
mainKey := fmt.Sprintf("doc:%d", doc.ID)
|
||||
media.mu.Lock()
|
||||
delete(media.blobs, mainKey)
|
||||
media.mu.Unlock()
|
||||
repaired, err := svc.SeedPremiumPromo(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo repair: %v", err)
|
||||
}
|
||||
if repaired.Skipped || repaired.Videos != 1 || repaired.Blobs != 1 {
|
||||
t.Fatalf("repair stats = %+v, want one repaired blob", repaired)
|
||||
}
|
||||
if _, ok, err := media.GetFileBlob(ctx, mainKey); err != nil || !ok {
|
||||
t.Fatalf("repaired main blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedPremiumPromoMissingAndInvalidSources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
newService := func(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
return NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
}
|
||||
|
||||
t.Run("missing directory falls back", func(t *testing.T) {
|
||||
svc := newService(t)
|
||||
stats, err := svc.SeedPremiumPromo(ctx, filepath.Join(t.TempDir(), "missing"))
|
||||
if err != nil || !stats.Skipped {
|
||||
t.Fatalf("stats=%+v err=%v, want optional-resource fallback", stats, err)
|
||||
}
|
||||
if _, found, err := svc.PremiumPromo(ctx); err != nil || found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v, want unavailable", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing directory without manifest fails", func(t *testing.T) {
|
||||
svc := newService(t)
|
||||
if _, err := svc.SeedPremiumPromo(ctx, t.TempDir()); err == nil {
|
||||
t.Fatal("existing incomplete seed directory was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positional vectors must match", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
|
||||
m["video_sections"] = []string{"no_ads", "extra"}
|
||||
})
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("mismatched video_sections/videos was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manifest size must match file", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
|
||||
videos := m["videos"].([]any)
|
||||
videos[0].(map[string]any)["size"] = float64(999)
|
||||
})
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("wrong video size was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing thumbnail fails", func(t *testing.T) {
|
||||
root, _, _ := writePremiumPromoFixture(t)
|
||||
thumbPath := filepath.Join(root, "thumbs", "1000000000000001.jpg")
|
||||
if err := os.Remove(thumbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
|
||||
t.Fatal("missing thumbnail was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSeedPremiumPromoFromRealExport(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR not set")
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("seed dir %s not present: %v", root, err)
|
||||
}
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
svc := NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
|
||||
stats, err := svc.SeedPremiumPromo(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPremiumPromo: %v", err)
|
||||
}
|
||||
catalog, found, err := svc.PremiumPromo(context.Background())
|
||||
if err != nil || !found {
|
||||
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
|
||||
}
|
||||
if stats.Videos != 31 || len(catalog.VideoSections) != 31 || len(catalog.Videos) != 31 {
|
||||
t.Fatalf("stats=%+v sections=%d videos=%d, want 31", stats, len(catalog.VideoSections), len(catalog.Videos))
|
||||
}
|
||||
t.Logf("real premium promo seed: videos=%d blobs=%d", stats.Videos, stats.Blobs)
|
||||
}
|
||||
|
||||
func writePremiumPromoFixture(t *testing.T) (string, []byte, []byte) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "thumbs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const documentID int64 = 1000000000000001
|
||||
videoBytes := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0, 0, 0, 0}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", documentID)), videoBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
|
||||
for y := 0; y < 240; y++ {
|
||||
for x := 0; x < 160; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x88, A: 0xff})
|
||||
}
|
||||
}
|
||||
var thumb bytes.Buffer
|
||||
if err := jpeg.Encode(&thumb, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thumbBytes := thumb.Bytes()
|
||||
if err := os.WriteFile(filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", documentID)), thumbBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := premiumPromoSeedJSON{
|
||||
APICall: "help.getPremiumPromo",
|
||||
StatusText: "ignored source status",
|
||||
VideoSections: []string{"no_ads"},
|
||||
Videos: []seedDocumentJSON{{
|
||||
ID: documentID,
|
||||
AccessHash: -7,
|
||||
FileReference: "00112233445566778899aabbccddeeff",
|
||||
Date: "2026-01-02T03:04:05Z",
|
||||
MimeType: "video/mp4",
|
||||
Size: int64(len(videoBytes)),
|
||||
DCID: 4,
|
||||
Attributes: []seedAttrJSON{
|
||||
{Type: "DocumentAttributeFilename", FileName: "promo.mp4"},
|
||||
{Type: "DocumentAttributeVideo", W: 720, H: 1070, Duration: 5, SupportsStreaming: true},
|
||||
{Type: "DocumentAttributeAnimated"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, premiumPromoManifestName), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, append([]byte(nil), videoBytes...), append([]byte(nil), thumbBytes...)
|
||||
}
|
||||
|
||||
func rewritePremiumPromoManifest(t *testing.T, root string, mutate func(map[string]any)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, premiumPromoManifestName)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutate(manifest)
|
||||
raw, err = json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -71,6 +72,13 @@ type Service struct {
|
|||
// effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。
|
||||
effects []domain.AvailableEffect
|
||||
effectsHash int
|
||||
|
||||
// premiumPromo is populated during startup seed and then read by RPC
|
||||
// handlers. Keep a lock so the ownership boundary remains race-safe even
|
||||
// when exercised concurrently in tests.
|
||||
premiumPromoMu sync.RWMutex
|
||||
premiumPromo domain.PremiumPromoCatalog
|
||||
premiumPromoReady bool
|
||||
}
|
||||
|
||||
// Option 配置 files 服务的可选能力。
|
||||
|
|
|
|||
|
|
@ -211,6 +211,9 @@ type Config struct {
|
|||
StickerSeedDir string
|
||||
// StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。
|
||||
StickerSeedMaxSets int
|
||||
// PremiumPromoSeedDir 是 help.getPremiumPromo 视频与缩略图导出目录。
|
||||
// 目录缺失时保留无视频兼容响应;目录存在但内容非法时启动失败。
|
||||
PremiumPromoSeedDir string
|
||||
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
|
||||
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
|
||||
// "template" 使用 quick reply 模板。
|
||||
|
|
@ -566,6 +569,7 @@ func Load() (Config, error) {
|
|||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
|
||||
PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"),
|
||||
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
|
||||
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
|
||||
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,22 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
|||
if cfg.CallRegistryMaxEntries != 10_000 {
|
||||
t.Fatalf("CallRegistryMaxEntries = %d, want 10000", cfg.CallRegistryMaxEntries)
|
||||
}
|
||||
if cfg.PremiumPromoSeedDir != "data/premium-promo" {
|
||||
t.Fatalf("PremiumPromoSeedDir = %q, want data/premium-promo", cfg.PremiumPromoSeedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPremiumPromoSeedDirOverride(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_PREMIUM_PROMO_SEED_DIR", `D:\seed\premium-promo`)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.PremiumPromoSeedDir != `D:\seed\premium-promo` {
|
||||
t.Fatalf("PremiumPromoSeedDir = %q", cfg.PremiumPromoSeedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||
|
|
|
|||
10
internal/domain/premium.go
Normal file
10
internal/domain/premium.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package domain
|
||||
|
||||
// PremiumPromoCatalog is the immutable, domain-only media catalog returned by
|
||||
// help.getPremiumPromo. VideoSections[i] describes Videos[i]; callers must
|
||||
// preserve the one-to-one ordering because official clients use positional
|
||||
// lookup.
|
||||
type PremiumPromoCatalog struct {
|
||||
VideoSections []string
|
||||
Videos []Document
|
||||
}
|
||||
|
|
@ -55,7 +55,6 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
|||
tg.HelpGetPeerProfileColorsRequestTypeID,
|
||||
tg.HelpGetPromoDataRequestTypeID,
|
||||
tg.HelpGetTermsOfServiceUpdateRequestTypeID,
|
||||
tg.HelpGetPremiumPromoRequestTypeID,
|
||||
tg.LangpackGetLanguagesRequestTypeID,
|
||||
tg.LangpackGetLanguageRequestTypeID,
|
||||
tg.LangpackGetLangPackRequestTypeID,
|
||||
|
|
|
|||
|
|
@ -924,6 +924,13 @@ type ModerationService interface {
|
|||
ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error)
|
||||
}
|
||||
|
||||
// PremiumPromoService exposes the immutable promo media catalog through a
|
||||
// domain-only boundary. File bytes remain served by upload.getFile through the
|
||||
// ordinary Files service.
|
||||
type PremiumPromoService interface {
|
||||
PremiumPromo(ctx context.Context) (domain.PremiumPromoCatalog, bool, error)
|
||||
}
|
||||
|
||||
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。
|
||||
type Deps struct {
|
||||
Auth AuthService
|
||||
|
|
@ -956,6 +963,7 @@ type Deps struct {
|
|||
Channels ChannelsService
|
||||
Communities CommunitiesService
|
||||
Files FilesService
|
||||
PremiumPromo PremiumPromoService
|
||||
Bots BotsService
|
||||
Polls PollsService
|
||||
Phone PhoneService
|
||||
|
|
|
|||
|
|
@ -176,11 +176,10 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
|
|||
return androidcompat.DismissSuggestion(req.Suggestion), nil
|
||||
}
|
||||
|
||||
// onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer
|
||||
// 的会员有效期生成;videos/period_options 留空——购买入口已被 appConfig
|
||||
// premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段(TDesktop
|
||||
// 空 period_options 仅隐藏价格按钮,DrKLO 回退到无价文案,均不报错)。
|
||||
// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。
|
||||
// onHelpGetPremiumPromo returns the viewer-specific Premium status plus the
|
||||
// immutable, startup-seeded video catalog. Period options intentionally remain
|
||||
// empty: telesrv has no subscription purchase backend and must not advertise
|
||||
// dead payment URLs. All six TL fields are mandatory.
|
||||
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
|
||||
promo := &tg.HelpPremiumPromo{
|
||||
StatusText: branding.PremiumName + " is not active on this account.",
|
||||
|
|
@ -190,17 +189,42 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
|
|||
PeriodOptions: []tg.PremiumSubscriptionOption{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil || r.deps.Users == nil {
|
||||
return promo, nil
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return nil, authKeyUnregisteredErr()
|
||||
}
|
||||
if r.deps.Users == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
u, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil {
|
||||
return promo, nil
|
||||
return nil, internalErr()
|
||||
}
|
||||
if u.ID != userID {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if u.Bot {
|
||||
return nil, botMethodInvalidErr()
|
||||
}
|
||||
if u.PremiumActiveAt(r.clock.Now().Unix()) {
|
||||
until := time.Unix(int64(u.PremiumUntil), 0)
|
||||
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
|
||||
}
|
||||
if r.deps.PremiumPromo != nil {
|
||||
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if found {
|
||||
if len(catalog.VideoSections) != len(catalog.Videos) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
promo.VideoSections = append([]string(nil), catalog.VideoSections...)
|
||||
promo.Videos = tgDocuments(catalog.Videos)
|
||||
}
|
||||
}
|
||||
return promo, nil
|
||||
}
|
||||
|
|
|
|||
176
internal/rpc/help_premium_promo_test.go
Normal file
176
internal/rpc/help_premium_promo_test.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type staticPremiumPromoService struct {
|
||||
catalog domain.PremiumPromoCatalog
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticPremiumPromoService) PremiumPromo(context.Context) (domain.PremiumPromoCatalog, bool, error) {
|
||||
out := domain.PremiumPromoCatalog{
|
||||
VideoSections: append([]string(nil), s.catalog.VideoSections...),
|
||||
Videos: append([]domain.Document(nil), s.catalog.Videos...),
|
||||
}
|
||||
for i := range out.Videos {
|
||||
out.Videos[i].FileReference = append([]byte(nil), out.Videos[i].FileReference...)
|
||||
out.Videos[i].Attributes = append([]domain.DocumentAttribute(nil), out.Videos[i].Attributes...)
|
||||
out.Videos[i].Thumbs = append([]domain.PhotoSize(nil), out.Videos[i].Thumbs...)
|
||||
}
|
||||
return out, s.found, s.err
|
||||
}
|
||||
|
||||
func TestHelpGetPremiumPromoReturnsSeededCatalogAcrossExactProfiles(t *testing.T) {
|
||||
const userID int64 = 1000000001
|
||||
now := time.Date(2026, 7, 26, 8, 0, 0, 0, time.UTC)
|
||||
user := domain.User{
|
||||
ID: userID,
|
||||
AccessHash: 17,
|
||||
FirstName: "Alice",
|
||||
PremiumUntil: int(now.Add(48 * time.Hour).Unix()),
|
||||
}
|
||||
catalog := premiumPromoRPCTestCatalog()
|
||||
r := New(Config{}, Deps{
|
||||
Users: staticUsersService{user: user},
|
||||
PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true},
|
||||
}, zaptest.NewLogger(t), fixedClock{now: now})
|
||||
ctx := WithUserID(context.Background(), userID)
|
||||
|
||||
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
||||
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
|
||||
result, method := dispatchExactLayerRPCTest(t, r, ctx, profile, &tg.HelpGetPremiumPromoRequest{})
|
||||
if method != "help.getPremiumPromo" {
|
||||
t.Fatalf("method = %q", method)
|
||||
}
|
||||
promo, ok := dispatchCanonicalValue(result).(*tg.HelpPremiumPromo)
|
||||
if !ok {
|
||||
t.Fatalf("response = %T, want *tg.HelpPremiumPromo", dispatchCanonicalValue(result))
|
||||
}
|
||||
if len(promo.VideoSections) != 1 || promo.VideoSections[0] != "no_ads" || len(promo.Videos) != 1 {
|
||||
t.Fatalf("promo vectors = sections:%v videos:%d", promo.VideoSections, len(promo.Videos))
|
||||
}
|
||||
doc, ok := promo.Videos[0].(*tg.Document)
|
||||
if !ok {
|
||||
t.Fatalf("video = %T, want *tg.Document", promo.Videos[0])
|
||||
}
|
||||
if doc.ID != catalog.Videos[0].ID || doc.DCID != 2 || len(doc.Thumbs) != 1 {
|
||||
t.Fatalf("document = %+v", doc)
|
||||
}
|
||||
thumb, ok := doc.Thumbs[0].(*tg.PhotoSize)
|
||||
if !ok || thumb.Type != "m" || thumb.Size != 1234 {
|
||||
t.Fatalf("thumb = %#v", doc.Thumbs[0])
|
||||
}
|
||||
if len(promo.PeriodOptions) != 0 {
|
||||
t.Fatalf("period options = %+v, want no dead purchase entry", promo.PeriodOptions)
|
||||
}
|
||||
if !strings.Contains(promo.StatusText, "2026-07-28") {
|
||||
t.Fatalf("status text = %q, want viewer expiry", promo.StatusText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpGetPremiumPromoAuthorizationBotAndFallback(t *testing.T) {
|
||||
const userID int64 = 1000000001
|
||||
user := domain.User{ID: userID, AccessHash: 17, FirstName: "Alice"}
|
||||
r := New(Config{}, Deps{
|
||||
Users: staticUsersService{user: user},
|
||||
PremiumPromo: staticPremiumPromoService{},
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
|
||||
|
||||
if rpcAllowedWithoutAuthorization(tg.HelpGetPremiumPromoRequestTypeID) {
|
||||
t.Fatal("help.getPremiumPromo must require a fully authorized user")
|
||||
}
|
||||
if _, err := r.onHelpGetPremiumPromo(context.Background()); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") {
|
||||
t.Fatalf("unauthorized error = %v, want AUTH_KEY_UNREGISTERED", err)
|
||||
}
|
||||
|
||||
promo, err := r.onHelpGetPremiumPromo(WithUserID(context.Background(), userID))
|
||||
if err != nil {
|
||||
t.Fatalf("fallback response: %v", err)
|
||||
}
|
||||
if len(promo.VideoSections) != 0 || len(promo.Videos) != 0 || len(promo.PeriodOptions) != 0 {
|
||||
t.Fatalf("fallback vectors = %+v", promo)
|
||||
}
|
||||
|
||||
botRouter := New(Config{}, Deps{
|
||||
Users: staticUsersService{user: domain.User{
|
||||
ID: userID,
|
||||
AccessHash: 19,
|
||||
FirstName: "PromoBot",
|
||||
Bot: true,
|
||||
}},
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
|
||||
if _, err := botRouter.onHelpGetPremiumPromo(WithUserID(context.Background(), userID)); !tgerr.Is(err, "BOT_METHOD_INVALID") {
|
||||
t.Fatalf("bot error = %v, want BOT_METHOD_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpGetPremiumPromoResponsesDoNotShareMutableDocuments(t *testing.T) {
|
||||
const userID int64 = 1000000001
|
||||
catalog := premiumPromoRPCTestCatalog()
|
||||
r := New(Config{}, Deps{
|
||||
Users: staticUsersService{user: domain.User{ID: userID}},
|
||||
PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true},
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
|
||||
ctx := WithUserID(context.Background(), userID)
|
||||
|
||||
first, err := r.onHelpGetPremiumPromo(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.VideoSections[0] = "mutated"
|
||||
firstDoc := first.Videos[0].(*tg.Document)
|
||||
firstDoc.DCID = 99
|
||||
firstDoc.FileReference[0] ^= 0xff
|
||||
|
||||
second, err := r.onHelpGetPremiumPromo(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondDoc := second.Videos[0].(*tg.Document)
|
||||
if second.VideoSections[0] != "no_ads" || secondDoc.DCID != 2 || secondDoc.FileReference[0] != 0 {
|
||||
t.Fatalf("second response inherited mutation: sections=%v doc=%+v", second.VideoSections, secondDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func premiumPromoRPCTestCatalog() domain.PremiumPromoCatalog {
|
||||
return domain.PremiumPromoCatalog{
|
||||
VideoSections: []string{"no_ads"},
|
||||
Videos: []domain.Document{{
|
||||
ID: 5814500255441357739,
|
||||
AccessHash: 5876417653416908580,
|
||||
FileReference: []byte{0, 1, 2, 3},
|
||||
Date: 1_654_006_663,
|
||||
MimeType: "video/mp4",
|
||||
Size: 2_650_178,
|
||||
DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrFilename, FileName: "promo.mp4"},
|
||||
{Kind: domain.DocAttrVideo, W: 720, H: 1070, Duration: 5, SupportsStreaming: true},
|
||||
{Kind: domain.DocAttrAnimated},
|
||||
},
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault,
|
||||
Type: "m",
|
||||
W: 160,
|
||||
H: 240,
|
||||
Size: 1234,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue