396 lines
13 KiB
Go
396 lines
13 KiB
Go
|
|
package usecase
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"haixun-backend/internal/library/clock"
|
||
|
|
app "haixun-backend/internal/library/errors"
|
||
|
|
"haixun-backend/internal/library/errors/code"
|
||
|
|
libmongo "haixun-backend/internal/library/mongo"
|
||
|
|
"haixun-backend/internal/library/placement"
|
||
|
|
"haixun-backend/internal/model/scan_post/domain/entity"
|
||
|
|
domrepo "haixun-backend/internal/model/scan_post/domain/repository"
|
||
|
|
domusecase "haixun-backend/internal/model/scan_post/domain/usecase"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
type scanPostUseCase struct {
|
||
|
|
repo domrepo.Repository
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
|
||
|
|
return &scanPostUseCase{repo: repo}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) ClearCopyMissionViralScan(ctx context.Context, tenantID, ownerUID, personaID, copyMissionID string) error {
|
||
|
|
if err := requireViralActor(tenantID, ownerUID, personaID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
copyMissionID = strings.TrimSpace(copyMissionID)
|
||
|
|
if copyMissionID == "" {
|
||
|
|
return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
|
||
|
|
}
|
||
|
|
return u.repo.ReplaceForViralScan(ctx, tenantID, ownerUID, personaID, copyMissionID, "", nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domusecase.ViralReplaceRequest) (int, error) {
|
||
|
|
if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
now := clock.NowUnixNano()
|
||
|
|
entities := make([]entity.ScanPost, 0, len(req.Posts))
|
||
|
|
for _, item := range req.Posts {
|
||
|
|
entities = append(entities, entity.ScanPost{
|
||
|
|
ID: uuid.NewString(),
|
||
|
|
TenantID: req.TenantID,
|
||
|
|
OwnerUID: req.OwnerUID,
|
||
|
|
LegacyPersonaID: req.PersonaID,
|
||
|
|
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
|
||
|
|
Flow: entity.FlowViral,
|
||
|
|
ScanJobID: req.ScanJobID,
|
||
|
|
SearchTag: item.SearchTag,
|
||
|
|
ExternalID: item.ExternalID,
|
||
|
|
Permalink: item.Permalink,
|
||
|
|
Author: item.Author,
|
||
|
|
AuthorVerified: item.AuthorVerified,
|
||
|
|
FollowerCount: item.FollowerCount,
|
||
|
|
Text: item.Text,
|
||
|
|
Priority: item.Priority,
|
||
|
|
LikeCount: item.LikeCount,
|
||
|
|
ReplyCount: item.ReplyCount,
|
||
|
|
EngagementScore: item.EngagementScore,
|
||
|
|
PlacementScore: item.PlacementScore,
|
||
|
|
Source: string(item.Source),
|
||
|
|
Replies: toReplyEntities(item.Replies),
|
||
|
|
CreateAt: now,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return len(entities), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
|
||
|
|
if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return u.repo.ClearForPlacementScan(ctx, tenantID, ownerUID, brandID, topicID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) {
|
||
|
|
if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
|
||
|
|
return u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
|
||
|
|
if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
|
||
|
|
count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
keep := make([]string, 0, len(entities))
|
||
|
|
for _, item := range entities {
|
||
|
|
if strings.TrimSpace(item.Permalink) != "" {
|
||
|
|
keep = append(keep, item.Permalink)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil {
|
||
|
|
return count, err
|
||
|
|
}
|
||
|
|
return count, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
|
||
|
|
if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
|
||
|
|
if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return len(entities), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost {
|
||
|
|
now := clock.NowUnixNano()
|
||
|
|
entities := make([]entity.ScanPost, 0, len(posts))
|
||
|
|
for _, item := range posts {
|
||
|
|
if strings.TrimSpace(item.Permalink) == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
entities = append(entities, entity.ScanPost{
|
||
|
|
ID: uuid.NewString(),
|
||
|
|
TenantID: tenantID,
|
||
|
|
OwnerUID: ownerUID,
|
||
|
|
BrandID: brandID,
|
||
|
|
TopicID: strings.TrimSpace(topicID),
|
||
|
|
Flow: entity.FlowPlacement,
|
||
|
|
GraphID: graphID,
|
||
|
|
ScanJobID: scanJobID,
|
||
|
|
GraphNodeID: item.GraphNodeID,
|
||
|
|
SearchTag: item.SearchTag,
|
||
|
|
QueryDimension: string(item.QueryDimension),
|
||
|
|
ExternalID: item.ExternalID,
|
||
|
|
Permalink: item.Permalink,
|
||
|
|
Author: item.Author,
|
||
|
|
Text: item.Text,
|
||
|
|
Priority: item.Priority,
|
||
|
|
PlacementScore: item.PlacementScore,
|
||
|
|
ProductFitScore: item.ProductFitScore,
|
||
|
|
SolvedByProduct: item.SolvedByProduct,
|
||
|
|
Source: string(item.Source),
|
||
|
|
OutreachStatus: entity.OutreachStatusPending,
|
||
|
|
PostedAt: strings.TrimSpace(item.PostedAt),
|
||
|
|
Replies: toReplyEntities(item.Replies),
|
||
|
|
CreateAt: now,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return entities
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*domusecase.ScanPostSummary, error) {
|
||
|
|
if err := requireActor(tenantID, ownerUID, brandID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
postID = strings.TrimSpace(postID)
|
||
|
|
if postID == "" {
|
||
|
|
return nil, errMissingBrand()
|
||
|
|
}
|
||
|
|
item, err := u.repo.Get(ctx, tenantID, ownerUID, brandID, postID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
summary := toSummary(*item)
|
||
|
|
return &summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.UpdateOutreachRequest) (*domusecase.ScanPostSummary, error) {
|
||
|
|
if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
postID := strings.TrimSpace(req.PostID)
|
||
|
|
if postID == "" {
|
||
|
|
return nil, errMissingBrand()
|
||
|
|
}
|
||
|
|
status := strings.TrimSpace(req.Status)
|
||
|
|
if status == "" {
|
||
|
|
return nil, app.For(code.Brand).InputMissingRequired("outreach status is required")
|
||
|
|
}
|
||
|
|
item, err := u.repo.UpdateOutreach(ctx, req.TenantID, req.OwnerUID, req.BrandID, postID, entity.OutreachPatch{
|
||
|
|
Status: status,
|
||
|
|
PublishedReplyID: req.PublishedReplyID,
|
||
|
|
PublishedPermalink: req.PublishedPermalink,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
summary := toSummary(*item)
|
||
|
|
return &summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error {
|
||
|
|
if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
postID = strings.TrimSpace(postID)
|
||
|
|
if postID == "" {
|
||
|
|
return app.For(code.Brand).InputMissingRequired("scan post id is required")
|
||
|
|
}
|
||
|
|
return u.repo.Delete(ctx, tenantID, ownerUID, brandID, topicID, postID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) {
|
||
|
|
if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
ids := make([]string, 0, len(postIDs))
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
for _, postID := range postIDs {
|
||
|
|
id := strings.TrimSpace(postID)
|
||
|
|
if id == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seen[id]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[id] = struct{}{}
|
||
|
|
ids = append(ids, id)
|
||
|
|
}
|
||
|
|
if len(ids) == 0 {
|
||
|
|
return 0, app.For(code.Brand).InputMissingRequired("post_ids is required")
|
||
|
|
}
|
||
|
|
return u.repo.DeleteMany(ctx, tenantID, ownerUID, brandID, topicID, ids)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*domusecase.ScanPostSummary, error) {
|
||
|
|
if err := requireViralActor(tenantID, ownerUID, personaID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
postID = strings.TrimSpace(postID)
|
||
|
|
if postID == "" {
|
||
|
|
return nil, errMissingPersona()
|
||
|
|
}
|
||
|
|
item, err := u.repo.GetForPersona(ctx, tenantID, ownerUID, personaID, postID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
summary := toSummary(*item)
|
||
|
|
return &summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) ListForPersona(ctx context.Context, req domusecase.PersonaListRequest) ([]domusecase.ScanPostSummary, error) {
|
||
|
|
if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items, err := u.repo.ListForPersona(ctx, req.TenantID, req.OwnerUID, domrepo.PersonaListFilter{
|
||
|
|
PersonaID: req.PersonaID,
|
||
|
|
CopyMissionID: req.CopyMissionID,
|
||
|
|
Flow: entity.FlowViral,
|
||
|
|
Limit: req.Limit,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := make([]domusecase.ScanPostSummary, 0, len(items))
|
||
|
|
for _, item := range items {
|
||
|
|
out = append(out, toSummary(item))
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.ScanPostSummary, error) {
|
||
|
|
if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, domrepo.ListFilter{
|
||
|
|
BrandID: req.BrandID,
|
||
|
|
TopicID: req.TopicID,
|
||
|
|
Priority: req.Priority,
|
||
|
|
ProductFitMin: req.ProductFitMin,
|
||
|
|
Recent7dOnly: req.Recent7dOnly,
|
||
|
|
Limit: req.Limit,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := make([]domusecase.ScanPostSummary, 0, len(items))
|
||
|
|
for _, item := range items {
|
||
|
|
out = append(out, toSummary(item))
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func requireActor(tenantID, ownerUID, brandID string) error {
|
||
|
|
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||
|
|
return errMissingActor()
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(brandID) == "" {
|
||
|
|
return errMissingBrand()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func requireListActor(tenantID, ownerUID, brandID, topicID string) error {
|
||
|
|
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||
|
|
return errMissingActor()
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" {
|
||
|
|
return errMissingBrand()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func requireViralActor(tenantID, ownerUID, personaID string) error {
|
||
|
|
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||
|
|
return errMissingActor()
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(personaID) == "" {
|
||
|
|
return errMissingPersona()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func errMissingPersona() error {
|
||
|
|
return app.For(code.Persona).InputMissingRequired("persona_id is required")
|
||
|
|
}
|
||
|
|
|
||
|
|
func toReplyEntities(replies []placement.ReplyCandidate) []entity.ScanReply {
|
||
|
|
if len(replies) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
out := make([]entity.ScanReply, 0, len(replies))
|
||
|
|
for _, reply := range replies {
|
||
|
|
out = append(out, entity.ScanReply{
|
||
|
|
ExternalID: reply.ExternalID,
|
||
|
|
Author: reply.Author,
|
||
|
|
Text: reply.Text,
|
||
|
|
Permalink: reply.Permalink,
|
||
|
|
LikeCount: reply.LikeCount,
|
||
|
|
PostedAt: reply.PostedAt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary {
|
||
|
|
if len(replies) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
out := make([]domusecase.ScanReplySummary, 0, len(replies))
|
||
|
|
for _, reply := range replies {
|
||
|
|
out = append(out, domusecase.ScanReplySummary{
|
||
|
|
ExternalID: reply.ExternalID,
|
||
|
|
Author: reply.Author,
|
||
|
|
Text: reply.Text,
|
||
|
|
Permalink: reply.Permalink,
|
||
|
|
LikeCount: reply.LikeCount,
|
||
|
|
PostedAt: reply.PostedAt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
|
||
|
|
return domusecase.ScanPostSummary{
|
||
|
|
ID: item.ID,
|
||
|
|
BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID),
|
||
|
|
PersonaID: strings.TrimSpace(item.LegacyPersonaID),
|
||
|
|
CopyMissionID: item.CopyMissionID,
|
||
|
|
Flow: item.Flow,
|
||
|
|
GraphNodeID: item.GraphNodeID,
|
||
|
|
SearchTag: item.SearchTag,
|
||
|
|
QueryDimension: item.QueryDimension,
|
||
|
|
ExternalID: item.ExternalID,
|
||
|
|
Permalink: item.Permalink,
|
||
|
|
Author: item.Author,
|
||
|
|
AuthorVerified: item.AuthorVerified,
|
||
|
|
FollowerCount: item.FollowerCount,
|
||
|
|
Text: item.Text,
|
||
|
|
Priority: item.Priority,
|
||
|
|
LikeCount: item.LikeCount,
|
||
|
|
ReplyCount: item.ReplyCount,
|
||
|
|
EngagementScore: item.EngagementScore,
|
||
|
|
PlacementScore: item.PlacementScore,
|
||
|
|
ProductFitScore: item.ProductFitScore,
|
||
|
|
SolvedByProduct: item.SolvedByProduct,
|
||
|
|
Source: item.Source,
|
||
|
|
ScanJobID: item.ScanJobID,
|
||
|
|
OutreachStatus: item.OutreachStatus,
|
||
|
|
PublishedReplyID: item.PublishedReplyID,
|
||
|
|
PublishedPermalink: item.PublishedPermalink,
|
||
|
|
OutreachUpdateAt: item.OutreachUpdateAt,
|
||
|
|
PostedAt: item.PostedAt,
|
||
|
|
Replies: toReplySummaries(item.Replies),
|
||
|
|
CreateAt: item.CreateAt,
|
||
|
|
}
|
||
|
|
}
|