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
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
65800
|
92316
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
65801
|
92317
|
||||||
|
|
|
||||||
|
|
@ -179,10 +179,11 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
StartBrandScanJobReq {
|
StartBrandScanJobReq {
|
||||||
GraphID string `json:"graph_id,optional"`
|
GraphID string `json:"graph_id,optional"`
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
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("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
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{}
|
merged := map[string]*ScanCandidate{}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
productBrief := strings.TrimSpace(brand.ProductBrief)
|
||||||
if len(patrolKeywords) == 0 && graph != nil {
|
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
||||||
productBrief := strings.TrimSpace(brand.ProductBrief)
|
productBrief = formatted
|
||||||
if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
|
||||||
productBrief = formatted
|
|
||||||
}
|
|
||||||
patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
|
|
||||||
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes))
|
|
||||||
}
|
}
|
||||||
|
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 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("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -68,18 +68,24 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
|
||||||
}
|
}
|
||||||
dualTrack := true
|
dualTrack := true
|
||||||
patrolMode := req.PatrolMode
|
patrolMode := req.PatrolMode
|
||||||
patrolKeywords := libkg.NormalizePatrolKeywordList(scope.Topic.ResearchMap.PatrolKeywords)
|
brandForPatrol := scope.Brand
|
||||||
if len(patrolKeywords) == 0 && graph != nil {
|
brandForPatrol.ProductID = scope.Topic.ProductID
|
||||||
brandForPatrol := scope.Brand
|
brandForPatrol.ResearchMap = scope.Topic.ResearchMap
|
||||||
brandForPatrol.ProductID = scope.Topic.ProductID
|
productBrief := strings.TrimSpace(brandForPatrol.ProductBrief)
|
||||||
brandForPatrol.ResearchMap = scope.Topic.ResearchMap
|
if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" {
|
||||||
productBrief := strings.TrimSpace(brandForPatrol.ProductBrief)
|
productBrief = formatted
|
||||||
if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" {
|
|
||||||
productBrief = formatted
|
|
||||||
}
|
|
||||||
patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
|
|
||||||
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes))
|
|
||||||
}
|
}
|
||||||
|
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 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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
filter := bson.M{
|
|
||||||
"tenant_id": tenantID,
|
|
||||||
"owner_uid": ownerUID,
|
|
||||||
}
|
|
||||||
if topicID != "" {
|
if topicID != "" {
|
||||||
legacy := bson.M{"topic_id": bson.M{"$in": []interface{}{nil, ""}}}
|
return bson.M{
|
||||||
if brandID != "" {
|
"tenant_id": tenantID,
|
||||||
for k, v := range libmongo.BrandScopeFilter(brandID) {
|
"owner_uid": ownerUID,
|
||||||
legacy[k] = v
|
"topic_id": topicID,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
filter["$or"] = []bson.M{
|
|
||||||
{"topic_id": topicID},
|
|
||||||
legacy,
|
|
||||||
}
|
|
||||||
return filter
|
|
||||||
}
|
}
|
||||||
for k, v := range libmongo.BrandScopeFilter(brandID) {
|
return brandOwnerFilter(tenantID, ownerUID, 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -911,10 +912,11 @@ type StartBrandScanJobHandlerReq struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartBrandScanJobReq struct {
|
type StartBrandScanJobReq struct {
|
||||||
GraphID string `json:"graph_id,optional"`
|
GraphID string `json:"graph_id,optional"`
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
graphID = brandID
|
if topicID != "" {
|
||||||
|
graphID = topicID
|
||||||
|
} else {
|
||||||
|
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,
|
||||||
|
|
|
||||||
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: 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 {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue