haixunMaster/haixun-backend/internal/model/placement_topic/usecase/usecase.go

401 lines
12 KiB
Go
Raw Normal View History

2026-06-24 16:48:56 +00:00
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) {
2026-06-25 08:20:03 +00:00
if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil {
return nil, err
}
2026-06-24 16:48:56 +00:00
item, err := u.assertOwned(ctx, tenantID, ownerUID, topicID)
if err != nil {
return nil, err
}
displayName := ""
2026-06-25 08:20:03 +00:00
var brand *brandentity.Brand
if loaded, err := u.brandRepo.FindByID(ctx, tenantID, ownerUID, item.BrandID); err == nil && loaded != nil {
brand = loaded
2026-06-24 16:48:56 +00:00
displayName = brand.DisplayName
}
summary := toSummary(item, displayName)
2026-06-25 08:20:03 +00:00
if summary.ResearchMap.IsEmpty() && brand != nil && !brand.ResearchMap.IsEmpty() {
summary.ResearchMap = brand.ResearchMap
}
2026-06-24 16:48:56 +00:00
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
}