fix all
This commit is contained in:
parent
7e58bdba45
commit
e2dc98d426
|
|
@ -1 +1 @@
|
||||||
19005
|
37538
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,22 +3,7 @@
|
||||||
> vite
|
> vite
|
||||||
|
|
||||||
|
|
||||||
VITE v6.4.3 ready in 134 ms
|
VITE v6.4.3 ready in 221 ms
|
||||||
|
|
||||||
➜ Local: http://localhost:5173/
|
➜ Local: http://localhost:5173/
|
||||||
➜ Network: use --host to expose
|
➜ Network: use --host to expose
|
||||||
9:30:54 AM [vite] (client) hmr update /src/components/OnboardingRouteGuard.tsx, /src/index.css, /src/components/MobileBottomNav.tsx, /src/components/AppSidebar.tsx, /src/onboarding/OnboardingContext.tsx
|
|
||||||
9:30:54 AM [vite] (client) hmr update /src/index.css
|
|
||||||
9:30:55 AM [vite] (client) hmr invalidate /src/onboarding/OnboardingContext.tsx Could not Fast Refresh ("useOnboarding" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
|
||||||
9:30:55 AM [vite] (client) hmr update /src/components/Layout.tsx, /src/index.css, /src/pages/PersonasPage.tsx, /src/components/OnboardingRouteGuard.tsx, /src/components/MobileBottomNav.tsx, /src/components/AccountSwitcher.tsx, /src/components/OnboardingBanner.tsx, /src/components/AppSidebar.tsx, /src/components/AccountConnectionMode.tsx, /src/components/islander/IslanderCompanion.tsx, /src/components/DevToolsPanel.tsx
|
|
||||||
9:31:02 AM [vite] (client) hmr update /src/components/AccountSwitcher.tsx, /src/index.css
|
|
||||||
9:31:02 AM [vite] (client) hmr update /src/components/MobileBottomNav.tsx, /src/index.css
|
|
||||||
9:31:02 AM [vite] (client) hmr update /src/components/AppSidebar.tsx, /src/index.css
|
|
||||||
9:31:12 AM [vite] (client) hmr update /src/components/OnboardingBanner.tsx, /src/index.css
|
|
||||||
9:31:12 AM [vite] (client) hmr update /src/components/ui.tsx, /src/index.css
|
|
||||||
9:31:12 AM [vite] (client) hmr update /src/pages/PersonasPage.tsx, /src/index.css
|
|
||||||
9:31:12 AM [vite] (client) hmr update /src/pages/ThreadsAccountConnectionsPage.tsx, /src/index.css
|
|
||||||
9:31:12 AM [vite] (client) hmr update /src/pages/SettingsPage.tsx, /src/index.css
|
|
||||||
9:31:18 AM [vite] (client) hmr update /src/pages/PersonasPage.tsx, /src/index.css
|
|
||||||
9:31:18 AM [vite] (client) hmr update /src/pages/SettingsPage.tsx, /src/index.css
|
|
||||||
9:31:23 AM [vite] (client) hmr update /src/index.css
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +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-19105 api=http://127.0.0.1:8890
|
[8d-worker] started id=local-style-8d-node-37646 api=http://127.0.0.1:8890
|
||||||
[8d-worker] claimed job=6a3b33a151becf68faf9ecf9 template=style-8d
|
|
||||||
[8d-worker] completed job=6a3b33a151becf68faf9ecf9 username=petopia_tw posts=12
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
19030
|
37575
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
19031
|
37576
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
# Agent Handoff Notes
|
# Agent Handoff Notes
|
||||||
|
|
||||||
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ service gateway {
|
||||||
@server (
|
@server (
|
||||||
group: ai
|
group: ai
|
||||||
prefix: /api/v1/ai
|
prefix: /api/v1/ai
|
||||||
middleware: Auth
|
middleware: AuthJWT
|
||||||
tags: "AI - Islander Guide"
|
tags: "AI - Islander Guide"
|
||||||
summary: "Floating islander chat; member JWT via Authorization; AI key from member settings"
|
summary: "Floating islander chat; member JWT via Authorization; AI key from member settings"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
type (
|
||||||
|
ResearchMapData {
|
||||||
|
AudienceSummary string `json:"audience_summary,omitempty"`
|
||||||
|
ContentGoal string `json:"content_goal,omitempty"`
|
||||||
|
Questions []string `json:"questions,omitempty"`
|
||||||
|
Pillars []string `json:"pillars,omitempty"`
|
||||||
|
Exclusions []string `json:"exclusions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
BrandData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
SeedQuery string `json:"seed_query,omitempty"`
|
||||||
|
Brief string `json:"brief,omitempty"`
|
||||||
|
ProductBrief string `json:"product_brief,omitempty"`
|
||||||
|
ProductContext string `json:"product_context,omitempty"`
|
||||||
|
TargetAudience string `json:"target_audience,omitempty"`
|
||||||
|
Goals string `json:"goals,omitempty"`
|
||||||
|
ResearchMap ResearchMapData `json:"research_map,omitempty"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
UpdateAt int64 `json:"update_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListBrandsData {
|
||||||
|
List []BrandData `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateBrandReq {
|
||||||
|
DisplayName string `json:"display_name,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
BrandPath {
|
||||||
|
ID string `path:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateBrandReq {
|
||||||
|
DisplayName *string `json:"display_name,optional"`
|
||||||
|
SeedQuery *string `json:"seed_query,optional"`
|
||||||
|
Brief *string `json:"brief,optional"`
|
||||||
|
ProductBrief *string `json:"product_brief,optional"`
|
||||||
|
ProductContext *string `json:"product_context,optional"`
|
||||||
|
TargetAudience *string `json:"target_audience,optional"`
|
||||||
|
Goals *string `json:"goals,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeGraphNodeData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
NodeKind string `json:"node_kind"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Layer int `json:"layer"`
|
||||||
|
Relation string `json:"relation,omitempty"`
|
||||||
|
PlacementValue string `json:"placement_value,omitempty"`
|
||||||
|
ProductFitScore int `json:"product_fit_score"`
|
||||||
|
SelectedForScan bool `json:"selected_for_scan"`
|
||||||
|
RelevanceTags []string `json:"relevance_tags"`
|
||||||
|
RecencyTags []string `json:"recency_tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeGraphEdgeData {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeGraphData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BrandID string `json:"brand_id"`
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
Nodes []KnowledgeGraphNodeData `json:"nodes"`
|
||||||
|
Edges []KnowledgeGraphEdgeData `json:"edges"`
|
||||||
|
PainTagCount int `json:"pain_tag_count"`
|
||||||
|
GeneratedAt int64 `json:"generated_at"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
UpdateAt int64 `json:"update_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpandKnowledgeGraphReq {
|
||||||
|
SeedQuery string `json:"seed_query" validate:"required"`
|
||||||
|
Supplemental bool `json:"supplemental,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpandKnowledgeGraphData {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeGraphNodeUpdate {
|
||||||
|
NodeID string `json:"node_id" validate:"required"`
|
||||||
|
SelectedForScan bool `json:"selected_for_scan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchKnowledgeGraphNodesReq {
|
||||||
|
Updates []KnowledgeGraphNodeUpdate `json:"updates" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
StartBrandScanJobReq {
|
||||||
|
GraphID string `json:"graph_id,optional"`
|
||||||
|
NodeIDs []string `json:"node_ids,optional"`
|
||||||
|
DualTrack bool `json:"dual_track,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
StartBrandScanJobData {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListBrandScanPostsReq {
|
||||||
|
Priority string `form:"priority,optional"`
|
||||||
|
Recent7d bool `form:"recent_7d,optional"`
|
||||||
|
ProductFitMin int `form:"product_fit_min,optional"`
|
||||||
|
Limit int `form:"limit,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanPostData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GraphNodeID string `json:"graph_node_id"`
|
||||||
|
SearchTag string `json:"search_tag"`
|
||||||
|
QueryDimension string `json:"query_dimension"`
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
PlacementScore int `json:"placement_score"`
|
||||||
|
ProductFitScore int `json:"product_fit_score"`
|
||||||
|
SolvedByProduct bool `json:"solved_by_product"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
ScanJobID string `json:"scan_job_id"`
|
||||||
|
OutreachStatus string `json:"outreach_status,omitempty"`
|
||||||
|
PublishedReplyID string `json:"published_reply_id,omitempty"`
|
||||||
|
PublishedPermalink string `json:"published_permalink,omitempty"`
|
||||||
|
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
|
||||||
|
Replies []ScanReplyData `json:"replies,omitempty"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListBrandScanPostsData {
|
||||||
|
List []ScanPostData `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateOutreachDraftsReq {
|
||||||
|
ScanPostID string `json:"scan_post_id" validate:"required"`
|
||||||
|
Count int `json:"count,optional"`
|
||||||
|
VoicePersonaID string `json:"voice_persona_id,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
OutreachDraftItemData {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Angle string `json:"angle"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateOutreachDraftsData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ScanPostID string `json:"scan_post_id"`
|
||||||
|
Relevance float64 `json:"relevance"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Drafts []OutreachDraftItemData `json:"drafts"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishOutreachDraftReq {
|
||||||
|
ScanPostID string `json:"scan_post_id" validate:"required"`
|
||||||
|
Text string `json:"text" validate:"required"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishOutreachDraftData {
|
||||||
|
ScanPostID string `json:"scan_post_id"`
|
||||||
|
ReplyID string `json:"reply_id"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
OutreachStatus string `json:"outreach_status"`
|
||||||
|
PublishedPermalink string `json:"published_permalink"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchScanPostOutreachReq {
|
||||||
|
OutreachStatus *string `json:"outreach_status,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentMatrixRowData {
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
SearchTag string `json:"search_tag"`
|
||||||
|
Angle string `json:"angle"`
|
||||||
|
Hook string `json:"hook"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ReferenceNotes string `json:"reference_notes"`
|
||||||
|
SourcePermalinks []string `json:"source_permalinks"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentMatrixData {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
BrandID string `json:"brand_id"`
|
||||||
|
Rows []ContentMatrixRowData `json:"rows"`
|
||||||
|
GeneratedAt int64 `json:"generated_at"`
|
||||||
|
CreateAt int64 `json:"create_at,omitempty"`
|
||||||
|
UpdateAt int64 `json:"update_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateContentMatrixReq {
|
||||||
|
Count int `json:"count,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanReplyData {
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Permalink string `json:"permalink,omitempty"`
|
||||||
|
LikeCount int `json:"like_count,omitempty"`
|
||||||
|
PostedAt string `json:"posted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
BrandScanScheduleData {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
BrandID string `json:"brand_id"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NextRunAt int64 `json:"next_run_at,omitempty"`
|
||||||
|
LastRunAt int64 `json:"last_run_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
UpsertBrandScanScheduleReq {
|
||||||
|
Cron string `json:"cron,optional"`
|
||||||
|
Timezone string `json:"timezone,optional"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateBrandHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
UpdateBrandReq
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpandKnowledgeGraphHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
ExpandKnowledgeGraphReq
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchKnowledgeGraphNodesHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
PatchKnowledgeGraphNodesReq
|
||||||
|
}
|
||||||
|
|
||||||
|
StartBrandScanJobHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
StartBrandScanJobReq
|
||||||
|
}
|
||||||
|
|
||||||
|
ListBrandScanPostsHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
ListBrandScanPostsReq
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateOutreachDraftsHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
GenerateOutreachDraftsReq
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishOutreachDraftHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
PublishOutreachDraftReq
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchScanPostOutreachHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
PostID string `path:"postId"`
|
||||||
|
PatchScanPostOutreachReq
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateContentMatrixHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
GenerateContentMatrixReq
|
||||||
|
}
|
||||||
|
|
||||||
|
UpsertBrandScanScheduleHandlerReq {
|
||||||
|
BrandPath
|
||||||
|
UpsertBrandScanScheduleReq
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@server(
|
||||||
|
group: brand
|
||||||
|
prefix: /api/v1/brands
|
||||||
|
middleware: AuthJWT
|
||||||
|
tags: "Brand"
|
||||||
|
summary: "Brand profiles for placement workflow. Requires Bearer JWT."
|
||||||
|
)
|
||||||
|
service gateway {
|
||||||
|
@handler listBrands
|
||||||
|
get / returns (ListBrandsData)
|
||||||
|
|
||||||
|
@handler createBrand
|
||||||
|
post / (CreateBrandReq) returns (BrandData)
|
||||||
|
|
||||||
|
@handler getBrand
|
||||||
|
get /:id (BrandPath) returns (BrandData)
|
||||||
|
|
||||||
|
@handler updateBrand
|
||||||
|
patch /:id (UpdateBrandHandlerReq) returns (BrandData)
|
||||||
|
|
||||||
|
@handler deleteBrand
|
||||||
|
delete /:id (BrandPath)
|
||||||
|
|
||||||
|
@handler expandKnowledgeGraph
|
||||||
|
post /:id/knowledge-graph/expand (ExpandKnowledgeGraphHandlerReq) returns (ExpandKnowledgeGraphData)
|
||||||
|
|
||||||
|
@handler getKnowledgeGraph
|
||||||
|
get /:id/knowledge-graph (BrandPath) returns (KnowledgeGraphData)
|
||||||
|
|
||||||
|
@handler patchKnowledgeGraphNodes
|
||||||
|
patch /:id/knowledge-graph/nodes (PatchKnowledgeGraphNodesHandlerReq) returns (KnowledgeGraphData)
|
||||||
|
|
||||||
|
@handler startBrandScanJob
|
||||||
|
post /:id/scan-jobs (StartBrandScanJobHandlerReq) returns (StartBrandScanJobData)
|
||||||
|
|
||||||
|
@handler listBrandScanPosts
|
||||||
|
get /:id/scan-posts (ListBrandScanPostsHandlerReq) returns (ListBrandScanPostsData)
|
||||||
|
|
||||||
|
@handler generateOutreachDrafts
|
||||||
|
post /:id/outreach-drafts/generate (GenerateOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
|
||||||
|
|
||||||
|
@handler publishOutreachDraft
|
||||||
|
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
|
||||||
|
|
||||||
|
@handler patchScanPostOutreach
|
||||||
|
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)
|
||||||
|
|
||||||
|
@handler getBrandContentMatrix
|
||||||
|
get /:id/content-matrix (BrandPath) returns (ContentMatrixData)
|
||||||
|
|
||||||
|
@handler generateBrandContentMatrix
|
||||||
|
post /:id/content-matrix/generate (GenerateContentMatrixHandlerReq) returns (ContentMatrixData)
|
||||||
|
|
||||||
|
@handler getBrandScanSchedule
|
||||||
|
get /:id/scan-schedule (BrandPath) returns (BrandScanScheduleData)
|
||||||
|
|
||||||
|
@handler upsertBrandScanSchedule
|
||||||
|
put /:id/scan-schedule (UpsertBrandScanScheduleHandlerReq) returns (BrandScanScheduleData)
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"permission.api"
|
"permission.api"
|
||||||
"threads_account.api"
|
"threads_account.api"
|
||||||
"persona.api"
|
"persona.api"
|
||||||
|
"brand.api"
|
||||||
"worker_internal.api"
|
"worker_internal.api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ type (
|
||||||
|
|
||||||
CreateJobReq {
|
CreateJobReq {
|
||||||
TemplateType string `json:"template_type" validate:"required"` // job template type
|
TemplateType string `json:"template_type" validate:"required"` // job template type
|
||||||
Scope string `json:"scope" validate:"required,oneof=user account system"` // job scope
|
Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope
|
||||||
ScopeID string `json:"scope_id" validate:"required"` // scope id
|
ScopeID string `json:"scope_id" validate:"required"` // scope id
|
||||||
Payload map[string]interface{} `json:"payload,optional"` // job payload
|
Payload map[string]interface{} `json:"payload,optional"` // job payload
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ type (
|
||||||
|
|
||||||
CreateJobScheduleReq {
|
CreateJobScheduleReq {
|
||||||
TemplateType string `json:"template_type" validate:"required"` // template type
|
TemplateType string `json:"template_type" validate:"required"` // template type
|
||||||
Scope string `json:"scope" validate:"required,oneof=user account system"` // scope
|
Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // scope
|
||||||
ScopeID string `json:"scope_id" validate:"required"` // scope id
|
ScopeID string `json:"scope_id" validate:"required"` // scope id
|
||||||
Cron string `json:"cron" validate:"required"` // cron expression
|
Cron string `json:"cron" validate:"required"` // cron expression
|
||||||
Timezone string `json:"timezone,optional"` // timezone
|
Timezone string `json:"timezone,optional"` // timezone
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ type (
|
||||||
Currency string `json:"currency,optional"`
|
Currency string `json:"currency,optional"`
|
||||||
Phone string `json:"phone,optional"`
|
Phone string `json:"phone,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MemberPlacementSettingsData {
|
||||||
|
BraveAPIKey string `json:"brave_api_key,omitempty"`
|
||||||
|
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
|
||||||
|
BraveCountry string `json:"brave_country"`
|
||||||
|
BraveSearchLang string `json:"brave_search_lang"`
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateMemberPlacementSettingsReq {
|
||||||
|
BraveAPIKey *string `json:"brave_api_key,optional"`
|
||||||
|
BraveCountry *string `json:"brave_country,optional"`
|
||||||
|
BraveSearchLang *string `json:"brave_search_lang,optional"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
|
|
@ -43,4 +56,10 @@ service gateway {
|
||||||
|
|
||||||
@handler updateMemberMe
|
@handler updateMemberMe
|
||||||
patch /me (UpdateMemberMeReq) returns (MemberMeData)
|
patch /me (UpdateMemberMeReq) returns (MemberMeData)
|
||||||
|
|
||||||
|
@handler getMemberPlacementSettings
|
||||||
|
get /me/placement-settings returns (MemberPlacementSettingsData)
|
||||||
|
|
||||||
|
@handler updateMemberPlacementSettings
|
||||||
|
patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
syntax = "v1"
|
syntax = "v1"
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
CopyResearchMapData {
|
||||||
|
AudienceSummary string `json:"audience_summary,omitempty"`
|
||||||
|
ContentGoal string `json:"content_goal,omitempty"`
|
||||||
|
Questions []string `json:"questions,omitempty"`
|
||||||
|
Pillars []string `json:"pillars,omitempty"`
|
||||||
|
Exclusions []string `json:"exclusions,omitempty"`
|
||||||
|
SuggestedTags []string `json:"suggested_tags,omitempty"`
|
||||||
|
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
PersonaData {
|
PersonaData {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Persona string `json:"persona,omitempty"`
|
Persona string `json:"persona,omitempty"`
|
||||||
Brief string `json:"brief,omitempty"`
|
Brief string `json:"brief,omitempty"`
|
||||||
ProductBrief string `json:"product_brief,omitempty"`
|
StyleProfile string `json:"style_profile,omitempty"`
|
||||||
TargetAudience string `json:"target_audience,omitempty"`
|
StyleBenchmark string `json:"style_benchmark,omitempty"`
|
||||||
Goals string `json:"goals,omitempty"`
|
SeedQuery string `json:"seed_query,omitempty"`
|
||||||
StyleProfile string `json:"style_profile,omitempty"`
|
CopyResearchMap CopyResearchMapData `json:"copy_research_map,omitempty"`
|
||||||
StyleBenchmark string `json:"style_benchmark,omitempty"`
|
CreateAt int64 `json:"create_at"`
|
||||||
CreateAt int64 `json:"create_at"`
|
UpdateAt int64 `json:"update_at"`
|
||||||
UpdateAt int64 `json:"update_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ListPersonasData {
|
ListPersonasData {
|
||||||
|
|
@ -30,10 +39,6 @@ type (
|
||||||
UpdatePersonaReq {
|
UpdatePersonaReq {
|
||||||
DisplayName *string `json:"display_name,optional"`
|
DisplayName *string `json:"display_name,optional"`
|
||||||
Persona *string `json:"persona,optional"`
|
Persona *string `json:"persona,optional"`
|
||||||
Brief *string `json:"brief,optional"`
|
|
||||||
ProductBrief *string `json:"product_brief,optional"`
|
|
||||||
TargetAudience *string `json:"target_audience,optional"`
|
|
||||||
Goals *string `json:"goals,optional"`
|
|
||||||
StyleProfile *string `json:"style_profile,optional"`
|
StyleProfile *string `json:"style_profile,optional"`
|
||||||
StyleBenchmark *string `json:"style_benchmark,optional"`
|
StyleBenchmark *string `json:"style_benchmark,optional"`
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +52,93 @@ type (
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdatePersonaHandlerReq {
|
||||||
|
PersonaPath
|
||||||
|
UpdatePersonaReq
|
||||||
|
}
|
||||||
|
|
||||||
|
StartPersonaStyleAnalysisHandlerReq {
|
||||||
|
PersonaPath
|
||||||
|
StartPersonaStyleAnalysisReq
|
||||||
|
}
|
||||||
|
|
||||||
|
StartPersonaViralScanJobReq {
|
||||||
|
Keywords []string `json:"keywords,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
StartPersonaViralScanJobData {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
StartPersonaViralScanJobHandlerReq {
|
||||||
|
PersonaPath
|
||||||
|
StartPersonaViralScanJobReq
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPersonaViralScanPostsReq {
|
||||||
|
Limit int `form:"limit,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ViralScanPostData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SearchTag string `json:"search_tag"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
LikeCount int `json:"like_count"`
|
||||||
|
ReplyCount int `json:"reply_count"`
|
||||||
|
EngagementScore int `json:"engagement_score"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
ScanJobID string `json:"scan_job_id"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPersonaViralScanPostsData {
|
||||||
|
List []ViralScanPostData `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPersonaViralScanPostsHandlerReq {
|
||||||
|
PersonaPath
|
||||||
|
ListPersonaViralScanPostsReq
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyDraftData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PersonaID string `json:"persona_id"`
|
||||||
|
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||||
|
DraftType string `json:"draft_type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Angle string `json:"angle,omitempty"`
|
||||||
|
Hook string `json:"hook,omitempty"`
|
||||||
|
Rationale string `json:"rationale,omitempty"`
|
||||||
|
ReferenceNotes string `json:"reference_notes,omitempty"`
|
||||||
|
Sources []string `json:"sources,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPersonaCopyDraftsData {
|
||||||
|
List []CopyDraftData `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratePersonaCopyDraftReq {
|
||||||
|
ScanPostID string `json:"scan_post_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratePersonaCopyDraftHandlerReq {
|
||||||
|
PersonaPath
|
||||||
|
GeneratePersonaCopyDraftReq
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratePersonaCopyDraftData {
|
||||||
|
Draft CopyDraftData `json:"draft"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
|
|
@ -67,11 +159,23 @@ service gateway {
|
||||||
get /:id (PersonaPath) returns (PersonaData)
|
get /:id (PersonaPath) returns (PersonaData)
|
||||||
|
|
||||||
@handler updatePersona
|
@handler updatePersona
|
||||||
patch /:id (PersonaPath, UpdatePersonaReq) returns (PersonaData)
|
patch /:id (UpdatePersonaHandlerReq) returns (PersonaData)
|
||||||
|
|
||||||
@handler deletePersona
|
@handler deletePersona
|
||||||
delete /:id (PersonaPath)
|
delete /:id (PersonaPath)
|
||||||
|
|
||||||
@handler startPersonaStyleAnalysis
|
@handler startPersonaStyleAnalysis
|
||||||
post /:id/style-analysis (PersonaPath, StartPersonaStyleAnalysisReq) returns (StartPersonaStyleAnalysisData)
|
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
|
||||||
|
|
||||||
|
@handler startPersonaViralScanJob
|
||||||
|
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
|
||||||
|
|
||||||
|
@handler listPersonaViralScanPosts
|
||||||
|
get /:id/viral-scan-posts (ListPersonaViralScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
|
||||||
|
|
||||||
|
@handler listPersonaCopyDrafts
|
||||||
|
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
|
||||||
|
|
||||||
|
@handler generatePersonaCopyDraft
|
||||||
|
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ type (
|
||||||
|
|
||||||
CreateThreadsAccountReq {
|
CreateThreadsAccountReq {
|
||||||
DisplayName string `json:"display_name,optional"`
|
DisplayName string `json:"display_name,optional"`
|
||||||
Activate bool `json:"activate,optional"`
|
Activate *bool `json:"activate,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateThreadsAccountReq {
|
UpdateThreadsAccountReq {
|
||||||
|
|
@ -95,6 +95,26 @@ type (
|
||||||
ResearchModel *string `json:"research_model,optional"`
|
ResearchModel *string `json:"research_model,optional"`
|
||||||
ApiKeys map[string]string `json:"api_keys,optional"`
|
ApiKeys map[string]string `json:"api_keys,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateThreadsAccountHandlerReq {
|
||||||
|
ThreadsAccountPath
|
||||||
|
UpdateThreadsAccountReq
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateThreadsAccountConnectionHandlerReq {
|
||||||
|
ThreadsAccountPath
|
||||||
|
UpdateThreadsAccountConnectionReq
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportThreadsAccountSessionHandlerReq {
|
||||||
|
ThreadsAccountPath
|
||||||
|
ImportThreadsAccountSessionReq
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateThreadsAccountAiSettingsHandlerReq {
|
||||||
|
ThreadsAccountPath
|
||||||
|
UpdateThreadsAccountAiSettingsReq
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
|
|
@ -115,7 +135,7 @@ service gateway {
|
||||||
get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
|
get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
|
||||||
|
|
||||||
@handler updateThreadsAccount
|
@handler updateThreadsAccount
|
||||||
patch /:id (ThreadsAccountPath, UpdateThreadsAccountReq) returns (ThreadsAccountData)
|
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
|
||||||
|
|
||||||
@handler activateThreadsAccount
|
@handler activateThreadsAccount
|
||||||
post /:id/activate (ThreadsAccountPath)
|
post /:id/activate (ThreadsAccountPath)
|
||||||
|
|
@ -124,14 +144,14 @@ service gateway {
|
||||||
get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
|
get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
|
||||||
|
|
||||||
@handler updateThreadsAccountConnection
|
@handler updateThreadsAccountConnection
|
||||||
patch /:id/connection (ThreadsAccountPath, UpdateThreadsAccountConnectionReq) returns (ThreadsAccountConnectionData)
|
patch /:id/connection (UpdateThreadsAccountConnectionHandlerReq) returns (ThreadsAccountConnectionData)
|
||||||
|
|
||||||
@handler importThreadsAccountSession
|
@handler importThreadsAccountSession
|
||||||
post /:id/session/import (ThreadsAccountPath, ImportThreadsAccountSessionReq) returns (ImportThreadsAccountSessionData)
|
post /:id/session/import (ImportThreadsAccountSessionHandlerReq) returns (ImportThreadsAccountSessionData)
|
||||||
|
|
||||||
@handler getThreadsAccountAiSettings
|
@handler getThreadsAccountAiSettings
|
||||||
get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
|
get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
|
||||||
|
|
||||||
@handler updateThreadsAccountAiSettings
|
@handler updateThreadsAccountAiSettings
|
||||||
put /:id/ai-settings (ThreadsAccountPath, UpdateThreadsAccountAiSettingsReq) returns (ThreadsAccountAiSettingsData)
|
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
|
||||||
}
|
}
|
||||||
|
|
@ -104,10 +104,11 @@ type (
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
group: job
|
group: job
|
||||||
prefix: /api/v1/internal
|
prefix: /api/v1/internal
|
||||||
tags: "Internal Worker"
|
middleware: WorkerSecret
|
||||||
summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
|
tags: "Internal Worker"
|
||||||
|
summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
|
||||||
)
|
)
|
||||||
service gateway {
|
service gateway {
|
||||||
@handler claimWorkerJob
|
@handler claimWorkerJob
|
||||||
|
|
@ -138,5 +139,5 @@ service gateway {
|
||||||
post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
|
post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
|
||||||
|
|
||||||
@handler analyzeStyle8DFromWorker
|
@handler analyzeStyle8DFromWorker
|
||||||
post /workers/jobs/:id/analyze-style-8d (AnalyzeStyle8DReq) returns (AnalyzeStyle8DData)
|
post /workers/jobs/:id/analyze-style8d (AnalyzeStyle8DReq) returns (AnalyzeStyle8DData)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
{{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
{{.ImportPackages}}
|
{{end}}{{.ImportPackages}}
|
||||||
)
|
)
|
||||||
|
|
||||||
{{if .HasDoc}}{{.Doc}}{{end}}
|
{{if .HasDoc}}{{.Doc}}{{end}}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ type InternalWorkerConf struct {
|
||||||
Secret string `json:",optional"`
|
Secret string `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BraveConf struct {
|
||||||
|
APIKey string `json:",optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
rest.RestConf
|
rest.RestConf
|
||||||
Mongo MongoConf `json:",optional"`
|
Mongo MongoConf `json:",optional"`
|
||||||
|
|
@ -50,4 +54,5 @@ type Config struct {
|
||||||
JobWorker JobWorkerConf `json:",optional"`
|
JobWorker JobWorkerConf `json:",optional"`
|
||||||
JobScheduler JobSchedulerConf `json:",optional"`
|
JobScheduler JobSchedulerConf `json:",optional"`
|
||||||
JobReaper JobReaperConf `json:",optional"`
|
JobReaper JobReaperConf `json:",optional"`
|
||||||
|
Brave BraveConf `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.CreateBrandReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewCreateBrandLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.CreateBrand(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeleteBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.BrandPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewDeleteBrandLogic(r.Context(), svcCtx)
|
||||||
|
err := l.DeleteBrand(&req)
|
||||||
|
response.Write(r.Context(), w, nil, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExpandKnowledgeGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ExpandKnowledgeGraphHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewExpandKnowledgeGraphLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ExpandKnowledgeGraph(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateBrandContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.GenerateContentMatrixHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGenerateBrandContentMatrixLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GenerateBrandContentMatrix(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.GenerateOutreachDraftsHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGenerateOutreachDraftsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GenerateOutreachDrafts(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBrandContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.BrandPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGetBrandContentMatrixLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetBrandContentMatrix(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.BrandPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGetBrandLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetBrand(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBrandScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.BrandPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGetBrandScanScheduleLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetBrandScanSchedule(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetKnowledgeGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.BrandPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewGetKnowledgeGraphLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetKnowledgeGraph(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListBrandScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ListBrandScanPostsHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewListBrandScanPostsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListBrandScanPosts(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListBrandsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := brand.NewListBrandsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListBrands()
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PatchKnowledgeGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PatchKnowledgeGraphNodesHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewPatchKnowledgeGraphNodesLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.PatchKnowledgeGraphNodes(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PatchScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PatchScanPostOutreachHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewPatchScanPostOutreachLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.PatchScanPostOutreach(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PublishOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PublishOutreachDraftHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewPublishOutreachDraftLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.PublishOutreachDraft(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartBrandScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.StartBrandScanJobHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewStartBrandScanJobLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.StartBrandScanJob(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdateBrandHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewUpdateBrandLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateBrand(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/brand"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpsertBrandScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpsertBrandScanScheduleHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := brand.NewUpsertBrandScanScheduleLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpsertBrandScanSchedule(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerJobReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewAckWorkerJobCancelLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.AckWorkerJobCancel(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.AnalyzeStyle8DReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewAnalyzeStyle8DFromWorkerLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.AnalyzeStyle8DFromWorker(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerJobReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewCheckWorkerJobCancelLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.CheckWorkerJobCancel(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ClaimWorkerJobReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewClaimWorkerJobLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ClaimWorkerJob(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerCompleteReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewCompleteWorkerJobLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.CompleteWorkerJob(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerFailReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewFailWorkerJobLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.FailWorkerJob(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerThreadsAccountSessionReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewGetWorkerThreadsAccountSessionLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetWorkerThreadsAccountSession(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerHeartbeatReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewRefreshWorkerJobLockLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.RefreshWorkerJobLock(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.StorePersonaStyleProfileReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewStorePersonaStyleProfileFromWorkerLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.StorePersonaStyleProfileFromWorker(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/job"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.WorkerProgressReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := job.NewUpdateWorkerJobProgressLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateWorkerJobProgress(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,549 +0,0 @@
|
||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/library/clock"
|
|
||||||
app "haixun-backend/internal/library/errors"
|
|
||||||
"haixun-backend/internal/library/errors/code"
|
|
||||||
libprompt "haixun-backend/internal/library/prompt"
|
|
||||||
"haixun-backend/internal/library/style8d"
|
|
||||||
joblogic "haixun-backend/internal/logic/job"
|
|
||||||
"haixun-backend/internal/model/ai/domain/enum"
|
|
||||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
|
||||||
jobentity "haixun-backend/internal/model/job/domain/entity"
|
|
||||||
jobenum "haixun-backend/internal/model/job/domain/enum"
|
|
||||||
jobusecase "haixun-backend/internal/model/job/domain/usecase"
|
|
||||||
personausecase "haixun-backend/internal/model/persona/domain/usecase"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
const workerSecretHeader = "X-Worker-Secret"
|
|
||||||
|
|
||||||
type workerJobPath struct {
|
|
||||||
ID string `path:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type claimWorkerJobReq struct {
|
|
||||||
WorkerType string `json:"worker_type"`
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerJobReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerHeartbeatReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
TTLSeconds int `json:"ttl_seconds,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerProgressReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
Phase string `json:"phase,optional"`
|
|
||||||
Summary string `json:"summary,optional"`
|
|
||||||
Percentage *int `json:"percentage,optional"`
|
|
||||||
Steps []types.JobStepProgressData `json:"steps,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerCompleteReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
Result map[string]interface{} `json:"result,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerFailReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Phase string `json:"phase,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type storePersonaStyleProfileReq struct {
|
|
||||||
ID string `path:"id"`
|
|
||||||
TenantID string `json:"tenant_id"`
|
|
||||||
OwnerUID string `json:"owner_uid"`
|
|
||||||
StyleProfile string `json:"style_profile"`
|
|
||||||
StyleBenchmark string `json:"style_benchmark,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerThreadsAccountSessionReq struct {
|
|
||||||
ID string `path:"id"`
|
|
||||||
TenantID string `json:"tenant_id"`
|
|
||||||
OwnerUID string `json:"owner_uid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type analyzeStyle8DPostReq struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Permalink string `json:"permalink,optional"`
|
|
||||||
LikeCount int `json:"like_count,optional"`
|
|
||||||
ReplyCount int `json:"reply_count,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type analyzeStyle8DReq struct {
|
|
||||||
workerJobPath
|
|
||||||
WorkerID string `json:"worker_id"`
|
|
||||||
TenantID string `json:"tenant_id"`
|
|
||||||
OwnerUID string `json:"owner_uid"`
|
|
||||||
PersonaID string `json:"persona_id"`
|
|
||||||
ThreadsAccountID string `json:"threads_account_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Posts []analyzeStyle8DPostReq `json:"posts"`
|
|
||||||
Steps []types.JobStepProgressData `json:"steps,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req claimWorkerJobReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run, err := svcCtx.Job.ClaimNext(r.Context(), jobusecase.ClaimNextRequest{
|
|
||||||
WorkerType: req.WorkerType,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
})
|
|
||||||
if err != nil || run == nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := joblogic.ToJobData(run)
|
|
||||||
response.Write(r.Context(), w, &data, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerHeartbeatReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ttl := req.TTLSeconds
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = 300
|
|
||||||
}
|
|
||||||
err := svcCtx.Job.RefreshRunLock(r.Context(), req.ID, req.WorkerID, ttl)
|
|
||||||
response.Write(r.Context(), w, map[string]bool{"ok": err == nil}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerJobReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cancelled, err := svcCtx.Job.IsCancelRequested(r.Context(), req.ID)
|
|
||||||
response.Write(r.Context(), w, map[string]bool{"cancelled": cancelled}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerJobReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run, err := svcCtx.Job.AcknowledgeCancel(r.Context(), jobusecase.AcknowledgeCancelRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := joblogic.ToJobData(run)
|
|
||||||
response.Write(r.Context(), w, &data, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerProgressReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
percentage := -1
|
|
||||||
if req.Percentage != nil {
|
|
||||||
percentage = *req.Percentage
|
|
||||||
}
|
|
||||||
run, err := svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Phase: req.Phase,
|
|
||||||
Summary: req.Summary,
|
|
||||||
Percentage: percentage,
|
|
||||||
Steps: toEntitySteps(req.Steps),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := joblogic.ToJobData(run)
|
|
||||||
response.Write(r.Context(), w, &data, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerCompleteReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run, err := svcCtx.Job.CompleteRun(r.Context(), jobusecase.CompleteRunRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Result: req.Result,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := joblogic.ToJobData(run)
|
|
||||||
response.Write(r.Context(), w, &data, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerFailReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run, err := svcCtx.Job.FailRun(r.Context(), jobusecase.FailRunRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Error: req.Error,
|
|
||||||
Phase: req.Phase,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := joblogic.ToJobData(run)
|
|
||||||
response.Write(r.Context(), w, &data, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req storePersonaStyleProfileReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.StyleProfile) == "" {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("style_profile is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
profile := strings.TrimSpace(req.StyleProfile)
|
|
||||||
benchmark := strings.TrimPrefix(strings.TrimSpace(req.StyleBenchmark), "@")
|
|
||||||
item, err := svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
|
||||||
TenantID: req.TenantID,
|
|
||||||
OwnerUID: req.OwnerUID,
|
|
||||||
PersonaID: req.ID,
|
|
||||||
Patch: personausecase.PersonaPatch{
|
|
||||||
StyleProfile: &profile,
|
|
||||||
StyleBenchmark: &benchmark,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Write(r.Context(), w, map[string]any{"id": item.ID, "update_at": item.UpdateAt}, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req analyzeStyle8DReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.WorkerID) == "" {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Job).InputMissingRequired("worker_id is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.PersonaID) == "" {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("persona_id is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.ThreadsAccountID) == "" {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputMissingRequired("threads_account_id is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Posts) == 0 {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("posts is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credential, err := svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
|
|
||||||
r.Context(),
|
|
||||||
req.TenantID,
|
|
||||||
req.OwnerUID,
|
|
||||||
req.ThreadsAccountID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
providerID, err := mapWorkerAIProvider(credential.Provider)
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
posts := make([]style8d.Post, 0, len(req.Posts))
|
|
||||||
for _, item := range req.Posts {
|
|
||||||
text := strings.TrimSpace(item.Text)
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
posts = append(posts, style8d.Post{
|
|
||||||
Text: text,
|
|
||||||
Permalink: strings.TrimSpace(item.Permalink),
|
|
||||||
LikeCount: item.LikeCount,
|
|
||||||
ReplyCount: item.ReplyCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(posts) == 0 {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputInvalidFormat("posts contain no readable text"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
steps := toEntitySteps(req.Steps)
|
|
||||||
steps = markWorkerStep(steps, "style", jobenum.StepStatusRunning, "AI 正在分析 D1–D8…")
|
|
||||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Phase: "style",
|
|
||||||
Summary: "AI 正在分析八個風格維度…",
|
|
||||||
Percentage: 55,
|
|
||||||
Steps: steps,
|
|
||||||
})
|
|
||||||
|
|
||||||
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
|
|
||||||
systemPrompt, err := libprompt.Style8DSystem()
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.AI).SysInternal("prompt config load failed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := svcCtx.AI.GenerateText(r.Context(), domai.GenerateRequest{
|
|
||||||
Provider: providerID,
|
|
||||||
Model: credential.Model,
|
|
||||||
Credential: domai.Credential{
|
|
||||||
APIKey: credential.APIKey,
|
|
||||||
},
|
|
||||||
System: systemPrompt,
|
|
||||||
Messages: []domai.Message{
|
|
||||||
{Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "HTTP 401") {
|
|
||||||
err = app.For(code.AI).SvcThirdParty(
|
|
||||||
"8D AI 分析授權失敗:目前帳號的研究用 Provider API key 無效或未授權。請到「設定 > 帳號 AI 設定」確認 research provider=" +
|
|
||||||
credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := style8d.ParseLLMOutput(result.Text)
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:"+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
profile := style8d.BuildStoredProfile(username, posts, parsed)
|
|
||||||
profileJSON, err := profile.JSON()
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
steps = markWorkerStep(steps, "style", jobenum.StepStatusSucceeded, "8D 風格策略已產生")
|
|
||||||
steps = markWorkerStep(steps, "store", jobenum.StepStatusRunning, "寫入人設風格策略…")
|
|
||||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Phase: "store",
|
|
||||||
Summary: "8D 分析完成,寫入人設…",
|
|
||||||
Percentage: 88,
|
|
||||||
Steps: steps,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
|
||||||
TenantID: req.TenantID,
|
|
||||||
OwnerUID: req.OwnerUID,
|
|
||||||
PersonaID: req.PersonaID,
|
|
||||||
Patch: personausecase.PersonaPatch{
|
|
||||||
StyleProfile: &profileJSON,
|
|
||||||
StyleBenchmark: &username,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
steps = markWorkerStep(steps, "store", jobenum.StepStatusSucceeded, "8D 策略已寫入人設")
|
|
||||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|
||||||
JobID: req.ID,
|
|
||||||
WorkerID: req.WorkerID,
|
|
||||||
Phase: "store",
|
|
||||||
Summary: "8D 策略已寫入人設",
|
|
||||||
Percentage: 92,
|
|
||||||
Steps: steps,
|
|
||||||
})
|
|
||||||
|
|
||||||
response.Write(r.Context(), w, map[string]any{
|
|
||||||
"persona_id": req.PersonaID,
|
|
||||||
"post_count": len(posts),
|
|
||||||
"style_profile": profileJSON,
|
|
||||||
"style_benchmark": username,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req workerThreadsAccountSessionReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session, err := svcCtx.ThreadsAccount.GetBrowserSession(r.Context(), req.TenantID, req.OwnerUID, req.ID)
|
|
||||||
if err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Write(r.Context(), w, map[string]any{
|
|
||||||
"account_id": session.AccountID,
|
|
||||||
"storage_state": session.StorageState,
|
|
||||||
"update_at": session.UpdateAt,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireWorkerSecret(r *http.Request, svcCtx *svc.ServiceContext) error {
|
|
||||||
secret := strings.TrimSpace(svcCtx.Config.InternalWorker.Secret)
|
|
||||||
if secret == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if r.Header.Get(workerSecretHeader) != secret {
|
|
||||||
return app.For(code.Auth).AuthUnauthorized("invalid worker secret")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapWorkerAIProvider(provider string) (enum.ProviderID, error) {
|
|
||||||
switch strings.TrimSpace(provider) {
|
|
||||||
case string(enum.ProviderOpenCode):
|
|
||||||
return enum.ProviderOpenCode, nil
|
|
||||||
case string(enum.ProviderXAI):
|
|
||||||
return enum.ProviderXAI, nil
|
|
||||||
default:
|
|
||||||
return "", app.For(code.AI).InputInvalidFormat("worker 8D 分析目前僅支援 opencode-go 與 xai,請在 AI 設定調整 research provider")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markWorkerStep(steps []jobentity.StepProgress, stepID string, status jobenum.StepStatus, message string) []jobentity.StepProgress {
|
|
||||||
now := clock.NowUnixNano()
|
|
||||||
found := false
|
|
||||||
for i := range steps {
|
|
||||||
if steps[i].ID != stepID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
steps[i].Status = status
|
|
||||||
steps[i].Message = message
|
|
||||||
if status == jobenum.StepStatusRunning && steps[i].StartedAt == nil {
|
|
||||||
steps[i].StartedAt = &now
|
|
||||||
}
|
|
||||||
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
|
||||||
steps[i].EndedAt = &now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
item := jobentity.StepProgress{ID: stepID, Status: status, Message: message}
|
|
||||||
if status == jobenum.StepStatusRunning {
|
|
||||||
item.StartedAt = &now
|
|
||||||
}
|
|
||||||
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
|
||||||
item.EndedAt = &now
|
|
||||||
}
|
|
||||||
steps = append(steps, item)
|
|
||||||
}
|
|
||||||
return steps
|
|
||||||
}
|
|
||||||
|
|
||||||
func toEntitySteps(steps []types.JobStepProgressData) []jobentity.StepProgress {
|
|
||||||
out := make([]jobentity.StepProgress, 0, len(steps))
|
|
||||||
for _, step := range steps {
|
|
||||||
out = append(out, jobentity.StepProgress{
|
|
||||||
ID: step.ID,
|
|
||||||
Status: jobenum.StepStatus(step.Status),
|
|
||||||
StartedAt: step.StartedAt,
|
|
||||||
EndedAt: step.EndedAt,
|
|
||||||
Message: step.Message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/member"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMemberPlacementSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := member.NewGetMemberPlacementSettingsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetMemberPlacementSettings()
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/member"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateMemberPlacementSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdateMemberPlacementSettingsReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := member.NewUpdateMemberPlacementSettingsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateMemberPlacementSettings(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.CreatePersonaReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewCreatePersonaLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.CreatePersona(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeletePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PersonaPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewDeletePersonaLogic(r.Context(), svcCtx)
|
||||||
|
err := l.DeletePersona(&req)
|
||||||
|
response.Write(r.Context(), w, nil, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GeneratePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.GeneratePersonaCopyDraftHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewGeneratePersonaCopyDraftLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GeneratePersonaCopyDraft(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetPersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PersonaPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewGetPersonaLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetPersona(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
package persona
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"haixun-backend/internal/logic/persona"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ListPersonasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := persona.NewListPersonasLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ListPersonas()
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CreatePersonaReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := persona.NewCreatePersonaLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.CreatePersona(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PersonaPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := persona.NewGetPersonaLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetPersona(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpdatePersonaHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := persona.NewUpdatePersonaLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdatePersona(&req.PersonaPath, &req.UpdatePersonaReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeletePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PersonaPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := persona.NewDeletePersonaLogic(r.Context(), svcCtx)
|
|
||||||
err := l.DeletePersona(&req)
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartPersonaStyleAnalysisHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.StartPersonaStyleAnalysisHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := persona.NewStartPersonaStyleAnalysisLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartPersonaStyleAnalysis(&req.PersonaPath, &req.StartPersonaStyleAnalysisReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListPersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.PersonaPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewListPersonaCopyDraftsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListPersonaCopyDrafts(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListPersonaViralScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ListPersonaViralScanPostsHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewListPersonaViralScanPostsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListPersonaViralScanPosts(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListPersonasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := persona.NewListPersonasLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListPersonas()
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartPersonaStyleAnalysisHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.StartPersonaStyleAnalysisHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewStartPersonaStyleAnalysisLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.StartPersonaStyleAnalysis(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartPersonaViralScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.StartPersonaViralScanJobHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewStartPersonaViralScanJobLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.StartPersonaViralScanJob(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/persona"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdatePersonaHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := persona.NewUpdatePersonaLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdatePersona(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,75 +8,20 @@ import (
|
||||||
|
|
||||||
ai "haixun-backend/internal/handler/ai"
|
ai "haixun-backend/internal/handler/ai"
|
||||||
auth "haixun-backend/internal/handler/auth"
|
auth "haixun-backend/internal/handler/auth"
|
||||||
|
brand "haixun-backend/internal/handler/brand"
|
||||||
job "haixun-backend/internal/handler/job"
|
job "haixun-backend/internal/handler/job"
|
||||||
member "haixun-backend/internal/handler/member"
|
member "haixun-backend/internal/handler/member"
|
||||||
normal "haixun-backend/internal/handler/normal"
|
normal "haixun-backend/internal/handler/normal"
|
||||||
permission "haixun-backend/internal/handler/permission"
|
permission "haixun-backend/internal/handler/permission"
|
||||||
persona "haixun-backend/internal/handler/persona"
|
persona "haixun-backend/internal/handler/persona"
|
||||||
setting "haixun-backend/internal/handler/setting"
|
setting "haixun-backend/internal/handler/setting"
|
||||||
threadsaccount "haixun-backend/internal/handler/threads_account"
|
threads_account "haixun-backend/internal/handler/threads_account"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest"
|
"github.com/zeromicro/go-zero/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
server.AddRoutes(
|
|
||||||
[]rest.Route{
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/claim",
|
|
||||||
Handler: job.ClaimWorkerJobHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/heartbeat",
|
|
||||||
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/cancel-check",
|
|
||||||
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/cancel-ack",
|
|
||||||
Handler: job.AckWorkerJobCancelHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/progress",
|
|
||||||
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/complete",
|
|
||||||
Handler: job.CompleteWorkerJobHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/fail",
|
|
||||||
Handler: job.FailWorkerJobHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPatch,
|
|
||||||
Path: "/workers/personas/:id/style-profile",
|
|
||||||
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/threads-accounts/:id/session",
|
|
||||||
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/workers/jobs/:id/analyze-style-8d",
|
|
||||||
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rest.WithPrefix("/api/v1/internal"),
|
|
||||||
)
|
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
|
|
@ -161,6 +106,159 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
rest.WithPrefix("/api/v1/auth"),
|
rest.WithPrefix("/api/v1/auth"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
rest.WithMiddlewares(
|
||||||
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/",
|
||||||
|
Handler: brand.ListBrandsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/",
|
||||||
|
Handler: brand.CreateBrandHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: brand.GetBrandHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: brand.UpdateBrandHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: brand.DeleteBrandHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/content-matrix",
|
||||||
|
Handler: brand.GetBrandContentMatrixHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/content-matrix/generate",
|
||||||
|
Handler: brand.GenerateBrandContentMatrixHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/knowledge-graph",
|
||||||
|
Handler: brand.GetKnowledgeGraphHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/knowledge-graph/expand",
|
||||||
|
Handler: brand.ExpandKnowledgeGraphHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id/knowledge-graph/nodes",
|
||||||
|
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/outreach-drafts/generate",
|
||||||
|
Handler: brand.GenerateOutreachDraftsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/outreach-drafts/publish",
|
||||||
|
Handler: brand.PublishOutreachDraftHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/scan-jobs",
|
||||||
|
Handler: brand.StartBrandScanJobHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/scan-posts",
|
||||||
|
Handler: brand.ListBrandScanPostsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id/scan-posts/:postId",
|
||||||
|
Handler: brand.PatchScanPostOutreachHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/scan-schedule",
|
||||||
|
Handler: brand.GetBrandScanScheduleHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/:id/scan-schedule",
|
||||||
|
Handler: brand.UpsertBrandScanScheduleHandler(serverCtx),
|
||||||
|
},
|
||||||
|
}...,
|
||||||
|
),
|
||||||
|
rest.WithPrefix("/api/v1/brands"),
|
||||||
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
rest.WithMiddlewares(
|
||||||
|
[]rest.Middleware{serverCtx.WorkerSecret},
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/analyze-style8d",
|
||||||
|
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/cancel-ack",
|
||||||
|
Handler: job.AckWorkerJobCancelHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/cancel-check",
|
||||||
|
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/complete",
|
||||||
|
Handler: job.CompleteWorkerJobHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/fail",
|
||||||
|
Handler: job.FailWorkerJobHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/heartbeat",
|
||||||
|
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/:id/progress",
|
||||||
|
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/jobs/claim",
|
||||||
|
Handler: job.ClaimWorkerJobHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/workers/personas/:id/style-profile",
|
||||||
|
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/workers/threads-accounts/:id/session",
|
||||||
|
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
|
||||||
|
},
|
||||||
|
}...,
|
||||||
|
),
|
||||||
|
rest.WithPrefix("/api/v1/internal"),
|
||||||
|
)
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
rest.WithMiddlewares(
|
rest.WithMiddlewares(
|
||||||
[]rest.Middleware{serverCtx.AuthJWT},
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
|
@ -254,6 +352,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/me",
|
Path: "/me",
|
||||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/me/placement-settings",
|
||||||
|
Handler: member.GetMemberPlacementSettingsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/me/placement-settings",
|
||||||
|
Handler: member.UpdateMemberPlacementSettingsHandler(serverCtx),
|
||||||
|
},
|
||||||
}...,
|
}...,
|
||||||
),
|
),
|
||||||
rest.WithPrefix("/api/v1/members"),
|
rest.WithPrefix("/api/v1/members"),
|
||||||
|
|
@ -318,75 +426,36 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/:id",
|
Path: "/:id",
|
||||||
Handler: persona.DeletePersonaHandler(serverCtx),
|
Handler: persona.DeletePersonaHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/copy-drafts",
|
||||||
|
Handler: persona.ListPersonaCopyDraftsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/copy-drafts/generate",
|
||||||
|
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/:id/style-analysis",
|
Path: "/:id/style-analysis",
|
||||||
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
|
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/viral-scan-jobs",
|
||||||
|
Handler: persona.StartPersonaViralScanJobHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/viral-scan-posts",
|
||||||
|
Handler: persona.ListPersonaViralScanPostsHandler(serverCtx),
|
||||||
|
},
|
||||||
}...,
|
}...,
|
||||||
),
|
),
|
||||||
rest.WithPrefix("/api/v1/personas"),
|
rest.WithPrefix("/api/v1/personas"),
|
||||||
)
|
)
|
||||||
|
|
||||||
server.AddRoutes(
|
|
||||||
rest.WithMiddlewares(
|
|
||||||
[]rest.Middleware{serverCtx.AuthJWT},
|
|
||||||
[]rest.Route{
|
|
||||||
{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Path: "/",
|
|
||||||
Handler: threadsaccount.ListThreadsAccountsHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/",
|
|
||||||
Handler: threadsaccount.CreateThreadsAccountHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Path: "/:id",
|
|
||||||
Handler: threadsaccount.GetThreadsAccountHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPatch,
|
|
||||||
Path: "/:id",
|
|
||||||
Handler: threadsaccount.UpdateThreadsAccountHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/:id/activate",
|
|
||||||
Handler: threadsaccount.ActivateThreadsAccountHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Path: "/:id/connection",
|
|
||||||
Handler: threadsaccount.GetThreadsAccountConnectionHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPatch,
|
|
||||||
Path: "/:id/connection",
|
|
||||||
Handler: threadsaccount.UpdateThreadsAccountConnectionHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/:id/session/import",
|
|
||||||
Handler: threadsaccount.ImportThreadsAccountSessionHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
Path: "/:id/ai-settings",
|
|
||||||
Handler: threadsaccount.GetThreadsAccountAiSettingsHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: http.MethodPut,
|
|
||||||
Path: "/:id/ai-settings",
|
|
||||||
Handler: threadsaccount.UpdateThreadsAccountAiSettingsHandler(serverCtx),
|
|
||||||
},
|
|
||||||
}...,
|
|
||||||
),
|
|
||||||
rest.WithPrefix("/api/v1/threads-accounts"),
|
|
||||||
)
|
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
rest.WithMiddlewares(
|
rest.WithMiddlewares(
|
||||||
[]rest.Middleware{serverCtx.AuthJWT},
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
|
@ -415,4 +484,63 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
),
|
),
|
||||||
rest.WithPrefix("/api/v1/settings"),
|
rest.WithPrefix("/api/v1/settings"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
rest.WithMiddlewares(
|
||||||
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/",
|
||||||
|
Handler: threads_account.ListThreadsAccountsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/",
|
||||||
|
Handler: threads_account.CreateThreadsAccountHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: threads_account.GetThreadsAccountHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: threads_account.UpdateThreadsAccountHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/activate",
|
||||||
|
Handler: threads_account.ActivateThreadsAccountHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/ai-settings",
|
||||||
|
Handler: threads_account.GetThreadsAccountAiSettingsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/:id/ai-settings",
|
||||||
|
Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id/connection",
|
||||||
|
Handler: threads_account.GetThreadsAccountConnectionHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id/connection",
|
||||||
|
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/session/import",
|
||||||
|
Handler: threads_account.ImportThreadsAccountSessionHandler(serverCtx),
|
||||||
|
},
|
||||||
|
}...,
|
||||||
|
),
|
||||||
|
rest.WithPrefix("/api/v1/threads-accounts"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ActivateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ThreadsAccountPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewActivateThreadsAccountLogic(r.Context(), svcCtx)
|
||||||
|
err := l.ActivateThreadsAccount(&req)
|
||||||
|
response.Write(r.Context(), w, nil, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.CreateThreadsAccountReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewCreateThreadsAccountLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.CreateThreadsAccount(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ThreadsAccountPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewGetThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetThreadsAccountAiSettings(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ThreadsAccountPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewGetThreadsAccountConnectionLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetThreadsAccountConnection(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ThreadsAccountPath
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewGetThreadsAccountLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.GetThreadsAccount(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
package threads_account
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"haixun-backend/internal/logic/threads_account"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ListThreadsAccountsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := threads_account.NewListThreadsAccountsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ListThreadsAccounts()
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CreateThreadsAccountReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewCreateThreadsAccountLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.CreateThreadsAccount(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ThreadsAccountPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewGetThreadsAccountLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetThreadsAccount(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpdateThreadsAccountHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewUpdateThreadsAccountLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateThreadsAccount(&req.ThreadsAccountPath, &req.UpdateThreadsAccountReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ActivateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ThreadsAccountPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewActivateThreadsAccountLogic(r.Context(), svcCtx)
|
|
||||||
err := l.ActivateThreadsAccount(&req)
|
|
||||||
response.Write(r.Context(), w, map[string]bool{"success": err == nil}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ThreadsAccountPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewGetThreadsAccountConnectionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetThreadsAccountConnection(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpdateThreadsAccountConnectionHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewUpdateThreadsAccountConnectionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateThreadsAccountConnection(&req.ThreadsAccountPath, &req.UpdateThreadsAccountConnectionReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ImportThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ImportThreadsAccountSessionHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewImportThreadsAccountSessionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ImportThreadsAccountSession(&req.ThreadsAccountPath, &req.ImportThreadsAccountSessionReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ThreadsAccountPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewGetThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetThreadsAccountAiSettings(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpdateThreadsAccountAiSettingsHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := threads_account.NewUpdateThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateThreadsAccountAiSettings(&req.ThreadsAccountPath, &req.UpdateThreadsAccountAiSettingsReq)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ImportThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ImportThreadsAccountSessionHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewImportThreadsAccountSessionLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ImportThreadsAccountSession(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListThreadsAccountsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := threads_account.NewListThreadsAccountsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListThreadsAccounts()
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdateThreadsAccountAiSettingsHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewUpdateThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateThreadsAccountAiSettings(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdateThreadsAccountConnectionHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewUpdateThreadsAccountConnectionLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateThreadsAccountConnection(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.UpdateThreadsAccountHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewUpdateThreadsAccountLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.UpdateThreadsAccount(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package brave
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
breakerFailureThreshold = 5
|
||||||
|
breakerCooldown = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type breakerState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures int
|
||||||
|
openUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalBreaker breakerState
|
||||||
|
|
||||||
|
// BreakerOpen reports whether Brave calls should be skipped due to recent failures.
|
||||||
|
func BreakerOpen() bool {
|
||||||
|
globalBreaker.mu.Lock()
|
||||||
|
defer globalBreaker.mu.Unlock()
|
||||||
|
return time.Now().Before(globalBreaker.openUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordBreakerSuccess() {
|
||||||
|
globalBreaker.mu.Lock()
|
||||||
|
defer globalBreaker.mu.Unlock()
|
||||||
|
globalBreaker.failures = 0
|
||||||
|
globalBreaker.openUntil = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordBreakerFailure() {
|
||||||
|
globalBreaker.mu.Lock()
|
||||||
|
defer globalBreaker.mu.Unlock()
|
||||||
|
globalBreaker.failures++
|
||||||
|
if globalBreaker.failures >= breakerFailureThreshold {
|
||||||
|
globalBreaker.openUntil = time.Now().Add(breakerCooldown)
|
||||||
|
globalBreaker.failures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetBreakerForTest clears breaker state.
|
||||||
|
func ResetBreakerForTest() {
|
||||||
|
globalBreaker.mu.Lock()
|
||||||
|
defer globalBreaker.mu.Unlock()
|
||||||
|
globalBreaker.failures = 0
|
||||||
|
globalBreaker.openUntil = time.Time{}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
package brave
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBaseURL = "https://api.search.brave.com/res/v1/web/search"
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeKnowledgeExpand Mode = "knowledge_expand"
|
||||||
|
ModeThreadsDiscover Mode = "threads_discover"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResponse struct {
|
||||||
|
Results []SearchResult
|
||||||
|
Query string
|
||||||
|
Status string // success | unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
apiKey: strings.TrimSpace(apiKey),
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Enabled() bool {
|
||||||
|
return c != nil && c.apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchOptions struct {
|
||||||
|
Query string
|
||||||
|
Limit int
|
||||||
|
Mode Mode
|
||||||
|
Country string
|
||||||
|
SearchLang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
|
||||||
|
out := SearchResponse{Query: strings.TrimSpace(opts.Query), Status: "unavailable"}
|
||||||
|
if !c.Enabled() {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if BreakerOpen() {
|
||||||
|
return out, fmt.Errorf("brave search temporarily paused after repeated failures")
|
||||||
|
}
|
||||||
|
if out.Query == "" {
|
||||||
|
return out, fmt.Errorf("brave search query is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := opts.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
country := strings.TrimSpace(opts.Country)
|
||||||
|
if country == "" {
|
||||||
|
country = "tw"
|
||||||
|
}
|
||||||
|
searchLang := strings.TrimSpace(opts.SearchLang)
|
||||||
|
if searchLang == "" {
|
||||||
|
searchLang = "zh-hant"
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("q", out.Query)
|
||||||
|
q.Set("count", fmt.Sprintf("%d", limit))
|
||||||
|
q.Set("country", country)
|
||||||
|
q.Set("search_lang", searchLang)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Subscription-Token", c.apiKey)
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
recordBreakerFailure()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
|
||||||
|
recordBreakerFailure()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Web struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"results"`
|
||||||
|
} `json:"web"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
threadsOnly := opts.Mode == ModeThreadsDiscover
|
||||||
|
for _, item := range payload.Web.Results {
|
||||||
|
rawURL := strings.TrimSpace(item.URL)
|
||||||
|
if rawURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if threadsOnly && !isThreadsURL(rawURL) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Results = append(out.Results, SearchResult{
|
||||||
|
Title: strings.TrimSpace(item.Title),
|
||||||
|
Snippet: strings.TrimSpace(item.Description),
|
||||||
|
URL: rawURL,
|
||||||
|
})
|
||||||
|
if len(out.Results) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Status = "success"
|
||||||
|
recordBreakerSuccess()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isThreadsURL(raw string) bool {
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net")
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ const (
|
||||||
Permission Scope = 37
|
Permission Scope = 37
|
||||||
ThreadsAccount Scope = 38
|
ThreadsAccount Scope = 38
|
||||||
Persona Scope = 39
|
Persona Scope = 39
|
||||||
|
Brand Scope = 40
|
||||||
CategoryMultiplier = 1000
|
CategoryMultiplier = 1000
|
||||||
ScopeMultiplier = 1000000
|
ScopeMultiplier = 1000000
|
||||||
DefaultDetail Detail = 0
|
DefaultDetail Detail = 0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxDerivedTagRunes = 8
|
||||||
|
|
||||||
|
func DeriveSearchTagsFromGraph(graph *Graph) {
|
||||||
|
if graph == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range graph.Nodes {
|
||||||
|
graph.Nodes[i].DerivedTags = deriveNodeTags(graph.Nodes[i])
|
||||||
|
}
|
||||||
|
graph.PainTagCount = CountPainTagCandidates(graph.Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveNodeTags(node Node) DerivedTags {
|
||||||
|
label := strings.TrimSpace(node.Label)
|
||||||
|
if label == "" {
|
||||||
|
return DerivedTags{}
|
||||||
|
}
|
||||||
|
relevance := []string{clampTag(label)}
|
||||||
|
recency := []string{}
|
||||||
|
if IsPainNode(node) {
|
||||||
|
if q := BuildRecencyQuery(label); q != "" {
|
||||||
|
recency = append(recency, clampTag(q))
|
||||||
|
}
|
||||||
|
if node.Layer >= 1 {
|
||||||
|
recency = append(recency, clampTag(label+" 推薦"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relevance = uniqueTags(relevance)
|
||||||
|
recency = uniqueTags(recency)
|
||||||
|
return DerivedTags{Relevance: relevance, Recency: recency}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampTag(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(text) <= maxDerivedTagRunes {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
return string(runes[:maxDerivedTagRunes])
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueTags(items []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type DerivedTags struct {
|
||||||
|
Relevance []string `json:"relevance"`
|
||||||
|
Recency []string `json:"recency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evidence struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
NodeKind string `json:"nodeKind"` // pain | knowledge | cause | symptom
|
||||||
|
Type string `json:"type"` // core | cause | symptom | mechanism
|
||||||
|
Layer int `json:"layer"`
|
||||||
|
Relation string `json:"relation,omitempty"`
|
||||||
|
PlacementValue string `json:"placementValue,omitempty"` // high | medium | low
|
||||||
|
ProductFitScore int `json:"productFitScore"`
|
||||||
|
SelectedForScan bool `json:"selectedForScan"`
|
||||||
|
Evidence []Evidence `json:"evidence"`
|
||||||
|
DerivedTags DerivedTags `json:"derivedTags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Edge struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BraveSource struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Graph struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
Nodes []Node `json:"nodes"`
|
||||||
|
Edges []Edge `json:"edges"`
|
||||||
|
BraveSources []BraveSource `json:"braveSources"`
|
||||||
|
PainTagCount int `json:"painTagCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPainNode(node Node) bool {
|
||||||
|
switch strings.TrimSpace(node.NodeKind) {
|
||||||
|
case "pain", "symptom", "cause":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountPainTagCandidates(nodes []Node) int {
|
||||||
|
count := 0
|
||||||
|
for _, node := range nodes {
|
||||||
|
if !IsPainNode(node) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(node.DerivedTags.Relevance) > 0 || len(node.DerivedTags.Recency) > 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TotalTagCandidates(nodes []Node) int {
|
||||||
|
count := 0
|
||||||
|
for _, node := range nodes {
|
||||||
|
count += len(node.DerivedTags.Relevance) + len(node.DerivedTags.Recency)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func NodeByID(nodes []Node, id string) (Node, bool) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.ID == id {
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Node{}, false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
libprompt "haixun-backend/internal/library/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type queryConfig struct {
|
||||||
|
MaxPlanQueries int `json:"max_plan_queries"`
|
||||||
|
MaxSupplemental int `json:"max_supplemental_queries"`
|
||||||
|
MinPainTagCandidates int `json:"min_pain_tag_candidates"`
|
||||||
|
MinTotalTagCandidates int `json:"min_total_tag_candidates"`
|
||||||
|
PlanBase []string `json:"plan_base"`
|
||||||
|
PlanPeripheral []string `json:"plan_peripheral"`
|
||||||
|
PlanAudience string `json:"plan_audience"`
|
||||||
|
PlanL1Cause string `json:"plan_l1_cause"`
|
||||||
|
PlanL1Pain string `json:"plan_l1_pain"`
|
||||||
|
Supplemental []string `json:"supplemental"`
|
||||||
|
SupplementalL1 string `json:"supplemental_l1"`
|
||||||
|
RecencySuffix string `json:"recency_suffix"`
|
||||||
|
RecencyHelpMarkers string `json:"recency_help_markers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
queryCfgOnce sync.Once
|
||||||
|
queryCfg queryConfig
|
||||||
|
queryCfgErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadQueryConfig() (queryConfig, error) {
|
||||||
|
queryCfgOnce.Do(func() {
|
||||||
|
raw, err := libprompt.KnowledgeGraphQueryConfig()
|
||||||
|
if err != nil {
|
||||||
|
queryCfgErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
queryCfgErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryCfgErr = json.Unmarshal(payload, &queryCfg)
|
||||||
|
})
|
||||||
|
return queryCfg, queryCfgErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaxPlanQueriesPerRound() int {
|
||||||
|
cfg, err := loadQueryConfig()
|
||||||
|
if err != nil || cfg.MaxPlanQueries <= 0 {
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
return cfg.MaxPlanQueries
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaxSupplementalQueries() int {
|
||||||
|
cfg, err := loadQueryConfig()
|
||||||
|
if err != nil || cfg.MaxSupplemental <= 0 {
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
return cfg.MaxSupplemental
|
||||||
|
}
|
||||||
|
|
||||||
|
func MinPainTagCandidates() int {
|
||||||
|
cfg, err := loadQueryConfig()
|
||||||
|
if err != nil || cfg.MinPainTagCandidates <= 0 {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return cfg.MinPainTagCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanInput struct {
|
||||||
|
Seed string
|
||||||
|
TargetAudience string
|
||||||
|
ProductBrief string
|
||||||
|
L1Labels []string
|
||||||
|
Supplemental bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlanQueries(in PlanInput) []string {
|
||||||
|
cfg, err := loadQueryConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seed := strings.TrimSpace(in.Seed)
|
||||||
|
if seed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if in.Supplemental {
|
||||||
|
return supplementalQueries(cfg, seed, in.L1Labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, cfg.MaxPlanQueries)
|
||||||
|
add := func(q string) {
|
||||||
|
q = strings.TrimSpace(q)
|
||||||
|
if q == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[q]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[q] = struct{}{}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]string{"seed": seed, "audience": strings.TrimSpace(in.TargetAudience)}
|
||||||
|
for _, tpl := range cfg.PlanBase {
|
||||||
|
add(renderQueryTemplate(tpl, vars))
|
||||||
|
if len(out) >= cfg.MaxPlanQueries {
|
||||||
|
return capQueries(out, cfg.MaxPlanQueries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tpl := range cfg.PlanPeripheral {
|
||||||
|
add(renderQueryTemplate(tpl, vars))
|
||||||
|
if len(out) >= cfg.MaxPlanQueries {
|
||||||
|
return capQueries(out, cfg.MaxPlanQueries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vars["audience"] != "" && strings.TrimSpace(cfg.PlanAudience) != "" {
|
||||||
|
add(renderQueryTemplate(cfg.PlanAudience, vars))
|
||||||
|
}
|
||||||
|
for _, label := range in.L1Labels {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label == "" || label == seed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l1vars := map[string]string{"seed": seed, "label": label}
|
||||||
|
add(renderQueryTemplate(cfg.PlanL1Cause, l1vars))
|
||||||
|
if len(out) >= cfg.MaxPlanQueries {
|
||||||
|
return capQueries(out, cfg.MaxPlanQueries)
|
||||||
|
}
|
||||||
|
add(renderQueryTemplate(cfg.PlanL1Pain, l1vars))
|
||||||
|
if len(out) >= cfg.MaxPlanQueries {
|
||||||
|
return capQueries(out, cfg.MaxPlanQueries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return capQueries(out, cfg.MaxPlanQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func supplementalQueries(cfg queryConfig, seed string, l1Labels []string) []string {
|
||||||
|
seed = strings.TrimSpace(seed)
|
||||||
|
if seed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, cfg.MaxSupplemental)
|
||||||
|
add := func(q string) {
|
||||||
|
q = strings.TrimSpace(q)
|
||||||
|
if q == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[q]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[q] = struct{}{}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
vars := map[string]string{"seed": seed}
|
||||||
|
for _, tpl := range cfg.Supplemental {
|
||||||
|
add(renderQueryTemplate(tpl, vars))
|
||||||
|
}
|
||||||
|
for _, label := range l1Labels {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(renderQueryTemplate(cfg.SupplementalL1, map[string]string{"seed": seed, "label": label}))
|
||||||
|
if len(out) >= cfg.MaxSupplemental {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return capQueries(out, cfg.MaxSupplemental)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildRecencyQuery(label string) string {
|
||||||
|
cfg, err := loadQueryConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(label, cfg.RecencyHelpMarkers) {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
suffix := strings.TrimSpace(cfg.RecencySuffix)
|
||||||
|
if suffix == "" {
|
||||||
|
suffix = "請問"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s", label, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderQueryTemplate(tpl string, vars map[string]string) string {
|
||||||
|
out := tpl
|
||||||
|
for key, value := range vars {
|
||||||
|
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func capQueries(items []string, max int) []string {
|
||||||
|
if max <= 0 || len(items) <= max {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return items[:max]
|
||||||
|
}
|
||||||
|
|
||||||
|
func L1LabelsFromNodes(nodes []Node) []string {
|
||||||
|
out := make([]string, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Layer != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(node.Label)
|
||||||
|
if label != "" {
|
||||||
|
out = append(out, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPlanQueriesCapsAtConfigLimit(t *testing.T) {
|
||||||
|
queries := PlanQueries(PlanInput{
|
||||||
|
Seed: "敏感肌",
|
||||||
|
TargetAudience: "孕婦",
|
||||||
|
L1Labels: []string{"a", "b", "c", "d", "e", "f", "g", "h"},
|
||||||
|
})
|
||||||
|
max := MaxPlanQueriesPerRound()
|
||||||
|
if len(queries) > max {
|
||||||
|
t.Fatalf("expected <= %d queries, got %d", max, len(queries))
|
||||||
|
}
|
||||||
|
if len(queries) < 8 {
|
||||||
|
t.Fatalf("expected at least 8 queries, got %d", len(queries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveSearchTagsFromGraph(t *testing.T) {
|
||||||
|
graph := Graph{
|
||||||
|
Nodes: []Node{
|
||||||
|
{ID: "n1", Label: "敏感肌", NodeKind: "pain", Layer: 0},
|
||||||
|
{ID: "n2", Label: "屏障受損", NodeKind: "symptom", Layer: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DeriveSearchTagsFromGraph(&graph)
|
||||||
|
if graph.PainTagCount != 2 {
|
||||||
|
t.Fatalf("expected pain tag count 2, got %d", graph.PainTagCount)
|
||||||
|
}
|
||||||
|
if len(graph.Nodes[0].DerivedTags.Relevance) == 0 {
|
||||||
|
t.Fatal("expected relevance tags on core node")
|
||||||
|
}
|
||||||
|
if len(graph.Nodes[0].DerivedTags.Recency) == 0 {
|
||||||
|
t.Fatal("expected recency tags on pain node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
libprompt "haixun-backend/internal/library/prompt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SynthInput struct {
|
||||||
|
Seed string
|
||||||
|
ProductBrief string
|
||||||
|
TargetAudience string
|
||||||
|
Persona string
|
||||||
|
Sources []BraveSource
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawSynthOutput struct {
|
||||||
|
Nodes []struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
NodeKind string `json:"nodeKind"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Layer int `json:"layer"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
PlacementValue string `json:"placementValue"`
|
||||||
|
ProductFitScore int `json:"productFitScore"`
|
||||||
|
EvidenceURLs []string `json:"evidenceUrls"`
|
||||||
|
} `json:"nodes"`
|
||||||
|
Edges []struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
} `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeFenceRE = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$")
|
||||||
|
|
||||||
|
func BuildUserPrompt(in SynthInput) (string, error) {
|
||||||
|
var sources strings.Builder
|
||||||
|
limit := len(in.Sources)
|
||||||
|
if limit > 30 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
src := in.Sources[i]
|
||||||
|
fmt.Fprintf(&sources, "[%d] query=%s\nurl=%s\ntitle=%s\nsnippet=%s\n\n",
|
||||||
|
i+1, src.Query, src.URL, src.Title, src.Snippet)
|
||||||
|
}
|
||||||
|
vars := map[string]string{
|
||||||
|
"seed": strings.TrimSpace(in.Seed),
|
||||||
|
"product_brief_line": optionalLine("產品簡述", in.ProductBrief),
|
||||||
|
"target_audience_line": optionalLine("目標受眾", in.TargetAudience),
|
||||||
|
"persona_line": optionalLine("人設", in.Persona),
|
||||||
|
"sources": strings.TrimSpace(sources.String()),
|
||||||
|
}
|
||||||
|
return libprompt.KnowledgeGraphUser(vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalLine(label, value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return label + ":" + value + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, error) {
|
||||||
|
payload, err := extractJSONObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Graph{}, err
|
||||||
|
}
|
||||||
|
var out rawSynthOutput
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
return Graph{}, fmt.Errorf("parse knowledge graph json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seed := strings.TrimSpace(in.Seed)
|
||||||
|
graph := Graph{
|
||||||
|
Seed: seed,
|
||||||
|
BraveSources: sources,
|
||||||
|
Nodes: []Node{},
|
||||||
|
Edges: []Edge{},
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceByURL := map[string]BraveSource{}
|
||||||
|
for _, src := range sources {
|
||||||
|
if src.URL != "" {
|
||||||
|
sourceByURL[src.URL] = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCore := false
|
||||||
|
for _, item := range out.Nodes {
|
||||||
|
label := strings.TrimSpace(item.Label)
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
layer := item.Layer
|
||||||
|
nodeType := strings.TrimSpace(item.Type)
|
||||||
|
nodeKind := strings.TrimSpace(item.NodeKind)
|
||||||
|
if layer == 0 || nodeType == "core" {
|
||||||
|
layer = 0
|
||||||
|
nodeType = "core"
|
||||||
|
if nodeKind == "" {
|
||||||
|
nodeKind = "pain"
|
||||||
|
}
|
||||||
|
hasCore = true
|
||||||
|
}
|
||||||
|
if nodeKind == "" {
|
||||||
|
if layer >= 2 {
|
||||||
|
nodeKind = "cause"
|
||||||
|
} else if layer == 1 {
|
||||||
|
nodeKind = "symptom"
|
||||||
|
} else {
|
||||||
|
nodeKind = "knowledge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evidence := make([]Evidence, 0, len(item.EvidenceURLs))
|
||||||
|
for _, u := range item.EvidenceURLs {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ev := Evidence{URL: u}
|
||||||
|
if src, ok := sourceByURL[u]; ok {
|
||||||
|
ev.Snippet = src.Snippet
|
||||||
|
ev.Query = src.Query
|
||||||
|
}
|
||||||
|
evidence = append(evidence, ev)
|
||||||
|
}
|
||||||
|
fit := item.ProductFitScore
|
||||||
|
if fit <= 0 {
|
||||||
|
fit = defaultProductFit(nodeKind, layer)
|
||||||
|
}
|
||||||
|
graph.Nodes = append(graph.Nodes, Node{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Label: label,
|
||||||
|
NodeKind: nodeKind,
|
||||||
|
Type: nodeType,
|
||||||
|
Layer: layer,
|
||||||
|
Relation: strings.TrimSpace(item.Relation),
|
||||||
|
PlacementValue: normalizePlacement(item.PlacementValue, nodeKind),
|
||||||
|
ProductFitScore: fit,
|
||||||
|
Evidence: evidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasCore && seed != "" {
|
||||||
|
graph.Nodes = append([]Node{{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Label: seed,
|
||||||
|
NodeKind: "pain",
|
||||||
|
Type: "core",
|
||||||
|
Layer: 0,
|
||||||
|
PlacementValue: "high",
|
||||||
|
ProductFitScore: 90,
|
||||||
|
}}, graph.Nodes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
labelToID := map[string]string{}
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
labelToID[strings.ToLower(strings.TrimSpace(node.Label))] = node.ID
|
||||||
|
}
|
||||||
|
for _, edge := range out.Edges {
|
||||||
|
from := resolveNodeRef(edge.From, labelToID, graph.Nodes)
|
||||||
|
to := resolveNodeRef(edge.To, labelToID, graph.Nodes)
|
||||||
|
if from == "" || to == "" || from == to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
graph.Edges = append(graph.Edges, Edge{
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Relation: strings.TrimSpace(edge.Relation),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
DeriveSearchTagsFromGraph(&graph)
|
||||||
|
return graph, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProductFit(nodeKind string, layer int) int {
|
||||||
|
switch nodeKind {
|
||||||
|
case "pain":
|
||||||
|
if layer == 0 {
|
||||||
|
return 90
|
||||||
|
}
|
||||||
|
return 80
|
||||||
|
case "symptom", "cause":
|
||||||
|
return 70
|
||||||
|
default:
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePlacement(value, nodeKind string) string {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
switch value {
|
||||||
|
case "high", "medium", "low":
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if IsPainNode(Node{NodeKind: nodeKind}) {
|
||||||
|
return "high"
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNodeRef(ref string, labelToID map[string]string, nodes []Node) string {
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
if ref == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.ID == ref {
|
||||||
|
return node.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id, ok := labelToID[strings.ToLower(ref)]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONObject(raw string) ([]byte, error) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return nil, fmt.Errorf("empty LLM response")
|
||||||
|
}
|
||||||
|
if m := codeFenceRE.FindStringSubmatch(text); len(m) == 2 {
|
||||||
|
text = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
start := strings.Index(text, "{")
|
||||||
|
end := strings.LastIndex(text, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("LLM response does not contain JSON object")
|
||||||
|
}
|
||||||
|
return []byte(text[start : end+1]), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
libprompt "haixun-backend/internal/library/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Row struct {
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
SearchTag string `json:"search_tag"`
|
||||||
|
Angle string `json:"angle"`
|
||||||
|
Hook string `json:"hook"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ReferenceNotes string `json:"reference_notes"`
|
||||||
|
SourcePermalinks []string `json:"source_permalinks"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateResult struct {
|
||||||
|
Rows []Row `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaterialPost struct {
|
||||||
|
SearchTag string
|
||||||
|
Author string
|
||||||
|
Text string
|
||||||
|
Permalink string
|
||||||
|
Priority string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateInput struct {
|
||||||
|
Persona string
|
||||||
|
TopicLabel string
|
||||||
|
AudienceBrief string
|
||||||
|
ProductBrief string
|
||||||
|
Posts []MaterialPost
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
||||||
|
|
||||||
|
func BuildUserPrompt(in GenerateInput) (string, error) {
|
||||||
|
count := in.Count
|
||||||
|
if count <= 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
|
personaBlock := ""
|
||||||
|
if strings.TrimSpace(in.Persona) != "" {
|
||||||
|
personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
|
||||||
|
}
|
||||||
|
audience := strings.TrimSpace(in.AudienceBrief)
|
||||||
|
if audience == "" {
|
||||||
|
audience = "(未指定)"
|
||||||
|
}
|
||||||
|
product := strings.TrimSpace(in.ProductBrief)
|
||||||
|
if product == "" {
|
||||||
|
product = "(尚未填寫)"
|
||||||
|
}
|
||||||
|
topic := strings.TrimSpace(in.TopicLabel)
|
||||||
|
if topic == "" {
|
||||||
|
topic = "未指定"
|
||||||
|
}
|
||||||
|
return libprompt.MatrixPlacementUser(map[string]string{
|
||||||
|
"persona_block": personaBlock,
|
||||||
|
"topic_label": topic,
|
||||||
|
"audience_line": audience,
|
||||||
|
"product_brief": product,
|
||||||
|
"post_count": fmt.Sprintf("%d", len(in.Posts)),
|
||||||
|
"materials_block": buildMaterialsBlock(in.Posts),
|
||||||
|
"count": fmt.Sprintf("%d", count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMaterialsBlock(posts []MaterialPost) string {
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return "(無素材)"
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(posts))
|
||||||
|
for i, post := range posts {
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"%d. [%s/%s] @%s\n%s\n連結:%s",
|
||||||
|
i+1,
|
||||||
|
strings.TrimSpace(post.Priority),
|
||||||
|
strings.TrimSpace(post.SearchTag),
|
||||||
|
strings.TrimSpace(post.Author),
|
||||||
|
strings.TrimSpace(post.Text),
|
||||||
|
strings.TrimSpace(post.Permalink),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
||||||
|
payload, err := extractJSONObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
return GenerateResult{}, err
|
||||||
|
}
|
||||||
|
var out GenerateResult
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err)
|
||||||
|
}
|
||||||
|
if len(out.Rows) == 0 {
|
||||||
|
return GenerateResult{}, fmt.Errorf("matrix rows missing")
|
||||||
|
}
|
||||||
|
for i := range out.Rows {
|
||||||
|
out.Rows[i].Text = trimText(out.Rows[i].Text)
|
||||||
|
if out.Rows[i].Text == "" {
|
||||||
|
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
|
||||||
|
}
|
||||||
|
if out.Rows[i].SortOrder <= 0 {
|
||||||
|
out.Rows[i].SortOrder = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimText(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > 500 {
|
||||||
|
return string(runes[:500])
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONObject(raw string) ([]byte, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
||||||
|
raw = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
start := strings.Index(raw, "{")
|
||||||
|
end := strings.LastIndex(raw, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("matrix output missing json object")
|
||||||
|
}
|
||||||
|
return []byte(raw[start : end+1]), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BrandScopeFilter matches documents keyed by brand_id or legacy persona_id.
|
||||||
|
func BrandScopeFilter(brandID string) bson.M {
|
||||||
|
id := strings.TrimSpace(brandID)
|
||||||
|
return bson.M{"$or": []bson.M{
|
||||||
|
{"brand_id": id},
|
||||||
|
{"persona_id": id},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBrandID returns brand_id when set, otherwise legacy persona_id.
|
||||||
|
func ResolveBrandID(brandID, legacyPersonaID string) string {
|
||||||
|
if trimmed := strings.TrimSpace(brandID); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(legacyPersonaID)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MongoDB server error codes for index conflicts.
|
||||||
|
const (
|
||||||
|
indexOptionsConflictCode = 85 // IndexOptionsConflict
|
||||||
|
indexKeySpecsConflictCode = 86 // IndexKeySpecsConflict
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureIndexes creates the requested indexes, recovering from conflicts caused
|
||||||
|
// by indexes that an earlier schema version created with the same name but
|
||||||
|
// different options (e.g. adding a partialFilterExpression during the
|
||||||
|
// persona -> brand migration). On a conflict it drops the stale index by its
|
||||||
|
// generated name and recreates it with the requested options, so startup does
|
||||||
|
// not panic on environments that still hold the legacy index.
|
||||||
|
func EnsureIndexes(ctx context.Context, coll *mongo.Collection, models []mongo.IndexModel) error {
|
||||||
|
if coll == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, model := range models {
|
||||||
|
if _, err := coll.Indexes().CreateOne(ctx, model); err != nil {
|
||||||
|
if !isIndexConflict(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := indexName(model)
|
||||||
|
if name == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, dropErr := coll.Indexes().DropOne(ctx, name); dropErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, retryErr := coll.Indexes().CreateOne(ctx, model); retryErr != nil {
|
||||||
|
return retryErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIndexConflict(err error) bool {
|
||||||
|
var serverErr mongo.ServerError
|
||||||
|
if errors.As(err, &serverErr) {
|
||||||
|
return serverErr.HasErrorCode(indexOptionsConflictCode) ||
|
||||||
|
serverErr.HasErrorCode(indexKeySpecsConflictCode)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexName reproduces MongoDB's default index name (key_direction pairs joined
|
||||||
|
// by underscores) so a conflicting index can be dropped by name.
|
||||||
|
func indexName(model mongo.IndexModel) string {
|
||||||
|
if model.Options != nil && model.Options.Name != nil {
|
||||||
|
return *model.Options.Name
|
||||||
|
}
|
||||||
|
keys, ok := model.Keys.(bson.D)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(keys)*2)
|
||||||
|
for _, e := range keys {
|
||||||
|
parts = append(parts, e.Key, indexValueToken(e.Value))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexValueToken(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(t)
|
||||||
|
case int32:
|
||||||
|
return strconv.Itoa(int(t))
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(t, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatInt(int64(t), 10)
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package outreach
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
libprompt "haixun-backend/internal/library/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxChars = 500
|
||||||
|
|
||||||
|
type Draft struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Angle string `json:"angle"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateResult struct {
|
||||||
|
Relevance float64 `json:"relevance"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Drafts []Draft `json:"drafts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateInput struct {
|
||||||
|
Persona string
|
||||||
|
TopicLabel string
|
||||||
|
AudienceBrief string
|
||||||
|
ProductBrief string
|
||||||
|
PlacementReason string
|
||||||
|
TargetText string
|
||||||
|
AuthorName string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
||||||
|
|
||||||
|
func BuildUserPrompt(in GenerateInput) (string, error) {
|
||||||
|
count := in.Count
|
||||||
|
if count <= 0 {
|
||||||
|
count = 2
|
||||||
|
}
|
||||||
|
personaBlock := ""
|
||||||
|
if strings.TrimSpace(in.Persona) != "" {
|
||||||
|
personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
|
||||||
|
}
|
||||||
|
audienceLine := ""
|
||||||
|
if strings.TrimSpace(in.AudienceBrief) != "" {
|
||||||
|
audienceLine = "受眾與情境:" + strings.TrimSpace(in.AudienceBrief)
|
||||||
|
}
|
||||||
|
productBrief := strings.TrimSpace(in.ProductBrief)
|
||||||
|
if productBrief == "" {
|
||||||
|
productBrief = "(尚未填寫品牌與產品,請先給實用建議,不要捏造品牌)"
|
||||||
|
}
|
||||||
|
reasonLine := ""
|
||||||
|
if strings.TrimSpace(in.PlacementReason) != "" {
|
||||||
|
reasonLine = "為何適合留言:" + strings.TrimSpace(in.PlacementReason)
|
||||||
|
}
|
||||||
|
topic := strings.TrimSpace(in.TopicLabel)
|
||||||
|
if topic == "" {
|
||||||
|
topic = "未指定"
|
||||||
|
}
|
||||||
|
author := strings.TrimSpace(in.AuthorName)
|
||||||
|
if author == "" {
|
||||||
|
author = "匿名"
|
||||||
|
}
|
||||||
|
return libprompt.OutreachPlacementUser(map[string]string{
|
||||||
|
"persona_block": personaBlock,
|
||||||
|
"topic_label": topic,
|
||||||
|
"audience_line": audienceLine,
|
||||||
|
"product_brief": productBrief,
|
||||||
|
"placement_reason_line": reasonLine,
|
||||||
|
"author_name": author,
|
||||||
|
"target_text": strings.TrimSpace(in.TargetText),
|
||||||
|
"count": fmt.Sprintf("%d", count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
||||||
|
payload, err := extractJSONObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
return GenerateResult{}, err
|
||||||
|
}
|
||||||
|
var out GenerateResult
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
return GenerateResult{}, fmt.Errorf("parse outreach json: %w", err)
|
||||||
|
}
|
||||||
|
if len(out.Drafts) == 0 {
|
||||||
|
return GenerateResult{}, fmt.Errorf("outreach drafts missing")
|
||||||
|
}
|
||||||
|
for i := range out.Drafts {
|
||||||
|
out.Drafts[i].Text = trimDraftText(out.Drafts[i].Text)
|
||||||
|
if out.Drafts[i].Text == "" {
|
||||||
|
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Relevance < 0 {
|
||||||
|
out.Relevance = 0
|
||||||
|
}
|
||||||
|
if out.Relevance > 1 {
|
||||||
|
out.Relevance = 1
|
||||||
|
}
|
||||||
|
out.Reason = strings.TrimSpace(out.Reason)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDraftText(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > maxChars {
|
||||||
|
return string(runes[:maxChars])
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONObject(raw string) ([]byte, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
||||||
|
raw = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
start := strings.Index(raw, "{")
|
||||||
|
end := strings.LastIndex(raw, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("outreach output missing json object")
|
||||||
|
}
|
||||||
|
return []byte(raw[start : end+1]), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package outreach
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseGenerateOutput(t *testing.T) {
|
||||||
|
raw := `{"relevance":0.85,"reason":"對方在問敏感肌保養","drafts":[{"text":"我之前也這樣…","angle":"共情","rationale":"先回應情緒"}]}`
|
||||||
|
got, err := ParseGenerateOutput(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseGenerateOutput() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Relevance != 0.85 {
|
||||||
|
t.Fatalf("relevance = %v, want 0.85", got.Relevance)
|
||||||
|
}
|
||||||
|
if len(got.Drafts) != 1 || got.Drafts[0].Text == "" {
|
||||||
|
t.Fatalf("drafts = %+v", got.Drafts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGenerateOutputCodeFence(t *testing.T) {
|
||||||
|
raw := "```json\n{\"relevance\":1.2,\"reason\":\"ok\",\"drafts\":[{\"text\":\"hi\",\"angle\":\"a\",\"rationale\":\"b\"}]}\n```"
|
||||||
|
got, err := ParseGenerateOutput(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseGenerateOutput() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Relevance != 1 {
|
||||||
|
t.Fatalf("relevance clamped = %v, want 1", got.Relevance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ConnectionPrefsInput mirrors persisted account connection prefs without importing threads_account.
|
||||||
|
type ConnectionPrefsInput struct {
|
||||||
|
DevMode bool
|
||||||
|
SearchSourceMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberContext is resolved per login member (email account) + active Threads operating account.
|
||||||
|
type MemberContext struct {
|
||||||
|
TenantID string
|
||||||
|
OwnerUID string
|
||||||
|
ActiveAccountID string
|
||||||
|
DevMode bool
|
||||||
|
SearchSourceMode SearchSourceMode
|
||||||
|
AllowsThreadsAPI bool
|
||||||
|
AllowsBrave bool
|
||||||
|
AllowsCrawler bool
|
||||||
|
BraveAPIKey string
|
||||||
|
BraveCountry string
|
||||||
|
BraveSearchLang string
|
||||||
|
ApiConnected bool
|
||||||
|
BrowserConnected bool
|
||||||
|
ThreadsAPIAccessToken string
|
||||||
|
ScrapeReplies bool
|
||||||
|
RepliesPerPost int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchSettings struct {
|
||||||
|
BraveAPIKey string
|
||||||
|
BraveCountry string
|
||||||
|
BraveSearchLang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMemberContext(
|
||||||
|
tenantID, ownerUID, activeAccountID string,
|
||||||
|
prefs ConnectionPrefsInput,
|
||||||
|
apiConnected, browserConnected bool,
|
||||||
|
research ResearchSettings,
|
||||||
|
scrapeReplies bool,
|
||||||
|
repliesPerPost int,
|
||||||
|
) MemberContext {
|
||||||
|
mode := ParseSearchSourceMode(prefs.SearchSourceMode)
|
||||||
|
allowsCrawler := ModeAllowsCrawler(mode)
|
||||||
|
allowsThreads := ModeAllowsThreadsAPI(mode)
|
||||||
|
allowsBrave := ModeAllowsBrave(mode)
|
||||||
|
|
||||||
|
if !prefs.DevMode {
|
||||||
|
mode = WithoutCrawler(mode)
|
||||||
|
allowsCrawler = false
|
||||||
|
} else {
|
||||||
|
mode = SearchSourceCrawler
|
||||||
|
allowsCrawler = true
|
||||||
|
allowsThreads = false
|
||||||
|
allowsBrave = false
|
||||||
|
}
|
||||||
|
|
||||||
|
country := strings.TrimSpace(research.BraveCountry)
|
||||||
|
if country == "" {
|
||||||
|
country = "tw"
|
||||||
|
}
|
||||||
|
lang := strings.TrimSpace(research.BraveSearchLang)
|
||||||
|
if lang == "" {
|
||||||
|
lang = "zh-hant"
|
||||||
|
}
|
||||||
|
|
||||||
|
if repliesPerPost <= 0 {
|
||||||
|
repliesPerPost = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return MemberContext{
|
||||||
|
TenantID: tenantID,
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
ActiveAccountID: activeAccountID,
|
||||||
|
DevMode: prefs.DevMode,
|
||||||
|
SearchSourceMode: mode,
|
||||||
|
AllowsThreadsAPI: allowsThreads,
|
||||||
|
AllowsBrave: allowsBrave,
|
||||||
|
AllowsCrawler: allowsCrawler,
|
||||||
|
BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
|
||||||
|
BraveCountry: country,
|
||||||
|
BraveSearchLang: lang,
|
||||||
|
ApiConnected: apiConnected,
|
||||||
|
BrowserConnected: browserConnected,
|
||||||
|
ScrapeReplies: scrapeReplies,
|
||||||
|
RepliesPerPost: repliesPerPost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MemberContext) PayloadFields() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"tenant_id": c.TenantID,
|
||||||
|
"owner_uid": c.OwnerUID,
|
||||||
|
"threads_account_id": c.ActiveAccountID,
|
||||||
|
"dev_mode": c.DevMode,
|
||||||
|
"search_source_mode": string(c.SearchSourceMode),
|
||||||
|
"allows_threads_api": c.AllowsThreadsAPI,
|
||||||
|
"allows_brave": c.AllowsBrave,
|
||||||
|
"allows_crawler": c.AllowsCrawler,
|
||||||
|
"brave_country": c.BraveCountry,
|
||||||
|
"brave_search_lang": c.BraveSearchLang,
|
||||||
|
"api_connected": c.ApiConnected,
|
||||||
|
"browser_connected": c.BrowserConnected,
|
||||||
|
"scrape_replies": c.ScrapeReplies,
|
||||||
|
"replies_per_post": c.RepliesPerPost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CrawlerSearchFn runs Playwright keyword search with a logged-in browser session.
|
||||||
|
type CrawlerSearchFn func(ctx context.Context, member MemberContext, keyword string, limit int) ([]DiscoverPost, error)
|
||||||
|
|
||||||
|
type execCrawlerInput struct {
|
||||||
|
StorageState string `json:"storage_state"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type execCrawlerPost struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
ExternalID string `json:"externalId"`
|
||||||
|
AuthorName string `json:"authorName"`
|
||||||
|
LikeCount int `json:"likeCount"`
|
||||||
|
ReplyCount int `json:"replyCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type execCrawlerOutput struct {
|
||||||
|
Posts []execCrawlerPost `json:"posts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunExecCrawlerSearch invokes the Node Playwright CLI (tsx) for keyword search.
|
||||||
|
func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, limit int) ([]DiscoverPost, error) {
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
storageState = strings.TrimSpace(storageState)
|
||||||
|
if storageState == "" {
|
||||||
|
return nil, fmt.Errorf("找不到 Chrome session,請先到連線頁同步 Threads 登入態")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, cliPath, err := resolveKeywordSearchCLI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(execCrawlerInput{
|
||||||
|
StorageState: storageState,
|
||||||
|
Query: keyword,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath)
|
||||||
|
cmd.Dir = repoRoot
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("crawler search failed: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out execCrawlerOutput
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("crawler search output parse failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make([]DiscoverPost, 0, len(out.Posts))
|
||||||
|
for _, item := range out.Posts {
|
||||||
|
text := strings.TrimSpace(item.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
author := strings.TrimSpace(item.AuthorName)
|
||||||
|
permalink := strings.TrimSpace(item.Permalink)
|
||||||
|
extID := strings.TrimSpace(item.ExternalID)
|
||||||
|
posts = append(posts, DiscoverPost{
|
||||||
|
Text: text,
|
||||||
|
Permalink: permalink,
|
||||||
|
ExternalID: extID,
|
||||||
|
Author: author,
|
||||||
|
LikeCount: item.LikeCount,
|
||||||
|
ReplyCount: item.ReplyCount,
|
||||||
|
Source: DiscoverCrawler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKeywordSearchCLI() (repoRoot, cliPath string, err error) {
|
||||||
|
if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" {
|
||||||
|
cli := filepath.Join(root, "haixun-backend", "worker", "threads-keyword-search-cli.ts")
|
||||||
|
if fileExists(cli) {
|
||||||
|
return root, cli, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("resolve crawler cli: %w", err)
|
||||||
|
}
|
||||||
|
dir := cwd
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
cli := filepath.Join(dir, "haixun-backend", "worker", "threads-keyword-search-cli.ts")
|
||||||
|
if fileExists(cli) {
|
||||||
|
return dir, cli, nil
|
||||||
|
}
|
||||||
|
cli = filepath.Join(dir, "worker", "threads-keyword-search-cli.ts")
|
||||||
|
if fileExists(cli) {
|
||||||
|
return dir, cli, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("找不到 threads-keyword-search-cli.ts,請設定 HAIXUN_REPO_ROOT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrawlerKeywordFromQuery extracts plain keyword from Brave-style query strings.
|
||||||
|
func CrawlerKeywordFromQuery(query, keyword string) string {
|
||||||
|
if k := strings.TrimSpace(keyword); k != "" {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
q = strings.TrimPrefix(q, "site:threads.net ")
|
||||||
|
q = strings.Trim(q, `"`)
|
||||||
|
if idx := strings.Index(q, " after:"); idx > 0 {
|
||||||
|
q = strings.TrimSpace(q[:idx])
|
||||||
|
}
|
||||||
|
q = strings.Trim(q, `"`)
|
||||||
|
if idx := strings.Index(q, " 請問"); idx > 0 {
|
||||||
|
q = strings.TrimSpace(q[:idx])
|
||||||
|
}
|
||||||
|
return strings.Trim(q, `"`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCrawlerKeywordFromQuery(t *testing.T) {
|
||||||
|
got := CrawlerKeywordFromQuery(`site:threads.net "敏感肌" 請問 after:2026-01-01`, "")
|
||||||
|
if got != "敏感肌" {
|
||||||
|
t.Fatalf("keyword = %q, want 敏感肌", got)
|
||||||
|
}
|
||||||
|
got = CrawlerKeywordFromQuery("", "換季泛紅")
|
||||||
|
if got != "換季泛紅" {
|
||||||
|
t.Fatalf("keyword = %q, want 換季泛紅", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverChannel identifies which backend fulfilled a placement discover query.
|
||||||
|
type DiscoverChannel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DiscoverThreadsAPI DiscoverChannel = "threads_api"
|
||||||
|
DiscoverBrave DiscoverChannel = "brave"
|
||||||
|
DiscoverCrawler DiscoverChannel = "crawler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverRequest is used by scan jobs; expand-graph only uses Brave knowledge_expand.
|
||||||
|
type DiscoverRequest struct {
|
||||||
|
Query string
|
||||||
|
Keyword string // plain tag for crawler; optional
|
||||||
|
Recency bool
|
||||||
|
Limit int
|
||||||
|
Member MemberContext
|
||||||
|
Crawler CrawlerSearchFn
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoverPost struct {
|
||||||
|
Text string
|
||||||
|
Permalink string
|
||||||
|
ExternalID string
|
||||||
|
Author string
|
||||||
|
PostedAt string
|
||||||
|
LikeCount int
|
||||||
|
ReplyCount int
|
||||||
|
Source DiscoverChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover runs keyword discovery respecting the member's connection prefs.
|
||||||
|
// Formal mode (dev_mode=false) never falls back to crawler.
|
||||||
|
func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) {
|
||||||
|
m := req.Member
|
||||||
|
if m.DevMode {
|
||||||
|
if !m.BrowserConnected {
|
||||||
|
return nil, "", fmt.Errorf("開發模式需先同步 Chrome Session")
|
||||||
|
}
|
||||||
|
if req.Crawler == nil {
|
||||||
|
return nil, DiscoverCrawler, fmt.Errorf("crawler search not configured")
|
||||||
|
}
|
||||||
|
keyword := CrawlerKeywordFromQuery(req.Query, req.Keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return nil, DiscoverCrawler, fmt.Errorf("crawler keyword is empty")
|
||||||
|
}
|
||||||
|
posts, err := req.Crawler(ctx, m, keyword, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, DiscoverCrawler, err
|
||||||
|
}
|
||||||
|
return posts, DiscoverCrawler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.AllowsThreadsAPI {
|
||||||
|
if !m.ApiConnected {
|
||||||
|
return nil, "", fmt.Errorf("正式模式需先完成 Threads API 連線")
|
||||||
|
}
|
||||||
|
posts, err := keywordSearchViaThreadsAPI(ctx, req)
|
||||||
|
if err == nil && len(posts) > 0 {
|
||||||
|
return posts, DiscoverThreadsAPI, nil
|
||||||
|
}
|
||||||
|
if err != nil && !m.AllowsBrave {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.AllowsBrave {
|
||||||
|
if m.BraveAPIKey == "" {
|
||||||
|
return nil, "", fmt.Errorf("請在設定頁設定 Brave Search API key(跟隨此登入帳號)")
|
||||||
|
}
|
||||||
|
return nil, DiscoverBrave, fmt.Errorf("brave threads discover delegated to worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
libbrave "haixun-backend/internal/library/brave"
|
||||||
|
libkg "haixun-backend/internal/library/knowledge"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
relevanceLimitPerTag = 12
|
||||||
|
recencyLimitPerTag = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScanCandidate struct {
|
||||||
|
Permalink string
|
||||||
|
ExternalID string
|
||||||
|
Author string
|
||||||
|
Text string
|
||||||
|
SearchTag string
|
||||||
|
QueryDimension QueryDimension
|
||||||
|
GraphNodeID string
|
||||||
|
ProductFitScore int
|
||||||
|
Source DiscoverChannel
|
||||||
|
HasRelevance bool
|
||||||
|
HasRecency bool
|
||||||
|
Priority string
|
||||||
|
LikeCount int
|
||||||
|
ReplyCount int
|
||||||
|
EngagementScore int
|
||||||
|
PlacementScore int
|
||||||
|
SolvedByProduct bool
|
||||||
|
Replies []ReplyCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
type DualTrackInput struct {
|
||||||
|
Nodes []libkg.Node
|
||||||
|
Exclusions []string
|
||||||
|
Member MemberContext
|
||||||
|
Client *libbrave.Client
|
||||||
|
Crawler CrawlerSearchFn
|
||||||
|
Limit int // max queries budget; 0 = default
|
||||||
|
}
|
||||||
|
|
||||||
|
type DualTrackProgress func(message string, pct int)
|
||||||
|
|
||||||
|
// CollectTagQueries builds crawl jobs from selected graph nodes.
|
||||||
|
func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
||||||
|
out := make([]TagQuery, 0, len(nodes)*4)
|
||||||
|
for _, node := range nodes {
|
||||||
|
if !node.SelectedForScan {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fit := node.ProductFitScore
|
||||||
|
for _, tag := range node.DerivedTags.Relevance {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q := BuildRelevanceQuery(tag)
|
||||||
|
if q == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, TagQuery{
|
||||||
|
Tag: tag,
|
||||||
|
Query: q,
|
||||||
|
Dimension: QueryRelevance,
|
||||||
|
GraphNodeID: node.ID,
|
||||||
|
ProductFitScore: fit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, tag := range node.DerivedTags.Recency {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays)
|
||||||
|
if q7 != "" {
|
||||||
|
out = append(out, TagQuery{
|
||||||
|
Tag: tag,
|
||||||
|
Query: q7,
|
||||||
|
Dimension: QueryRecency,
|
||||||
|
GraphNodeID: node.ID,
|
||||||
|
ProductFitScore: fit,
|
||||||
|
RecencyDays: IdealMaxPostAgeDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
q30 := BuildRecencyQuery(tag, MaxPostAgeDays)
|
||||||
|
if q30 != "" && q30 != q7 {
|
||||||
|
out = append(out, TagQuery{
|
||||||
|
Tag: tag,
|
||||||
|
Query: q30,
|
||||||
|
Dimension: QueryRecency,
|
||||||
|
GraphNodeID: node.ID,
|
||||||
|
ProductFitScore: fit,
|
||||||
|
RecencyDays: MaxPostAgeDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
||||||
|
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
|
||||||
|
queries := CollectTagQueries(input.Nodes)
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return nil, fmt.Errorf("沒有勾選的節點或可用 tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := map[string]*ScanCandidate{}
|
||||||
|
order := make([]string, 0, 64)
|
||||||
|
|
||||||
|
runQuery := func(tq TagQuery, limit int) error {
|
||||||
|
posts, channel, err := discoverForQuery(ctx, input, tq, limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, post := range posts {
|
||||||
|
if MatchesExclusion(post.Text, input.Exclusions) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !PassesPlacementFilter(post.Text) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := post.Permalink
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, ok := merged[key]
|
||||||
|
if !ok {
|
||||||
|
priority := "relevant"
|
||||||
|
if tq.Dimension == QueryRecency {
|
||||||
|
priority = "recent"
|
||||||
|
}
|
||||||
|
extID := post.ExternalID
|
||||||
|
if extID == "" {
|
||||||
|
if parsed, ok := ParseThreadsPostFromWebResult(post.Text, "", post.Permalink); ok {
|
||||||
|
extID = parsed.ExternalID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged[key] = &ScanCandidate{
|
||||||
|
Permalink: post.Permalink,
|
||||||
|
ExternalID: extID,
|
||||||
|
Author: post.Author,
|
||||||
|
Text: post.Text,
|
||||||
|
SearchTag: tq.Tag,
|
||||||
|
QueryDimension: tq.Dimension,
|
||||||
|
GraphNodeID: tq.GraphNodeID,
|
||||||
|
ProductFitScore: tq.ProductFitScore,
|
||||||
|
Source: channel,
|
||||||
|
HasRelevance: tq.Dimension == QueryRelevance,
|
||||||
|
HasRecency: tq.Dimension == QueryRecency,
|
||||||
|
Priority: priority,
|
||||||
|
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
|
||||||
|
SolvedByProduct: tq.ProductFitScore >= 55,
|
||||||
|
}
|
||||||
|
order = append(order, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tq.Dimension == QueryRelevance {
|
||||||
|
existing.HasRelevance = true
|
||||||
|
}
|
||||||
|
if tq.Dimension == QueryRecency {
|
||||||
|
existing.HasRecency = true
|
||||||
|
}
|
||||||
|
if tq.ProductFitScore > existing.ProductFitScore {
|
||||||
|
existing.ProductFitScore = tq.ProductFitScore
|
||||||
|
existing.SolvedByProduct = tq.ProductFitScore >= 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(queries)
|
||||||
|
for i, tq := range queries {
|
||||||
|
if onProgress != nil {
|
||||||
|
pct := 10 + ((i + 1) * 75 / max(total, 1))
|
||||||
|
onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
|
||||||
|
}
|
||||||
|
limit := relevanceLimitPerTag
|
||||||
|
if tq.Dimension == QueryRecency {
|
||||||
|
limit = recencyLimitPerTag
|
||||||
|
}
|
||||||
|
if err := runQuery(tq, limit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ScanCandidate, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
item := merged[key]
|
||||||
|
if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 {
|
||||||
|
item.Priority = "gold"
|
||||||
|
} else if item.HasRecency {
|
||||||
|
item.Priority = "recent"
|
||||||
|
} else {
|
||||||
|
item.Priority = "relevant"
|
||||||
|
}
|
||||||
|
if item.ProductFitScore < 30 && item.Priority != "gold" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency)
|
||||||
|
item.SolvedByProduct = item.ProductFitScore >= 55
|
||||||
|
out = append(out, *item)
|
||||||
|
}
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
|
||||||
|
req := DiscoverRequest{
|
||||||
|
Query: tq.Query,
|
||||||
|
Keyword: tq.Tag,
|
||||||
|
Recency: tq.Dimension == QueryRecency,
|
||||||
|
Limit: limit,
|
||||||
|
Member: input.Member,
|
||||||
|
Crawler: input.Crawler,
|
||||||
|
}
|
||||||
|
posts, channel, err := Discover(ctx, req)
|
||||||
|
if err == nil && len(posts) > 0 {
|
||||||
|
return posts, channel, nil
|
||||||
|
}
|
||||||
|
if input.Client == nil || !input.Client.Enabled() {
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("Brave 未設定且 Threads API 無結果")
|
||||||
|
}
|
||||||
|
bravePosts, berr := discoverViaBrave(ctx, input.Client, input.Member, tq.Query, limit)
|
||||||
|
if berr != nil {
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return nil, "", berr
|
||||||
|
}
|
||||||
|
return bravePosts, DiscoverBrave, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverViaBrave(ctx context.Context, client *libbrave.Client, member MemberContext, query string, limit int) ([]DiscoverPost, error) {
|
||||||
|
res, err := client.Search(ctx, libbrave.SearchOptions{
|
||||||
|
Query: query,
|
||||||
|
Limit: limit,
|
||||||
|
Mode: libbrave.ModeThreadsDiscover,
|
||||||
|
Country: member.BraveCountry,
|
||||||
|
SearchLang: member.BraveSearchLang,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Status != "success" || len(res.Results) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := make([]DiscoverPost, 0, len(res.Results))
|
||||||
|
for _, item := range res.Results {
|
||||||
|
parsed, ok := ParseThreadsPostFromWebResult(item.Title, item.Snippet, item.URL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, DiscoverPost{
|
||||||
|
Text: parsed.Text,
|
||||||
|
Permalink: parsed.Permalink,
|
||||||
|
ExternalID: parsed.ExternalID,
|
||||||
|
Author: parsed.Author,
|
||||||
|
Source: DiscoverBrave,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePlacementScore(text string, productFit int, recent bool) int {
|
||||||
|
score := 30 + productFit/4
|
||||||
|
if HasPlacementIntent(text) {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if LooksLikeRecommendationPost(text) {
|
||||||
|
score += 12
|
||||||
|
}
|
||||||
|
if recent {
|
||||||
|
score += 15
|
||||||
|
}
|
||||||
|
if productFit >= 60 {
|
||||||
|
score += 8
|
||||||
|
}
|
||||||
|
if score > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func MatchesExclusion(text string, exclusions []string) bool {
|
||||||
|
text = strings.ToLower(strings.TrimSpace(text))
|
||||||
|
if text == "" || len(exclusions) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, rule := range exclusions {
|
||||||
|
rule = strings.ToLower(strings.TrimSpace(rule))
|
||||||
|
if rule == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(text, rule) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var (
|
||||||
|
placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
|
||||||
|
placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
|
||||||
|
casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func LooksLikeRecommendationPost(text string) bool {
|
||||||
|
return placementRecommendRe.MatchString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasPlacementIntent(text string) bool {
|
||||||
|
if LooksLikeRecommendationPost(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return placementIntentRe.MatchString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LooksLikeCasualChat(text string) bool {
|
||||||
|
if HasPlacementIntent(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return casualChatRe.MatchString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PassesPlacementFilter(text string) bool {
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if LooksLikeCasualChat(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return HasPlacementIntent(text)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPassesPlacementFilter(t *testing.T) {
|
||||||
|
if !PassesPlacementFilter("敏感肌請問有推薦的洗臉產品嗎") {
|
||||||
|
t.Fatal("expected placement intent")
|
||||||
|
}
|
||||||
|
if PassesPlacementFilter("今天天氣真好晒照") {
|
||||||
|
t.Fatal("expected casual chat rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var threadsPostURLRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`)
|
||||||
|
|
||||||
|
type ParsedThreadsPost struct {
|
||||||
|
Permalink string
|
||||||
|
ExternalID string
|
||||||
|
Author string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPost, bool) {
|
||||||
|
permalink := normalizeThreadsPermalink(url)
|
||||||
|
if permalink == "" {
|
||||||
|
return ParsedThreadsPost{}, false
|
||||||
|
}
|
||||||
|
match := threadsPostURLRe.FindStringSubmatch(permalink)
|
||||||
|
if len(match) < 3 {
|
||||||
|
return ParsedThreadsPost{}, false
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — "))
|
||||||
|
if len([]rune(text)) < 8 {
|
||||||
|
return ParsedThreadsPost{}, false
|
||||||
|
}
|
||||||
|
return ParsedThreadsPost{
|
||||||
|
Permalink: permalink,
|
||||||
|
ExternalID: match[2],
|
||||||
|
Author: match[1],
|
||||||
|
Text: text,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeThreadsPermalink(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw = strings.Split(raw, "?")[0]
|
||||||
|
raw = strings.Split(raw, "#")[0]
|
||||||
|
if threadsPostURLRe.MatchString(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CtaType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CtaNone CtaType = "none"
|
||||||
|
CtaLink CtaType = "link"
|
||||||
|
CtaDM CtaType = "dm"
|
||||||
|
CtaFollow CtaType = "follow"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductContextFields struct {
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Product string `json:"product"`
|
||||||
|
Features string `json:"features"`
|
||||||
|
PlacementTone string `json:"placementTone,omitempty"`
|
||||||
|
CtaType CtaType `json:"ctaType,omitempty"`
|
||||||
|
CtaUrl string `json:"ctaUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseProductContext(raw string) ProductContextFields {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ProductContextFields{CtaType: CtaNone}
|
||||||
|
}
|
||||||
|
var parsed ProductContextFields
|
||||||
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||||
|
return ProductContextFields{CtaType: CtaNone, Features: raw}
|
||||||
|
}
|
||||||
|
parsed.Brand = strings.TrimSpace(parsed.Brand)
|
||||||
|
parsed.Product = strings.TrimSpace(parsed.Product)
|
||||||
|
parsed.Features = strings.TrimSpace(parsed.Features)
|
||||||
|
parsed.PlacementTone = strings.TrimSpace(parsed.PlacementTone)
|
||||||
|
parsed.CtaUrl = strings.TrimSpace(parsed.CtaUrl)
|
||||||
|
if parsed.CtaType == "" {
|
||||||
|
parsed.CtaType = CtaNone
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func SerializeProductContext(fields ProductContextFields) string {
|
||||||
|
brand := strings.TrimSpace(fields.Brand)
|
||||||
|
product := strings.TrimSpace(fields.Product)
|
||||||
|
features := strings.TrimSpace(fields.Features)
|
||||||
|
tone := strings.TrimSpace(fields.PlacementTone)
|
||||||
|
ctaType := fields.CtaType
|
||||||
|
if ctaType == "" {
|
||||||
|
ctaType = CtaNone
|
||||||
|
}
|
||||||
|
ctaUrl := strings.TrimSpace(fields.CtaUrl)
|
||||||
|
if brand == "" && product == "" && features == "" && tone == "" && ctaType == CtaNone && ctaUrl == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(ProductContextFields{
|
||||||
|
Brand: brand,
|
||||||
|
Product: product,
|
||||||
|
Features: features,
|
||||||
|
PlacementTone: tone,
|
||||||
|
CtaType: ctaType,
|
||||||
|
CtaUrl: ctaUrl,
|
||||||
|
})
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatProductContextForPrompt(raw string) string {
|
||||||
|
fields := ParseProductContext(raw)
|
||||||
|
lines := []string{}
|
||||||
|
if fields.Brand != "" {
|
||||||
|
lines = append(lines, "品牌:"+fields.Brand)
|
||||||
|
}
|
||||||
|
if fields.Product != "" {
|
||||||
|
lines = append(lines, "產品:"+fields.Product)
|
||||||
|
}
|
||||||
|
if fields.Features != "" {
|
||||||
|
lines = append(lines, "特色/能幫上忙的地方:"+fields.Features)
|
||||||
|
}
|
||||||
|
if fields.PlacementTone != "" {
|
||||||
|
lines = append(lines, "置入語氣偏好:"+fields.PlacementTone)
|
||||||
|
}
|
||||||
|
if fields.CtaType == CtaLink && fields.CtaUrl != "" {
|
||||||
|
lines = append(lines, "留言 CTA 連結:"+fields.CtaUrl)
|
||||||
|
} else if fields.CtaType == CtaDM {
|
||||||
|
lines = append(lines, "留言 CTA:引導私訊")
|
||||||
|
} else if fields.CtaType == CtaFollow {
|
||||||
|
lines = append(lines, "留言 CTA:引導追蹤")
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProductBriefFromContext(raw string) string {
|
||||||
|
formatted := FormatProductContextForPrompt(raw)
|
||||||
|
if formatted != "" {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasProductContext(raw string) bool {
|
||||||
|
fields := ParseProductContext(raw)
|
||||||
|
return fields.Brand != "" || fields.Product != "" || fields.Features != ""
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QueryDimension string
|
||||||
|
|
||||||
|
const (
|
||||||
|
QueryRelevance QueryDimension = "relevance"
|
||||||
|
QueryRecency QueryDimension = "recency"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TagQuery struct {
|
||||||
|
Tag string
|
||||||
|
Query string
|
||||||
|
Dimension QueryDimension
|
||||||
|
GraphNodeID string
|
||||||
|
ProductFitScore int
|
||||||
|
RecencyDays int // 0 = no after filter; 7 or 30 for recency track
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildRelevanceQuery(tag string) string {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return `site:threads.net "` + tag + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildRecencyQuery(tag string, maxAgeDays int) string {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
after := FormatAfterDate(maxAgeDays, timeNow())
|
||||||
|
return `site:threads.net "` + tag + `" 請問 after:` + after
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeNow = func() time.Time { return time.Now() }
|
||||||
|
|
||||||
|
// SetTimeNowForTest overrides time source in tests.
|
||||||
|
func SetTimeNowForTest(fn func() time.Time) {
|
||||||
|
timeNow = fn
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxPostAgeDays = 30
|
||||||
|
IdealMaxPostAgeDays = 7
|
||||||
|
WebSearchMaxAgeDays = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
func FormatAfterDate(maxAgeDays int, now time.Time) string {
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
date := now.AddDate(0, 0, -maxAgeDays).UTC()
|
||||||
|
return date.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResearchMap struct {
|
||||||
|
AudienceSummary string `json:"audienceSummary"`
|
||||||
|
ContentGoal string `json:"contentGoal"`
|
||||||
|
Questions []string `json:"questions"`
|
||||||
|
Pillars []string `json:"pillars"`
|
||||||
|
Exclusions []string `json:"exclusions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchMapInput struct {
|
||||||
|
Label string
|
||||||
|
SeedQuery string
|
||||||
|
Brief string
|
||||||
|
ProductContext string
|
||||||
|
}
|
||||||
|
|
||||||
|
var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
||||||
|
|
||||||
|
func BuildResearchMapSystemPrompt() string {
|
||||||
|
return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文。
|
||||||
|
|
||||||
|
規則:
|
||||||
|
1. questions 與 pillars 會直接拿去 Threads 搜尋——每句 5~20 字,像真人求助
|
||||||
|
2. questions 至少 5 個;pillars 至少 4 個;exclusions 至少 4 個
|
||||||
|
3. contentGoal 要寫:找到近期發文且可自然留言置入的貼文
|
||||||
|
4. 全部繁體中文,貼近台灣 Threads
|
||||||
|
5. 只回傳一個 JSON:audienceSummary, contentGoal, questions, pillars, exclusions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildResearchMapUserPrompt(in ResearchMapInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("【主題名稱】")
|
||||||
|
b.WriteString(strings.TrimSpace(in.Label))
|
||||||
|
b.WriteString("\n【種子關鍵字】")
|
||||||
|
b.WriteString(strings.TrimSpace(in.SeedQuery))
|
||||||
|
b.WriteString("\n【這個主題想做什么】\n")
|
||||||
|
b.WriteString(strings.TrimSpace(in.Brief))
|
||||||
|
b.WriteString("\n【產品置入】\n")
|
||||||
|
product := FormatProductContextForPrompt(in.ProductContext)
|
||||||
|
if product == "" {
|
||||||
|
product = "(尚未填寫)"
|
||||||
|
}
|
||||||
|
b.WriteString(product)
|
||||||
|
b.WriteString("\n\n請產出研究地圖 JSON。")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseResearchMapOutput(raw string) (ResearchMap, error) {
|
||||||
|
payload, err := extractResearchJSONObject(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ResearchMap{}, err
|
||||||
|
}
|
||||||
|
var out ResearchMap
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
return ResearchMap{}, fmt.Errorf("parse research map json: %w", err)
|
||||||
|
}
|
||||||
|
out.AudienceSummary = strings.TrimSpace(out.AudienceSummary)
|
||||||
|
out.ContentGoal = strings.TrimSpace(out.ContentGoal)
|
||||||
|
out.Questions = cleanStringList(out.Questions)
|
||||||
|
out.Pillars = cleanStringList(out.Pillars)
|
||||||
|
out.Exclusions = cleanStringList(out.Exclusions)
|
||||||
|
if out.AudienceSummary == "" && len(out.Questions) == 0 {
|
||||||
|
return ResearchMap{}, fmt.Errorf("research map missing audience or questions")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanStringList(items []string) []string {
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range items {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractResearchJSONObject(raw string) ([]byte, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if m := researchMapFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
||||||
|
raw = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
start := strings.Index(raw, "{")
|
||||||
|
end := strings.LastIndex(raw, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("research map output missing json object")
|
||||||
|
}
|
||||||
|
return []byte(raw[start : end+1]), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
libthreads "haixun-backend/internal/library/threadsapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const replyFetchTopPosts = 10
|
||||||
|
|
||||||
|
// ReplyCandidate is a normalized reply attached to a scan post.
|
||||||
|
type ReplyCandidate struct {
|
||||||
|
ExternalID string
|
||||||
|
Author string
|
||||||
|
Text string
|
||||||
|
Permalink string
|
||||||
|
LikeCount int
|
||||||
|
PostedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrapeRepliesInput struct {
|
||||||
|
Posts []ScanCandidate
|
||||||
|
Member MemberContext
|
||||||
|
RepliesPerPost int
|
||||||
|
MaxPosts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachReplies fetches replies for top-priority posts when scrape is enabled.
|
||||||
|
func AttachReplies(ctx context.Context, input ScrapeRepliesInput) []ScanCandidate {
|
||||||
|
if len(input.Posts) == 0 {
|
||||||
|
return input.Posts
|
||||||
|
}
|
||||||
|
perPost := input.RepliesPerPost
|
||||||
|
if perPost <= 0 {
|
||||||
|
perPost = 10
|
||||||
|
}
|
||||||
|
if perPost > 25 {
|
||||||
|
perPost = 25
|
||||||
|
}
|
||||||
|
maxPosts := input.MaxPosts
|
||||||
|
if maxPosts <= 0 {
|
||||||
|
maxPosts = replyFetchTopPosts
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := pickReplyTargets(input.Posts, maxPosts)
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return input.Posts
|
||||||
|
}
|
||||||
|
|
||||||
|
client := libthreads.NewClient(input.Member.ThreadsAPIAccessToken)
|
||||||
|
byKey := make(map[string][]ReplyCandidate, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
externalID := strings.TrimSpace(target.ExternalID)
|
||||||
|
if externalID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var replies []ReplyCandidate
|
||||||
|
if input.Member.ApiConnected && input.Member.ThreadsAPIAccessToken != "" {
|
||||||
|
items, err := client.MediaReplies(ctx, externalID, perPost)
|
||||||
|
if err == nil {
|
||||||
|
for _, item := range items {
|
||||||
|
text := strings.TrimSpace(item.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
replies = append(replies, ReplyCandidate{
|
||||||
|
ExternalID: strings.TrimSpace(item.ID),
|
||||||
|
Author: strings.TrimSpace(item.Username),
|
||||||
|
Text: text,
|
||||||
|
Permalink: strings.TrimSpace(item.Permalink),
|
||||||
|
LikeCount: item.LikeCount,
|
||||||
|
PostedAt: strings.TrimSpace(item.Timestamp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := candidateKey(target)
|
||||||
|
byKey[key] = replies
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ScanCandidate, 0, len(input.Posts))
|
||||||
|
for _, post := range input.Posts {
|
||||||
|
key := candidateKey(post)
|
||||||
|
if replies, ok := byKey[key]; ok && len(replies) > 0 {
|
||||||
|
post.Replies = replies
|
||||||
|
}
|
||||||
|
out = append(out, post)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickReplyTargets(posts []ScanCandidate, maxPosts int) []ScanCandidate {
|
||||||
|
ranked := append([]ScanCandidate(nil), posts...)
|
||||||
|
for i := 0; i < len(ranked); i++ {
|
||||||
|
for j := i + 1; j < len(ranked); j++ {
|
||||||
|
if replyTargetRank(ranked[j]) > replyTargetRank(ranked[i]) {
|
||||||
|
ranked[i], ranked[j] = ranked[j], ranked[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]ScanCandidate, 0, maxPosts)
|
||||||
|
for _, post := range ranked {
|
||||||
|
if strings.TrimSpace(post.ExternalID) == "" && strings.TrimSpace(post.Permalink) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, post)
|
||||||
|
if len(out) >= maxPosts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyTargetRank(post ScanCandidate) int {
|
||||||
|
switch post.Priority {
|
||||||
|
case "gold":
|
||||||
|
return 300 + post.PlacementScore
|
||||||
|
case "recent":
|
||||||
|
return 200 + post.PlacementScore
|
||||||
|
case "relevant":
|
||||||
|
return 100 + post.PlacementScore
|
||||||
|
default:
|
||||||
|
return post.PlacementScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(post ScanCandidate) string {
|
||||||
|
if id := strings.TrimSpace(post.ExternalID); id != "" {
|
||||||
|
return "id:" + id
|
||||||
|
}
|
||||||
|
return "url:" + strings.TrimSpace(post.Permalink)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReplyTargetRankPrefersGold(t *testing.T) {
|
||||||
|
gold := ScanCandidate{Priority: "gold", PlacementScore: 10}
|
||||||
|
recent := ScanCandidate{Priority: "recent", PlacementScore: 90}
|
||||||
|
if replyTargetRank(recent) >= replyTargetRank(gold) {
|
||||||
|
t.Fatalf("expected gold to outrank recent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickReplyTargetsRespectsLimit(t *testing.T) {
|
||||||
|
posts := make([]ScanCandidate, 0, 15)
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
posts = append(posts, ScanCandidate{
|
||||||
|
ExternalID: "id-" + string(rune('a'+i)),
|
||||||
|
Priority: "relevant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
targets := pickReplyTargets(posts, 5)
|
||||||
|
if len(targets) != 5 {
|
||||||
|
t.Fatalf("expected 5 targets, got %d", len(targets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// SearchSourceMode mirrors the legacy Next.js search source options per Threads account.
|
||||||
|
type SearchSourceMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SearchSourceMixed SearchSourceMode = "mixed"
|
||||||
|
SearchSourceThreads SearchSourceMode = "threads"
|
||||||
|
SearchSourceBrave SearchSourceMode = "brave"
|
||||||
|
SearchSourceCrawler SearchSourceMode = "crawler"
|
||||||
|
SearchSourceThreadsBrave SearchSourceMode = "threads_brave"
|
||||||
|
SearchSourceThreadsCrawler SearchSourceMode = "threads_crawler"
|
||||||
|
SearchSourceBraveCrawler SearchSourceMode = "brave_crawler"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSearchSourceMode = SearchSourceMixed
|
||||||
|
|
||||||
|
func ParseSearchSourceMode(raw string) SearchSourceMode {
|
||||||
|
switch SearchSourceMode(strings.TrimSpace(raw)) {
|
||||||
|
case SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceCrawler,
|
||||||
|
SearchSourceThreadsBrave, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
|
||||||
|
return SearchSourceMode(strings.TrimSpace(raw))
|
||||||
|
default:
|
||||||
|
return DefaultSearchSourceMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModeAllowsThreadsAPI(mode SearchSourceMode) bool {
|
||||||
|
switch mode {
|
||||||
|
case SearchSourceMixed, SearchSourceThreads, SearchSourceThreadsBrave, SearchSourceThreadsCrawler:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModeAllowsBrave(mode SearchSourceMode) bool {
|
||||||
|
switch mode {
|
||||||
|
case SearchSourceMixed, SearchSourceBrave, SearchSourceThreadsBrave, SearchSourceBraveCrawler:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModeAllowsCrawler(mode SearchSourceMode) bool {
|
||||||
|
switch mode {
|
||||||
|
case SearchSourceMixed, SearchSourceCrawler, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithoutCrawler returns a mode that never uses Playwright, for formal API-only routing.
|
||||||
|
func WithoutCrawler(mode SearchSourceMode) SearchSourceMode {
|
||||||
|
switch mode {
|
||||||
|
case SearchSourceMixed, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
|
||||||
|
return SearchSourceThreadsBrave
|
||||||
|
case SearchSourceCrawler:
|
||||||
|
return SearchSourceThreads
|
||||||
|
default:
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package placement
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWithoutCrawlerStripsBrowserModes(t *testing.T) {
|
||||||
|
if got := WithoutCrawler(SearchSourceMixed); got != SearchSourceThreadsBrave {
|
||||||
|
t.Fatalf("mixed -> threads_brave, got %s", got)
|
||||||
|
}
|
||||||
|
if got := WithoutCrawler(SearchSourceCrawler); got != SearchSourceThreads {
|
||||||
|
t.Fatalf("crawler -> threads, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMemberContextFormalModeNeverAllowsCrawler(t *testing.T) {
|
||||||
|
prefs := ConnectionPrefsInput{
|
||||||
|
DevMode: false,
|
||||||
|
SearchSourceMode: string(SearchSourceMixed),
|
||||||
|
}
|
||||||
|
ctx := BuildMemberContext("t", "u", "acc", prefs, true, false, ResearchSettings{}, false, 10)
|
||||||
|
if ctx.AllowsCrawler {
|
||||||
|
t.Fatal("formal mode must not allow crawler")
|
||||||
|
}
|
||||||
|
if ctx.SearchSourceMode != SearchSourceThreadsBrave {
|
||||||
|
t.Fatalf("expected threads_brave, got %s", ctx.SearchSourceMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue