thread-master/old/backend/internal/model/copy_mission/usecase/usecase.go

405 lines
13 KiB
Go

package usecase
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/copy_mission/domain/entity"
domrepo "haixun-backend/internal/model/copy_mission/domain/repository"
domusecase "haixun-backend/internal/model/copy_mission/domain/usecase"
"github.com/google/uuid"
)
type missionUseCase struct {
repo domrepo.Repository
}
func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
return &missionUseCase{repo: repo}
}
func (u *missionUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) (*domusecase.ListResult, error) {
if err := requireActor(tenantID, ownerUID, personaID); err != nil {
return nil, err
}
items, err := u.repo.ListByPersona(ctx, tenantID, ownerUID, personaID)
if err != nil {
return nil, err
}
list := make([]domusecase.MissionSummary, 0, len(items))
for _, item := range items {
list = append(list, toSummary(item))
}
return &domusecase.ListResult{List: list}, nil
}
func (u *missionUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.MissionSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
label := strings.TrimSpace(req.Label)
seedQuery := strings.TrimSpace(req.SeedQuery)
brief := strings.TrimSpace(req.Brief)
if label == "" {
return nil, app.For(code.Persona).InputMissingRequired("label is required")
}
if seedQuery == "" {
return nil, app.For(code.Persona).InputMissingRequired("seed_query is required")
}
if brief == "" {
return nil, app.For(code.Persona).InputMissingRequired("brief is required")
}
mission := &entity.Mission{
ID: uuid.NewString(),
TenantID: req.TenantID,
OwnerUID: req.OwnerUID,
PersonaID: strings.TrimSpace(req.PersonaID),
Label: label,
SeedQuery: seedQuery,
Brief: brief,
Status: entity.StatusOpen,
}
if req.InitialResearchMap != nil {
mission.ResearchMap = *req.InitialResearchMap
}
if len(req.InitialSelectedTags) > 0 {
mission.SelectedTags = cleanTags(req.InitialSelectedTags)
}
item, err := u.repo.Create(ctx, mission)
if err != nil {
return nil, err
}
summary := toSummary(item)
return &summary, nil
}
func (u *missionUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*domusecase.MissionSummary, error) {
if err := requireActor(tenantID, ownerUID, personaID); err != nil {
return nil, err
}
item, err := u.repo.FindByID(ctx, tenantID, ownerUID, personaID, missionID)
if err != nil {
return nil, err
}
summary := toSummary(item)
return &summary, nil
}
func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.MissionSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
patch := map[string]interface{}{}
if req.Patch.Label != nil {
label := strings.TrimSpace(*req.Patch.Label)
if label == "" {
return nil, app.For(code.Persona).InputMissingRequired("label cannot be empty")
}
patch["label"] = label
}
if req.Patch.SeedQuery != nil {
seed := strings.TrimSpace(*req.Patch.SeedQuery)
if seed == "" {
return nil, app.For(code.Persona).InputMissingRequired("seed_query cannot be empty")
}
patch["seed_query"] = seed
}
if req.Patch.Brief != nil {
brief := strings.TrimSpace(*req.Patch.Brief)
if brief == "" {
return nil, app.For(code.Persona).InputMissingRequired("brief cannot be empty")
}
patch["brief"] = brief
}
if req.Patch.AudienceSummary != nil {
patch["research_map.audience_summary"] = strings.TrimSpace(*req.Patch.AudienceSummary)
}
if req.Patch.ContentGoal != nil {
patch["research_map.content_goal"] = strings.TrimSpace(*req.Patch.ContentGoal)
}
if req.Patch.QuestionsSet {
patch["research_map.questions"] = cleanStringList(req.Patch.Questions)
}
if req.Patch.PillarsSet {
patch["research_map.pillars"] = cleanStringList(req.Patch.Pillars)
}
if req.Patch.ExclusionsSet {
patch["research_map.exclusions"] = cleanStringList(req.Patch.Exclusions)
}
if req.Patch.KnowledgeItemsSet {
patch["research_map.knowledge_items"] = cleanStringList(req.Patch.KnowledgeItems)
}
if req.Patch.SelectedKnowledgeItemsSet {
patch["research_map.selected_knowledge_items"] = cleanStringList(req.Patch.SelectedKnowledgeItems)
}
if req.Patch.BenchmarkNotes != nil {
patch["research_map.benchmark_notes"] = strings.TrimSpace(*req.Patch.BenchmarkNotes)
}
if req.Patch.SelectedTagsSet {
patch["selected_tags"] = cleanTags(req.Patch.SelectedTags)
}
if req.Patch.ResearchMap != nil {
patch["research_map"] = *req.Patch.ResearchMap
}
if req.Patch.LastScanJobID != nil {
patch["last_scan_job_id"] = strings.TrimSpace(*req.Patch.LastScanJobID)
}
if req.Patch.Status != nil {
patch["status"] = *req.Patch.Status
}
item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, patch)
if err != nil {
return nil, err
}
summary := toSummary(item)
return &summary, nil
}
func (u *missionUseCase) PatchSimilarAccount(ctx context.Context, req domusecase.PatchSimilarAccountRequest) (*domusecase.MissionSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
status, err := validateSimilarAccountStatus(req.Status)
if err != nil {
return nil, err
}
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
if username == "" {
return nil, app.For(code.Facade).InputMissingRequired("username is required")
}
item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
if err != nil {
return nil, err
}
found := false
accounts := append([]entity.SimilarAccount(nil), item.ResearchMap.SimilarAccounts...)
for i := range accounts {
if strings.EqualFold(strings.TrimSpace(accounts[i].Username), username) {
accounts[i].Status = status
found = true
break
}
}
if !found {
return nil, app.For(code.Facade).ResNotFound("similar account not found")
}
updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
"research_map.similar_accounts": accounts,
})
if err != nil {
return nil, err
}
summary := toSummary(updated)
return &summary, nil
}
func (u *missionUseCase) PatchAudienceSample(ctx context.Context, req domusecase.PatchAudienceSampleRequest) (*domusecase.MissionSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
status, err := validateAudienceSampleStatus(req.Status)
if err != nil {
return nil, err
}
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
if username == "" {
return nil, app.For(code.Facade).InputMissingRequired("username is required")
}
item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
if err != nil {
return nil, err
}
found := false
samples := append([]entity.AudienceSample(nil), item.ResearchMap.AudienceSamples...)
for i := range samples {
if strings.EqualFold(strings.TrimSpace(samples[i].Username), username) {
samples[i].Status = status
found = true
break
}
}
if !found {
return nil, app.For(code.Facade).ResNotFound("audience sample not found")
}
updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
"research_map.audience_samples": samples,
})
if err != nil {
return nil, err
}
summary := toSummary(updated)
return &summary, nil
}
func (u *missionUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
if err := requireActor(tenantID, ownerUID, personaID); err != nil {
return err
}
return u.repo.Delete(ctx, tenantID, ownerUID, personaID, missionID)
}
func requireActor(tenantID, ownerUID, personaID string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
return app.For(code.Auth).AuthUnauthorized("missing actor")
}
if strings.TrimSpace(personaID) == "" {
return app.For(code.Persona).InputMissingRequired("persona_id is required")
}
return nil
}
func toSummary(item *entity.Mission) domusecase.MissionSummary {
if item == nil {
return domusecase.MissionSummary{}
}
tags := make([]domusecase.SuggestedTagSummary, 0, len(item.ResearchMap.SuggestedTags))
for _, tag := range item.ResearchMap.SuggestedTags {
tags = append(tags, domusecase.SuggestedTagSummary{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts))
for _, acc := range item.ResearchMap.SimilarAccounts {
matched := append([]string(nil), acc.MatchedSource...)
accounts = append(accounts, domusecase.SimilarAccountSummary{
Username: acc.Username,
Reason: acc.Reason,
Source: acc.Source,
MatchedSource: matched,
Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileURL: acc.ProfileURL,
AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount,
EngagementScore: acc.EngagementScore,
LikeCount: acc.LikeCount,
ReplyCount: acc.ReplyCount,
PostCount: acc.PostCount,
})
}
samples := make([]domusecase.AudienceSampleSummary, 0, len(item.ResearchMap.AudienceSamples))
for _, sample := range item.ResearchMap.AudienceSamples {
samples = append(samples, domusecase.AudienceSampleSummary{
Username: sample.Username,
SamplePostID: sample.SamplePostID,
SampleText: sample.SampleText,
ReplyLikeCount: sample.ReplyLikeCount,
Appearances: sample.Appearances,
FirstSeenAt: sample.FirstSeenAt,
LastSeenAt: sample.LastSeenAt,
Status: sample.Status,
})
}
return domusecase.MissionSummary{
ID: item.ID,
PersonaID: item.PersonaID,
Label: item.Label,
SeedQuery: item.SeedQuery,
Brief: item.Brief,
ResearchMap: toMapSummary(item.ResearchMap, tags, accounts, samples),
SelectedTags: append([]string(nil), item.SelectedTags...),
LastScanJobID: item.LastScanJobID,
Status: string(item.Status),
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
}
}
func toResearchItemSummaries(items []entity.ResearchItem) []domusecase.ResearchItemSummary {
out := make([]domusecase.ResearchItemSummary, 0, len(items))
for _, item := range items {
out = append(out, domusecase.ResearchItemSummary{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Query: item.Query,
})
}
return out
}
func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary, samples []domusecase.AudienceSampleSummary) domusecase.ResearchMapSummary {
return domusecase.ResearchMapSummary{
AudienceSummary: m.AudienceSummary,
ContentGoal: m.ContentGoal,
Questions: append([]string(nil), m.Questions...),
Pillars: append([]string(nil), m.Pillars...),
Exclusions: append([]string(nil), m.Exclusions...),
KnowledgeItems: append([]string(nil), m.KnowledgeItems...),
SelectedKnowledgeItems: append([]string(nil), m.SelectedKnowledgeItems...),
ResearchItems: toResearchItemSummaries(m.ResearchItems),
SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: m.BenchmarkNotes,
}
}
func validateSimilarAccountStatus(raw string) (string, error) {
status := strings.TrimSpace(raw)
if status == "" {
status = entity.SimilarAccountStatusRecommended
}
switch status {
case entity.SimilarAccountStatusRecommended,
entity.SimilarAccountStatusPinned,
entity.SimilarAccountStatusExcluded,
entity.SimilarAccountStatusPromoted:
return status, nil
default:
return "", app.For(code.Facade).ResInvalidState("similar account status not allowed")
}
}
func validateAudienceSampleStatus(raw string) (string, error) {
status := strings.TrimSpace(raw)
switch status {
case "", entity.AudienceSampleStatusPinned, entity.AudienceSampleStatusExcluded:
return status, nil
default:
return "", app.For(code.Facade).ResInvalidState("audience sample status not allowed")
}
}
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
}
func cleanTags(tags []string) []string {
out := []string{}
seen := map[string]struct{}{}
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
out = append(out, tag)
}
return out
}