添加公众号助手
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type taskRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type articleRow struct {
|
||||
TaskID int64 `json:"task_id"`
|
||||
Keyword string `json:"keyword"`
|
||||
SelectedTopic string `json:"selected_topic"`
|
||||
TopicHeat string `json:"topic_heat"`
|
||||
OutlineStyle string `json:"outline_style"`
|
||||
OutlineWords int `json:"outline_words"`
|
||||
ContentTargetWords int `json:"content_target_words"`
|
||||
HotspotsJSON string `json:"hotspots_json"`
|
||||
TopicCandidatesJSON string `json:"topic_candidates_json"`
|
||||
}
|
||||
|
||||
type runRow struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
ActionKey string `json:"action_key"`
|
||||
ActionTitle string `json:"action_title"`
|
||||
OutputJSON string `json:"output_json"`
|
||||
LogsJSON string `json:"logs_json"`
|
||||
StartedAt string `json:"started_at"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
taskID := flag.Int64("task", 0, "official account task id")
|
||||
resetStuck := flag.Bool("reset-stuck", false, "reset stuck running state for the given task")
|
||||
flag.Parse()
|
||||
|
||||
dbPath := `d:\traecode\pj0235-eai_agentplatform\eai_ap_app\backend-go\data\eaisalestrain.db`
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if *resetStuck {
|
||||
if *taskID <= 0 {
|
||||
log.Fatal("please pass -task when using -reset-stuck")
|
||||
}
|
||||
if err := resetOfficialAccountTaskState(db, *taskID); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("reset stuck running state for task %d\n", *taskID)
|
||||
return
|
||||
}
|
||||
|
||||
tasks := make([]taskRow, 0, 5)
|
||||
taskRows, err := db.Query(`
|
||||
select id, title, status, updated_at
|
||||
from worker_task
|
||||
where specialist_key = 'wechat-official-account'
|
||||
order by id desc
|
||||
limit 5
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for taskRows.Next() {
|
||||
var row taskRow
|
||||
if err := taskRows.Scan(&row.ID, &row.Title, &row.Status, &row.UpdatedAt); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
tasks = append(tasks, row)
|
||||
}
|
||||
_ = taskRows.Close()
|
||||
|
||||
var article articleRow
|
||||
_ = db.QueryRow(`
|
||||
select task_id, keyword, selected_topic, topic_heat, outline_style, outline_words, content_target_words, hotspots_json, topic_candidates_json
|
||||
from official_account_article
|
||||
order by id desc
|
||||
limit 1
|
||||
`).Scan(
|
||||
&article.TaskID,
|
||||
&article.Keyword,
|
||||
&article.SelectedTopic,
|
||||
&article.TopicHeat,
|
||||
&article.OutlineStyle,
|
||||
&article.OutlineWords,
|
||||
&article.ContentTargetWords,
|
||||
&article.HotspotsJSON,
|
||||
&article.TopicCandidatesJSON,
|
||||
)
|
||||
|
||||
runs := make([]runRow, 0, 5)
|
||||
runRows, err := db.Query(`
|
||||
select id, task_id, action_key, action_title, output_json, logs_json, started_at
|
||||
from worker_run
|
||||
where task_id = ?
|
||||
order by id desc
|
||||
limit 5
|
||||
`, article.TaskID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for runRows.Next() {
|
||||
var row runRow
|
||||
if err := runRows.Scan(&row.ID, &row.TaskID, &row.ActionKey, &row.ActionTitle, &row.OutputJSON, &row.LogsJSON, &row.StartedAt); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
runs = append(runs, row)
|
||||
}
|
||||
_ = runRows.Close()
|
||||
|
||||
var hotspotCount int
|
||||
_ = db.QueryRow(`select count(1) from official_account_hotspot`).Scan(&hotspotCount)
|
||||
|
||||
result := map[string]any{
|
||||
"tasks": tasks,
|
||||
"latest_article": article,
|
||||
"latest_runs": runs,
|
||||
"hotspot_count": hotspotCount,
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(result); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func resetOfficialAccountTaskState(db *sql.DB, taskID int64) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var contextJSON string
|
||||
if err = tx.QueryRow(`select context_json from worker_task where id = ?`, taskID).Scan(&contextJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type stepState struct {
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
ProgressText string `json:"progress_text"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
Logs []string `json:"logs"`
|
||||
Output map[string]interface{} `json:"output"`
|
||||
}
|
||||
type workflowState struct {
|
||||
CurrentStep string `json:"current_step"`
|
||||
Steps map[string]*stepState `json:"steps"`
|
||||
}
|
||||
|
||||
var workflow workflowState
|
||||
if strings.TrimSpace(contextJSON) != "" {
|
||||
if err = json.Unmarshal([]byte(contextJSON), &workflow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if workflow.Steps == nil {
|
||||
workflow.Steps = map[string]*stepState{}
|
||||
}
|
||||
for _, key := range []string{"topic_recommendation", "title_generation", "outline_generation", "content_creation"} {
|
||||
step := workflow.Steps[key]
|
||||
if step == nil || step.Status != "running" {
|
||||
continue
|
||||
}
|
||||
step.Status = "error"
|
||||
step.ProgressText = "上次执行已中断,请重新执行"
|
||||
step.FinishedAt = time.Now().Format(time.RFC3339)
|
||||
step.Logs = append(step.Logs, time.Now().Format("15:04:05")+" 执行已中断,已自动停止")
|
||||
}
|
||||
workflow.CurrentStep = "topic_recommendation"
|
||||
newContext, err := json.Marshal(workflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
update worker_task
|
||||
set status = ?, current_result = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
where id = ?
|
||||
`, "待处理", "", string(newContext), taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
update worker_run
|
||||
set status = ?, output_json = ?, logs_json = ?, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
where task_id = ? and status = ?
|
||||
`, "error", `{"summary":"上次执行已中断,请重新执行","error":"interrupted"}`, `["执行已中断,已自动停止"]`, taskID, "running"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
|
||||
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
)
|
||||
|
||||
func loadOfficialAccountArticle(taskID uint) (*model.OfficialAccountArticle, error) {
|
||||
var article model.OfficialAccountArticle
|
||||
if err := store.DB.Where("task_id = ?", taskID).First(&article).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &article, nil
|
||||
}
|
||||
|
||||
func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccountWorkflowState) (*model.OfficialAccountArticle, error) {
|
||||
article, err := loadOfficialAccountArticle(task.ID)
|
||||
if err == nil {
|
||||
return article, nil
|
||||
}
|
||||
row := &model.OfficialAccountArticle{
|
||||
TaskID: task.ID,
|
||||
SpecialistKey: task.SpecialistKey,
|
||||
BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
|
||||
Keyword: firstNonEmpty(workflow.Form.Keyword, getOfficialAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword),
|
||||
Audience: firstNonEmpty(workflow.Form.Audience, "公众号读者"),
|
||||
Goal: firstNonEmpty(workflow.Form.Goal, "输出一篇可发布的公众号文章"),
|
||||
Tone: firstNonEmpty(workflow.Form.Tone, "专业但好懂"),
|
||||
Requirements: workflow.Form.Requirements,
|
||||
OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle),
|
||||
OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords),
|
||||
ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords),
|
||||
Status: "draft",
|
||||
}
|
||||
if err := store.DB.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) {
|
||||
if workflow == nil || article == nil {
|
||||
return
|
||||
}
|
||||
workflow.Form.Keyword = firstNonEmpty(article.Keyword, workflow.Form.Keyword)
|
||||
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(article.BusinessDomain, workflow.Form.Keyword)
|
||||
workflow.Form.Audience = firstNonEmpty(article.Audience, workflow.Form.Audience)
|
||||
workflow.Form.Goal = firstNonEmpty(article.Goal, workflow.Form.Goal)
|
||||
workflow.Form.Tone = firstNonEmpty(article.Tone, workflow.Form.Tone)
|
||||
workflow.Form.Requirements = article.Requirements
|
||||
workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle)
|
||||
workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords)
|
||||
workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords)
|
||||
|
||||
if strings.TrimSpace(article.TopicCandidatesJSON) != "" {
|
||||
var items []officialAccountTopicCandidate
|
||||
if json.Unmarshal([]byte(article.TopicCandidatesJSON), &items) == nil {
|
||||
workflow.Shared.TopicCandidates = items
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(article.TitleCandidatesJSON) != "" {
|
||||
var items []string
|
||||
if json.Unmarshal([]byte(article.TitleCandidatesJSON), &items) == nil {
|
||||
workflow.Shared.TitleCandidates = items
|
||||
}
|
||||
}
|
||||
workflow.Shared.SelectedTopic = article.SelectedTopic
|
||||
workflow.Shared.SelectedTitle = article.SelectedTitle
|
||||
workflow.Shared.Outline = article.Outline
|
||||
workflow.Shared.Content = article.Content
|
||||
}
|
||||
|
||||
func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) {
|
||||
if article == nil {
|
||||
return
|
||||
}
|
||||
article.BusinessDomain = resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword)
|
||||
article.Keyword = firstNonEmpty(workflow.Form.Keyword, article.Keyword)
|
||||
article.Audience = firstNonEmpty(workflow.Form.Audience, article.Audience)
|
||||
article.Goal = firstNonEmpty(workflow.Form.Goal, article.Goal)
|
||||
article.Tone = firstNonEmpty(workflow.Form.Tone, article.Tone)
|
||||
article.Requirements = workflow.Form.Requirements
|
||||
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle)
|
||||
article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)
|
||||
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)
|
||||
article.SelectedTopic = workflow.Shared.SelectedTopic
|
||||
article.SelectedTitle = workflow.Shared.SelectedTitle
|
||||
article.Outline = workflow.Shared.Outline
|
||||
article.Content = workflow.Shared.Content
|
||||
if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil {
|
||||
article.TopicCandidatesJSON = string(data)
|
||||
}
|
||||
if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil {
|
||||
article.TitleCandidatesJSON = string(data)
|
||||
}
|
||||
if workflow.Shared.Content != "" {
|
||||
article.Status = "content_ready"
|
||||
} else if workflow.Shared.Outline != "" {
|
||||
article.Status = "outline_ready"
|
||||
} else if workflow.Shared.SelectedTitle != "" {
|
||||
article.Status = "title_ready"
|
||||
} else if workflow.Shared.SelectedTopic != "" {
|
||||
article.Status = "topic_ready"
|
||||
} else {
|
||||
article.Status = "draft"
|
||||
}
|
||||
}
|
||||
|
||||
func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req officialAccountTaskUpdateReq) {
|
||||
if article == nil {
|
||||
return
|
||||
}
|
||||
article.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
|
||||
article.Keyword = req.Keyword
|
||||
article.Audience = firstNonEmpty(req.Audience, "公众号读者")
|
||||
article.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
|
||||
article.Tone = firstNonEmpty(req.Tone, "专业但好懂")
|
||||
article.Requirements = req.Requirements
|
||||
article.TopicCandidatesJSON = "[]"
|
||||
article.HotspotsJSON = "[]"
|
||||
article.SelectedTopic = ""
|
||||
article.TopicHeat = ""
|
||||
article.TitleCandidatesJSON = "[]"
|
||||
article.SelectedTitle = ""
|
||||
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle)
|
||||
article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
|
||||
article.Outline = ""
|
||||
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
|
||||
article.Content = ""
|
||||
article.Status = "draft"
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []model.OfficialAccountHotspot) []officialAccountTopicCandidate {
|
||||
domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain)
|
||||
candidates := make([]officialAccountTopicCandidate, 0, 4)
|
||||
for _, item := range hotspots {
|
||||
topic := deriveOfficialAccountTopic(form.Keyword, item.Title)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, officialAccountTopicCandidate{
|
||||
Topic: topic,
|
||||
Angle: deriveOfficialAccountAngle(item.Title),
|
||||
Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"),
|
||||
Heat: firstNonEmpty(item.HeatLabel, "中高"),
|
||||
})
|
||||
if len(candidates) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return buildOfficialAccountTopicCandidates(form)
|
||||
}
|
||||
return dedupeOfficialAccountTopicCandidates(candidates)
|
||||
}
|
||||
|
||||
func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) []officialAccountTopicCandidate {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]officialAccountTopicCandidate, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Topic)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
sourceTitle = strings.TrimSpace(sourceTitle)
|
||||
switch {
|
||||
case keyword == "":
|
||||
return sourceTitle
|
||||
case strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(keyword)):
|
||||
return sourceTitle
|
||||
default:
|
||||
return fmt.Sprintf("%s:%s", keyword, sourceTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func deriveOfficialAccountAngle(sourceTitle string) string {
|
||||
switch {
|
||||
case strings.Contains(sourceTitle, "融资") || strings.Contains(sourceTitle, "估值"):
|
||||
return "产业动向"
|
||||
case strings.Contains(sourceTitle, "发布") || strings.Contains(sourceTitle, "上线"):
|
||||
return "产品更新"
|
||||
case strings.Contains(sourceTitle, "落地") || strings.Contains(sourceTitle, "案例"):
|
||||
return "场景落地"
|
||||
default:
|
||||
return "热点追踪"
|
||||
}
|
||||
}
|
||||
|
||||
func marshalOfficialAccountHotspots(hotspots []model.OfficialAccountHotspot) string {
|
||||
if len(hotspots) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
data, err := json.Marshal(hotspots)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []model.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel),
|
||||
"要求:",
|
||||
fmt.Sprintf("1. 主题必须适合%s方向的公众号写作;", cfg.PromptLabel),
|
||||
"2. 要能写成公众号文章,不是简单复述新闻;",
|
||||
"3. 每个候选返回 topic、angle、reason、heat 四个字段;",
|
||||
"4. 输出 JSON 数组,不要 markdown。",
|
||||
fmt.Sprintf("业务域:%s", cfg.Label),
|
||||
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
|
||||
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
|
||||
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
|
||||
fmt.Sprintf("语气:%s", firstNonEmpty(form.Tone, "专业但好懂")),
|
||||
"热点列表:",
|
||||
}
|
||||
for idx, item := range hotspots {
|
||||
lines = append(lines, fmt.Sprintf("%d. 标题:%s | 热度:%s | 摘要:%s", idx+1, item.Title, item.HeatLabel, firstNonEmpty(item.Summary, "无摘要")))
|
||||
if idx >= 7 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel),
|
||||
fmt.Sprintf("选题:%s", topic),
|
||||
fmt.Sprintf("业务域:%s", cfg.Label),
|
||||
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
|
||||
"参考热点:",
|
||||
}
|
||||
for idx, item := range hotspots {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s(%s)", idx+1, item.Title, item.HeatLabel))
|
||||
if idx >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []model.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel),
|
||||
"要求:",
|
||||
"1. 结合热点相关性、传播性、公众号可写性综合判断;",
|
||||
"2. 每个候选都要返回 topic、heat、reason、score 四个字段;",
|
||||
"3. heat 只能是 爆热 / 高 / 中高 / 中 其中之一;",
|
||||
"4. score 为 0-100 的整数;",
|
||||
"5. 只返回 JSON 数组,不要 markdown,不要额外解释。",
|
||||
fmt.Sprintf("业务域:%s", cfg.Label),
|
||||
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
|
||||
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
|
||||
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
|
||||
"待评估选题:",
|
||||
}
|
||||
for idx, item := range candidates {
|
||||
lines = append(lines, fmt.Sprintf("%d. 选题:%s | 角度:%s | 当前理由:%s | 当前热度:%s", idx+1, item.Topic, firstNonEmpty(item.Angle, "热点追踪"), firstNonEmpty(item.Reason, "待评估"), firstNonEmpty(item.Heat, "中高")))
|
||||
}
|
||||
lines = append(lines, "参考热点:")
|
||||
for idx, item := range hotspots {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s(%s)| 摘要:%s", idx+1, item.Title, firstNonEmpty(item.HeatLabel, "中高"), firstNonEmpty(item.Summary, "无摘要")))
|
||||
if idx >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sortOfficialAccountHotspots(items []model.OfficialAccountHotspot) []model.OfficialAccountHotspot {
|
||||
cloned := append([]model.OfficialAccountHotspot(nil), items...)
|
||||
sort.Slice(cloned, func(i, j int) bool {
|
||||
if cloned[i].DomainScore == cloned[j].DomainScore {
|
||||
return cloned[i].FetchedAt.After(cloned[j].FetchedAt)
|
||||
}
|
||||
return cloned[i].DomainScore > cloned[j].DomainScore
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
officialAccountBusinessDomainAuto = "auto"
|
||||
officialAccountBusinessDomainAI = "ai"
|
||||
officialAccountBusinessDomainMedical = "medical"
|
||||
officialAccountBusinessDomainInsurance = "insurance"
|
||||
officialAccountBusinessDomainHR = "hr"
|
||||
officialAccountBusinessDomainLegal = "legal"
|
||||
officialAccountBusinessDomainGeneric = "generic"
|
||||
)
|
||||
|
||||
type officialAccountHotspotSource struct {
|
||||
Key string
|
||||
Label string
|
||||
Type string
|
||||
URL string
|
||||
}
|
||||
|
||||
type officialAccountBusinessDomainConfig struct {
|
||||
Key string
|
||||
Label string
|
||||
PromptLabel string
|
||||
DefaultKeyword string
|
||||
DomainKeywords []string
|
||||
BusinessKeywords []string
|
||||
BoostKeywords []string
|
||||
FallbackSearchHints []string
|
||||
Sources []officialAccountHotspotSource
|
||||
}
|
||||
|
||||
var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDomainConfig{
|
||||
officialAccountBusinessDomainAI: {
|
||||
Key: officialAccountBusinessDomainAI,
|
||||
Label: "AI / 智能体",
|
||||
PromptLabel: "AI 业务域",
|
||||
DefaultKeyword: "AI 落地",
|
||||
DomainKeywords: []string{
|
||||
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "工作流", "知识库",
|
||||
"rag", "copilot", "自动化", "机器人", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"企业", "落地", "场景", "产品", "平台", "工作台", "办公", "培训", "实施", "知识库", "销售", "客户",
|
||||
"提效", "运营", "私有化", "部署", "公司", "团队", "组织", "流程", "项目", "交付",
|
||||
},
|
||||
BoostKeywords: []string{"工作流", "智能体", "企业ai"},
|
||||
FallbackSearchHints: []string{"AI", "大模型", "智能体", "企业AI"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
|
||||
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
|
||||
{Key: "36kr_ai_web", Label: "36氪AI专题", Type: "web", URL: "https://www.36kr.com/information/AI/"},
|
||||
{Key: "36kr_aigc_web", Label: "36氪AIGC专题", Type: "web", URL: "https://www.36kr.com/information/AIGC/"},
|
||||
},
|
||||
},
|
||||
officialAccountBusinessDomainMedical: {
|
||||
Key: officialAccountBusinessDomainMedical,
|
||||
Label: "医疗 / 健康",
|
||||
PromptLabel: "医疗健康业务域",
|
||||
DefaultKeyword: "医疗健康",
|
||||
DomainKeywords: []string{
|
||||
"医疗", "医保", "医院", "医生", "患者", "医药", "药企", "药品", "诊疗", "健康", "护理", "手术", "疾病", "控费",
|
||||
"集采", "互联网医疗", "医疗器械", "生物医药", "健康险", "临床", "门诊", "住院", "卫健", "医保局", "药监", "药审",
|
||||
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"政策", "支付", "落地", "案例", "服务", "行业", "机构", "改革", "商业模式", "运营", "平台", "场景", "解决方案",
|
||||
"通知", "公告", "指南", "征求意见", "审评", "审批", "基金", "目录", "招采", "挂网", "集采",
|
||||
},
|
||||
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗"},
|
||||
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "nhsa_policy_web", Label: "国家医保局政策栏目", Type: "web", URL: "https://www.nhsa.gov.cn/col/col14/index.html"},
|
||||
{Key: "nhsa_news_web", Label: "国家医保局工作动态", Type: "web", URL: "https://www.nhsa.gov.cn/col/col104/index.html"},
|
||||
{Key: "cde_home_web", Label: "药审中心", Type: "web", URL: "https://www.cde.org.cn/"},
|
||||
},
|
||||
},
|
||||
officialAccountBusinessDomainInsurance: {
|
||||
Key: officialAccountBusinessDomainInsurance,
|
||||
Label: "保险 / 金融",
|
||||
PromptLabel: "保险金融业务域",
|
||||
DefaultKeyword: "保险行业",
|
||||
DomainKeywords: []string{
|
||||
"保险", "险资", "寿险", "财险", "健康险", "车险", "养老保险", "理赔", "赔付", "保单", "代理人", "年金", "再保险",
|
||||
"偿付能力", "分红险", "重疾险", "医疗险", "保险公司", "保险经纪", "保险中介",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"监管", "政策", "行业", "产品", "销售", "转型", "增长", "客户", "渠道", "经营", "服务", "投资", "市场", "赔付",
|
||||
},
|
||||
BoostKeywords: []string{"理赔", "偿付能力", "代理人", "保险公司"},
|
||||
FallbackSearchHints: []string{"保险", "理赔", "代理人", "养老保险"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "iachina_web", Label: "中国保险行业协会", Type: "web", URL: "https://www.iachina.cn/col/col3/index.html"},
|
||||
{Key: "cnstock_insurance_web", Label: "上海证券报保险频道", Type: "web", URL: "http://www.cnstock.com/channel/10120"},
|
||||
{Key: "china_finance_insurance_web", Label: "中国网财经保险", Type: "web", URL: "http://finance.china.com.cn/money/insurance/index.shtml"},
|
||||
},
|
||||
},
|
||||
officialAccountBusinessDomainHR: {
|
||||
Key: officialAccountBusinessDomainHR,
|
||||
Label: "HR / 招聘",
|
||||
PromptLabel: "人力资源业务域",
|
||||
DefaultKeyword: "人力资源",
|
||||
DomainKeywords: []string{
|
||||
"hr", "人力", "招聘", "人才", "雇主", "员工", "绩效", "薪酬", "福利", "组织", "用工", "劳务", "社保",
|
||||
"培训", "面试", "招聘流程", "入职", "离职", "考勤", "激励",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"管理", "平台", "系统", "政策", "趋势", "案例", "企业", "数字化", "转型", "场景", "效率", "合规",
|
||||
},
|
||||
BoostKeywords: []string{"招聘", "社保", "薪酬", "劳动"},
|
||||
FallbackSearchHints: []string{"招聘", "人力资源", "薪酬", "社保"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "hrac_web", Label: "中国人力资源协会", Type: "web", URL: "http://www.hrac.net.cn/CN/HR/"},
|
||||
{Key: "hrtechweekly_rss", Label: "HR Tech Weekly", Type: "rss", URL: "https://hrtechweekly.com/feed/"},
|
||||
{Key: "techrseries_rss", Label: "HRTech Series", Type: "rss", URL: "https://techrseries.com/feed/"},
|
||||
},
|
||||
},
|
||||
officialAccountBusinessDomainLegal: {
|
||||
Key: officialAccountBusinessDomainLegal,
|
||||
Label: "法务 / 合规",
|
||||
PromptLabel: "法务合规业务域",
|
||||
DefaultKeyword: "法务合规",
|
||||
DomainKeywords: []string{
|
||||
"法务", "法律", "合规", "合同", "诉讼", "仲裁", "监管", "公司法", "劳动法", "数据合规", "知产", "知识产权",
|
||||
"审查", "风控", "条款", "争议", "律师", "执法", "规范",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"企业", "实务", "案例", "解读", "风控", "业务", "制度", "管理", "要求", "落地", "操作", "要点",
|
||||
},
|
||||
BoostKeywords: []string{"合同", "合规", "数据", "劳动法"},
|
||||
FallbackSearchHints: []string{"合同", "合规", "劳动法", "数据合规"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "law_asia_cn_web", Label: "商法中国", Type: "web", URL: "https://law.asia/zh-hans/china/"},
|
||||
{Key: "law_asia_en_web", Label: "China Business Law Journal", Type: "web", URL: "https://law.asia/china/"},
|
||||
{Key: "jurist_rss", Label: "Jurist", Type: "rss", URL: "https://www.jurist.org/news/feed/"},
|
||||
},
|
||||
},
|
||||
officialAccountBusinessDomainGeneric: {
|
||||
Key: officialAccountBusinessDomainGeneric,
|
||||
Label: "通用业务域",
|
||||
PromptLabel: "通用业务域",
|
||||
DefaultKeyword: "业务热点",
|
||||
DomainKeywords: []string{
|
||||
"行业", "企业", "客户", "增长", "经营", "产品", "服务", "转型", "市场", "政策", "平台", "解决方案",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"热点", "趋势", "案例", "落地", "复盘", "方法", "实践", "问题", "经验", "机会", "风险",
|
||||
},
|
||||
BoostKeywords: []string{"行业", "企业", "趋势"},
|
||||
FallbackSearchHints: []string{"行业", "企业", "政策", "趋势"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
|
||||
{Key: "cnstock_business_web", Label: "上海证券报", Type: "web", URL: "http://www.cnstock.com/"},
|
||||
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var officialAccountBusinessDomainDetectionHints = map[string][]string{
|
||||
officialAccountBusinessDomainAI: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainAI].DomainKeywords,
|
||||
officialAccountBusinessDomainMedical: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainMedical].DomainKeywords,
|
||||
officialAccountBusinessDomainInsurance: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainInsurance].DomainKeywords,
|
||||
officialAccountBusinessDomainHR: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainHR].DomainKeywords,
|
||||
officialAccountBusinessDomainLegal: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainLegal].DomainKeywords,
|
||||
}
|
||||
|
||||
func normalizeOfficialAccountBusinessDomain(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "", officialAccountBusinessDomainAuto, "自动识别", "自动":
|
||||
return officialAccountBusinessDomainAuto
|
||||
case "ai", "aigc", "智能体", "人工智能":
|
||||
return officialAccountBusinessDomainAI
|
||||
case "medical", "health", "医疗", "健康":
|
||||
return officialAccountBusinessDomainMedical
|
||||
case "insurance", "finance", "保险", "金融":
|
||||
return officialAccountBusinessDomainInsurance
|
||||
case "hr", "recruiting", "招聘", "人力", "人力资源":
|
||||
return officialAccountBusinessDomainHR
|
||||
case "legal", "compliance", "法务", "合规", "法律":
|
||||
return officialAccountBusinessDomainLegal
|
||||
case "generic", "other", "其他", "通用":
|
||||
return officialAccountBusinessDomainGeneric
|
||||
default:
|
||||
if _, ok := officialAccountBusinessDomainConfigs[value]; ok {
|
||||
return value
|
||||
}
|
||||
return officialAccountBusinessDomainGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func detectOfficialAccountBusinessDomain(keyword string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(keyword))
|
||||
if text == "" {
|
||||
return officialAccountBusinessDomainAI
|
||||
}
|
||||
bestKey := officialAccountBusinessDomainAI
|
||||
bestScore := 0
|
||||
for key, hints := range officialAccountBusinessDomainDetectionHints {
|
||||
score := 0
|
||||
for _, hint := range hints {
|
||||
if strings.Contains(text, strings.ToLower(hint)) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
if bestScore == 0 {
|
||||
return officialAccountBusinessDomainGeneric
|
||||
}
|
||||
return bestKey
|
||||
}
|
||||
|
||||
func resolveOfficialAccountBusinessDomain(domain, keyword string) string {
|
||||
normalized := normalizeOfficialAccountBusinessDomain(domain)
|
||||
if normalized == officialAccountBusinessDomainAuto {
|
||||
return detectOfficialAccountBusinessDomain(keyword)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func getOfficialAccountBusinessDomainConfig(domain string) officialAccountBusinessDomainConfig {
|
||||
key := normalizeOfficialAccountBusinessDomain(domain)
|
||||
if key == officialAccountBusinessDomainAuto {
|
||||
key = officialAccountBusinessDomainAI
|
||||
}
|
||||
if cfg, ok := officialAccountBusinessDomainConfigs[key]; ok {
|
||||
return cfg
|
||||
}
|
||||
return officialAccountBusinessDomainConfigs[officialAccountBusinessDomainGeneric]
|
||||
}
|
||||
|
||||
func officialAccountBusinessDomainLabel(domain string) string {
|
||||
return getOfficialAccountBusinessDomainConfig(domain).Label
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
)
|
||||
|
||||
type officialAccountFetchedHotspot struct {
|
||||
Title string
|
||||
URL string
|
||||
Summary string
|
||||
PublishedAt *time.Time
|
||||
SourceKey string
|
||||
SourceLabel string
|
||||
SourceType string
|
||||
}
|
||||
|
||||
var (
|
||||
officialAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
|
||||
officialAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
officialAccountSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
type officialAccountRSS struct {
|
||||
Channel struct {
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
Entries []struct {
|
||||
Title string `xml:"title"`
|
||||
Summary string `xml:"summary"`
|
||||
Content string `xml:"content"`
|
||||
Updated string `xml:"updated"`
|
||||
Published string `xml:"published"`
|
||||
ID string `xml:"id"`
|
||||
Links []struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
} `xml:"entry"`
|
||||
}
|
||||
|
||||
func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]model.OfficialAccountHotspot, []string, error) {
|
||||
domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword)
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
logs := []string{}
|
||||
if !force {
|
||||
cached, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
|
||||
if err == nil && len(cached) >= 8 {
|
||||
logs = append(logs, "命中热点缓存,直接复用近 90 分钟"+cfg.Label+"热点。")
|
||||
return cached, logs, nil
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
totalUpserts := 0
|
||||
sourceKeywords := buildOfficialAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
|
||||
for _, source := range cfg.Sources {
|
||||
items, err := fetchOfficialAccountHotspotsFromSource(client, source, sourceKeywords)
|
||||
if err != nil {
|
||||
logs = append(logs, source.Label+" 拉取失败:"+err.Error())
|
||||
continue
|
||||
}
|
||||
upserts, sourceLogs := upsertOfficialAccountHotspots(domainKey, form.Keyword, items)
|
||||
totalUpserts += upserts
|
||||
logs = append(logs, sourceLogs...)
|
||||
}
|
||||
if totalUpserts == 0 {
|
||||
logs = append(logs, "本轮未写入新热点,回退读取最近缓存。")
|
||||
}
|
||||
latest, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 48*time.Hour)
|
||||
if err == nil && len(latest) == 0 && domainKey != officialAccountBusinessDomainGeneric {
|
||||
logs = append(logs, "当前业务域命中热点较少,已自动降级到通用热点检索。")
|
||||
genericCfg := getOfficialAccountBusinessDomainConfig(officialAccountBusinessDomainGeneric)
|
||||
genericKeywords := buildOfficialAccountSourceKeywords(
|
||||
officialAccountBusinessDomainGeneric,
|
||||
form.Keyword,
|
||||
append(cfg.FallbackSearchHints, genericCfg.FallbackSearchHints...),
|
||||
)
|
||||
for _, source := range genericCfg.Sources {
|
||||
items, fetchErr := fetchOfficialAccountHotspotsFromSource(client, source, genericKeywords)
|
||||
if fetchErr != nil {
|
||||
logs = append(logs, source.Label+" 泛检索失败:"+fetchErr.Error())
|
||||
continue
|
||||
}
|
||||
upserts, sourceLogs := upsertOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, items)
|
||||
totalUpserts += upserts
|
||||
logs = append(logs, sourceLogs...)
|
||||
}
|
||||
latest, err = loadFreshOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, logs, err
|
||||
}
|
||||
return latest, logs, nil
|
||||
}
|
||||
|
||||
func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]model.OfficialAccountHotspot, error) {
|
||||
var items []model.OfficialAccountHotspot
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
err := store.DB.
|
||||
Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff).
|
||||
Order("domain_score DESC, published_at DESC, id DESC").
|
||||
Limit(40).
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := filterOfficialAccountHotspotsByKeyword(items, keyword)
|
||||
if len(filtered) > 0 {
|
||||
return filtered, nil
|
||||
}
|
||||
if len(items) > 20 {
|
||||
return items[:20], nil
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountHotspotsFromSource(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
|
||||
switch source.Type {
|
||||
case "rss":
|
||||
return fetchOfficialAccountRSSHotspots(client, source)
|
||||
default:
|
||||
return fetchOfficialAccountWebHotspots(client, source, keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchOfficialAccountRSSHotspots(client *http.Client, source officialAccountHotspotSource) ([]officialAccountFetchedHotspot, error) {
|
||||
body, err := fetchOfficialAccountSourceBody(client, source.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var feed officialAccountRSS
|
||||
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]officialAccountFetchedHotspot, 0, 24)
|
||||
for _, item := range feed.Channel.Items {
|
||||
title := normalizeOfficialAccountText(item.Title)
|
||||
link := strings.TrimSpace(item.Link)
|
||||
summary := normalizeOfficialAccountText(item.Description)
|
||||
if title == "" || link == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
Title: title,
|
||||
URL: link,
|
||||
Summary: summary,
|
||||
PublishedAt: parseOfficialAccountTime(item.PubDate),
|
||||
SourceKey: source.Key,
|
||||
SourceLabel: source.Label,
|
||||
SourceType: source.Type,
|
||||
})
|
||||
}
|
||||
for _, entry := range feed.Entries {
|
||||
title := normalizeOfficialAccountText(entry.Title)
|
||||
link := strings.TrimSpace(entry.ID)
|
||||
if len(entry.Links) > 0 && strings.TrimSpace(entry.Links[0].Href) != "" {
|
||||
link = strings.TrimSpace(entry.Links[0].Href)
|
||||
}
|
||||
summary := normalizeOfficialAccountText(firstNonEmpty(entry.Summary, entry.Content))
|
||||
if title == "" || link == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
Title: title,
|
||||
URL: link,
|
||||
Summary: summary,
|
||||
PublishedAt: parseOfficialAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
|
||||
SourceKey: source.Key,
|
||||
SourceLabel: source.Label,
|
||||
SourceType: source.Type,
|
||||
})
|
||||
}
|
||||
return dedupeOfficialAccountFetched(items), nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
|
||||
body, err := fetchOfficialAccountSourceBody(client, source.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, _ := neturl.Parse(source.URL)
|
||||
matches := officialAccountAnchorRE.FindAllStringSubmatch(string(body), -1)
|
||||
items := make([]officialAccountFetchedHotspot, 0, 24)
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(match[1])
|
||||
text := normalizeOfficialAccountText(match[2])
|
||||
if text == "" || len([]rune(text)) < 8 || len([]rune(text)) > 90 {
|
||||
continue
|
||||
}
|
||||
if len(keywords) > 0 && !containsOfficialAccountKeywords(text, keywords) {
|
||||
continue
|
||||
}
|
||||
if base != nil {
|
||||
if ref, err := neturl.Parse(href); err == nil {
|
||||
href = base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
Title: text,
|
||||
URL: href,
|
||||
Summary: "",
|
||||
PublishedAt: nil,
|
||||
SourceKey: source.Key,
|
||||
SourceLabel: source.Label,
|
||||
SourceType: source.Type,
|
||||
})
|
||||
if len(items) >= 24 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return dedupeOfficialAccountFetched(items), nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 official-account-assistant/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rawBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedReader, err := charset.NewReader(bytes.NewReader(rawBody), resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
decodedBody, err := io.ReadAll(decodedReader)
|
||||
if err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
return decodedBody, nil
|
||||
}
|
||||
|
||||
func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAccountFetchedHotspot) (int, []string) {
|
||||
logs := []string{}
|
||||
if len(items) == 0 {
|
||||
return 0, logs
|
||||
}
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
now := time.Now()
|
||||
upserts := make([]model.OfficialAccountHotspot, 0, len(items))
|
||||
for _, item := range items {
|
||||
rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary)
|
||||
if domainScore <= 0 {
|
||||
continue
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(tags)
|
||||
upserts = append(upserts, model.OfficialAccountHotspot{
|
||||
BusinessDomain: domainKey,
|
||||
SourceKey: item.SourceKey,
|
||||
SourceLabel: item.SourceLabel,
|
||||
SourceType: item.SourceType,
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Summary: item.Summary,
|
||||
PublishedAt: item.PublishedAt,
|
||||
RawScore: rawScore,
|
||||
DomainScore: domainScore,
|
||||
HeatLabel: officialAccountHeatLabel(domainScore),
|
||||
TagsJSON: string(tagsJSON),
|
||||
FetchedAt: now,
|
||||
})
|
||||
}
|
||||
if len(upserts) == 0 {
|
||||
return 0, logs
|
||||
}
|
||||
if err := store.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}, {Name: "url"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"title", "summary", "published_at", "raw_score", "domain_score", "heat_label", "tags_json", "fetched_at", "updated_at"}),
|
||||
}).Create(&upserts).Error; err != nil {
|
||||
logs = append(logs, "热点入库失败:"+err.Error())
|
||||
return 0, logs
|
||||
}
|
||||
logs = append(logs, "已从公开 RSS / 网页源更新 "+itoa(len(upserts))+" 条"+cfg.Label+"热点。")
|
||||
return len(upserts), logs
|
||||
}
|
||||
|
||||
func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
text := strings.ToLower(title + "\n" + summary)
|
||||
rawScore := 0.0
|
||||
domainScore := 0.0
|
||||
tags := make([]string, 0, 8)
|
||||
for _, item := range cfg.DomainKeywords {
|
||||
if strings.Contains(text, strings.ToLower(item)) {
|
||||
rawScore += 1
|
||||
domainScore += 1.5
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
for _, item := range cfg.BusinessKeywords {
|
||||
if strings.Contains(text, strings.ToLower(item)) {
|
||||
rawScore += 1
|
||||
domainScore += 2
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
for _, item := range cfg.BoostKeywords {
|
||||
if strings.Contains(text, strings.ToLower(item)) {
|
||||
domainScore += 2
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
for _, item := range buildOfficialAccountKeywordTerms(keyword) {
|
||||
if strings.Contains(text, strings.ToLower(item)) {
|
||||
domainScore += 2.5
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
if domainKey == officialAccountBusinessDomainGeneric {
|
||||
domainScore += 2
|
||||
}
|
||||
return rawScore, domainScore, uniqueOfficialAccountStrings(tags)
|
||||
}
|
||||
|
||||
func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []officialAccountFetchedHotspot {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]officialAccountFetchedHotspot, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.URL)
|
||||
if key == "" {
|
||||
key = item.SourceKey + "::" + item.Title
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeOfficialAccountText(value string) string {
|
||||
value = html.UnescapeString(value)
|
||||
value = officialAccountStripTagRE.ReplaceAllString(value, " ")
|
||||
value = officialAccountSpaceRE.ReplaceAllString(value, " ")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func parseOfficialAccountTime(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsOfficialAccountKeywords(text string, keywords []string) bool {
|
||||
text = strings.ToLower(text)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterOfficialAccountHotspotsByKeyword(items []model.OfficialAccountHotspot, keyword string) []model.OfficialAccountHotspot {
|
||||
terms := buildOfficialAccountKeywordTerms(keyword)
|
||||
if len(terms) == 0 {
|
||||
return items
|
||||
}
|
||||
type scoredHotspot struct {
|
||||
item model.OfficialAccountHotspot
|
||||
score int
|
||||
}
|
||||
scored := make([]scoredHotspot, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.ToLower(item.Title + "\n" + item.Summary)
|
||||
score := 0
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, strings.ToLower(term)) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
scored = append(scored, scoredHotspot{item: item, score: score})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
if scored[i].score == scored[j].score {
|
||||
if scored[i].item.DomainScore == scored[j].item.DomainScore {
|
||||
return scored[i].item.ID > scored[j].item.ID
|
||||
}
|
||||
return scored[i].item.DomainScore > scored[j].item.DomainScore
|
||||
}
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
result := make([]model.OfficialAccountHotspot, 0, len(scored))
|
||||
for _, item := range scored {
|
||||
result = append(result, item.item)
|
||||
if len(result) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildOfficialAccountKeywordTerms(keyword string) []string {
|
||||
raw := strings.TrimSpace(strings.ToLower(keyword))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
splitter := func(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', ',', ',', '、', '/', '|', ';', ';', ':', ':', '(', ')', '(', ')', '+', '-', '_':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
parts := strings.FieldsFunc(raw, splitter)
|
||||
terms := make([]string, 0, len(parts)+1)
|
||||
if utf8RuneCount(raw) >= 2 {
|
||||
terms = append(terms, raw)
|
||||
}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if utf8RuneCount(part) < 2 {
|
||||
continue
|
||||
}
|
||||
terms = append(terms, part)
|
||||
}
|
||||
return uniqueOfficialAccountStrings(terms)
|
||||
}
|
||||
|
||||
func buildOfficialAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
keywords := append([]string{}, cfg.DomainKeywords...)
|
||||
keywords = append(keywords, buildOfficialAccountKeywordTerms(keyword)...)
|
||||
keywords = append(keywords, extras...)
|
||||
if domainKey == officialAccountBusinessDomainGeneric && len(buildOfficialAccountKeywordTerms(keyword)) == 0 {
|
||||
keywords = append(keywords, cfg.FallbackSearchHints...)
|
||||
}
|
||||
return uniqueOfficialAccountStrings(keywords)
|
||||
}
|
||||
|
||||
func utf8RuneCount(value string) int {
|
||||
return len([]rune(value))
|
||||
}
|
||||
|
||||
func officialAccountHeatLabel(score float64) string {
|
||||
switch {
|
||||
case score >= 12:
|
||||
return "爆热"
|
||||
case score >= 8:
|
||||
return "高"
|
||||
case score >= 5:
|
||||
return "中高"
|
||||
default:
|
||||
return "中"
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueOfficialAccountStrings(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
result := 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{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
|
||||
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
|
||||
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
|
||||
r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask)
|
||||
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
|
||||
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
|
||||
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep)
|
||||
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
|
||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文显式传递。
|
||||
type OfficialAccountArticle struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
|
||||
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
|
||||
BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"`
|
||||
Keyword string `gorm:"size:256;not null" json:"keyword"`
|
||||
Audience string `gorm:"size:128;not null" json:"audience"`
|
||||
Goal string `gorm:"size:128;not null" json:"goal"`
|
||||
Tone string `gorm:"size:128;not null" json:"tone"`
|
||||
Requirements string `gorm:"type:text" json:"requirements"`
|
||||
TopicCandidatesJSON string `gorm:"type:text" json:"topic_candidates_json"`
|
||||
HotspotsJSON string `gorm:"type:text" json:"hotspots_json"`
|
||||
SelectedTopic string `gorm:"size:256" json:"selected_topic"`
|
||||
TopicHeat string `gorm:"size:64" json:"topic_heat"`
|
||||
TitleCandidatesJSON string `gorm:"type:text" json:"title_candidates_json"`
|
||||
SelectedTitle string `gorm:"size:256" json:"selected_title"`
|
||||
OutlineStyle string `gorm:"size:64;not null;default:问题拆解型" json:"outline_style"`
|
||||
OutlineWords int `gorm:"not null;default:600" json:"outline_words"`
|
||||
Outline string `gorm:"type:text" json:"outline"`
|
||||
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (OfficialAccountArticle) TableName() string { return "official_account_article" }
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
|
||||
type OfficialAccountHotspot struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"`
|
||||
SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"`
|
||||
SourceLabel string `gorm:"size:128;not null" json:"source_label"`
|
||||
SourceType string `gorm:"size:16;not null" json:"source_type"`
|
||||
Title string `gorm:"size:512;not null" json:"title"`
|
||||
URL string `gorm:"size:1024;not null;uniqueIndex:idx_hotspot_source_url" json:"url"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
RawScore float64 `gorm:"not null;default:0" json:"raw_score"`
|
||||
DomainScore float64 `gorm:"not null;default:0" json:"domain_score"`
|
||||
HeatLabel string `gorm:"size:32;default:''" json:"heat_label"`
|
||||
TagsJSON string `gorm:"type:text" json:"tags_json"`
|
||||
FetchedAt time.Time `gorm:"index" json:"fetched_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" }
|
||||
@@ -58,6 +58,8 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.WorkerTask{},
|
||||
&model.WorkerArtifact{},
|
||||
&model.WorkerRun{},
|
||||
&model.OfficialAccountArticle{},
|
||||
&model.OfficialAccountHotspot{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"eaisalestrain/backend/internal/auth"
|
||||
"eaisalestrain/backend/internal/config"
|
||||
@@ -381,6 +381,30 @@ func seedSpecialists() error {
|
||||
State: "active",
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
Key: "wechat-official-account",
|
||||
Label: "公众号助手",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/apps/wechat-official-account",
|
||||
Summary: "热点选题、标题、提纲、正文四步创作工作流",
|
||||
WorkStatus: "4 个节点可执行",
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#2563eb",
|
||||
Stage: "正文创作",
|
||||
Progress: 0,
|
||||
MarketTag: "已安装",
|
||||
Version: "v1.0",
|
||||
ConnectorScope: "连接知识库 / 公众号工作区上下文",
|
||||
PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认",
|
||||
ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿",
|
||||
InfoSources: "选题关键词、目标受众、文章目标、风格要求",
|
||||
BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作",
|
||||
AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出",
|
||||
GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿",
|
||||
State: "active",
|
||||
SortOrder: 45,
|
||||
},
|
||||
{
|
||||
Key: "contract-review",
|
||||
Label: "合同审查专员",
|
||||
@@ -635,40 +659,40 @@ func ensureMediaLink(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(absSrc, dst); err == nil {
|
||||
return nil
|
||||
} else if shouldCopySeedMedia(err) {
|
||||
return copySeedMedia(absSrc, dst)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(absSrc, dst); err == nil {
|
||||
return nil
|
||||
} else if shouldCopySeedMedia(err) {
|
||||
return copySeedMedia(absSrc, dst)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCopySeedMedia(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
|
||||
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
|
||||
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
|
||||
}
|
||||
|
||||
func copySeedMedia(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func firstExistingPath(paths ...string) string {
|
||||
|
||||
Reference in New Issue
Block a user