chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
230
internal/app/themes/service.go
Normal file
230
internal/app/themes/service.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// 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)
|
||||
}
|
||||
72
internal/app/themes/service_test.go
Normal file
72
internal/app/themes/service_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package themes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewThemeStore())
|
||||
|
||||
const owner = int64(1001)
|
||||
a, err := svc.Create(ctx, domain.ThemeSpec{CreatorUserID: owner, Title: "A", DocumentID: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
if a.Slug == "" || a.ID == 0 || a.AccessHash == 0 {
|
||||
t.Fatalf("create a = %+v, want auto slug + ids", a)
|
||||
}
|
||||
if !a.IsCreator(owner) {
|
||||
t.Fatalf("creator mismatch")
|
||||
}
|
||||
|
||||
// 两次空 slug 创建得到不同的自动 slug。
|
||||
b, err := svc.Create(ctx, domain.ThemeSpec{CreatorUserID: owner, Title: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
if b.Slug == a.Slug {
|
||||
t.Fatalf("auto slugs collided: %q", a.Slug)
|
||||
}
|
||||
|
||||
// 非创建者不能改。
|
||||
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
|
||||
// 创建者可改 title + document。
|
||||
newTitle := "A2"
|
||||
newDoc := int64(99)
|
||||
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
|
||||
if err != nil {
|
||||
t.Fatalf("creator update: %v", err)
|
||||
}
|
||||
if updated.Title != "A2" || updated.DocumentID != 99 {
|
||||
t.Fatalf("update result = %+v, want title A2 doc 99", updated)
|
||||
}
|
||||
|
||||
// install 计数 + 列表。
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug})
|
||||
if !ok || got.InstallsCount != 1 {
|
||||
t.Fatalf("after install installs = %d ok=%v, want 1", got.InstallsCount, ok)
|
||||
}
|
||||
list, _ := svc.ListInstalled(ctx, owner)
|
||||
if len(list) != 1 || list[0].ID != a.ID {
|
||||
t.Fatalf("installed list = %+v, want [a]", list)
|
||||
}
|
||||
|
||||
// 未知引用 → ErrThemeInvalid。
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
Loading…
Add table
Add a link
Reference in a new issue