update dashboard

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

View File

@ -1 +1 @@
65532
91942

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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

View File

@ -1 +1 @@
65800
92316

View File

@ -1 +1 @@
65801
92317

View File

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

View File

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

View File

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

View File

@ -58,7 +58,11 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
continue
}
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("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句")
}
return nil, fmt.Errorf("沒有勾選的節點或可用 tag")
selected := 0
for _, node := range input.Nodes {
if node.SelectedForScan {
selected++
}
}
if selected > 0 {
return nil, fmt.Errorf("已勾選節點但沒有可用的海巡 tag請重新擴展圖譜或手動編輯 tag")
}
return nil, fmt.Errorf("請先勾選要海巡的節點並儲存")
}
merged := map[string]*ScanCandidate{}

View File

@ -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,

View File

@ -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)

View File

@ -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))
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("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")

View File

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

View File

@ -18,6 +18,17 @@ func NewCancelJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelJ
}
func (l *CancelJobLogic) CancelJob(req *types.CancelJobReq) (*types.JobData, error) {
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,

View File

@ -18,11 +18,31 @@ func NewCreateJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJ
}
func (l *CreateJobLogic) CreateJob(req *types.CreateJobReq) (*types.JobData, error) {
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

View File

@ -17,10 +17,17 @@ func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogi
}
func (l *GetJobLogic) GetJob(req *types.JobIDPath) (*types.JobData, error) {
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
}

View File

@ -17,6 +17,17 @@ func NewListJobEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Lis
}
func (l *ListJobEventsLogic) ListJobEvents(req *types.ListJobEventsReq) (*types.JobEventListData, error) {
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

View File

@ -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
}

View File

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

View File

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

View File

@ -17,6 +17,17 @@ func NewRetryJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryJob
}
func (l *RetryJobLogic) RetryJob(req *types.JobIDPath) (*types.JobData, error) {
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

View File

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

View File

@ -50,7 +50,7 @@ func (l *ListPlacementTopicScanPostsLogic) ListPlacementTopicScanPosts(req *type
list := make([]types.ScanPostData, 0, len(posts))
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)

View File

@ -68,8 +68,6 @@ 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
@ -78,8 +76,16 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
productBrief = formatted
}
patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes))
patrolNodes := []libkg.Node{}
if graph != nil {
patrolNodes = graph.Nodes
}
patrolKeywords := libkg.ResolveScanPatrolKeywords(
req.PatrolKeywords,
scope.Topic.ResearchMap.PatrolKeywords,
patrolInput,
patrolNodes,
)
if patrolMode || (len(nodeIDs) == 0 && selected == 0) {
if 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 {

View File

@ -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"`

View File

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

View File

@ -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)

View File

@ -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}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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"`

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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{
if topicID != "" {
return 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
"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

View File

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

View File

@ -59,27 +59,27 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
return len(entities), nil
}
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,

View File

@ -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,

View File

@ -325,6 +325,7 @@ type GenerateOutreachDraftsData struct {
type GenerateOutreachDraftsHandlerReq struct {
BrandPath
GenerateOutreachDraftsReq
TopicID string `json:"-"`
}
type GenerateOutreachDraftsReq struct {
@ -915,6 +916,7 @@ type StartBrandScanJobReq struct {
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 {

View File

@ -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 == "" {
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,

View File

@ -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 {

View File

@ -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">

View File

@ -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}

View File

@ -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">