package usecase import ( "context" "reflect" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" brandentity "haixun-backend/internal/model/brand/domain/entity" brandrepo "haixun-backend/internal/model/brand/domain/repository" kgdomusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" "haixun-backend/internal/model/placement_topic/domain/entity" domrepo "haixun-backend/internal/model/placement_topic/domain/repository" domusecase "haixun-backend/internal/model/placement_topic/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/domain/repository" "github.com/google/uuid" ) type topicUseCase struct { repo domrepo.Repository brandRepo brandrepo.Repository kg kgdomusecase.UseCase scanRepo scanpostrepo.Repository } func NewUseCase( repo domrepo.Repository, brandRepo brandrepo.Repository, kg kgdomusecase.UseCase, scanRepo scanpostrepo.Repository, ) domusecase.UseCase { return &topicUseCase{repo: repo, brandRepo: brandRepo, kg: kg, scanRepo: scanRepo} } func (u *topicUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil { return nil, err } items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) if err != nil { return nil, err } brandNames, err := u.brandDisplayNames(ctx, tenantID, ownerUID) if err != nil { return nil, err } list := make([]domusecase.TopicSummary, 0, len(items)) for _, item := range items { list = append(list, toSummary(item, brandNames[item.BrandID])) } return &domusecase.ListResult{List: list}, nil } func (u *topicUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.TopicSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err } brandID := strings.TrimSpace(req.BrandID) if brandID == "" { return nil, app.For(code.Brand).InputMissingRequired("brand_id is required") } topicName := strings.TrimSpace(req.TopicName) seedQuery := strings.TrimSpace(req.SeedQuery) brief := strings.TrimSpace(req.Brief) if topicName == "" { return nil, app.For(code.Brand).InputMissingRequired("topic_name is required") } if seedQuery == "" || brief == "" { return nil, app.For(code.Brand).InputMissingRequired("seed_query and brief are required") } brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID) if err != nil { return nil, err } productID := strings.TrimSpace(req.ProductID) if productID != "" && !brandHasProduct(brand, productID) { return nil, app.For(code.Brand).InputMissingRequired("product not found on brand") } topic := &entity.Topic{ ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, BrandID: brandID, TopicName: topicName, SeedQuery: seedQuery, Brief: brief, ProductID: productID, Status: entity.StatusOpen, } item, err := u.repo.Create(ctx, topic) if err != nil { return nil, err } summary := toSummary(item, brand.DisplayName) return &summary, nil } func (u *topicUseCase) Get(ctx context.Context, tenantID, ownerUID, topicID string) (*domusecase.TopicSummary, error) { if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil { return nil, err } item, err := u.assertOwned(ctx, tenantID, ownerUID, topicID) if err != nil { return nil, err } displayName := "" var brand *brandentity.Brand if loaded, err := u.brandRepo.FindByID(ctx, tenantID, ownerUID, item.BrandID); err == nil && loaded != nil { brand = loaded displayName = brand.DisplayName } summary := toSummary(item, displayName) if summary.ResearchMap.IsEmpty() && brand != nil && !brand.ResearchMap.IsEmpty() { summary.ResearchMap = brand.ResearchMap } return &summary, nil } func (u *topicUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.TopicSummary, error) { topic, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.TopicID) if err != nil { return nil, err } if req.Patch.BrandID != nil { brandID := strings.TrimSpace(*req.Patch.BrandID) if brandID == "" { return nil, app.For(code.Brand).InputMissingRequired("brand_id is required") } if _, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID); err != nil { return nil, err } } if req.Patch.ProductID != nil { productID := strings.TrimSpace(*req.Patch.ProductID) if productID != "" { brandID := topic.BrandID if req.Patch.BrandID != nil { brandID = strings.TrimSpace(*req.Patch.BrandID) } brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID) if err != nil { return nil, err } if !brandHasProduct(brand, productID) { return nil, app.For(code.Brand).InputMissingRequired("product not found on brand") } } } patch := patchToMap(req.Patch) item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.TopicID, patch) if err != nil { return nil, err } displayName := "" if brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, item.BrandID); err == nil && brand != nil { displayName = brand.DisplayName } summary := toSummary(item, displayName) return &summary, nil } func (u *topicUseCase) Delete(ctx context.Context, tenantID, ownerUID, topicID string) error { if _, err := u.assertOwned(ctx, tenantID, ownerUID, topicID); err != nil { return err } return u.repo.SoftDelete(ctx, tenantID, ownerUID, topicID) } func (u *topicUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if strings.TrimSpace(topicID) == "" { return nil, app.For(code.Brand).InputMissingRequired("topic id is required") } return u.repo.FindByID(ctx, tenantID, ownerUID, topicID) } func (u *topicUseCase) migrateLegacyBrands(ctx context.Context, tenantID, ownerUID string) error { if u.brandRepo == nil { return nil } brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID) if err != nil { return err } for _, brand := range brands { if !legacyBrandHasTopicSignals(brand) { continue } existing, err := u.repo.ListByBrand(ctx, tenantID, ownerUID, brand.ID) if err != nil { return err } if legacyTopicAlreadyMigrated(brand, existing) { continue } topic := &entity.Topic{ ID: uuid.NewString(), TenantID: tenantID, OwnerUID: ownerUID, BrandID: brand.ID, TopicName: legacyTopicName(brand), SeedQuery: strings.TrimSpace(brand.SeedQuery), Brief: strings.TrimSpace(brand.Brief), ProductID: strings.TrimSpace(brand.ProductID), ResearchMap: brand.ResearchMap, Status: entity.StatusOpen, } created, err := u.repo.Create(ctx, topic) if err != nil { return err } if u.kg != nil { _ = u.kg.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID) } if u.scanRepo != nil { _ = u.scanRepo.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID) } _, _ = u.brandRepo.Update(ctx, tenantID, ownerUID, brand.ID, map[string]interface{}{ "topic_name": "", "seed_query": "", "brief": "", "product_id": "", "research_map": brandentity.ResearchMap{}, }) } return nil } func (u *topicUseCase) brandDisplayNames(ctx context.Context, tenantID, ownerUID string) (map[string]string, error) { brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID) if err != nil { return nil, err } out := make(map[string]string, len(brands)) for _, brand := range brands { out[brand.ID] = brand.DisplayName } return out, nil } func legacyBrandHasTopicSignals(brand *brandentity.Brand) bool { if brand == nil { return false } return strings.TrimSpace(brand.TopicName) != "" || strings.TrimSpace(brand.SeedQuery) != "" || strings.TrimSpace(brand.Brief) != "" || !brand.ResearchMap.IsEmpty() } func legacyTopicAlreadyMigrated(brand *brandentity.Brand, existing []*entity.Topic) bool { if brand == nil { return true } legacyName := strings.TrimSpace(brand.TopicName) legacySeed := strings.TrimSpace(brand.SeedQuery) legacyBrief := strings.TrimSpace(brand.Brief) legacyProductID := strings.TrimSpace(brand.ProductID) for _, item := range existing { if item == nil { continue } sameFields := strings.TrimSpace(item.TopicName) == legacyName && strings.TrimSpace(item.SeedQuery) == legacySeed && strings.TrimSpace(item.Brief) == legacyBrief && strings.TrimSpace(item.ProductID) == legacyProductID if sameFields { return true } if legacyName == "" && legacySeed == "" && legacyBrief == "" && !brand.ResearchMap.IsEmpty() && reflect.DeepEqual(item.ResearchMap, brand.ResearchMap) { return true } } return false } func legacyTopicName(brand *brandentity.Brand) string { if brand == nil { return "" } if name := strings.TrimSpace(brand.TopicName); name != "" { return name } for _, pillar := range brand.ResearchMap.Pillars { if name := strings.TrimSpace(pillar); name != "" { return name } } for _, question := range brand.ResearchMap.Questions { if name := strings.TrimSpace(question); name != "" { return name } } return strings.TrimSpace(brand.DisplayName) } func brandHasProduct(brand *brandentity.Brand, productID string) bool { if brand == nil { return false } for _, product := range brand.Products { if product.ID == productID { return true } } return false } func requireActor(tenantID, ownerUID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required") } return nil } func toSummary(item *entity.Topic, brandDisplayName string) domusecase.TopicSummary { if item == nil { return domusecase.TopicSummary{} } return domusecase.TopicSummary{ ID: item.ID, BrandID: item.BrandID, BrandDisplayName: brandDisplayName, TopicName: item.TopicName, SeedQuery: item.SeedQuery, Brief: item.Brief, ProductID: item.ProductID, ResearchMap: item.ResearchMap, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, } } func patchToMap(patch domusecase.TopicPatch) map[string]interface{} { out := map[string]interface{}{} if patch.BrandID != nil { out["brand_id"] = strings.TrimSpace(*patch.BrandID) } if patch.TopicName != nil { out["topic_name"] = strings.TrimSpace(*patch.TopicName) } if patch.SeedQuery != nil { out["seed_query"] = strings.TrimSpace(*patch.SeedQuery) } if patch.Brief != nil { out["brief"] = strings.TrimSpace(*patch.Brief) } if patch.ProductID != nil { out["product_id"] = strings.TrimSpace(*patch.ProductID) } if patch.AudienceSummary != nil { out["research_map.audience_summary"] = strings.TrimSpace(*patch.AudienceSummary) } if patch.ContentGoal != nil { out["research_map.content_goal"] = strings.TrimSpace(*patch.ContentGoal) } if patch.QuestionsSet { out["research_map.questions"] = cleanStringList(patch.Questions) } if patch.PillarsSet { out["research_map.pillars"] = cleanStringList(patch.Pillars) } if patch.ExclusionsSet { out["research_map.exclusions"] = cleanStringList(patch.Exclusions) } if patch.ResearchMap != nil { out["research_map"] = *patch.ResearchMap } if patch.PatrolKeywordsSet { out["research_map.patrol_keywords"] = libkg.NormalizePatrolKeywordList(patch.PatrolKeywords) } return out } func cleanStringList(items []string) []string { out := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { trimmed := strings.TrimSpace(item) if trimmed == "" { continue } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } return out }