230 lines
6.4 KiB
Go
230 lines
6.4 KiB
Go
// Package themes 实现自定义云主题(account.createTheme/updateTheme/getTheme/
|
||
// saveTheme/installTheme)的业务编排:分配 id/access_hash/slug、校验创建者、维护每用户
|
||
// 已安装列表。文件上传(uploadTheme)由 rpc 层直接经 Files 服务完成,不经此服务。
|
||
package themes
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"strings"
|
||
"time"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store"
|
||
)
|
||
|
||
// 自定义主题 id/access_hash 用 crypto 随机 63 位,刻意区别于静态目录的保留区间
|
||
// (静态 emoji 主题用 92e16/93e16),避免 install/get-by-id 串到静态目录。
|
||
const defaultSlugPrefix = "t-"
|
||
|
||
// slug 字母表:小写 + 数字,URL 安全。
|
||
const slugAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
|
||
// Service 提供自定义云主题业务。
|
||
type Service struct {
|
||
store store.ThemeStore
|
||
now func() time.Time
|
||
slugPrefix string
|
||
}
|
||
|
||
// Option 调整主题服务可选项。
|
||
type Option func(*Service)
|
||
|
||
// WithClock 注入时钟(测试用)。
|
||
func WithClock(now func() time.Time) Option {
|
||
return func(s *Service) {
|
||
if now != nil {
|
||
s.now = now
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithSlugPrefix 覆盖自动分配 slug 的前缀。
|
||
func WithSlugPrefix(prefix string) Option {
|
||
return func(s *Service) { s.slugPrefix = prefix }
|
||
}
|
||
|
||
// NewService 创建主题服务。
|
||
func NewService(st store.ThemeStore, opts ...Option) *Service {
|
||
s := &Service{store: st, now: time.Now, slugPrefix: defaultSlugPrefix}
|
||
for _, opt := range opts {
|
||
opt(s)
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (s *Service) ready() bool { return s != nil && s.store != nil }
|
||
|
||
// Create 创建一份新主题。spec.Slug 为空时自动分配;slug 冲突自动重试。
|
||
func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.Theme, error) {
|
||
if !s.ready() {
|
||
return domain.Theme{}, domain.ErrThemeInvalid
|
||
}
|
||
if spec.CreatorUserID == 0 {
|
||
return domain.Theme{}, domain.ErrThemeInvalid
|
||
}
|
||
userSlug := strings.TrimSpace(spec.Slug)
|
||
// 最多重试若干次(自动 slug/极小概率 id 冲突)。
|
||
for attempt := 0; attempt < 8; attempt++ {
|
||
t := domain.Theme{
|
||
ID: randInt63(),
|
||
AccessHash: randInt63(),
|
||
CreatorUserID: spec.CreatorUserID,
|
||
Title: spec.Title,
|
||
Emoticon: spec.Emoticon,
|
||
ForChat: spec.ForChat,
|
||
DocumentID: spec.DocumentID,
|
||
Settings: spec.Settings,
|
||
InstallsCount: 0,
|
||
CreatedAt: s.now().Unix(),
|
||
}
|
||
if userSlug != "" {
|
||
t.Slug = userSlug
|
||
} else {
|
||
t.Slug = s.slugPrefix + randSlug(12)
|
||
}
|
||
err := s.store.CreateTheme(ctx, t)
|
||
switch {
|
||
case err == nil:
|
||
return t.Clone(), nil
|
||
case err == domain.ErrThemeSlugTaken && userSlug != "":
|
||
// 用户指定的 slug 已被占用:不重试,直接报错。
|
||
return domain.Theme{}, domain.ErrThemeSlugTaken
|
||
case err == domain.ErrThemeSlugTaken:
|
||
continue // 自动 slug/id 冲突:换一组重试
|
||
default:
|
||
return domain.Theme{}, err
|
||
}
|
||
}
|
||
return domain.Theme{}, domain.ErrThemeSlugTaken
|
||
}
|
||
|
||
func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
|
||
if ref.ID != 0 {
|
||
return s.store.GetThemeByID(ctx, ref.ID)
|
||
}
|
||
if ref.Slug != "" {
|
||
return s.store.GetThemeBySlug(ctx, ref.Slug)
|
||
}
|
||
return domain.Theme{}, false, nil
|
||
}
|
||
|
||
// Get 按 id 或 slug 解析一份主题。
|
||
func (s *Service) Get(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
|
||
if !s.ready() {
|
||
return domain.Theme{}, false, domain.ErrThemeInvalid
|
||
}
|
||
return s.resolve(ctx, ref)
|
||
}
|
||
|
||
// Update 更新一份主题(仅创建者可改);部分字段。
|
||
func (s *Service) Update(ctx context.Context, userID int64, ref domain.ThemeRef, upd domain.ThemeUpdate) (domain.Theme, error) {
|
||
if !s.ready() {
|
||
return domain.Theme{}, domain.ErrThemeInvalid
|
||
}
|
||
t, ok, err := s.resolve(ctx, ref)
|
||
if err != nil {
|
||
return domain.Theme{}, err
|
||
}
|
||
if !ok {
|
||
return domain.Theme{}, domain.ErrThemeNotFound
|
||
}
|
||
if !t.IsCreator(userID) {
|
||
return domain.Theme{}, domain.ErrThemeInvalid
|
||
}
|
||
if upd.Slug != nil {
|
||
t.Slug = strings.TrimSpace(*upd.Slug)
|
||
}
|
||
if upd.Title != nil {
|
||
t.Title = *upd.Title
|
||
}
|
||
if upd.DocumentID != nil {
|
||
t.DocumentID = *upd.DocumentID
|
||
}
|
||
if upd.Settings != nil {
|
||
t.Settings = *upd.Settings
|
||
}
|
||
if err := s.store.UpdateTheme(ctx, t); err != nil {
|
||
return domain.Theme{}, err
|
||
}
|
||
return t.Clone(), nil
|
||
}
|
||
|
||
// Save 把主题加入用户已存列表(saveTheme,unsave=false)。
|
||
func (s *Service) Save(ctx context.Context, userID int64, ref domain.ThemeRef) error {
|
||
return s.membership(ctx, userID, ref, false, false)
|
||
}
|
||
|
||
// Unsave 从用户列表移除(saveTheme,unsave=true)。
|
||
func (s *Service) Unsave(ctx context.Context, userID int64, ref domain.ThemeRef) error {
|
||
if !s.ready() {
|
||
return domain.ErrThemeInvalid
|
||
}
|
||
t, ok, err := s.resolve(ctx, ref)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return domain.ErrThemeInvalid
|
||
}
|
||
return s.store.RemoveInstalled(ctx, userID, t.ID)
|
||
}
|
||
|
||
// Install 应用主题:计数 +1 并加入用户已安装列表。
|
||
func (s *Service) Install(ctx context.Context, userID int64, ref domain.ThemeRef, dark bool) error {
|
||
return s.membership(ctx, userID, ref, true, dark)
|
||
}
|
||
|
||
func (s *Service) membership(ctx context.Context, userID int64, ref domain.ThemeRef, increment, dark bool) error {
|
||
if !s.ready() {
|
||
return domain.ErrThemeInvalid
|
||
}
|
||
t, ok, err := s.resolve(ctx, ref)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return domain.ErrThemeInvalid
|
||
}
|
||
if increment {
|
||
if err := s.store.IncrementInstalls(ctx, t.ID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return s.store.SetInstalled(ctx, userID, t.ID, dark)
|
||
}
|
||
|
||
// ListInstalled 返回用户已安装/已存主题。
|
||
func (s *Service) ListInstalled(ctx context.Context, userID int64) ([]domain.Theme, error) {
|
||
if !s.ready() {
|
||
return nil, domain.ErrThemeInvalid
|
||
}
|
||
return s.store.ListInstalledByUser(ctx, userID)
|
||
}
|
||
|
||
// ListForUser 返回用户「创建 ∪ 安装」的主题,供 getThemes 跨设备同步。
|
||
func (s *Service) ListForUser(ctx context.Context, userID int64) ([]domain.Theme, error) {
|
||
if !s.ready() {
|
||
return nil, domain.ErrThemeInvalid
|
||
}
|
||
return s.store.ListThemesForUser(ctx, userID)
|
||
}
|
||
|
||
// randInt63 返回一个 crypto 随机的正 63 位整数。
|
||
func randInt63() int64 {
|
||
var b [8]byte
|
||
_, _ = rand.Read(b[:])
|
||
return int64(binary.BigEndian.Uint64(b[:]) & 0x7fffffffffffffff)
|
||
}
|
||
|
||
// randSlug 返回 n 个字符的随机 slug 片段。
|
||
func randSlug(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
out := make([]byte, n)
|
||
for i := range out {
|
||
out[i] = slugAlphabet[int(b[i])%len(slugAlphabet)]
|
||
}
|
||
return string(out)
|
||
}
|