thread-master/apps/backend/internal/module/growth/repository/memory.go

675 lines
15 KiB
Go
Raw Permalink Normal View History

2026-07-23 05:56:42 +00:00
package repository
import (
"context"
"sort"
"strings"
"sync"
"time"
"apps/backend/internal/module/growth/domain"
)
type Memory struct {
mu sync.Mutex
outcomes map[string]*domain.OutcomeEvent
bySource map[string]string
checkups map[string]*domain.WeeklyCheckup
health map[string]*domain.AccountHealth
workspaces map[string]*domain.Workspace
wsState map[int64]*domain.MemberWorkspaceState
reviews map[string]*domain.DraftReview
rewards map[string]*domain.InviteReward
playbooks map[string]*domain.Playbook
utm map[string]*domain.UtmLink
wsMembers map[string]*domain.WorkspaceMember
bench map[string]*domain.BenchmarkSample
}
func NewMemory() *Memory {
return &Memory{
outcomes: map[string]*domain.OutcomeEvent{},
bySource: map[string]string{},
checkups: map[string]*domain.WeeklyCheckup{},
health: map[string]*domain.AccountHealth{},
workspaces: map[string]*domain.Workspace{},
wsState: map[int64]*domain.MemberWorkspaceState{},
reviews: map[string]*domain.DraftReview{},
rewards: map[string]*domain.InviteReward{},
playbooks: map[string]*domain.Playbook{},
utm: map[string]*domain.UtmLink{},
wsMembers: map[string]*domain.WorkspaceMember{},
bench: map[string]*domain.BenchmarkSample{},
}
}
func sourceKey(t, id string) string { return t + ":" + id }
func (m *Memory) UpsertOutcomeBySource(_ context.Context, e *domain.OutcomeEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
sk := sourceKey(e.SourceType, e.SourceID)
if id, ok := m.bySource[sk]; ok {
// 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。
*e = *m.outcomes[id]
return nil
} else if e.ID == "" {
e.ID = domain.NewID()
}
cp := *e
m.outcomes[e.ID] = &cp
m.bySource[sk] = e.ID
return nil
}
func (m *Memory) GetOutcome(_ context.Context, id string) (*domain.OutcomeEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.outcomes[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *e
return &cp, nil
}
func (m *Memory) ListOutcomes(_ context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var all []*domain.OutcomeEvent
for _, e := range m.outcomes {
if e.OwnerUID != ownerUID {
continue
}
if f.Kind != "" && e.Kind != f.Kind {
continue
}
if f.Confidence != "" && e.Confidence != f.Confidence {
continue
}
if f.SourceType != "" && e.SourceType != f.SourceType {
continue
}
if f.From > 0 && e.SentAt < f.From {
continue
}
if f.To > 0 && e.SentAt > f.To {
continue
}
cp := *e
all = append(all, &cp)
}
sort.Slice(all, func(i, j int) bool { return all[i].SentAt > all[j].SentAt })
total := int64(len(all))
page, ps := f.Page, f.PageSize
if page < 1 {
page = 1
}
if ps < 1 {
ps = 20
}
start := (page - 1) * ps
if start >= len(all) {
return nil, total, nil
}
end := start + ps
if end > len(all) {
end = len(all)
}
return all[start:end], total, nil
}
func (m *Memory) SaveOutcome(_ context.Context, e *domain.OutcomeEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *e
m.outcomes[e.ID] = &cp
m.bySource[sourceKey(e.SourceType, e.SourceID)] = e.ID
return nil
}
func (m *Memory) ListObservingDue(_ context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.OutcomeEvent
for _, e := range m.outcomes {
if e.Status != domain.StatusObserving {
continue
}
cp := *e
out = append(out, &cp)
if limit > 0 && len(out) >= limit {
break
}
}
return out, nil
}
func (m *Memory) SaveCheckup(_ context.Context, c *domain.WeeklyCheckup) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *c
m.checkups[c.ID] = &cp
return nil
}
func (m *Memory) GetCheckup(_ context.Context, id string) (*domain.WeeklyCheckup, error) {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.checkups[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *c
return &cp, nil
}
func (m *Memory) LatestCheckup(_ context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) {
m.mu.Lock()
defer m.mu.Unlock()
var best *domain.WeeklyCheckup
for _, c := range m.checkups {
if c.OwnerUID != ownerUID {
continue
}
if best == nil || c.CreatedAt > best.CreatedAt {
cp := *c
best = &cp
}
}
if best == nil {
return nil, domain.ErrNotFound
}
return best, nil
}
func (m *Memory) ListCheckups(_ context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var all []*domain.WeeklyCheckup
for _, c := range m.checkups {
if c.OwnerUID == ownerUID {
cp := *c
all = append(all, &cp)
}
}
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
total := int64(len(all))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= len(all) {
return nil, total, nil
}
end := start + pageSize
if end > len(all) {
end = len(all)
}
return all[start:end], total, nil
}
func (m *Memory) CountCheckupGenerations(_ context.Context, ownerUID int64, sinceNano int64) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var n int64
for _, c := range m.checkups {
if c.OwnerUID == ownerUID && c.CreatedAt >= sinceNano {
n++
}
}
return n, nil
}
func (m *Memory) SaveHealth(_ context.Context, h *domain.AccountHealth) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *h
m.health[h.ThreadsAccountID] = &cp
return nil
}
func (m *Memory) GetHealth(_ context.Context, accountID string) (*domain.AccountHealth, error) {
m.mu.Lock()
defer m.mu.Unlock()
h, ok := m.health[accountID]
if !ok {
return nil, domain.ErrNotFound
}
cp := *h
return &cp, nil
}
func (m *Memory) ListHealth(_ context.Context, ownerUID int64) ([]*domain.AccountHealth, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.AccountHealth
for _, h := range m.health {
if h.OwnerUID == ownerUID {
cp := *h
out = append(out, &cp)
}
}
return out, nil
}
func (m *Memory) SaveWorkspace(_ context.Context, w *domain.Workspace) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *w
m.workspaces[w.ID] = &cp
return nil
}
func (m *Memory) GetWorkspace(_ context.Context, id string) (*domain.Workspace, error) {
m.mu.Lock()
defer m.mu.Unlock()
w, ok := m.workspaces[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *w
return &cp, nil
}
func (m *Memory) ListWorkspaces(_ context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.Workspace
for _, w := range m.workspaces {
if w.OwnerUID != ownerUID {
continue
}
if w.Archived && !includeArchived {
continue
}
cp := *w
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil
}
func (m *Memory) CountActiveWorkspaces(_ context.Context, ownerUID int64) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var n int64
for _, w := range m.workspaces {
if w.OwnerUID == ownerUID && !w.Archived {
n++
}
}
return n, nil
}
func (m *Memory) GetMemberWSState(_ context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.wsState[ownerUID]
if !ok {
return nil, domain.ErrNotFound
}
cp := *s
return &cp, nil
}
func (m *Memory) SaveMemberWSState(_ context.Context, s *domain.MemberWorkspaceState) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *s
m.wsState[s.OwnerUID] = &cp
return nil
}
func (m *Memory) SaveReview(_ context.Context, r *domain.DraftReview) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *r
m.reviews[r.ID] = &cp
return nil
}
func (m *Memory) GetReview(_ context.Context, id string) (*domain.DraftReview, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.reviews[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *r
return &cp, nil
}
func (m *Memory) FindReviewByRef(_ context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) {
m.mu.Lock()
defer m.mu.Unlock()
var best *domain.DraftReview
for _, r := range m.reviews {
if r.OwnerUID == ownerUID && r.RefType == refType && r.RefID == refID {
if best == nil || r.UpdatedAt > best.UpdatedAt {
cp := *r
best = &cp
}
}
}
if best == nil {
return nil, domain.ErrNotFound
}
return best, nil
}
func (m *Memory) ListReviews(_ context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var all []*domain.DraftReview
for _, r := range m.reviews {
if r.OwnerUID != ownerUID {
continue
}
if status != "" && r.Status != status {
continue
}
cp := *r
all = append(all, &cp)
}
total := int64(len(all))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= len(all) {
return nil, total, nil
}
end := start + pageSize
if end > len(all) {
end = len(all)
}
return all[start:end], total, nil
}
func (m *Memory) SaveInviteReward(_ context.Context, r *domain.InviteReward) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *r
m.rewards[r.ID] = &cp
return nil
}
func (m *Memory) ListInviteRewards(_ context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) {
m.mu.Lock()
defer m.mu.Unlock()
var all []*domain.InviteReward
for _, r := range m.rewards {
if r.InviterUID == inviterUID {
cp := *r
all = append(all, &cp)
}
}
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}
func (m *Memory) SumInviteRewardPointsMonth(_ context.Context, inviterUID int64, monthStartNano int64) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
sum := 0
for _, r := range m.rewards {
if r.InviterUID == inviterUID && r.Status == domain.RewardCredited && r.CreatedAt >= monthStartNano {
sum += r.Points
}
}
return sum, nil
}
func (m *Memory) SumInviteRewardPointsTotal(_ context.Context, inviterUID int64) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
sum := 0
for _, r := range m.rewards {
if r.InviterUID == inviterUID && r.Status == domain.RewardCredited {
sum += r.Points
}
}
return sum, nil
}
func (m *Memory) HasInviteRewardForInvitee(_ context.Context, inviteeUID int64) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, r := range m.rewards {
if r.InviteeUID == inviteeUID && (r.Status == domain.RewardCredited || r.Status == domain.RewardCapped) {
return true, nil
}
}
return false, nil
}
// Ensure compile-time interface
var _ domain.Repository = (*Memory)(nil)
// MonthStartNano helper for tests
func MonthStartNano(t time.Time) int64 {
u := t.UTC()
return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano()
}
// --- P2 ---
func (m *Memory) SavePlaybook(_ context.Context, p *domain.Playbook) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.playbooks == nil {
m.playbooks = map[string]*domain.Playbook{}
}
cp := *p
m.playbooks[p.ID] = &cp
return nil
}
func (m *Memory) GetPlaybook(_ context.Context, id string) (*domain.Playbook, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.playbooks[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *p
return &cp, nil
}
func (m *Memory) ListPlaybooks(_ context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var all []*domain.Playbook
for _, p := range m.playbooks {
if kind != "" && p.Kind != kind {
continue
}
if niche != "" && !stringsContainsFold(p.Niche, niche) {
continue
}
if ownerOnlyUID > 0 && p.OwnerUID != ownerOnlyUID {
continue
}
cp := *p
all = append(all, &cp)
}
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
total := int64(len(all))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= len(all) {
return nil, total, nil
}
end := start + pageSize
if end > len(all) {
end = len(all)
}
return all[start:end], total, nil
}
func stringsContainsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
}
func (m *Memory) DeletePlaybook(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.playbooks[id]; !ok {
return domain.ErrNotFound
}
delete(m.playbooks, id)
return nil
}
func (m *Memory) IncPlaybookImport(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.playbooks[id]
if !ok {
return domain.ErrNotFound
}
p.ImportCount++
return nil
}
func (m *Memory) SaveUtmLink(_ context.Context, u *domain.UtmLink) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.utm == nil {
m.utm = map[string]*domain.UtmLink{}
}
cp := *u
m.utm[u.Code] = &cp
return nil
}
func (m *Memory) GetUtmByCode(_ context.Context, code string) (*domain.UtmLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.utm[code]
if !ok {
return nil, domain.ErrNotFound
}
cp := *u
return &cp, nil
}
func (m *Memory) ListUtmLinks(_ context.Context, ownerUID int64) ([]*domain.UtmLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.UtmLink
for _, u := range m.utm {
if u.OwnerUID == ownerUID {
cp := *u
out = append(out, &cp)
}
}
return out, nil
}
func (m *Memory) IncUtmClick(_ context.Context, code string) (*domain.UtmLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.utm[code]
if !ok {
return nil, domain.ErrNotFound
}
u.Clicks++
cp := *u
return &cp, nil
}
func (m *Memory) SaveWorkspaceMember(_ context.Context, mem *domain.WorkspaceMember) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.wsMembers == nil {
m.wsMembers = map[string]*domain.WorkspaceMember{}
}
cp := *mem
m.wsMembers[mem.ID] = &cp
return nil
}
func (m *Memory) ListWorkspaceMembers(_ context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.WorkspaceMember
for _, mem := range m.wsMembers {
if mem.WorkspaceID == workspaceID {
cp := *mem
out = append(out, &cp)
}
}
return out, nil
}
func (m *Memory) RemoveWorkspaceMember(_ context.Context, workspaceID string, uid int64) error {
m.mu.Lock()
defer m.mu.Unlock()
for id, mem := range m.wsMembers {
if mem.WorkspaceID == workspaceID && mem.UID == uid {
delete(m.wsMembers, id)
return nil
}
}
return domain.ErrNotFound
}
func (m *Memory) IsWorkspaceMember(_ context.Context, workspaceID string, uid int64) (bool, string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if w, ok := m.workspaces[workspaceID]; ok && w.OwnerUID == uid {
return true, domain.WSRoleOwner, nil
}
for _, mem := range m.wsMembers {
if mem.WorkspaceID == workspaceID && mem.UID == uid {
return true, mem.Role, nil
}
}
return false, "", nil
}
func (m *Memory) UpsertBenchmarkSample(_ context.Context, s *domain.BenchmarkSample) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.bench == nil {
m.bench = map[string]*domain.BenchmarkSample{}
}
key := s.Niche + ":" + s.OwnerHash
s.ID = key
cp := *s
m.bench[key] = &cp
return nil
}
func (m *Memory) ListBenchmarkSamples(_ context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []*domain.BenchmarkSample
for _, s := range m.bench {
if niche == "" || s.Niche == niche {
cp := *s
out = append(out, &cp)
}
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}