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

474 lines
15 KiB
Go
Raw Permalink Normal View History

2026-07-23 05:56:42 +00:00
package repository
import (
"context"
libmongo "apps/backend/internal/lib/mongo"
"apps/backend/internal/module/growth/domain"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MonStore struct {
outcomes *mon.Model
checkups *mon.Model
health *mon.Model
workspaces *mon.Model
wsState *mon.Model
reviews *mon.Model
rewards *mon.Model
playbooks *mon.Model
utm *mon.Model
wsMembers *mon.Model
bench *mon.Model
}
func NewMonStore(uri, database string) *MonStore {
uri = libmongo.MustMongoURI(uri)
return &MonStore{
outcomes: mon.MustNewModel(uri, database, "growth_outcomes"),
checkups: mon.MustNewModel(uri, database, "growth_checkups"),
health: mon.MustNewModel(uri, database, "growth_account_health"),
workspaces: mon.MustNewModel(uri, database, "growth_workspaces"),
wsState: mon.MustNewModel(uri, database, "growth_ws_state"),
reviews: mon.MustNewModel(uri, database, "growth_draft_reviews"),
rewards: mon.MustNewModel(uri, database, "growth_invite_rewards"),
playbooks: mon.MustNewModel(uri, database, "growth_playbooks"),
utm: mon.MustNewModel(uri, database, "growth_utm_links"),
wsMembers: mon.MustNewModel(uri, database, "growth_ws_members"),
bench: mon.MustNewModel(uri, database, "growth_benchmark"),
}
}
func (s *MonStore) UpsertOutcomeBySource(ctx context.Context, e *domain.OutcomeEvent) error {
filter := bson.M{"owner_uid": e.OwnerUID, "source_type": e.SourceType, "source_id": e.SourceID}
var existing domain.OutcomeEvent
err := s.outcomes.FindOne(ctx, &existing, filter)
if err == nil {
// 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。
*e = existing
return nil
}
if err != mon.ErrNotFound {
return err
}
if e.ID == "" {
e.ID = domain.NewID()
}
_, err = s.outcomes.InsertOne(ctx, e)
return err
}
func (s *MonStore) GetOutcome(ctx context.Context, id string) (*domain.OutcomeEvent, error) {
var e domain.OutcomeEvent
err := s.outcomes.FindOne(ctx, &e, bson.M{"_id": id})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &e, nil
}
func (s *MonStore) ListOutcomes(ctx context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) {
q := bson.M{"owner_uid": ownerUID}
if f.Kind != "" {
q["kind"] = f.Kind
}
if f.Confidence != "" {
q["confidence"] = f.Confidence
}
if f.SourceType != "" {
q["source_type"] = f.SourceType
}
if f.From > 0 || f.To > 0 {
rangeQ := bson.M{}
if f.From > 0 {
rangeQ["$gte"] = f.From
}
if f.To > 0 {
rangeQ["$lte"] = f.To
}
q["sent_at"] = rangeQ
}
total, err := s.outcomes.CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
}
page, ps := f.Page, f.PageSize
if page < 1 {
page = 1
}
if ps < 1 {
ps = 20
}
var list []*domain.OutcomeEvent
err = s.outcomes.Find(ctx, &list, q, options.Find().
SetSort(bson.D{{Key: "sent_at", Value: -1}}).
SetSkip(int64((page-1)*ps)).
SetLimit(int64(ps)))
return list, total, err
}
func (s *MonStore) SaveOutcome(ctx context.Context, e *domain.OutcomeEvent) error {
_, err := s.outcomes.UpdateOne(ctx, bson.M{"_id": e.ID}, bson.M{"$set": e}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) ListObservingDue(ctx context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) {
var list []*domain.OutcomeEvent
opts := options.Find().SetSort(bson.D{{Key: "sent_at", Value: 1}})
if limit > 0 {
opts.SetLimit(int64(limit))
}
err := s.outcomes.Find(ctx, &list, bson.M{"status": domain.StatusObserving}, opts)
return list, err
}
func (s *MonStore) SaveCheckup(ctx context.Context, c *domain.WeeklyCheckup) error {
_, err := s.checkups.UpdateOne(ctx, bson.M{"_id": c.ID}, bson.M{"$set": c}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetCheckup(ctx context.Context, id string) (*domain.WeeklyCheckup, error) {
var c domain.WeeklyCheckup
err := s.checkups.FindOne(ctx, &c, bson.M{"_id": id})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &c, err
}
func (s *MonStore) LatestCheckup(ctx context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) {
var list []*domain.WeeklyCheckup
err := s.checkups.Find(ctx, &list, bson.M{"owner_uid": ownerUID},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetLimit(1))
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, domain.ErrNotFound
}
return list[0], nil
}
func (s *MonStore) ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) {
q := bson.M{"owner_uid": ownerUID}
total, err := s.checkups.CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var list []*domain.WeeklyCheckup
err = s.checkups.Find(ctx, &list, q, options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
return list, total, err
}
func (s *MonStore) CountCheckupGenerations(ctx context.Context, ownerUID int64, sinceNano int64) (int64, error) {
return s.checkups.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "created_at": bson.M{"$gte": sinceNano}})
}
func (s *MonStore) SaveHealth(ctx context.Context, h *domain.AccountHealth) error {
_, err := s.health.UpdateOne(ctx, bson.M{"_id": h.ThreadsAccountID}, bson.M{"$set": h}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetHealth(ctx context.Context, accountID string) (*domain.AccountHealth, error) {
var h domain.AccountHealth
err := s.health.FindOne(ctx, &h, bson.M{"_id": accountID})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &h, err
}
func (s *MonStore) ListHealth(ctx context.Context, ownerUID int64) ([]*domain.AccountHealth, error) {
var list []*domain.AccountHealth
err := s.health.Find(ctx, &list, bson.M{"owner_uid": ownerUID})
return list, err
}
func (s *MonStore) SaveWorkspace(ctx context.Context, w *domain.Workspace) error {
_, err := s.workspaces.UpdateOne(ctx, bson.M{"_id": w.ID}, bson.M{"$set": w}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetWorkspace(ctx context.Context, id string) (*domain.Workspace, error) {
var w domain.Workspace
err := s.workspaces.FindOne(ctx, &w, bson.M{"_id": id})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &w, err
}
func (s *MonStore) ListWorkspaces(ctx context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) {
q := bson.M{"owner_uid": ownerUID}
if !includeArchived {
q["archived"] = bson.M{"$ne": true}
}
var list []*domain.Workspace
err := s.workspaces.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
return list, err
}
func (s *MonStore) CountActiveWorkspaces(ctx context.Context, ownerUID int64) (int64, error) {
return s.workspaces.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "archived": bson.M{"$ne": true}})
}
func (s *MonStore) GetMemberWSState(ctx context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) {
var st domain.MemberWorkspaceState
err := s.wsState.FindOne(ctx, &st, bson.M{"_id": ownerUID})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &st, err
}
func (s *MonStore) SaveMemberWSState(ctx context.Context, st *domain.MemberWorkspaceState) error {
_, err := s.wsState.UpdateOne(ctx, bson.M{"_id": st.OwnerUID}, bson.M{"$set": st}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) SaveReview(ctx context.Context, r *domain.DraftReview) error {
_, err := s.reviews.UpdateOne(ctx, bson.M{"_id": r.ID}, bson.M{"$set": r}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetReview(ctx context.Context, id string) (*domain.DraftReview, error) {
var r domain.DraftReview
err := s.reviews.FindOne(ctx, &r, bson.M{"_id": id})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &r, err
}
func (s *MonStore) FindReviewByRef(ctx context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) {
var list []*domain.DraftReview
err := s.reviews.Find(ctx, &list, bson.M{"owner_uid": ownerUID, "ref_type": refType, "ref_id": refID},
options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(1))
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, domain.ErrNotFound
}
return list[0], nil
}
func (s *MonStore) ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) {
q := bson.M{"owner_uid": ownerUID}
if status != "" {
q["status"] = status
}
total, err := s.reviews.CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var list []*domain.DraftReview
err = s.reviews.Find(ctx, &list, q, options.Find().SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
return list, total, err
}
func (s *MonStore) SaveInviteReward(ctx context.Context, r *domain.InviteReward) error {
_, err := s.rewards.InsertOne(ctx, r)
return err
}
func (s *MonStore) ListInviteRewards(ctx context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) {
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
if limit > 0 {
opts.SetLimit(int64(limit))
}
var list []*domain.InviteReward
err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID}, opts)
return list, err
}
func (s *MonStore) SumInviteRewardPointsMonth(ctx context.Context, inviterUID int64, monthStartNano int64) (int, error) {
var list []*domain.InviteReward
err := s.rewards.Find(ctx, &list, bson.M{
"inviter_uid": inviterUID,
"status": domain.RewardCredited,
"created_at": bson.M{"$gte": monthStartNano},
})
if err != nil {
return 0, err
}
sum := 0
for _, r := range list {
sum += r.Points
}
return sum, nil
}
func (s *MonStore) SumInviteRewardPointsTotal(ctx context.Context, inviterUID int64) (int, error) {
var list []*domain.InviteReward
err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID, "status": domain.RewardCredited})
if err != nil {
return 0, err
}
sum := 0
for _, r := range list {
sum += r.Points
}
return sum, nil
}
func (s *MonStore) HasInviteRewardForInvitee(ctx context.Context, inviteeUID int64) (bool, error) {
n, err := s.rewards.CountDocuments(ctx, bson.M{
"invitee_uid": inviteeUID,
"status": bson.M{"$in": []string{domain.RewardCredited, domain.RewardCapped}},
})
return n > 0, err
}
var _ domain.Repository = (*MonStore)(nil)
func (s *MonStore) SavePlaybook(ctx context.Context, p *domain.Playbook) error {
_, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": p.ID}, bson.M{"$set": p}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) {
var p domain.Playbook
err := s.playbooks.FindOne(ctx, &p, bson.M{"_id": id})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &p, err
}
func (s *MonStore) ListPlaybooks(ctx context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) {
q := bson.M{}
if kind != "" {
q["kind"] = kind
}
if niche != "" {
q["niche"] = bson.M{"$regex": niche, "$options": "i"}
}
if ownerOnlyUID > 0 {
q["owner_uid"] = ownerOnlyUID
}
total, err := s.playbooks.CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var list []*domain.Playbook
err = s.playbooks.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
return list, total, err
}
func (s *MonStore) DeletePlaybook(ctx context.Context, id string) error {
res, err := s.playbooks.DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
return err
}
if res == 0 {
return domain.ErrNotFound
}
return nil
}
func (s *MonStore) IncPlaybookImport(ctx context.Context, id string) error {
_, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$inc": bson.M{"import_count": 1}})
return err
}
func (s *MonStore) SaveUtmLink(ctx context.Context, u *domain.UtmLink) error {
_, err := s.utm.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{"$set": u}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) GetUtmByCode(ctx context.Context, code string) (*domain.UtmLink, error) {
var u domain.UtmLink
err := s.utm.FindOne(ctx, &u, bson.M{"code": code})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
return &u, err
}
func (s *MonStore) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) {
var list []*domain.UtmLink
err := s.utm.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
return list, err
}
func (s *MonStore) IncUtmClick(ctx context.Context, code string) (*domain.UtmLink, error) {
_, err := s.utm.UpdateOne(ctx, bson.M{"code": code}, bson.M{"$inc": bson.M{"clicks": 1}})
if err != nil {
return nil, err
}
return s.GetUtmByCode(ctx, code)
}
func (s *MonStore) SaveWorkspaceMember(ctx context.Context, m *domain.WorkspaceMember) error {
_, err := s.wsMembers.UpdateOne(ctx, bson.M{"_id": m.ID}, bson.M{"$set": m}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) {
var list []*domain.WorkspaceMember
err := s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID})
return list, err
}
func (s *MonStore) RemoveWorkspaceMember(ctx context.Context, workspaceID string, uid int64) error {
res, err := s.wsMembers.DeleteOne(ctx, bson.M{"workspace_id": workspaceID, "uid": uid})
if err != nil {
return err
}
if res == 0 {
return domain.ErrNotFound
}
return nil
}
func (s *MonStore) IsWorkspaceMember(ctx context.Context, workspaceID string, uid int64) (bool, string, error) {
w, err := s.GetWorkspace(ctx, workspaceID)
if err == nil && w.OwnerUID == uid {
return true, domain.WSRoleOwner, nil
}
var list []*domain.WorkspaceMember
err = s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID, "uid": uid}, options.Find().SetLimit(1))
if err != nil {
return false, "", err
}
if len(list) == 0 {
return false, "", nil
}
return true, list[0].Role, nil
}
func (s *MonStore) UpsertBenchmarkSample(ctx context.Context, sample *domain.BenchmarkSample) error {
if sample.ID == "" {
sample.ID = sample.Niche + ":" + sample.OwnerHash
}
_, err := s.bench.UpdateOne(ctx, bson.M{"_id": sample.ID}, bson.M{"$set": sample}, options.Update().SetUpsert(true))
return err
}
func (s *MonStore) ListBenchmarkSamples(ctx context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) {
q := bson.M{}
if niche != "" {
q["niche"] = niche
}
opts := options.Find()
if limit > 0 {
opts.SetLimit(int64(limit))
}
var list []*domain.BenchmarkSample
err := s.bench.Find(ctx, &list, q, opts)
return list, err
}