401 lines
12 KiB
Go
401 lines
12 KiB
Go
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
|
|
}
|