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

378 lines
12 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) 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,
Flow: entity.FlowViral,
ScanJobID: req.ScanJobID,
SearchTag: item.SearchTag,
ExternalID: item.ExternalID,
Permalink: item.Permalink,
Author: item.Author,
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.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,
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),
Flow: item.Flow,
GraphNodeID: item.GraphNodeID,
SearchTag: item.SearchTag,
QueryDimension: item.QueryDimension,
ExternalID: item.ExternalID,
Permalink: item.Permalink,
Author: item.Author,
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,
}
}