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 v6.4.3 ready in 134 ms
|
||||
VITE v6.4.3 ready in 221 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ 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
|
||||
> . 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] claimed job=6a3b33a151becf68faf9ecf9 template=style-8d
|
||||
[8d-worker] completed job=6a3b33a151becf68faf9ecf9 username=petopia_tw posts=12
|
||||
[8d-worker] started id=local-style-8d-node-37646 api=http://127.0.0.1:8890
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
19030
|
||||
37575
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
19031
|
||||
37576
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
# Agent Handoff Notes
|
||||
|
||||
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ service gateway {
|
|||
@server (
|
||||
group: ai
|
||||
prefix: /api/v1/ai
|
||||
middleware: Auth
|
||||
middleware: AuthJWT
|
||||
tags: "AI - Islander Guide"
|
||||
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"
|
||||
"threads_account.api"
|
||||
"persona.api"
|
||||
"brand.api"
|
||||
"worker_internal.api"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ type (
|
|||
|
||||
CreateJobReq {
|
||||
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
|
||||
Payload map[string]interface{} `json:"payload,optional"` // job payload
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ type (
|
|||
|
||||
CreateJobScheduleReq {
|
||||
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
|
||||
Cron string `json:"cron" validate:"required"` // cron expression
|
||||
Timezone string `json:"timezone,optional"` // timezone
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ type (
|
|||
Currency string `json:"currency,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(
|
||||
|
|
@ -43,4 +56,10 @@ service gateway {
|
|||
|
||||
@handler updateMemberMe
|
||||
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"
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Persona string `json:"persona,omitempty"`
|
||||
Brief string `json:"brief,omitempty"`
|
||||
ProductBrief string `json:"product_brief,omitempty"`
|
||||
TargetAudience string `json:"target_audience,omitempty"`
|
||||
Goals string `json:"goals,omitempty"`
|
||||
StyleProfile string `json:"style_profile,omitempty"`
|
||||
StyleBenchmark string `json:"style_benchmark,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Persona string `json:"persona,omitempty"`
|
||||
Brief string `json:"brief,omitempty"`
|
||||
StyleProfile string `json:"style_profile,omitempty"`
|
||||
StyleBenchmark string `json:"style_benchmark,omitempty"`
|
||||
SeedQuery string `json:"seed_query,omitempty"`
|
||||
CopyResearchMap CopyResearchMapData `json:"copy_research_map,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
ListPersonasData {
|
||||
|
|
@ -30,10 +39,6 @@ type (
|
|||
UpdatePersonaReq {
|
||||
DisplayName *string `json:"display_name,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"`
|
||||
StyleBenchmark *string `json:"style_benchmark,optional"`
|
||||
}
|
||||
|
|
@ -47,6 +52,93 @@ type (
|
|||
Status string `json:"status"`
|
||||
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(
|
||||
|
|
@ -67,11 +159,23 @@ service gateway {
|
|||
get /:id (PersonaPath) returns (PersonaData)
|
||||
|
||||
@handler updatePersona
|
||||
patch /:id (PersonaPath, UpdatePersonaReq) returns (PersonaData)
|
||||
patch /:id (UpdatePersonaHandlerReq) returns (PersonaData)
|
||||
|
||||
@handler deletePersona
|
||||
delete /:id (PersonaPath)
|
||||
|
||||
@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 {
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Activate bool `json:"activate,optional"`
|
||||
Activate *bool `json:"activate,optional"`
|
||||
}
|
||||
|
||||
UpdateThreadsAccountReq {
|
||||
|
|
@ -95,6 +95,26 @@ type (
|
|||
ResearchModel *string `json:"research_model,optional"`
|
||||
ApiKeys map[string]string `json:"api_keys,optional"`
|
||||
}
|
||||
|
||||
UpdateThreadsAccountHandlerReq {
|
||||
ThreadsAccountPath
|
||||
UpdateThreadsAccountReq
|
||||
}
|
||||
|
||||
UpdateThreadsAccountConnectionHandlerReq {
|
||||
ThreadsAccountPath
|
||||
UpdateThreadsAccountConnectionReq
|
||||
}
|
||||
|
||||
ImportThreadsAccountSessionHandlerReq {
|
||||
ThreadsAccountPath
|
||||
ImportThreadsAccountSessionReq
|
||||
}
|
||||
|
||||
UpdateThreadsAccountAiSettingsHandlerReq {
|
||||
ThreadsAccountPath
|
||||
UpdateThreadsAccountAiSettingsReq
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -115,7 +135,7 @@ service gateway {
|
|||
get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
|
||||
|
||||
@handler updateThreadsAccount
|
||||
patch /:id (ThreadsAccountPath, UpdateThreadsAccountReq) returns (ThreadsAccountData)
|
||||
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
|
||||
|
||||
@handler activateThreadsAccount
|
||||
post /:id/activate (ThreadsAccountPath)
|
||||
|
|
@ -124,14 +144,14 @@ service gateway {
|
|||
get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
|
||||
|
||||
@handler updateThreadsAccountConnection
|
||||
patch /:id/connection (ThreadsAccountPath, UpdateThreadsAccountConnectionReq) returns (ThreadsAccountConnectionData)
|
||||
patch /:id/connection (UpdateThreadsAccountConnectionHandlerReq) returns (ThreadsAccountConnectionData)
|
||||
|
||||
@handler importThreadsAccountSession
|
||||
post /:id/session/import (ThreadsAccountPath, ImportThreadsAccountSessionReq) returns (ImportThreadsAccountSessionData)
|
||||
post /:id/session/import (ImportThreadsAccountSessionHandlerReq) returns (ImportThreadsAccountSessionData)
|
||||
|
||||
@handler getThreadsAccountAiSettings
|
||||
get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
|
||||
|
||||
@handler updateThreadsAccountAiSettings
|
||||
put /:id/ai-settings (ThreadsAccountPath, UpdateThreadsAccountAiSettingsReq) returns (ThreadsAccountAiSettingsData)
|
||||
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
|
||||
}
|
||||
|
|
@ -104,10 +104,11 @@ type (
|
|||
)
|
||||
|
||||
@server(
|
||||
group: job
|
||||
prefix: /api/v1/internal
|
||||
tags: "Internal Worker"
|
||||
summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
|
||||
group: job
|
||||
prefix: /api/v1/internal
|
||||
middleware: WorkerSecret
|
||||
tags: "Internal Worker"
|
||||
summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
|
||||
)
|
||||
service gateway {
|
||||
@handler claimWorkerJob
|
||||
|
|
@ -138,5 +139,5 @@ service gateway {
|
|||
post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
|
||||
|
||||
@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"
|
||||
|
||||
"haixun-backend/internal/response"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
{{.ImportPackages}}
|
||||
{{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
|
||||
{{end}}{{.ImportPackages}}
|
||||
)
|
||||
|
||||
{{if .HasDoc}}{{.Doc}}{{end}}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ type InternalWorkerConf struct {
|
|||
Secret string `json:",optional"`
|
||||
}
|
||||
|
||||
type BraveConf struct {
|
||||
APIKey string `json:",optional"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
rest.RestConf
|
||||
Mongo MongoConf `json:",optional"`
|
||||
|
|
@ -50,4 +54,5 @@ type Config struct {
|
|||
JobWorker JobWorkerConf `json:",optional"`
|
||||
JobScheduler JobSchedulerConf `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"
|
||||
auth "haixun-backend/internal/handler/auth"
|
||||
brand "haixun-backend/internal/handler/brand"
|
||||
job "haixun-backend/internal/handler/job"
|
||||
member "haixun-backend/internal/handler/member"
|
||||
normal "haixun-backend/internal/handler/normal"
|
||||
permission "haixun-backend/internal/handler/permission"
|
||||
persona "haixun-backend/internal/handler/persona"
|
||||
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"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
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(
|
||||
[]rest.Route{
|
||||
{
|
||||
|
|
@ -161,6 +106,159 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
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(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -254,6 +352,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/me",
|
||||
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"),
|
||||
|
|
@ -318,75 +426,36 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id",
|
||||
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,
|
||||
Path: "/:id/style-analysis",
|
||||
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"),
|
||||
)
|
||||
|
||||
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(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -415,4 +484,63 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
),
|
||||
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
|
||||
ThreadsAccount Scope = 38
|
||||
Persona Scope = 39
|
||||
Brand Scope = 40
|
||||
CategoryMultiplier = 1000
|
||||
ScopeMultiplier = 1000000
|
||||
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