spark-server/internal/module/square/model/memory.go
DESKTOP-L8OUL9F\gfl0034 53426af122 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>
2026-09-30 09:02:28 +08:00

328 lines
7.9 KiB
Go

package model
import (
"context"
"math"
"sort"
"sync"
"time"
"spark-server/internal/module/square/entity"
"spark-server/internal/pkg/errs"
)
type likeKey struct {
PostID int64
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
posts map[int64]*entity.Post
audience map[int64][]int64
likes map[likeKey]*entity.Like
comments map[int64]*entity.Comment
notices map[int64]*entity.Notice
noticeState map[noticeStateKey]*entity.NoticeState
botLinks map[string]int64 // url -> post_id
}
func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{
posts: make(map[int64]*entity.Post),
audience: make(map[int64][]int64),
likes: make(map[likeKey]*entity.Like),
comments: make(map[int64]*entity.Comment),
notices: make(map[int64]*entity.Notice),
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()
row, ok := m.posts[id]
if !ok {
return nil, errs.ErrNotFound
}
return clonePost(row), nil
}
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] || memChannel(row.Channel) != ch {
continue
}
if row.Expired(nowMS) {
continue
}
if cursor > 0 && row.ID >= cursor {
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]
}
return out, nil
}
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) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]int64)
for id, peers := range m.audience {
if !allow[id] {
continue
}
out[id] = append([]int64(nil), peers...)
}
return out, nil
}
func (m *MemoryRepo) ListLikes(_ context.Context, postIDs []int64) (map[int64][]*entity.Like, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]*entity.Like)
for _, like := range m.likes {
if !allow[like.PostID] {
continue
}
cp := *like
out[like.PostID] = append(out[like.PostID], &cp)
}
for id := range out {
sort.Slice(out[id], func(i, j int) bool { return out[id][i].CreatedAtMS < out[id][j].CreatedAtMS })
}
return out, nil
}
func (m *MemoryRepo) ListComments(_ context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) {
m.mu.Lock()
defer m.mu.Unlock()
allow := toSet(postIDs)
out := make(map[int64][]*entity.Comment)
for _, c := range m.comments {
if !allow[c.PostID] {
continue
}
out[c.PostID] = append(out[c.PostID], cloneComment(c))
}
for id := range out {
sort.Slice(out[id], func(i, j int) bool { return out[id][i].ID < out[id][j].ID })
}
return out, nil
}
func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.comments[id]
if !ok {
return nil, errs.ErrNotFound
}
return cloneComment(row), nil
}
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 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))
}
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, channel entity.Channel) (*entity.NoticeState, error) {
m.mu.Lock()
defer m.mu.Unlock()
ch := memChannel(channel)
if row, ok := m.noticeState[noticeStateKey{UserID: userID, Channel: ch}]; ok {
cp := *row
return &cp, nil
}
return entity.DefaultNoticeState(userID, ch), nil
}
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 && memChannel(n.Channel) == ch && n.ID > latest {
latest = n.ID
}
}
return latest, nil
}
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 || memChannel(n.Channel) != ch {
continue
}
if cursor > 0 && n.ID >= cursor {
continue
}
cp := *n
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func toSet(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}
func clonePost(p *entity.Post) *entity.Post {
if p == nil {
return nil
}
cp := *p
if p.Content != nil {
cp.Content = append([]byte(nil), p.Content...)
}
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
return &cp
}
func cloneComment(c *entity.Comment) *entity.Comment {
if c == nil {
return nil
}
cp := *c
return &cp
}