This commit is contained in:
王性驊 2026-06-24 18:02:42 +08:00
parent 7e58bdba45
commit e2dc98d426
308 changed files with 19462 additions and 12161 deletions

View File

@ -1 +1 @@
19005
37538

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1 +1 @@
19030
37575

View File

@ -1 +1 @@
19031
37576

View File

@ -1,3 +1,4 @@
# Agent Handoff Notes
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import (
"permission.api"
"threads_account.api"
"persona.api"
"brand.api"
"worker_internal.api"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 正在分析 D1D8…")
_, _ = 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ const (
Permission Scope = 37
ThreadsAccount Scope = 38
Persona Scope = 39
Brand Scope = 40
CategoryMultiplier = 1000
ScopeMultiplier = 1000000
DefaultDetail Detail = 0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, `"`)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != ""
}

View File

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

View File

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

View File

@ -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 搜尋每句 520 像真人求助
2. questions 至少 5 pillars 至少 4 exclusions 至少 4
3. contentGoal 要寫找到近期發文且可自然留言置入的貼文
4. 全部繁體中文貼近台灣 Threads
5. 只回傳一個 JSONaudienceSummary, 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
}

View File

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

View File

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

View File

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

View File

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