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 }