update dashboard
This commit is contained in:
parent
66ef6b3d4a
commit
a66a3d81ee
|
|
@ -1 +1 @@
|
|||
65532
|
||||
91942
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,7 +3,7 @@
|
|||
> vite
|
||||
|
||||
|
||||
VITE v6.4.3 ready in 167 ms
|
||||
VITE v6.4.3 ready in 111 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
> haixun-master@0.1.0 worker:style-8d
|
||||
> . 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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
65800
|
||||
92316
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
65801
|
||||
92317
|
||||
|
|
|
|||
|
|
@ -179,10 +179,11 @@ type (
|
|||
}
|
||||
|
||||
StartBrandScanJobReq {
|
||||
GraphID string `json:"graph_id,optional"`
|
||||
NodeIDs []string `json:"node_ids,optional"`
|
||||
DualTrack bool `json:"dual_track,optional"`
|
||||
PatrolMode bool `json:"patrol_mode,optional"`
|
||||
GraphID string `json:"graph_id,optional"`
|
||||
NodeIDs []string `json:"node_ids,optional"`
|
||||
DualTrack bool `json:"dual_track,optional"`
|
||||
PatrolMode bool `json:"patrol_mode,optional"`
|
||||
PatrolKeywords []string `json:"patrol_keywords,optional"`
|
||||
}
|
||||
|
||||
StartBrandScanJobData {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,11 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
|||
continue
|
||||
}
|
||||
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)
|
||||
if tag == "" {
|
||||
continue
|
||||
|
|
@ -75,7 +79,7 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
|||
ProductFitScore: fit,
|
||||
})
|
||||
}
|
||||
for _, tag := range node.DerivedTags.Recency {
|
||||
for _, tag := range derived.Recency {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
|
|
@ -114,7 +118,16 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
if len(input.PatrolKeywords) > 0 {
|
||||
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
||||
}
|
||||
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{}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate
|
|||
TenantID: tenantID,
|
||||
OwnerUID: uid,
|
||||
BrandID: req.ID,
|
||||
TopicID: strings.TrimSpace(req.TopicID),
|
||||
ScanPostID: scanPostID,
|
||||
Relevance: parsed.Relevance,
|
||||
Reason: parsed.Reason,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func (l *ListBrandScanPostsLogic) ListBrandScanPosts(req *types.ListBrandScanPos
|
|||
list := make([]types.ScanPostData, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
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
|
||||
} else if draft != nil {
|
||||
mapped.LatestDraft = toOutreachDraftData(draft)
|
||||
|
|
|
|||
|
|
@ -85,15 +85,21 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH
|
|||
}
|
||||
dualTrack := true
|
||||
patrolMode := req.PatrolMode
|
||||
patrolKeywords := libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords)
|
||||
if len(patrolKeywords) == 0 && graph != nil {
|
||||
productBrief := strings.TrimSpace(brand.ProductBrief)
|
||||
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
||||
productBrief = formatted
|
||||
}
|
||||
patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
|
||||
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes))
|
||||
productBrief := strings.TrimSpace(brand.ProductBrief)
|
||||
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
||||
productBrief = formatted
|
||||
}
|
||||
patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
|
||||
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 len(patrolKeywords) == 0 {
|
||||
return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -18,6 +18,17 @@ func NewCancelJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelJ
|
|||
}
|
||||
|
||||
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{
|
||||
JobID: req.ID,
|
||||
Reason: req.Reason,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,31 @@ func NewCreateJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJ
|
|||
}
|
||||
|
||||
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{
|
||||
TemplateType: req.TemplateType,
|
||||
Scope: req.Scope,
|
||||
ScopeID: req.ScopeID,
|
||||
Payload: req.Payload,
|
||||
ScopeID: scopeID,
|
||||
TenantID: tenantID,
|
||||
OwnerUID: uid,
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -17,10 +17,17 @@ func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogi
|
|||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureRunAccess(run, tenantID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := toJobData(run)
|
||||
return &data, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,17 @@ func NewListJobEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Lis
|
|||
}
|
||||
|
||||
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
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package job
|
|||
import (
|
||||
"context"
|
||||
|
||||
domrepo "haixun-backend/internal/model/job/domain/repository"
|
||||
"haixun-backend/internal/svc"
|
||||
"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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,17 @@ func NewRetryJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryJob
|
|||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea
|
|||
VoicePersonaID: req.VoicePersonaID,
|
||||
ProductID: scope.Topic.ProductID,
|
||||
},
|
||||
TopicID: scope.TopicID,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func (l *ListPlacementTopicScanPostsLogic) ListPlacementTopicScanPosts(req *type
|
|||
list := make([]types.ScanPostData, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
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
|
||||
} else if draft != nil {
|
||||
mapped.LatestDraft = toOutreachDraftData(draft)
|
||||
|
|
|
|||
|
|
@ -68,18 +68,24 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
|
|||
}
|
||||
dualTrack := true
|
||||
patrolMode := req.PatrolMode
|
||||
patrolKeywords := libkg.NormalizePatrolKeywordList(scope.Topic.ResearchMap.PatrolKeywords)
|
||||
if len(patrolKeywords) == 0 && graph != nil {
|
||||
brandForPatrol := scope.Brand
|
||||
brandForPatrol.ProductID = scope.Topic.ProductID
|
||||
brandForPatrol.ResearchMap = scope.Topic.ResearchMap
|
||||
productBrief := strings.TrimSpace(brandForPatrol.ProductBrief)
|
||||
if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" {
|
||||
productBrief = formatted
|
||||
}
|
||||
patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
|
||||
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes))
|
||||
brandForPatrol := scope.Brand
|
||||
brandForPatrol.ProductID = scope.Topic.ProductID
|
||||
brandForPatrol.ResearchMap = scope.Topic.ResearchMap
|
||||
productBrief := strings.TrimSpace(brandForPatrol.ProductBrief)
|
||||
if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" {
|
||||
productBrief = formatted
|
||||
}
|
||||
patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
|
||||
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 len(patrolKeywords) == 0 {
|
||||
return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
|
||||
|
|
@ -126,6 +132,8 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
|
|||
TemplateType: "placement-scan",
|
||||
Scope: "placement_topic",
|
||||
ScopeID: scope.TopicID,
|
||||
TenantID: tenantID,
|
||||
OwnerUID: uid,
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ type RunProgress struct {
|
|||
|
||||
type Run struct {
|
||||
ID primitive.ObjectID `bson:"_id,omitempty"`
|
||||
TenantID string `bson:"tenant_id,omitempty"`
|
||||
OwnerUID string `bson:"owner_uid,omitempty"`
|
||||
TemplateType string `bson:"template_type"`
|
||||
TemplateVersion int `bson:"template_version"`
|
||||
Scope string `bson:"scope"`
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
type RunListFilter struct {
|
||||
Scope string
|
||||
ScopeID string
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
Statuses []enum.RunStatus
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import (
|
|||
"context"
|
||||
|
||||
"haixun-backend/internal/model/job/domain/entity"
|
||||
"haixun-backend/internal/model/job/domain/repository"
|
||||
)
|
||||
|
||||
type CreateRunRequest struct {
|
||||
TemplateType string
|
||||
Scope string
|
||||
ScopeID string
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ type UseCase interface {
|
|||
|
||||
CreateRun(ctx context.Context, req CreateRunRequest) (*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)
|
||||
RetryRun(ctx context.Context, jobID string) (*entity.Run, error)
|
||||
ListJobEvents(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ func (r *mongoRunRepository) EnsureIndexes(ctx context.Context) error {
|
|||
}
|
||||
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: "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: "dedupe_key", Value: 1}}},
|
||||
}
|
||||
|
|
@ -161,16 +163,7 @@ func (r *mongoRunRepository) List(ctx context.Context, filter domrepo.RunListFil
|
|||
if r.collection == nil {
|
||||
return nil, 0, app.For(code.Job).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
query := bson.M{}
|
||||
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}
|
||||
}
|
||||
query := buildRunListQuery(filter)
|
||||
|
||||
total, err := r.collection.CountDocuments(ctx, query)
|
||||
if err != nil {
|
||||
|
|
@ -315,3 +308,46 @@ func (r *mongoRunRepository) FindRunningTimedOut(ctx context.Context, now int64,
|
|||
}
|
||||
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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,36 @@ import (
|
|||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/model/job/domain/entity"
|
||||
"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 {
|
||||
parts := []string{template.Type, scope}
|
||||
for _, key := range template.DedupeKeys {
|
||||
|
|
|
|||
|
|
@ -241,7 +241,10 @@ func (u *jobUseCase) CreateRun(ctx context.Context, req domusecase.CreateRunRequ
|
|||
return nil, err
|
||||
}
|
||||
|
||||
tenantID, ownerUID := extractRunActor(req)
|
||||
run := &entity.Run{
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
TemplateType: template.Type,
|
||||
TemplateVersion: template.Version,
|
||||
Scope: scope,
|
||||
|
|
@ -307,7 +310,7 @@ func (u *jobUseCase) GetRun(ctx context.Context, jobID string) (*entity.Run, err
|
|||
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 {
|
||||
page = 1
|
||||
}
|
||||
|
|
@ -318,10 +321,7 @@ func (u *jobUseCase) ListRuns(ctx context.Context, scope, scopeID string, page,
|
|||
pageSize = 200
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
items, total, err := u.runs.List(ctx, domrepo.RunListFilter{
|
||||
Scope: scope,
|
||||
ScopeID: scopeID,
|
||||
}, offset, pageSize)
|
||||
items, total, err := u.runs.List(ctx, filter, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, 0, 0, 0, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type OutreachDraft struct {
|
|||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
BrandID string `bson:"brand_id"`
|
||||
TopicID string `bson:"topic_id,omitempty"`
|
||||
LegacyPersonaID string `bson:"persona_id,omitempty"`
|
||||
ScanPostID string `bson:"scan_post_id"`
|
||||
Relevance float64 `bson:"relevance"`
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@ import (
|
|||
type Repository interface {
|
||||
EnsureIndexes(ctx context.Context) 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type CreateRequest struct {
|
|||
TenantID string
|
||||
OwnerUID string
|
||||
BrandID string
|
||||
TopicID string
|
||||
ScanPostID string
|
||||
Relevance float64
|
||||
Reason string
|
||||
|
|
@ -32,5 +33,5 @@ type CreateRequest struct {
|
|||
|
||||
type UseCase interface {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,14 +75,23 @@ func (r *mongoRepository) Create(ctx context.Context, draft *entity.OutreachDraf
|
|||
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(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, brandID, scanPostID string,
|
||||
tenantID, ownerUID, brandID, topicID, scanPostID string,
|
||||
) (*entity.OutreachDraft, error) {
|
||||
if r.collection == nil {
|
||||
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)
|
||||
opts := options.FindOne().SetSort(bson.D{{Key: "create_at", Value: -1}})
|
||||
var out entity.OutreachDraft
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create
|
|||
TenantID: req.TenantID,
|
||||
OwnerUID: req.OwnerUID,
|
||||
BrandID: req.BrandID,
|
||||
TopicID: strings.TrimSpace(req.TopicID),
|
||||
ScanPostID: scanPostID,
|
||||
Relevance: req.Relevance,
|
||||
Reason: req.Reason,
|
||||
|
|
@ -57,7 +58,7 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create
|
|||
|
||||
func (u *outreachDraftUseCase) GetLatestByScanPost(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, brandID, scanPostID string,
|
||||
tenantID, ownerUID, brandID, topicID, scanPostID string,
|
||||
) (*domusecase.DraftSummary, error) {
|
||||
if err := requireActor(tenantID, ownerUID, brandID); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -66,7 +67,7 @@ func (u *outreachDraftUseCase) GetLatestByScanPost(
|
|||
if scanPostID == "" {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ type PersonaListFilter struct {
|
|||
|
||||
type Repository interface {
|
||||
EnsureIndexes(ctx context.Context) error
|
||||
ClearForBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error
|
||||
UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID string, posts []entity.ScanPost) (int, error)
|
||||
PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, keepPermalinks []string) error
|
||||
ClearForPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
|
||||
UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string, posts []entity.ScanPost) (int, 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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type ReplaceRequest struct {
|
|||
TenantID string
|
||||
OwnerUID string
|
||||
BrandID string
|
||||
TopicID string
|
||||
GraphID string
|
||||
ScanJobID string
|
||||
Posts []placement.ScanCandidate
|
||||
|
|
@ -94,13 +95,14 @@ type CheckpointRequest struct {
|
|||
TenantID string
|
||||
OwnerUID string
|
||||
BrandID string
|
||||
TopicID string
|
||||
GraphID string
|
||||
ScanJobID string
|
||||
Posts []placement.ScanCandidate
|
||||
}
|
||||
|
||||
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)
|
||||
FinalizeScan(ctx context.Context, req ReplaceRequest) (int, error)
|
||||
ReplaceFromScan(ctx context.Context, req ReplaceRequest) (int, error)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
|
|||
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}},
|
||||
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}},
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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 == "" {
|
||||
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
|
||||
|
||||
var existing entity.ScanPost
|
||||
|
|
@ -130,6 +173,12 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
|
|||
post.PostedAt = existing.PostedAt
|
||||
}
|
||||
}
|
||||
if writeTopicID != "" {
|
||||
post.TopicID = writeTopicID
|
||||
}
|
||||
if strings.TrimSpace(post.BrandID) == "" {
|
||||
post.BrandID = brandID
|
||||
}
|
||||
if strings.TrimSpace(post.ID) == "" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -161,11 +210,11 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
|
|||
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 {
|
||||
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)
|
||||
if len(keepPermalinks) > 0 {
|
||||
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 {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
brandID = strings.TrimSpace(brandID)
|
||||
filter := bson.M{
|
||||
"tenant_id": tenantID,
|
||||
"owner_uid": ownerUID,
|
||||
}
|
||||
if topicID != "" {
|
||||
legacy := bson.M{"topic_id": bson.M{"$in": []interface{}{nil, ""}}}
|
||||
if brandID != "" {
|
||||
for k, v := range libmongo.BrandScopeFilter(brandID) {
|
||||
legacy[k] = v
|
||||
}
|
||||
return bson.M{
|
||||
"tenant_id": tenantID,
|
||||
"owner_uid": ownerUID,
|
||||
"topic_id": topicID,
|
||||
}
|
||||
filter["$or"] = []bson.M{
|
||||
{"topic_id": topicID},
|
||||
legacy,
|
||||
}
|
||||
return filter
|
||||
}
|
||||
for k, v := range libmongo.BrandScopeFilter(brandID) {
|
||||
filter[k] = v
|
||||
}
|
||||
return filter
|
||||
return brandOwnerFilter(tenantID, ownerUID, brandID)
|
||||
}
|
||||
|
||||
func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
|
||||
if r.collection == nil {
|
||||
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(
|
||||
ctx,
|
||||
brandOwnerFilter(tenantID, ownerUID, brandID),
|
||||
filter,
|
||||
bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}},
|
||||
)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,27 +59,27 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
|
|||
return len(entities), nil
|
||||
}
|
||||
|
||||
func (u *scanPostUseCase) ClearBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error {
|
||||
if err := requireActor(tenantID, ownerUID, brandID); err != 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.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) {
|
||||
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
|
||||
}
|
||||
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts)
|
||||
return u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, entities)
|
||||
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 := 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
|
||||
}
|
||||
entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts)
|
||||
count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, entities)
|
||||
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
|
||||
}
|
||||
|
|
@ -89,24 +89,24 @@ func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.Repla
|
|||
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, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return 0, err
|
||||
}
|
||||
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()
|
||||
entities := make([]entity.ScanPost, 0, len(posts))
|
||||
for _, item := range posts {
|
||||
|
|
@ -118,6 +118,7 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, graphID, scanJob
|
|||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
BrandID: brandID,
|
||||
TopicID: strings.TrimSpace(topicID),
|
||||
Flow: entity.FlowPlacement,
|
||||
GraphID: graphID,
|
||||
ScanJobID: scanJobID,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||
jobworker.RegisterScanPlacementHandler(runner, jobworker.ScanPlacementDeps{
|
||||
Jobs: jobUseCase,
|
||||
Brand: brandUseCase,
|
||||
PlacementTopic: placementTopicUseCase,
|
||||
KnowledgeGraph: knowledgeGraphUseCase,
|
||||
ScanPost: scanPostUseCase,
|
||||
ThreadsAccount: threadsAccountUseCase,
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ type GenerateOutreachDraftsData struct {
|
|||
type GenerateOutreachDraftsHandlerReq struct {
|
||||
BrandPath
|
||||
GenerateOutreachDraftsReq
|
||||
TopicID string `json:"-"`
|
||||
}
|
||||
|
||||
type GenerateOutreachDraftsReq struct {
|
||||
|
|
@ -911,10 +912,11 @@ type StartBrandScanJobHandlerReq struct {
|
|||
}
|
||||
|
||||
type StartBrandScanJobReq struct {
|
||||
GraphID string `json:"graph_id,optional"`
|
||||
NodeIDs []string `json:"node_ids,optional"`
|
||||
DualTrack bool `json:"dual_track,optional"`
|
||||
PatrolMode bool `json:"patrol_mode,optional"`
|
||||
GraphID string `json:"graph_id,optional"`
|
||||
NodeIDs []string `json:"node_ids,optional"`
|
||||
DualTrack bool `json:"dual_track,optional"`
|
||||
PatrolMode bool `json:"patrol_mode,optional"`
|
||||
PatrolKeywords []string `json:"patrol_keywords,optional"`
|
||||
}
|
||||
|
||||
type StartPersonaStyleAnalysisData struct {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
branddomain "haixun-backend/internal/model/brand/domain/usecase"
|
||||
jobdom "haixun-backend/internal/model/job/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"
|
||||
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
|
||||
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||
|
|
@ -21,6 +22,7 @@ import (
|
|||
type ScanPlacementDeps struct {
|
||||
Jobs jobdom.UseCase
|
||||
Brand branddomain.UseCase
|
||||
PlacementTopic placementtopicdomain.UseCase
|
||||
KnowledgeGraph kgusecase.UseCase
|
||||
ScanPost scanpostusecase.UseCase
|
||||
ThreadsAccount threadsaccountdomain.UseCase
|
||||
|
|
@ -57,46 +59,78 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
|
|||
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 patrolMode && isKnowledgeGraphNotFound(graphErr) {
|
||||
graph = nil
|
||||
graphSummary = nil
|
||||
if graphID == "" {
|
||||
graphID = brandID
|
||||
if topicID != "" {
|
||||
graphID = topicID
|
||||
} else {
|
||||
graphID = brandID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return graphErr
|
||||
}
|
||||
} 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{}
|
||||
if patrolMode {
|
||||
patrolKeywords = stringSliceField(payload, "patrol_keywords")
|
||||
if len(patrolKeywords) == 0 {
|
||||
patrolKeywords = libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords)
|
||||
productBrief := strings.TrimSpace(brand.ProductBrief)
|
||||
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
||||
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 {
|
||||
return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字")
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []libkg.Node{}
|
||||
if graph != nil {
|
||||
nodes = graph.Nodes
|
||||
if graphSummary != nil {
|
||||
nodes = graphSummary.Nodes
|
||||
}
|
||||
if !patrolMode {
|
||||
if graph == nil {
|
||||
if graphSummary == nil {
|
||||
return fmt.Errorf("請先產生延伸知識圖譜,或改用手動海巡關鍵字")
|
||||
}
|
||||
if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 {
|
||||
nodes = filterNodesByIDs(graph.Nodes, ids)
|
||||
nodes = filterNodesByIDs(graphSummary.Nodes, ids)
|
||||
} else {
|
||||
nodes = selectedNodes(graph.Nodes)
|
||||
nodes = selectedNodes(graphSummary.Nodes)
|
||||
}
|
||||
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 {
|
||||
updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5)
|
||||
|
|
@ -140,20 +174,21 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
|
|||
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
|
||||
}
|
||||
|
||||
braveClient := libbrave.NewClient(memberCtx.BraveAPIKey)
|
||||
crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID))
|
||||
graphNodes := []libkg.Node{}
|
||||
if graph != nil {
|
||||
graphNodes = graph.Nodes
|
||||
if graphSummary != nil {
|
||||
graphNodes = graphSummary.Nodes
|
||||
}
|
||||
checkpointReq := scanpostusecase.CheckpointRequest{
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
BrandID: brandID,
|
||||
TopicID: topicID,
|
||||
GraphID: graphID,
|
||||
ScanJobID: step.JobID,
|
||||
}
|
||||
|
|
@ -202,6 +237,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
|
|||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
BrandID: brandID,
|
||||
TopicID: topicID,
|
||||
GraphID: graphID,
|
||||
ScanJobID: step.JobID,
|
||||
Posts: candidates,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -4698,7 +4698,7 @@ th {
|
|||
border: 2px solid color-mix(in srgb, var(--hx-brand) 25%, var(--hx-line) 75%);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--hx-surface);
|
||||
padding: 0.85rem 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
|
@ -4706,12 +4706,26 @@ th {
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: 1.25rem;
|
||||
min-height: 5.75rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ac-persona-flow-nav__head {
|
||||
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 {
|
||||
|
|
@ -4838,10 +4852,24 @@ th {
|
|||
.ac-brand-switcher {
|
||||
display: grid;
|
||||
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 {
|
||||
padding: 0.25rem 0;
|
||||
min-height: 5.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.15rem;
|
||||
}
|
||||
|
||||
.ac-brand-switcher__link {
|
||||
|
|
|
|||
|
|
@ -111,17 +111,17 @@ export function JobsPage() {
|
|||
</Card>
|
||||
<Card className="mb-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="列表 Scope 篩選(空白=全部)">
|
||||
<Field label="列表 Scope 篩選(空白=我的全部任務)">
|
||||
<Input
|
||||
value={filterScope}
|
||||
placeholder="例如 placement_topic / persona / user"
|
||||
placeholder="例如 placement_topic / persona / brand"
|
||||
onChange={(e) => setFilterScope(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="列表 Scope ID 篩選(空白=全部)">
|
||||
<Field label="列表 Scope ID 篩選(可選)">
|
||||
<Input
|
||||
value={filterScopeId}
|
||||
placeholder="主題 ID、人設 ID 或會員 ID"
|
||||
placeholder="主題 ID、品牌 ID 或人設 ID"
|
||||
onChange={(e) => setFilterScopeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -137,7 +137,7 @@ export function JobsPage() {
|
|||
setFilterScopeId('')
|
||||
}}
|
||||
>
|
||||
顯示全部任務
|
||||
清除篩選
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -150,7 +150,11 @@ export function JobsPage() {
|
|||
<Input value={scope} onChange={(e) => setScope(e.target.value)} />
|
||||
</Field>
|
||||
<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>
|
||||
</div>
|
||||
<Field label="Payload JSON">
|
||||
|
|
|
|||
|
|
@ -329,6 +329,14 @@ export function PersonaOutreachPage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setPosts([])
|
||||
setDraftsByPost({})
|
||||
setSelectedDraftIndex({})
|
||||
setDraftTextByPost({})
|
||||
setSelectedPostIds([])
|
||||
setScanJob(null)
|
||||
setScanJobId(null)
|
||||
setError('')
|
||||
loadActiveScanJob().catch(() => undefined)
|
||||
}, [id, loadActiveScanJob])
|
||||
|
||||
|
|
@ -468,7 +476,7 @@ export function PersonaOutreachPage() {
|
|||
|
||||
if (!topicLoading && !id) {
|
||||
return (
|
||||
<div className="ac-persona-page space-y-8">
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<PlacementFlowNav
|
||||
active="outreach"
|
||||
topicId=""
|
||||
|
|
@ -493,7 +501,7 @@ export function PersonaOutreachPage() {
|
|||
const allVisibleSelected = posts.length > 0 && selectedPostIds.length === posts.length
|
||||
|
||||
return (
|
||||
<div className="ac-persona-page space-y-8">
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<PlacementFlowNav
|
||||
active="outreach"
|
||||
topicId={id}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import { ExpandGraphJobPanel } from '../components/ExpandGraphJobPanel'
|
||||
import { PlacementFlowNav } from '../components/PlacementFlowNav'
|
||||
import { PlacementScanJobPanel } from '../components/PlacementScanJobPanel'
|
||||
import { ResearchMapOverview } from '../components/ResearchMapOverview'
|
||||
import { rememberTopicId } from '../lib/brandContext'
|
||||
|
|
@ -25,7 +26,7 @@ import { topicResearchMapPath, topicSettingsPath, topicTitle } from '../lib/plac
|
|||
import type { ResearchMapDraft } from '../components/ResearchMapEditor'
|
||||
import { hasResearchMap } from '../lib/placementTopics'
|
||||
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 { AcLink, Button, Card, ErrorText, Notice, PageTitle, SuccessText } from '../components/ui'
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export function PlacementTopicResearchMapPage() {
|
|||
const [scanning, setScanning] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [topics, setTopics] = useState<PlacementTopicData[]>([])
|
||||
|
||||
const reloadGraph = useCallback(async () => {
|
||||
if (!id) return null
|
||||
|
|
@ -137,6 +139,13 @@ export function PlacementTopicResearchMapPage() {
|
|||
[reloadTopic],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true })
|
||||
.then((data) => setTopics(data.list ?? []))
|
||||
.catch(() => setTopics([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
|
|
@ -246,7 +255,7 @@ export function PlacementTopicResearchMapPage() {
|
|||
rememberTopicId(id)
|
||||
const data = await api.post<{ job_id: string; message?: string }>(
|
||||
`/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 },
|
||||
)
|
||||
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
|
||||
|
|
@ -326,12 +335,25 @@ export function PlacementTopicResearchMapPage() {
|
|||
? '請先產生研究地圖(會自動整理海巡關鍵字)'
|
||||
: ''
|
||||
|
||||
const onTopicChange = (nextId: string) => {
|
||||
rememberTopicId(nextId)
|
||||
navigate(topicResearchMapPath(nextId))
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
← 找 TA 主題
|
||||
</AcLink>
|
||||
|
||||
<PlacementFlowNav
|
||||
active="research"
|
||||
topicId={id}
|
||||
topics={topics}
|
||||
onTopicChange={onTopicChange}
|
||||
topicLoading={loading}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageTitle title="研究地圖" subtitle={loading ? '載入中…' : `「${title}」的受眾方向與延伸知識。`} />
|
||||
<div className="hx-page-actions">
|
||||
|
|
|
|||
Loading…
Reference in New Issue