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 }