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:
parent
bec98be1da
commit
ce2cdf6bf3
@ -84,7 +84,7 @@ Do not convert these to integers.
|
|||||||
| `MomentStatus` | `moment/entity.Status` | | 0正常 1已删 |
|
| `MomentStatus` | `moment/entity.Status` | | 0正常 1已删 |
|
||||||
| `MomentNoticeKind` | `moment/entity.NoticeKind` | `kind` | 1赞 2评论 3回复 |
|
| `MomentNoticeKind` | `moment/entity.NoticeKind` | `kind` | 1赞 2评论 3回复 |
|
||||||
| `MomentVisibleDays` | `moment/entity.VisibleDays` | `visible_days` | 0 / 3 / 30 / 180 |
|
| `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.
|
Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum.
|
||||||
|
|
||||||
|
|||||||
6
Makefile
6
Makefile
@ -8,7 +8,7 @@ LINT ?= golangci-lint
|
|||||||
CONFIG ?= configs/config.yaml
|
CONFIG ?= configs/config.yaml
|
||||||
LOGIC_COVER_MIN ?= 65
|
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
|
all: lint test build
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ image:
|
|||||||
mkdir -p $(LINUX_DIR)
|
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-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-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) .
|
docker build --platform linux/amd64 -t $(IMAGE) .
|
||||||
|
|
||||||
# All unit tests, then each module logic package must be >= LOGIC_COVER_MIN.
|
# All unit tests, then each module logic package must be >= LOGIC_COVER_MIN.
|
||||||
@ -60,6 +61,9 @@ tidy:
|
|||||||
migrate:
|
migrate:
|
||||||
$(GO) run ./cmd/migrate -config $(CONFIG)
|
$(GO) run ./cmd/migrate -config $(CONFIG)
|
||||||
|
|
||||||
|
seed:
|
||||||
|
$(GO) run ./cmd/seed -config $(CONFIG)
|
||||||
|
|
||||||
start:
|
start:
|
||||||
$(GO) run ./cmd/server -config $(CONFIG)
|
$(GO) run ./cmd/server -config $(CONFIG)
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@ -111,6 +111,26 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
|
|||||||
|
|
||||||
`uid` 就是 Snowflake `users.id`。对外展示用可变的 `spark_id`,不要把 uid 写进对象存储路径。
|
`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 资源
|
## HTTP 资源
|
||||||
|
|||||||
90
cmd/seed/main.go
Normal file
90
cmd/seed/main.go
Normal 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
|
||||||
|
}
|
||||||
@ -28,8 +28,8 @@ location ~ ^/api/admin/spark/users/export-zip {
|
|||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# spark-server:IM / 群 / 投诉 / 排行 / 用户检索与 V3 账号运营
|
# spark-server:IM / 群 / 投诉 / 排行 / 用户检索 / 音乐列表与 V3 账号运营
|
||||||
location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings)(/|$) {
|
location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings|music)(/|$) {
|
||||||
proxy_pass http://spark_im_admin;
|
proxy_pass http://spark_im_admin;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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-Client-Port $remote_port;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
client_max_body_size 55m;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/api/admin/(spark|pop)/users/by-(nickname|device|ip)(\?|$) {
|
location ~ ^/api/admin/(spark|pop)/users/by-(nickname|device|ip)(\?|$) {
|
||||||
|
|||||||
41
docs/popadmin-music-novels-deploy.md
Normal file
41
docs/popadmin-music-novels-deploy.md
Normal 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 枚举。
|
||||||
@ -41,6 +41,8 @@ import (
|
|||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
momentlogic "spark-server/internal/module/moment/logic"
|
momentlogic "spark-server/internal/module/moment/logic"
|
||||||
momentmodel "spark-server/internal/module/moment/model"
|
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"
|
smslogic "spark-server/internal/module/sms/logic"
|
||||||
squarelogic "spark-server/internal/module/square/logic"
|
squarelogic "spark-server/internal/module/square/logic"
|
||||||
squaremodel "spark-server/internal/module/square/model"
|
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)
|
favorites := wireFavorites(idgen, db, objects, chat)
|
||||||
moments := wireMoments(idgen, db, objects, friends, users)
|
moments := wireMoments(idgen, db, objects, friends, users)
|
||||||
square := wireSquare(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)...)
|
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...)
|
||||||
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
||||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
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,
|
Sync: syncSvc,
|
||||||
})
|
})
|
||||||
app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps(
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
@ -223,6 +226,7 @@ func httpEngineDeps(
|
|||||||
favorites *favlogic.Service,
|
favorites *favlogic.Service,
|
||||||
moments *momentlogic.Service,
|
moments *momentlogic.Service,
|
||||||
square *squarelogic.Service,
|
square *squarelogic.Service,
|
||||||
|
music *musiclogic.Service,
|
||||||
messages *msglogic.Service,
|
messages *msglogic.Service,
|
||||||
syncSvc *synclogic.Service,
|
syncSvc *synclogic.Service,
|
||||||
adminSvc *adminlogic.Service,
|
adminSvc *adminlogic.Service,
|
||||||
@ -232,7 +236,7 @@ func httpEngineDeps(
|
|||||||
) *httpx.EngineDeps {
|
) *httpx.EngineDeps {
|
||||||
return &httpx.EngineDeps{
|
return &httpx.EngineDeps{
|
||||||
Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups,
|
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,
|
Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens,
|
||||||
Health: probe, AuthLimiter: authLimit,
|
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)
|
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 {
|
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 {
|
if idgen == nil || friends == nil || users == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -19,10 +19,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxGetBytes = 32 << 20
|
maxGetBytes = 32 << 20
|
||||||
maxPutBytes = 4 << 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) {
|
func (s *Store) PresignPut(ctx context.Context, key, contentType string, size int64, ttl time.Duration) (string, error) {
|
||||||
if !s.Ready() {
|
if !s.Ready() {
|
||||||
return "", errs.ErrAvailable
|
return "", errs.ErrAvailable
|
||||||
|
|||||||
27
internal/module/group/model/find.go
Normal file
27
internal/module/group/model/find.go
Normal 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
|
||||||
|
}
|
||||||
@ -50,6 +50,29 @@ func (m *MemoryRepo) GetByConversation(_ context.Context, conversationID int64)
|
|||||||
return copyGroup(m.groups[id])
|
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) {
|
func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Group, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
groupentity "spark-server/internal/module/group/entity"
|
groupentity "spark-server/internal/module/group/entity"
|
||||||
"spark-server/internal/module/meta/entity"
|
"spark-server/internal/module/meta/entity"
|
||||||
uploadentity "spark-server/internal/module/upload/entity"
|
uploadentity "spark-server/internal/module/upload/entity"
|
||||||
|
userentity "spark-server/internal/module/user/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
"spark-server/internal/pkg/i18n"
|
||||||
"spark-server/internal/transport/tcp/protocol"
|
"spark-server/internal/transport/tcp/protocol"
|
||||||
@ -74,7 +75,8 @@ func uploadPurpose(lang i18n.Lang) []entity.Item {
|
|||||||
|
|
||||||
func systemKind(lang i18n.Lang) []entity.Item {
|
func systemKind(lang i18n.Lang) []entity.Item {
|
||||||
return strSet(lang, "system_kind",
|
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,
|
groupentity.SystemKindLeave, groupentity.SystemKindKick, groupentity.SystemKindDissolve, groupentity.SystemKindTransfer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/module/music/entity/track.go
Normal file
37
internal/module/music/entity/track.go
Normal 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"`
|
||||||
|
}
|
||||||
334
internal/module/music/logic/service.go
Normal file
334
internal/module/music/logic/service.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
203
internal/module/music/model/repo.go
Normal file
203
internal/module/music/model/repo.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
internal/module/seed/logic/bots.go
Normal file
100
internal/module/seed/logic/bots.go
Normal 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
|
||||||
|
}
|
||||||
179
internal/module/seed/logic/group.go
Normal file
179
internal/module/seed/logic/group.go
Normal 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.",
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/module/seed/logic/nicknames.go
Normal file
49
internal/module/seed/logic/nicknames.go
Normal 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
|
||||||
|
}
|
||||||
140
internal/module/seed/logic/service.go
Normal file
140
internal/module/seed/logic/service.go
Normal 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
|
||||||
|
}
|
||||||
113
internal/module/seed/logic/service_test.go
Normal file
113
internal/module/seed/logic/service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
internal/module/seed/logic/welcome.go
Normal file
107
internal/module/seed/logic/welcome.go
Normal 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)
|
||||||
|
}
|
||||||
45
internal/module/user/entity/bot.go
Normal file
45
internal/module/user/entity/bot.go
Normal 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"
|
||||||
24
internal/module/user/entity/bot_test.go
Normal file
24
internal/module/user/entity/bot_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
internal/module/user/entity/system.go
Normal file
4
internal/module/user/entity/system.go
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
// SystemKindWelcome is messages.content system.kind for the English onboarding notice.
|
||||||
|
const SystemKindWelcome = "system.welcome"
|
||||||
38
internal/module/user/model/list.go
Normal file
38
internal/module/user/model/list.go
Normal 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
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -104,6 +105,31 @@ func (m *MemoryRepo) CreateWithEmail(_ context.Context, u *entity.User, email st
|
|||||||
return nil
|
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 {
|
func (m *MemoryRepo) UpdateSparkID(_ context.Context, userID int64, sparkID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -35,6 +35,7 @@ func init() {
|
|||||||
"enum.upload_purpose.chat_gif": {ZhCN: "聊天GIF", En: "Chat GIF", ZhTW: "聊天GIF"},
|
"enum.upload_purpose.chat_gif": {ZhCN: "聊天GIF", En: "Chat GIF", ZhTW: "聊天GIF"},
|
||||||
"enum.upload_purpose.chat_file": {ZhCN: "聊天文件", En: "Chat file", ZhTW: "聊天檔案"},
|
"enum.upload_purpose.chat_file": {ZhCN: "聊天文件", En: "Chat file", ZhTW: "聊天檔案"},
|
||||||
"enum.system_kind.friend_became": {ZhCN: "已成为好友", En: "Became friends", 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_created": {ZhCN: "已创建群", En: "Group created", ZhTW: "已建立群"},
|
||||||
"enum.system_kind.group_invite": {ZhCN: "邀请入群", En: "Invited to group", ZhTW: "邀請入群"},
|
"enum.system_kind.group_invite": {ZhCN: "邀请入群", En: "Invited to group", ZhTW: "邀請入群"},
|
||||||
"enum.system_kind.group_leave": {ZhCN: "退出群", En: "Left group", ZhTW: "退出群"},
|
"enum.system_kind.group_leave": {ZhCN: "退出群", En: "Left group", ZhTW: "退出群"},
|
||||||
|
|||||||
@ -88,7 +88,8 @@ func publicPath(path string) bool {
|
|||||||
switch path {
|
switch path {
|
||||||
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
|
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
|
||||||
return true
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
199
internal/transport/http/handler/admin/music.go
Normal file
199
internal/transport/http/handler/admin/music.go
Normal 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())
|
||||||
|
}
|
||||||
45
internal/transport/http/handler/music/handler.go
Normal file
45
internal/transport/http/handler/music/handler.go
Normal 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)
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import (
|
|||||||
msghandler "spark-server/internal/transport/http/handler/message"
|
msghandler "spark-server/internal/transport/http/handler/message"
|
||||||
metahandler "spark-server/internal/transport/http/handler/meta"
|
metahandler "spark-server/internal/transport/http/handler/meta"
|
||||||
momenthandler "spark-server/internal/transport/http/handler/moment"
|
momenthandler "spark-server/internal/transport/http/handler/moment"
|
||||||
|
musichandler "spark-server/internal/transport/http/handler/music"
|
||||||
squarehandler "spark-server/internal/transport/http/handler/square"
|
squarehandler "spark-server/internal/transport/http/handler/square"
|
||||||
smshandler "spark-server/internal/transport/http/handler/sms"
|
smshandler "spark-server/internal/transport/http/handler/sms"
|
||||||
userhandler "spark-server/internal/transport/http/handler/user"
|
userhandler "spark-server/internal/transport/http/handler/user"
|
||||||
@ -39,6 +40,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
|
|||||||
registerFavoriteRoutes(v1, deps)
|
registerFavoriteRoutes(v1, deps)
|
||||||
registerMomentRoutes(v1, deps)
|
registerMomentRoutes(v1, deps)
|
||||||
registerSquareRoutes(v1, deps)
|
registerSquareRoutes(v1, deps)
|
||||||
|
registerMusicRoutes(v1, deps)
|
||||||
registerConversationRoutes(v1, deps)
|
registerConversationRoutes(v1, deps)
|
||||||
registerMessageRoutes(v1, deps)
|
registerMessageRoutes(v1, deps)
|
||||||
}
|
}
|
||||||
@ -209,6 +211,17 @@ func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
g.POST("/notices/read", h.ReadNotices)
|
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) {
|
func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||||
if deps.Sync == nil {
|
if deps.Sync == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -7,35 +7,44 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
|
func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
|
||||||
if deps == nil || deps.Admin == nil || deps.AdminTokens == nil {
|
if deps == nil || deps.AdminTokens == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h := adminhandler.New(deps.Admin)
|
|
||||||
auth := AdminAuth(deps.AdminTokens)
|
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* 切到本服务)
|
// Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务)
|
||||||
ug := r.Group("/api/admin")
|
if h != nil {
|
||||||
ug.Use(auth)
|
ug := r.Group("/api/admin")
|
||||||
{
|
ug.Use(auth)
|
||||||
ug.GET("/api-doc", h.ApiDoc)
|
{
|
||||||
ug.GET("/users", h.ListUsers)
|
ug.GET("/api-doc", h.ApiDoc)
|
||||||
ug.GET("/users/by-email", h.UserByEmail)
|
ug.GET("/users", h.ListUsers)
|
||||||
ug.GET("/users/by-nickname", h.UserByNickname)
|
ug.GET("/users/by-email", h.UserByEmail)
|
||||||
ug.GET("/users/by-device", h.UserByDevice)
|
ug.GET("/users/by-nickname", h.UserByNickname)
|
||||||
ug.GET("/users/by-ip", h.UserByIP)
|
ug.GET("/users/by-device", h.UserByDevice)
|
||||||
ug.GET("/users/:id", h.UserByID)
|
ug.GET("/users/by-ip", h.UserByIP)
|
||||||
ug.POST("/users/by-email/ban", h.BanByEmail)
|
ug.GET("/users/:id", h.UserByID)
|
||||||
ug.POST("/users/by-email/whitelist", h.WhitelistByEmail)
|
ug.POST("/users/by-email/ban", h.BanByEmail)
|
||||||
ug.POST("/users/by-email/mark-abnormal", h.MarkAbnormalByEmail)
|
ug.POST("/users/by-email/whitelist", h.WhitelistByEmail)
|
||||||
ug.POST("/users/by-email/reset-nickname", h.ResetNicknameByEmail)
|
ug.POST("/users/by-email/mark-abnormal", h.MarkAbnormalByEmail)
|
||||||
ug.POST("/users/by-email/reset-avatar", h.ResetAvatarByEmail)
|
ug.POST("/users/by-email/reset-nickname", h.ResetNicknameByEmail)
|
||||||
ug.POST("/users/by-email/reset-password", h.ResetPasswordByEmail)
|
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"} {
|
for _, prefix := range []string{"/api/admin/spark", "/api/admin/pop"} {
|
||||||
g := r.Group(prefix)
|
g := r.Group(prefix)
|
||||||
g.Use(auth)
|
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.GET("/complaints", h.ListComplaints)
|
||||||
g.POST("/complaints", h.AddComplaint)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import (
|
|||||||
grouplogic "spark-server/internal/module/group/logic"
|
grouplogic "spark-server/internal/module/group/logic"
|
||||||
msglogic "spark-server/internal/module/message/logic"
|
msglogic "spark-server/internal/module/message/logic"
|
||||||
momentlogic "spark-server/internal/module/moment/logic"
|
momentlogic "spark-server/internal/module/moment/logic"
|
||||||
|
musiclogic "spark-server/internal/module/music/logic"
|
||||||
squarelogic "spark-server/internal/module/square/logic"
|
squarelogic "spark-server/internal/module/square/logic"
|
||||||
smslogic "spark-server/internal/module/sms/logic"
|
smslogic "spark-server/internal/module/sms/logic"
|
||||||
synclogic "spark-server/internal/module/sync/logic"
|
synclogic "spark-server/internal/module/sync/logic"
|
||||||
@ -56,6 +57,7 @@ type EngineDeps struct {
|
|||||||
Sync *synclogic.Service
|
Sync *synclogic.Service
|
||||||
Admin *adminlogic.Service
|
Admin *adminlogic.Service
|
||||||
AdminTokens *adminlogic.TokenService
|
AdminTokens *adminlogic.TokenService
|
||||||
|
Music *musiclogic.Service
|
||||||
Health Health
|
Health Health
|
||||||
AuthLimiter *ratelimit.Limiter
|
AuthLimiter *ratelimit.Limiter
|
||||||
}
|
}
|
||||||
|
|||||||
26
migrations/0020_music.sql
Normal file
26
migrations/0020_music.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user