diff --git a/docs/daily/2026-09-24-change-list.md b/docs/daily/2026-09-24-change-list.md index 28d4d18..f2ac968 100644 --- a/docs/daily/2026-09-24-change-list.md +++ b/docs/daily/2026-09-24-change-list.md @@ -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) diff --git a/internal/module/client/entity/types.go b/internal/module/client/entity/types.go index b6e7768..dcd8350 100644 --- a/internal/module/client/entity/types.go +++ b/internal/module/client/entity/types.go @@ -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"` diff --git a/internal/module/client/logic/service.go b/internal/module/client/logic/service.go index 87fd00c..5e2f237 100644 --- a/internal/module/client/logic/service.go +++ b/internal/module/client/logic/service.go @@ -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: diff --git a/internal/module/client/logic/service_test.go b/internal/module/client/logic/service_test.go index 2abf56b..b1517c6 100644 --- a/internal/module/client/logic/service_test.go +++ b/internal/module/client/logic/service_test.go @@ -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) } diff --git a/internal/module/client/model/repo.go b/internal/module/client/model/repo.go index ca6e213..a573c4d 100644 --- a/internal/module/client/model/repo.go +++ b/internal/module/client/model/repo.go @@ -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(), diff --git a/internal/module/meta/entity/enum.go b/internal/module/meta/entity/enum.go index aa9bb9c..a2cd5ed 100644 --- a/internal/module/meta/entity/enum.go +++ b/internal/module/meta/entity/enum.go @@ -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"` } diff --git a/internal/module/meta/logic/enums.go b/internal/module/meta/logic/enums.go index 8640cd3..af0e414 100644 --- a/internal/module/meta/logic/enums.go +++ b/internal/module/meta/logic/enums.go @@ -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), } } diff --git a/internal/module/meta/logic/enums_square.go b/internal/module/meta/logic/enums_square.go new file mode 100644 index 0000000..15a0f81 --- /dev/null +++ b/internal/module/meta/logic/enums_square.go @@ -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...) +} diff --git a/internal/module/meta/logic/enums_test.go b/internal/module/meta/logic/enums_test.go index 36247b4..4a2eda2 100644 --- a/internal/module/meta/logic/enums_test.go +++ b/internal/module/meta/logic/enums_test.go @@ -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") diff --git a/internal/module/music/entity/track.go b/internal/module/music/entity/track.go index e455ff9..6ad2482 100644 --- a/internal/module/music/entity/track.go +++ b/internal/module/music/entity/track.go @@ -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"` +} diff --git a/internal/module/music/logic/service.go b/internal/module/music/logic/service.go index ee786ac..48cd511 100644 --- a/internal/module/music/logic/service.go +++ b/internal/module/music/logic/service.go @@ -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 diff --git a/internal/module/music/logic/service_test.go b/internal/module/music/logic/service_test.go index df9c2db..a1072a3 100644 --- a/internal/module/music/logic/service_test.go +++ b/internal/module/music/logic/service_test.go @@ -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) } } diff --git a/internal/module/square/entity/channel.go b/internal/module/square/entity/channel.go new file mode 100644 index 0000000..d064074 --- /dev/null +++ b/internal/module/square/entity/channel.go @@ -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 +} diff --git a/internal/module/square/entity/post.go b/internal/module/square/entity/post.go index 510f3fe..cf02543 100644 --- a/internal/module/square/entity/post.go +++ b/internal/module/square/entity/post.go @@ -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} } diff --git a/internal/module/square/logic/album.go b/internal/module/square/logic/album.go index 14e4b51..c175572 100644 --- a/internal/module/square/logic/album.go +++ b/internal/module/square/logic/album.go @@ -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 } diff --git a/internal/module/square/logic/attach.go b/internal/module/square/logic/attach.go index 2788b06..67d41d3 100644 --- a/internal/module/square/logic/attach.go +++ b/internal/module/square/logic/attach.go @@ -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, diff --git a/internal/module/square/logic/comment.go b/internal/module/square/logic/comment.go index 5124cb9..d8c8304 100644 --- a/internal/module/square/logic/comment.go +++ b/internal/module/square/logic/comment.go @@ -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 { diff --git a/internal/module/square/logic/delete.go b/internal/module/square/logic/delete.go index ee5c268..383f81e 100644 --- a/internal/module/square/logic/delete.go +++ b/internal/module/square/logic/delete.go @@ -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 } diff --git a/internal/module/square/logic/feed.go b/internal/module/square/logic/feed.go index 9bb846a..15944c5 100644 --- a/internal/module/square/logic/feed.go +++ b/internal/module/square/logic/feed.go @@ -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 +} diff --git a/internal/module/square/logic/like.go b/internal/module/square/logic/like.go index 5f13703..2562939 100644 --- a/internal/module/square/logic/like.go +++ b/internal/module/square/logic/like.go @@ -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 diff --git a/internal/module/square/logic/notice.go b/internal/module/square/logic/notice.go index e28beb4..b0c331c 100644 --- a/internal/module/square/logic/notice.go +++ b/internal/module/square/logic/notice.go @@ -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} } diff --git a/internal/module/square/logic/publish.go b/internal/module/square/logic/publish.go index 132ae50..fd7670e 100644 --- a/internal/module/square/logic/publish.go +++ b/internal/module/square/logic/publish.go @@ -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(), } diff --git a/internal/module/square/logic/service.go b/internal/module/square/logic/service.go index 421df91..d9e24e5 100644 --- a/internal/module/square/logic/service.go +++ b/internal/module/square/logic/service.go @@ -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 +} diff --git a/internal/module/square/logic/square_test.go b/internal/module/square/logic/square_test.go index 9122193..a96f50c 100644 --- a/internal/module/square/logic/square_test.go +++ b/internal/module/square/logic/square_test.go @@ -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) + } +} diff --git a/internal/module/square/logic/view.go b/internal/module/square/logic/view.go index 055d5b2..114ebdd 100644 --- a/internal/module/square/logic/view.go +++ b/internal/module/square/logic/view.go @@ -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 diff --git a/internal/module/square/model/memory.go b/internal/module/square/model/memory.go index 4197f0e..64c540d 100644 --- a/internal/module/square/model/memory.go +++ b/internal/module/square/model/memory.go @@ -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 } diff --git a/internal/module/square/model/memory_purge.go b/internal/module/square/model/memory_purge.go index 1c645b2..6e442fe 100644 --- a/internal/module/square/model/memory_purge.go +++ b/internal/module/square/model/memory_purge.go @@ -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) diff --git a/internal/module/square/model/memory_write.go b/internal/module/square/model/memory_write.go index 59eecea..57128e7 100644 --- a/internal/module/square/model/memory_write.go +++ b/internal/module/square/model/memory_write.go @@ -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 diff --git a/internal/module/square/model/post.go b/internal/module/square/model/post.go index d242638..8b6cf6f 100644 --- a/internal/module/square/model/post.go +++ b/internal/module/square/model/post.go @@ -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(), } } diff --git a/internal/module/square/model/query.go b/internal/module/square/model/query.go index 498f4a0..c96a074 100644 --- a/internal/module/square/model/query.go +++ b/internal/module/square/model/query.go @@ -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) } diff --git a/internal/module/square/model/write.go b/internal/module/square/model/write.go index 0f6f7ef..aed00ec 100644 --- a/internal/module/square/model/write.go +++ b/internal/module/square/model/write.go @@ -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) } diff --git a/internal/pkg/i18n/catalog_validate_square.go b/internal/pkg/i18n/catalog_validate_square.go index 5063191..cf94ddb 100644 --- a/internal/pkg/i18n/catalog_validate_square.go +++ b/internal/pkg/i18n/catalog_validate_square.go @@ -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: "广场影片"}, }) diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index e5f2709..fa61205 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -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" diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index a984c2b..a9c4838 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -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 diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 2baf4ff..751df2b 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -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 `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 | + | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/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 diff --git a/internal/transport/http/handler/admin/client.go b/internal/transport/http/handler/admin/client.go index 1037f03..8bcb84c 100644 --- a/internal/transport/http/handler/admin/client.go +++ b/internal/transport/http/handler/admin/client.go @@ -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 diff --git a/internal/transport/http/handler/client/handler.go b/internal/transport/http/handler/client/handler.go index db0dfb9..b5277ae 100644 --- a/internal/transport/http/handler/client/handler.go +++ b/internal/transport/http/handler/client/handler.go @@ -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 diff --git a/internal/transport/http/handler/music/handler.go b/internal/transport/http/handler/music/handler.go index 7ae8272..6544141 100644 --- a/internal/transport/http/handler/music/handler.go +++ b/internal/transport/http/handler/music/handler.go @@ -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 +} diff --git a/internal/transport/http/handler/square/handler.go b/internal/transport/http/handler/square/handler.go index 4674115..128ba58 100644 --- a/internal/transport/http/handler/square/handler.go +++ b/internal/transport/http/handler/square/handler.go @@ -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 } diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 66cb06c..a1a1b3a 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -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) } diff --git a/migrations/0030_subscription_plans_package_name.sql b/migrations/0030_subscription_plans_package_name.sql new file mode 100644 index 0000000..cc09f62 --- /dev/null +++ b/migrations/0030_subscription_plans_package_name.sql @@ -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); diff --git a/migrations/0031_square_channel_category.sql b/migrations/0031_square_channel_category.sql new file mode 100644 index 0000000..4542ee2 --- /dev/null +++ b/migrations/0031_square_channel_category.sql @@ -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);