feat: 多广场 channel、音乐公开 API 与订阅 package_name
支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7f2567cf15
commit
53426af122
@ -77,7 +77,6 @@
|
||||
|
||||
- Spark Messenger 全页面接入检查
|
||||
|
||||
|
||||
## 14:48 — 修复 make lint / make test
|
||||
|
||||
### 概要
|
||||
@ -130,6 +129,14 @@ make install-git-hooks # 本机只需一次
|
||||
- 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉
|
||||
- OpenAPI 同步说明
|
||||
|
||||
## 14:53 — Android 白噪音对照后补音乐公开 API
|
||||
|
||||
### 概要
|
||||
|
||||
- 对照 `WhiteNoiseJSPPOldAndroid`:音乐主路径仍走欢休 `app.huanxiu.vip`;IM/登录/支付多为旧 JSPP TCP protobuf。
|
||||
- 在 Spark V3 风格下补齐迁端缺口:单曲详情、分区嵌套列表、`requireMember` 筛选;公开路由免登录。
|
||||
- 顺手修复先前好友拒绝用例测试(Accept 双返回值、测试邮箱)。
|
||||
|
||||
### 变更范围
|
||||
|
||||
| 类型 | 说明 |
|
||||
@ -178,6 +185,35 @@ make install-git-hooks # 本机只需一次
|
||||
- 用户上传路径:凭证 size + 发帖声明宽高/体积软检测(默认可日志,可拒收)。
|
||||
- 机器人配图:入库对象存储前压到最长边 ≤1080;视频路径 ≤720(ffmpeg)。
|
||||
|
||||
| 功能 | 音乐公开读 API 增强 |
|
||||
| 文档 | OpenAPI music 路径与 schema |
|
||||
| 修复 | friend Reject 单测 |
|
||||
|
||||
### 主要文件
|
||||
|
||||
- `internal/module/music/entity/track.go` — GroupedPlaylist / PlaylistGroup
|
||||
- `internal/module/music/logic/service.go` — GetPublic、PlaylistGrouped、PlaylistQuery
|
||||
- `internal/transport/http/handler/music/handler.go`、`router.go`、`auth.go`
|
||||
- `internal/transport/http/docs/openapi.yaml`
|
||||
- `internal/module/music/logic/service_test.go`、`friend/logic/friend_test.go`
|
||||
|
||||
### 验证
|
||||
|
||||
- `go test ./...`:通过(本机无 `make`;`golangci-lint` 与 Go 1.27 export data 不兼容,未跑 lint)
|
||||
- `music/logic` coverage:74.7%
|
||||
|
||||
### 关联
|
||||
|
||||
- 用户:对照 Android 补全 spark-server 缺失 API(不 commit/push)
|
||||
|
||||
## 15:20 — 多广场实例 + 分类筛选
|
||||
|
||||
### 概要
|
||||
|
||||
- 同一套广场能力按 `channel` 隔离数据:`square` / `squarenovel` / `squaresteps` / `squarehealth`。
|
||||
- 各广场支持分类 Tab:推荐/最新/热门/附近(排序)+ 搞笑/美女(帖标签);附近为 lat/lng 曼哈顿距离 stub。
|
||||
- 发帖/点赞/评论/通知水位均带 channel;保留原 `/v1/square/...` 路径。
|
||||
|
||||
### 变更范围
|
||||
|
||||
| 类型 | 说明 |
|
||||
@ -238,3 +274,25 @@ make install-git-hooks # 本机只需一次
|
||||
### 关联
|
||||
|
||||
- 任务:发现页顶部接入同开发者应用列表(stash 合并进 main)
|
||||
|
||||
| 功能 | 多广场 + Feed `category` 筛选 |
|
||||
| 迁移 | `0031_square_channel_category.sql` |
|
||||
| 文档 | OpenAPI `{square_channel}` 路径;meta 枚举 |
|
||||
|
||||
### 主要文件
|
||||
|
||||
- `migrations/0031_square_channel_category.sql` — posts/notices/notice_state 加 channel/category
|
||||
- `internal/module/square/entity/channel.go` — Channel / Category 枚举
|
||||
- `internal/module/square/logic/*`、`model/*` — channel 隔离与 Feed 排序
|
||||
- `internal/transport/http/handler/square`、`router.go` — 四前缀同路由 + `/categories`
|
||||
- `internal/transport/http/docs/openapi.yaml`、`meta`、`i18n`
|
||||
|
||||
### 验证
|
||||
|
||||
- `go run ./cmd/migrate -config configs/config.yaml`:通过
|
||||
- `go test ./...`:通过(本机无 `make`;golangci-lint 与 Go 1.27 不兼容)
|
||||
- `square/logic` coverage:77.5%
|
||||
|
||||
### 关联
|
||||
|
||||
- 用户:spark-server 多广场 + 分类筛选(不 commit/push)
|
||||
|
||||
@ -44,9 +44,11 @@ type Feedback struct {
|
||||
// SubscriptionPlan is one week/month/quarter/year card.
|
||||
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
|
||||
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
|
||||
// PackageName 为空表示默认兜底;客户端传 packageName / X-Wyrs-Package 时优先精确匹配。
|
||||
type SubscriptionPlan struct {
|
||||
PlanID string `json:"plan_id"`
|
||||
Lang string `json:"lang"`
|
||||
PackageName string `json:"package_name,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
PriceDisplay string `json:"price_display"`
|
||||
|
||||
@ -22,8 +22,8 @@ type Repository interface {
|
||||
InsertFeedback(ctx context.Context, f *entity.Feedback) error
|
||||
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
|
||||
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
|
||||
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
||||
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
||||
ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
|
||||
ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
|
||||
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
||||
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
||||
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
||||
@ -183,16 +183,17 @@ func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status str
|
||||
return s.repo.UpdateFeedbackStatus(ctx, id, status)
|
||||
}
|
||||
|
||||
func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionConfig, error) {
|
||||
func (s *Service) SubscriptionPlans(ctx context.Context, packageName string) (*entity.SubscriptionConfig, error) {
|
||||
lang := normalizeLang(reqctx.Language(ctx))
|
||||
packageName = strings.TrimSpace(packageName)
|
||||
headline, sub := subscriptionCopy(lang)
|
||||
if s.repo != nil {
|
||||
plans, err := s.repo.ListPlans(ctx, lang)
|
||||
plans, err := s.repo.ListPlans(ctx, lang, packageName)
|
||||
if err == nil && len(plans) > 0 {
|
||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||
}
|
||||
if lang != string(i18n.ZhCN) {
|
||||
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN))
|
||||
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN), packageName)
|
||||
if err == nil && len(plans) > 0 {
|
||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||
}
|
||||
@ -201,11 +202,11 @@ func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionCo
|
||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
||||
func (s *Service) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||
if s.repo == nil {
|
||||
return defaultPlans(normalizeLang(lang)), nil
|
||||
}
|
||||
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang))
|
||||
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang), strings.TrimSpace(packageName))
|
||||
}
|
||||
|
||||
func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||
@ -217,6 +218,7 @@ func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPla
|
||||
}
|
||||
p.Lang = normalizeLang(p.Lang)
|
||||
p.PlanID = strings.TrimSpace(p.PlanID)
|
||||
p.PackageName = strings.TrimSpace(p.PackageName)
|
||||
switch p.PlanID {
|
||||
case "week", "month", "quarter", "year":
|
||||
default:
|
||||
|
||||
@ -71,7 +71,7 @@ func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, statu
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
||||
func (s *stubClientRepo) ListPlans(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||
out := make([]entity.SubscriptionPlan, 0)
|
||||
for i := range s.plans {
|
||||
p := &s.plans[i]
|
||||
@ -79,19 +79,35 @@ func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.Sub
|
||||
out = append(out, *p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
if packageName == "" {
|
||||
return out, nil
|
||||
}
|
||||
exact := make([]entity.SubscriptionPlan, 0)
|
||||
fallback := make([]entity.SubscriptionPlan, 0)
|
||||
for _, p := range out {
|
||||
if p.PackageName == packageName {
|
||||
exact = append(exact, p)
|
||||
} else if p.PackageName == "" {
|
||||
fallback = append(fallback, p)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
||||
if lang == "" {
|
||||
return s.plans, nil
|
||||
}
|
||||
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||
out := make([]entity.SubscriptionPlan, 0)
|
||||
for i := range s.plans {
|
||||
p := &s.plans[i]
|
||||
if p.Lang == lang {
|
||||
out = append(out, *p)
|
||||
if lang != "" && p.Lang != lang {
|
||||
continue
|
||||
}
|
||||
if packageName != "" && p.PackageName != packageName {
|
||||
continue
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@ -167,7 +183,7 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
|
||||
if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" {
|
||||
t.Fatalf("legal %+v err=%v", legal, err)
|
||||
}
|
||||
plans, err := svc.SubscriptionPlans(t.Context())
|
||||
plans, err := svc.SubscriptionPlans(t.Context(), "")
|
||||
if err != nil || len(plans.Plans) != 4 {
|
||||
t.Fatalf("plans %+v err=%v", plans, err)
|
||||
}
|
||||
@ -231,7 +247,7 @@ func TestClientServiceAdminAndRepo(t *testing.T) {
|
||||
if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN))
|
||||
adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN), "")
|
||||
if err != nil || len(adminPlans) != 1 {
|
||||
t.Fatalf("plans %+v %v", adminPlans, err)
|
||||
}
|
||||
@ -315,7 +331,7 @@ func TestDeveloperAppsAdmin(t *testing.T) {
|
||||
if err != nil || len(apps.Items) != 0 {
|
||||
t.Fatalf("exclude package %+v %v", apps, err)
|
||||
}
|
||||
cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW)))
|
||||
cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW)), "")
|
||||
if err != nil || len(cfg.Plans) == 0 {
|
||||
t.Fatalf("plans %+v %v", cfg, err)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package model
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@ -50,6 +51,7 @@ func (feedbackRow) TableName() string { return "t_feedback" }
|
||||
type planRow struct {
|
||||
PlanID string `gorm:"column:plan_id;primaryKey"`
|
||||
Lang string `gorm:"column:lang;primaryKey"`
|
||||
PackageName string `gorm:"column:package_name;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
PriceDisplay string `gorm:"column:price_display"`
|
||||
@ -170,7 +172,7 @@ func (r *Repo) UpdateFeedbackStatus(ctx context.Context, id int64, status string
|
||||
return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
||||
func (r *Repo) ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||
var rows []planRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("lang = ? AND enabled = 1", lang).
|
||||
@ -179,14 +181,17 @@ func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.Subscriptio
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapPlans(rows), nil
|
||||
return filterPlansByPackage(mapPlans(rows), packageName), nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
||||
q := r.db.WithContext(ctx).Order("lang ASC, sort_order ASC, plan_id ASC")
|
||||
func (r *Repo) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||
q := r.db.WithContext(ctx).Order("package_name ASC, lang ASC, sort_order ASC, plan_id ASC")
|
||||
if lang != "" {
|
||||
q = q.Where("lang = ?", lang)
|
||||
}
|
||||
if packageName != "" {
|
||||
q = q.Where("package_name = ?", packageName)
|
||||
}
|
||||
var rows []planRow
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
@ -199,7 +204,8 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
out = append(out, entity.SubscriptionPlan{
|
||||
PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle,
|
||||
PlanID: row.PlanID, Lang: row.Lang, PackageName: row.PackageName,
|
||||
Title: row.Title, Subtitle: row.Subtitle,
|
||||
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
|
||||
PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder,
|
||||
Enabled: row.Enabled, ProductID: row.ProductID,
|
||||
@ -208,12 +214,46 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
||||
return out
|
||||
}
|
||||
|
||||
// filterPlansByPackage:有精确包名则用之,否则回退 package_name 为空的默认档。
|
||||
func filterPlansByPackage(items []entity.SubscriptionPlan, packageName string) []entity.SubscriptionPlan {
|
||||
packageName = strings.TrimSpace(packageName)
|
||||
if packageName == "" {
|
||||
// 无包名:仅返回默认兜底,避免多包混在一起
|
||||
out := make([]entity.SubscriptionPlan, 0, len(items))
|
||||
for _, p := range items {
|
||||
if strings.TrimSpace(p.PackageName) == "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
return items
|
||||
}
|
||||
want := strings.ToLower(packageName)
|
||||
exact := make([]entity.SubscriptionPlan, 0)
|
||||
fallback := make([]entity.SubscriptionPlan, 0)
|
||||
for _, p := range items {
|
||||
pkg := strings.TrimSpace(p.PackageName)
|
||||
if pkg != "" && strings.ToLower(pkg) == want {
|
||||
exact = append(exact, p)
|
||||
} else if pkg == "" {
|
||||
fallback = append(fallback, p)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||
if p == nil {
|
||||
return errors.New("client: nil subscription plan")
|
||||
}
|
||||
row := planRow{
|
||||
PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle,
|
||||
PlanID: p.PlanID, Lang: p.Lang, PackageName: strings.TrimSpace(p.PackageName),
|
||||
Title: p.Title, Subtitle: p.Subtitle,
|
||||
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
|
||||
PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder,
|
||||
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),
|
||||
|
||||
@ -37,4 +37,6 @@ type Catalog struct {
|
||||
MomentVisibleDays []Item `json:"moment_visible_days"`
|
||||
VerificationPurpose []Item `json:"verification_purpose"`
|
||||
PhoneCarrier []Item `json:"phone_carrier"`
|
||||
SquareChannel []Item `json:"square_channel"`
|
||||
SquareCategory []Item `json:"square_category"`
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
||||
MomentVisibleDays: momentVisibleDays(lang),
|
||||
VerificationPurpose: verificationPurpose(lang),
|
||||
PhoneCarrier: phoneCarrier(lang),
|
||||
SquareChannel: squareChannel(lang),
|
||||
SquareCategory: squareCategory(lang),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
internal/module/meta/logic/enums_square.go
Normal file
25
internal/module/meta/logic/enums_square.go
Normal file
@ -0,0 +1,25 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"spark-server/internal/module/meta/entity"
|
||||
squareentity "spark-server/internal/module/square/entity"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
)
|
||||
|
||||
func squareChannel(lang i18n.Lang) []entity.Item {
|
||||
chs := squareentity.Channels()
|
||||
vals := make([]string, 0, len(chs))
|
||||
for _, c := range chs {
|
||||
vals = append(vals, string(c))
|
||||
}
|
||||
return strSet(lang, "square_channel", vals...)
|
||||
}
|
||||
|
||||
func squareCategory(lang i18n.Lang) []entity.Item {
|
||||
cats := squareentity.Categories()
|
||||
vals := make([]string, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
vals = append(vals, string(c))
|
||||
}
|
||||
return strSet(lang, "square_category", vals...)
|
||||
}
|
||||
@ -32,6 +32,12 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
||||
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
|
||||
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
|
||||
}
|
||||
if len(c.SquareChannel) != 4 || c.SquareChannel[0].Value != "square" {
|
||||
t.Fatalf("square_channel %+v", c.SquareChannel)
|
||||
}
|
||||
if len(c.SquareCategory) != 6 || c.SquareCategory[0].Value != "recommend" {
|
||||
t.Fatalf("square_category %+v", c.SquareCategory)
|
||||
}
|
||||
if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" {
|
||||
t.Fatalf("device_type %+v", c.DeviceType)
|
||||
}
|
||||
@ -44,7 +50,7 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
||||
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
|
||||
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
|
||||
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
||||
c.AppID,
|
||||
c.AppID, c.SquareChannel, c.SquareCategory,
|
||||
} {
|
||||
if len(items) == 0 {
|
||||
t.Fatal("empty set")
|
||||
|
||||
@ -52,3 +52,18 @@ type Playlist struct {
|
||||
Categories []Category `json:"categories"`
|
||||
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
|
||||
}
|
||||
|
||||
// PlaylistGroup is one category (zone) with its enabled tracks.
|
||||
// Maps to HuanXiu-style zoneAndZoneMusicList entries for Spark clients.
|
||||
type PlaylistGroup struct {
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
Name string `json:"name"`
|
||||
ParentID *int64 `json:"parentId"`
|
||||
Items []Track `json:"items"`
|
||||
}
|
||||
|
||||
// GroupedPlaylist is categories-with-tracks for home tab migration.
|
||||
type GroupedPlaylist struct {
|
||||
Groups []PlaylistGroup `json:"groups"`
|
||||
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
|
||||
}
|
||||
|
||||
@ -106,7 +106,13 @@ func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
|
||||
return s.repo.ListAll(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playlist, error) {
|
||||
// PlaylistQuery filters the public music list.
|
||||
type PlaylistQuery struct {
|
||||
CategoryID int64
|
||||
RequireMember *bool // nil=all; true=仅会员曲;false=仅免费曲
|
||||
}
|
||||
|
||||
func (s *Service) Playlist(ctx context.Context, q PlaylistQuery) (*entity.Playlist, error) {
|
||||
if !s.Ready() {
|
||||
return nil, ErrNotReady
|
||||
}
|
||||
@ -114,14 +120,15 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
|
||||
items []entity.Track
|
||||
err error
|
||||
)
|
||||
if categoryID > 0 {
|
||||
items, err = s.repo.ListEnabledByCategory(ctx, categoryID)
|
||||
if q.CategoryID > 0 {
|
||||
items, err = s.repo.ListEnabledByCategory(ctx, q.CategoryID)
|
||||
} else {
|
||||
items, err = s.repo.ListEnabled(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = filterRequireMember(items, q.RequireMember)
|
||||
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -133,6 +140,115 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
|
||||
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
|
||||
}
|
||||
|
||||
// GetPublic returns one enabled track for clients (maps to HuanXiu /music/v1/detail).
|
||||
func (s *Service) GetPublic(ctx context.Context, id int64) (*entity.Track, error) {
|
||||
if !s.Ready() {
|
||||
return nil, ErrNotReady
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil, errs.ErrInvalidArgument.WithMsg("id 无效")
|
||||
}
|
||||
t, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||||
}
|
||||
if !t.Enabled {
|
||||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// PlaylistGrouped returns leaf categories each with their tracks
|
||||
// (maps to HuanXiu zoneAndZoneMusicList for home tab).
|
||||
// parentID>0 limits to that category itself (if leaf) or its children.
|
||||
func (s *Service) PlaylistGrouped(ctx context.Context, parentID int64) (*entity.GroupedPlaylist, error) {
|
||||
if !s.Ready() {
|
||||
return nil, ErrNotReady
|
||||
}
|
||||
if parentID < 0 {
|
||||
return nil, errs.ErrInvalidArgument.WithMsg("parentId 无效")
|
||||
}
|
||||
tree, err := s.CategoryTree(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaves := collectLeafCategories(tree, parentID)
|
||||
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := make([]entity.PlaylistGroup, 0, len(leaves))
|
||||
for _, cat := range leaves {
|
||||
items, err := s.repo.ListEnabledByCategory(ctx, cat.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []entity.Track{}
|
||||
}
|
||||
groups = append(groups, entity.PlaylistGroup{
|
||||
CategoryID: cat.ID,
|
||||
Name: cat.Name,
|
||||
ParentID: cat.ParentID,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
return &entity.GroupedPlaylist{Groups: groups, MemberPreviewSeconds: sec}, nil
|
||||
}
|
||||
|
||||
func filterRequireMember(items []entity.Track, requireMember *bool) []entity.Track {
|
||||
if requireMember == nil {
|
||||
return items
|
||||
}
|
||||
out := make([]entity.Track, 0, len(items))
|
||||
want := *requireMember
|
||||
for _, t := range items {
|
||||
if t.RequireMember == want {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectLeafCategories walks the enabled tree.
|
||||
// parentID==0 → all leaves; parentID>0 → that node if leaf, else its child leaves.
|
||||
func collectLeafCategories(tree []entity.Category, parentID int64) []entity.Category {
|
||||
if parentID > 0 {
|
||||
if cat, ok := findCategory(tree, parentID); ok {
|
||||
if len(cat.Children) == 0 {
|
||||
return []entity.Category{cat}
|
||||
}
|
||||
return leafCategories(cat.Children)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return leafCategories(tree)
|
||||
}
|
||||
|
||||
func findCategory(tree []entity.Category, id int64) (entity.Category, bool) {
|
||||
for _, c := range tree {
|
||||
if c.ID == id {
|
||||
return c, true
|
||||
}
|
||||
if found, ok := findCategory(c.Children, id); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
return entity.Category{}, false
|
||||
}
|
||||
|
||||
func leafCategories(tree []entity.Category) []entity.Category {
|
||||
var out []entity.Category
|
||||
for _, c := range tree {
|
||||
if len(c.Children) == 0 {
|
||||
out = append(out, c)
|
||||
continue
|
||||
}
|
||||
out = append(out, leafCategories(c.Children)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
|
||||
if !s.Ready() {
|
||||
return nil, ErrNotReady
|
||||
|
||||
@ -42,8 +42,24 @@ func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, _ int64) ([]entity.Track, error) {
|
||||
return s.ListEnabled(context.Background())
|
||||
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, categoryID int64) ([]entity.Track, error) {
|
||||
out := make([]entity.Track, 0)
|
||||
for _, t := range s.tracks {
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
ids := s.trackCategories[t.ID]
|
||||
if ids == nil {
|
||||
ids = t.CategoryIDs
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == categoryID {
|
||||
out = append(out, *t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) {
|
||||
@ -182,7 +198,7 @@ func newTestService() (*Service, *stubMusicRepo) {
|
||||
func TestServicePlaylistAndSettings(t *testing.T) {
|
||||
svc, repo := newTestService()
|
||||
_ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true})
|
||||
pl, err := svc.Playlist(context.Background(), 0)
|
||||
pl, err := svc.Playlist(context.Background(), PlaylistQuery{})
|
||||
if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 {
|
||||
t.Fatalf("playlist %+v %v", pl, err)
|
||||
}
|
||||
@ -196,6 +212,73 @@ func TestServicePlaylistAndSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetPublicAndRequireMember(t *testing.T) {
|
||||
svc, repo := newTestService()
|
||||
ctx := context.Background()
|
||||
_ = repo.Insert(ctx, &entity.Track{Title: "free", Enabled: true, RequireMember: false})
|
||||
_ = repo.Insert(ctx, &entity.Track{Title: "vip", Enabled: true, RequireMember: true})
|
||||
_ = repo.Insert(ctx, &entity.Track{Title: "off", Enabled: false})
|
||||
|
||||
got, err := svc.GetPublic(ctx, 1)
|
||||
if err != nil || got.Title != "free" {
|
||||
t.Fatalf("get %+v %v", got, err)
|
||||
}
|
||||
if _, err := svc.GetPublic(ctx, 3); err == nil {
|
||||
t.Fatal("want disabled not found")
|
||||
}
|
||||
if _, err := svc.GetPublic(ctx, 0); err == nil {
|
||||
t.Fatal("want invalid id")
|
||||
}
|
||||
|
||||
wantVIP := true
|
||||
vipOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantVIP})
|
||||
if err != nil || len(vipOnly.Items) != 1 || !vipOnly.Items[0].RequireMember {
|
||||
t.Fatalf("vip filter %+v %v", vipOnly, err)
|
||||
}
|
||||
wantFree := false
|
||||
freeOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantFree})
|
||||
if err != nil || len(freeOnly.Items) != 1 || freeOnly.Items[0].RequireMember {
|
||||
t.Fatalf("free filter %+v %v", freeOnly, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePlaylistGrouped(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Home"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid := root.ID
|
||||
rain, err := svc.CreateCategory(ctx, CategoryInput{Name: "Rain", ParentID: &pid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wind, err := svc.CreateCategory(ctx, CategoryInput{Name: "Wind", ParentID: &pid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Create(ctx, UpsertInput{Title: "soft rain", CategoryIDs: []int64{rain.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Create(ctx, UpsertInput{Title: "gentle wind", CategoryIDs: []int64{wind.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, err := svc.PlaylistGrouped(ctx, 0)
|
||||
if err != nil || len(all.Groups) != 2 {
|
||||
t.Fatalf("grouped all %+v %v", all, err)
|
||||
}
|
||||
underHome, err := svc.PlaylistGrouped(ctx, root.ID)
|
||||
if err != nil || len(underHome.Groups) != 2 {
|
||||
t.Fatalf("under home %+v %v", underHome, err)
|
||||
}
|
||||
one, err := svc.PlaylistGrouped(ctx, rain.ID)
|
||||
if err != nil || len(one.Groups) != 1 || one.Groups[0].CategoryID != rain.ID || len(one.Groups[0].Items) != 1 {
|
||||
t.Fatalf("leaf group %+v %v", one, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCRUD(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
@ -308,7 +391,7 @@ func TestNewServicePublicURLDefaults(t *testing.T) {
|
||||
|
||||
func TestServiceNotReady(t *testing.T) {
|
||||
var svc *Service
|
||||
if _, err := svc.Playlist(context.Background(), 0); !errors.Is(err, ErrNotReady) {
|
||||
if _, err := svc.Playlist(context.Background(), PlaylistQuery{}); !errors.Is(err, ErrNotReady) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
104
internal/module/square/entity/channel.go
Normal file
104
internal/module/square/entity/channel.go
Normal file
@ -0,0 +1,104 @@
|
||||
package entity
|
||||
|
||||
// Channel is a square plaza instance (path prefix / data isolation).
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelSquare Channel = "square"
|
||||
ChannelSquareNovel Channel = "squarenovel"
|
||||
ChannelSquareSteps Channel = "squaresteps"
|
||||
ChannelSquareHealth Channel = "squarehealth"
|
||||
)
|
||||
|
||||
// Channels is the closed set of plaza instances.
|
||||
func Channels() []Channel {
|
||||
return []Channel{ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth}
|
||||
}
|
||||
|
||||
func (c Channel) Valid() bool {
|
||||
switch c {
|
||||
case ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseChannel(s string) (Channel, bool) {
|
||||
c := Channel(s)
|
||||
if !c.Valid() {
|
||||
return "", false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// Category is a feed tab / post tag under every channel.
|
||||
// Feed modes (recommend/latest/hot/nearby) only affect list sort; tags are stored on posts.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryRecommend Category = "recommend"
|
||||
CategoryLatest Category = "latest"
|
||||
CategoryHot Category = "hot"
|
||||
CategoryNearby Category = "nearby"
|
||||
CategoryFunny Category = "funny"
|
||||
CategoryBeauty Category = "beauty"
|
||||
)
|
||||
|
||||
// Categories lists every filter tab (feed modes + tags).
|
||||
func Categories() []Category {
|
||||
return []Category{
|
||||
CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
|
||||
CategoryFunny, CategoryBeauty,
|
||||
}
|
||||
}
|
||||
|
||||
// PublishCategories are tags that may be written on a post.
|
||||
func PublishCategories() []Category {
|
||||
return []Category{CategoryFunny, CategoryBeauty}
|
||||
}
|
||||
|
||||
func (c Category) Valid() bool {
|
||||
switch c {
|
||||
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
|
||||
CategoryFunny, CategoryBeauty, "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c Category) IsFeedMode() bool {
|
||||
switch c {
|
||||
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby, "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c Category) IsTag() bool {
|
||||
switch c {
|
||||
case CategoryFunny, CategoryBeauty:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseCategory(s string) (Category, bool) {
|
||||
c := Category(s)
|
||||
if !c.Valid() {
|
||||
return "", false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// FeedListQuery drives public feed filtering and sort in the repository.
|
||||
type FeedListQuery struct {
|
||||
Cursor int64
|
||||
Limit int
|
||||
Category Category
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
@ -4,6 +4,8 @@ package entity
|
||||
type Post struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Channel Channel
|
||||
Category Category
|
||||
Kind Kind
|
||||
Text string
|
||||
Content []byte
|
||||
@ -65,6 +67,7 @@ type Comment struct {
|
||||
type Notice struct {
|
||||
ID int64
|
||||
ToUserID int64
|
||||
Channel Channel
|
||||
FromUserID int64
|
||||
Kind NoticeKind
|
||||
PostID int64
|
||||
@ -73,13 +76,17 @@ type Notice struct {
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// NoticeState is per-user read watermark for square notices.
|
||||
// NoticeState is per-user per-channel read watermark for square notices.
|
||||
type NoticeState struct {
|
||||
UserID int64
|
||||
Channel Channel
|
||||
LastReadNoticeID int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
func DefaultNoticeState(userID int64) *NoticeState {
|
||||
return &NoticeState{UserID: userID}
|
||||
func DefaultNoticeState(userID int64, channel Channel) *NoticeState {
|
||||
if channel == "" {
|
||||
channel = ChannelSquare
|
||||
}
|
||||
return &NoticeState{UserID: userID, Channel: channel}
|
||||
}
|
||||
|
||||
@ -8,13 +8,17 @@ import (
|
||||
)
|
||||
|
||||
// UserPosts lists posts by owner visible to viewer (profile square tab).
|
||||
func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64, limit int) (*ListResult, error) {
|
||||
func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID int64, channel entity.Channel, cursor int64, limit int) (*ListResult, error) {
|
||||
if !s.ready() || viewerID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if ownerID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if ownerID != viewerID {
|
||||
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
|
||||
if err != nil {
|
||||
@ -28,7 +32,7 @@ func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64
|
||||
picked := make([]*entity.Post, 0, limit)
|
||||
scan := cursor
|
||||
for range entity.OverfetchRounds {
|
||||
batch, err := s.repo.ListByAuthor(ctx, ownerID, scan, entity.Overfetch)
|
||||
batch, err := s.repo.ListByAuthor(ctx, channel, ownerID, scan, entity.Overfetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -34,7 +34,8 @@ func (s *Service) oneView(viewer int64, p *entity.Post, likes []*entity.Like, co
|
||||
likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full)
|
||||
commentViews, commentN := filterComments(comments, users, remarks, full)
|
||||
return &View{
|
||||
ID: p.ID, User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
|
||||
ID: p.ID, Channel: normalizeChannel(p.Channel), Category: p.Category,
|
||||
User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
|
||||
Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility,
|
||||
LikeCount: likeN, CommentCount: commentN,
|
||||
Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS,
|
||||
|
||||
@ -18,8 +18,8 @@ type CommentInput struct {
|
||||
ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"`
|
||||
}
|
||||
|
||||
func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID)
|
||||
func (s *Service) Comment(ctx context.Context, userID, postID int64, channel entity.Channel, in *CommentInput) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -56,11 +56,11 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment
|
||||
return nil, err
|
||||
}
|
||||
_ = s.notify(ctx, commentNotices(post, row, userID))
|
||||
return s.Get(ctx, userID, postID)
|
||||
return s.Get(ctx, userID, postID, channel)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID)
|
||||
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64, channel entity.Channel) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -77,7 +77,7 @@ func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID i
|
||||
if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, userID, postID)
|
||||
return s.Get(ctx, userID, postID, channel)
|
||||
}
|
||||
|
||||
func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) {
|
||||
@ -97,6 +97,10 @@ func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*ent
|
||||
func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice {
|
||||
seen := map[int64]struct{}{}
|
||||
out := make([]*entity.Notice, 0, 2)
|
||||
ch := post.Channel
|
||||
if ch == "" {
|
||||
ch = entity.ChannelSquare
|
||||
}
|
||||
add := func(to int64, kind entity.NoticeKind) {
|
||||
if to <= 0 || to == from {
|
||||
return
|
||||
@ -106,7 +110,7 @@ func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.
|
||||
}
|
||||
seen[to] = struct{}{}
|
||||
out = append(out, &entity.Notice{
|
||||
ToUserID: to, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
|
||||
ToUserID: to, Channel: ch, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
|
||||
})
|
||||
}
|
||||
if c.ReplyToUID > 0 {
|
||||
|
||||
@ -7,18 +7,26 @@ import (
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID, postID int64) error {
|
||||
func (s *Service) Delete(ctx context.Context, userID, postID int64, channel entity.Channel) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
if postID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
post, err := s.repo.SoftDeletePost(ctx, userID, postID)
|
||||
channel = normalizeChannel(channel)
|
||||
post, err := s.repo.GetPost(ctx, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteMedia(ctx, post)
|
||||
if normalizeChannel(post.Channel) != channel || post.UserID != userID {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
deleted, err := s.repo.SoftDeletePost(ctx, userID, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteMedia(ctx, deleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -7,15 +7,33 @@ import (
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func (s *Service) Feed(ctx context.Context, viewerID, cursor int64, limit int) (*ListResult, error) {
|
||||
// FeedQuery is list filter/sort for a plaza channel.
|
||||
type FeedQuery struct {
|
||||
Cursor int64
|
||||
Limit int
|
||||
Category entity.Category
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
|
||||
func (s *Service) Feed(ctx context.Context, viewerID int64, channel entity.Channel, q FeedQuery) (*ListResult, error) {
|
||||
if !s.ready() || viewerID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
limit = entity.ClampLimit(limit)
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if !q.Category.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
limit := entity.ClampLimit(q.Limit)
|
||||
picked := make([]*entity.Post, 0, limit)
|
||||
scan := cursor
|
||||
scan := q.Cursor
|
||||
for range entity.OverfetchRounds {
|
||||
batch, err := s.repo.ListPublicFeed(ctx, scan, entity.Overfetch)
|
||||
batch, err := s.repo.ListPublicFeed(ctx, channel, entity.FeedListQuery{
|
||||
Cursor: scan, Limit: entity.Overfetch, Category: q.Category, Lat: q.Lat, Lng: q.Lng,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -55,3 +73,23 @@ func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.
|
||||
hasMore := len(posts) >= limit && limit > 0
|
||||
return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
// CategoryItem is one filter tab for clients.
|
||||
type CategoryItem struct {
|
||||
Value entity.Category `json:"value"`
|
||||
Kind string `json:"kind"` // feed | tag
|
||||
LabelKey string `json:"-"`
|
||||
}
|
||||
|
||||
// Categories returns filter tabs (feed modes + tags) shared by every channel.
|
||||
func (s *Service) Categories() []CategoryItem {
|
||||
out := make([]CategoryItem, 0, len(entity.Categories()))
|
||||
for _, c := range entity.Categories() {
|
||||
kind := "feed"
|
||||
if c.IsTag() {
|
||||
kind = "tag"
|
||||
}
|
||||
out = append(out, CategoryItem{Value: c, Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID)
|
||||
func (s *Service) Like(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
|
||||
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -22,32 +22,36 @@ func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error)
|
||||
return nil, err
|
||||
}
|
||||
if created {
|
||||
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, postID, 0)})
|
||||
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, post.Channel, postID, 0)})
|
||||
}
|
||||
return s.Get(ctx, userID, postID)
|
||||
return s.Get(ctx, userID, postID, channel)
|
||||
}
|
||||
|
||||
func (s *Service) Unlike(ctx context.Context, userID, postID int64) (*View, error) {
|
||||
if _, err := s.visiblePost(ctx, userID, postID); err != nil {
|
||||
func (s *Service) Unlike(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
|
||||
if _, err := s.visiblePost(ctx, userID, postID, channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, userID, postID)
|
||||
return s.Get(ctx, userID, postID, channel)
|
||||
}
|
||||
|
||||
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64) (*entity.Post, error) {
|
||||
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*entity.Post, error) {
|
||||
if !s.ready() || viewerID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if postID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
post, err := s.repo.GetPost(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizeChannel(post.Channel) != channel {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -32,12 +32,16 @@ type NoticeReadInput struct {
|
||||
Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"`
|
||||
}
|
||||
|
||||
func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit int) (*NoticeList, error) {
|
||||
func (s *Service) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) (*NoticeList, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
limit = entity.ClampLimit(limit)
|
||||
rows, err := s.repo.ListNotices(ctx, userID, cursor, limit)
|
||||
rows, err := s.repo.ListNotices(ctx, userID, channel, cursor, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -59,39 +63,47 @@ func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit i
|
||||
return &NoticeList{Items: items, NextCursor: next}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReadNotices(ctx context.Context, userID int64, in *NoticeReadInput) error {
|
||||
func (s *Service) ReadNotices(ctx context.Context, userID int64, channel entity.Channel, in *NoticeReadInput) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
cursor := int64(0)
|
||||
if in != nil {
|
||||
cursor = in.Cursor
|
||||
}
|
||||
if err := s.repo.MarkNoticesRead(ctx, userID, cursor); err != nil {
|
||||
if err := s.repo.MarkNoticesRead(ctx, userID, channel, cursor); err != nil {
|
||||
return err
|
||||
}
|
||||
if cursor > 0 {
|
||||
return s.repo.AdvanceNoticeRead(ctx, userID, cursor)
|
||||
return s.repo.AdvanceNoticeRead(ctx, userID, channel, cursor)
|
||||
}
|
||||
latest, err := s.repo.LatestNoticeID(ctx, userID)
|
||||
latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest > 0 {
|
||||
return s.repo.AdvanceNoticeRead(ctx, userID, latest)
|
||||
return s.repo.AdvanceNoticeRead(ctx, userID, channel, latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) NoticeUnread(ctx context.Context, userID int64) (*NoticeUnread, error) {
|
||||
func (s *Service) NoticeUnread(ctx context.Context, userID int64, channel entity.Channel) (*NoticeUnread, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
st, err := s.repo.GetNoticeState(ctx, userID)
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
st, err := s.repo.GetNoticeState(ctx, userID, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest, err := s.repo.LatestNoticeID(ctx, userID)
|
||||
latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -111,14 +123,20 @@ func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error {
|
||||
}
|
||||
n.ID = id
|
||||
n.CreatedAtMS = now
|
||||
if n.Channel == "" {
|
||||
n.Channel = entity.ChannelSquare
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return s.repo.CreateNotices(ctx, out)
|
||||
}
|
||||
|
||||
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, postID, commentID int64) *entity.Notice {
|
||||
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, channel entity.Channel, postID, commentID int64) *entity.Notice {
|
||||
if to <= 0 || to == from {
|
||||
return nil
|
||||
}
|
||||
return &entity.Notice{ToUserID: to, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
|
||||
if channel == "" {
|
||||
channel = entity.ChannelSquare
|
||||
}
|
||||
return &entity.Notice{ToUserID: to, Channel: channel, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
|
||||
}
|
||||
|
||||
@ -21,18 +21,26 @@ type PublishInput struct {
|
||||
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"`
|
||||
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
|
||||
POI *entity.POI `json:"poi"`
|
||||
Category entity.Category `json:"category" validate:"omitempty,oneof=funny beauty" label:"field.square_category" errmsg:"oneof:validate.square.category_invalid"`
|
||||
}
|
||||
|
||||
func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) {
|
||||
func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Channel, in *PublishInput) (*View, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
if !channel.Valid() {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if in == nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Category != "" && !in.Category.IsTag() {
|
||||
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCategoryInvalid, nil))
|
||||
}
|
||||
if !s.allowPublish(ctx, userID) {
|
||||
return nil, errs.ErrRateLimited
|
||||
}
|
||||
@ -60,7 +68,8 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (
|
||||
vis = entity.VisibilityPublic
|
||||
}
|
||||
post := &entity.Post{
|
||||
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
|
||||
ID: id, UserID: userID, Channel: channel, Category: in.Category,
|
||||
Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
|
||||
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
|
||||
CreatedAtMS: now.UnixMilli(),
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ type Repository interface {
|
||||
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
|
||||
CreateBotPost(ctx context.Context, post *entity.Post) error
|
||||
GetPost(ctx context.Context, id int64) (*entity.Post, error)
|
||||
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error)
|
||||
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
||||
ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error)
|
||||
ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
||||
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
|
||||
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error)
|
||||
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
|
||||
@ -29,11 +29,11 @@ type Repository interface {
|
||||
AddComment(ctx context.Context, c *entity.Comment) error
|
||||
SoftDeleteComment(ctx context.Context, id int64) error
|
||||
CreateNotices(ctx context.Context, rows []*entity.Notice) error
|
||||
ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error)
|
||||
MarkNoticesRead(ctx context.Context, userID, cursor int64) error
|
||||
GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error)
|
||||
LatestNoticeID(ctx context.Context, userID int64) (int64, error)
|
||||
AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error
|
||||
ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error)
|
||||
MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error
|
||||
GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error)
|
||||
LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error)
|
||||
AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error
|
||||
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error)
|
||||
PurgeUser(ctx context.Context, userID int64) error
|
||||
}
|
||||
@ -91,3 +91,10 @@ func (s *Service) ConfigureMedia(p media.Policy, log *zap.Logger) {
|
||||
func (s *Service) ready() bool {
|
||||
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
|
||||
}
|
||||
|
||||
func normalizeChannel(ch entity.Channel) entity.Channel {
|
||||
if ch == "" {
|
||||
return entity.ChannelSquare
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
@ -75,34 +75,35 @@ func TestSquarePublishFeedPrivateAndNotice(t *testing.T) {
|
||||
becomeFriends(t, friends, a, b)
|
||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||
pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hello square"})
|
||||
if err != nil || pub.Text != "hello square" {
|
||||
ch := entity.ChannelSquare
|
||||
pub, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hello square"})
|
||||
if err != nil || pub.Text != "hello square" || pub.Channel != ch {
|
||||
t.Fatalf("%+v %v", pub, err)
|
||||
}
|
||||
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
|
||||
feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feed.Items) != 1 {
|
||||
t.Fatalf("feed %+v %v", feed, err)
|
||||
}
|
||||
priv, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
|
||||
priv, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feed, err = svc.Feed(ctxB, b.ID, 0, 20)
|
||||
feed, err = svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feed.Items) != 1 {
|
||||
t.Fatalf("private in feed %+v", feed)
|
||||
}
|
||||
if _, err := svc.Get(ctxB, b.ID, priv.ID); !errors.Is(err, errs.ErrNotFound) {
|
||||
if _, err := svc.Get(ctxB, b.ID, priv.ID, ch); !errors.Is(err, errs.ErrNotFound) {
|
||||
t.Fatalf("private get %v", err)
|
||||
}
|
||||
liked, err := svc.Like(ctxB, b.ID, pub.ID)
|
||||
liked, err := svc.Like(ctxB, b.ID, pub.ID, ch)
|
||||
if err != nil || !liked.Liked {
|
||||
t.Fatal(liked, err)
|
||||
}
|
||||
notices, err := svc.ListNotices(ctxA, a.ID, 0, 20)
|
||||
notices, err := svc.ListNotices(ctxA, a.ID, ch, 0, 20)
|
||||
if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike {
|
||||
t.Fatalf("notice %+v %v", notices, err)
|
||||
}
|
||||
unread, err := svc.NoticeUnread(ctxA, a.ID)
|
||||
unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
|
||||
if err != nil || !unread.HasNew {
|
||||
t.Fatalf("unread %+v", unread)
|
||||
}
|
||||
@ -117,16 +118,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ch := entity.ChannelSquare
|
||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||
ctxC := ctxUser(t, c.ID, "10.0.0.3")
|
||||
excl, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
||||
excl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||
Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feedB, err := svc.Feed(ctxB, b.ID, 0, 20)
|
||||
feedB, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -135,17 +137,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
||||
t.Fatal("excluded user still sees post in feed")
|
||||
}
|
||||
}
|
||||
feedC, err := svc.Feed(ctxC, c.ID, 0, 20)
|
||||
feedC, err := svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feedC.Items) != 1 {
|
||||
t.Fatalf("stranger feed %+v %v", feedC, err)
|
||||
}
|
||||
incl, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
||||
incl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||
Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feedC, err = svc.Feed(ctxC, c.ID, 0, 20)
|
||||
feedC, err = svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -154,7 +156,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
||||
t.Fatal("include post leaked to stranger feed")
|
||||
}
|
||||
}
|
||||
album, err := svc.UserPosts(ctxB, b.ID, a.ID, 0, 20)
|
||||
album, err := svc.UserPosts(ctxB, b.ID, a.ID, ch, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -170,7 +172,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
||||
if !sawIncl {
|
||||
t.Fatalf("include missing from album %+v", album)
|
||||
}
|
||||
if _, err := svc.Get(ctxB, b.ID, incl.ID); err != nil {
|
||||
if _, err := svc.Get(ctxB, b.ID, incl.ID, ch); err != nil {
|
||||
t.Fatalf("include get %v", err)
|
||||
}
|
||||
}
|
||||
@ -179,59 +181,60 @@ func TestSquareUnlikeCommentDeleteAndPurge(t *testing.T) {
|
||||
svc, friends, users := testSquare(t)
|
||||
a, b := twoAccounts(t, users)
|
||||
becomeFriends(t, friends, a, b)
|
||||
ch := entity.ChannelSquare
|
||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||
post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"})
|
||||
post, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hi"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Like(ctxB, b.ID, post.ID); err != nil {
|
||||
if _, err := svc.Like(ctxB, b.ID, post.ID, ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unliked, err := svc.Unlike(ctxB, b.ID, post.ID)
|
||||
unliked, err := svc.Unlike(ctxB, b.ID, post.ID, ch)
|
||||
if err != nil || unliked.Liked || unliked.LikeCount != 0 {
|
||||
t.Fatalf("unlike %+v %v", unliked, err)
|
||||
}
|
||||
commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"})
|
||||
commented, err := svc.Comment(ctxB, b.ID, post.ID, ch, &CommentInput{Text: "nice"})
|
||||
if err != nil || commented.CommentCount != 1 {
|
||||
t.Fatalf("comment %+v %v", commented, err)
|
||||
}
|
||||
cid := commented.Comments[0].ID
|
||||
replied, err := svc.Comment(ctxA, a.ID, post.ID, &CommentInput{Text: "thanks", ReplyToID: cid})
|
||||
replied, err := svc.Comment(ctxA, a.ID, post.ID, ch, &CommentInput{Text: "thanks", ReplyToID: cid})
|
||||
if err != nil || replied.CommentCount != 2 {
|
||||
t.Fatalf("reply %+v %v", replied, err)
|
||||
}
|
||||
if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid); err != nil {
|
||||
if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid, ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.Get(ctxA, a.ID, post.ID)
|
||||
got, err := svc.Get(ctxA, a.ID, post.ID, ch)
|
||||
if err != nil || got.CommentCount != 1 {
|
||||
t.Fatalf("deleted comment %+v %v", got, err)
|
||||
}
|
||||
if err := svc.Delete(ctxA, a.ID, post.ID); err != nil {
|
||||
if err := svc.Delete(ctxA, a.ID, post.ID, ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
|
||||
feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feed.Items) != 0 {
|
||||
t.Fatalf("deleted feed %+v %v", feed, err)
|
||||
}
|
||||
post2, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
||||
post2, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||
Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ReadNotices(ctxA, a.ID, &NoticeReadInput{Cursor: 0}); err != nil {
|
||||
if err := svc.ReadNotices(ctxA, a.ID, ch, &NoticeReadInput{Cursor: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unread, err := svc.NoticeUnread(ctxA, a.ID)
|
||||
unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
|
||||
if err != nil || unread.HasNew {
|
||||
t.Fatalf("after read %+v %v", unread, err)
|
||||
}
|
||||
if err := svc.PurgeUser(ctxA, a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Get(ctxA, a.ID, post2.ID); !errors.Is(err, errs.ErrNotFound) {
|
||||
if _, err := svc.Get(ctxA, a.ID, post2.ID, ch); !errors.Is(err, errs.ErrNotFound) {
|
||||
t.Fatalf("purged get %v", err)
|
||||
}
|
||||
}
|
||||
@ -240,10 +243,49 @@ func TestSquareImageKeyRejectedIfChatPrefix(t *testing.T) {
|
||||
svc, _, users := testSquare(t)
|
||||
a, _ := twoAccounts(t, users)
|
||||
ctx := ctxUser(t, a.ID, "10.0.0.1")
|
||||
_, err := svc.Publish(ctx, a.ID, &PublishInput{
|
||||
_, err := svc.Publish(ctx, a.ID, entity.ChannelSquare, &PublishInput{
|
||||
Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("accepted chat key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSquareChannelIsolationAndCategory(t *testing.T) {
|
||||
svc, friends, users := testSquare(t)
|
||||
a, b := twoAccounts(t, users)
|
||||
becomeFriends(t, friends, a, b)
|
||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||
main, err := svc.Publish(ctxA, a.ID, entity.ChannelSquare, &PublishInput{Text: "main", Category: entity.CategoryFunny})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
novel, err := svc.Publish(ctxA, a.ID, entity.ChannelSquareNovel, &PublishInput{Text: "novel", Category: entity.CategoryBeauty})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feedMain, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feedMain.Items) != 1 || feedMain.Items[0].ID != main.ID {
|
||||
t.Fatalf("main feed %+v %v", feedMain, err)
|
||||
}
|
||||
feedNovel, err := svc.Feed(ctxB, b.ID, entity.ChannelSquareNovel, FeedQuery{Limit: 20})
|
||||
if err != nil || len(feedNovel.Items) != 1 || feedNovel.Items[0].ID != novel.ID {
|
||||
t.Fatalf("novel feed %+v %v", feedNovel, err)
|
||||
}
|
||||
if _, err := svc.Get(ctxB, b.ID, novel.ID, entity.ChannelSquare); !errors.Is(err, errs.ErrNotFound) {
|
||||
t.Fatalf("cross-channel get %v", err)
|
||||
}
|
||||
funny, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryFunny, Limit: 20})
|
||||
if err != nil || len(funny.Items) != 1 || funny.Items[0].Category != entity.CategoryFunny {
|
||||
t.Fatalf("funny %+v %v", funny, err)
|
||||
}
|
||||
beauty, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryBeauty, Limit: 20})
|
||||
if err != nil || len(beauty.Items) != 0 {
|
||||
t.Fatalf("beauty on main %+v %v", beauty, err)
|
||||
}
|
||||
cats := svc.Categories()
|
||||
if len(cats) < 6 {
|
||||
t.Fatalf("categories %+v", cats)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,8 @@ type CommentView struct {
|
||||
|
||||
type View struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel entity.Channel `json:"channel"`
|
||||
Category entity.Category `json:"category,omitempty"`
|
||||
User UserCard `json:"user"`
|
||||
Kind entity.Kind `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
@ -49,17 +51,21 @@ type ListResult struct {
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, viewerID, postID int64) (*View, error) {
|
||||
func (s *Service) Get(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*View, error) {
|
||||
if !s.ready() || viewerID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if postID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
channel = normalizeChannel(channel)
|
||||
post, err := s.repo.GetPost(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizeChannel(post.Channel) != channel {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
@ -15,6 +16,11 @@ type likeKey struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type noticeStateKey struct {
|
||||
UserID int64
|
||||
Channel entity.Channel
|
||||
}
|
||||
|
||||
// MemoryRepo is an in-process square store for tests and empty-DSN bring-up.
|
||||
type MemoryRepo struct {
|
||||
mu sync.Mutex
|
||||
@ -23,7 +29,7 @@ type MemoryRepo struct {
|
||||
likes map[likeKey]*entity.Like
|
||||
comments map[int64]*entity.Comment
|
||||
notices map[int64]*entity.Notice
|
||||
noticeState map[int64]*entity.NoticeState
|
||||
noticeState map[noticeStateKey]*entity.NoticeState
|
||||
botLinks map[string]int64 // url -> post_id
|
||||
}
|
||||
|
||||
@ -34,11 +40,18 @@ func NewMemoryRepo() *MemoryRepo {
|
||||
likes: make(map[likeKey]*entity.Like),
|
||||
comments: make(map[int64]*entity.Comment),
|
||||
notices: make(map[int64]*entity.Notice),
|
||||
noticeState: make(map[int64]*entity.NoticeState),
|
||||
noticeState: make(map[noticeStateKey]*entity.NoticeState),
|
||||
botLinks: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func memChannel(ch entity.Channel) entity.Channel {
|
||||
if ch == "" {
|
||||
return entity.ChannelSquare
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@ -49,14 +62,15 @@ func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error)
|
||||
return clonePost(row), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
func (m *MemoryRepo) ListByAuthors(_ context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch := memChannel(channel)
|
||||
allow := toSet(authorIDs)
|
||||
nowMS := time.Now().UnixMilli()
|
||||
out := make([]*entity.Post, 0)
|
||||
for _, row := range m.posts {
|
||||
if row.Status != entity.StatusNormal || !allow[row.UserID] {
|
||||
if row.Status != entity.StatusNormal || !allow[row.UserID] || memChannel(row.Channel) != ch {
|
||||
continue
|
||||
}
|
||||
if row.Expired(nowMS) {
|
||||
@ -74,8 +88,8 @@ func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
|
||||
func (m *MemoryRepo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) {
|
||||
@ -137,61 +151,136 @@ func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, e
|
||||
return cloneComment(row), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListPublicFeed(_ context.Context, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
func (m *MemoryRepo) ListPublicFeed(_ context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
nowMS := time.Now().UnixMilli()
|
||||
ch := memChannel(channel)
|
||||
out := make([]*entity.Post, 0)
|
||||
for _, row := range m.posts {
|
||||
if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved {
|
||||
continue
|
||||
}
|
||||
if memChannel(row.Channel) != ch {
|
||||
continue
|
||||
}
|
||||
if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude {
|
||||
continue
|
||||
}
|
||||
if row.Expired(nowMS) {
|
||||
continue
|
||||
}
|
||||
if cursor > 0 && row.ID >= cursor {
|
||||
if q.Category.IsTag() && row.Category != q.Category {
|
||||
continue
|
||||
}
|
||||
if q.Category == entity.CategoryNearby && row.POI.Lat == 0 && row.POI.Lng == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, clonePost(row))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
switch q.Category {
|
||||
case entity.CategoryHot:
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LikeCount == out[j].LikeCount {
|
||||
return out[i].ID > out[j].ID
|
||||
}
|
||||
return out[i].LikeCount > out[j].LikeCount
|
||||
})
|
||||
if q.Cursor > 0 {
|
||||
var lc int
|
||||
var found bool
|
||||
for _, p := range out {
|
||||
if p.ID == q.Cursor {
|
||||
lc = p.LikeCount
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
filtered := make([]*entity.Post, 0, len(out))
|
||||
for _, p := range out {
|
||||
if !found {
|
||||
if p.ID < q.Cursor {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p.LikeCount < lc || (p.LikeCount == lc && p.ID < q.Cursor) {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
out = filtered
|
||||
}
|
||||
case entity.CategoryNearby:
|
||||
if q.Lat != 0 || q.Lng != 0 {
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
di := math.Abs(q.Lat-out[i].POI.Lat) + math.Abs(q.Lng-out[i].POI.Lng)
|
||||
dj := math.Abs(q.Lat-out[j].POI.Lat) + math.Abs(q.Lng-out[j].POI.Lng)
|
||||
if di == dj {
|
||||
return out[i].ID > out[j].ID
|
||||
}
|
||||
return di < dj
|
||||
})
|
||||
} else {
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
}
|
||||
if q.Cursor > 0 {
|
||||
filtered := make([]*entity.Post, 0, len(out))
|
||||
for _, p := range out {
|
||||
if p.ID < q.Cursor {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
out = filtered
|
||||
}
|
||||
default:
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if q.Cursor > 0 {
|
||||
filtered := make([]*entity.Post, 0, len(out))
|
||||
for _, p := range out {
|
||||
if p.ID < q.Cursor {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
out = filtered
|
||||
}
|
||||
}
|
||||
if q.Limit > 0 && len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64) (*entity.NoticeState, error) {
|
||||
func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if row, ok := m.noticeState[userID]; ok {
|
||||
ch := memChannel(channel)
|
||||
if row, ok := m.noticeState[noticeStateKey{UserID: userID, Channel: ch}]; ok {
|
||||
cp := *row
|
||||
return &cp, nil
|
||||
}
|
||||
return entity.DefaultNoticeState(userID), nil
|
||||
return entity.DefaultNoticeState(userID, ch), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64) (int64, error) {
|
||||
func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64, channel entity.Channel) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch := memChannel(channel)
|
||||
var latest int64
|
||||
for _, n := range m.notices {
|
||||
if n.ToUserID == userID && n.ID > latest {
|
||||
if n.ToUserID == userID && memChannel(n.Channel) == ch && n.ID > latest {
|
||||
latest = n.ID
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListNotices(_ context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||
func (m *MemoryRepo) ListNotices(_ context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch := memChannel(channel)
|
||||
out := make([]*entity.Notice, 0)
|
||||
for _, n := range m.notices {
|
||||
if n.ToUserID != userID {
|
||||
if n.ToUserID != userID || memChannel(n.Channel) != ch {
|
||||
continue
|
||||
}
|
||||
if cursor > 0 && n.ID >= cursor {
|
||||
@ -223,6 +312,9 @@ func clonePost(p *entity.Post) *entity.Post {
|
||||
if p.Content != nil {
|
||||
cp.Content = append([]byte(nil), p.Content...)
|
||||
}
|
||||
if cp.Channel == "" {
|
||||
cp.Channel = entity.ChannelSquare
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
|
||||
@ -78,7 +78,11 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
|
||||
}
|
||||
m.audience[postID] = kept
|
||||
}
|
||||
delete(m.noticeState, userID)
|
||||
for key := range m.noticeState {
|
||||
if key.UserID == userID {
|
||||
delete(m.noticeState, key)
|
||||
}
|
||||
}
|
||||
for id := range owned {
|
||||
delete(m.posts, id)
|
||||
delete(m.audience, id)
|
||||
|
||||
@ -18,7 +18,11 @@ func (m *MemoryRepo) CreatePost(_ context.Context, post *entity.Post, audience [
|
||||
if _, ok := m.posts[post.ID]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
m.posts[post.ID] = clonePost(post)
|
||||
cp := clonePost(post)
|
||||
if cp.Channel == "" {
|
||||
cp.Channel = entity.ChannelSquare
|
||||
}
|
||||
m.posts[post.ID] = cp
|
||||
if len(audience) > 0 {
|
||||
m.audience[post.ID] = append([]int64(nil), audience...)
|
||||
}
|
||||
@ -167,17 +171,21 @@ func (m *MemoryRepo) CreateNotices(_ context.Context, rows []*entity.Notice) err
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
cp := *n
|
||||
if cp.Channel == "" {
|
||||
cp.Channel = entity.ChannelSquare
|
||||
}
|
||||
m.notices[n.ID] = &cp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) error {
|
||||
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID int64, channel entity.Channel, cursor int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch := memChannel(channel)
|
||||
now := time.Now().UnixMilli()
|
||||
for _, n := range m.notices {
|
||||
if n.ToUserID != userID || n.ReadAtMS > 0 {
|
||||
if n.ToUserID != userID || memChannel(n.Channel) != ch || n.ReadAtMS > 0 {
|
||||
continue
|
||||
}
|
||||
if cursor > 0 && n.ID > cursor {
|
||||
@ -188,16 +196,18 @@ func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID, noticeID int64) error {
|
||||
func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID int64, channel entity.Channel, noticeID int64) error {
|
||||
if userID <= 0 || noticeID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cur := m.noticeState[userID]
|
||||
ch := memChannel(channel)
|
||||
key := noticeStateKey{UserID: userID, Channel: ch}
|
||||
cur := m.noticeState[key]
|
||||
if cur == nil {
|
||||
cur = entity.DefaultNoticeState(userID)
|
||||
m.noticeState[userID] = cur
|
||||
cur = entity.DefaultNoticeState(userID, ch)
|
||||
m.noticeState[key] = cur
|
||||
}
|
||||
if cur.LastReadNoticeID >= noticeID {
|
||||
return nil
|
||||
|
||||
@ -10,6 +10,8 @@ import (
|
||||
type Post struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"`
|
||||
UserID int64 `gorm:"column:user_id;comment:作者 users.id"`
|
||||
Channel string `gorm:"column:channel;comment:广场实例"`
|
||||
Category string `gorm:"column:category;comment:内容标签"`
|
||||
Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"`
|
||||
Text string `gorm:"column:text;comment:正文"`
|
||||
Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"`
|
||||
@ -36,8 +38,13 @@ func (r *Post) Entity() *entity.Post {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ch := entity.Channel(r.Channel)
|
||||
if ch == "" {
|
||||
ch = entity.ChannelSquare
|
||||
}
|
||||
p := &entity.Post{
|
||||
ID: r.ID, UserID: r.UserID, Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
|
||||
ID: r.ID, UserID: r.UserID, Channel: ch, Category: entity.Category(r.Category),
|
||||
Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
|
||||
POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress},
|
||||
Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus),
|
||||
Status: entity.Status(r.Status), Source: entity.Source(r.Source), SourceURL: r.SourceURL,
|
||||
@ -53,8 +60,13 @@ func postFrom(p *entity.Post) *Post {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
ch := string(p.Channel)
|
||||
if ch == "" {
|
||||
ch = string(entity.ChannelSquare)
|
||||
}
|
||||
row := &Post{
|
||||
ID: p.ID, UserID: p.UserID, Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
|
||||
ID: p.ID, UserID: p.UserID, Channel: ch, Category: string(p.Category),
|
||||
Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
|
||||
POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address,
|
||||
Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status),
|
||||
Source: int16(p.Source), SourceURL: p.SourceURL, LikeCount: p.LikeCount, CommentCount: p.CommentCount,
|
||||
@ -152,6 +164,7 @@ func commentFrom(c *entity.Comment) *Comment {
|
||||
type Notice struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"`
|
||||
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
|
||||
Channel string `gorm:"column:channel;comment:广场实例"`
|
||||
FromUserID int64 `gorm:"column:from_user_id;comment:发起人"`
|
||||
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
|
||||
PostID int64 `gorm:"column:post_id;comment:帖 id"`
|
||||
@ -166,8 +179,12 @@ func (r *Notice) Entity() *entity.Notice {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ch := entity.Channel(r.Channel)
|
||||
if ch == "" {
|
||||
ch = entity.ChannelSquare
|
||||
}
|
||||
n := &entity.Notice{
|
||||
ID: r.ID, ToUserID: r.ToUserID, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
|
||||
ID: r.ID, ToUserID: r.ToUserID, Channel: ch, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
|
||||
PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(),
|
||||
}
|
||||
if r.ReadAt != nil {
|
||||
@ -179,6 +196,7 @@ func (r *Notice) Entity() *entity.Notice {
|
||||
// NoticeState maps square_notice_state.
|
||||
type NoticeState struct {
|
||||
UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"`
|
||||
Channel string `gorm:"column:channel;primaryKey;comment:广场实例"`
|
||||
LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
|
||||
}
|
||||
@ -189,7 +207,11 @@ func (r *NoticeState) Entity() *entity.NoticeState {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ch := entity.Channel(r.Channel)
|
||||
if ch == "" {
|
||||
ch = entity.ChannelSquare
|
||||
}
|
||||
return &entity.NoticeState{
|
||||
UserID: r.UserID, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
|
||||
UserID: r.UserID, Channel: ch, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ package model
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@ -25,7 +27,7 @@ func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) {
|
||||
return row.Entity(), nil
|
||||
}
|
||||
|
||||
func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
func (m *Repo) ListByAuthors(ctx context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
if len(authorIDs) == 0 {
|
||||
return []*entity.Post{}, nil
|
||||
}
|
||||
@ -33,7 +35,8 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := db.Where("user_id IN ? AND status = ? AND "+notExpiredSQL, authorIDs, entity.StatusNormal, time.Now())
|
||||
ch := normalizeChannel(channel)
|
||||
q := db.Where("channel = ? AND user_id IN ? AND status = ? AND "+notExpiredSQL, ch, authorIDs, entity.StatusNormal, time.Now())
|
||||
if cursor > 0 {
|
||||
q = q.Where("id < ?", cursor)
|
||||
}
|
||||
@ -44,31 +47,113 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
|
||||
return collectPosts(rows), nil
|
||||
}
|
||||
|
||||
func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
|
||||
func (m *Repo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
|
||||
}
|
||||
|
||||
func (m *Repo) ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) {
|
||||
func (m *Repo) ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := db.Where(
|
||||
"audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
|
||||
entity.AuditApproved, entity.StatusNormal,
|
||||
ch := normalizeChannel(channel)
|
||||
base := db.Where(
|
||||
"channel = ? AND audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
|
||||
ch, entity.AuditApproved, entity.StatusNormal,
|
||||
[]int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)},
|
||||
time.Now(),
|
||||
)
|
||||
if cursor > 0 {
|
||||
q = q.Where("id < ?", cursor)
|
||||
if q.Category.IsTag() {
|
||||
base = base.Where("category = ?", string(q.Category))
|
||||
}
|
||||
switch q.Category {
|
||||
case entity.CategoryHot:
|
||||
return listHotFeed(base, q)
|
||||
case entity.CategoryNearby:
|
||||
return listNearbyFeed(base, q)
|
||||
default:
|
||||
return listLatestFeed(base, q)
|
||||
}
|
||||
}
|
||||
|
||||
func listLatestFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||
query := base
|
||||
if q.Cursor > 0 {
|
||||
query = query.Where("id < ?", q.Cursor)
|
||||
}
|
||||
var rows []Post
|
||||
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
if err := query.Order("id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return collectPosts(rows), nil
|
||||
}
|
||||
|
||||
func listHotFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||
query := base
|
||||
if q.Cursor > 0 {
|
||||
var cur Post
|
||||
if err := base.Session(&gorm.Session{}).Where("id = ?", q.Cursor).Take(&cur).Error; err == nil {
|
||||
query = query.Where(
|
||||
"(like_count < ? OR (like_count = ? AND id < ?))",
|
||||
cur.LikeCount, cur.LikeCount, cur.ID,
|
||||
)
|
||||
} else {
|
||||
query = query.Where("id < ?", q.Cursor)
|
||||
}
|
||||
}
|
||||
var rows []Post
|
||||
if err := query.Order("like_count DESC, id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return collectPosts(rows), nil
|
||||
}
|
||||
|
||||
func listNearbyFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||
query := base.Where("(poi_lat <> 0 OR poi_lng <> 0)")
|
||||
if q.Cursor > 0 {
|
||||
query = query.Where("id < ?", q.Cursor)
|
||||
}
|
||||
// Over-fetch then sort by manhattan distance in process (nearby stub).
|
||||
fetch := q.Limit
|
||||
if fetch < 50 {
|
||||
fetch = 50
|
||||
}
|
||||
var rows []Post
|
||||
if err := query.Order("id DESC").Limit(fetch).Find(&rows).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
posts := collectPosts(rows)
|
||||
if q.Lat == 0 && q.Lng == 0 {
|
||||
if q.Limit > 0 && len(posts) > q.Limit {
|
||||
posts = posts[:q.Limit]
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
sort.SliceStable(posts, func(i, j int) bool {
|
||||
di := manhattan(q.Lat, q.Lng, posts[i].POI.Lat, posts[i].POI.Lng)
|
||||
dj := manhattan(q.Lat, q.Lng, posts[j].POI.Lat, posts[j].POI.Lng)
|
||||
if di == dj {
|
||||
return posts[i].ID > posts[j].ID
|
||||
}
|
||||
return di < dj
|
||||
})
|
||||
if q.Limit > 0 && len(posts) > q.Limit {
|
||||
posts = posts[:q.Limit]
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func manhattan(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
return math.Abs(lat1-lat2) + math.Abs(lng1-lng2)
|
||||
}
|
||||
|
||||
func normalizeChannel(ch entity.Channel) string {
|
||||
if ch == "" {
|
||||
return string(entity.ChannelSquare)
|
||||
}
|
||||
return string(ch)
|
||||
}
|
||||
|
||||
func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) {
|
||||
out := make(map[int64][]int64)
|
||||
if len(postIDs) == 0 {
|
||||
@ -140,28 +225,30 @@ func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error
|
||||
return row.Entity(), nil
|
||||
}
|
||||
|
||||
func (m *Repo) GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) {
|
||||
func (m *Repo) GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := normalizeChannel(channel)
|
||||
var row NoticeState
|
||||
if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
|
||||
if err := db.Where("user_id = ? AND channel = ?", userID, ch).Take(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return entity.DefaultNoticeState(userID), nil
|
||||
return entity.DefaultNoticeState(userID, entity.Channel(ch)), nil
|
||||
}
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return row.Entity(), nil
|
||||
}
|
||||
|
||||
func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error) {
|
||||
func (m *Repo) LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ch := normalizeChannel(channel)
|
||||
var row Notice
|
||||
if err := db.Where("to_user_id = ?", userID).Order("id DESC").Limit(1).Take(&row).Error; err != nil {
|
||||
if err := db.Where("to_user_id = ? AND channel = ?", userID, ch).Order("id DESC").Limit(1).Take(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
@ -170,12 +257,13 @@ func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error)
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (m *Repo) ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||
func (m *Repo) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := db.Where("to_user_id = ?", userID)
|
||||
ch := normalizeChannel(channel)
|
||||
q := db.Where("to_user_id = ? AND channel = ?", userID, ch)
|
||||
if cursor > 0 {
|
||||
q = q.Where("id < ?", cursor)
|
||||
}
|
||||
|
||||
@ -163,7 +163,8 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
|
||||
now := time.Now()
|
||||
for _, n := range rows {
|
||||
row := Notice{
|
||||
ID: n.ID, ToUserID: n.ToUserID, FromUserID: n.FromUserID, Kind: int16(n.Kind),
|
||||
ID: n.ID, ToUserID: n.ToUserID, Channel: normalizeChannel(n.Channel),
|
||||
FromUserID: n.FromUserID, Kind: int16(n.Kind),
|
||||
PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now,
|
||||
}
|
||||
if n.CreatedAtMS > 0 {
|
||||
@ -176,19 +177,20 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Repo) MarkNoticesRead(ctx context.Context, userID, cursor int64) error {
|
||||
func (m *Repo) MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := db.Model(&Notice{}).Where("to_user_id = ? AND read_at IS NULL", userID)
|
||||
ch := normalizeChannel(channel)
|
||||
q := db.Model(&Notice{}).Where("to_user_id = ? AND channel = ? AND read_at IS NULL", userID, ch)
|
||||
if cursor > 0 {
|
||||
q = q.Where("id <= ?", cursor)
|
||||
}
|
||||
return mapDBErr(q.Update("read_at", time.Now()).Error)
|
||||
}
|
||||
|
||||
func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error {
|
||||
func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error {
|
||||
if userID <= 0 || noticeID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
@ -196,7 +198,8 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := m.GetNoticeState(ctx, userID)
|
||||
ch := entity.Channel(normalizeChannel(channel))
|
||||
cur, err := m.GetNoticeState(ctx, userID, ch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -206,11 +209,11 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
|
||||
cur.LastReadNoticeID = noticeID
|
||||
cur.UpdatedAtMS = time.Now().UnixMilli()
|
||||
row := NoticeState{
|
||||
UserID: cur.UserID, LastReadNoticeID: cur.LastReadNoticeID,
|
||||
UserID: cur.UserID, Channel: string(ch), LastReadNoticeID: cur.LastReadNoticeID,
|
||||
UpdatedAt: time.UnixMilli(cur.UpdatedAtMS),
|
||||
}
|
||||
return mapDBErr(db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "channel"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}),
|
||||
}).Create(&row).Error)
|
||||
}
|
||||
|
||||
@ -20,6 +20,9 @@ func init() {
|
||||
KeyFieldSquareNoticeCursor: {
|
||||
ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
|
||||
},
|
||||
KeyFieldSquareCategory: {
|
||||
ZhCN: "分类", En: "Category", ZhTW: "分類",
|
||||
},
|
||||
KeySquareTextInvalid: {
|
||||
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
|
||||
},
|
||||
@ -47,6 +50,9 @@ func init() {
|
||||
KeySquareReplyInvalid: {
|
||||
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
|
||||
},
|
||||
KeySquareCategoryInvalid: {
|
||||
ZhCN: "分类无效", En: "Invalid category", ZhTW: "分類無效",
|
||||
},
|
||||
"enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
|
||||
"enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"},
|
||||
"enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"},
|
||||
@ -59,6 +65,16 @@ func init() {
|
||||
"enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"},
|
||||
"enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"},
|
||||
"enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"},
|
||||
"enum.square_channel.square": {ZhCN: "广场", En: "Square", ZhTW: "廣場"},
|
||||
"enum.square_channel.squarenovel": {ZhCN: "小说广场", En: "Novel square", ZhTW: "小說廣場"},
|
||||
"enum.square_channel.squaresteps": {ZhCN: "步数广场", En: "Steps square", ZhTW: "步數廣場"},
|
||||
"enum.square_channel.squarehealth": {ZhCN: "健康广场", En: "Health square", ZhTW: "健康廣場"},
|
||||
"enum.square_category.recommend": {ZhCN: "推荐", En: "Recommend", ZhTW: "推薦"},
|
||||
"enum.square_category.latest": {ZhCN: "最新", En: "Latest", ZhTW: "最新"},
|
||||
"enum.square_category.hot": {ZhCN: "热门", En: "Hot", ZhTW: "熱門"},
|
||||
"enum.square_category.nearby": {ZhCN: "附近", En: "Nearby", ZhTW: "附近"},
|
||||
"enum.square_category.funny": {ZhCN: "搞笑", En: "Funny", ZhTW: "搞笑"},
|
||||
"enum.square_category.beauty": {ZhCN: "美女", En: "Beauty", ZhTW: "美女"},
|
||||
"enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"},
|
||||
"enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"},
|
||||
})
|
||||
|
||||
@ -161,6 +161,7 @@ const (
|
||||
KeyFieldSquareComment = "field.square_comment"
|
||||
KeyFieldSquareReply = "field.square_reply"
|
||||
KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
|
||||
KeyFieldSquareCategory = "field.square_category"
|
||||
KeySquareTextInvalid = "validate.square.text_invalid"
|
||||
KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
|
||||
KeySquareAudienceInvalid = "validate.square.audience_invalid"
|
||||
@ -170,6 +171,7 @@ const (
|
||||
KeySquareCommentRequired = "validate.square.comment_required"
|
||||
KeySquareCommentInvalid = "validate.square.comment_invalid"
|
||||
KeySquareReplyInvalid = "validate.square.reply_invalid"
|
||||
KeySquareCategoryInvalid = "validate.square.category_invalid"
|
||||
|
||||
KeyFieldVerificationTarget = "field.verification_target"
|
||||
KeySMSLanguageRequired = "validate.sms.language_required"
|
||||
|
||||
@ -102,6 +102,9 @@ func publicPath(path string) bool {
|
||||
"/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
|
||||
return true
|
||||
default:
|
||||
if publicMusicPath(path) {
|
||||
return true
|
||||
}
|
||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||
if adminPath(path) {
|
||||
return true
|
||||
@ -110,6 +113,16 @@ func publicPath(path string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func publicMusicPath(path string) bool {
|
||||
switch path {
|
||||
case "/v1/music/playlist", "/v1/music/playlist/grouped",
|
||||
"/v1/music/categories", "/v1/music/settings":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(path, "/v1/music/tracks/")
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(h string) (string, bool) {
|
||||
if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
|
||||
return "", false
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
openapi: 3.0.3
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Spark IM API
|
||||
version: 0.1.0
|
||||
@ -65,7 +65,7 @@ info:
|
||||
|
||||
| Header | 放哪 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 |
|
||||
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、以及公开音乐读接口(`/v1/music/playlist`、`/v1/music/playlist/grouped`、`/v1/music/tracks/{id}`、`/v1/music/categories`、`/v1/music/settings`)**不要**带。 |
|
||||
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
|
||||
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
|
||||
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
|
||||
@ -373,9 +373,16 @@ tags:
|
||||
- name: favorites
|
||||
- name: moments
|
||||
- name: square
|
||||
description: |
|
||||
多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。
|
||||
分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。
|
||||
- name: music
|
||||
description: |
|
||||
运营音乐列表。客户端公开读 `GET /v1/music/playlist`(可带 `categoryId`)、`GET /v1/music/categories`、`GET /v1/music/settings`(无需登录,V3 envelope)。
|
||||
运营音乐列表。客户端公开读(无需登录,V3 envelope):
|
||||
`GET /v1/music/playlist`(可带 `categoryId` / `requireMember`)、
|
||||
`GET /v1/music/playlist/grouped`(分类分区嵌套曲目,对齐欢休 zone 列表)、
|
||||
`GET /v1/music/tracks/{id}`(单曲详情)、
|
||||
`GET /v1/music/categories`、`GET /v1/music/settings`。
|
||||
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
|
||||
- name: admin-music
|
||||
description: |
|
||||
@ -821,9 +828,21 @@ paths:
|
||||
description: |
|
||||
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
|
||||
公开接口;无库数据时回退默认四档。
|
||||
支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。
|
||||
有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。
|
||||
operationId: subscriptionPlans
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-Language"
|
||||
- name: packageName
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: applicationId / Bundle ID / 包名;也可用 package_name 或 Header X-Wyrs-Package
|
||||
- name: X-Wyrs-Package
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: 与 packageName 等价
|
||||
responses:
|
||||
"200":
|
||||
description: data.plans
|
||||
@ -1837,21 +1856,54 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts:
|
||||
/v1/{square_channel}/categories:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 全站广场 Feed
|
||||
summary: 广场分类 Tab
|
||||
description: |
|
||||
`cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖;
|
||||
读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。
|
||||
返回筛选分类:`recommend`/`latest`/`hot`/`nearby`(feed 模式)与 `funny`/`beauty`(内容标签)。
|
||||
各广场实例共用同一套分类枚举;数据按 `square_channel` 隔离。
|
||||
operationId: listSquareCategories
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope;data.items[].value / kind(feed|tag)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/{square_channel}/posts:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 广场 Feed
|
||||
description: |
|
||||
`square_channel`:`square`(原路径兼容)/ `squarenovel` / `squaresteps` / `squarehealth`。
|
||||
`category`:推荐/最新/热门/附近为排序模式;搞笑/美女按帖标签筛选。
|
||||
`nearby` 可传 `lat`/`lng`(曼哈顿距离 stub);未传则仅返回带 POI 的帖并按 id 倒序。
|
||||
`cursor` 为上一页最后一条 `post_id`,0 表示最新。
|
||||
operationId: listSquareFeed
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- in: query
|
||||
name: category
|
||||
schema:
|
||||
$ref: "#/components/schemas/SquareCategory"
|
||||
description: 缺省 recommend
|
||||
- in: query
|
||||
name: cursor
|
||||
schema: { type: integer, format: int64, default: 0 }
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20, maximum: 50 }
|
||||
- in: query
|
||||
name: lat
|
||||
schema: { type: number, format: double }
|
||||
description: 附近排序纬度(category=nearby)
|
||||
- in: query
|
||||
name: lng
|
||||
schema: { type: number, format: double }
|
||||
description: 附近排序经度(category=nearby)
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
@ -1865,13 +1917,16 @@ paths:
|
||||
description: |
|
||||
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
|
||||
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
|
||||
可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。
|
||||
operationId: publishSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishMomentRequest"
|
||||
$ref: "#/components/schemas/PublishSquareRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
@ -1879,12 +1934,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}:
|
||||
/v1/{square_channel}/posts/{id}:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 单条广场帖
|
||||
operationId: getSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
@ -1898,6 +1954,7 @@ paths:
|
||||
summary: 删除自己的广场帖
|
||||
operationId: deleteSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
@ -1906,12 +1963,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/like:
|
||||
/v1/{square_channel}/posts/{id}/like:
|
||||
put:
|
||||
tags: [square]
|
||||
summary: 点赞
|
||||
operationId: likeSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
@ -1925,6 +1983,7 @@ paths:
|
||||
summary: 取消赞
|
||||
operationId: unlikeSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
@ -1933,12 +1992,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/comments:
|
||||
/v1/{square_channel}/posts/{id}/comments:
|
||||
post:
|
||||
tags: [square]
|
||||
summary: 评论或回复
|
||||
operationId: commentSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
requestBody:
|
||||
required: true
|
||||
@ -1953,12 +2013,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/comments/{cid}:
|
||||
/v1/{square_channel}/posts/{id}/comments/{cid}:
|
||||
delete:
|
||||
tags: [square]
|
||||
summary: 删除评论
|
||||
operationId: deleteSquareComment
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
- name: cid
|
||||
in: path
|
||||
@ -1971,13 +2032,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/users/{uid}/posts:
|
||||
/v1/{square_channel}/users/{uid}/posts:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 某用户广场帖
|
||||
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。
|
||||
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。
|
||||
operationId: listSquareUserPosts
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- $ref: "#/components/parameters/FriendUID"
|
||||
- in: query
|
||||
name: cursor
|
||||
@ -1992,12 +2054,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices:
|
||||
/v1/{square_channel}/notices:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 赞评通知列表
|
||||
operationId: listSquareNotices
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
- in: query
|
||||
name: cursor
|
||||
schema: { type: integer, format: int64, default: 0 }
|
||||
@ -2011,11 +2074,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices/unread:
|
||||
/v1/{square_channel}/notices/unread:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 广场互动未读角标
|
||||
operationId: squareNoticeUnread
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
@ -2023,11 +2088,13 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices/read:
|
||||
/v1/{square_channel}/notices/read:
|
||||
post:
|
||||
tags: [square]
|
||||
summary: 通知标已读
|
||||
operationId: readSquareNotices
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquareChannel"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@ -2050,6 +2117,7 @@ paths:
|
||||
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
|
||||
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
|
||||
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
|
||||
传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。
|
||||
operationId: getMusicPlaylist
|
||||
security: []
|
||||
parameters:
|
||||
@ -2058,6 +2126,11 @@ paths:
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0 }
|
||||
description: 按分类筛选;0 或不传=全部上架曲目
|
||||
- name: requireMember
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean }
|
||||
description: true=仅会员曲;false=仅免费曲;不传=全部
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope,`data` 为 MusicPlaylist
|
||||
@ -2070,6 +2143,61 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MusicPlaylist"
|
||||
/v1/music/playlist/grouped:
|
||||
get:
|
||||
tags: [music]
|
||||
summary: 分区嵌套音乐列表(公开)
|
||||
description: |
|
||||
按叶子分类分组返回曲目,便于首页「分区 + 分区下音乐」一次拉取。
|
||||
对齐欢休 `zoneAndZoneMusicList` 语义(Spark 用 category 作 zone)。
|
||||
`parentId`>0 时:该分类为叶子则只返回自身;有子分类则返回其子叶子。
|
||||
无需登录。
|
||||
operationId: getMusicPlaylistGrouped
|
||||
security: []
|
||||
parameters:
|
||||
- name: parentId
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0 }
|
||||
description: 父分类 id;0 或不传=全部叶子分类
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope,`data` 为 MusicGroupedPlaylist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MusicGroupedPlaylist"
|
||||
/v1/music/tracks/{id}:
|
||||
get:
|
||||
tags: [music]
|
||||
summary: 单曲详情(公开)
|
||||
description: |
|
||||
返回已上架曲目详情(含播放 `audioUrl`、封面/背景)。下架或不存在返回 `40401`。
|
||||
对齐欢休 `/music/v1/detail`;无需登录。
|
||||
operationId: getMusicTrack
|
||||
security: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, format: int64, minimum: 1 }
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope,`data` 为 MusicTrack
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MusicTrack"
|
||||
/v1/music/categories:
|
||||
get:
|
||||
tags: [music]
|
||||
@ -3327,6 +3455,23 @@ components:
|
||||
type: integer
|
||||
enum: [0, 3, 30, 180]
|
||||
description: moment_settings.visible_days。0全部 3/30/180 天
|
||||
SquareChannel:
|
||||
type: string
|
||||
enum: [square, squarenovel, squaresteps, squarehealth]
|
||||
description: 广场实例路径段;与 t_square_posts.channel 一致
|
||||
SquareCategory:
|
||||
type: string
|
||||
enum: [recommend, latest, hot, nearby, funny, beauty]
|
||||
description: |
|
||||
Feed 筛选。recommend/latest/hot/nearby 为排序模式;funny/beauty 为帖标签(发帖可写)。
|
||||
PublishSquareRequest:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/PublishMomentRequest"
|
||||
- type: object
|
||||
properties:
|
||||
category:
|
||||
$ref: "#/components/schemas/SquareCategory"
|
||||
description: 仅 funny/beauty;发帖不可传 feed 模式值
|
||||
PublishMomentRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -3516,6 +3661,25 @@ components:
|
||||
$ref: "#/components/schemas/MusicCategory"
|
||||
description: 启用中的分类树
|
||||
memberPreviewSeconds: { type: integer }
|
||||
MusicPlaylistGroup:
|
||||
type: object
|
||||
description: 一个分区(叶子分类)及其曲目
|
||||
properties:
|
||||
categoryId: { type: integer, format: int64 }
|
||||
name: { type: string }
|
||||
parentId: { type: integer, format: int64, nullable: true }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MusicTrack"
|
||||
MusicGroupedPlaylist:
|
||||
type: object
|
||||
properties:
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MusicPlaylistGroup"
|
||||
memberPreviewSeconds: { type: integer }
|
||||
SensitiveCategory:
|
||||
type: integer
|
||||
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
@ -4331,6 +4495,13 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 朋友圈 Snowflake post_id。
|
||||
SquareChannel:
|
||||
in: path
|
||||
name: square_channel
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/SquareChannel"
|
||||
description: 广场实例 square / squarenovel / squaresteps / squarehealth
|
||||
SquarePostID:
|
||||
in: path
|
||||
name: id
|
||||
|
||||
@ -61,7 +61,11 @@ func (h *ClientHandler) ListPlans(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
lang := c.Query("lang")
|
||||
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang)
|
||||
pkg := strings.TrimSpace(c.Query("packageName"))
|
||||
if pkg == "" {
|
||||
pkg = strings.TrimSpace(c.Query("package_name"))
|
||||
}
|
||||
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang, pkg)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package clienthandler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
clientlogic "spark-server/internal/module/client/logic"
|
||||
@ -39,7 +41,14 @@ func (h *Handler) SubmitFeedback(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) SubscriptionPlans(c *gin.Context) {
|
||||
out, err := h.svc.SubscriptionPlans(c.Request.Context())
|
||||
pkg := strings.TrimSpace(c.Query("packageName"))
|
||||
if pkg == "" {
|
||||
pkg = strings.TrimSpace(c.Query("package_name"))
|
||||
}
|
||||
if pkg == "" {
|
||||
pkg = strings.TrimSpace(c.GetHeader("X-Wyrs-Package"))
|
||||
}
|
||||
out, err := h.svc.SubscriptionPlans(c.Request.Context(), pkg)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
|
||||
@ -20,21 +20,60 @@ func New(svc *musiclogic.Service) *Handler {
|
||||
|
||||
// Playlist GET /v1/music/playlist — public curated list + category tree.
|
||||
// Query categoryId>0 filters tracks belonging to that category or its descendants.
|
||||
// Query requireMember=true|false filters member-only / free tracks.
|
||||
func (h *Handler) Playlist(c *gin.Context) {
|
||||
if h == nil || h.svc == nil || !h.svc.Ready() {
|
||||
respond.JSONError(c, errs.ErrAvailable)
|
||||
return
|
||||
}
|
||||
var categoryID int64
|
||||
if raw := c.Query("categoryId"); raw != "" {
|
||||
q, err := parsePlaylistQuery(c)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Playlist(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
respond.JSON(c, out)
|
||||
}
|
||||
|
||||
// PlaylistGrouped GET /v1/music/playlist/grouped — leaf categories with nested items.
|
||||
func (h *Handler) PlaylistGrouped(c *gin.Context) {
|
||||
if h == nil || h.svc == nil || !h.svc.Ready() {
|
||||
respond.JSONError(c, errs.ErrAvailable)
|
||||
return
|
||||
}
|
||||
var parentID int64
|
||||
if raw := c.Query("parentId"); raw != "" {
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("categoryId 无效"))
|
||||
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("parentId 无效"))
|
||||
return
|
||||
}
|
||||
categoryID = n
|
||||
parentID = n
|
||||
}
|
||||
out, err := h.svc.Playlist(c.Request.Context(), categoryID)
|
||||
out, err := h.svc.PlaylistGrouped(c.Request.Context(), parentID)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
respond.JSON(c, out)
|
||||
}
|
||||
|
||||
// GetTrack GET /v1/music/tracks/:id — single enabled track (public).
|
||||
func (h *Handler) GetTrack(c *gin.Context) {
|
||||
if h == nil || h.svc == nil || !h.svc.Ready() {
|
||||
respond.JSONError(c, errs.ErrAvailable)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("id 无效"))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.GetPublic(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -69,3 +108,27 @@ func (h *Handler) Settings(c *gin.Context) {
|
||||
}
|
||||
respond.JSON(c, out)
|
||||
}
|
||||
|
||||
func parsePlaylistQuery(c *gin.Context) (musiclogic.PlaylistQuery, error) {
|
||||
var q musiclogic.PlaylistQuery
|
||||
if raw := c.Query("categoryId"); raw != "" {
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return q, errs.ErrInvalidArgument.WithMsg("categoryId 无效")
|
||||
}
|
||||
q.CategoryID = n
|
||||
}
|
||||
if raw := c.Query("requireMember"); raw != "" {
|
||||
switch raw {
|
||||
case "true", "1":
|
||||
v := true
|
||||
q.RequireMember = &v
|
||||
case "false", "0":
|
||||
v := false
|
||||
q.RequireMember = &v
|
||||
default:
|
||||
return q, errs.ErrInvalidArgument.WithMsg("requireMember 须为 true|false")
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
@ -5,19 +5,24 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"spark-server/internal/module/square/entity"
|
||||
squarelogic "spark-server/internal/module/square/logic"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
"spark-server/internal/transport/http/respond"
|
||||
)
|
||||
|
||||
// Handler is the square HTTP controller.
|
||||
// Handler is the square HTTP controller for one plaza channel.
|
||||
type Handler struct {
|
||||
square *squarelogic.Service
|
||||
square *squarelogic.Service
|
||||
channel entity.Channel
|
||||
}
|
||||
|
||||
func New(square *squarelogic.Service) *Handler {
|
||||
return &Handler{square: square}
|
||||
func New(square *squarelogic.Service, channel entity.Channel) *Handler {
|
||||
if channel == "" {
|
||||
channel = entity.ChannelSquare
|
||||
}
|
||||
return &Handler{square: square, channel: channel}
|
||||
}
|
||||
|
||||
func currentUID(c *gin.Context) (int64, bool) {
|
||||
@ -43,12 +48,32 @@ func queryInt(c *gin.Context, name string) int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
func queryFloat(c *gin.Context, name string) float64 {
|
||||
n, _ := strconv.ParseFloat(c.Query(name), 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *Handler) Categories(c *gin.Context) {
|
||||
respond.JSON(c, gin.H{"items": h.square.Categories()})
|
||||
}
|
||||
|
||||
func (h *Handler) Feed(c *gin.Context) {
|
||||
uid, ok := currentUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.Feed(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||
cat, ok := entity.ParseCategory(c.Query("category"))
|
||||
if !ok {
|
||||
respond.JSONError(c, errs.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if cat == "" {
|
||||
cat = entity.CategoryRecommend
|
||||
}
|
||||
out, err := h.square.Feed(c.Request.Context(), uid, h.channel, squarelogic.FeedQuery{
|
||||
Cursor: queryInt(c, "cursor"), Limit: int(queryInt(c, "limit")),
|
||||
Category: cat, Lat: queryFloat(c, "lat"), Lng: queryFloat(c, "lng"),
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -66,7 +91,7 @@ func (h *Handler) Publish(c *gin.Context) {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.square.Publish(c.Request.Context(), uid, &in)
|
||||
out, err := h.square.Publish(c.Request.Context(), uid, h.channel, &in)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -83,7 +108,7 @@ func (h *Handler) UserPosts(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.UserPosts(c.Request.Context(), uid, owner, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||
out, err := h.square.UserPosts(c.Request.Context(), uid, owner, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -100,7 +125,7 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.Get(c.Request.Context(), uid, id)
|
||||
out, err := h.square.Get(c.Request.Context(), uid, id, h.channel)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -117,7 +142,7 @@ func (h *Handler) Delete(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.square.Delete(c.Request.Context(), uid, id); err != nil {
|
||||
if err := h.square.Delete(c.Request.Context(), uid, id, h.channel); err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
@ -133,7 +158,7 @@ func (h *Handler) Like(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.Like(c.Request.Context(), uid, id)
|
||||
out, err := h.square.Like(c.Request.Context(), uid, id, h.channel)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -150,7 +175,7 @@ func (h *Handler) Unlike(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.Unlike(c.Request.Context(), uid, id)
|
||||
out, err := h.square.Unlike(c.Request.Context(), uid, id, h.channel)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -172,7 +197,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.square.Comment(c.Request.Context(), uid, id, &in)
|
||||
out, err := h.square.Comment(c.Request.Context(), uid, id, h.channel, &in)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -193,7 +218,7 @@ func (h *Handler) DeleteComment(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid)
|
||||
out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid, h.channel)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -206,7 +231,7 @@ func (h *Handler) ListNotices(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.ListNotices(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||
out, err := h.square.ListNotices(c.Request.Context(), uid, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -219,7 +244,7 @@ func (h *Handler) NoticeUnread(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.square.NoticeUnread(c.Request.Context(), uid)
|
||||
out, err := h.square.NoticeUnread(c.Request.Context(), uid, h.channel)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
@ -237,7 +262,7 @@ func (h *Handler) ReadNotices(c *gin.Context) {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.square.ReadNotices(c.Request.Context(), uid, &in); err != nil {
|
||||
if err := h.square.ReadNotices(c.Request.Context(), uid, h.channel, &in); err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"spark-server/internal/module/square/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
"spark-server/internal/pkg/reply"
|
||||
@ -226,8 +227,15 @@ func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||
if deps.Square == nil {
|
||||
return
|
||||
}
|
||||
h := squarehandler.New(deps.Square)
|
||||
g := v1.Group("/square")
|
||||
for _, ch := range []string{"square", "squarenovel", "squaresteps", "squarehealth"} {
|
||||
registerSquareChannelRoutes(v1, deps, ch)
|
||||
}
|
||||
}
|
||||
|
||||
func registerSquareChannelRoutes(v1 *gin.RouterGroup, deps *EngineDeps, channel string) {
|
||||
h := squarehandler.New(deps.Square, entity.Channel(channel))
|
||||
g := v1.Group("/" + channel)
|
||||
g.GET("/categories", h.Categories)
|
||||
g.GET("/posts", h.Feed)
|
||||
g.POST("/posts", h.Publish)
|
||||
g.GET("/posts/:id", h.Get)
|
||||
@ -250,6 +258,8 @@ func registerMusicRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||
g := v1.Group("/music")
|
||||
// 客户端可读:无需登录(列表为运营配置的公开曲目元数据)
|
||||
g.GET("/playlist", h.Playlist)
|
||||
g.GET("/playlist/grouped", h.PlaylistGrouped)
|
||||
g.GET("/tracks/:id", h.GetTrack)
|
||||
g.GET("/categories", h.Categories)
|
||||
g.GET("/settings", h.Settings)
|
||||
}
|
||||
|
||||
12
migrations/0030_subscription_plans_package_name.sql
Normal file
12
migrations/0030_subscription_plans_package_name.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- 订阅套餐按 applicationId / 包名区分定价;空字符串 = 默认兜底。
|
||||
-- 原 PRIMARY KEY (plan_id, lang) → (plan_id, lang, package_name)
|
||||
|
||||
ALTER TABLE t_subscription_plans
|
||||
ADD COLUMN package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'applicationId/包名,空=默认兜底' AFTER lang;
|
||||
|
||||
ALTER TABLE t_subscription_plans
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (plan_id, lang, package_name);
|
||||
|
||||
ALTER TABLE t_subscription_plans
|
||||
ADD KEY idx_sub_plans_pkg (package_name, enabled, sort_order);
|
||||
24
migrations/0031_square_channel_category.sql
Normal file
24
migrations/0031_square_channel_category.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 多广场实例(channel)+ 分类筛选(category)。旧帖默认 channel=square、category 空。
|
||||
|
||||
ALTER TABLE t_square_posts
|
||||
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例:square/squarenovel/squaresteps/squarehealth' AFTER user_id,
|
||||
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT '' COMMENT '内容标签:funny/beauty 等;空为未分类' AFTER channel;
|
||||
|
||||
ALTER TABLE t_square_posts
|
||||
ADD KEY idx_channel_public_feed (channel, audit_status, visibility, id),
|
||||
ADD KEY idx_channel_hot (channel, audit_status, visibility, like_count, id),
|
||||
ADD KEY idx_channel_category (channel, category, id),
|
||||
ADD KEY idx_channel_user (channel, user_id, id);
|
||||
|
||||
ALTER TABLE t_square_notices
|
||||
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例,与帖一致' AFTER to_user_id;
|
||||
|
||||
ALTER TABLE t_square_notices
|
||||
ADD KEY idx_channel_to_user (channel, to_user_id, id);
|
||||
|
||||
ALTER TABLE t_square_notice_state
|
||||
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例' AFTER user_id;
|
||||
|
||||
ALTER TABLE t_square_notice_state
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (user_id, channel);
|
||||
Loading…
Reference in New Issue
Block a user