feat: music 运营曲目、seed 机器人欢迎消息,并补充 novels/music OpenAPI

合并 origin/main 设备注册后,纳入本地 music 模块、seed 命令与 popadmin 相关路由/文档;music migration 编号为 0020 以避免与 0019_devices 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-23 16:07:05 +08:00
parent bec98be1da
commit ce2cdf6bf3
34 changed files with 2927 additions and 46 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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 资源

90
cmd/seed/main.go Normal file
View File

@ -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
}

View File

@ -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)(\?|$) {

View File

@ -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 枚举。

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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()

View File

@ -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,
)
}

View File

@ -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"`
}

View File

@ -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
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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.",
})},
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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"

View File

@ -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)
}
}

View File

@ -0,0 +1,4 @@
package entity
// SystemKindWelcome is messages.content system.kind for the English onboarding notice.
const SystemKindWelcome = "system.welcome"

View File

@ -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
}

View File

@ -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()

View File

@ -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: "退出群"},

View File

@ -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.

File diff suppressed because it is too large Load Diff

View File

@ -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())
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

26
migrations/0020_music.sql Normal file
View File

@ -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;