feat: 多广场 channel、音乐公开 API 与订阅 package_name

支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-30 09:02:28 +08:00
parent 7f2567cf15
commit 53426af122
42 changed files with 1397 additions and 210 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
package logic
import (
"spark-server/internal/module/meta/entity"
squareentity "spark-server/internal/module/square/entity"
"spark-server/internal/pkg/i18n"
)
func squareChannel(lang i18n.Lang) []entity.Item {
chs := squareentity.Channels()
vals := make([]string, 0, len(chs))
for _, c := range chs {
vals = append(vals, string(c))
}
return strSet(lang, "square_channel", vals...)
}
func squareCategory(lang i18n.Lang) []entity.Item {
cats := squareentity.Categories()
vals := make([]string, 0, len(cats))
for _, c := range cats {
vals = append(vals, string(c))
}
return strSet(lang, "square_category", vals...)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,104 @@
package entity
// Channel is a square plaza instance (path prefix / data isolation).
type Channel string
const (
ChannelSquare Channel = "square"
ChannelSquareNovel Channel = "squarenovel"
ChannelSquareSteps Channel = "squaresteps"
ChannelSquareHealth Channel = "squarehealth"
)
// Channels is the closed set of plaza instances.
func Channels() []Channel {
return []Channel{ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth}
}
func (c Channel) Valid() bool {
switch c {
case ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth:
return true
default:
return false
}
}
func ParseChannel(s string) (Channel, bool) {
c := Channel(s)
if !c.Valid() {
return "", false
}
return c, true
}
// Category is a feed tab / post tag under every channel.
// Feed modes (recommend/latest/hot/nearby) only affect list sort; tags are stored on posts.
type Category string
const (
CategoryRecommend Category = "recommend"
CategoryLatest Category = "latest"
CategoryHot Category = "hot"
CategoryNearby Category = "nearby"
CategoryFunny Category = "funny"
CategoryBeauty Category = "beauty"
)
// Categories lists every filter tab (feed modes + tags).
func Categories() []Category {
return []Category{
CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
CategoryFunny, CategoryBeauty,
}
}
// PublishCategories are tags that may be written on a post.
func PublishCategories() []Category {
return []Category{CategoryFunny, CategoryBeauty}
}
func (c Category) Valid() bool {
switch c {
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
CategoryFunny, CategoryBeauty, "":
return true
default:
return false
}
}
func (c Category) IsFeedMode() bool {
switch c {
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby, "":
return true
default:
return false
}
}
func (c Category) IsTag() bool {
switch c {
case CategoryFunny, CategoryBeauty:
return true
default:
return false
}
}
func ParseCategory(s string) (Category, bool) {
c := Category(s)
if !c.Valid() {
return "", false
}
return c, true
}
// FeedListQuery drives public feed filtering and sort in the repository.
type FeedListQuery struct {
Cursor int64
Limit int
Category Category
Lat float64
Lng float64
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "广场影片"},
})

View File

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

View File

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

View File

@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.0.3
info:
title: Spark IM API
version: 0.1.0
@ -65,7 +65,7 @@ info:
| Header | 放哪 | 必填 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 |
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、以及公开音乐读接口(`/v1/music/playlist`、`/v1/music/playlist/grouped`、`/v1/music/tracks/{id}`、`/v1/music/categories`、`/v1/music/settings`)**不要**带。 |
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
@ -373,9 +373,16 @@ tags:
- name: favorites
- name: moments
- name: square
description: |
多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。
分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。
- name: music
description: |
运营音乐列表。客户端公开读 `GET /v1/music/playlist`(可带 `categoryId`)、`GET /v1/music/categories`、`GET /v1/music/settings`(无需登录,V3 envelope)。
运营音乐列表。客户端公开读(无需登录,V3 envelope):
`GET /v1/music/playlist`(可带 `categoryId` / `requireMember`)、
`GET /v1/music/playlist/grouped`(分类分区嵌套曲目,对齐欢休 zone 列表)、
`GET /v1/music/tracks/{id}`(单曲详情)、
`GET /v1/music/categories`、`GET /v1/music/settings`。
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
- name: admin-music
description: |
@ -821,9 +828,21 @@ paths:
description: |
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
公开接口;无库数据时回退默认四档。
支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。
有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。
operationId: subscriptionPlans
parameters:
- $ref: "#/components/parameters/X-Language"
- name: packageName
in: query
required: false
schema: { type: string }
description: applicationId / Bundle ID / 包名;也可用 package_name 或 Header X-Wyrs-Package
- name: X-Wyrs-Package
in: header
required: false
schema: { type: string }
description: 与 packageName 等价
responses:
"200":
description: data.plans
@ -1837,21 +1856,54 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts:
/v1/{square_channel}/categories:
get:
tags: [square]
summary: 全站广场 Feed
summary: 广场分类 Tab
description: |
`cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖;
读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。
返回筛选分类:`recommend`/`latest`/`hot`/`nearby`(feed 模式)与 `funny`/`beauty`(内容标签)。
各广场实例共用同一套分类枚举;数据按 `square_channel` 隔离。
operationId: listSquareCategories
parameters:
- $ref: "#/components/parameters/SquareChannel"
responses:
"200":
description: 统一 envelope;data.items[].value / kind(feed|tag)
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/{square_channel}/posts:
get:
tags: [square]
summary: 广场 Feed
description: |
`square_channel`:`square`(原路径兼容)/ `squarenovel` / `squaresteps` / `squarehealth`。
`category`:推荐/最新/热门/附近为排序模式;搞笑/美女按帖标签筛选。
`nearby` 可传 `lat`/`lng`(曼哈顿距离 stub);未传则仅返回带 POI 的帖并按 id 倒序。
`cursor` 为上一页最后一条 `post_id`,0 表示最新。
operationId: listSquareFeed
parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query
name: category
schema:
$ref: "#/components/schemas/SquareCategory"
description: 缺省 recommend
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 50 }
- in: query
name: lat
schema: { type: number, format: double }
description: 附近排序纬度(category=nearby)
- in: query
name: lng
schema: { type: number, format: double }
description: 附近排序经度(category=nearby)
responses:
"200":
description: 统一 envelope
@ -1865,13 +1917,16 @@ paths:
description: |
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。
operationId: publishSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMomentRequest"
$ref: "#/components/schemas/PublishSquareRequest"
responses:
"200":
description: 统一 envelope
@ -1879,12 +1934,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}:
/v1/{square_channel}/posts/{id}:
get:
tags: [square]
summary: 单条广场帖
operationId: getSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1898,6 +1954,7 @@ paths:
summary: 删除自己的广场帖
operationId: deleteSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1906,12 +1963,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/like:
/v1/{square_channel}/posts/{id}/like:
put:
tags: [square]
summary: 点赞
operationId: likeSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1925,6 +1983,7 @@ paths:
summary: 取消赞
operationId: unlikeSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1933,12 +1992,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments:
/v1/{square_channel}/posts/{id}/comments:
post:
tags: [square]
summary: 评论或回复
operationId: commentSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
requestBody:
required: true
@ -1953,12 +2013,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments/{cid}:
/v1/{square_channel}/posts/{id}/comments/{cid}:
delete:
tags: [square]
summary: 删除评论
operationId: deleteSquareComment
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
- name: cid
in: path
@ -1971,13 +2032,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/users/{uid}/posts:
/v1/{square_channel}/users/{uid}/posts:
get:
tags: [square]
summary: 某用户广场帖
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。
operationId: listSquareUserPosts
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/FriendUID"
- in: query
name: cursor
@ -1992,12 +2054,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices:
/v1/{square_channel}/notices:
get:
tags: [square]
summary: 赞评通知列表
operationId: listSquareNotices
parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
@ -2011,11 +2074,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices/unread:
/v1/{square_channel}/notices/unread:
get:
tags: [square]
summary: 广场互动未读角标
operationId: squareNoticeUnread
parameters:
- $ref: "#/components/parameters/SquareChannel"
responses:
"200":
description: 统一 envelope
@ -2023,11 +2088,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices/read:
/v1/{square_channel}/notices/read:
post:
tags: [square]
summary: 通知标已读
operationId: readSquareNotices
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody:
content:
application/json:
@ -2050,6 +2117,7 @@ paths:
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。
operationId: getMusicPlaylist
security: []
parameters:
@ -2058,6 +2126,11 @@ paths:
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 按分类筛选;0 或不传=全部上架曲目
- name: requireMember
in: query
required: false
schema: { type: boolean }
description: true=仅会员曲;false=仅免费曲;不传=全部
responses:
"200":
description: 统一 envelope,`data` 为 MusicPlaylist
@ -2070,6 +2143,61 @@ paths:
properties:
data:
$ref: "#/components/schemas/MusicPlaylist"
/v1/music/playlist/grouped:
get:
tags: [music]
summary: 分区嵌套音乐列表(公开)
description: |
按叶子分类分组返回曲目,便于首页「分区 + 分区下音乐」一次拉取。
对齐欢休 `zoneAndZoneMusicList` 语义(Spark 用 category 作 zone)。
`parentId`>0 时:该分类为叶子则只返回自身;有子分类则返回其子叶子。
无需登录。
operationId: getMusicPlaylistGrouped
security: []
parameters:
- name: parentId
in: query
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 父分类 id;0 或不传=全部叶子分类
responses:
"200":
description: 统一 envelope,`data` 为 MusicGroupedPlaylist
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/MusicGroupedPlaylist"
/v1/music/tracks/{id}:
get:
tags: [music]
summary: 单曲详情(公开)
description: |
返回已上架曲目详情(含播放 `audioUrl`、封面/背景)。下架或不存在返回 `40401`。
对齐欢休 `/music/v1/detail`;无需登录。
operationId: getMusicTrack
security: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64, minimum: 1 }
responses:
"200":
description: 统一 envelope,`data` 为 MusicTrack
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/MusicTrack"
/v1/music/categories:
get:
tags: [music]
@ -3327,6 +3455,23 @@ components:
type: integer
enum: [0, 3, 30, 180]
description: moment_settings.visible_days。0全部 3/30/180 天
SquareChannel:
type: string
enum: [square, squarenovel, squaresteps, squarehealth]
description: 广场实例路径段;与 t_square_posts.channel 一致
SquareCategory:
type: string
enum: [recommend, latest, hot, nearby, funny, beauty]
description: |
Feed 筛选。recommend/latest/hot/nearby 为排序模式;funny/beauty 为帖标签(发帖可写)。
PublishSquareRequest:
allOf:
- $ref: "#/components/schemas/PublishMomentRequest"
- type: object
properties:
category:
$ref: "#/components/schemas/SquareCategory"
description: 仅 funny/beauty;发帖不可传 feed 模式值
PublishMomentRequest:
type: object
properties:
@ -3516,6 +3661,25 @@ components:
$ref: "#/components/schemas/MusicCategory"
description: 启用中的分类树
memberPreviewSeconds: { type: integer }
MusicPlaylistGroup:
type: object
description: 一个分区(叶子分类)及其曲目
properties:
categoryId: { type: integer, format: int64 }
name: { type: string }
parentId: { type: integer, format: int64, nullable: true }
items:
type: array
items:
$ref: "#/components/schemas/MusicTrack"
MusicGroupedPlaylist:
type: object
properties:
groups:
type: array
items:
$ref: "#/components/schemas/MusicPlaylistGroup"
memberPreviewSeconds: { type: integer }
SensitiveCategory:
type: integer
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
@ -4331,6 +4495,13 @@ components:
type: integer
format: int64
description: 朋友圈 Snowflake post_id。
SquareChannel:
in: path
name: square_channel
required: true
schema:
$ref: "#/components/schemas/SquareChannel"
description: 广场实例 square / squarenovel / squaresteps / squarehealth
SquarePostID:
in: path
name: id

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
-- 订阅套餐按 applicationId / 包名区分定价;空字符串 = 默认兜底。
-- 原 PRIMARY KEY (plan_id, lang) → (plan_id, lang, package_name)
ALTER TABLE t_subscription_plans
ADD COLUMN package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'applicationId/包名,空=默认兜底' AFTER lang;
ALTER TABLE t_subscription_plans
DROP PRIMARY KEY,
ADD PRIMARY KEY (plan_id, lang, package_name);
ALTER TABLE t_subscription_plans
ADD KEY idx_sub_plans_pkg (package_name, enabled, sort_order);

View File

@ -0,0 +1,24 @@
-- 多广场实例(channel)+ 分类筛选(category)。旧帖默认 channel=square、category 空。
ALTER TABLE t_square_posts
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例:square/squarenovel/squaresteps/squarehealth' AFTER user_id,
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT '' COMMENT '内容标签:funny/beauty 等;空为未分类' AFTER channel;
ALTER TABLE t_square_posts
ADD KEY idx_channel_public_feed (channel, audit_status, visibility, id),
ADD KEY idx_channel_hot (channel, audit_status, visibility, like_count, id),
ADD KEY idx_channel_category (channel, category, id),
ADD KEY idx_channel_user (channel, user_id, id);
ALTER TABLE t_square_notices
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例,与帖一致' AFTER to_user_id;
ALTER TABLE t_square_notices
ADD KEY idx_channel_to_user (channel, to_user_id, id);
ALTER TABLE t_square_notice_state
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例' AFTER user_id;
ALTER TABLE t_square_notice_state
DROP PRIMARY KEY,
ADD PRIMARY KEY (user_id, channel);