update dashboard

This commit is contained in:
王性驊 2026-06-25 01:30:47 +08:00
parent 66ef6b3d4a
commit a66a3d81ee
49 changed files with 1831 additions and 234 deletions

View File

@ -1 +1 @@
65532 91942

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
> vite > vite
VITE v6.4.3 ready in 167 ms VITE v6.4.3 ready in 111 ms
➜ Local: http://localhost:5173/ ➜ Local: http://localhost:5173/
➜ Network: use --host to expose ➜ Network: use --host to expose

View File

@ -2,4 +2,4 @@
> haixun-master@0.1.0 worker:style-8d > haixun-master@0.1.0 worker:style-8d
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts > . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
[8d-worker] started id=local-style-8d-node-65878 api=http://127.0.0.1:8890 [8d-worker] started id=local-style-8d-node-92403 api=http://127.0.0.1:8890

View File

@ -1 +1 @@
65800 92316

View File

@ -1 +1 @@
65801 92317

View File

@ -183,6 +183,7 @@ type (
NodeIDs []string `json:"node_ids,optional"` NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"` DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"` PatrolMode bool `json:"patrol_mode,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
} }
StartBrandScanJobData { StartBrandScanJobData {

View File

@ -0,0 +1,13 @@
package knowledge
// ResolveScanPatrolKeywords picks keywords for placement-scan in UI/API order:
// explicit request > saved research map > auto-ranked from map + graph nodes.
func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string {
if keywords := NormalizePatrolKeywordList(explicit); len(keywords) > 0 {
return keywords
}
if keywords := NormalizePatrolKeywordList(saved); len(keywords) > 0 {
return keywords
}
return NormalizePatrolKeywordList(CollectPatrolTagsFromGraph(input, nodes))
}

View File

@ -0,0 +1,24 @@
package knowledge
import "testing"
func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) {
got := ResolveScanPatrolKeywords(
[]string{"手動 關鍵字"},
[]string{"已儲存"},
PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
nil,
)
if len(got) != 1 || got[0] != "手動 關鍵字" {
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got)
}
}
func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) {
got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{
Questions: []string{"化療後 皮膚乾 怎麼辦"},
}, nil)
if len(got) == 0 {
t.Fatal("expected research map questions to produce patrol keywords without graph nodes")
}
}

View File

@ -58,7 +58,11 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
continue continue
} }
fit := node.ProductFitScore fit := node.ProductFitScore
for _, tag := range node.DerivedTags.Relevance { derived := node.DerivedTags
if len(derived.Relevance) == 0 && len(derived.Recency) == 0 {
derived = libkg.DerivePatrolTagsForNode(node, libkg.PatrolTagInput{})
}
for _, tag := range derived.Relevance {
tag = strings.TrimSpace(tag) tag = strings.TrimSpace(tag)
if tag == "" { if tag == "" {
continue continue
@ -75,7 +79,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
ProductFitScore: fit, ProductFitScore: fit,
}) })
} }
for _, tag := range node.DerivedTags.Recency { for _, tag := range derived.Recency {
tag = strings.TrimSpace(tag) tag = strings.TrimSpace(tag)
if tag == "" { if tag == "" {
continue continue
@ -114,7 +118,16 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
if len(input.PatrolKeywords) > 0 { if len(input.PatrolKeywords) > 0 {
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句") return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句")
} }
return nil, fmt.Errorf("沒有勾選的節點或可用 tag") selected := 0
for _, node := range input.Nodes {
if node.SelectedForScan {
selected++
}
}
if selected > 0 {
return nil, fmt.Errorf("已勾選節點但沒有可用的海巡 tag請重新擴展圖譜或手動編輯 tag")
}
return nil, fmt.Errorf("請先勾選要海巡的節點並儲存")
} }
merged := map[string]*ScanCandidate{} merged := map[string]*ScanCandidate{}

View File

@ -153,6 +153,7 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate
TenantID: tenantID, TenantID: tenantID,
OwnerUID: uid, OwnerUID: uid,
BrandID: req.ID, BrandID: req.ID,
TopicID: strings.TrimSpace(req.TopicID),
ScanPostID: scanPostID, ScanPostID: scanPostID,
Relevance: parsed.Relevance, Relevance: parsed.Relevance,
Reason: parsed.Reason, Reason: parsed.Reason,

View File

@ -58,7 +58,7 @@ func (l *ListBrandScanPostsLogic) ListBrandScanPosts(req *types.ListBrandScanPos
list := make([]types.ScanPostData, 0, len(posts)) list := make([]types.ScanPostData, 0, len(posts))
for _, post := range posts { for _, post := range posts {
if mapped := toScanPostData(&post); mapped != nil { if mapped := toScanPostData(&post); mapped != nil {
if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, req.ID, post.ID); draftErr != nil { if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, req.ID, "", post.ID); draftErr != nil {
return nil, draftErr return nil, draftErr
} else if draft != nil { } else if draft != nil {
mapped.LatestDraft = toOutreachDraftData(draft) mapped.LatestDraft = toOutreachDraftData(draft)

View File

@ -85,15 +85,21 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH
} }
dualTrack := true dualTrack := true
patrolMode := req.PatrolMode patrolMode := req.PatrolMode
patrolKeywords := libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords)
if len(patrolKeywords) == 0 && graph != nil {
productBrief := strings.TrimSpace(brand.ProductBrief) productBrief := strings.TrimSpace(brand.ProductBrief)
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
productBrief = formatted productBrief = formatted
} }
patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes)) patrolNodes := []libkg.Node{}
if graph != nil {
patrolNodes = graph.Nodes
} }
patrolKeywords := libkg.ResolveScanPatrolKeywords(
req.PatrolKeywords,
brand.ResearchMap.PatrolKeywords,
patrolInput,
patrolNodes,
)
if patrolMode || (len(nodeIDs) == 0 && selected == 0) { if patrolMode || (len(nodeIDs) == 0 && selected == 0) {
if len(patrolKeywords) == 0 { if len(patrolKeywords) == 0 {
return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")

View File

@ -0,0 +1,17 @@
package job
import (
"context"
"haixun-backend/internal/library/authctx"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
)
func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
actor, ok := authctx.ActorFromContext(ctx)
if !ok {
return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
}
return actor.TenantID, actor.UID, nil
}

View File

@ -18,6 +18,17 @@ func NewCancelJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelJ
} }
func (l *CancelJobLogic) CancelJob(req *types.CancelJobReq) (*types.JobData, error) { func (l *CancelJobLogic) CancelJob(req *types.CancelJobReq) (*types.JobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
existing, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
if err != nil {
return nil, err
}
if err := ensureRunAccess(existing, tenantID, uid); err != nil {
return nil, err
}
run, err := l.svcCtx.Job.RequestCancel(l.ctx, domusecase.CancelRunRequest{ run, err := l.svcCtx.Job.RequestCancel(l.ctx, domusecase.CancelRunRequest{
JobID: req.ID, JobID: req.ID,
Reason: req.Reason, Reason: req.Reason,

View File

@ -18,11 +18,31 @@ func NewCreateJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJ
} }
func (l *CreateJobLogic) CreateJob(req *types.CreateJobReq) (*types.JobData, error) { func (l *CreateJobLogic) CreateJob(req *types.CreateJobReq) (*types.JobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
payload := req.Payload
if payload == nil {
payload = map[string]any{}
}
if _, ok := payload["tenant_id"]; !ok && tenantID != "" {
payload["tenant_id"] = tenantID
}
if _, ok := payload["owner_uid"]; !ok && uid != "" {
payload["owner_uid"] = uid
}
scopeID := req.ScopeID
if req.Scope == "user" && scopeID == "" {
scopeID = uid
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, domusecase.CreateRunRequest{ run, err := l.svcCtx.Job.CreateRun(l.ctx, domusecase.CreateRunRequest{
TemplateType: req.TemplateType, TemplateType: req.TemplateType,
Scope: req.Scope, Scope: req.Scope,
ScopeID: req.ScopeID, ScopeID: scopeID,
Payload: req.Payload, TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -17,10 +17,17 @@ func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogi
} }
func (l *GetJobLogic) GetJob(req *types.JobIDPath) (*types.JobData, error) { func (l *GetJobLogic) GetJob(req *types.JobIDPath) (*types.JobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID) run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ensureRunAccess(run, tenantID, uid); err != nil {
return nil, err
}
data := toJobData(run) data := toJobData(run)
return &data, nil return &data, nil
} }

View File

@ -17,6 +17,17 @@ func NewListJobEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Lis
} }
func (l *ListJobEventsLogic) ListJobEvents(req *types.ListJobEventsReq) (*types.JobEventListData, error) { func (l *ListJobEventsLogic) ListJobEvents(req *types.ListJobEventsReq) (*types.JobEventListData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
if err != nil {
return nil, err
}
if err := ensureRunAccess(run, tenantID, uid); err != nil {
return nil, err
}
limit := req.Limit limit := req.Limit
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50

View File

@ -3,6 +3,7 @@ package job
import ( import (
"context" "context"
domrepo "haixun-backend/internal/model/job/domain/repository"
"haixun-backend/internal/svc" "haixun-backend/internal/svc"
"haixun-backend/internal/types" "haixun-backend/internal/types"
) )
@ -17,7 +18,16 @@ func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobs
} }
func (l *ListJobsLogic) ListJobs(req *types.ListJobsReq) (*types.JobListData, error) { func (l *ListJobsLogic) ListJobs(req *types.ListJobsReq) (*types.JobListData, error) {
runs, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListRuns(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize) tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
runs, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListRuns(l.ctx, domrepo.RunListFilter{
Scope: req.Scope,
ScopeID: req.ScopeID,
TenantID: tenantID,
OwnerUID: uid,
}, req.Page, req.PageSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,53 @@
package job
import (
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/job/domain/entity"
)
func runOwnedBy(run *entity.Run, tenantID, uid string) bool {
if run == nil || strings.TrimSpace(uid) == "" {
return false
}
ownerUID := strings.TrimSpace(run.OwnerUID)
runTenantID := strings.TrimSpace(run.TenantID)
if ownerUID == "" && run.Payload != nil {
ownerUID = stringFromPayload(run.Payload, "owner_uid")
runTenantID = stringFromPayload(run.Payload, "tenant_id")
}
if ownerUID != "" {
if ownerUID != uid {
return false
}
if runTenantID != "" && tenantID != "" && runTenantID != tenantID {
return false
}
return true
}
return run.Scope == "user" && run.ScopeID == uid
}
func ensureRunAccess(run *entity.Run, tenantID, uid string) error {
if runOwnedBy(run, tenantID, uid) {
return nil
}
return app.For(code.Job).ResNotFound("job run not found")
}
func stringFromPayload(payload map[string]any, key string) string {
if payload == nil {
return ""
}
value, ok := payload[key]
if !ok || value == nil {
return ""
}
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}

View File

@ -0,0 +1,42 @@
package job
import (
"testing"
"haixun-backend/internal/model/job/domain/entity"
)
func TestRunOwnedByTopLevelFields(t *testing.T) {
run := &entity.Run{
TenantID: "tenant-a",
OwnerUID: "user-1",
}
if !runOwnedBy(run, "tenant-a", "user-1") {
t.Fatal("expected top-level owner match")
}
if runOwnedBy(run, "tenant-a", "user-2") {
t.Fatal("expected different uid to be rejected")
}
}
func TestRunOwnedByPayloadFallback(t *testing.T) {
run := &entity.Run{
Payload: map[string]any{
"tenant_id": "tenant-a",
"owner_uid": "user-1",
},
}
if !runOwnedBy(run, "tenant-a", "user-1") {
t.Fatal("expected payload owner match")
}
}
func TestRunOwnedByUserScopeLegacy(t *testing.T) {
run := &entity.Run{
Scope: "user",
ScopeID: "user-1",
}
if !runOwnedBy(run, "tenant-a", "user-1") {
t.Fatal("expected legacy user scope match")
}
}

View File

@ -17,6 +17,17 @@ func NewRetryJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryJob
} }
func (l *RetryJobLogic) RetryJob(req *types.JobIDPath) (*types.JobData, error) { func (l *RetryJobLogic) RetryJob(req *types.JobIDPath) (*types.JobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
existing, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
if err != nil {
return nil, err
}
if err := ensureRunAccess(existing, tenantID, uid); err != nil {
return nil, err
}
run, err := l.svcCtx.Job.RetryRun(l.ctx, req.ID) run, err := l.svcCtx.Job.RetryRun(l.ctx, req.ID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -37,5 +37,6 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea
VoicePersonaID: req.VoicePersonaID, VoicePersonaID: req.VoicePersonaID,
ProductID: scope.Topic.ProductID, ProductID: scope.Topic.ProductID,
}, },
TopicID: scope.TopicID,
}) })
} }

View File

@ -50,7 +50,7 @@ func (l *ListPlacementTopicScanPostsLogic) ListPlacementTopicScanPosts(req *type
list := make([]types.ScanPostData, 0, len(posts)) list := make([]types.ScanPostData, 0, len(posts))
for _, post := range posts { for _, post := range posts {
if mapped := toScanPostData(&post); mapped != nil { if mapped := toScanPostData(&post); mapped != nil {
if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, scope.BrandID, post.ID); draftErr != nil { if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, post.ID); draftErr != nil {
return nil, draftErr return nil, draftErr
} else if draft != nil { } else if draft != nil {
mapped.LatestDraft = toOutreachDraftData(draft) mapped.LatestDraft = toOutreachDraftData(draft)

View File

@ -68,8 +68,6 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
} }
dualTrack := true dualTrack := true
patrolMode := req.PatrolMode patrolMode := req.PatrolMode
patrolKeywords := libkg.NormalizePatrolKeywordList(scope.Topic.ResearchMap.PatrolKeywords)
if len(patrolKeywords) == 0 && graph != nil {
brandForPatrol := scope.Brand brandForPatrol := scope.Brand
brandForPatrol.ProductID = scope.Topic.ProductID brandForPatrol.ProductID = scope.Topic.ProductID
brandForPatrol.ResearchMap = scope.Topic.ResearchMap brandForPatrol.ResearchMap = scope.Topic.ResearchMap
@ -78,8 +76,16 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
productBrief = formatted productBrief = formatted
} }
patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief) patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes)) patrolNodes := []libkg.Node{}
if graph != nil {
patrolNodes = graph.Nodes
} }
patrolKeywords := libkg.ResolveScanPatrolKeywords(
req.PatrolKeywords,
scope.Topic.ResearchMap.PatrolKeywords,
patrolInput,
patrolNodes,
)
if patrolMode || (len(nodeIDs) == 0 && selected == 0) { if patrolMode || (len(nodeIDs) == 0 && selected == 0) {
if len(patrolKeywords) == 0 { if len(patrolKeywords) == 0 {
return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
@ -126,6 +132,8 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
TemplateType: "placement-scan", TemplateType: "placement-scan",
Scope: "placement_topic", Scope: "placement_topic",
ScopeID: scope.TopicID, ScopeID: scope.TopicID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload, Payload: payload,
}) })
if err != nil { if err != nil {

View File

@ -24,6 +24,8 @@ type RunProgress struct {
type Run struct { type Run struct {
ID primitive.ObjectID `bson:"_id,omitempty"` ID primitive.ObjectID `bson:"_id,omitempty"`
TenantID string `bson:"tenant_id,omitempty"`
OwnerUID string `bson:"owner_uid,omitempty"`
TemplateType string `bson:"template_type"` TemplateType string `bson:"template_type"`
TemplateVersion int `bson:"template_version"` TemplateVersion int `bson:"template_version"`
Scope string `bson:"scope"` Scope string `bson:"scope"`

View File

@ -10,6 +10,8 @@ import (
type RunListFilter struct { type RunListFilter struct {
Scope string Scope string
ScopeID string ScopeID string
TenantID string
OwnerUID string
Statuses []enum.RunStatus Statuses []enum.RunStatus
} }

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/model/job/domain/repository"
) )
type CreateRunRequest struct { type CreateRunRequest struct {
TemplateType string TemplateType string
Scope string Scope string
ScopeID string ScopeID string
TenantID string
OwnerUID string
Payload map[string]any Payload map[string]any
} }
@ -95,7 +98,7 @@ type UseCase interface {
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error) CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
GetRun(ctx context.Context, jobID string) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error)
ListRuns(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error) ListRuns(ctx context.Context, filter repository.RunListFilter, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error)
RequestCancel(ctx context.Context, req CancelRunRequest) (*entity.Run, error) RequestCancel(ctx context.Context, req CancelRunRequest) (*entity.Run, error)
RetryRun(ctx context.Context, jobID string) (*entity.Run, error) RetryRun(ctx context.Context, jobID string) (*entity.Run, error)
ListJobEvents(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error) ListJobEvents(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error)

View File

@ -33,6 +33,8 @@ func (r *mongoRunRepository) EnsureIndexes(ctx context.Context) error {
} }
models := []mongo.IndexModel{ models := []mongo.IndexModel{
{Keys: bson.D{{Key: "template_type", Value: 1}, {Key: "scope", Value: 1}, {Key: "scope_id", Value: 1}, {Key: "status", Value: 1}}}, {Keys: bson.D{{Key: "template_type", Value: 1}, {Key: "scope", Value: 1}, {Key: "scope_id", Value: 1}, {Key: "status", Value: 1}}},
{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "create_at", Value: -1}}},
{Keys: bson.D{{Key: "payload.owner_uid", Value: 1}, {Key: "create_at", Value: -1}}},
{Keys: bson.D{{Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "create_at", Value: -1}}},
{Keys: bson.D{{Key: "dedupe_key", Value: 1}}}, {Keys: bson.D{{Key: "dedupe_key", Value: 1}}},
} }
@ -161,16 +163,7 @@ func (r *mongoRunRepository) List(ctx context.Context, filter domrepo.RunListFil
if r.collection == nil { if r.collection == nil {
return nil, 0, app.For(code.Job).DBUnavailable("Mongo is not configured") return nil, 0, app.For(code.Job).DBUnavailable("Mongo is not configured")
} }
query := bson.M{} query := buildRunListQuery(filter)
if filter.Scope != "" {
query["scope"] = filter.Scope
}
if filter.ScopeID != "" {
query["scope_id"] = filter.ScopeID
}
if len(filter.Statuses) > 0 {
query["status"] = bson.M{"$in": filter.Statuses}
}
total, err := r.collection.CountDocuments(ctx, query) total, err := r.collection.CountDocuments(ctx, query)
if err != nil { if err != nil {
@ -315,3 +308,46 @@ func (r *mongoRunRepository) FindRunningTimedOut(ctx context.Context, now int64,
} }
return items, nil return items, nil
} }
func buildRunListQuery(filter domrepo.RunListFilter) bson.M {
clauses := make([]bson.M, 0, 4)
if filter.Scope != "" {
clauses = append(clauses, bson.M{"scope": filter.Scope})
}
if filter.ScopeID != "" {
clauses = append(clauses, bson.M{"scope_id": filter.ScopeID})
}
if filter.OwnerUID != "" {
clauses = append(clauses, bson.M{"$or": []bson.M{
{"owner_uid": filter.OwnerUID},
{"payload.owner_uid": filter.OwnerUID},
{"scope": "user", "scope_id": filter.OwnerUID},
}})
}
if filter.TenantID != "" {
clauses = append(clauses, bson.M{"$or": []bson.M{
{"tenant_id": filter.TenantID},
{"payload.tenant_id": filter.TenantID},
{"$and": []bson.M{
{"$or": []bson.M{
{"tenant_id": bson.M{"$exists": false}},
{"tenant_id": ""},
}},
{"$or": []bson.M{
{"payload.tenant_id": bson.M{"$exists": false}},
{"payload.tenant_id": ""},
}},
}},
}})
}
if len(filter.Statuses) > 0 {
clauses = append(clauses, bson.M{"status": bson.M{"$in": filter.Statuses}})
}
if len(clauses) == 0 {
return bson.M{}
}
if len(clauses) == 1 {
return clauses[0]
}
return bson.M{"$and": clauses}
}

View File

@ -11,8 +11,36 @@ import (
"haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/model/job/domain/enum" "haixun-backend/internal/model/job/domain/enum"
domusecase "haixun-backend/internal/model/job/domain/usecase"
) )
func stringFromAny(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return ""
}
}
func extractRunActor(req domusecase.CreateRunRequest) (tenantID, ownerUID string) {
tenantID = strings.TrimSpace(req.TenantID)
ownerUID = strings.TrimSpace(req.OwnerUID)
if req.Payload != nil {
if tenantID == "" {
tenantID = stringFromAny(req.Payload["tenant_id"])
}
if ownerUID == "" {
ownerUID = stringFromAny(req.Payload["owner_uid"])
}
}
scope := strings.TrimSpace(req.Scope)
if ownerUID == "" && scope == "user" {
ownerUID = strings.TrimSpace(req.ScopeID)
}
return tenantID, ownerUID
}
func buildDedupeKey(template *entity.Template, scope, scopeID string, payload map[string]any) string { func buildDedupeKey(template *entity.Template, scope, scopeID string, payload map[string]any) string {
parts := []string{template.Type, scope} parts := []string{template.Type, scope}
for _, key := range template.DedupeKeys { for _, key := range template.DedupeKeys {

View File

@ -241,7 +241,10 @@ func (u *jobUseCase) CreateRun(ctx context.Context, req domusecase.CreateRunRequ
return nil, err return nil, err
} }
tenantID, ownerUID := extractRunActor(req)
run := &entity.Run{ run := &entity.Run{
TenantID: tenantID,
OwnerUID: ownerUID,
TemplateType: template.Type, TemplateType: template.Type,
TemplateVersion: template.Version, TemplateVersion: template.Version,
Scope: scope, Scope: scope,
@ -307,7 +310,7 @@ func (u *jobUseCase) GetRun(ctx context.Context, jobID string) (*entity.Run, err
return u.runs.FindByID(ctx, jobID) return u.runs.FindByID(ctx, jobID)
} }
func (u *jobUseCase) ListRuns(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error) { func (u *jobUseCase) ListRuns(ctx context.Context, filter domrepo.RunListFilter, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error) {
if page <= 0 { if page <= 0 {
page = 1 page = 1
} }
@ -318,10 +321,7 @@ func (u *jobUseCase) ListRuns(ctx context.Context, scope, scopeID string, page,
pageSize = 200 pageSize = 200
} }
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
items, total, err := u.runs.List(ctx, domrepo.RunListFilter{ items, total, err := u.runs.List(ctx, filter, offset, pageSize)
Scope: scope,
ScopeID: scopeID,
}, offset, pageSize)
if err != nil { if err != nil {
return nil, 0, 0, 0, 0, err return nil, 0, 0, 0, 0, err
} }

View File

@ -13,6 +13,7 @@ type OutreachDraft struct {
TenantID string `bson:"tenant_id"` TenantID string `bson:"tenant_id"`
OwnerUID string `bson:"owner_uid"` OwnerUID string `bson:"owner_uid"`
BrandID string `bson:"brand_id"` BrandID string `bson:"brand_id"`
TopicID string `bson:"topic_id,omitempty"`
LegacyPersonaID string `bson:"persona_id,omitempty"` LegacyPersonaID string `bson:"persona_id,omitempty"`
ScanPostID string `bson:"scan_post_id"` ScanPostID string `bson:"scan_post_id"`
Relevance float64 `bson:"relevance"` Relevance float64 `bson:"relevance"`

View File

@ -9,5 +9,5 @@ import (
type Repository interface { type Repository interface {
EnsureIndexes(ctx context.Context) error EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, draft *entity.OutreachDraft) error Create(ctx context.Context, draft *entity.OutreachDraft) error
GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, scanPostID string) (*entity.OutreachDraft, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*entity.OutreachDraft, error)
} }

View File

@ -24,6 +24,7 @@ type CreateRequest struct {
TenantID string TenantID string
OwnerUID string OwnerUID string
BrandID string BrandID string
TopicID string
ScanPostID string ScanPostID string
Relevance float64 Relevance float64
Reason string Reason string
@ -32,5 +33,5 @@ type CreateRequest struct {
type UseCase interface { type UseCase interface {
Create(ctx context.Context, req CreateRequest) (*DraftSummary, error) Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, scanPostID string) (*DraftSummary, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*DraftSummary, error)
} }

View File

@ -75,14 +75,23 @@ func (r *mongoRepository) Create(ctx context.Context, draft *entity.OutreachDraf
return err return err
} }
func draftScopeFilter(tenantID, ownerUID, brandID, topicID string) bson.M {
filter := brandOwnerFilter(tenantID, ownerUID, brandID)
topicID = strings.TrimSpace(topicID)
if topicID != "" {
filter["topic_id"] = topicID
}
return filter
}
func (r *mongoRepository) GetLatestByScanPost( func (r *mongoRepository) GetLatestByScanPost(
ctx context.Context, ctx context.Context,
tenantID, ownerUID, brandID, scanPostID string, tenantID, ownerUID, brandID, topicID, scanPostID string,
) (*entity.OutreachDraft, error) { ) (*entity.OutreachDraft, error) {
if r.collection == nil { if r.collection == nil {
return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
} }
filter := brandOwnerFilter(tenantID, ownerUID, brandID) filter := draftScopeFilter(tenantID, ownerUID, brandID, topicID)
filter["scan_post_id"] = strings.TrimSpace(scanPostID) filter["scan_post_id"] = strings.TrimSpace(scanPostID)
opts := options.FindOne().SetSort(bson.D{{Key: "create_at", Value: -1}}) opts := options.FindOne().SetSort(bson.D{{Key: "create_at", Value: -1}})
var out entity.OutreachDraft var out entity.OutreachDraft

View File

@ -43,6 +43,7 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create
TenantID: req.TenantID, TenantID: req.TenantID,
OwnerUID: req.OwnerUID, OwnerUID: req.OwnerUID,
BrandID: req.BrandID, BrandID: req.BrandID,
TopicID: strings.TrimSpace(req.TopicID),
ScanPostID: scanPostID, ScanPostID: scanPostID,
Relevance: req.Relevance, Relevance: req.Relevance,
Reason: req.Reason, Reason: req.Reason,
@ -57,7 +58,7 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create
func (u *outreachDraftUseCase) GetLatestByScanPost( func (u *outreachDraftUseCase) GetLatestByScanPost(
ctx context.Context, ctx context.Context,
tenantID, ownerUID, brandID, scanPostID string, tenantID, ownerUID, brandID, topicID, scanPostID string,
) (*domusecase.DraftSummary, error) { ) (*domusecase.DraftSummary, error) {
if err := requireActor(tenantID, ownerUID, brandID); err != nil { if err := requireActor(tenantID, ownerUID, brandID); err != nil {
return nil, err return nil, err
@ -66,7 +67,7 @@ func (u *outreachDraftUseCase) GetLatestByScanPost(
if scanPostID == "" { if scanPostID == "" {
return nil, errMissingScanPost() return nil, errMissingScanPost()
} }
record, err := u.repo.GetLatestByScanPost(ctx, tenantID, ownerUID, brandID, scanPostID) record, err := u.repo.GetLatestByScanPost(ctx, tenantID, ownerUID, brandID, topicID, scanPostID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -23,9 +23,9 @@ type PersonaListFilter struct {
type Repository interface { type Repository interface {
EnsureIndexes(ctx context.Context) error EnsureIndexes(ctx context.Context) error
ClearForBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error ClearForPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID string, posts []entity.ScanPost) (int, error) UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string, posts []entity.ScanPost) (int, error)
PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, keepPermalinks []string) error PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanJobID string, keepPermalinks []string) error
ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error
ReplaceForViralScan(ctx context.Context, tenantID, ownerUID, personaID, scanJobID string, posts []entity.ScanPost) error ReplaceForViralScan(ctx context.Context, tenantID, ownerUID, personaID, scanJobID string, posts []entity.ScanPost) error
Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*entity.ScanPost, error) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*entity.ScanPost, error)

View File

@ -60,6 +60,7 @@ type ReplaceRequest struct {
TenantID string TenantID string
OwnerUID string OwnerUID string
BrandID string BrandID string
TopicID string
GraphID string GraphID string
ScanJobID string ScanJobID string
Posts []placement.ScanCandidate Posts []placement.ScanCandidate
@ -94,13 +95,14 @@ type CheckpointRequest struct {
TenantID string TenantID string
OwnerUID string OwnerUID string
BrandID string BrandID string
TopicID string
GraphID string GraphID string
ScanJobID string ScanJobID string
Posts []placement.ScanCandidate Posts []placement.ScanCandidate
} }
type UseCase interface { type UseCase interface {
ClearBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
UpsertScanCheckpoint(ctx context.Context, req CheckpointRequest) (int, error) UpsertScanCheckpoint(ctx context.Context, req CheckpointRequest) (int, error)
FinalizeScan(ctx context.Context, req ReplaceRequest) (int, error) FinalizeScan(ctx context.Context, req ReplaceRequest) (int, error)
ReplaceFromScan(ctx context.Context, req ReplaceRequest) (int, error) ReplaceFromScan(ctx context.Context, req ReplaceRequest) (int, error)

View File

@ -34,7 +34,10 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{ return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{
{ {
Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "permalink", Value: 1}}, Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "permalink", Value: 1}},
Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}})}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}, "topic_id": bson.M{"$in": []interface{}{nil, ""}}})},
{
Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "topic_id", Value: 1}, {Key: "permalink", Value: 1}},
Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"topic_id": bson.M{"$gt": ""}})},
{ {
Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "permalink", Value: 1}}, Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "permalink", Value: 1}},
Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})},
@ -89,15 +92,51 @@ func (r *mongoRepository) ReplaceForViralScan(ctx context.Context, tenantID, own
return err return err
} }
func (r *mongoRepository) ClearForBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error { func placementWriteFilter(tenantID, ownerUID, brandID, topicID string) bson.M {
topicID = strings.TrimSpace(topicID)
if topicID != "" {
return bson.M{
"tenant_id": tenantID,
"owner_uid": ownerUID,
"topic_id": topicID,
}
}
return brandOwnerFilter(tenantID, ownerUID, brandID)
}
func untaggedTopicFilter() bson.M {
return bson.M{
"$or": []bson.M{
{"topic_id": bson.M{"$exists": false}},
{"topic_id": ""},
},
}
}
func (r *mongoRepository) ClearForPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
if r.collection == nil { if r.collection == nil {
return app.For(code.Brand).DBUnavailable("Mongo is not configured") return app.For(code.Brand).DBUnavailable("Mongo is not configured")
} }
_, err := r.collection.DeleteMany(ctx, brandOwnerFilter(tenantID, ownerUID, brandID)) topicID = strings.TrimSpace(topicID)
filter := bson.M{
"tenant_id": tenantID,
"owner_uid": ownerUID,
}
if topicID != "" {
filter["topic_id"] = topicID
} else {
for k, v := range brandOwnerFilter(tenantID, ownerUID, brandID) {
filter[k] = v
}
for k, v := range untaggedTopicFilter() {
filter[k] = v
}
}
_, err := r.collection.DeleteMany(ctx, filter)
return err return err
} }
func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID string, posts []entity.ScanPost) (int, error) { func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string, posts []entity.ScanPost) (int, error) {
if r.collection == nil { if r.collection == nil {
return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured") return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured")
} }
@ -110,7 +149,11 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
if permalink == "" { if permalink == "" {
continue continue
} }
filter := brandOwnerFilter(tenantID, ownerUID, brandID) writeTopicID := strings.TrimSpace(topicID)
if writeTopicID == "" && strings.TrimSpace(post.TopicID) != "" {
writeTopicID = strings.TrimSpace(post.TopicID)
}
filter := placementWriteFilter(tenantID, ownerUID, brandID, writeTopicID)
filter["permalink"] = permalink filter["permalink"] = permalink
var existing entity.ScanPost var existing entity.ScanPost
@ -130,6 +173,12 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
post.PostedAt = existing.PostedAt post.PostedAt = existing.PostedAt
} }
} }
if writeTopicID != "" {
post.TopicID = writeTopicID
}
if strings.TrimSpace(post.BrandID) == "" {
post.BrandID = brandID
}
if strings.TrimSpace(post.ID) == "" { if strings.TrimSpace(post.ID) == "" {
continue continue
} }
@ -161,11 +210,11 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
return upserted, nil return upserted, nil
} }
func (r *mongoRepository) PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, keepPermalinks []string) error { func (r *mongoRepository) PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanJobID string, keepPermalinks []string) error {
if r.collection == nil { if r.collection == nil {
return app.For(code.Brand).DBUnavailable("Mongo is not configured") return app.For(code.Brand).DBUnavailable("Mongo is not configured")
} }
filter := brandOwnerFilter(tenantID, ownerUID, brandID) filter := placementWriteFilter(tenantID, ownerUID, brandID, topicID)
filter["scan_job_id"] = strings.TrimSpace(scanJobID) filter["scan_job_id"] = strings.TrimSpace(scanJobID)
if len(keepPermalinks) > 0 { if len(keepPermalinks) > 0 {
filter["permalink"] = bson.M{"$nin": keepPermalinks} filter["permalink"] = bson.M{"$nin": keepPermalinks}
@ -345,37 +394,27 @@ func (r *mongoRepository) ListForPersona(ctx context.Context, tenantID, ownerUID
func topicScopeFilter(tenantID, ownerUID, topicID, brandID string) bson.M { func topicScopeFilter(tenantID, ownerUID, topicID, brandID string) bson.M {
topicID = strings.TrimSpace(topicID) topicID = strings.TrimSpace(topicID)
brandID = strings.TrimSpace(brandID) if topicID != "" {
filter := bson.M{ return bson.M{
"tenant_id": tenantID, "tenant_id": tenantID,
"owner_uid": ownerUID, "owner_uid": ownerUID,
} "topic_id": topicID,
if topicID != "" {
legacy := bson.M{"topic_id": bson.M{"$in": []interface{}{nil, ""}}}
if brandID != "" {
for k, v := range libmongo.BrandScopeFilter(brandID) {
legacy[k] = v
} }
} }
filter["$or"] = []bson.M{ return brandOwnerFilter(tenantID, ownerUID, brandID)
{"topic_id": topicID},
legacy,
}
return filter
}
for k, v := range libmongo.BrandScopeFilter(brandID) {
filter[k] = v
}
return filter
} }
func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
if r.collection == nil { if r.collection == nil {
return app.For(code.Brand).DBUnavailable("Mongo is not configured") return app.For(code.Brand).DBUnavailable("Mongo is not configured")
} }
filter := brandOwnerFilter(tenantID, ownerUID, brandID)
for k, v := range untaggedTopicFilter() {
filter[k] = v
}
_, err := r.collection.UpdateMany( _, err := r.collection.UpdateMany(
ctx, ctx,
brandOwnerFilter(tenantID, ownerUID, brandID), filter,
bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}}, bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}},
) )
return err return err

View File

@ -0,0 +1,28 @@
package repository
import (
"testing"
"go.mongodb.org/mongo-driver/bson"
)
func TestClearForPlacementScanFilter_ScopedTopic(t *testing.T) {
filter := placementWriteFilter("tenant-1", "user-1", "brand-1", "topic-a")
if filter["topic_id"] != "topic-a" {
t.Fatalf("expected topic_id topic-a, got %v", filter["topic_id"])
}
if filter["tenant_id"] != "tenant-1" || filter["owner_uid"] != "user-1" {
t.Fatalf("unexpected actor filter: %v", filter)
}
if _, ok := filter["brand_id"]; ok {
t.Fatalf("topic-scoped filter must not include brand_id: %v", filter)
}
}
func TestUntaggedTopicFilter_OnlyMissingTopicID(t *testing.T) {
legacy := untaggedTopicFilter()
orClause, ok := legacy["$or"].([]bson.M)
if !ok || len(orClause) != 2 {
t.Fatalf("expected two untagged topic clauses, got %v", legacy)
}
}

View File

@ -59,27 +59,27 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
return len(entities), nil return len(entities), nil
} }
func (u *scanPostUseCase) ClearBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error { func (u *scanPostUseCase) ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
if err := requireActor(tenantID, ownerUID, brandID); err != nil { if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
return err return err
} }
return u.repo.ClearForBrandScan(ctx, tenantID, ownerUID, brandID) return u.repo.ClearForPlacementScan(ctx, tenantID, ownerUID, brandID, topicID)
} }
func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) { func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
return 0, err return 0, err
} }
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) 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, entities) 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) { func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
return 0, err return 0, err
} }
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) 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, entities) count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@ -89,24 +89,24 @@ func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.Repla
keep = append(keep, item.Permalink) keep = append(keep, item.Permalink)
} }
} }
if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, keep); err != nil { if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil {
return count, err return count, err
} }
return count, nil return count, nil
} }
func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) { func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
return 0, err return 0, err
} }
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) 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 { if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil {
return 0, err return 0, err
} }
return len(entities), nil return len(entities), nil
} }
func placementCandidatesToEntities(tenantID, ownerUID, brandID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost { func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost {
now := clock.NowUnixNano() now := clock.NowUnixNano()
entities := make([]entity.ScanPost, 0, len(posts)) entities := make([]entity.ScanPost, 0, len(posts))
for _, item := range posts { for _, item := range posts {
@ -118,6 +118,7 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, graphID, scanJob
TenantID: tenantID, TenantID: tenantID,
OwnerUID: ownerUID, OwnerUID: ownerUID,
BrandID: brandID, BrandID: brandID,
TopicID: strings.TrimSpace(topicID),
Flow: entity.FlowPlacement, Flow: entity.FlowPlacement,
GraphID: graphID, GraphID: graphID,
ScanJobID: scanJobID, ScanJobID: scanJobID,

View File

@ -284,6 +284,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
jobworker.RegisterScanPlacementHandler(runner, jobworker.ScanPlacementDeps{ jobworker.RegisterScanPlacementHandler(runner, jobworker.ScanPlacementDeps{
Jobs: jobUseCase, Jobs: jobUseCase,
Brand: brandUseCase, Brand: brandUseCase,
PlacementTopic: placementTopicUseCase,
KnowledgeGraph: knowledgeGraphUseCase, KnowledgeGraph: knowledgeGraphUseCase,
ScanPost: scanPostUseCase, ScanPost: scanPostUseCase,
ThreadsAccount: threadsAccountUseCase, ThreadsAccount: threadsAccountUseCase,

View File

@ -325,6 +325,7 @@ type GenerateOutreachDraftsData struct {
type GenerateOutreachDraftsHandlerReq struct { type GenerateOutreachDraftsHandlerReq struct {
BrandPath BrandPath
GenerateOutreachDraftsReq GenerateOutreachDraftsReq
TopicID string `json:"-"`
} }
type GenerateOutreachDraftsReq struct { type GenerateOutreachDraftsReq struct {
@ -915,6 +916,7 @@ type StartBrandScanJobReq struct {
NodeIDs []string `json:"node_ids,optional"` NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"` DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"` PatrolMode bool `json:"patrol_mode,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
} }
type StartPersonaStyleAnalysisData struct { type StartPersonaStyleAnalysisData struct {

View File

@ -13,6 +13,7 @@ import (
branddomain "haixun-backend/internal/model/brand/domain/usecase" branddomain "haixun-backend/internal/model/brand/domain/usecase"
jobdom "haixun-backend/internal/model/job/domain/usecase" jobdom "haixun-backend/internal/model/job/domain/usecase"
kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
placementusecase "haixun-backend/internal/model/placement/usecase" placementusecase "haixun-backend/internal/model/placement/usecase"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
@ -21,6 +22,7 @@ import (
type ScanPlacementDeps struct { type ScanPlacementDeps struct {
Jobs jobdom.UseCase Jobs jobdom.UseCase
Brand branddomain.UseCase Brand branddomain.UseCase
PlacementTopic placementtopicdomain.UseCase
KnowledgeGraph kgusecase.UseCase KnowledgeGraph kgusecase.UseCase
ScanPost scanpostusecase.UseCase ScanPost scanpostusecase.UseCase
ThreadsAccount threadsaccountdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase
@ -57,46 +59,78 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
return fmt.Errorf("brand not found") return fmt.Errorf("brand not found")
} }
graph, graphErr := deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) topicID := topicIDFromPayload(payload)
if topicID == "" && step.Run != nil && strings.TrimSpace(step.Run.Scope) == "placement_topic" {
topicID = strings.TrimSpace(step.Run.ScopeID)
}
var graphSummary *kgusecase.GraphSummary
var graphErr error
if topicID != "" {
graphSummary, graphErr = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, topicID)
} else {
graphSummary, graphErr = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
}
if graphErr != nil { if graphErr != nil {
if patrolMode && isKnowledgeGraphNotFound(graphErr) { if patrolMode && isKnowledgeGraphNotFound(graphErr) {
graph = nil graphSummary = nil
if graphID == "" { if graphID == "" {
if topicID != "" {
graphID = topicID
} else {
graphID = brandID graphID = brandID
} }
}
} else { } else {
return graphErr return graphErr
} }
} else if graphID == "" { } else if graphID == "" {
graphID = graph.ID graphID = graphSummary.ID
}
researchMap := brand.ResearchMap
if topicID != "" && deps.PlacementTopic != nil {
if topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, topicID); topicErr == nil && topic != nil {
researchMap = topic.ResearchMap
brand.ProductID = topic.ProductID
}
} }
patrolKeywords := []string{} patrolKeywords := []string{}
if patrolMode { if patrolMode {
patrolKeywords = stringSliceField(payload, "patrol_keywords") productBrief := strings.TrimSpace(brand.ProductBrief)
if len(patrolKeywords) == 0 { if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
patrolKeywords = libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords) productBrief = formatted
} }
patrolNodes := []libkg.Node{}
if graphSummary != nil {
patrolNodes = graphSummary.Nodes
}
patrolKeywords = libkg.ResolveScanPatrolKeywords(
stringSliceField(payload, "patrol_keywords"),
researchMap.PatrolKeywords,
libkg.PatrolTagInputFromBrand(brand, productBrief),
patrolNodes,
)
if len(patrolKeywords) == 0 { if len(patrolKeywords) == 0 {
return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字")
} }
} }
nodes := []libkg.Node{} nodes := []libkg.Node{}
if graph != nil { if graphSummary != nil {
nodes = graph.Nodes nodes = graphSummary.Nodes
} }
if !patrolMode { if !patrolMode {
if graph == nil { if graphSummary == nil {
return fmt.Errorf("請先產生延伸知識圖譜,或改用手動海巡關鍵字") return fmt.Errorf("請先產生延伸知識圖譜,或改用手動海巡關鍵字")
} }
if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 { if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 {
nodes = filterNodesByIDs(graph.Nodes, ids) nodes = filterNodesByIDs(graphSummary.Nodes, ids)
} else { } else {
nodes = selectedNodes(graph.Nodes) nodes = selectedNodes(graphSummary.Nodes)
} }
if len(nodes) == 0 { if len(nodes) == 0 {
return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") return fmt.Errorf("請先勾選要海巡的節點並儲存")
} }
} }
@ -132,7 +166,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
}) })
} }
exclusions := append([]string{}, brand.ResearchMap.Exclusions...) exclusions := append([]string{}, researchMap.Exclusions...)
if len(patrolKeywords) > 0 { if len(patrolKeywords) > 0 {
updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5) updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5)
@ -140,20 +174,21 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
updateProgress("準備置入海巡…", 5) updateProgress("準備置入海巡…", 5)
} }
if err := deps.ScanPost.ClearBrandScan(ctx, tenantID, ownerUID, brandID); err != nil { if err := deps.ScanPost.ClearPlacementScan(ctx, tenantID, ownerUID, brandID, topicID); err != nil {
return err return err
} }
braveClient := libbrave.NewClient(memberCtx.BraveAPIKey) braveClient := libbrave.NewClient(memberCtx.BraveAPIKey)
crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID)) crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID))
graphNodes := []libkg.Node{} graphNodes := []libkg.Node{}
if graph != nil { if graphSummary != nil {
graphNodes = graph.Nodes graphNodes = graphSummary.Nodes
} }
checkpointReq := scanpostusecase.CheckpointRequest{ checkpointReq := scanpostusecase.CheckpointRequest{
TenantID: tenantID, TenantID: tenantID,
OwnerUID: ownerUID, OwnerUID: ownerUID,
BrandID: brandID, BrandID: brandID,
TopicID: topicID,
GraphID: graphID, GraphID: graphID,
ScanJobID: step.JobID, ScanJobID: step.JobID,
} }
@ -202,6 +237,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
TenantID: tenantID, TenantID: tenantID,
OwnerUID: ownerUID, OwnerUID: ownerUID,
BrandID: brandID, BrandID: brandID,
TopicID: topicID,
GraphID: graphID, GraphID: graphID,
ScanJobID: step.JobID, ScanJobID: step.JobID,
Posts: candidates, Posts: candidates,

View File

@ -4698,7 +4698,7 @@ th {
border: 2px solid color-mix(in srgb, var(--hx-brand) 25%, var(--hx-line) 75%); border: 2px solid color-mix(in srgb, var(--hx-brand) 25%, var(--hx-line) 75%);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--hx-surface); background: var(--hx-surface);
padding: 0.85rem 1rem; padding: 0.85rem 1.25rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -4706,12 +4706,26 @@ th {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1.25rem;
min-height: 5.75rem;
padding-inline: 1.5rem;
} }
} }
.ac-persona-flow-nav__head { .ac-persona-flow-nav__head {
min-width: 0; min-width: 0;
width: 100%;
padding-inline: 0.25rem;
box-sizing: border-box;
}
@media (min-width: 768px) {
.ac-persona-flow-nav__head {
flex: 0 0 17.5rem;
width: 17.5rem;
max-width: 17.5rem;
padding-inline: 0.5rem;
}
} }
.ac-persona-flow-nav__steps { .ac-persona-flow-nav__steps {
@ -4838,10 +4852,24 @@ th {
.ac-brand-switcher { .ac-brand-switcher {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
width: 100%;
min-height: 5.35rem;
}
.ac-brand-switcher label {
width: 100%;
}
.ac-brand-switcher select {
width: 100%;
min-height: 2.875rem;
} }
.ac-brand-switcher--empty { .ac-brand-switcher--empty {
padding: 0.25rem 0; min-height: 5.35rem;
display: flex;
align-items: center;
padding: 0.25rem 0.15rem;
} }
.ac-brand-switcher__link { .ac-brand-switcher__link {

View File

@ -111,17 +111,17 @@ export function JobsPage() {
</Card> </Card>
<Card className="mb-4 space-y-3"> <Card className="mb-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<Field label="列表 Scope 篩選(空白=全部)"> <Field label="列表 Scope 篩選(空白=我的全部任務">
<Input <Input
value={filterScope} value={filterScope}
placeholder="例如 placement_topic / persona / user" placeholder="例如 placement_topic / persona / brand"
onChange={(e) => setFilterScope(e.target.value)} onChange={(e) => setFilterScope(e.target.value)}
/> />
</Field> </Field>
<Field label="列表 Scope ID 篩選(空白=全部"> <Field label="列表 Scope ID 篩選(可選">
<Input <Input
value={filterScopeId} value={filterScopeId}
placeholder="主題 ID、人設 ID 或會員 ID" placeholder="主題 ID、品牌 ID 或人設 ID"
onChange={(e) => setFilterScopeId(e.target.value)} onChange={(e) => setFilterScopeId(e.target.value)}
/> />
</Field> </Field>
@ -137,7 +137,7 @@ export function JobsPage() {
setFilterScopeId('') setFilterScopeId('')
}} }}
> >
</Button> </Button>
</div> </div>
</Card> </Card>
@ -150,7 +150,11 @@ export function JobsPage() {
<Input value={scope} onChange={(e) => setScope(e.target.value)} /> <Input value={scope} onChange={(e) => setScope(e.target.value)} />
</Field> </Field>
<Field label="Scope ID"> <Field label="Scope ID">
<Input value={scopeId} onChange={(e) => setScopeId(e.target.value)} /> <Input
value={scopeId}
readOnly={scope === 'user'}
onChange={(e) => setScopeId(e.target.value)}
/>
</Field> </Field>
</div> </div>
<Field label="Payload JSON"> <Field label="Payload JSON">

View File

@ -329,6 +329,14 @@ export function PersonaOutreachPage() {
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
setPosts([])
setDraftsByPost({})
setSelectedDraftIndex({})
setDraftTextByPost({})
setSelectedPostIds([])
setScanJob(null)
setScanJobId(null)
setError('')
loadActiveScanJob().catch(() => undefined) loadActiveScanJob().catch(() => undefined)
}, [id, loadActiveScanJob]) }, [id, loadActiveScanJob])
@ -468,7 +476,7 @@ export function PersonaOutreachPage() {
if (!topicLoading && !id) { if (!topicLoading && !id) {
return ( return (
<div className="ac-persona-page space-y-8"> <div className="mx-auto w-full max-w-6xl space-y-6">
<PlacementFlowNav <PlacementFlowNav
active="outreach" active="outreach"
topicId="" topicId=""
@ -493,7 +501,7 @@ export function PersonaOutreachPage() {
const allVisibleSelected = posts.length > 0 && selectedPostIds.length === posts.length const allVisibleSelected = posts.length > 0 && selectedPostIds.length === posts.length
return ( return (
<div className="ac-persona-page space-y-8"> <div className="mx-auto w-full max-w-6xl space-y-6">
<PlacementFlowNav <PlacementFlowNav
active="outreach" active="outreach"
topicId={id} topicId={id}

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import { ExpandGraphJobPanel } from '../components/ExpandGraphJobPanel' import { ExpandGraphJobPanel } from '../components/ExpandGraphJobPanel'
import { PlacementFlowNav } from '../components/PlacementFlowNav'
import { PlacementScanJobPanel } from '../components/PlacementScanJobPanel' import { PlacementScanJobPanel } from '../components/PlacementScanJobPanel'
import { ResearchMapOverview } from '../components/ResearchMapOverview' import { ResearchMapOverview } from '../components/ResearchMapOverview'
import { rememberTopicId } from '../lib/brandContext' import { rememberTopicId } from '../lib/brandContext'
@ -25,7 +26,7 @@ import { topicResearchMapPath, topicSettingsPath, topicTitle } from '../lib/plac
import type { ResearchMapDraft } from '../components/ResearchMapEditor' import type { ResearchMapDraft } from '../components/ResearchMapEditor'
import { hasResearchMap } from '../lib/placementTopics' import { hasResearchMap } from '../lib/placementTopics'
import type { BrandData } from '../types/brand' import type { BrandData } from '../types/brand'
import type { PlacementTopicData } from '../types/placementTopic' import type { ListPlacementTopicsData, PlacementTopicData } from '../types/placementTopic'
import type { JobData } from '../types/api' import type { JobData } from '../types/api'
import { AcLink, Button, Card, ErrorText, Notice, PageTitle, SuccessText } from '../components/ui' import { AcLink, Button, Card, ErrorText, Notice, PageTitle, SuccessText } from '../components/ui'
@ -49,6 +50,7 @@ export function PlacementTopicResearchMapPage() {
const [scanning, setScanning] = useState(false) const [scanning, setScanning] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [topics, setTopics] = useState<PlacementTopicData[]>([])
const reloadGraph = useCallback(async () => { const reloadGraph = useCallback(async () => {
if (!id) return null if (!id) return null
@ -137,6 +139,13 @@ export function PlacementTopicResearchMapPage() {
[reloadTopic], [reloadTopic],
) )
useEffect(() => {
api
.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true })
.then((data) => setTopics(data.list ?? []))
.catch(() => setTopics([]))
}, [])
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)
@ -246,7 +255,7 @@ export function PlacementTopicResearchMapPage() {
rememberTopicId(id) rememberTopicId(id)
const data = await api.post<{ job_id: string; message?: string }>( const data = await api.post<{ job_id: string; message?: string }>(
`/api/v1/placement/topics/${encodeURIComponent(id)}/scan-jobs`, `/api/v1/placement/topics/${encodeURIComponent(id)}/scan-jobs`,
{ dual_track: true, patrol_mode: true }, { dual_track: true, patrol_mode: true, patrol_keywords: keywords },
{ auth: true }, { auth: true },
) )
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`) setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
@ -326,12 +335,25 @@ export function PlacementTopicResearchMapPage() {
? '請先產生研究地圖(會自動整理海巡關鍵字)' ? '請先產生研究地圖(會自動整理海巡關鍵字)'
: '' : ''
const onTopicChange = (nextId: string) => {
rememberTopicId(nextId)
navigate(topicResearchMapPath(nextId))
}
return ( return (
<div className="mx-auto w-full max-w-6xl space-y-6"> <div className="mx-auto w-full max-w-6xl space-y-6">
<AcLink to="/placement/topics" className="inline-flex items-center gap-1.5 text-sm"> <AcLink to="/placement/topics" className="inline-flex items-center gap-1.5 text-sm">
TA TA
</AcLink> </AcLink>
<PlacementFlowNav
active="research"
topicId={id}
topics={topics}
onTopicChange={onTopicChange}
topicLoading={loading}
/>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<PageTitle title="研究地圖" subtitle={loading ? '載入中…' : `${title}」的受眾方向與延伸知識。`} /> <PageTitle title="研究地圖" subtitle={loading ? '載入中…' : `${title}」的受眾方向與延伸知識。`} />
<div className="hx-page-actions"> <div className="hx-page-actions">