405 lines
13 KiB
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
|
|
}
|