From ce2cdf6bf30fbbeb7107550102fad535068d3a6e Mon Sep 17 00:00:00 2001 From: "DESKTOP-L8OUL9F\\gfl0034" <1003614916@qq.com> Date: Wed, 23 Sep 2026 16:07:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20music=20=E8=BF=90=E8=90=A5=E6=9B=B2?= =?UTF-8?q?=E7=9B=AE=E3=80=81seed=20=E6=9C=BA=E5=99=A8=E4=BA=BA=E6=AC=A2?= =?UTF-8?q?=E8=BF=8E=E6=B6=88=E6=81=AF=EF=BC=8C=E5=B9=B6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=20novels/music=20OpenAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 origin/main 设备注册后,纳入本地 music 模块、seed 命令与 popadmin 相关路由/文档;music migration 编号为 0020 以避免与 0019_devices 冲突。 Co-authored-by: Cursor --- .cursor/skills/spark-im-v3/enums.md | 2 +- Makefile | 6 +- README.md | 20 + cmd/seed/main.go | 90 ++ .../host/popadmin-spark-admin-locations.conf | 5 +- docs/popadmin-music-novels-deploy.md | 41 + internal/bootstrap/app.go | 27 +- internal/infrastructure/objectstore/ops.go | 31 +- internal/module/group/model/find.go | 27 + internal/module/group/model/memory.go | 23 + internal/module/meta/logic/enums_wire.go | 4 +- internal/module/music/entity/track.go | 37 + internal/module/music/logic/service.go | 334 ++++++ internal/module/music/model/repo.go | 203 ++++ internal/module/seed/logic/bots.go | 100 ++ internal/module/seed/logic/group.go | 179 ++++ internal/module/seed/logic/nicknames.go | 49 + internal/module/seed/logic/service.go | 140 +++ internal/module/seed/logic/service_test.go | 113 +++ internal/module/seed/logic/welcome.go | 107 ++ internal/module/user/entity/bot.go | 45 + internal/module/user/entity/bot_test.go | 24 + internal/module/user/entity/system.go | 4 + internal/module/user/model/list.go | 38 + internal/module/user/model/memory.go | 26 + internal/pkg/i18n/catalog_enum_wire.go | 1 + internal/transport/http/auth.go | 3 +- internal/transport/http/docs/openapi.yaml | 948 +++++++++++++++++- .../transport/http/handler/admin/music.go | 199 ++++ .../transport/http/handler/music/handler.go | 45 + internal/transport/http/router.go | 13 + internal/transport/http/router_admin.go | 61 +- internal/transport/http/server.go | 2 + migrations/0020_music.sql | 26 + 34 files changed, 2927 insertions(+), 46 deletions(-) create mode 100644 cmd/seed/main.go create mode 100644 docs/popadmin-music-novels-deploy.md create mode 100644 internal/module/group/model/find.go create mode 100644 internal/module/music/entity/track.go create mode 100644 internal/module/music/logic/service.go create mode 100644 internal/module/music/model/repo.go create mode 100644 internal/module/seed/logic/bots.go create mode 100644 internal/module/seed/logic/group.go create mode 100644 internal/module/seed/logic/nicknames.go create mode 100644 internal/module/seed/logic/service.go create mode 100644 internal/module/seed/logic/service_test.go create mode 100644 internal/module/seed/logic/welcome.go create mode 100644 internal/module/user/entity/bot.go create mode 100644 internal/module/user/entity/bot_test.go create mode 100644 internal/module/user/entity/system.go create mode 100644 internal/module/user/model/list.go create mode 100644 internal/transport/http/handler/admin/music.go create mode 100644 internal/transport/http/handler/music/handler.go create mode 100644 migrations/0020_music.sql diff --git a/.cursor/skills/spark-im-v3/enums.md b/.cursor/skills/spark-im-v3/enums.md index b4f53ca..45d9c17 100644 --- a/.cursor/skills/spark-im-v3/enums.md +++ b/.cursor/skills/spark-im-v3/enums.md @@ -84,7 +84,7 @@ Do not convert these to integers. | `MomentStatus` | `moment/entity.Status` | | 0正常 1已删 | | `MomentNoticeKind` | `moment/entity.NoticeKind` | `kind` | 1赞 2评论 3回复 | | `MomentVisibleDays` | `moment/entity.VisibleDays` | `visible_days` | 0 / 3 / 30 / 180 | -| `SystemKind` | `friend/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` | +| `SystemKind` | `friend/entity.SystemKind*` / `user/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `system.welcome` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` | Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum. diff --git a/Makefile b/Makefile index ac8be9f..3072523 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LINT ?= golangci-lint CONFIG ?= configs/config.yaml LOGIC_COVER_MIN ?= 65 -.PHONY: all build image test lint fmt vet tidy migrate start run deploy deploy-rollback +.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback all: lint test build @@ -21,6 +21,7 @@ image: mkdir -p $(LINUX_DIR) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="$(LDFLAGS)" -o $(LINUX_DIR)/spark-server ./cmd/server CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="$(LDFLAGS)" -o $(LINUX_DIR)/spark-migrate ./cmd/migrate + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="$(LDFLAGS)" -o $(LINUX_DIR)/spark-seed ./cmd/seed docker build --platform linux/amd64 -t $(IMAGE) . # All unit tests, then each module logic package must be >= LOGIC_COVER_MIN. @@ -60,6 +61,9 @@ tidy: migrate: $(GO) run ./cmd/migrate -config $(CONFIG) +seed: + $(GO) run ./cmd/seed -config $(CONFIG) + start: $(GO) run ./cmd/server -config $(CONFIG) diff --git a/README.md b/README.md index 9878504..53f0a3f 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,26 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 `uid` 就是 Snowflake `users.id`。对外展示用可变的 `spark_id`,不要把 uid 写进对象存储路径。 +### 演示种子(欢迎消息 / 机器人 / 示例群) + +幂等命令(可重复执行): + +```bash +make seed +# 或 go run ./cmd/seed -config configs/config.yaml +``` + +会确保: + +| 项 | 说明 | +| --- | --- | +| 100 个机器人 | `bot001@spark.bot`~`bot100@spark.bot`,英文昵称 + 字母头像(`LetterAvatarDataURI`) | +| 测试号 | 若尚无则创建 `test01`~`test10` | +| 正式/测试用户 | 每人对 `Spark Assistant`(bot001)有好友边;一条 `system.welcome` 英文系统消息;一条英文打招呼文本 | +| 示例群 | 群名 `Spark Samples`,含多个英文昵称机器人 + 上述用户;覆盖文本/图片/语音/视频/表情/GIF/文件/位置/系统等类型 | + +「正式用户」= 库中已有、邮箱不是 `bot*@spark.bot` 的活跃账号;「测试用户」= `test01@spark.com`~`test10@spark.com`。 + --- ## HTTP 资源 diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..6a74858 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "go.uber.org/zap" + + "spark-server/internal/bootstrap" + "spark-server/internal/infrastructure/id" + "spark-server/internal/infrastructure/mysql" + redisx "spark-server/internal/infrastructure/redis" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + friendmodel "spark-server/internal/module/friend/model" + groupmodel "spark-server/internal/module/group/model" + msgmodel "spark-server/internal/module/message/model" + seedlogic "spark-server/internal/module/seed/logic" + usermodel "spark-server/internal/module/user/model" +) + +func main() { + cfgPath := flag.String("config", "configs/config.yaml", "config path") + flag.Parse() + if err := run(*cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func run(cfgPath string) error { + cfg, err := bootstrap.Load(cfgPath) + if err != nil { + return err + } + log := zap.NewNop() + db, err := mysql.Open(log, &mysql.Options{ + DSN: cfg.MySQL.DSN, + SlowThreshold: cfg.MySQL.SlowThreshold, + MaxOpenConns: cfg.MySQL.MaxOpenConns, + MaxIdleConns: cfg.MySQL.MaxIdleConns, + ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime, + ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime, + }) + if err != nil { + return fmt.Errorf("mysql: %w", err) + } + defer db.Close() + if !db.Ready() { + return fmt.Errorf("mysql: dsn is empty") + } + rdb, err := redisx.Open(&redisx.Options{ + Addr: cfg.Redis.Addr, + Username: cfg.Redis.Username, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) + if err != nil { + return fmt.Errorf("redis: %w", err) + } + defer rdb.Close() + pingCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := rdb.Ping(pingCtx); err != nil { + return fmt.Errorf("redis ping: %w", err) + } + ids, err := id.New(cfg.ID.SnowflakeWorkerID) + if err != nil { + return err + } + gdb := db.Gorm() + users := usermodel.NewRepo(gdb) + friends := friendmodel.NewRepo(gdb) + chats := convlogic.NewP2PService(convmodel.NewRepo(gdb), ids) + msgs := msgmodel.NewRepo(gdb) + groups := groupmodel.NewRepo(gdb) + svc := seedlogic.NewService(users, friends, chats, msgs, groups, ids) + ctx, seedCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer seedCancel() + res, err := svc.Run(ctx) + if err != nil { + return err + } + fmt.Printf("seed ok bots=%d targets=%d group_id=%d sample_msgs=%d\n", + res.Bots, res.Targets, res.GroupID, res.SampleMsgs) + return nil +} diff --git a/deploy/nginx/host/popadmin-spark-admin-locations.conf b/deploy/nginx/host/popadmin-spark-admin-locations.conf index e37c70f..59422b1 100644 --- a/deploy/nginx/host/popadmin-spark-admin-locations.conf +++ b/deploy/nginx/host/popadmin-spark-admin-locations.conf @@ -28,8 +28,8 @@ location ~ ^/api/admin/spark/users/export-zip { proxy_set_header X-Forwarded-Proto https; } -# spark-server:IM / 群 / 投诉 / 排行 / 用户检索与 V3 账号运营 -location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings)(/|$) { +# spark-server:IM / 群 / 投诉 / 排行 / 用户检索 / 音乐列表与 V3 账号运营 +location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings|music)(/|$) { proxy_pass http://spark_im_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -37,6 +37,7 @@ location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings)(/ proxy_set_header X-Client-Port $remote_port; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; + client_max_body_size 55m; } location ~ ^/api/admin/(spark|pop)/users/by-(nickname|device|ip)(\?|$) { diff --git a/docs/popadmin-music-novels-deploy.md b/docs/popadmin-music-novels-deploy.md new file mode 100644 index 0000000..3bcbb6c --- /dev/null +++ b/docs/popadmin-music-novels-deploy.md @@ -0,0 +1,41 @@ +# popadmin 音乐 / 小说 & sparkapi `/docs` 部署要点 + +## 1. spark-server(音乐 API + OpenAPI) + +```bash +# 新加坡机 +cd /opt/spark-server # 或实际路径 +# 拉代码后 +./cmd/migrate # 或项目惯用 migrate,执行 migrations/0020_music.sql +# 重启 spark-server 进程 / docker compose +sudo nginx -t && sudo nginx -s reload +# 确认 include 了更新后的 popadmin-spark-admin-locations.conf(含 music) +``` + +验证: + +- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0` +- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music / novels / admin-novels / 登录设备 Breaking 说明 +- popadmin 登录后「音乐列表」:`GET/POST /api/admin/spark/music` + +## 2. Aliyun wyrs-admin(popadmin 静态) + +热更或发版 `static/popadmin/js/popadmin.js`(侧栏「音乐列表」「小说内容管理」)。 + +新加坡若用 static-override,同步覆盖: + +`/opt/wyrs-admin/static-override/popadmin.js`(或等价路径)并 bump `?v=`。 + +## 3. FullStoryNovel CMS(小说 iframe) + +- 部署 `static/admin/admin.js`(`embed=1&from=popadmin` + postMessage SSO) +- 部署 nginx:去掉 `X-Frame-Options SAMEORIGIN`,改为 + `Content-Security-Policy: frame-ancestors 'self' https://popadmin.wyrs365.com` +- 确认 `fullstorynovel.service` :8095 与 `adopt-ops-session` 可用 + +验证:popadmin →「小说内容管理」iframe 加载 novels;或新窗口打开 `https://admin.fullstorynovel.com/admin/`。 + +## 4. 设备登录文档(devices-client) + +OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。 +**当前 Go 实现仍为旧登录**(Body 必填客户端 `device_id`、字符串 `device_type`,JWT 无 `aid`)。上线新契约前需改 auth/login、JWT、meta 枚举。 diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 7e1f436..d9840e9 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -41,6 +41,8 @@ import ( msgmodel "spark-server/internal/module/message/model" momentlogic "spark-server/internal/module/moment/logic" momentmodel "spark-server/internal/module/moment/model" + musiclogic "spark-server/internal/module/music/logic" + musicmodel "spark-server/internal/module/music/model" smslogic "spark-server/internal/module/sms/logic" squarelogic "spark-server/internal/module/square/logic" squaremodel "spark-server/internal/module/square/model" @@ -185,6 +187,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(idgen, db, objects, friends, users) square := wireSquare(idgen, db, objects, friends, users) + music := wireMusic(cfg, db, objects) users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...) adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen) app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ @@ -207,7 +210,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge Sync: syncSvc, }) app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps( - jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, messages, syncSvc, adminSvc, adminTokens, probe, authLimit, + jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, music, messages, syncSvc, adminSvc, adminTokens, probe, authLimit, )) return app, nil } @@ -223,6 +226,7 @@ func httpEngineDeps( favorites *favlogic.Service, moments *momentlogic.Service, square *squarelogic.Service, + music *musiclogic.Service, messages *msglogic.Service, syncSvc *synclogic.Service, adminSvc *adminlogic.Service, @@ -232,7 +236,7 @@ func httpEngineDeps( ) *httpx.EngineDeps { return &httpx.EngineDeps{ Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups, - Files: files, Favorites: favorites, Moments: moments, Square: square, + Files: files, Favorites: favorites, Moments: moments, Square: square, Music: music, Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens, Health: probe, AuthLimiter: authLimit, } @@ -359,6 +363,25 @@ func wireSquare(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, fri return squarelogic.NewService(repo, friends, users, objects, idgen) } +func wireMusic(cfg *Config, db *mysql.DB, store *objectstore.Store) *musiclogic.Service { + if db == nil || !db.Ready() { + return nil + } + var objects musiclogic.Objects + if store != nil && store.Ready() { + objects = store + } + publicBase := "" + bucket := "spark" + if cfg != nil { + publicBase = cfg.ObjectStorage.PublicEndpoint + if cfg.ObjectStorage.Bucket != "" { + bucket = cfg.ObjectStorage.Bucket + } + } + return musiclogic.NewService(musicmodel.NewRepo(db.Gorm()), objects, publicBase, bucket) +} + func wireMoments(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *momentlogic.Service { if idgen == nil || friends == nil || users == nil { return nil diff --git a/internal/infrastructure/objectstore/ops.go b/internal/infrastructure/objectstore/ops.go index 02ca246..1af6693 100644 --- a/internal/infrastructure/objectstore/ops.go +++ b/internal/infrastructure/objectstore/ops.go @@ -19,10 +19,37 @@ import ( ) const ( - maxGetBytes = 32 << 20 - maxPutBytes = 4 << 20 + maxGetBytes = 32 << 20 + maxPutBytes = 4 << 20 + maxPutLarge = 50 << 20 // admin music audio etc. ) +// PutLarge writes an object up to maxBytes (capped at 50 MiB). +func (s *Store) PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error { + if !s.Ready() { + return errs.ErrAvailable + } + if maxBytes <= 0 || maxBytes > maxPutLarge { + maxBytes = maxPutLarge + } + if len(body) == 0 || len(body) > maxBytes { + return errs.ErrInvalidArgument + } + in := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(body), + ContentLength: aws.Int64(int64(len(body))), + } + if contentType != "" { + in.ContentType = aws.String(contentType) + } + if _, err := s.client.PutObject(ctx, in); err != nil { + return mapObjectErr(err) + } + return nil +} + func (s *Store) PresignPut(ctx context.Context, key, contentType string, size int64, ttl time.Duration) (string, error) { if !s.Ready() { return "", errs.ErrAvailable diff --git a/internal/module/group/model/find.go b/internal/module/group/model/find.go new file mode 100644 index 0000000..e1c7aab --- /dev/null +++ b/internal/module/group/model/find.go @@ -0,0 +1,27 @@ +package model + +import ( + "context" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +// FindByOwnerAndName returns the active group owned by ownerID with the given name. +func (m *Repo) FindByOwnerAndName(ctx context.Context, ownerID int64, name string) (*entity.Group, error) { + if ownerID <= 0 || name == "" { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Group + err = db.Table("`groups`"). + Where("owner_id = ? AND name = ? AND status = ?", ownerID, name, entity.StatusActive). + Order("id ASC").Limit(1).Take(&row).Error + if err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} diff --git a/internal/module/group/model/memory.go b/internal/module/group/model/memory.go index ae1b603..e33fda1 100644 --- a/internal/module/group/model/memory.go +++ b/internal/module/group/model/memory.go @@ -50,6 +50,29 @@ func (m *MemoryRepo) GetByConversation(_ context.Context, conversationID int64) return copyGroup(m.groups[id]) } +// FindByOwnerAndName returns the active group owned by ownerID with the given name. +func (m *MemoryRepo) FindByOwnerAndName(_ context.Context, ownerID int64, name string) (*entity.Group, error) { + if ownerID <= 0 || name == "" { + return nil, errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + var best *entity.Group + for _, g := range m.groups { + if g == nil || g.OwnerID != ownerID || g.Name != name || g.Status != entity.StatusActive { + continue + } + if best == nil || g.ID < best.ID { + cp := *g + best = &cp + } + } + if best == nil { + return nil, errs.ErrNotFound + } + return best, nil +} + func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Group, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/module/meta/logic/enums_wire.go b/internal/module/meta/logic/enums_wire.go index 32ddd5d..0b3383d 100644 --- a/internal/module/meta/logic/enums_wire.go +++ b/internal/module/meta/logic/enums_wire.go @@ -5,6 +5,7 @@ import ( groupentity "spark-server/internal/module/group/entity" "spark-server/internal/module/meta/entity" uploadentity "spark-server/internal/module/upload/entity" + userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/transport/tcp/protocol" @@ -74,7 +75,8 @@ func uploadPurpose(lang i18n.Lang) []entity.Item { func systemKind(lang i18n.Lang) []entity.Item { return strSet(lang, "system_kind", - friendentity.SystemKindBecameFriends, groupentity.SystemKindCreated, groupentity.SystemKindInvite, + friendentity.SystemKindBecameFriends, userentity.SystemKindWelcome, + groupentity.SystemKindCreated, groupentity.SystemKindInvite, groupentity.SystemKindLeave, groupentity.SystemKindKick, groupentity.SystemKindDissolve, groupentity.SystemKindTransfer, ) } diff --git a/internal/module/music/entity/track.go b/internal/module/music/entity/track.go new file mode 100644 index 0000000..dbef646 --- /dev/null +++ b/internal/module/music/entity/track.go @@ -0,0 +1,37 @@ +package entity + +import "time" + +const ( + SettingMemberPreviewSeconds = "member_preview_seconds" + DefaultPreviewSeconds = 30 + + MaxCoverBytes = 5 << 20 // 5 MiB + MaxAudioBytes = 50 << 20 // 50 MiB +) + +// Track is one curated music item for the Spark music list. +type Track struct { + ID int64 `json:"id"` + Title string `json:"title"` + CoverKey string `json:"coverKey"` + AudioKey string `json:"audioKey"` + CoverURL string `json:"coverUrl"` + AudioURL string `json:"audioUrl"` + RequireMember bool `json:"requireMember"` + SortOrder int `json:"sortOrder"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Settings is the global music ops config. +type Settings struct { + MemberPreviewSeconds int `json:"memberPreviewSeconds"` +} + +// Playlist is the client-facing payload. +type Playlist struct { + Items []Track `json:"items"` + MemberPreviewSeconds int `json:"memberPreviewSeconds"` +} diff --git a/internal/module/music/logic/service.go b/internal/module/music/logic/service.go new file mode 100644 index 0000000..f437052 --- /dev/null +++ b/internal/module/music/logic/service.go @@ -0,0 +1,334 @@ +package logic + +import ( + "context" + "errors" + "fmt" + "path" + "strconv" + "strings" + "time" + + "spark-server/internal/module/music/entity" + "spark-server/internal/pkg/errs" +) + +var ErrNotReady = errors.New("music: service not ready") + +type Repository interface { + ListAll(ctx context.Context) ([]entity.Track, error) + ListEnabled(ctx context.Context) ([]entity.Track, error) + Get(ctx context.Context, id int64) (*entity.Track, error) + Insert(ctx context.Context, t *entity.Track) error + Update(ctx context.Context, t *entity.Track) error + Delete(ctx context.Context, id int64) error + MemberPreviewSeconds(ctx context.Context) (int, error) + SetSetting(ctx context.Context, key, value string) error +} + +type Objects interface { + Ready() bool + PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error + Delete(ctx context.Context, key string) error +} + +type Service struct { + repo Repository + objects Objects + publicURL func(objectKey string) string +} + +func NewService(repo Repository, objects Objects, publicBase, bucket string) *Service { + base := strings.TrimRight(strings.TrimSpace(publicBase), "/") + bkt := strings.TrimSpace(bucket) + if bkt == "" { + bkt = "spark" + } + return &Service{ + repo: repo, + objects: objects, + publicURL: func(key string) string { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" || base == "" { + return "" + } + return base + "/" + bkt + "/" + key + }, + } +} + +func (s *Service) Ready() bool { + return s != nil && s.repo != nil +} + +type UpsertInput struct { + Title string `json:"title"` + RequireMember *bool `json:"requireMember"` + SortOrder *int `json:"sortOrder"` + Enabled *bool `json:"enabled"` +} + +func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) { + if !s.Ready() { + return nil, ErrNotReady + } + return s.repo.ListAll(ctx) +} + +func (s *Service) Playlist(ctx context.Context) (*entity.Playlist, error) { + if !s.Ready() { + return nil, ErrNotReady + } + items, err := s.repo.ListEnabled(ctx) + if err != nil { + return nil, err + } + sec, err := s.repo.MemberPreviewSeconds(ctx) + if err != nil { + return nil, err + } + return &entity.Playlist{Items: items, MemberPreviewSeconds: sec}, nil +} + +func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) { + if !s.Ready() { + return nil, ErrNotReady + } + sec, err := s.repo.MemberPreviewSeconds(ctx) + if err != nil { + return nil, err + } + return &entity.Settings{MemberPreviewSeconds: sec}, nil +} + +func (s *Service) UpdateSettings(ctx context.Context, previewSeconds int) (*entity.Settings, error) { + if !s.Ready() { + return nil, ErrNotReady + } + if previewSeconds < 0 || previewSeconds > 3600 { + return nil, errs.ErrInvalidArgument.WithMsg("memberPreviewSeconds 须在 0~3600") + } + if err := s.repo.SetSetting(ctx, entity.SettingMemberPreviewSeconds, strconv.Itoa(previewSeconds)); err != nil { + return nil, err + } + return &entity.Settings{MemberPreviewSeconds: previewSeconds}, nil +} + +func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, error) { + if !s.Ready() { + return nil, ErrNotReady + } + title := strings.TrimSpace(in.Title) + if title == "" { + return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空") + } + t := &entity.Track{ + Title: title, + RequireMember: false, + SortOrder: 0, + Enabled: true, + } + if in.RequireMember != nil { + t.RequireMember = *in.RequireMember + } + if in.SortOrder != nil { + t.SortOrder = *in.SortOrder + } + if in.Enabled != nil { + t.Enabled = *in.Enabled + } + if err := s.repo.Insert(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +func (s *Service) Update(ctx context.Context, id int64, in UpsertInput) (*entity.Track, error) { + if !s.Ready() { + return nil, ErrNotReady + } + t, err := s.repo.Get(ctx, id) + if err != nil { + return nil, errs.ErrNotFound.WithMsg("曲目不存在") + } + if in.Title != "" { + t.Title = strings.TrimSpace(in.Title) + } + if t.Title == "" { + return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空") + } + if in.RequireMember != nil { + t.RequireMember = *in.RequireMember + } + if in.SortOrder != nil { + t.SortOrder = *in.SortOrder + } + if in.Enabled != nil { + t.Enabled = *in.Enabled + } + if err := s.repo.Update(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if !s.Ready() { + return ErrNotReady + } + t, err := s.repo.Get(ctx, id) + if err != nil { + return errs.ErrNotFound.WithMsg("曲目不存在") + } + _ = s.deleteObject(ctx, t.CoverKey) + _ = s.deleteObject(ctx, t.AudioKey) + return s.repo.Delete(ctx, id) +} + +func (s *Service) UploadCover(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) { + if !s.Ready() { + return nil, ErrNotReady + } + if len(body) == 0 { + return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件") + } + if len(body) > entity.MaxCoverBytes { + return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB") + } + ct := normalizeMIME(contentType, filename) + if !isImageMIME(ct) { + return nil, errs.ErrInvalidArgument.WithMsg("封面仅支持 jpg/png/webp/gif") + } + t, err := s.repo.Get(ctx, id) + if err != nil { + return nil, errs.ErrNotFound.WithMsg("曲目不存在") + } + ext := extFor(ct, filename, "jpg") + key := fmt.Sprintf("perm/music/cover/%d/%d.%s", id, time.Now().UnixNano(), ext) + if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil { + return nil, err + } + old := t.CoverKey + t.CoverKey = key + t.CoverURL = s.publicURL(key) + if err := s.repo.Update(ctx, t); err != nil { + return nil, err + } + if old != "" && old != key { + _ = s.deleteObject(ctx, old) + } + return t, nil +} + +func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) { + if !s.Ready() { + return nil, ErrNotReady + } + if len(body) == 0 { + return nil, errs.ErrInvalidArgument.WithMsg("请选择 mp3 文件") + } + if len(body) > entity.MaxAudioBytes { + return nil, errs.ErrInvalidArgument.WithMsg("音频不能超过 50MB") + } + ct := normalizeMIME(contentType, filename) + if !isAudioMIME(ct, filename) { + return nil, errs.ErrInvalidArgument.WithMsg("音频仅支持 mp3(audio/mpeg)") + } + t, err := s.repo.Get(ctx, id) + if err != nil { + return nil, errs.ErrNotFound.WithMsg("曲目不存在") + } + ext := extFor(ct, filename, "mp3") + key := fmt.Sprintf("perm/music/audio/%d/%d.%s", id, time.Now().UnixNano(), ext) + if err := s.put(ctx, key, body, ct, entity.MaxAudioBytes); err != nil { + return nil, err + } + old := t.AudioKey + t.AudioKey = key + t.AudioURL = s.publicURL(key) + if err := s.repo.Update(ctx, t); err != nil { + return nil, err + } + if old != "" && old != key { + _ = s.deleteObject(ctx, old) + } + return t, nil +} + +func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error { + if s.objects == nil || !s.objects.Ready() { + return errs.ErrAvailable.WithMsg("对象存储未就绪") + } + return s.objects.PutLarge(ctx, key, body, contentType, max) +} + +func (s *Service) deleteObject(ctx context.Context, key string) error { + if key == "" || s.objects == nil || !s.objects.Ready() { + return nil + } + return s.objects.Delete(ctx, key) +} + +func normalizeMIME(raw, filename string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + s, _, _ = strings.Cut(s, ";") + s = strings.TrimSpace(s) + if s != "" && s != "application/octet-stream" { + return s + } + ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), ".")) + switch ext { + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + case "mp3": + return "audio/mpeg" + case "m4a": + return "audio/mp4" + default: + return s + } +} + +func isImageMIME(ct string) bool { + switch ct { + case "image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif": + return true + default: + return false + } +} + +func isAudioMIME(ct, filename string) bool { + switch ct { + case "audio/mpeg", "audio/mp3", "audio/x-mpeg": + return true + } + return strings.HasSuffix(strings.ToLower(filename), ".mp3") +} + +func extFor(ct, filename, fallback string) string { + ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), ".")) + if ext != "" && len(ext) <= 8 { + return ext + } + switch ct { + case "image/jpeg", "image/jpg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "audio/mpeg", "audio/mp3": + return "mp3" + default: + return fallback + } +} diff --git a/internal/module/music/model/repo.go b/internal/module/music/model/repo.go new file mode 100644 index 0000000..c21d9ee --- /dev/null +++ b/internal/module/music/model/repo.go @@ -0,0 +1,203 @@ +package model + +import ( + "context" + "errors" + "strconv" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/music/entity" +) + +type Repo struct { + db *gorm.DB +} + +func NewRepo(db *gorm.DB) *Repo { + if db == nil { + return nil + } + return &Repo{db: db} +} + +func (r *Repo) Ready() bool { + return r != nil && r.db != nil +} + +type trackRow struct { + ID int64 `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + CoverKey string `gorm:"column:cover_key"` + AudioKey string `gorm:"column:audio_key"` + CoverURL string `gorm:"column:cover_url"` + AudioURL string `gorm:"column:audio_url"` + RequireMember bool `gorm:"column:require_member"` + SortOrder int `gorm:"column:sort_order"` + Enabled bool `gorm:"column:enabled"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (trackRow) TableName() string { return "music_tracks" } + +type settingRow struct { + SettingKey string `gorm:"column:setting_key;primaryKey"` + SettingValue string `gorm:"column:setting_value"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (settingRow) TableName() string { return "music_settings" } + +func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) { + if !r.Ready() { + return nil, errors.New("music repo not ready") + } + var rows []trackRow + err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]entity.Track, 0, len(rows)) + for _, row := range rows { + out = append(out, toEntity(row)) + } + return out, nil +} + +func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) { + if !r.Ready() { + return nil, errors.New("music repo not ready") + } + var rows []trackRow + err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("sort_order ASC, id ASC").Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]entity.Track, 0, len(rows)) + for _, row := range rows { + out = append(out, toEntity(row)) + } + return out, nil +} + +func (r *Repo) Get(ctx context.Context, id int64) (*entity.Track, error) { + if !r.Ready() { + return nil, errors.New("music repo not ready") + } + var row trackRow + err := r.db.WithContext(ctx).First(&row, id).Error + if err != nil { + return nil, err + } + t := toEntity(row) + return &t, nil +} + +func (r *Repo) Insert(ctx context.Context, t *entity.Track) error { + if !r.Ready() { + return errors.New("music repo not ready") + } + now := time.Now().UTC() + row := trackRow{ + Title: t.Title, + CoverKey: t.CoverKey, + AudioKey: t.AudioKey, + CoverURL: t.CoverURL, + AudioURL: t.AudioURL, + RequireMember: t.RequireMember, + SortOrder: t.SortOrder, + Enabled: t.Enabled, + CreatedAt: now, + UpdatedAt: now, + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + return err + } + t.ID = row.ID + t.CreatedAt = row.CreatedAt + t.UpdatedAt = row.UpdatedAt + return nil +} + +func (r *Repo) Update(ctx context.Context, t *entity.Track) error { + if !r.Ready() { + return errors.New("music repo not ready") + } + now := time.Now().UTC() + t.UpdatedAt = now + return r.db.WithContext(ctx).Model(&trackRow{}).Where("id = ?", t.ID).Updates(map[string]any{ + "title": t.Title, + "cover_key": t.CoverKey, + "audio_key": t.AudioKey, + "cover_url": t.CoverURL, + "audio_url": t.AudioURL, + "require_member": t.RequireMember, + "sort_order": t.SortOrder, + "enabled": t.Enabled, + "updated_at": now, + }).Error +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + if !r.Ready() { + return errors.New("music repo not ready") + } + return r.db.WithContext(ctx).Delete(&trackRow{}, id).Error +} + +func (r *Repo) GetSetting(ctx context.Context, key string) (string, error) { + if !r.Ready() { + return "", errors.New("music repo not ready") + } + var row settingRow + err := r.db.WithContext(ctx).Where("setting_key = ?", key).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + if err != nil { + return "", err + } + return row.SettingValue, nil +} + +func (r *Repo) SetSetting(ctx context.Context, key, value string) error { + if !r.Ready() { + return errors.New("music repo not ready") + } + now := time.Now().UTC() + row := settingRow{SettingKey: key, SettingValue: value, UpdatedAt: now} + return r.db.WithContext(ctx).Save(&row).Error +} + +func (r *Repo) MemberPreviewSeconds(ctx context.Context) (int, error) { + raw, err := r.GetSetting(ctx, entity.SettingMemberPreviewSeconds) + if err != nil { + return entity.DefaultPreviewSeconds, err + } + if raw == "" { + return entity.DefaultPreviewSeconds, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return entity.DefaultPreviewSeconds, nil + } + return n, nil +} + +func toEntity(row trackRow) entity.Track { + return entity.Track{ + ID: row.ID, + Title: row.Title, + CoverKey: row.CoverKey, + AudioKey: row.AudioKey, + CoverURL: row.CoverURL, + AudioURL: row.AudioURL, + RequireMember: row.RequireMember, + SortOrder: row.SortOrder, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/internal/module/seed/logic/bots.go b/internal/module/seed/logic/bots.go new file mode 100644 index 0000000..ede9fdd --- /dev/null +++ b/internal/module/seed/logic/bots.go @@ -0,0 +1,100 @@ +package logic + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) ensureBots(ctx context.Context) ([]*userentity.User, error) { + out := make([]*userentity.User, 0, userentity.BotCount) + for i := 1; i <= userentity.BotCount; i++ { + u, err := s.ensureBot(ctx, i) + if err != nil { + return nil, fmt.Errorf("bot %d: %w", i, err) + } + out = append(out, u) + } + return out, nil +} + +func (s *Service) ensureBot(ctx context.Context, index int) (*userentity.User, error) { + email := userentity.BotEmail(index) + if u, err := s.users.GetByEmail(ctx, email); err == nil { + return u, nil + } else if !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + nick := nicknameForBot(index) + qr, err := userentity.NewQRCode() + if err != nil { + return nil, err + } + now := time.Now() + u := &userentity.User{ + ID: id, SparkID: userentity.BotSparkID(index), Nickname: nick, + Avatar: userentity.LetterAvatarDataURI(nick), Gender: userentity.GenderUnknown, + Status: userentity.StatusActive, QRCode: qr, + CreatedAt: now, UpdatedAt: now, LastActiveAt: now, + } + if err := s.users.CreateWithEmail(ctx, u, email); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + return s.users.GetByEmail(ctx, email) + } + return nil, err + } + return u, nil +} + +func (s *Service) ensureTestUsers(ctx context.Context) error { + for i := 1; i <= 10; i++ { + email := fmt.Sprintf("test%02d@spark.com", i) + if _, err := s.users.GetByEmail(ctx, email); err == nil { + continue + } else if !errors.Is(err, errs.ErrNotFound) { + return err + } + if _, err := s.createPlainUser(ctx, email); err != nil { + return err + } + } + return nil +} + +func (s *Service) createPlainUser(ctx context.Context, email string) (*userentity.User, error) { + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + qr, err := userentity.NewQRCode() + if err != nil { + return nil, err + } + local, _, _ := strings.Cut(email, "@") + if local == "" { + local = "user" + } + now := time.Now() + u := &userentity.User{ + ID: id, SparkID: userentity.DefaultSparkID(id), Nickname: local, + Avatar: userentity.LetterAvatarDataURI(local), Gender: userentity.GenderUnknown, + Status: userentity.StatusActive, QRCode: qr, + CreatedAt: now, UpdatedAt: now, LastActiveAt: now, + } + if err := s.users.CreateWithEmail(ctx, u, email); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + return s.users.GetByEmail(ctx, email) + } + return nil, err + } + return u, nil +} diff --git a/internal/module/seed/logic/group.go b/internal/module/seed/logic/group.go new file mode 100644 index 0000000..aad0004 --- /dev/null +++ b/internal/module/seed/logic/group.go @@ -0,0 +1,179 @@ +package logic + +import ( + "context" + "errors" + "fmt" + "time" + + groupentity "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) ensureSampleGroup(ctx context.Context, bots, targets []*userentity.User) (groupID int64, sampleCount int, err error) { + if len(bots) < userentity.SampleGroupBotCount { + return 0, 0, errs.ErrAvailable + } + owner := bots[0] + existing, err := s.groups.FindByOwnerAndName(ctx, owner.ID, userentity.SampleGroupName) + if err != nil && !errors.Is(err, errs.ErrNotFound) { + return 0, 0, err + } + var g *groupentity.Group + if existing != nil { + g = existing + } else { + g, err = s.createSampleGroup(ctx, bots[:userentity.SampleGroupBotCount]) + if err != nil { + return 0, 0, err + } + } + if err := s.ensureGroupMembers(ctx, g, bots[:userentity.SampleGroupBotCount], targets); err != nil { + return 0, 0, err + } + n, err := s.ensureSampleMessages(ctx, g, bots[:userentity.SampleGroupBotCount]) + if err != nil { + return 0, 0, err + } + return g.ID, n, nil +} + +func (s *Service) createSampleGroup(ctx context.Context, bots []*userentity.User) (*groupentity.Group, error) { + convID, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := time.Now().UnixMilli() + owner := bots[0] + g := &groupentity.Group{ + ConversationID: convID, Name: userentity.SampleGroupName, OwnerID: owner.ID, + Status: groupentity.StatusActive, CreatedAtMS: now, UpdatedAtMS: now, + } + members := make([]*groupentity.Member, 0, len(bots)) + for i, b := range bots { + role := groupentity.RoleMember + if i == 0 { + role = groupentity.RoleOwner + } + members = append(members, &groupentity.Member{ + UserID: b.ID, Role: role, JoinedAtMS: now, + }) + } + if err := s.groups.CreateGroup(ctx, g, members); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + return s.groups.FindByOwnerAndName(ctx, owner.ID, userentity.SampleGroupName) + } + return nil, err + } + return g, nil +} + +func (s *Service) ensureGroupMembers(ctx context.Context, g *groupentity.Group, bots, targets []*userentity.User) error { + now := time.Now().UnixMilli() + add := func(uid int64) error { + if _, err := s.groups.GetMember(ctx, g.ID, uid); err == nil { + return nil + } else if !errors.Is(err, errs.ErrNotFound) { + return err + } + return ignoreDup(s.groups.AddMembers(ctx, g.ID, []*groupentity.Member{ + {UserID: uid, Role: groupentity.RoleMember, JoinedAtMS: now}, + })) + } + for _, b := range bots { + if err := add(b.ID); err != nil { + return err + } + } + for _, u := range targets { + if err := add(u.ID); err != nil { + return err + } + } + return nil +} + +type sampleSpec struct { + Key string + Sender int // bot index within sample bots slice + Type msgentity.Type + Content *msgentity.Content +} + +func (s *Service) ensureSampleMessages(ctx context.Context, g *groupentity.Group, bots []*userentity.User) (int, error) { + specs := sampleSpecs() + n := 0 + for _, spec := range specs { + senderID := int64(0) + if spec.Type != msgentity.TypeSystem { + senderID = bots[spec.Sender%len(bots)].ID + } + clientID := fmt.Sprintf("seed:group:sample:%s", spec.Key) + if senderID > 0 { + if _, err := s.msgs.GetByClientMsgID(ctx, senderID, g.ConversationID, clientID); err == nil { + n++ + continue + } else if err != nil && !errors.Is(err, errs.ErrNotFound) { + return n, err + } + } + msgID, err := s.ids.NextID() + if err != nil { + return n, err + } + raw, err := spec.Content.Marshal() + if err != nil { + return n, err + } + now := time.Now().UnixMilli() + msg := &msgentity.Message{ + ID: msgID, ConversationID: g.ConversationID, SenderID: senderID, GroupID: g.ID, + ClientMsgID: clientID, Type: spec.Type, Kind: msgentity.KindGroup, + Content: raw, Status: msgentity.StatusNormal, CreatedAtMS: now, + } + if err := s.msgs.Create(ctx, msg); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + n++ + continue + } + return n, err + } + if err := s.groups.UpdateLastMsg(ctx, g.ConversationID, msgID, now); err != nil { + return n, err + } + n++ + } + return n, nil +} + +func sampleSpecs() []sampleSpec { + return []sampleSpec{ + {Key: "text", Sender: 0, Type: msgentity.TypeText, Content: msgentity.NewText("Hello from Spark Samples — multi-type English demo messages live here.")}, + {Key: "image", Sender: 1, Type: msgentity.TypeImage, Content: msgentity.NewImage(&msgentity.Image{ + Key: "seed/demo/image.jpg", Mime: "image/jpeg", Width: 800, Height: 600, Size: 120000, + ThumbKey: "seed/demo/image_thumb.jpg", + })}, + {Key: "voice", Sender: 2, Type: msgentity.TypeVoice, Content: msgentity.NewVoice(&msgentity.Voice{ + Key: "seed/demo/voice.m4a", DurationMS: 3200, Size: 48000, + })}, + {Key: "video", Sender: 3, Type: msgentity.TypeVideo, Content: msgentity.NewVideo(&msgentity.Video{ + Key: "seed/demo/video.mp4", Mime: "video/mp4", Width: 1280, Height: 720, DurationMS: 8000, Size: 1500000, + CoverKey: "seed/demo/video_cover.jpg", + })}, + {Key: "emoji", Sender: 4, Type: msgentity.TypeEmoji, Content: msgentity.NewEmoji(&msgentity.Emoji{Pack: "spark", ID: "wave"})}, + {Key: "gif", Sender: 5, Type: msgentity.TypeGIF, Content: msgentity.NewGIF(&msgentity.GIF{ + Key: "seed/demo/cheer.gif", Mime: "image/gif", Width: 240, Height: 240, Size: 90000, + })}, + {Key: "file", Sender: 6, Type: msgentity.TypeFile, Content: msgentity.NewFile(&msgentity.File{ + Key: "seed/demo/readme.pdf", Name: "spark-samples.pdf", Size: 20480, Mime: "application/pdf", + })}, + {Key: "location", Sender: 7, Type: msgentity.TypeLocation, Content: msgentity.NewLocation(&msgentity.Location{ + Lat: 37.7749, Lng: -122.4194, Name: "Spark HQ", Address: "San Francisco, CA", + })}, + {Key: "system", Sender: 0, Type: msgentity.TypeSystem, Content: msgentity.NewSystem(&msgentity.System{ + Kind: groupentity.SystemKindCreated, Body: "Sample group created for English multi-type demos.", + })}, + } +} diff --git a/internal/module/seed/logic/nicknames.go b/internal/module/seed/logic/nicknames.go new file mode 100644 index 0000000..9a810df --- /dev/null +++ b/internal/module/seed/logic/nicknames.go @@ -0,0 +1,49 @@ +package logic + +import ( + "fmt" + "math/rand" +) + +// botNicknames are English display names assigned deterministically to bot indexes. +var botNicknames = []string{ + "Spark Assistant", "Nova Guide", "Echo Helper", "Atlas Bot", "Luna Concierge", + "Orion Buddy", "Mira Support", "Felix Demo", "Aria Sample", "Kai Showcase", + "Nina Pilot", "Leo Welcome", "Zoe Messenger", "Owen Chat", "Ivy Reply", + "Ethan Pulse", "Clara Beam", "Miles Spark", "Ruby Wave", "Hugo Note", + "Vera Link", "Jonas Thread", "Piper Mail", "Silas Ping", "Nora Signal", + "Quinn Relay", "Mabel Soft", "Theo Bright", "Elsie Calm", "Finn Steady", + "Iris Clear", "Jude Warm", "Lana Quick", "Marco Soft", "Nell Crisp", + "Omar Fresh", "Paige Light", "Reed Cool", "Sage Kind", "Tess Open", + "Uma Soft", "Vince Clear", "Willa Bright", "Xander Calm", "Yara Warm", + "Zane Quick", "Ada Soft", "Blake Crisp", "Cora Fresh", "Drew Light", + "Eve Cool", "Ford Kind", "Gina Open", "Hank Soft", "Indie Clear", + "Jade Bright", "Kurt Calm", "Liv Warm", "Moss Quick", "Nia Soft", + "Otto Crisp", "Pearl Fresh", "Quill Light", "Rose Cool", "Seth Kind", + "Tara Open", "Uri Soft", "Veda Clear", "Wes Bright", "Xena Calm", + "York Warm", "Zara Quick", "Amy Soft", "Ben Crisp", "Cy Fresh", + "Dot Light", "Eli Cool", "Fay Kind", "Gus Open", "Hal Soft", + "Ina Clear", "Jay Bright", "Kim Calm", "Lou Warm", "Max Quick", + "Nyx Soft", "Oli Crisp", "Pam Fresh", "Rex Light", "Sky Cool", + "Ty Kind", "Una Open", "Vic Soft", "Wyn Clear", "Xia Bright", + "Yen Calm", "Zed Warm", "Ash Quick", "Bea Soft", "Cal Crisp", +} + +// nicknameForBot returns a stable English nickname for bot index 1..100. +func nicknameForBot(index int) string { + if index < 1 { + index = 1 + } + if index == 1 { + return botNicknames[0] + } + // Deterministic shuffle pick so re-runs keep the same names/avatars. + r := rand.New(rand.NewSource(int64(20260923 + index*9973))) + base := botNicknames[1+(index-2)%(len(botNicknames)-1)] + suffix := r.Intn(90) + 10 + nick := fmt.Sprintf("%s %d", base, suffix) + if len(nick) > 64 { + return nick[:64] + } + return nick +} diff --git a/internal/module/seed/logic/service.go b/internal/module/seed/logic/service.go new file mode 100644 index 0000000..caa5285 --- /dev/null +++ b/internal/module/seed/logic/service.go @@ -0,0 +1,140 @@ +// Package logic seeds demo bots, welcome messages, and the English sample group. +package logic + +import ( + "context" + "errors" + "fmt" + + friendentity "spark-server/internal/module/friend/entity" + groupentity "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" +) + +// IDGen issues Snowflake ids for users, conversations, and messages. +type IDGen interface { + NextID() (int64, error) +} + +// Users loads and creates accounts used by the seed. +type Users interface { + GetByEmail(ctx context.Context, email string) (*userentity.User, error) + CreateWithEmail(ctx context.Context, u *userentity.User, email string) error + ListAccounts(ctx context.Context) ([]usermodel.Account, error) +} + +// Friends writes directed friend edges. +type Friends interface { + GetRelation(ctx context.Context, userID, friendID int64) (*friendentity.Relation, error) + AcceptFriend(ctx context.Context, in friendentity.AcceptParams) error +} + +// Chats opens P2P conversations and bumps last_msg. +type Chats interface { + EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) + UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error +} + +// Messages persists private / group bodies. +type Messages interface { + Create(ctx context.Context, m *msgentity.Message) error + GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*msgentity.Message, error) +} + +// Groups creates the sample group and membership. +type Groups interface { + FindByOwnerAndName(ctx context.Context, ownerID int64, name string) (*groupentity.Group, error) + CreateGroup(ctx context.Context, g *groupentity.Group, members []*groupentity.Member) error + GetMember(ctx context.Context, groupID, userID int64) (*groupentity.Member, error) + AddMembers(ctx context.Context, groupID int64, members []*groupentity.Member) error + UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error +} + +// Service is the idempotent demo / onboarding seeder. +type Service struct { + users Users + friends Friends + chats Chats + msgs Messages + groups Groups + ids IDGen +} + +// NewService wires seed dependencies. +func NewService(users Users, friends Friends, chats Chats, msgs Messages, groups Groups, ids IDGen) *Service { + return &Service{users: users, friends: friends, chats: chats, msgs: msgs, groups: groups, ids: ids} +} + +// Result summarizes what the seed pass did (created or already present). +type Result struct { + Bots int `json:"bots"` + TestUsers int `json:"test_users"` + Targets int `json:"targets"` + GroupID int64 `json:"group_id"` + SampleMsgs int `json:"sample_msgs"` +} + +func (s *Service) ready() bool { + return s != nil && s.users != nil && s.friends != nil && s.chats != nil && + s.msgs != nil && s.groups != nil && s.ids != nil +} + +// Run ensures bots, test accounts, per-user welcome/greeting, and the sample group. +func (s *Service) Run(ctx context.Context) (*Result, error) { + if !s.ready() { + return nil, errs.ErrAvailable + } + bots, err := s.ensureBots(ctx) + if err != nil { + return nil, err + } + if err := s.ensureTestUsers(ctx); err != nil { + return nil, err + } + targets, err := s.listTargets(ctx) + if err != nil { + return nil, err + } + welcome := bots[userentity.WelcomeBotIndex-1] + for _, u := range targets { + if err := s.onboardUser(ctx, u, welcome); err != nil { + return nil, fmt.Errorf("onboard %d: %w", u.ID, err) + } + } + gid, samples, err := s.ensureSampleGroup(ctx, bots, targets) + if err != nil { + return nil, err + } + return &Result{ + Bots: len(bots), TestUsers: 10, Targets: len(targets), + GroupID: gid, SampleMsgs: samples, + }, nil +} + +func (s *Service) listTargets(ctx context.Context) ([]*userentity.User, error) { + accounts, err := s.users.ListAccounts(ctx) + if err != nil { + return nil, err + } + out := make([]*userentity.User, 0, len(accounts)) + for _, a := range accounts { + if a.User == nil || userentity.IsBotEmail(a.Email) { + continue + } + if a.User.Status != userentity.StatusActive { + continue + } + out = append(out, a.User) + } + return out, nil +} + +func ignoreDup(err error) error { + if err == nil || errors.Is(err, errs.ErrDuplicate) { + return nil + } + return err +} diff --git a/internal/module/seed/logic/service_test.go b/internal/module/seed/logic/service_test.go new file mode 100644 index 0000000..e483470 --- /dev/null +++ b/internal/module/seed/logic/service_test.go @@ -0,0 +1,113 @@ +package logic + +import ( + "context" + "errors" + "strconv" + "testing" + + "spark-server/internal/infrastructure/id" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + friendmodel "spark-server/internal/module/friend/model" + groupmodel "spark-server/internal/module/group/model" + msgentity "spark-server/internal/module/message/entity" + msgmodel "spark-server/internal/module/message/model" + userentity "spark-server/internal/module/user/entity" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" +) + +type seedHarness struct { + *Service + msgs *msgmodel.MemoryRepo +} + +func testSeed(t *testing.T) *seedHarness { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := usermodel.NewMemoryRepo() + friends := friendmodel.NewMemoryRepo() + chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids) + msgs := msgmodel.NewMemoryRepo() + groups := groupmodel.NewMemoryRepo() + return &seedHarness{ + Service: NewService(users, friends, chats, msgs, groups, ids), + msgs: msgs, + } +} + +func TestSeedRunIdempotent(t *testing.T) { + h := testSeed(t) + ctx := context.Background() + first, err := h.Run(ctx) + if err != nil { + t.Fatal(err) + } + if first.Bots != userentity.BotCount || first.Targets != 10 || first.GroupID <= 0 || first.SampleMsgs != 9 { + t.Fatalf("first %+v", first) + } + again, err := h.Run(ctx) + if err != nil { + t.Fatal(err) + } + if again.Bots != first.Bots || again.Targets != first.Targets || again.GroupID != first.GroupID || again.SampleMsgs != first.SampleMsgs { + t.Fatalf("again %+v want %+v", again, first) + } + bot, err := h.users.GetByEmail(ctx, userentity.BotEmail(1)) + if err != nil { + t.Fatal(err) + } + if bot.Nickname != "Spark Assistant" || bot.Avatar == "" { + t.Fatalf("welcome bot %+v", bot) + } + other, err := h.users.GetByEmail(ctx, userentity.BotEmail(50)) + if err != nil || other.Nickname == "" || other.Avatar == "" { + t.Fatalf("random bot %+v %v", other, err) + } + u, err := h.users.GetByEmail(ctx, "test01@spark.com") + if err != nil { + t.Fatal(err) + } + rel, err := h.friends.GetRelation(ctx, u.ID, bot.ID) + if err != nil || !rel.Active() { + t.Fatalf("friend %+v %v", rel, err) + } + convID, err := h.chats.EnsureP2P(ctx, u.ID, bot.ID) + if err != nil { + t.Fatal(err) + } + greetID := "seed:greet:" + strconv.FormatInt(u.ID, 10) + greet, err := h.msgs.GetByClientMsgID(ctx, bot.ID, convID, greetID) + if err != nil || greet.Type != msgentity.TypeText { + t.Fatalf("greet %+v %v", greet, err) + } + welcomeID := "seed:welcome:" + strconv.FormatInt(u.ID, 10) + list, err := h.msgs.ListByConversation(ctx, convID, u.ID, 0, 50) + if err != nil { + t.Fatal(err) + } + foundWelcome := false + for _, m := range list { + if m.ClientMsgID != welcomeID { + continue + } + foundWelcome = true + payload, perr := m.Payload() + if perr != nil || payload.System == nil || payload.System.Kind != userentity.SystemKindWelcome { + t.Fatalf("welcome payload %+v %v", payload, perr) + } + } + if !foundWelcome { + t.Fatal("welcome missing") + } +} + +func TestSeedNilService(t *testing.T) { + if _, err := (*Service)(nil).Run(context.Background()); !errors.Is(err, errs.ErrAvailable) { + t.Fatalf("got %v", err) + } +} diff --git a/internal/module/seed/logic/welcome.go b/internal/module/seed/logic/welcome.go new file mode 100644 index 0000000..89227c1 --- /dev/null +++ b/internal/module/seed/logic/welcome.go @@ -0,0 +1,107 @@ +package logic + +import ( + "context" + "errors" + "fmt" + "time" + + friendentity "spark-server/internal/module/friend/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +const ( + welcomeBody = "Welcome to Spark! This is a system welcome message. Explore chats, groups, and sample content anytime." + greetingBody = "Hi! I'm %s — your sample robot friend. Reply anytime; this is an English greeting demo." +) + +func (s *Service) onboardUser(ctx context.Context, user, bot *userentity.User) error { + if user == nil || bot == nil || user.ID == bot.ID { + return errs.ErrInvalidArgument + } + if err := s.ensureFriendship(ctx, user.ID, bot.ID); err != nil { + return err + } + convID, err := s.chats.EnsureP2P(ctx, user.ID, bot.ID) + if err != nil { + return err + } + if err := s.ensureSystemWelcome(ctx, user.ID, convID); err != nil { + return err + } + return s.ensureBotGreeting(ctx, user.ID, bot, convID) +} + +func (s *Service) ensureFriendship(ctx context.Context, userID, botID int64) error { + rel, err := s.friends.GetRelation(ctx, userID, botID) + if err == nil && rel.Active() { + return nil + } + if err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + now := time.Now() + return ignoreDup(s.friends.AcceptFriend(ctx, friendentity.AcceptParams{ + Edges: []*friendentity.Relation{ + friendentity.NewFriendEdge(userID, botID, "", friendentity.SourceEmail, now), + friendentity.NewFriendEdge(botID, userID, "", friendentity.SourceEmail, now), + }, + })) +} + +func (s *Service) ensureSystemWelcome(ctx context.Context, userID, convID int64) error { + clientID := fmt.Sprintf("seed:welcome:%d", userID) + msgID, err := s.ids.NextID() + if err != nil { + return err + } + raw, err := msgentity.NewSystem(&msgentity.System{ + Kind: userentity.SystemKindWelcome, Body: welcomeBody, + }).Marshal() + if err != nil { + return err + } + now := time.Now().UnixMilli() + msg := &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: 0, ReceiverID: userID, + ClientMsgID: clientID, Type: msgentity.TypeSystem, Kind: msgentity.KindPrivate, + Content: raw, Status: msgentity.StatusNormal, CreatedAtMS: now, + } + if err := s.msgs.Create(ctx, msg); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + return nil + } + return err + } + return s.chats.UpdateLastMsg(ctx, convID, msgID, now) +} + +func (s *Service) ensureBotGreeting(ctx context.Context, userID int64, bot *userentity.User, convID int64) error { + clientID := fmt.Sprintf("seed:greet:%d", userID) + if _, err := s.msgs.GetByClientMsgID(ctx, bot.ID, convID, clientID); err == nil { + return nil + } else if err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + msgID, err := s.ids.NextID() + if err != nil { + return err + } + body := fmt.Sprintf(greetingBody, bot.Nickname) + raw, err := msgentity.NewText(body).Marshal() + if err != nil { + return err + } + now := time.Now().UnixMilli() + msg := &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: bot.ID, ReceiverID: userID, + ClientMsgID: clientID, Type: msgentity.TypeText, Kind: msgentity.KindPrivate, + Content: raw, Status: msgentity.StatusNormal, CreatedAtMS: now, + } + if err := ignoreDup(s.msgs.Create(ctx, msg)); err != nil { + return err + } + return s.chats.UpdateLastMsg(ctx, convID, msgID, now) +} diff --git a/internal/module/user/entity/bot.go b/internal/module/user/entity/bot.go new file mode 100644 index 0000000..d6427f3 --- /dev/null +++ b/internal/module/user/entity/bot.go @@ -0,0 +1,45 @@ +package entity + +import ( + "fmt" + "regexp" +) + +const ( + // BotCount is how many seeded robot accounts the seed command ensures. + BotCount = 100 + // BotEmailDomain is the reserved mailbox domain for seed bots (not OTP-free). + BotEmailDomain = "spark.bot" +) + +var botEmailRe = regexp.MustCompile(`^bot(0[0-9]{2}|100)@spark\.bot$`) + +// BotEmail returns bot001@spark.bot … bot100@spark.bot. +func BotEmail(index int) string { + if index < 1 || index > BotCount { + return "" + } + return fmt.Sprintf("bot%03d@%s", index, BotEmailDomain) +} + +// BotSparkID returns sparkbot001 … sparkbot100 (ValidSparkID length). +func BotSparkID(index int) string { + if index < 1 || index > BotCount { + return "" + } + return fmt.Sprintf("sparkbot%03d", index) +} + +// IsBotEmail reports whether email is a seeded robot mailbox. +func IsBotEmail(email string) bool { + return botEmailRe.MatchString(NormalizeEmail(email)) +} + +// WelcomeBotIndex is the robot friended to every formal/test user for greetings. +const WelcomeBotIndex = 1 + +// SampleGroupBotCount is how many robots join the English sample group (owner + peers). +const SampleGroupBotCount = 8 + +// SampleGroupName is the seeded multi-bot English sample chat. +const SampleGroupName = "Spark Samples" diff --git a/internal/module/user/entity/bot_test.go b/internal/module/user/entity/bot_test.go new file mode 100644 index 0000000..3cdda98 --- /dev/null +++ b/internal/module/user/entity/bot_test.go @@ -0,0 +1,24 @@ +package entity + +import "testing" + +func TestBotEmailAndSparkID(t *testing.T) { + if BotEmail(0) != "" || BotEmail(101) != "" { + t.Fatal("out of range") + } + if got := BotEmail(1); got != "bot001@spark.bot" { + t.Fatalf("email %s", got) + } + if got := BotEmail(100); got != "bot100@spark.bot" { + t.Fatalf("email %s", got) + } + if !IsBotEmail("bot001@spark.bot") || !IsBotEmail("BOT050@SPARK.BOT") { + t.Fatal("IsBotEmail true") + } + if IsBotEmail("test01@spark.com") || IsBotEmail("bot101@spark.bot") { + t.Fatal("IsBotEmail false") + } + if sid := BotSparkID(7); sid != "sparkbot007" || !ValidSparkID(sid) { + t.Fatalf("spark_id %s", sid) + } +} diff --git a/internal/module/user/entity/system.go b/internal/module/user/entity/system.go new file mode 100644 index 0000000..2a9f40c --- /dev/null +++ b/internal/module/user/entity/system.go @@ -0,0 +1,4 @@ +package entity + +// SystemKindWelcome is messages.content system.kind for the English onboarding notice. +const SystemKindWelcome = "system.welcome" diff --git a/internal/module/user/model/list.go b/internal/module/user/model/list.go new file mode 100644 index 0000000..637a9e9 --- /dev/null +++ b/internal/module/user/model/list.go @@ -0,0 +1,38 @@ +package model + +import ( + "context" + + "spark-server/internal/module/user/entity" +) + +// Account is a users row plus its primary login email. +type Account struct { + User *entity.User + Email string +} + +// ListAccounts returns every user that still has a user_emails binding. +func (m *Repo) ListAccounts(ctx context.Context) ([]Account, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []struct { + User + Email string `gorm:"column:email"` + } + err = db.Table("users AS u"). + Select("u.*, e.email"). + Joins("INNER JOIN user_emails e ON e.user_id = u.id"). + Order("u.id ASC"). + Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]Account, 0, len(rows)) + for i := range rows { + out = append(out, Account{User: rows[i].User.entity(), Email: rows[i].Email}) + } + return out, nil +} diff --git a/internal/module/user/model/memory.go b/internal/module/user/model/memory.go index 2e618f0..0e79f8a 100644 --- a/internal/module/user/model/memory.go +++ b/internal/module/user/model/memory.go @@ -2,6 +2,7 @@ package model import ( "context" + "sort" "sync" "time" @@ -104,6 +105,31 @@ func (m *MemoryRepo) CreateWithEmail(_ context.Context, u *entity.User, email st return nil } +// ListAccounts returns every user that still has a user_emails binding. +func (m *MemoryRepo) ListAccounts(_ context.Context) ([]Account, error) { + m.mu.Lock() + defer m.mu.Unlock() + type pair struct { + id int64 + email string + } + pairs := make([]pair, 0, len(m.byEmail)) + for email, id := range m.byEmail { + pairs = append(pairs, pair{id: id, email: email}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].id < pairs[j].id }) + out := make([]Account, 0, len(pairs)) + for _, p := range pairs { + u := m.byID[p.id] + if u == nil { + continue + } + cp := *u + out = append(out, Account{User: &cp, Email: p.email}) + } + return out, nil +} + func (m *MemoryRepo) UpdateSparkID(_ context.Context, userID int64, sparkID string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/pkg/i18n/catalog_enum_wire.go b/internal/pkg/i18n/catalog_enum_wire.go index 745a4ab..040b194 100644 --- a/internal/pkg/i18n/catalog_enum_wire.go +++ b/internal/pkg/i18n/catalog_enum_wire.go @@ -35,6 +35,7 @@ func init() { "enum.upload_purpose.chat_gif": {ZhCN: "聊天GIF", En: "Chat GIF", ZhTW: "聊天GIF"}, "enum.upload_purpose.chat_file": {ZhCN: "聊天文件", En: "Chat file", ZhTW: "聊天檔案"}, "enum.system_kind.friend_became": {ZhCN: "已成为好友", En: "Became friends", ZhTW: "已成為好友"}, + "enum.system_kind.system_welcome": {ZhCN: "系统欢迎", En: "System welcome", ZhTW: "系統歡迎"}, "enum.system_kind.group_created": {ZhCN: "已创建群", En: "Group created", ZhTW: "已建立群"}, "enum.system_kind.group_invite": {ZhCN: "邀请入群", En: "Invited to group", ZhTW: "邀請入群"}, "enum.system_kind.group_leave": {ZhCN: "退出群", En: "Left group", ZhTW: "退出群"}, diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index 5a00264..25fcc21 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -88,7 +88,8 @@ func publicPath(path string) bool { switch path { case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/": return true - case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes": + case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", + "/v1/music/playlist", "/v1/music/settings": return true default: // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 4a5bc7b..e1cc0bc 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -65,14 +65,15 @@ info: | Header | 放哪 | 必填 | 说明 | | --- | --- | --- | --- | - | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums` **不要**带。 | - | `X-Device-ID` | Header | 可选 | 十进制设备 id,必须等于登录时 Body 里的 `device_id`(也等于 JWT `did`),否则 `40101`。 | + | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/settings` **不要**带。 | + | `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 | + | `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 | | `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 | | `Accept-Language` | Header | 可选 | 无 `X-Language` 时使用。 | | `X-App-Version` | Header | 可选 | 客户端版本,写入请求上下文。 | | `Content-Type` | Header | 有 Body 时必填 | `application/json`。 | - **不要**把 token、语言、device_id 塞进业务 JSON(登录/刷新除外:登录 Body 带 `device_id`,刷新 Body 带 `refresh_token`)。 + **不要**把 token、语言、`device_id`、`app_id` 塞进业务 JSON(登录 / 刷新除外:登录 Body 带 `app_id` 与物理指纹字段,刷新 Body 带 `refresh_token`)。 网关写入、客户端不要发:`X-Real-IP`、`X-Forwarded-For`(限流/审计用,见 C6)。 @@ -89,14 +90,14 @@ info: | --- | --- | --- | | Path | 资源 id | `/v1/friends/{uid}` 的 `uid`;`/v1/groups/{id}` 的群号;`/v1/messages/{id}` 的 MsgId | | Query | GET 筛选 / 分页 | `GET /v1/friends/lookup?q=`;`limit`;历史 `conversation_id`、`last_msg_id` | - | Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`device_id`;发消息的 `content` | + | Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`app_id`/`device_type`/`idfv` 或 `android_id`;发消息的 `content` | ### 调用顺序 1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。封控见 [docs/sms.md](../../../docs/sms.md)(IP 桶 + 同 target 60s 冷却,均 `42902`)。测试号 `test01@spark.com`~`test100@spark.com` 可跳过发码。 - 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。 - 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。 - 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。 + 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。**不要**传客户端自造 `device_id`;传 **`app_id`**、**`device_type`(1–6)** 与 **`idfv` / `android_id`**,由服务端生成 `device_id`。持久化响应里的 `device_id` 与 `app_id`。 + 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`(= JWT `did`)、`X-App-ID`(= JWT `aid`)、`X-Language`。 + 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer;新 token 保留相同 `did` / `aid`。 5. App 在线发消息、收推送、增量同步走 **TCP**,不要用 `POST /v1/messages` 当主路径。 请求示例(生产): @@ -116,6 +117,7 @@ info: Host: sparkapi.wyrs365.com Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... X-Device-ID: 7 + X-App-ID: 1 X-Language: zh-CN ``` @@ -164,7 +166,7 @@ info: 值名方向请求 Body响应 Command / Body - 1AUTH客户端 → 服务端不是 envelope:{"token","device_id","app_version","language"}同 1,envelope,data={"user_id","device_id"} + 1AUTH客户端 → 服务端不是 envelope:{"token","device_id?","app_version","language"};device_id 可选但须等于 JWT did;不必传 app_id(以 JWT aid 为准)同 1,envelope,data={"user_id","device_id"} 2HEARTBEAT客户端 → 服务端空 Body同 2,envelope data={} 3GENERATE_MSG_ID客户端 → 服务端空同 3,data.msg_id 4SEND_MESSAGE客户端 → 服务端不是 envelope:与 HTTP POST /v1/messages 同一 JSON5 SEND_ACK,data={"msg_id","conversation_id","client_msg_id","server_time"} @@ -334,9 +336,15 @@ info: 仓库可运行示例:`docs/clients/SparkImClient.java`、`docs/clients/SparkImClient.swift`。 servers: - url: https://sparkapi.wyrs365.com - description: 生产 + description: 生产(IM / 音乐客户端) - url: http://127.0.0.1:8080 description: 本机开发 + - url: https://popadmin.wyrs365.com + description: popadmin(音乐管理 Admin API) + - url: https://admin.fullstorynovel.com + description: FullStoryNovel 小说 CMS / 公开 API + - url: https://fullstorynovel.com + description: FullStoryNovel 官网(公开 /api 同源时可切换) security: - bearerAuth: [] tags: @@ -363,6 +371,22 @@ tags: - name: favorites - name: moments - name: square + - name: music + description: | + 运营音乐列表。客户端公开读 `GET /v1/music/playlist`、`GET /v1/music/settings`(无需登录,V3 envelope)。 + - name: admin-music + description: | + popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。 + Admin JWT;响应 `{success,message,data}`。Host 常用 `https://popadmin.wyrs365.com`。 + - name: novels + description: | + FullStoryNovel 公开小说 API(App/官网)。生产基址见 servers 中 fullstorynovel。 + 响应多为直接 JSON(非 V3 envelope)。 + - name: admin-novels + description: | + FullStoryNovel 小说 CMS(8095)。生产 `https://admin.fullstorynovel.com`。 + CMS Bearer;或用 wyrs Admin JWT 调 `POST /api/admin/adopt-ops-session` 换取。 + popadmin「小说内容管理」iframe 嵌入该后台。 paths: /healthz: get: @@ -553,7 +577,17 @@ paths: 登录 UPSERT `user_devices` 与 `devices`,并追加 `user_login_logs`(含 app_id)。 返回 access_token 与 refresh_token;`device_id` 由服务端生成。测试账号任意验证码即可。 `device_type` 必填:整数 1–6(见 meta `device_type`);可选 `platform` 同枚举作别名。 + **设备模型(Breaking,见 `docs/devices-client.md`)**: + - Body **不要**再传客户端自造 `device_id`。 + - **必填** `app_id`(整数,如 Spark IM=`1`)、`device_type`(整数 1–6,见 meta / `AppId` / `DeviceType`)。 + - 物理指纹:ios/macos 必填 `idfv`;android/harmony 必填 `android_id`;windows/web 至少一个。 + - 服务端按 `SHA256("{device_type}:{idfv|android_id}")` 派生 `device_id`(同机多 App **共用**同一 `device_id`)。 + - JWT access/refresh 含 `sub`(user_id)、`did`(device_id)、**`aid`(app_id)**、`use`。 + - 响应 `data` 含服务端 `device_id` 与 `app_id`;客户端须持久化后用于 Header / TCP。 + - 可选 `platform`:与 `device_type` 同枚举,仅当未传 `device_type` 时作别名;可选 `device_name`。 + + 首次验证成功会创建用户。测试账号任意验证码即可。 Header(可选):`X-Language` / `Accept-Language`、`X-App-Version`。不要带 `Authorization`。 Body:`email`、`verify_code`、`app_id`、`device_type`、`idfv`/`android_id`、可选 `device_name`。 operationId: login @@ -593,6 +627,7 @@ paths: refresh_expires_in: 2592000 user_id: 1940000000000000000 device_id: 7 + app_id: 1 spark_id: s1a2b3c0 unauthorized: value: @@ -611,7 +646,8 @@ paths: summary: 用 refresh_token 续期 description: | 校验 refresh_token 后签发新的 access/refresh,旧一对立即失效(jti denylist)。 - 不需要 access Bearer。按 RealIP 限流。 + 新 token 对保留相同 **`did` / `aid`**(与首次登录 App 绑定)。 + 不需要 access Bearer。按 RealIP 限流。Body 仅 `refresh_token`。 operationId: refreshToken requestBody: required: true @@ -631,8 +667,11 @@ paths: /v1/auth/logout: post: tags: [auth] - summary: 退出当前设备 - description: 吊销该设备的 access/refresh,并踢掉同 device_id 的 TCP 连接。 + summary: 退出当前设备会话 + description: | + 吊销当前 **`(user, device, app)`** 会话(默认 `triple`:各 App 独立 refresh,互不踢)。 + 若运维切到 `pair` 模式,同设备其它 App 的 refresh 可能被互踢(需产品公告)。 + 并踢掉同 `device_id` 的 TCP 连接。 operationId: logout responses: "200": @@ -646,11 +685,12 @@ paths: tags: [users] summary: 当前用户资料 description: | - Header:`Authorization: Bearer `(必填),可选 `X-Device-ID`、`X-Language`。无 Body。 + Header:`Authorization: Bearer `(必填),可选 `X-Device-ID`、`X-App-ID`、`X-Language`。无 Body。 `avatar` 是 object_key,可匿名 GET `https://sparkr.wyrs365.com/spark/{avatar}`。 operationId: getMe parameters: - $ref: "#/components/parameters/X-Device-ID" + - $ref: "#/components/parameters/X-App-ID" - $ref: "#/components/parameters/X-Language" responses: "200": @@ -1822,6 +1862,679 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + + /v1/music/playlist: + get: + tags: [music] + summary: 音乐列表(公开) + description: | + 返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。 + 封面/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。 + 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听。 + operationId: getMusicPlaylist + security: [] + responses: + "200": + description: 统一 envelope,`data` 为 MusicPlaylist + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + $ref: "#/components/schemas/MusicPlaylist" + /v1/music/settings: + get: + tags: [music] + summary: 音乐全局配置(公开) + description: 仅返回 `memberPreviewSeconds`(会员曲免费试听秒数)。无需登录。 + operationId: getMusicSettings + security: [] + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + $ref: "#/components/schemas/MusicSettings" + + /api/admin/spark/music: + get: + tags: [admin-music] + summary: 管理端曲目列表 + description: | + popadmin 同源调用。`data.items` 为全部曲目(含下架),`data.settings` 为全局试听配置。 + 别名:`GET /api/admin/pop/music`。 + operationId: adminListMusic + security: + - bearerAuth: [] + responses: + "200": + description: `{success,message,data:{items,settings}}` + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + post: + tags: [admin-music] + summary: 新建曲目 + operationId: adminCreateMusic + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MusicUpsertRequest" + responses: + "200": + description: `data` 为 MusicTrack + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + /api/admin/spark/music/settings: + get: + tags: [admin-music] + summary: 读取全局试听时长 + operationId: adminGetMusicSettings + security: + - bearerAuth: [] + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + put: + tags: [admin-music] + summary: 更新全局试听时长 + description: Body `{"memberPreviewSeconds":0..3600}` + operationId: adminPutMusicSettings + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MusicSettings" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + /api/admin/spark/music/{id}: + patch: + tags: [admin-music] + summary: 更新曲目元数据 + operationId: adminUpdateMusic + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MusicUpsertRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + delete: + tags: [admin-music] + summary: 删除曲目(并尝试删除对象存储文件) + operationId: adminDeleteMusic + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + /api/admin/spark/music/{id}/cover: + post: + tags: [admin-music] + summary: 上传封面(multipart file) + description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/cover/...`。 + operationId: adminUploadMusicCover + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + /api/admin/spark/music/{id}/audio: + post: + tags: [admin-music] + summary: 上传 mp3(multipart file) + description: 字段名 `file`;audio/mpeg,≤50MB。写入 RustFS `perm/music/audio/...`。 + operationId: adminUploadMusicAudio + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AdminApiResponse" + + # —— FullStoryNovel 公开小说 API(基址选 admin.fullstorynovel.com 或 fullstorynovel.com)—— + /api/home: + get: + tags: [novels] + summary: 首页聚合(精选/热门/新书/更新/分类) + operationId: fsnHome + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + responses: + "200": + description: HomeDto 直接 JSON + content: + application/json: + schema: + $ref: "#/components/schemas/FsnHome" + /api/genres: + get: + tags: [novels] + summary: 分类列表 + operationId: fsnGenres + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + parameters: + - name: tree + in: query + schema: { type: boolean } + description: true 时返回一级含 children 树 + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FsnGenre" + /api/novels: + get: + tags: [novels] + summary: 小说分页搜索 + operationId: fsnListNovels + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + parameters: + - { name: status, in: query, schema: { type: string } } + - { name: language, in: query, schema: { type: string } } + - { name: genre, in: query, schema: { type: string }, description: 一级或二级 slug } + - { name: q, in: query, schema: { type: string } } + - { name: sort, in: query, schema: { type: string, default: popular } } + - { name: page, in: query, schema: { type: integer, default: 0 } } + - { name: size, in: query, schema: { type: integer, default: 24 } } + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnNovelPage" + /api/novels/{slug}: + get: + tags: [novels] + summary: 小说详情 + operationId: fsnGetNovel + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + parameters: + - name: slug + in: path + required: true + schema: { type: string } + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnNovelCard" + /api/novels/{slug}/chapters: + get: + tags: [novels] + summary: 章节目录 + operationId: fsnListChapters + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + parameters: + - name: slug + in: path + required: true + schema: { type: string } + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FsnChapterMeta" + /api/novels/{novelSlug}/chapters/{chapterSlug}: + get: + tags: [novels] + summary: 章节正文 + operationId: fsnGetChapter + servers: + - url: https://admin.fullstorynovel.com + - url: https://fullstorynovel.com + security: [] + parameters: + - { name: novelSlug, in: path, required: true, schema: { type: string } } + - { name: chapterSlug, in: path, required: true, schema: { type: string } } + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnChapterDetail" + + # —— FullStoryNovel CMS 管理 API(基址 admin.fullstorynovel.com)—— + /api/admin/adopt-ops-session: + post: + tags: [admin-novels] + summary: 用 wyrs Admin JWT 换取 CMS 会话 + description: | + Header `Authorization: Bearer `(与 popadmin 登录同一 JWT)。 + 返回 CMS `token`;popadmin iframe 嵌入时用 postMessage 注入。 + operationId: fsnAdoptOpsSession + servers: + - url: https://admin.fullstorynovel.com + security: [] + responses: + "200": + description: `{token,username,opsToken,...}` + content: + application/json: + schema: + type: object + properties: + token: { type: string, description: FSN CMS Bearer } + username: { type: string } + opsToken: { type: string } + expiresInHours: { type: integer } + /api/admin/stats: + get: + tags: [admin-novels] + summary: CMS 仪表盘计数 + operationId: fsnAdminStats + servers: + - url: https://admin.fullstorynovel.com + responses: + "200": + content: + application/json: + schema: + type: object + properties: + novels: { type: integer, format: int64 } + publishedNovels: { type: integer, format: int64 } + chapters: { type: integer, format: int64 } + genres: { type: integer, format: int64 } + /api/admin/novels: + get: + tags: [admin-novels] + summary: 管理端小说列表 + operationId: fsnAdminListNovels + servers: + - url: https://admin.fullstorynovel.com + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FsnAdminNovelCard" + post: + tags: [admin-novels] + summary: 新建小说 + operationId: fsnAdminCreateNovel + servers: + - url: https://admin.fullstorynovel.com + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnNovelUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnAdminNovelCard" + /api/admin/novels/{id}: + get: + tags: [admin-novels] + summary: 管理端小说详情 + operationId: fsnAdminGetNovel + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnAdminNovelCard" + put: + tags: [admin-novels] + summary: 更新小说(含上下架 published / 精选 featured) + operationId: fsnAdminUpdateNovel + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnNovelUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnAdminNovelCard" + delete: + tags: [admin-novels] + summary: 删除小说及章节 + operationId: fsnAdminDeleteNovel + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + /api/admin/covers: + post: + tags: [admin-novels] + summary: 上传封面 multipart + operationId: fsnAdminUploadCover + servers: + - url: https://admin.fullstorynovel.com + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + "200": + description: 含 coverUrl + content: + application/json: + schema: + type: object + properties: + coverUrl: { type: string } + /api/admin/novels/{id}/chapters: + get: + tags: [admin-novels] + summary: 管理端章节列表 + operationId: fsnAdminListChapters + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FsnChapterMeta" + post: + tags: [admin-novels] + summary: 新增章节 + operationId: fsnAdminCreateChapter + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnChapterUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnChapterMeta" + /api/admin/chapters/{id}: + get: + tags: [admin-novels] + summary: 章节详情(含正文) + operationId: fsnAdminGetChapter + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + description: Chapter 实体 JSON + put: + tags: [admin-novels] + summary: 更新章节 + operationId: fsnAdminUpdateChapter + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnChapterUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnChapterMeta" + delete: + tags: [admin-novels] + summary: 删除章节 + operationId: fsnAdminDeleteChapter + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + /api/admin/genres: + get: + tags: [admin-novels] + summary: 管理端分类/标签 + operationId: fsnAdminListGenres + servers: + - url: https://admin.fullstorynovel.com + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FsnAdminGenre" + post: + tags: [admin-novels] + summary: 新建分类或二级标签 + operationId: fsnAdminCreateGenre + servers: + - url: https://admin.fullstorynovel.com + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnGenreUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnAdminGenre" + /api/admin/genres/{id}: + put: + tags: [admin-novels] + summary: 更新分类 + operationId: fsnAdminUpdateGenre + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FsnGenreUpsert" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FsnAdminGenre" + delete: + tags: [admin-novels] + summary: 删除分类 + operationId: fsnAdminDeleteGenre + servers: + - url: https://admin.fullstorynovel.com + parameters: + - name: id + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + /v1/conversations: get: tags: [conversations] @@ -2048,6 +2761,11 @@ components: description: | 设备类型,见 meta `device_type`。1=ios 2=android 3=harmony 4=windows 5=macos 6=web。 指纹 payload 使用该数值。 + AppId: + type: integer + enum: [1] + description: | + 登录 App 标识。写入 JWT `aid`。当前文档约定 Spark IM=`1`(以 `GET /v1/meta/enums` → `app_id` 为准;实现落地后枚举会扩展)。 Language: type: string enum: [zh-CN, en, zh-TW] @@ -2057,7 +2775,7 @@ components: enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover, square_image, square_video] SystemKind: type: string - enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] + enum: [friend.became, system.welcome, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] description: content.system.kind,服务端系统消息 MomentKind: type: integer @@ -2194,6 +2912,185 @@ components: additionalProperties: true description: 成功为业务对象;失败为 {} default: {} + AdminApiResponse: + type: object + description: popadmin / Java 风格管理端响应(非 V3 envelope) + properties: + success: { type: boolean } + message: { type: string } + data: {} + MusicTrack: + type: object + properties: + id: { type: integer, format: int64 } + title: { type: string } + coverKey: { type: string } + audioKey: { type: string } + coverUrl: { type: string, description: sparkr 公网 URL } + audioUrl: { type: string, description: sparkr 公网 URL } + requireMember: { type: boolean, description: true=需会员 } + sortOrder: { type: integer } + enabled: { type: boolean } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + MusicSettings: + type: object + properties: + memberPreviewSeconds: + type: integer + minimum: 0 + maximum: 3600 + description: 会员曲免费试听秒数(全局) + MusicPlaylist: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/MusicTrack" + memberPreviewSeconds: { type: integer } + MusicUpsertRequest: + type: object + properties: + title: { type: string } + requireMember: { type: boolean } + sortOrder: { type: integer } + enabled: { type: boolean } + FsnGenre: + type: object + properties: + id: { type: integer, format: int64 } + slug: { type: string } + name: { type: string } + description: { type: string } + parentId: { type: integer, format: int64, nullable: true } + parentSlug: { type: string, nullable: true } + sortOrder: { type: integer } + children: + type: array + items: + $ref: "#/components/schemas/FsnGenre" + FsnAdminGenre: + allOf: + - $ref: "#/components/schemas/FsnGenre" + - type: object + properties: + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + FsnNovelCard: + type: object + properties: + id: { type: integer, format: int64 } + slug: { type: string } + title: { type: string } + author: { type: string } + synopsis: { type: string } + coverUrl: { type: string } + status: { type: string } + language: { type: string } + genres: { type: array, items: { type: string } } + rating: { type: number } + views: { type: integer, format: int64 } + chapterCount: { type: integer } + featured: { type: boolean } + published: { type: boolean } + latestChapterAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + FsnAdminNovelCard: + allOf: + - $ref: "#/components/schemas/FsnNovelCard" + - type: object + properties: + updatedAt: { type: string, format: date-time } + FsnNovelPage: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/FsnNovelCard" + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + totalPages: { type: integer } + FsnHome: + type: object + properties: + featured: + type: array + items: + $ref: "#/components/schemas/FsnNovelCard" + popular: + type: array + items: + $ref: "#/components/schemas/FsnNovelCard" + newReleases: + type: array + items: + $ref: "#/components/schemas/FsnNovelCard" + recentlyUpdated: + type: array + items: + $ref: "#/components/schemas/FsnNovelCard" + genres: + type: array + items: + $ref: "#/components/schemas/FsnGenre" + FsnChapterMeta: + type: object + properties: + id: { type: integer, format: int64 } + chapterNumber: { type: integer } + slug: { type: string } + title: { type: string } + publishedAt: { type: string, format: date-time, nullable: true } + FsnChapterDetail: + type: object + properties: + id: { type: integer, format: int64 } + novelId: { type: integer, format: int64 } + novelSlug: { type: string } + novelTitle: { type: string } + chapterNumber: { type: integer } + slug: { type: string } + title: { type: string } + content: { type: string } + publishedAt: { type: string, format: date-time, nullable: true } + prev: + $ref: "#/components/schemas/FsnChapterMeta" + next: + $ref: "#/components/schemas/FsnChapterMeta" + FsnNovelUpsert: + type: object + properties: + title: { type: string } + slug: { type: string } + author: { type: string } + synopsis: { type: string } + coverUrl: { type: string } + status: { type: string } + language: { type: string } + genres: { type: array, items: { type: string } } + rating: { type: number } + featured: { type: boolean } + published: { type: boolean, description: 上下架 } + FsnChapterUpsert: + type: object + properties: + chapterNumber: { type: integer } + title: { type: string } + slug: { type: string } + content: { type: string } + published: { type: boolean } + FsnGenreUpsert: + type: object + properties: + name: { type: string } + slug: { type: string } + description: { type: string } + parentId: { type: integer, format: int64, nullable: true } + parentSlug: { type: string, nullable: true } + sortOrder: { type: integer } EnumsEnvelope: type: object required: [code, msg, request_id, data] @@ -2322,6 +3219,9 @@ components: LoginRequest: type: object required: [email, verify_code, app_id, device_type] + description: | + Breaking:删除客户端 `device_id`;新增 `app_id`、整数 `device_type`、物理指纹字段。 + 指纹:`payload="{device_type}:{idfv|android_id}"` → `device_id=uint64(SHA256(HEX(SHA256(payload)))[0:8])`(非 0)。 properties: email: type: string @@ -2357,8 +3257,10 @@ components: properties: access_token: type: string + description: JWT。claims 含 sub、did、aid、use=access refresh_token: type: string + description: JWT。claims 含 sub、did、aid、use=refresh;刷新后 aid 不变 expires_in: type: integer format: int64 @@ -2372,7 +3274,7 @@ components: user_id: type: integer format: int64 - description: Snowflake user id + description: Snowflake user id(= JWT sub) device_id: type: integer format: int64 @@ -2710,7 +3612,11 @@ components: X-Device-ID: schema: type: string - description: Optional. Must match JWT did when present. + description: Optional. Must match JWT did when present(服务端生成的 device_id)。 + X-App-ID: + schema: + type: integer + description: Optional. Must match JWT aid(app_id) when present. X-App-Version: schema: type: string @@ -2743,7 +3649,15 @@ components: schema: type: string example: "7" - description: Header。十进制 device_id,须等于登录 Body 的 device_id / JWT did。不放 Body。 + description: Header。十进制 device_id,须等于登录响应 / JWT did(服务端按物理指纹生成)。不放 Body。 + X-App-ID: + in: header + name: X-App-ID + required: false + schema: + type: integer + example: 1 + description: Header。若传须等于 JWT aid(app_id)。不放业务 Body。 X-App-Version: in: header name: X-App-Version diff --git a/internal/transport/http/handler/admin/music.go b/internal/transport/http/handler/admin/music.go new file mode 100644 index 0000000..4b984b6 --- /dev/null +++ b/internal/transport/http/handler/admin/music.go @@ -0,0 +1,199 @@ +package adminhandler + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + musiclogic "spark-server/internal/module/music/logic" + "spark-server/internal/pkg/errs" +) + +// MusicHandler serves popadmin music CRUD (Java-style ApiResponse). +type MusicHandler struct { + svc *musiclogic.Service +} + +func NewMusic(svc *musiclogic.Service) *MusicHandler { + return &MusicHandler{svc: svc} +} + +func (h *MusicHandler) ready(c *gin.Context) bool { + if h == nil || h.svc == nil || !h.svc.Ready() { + Fail(c, http.StatusServiceUnavailable, "music 服务未就绪") + return false + } + return true +} + +func (h *MusicHandler) List(c *gin.Context) { + if !h.ready(c) { + return + } + list, err := h.svc.ListAdmin(c.Request.Context()) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + settings, err := h.svc.Settings(c.Request.Context()) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"items": list, "settings": settings}) +} + +func (h *MusicHandler) GetSettings(c *gin.Context) { + if !h.ready(c) { + return + } + out, err := h.svc.Settings(c.Request.Context()) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, out) +} + +func (h *MusicHandler) PutSettings(c *gin.Context) { + if !h.ready(c) { + return + } + var body struct { + MemberPreviewSeconds int `json:"memberPreviewSeconds"` + } + if err := c.ShouldBindJSON(&body); err != nil { + Fail(c, http.StatusBadRequest, "无效 JSON") + return + } + out, err := h.svc.UpdateSettings(c.Request.Context(), body.MemberPreviewSeconds) + if err != nil { + failMusic(c, err) + return + } + OK(c, out) +} + +func (h *MusicHandler) Create(c *gin.Context) { + if !h.ready(c) { + return + } + var in musiclogic.UpsertInput + if err := c.ShouldBindJSON(&in); err != nil { + Fail(c, http.StatusBadRequest, "无效 JSON") + return + } + out, err := h.svc.Create(c.Request.Context(), in) + if err != nil { + failMusic(c, err) + return + } + OK(c, out) +} + +func (h *MusicHandler) Update(c *gin.Context) { + if !h.ready(c) { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + Fail(c, http.StatusBadRequest, "无效 id") + return + } + var in musiclogic.UpsertInput + if err := c.ShouldBindJSON(&in); err != nil { + Fail(c, http.StatusBadRequest, "无效 JSON") + return + } + out, err := h.svc.Update(c.Request.Context(), id, in) + if err != nil { + failMusic(c, err) + return + } + OK(c, out) +} + +func (h *MusicHandler) Delete(c *gin.Context) { + if !h.ready(c) { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + Fail(c, http.StatusBadRequest, "无效 id") + return + } + if err := h.svc.Delete(c.Request.Context(), id); err != nil { + failMusic(c, err) + return + } + OK(c, nil) +} + +func (h *MusicHandler) UploadCover(c *gin.Context) { + h.upload(c, true) +} + +func (h *MusicHandler) UploadAudio(c *gin.Context) { + h.upload(c, false) +} + +func (h *MusicHandler) upload(c *gin.Context, cover bool) { + if !h.ready(c) { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + Fail(c, http.StatusBadRequest, "无效 id") + return + } + file, hdr, err := c.Request.FormFile("file") + if err != nil { + Fail(c, http.StatusBadRequest, "请用 multipart 字段 file 上传") + return + } + defer file.Close() + body, err := io.ReadAll(io.LimitReader(file, 51<<20)) + if err != nil { + Fail(c, http.StatusBadRequest, "读取文件失败") + return + } + ct := hdr.Header.Get("Content-Type") + var out any + if cover { + out, err = h.svc.UploadCover(c.Request.Context(), id, hdr.Filename, body, ct) + } else { + out, err = h.svc.UploadAudio(c.Request.Context(), id, hdr.Filename, body, ct) + } + if err != nil { + failMusic(c, err) + return + } + OK(c, out) +} + +func failMusic(c *gin.Context, err error) { + if err == nil { + return + } + if errors.Is(err, musiclogic.ErrNotReady) { + Fail(c, http.StatusServiceUnavailable, err.Error()) + return + } + if app, ok := errs.AsAppError(err); ok { + switch app.Code { + case errs.ErrNotFound.Code: + Fail(c, http.StatusNotFound, app.Msg) + case errs.ErrInvalidArgument.Code: + Fail(c, http.StatusBadRequest, app.Msg) + case errs.ErrAvailable.Code: + Fail(c, http.StatusServiceUnavailable, app.Msg) + default: + Fail(c, http.StatusBadRequest, app.Msg) + } + return + } + Fail(c, http.StatusInternalServerError, err.Error()) +} diff --git a/internal/transport/http/handler/music/handler.go b/internal/transport/http/handler/music/handler.go new file mode 100644 index 0000000..9c65130 --- /dev/null +++ b/internal/transport/http/handler/music/handler.go @@ -0,0 +1,45 @@ +package musichandler + +import ( + "github.com/gin-gonic/gin" + + musiclogic "spark-server/internal/module/music/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/transport/http/respond" +) + +type Handler struct { + svc *musiclogic.Service +} + +func New(svc *musiclogic.Service) *Handler { + return &Handler{svc: svc} +} + +// Playlist GET /v1/music/playlist — public curated list + global preview seconds. +func (h *Handler) Playlist(c *gin.Context) { + if h == nil || h.svc == nil || !h.svc.Ready() { + respond.JSONError(c, errs.ErrAvailable) + return + } + out, err := h.svc.Playlist(c.Request.Context()) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +// Settings GET /v1/music/settings — global member preview seconds only. +func (h *Handler) Settings(c *gin.Context) { + if h == nil || h.svc == nil || !h.svc.Ready() { + respond.JSONError(c, errs.ErrAvailable) + return + } + out, err := h.svc.Settings(c.Request.Context()) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 5392645..202fcdc 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -18,6 +18,7 @@ import ( msghandler "spark-server/internal/transport/http/handler/message" metahandler "spark-server/internal/transport/http/handler/meta" momenthandler "spark-server/internal/transport/http/handler/moment" + musichandler "spark-server/internal/transport/http/handler/music" squarehandler "spark-server/internal/transport/http/handler/square" smshandler "spark-server/internal/transport/http/handler/sms" userhandler "spark-server/internal/transport/http/handler/user" @@ -39,6 +40,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) { registerFavoriteRoutes(v1, deps) registerMomentRoutes(v1, deps) registerSquareRoutes(v1, deps) + registerMusicRoutes(v1, deps) registerConversationRoutes(v1, deps) registerMessageRoutes(v1, deps) } @@ -209,6 +211,17 @@ func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g.POST("/notices/read", h.ReadNotices) } +func registerMusicRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Music == nil { + return + } + h := musichandler.New(deps.Music) + g := v1.Group("/music") + // 客户端可读:无需登录(列表为运营配置的公开曲目元数据) + g.GET("/playlist", h.Playlist) + g.GET("/settings", h.Settings) +} + func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { if deps.Sync == nil { return diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go index a4a9928..5184b3e 100644 --- a/internal/transport/http/router_admin.go +++ b/internal/transport/http/router_admin.go @@ -7,35 +7,44 @@ import ( ) func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { - if deps == nil || deps.Admin == nil || deps.AdminTokens == nil { + if deps == nil || deps.AdminTokens == nil { return } - h := adminhandler.New(deps.Admin) auth := AdminAuth(deps.AdminTokens) + var h *adminhandler.Handler + if deps.Admin != nil { + h = adminhandler.New(deps.Admin) + } + mh := adminhandler.NewMusic(deps.Music) // Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务) - ug := r.Group("/api/admin") - ug.Use(auth) - { - ug.GET("/api-doc", h.ApiDoc) - ug.GET("/users", h.ListUsers) - ug.GET("/users/by-email", h.UserByEmail) - ug.GET("/users/by-nickname", h.UserByNickname) - ug.GET("/users/by-device", h.UserByDevice) - ug.GET("/users/by-ip", h.UserByIP) - ug.GET("/users/:id", h.UserByID) - ug.POST("/users/by-email/ban", h.BanByEmail) - ug.POST("/users/by-email/whitelist", h.WhitelistByEmail) - ug.POST("/users/by-email/mark-abnormal", h.MarkAbnormalByEmail) - ug.POST("/users/by-email/reset-nickname", h.ResetNicknameByEmail) - ug.POST("/users/by-email/reset-avatar", h.ResetAvatarByEmail) - ug.POST("/users/by-email/reset-password", h.ResetPasswordByEmail) + if h != nil { + ug := r.Group("/api/admin") + ug.Use(auth) + { + ug.GET("/api-doc", h.ApiDoc) + ug.GET("/users", h.ListUsers) + ug.GET("/users/by-email", h.UserByEmail) + ug.GET("/users/by-nickname", h.UserByNickname) + ug.GET("/users/by-device", h.UserByDevice) + ug.GET("/users/by-ip", h.UserByIP) + ug.GET("/users/:id", h.UserByID) + ug.POST("/users/by-email/ban", h.BanByEmail) + ug.POST("/users/by-email/whitelist", h.WhitelistByEmail) + ug.POST("/users/by-email/mark-abnormal", h.MarkAbnormalByEmail) + ug.POST("/users/by-email/reset-nickname", h.ResetNicknameByEmail) + ug.POST("/users/by-email/reset-avatar", h.ResetAvatarByEmail) + ug.POST("/users/by-email/reset-password", h.ResetPasswordByEmail) + } } for _, prefix := range []string{"/api/admin/spark", "/api/admin/pop"} { g := r.Group(prefix) g.Use(auth) - registerSparkAdmin(g, h) + if h != nil { + registerSparkAdmin(g, h) + } + registerMusicAdmin(g, mh) } } @@ -63,3 +72,17 @@ func registerSparkAdmin(g *gin.RouterGroup, h *adminhandler.Handler) { g.GET("/complaints", h.ListComplaints) g.POST("/complaints", h.AddComplaint) } + +func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) { + if h == nil { + return + } + g.GET("/music", h.List) + g.GET("/music/settings", h.GetSettings) + g.PUT("/music/settings", h.PutSettings) + g.POST("/music", h.Create) + g.PATCH("/music/:id", h.Update) + g.DELETE("/music/:id", h.Delete) + g.POST("/music/:id/cover", h.UploadCover) + g.POST("/music/:id/audio", h.UploadAudio) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 1a65209..6a0854d 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -19,6 +19,7 @@ import ( grouplogic "spark-server/internal/module/group/logic" msglogic "spark-server/internal/module/message/logic" momentlogic "spark-server/internal/module/moment/logic" + musiclogic "spark-server/internal/module/music/logic" squarelogic "spark-server/internal/module/square/logic" smslogic "spark-server/internal/module/sms/logic" synclogic "spark-server/internal/module/sync/logic" @@ -56,6 +57,7 @@ type EngineDeps struct { Sync *synclogic.Service Admin *adminlogic.Service AdminTokens *adminlogic.TokenService + Music *musiclogic.Service Health Health AuthLimiter *ratelimit.Limiter } diff --git a/migrations/0020_music.sql b/migrations/0020_music.sql new file mode 100644 index 0000000..205c7cb --- /dev/null +++ b/migrations/0020_music.sql @@ -0,0 +1,26 @@ +-- 运营音乐列表 + 全局会员试听时长。 + +CREATE TABLE IF NOT EXISTS music_tracks ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '曲目自增 id', + title VARCHAR(256) NOT NULL DEFAULT '' COMMENT '曲名', + cover_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 object_key(sparkr)', + audio_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '音频 object_key(sparkr)', + cover_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '封面公网 URL', + audio_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '音频公网 URL', + require_member TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=需会员', + sort_order INT NOT NULL DEFAULT 0 COMMENT '越小越靠前', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=上架', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + KEY idx_music_list (enabled, sort_order, id) +) COMMENT='运营音乐曲目'; + +CREATE TABLE IF NOT EXISTS music_settings ( + setting_key VARCHAR(64) PRIMARY KEY COMMENT '配置键', + setting_value VARCHAR(256) NOT NULL DEFAULT '' COMMENT '配置值', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间' +) COMMENT='音乐全局配置(如会员免费试听秒数)'; + +INSERT INTO music_settings (setting_key, setting_value, updated_at) +VALUES ('member_preview_seconds', '30', CURRENT_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE setting_key = setting_key;