package repository import ( "context" libmongo "apps/backend/internal/lib/mongo" "apps/backend/internal/module/scout/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 { brands *mon.Model products *mon.Model active *mon.Model posts *mon.Model runs *mon.Model seen *mon.Model hw *mon.Model crawler *mon.Model } func NewMonStore(uri, database string) *MonStore { uri = libmongo.MustMongoURI(uri) return &MonStore{ brands: mon.MustNewModel(uri, database, "scout_brands"), products: mon.MustNewModel(uri, database, "scout_products"), active: mon.MustNewModel(uri, database, "scout_active_brand"), posts: mon.MustNewModel(uri, database, "scout_posts"), runs: mon.MustNewModel(uri, database, "scout_runs"), seen: mon.MustNewModel(uri, database, "scout_seen_identities"), hw: mon.MustNewModel(uri, database, "scout_homework"), crawler: mon.MustNewModel(uri, database, "scout_crawler_session"), } } func (s *MonStore) SaveBrand(ctx context.Context, b *domain.Brand) error { _, err := s.brands.ReplaceOne(ctx, bson.M{"_id": b.ID}, b, options.Replace().SetUpsert(true)) return err } func (s *MonStore) GetBrand(ctx context.Context, id string) (*domain.Brand, error) { var b domain.Brand err := s.brands.FindOne(ctx, &b, bson.M{"_id": id}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } return &b, nil } func (s *MonStore) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) { var list []*domain.Brand err := s.brands.Find(ctx, &list, bson.M{"owner_uid": ownerUID}) return list, err } func (s *MonStore) DeleteBrand(ctx context.Context, id string) error { res, err := s.brands.DeleteOne(ctx, bson.M{"_id": id}) if err != nil { return err } if res == 0 { return domain.ErrNotFound } return nil } func (s *MonStore) SaveProduct(ctx context.Context, p *domain.Product) error { _, err := s.products.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) return err } func (s *MonStore) GetProduct(ctx context.Context, id string) (*domain.Product, error) { var p domain.Product err := s.products.FindOne(ctx, &p, bson.M{"_id": id}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } return &p, nil } func (s *MonStore) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) { filter := bson.M{"owner_uid": ownerUID} if brandID != "" { filter["brand_id"] = brandID } var list []*domain.Product err := s.products.Find(ctx, &list, filter) return list, err } func (s *MonStore) DeleteProduct(ctx context.Context, id string) error { res, err := s.products.DeleteOne(ctx, bson.M{"_id": id}) if err != nil { return err } if res == 0 { return domain.ErrNotFound } return nil } func (s *MonStore) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) { var a domain.ActiveBrand err := s.active.FindOne(ctx, &a, bson.M{"_id": ownerUID}) if err != nil { if err == mon.ErrNotFound { return "", nil } return "", err } return a.BrandID, nil } func (s *MonStore) SetActiveBrandID(ctx context.Context, ownerUID int64, brandID string) error { doc := &domain.ActiveBrand{OwnerUID: ownerUID, BrandID: brandID, UpdatedAt: domain.NowNano()} _, err := s.active.ReplaceOne(ctx, bson.M{"_id": ownerUID}, doc, options.Replace().SetUpsert(true)) return err } func (s *MonStore) SavePost(ctx context.Context, p *domain.Post) error { _, err := s.posts.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) return err } func (s *MonStore) GetPost(ctx context.Context, id string) (*domain.Post, error) { var p domain.Post err := s.posts.FindOne(ctx, &p, bson.M{"_id": id}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } return &p, nil } func (s *MonStore) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { filter := bson.M{"owner_uid": ownerUID} if brandID != "" { filter["brand_id"] = brandID } var list []*domain.Post // score 是品質主排序;時間只作相同分數的 deterministic tie-breaker。 err := s.posts.Find(ctx, &list, filter, options.Find().SetSort(bson.D{ {Key: "score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}, })) return list, err } func (s *MonStore) DeletePost(ctx context.Context, id string) error { res, err := s.posts.DeleteOne(ctx, bson.M{"_id": id}) if err != nil { return err } if res == 0 { return domain.ErrNotFound } return nil } func (s *MonStore) DeletePostsByTheme(ctx context.Context, ownerUID int64, themeKey string) error { _, err := s.posts.DeleteMany(ctx, bson.M{"owner_uid": ownerUID, "theme_key": themeKey}) return err } func (s *MonStore) SaveHomework(ctx context.Context, h *domain.Homework) error { // composite: store theme as id, filter by owner docID := formatKey(h.OwnerUID, h.ThemeKey) type hwDoc struct { ID string `bson:"_id"` *domain.Homework } _, err := s.hw.ReplaceOne(ctx, bson.M{"_id": docID}, &hwDoc{ID: docID, Homework: h}, options.Replace().SetUpsert(true)) return err } func (s *MonStore) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) { type hwDoc struct { ID string `bson:"_id"` domain.Homework `bson:",inline"` } var doc hwDoc err := s.hw.FindOne(ctx, &doc, bson.M{"_id": formatKey(ownerUID, themeKey)}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } h := doc.Homework return &h, nil } func (s *MonStore) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) { var list []*domain.Homework err := s.hw.Find(ctx, &list, bson.M{"owner_uid": ownerUID}) return list, err } func (s *MonStore) DeleteHomework(ctx context.Context, ownerUID int64, themeKey string) error { res, err := s.hw.DeleteOne(ctx, bson.M{"_id": formatKey(ownerUID, themeKey)}) if err != nil { return err } if res == 0 { return domain.ErrNotFound } return nil } func (s *MonStore) GetCrawlerSession(ctx context.Context, ownerUID int64) (*domain.CrawlerSession, error) { var c domain.CrawlerSession err := s.crawler.FindOne(ctx, &c, bson.M{"_id": ownerUID}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } if c.StorageStateEnc == "" { return nil, domain.ErrNotFound } return &c, nil } func (s *MonStore) SetCrawlerSession(ctx context.Context, sess *domain.CrawlerSession) error { _, err := s.crawler.ReplaceOne(ctx, bson.M{"_id": sess.OwnerUID}, sess, options.Replace().SetUpsert(true)) return err } func (s *MonStore) ClearCrawlerSession(ctx context.Context, ownerUID int64) error { _, err := s.crawler.DeleteOne(ctx, bson.M{"_id": ownerUID}) return err } func (s *MonStore) CreateRun(ctx context.Context, r *domain.Run) error { if r == nil || r.ID == "" || r.OwnerUID == 0 { return domain.ErrValidation } _, err := s.runs.InsertOne(ctx, r) return err } func (s *MonStore) GetRun(ctx context.Context, ownerUID int64, id string) (*domain.Run, error) { var r domain.Run err := s.runs.FindOne(ctx, &r, bson.M{"_id": id, "owner_uid": ownerUID}) if err != nil { if err == mon.ErrNotFound { return nil, domain.ErrNotFound } return nil, err } return &r, nil } func (s *MonStore) ListRuns(ctx context.Context, filter domain.RunFilter) (domain.RunPage, error) { page := domain.NormalizePage(filter.Page, filter.PageSize) q := bson.M{"owner_uid": filter.OwnerUID} if filter.BrandID != "" { q["brand_id"] = filter.BrandID } if filter.Mode != "" { q["mode"] = filter.Mode } total, err := s.runs.CountDocuments(ctx, q) if err != nil { return domain.RunPage{}, err } page = page.WithTotal(total) var list []*domain.Run err = s.runs.Find(ctx, &list, q, options.Find().SetSort(bson.D{ {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}, }).SetSkip(int64((page.Page-1)*page.PageSize)).SetLimit(int64(page.PageSize))) return domain.RunPage{Items: list, Pagination: page}, err } func (s *MonStore) ListRunPosts(ctx context.Context, ownerUID int64, runID string, requestedPage, requestedSize int) (domain.RunPostPage, error) { r, err := s.GetRun(ctx, ownerUID, runID) if err != nil { return domain.RunPostPage{}, err } page := domain.NormalizePage(requestedPage, requestedSize) q := bson.M{"owner_uid": ownerUID, "run_id": runID} total, err := s.posts.CountDocuments(ctx, q) if err != nil { return domain.RunPostPage{}, err } page = page.WithTotal(total) var list []*domain.Post err = s.posts.Find(ctx, &list, q, options.Find().SetSort(bson.D{ {Key: "score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}, }).SetSkip(int64((page.Page-1)*page.PageSize)).SetLimit(int64(page.PageSize))) return domain.RunPostPage{Run: r, Items: list, Pagination: page}, err } func (s *MonStore) ReplaceRunGuarded(ctx context.Context, ownerUID int64, id string, expected []string, replacement *domain.Run) error { if replacement == nil || replacement.ID != id { return domain.ErrValidation } filter := bson.M{"_id": id, "owner_uid": ownerUID} if len(expected) > 0 { filter["status"] = bson.M{"$in": expected} } res, err := s.runs.ReplaceOne(ctx, filter, replacement) if err != nil { return err } if res.MatchedCount == 0 { if _, getErr := s.GetRun(ctx, ownerUID, id); getErr != nil { return getErr } return domain.ErrIllegalRunStatus } return nil } func (s *MonStore) DeleteRun(ctx context.Context, ownerUID int64, id string) error { res, err := s.runs.DeleteOne(ctx, bson.M{"_id": id, "owner_uid": ownerUID}) if err != nil { return err } if res == 0 { return domain.ErrNotFound } _, err = s.posts.DeleteMany(ctx, bson.M{"owner_uid": ownerUID, "run_id": id}) return err } func (s *MonStore) PublishRunPosts(ctx context.Context, ownerUID int64, runID string, posts []*domain.Post) error { if _, err := s.GetRun(ctx, ownerUID, runID); err != nil { return err } for _, p := range posts { if p == nil || p.ID == "" || p.OwnerUID != ownerUID || p.RunID != runID { return domain.ErrValidation } } for _, p := range posts { if _, err := s.posts.ReplaceOne(ctx, bson.M{"_id": p.ID, "owner_uid": ownerUID}, p, options.Replace().SetUpsert(true)); err != nil { return err } } return nil } func (s *MonStore) HasSeenIdentity(ctx context.Context, ownerUID int64, identity string) (bool, error) { if identity == "" { return false, domain.ErrValidation } var doc struct { ID string `bson:"_id"` } err := s.seen.FindOne(ctx, &doc, bson.M{"_id": identityKey(ownerUID, identity)}) if err == mon.ErrNotFound { return false, nil } return err == nil, err } func (s *MonStore) MarkSeenIdentity(ctx context.Context, ownerUID int64, identity, postID string, seenAt int64) error { if identity == "" { return domain.ErrValidation } doc := bson.M{"_id": identityKey(ownerUID, identity), "owner_uid": ownerUID, "identity": identity, "post_id": postID, "seen_at": seenAt} _, err := s.seen.ReplaceOne(ctx, bson.M{"_id": identityKey(ownerUID, identity)}, doc, options.Replace().SetUpsert(true)) return err } func formatKey(uid int64, theme string) string { return formatUID(uid) + "|" + theme } var _ domain.Repository = (*MonStore)(nil)