feat: 微信公众号技能包重命名(weixin_public_account)并增强功能
- 将 wechat_official_account 重命名为 weixin_public_account,符合中文命名规范 - 新增 DOCX 文档生成技能、聊天历史、请求 ID 中间件 - 增强工作流、热点服务、文章服务等模块功能 - 前端同步重命名组件和 API - 新增架构文档 AR13/AR14、专员文档更新 - 补充测试用例(seed_specialists_test, db_migration_test) Co-Authored-AI: yes
This commit is contained in:
@@ -31,6 +31,12 @@ func main() {
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
// 启动前校验 AI 路由配置:若 ai_config.json 不可解析或默认路由无效,
|
||||
// 立刻失败而不是等用户聊天时才爆"AI 模型不可用"。
|
||||
if err := config.ValidateAIConfig(); err != nil {
|
||||
log.Fatalf("AI 路由配置校验失败(请检查 config/ai_config.json): %v", err)
|
||||
}
|
||||
|
||||
db, err := store.Init(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
{
|
||||
"agent_routes": {
|
||||
"embed_gen": "embed_route_openrouter_text_v3",
|
||||
"embed_gen": "embed_route_llamacpp_nomic",
|
||||
"image_gen": "image_route_openrouter_gpt_image_2",
|
||||
"path_coach": "chat_route_lmuai_deepseek_flash",
|
||||
"title_gen": "chat_route_lmuai_deepseek_flash"
|
||||
"video_gen": "video_route_openrouter_veo_3",
|
||||
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
|
||||
"title_gen": "chat_route_lmuai_deepseek_v4_flash"
|
||||
},
|
||||
"chat_routes": {
|
||||
"chat_route_lmuai_deepseek_flash": {
|
||||
"chat_route_lmuai_deepseek_v4_flash": {
|
||||
"description": "LMUAI / DeepSeek Flash",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek-flash",
|
||||
"model": "deepseek-v4-flash",
|
||||
"provider": "lmuai",
|
||||
"short_model_name": "DeepSeek Flash",
|
||||
"short_route_name": "LMUAI",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"chat_route_siliconflow_deepseek_v4_flash": {
|
||||
"description": "SiliconFlow / DeepSeek V4 Flash",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"provider": "siliconflow",
|
||||
"short_model_name": "DeepSeek V4 Flash",
|
||||
"short_route_name": "SiliconFlow",
|
||||
"short_route_name": "LMUAI",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"chat_route_ollama_qwen": {
|
||||
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 2048,
|
||||
"max_tokens": 512,
|
||||
"model": "Qwen3.5-4B-Q4_K_M.gguf",
|
||||
"provider": "llamacpp",
|
||||
"short_model_name": "Qwen3.5 4B",
|
||||
"short_route_name": "本地对话",
|
||||
"temperature": 0.7
|
||||
"temperature": 0.7,
|
||||
"timeout_seconds": 90
|
||||
},
|
||||
"chat_route_ollama_qwen_fast": {
|
||||
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
|
||||
@@ -85,12 +77,32 @@
|
||||
"short_model_name": "Grok 4.20",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.6
|
||||
},
|
||||
"chat_route_siliconflow_deepseek_v4_flash": {
|
||||
"description": "SiliconFlow / DeepSeek V4 Flash",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"provider": "siliconflow",
|
||||
"short_model_name": "DeepSeek V4 Flash",
|
||||
"short_route_name": "SiliconFlow",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"default_embed_route": "embed_route_openrouter_text_v3",
|
||||
"default_route": "chat_route_lmuai_deepseek_flash",
|
||||
"default_embed_route": "embed_route_llamacpp_nomic",
|
||||
"default_route": "chat_route_lmuai_deepseek_v4_flash",
|
||||
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
|
||||
"embed_routes": {
|
||||
"embed_route_llamacpp_nomic": {
|
||||
"base_url": "http://127.0.0.1:8081/v1",
|
||||
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
|
||||
"endpoint": "/embeddings",
|
||||
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
|
||||
"provider": "llamacpp",
|
||||
"short_model_name": "Nomic Embed v1.5",
|
||||
"short_route_name": "本地向量",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"embed_route_ollama_bge_m3": {
|
||||
"description": "本地向量 · Ollama / BGE-M3",
|
||||
"endpoint": "/embeddings",
|
||||
@@ -113,10 +125,6 @@
|
||||
"chat_route_ollama_qwen_fast",
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"chat_route_siliconflow_deepseek_v4_flash": [
|
||||
"chat_route_ollama_qwen_fast",
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"chat_route_openrouter_deepseek_v3": [
|
||||
"chat_route_openrouter_grok_4",
|
||||
"chat_route_ollama_qwen"
|
||||
@@ -129,6 +137,13 @@
|
||||
"chat_route_openrouter_grok_4": [
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"chat_route_siliconflow_deepseek_v4_flash": [
|
||||
"chat_route_ollama_qwen_fast",
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"embed_route_llamacpp_nomic": [
|
||||
"embed_route_openrouter_text_v3"
|
||||
],
|
||||
"embed_route_ollama_bge_m3": [
|
||||
"embed_route_openrouter_text_v3"
|
||||
],
|
||||
@@ -168,5 +183,17 @@
|
||||
"temperature": 1
|
||||
}
|
||||
},
|
||||
"video_routes": {
|
||||
"video_route_openrouter_veo_3": {
|
||||
"description": "OpenRouter / Google Veo 3",
|
||||
"endpoint": "/videos/generations",
|
||||
"model": "google/veo-3",
|
||||
"max_tokens": 4096,
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "Veo 3",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"version": "2.1.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"agent_routes": {
|
||||
"embed_gen": "embed_route_llamacpp_nomic",
|
||||
"image_gen": "image_route_openrouter_gpt_image_2",
|
||||
"video_gen": "video_route_openrouter_veo_3",
|
||||
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
|
||||
"title_gen": "chat_route_lmuai_deepseek_v4_flash"
|
||||
},
|
||||
"chat_routes": {
|
||||
"chat_route_lmuai_deepseek_v4_flash": {
|
||||
"description": "LMUAI / DeepSeek Flash",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek-v4-flash",
|
||||
"provider": "lmuai",
|
||||
"short_model_name": "DeepSeek V4 Flash",
|
||||
"short_route_name": "LMUAI",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"chat_route_ollama_qwen": {
|
||||
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 512,
|
||||
"model": "Qwen3.5-4B-Q4_K_M.gguf",
|
||||
"provider": "llamacpp",
|
||||
"short_model_name": "Qwen3.5 4B",
|
||||
"short_route_name": "本地对话",
|
||||
"temperature": 0.7,
|
||||
"timeout_seconds": 90
|
||||
},
|
||||
"chat_route_ollama_qwen_fast": {
|
||||
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 512,
|
||||
"model": "Qwen3.5-4B-Q4_K_M.gguf",
|
||||
"provider": "llamacpp",
|
||||
"short_model_name": "Qwen3.5 4B",
|
||||
"short_route_name": "本地快聊",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"chat_route_openrouter_deepseek_v3": {
|
||||
"description": "OpenRouter / DeepSeek V3.2",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek/deepseek-v3.2",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "DeepSeek V3.2",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.6
|
||||
},
|
||||
"chat_route_openrouter_deepseek_v4_flash": {
|
||||
"description": "OpenRouter / DeepSeek V4 Flash 0731",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek/deepseek-v4-flash-0731",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "DeepSeek V4 Flash 0731",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"chat_route_openrouter_gpt_5": {
|
||||
"description": "OpenRouter / GPT 5.4",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 8192,
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "GPT 5.4",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"chat_route_openrouter_grok_4": {
|
||||
"description": "OpenRouter / Grok 4.20",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "x-ai/grok-4.20",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "Grok 4.20",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.6
|
||||
},
|
||||
"chat_route_siliconflow_deepseek_v4_flash": {
|
||||
"description": "SiliconFlow / DeepSeek V4 Flash",
|
||||
"endpoint": "/chat/completions",
|
||||
"max_tokens": 4096,
|
||||
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"provider": "siliconflow",
|
||||
"short_model_name": "DeepSeek V4 Flash",
|
||||
"short_route_name": "SiliconFlow",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"default_embed_route": "embed_route_llamacpp_nomic",
|
||||
"default_route": "chat_route_lmuai_deepseek_v4_flash",
|
||||
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
|
||||
"embed_routes": {
|
||||
"embed_route_llamacpp_nomic": {
|
||||
"base_url": "http://127.0.0.1:8081/v1",
|
||||
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
|
||||
"endpoint": "/embeddings",
|
||||
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
|
||||
"provider": "llamacpp",
|
||||
"short_model_name": "Nomic Embed v1.5",
|
||||
"short_route_name": "本地向量",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"embed_route_ollama_bge_m3": {
|
||||
"description": "本地向量 · Ollama / BGE-M3",
|
||||
"endpoint": "/embeddings",
|
||||
"model": "bge-m3",
|
||||
"provider": "ollama",
|
||||
"short_model_name": "BGE-M3",
|
||||
"short_route_name": "本地向量"
|
||||
},
|
||||
"embed_route_openrouter_text_v3": {
|
||||
"description": "OpenRouter / text-embedding-3-small",
|
||||
"endpoint": "/embeddings",
|
||||
"model": "openai/text-embedding-3-small",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "text-embedding-3-small",
|
||||
"short_route_name": "OpenRouter"
|
||||
}
|
||||
},
|
||||
"fallback_routes": {
|
||||
"chat_route_lmuai_deepseek_flash": [
|
||||
"chat_route_ollama_qwen_fast",
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"chat_route_siliconflow_deepseek_v4_flash": [
|
||||
"chat_route_ollama_qwen_fast",
|
||||
"chat_route_ollama_qwen"
|
||||
],
|
||||
"embed_route_llamacpp_nomic": [
|
||||
"embed_route_openrouter_text_v3"
|
||||
],
|
||||
"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",
|
||||
"short_model_name": "LLaVA",
|
||||
"short_route_name": "本地图像",
|
||||
"temperature": 0.7
|
||||
},
|
||||
"image_route_openrouter_flux": {
|
||||
"description": "OpenRouter / FLUX 1.1 Pro",
|
||||
"endpoint": "/images/generations",
|
||||
"max_tokens": 1024,
|
||||
"model": "black-forest-labs/flux-1.1-pro",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "FLUX 1.1 Pro",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 1
|
||||
},
|
||||
"image_route_openrouter_gpt_image_2": {
|
||||
"description": "OpenRouter / GPT 5.4 Image 2",
|
||||
"endpoint": "/images/generations",
|
||||
"max_tokens": 4096,
|
||||
"model": "openai/gpt-5.4-image-2",
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "GPT 5.4 Image 2",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 1
|
||||
}
|
||||
},
|
||||
"video_routes": {
|
||||
"video_route_openrouter_veo_3": {
|
||||
"description": "OpenRouter / Google Veo 3",
|
||||
"endpoint": "/videos/generations",
|
||||
"model": "google/veo-3",
|
||||
"max_tokens": 4096,
|
||||
"provider": "openrouter",
|
||||
"short_model_name": "Veo 3",
|
||||
"short_route_name": "OpenRouter",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"version": "2.1.0"
|
||||
}
|
||||
@@ -50,15 +50,22 @@ type Client struct {
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// defaultHTTPTimeout 默认 HTTP 整体超时;路由可用 timeout_seconds 单独覆盖
|
||||
const defaultHTTPTimeout = 60 * time.Second
|
||||
|
||||
// NewClient 从 RouteConfig 创建客户端
|
||||
func NewClient(aiRoute *config.RouteConfig) *Client {
|
||||
timeout := defaultHTTPTimeout
|
||||
if aiRoute.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(aiRoute.TimeoutSeconds) * time.Second
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
|
||||
apiKey: aiRoute.APIKey,
|
||||
model: aiRoute.Model,
|
||||
maxTokens: aiRoute.MaxTokens,
|
||||
temperature: aiRoute.Temperature,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
hc: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,32 +222,60 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
|
||||
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
|
||||
// 流式空闲超时保护:若超过 idleTimeout 没有收到任何可消费 chunk,
|
||||
// 判定为卡死并返回错误,避免前端无限等待(此前本地模型卡顿时会干等 120s)。
|
||||
const idleTimeout = 45 * time.Second
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
scannerCh := make(chan string, 1)
|
||||
// 驱动 scanner 的 goroutine,配合 select 实现空闲超时。
|
||||
// 函数返回时 defer resp.Body.Close() 会让 scanner.Scan() 立即返回,
|
||||
// goroutine 随之退出并通过 close 通知主循环(缓冲 channel 避免阻塞发送)。
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
scannerCh <- scanner.Text()
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
break
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
onChunk(chunk.Choices[0].Delta.Content)
|
||||
close(scannerCh)
|
||||
}()
|
||||
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-idleTimer.C:
|
||||
return fmt.Errorf("LLM 流式响应空闲超时(%s 内无数据)", idleTimeout)
|
||||
case line, ok := <-scannerCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
// 收到有效内容,重置空闲计时
|
||||
if !idleTimer.Stop() {
|
||||
<-idleTimer.C
|
||||
}
|
||||
idleTimer.Reset(idleTimeout)
|
||||
onChunk(chunk.Choices[0].Delta.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Package routetest 提供「AI 通路测试」能力。
|
||||
//
|
||||
// 与线上定时探针(config/route_health.go 的 30 分钟健康检查)互补:
|
||||
// 探针只做最简连通性验证;本包用于**开发/运维**对某个具体模型做一次真实业务调用,
|
||||
// 逐个候选模型发独立请求并记录 request/response 原始报文,验证"真的能跑出结果"。
|
||||
//
|
||||
// 设计约束:
|
||||
// - 本包的所有测试调用**绝不扣算力点、不写入 ai_call_log**(纯后台诊断,避免污染用量/计费)。
|
||||
// - 支持 chat / embed / image 三类路由,按路由 Category 分发。
|
||||
package routetest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
)
|
||||
|
||||
// 默认测试超时与默认测试提示词(真实业务类提示词,而非 "ping")。
|
||||
const (
|
||||
defaultTestTimeout = 60 * time.Second
|
||||
// defaultChatProbe 对话类默认测试提示词
|
||||
defaultChatProbe = "你好,请用一句简短的话回复我(用于连通性测试)。"
|
||||
// 模型发现请求的 base 路径:/models
|
||||
modelsPath = "/models"
|
||||
)
|
||||
|
||||
// TestOptions 单次 / 批量通路测试入参
|
||||
type TestOptions struct {
|
||||
// Prompt 业务测试提示词;为空时使用各分类的默认提示词。
|
||||
Prompt string
|
||||
// Models 待测试的候选模型(传空则使用 route 自身 model;批量测试时传候选列表)。
|
||||
Models []string
|
||||
// Size image 类测试的图片尺寸(默认 1024x1024)。
|
||||
Size string
|
||||
}
|
||||
|
||||
// ResultItem 单个模型的测试结果(对应每个模型一个结果行)
|
||||
type ResultItem struct {
|
||||
Model string `json:"model"`
|
||||
Ok bool `json:"ok"`
|
||||
StatusCode int `json:"status_code"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// ModelEcho 服务端回显的 model 字段(用于确认实际被路由到的模型)
|
||||
ModelEcho string `json:"model_echo,omitempty"`
|
||||
// Summary text 类测试返回正文摘要 / image 类成功标志
|
||||
Summary string `json:"summary,omitempty"`
|
||||
// OutputPreview 产物预览:image 类返回 data-url(base64),text 类返回正文片段
|
||||
OutputPreview string `json:"output_preview,omitempty"`
|
||||
// RawRequest / RawResponse 原始报文(JSON 字符串,供诊断展开)
|
||||
RawRequest string `json:"raw_request,omitempty"`
|
||||
RawResponse string `json:"raw_response,omitempty"`
|
||||
}
|
||||
|
||||
// Result 一次通路测试的整体返回
|
||||
type Result struct {
|
||||
RouteID string `json:"route_id"`
|
||||
Provider string `json:"provider"`
|
||||
Category string `json:"category"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TestedAt string `json:"tested_at"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
Items []ResultItem `json:"items"`
|
||||
}
|
||||
|
||||
// Do 对指定 RouteConfig 发起通路测试。
|
||||
// - opts.Models 为空 → 仅测试 route 自身 model(单模型测试)。
|
||||
// - opts.Models 非空 → 逐个模型测试(批量/候选 试跑)。
|
||||
func Do(route *config.RouteConfig, opts *TestOptions) *Result {
|
||||
if route == nil {
|
||||
return &Result{Succeeded: false, Items: []ResultItem{{Ok: false, Error: "路由不存在"}}}
|
||||
}
|
||||
r := &Result{
|
||||
RouteID: route.RouteID,
|
||||
Provider: route.Provider,
|
||||
Category: route.Category,
|
||||
BaseURL: route.BaseURL,
|
||||
Endpoint: route.Endpoint,
|
||||
TestedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
models := opts.Models
|
||||
if len(models) == 0 {
|
||||
models = []string{route.Model}
|
||||
}
|
||||
size := opts.Size
|
||||
if size == "" {
|
||||
size = "1024x1024"
|
||||
}
|
||||
for _, model := range models {
|
||||
item := testOne(route, model, opts.Prompt, size)
|
||||
r.Items = append(r.Items, item)
|
||||
}
|
||||
for _, it := range r.Items {
|
||||
if it.Ok {
|
||||
r.Succeeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// testOne 对单个模型(+ 某条路由)+ 真实请求做测试,返回该模型独立结果行。
|
||||
func testOne(route *config.RouteConfig, model string, prompt, size string) ResultItem {
|
||||
item := ResultItem{Model: model}
|
||||
if strings.TrimSpace(route.BaseURL) == "" {
|
||||
item.Error = "base_url 未配置"
|
||||
return item
|
||||
}
|
||||
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
item.Error = "API Key 未配置"
|
||||
return item
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
switch route.Category {
|
||||
case "image":
|
||||
testImage(route, model, prompt, size, &item)
|
||||
case "video":
|
||||
testVideo(route, model, prompt, &item)
|
||||
case "embed":
|
||||
testEmbed(route, model, prompt, &item)
|
||||
default:
|
||||
testChat(route, model, prompt, &item)
|
||||
}
|
||||
item.LatencyMs = time.Since(start).Milliseconds()
|
||||
if item.Error != "" {
|
||||
item.Ok = false
|
||||
} else {
|
||||
item.Ok = true
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// httpPost 发一次 POST,返回状态码、原始响应体、错误。用于记录原始报文。
|
||||
func httpPost(route *config.RouteConfig, path string, body any, out any) (int, string, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("请求序列化失败: %w", err)
|
||||
}
|
||||
fullURL := strings.TrimRight(route.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("构造请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if strings.TrimSpace(route.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+route.APIKey)
|
||||
}
|
||||
client := &http.Client{Timeout: defaultTestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("服务不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
rawStr := string(data)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
|
||||
}
|
||||
if len(data) > 0 && out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, rawStr, nil
|
||||
}
|
||||
|
||||
// httpGet 发一次 GET,用于 /models 模型发现。
|
||||
func httpGet(route *config.RouteConfig, path string) ([]string, error) {
|
||||
fullURL := strings.TrimRight(route.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(route.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+route.APIKey)
|
||||
}
|
||||
client := &http.Client{Timeout: defaultTestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("返回 %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := make([]string, 0, len(out.Data))
|
||||
for _, d := range out.Data {
|
||||
if strings.TrimSpace(d.ID) != "" {
|
||||
models = append(models, d.ID)
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// testChat 对话类单模型测试。
|
||||
func testChat(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = defaultChatProbe
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"stream": false,
|
||||
}
|
||||
var out struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/chat/completions", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
|
||||
item.Error = "返回空正文"
|
||||
return
|
||||
}
|
||||
item.ModelEcho = out.Model
|
||||
content := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
item.Summary = truncate(content, 120)
|
||||
item.OutputPreview = truncate(content, 2000)
|
||||
}
|
||||
|
||||
// testEmbed 向量类单模型测试。
|
||||
func testEmbed(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
input := "ping"
|
||||
if strings.TrimSpace(prompt) != "" {
|
||||
input = prompt
|
||||
}
|
||||
body := map[string]any{"model": model, "input": input}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/embeddings", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
|
||||
item.Error = "返回空 embedding"
|
||||
return
|
||||
}
|
||||
item.ModelEcho = model
|
||||
item.Summary = fmt.Sprintf("返回 %d 维向量", len(out.Data[0].Embedding))
|
||||
}
|
||||
|
||||
// testImage 生图类单模型测试;成功时以 data-url 预览产物。
|
||||
func testImage(route *config.RouteConfig, model, prompt, size string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "生成一张简洁的产品展示图(用于连通性测试)"
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64JSON string `json:"b64_json"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/images/generations", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
item.Error = "返回空 data"
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功"
|
||||
item.OutputPreview = "data:image/png;base64," + out.Data[0].B64JSON
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].URL) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功(URL 模式)"
|
||||
item.OutputPreview = out.Data[0].URL
|
||||
return
|
||||
}
|
||||
item.Error = "响应缺少 b64_json / url"
|
||||
}
|
||||
|
||||
// testVideo 对视频生成模型做一次真实调用(OpenAI 兼容 POST /videos/generations)。
|
||||
// 视频响应通常回传 data[].url(视频文件地址)。
|
||||
func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "请生成一段简洁的演示视频(用于连通性测试)"
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
B64JSON string `json:"b64_json"`
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/videos/generations", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
item.Error = "返回空 data"
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].URL) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功(URL 模式)"
|
||||
item.OutputPreview = out.Data[0].URL
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功"
|
||||
item.OutputPreview = "data:video/mp4;base64," + out.Data[0].B64JSON
|
||||
return
|
||||
}
|
||||
item.Error = "响应缺少 url / b64_json"
|
||||
}
|
||||
|
||||
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
|
||||
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
|
||||
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
|
||||
if route == nil {
|
||||
return nil, fmt.Errorf("路由不存在")
|
||||
}
|
||||
models, err := httpGet(route, modelsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// MergeCandidates 将配置/已知候选与自动发现的模型合并去重。
|
||||
// 优先保留调用方给定顺序;用于「候选逐个试跑」。
|
||||
func MergeCandidates(known, discovered []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(known)+len(discovered))
|
||||
for _, m := range append(append([]string{}, known...), discovered...) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
|
||||
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
|
||||
func requiresAPIKey(route *config.RouteConfig) bool {
|
||||
if route == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
|
||||
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
|
||||
return true
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(route.Provider))
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...[截断]"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -37,10 +39,11 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Context map[string]any `json:"context"`
|
||||
History []ai.Message `json:"history"`
|
||||
AIRouteID string `json:"ai_route_id"`
|
||||
Message string `json:"message"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Context map[string]any `json:"context"`
|
||||
History []ai.Message `json:"history"`
|
||||
AIRouteID string `json:"ai_route_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
|
||||
web.Fail(c, web.NewBadRequest("message 必填"))
|
||||
@@ -52,6 +55,11 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 完整对话落库:先确保会话存在,再写用户消息(失败只记日志,不影响主流程)。
|
||||
spaceKey := extractKnowledgeSpaceKey(req.Context)
|
||||
convID := ensureChatConversation(req.ConversationID, user, req.Context, spaceKey)
|
||||
saveChatMessageRecord(convID, user.ID, "user", req.Message)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
@@ -79,6 +87,7 @@ func ChatMessage(c *gin.Context) {
|
||||
if len(plan.LLMMessages) == 0 {
|
||||
writeEvent(gin.H{"type": "text", "content": plan.Answer})
|
||||
writeEvent(gin.H{"type": "done"})
|
||||
saveChatMessageRecord(convID, user.ID, "assistant", plan.Answer)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +105,9 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
var replyBuilder strings.Builder
|
||||
usedAiRoute, err := ai.GenerateStreamWithFallback(aiRoute, plan.LLMMessages, func(chunk string) {
|
||||
replyBuilder.WriteString(chunk)
|
||||
writeEvent(gin.H{"type": "text", "content": chunk})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -107,6 +118,7 @@ func ChatMessage(c *gin.Context) {
|
||||
message = "当前知识库已进入 LLM 兜底层,但本机没有可用的模型服务正在运行。请先启动本地 Ollama,或补充可用的远端模型配置。"
|
||||
}
|
||||
writeEvent(gin.H{"type": "error", "message": message})
|
||||
saveChatMessageRecord(convID, user.ID, "assistant", message)
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
@@ -115,6 +127,7 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
writeEvent(gin.H{"type": "done"})
|
||||
saveChatMessageRecord(convID, user.ID, "assistant", replyBuilder.String())
|
||||
finalAiRoute := aiRoute
|
||||
if usedAiRoute != nil {
|
||||
finalAiRoute = usedAiRoute
|
||||
@@ -128,6 +141,60 @@ func ChatMessage(c *gin.Context) {
|
||||
|
||||
// buildSystemPrompt 已迁移至 internal/specialists/runtime(specialistruntime.BuildSystemPrompt)。
|
||||
|
||||
// chatSpecialistKey 尽量从请求上下文里取专员 key,取不到就留空,不报错。
|
||||
func chatSpecialistKey(ctx map[string]any) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"specialist_key", "specialistKey", "role_key"} {
|
||||
if raw, ok := ctx[key]; ok {
|
||||
s := strings.TrimSpace(fmt.Sprint(raw))
|
||||
if s != "" && s != "general" && s != "all" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ensureChatConversation 返回业务会话ID:优先用请求传入的;为空则新建一个会话落库。
|
||||
func ensureChatConversation(convID string, user *model.User, ctx map[string]any, spaceKey string) string {
|
||||
convID = strings.TrimSpace(convID)
|
||||
if convID != "" {
|
||||
return convID
|
||||
}
|
||||
convID = fmt.Sprintf("conv-%d", time.Now().UnixNano())
|
||||
conv := model.ChatConversation{
|
||||
UserID: user.ID,
|
||||
ConversationID: convID,
|
||||
SpecialistKey: chatSpecialistKey(ctx),
|
||||
SpaceKey: spaceKey,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.DB.Create(&conv).Error; err != nil {
|
||||
log.Printf("创建 ChatConversation 失败: %v", err)
|
||||
}
|
||||
return convID
|
||||
}
|
||||
|
||||
// saveChatMessageRecord 落库一条对话消息;失败只记日志,不中断主流程。
|
||||
func saveChatMessageRecord(conversationID string, userID uint, role, content string) {
|
||||
if conversationID == "" {
|
||||
return
|
||||
}
|
||||
rec := model.ChatMessage{
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := store.DB.Create(&rec).Error; err != nil {
|
||||
log.Printf("写入 ChatMessage 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮
|
||||
func QuickActions(c *gin.Context) {
|
||||
web.OK(c, gin.H{"actions": []gin.H{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// ai_routetest.go —— 「AI 通路测试」的管理员接口。
|
||||
//
|
||||
// 用于开发/运维对某个 AI 模型路由/具体模型做一次完整业务验证:
|
||||
// - 获取某路由的候选模型清单(配置 model ∪ 自动发现 /models)
|
||||
// - 对单个模型 或 候选模型逐个,发起一次真实请求并记录原始报文 / 产物预览
|
||||
//
|
||||
// 设计约束:本接口的测试调用**不扣算力点、不写 ai_call_log**,纯后台诊断。
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/ai/routetest"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// AIRouteTestModels POST /api/ai/route-test/models
|
||||
// 入参:{ "route_id": "..." },返回该路由可测的候选模型清单 + 路由信息。
|
||||
func AIRouteTestModels(c *gin.Context) {
|
||||
var req struct {
|
||||
RouteID string `json:"route_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.RouteID == "" {
|
||||
web.Fail(c, web.NewBadRequest("请提供 route_id"))
|
||||
return
|
||||
}
|
||||
route, err := config.GetRoute(req.RouteID)
|
||||
if err != nil || route == nil {
|
||||
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
|
||||
return
|
||||
}
|
||||
|
||||
candidates := []string{route.Model}
|
||||
discovered, _ := routetest.DiscoverModels(route)
|
||||
candidates = routetest.MergeCandidates(candidates, discovered)
|
||||
if len(candidates) == 0 {
|
||||
candidates = []string{route.Model}
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"route_id": route.RouteID,
|
||||
"provider": route.Provider,
|
||||
"category": route.Category,
|
||||
"base_url": route.BaseURL,
|
||||
"endpoint": route.Endpoint,
|
||||
"config_model": route.Model,
|
||||
"candidates": candidates,
|
||||
"discovered": discovered,
|
||||
})
|
||||
}
|
||||
|
||||
// AIRouteTestRun POST /api/ai/route-test/run
|
||||
// 入参:{ "route_id": "...", "models": ["..."], "prompt": "...", "size": "1024x1024" }
|
||||
// models 为空 → 只测配置 model;填写多个 → 逐个测试(批量试跑)。
|
||||
func AIRouteTestRun(c *gin.Context) {
|
||||
raw, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("读取请求体失败"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RouteID string `json:"route_id"`
|
||||
Models []string `json:"models"`
|
||||
Prompt string `json:"prompt"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &req); err != nil || req.RouteID == "" {
|
||||
web.Fail(c, web.NewBadRequest("请提供合法的 route_id / models"))
|
||||
return
|
||||
}
|
||||
route, err := config.GetRoute(req.RouteID)
|
||||
if err != nil || route == nil {
|
||||
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
|
||||
return
|
||||
}
|
||||
|
||||
opts := &routetest.TestOptions{Prompt: req.Prompt, Models: req.Models, Size: req.Size}
|
||||
result := routetest.Do(route, opts)
|
||||
web.OK(c, gin.H{"result": result})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// ListConversations GET /api/ai-chat/conversations —— 当前用户的会话列表(按最近更新倒序)。
|
||||
// 每个会话含 ConversationID、Title、SpaceKey、SpecialistKey、CreatedAt、
|
||||
// 最后一条消息摘要 last_content、消息数 message_count。
|
||||
func ListConversations(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var convs []model.ChatConversation
|
||||
if err := store.DB.Where("user_id = ?", user.ID).
|
||||
Order("updated_at DESC").Order("created_at DESC").Find(&convs).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("会话列表查询失败"))
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(convs))
|
||||
for _, conv := range convs {
|
||||
var messageCount int64
|
||||
store.DB.Model(&model.ChatMessage{}).
|
||||
Where("conversation_id = ? AND user_id = ?", conv.ConversationID, user.ID).
|
||||
Count(&messageCount)
|
||||
|
||||
lastContent := ""
|
||||
var last model.ChatMessage
|
||||
if err := store.DB.Where("conversation_id = ? AND user_id = ?", conv.ConversationID, user.ID).
|
||||
Order("created_at DESC").First(&last).Error; err == nil {
|
||||
lastContent = last.Content
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"conversation_id": conv.ConversationID,
|
||||
"title": conv.Title,
|
||||
"space_key": conv.SpaceKey,
|
||||
"specialist_key": conv.SpecialistKey,
|
||||
"created_at": conv.CreatedAt,
|
||||
"last_content": lastContent,
|
||||
"message_count": messageCount,
|
||||
})
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// ConversationMessages GET /api/ai-chat/conversations/:id/messages —— 某会话的全部消息(时间升序)。
|
||||
func ConversationMessages(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
convID := c.Param("id")
|
||||
var conv model.ChatConversation
|
||||
if err := store.DB.Where("conversation_id = ? AND user_id = ?", convID, user.ID).First(&conv).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("会话不存在"))
|
||||
return
|
||||
}
|
||||
var msgs []model.ChatMessage
|
||||
if err := store.DB.Where("conversation_id = ? AND user_id = ?", convID, user.ID).
|
||||
Order("created_at ASC").Find(&msgs).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("消息查询失败"))
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
items = append(items, gin.H{
|
||||
"role": m.Role,
|
||||
"content": m.Content,
|
||||
"meta": m.Meta,
|
||||
"created_at": m.CreatedAt,
|
||||
})
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -169,7 +170,8 @@ func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmo
|
||||
aiRoute, err := config.GetRoute(aiRouteID)
|
||||
if err != nil {
|
||||
logEntry.ErrorMessage = err.Error()
|
||||
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。", logEntry
|
||||
errMsg := fmt.Sprintf("抱歉,当前后台AI模型不可用,请检查 AI 路由配置。详情:%v", err)
|
||||
return errMsg, logEntry
|
||||
}
|
||||
logEntry.Provider = aiRoute.Provider
|
||||
logEntry.Model = aiRoute.Model
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
skillapi "eai_agentplatform/backend/internal/skills/api"
|
||||
specialistapi "eai_agentplatform/backend/internal/specialists/api"
|
||||
wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api"
|
||||
weixinpublicaccountapi "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/api"
|
||||
"eai_agentplatform/backend/internal/specialists/runtime"
|
||||
xappapi "eai_agentplatform/backend/internal/xapps/api"
|
||||
)
|
||||
@@ -73,13 +73,13 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.DELETE("/api/projects/:id", middleware.Auth(cfg), DeleteProject)
|
||||
r.GET("/api/projects/:id/tasks", middleware.Auth(cfg), ListProjectTasks)
|
||||
r.GET("/api/artifacts/:id", middleware.Auth(cfg), GetArtifactDetail)
|
||||
r.POST("/api/official-account/tasks", middleware.Auth(cfg), wechatofficialaccountapi.CreateOfficialAccountTask)
|
||||
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), wechatofficialaccountapi.GetOfficialAccountWorkflow)
|
||||
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), wechatofficialaccountapi.UpdateOfficialAccountTask)
|
||||
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), wechatofficialaccountapi.ExecuteOfficialAccountWorkflowStep)
|
||||
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), wechatofficialaccountapi.RegenerateOfficialAccountImage)
|
||||
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), wechatofficialaccountapi.ExportOfficialAccountDocument)
|
||||
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), wechatofficialaccountapi.ServeOfficialAccountGeneratedImage)
|
||||
r.POST("/api/weixin-public-account/tasks", middleware.Auth(cfg), weixinpublicaccountapi.CreateWeixinPublicAccountTask)
|
||||
r.GET("/api/weixin-public-account/tasks/:id/workflow", middleware.Auth(cfg), weixinpublicaccountapi.GetWeixinPublicAccountWorkflow)
|
||||
r.PUT("/api/weixin-public-account/tasks/:id", middleware.Auth(cfg), weixinpublicaccountapi.UpdateWeixinPublicAccountTask)
|
||||
r.POST("/api/weixin-public-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), weixinpublicaccountapi.ExecuteWeixinPublicAccountWorkflowStep)
|
||||
r.POST("/api/weixin-public-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), weixinpublicaccountapi.RegenerateWeixinPublicAccountImage)
|
||||
r.GET("/api/weixin-public-account/tasks/:id/export", middleware.Auth(cfg), weixinpublicaccountapi.ExportWeixinPublicAccountDocument)
|
||||
r.GET("/api/weixin-public-account/generated-images/:filename", middleware.Auth(cfg), weixinpublicaccountapi.ServeWeixinPublicAccountGeneratedImage)
|
||||
r.GET("/api/connectors", middleware.Auth(cfg), connectorapi.ListConnectors)
|
||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), connectorapi.GetConnector)
|
||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), connectorapi.QueryConnector)
|
||||
@@ -123,6 +123,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
|
||||
// ── AI 对话(普通员工可访问,管理员访问)──
|
||||
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
|
||||
r.GET("/api/ai-chat/conversations", middleware.Auth(cfg), ListConversations)
|
||||
r.GET("/api/ai-chat/conversations/:id/messages", middleware.Auth(cfg), ConversationMessages)
|
||||
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
|
||||
r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction)
|
||||
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
|
||||
@@ -259,5 +261,9 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
admin.POST("/ai/reload", ReloadAIConfig)
|
||||
admin.GET("/ai/secrets-status", SecretsStatusHandler)
|
||||
admin.GET("/ai/usage/users", AIUsageUsers)
|
||||
|
||||
// AI 通路测试(不扣算力点、不写调用日志)
|
||||
admin.POST("/ai/route-test/models", AIRouteTestModels)
|
||||
admin.POST("/ai/route-test/run", AIRouteTestRun)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func setupAPITestDB(t *testing.T) {
|
||||
|
||||
for _, s := range []specialistmodel.Specialist{
|
||||
{Key: "contract-review", Label: "合同审查专员", State: "active", Tier: "industry", ObjectEntryRoute: "/apps/contract-review"},
|
||||
{Key: "wechat-official-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/wechat-official-account"},
|
||||
{Key: "weixin-public-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/weixin-public-account"},
|
||||
{Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"},
|
||||
{Key: "general-assistant", Label: "通用助手", State: "system", Tier: "generic", ObjectEntryRoute: "/home"},
|
||||
} {
|
||||
@@ -168,7 +168,7 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
|
||||
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"})
|
||||
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "weixin-public-account"})
|
||||
if got == nil || got.Key != "contract-review" {
|
||||
t.Fatalf("任务上的专员应优先,实际 %v", got)
|
||||
}
|
||||
@@ -178,8 +178,8 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
|
||||
func TestResolveSpecialistFallsBackToRequest(t *testing.T) {
|
||||
setupAPITestDB(t)
|
||||
|
||||
got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "wechat-official-account"})
|
||||
if got == nil || got.Key != "wechat-official-account" {
|
||||
got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "weixin-public-account"})
|
||||
if got == nil || got.Key != "weixin-public-account" {
|
||||
t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -228,8 +228,8 @@ func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
|
||||
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"})
|
||||
if got == nil || got.Key != "wechat-official-account" {
|
||||
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "weixin-public-account"})
|
||||
if got == nil || got.Key != "weixin-public-account" {
|
||||
t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"eai_agentplatform/backend/internal/dal"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api"
|
||||
weixinpublicaccountapi "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/api"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
@@ -60,9 +60,9 @@ func GetTaskDetail(c *gin.Context) {
|
||||
|
||||
artifacts := taskArtifactDAO.ListByTask(id)
|
||||
runs := taskRunDAO.ListByTask(id)
|
||||
if task.SpecialistKey == wechatofficialaccountapi.OfficialAccountSpecialistKey {
|
||||
artifacts = wechatofficialaccountapi.CompactOfficialAccountArtifactsForResponse(artifacts)
|
||||
runs = wechatofficialaccountapi.CompactOfficialAccountRunsForResponse(runs)
|
||||
if task.SpecialistKey == weixinpublicaccountapi.WeixinPublicAccountSpecialistKey {
|
||||
artifacts = weixinpublicaccountapi.CompactWeixinPublicAccountArtifactsForResponse(artifacts)
|
||||
runs = weixinpublicaccountapi.CompactWeixinPublicAccountRunsForResponse(runs)
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
|
||||
@@ -21,6 +21,8 @@ type RouteInfo struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
BaseURL string `json:"base_url,omitempty"` // 可选:覆盖 provider 默认 base_url(如本地服务非默认端口时)
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 可选:HTTP 超时(秒),默认 60
|
||||
Category string `json:"category,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ShortRouteName string `json:"short_route_name,omitempty"`
|
||||
@@ -38,6 +40,7 @@ type RouteConfig struct {
|
||||
APIKey string
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
TimeoutSeconds int // HTTP 超时(秒),0 表示使用默认
|
||||
Category string // chat / embed / image
|
||||
Description string
|
||||
ShortRouteName string
|
||||
@@ -54,6 +57,7 @@ type AIConfig struct {
|
||||
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
|
||||
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
|
||||
ImageRoutes map[string]RouteInfo `json:"image_routes"`
|
||||
VideoRoutes map[string]RouteInfo `json:"video_routes"`
|
||||
FallbackRoutes map[string][]string `json:"fallback_routes"`
|
||||
// 兼容旧版平铺 routes(若有则回退)
|
||||
Routes map[string]RouteInfo `json:"routes,omitempty"`
|
||||
@@ -201,6 +205,41 @@ func LoadAIConfig(forceReload ...bool) (*AIConfig, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ValidateAIConfig 启动时强校验:确保 ai_config.json 可解析,且默认路由与
|
||||
// agent 映射所指向的路由均可解析。任一步失败即返回错误,由调用方(main)
|
||||
// 直接拒绝启动,从而把"配置损坏"这类问题在启动阶段暴露,而不是拖到用户
|
||||
// 聊天时才报"AI 模型不可用"。
|
||||
//
|
||||
// 注意:本文件及 config/ai_config.json 保持多行、带缩进的普通格式。
|
||||
// 严禁把 JSON 或本函数折叠成单行——单行会让格式错误难定位、diff 难读。
|
||||
// (历史教训:ai_config.json 曾因缺一个右花括号导致整个 AI 路由不可用,
|
||||
// 且文件原本被压成单行 5KB,错误极难排查。)
|
||||
func ValidateAIConfig() error {
|
||||
aiCfg, err := LoadAIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if aiCfg.Version == "" {
|
||||
return fmt.Errorf("缺少 version 字段")
|
||||
}
|
||||
// 校验 default_route 与 default_embed_route 指向的路由真实存在且可解析
|
||||
for _, id := range []string{aiCfg.DefaultRoute, aiCfg.DefaultEmbedRoute} {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := GetRoute(id); err != nil {
|
||||
return fmt.Errorf("默认路由 %q 不可用: %w", id, err)
|
||||
}
|
||||
}
|
||||
// 校验 agent_routes 中每个映射目标均可解析
|
||||
for agent, rid := range aiCfg.AgentRoutes {
|
||||
if _, err := GetRoute(rid); err != nil {
|
||||
return fmt.Errorf("agent[%q] -> 路由 %q 不可用: %w", agent, rid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadAISecrets(forceReload ...bool) (*AISecrets, error) {
|
||||
fr := len(forceReload) > 0 && forceReload[0]
|
||||
if !fr {
|
||||
@@ -301,9 +340,13 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
|
||||
apiKey = getSecretByField(secrets, secretField)
|
||||
}
|
||||
|
||||
// base_url: 先从 ProviderDefaultBaseURL 取
|
||||
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
|
||||
baseURL = url
|
||||
// base_url: 优先取路由级 base_url 覆盖,否则用 ProviderDefaultBaseURL
|
||||
if info.BaseURL != "" {
|
||||
baseURL = info.BaseURL
|
||||
} else {
|
||||
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// 组装
|
||||
@@ -319,6 +362,7 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
|
||||
APIKey: apiKey,
|
||||
MaxTokens: info.MaxTokens,
|
||||
Temperature: info.Temperature,
|
||||
TimeoutSeconds: info.TimeoutSeconds,
|
||||
Category: category,
|
||||
Description: info.Description,
|
||||
ShortRouteName: info.ShortRouteName,
|
||||
@@ -350,6 +394,11 @@ func findRoute(cfg *AIConfig, routeID string) (RouteInfo, string, bool) {
|
||||
return info, "image", true
|
||||
}
|
||||
}
|
||||
if cfg.VideoRoutes != nil {
|
||||
if info, ok := cfg.VideoRoutes[routeID]; ok {
|
||||
return info, "video", true
|
||||
}
|
||||
}
|
||||
// 兼容旧版平铺 routes
|
||||
if cfg.Routes != nil {
|
||||
if info, ok := cfg.Routes[routeID]; ok {
|
||||
@@ -429,6 +478,8 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
|
||||
routeMap = aiCfg.EmbedRoutes
|
||||
case "image":
|
||||
routeMap = aiCfg.ImageRoutes
|
||||
case "video":
|
||||
routeMap = aiCfg.VideoRoutes
|
||||
default:
|
||||
return nil, fmt.Errorf("未知路由分类: %s", category)
|
||||
}
|
||||
@@ -491,6 +542,11 @@ func SaveAIConfig(raw []byte) error {
|
||||
}
|
||||
|
||||
path := resolveConfigPath("ai_config.json")
|
||||
|
||||
// 写前备份:把当前可用版本复制为 .bak,防止一次写坏后无法恢复。
|
||||
// 备份失败不影响主变更(仅告警日志)。
|
||||
backupAIConfig(path)
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||
return fmt.Errorf("写入临时文件失败: %w", err)
|
||||
@@ -505,6 +561,21 @@ func SaveAIConfig(raw []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupAIConfig 把当前 ai_config.json 复制到 ai_config.json.bak。
|
||||
// 若目标不存在或读取失败则静默跳过(首次写入或尚未建文件时无备份可做)。
|
||||
func backupAIConfig(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 仅当现有文件是合法 JSON 时才备份,避免把上一份已经损坏的内容固化成备份。
|
||||
var probe AIConfig
|
||||
if err := json.Unmarshal(data, &probe); err != nil || probe.Version == "" {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path+".bak", data, 0o644)
|
||||
}
|
||||
|
||||
// ResetCache 清空全部配置缓存(热重载,无需重启)
|
||||
func ResetCache() {
|
||||
mu.Lock()
|
||||
|
||||
+8
-8
@@ -5,14 +5,14 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
oaamodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
oaamodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
)
|
||||
|
||||
// OfficialAccountArticle 公众号文章状态仓库(一个任务一篇,task_id 上有唯一索引)。
|
||||
// WeixinPublicAccountArticle 公众号文章状态仓库(一个任务一篇,task_id 上有唯一索引)。
|
||||
//
|
||||
// 模型定义在专员技能包内(specialists/packages/wechat_official_account/model),
|
||||
// 模型定义在专员技能包内(specialists/packages/weixin_public_account/model),
|
||||
// 原先这个包自己拿 store.DB 读写,收口后统一走这里。
|
||||
type OfficialAccountArticleDAO struct{ *QueryBuilder }
|
||||
type WeixinPublicAccountArticleDAO struct{ *QueryBuilder }
|
||||
|
||||
// FindByTaskID 按任务取文章状态。三种结果,**调用方必须分清**:
|
||||
// - row != nil 找到了
|
||||
@@ -23,8 +23,8 @@ type OfficialAccountArticleDAO struct{ *QueryBuilder }
|
||||
// `if err == nil { return article }` 其余一律往下走新建 —— 也就是说读取
|
||||
// 真出错时也会去建一篇新的。task_id 上有唯一索引,这种误建多半会撞唯一键
|
||||
// 而失败,但那是运气不是设计。(同 UserXAppCenterDAO.FindByUser)
|
||||
func (r OfficialAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.OfficialAccountArticle, error) {
|
||||
var row oaamodel.OfficialAccountArticle
|
||||
func (r WeixinPublicAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.WeixinPublicAccountArticle, error) {
|
||||
var row oaamodel.WeixinPublicAccountArticle
|
||||
err := r.Inner().Where("task_id = ?", taskID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@@ -36,11 +36,11 @@ func (r OfficialAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.Official
|
||||
}
|
||||
|
||||
// Insert 新建文章状态。
|
||||
func (r OfficialAccountArticleDAO) Insert(a *oaamodel.OfficialAccountArticle) bool {
|
||||
func (r WeixinPublicAccountArticleDAO) Insert(a *oaamodel.WeixinPublicAccountArticle) bool {
|
||||
return r.QueryBuilder.Insert(a)
|
||||
}
|
||||
|
||||
// Update 保存文章状态(改标题/提纲/正文/配图等一律走这里)。
|
||||
func (r OfficialAccountArticleDAO) Update(a *oaamodel.OfficialAccountArticle) bool {
|
||||
func (r WeixinPublicAccountArticleDAO) Update(a *oaamodel.WeixinPublicAccountArticle) bool {
|
||||
return r.Save(a)
|
||||
}
|
||||
+7
-7
@@ -5,18 +5,18 @@ import (
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
oahmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
oahmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
)
|
||||
|
||||
// OfficialAccountHotspot 公众号热点缓存仓库。
|
||||
// WeixinPublicAccountHotspot 公众号热点缓存仓库。
|
||||
//
|
||||
// 表里存的是抓取回来的 RSS/网页条目及其领域评分,按 (source_key, url)
|
||||
// 唯一,重复抓到同一条要就地更新而不是堆新行。
|
||||
type OfficialAccountHotspotDAO struct{ *QueryBuilder }
|
||||
type WeixinPublicAccountHotspotDAO struct{ *QueryBuilder }
|
||||
|
||||
// hotspotListLimit 单次取回的热点条数上限。
|
||||
//
|
||||
// 调用方(loadFreshOfficialAccountHotspots)取回后还要按关键词在内存里再滤一遍,
|
||||
// 调用方(loadFreshWeixinPublicAccountHotspots)取回后还要按关键词在内存里再滤一遍,
|
||||
// 所以这里多取一些留余量 —— 这个 40 是原实现写死的值,收口时原样保留。
|
||||
const hotspotListLimit = 40
|
||||
|
||||
@@ -33,8 +33,8 @@ const hotspotListLimit = 40
|
||||
// 调用方会走到不依赖热点的兜底选题路径。这个降级是有意接受的 ——
|
||||
// 数据访问层统一是 bool / 裸返回的风格(同 TaskRunDAO.ListByTask),
|
||||
// 不为一次缓存读再造一个 error 出口;但这是**行为变化**,不是等价重构。
|
||||
func (r OfficialAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time) []oahmodel.OfficialAccountHotspot {
|
||||
var items []oahmodel.OfficialAccountHotspot
|
||||
func (r WeixinPublicAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time) []oahmodel.WeixinPublicAccountHotspot {
|
||||
var items []oahmodel.WeixinPublicAccountHotspot
|
||||
if r.Type(&items).
|
||||
Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff).
|
||||
Order("domain_score DESC, published_at DESC, id DESC").
|
||||
@@ -50,7 +50,7 @@ func (r OfficialAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time)
|
||||
// 这里返回 error 而不是 bool,是因为调用方要把 err.Error() 拼进给用户看的
|
||||
// 抓取日志(「热点入库失败:…」)——bool 装不下这句话,丢掉它用户就只看到
|
||||
// 「本轮未写入新热点」,无从知道是库的问题还是源站的问题。
|
||||
func (r OfficialAccountHotspotDAO) UpsertAll(rows []oahmodel.OfficialAccountHotspot) error {
|
||||
func (r WeixinPublicAccountHotspotDAO) UpsertAll(rows []oahmodel.WeixinPublicAccountHotspot) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// RequestID 为每个请求注入追踪编号:优先沿用调用方传入的 X-Request-ID,
|
||||
// 否则生成 8 字节随机十六进制串。编号写入 Gin Context 与请求头,
|
||||
// 由 web.OK / web.Fail 统一回写到响应头与错误体,支撑前后端与日志联查。
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.Request.Header.Get("X-Request-ID")
|
||||
if rid == "" {
|
||||
rid = newRequestID()
|
||||
}
|
||||
c.Set(web.CtxRequestID, rid)
|
||||
c.Request.Header.Set("X-Request-ID", rid)
|
||||
|
||||
defer func() {
|
||||
// 无论 handler 是否写入响应头,统一保证带上请求编号
|
||||
c.Writer.Header().Set("X-Request-ID", c.GetString(web.CtxRequestID))
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
return "req-00000000"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ChatConversation 一次完整的多轮对话(业务会话),用于聊天记录持久化与回溯。
|
||||
type ChatConversation struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
ConversationID string `gorm:"size:128;uniqueIndex;not null" json:"conversation_id"` // 业务会话ID
|
||||
SpecialistKey string `gorm:"size:64" json:"specialist_key"`
|
||||
SpaceKey string `gorm:"size:64" json:"space_key"`
|
||||
Title string `gorm:"size:256" json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ChatConversation) TableName() string { return "chat_conversation" }
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ChatMessage 单条对话消息(用户提问或 AI 回复),归属某个 ChatConversation。
|
||||
type ChatMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ConversationID string `gorm:"size:128;index;not null" json:"conversation_id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Role string `gorm:"size:16;not null;index" json:"role"` // user / assistant
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
// Meta 可选结构化信息(如 citations / layer / intent 等),以 JSON 字符串存储。
|
||||
Meta string `gorm:"type:text" json:"meta,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ChatMessage) TableName() string { return "chat_message" }
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
customerfollowup "eai_agentplatform/backend/internal/specialists/packages/customer_followup"
|
||||
resumeprocessor "eai_agentplatform/backend/internal/specialists/packages/resume_processor"
|
||||
solutionproposal "eai_agentplatform/backend/internal/specialists/packages/solution_proposal"
|
||||
wechatofficialaccount "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account"
|
||||
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
|
||||
)
|
||||
|
||||
var builtinRegistry = NewRegistry(
|
||||
wechatofficialaccount.Manifest,
|
||||
weixinpublicaccount.Manifest,
|
||||
contractreview.Manifest,
|
||||
solutionproposal.Manifest,
|
||||
customerfollowup.Manifest,
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import "eai_agentplatform/backend/internal/dal"
|
||||
|
||||
@@ -15,8 +15,8 @@ var (
|
||||
taskRecordDAO dal.TaskRecordDAO
|
||||
taskRunDAO dal.TaskRunDAO
|
||||
taskArtifactDAO dal.TaskArtifactDAO
|
||||
oaArticleDAO dal.OfficialAccountArticleDAO
|
||||
oaHotspotDAO dal.OfficialAccountHotspotDAO
|
||||
oaArticleDAO dal.WeixinPublicAccountArticleDAO
|
||||
oaHotspotDAO dal.WeixinPublicAccountHotspotDAO
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -24,6 +24,6 @@ func init() {
|
||||
taskRecordDAO = dal.TaskRecordDAO{}
|
||||
taskRunDAO = dal.TaskRunDAO{}
|
||||
taskArtifactDAO = dal.TaskArtifactDAO{}
|
||||
oaArticleDAO = dal.OfficialAccountArticleDAO{}
|
||||
oaHotspotDAO = dal.OfficialAccountHotspotDAO{}
|
||||
oaArticleDAO = dal.WeixinPublicAccountArticleDAO{}
|
||||
oaHotspotDAO = dal.WeixinPublicAccountHotspotDAO{}
|
||||
}
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
OfficialAccountSpecialistKey = "wechat-official-account"
|
||||
officialAccountSpecialistKey = OfficialAccountSpecialistKey
|
||||
WeixinPublicAccountSpecialistKey = "weixin-public-account"
|
||||
weixinPublicAccountSpecialistKey = WeixinPublicAccountSpecialistKey
|
||||
|
||||
taskStatusPending = "待处理"
|
||||
taskStatusInProgress = "进行中"
|
||||
@@ -23,12 +23,12 @@ const (
|
||||
artifactStatusDraft = "draft"
|
||||
)
|
||||
|
||||
func CompactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
|
||||
return compactOfficialAccountRunsForResponse(items)
|
||||
func CompactWeixinPublicAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
|
||||
return compactWeixinPublicAccountRunsForResponse(items)
|
||||
}
|
||||
|
||||
func CompactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
|
||||
return compactOfficialAccountArtifactsForResponse(items)
|
||||
func CompactWeixinPublicAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
|
||||
return compactWeixinPublicAccountArtifactsForResponse(items)
|
||||
}
|
||||
|
||||
func bootstrapOwner(user *model.User) string {
|
||||
+60
-55
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
"strings"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
)
|
||||
|
||||
// ensureOfficialAccountArticle 取该任务的文章状态,没有就按当前工作流新建一篇。
|
||||
// ensureWeixinPublicAccountArticle 取该任务的文章状态,没有就按当前工作流新建一篇。
|
||||
//
|
||||
// 三种情况要分开走,不能把「读取出错」和「还没有」合并:
|
||||
// - 读到了 → 直接用
|
||||
// - 确实没有 → 新建(正常路径,每个任务第一次进来都会走这里)
|
||||
// - 读取出错 → 原样返回错误,**不要**去建新的 —— task_id 上有唯一索引,
|
||||
// 误建多半会撞唯一键而失败,但那是运气不是设计
|
||||
func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccountWorkflowState) (*officialaccountmodel.OfficialAccountArticle, error) {
|
||||
func ensureWeixinPublicAccountArticle(task model.TaskRecord, workflow weixinPublicAccountWorkflowState) (*weixinpublicaccountmodel.WeixinPublicAccountArticle, error) {
|
||||
article, err := oaArticleDAO.FindByTaskID(task.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -26,19 +26,19 @@ func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccoun
|
||||
if article != nil {
|
||||
return article, nil
|
||||
}
|
||||
row := &officialaccountmodel.OfficialAccountArticle{
|
||||
row := &weixinpublicaccountmodel.WeixinPublicAccountArticle{
|
||||
TaskID: task.ID,
|
||||
SpecialistKey: task.SpecialistKey,
|
||||
BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
|
||||
Keyword: firstNonEmpty(workflow.Form.Keyword, getOfficialAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword),
|
||||
BusinessDomain: resolveWeixinPublicAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
|
||||
Keyword: firstNonEmpty(workflow.Form.Keyword, getWeixinPublicAccountBusinessDomainConfig(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),
|
||||
MaxContentImages: normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages),
|
||||
OutlineStyle: normalizeWeixinPublicAccountOutlineStyle(workflow.Form.OutlineStyle),
|
||||
OutlineWords: normalizeWeixinPublicAccountOutlineWords(workflow.Form.OutlineWords),
|
||||
ContentTargetWords: normalizeWeixinPublicAccountContentTargetWords(workflow.Form.ContentTargetWords),
|
||||
MaxContentImages: normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages),
|
||||
Status: "draft",
|
||||
}
|
||||
if !oaArticleDAO.Insert(row) {
|
||||
@@ -47,23 +47,23 @@ func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccoun
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) {
|
||||
func syncWorkflowFromWeixinPublicAccountArticle(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle) {
|
||||
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.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(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)
|
||||
workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(article.MaxContentImages)
|
||||
workflow.Form.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(article.OutlineStyle)
|
||||
workflow.Form.OutlineWords = normalizeWeixinPublicAccountOutlineWords(article.OutlineWords)
|
||||
workflow.Form.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(article.ContentTargetWords)
|
||||
workflow.Form.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(article.MaxContentImages)
|
||||
|
||||
if strings.TrimSpace(article.TopicCandidatesJSON) != "" {
|
||||
var items []officialAccountTopicCandidate
|
||||
var items []weixinPublicAccountTopicCandidate
|
||||
if json.Unmarshal([]byte(article.TopicCandidatesJSON), &items) == nil {
|
||||
workflow.Shared.TopicCandidates = items
|
||||
}
|
||||
@@ -80,13 +80,13 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
|
||||
workflow.Shared.Content = article.Content
|
||||
workflow.Shared.ContentWithPrompts = article.ContentWithPrompts
|
||||
if strings.TrimSpace(article.ImagePromptsJSON) != "" {
|
||||
var items []officialAccountImagePrompt
|
||||
var items []weixinPublicAccountImagePrompt
|
||||
if json.Unmarshal([]byte(article.ImagePromptsJSON), &items) == nil {
|
||||
workflow.Shared.ImagePrompts = items
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(article.ImageRefsJSON) != "" {
|
||||
var items []officialAccountImageRef
|
||||
var items []weixinPublicAccountImageRef
|
||||
if json.Unmarshal([]byte(article.ImageRefsJSON), &items) == nil {
|
||||
workflow.Shared.ImageRefs = items
|
||||
}
|
||||
@@ -95,27 +95,27 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
|
||||
workflow.Shared.PreviewHTML = article.PreviewHTML
|
||||
workflow.Shared.PageMarkdown = article.PageMarkdown
|
||||
if strings.TrimSpace(article.PagesJSON) != "" {
|
||||
var items []officialAccountPageChunk
|
||||
var items []weixinPublicAccountPageChunk
|
||||
if json.Unmarshal([]byte(article.PagesJSON), &items) == nil {
|
||||
workflow.Shared.Pages = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncOfficialAccountArticleFromWorkflow(article *officialaccountmodel.OfficialAccountArticle, workflow officialAccountWorkflowState) {
|
||||
func syncWeixinPublicAccountArticleFromWorkflow(article *weixinpublicaccountmodel.WeixinPublicAccountArticle, workflow weixinPublicAccountWorkflowState) {
|
||||
if article == nil {
|
||||
return
|
||||
}
|
||||
article.BusinessDomain = resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword)
|
||||
article.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(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.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
|
||||
article.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(workflow.Form.OutlineStyle)
|
||||
article.OutlineWords = normalizeWeixinPublicAccountOutlineWords(workflow.Form.OutlineWords)
|
||||
article.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(workflow.Form.ContentTargetWords)
|
||||
article.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages)
|
||||
article.SelectedTopic = workflow.Shared.SelectedTopic
|
||||
article.SelectedTitle = workflow.Shared.SelectedTitle
|
||||
article.Outline = workflow.Shared.Outline
|
||||
@@ -160,11 +160,11 @@ func syncOfficialAccountArticleFromWorkflow(article *officialaccountmodel.Offici
|
||||
}
|
||||
}
|
||||
|
||||
func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountArticle, req officialAccountTaskUpdateReq) {
|
||||
func resetWeixinPublicAccountArticle(article *weixinpublicaccountmodel.WeixinPublicAccountArticle, req weixinPublicAccountTaskUpdateReq) {
|
||||
if article == nil {
|
||||
return
|
||||
}
|
||||
article.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
|
||||
article.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(req.BusinessDomain, req.Keyword)
|
||||
article.Keyword = req.Keyword
|
||||
article.Audience = firstNonEmpty(req.Audience, "公众号读者")
|
||||
article.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
|
||||
@@ -176,11 +176,11 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr
|
||||
article.TopicHeat = ""
|
||||
article.TitleCandidatesJSON = "[]"
|
||||
article.SelectedTitle = ""
|
||||
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle)
|
||||
article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
|
||||
article.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(req.OutlineStyle)
|
||||
article.OutlineWords = normalizeWeixinPublicAccountOutlineWords(req.OutlineWords)
|
||||
article.Outline = ""
|
||||
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
|
||||
article.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages)
|
||||
article.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(req.ContentTargetWords)
|
||||
article.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(req.MaxContentImages)
|
||||
article.Content = ""
|
||||
article.ContentWithPrompts = ""
|
||||
article.ImagePromptsJSON = "[]"
|
||||
@@ -192,17 +192,17 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr
|
||||
article.Status = "draft"
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) []officialAccountTopicCandidate {
|
||||
domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain)
|
||||
candidates := make([]officialAccountTopicCandidate, 0, 3)
|
||||
func buildWeixinPublicAccountTopicCandidatesFromHotspots(form weixinPublicAccountForm, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) []weixinPublicAccountTopicCandidate {
|
||||
domainLabel := weixinPublicAccountBusinessDomainLabel(form.BusinessDomain)
|
||||
candidates := make([]weixinPublicAccountTopicCandidate, 0, 3)
|
||||
for _, item := range hotspots {
|
||||
topic := deriveOfficialAccountTopic(form.Keyword, item.Title)
|
||||
topic := deriveWeixinPublicAccountTopic(form.Keyword, item.Title)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, officialAccountTopicCandidate{
|
||||
candidates = append(candidates, weixinPublicAccountTopicCandidate{
|
||||
Topic: topic,
|
||||
Angle: deriveOfficialAccountAngle(item.Title),
|
||||
Angle: deriveWeixinPublicAccountAngle(item.Title),
|
||||
Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"),
|
||||
Heat: firstNonEmpty(item.HeatLabel, "中高"),
|
||||
})
|
||||
@@ -211,14 +211,14 @@ func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, h
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return buildOfficialAccountTopicCandidates(form)
|
||||
return buildWeixinPublicAccountTopicCandidates(form)
|
||||
}
|
||||
return dedupeOfficialAccountTopicCandidates(candidates)
|
||||
return dedupeWeixinPublicAccountTopicCandidates(candidates)
|
||||
}
|
||||
|
||||
func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) []officialAccountTopicCandidate {
|
||||
func dedupeWeixinPublicAccountTopicCandidates(items []weixinPublicAccountTopicCandidate) []weixinPublicAccountTopicCandidate {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]officialAccountTopicCandidate, 0, len(items))
|
||||
result := make([]weixinPublicAccountTopicCandidate, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Topic)
|
||||
if key == "" {
|
||||
@@ -233,7 +233,7 @@ func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate)
|
||||
return result
|
||||
}
|
||||
|
||||
func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
|
||||
func deriveWeixinPublicAccountTopic(keyword, sourceTitle string) string {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
sourceTitle = strings.TrimSpace(sourceTitle)
|
||||
switch {
|
||||
@@ -242,11 +242,16 @@ func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
|
||||
case strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(keyword)):
|
||||
return sourceTitle
|
||||
default:
|
||||
return fmt.Sprintf("%s:%s", keyword, sourceTitle)
|
||||
for _, term := range buildWeixinPublicAccountKeywordTerms(keyword) {
|
||||
if strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(term)) {
|
||||
return sourceTitle
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func deriveOfficialAccountAngle(sourceTitle string) string {
|
||||
func deriveWeixinPublicAccountAngle(sourceTitle string) string {
|
||||
switch {
|
||||
case strings.Contains(sourceTitle, "融资") || strings.Contains(sourceTitle, "估值"):
|
||||
return "产业动向"
|
||||
@@ -259,7 +264,7 @@ func deriveOfficialAccountAngle(sourceTitle string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAccountHotspot) string {
|
||||
func marshalWeixinPublicAccountHotspots(hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
|
||||
if len(hotspots) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
@@ -270,8 +275,8 @@ func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAcco
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
func buildWeixinPublicAccountHotspotPrompt(form weixinPublicAccountForm, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 3 个值得写的热点选题。", cfg.PromptLabel),
|
||||
"要求:",
|
||||
@@ -296,8 +301,8 @@ func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []offi
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
func buildWeixinPublicAccountTopicEvalPrompt(form weixinPublicAccountForm, topic string, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel),
|
||||
fmt.Sprintf("选题:%s", topic),
|
||||
@@ -315,8 +320,8 @@ func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string,
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
func buildWeixinPublicAccountTopicBatchEvalPrompt(form weixinPublicAccountForm, candidates []weixinPublicAccountTopicCandidate, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
|
||||
lines := []string{
|
||||
fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel),
|
||||
"要求:",
|
||||
@@ -345,8 +350,8 @@ func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidat
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sortOfficialAccountHotspots(items []officialaccountmodel.OfficialAccountHotspot) []officialaccountmodel.OfficialAccountHotspot {
|
||||
cloned := append([]officialaccountmodel.OfficialAccountHotspot(nil), items...)
|
||||
func sortWeixinPublicAccountHotspots(items []weixinpublicaccountmodel.WeixinPublicAccountHotspot) []weixinpublicaccountmodel.WeixinPublicAccountHotspot {
|
||||
cloned := append([]weixinpublicaccountmodel.WeixinPublicAccountHotspot(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)
|
||||
+99
-89
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -17,18 +17,18 @@ import (
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
type officialAccountSection struct {
|
||||
type weixinPublicAccountSection struct {
|
||||
Title string
|
||||
Content []string
|
||||
}
|
||||
|
||||
func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
|
||||
prompts := buildOfficialAccountImagePrompts(workflow)
|
||||
contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
||||
func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
|
||||
prompts := buildWeixinPublicAccountImagePrompts(workflow)
|
||||
contentWithPrompts := buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
||||
workflow.Shared.ContentWithPrompts = contentWithPrompts
|
||||
workflow.Shared.ImagePrompts = prompts
|
||||
|
||||
@@ -37,9 +37,9 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat
|
||||
"image_prompts": prompts,
|
||||
"count": len(prompts),
|
||||
}
|
||||
return officialAccountStepExecution{
|
||||
return weixinPublicAccountStepExecution{
|
||||
Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)),
|
||||
TaskStatus: taskStatusDraft,
|
||||
TaskStatus: taskStatusInProgress,
|
||||
ArtifactTitle: "公众号配图提示词",
|
||||
ArtifactType: "image_prompts",
|
||||
ArtifactStatus: artifactStatusDraft,
|
||||
@@ -52,16 +52,16 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat
|
||||
}
|
||||
}
|
||||
|
||||
func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) {
|
||||
func executeWeixinPublicAccountImageGenerationStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) (weixinPublicAccountStepExecution, *web.AppError) {
|
||||
prompts := workflow.Shared.ImagePrompts
|
||||
if len(prompts) == 0 {
|
||||
prompts = buildOfficialAccountImagePrompts(workflow)
|
||||
prompts = buildWeixinPublicAccountImagePrompts(workflow)
|
||||
workflow.Shared.ImagePrompts = prompts
|
||||
workflow.Shared.ContentWithPrompts = buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
||||
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
||||
}
|
||||
aiRoute, refs, appErr := generateOfficialAccountImages(article.TaskID, workflow, prompts, user)
|
||||
aiRoute, refs, appErr := generateWeixinPublicAccountImages(article.TaskID, workflow, prompts, user)
|
||||
if appErr != nil {
|
||||
return officialAccountStepExecution{}, appErr
|
||||
return weixinPublicAccountStepExecution{}, appErr
|
||||
}
|
||||
workflow.Shared.ImageRefs = refs
|
||||
|
||||
@@ -72,13 +72,13 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow
|
||||
"ai_route_model": aiRoute.Model,
|
||||
"ai_route_provider": aiRoute.Provider,
|
||||
}
|
||||
return officialAccountStepExecution{
|
||||
return weixinPublicAccountStepExecution{
|
||||
Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)),
|
||||
TaskStatus: taskStatusDraft,
|
||||
TaskStatus: taskStatusInProgress,
|
||||
ArtifactTitle: "公众号配图结果",
|
||||
ArtifactType: "images",
|
||||
ArtifactStatus: artifactStatusDraft,
|
||||
ArtifactContent: buildOfficialAccountImageArtifact(refs),
|
||||
ArtifactContent: buildWeixinPublicAccountImageArtifact(refs),
|
||||
Output: output,
|
||||
Logs: []string{
|
||||
fmt.Sprintf("%s 已通过 %s 生成图片结果。", now.Format("15:04:05"), aiRoute.Model),
|
||||
@@ -87,9 +87,9 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
|
||||
previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow)
|
||||
previewHTML := renderOfficialAccountHTML(previewMarkdown)
|
||||
func executeWeixinPublicAccountPreviewStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
|
||||
previewMarkdown := buildWeixinPublicAccountPreviewMarkdown(workflow)
|
||||
previewHTML := renderWeixinPublicAccountHTML(previewMarkdown)
|
||||
workflow.Shared.PreviewMarkdown = previewMarkdown
|
||||
workflow.Shared.PreviewHTML = previewHTML
|
||||
|
||||
@@ -98,9 +98,9 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a
|
||||
"preview_html": previewHTML,
|
||||
"image_count": len(workflow.Shared.ImageRefs),
|
||||
}
|
||||
return officialAccountStepExecution{
|
||||
return weixinPublicAccountStepExecution{
|
||||
Summary: "预览稿已生成,可直接检查排版并导出。",
|
||||
TaskStatus: taskStatusDraft,
|
||||
TaskStatus: taskStatusInProgress,
|
||||
ArtifactTitle: "公众号预览稿",
|
||||
ArtifactType: "preview",
|
||||
ArtifactStatus: artifactStatusDraft,
|
||||
@@ -113,16 +113,16 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a
|
||||
}
|
||||
}
|
||||
|
||||
func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
|
||||
pages := buildOfficialAccountPages(workflow)
|
||||
pageMarkdown := buildOfficialAccountPageMarkdown(pages)
|
||||
func executeWeixinPublicAccountPageMarkdownStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
|
||||
pages := buildWeixinPublicAccountPages(workflow)
|
||||
pageMarkdown := buildWeixinPublicAccountPageMarkdown(pages)
|
||||
workflow.Shared.Pages = pages
|
||||
workflow.Shared.PageMarkdown = pageMarkdown
|
||||
if workflow.Shared.PreviewMarkdown == "" {
|
||||
workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow)
|
||||
workflow.Shared.PreviewMarkdown = buildWeixinPublicAccountPreviewMarkdown(workflow)
|
||||
}
|
||||
if workflow.Shared.PreviewHTML == "" {
|
||||
workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown)
|
||||
workflow.Shared.PreviewHTML = renderWeixinPublicAccountHTML(workflow.Shared.PreviewMarkdown)
|
||||
}
|
||||
|
||||
output := map[string]any{
|
||||
@@ -130,7 +130,7 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta
|
||||
"pages": pages,
|
||||
"count": len(pages),
|
||||
}
|
||||
return officialAccountStepExecution{
|
||||
return weixinPublicAccountStepExecution{
|
||||
Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)),
|
||||
TaskStatus: taskStatusDraft,
|
||||
ArtifactTitle: "公众号分页 MD",
|
||||
@@ -145,31 +145,31 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta
|
||||
}
|
||||
}
|
||||
|
||||
func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []officialAccountImagePrompt {
|
||||
func buildWeixinPublicAccountImagePrompts(workflow *weixinPublicAccountWorkflowState) []weixinPublicAccountImagePrompt {
|
||||
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
||||
sections := extractOfficialAccountSections(workflow.Shared.Content)
|
||||
maxContentImages := normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
|
||||
prompts := []officialAccountImagePrompt{
|
||||
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
|
||||
maxContentImages := normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages)
|
||||
prompts := []weixinPublicAccountImagePrompt{
|
||||
{
|
||||
Key: "cover",
|
||||
Section: "封面配图",
|
||||
Prompt: buildOfficialAccountVisualPrompt(workflow, title, "公众号封面主视觉"),
|
||||
Prompt: buildWeixinPublicAccountVisualPrompt(workflow, title, "公众号封面主视觉"),
|
||||
},
|
||||
}
|
||||
for idx, item := range sections {
|
||||
if maxContentImages > 0 && idx >= maxContentImages {
|
||||
break
|
||||
}
|
||||
prompts = append(prompts, officialAccountImagePrompt{
|
||||
prompts = append(prompts, weixinPublicAccountImagePrompt{
|
||||
Key: fmt.Sprintf("section_%02d", idx+1),
|
||||
Section: item.Title,
|
||||
Prompt: buildOfficialAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")),
|
||||
Prompt: buildWeixinPublicAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")),
|
||||
})
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
func buildOfficialAccountContentWithPrompts(content string, prompts []officialAccountImagePrompt) string {
|
||||
func buildWeixinPublicAccountContentWithPrompts(content string, prompts []weixinPublicAccountImagePrompt) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
@@ -181,14 +181,14 @@ func buildOfficialAccountContentWithPrompts(content string, prompts []officialAc
|
||||
return strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func buildOfficialAccountVisualPrompt(workflow *officialAccountWorkflowState, sectionTitle, context string) string {
|
||||
func buildWeixinPublicAccountVisualPrompt(workflow *weixinPublicAccountWorkflowState, sectionTitle, context string) string {
|
||||
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
|
||||
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
|
||||
domain := officialAccountBusinessDomainLabel(workflow.Form.BusinessDomain)
|
||||
domain := weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain)
|
||||
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark", domain, firstNonEmpty(sectionTitle, keyword), firstNonEmpty(context, tone))
|
||||
}
|
||||
|
||||
func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string {
|
||||
func buildWeixinPublicAccountImageArtifact(refs []weixinPublicAccountImageRef) string {
|
||||
lines := []string{"# 图片结果", ""}
|
||||
for idx, item := range refs {
|
||||
lines = append(lines,
|
||||
@@ -202,7 +202,7 @@ func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflowState, prompts []officialAccountImagePrompt, user *model.User) (*config.RouteConfig, []officialAccountImageRef, *web.AppError) {
|
||||
func generateWeixinPublicAccountImages(taskID uint, workflow *weixinPublicAccountWorkflowState, prompts []weixinPublicAccountImagePrompt, user *model.User) (*config.RouteConfig, []weixinPublicAccountImageRef, *web.AppError) {
|
||||
aiRoute, err := config.GetRoute("image_gen")
|
||||
if err != nil {
|
||||
return nil, nil, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
|
||||
@@ -212,9 +212,9 @@ func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflo
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 240 * time.Second}
|
||||
refs := make([]officialAccountImageRef, 0, len(prompts))
|
||||
refs := make([]weixinPublicAccountImageRef, 0, len(prompts))
|
||||
for idx, item := range prompts {
|
||||
ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, item, idx, user)
|
||||
ref, appErr := generateWeixinPublicAccountImageRefWithRoute(client, aiRoute, taskID, item, idx, user)
|
||||
if appErr != nil {
|
||||
return nil, nil, appErr
|
||||
}
|
||||
@@ -223,27 +223,27 @@ func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflo
|
||||
return aiRoute, refs, nil
|
||||
}
|
||||
|
||||
func generateOfficialAccountImageRef(taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, officialAccountImageRef, *web.AppError) {
|
||||
func generateWeixinPublicAccountImageRef(taskID uint, prompt weixinPublicAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, weixinPublicAccountImageRef, *web.AppError) {
|
||||
aiRoute, err := config.GetRoute("image_gen")
|
||||
if err != nil {
|
||||
return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
|
||||
return nil, weixinPublicAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
|
||||
}
|
||||
if aiRoute.Category != "image" || strings.TrimSpace(aiRoute.FullURL) == "" || strings.TrimSpace(aiRoute.Model) == "" {
|
||||
return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整")
|
||||
return nil, weixinPublicAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整")
|
||||
}
|
||||
client := &http.Client{Timeout: 240 * time.Second}
|
||||
ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, prompt, index, user)
|
||||
ref, appErr := generateWeixinPublicAccountImageRefWithRoute(client, aiRoute, taskID, prompt, index, user)
|
||||
if appErr != nil {
|
||||
return nil, officialAccountImageRef{}, appErr
|
||||
return nil, weixinPublicAccountImageRef{}, appErr
|
||||
}
|
||||
return aiRoute, ref, nil
|
||||
}
|
||||
|
||||
func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *config.RouteConfig, taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (officialAccountImageRef, *web.AppError) {
|
||||
imageSize := officialAccountImageSlotSize(index)
|
||||
requestSize := officialAccountImageRequestSize(imageSize)
|
||||
func generateWeixinPublicAccountImageRefWithRoute(client *http.Client, aiRoute *config.RouteConfig, taskID uint, prompt weixinPublicAccountImagePrompt, index int, user *model.User) (weixinPublicAccountImageRef, *web.AppError) {
|
||||
imageSize := weixinPublicAccountImageSlotSize(index)
|
||||
requestSize := weixinPublicAccountImageRequestSize(imageSize)
|
||||
started := time.Now()
|
||||
imageURL, reqErr := requestOfficialAccountImage(client, aiRoute, prompt.Prompt, requestSize)
|
||||
imageURL, reqErr := requestWeixinPublicAccountImage(client, aiRoute, prompt.Prompt, requestSize)
|
||||
latencyMs := int(time.Since(started) / time.Millisecond)
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID,
|
||||
@@ -256,15 +256,15 @@ func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *conf
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
if reqErr != nil {
|
||||
return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error()))
|
||||
return weixinPublicAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error()))
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") {
|
||||
imageURL, reqErr = persistOfficialAccountDataURL(taskID, prompt.Key, imageURL)
|
||||
imageURL, reqErr = persistWeixinPublicAccountDataURL(taskID, prompt.Key, imageURL)
|
||||
if reqErr != nil {
|
||||
return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error()))
|
||||
return weixinPublicAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error()))
|
||||
}
|
||||
}
|
||||
return officialAccountImageRef{
|
||||
return weixinPublicAccountImageRef{
|
||||
Key: prompt.Key,
|
||||
Section: prompt.Section,
|
||||
Prompt: prompt.Prompt,
|
||||
@@ -276,23 +276,23 @@ func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *conf
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requestOfficialAccountImage(client *http.Client, aiRoute *config.RouteConfig, prompt, size string) (string, error) {
|
||||
func requestWeixinPublicAccountImage(client *http.Client, aiRoute *config.RouteConfig, prompt, size string) (string, error) {
|
||||
payload := map[string]any{
|
||||
"model": aiRoute.Model,
|
||||
"prompt": strings.TrimSpace(prompt),
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
imageURL, err := postOfficialAccountImageRequest(client, aiRoute, payload)
|
||||
imageURL, err := postWeixinPublicAccountImageRequest(client, aiRoute, payload)
|
||||
if err == nil {
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
payload["response_format"] = "b64_json"
|
||||
return postOfficialAccountImageRequest(client, aiRoute, payload)
|
||||
return postWeixinPublicAccountImageRequest(client, aiRoute, payload)
|
||||
}
|
||||
|
||||
func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteConfig, payload map[string]any) (string, error) {
|
||||
func postWeixinPublicAccountImageRequest(client *http.Client, aiRoute *config.RouteConfig, payload map[string]any) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -318,7 +318,7 @@ func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteC
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateOfficialAccountErrorBody(string(raw)))
|
||||
return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateWeixinPublicAccountErrorBody(string(raw)))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
@@ -343,14 +343,14 @@ func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteC
|
||||
return "", fmt.Errorf("图片接口未返回 url 或 b64_json")
|
||||
}
|
||||
|
||||
func officialAccountImageSlotSize(index int) string {
|
||||
func weixinPublicAccountImageSlotSize(index int) string {
|
||||
if index <= 0 {
|
||||
return "landscape_16_9"
|
||||
}
|
||||
return "landscape_4_3"
|
||||
}
|
||||
|
||||
func officialAccountImageRequestSize(size string) string {
|
||||
func weixinPublicAccountImageRequestSize(size string) string {
|
||||
switch strings.TrimSpace(size) {
|
||||
case "landscape_16_9":
|
||||
return "1536x864"
|
||||
@@ -367,7 +367,7 @@ func officialAccountImageRequestSize(size string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func truncateOfficialAccountErrorBody(raw string) string {
|
||||
func truncateWeixinPublicAccountErrorBody(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) <= 240 {
|
||||
return raw
|
||||
@@ -382,9 +382,9 @@ func errorMessage(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState) string {
|
||||
func buildWeixinPublicAccountPreviewMarkdown(workflow *weixinPublicAccountWorkflowState) string {
|
||||
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
||||
sections := extractOfficialAccountSections(workflow.Shared.Content)
|
||||
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
|
||||
refs := workflow.Shared.ImageRefs
|
||||
lines := []string{fmt.Sprintf("# %s", title), ""}
|
||||
if len(refs) > 0 {
|
||||
@@ -404,16 +404,16 @@ func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState)
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officialAccountPageChunk {
|
||||
func buildWeixinPublicAccountPages(workflow *weixinPublicAccountWorkflowState) []weixinPublicAccountPageChunk {
|
||||
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
||||
sections := extractOfficialAccountSections(workflow.Shared.Content)
|
||||
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
|
||||
refs := workflow.Shared.ImageRefs
|
||||
pages := make([]officialAccountPageChunk, 0, len(sections)+1)
|
||||
pages := make([]weixinPublicAccountPageChunk, 0, len(sections)+1)
|
||||
intro := []string{fmt.Sprintf("# %s", title)}
|
||||
if len(refs) > 0 {
|
||||
intro = append(intro, "", fmt.Sprintf("", refs[0].URL))
|
||||
}
|
||||
pages = append(pages, officialAccountPageChunk{
|
||||
pages = append(pages, weixinPublicAccountPageChunk{
|
||||
Index: 1,
|
||||
Title: title,
|
||||
Content: strings.Join(intro, "\n"),
|
||||
@@ -424,7 +424,7 @@ func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officia
|
||||
if idx+1 < len(refs) {
|
||||
lines = append(lines, "", fmt.Sprintf("", refs[idx+1].URL))
|
||||
}
|
||||
pages = append(pages, officialAccountPageChunk{
|
||||
pages = append(pages, weixinPublicAccountPageChunk{
|
||||
Index: idx + 2,
|
||||
Title: item.Title,
|
||||
Content: strings.TrimSpace(strings.Join(lines, "\n")),
|
||||
@@ -433,7 +433,7 @@ func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officia
|
||||
return pages
|
||||
}
|
||||
|
||||
func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string {
|
||||
func buildWeixinPublicAccountPageMarkdown(pages []weixinPublicAccountPageChunk) string {
|
||||
chunks := make([]string, 0, len(pages))
|
||||
for _, item := range pages {
|
||||
chunks = append(chunks, fmt.Sprintf("<!-- PAGE %d: %s -->\n%s", item.Index, item.Title, item.Content))
|
||||
@@ -441,10 +441,10 @@ func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string {
|
||||
return strings.Join(chunks, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
func extractOfficialAccountSections(content string) []officialAccountSection {
|
||||
func extractWeixinPublicAccountSections(content string) []weixinPublicAccountSection {
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
sections := make([]officialAccountSection, 0, 6)
|
||||
current := officialAccountSection{}
|
||||
sections := make([]weixinPublicAccountSection, 0, 6)
|
||||
current := weixinPublicAccountSection{}
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
@@ -454,7 +454,7 @@ func extractOfficialAccountSections(content string) []officialAccountSection {
|
||||
if current.Title != "" || len(current.Content) > 0 {
|
||||
sections = append(sections, current)
|
||||
}
|
||||
current = officialAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))}
|
||||
current = weixinPublicAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))}
|
||||
continue
|
||||
}
|
||||
if current.Title == "" && line == "" {
|
||||
@@ -486,7 +486,7 @@ func trimEmptyLines(lines []string) []string {
|
||||
return append([]string(nil), lines[start:end]...)
|
||||
}
|
||||
|
||||
func renderOfficialAccountHTML(markdown string) string {
|
||||
func renderWeixinPublicAccountHTML(markdown string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`<article class="oa-preview">`)
|
||||
listOpen := false
|
||||
@@ -505,7 +505,7 @@ func renderOfficialAccountHTML(markdown string) string {
|
||||
builder.WriteString(`</ul>`)
|
||||
listOpen = false
|
||||
}
|
||||
alt, src := parseOfficialAccountImageMarkdown(line)
|
||||
alt, src := parseWeixinPublicAccountImageMarkdown(line)
|
||||
builder.WriteString(fmt.Sprintf(`<figure class="oa-preview-figure"><img src="%s" alt="%s" /><figcaption>%s</figcaption></figure>`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt)))
|
||||
continue
|
||||
}
|
||||
@@ -554,7 +554,7 @@ func renderOfficialAccountHTML(markdown string) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func parseOfficialAccountImageMarkdown(line string) (string, string) {
|
||||
func parseWeixinPublicAccountImageMarkdown(line string) (string, string) {
|
||||
startAlt := strings.Index(line, "
|
||||
end := strings.LastIndex(line, ")")
|
||||
@@ -566,49 +566,59 @@ func parseOfficialAccountImageMarkdown(line string) (string, string) {
|
||||
return firstNonEmpty(strings.TrimSpace(alt), "配图"), strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
func ExportOfficialAccountDocument(c *gin.Context) {
|
||||
func ExportWeixinPublicAccountDocument(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
task, ok := loadAccessibleTask(c, user)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if task.SpecialistKey != officialAccountSpecialistKey {
|
||||
if task.SpecialistKey != weixinPublicAccountSpecialistKey {
|
||||
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
|
||||
return
|
||||
}
|
||||
|
||||
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
|
||||
article, err := ensureOfficialAccountArticle(task, workflow)
|
||||
workflow := parseWeixinPublicAccountWorkflow(task.ContextJSON)
|
||||
article, err := ensureWeixinPublicAccountArticle(task, workflow)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("读取公众号文章状态失败"))
|
||||
return
|
||||
}
|
||||
syncWorkflowFromOfficialAccountArticle(&workflow, article)
|
||||
syncWorkflowFromWeixinPublicAccountArticle(&workflow, article)
|
||||
|
||||
format := strings.TrimSpace(strings.ToLower(c.DefaultQuery("format", "preview_md")))
|
||||
filename := "official-account-export.md"
|
||||
filename := "weixin-public-account-export.md"
|
||||
contentType := "text/markdown; charset=utf-8"
|
||||
body := workflow.Shared.PreviewMarkdown
|
||||
|
||||
switch format {
|
||||
case "preview_md":
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = buildOfficialAccountPreviewMarkdown(&workflow)
|
||||
body = buildWeixinPublicAccountPreviewMarkdown(&workflow)
|
||||
}
|
||||
filename = "official-account-preview.md"
|
||||
filename = "weixin-public-account-preview.md"
|
||||
case "preview_html":
|
||||
contentType = "text/html; charset=utf-8"
|
||||
body = workflow.Shared.PreviewHTML
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = renderOfficialAccountHTML(buildOfficialAccountPreviewMarkdown(&workflow))
|
||||
body = renderWeixinPublicAccountHTML(buildWeixinPublicAccountPreviewMarkdown(&workflow))
|
||||
}
|
||||
filename = "official-account-preview.html"
|
||||
filename = "weixin-public-account-preview.html"
|
||||
case "pagemd":
|
||||
body = workflow.Shared.PageMarkdown
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = buildOfficialAccountPageMarkdown(buildOfficialAccountPages(&workflow))
|
||||
body = buildWeixinPublicAccountPageMarkdown(buildWeixinPublicAccountPages(&workflow))
|
||||
}
|
||||
filename = "official-account-pagemd.md"
|
||||
filename = "weixin-public-account-pagemd.md"
|
||||
case "docx":
|
||||
docxBytes, buildErr := buildWeixinPublicAccountDOCX(&workflow)
|
||||
if buildErr != nil {
|
||||
web.Fail(c, web.NewBadRequest("生成 Word 文档失败"))
|
||||
return
|
||||
}
|
||||
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''weixin-public-account-preview.docx"))
|
||||
c.Data(200, contentType, docxBytes)
|
||||
return
|
||||
default:
|
||||
web.Fail(c, web.NewBadRequest("导出格式不支持"))
|
||||
return
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// weixinPublicAccountDocImage 保存一张待写进 docx 的图片。
|
||||
type weixinPublicAccountDocImage struct {
|
||||
ID int
|
||||
Ext string
|
||||
Content []byte
|
||||
WidthEMU int
|
||||
HeightEMU int
|
||||
}
|
||||
|
||||
// buildWeixinPublicAccountDOCX 把「预览稿」导出为排版良好的 Word 文档:
|
||||
// 题目为 Title 样式,各级小节为 Heading1,正文按段落排,封面图与各小节配图
|
||||
// 分别嵌入到合适位置并居中。图片优先读本地落盘文件,其次解析 data: URL,
|
||||
// 对外部 HTTP 地址会尝试下载;三种来源任一拿不到字节就跳过内嵌,不影响整份文档。
|
||||
func buildWeixinPublicAccountDOCX(workflow *weixinPublicAccountWorkflowState) ([]byte, error) {
|
||||
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
||||
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
|
||||
refs := workflow.Shared.ImageRefs
|
||||
|
||||
images := make([]weixinPublicAccountDocImage, 0, len(refs))
|
||||
for idx := 0; idx < len(refs); idx++ {
|
||||
data, ext, ok := readWeixinPublicAccountDocImage(refs[idx].URL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w, h, wErr := imageDecodeWeixinPublicAccountImage(data)
|
||||
if wErr != nil {
|
||||
w, h = 0, 0
|
||||
}
|
||||
images = append(images, weixinPublicAccountDocImage{
|
||||
ID: idx + 1,
|
||||
Ext: ext,
|
||||
Content: data,
|
||||
WidthEMU: w,
|
||||
HeightEMU: h,
|
||||
})
|
||||
}
|
||||
coverID := -1
|
||||
for i := range images {
|
||||
if images[i].ID == 1 {
|
||||
coverID = images[i].ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// 包级内容类型与根关系。
|
||||
zipWriteDocxFile(zw, "[Content_Types].xml", buildWeixinOAContentTypes(images))
|
||||
zipWriteDocxFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
|
||||
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>`+
|
||||
`</Relationships>`)
|
||||
|
||||
// 关系列表:rId1 = styles,rId2.. = 图片。
|
||||
var rels strings.Builder
|
||||
rels.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`)
|
||||
imageRel := make(map[int]string)
|
||||
for _, img := range images {
|
||||
rid := fmt.Sprintf("rId%d", img.ID+1)
|
||||
imageRel[img.ID] = rid
|
||||
rels.WriteString(fmt.Sprintf(`<Relationship Id="%s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/weixin_oa_image_%d.%s"/>`, rid, img.ID, img.Ext))
|
||||
}
|
||||
rels.WriteString(`</Relationships>`)
|
||||
zipWriteDocxFile(zw, "word/_rels/document.xml.rels", rels.String())
|
||||
|
||||
// 正文。
|
||||
var doc strings.Builder
|
||||
doc.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ` +
|
||||
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ` +
|
||||
`xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ` +
|
||||
`xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` +
|
||||
`xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>`)
|
||||
|
||||
// 标题。
|
||||
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Title"/><w:jc w:val="center"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:b/><w:sz w:val="44"/></w:rPr><w:t>` + escapeWeixinOADocXML(title) + `</w:t></w:r></w:p>`)
|
||||
|
||||
// 封面图。
|
||||
if coverID > 0 {
|
||||
doc.WriteString(buildWeixinOADocParagraphImage(coverID, images, imageRel, 16))
|
||||
}
|
||||
|
||||
// 各小节 + 段内配图。
|
||||
for idx, sec := range sections {
|
||||
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>` + escapeWeixinOADocXML(sec.Title) + `</w:t></w:r></w:p>`)
|
||||
for _, line := range sec.Content {
|
||||
text := strings.TrimSpace(line)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr>` +
|
||||
`<w:t xml:space="preserve">` + escapeWeixinOADocXML(text) + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
// 小节配图:封面图是第 1 张、已放开头,这里按顺序放第 2..N 张。
|
||||
needID := idx + 2
|
||||
if _, ok := findWeixinOADocImageByID(images, needID); ok {
|
||||
doc.WriteString(buildWeixinOADocParagraphImage(needID, images, imageRel, 12))
|
||||
}
|
||||
}
|
||||
|
||||
doc.WriteString(`</w:body></w:document>`)
|
||||
zipWriteDocxFile(zw, "word/document.xml", doc.String())
|
||||
|
||||
// styles.xml。
|
||||
zipWriteDocxFile(zw, "word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
||||
`<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults>`+
|
||||
`<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:b/><w:sz w:val="44"/></w:rPr></w:style>`+
|
||||
`<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="Heading 1"/><w:basedOn w:val="Normal"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:b/><w:sz w:val="28"/></w:rPr></w:style>`+
|
||||
`</w:styles>`)
|
||||
|
||||
// 图片媒体文件。
|
||||
for _, img := range images {
|
||||
zipWriteDocxBinary(zw, fmt.Sprintf("word/media/weixin_oa_image_%d.%s", img.ID, img.Ext), img.Content)
|
||||
}
|
||||
|
||||
zipWriteDocxFile(zw, "docProps/core.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>`+escapeWeixinOADocXML(title)+`</dc:title>`+
|
||||
`<dcterms:created xmlns:dcterms="http://purl.org/dc/terms/" xsi:type="dcterms:W3CDTF" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`+time.Now().UTC().Format("2006-01-02T15:04:05Z")+`</dcterms:created></cp:coreProperties>`)
|
||||
zipWriteDocxFile(zw, "docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>EAI Agent Platform</Application></Properties>`)
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildWeixinOAContentTypes 生成包级内容类型;图片需要按扩展名声明 Default。
|
||||
func buildWeixinOAContentTypes(images []weixinPublicAccountDocImage) string {
|
||||
defaults := map[string]string{
|
||||
"xml": "application/xml",
|
||||
"rels": "application/vnd.openxmlformats-package.relationships+xml",
|
||||
}
|
||||
imageTypes := map[string]string{
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"bmp": "image/bmp",
|
||||
}
|
||||
for _, img := range images {
|
||||
if ct, ok := imageTypes[strings.ToLower(img.Ext)]; ok {
|
||||
defaults[strings.ToLower(img.Ext)] = ct
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`)
|
||||
for ext, ct := range defaults {
|
||||
b.WriteString(`<Default Extension="` + ext + `" ContentType="` + ct + `"/>`)
|
||||
}
|
||||
b.WriteString(`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
|
||||
b.WriteString(`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`)
|
||||
b.WriteString(`</Types>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildWeixinOADocParagraphImage 构造一个居中插入图片的段落。
|
||||
// widthCm 用于给出期望显示宽度,高按原图比例自适应。
|
||||
func buildWeixinOADocParagraphImage(id int, images []weixinPublicAccountDocImage, rel map[int]string, widthCm int) string {
|
||||
img, ok := findWeixinOADocImageByID(images, id)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rid := rel[id]
|
||||
if rid == "" {
|
||||
return ""
|
||||
}
|
||||
const emuPerCm = 360000
|
||||
cx := widthCm * emuPerCm
|
||||
cy := cx
|
||||
if img.WidthEMU > 0 && img.HeightEMU > 0 {
|
||||
cy = cx * img.HeightEMU / img.WidthEMU
|
||||
}
|
||||
// 高度限制,避免超界。
|
||||
if cy <= 0 || cy > 12*emuPerCm {
|
||||
cy = cx * 9 / 16
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<w:p><w:pPr><w:jc w:val="center"/><w:spacing w:before="120" w:after="120"/></w:pPr>`+
|
||||
`<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">`+
|
||||
`<wp:extent cx="%d" cy="%d"/>`+
|
||||
`<wp:docPr id="%d" name="Picture %d"/>`+
|
||||
`<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">`+
|
||||
`<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">`+
|
||||
`<pic:nvPicPr><pic:cNvPr id="%d" name="Picture %d"/><pic:cNvPicPr/></pic:nvPicPr>`+
|
||||
`<pic:blipFill><a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>`+
|
||||
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="%d" cy="%d"/></a:xfrm>`+
|
||||
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>`+
|
||||
`</pic:pic></a:graphicData></a:graphic>`+
|
||||
`</wp:inline></w:drawing></w:r></w:p>`,
|
||||
cx, cy, id, id, id, id, rid, cx, cy)
|
||||
}
|
||||
|
||||
// readWeixinPublicAccountDocImage 从三种来源读取图片字节:
|
||||
// 本地落盘文件路径、data: URL、外部 HTTP 地址。返回字节、扩展名、是否成功。
|
||||
func readWeixinPublicAccountDocImage(ref string) ([]byte, string, bool) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
if strings.HasPrefix(ref, "data:image/") {
|
||||
header := strings.SplitN(ref, ",", 2)
|
||||
if len(header) != 2 {
|
||||
return nil, "", false
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(header[1])
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return payload, guessWeixinOADocExt(header[0]), true
|
||||
}
|
||||
// 本地文件:URL 形如 /api/weixin-public-account/generated-images/<filename>
|
||||
if strings.HasPrefix(ref, "/api/weixin-public-account/generated-images/") {
|
||||
name := filepath.Base(ref)
|
||||
full := filepath.Join(weixinPublicAccountGeneratedImageDir(), name)
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return data, guessWeixinOADocExtFromName(name), true
|
||||
}
|
||||
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Get(ref)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", false
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
return data, guessWeixinOADocExt(ctype), true
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// imageDecodeWeixinPublicAccountImage 读取图片宽高(EMU 无关,返回像素宽高)。
|
||||
func imageDecodeWeixinPublicAccountImage(data []byte) (w, h int, err error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func findWeixinOADocImageByID(images []weixinPublicAccountDocImage, id int) (weixinPublicAccountDocImage, bool) {
|
||||
for _, img := range images {
|
||||
if img.ID == id {
|
||||
return img, true
|
||||
}
|
||||
}
|
||||
return weixinPublicAccountDocImage{}, false
|
||||
}
|
||||
|
||||
func guessWeixinOADocExt(header string) string {
|
||||
header = strings.ToLower(header)
|
||||
switch {
|
||||
case strings.Contains(header, "jpeg") || strings.Contains(header, "jpg"):
|
||||
return "jpg"
|
||||
case strings.Contains(header, "gif"):
|
||||
return "gif"
|
||||
case strings.Contains(header, "webp"):
|
||||
return "webp"
|
||||
case strings.Contains(header, "bmp"):
|
||||
return "bmp"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
func guessWeixinOADocExtFromName(name string) string {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
if ext == "" {
|
||||
return "png"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func escapeWeixinOADocXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
func zipWriteDocxFile(zw *zip.Writer, name, content string) error {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write([]byte(content))
|
||||
return err
|
||||
}
|
||||
|
||||
func zipWriteDocxBinary(zw *zip.Writer, name string, content []byte) error {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(content)
|
||||
return err
|
||||
}
|
||||
+78
-68
@@ -1,25 +1,25 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
officialAccountBusinessDomainAuto = "auto"
|
||||
officialAccountBusinessDomainAI = "ai"
|
||||
officialAccountBusinessDomainMedical = "medical"
|
||||
officialAccountBusinessDomainInsurance = "insurance"
|
||||
officialAccountBusinessDomainHR = "hr"
|
||||
officialAccountBusinessDomainLegal = "legal"
|
||||
officialAccountBusinessDomainGeneric = "generic"
|
||||
weixinPublicAccountBusinessDomainAuto = "auto"
|
||||
weixinPublicAccountBusinessDomainAI = "ai"
|
||||
weixinPublicAccountBusinessDomainMedical = "medical"
|
||||
weixinPublicAccountBusinessDomainInsurance = "insurance"
|
||||
weixinPublicAccountBusinessDomainHR = "hr"
|
||||
weixinPublicAccountBusinessDomainLegal = "legal"
|
||||
weixinPublicAccountBusinessDomainGeneric = "generic"
|
||||
)
|
||||
|
||||
type officialAccountHotspotSource struct {
|
||||
type weixinPublicAccountHotspotSource struct {
|
||||
Key string
|
||||
Label string
|
||||
Type string
|
||||
URL string
|
||||
}
|
||||
|
||||
type officialAccountBusinessDomainConfig struct {
|
||||
type weixinPublicAccountBusinessDomainConfig struct {
|
||||
Key string
|
||||
Label string
|
||||
PromptLabel string
|
||||
@@ -28,18 +28,18 @@ type officialAccountBusinessDomainConfig struct {
|
||||
BusinessKeywords []string
|
||||
BoostKeywords []string
|
||||
FallbackSearchHints []string
|
||||
Sources []officialAccountHotspotSource
|
||||
Sources []weixinPublicAccountHotspotSource
|
||||
}
|
||||
|
||||
var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDomainConfig{
|
||||
officialAccountBusinessDomainAI: {
|
||||
Key: officialAccountBusinessDomainAI,
|
||||
var weixinPublicAccountBusinessDomainConfigs = map[string]weixinPublicAccountBusinessDomainConfig{
|
||||
weixinPublicAccountBusinessDomainAI: {
|
||||
Key: weixinPublicAccountBusinessDomainAI,
|
||||
Label: "AI / 智能体",
|
||||
PromptLabel: "AI 业务域",
|
||||
DefaultKeyword: "AI 落地",
|
||||
DomainKeywords: []string{
|
||||
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "工作流", "知识库",
|
||||
"rag", "copilot", "自动化", "机器人", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
|
||||
"rag", "copilot", "自动化", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"企业", "落地", "场景", "产品", "平台", "工作台", "办公", "培训", "实施", "知识库", "销售", "客户",
|
||||
@@ -47,37 +47,38 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
BoostKeywords: []string{"工作流", "智能体", "企业ai"},
|
||||
FallbackSearchHints: []string{"AI", "大模型", "智能体", "企业AI"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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,
|
||||
weixinPublicAccountBusinessDomainMedical: {
|
||||
Key: weixinPublicAccountBusinessDomainMedical,
|
||||
Label: "医疗 / 健康",
|
||||
PromptLabel: "医疗健康业务域",
|
||||
DefaultKeyword: "医疗健康",
|
||||
DomainKeywords: []string{
|
||||
"医疗", "医保", "医院", "医生", "患者", "医药", "药企", "药品", "诊疗", "健康", "护理", "手术", "疾病", "控费",
|
||||
"集采", "互联网医疗", "医疗器械", "生物医药", "健康险", "临床", "门诊", "住院", "卫健", "医保局", "药监", "药审",
|
||||
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
|
||||
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
|
||||
"外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫",
|
||||
},
|
||||
BusinessKeywords: []string{
|
||||
"政策", "支付", "落地", "案例", "服务", "行业", "机构", "改革", "商业模式", "运营", "平台", "场景", "解决方案",
|
||||
"通知", "公告", "指南", "征求意见", "审评", "审批", "基金", "目录", "招采", "挂网", "集采",
|
||||
},
|
||||
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗"},
|
||||
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗", "外骨骼", "康复机器人", "康复训练"},
|
||||
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械", "外骨骼", "康复机器人"},
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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,
|
||||
weixinPublicAccountBusinessDomainInsurance: {
|
||||
Key: weixinPublicAccountBusinessDomainInsurance,
|
||||
Label: "保险 / 金融",
|
||||
PromptLabel: "保险金融业务域",
|
||||
DefaultKeyword: "保险行业",
|
||||
@@ -90,14 +91,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
BoostKeywords: []string{"理赔", "偿付能力", "代理人", "保险公司"},
|
||||
FallbackSearchHints: []string{"保险", "理赔", "代理人", "养老保险"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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,
|
||||
weixinPublicAccountBusinessDomainHR: {
|
||||
Key: weixinPublicAccountBusinessDomainHR,
|
||||
Label: "HR / 招聘",
|
||||
PromptLabel: "人力资源业务域",
|
||||
DefaultKeyword: "人力资源",
|
||||
@@ -110,14 +111,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
BoostKeywords: []string{"招聘", "社保", "薪酬", "劳动"},
|
||||
FallbackSearchHints: []string{"招聘", "人力资源", "薪酬", "社保"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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,
|
||||
weixinPublicAccountBusinessDomainLegal: {
|
||||
Key: weixinPublicAccountBusinessDomainLegal,
|
||||
Label: "法务 / 合规",
|
||||
PromptLabel: "法务合规业务域",
|
||||
DefaultKeyword: "法务合规",
|
||||
@@ -130,14 +131,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
BoostKeywords: []string{"合同", "合规", "数据", "劳动法"},
|
||||
FallbackSearchHints: []string{"合同", "合规", "劳动法", "数据合规"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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,
|
||||
weixinPublicAccountBusinessDomainGeneric: {
|
||||
Key: weixinPublicAccountBusinessDomainGeneric,
|
||||
Label: "通用业务域",
|
||||
PromptLabel: "通用业务域",
|
||||
DefaultKeyword: "业务热点",
|
||||
@@ -149,7 +150,7 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
BoostKeywords: []string{"行业", "企业", "趋势"},
|
||||
FallbackSearchHints: []string{"行业", "企业", "政策", "趋势"},
|
||||
Sources: []officialAccountHotspotSource{
|
||||
Sources: []weixinPublicAccountHotspotSource{
|
||||
{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/"},
|
||||
@@ -157,47 +158,56 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
var weixinPublicAccountBusinessDomainDetectionHints = map[string][]string{
|
||||
weixinPublicAccountBusinessDomainAI: {
|
||||
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "rag", "copilot",
|
||||
},
|
||||
weixinPublicAccountBusinessDomainMedical: {
|
||||
"医疗", "健康", "医院", "医保", "医疗器械", "临床", "外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫",
|
||||
},
|
||||
weixinPublicAccountBusinessDomainInsurance: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainInsurance].DomainKeywords,
|
||||
weixinPublicAccountBusinessDomainHR: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainHR].DomainKeywords,
|
||||
weixinPublicAccountBusinessDomainLegal: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainLegal].DomainKeywords,
|
||||
}
|
||||
|
||||
func normalizeOfficialAccountBusinessDomain(value string) string {
|
||||
func normalizeWeixinPublicAccountBusinessDomain(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "", officialAccountBusinessDomainAuto, "自动识别", "自动":
|
||||
return officialAccountBusinessDomainAuto
|
||||
case "", weixinPublicAccountBusinessDomainAuto, "自动识别", "自动":
|
||||
return weixinPublicAccountBusinessDomainAuto
|
||||
case "ai", "aigc", "智能体", "人工智能":
|
||||
return officialAccountBusinessDomainAI
|
||||
return weixinPublicAccountBusinessDomainAI
|
||||
case "medical", "health", "医疗", "健康":
|
||||
return officialAccountBusinessDomainMedical
|
||||
return weixinPublicAccountBusinessDomainMedical
|
||||
case "insurance", "finance", "保险", "金融":
|
||||
return officialAccountBusinessDomainInsurance
|
||||
return weixinPublicAccountBusinessDomainInsurance
|
||||
case "hr", "recruiting", "招聘", "人力", "人力资源":
|
||||
return officialAccountBusinessDomainHR
|
||||
return weixinPublicAccountBusinessDomainHR
|
||||
case "legal", "compliance", "法务", "合规", "法律":
|
||||
return officialAccountBusinessDomainLegal
|
||||
return weixinPublicAccountBusinessDomainLegal
|
||||
case "generic", "other", "其他", "通用":
|
||||
return officialAccountBusinessDomainGeneric
|
||||
return weixinPublicAccountBusinessDomainGeneric
|
||||
default:
|
||||
if _, ok := officialAccountBusinessDomainConfigs[value]; ok {
|
||||
if _, ok := weixinPublicAccountBusinessDomainConfigs[value]; ok {
|
||||
return value
|
||||
}
|
||||
return officialAccountBusinessDomainGeneric
|
||||
return weixinPublicAccountBusinessDomainGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func detectOfficialAccountBusinessDomain(keyword string) string {
|
||||
func detectWeixinPublicAccountBusinessDomain(keyword string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(keyword))
|
||||
if text == "" {
|
||||
return officialAccountBusinessDomainAI
|
||||
return weixinPublicAccountBusinessDomainAI
|
||||
}
|
||||
bestKey := officialAccountBusinessDomainAI
|
||||
for _, anchor := range []string{"外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫"} {
|
||||
if strings.Contains(text, strings.ToLower(anchor)) {
|
||||
return weixinPublicAccountBusinessDomainMedical
|
||||
}
|
||||
}
|
||||
bestKey := weixinPublicAccountBusinessDomainAI
|
||||
bestScore := 0
|
||||
for key, hints := range officialAccountBusinessDomainDetectionHints {
|
||||
for key, hints := range weixinPublicAccountBusinessDomainDetectionHints {
|
||||
score := 0
|
||||
for _, hint := range hints {
|
||||
if strings.Contains(text, strings.ToLower(hint)) {
|
||||
@@ -210,30 +220,30 @@ func detectOfficialAccountBusinessDomain(keyword string) string {
|
||||
}
|
||||
}
|
||||
if bestScore == 0 {
|
||||
return officialAccountBusinessDomainGeneric
|
||||
return weixinPublicAccountBusinessDomainGeneric
|
||||
}
|
||||
return bestKey
|
||||
}
|
||||
|
||||
func resolveOfficialAccountBusinessDomain(domain, keyword string) string {
|
||||
normalized := normalizeOfficialAccountBusinessDomain(domain)
|
||||
if normalized == officialAccountBusinessDomainAuto {
|
||||
return detectOfficialAccountBusinessDomain(keyword)
|
||||
func resolveWeixinPublicAccountBusinessDomain(domain, keyword string) string {
|
||||
normalized := normalizeWeixinPublicAccountBusinessDomain(domain)
|
||||
if normalized == weixinPublicAccountBusinessDomainAuto {
|
||||
return detectWeixinPublicAccountBusinessDomain(keyword)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func getOfficialAccountBusinessDomainConfig(domain string) officialAccountBusinessDomainConfig {
|
||||
key := normalizeOfficialAccountBusinessDomain(domain)
|
||||
if key == officialAccountBusinessDomainAuto {
|
||||
key = officialAccountBusinessDomainAI
|
||||
func getWeixinPublicAccountBusinessDomainConfig(domain string) weixinPublicAccountBusinessDomainConfig {
|
||||
key := normalizeWeixinPublicAccountBusinessDomain(domain)
|
||||
if key == weixinPublicAccountBusinessDomainAuto {
|
||||
key = weixinPublicAccountBusinessDomainAI
|
||||
}
|
||||
if cfg, ok := officialAccountBusinessDomainConfigs[key]; ok {
|
||||
if cfg, ok := weixinPublicAccountBusinessDomainConfigs[key]; ok {
|
||||
return cfg
|
||||
}
|
||||
return officialAccountBusinessDomainConfigs[officialAccountBusinessDomainGeneric]
|
||||
return weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainGeneric]
|
||||
}
|
||||
|
||||
func officialAccountBusinessDomainLabel(domain string) string {
|
||||
return getOfficialAccountBusinessDomainConfig(domain).Label
|
||||
func weixinPublicAccountBusinessDomainLabel(domain string) string {
|
||||
return getWeixinPublicAccountBusinessDomainConfig(domain).Label
|
||||
}
|
||||
+25
-25
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
@@ -13,20 +13,20 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
func officialAccountGeneratedImageDir() string {
|
||||
func weixinPublicAccountGeneratedImageDir() string {
|
||||
cfg := config.Load()
|
||||
return filepath.Join(filepath.Dir(cfg.DBPath), "official_account_generated_images")
|
||||
return filepath.Join(filepath.Dir(cfg.DBPath), "weixin_public_account_generated_images")
|
||||
}
|
||||
|
||||
func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) error {
|
||||
func normalizeWeixinPublicAccountMediaForResponse(task *model.TaskRecord, workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle) error {
|
||||
if workflow == nil || article == nil || task == nil {
|
||||
return nil
|
||||
}
|
||||
refs, replacements, changed, err := persistOfficialAccountImageRefs(task.ID, workflow.Shared.ImageRefs)
|
||||
refs, replacements, changed, err := persistWeixinPublicAccountImageRefs(task.ID, workflow.Shared.ImageRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,14 +35,14 @@ func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *
|
||||
}
|
||||
|
||||
workflow.Shared.ImageRefs = refs
|
||||
workflow.Shared.PreviewMarkdown = replaceOfficialAccountURLs(workflow.Shared.PreviewMarkdown, replacements)
|
||||
workflow.Shared.PreviewHTML = replaceOfficialAccountURLs(workflow.Shared.PreviewHTML, replacements)
|
||||
workflow.Shared.PageMarkdown = replaceOfficialAccountURLs(workflow.Shared.PageMarkdown, replacements)
|
||||
workflow.Shared.PreviewMarkdown = replaceWeixinPublicAccountURLs(workflow.Shared.PreviewMarkdown, replacements)
|
||||
workflow.Shared.PreviewHTML = replaceWeixinPublicAccountURLs(workflow.Shared.PreviewHTML, replacements)
|
||||
workflow.Shared.PageMarkdown = replaceWeixinPublicAccountURLs(workflow.Shared.PageMarkdown, replacements)
|
||||
for idx := range workflow.Shared.Pages {
|
||||
workflow.Shared.Pages[idx].Content = replaceOfficialAccountURLs(workflow.Shared.Pages[idx].Content, replacements)
|
||||
workflow.Shared.Pages[idx].Content = replaceWeixinPublicAccountURLs(workflow.Shared.Pages[idx].Content, replacements)
|
||||
}
|
||||
syncOfficialAccountArticleFromWorkflow(article, *workflow)
|
||||
task.ContextJSON = marshalOfficialAccountWorkflow(*workflow)
|
||||
syncWeixinPublicAccountArticleFromWorkflow(article, *workflow)
|
||||
task.ContextJSON = marshalWeixinPublicAccountWorkflow(*workflow)
|
||||
|
||||
if !oaArticleDAO.Update(article) {
|
||||
return errors.New("保存公众号文章状态失败")
|
||||
@@ -53,11 +53,11 @@ func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef) ([]officialAccountImageRef, map[string]string, bool, error) {
|
||||
func persistWeixinPublicAccountImageRefs(taskID uint, refs []weixinPublicAccountImageRef) ([]weixinPublicAccountImageRef, map[string]string, bool, error) {
|
||||
if len(refs) == 0 {
|
||||
return refs, map[string]string{}, false, nil
|
||||
}
|
||||
next := make([]officialAccountImageRef, len(refs))
|
||||
next := make([]weixinPublicAccountImageRef, len(refs))
|
||||
replacements := map[string]string{}
|
||||
changed := false
|
||||
for idx, item := range refs {
|
||||
@@ -65,7 +65,7 @@ func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef
|
||||
if !strings.HasPrefix(strings.TrimSpace(item.URL), "data:image/") {
|
||||
continue
|
||||
}
|
||||
localURL, err := persistOfficialAccountDataURL(taskID, item.Key, item.URL)
|
||||
localURL, err := persistWeixinPublicAccountDataURL(taskID, item.Key, item.URL)
|
||||
if err != nil {
|
||||
return refs, nil, false, err
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef
|
||||
return next, replacements, changed, nil
|
||||
}
|
||||
|
||||
func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error) {
|
||||
func persistWeixinPublicAccountDataURL(taskID uint, key, raw string) (string, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(raw), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("图片数据格式错误")
|
||||
@@ -99,22 +99,22 @@ func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := officialAccountGeneratedImageDir()
|
||||
dir := weixinPublicAccountGeneratedImageDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha1.Sum(payload)
|
||||
filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeOfficialAccountImageKey(key), hash[:6], ext)
|
||||
filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeWeixinPublicAccountImageKey(key), hash[:6], ext)
|
||||
fullPath := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
if writeErr := os.WriteFile(fullPath, payload, 0o644); writeErr != nil {
|
||||
return "", writeErr
|
||||
}
|
||||
}
|
||||
return "/api/official-account/generated-images/" + filename, nil
|
||||
return "/api/weixin-public-account/generated-images/" + filename, nil
|
||||
}
|
||||
|
||||
func sanitizeOfficialAccountImageKey(key string) string {
|
||||
func sanitizeWeixinPublicAccountImageKey(key string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(key))
|
||||
trimmed = strings.ReplaceAll(trimmed, " ", "_")
|
||||
trimmed = strings.ReplaceAll(trimmed, "/", "_")
|
||||
@@ -125,7 +125,7 @@ func sanitizeOfficialAccountImageKey(key string) string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func replaceOfficialAccountURLs(content string, replacements map[string]string) string {
|
||||
func replaceWeixinPublicAccountURLs(content string, replacements map[string]string) string {
|
||||
next := content
|
||||
for oldURL, newURL := range replacements {
|
||||
next = strings.ReplaceAll(next, oldURL, newURL)
|
||||
@@ -133,13 +133,13 @@ func replaceOfficialAccountURLs(content string, replacements map[string]string)
|
||||
return next
|
||||
}
|
||||
|
||||
func ServeOfficialAccountGeneratedImage(c *gin.Context) {
|
||||
func ServeWeixinPublicAccountGeneratedImage(c *gin.Context) {
|
||||
filename := filepath.Base(strings.TrimSpace(c.Param("filename")))
|
||||
if filename == "." || filename == "" {
|
||||
web.Fail(c, web.NewNotFoundError("图片不存在"))
|
||||
return
|
||||
}
|
||||
fullPath := filepath.Join(officialAccountGeneratedImageDir(), filename)
|
||||
fullPath := filepath.Join(weixinPublicAccountGeneratedImageDir(), filename)
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("图片不存在"))
|
||||
return
|
||||
@@ -147,7 +147,7 @@ func ServeOfficialAccountGeneratedImage(c *gin.Context) {
|
||||
c.File(fullPath)
|
||||
}
|
||||
|
||||
func compactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
|
||||
func compactWeixinPublicAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
|
||||
next := make([]model.TaskRun, len(items))
|
||||
for idx, item := range items {
|
||||
next[idx] = item
|
||||
@@ -161,7 +161,7 @@ func compactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRu
|
||||
return next
|
||||
}
|
||||
|
||||
func compactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
|
||||
func compactWeixinPublicAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
|
||||
next := make([]model.TaskArtifact, len(items))
|
||||
for idx, item := range items {
|
||||
next[idx] = item
|
||||
+86
-81
@@ -1,4 +1,4 @@
|
||||
package wechatofficialaccountapi
|
||||
package weixinpublicaccountapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type officialAccountFetchedHotspot struct {
|
||||
type weixinPublicAccountFetchedHotspot struct {
|
||||
Title string
|
||||
URL string
|
||||
Summary string
|
||||
@@ -29,12 +29,12 @@ type officialAccountFetchedHotspot struct {
|
||||
}
|
||||
|
||||
var (
|
||||
officialAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
|
||||
officialAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
officialAccountSpaceRE = regexp.MustCompile(`\s+`)
|
||||
weixinPublicAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
|
||||
weixinPublicAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
weixinPublicAccountSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
type officialAccountRSS struct {
|
||||
type weixinPublicAccountRSS struct {
|
||||
Channel struct {
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
@@ -56,12 +56,12 @@ type officialAccountRSS struct {
|
||||
} `xml:"entry"`
|
||||
}
|
||||
|
||||
func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]officialaccountmodel.OfficialAccountHotspot, []string, error) {
|
||||
domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword)
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
func ensureWeixinPublicAccountHotspots(form weixinPublicAccountForm, force bool) ([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, []string, error) {
|
||||
domainKey := resolveWeixinPublicAccountBusinessDomain(form.BusinessDomain, form.Keyword)
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
|
||||
logs := []string{}
|
||||
if !force {
|
||||
cached, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
|
||||
cached, err := loadFreshWeixinPublicAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
|
||||
if err == nil && len(cached) >= 8 {
|
||||
logs = append(logs, "命中热点缓存,直接复用近 90 分钟"+cfg.Label+"热点。")
|
||||
return cached, logs, nil
|
||||
@@ -70,40 +70,40 @@ func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]offi
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
totalUpserts := 0
|
||||
sourceKeywords := buildOfficialAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
|
||||
sourceKeywords := buildWeixinPublicAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
|
||||
for _, source := range cfg.Sources {
|
||||
items, err := fetchOfficialAccountHotspotsFromSource(client, source, sourceKeywords)
|
||||
items, err := fetchWeixinPublicAccountHotspotsFromSource(client, source, sourceKeywords)
|
||||
if err != nil {
|
||||
logs = append(logs, source.Label+" 拉取失败:"+err.Error())
|
||||
continue
|
||||
}
|
||||
upserts, sourceLogs := upsertOfficialAccountHotspots(domainKey, form.Keyword, items)
|
||||
upserts, sourceLogs := upsertWeixinPublicAccountHotspots(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 {
|
||||
latest, err := loadFreshWeixinPublicAccountHotspots(domainKey, form.Keyword, 48*time.Hour)
|
||||
if err == nil && len(latest) == 0 && domainKey != weixinPublicAccountBusinessDomainGeneric {
|
||||
logs = append(logs, "当前业务域命中热点较少,已自动降级到通用热点检索。")
|
||||
genericCfg := getOfficialAccountBusinessDomainConfig(officialAccountBusinessDomainGeneric)
|
||||
genericKeywords := buildOfficialAccountSourceKeywords(
|
||||
officialAccountBusinessDomainGeneric,
|
||||
genericCfg := getWeixinPublicAccountBusinessDomainConfig(weixinPublicAccountBusinessDomainGeneric)
|
||||
genericKeywords := buildWeixinPublicAccountSourceKeywords(
|
||||
weixinPublicAccountBusinessDomainGeneric,
|
||||
form.Keyword,
|
||||
append(cfg.FallbackSearchHints, genericCfg.FallbackSearchHints...),
|
||||
)
|
||||
for _, source := range genericCfg.Sources {
|
||||
items, fetchErr := fetchOfficialAccountHotspotsFromSource(client, source, genericKeywords)
|
||||
items, fetchErr := fetchWeixinPublicAccountHotspotsFromSource(client, source, genericKeywords)
|
||||
if fetchErr != nil {
|
||||
logs = append(logs, source.Label+" 泛检索失败:"+fetchErr.Error())
|
||||
continue
|
||||
}
|
||||
upserts, sourceLogs := upsertOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, items)
|
||||
upserts, sourceLogs := upsertWeixinPublicAccountHotspots(weixinPublicAccountBusinessDomainGeneric, form.Keyword, items)
|
||||
totalUpserts += upserts
|
||||
logs = append(logs, sourceLogs...)
|
||||
}
|
||||
latest, err = loadFreshOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
|
||||
latest, err = loadFreshWeixinPublicAccountHotspots(weixinPublicAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, logs, err
|
||||
@@ -111,97 +111,100 @@ func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]offi
|
||||
return latest, logs, nil
|
||||
}
|
||||
|
||||
func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]officialaccountmodel.OfficialAccountHotspot, error) {
|
||||
func loadFreshWeixinPublicAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, error) {
|
||||
// keyword 不在 SQL 里过滤,是取回来在内存里再滤一遍(见下)——
|
||||
// 库里那 40 条是「该业务域里分数最高的」,关键词只用来在其中挑相关的。
|
||||
items := oaHotspotDAO.ListFresh(domainKey, time.Now().Add(-maxAge))
|
||||
filtered := filterOfficialAccountHotspotsByKeyword(items, keyword)
|
||||
filtered := filterWeixinPublicAccountHotspotsByKeyword(items, keyword)
|
||||
if len(filtered) > 0 {
|
||||
return filtered, nil
|
||||
}
|
||||
if len(buildWeixinPublicAccountKeywordTerms(keyword)) > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(items) > 20 {
|
||||
return items[:20], nil
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountHotspotsFromSource(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
|
||||
func fetchWeixinPublicAccountHotspotsFromSource(client *http.Client, source weixinPublicAccountHotspotSource, keywords []string) ([]weixinPublicAccountFetchedHotspot, error) {
|
||||
switch source.Type {
|
||||
case "rss":
|
||||
return fetchOfficialAccountRSSHotspots(client, source)
|
||||
return fetchWeixinPublicAccountRSSHotspots(client, source)
|
||||
default:
|
||||
return fetchOfficialAccountWebHotspots(client, source, keywords)
|
||||
return fetchWeixinPublicAccountWebHotspots(client, source, keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchOfficialAccountRSSHotspots(client *http.Client, source officialAccountHotspotSource) ([]officialAccountFetchedHotspot, error) {
|
||||
body, err := fetchOfficialAccountSourceBody(client, source.URL)
|
||||
func fetchWeixinPublicAccountRSSHotspots(client *http.Client, source weixinPublicAccountHotspotSource) ([]weixinPublicAccountFetchedHotspot, error) {
|
||||
body, err := fetchWeixinPublicAccountSourceBody(client, source.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var feed officialAccountRSS
|
||||
var feed weixinPublicAccountRSS
|
||||
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]officialAccountFetchedHotspot, 0, 24)
|
||||
items := make([]weixinPublicAccountFetchedHotspot, 0, 24)
|
||||
for _, item := range feed.Channel.Items {
|
||||
title := normalizeOfficialAccountText(item.Title)
|
||||
title := normalizeWeixinPublicAccountText(item.Title)
|
||||
link := strings.TrimSpace(item.Link)
|
||||
summary := normalizeOfficialAccountText(item.Description)
|
||||
summary := normalizeWeixinPublicAccountText(item.Description)
|
||||
if title == "" || link == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
items = append(items, weixinPublicAccountFetchedHotspot{
|
||||
Title: title,
|
||||
URL: link,
|
||||
Summary: summary,
|
||||
PublishedAt: parseOfficialAccountTime(item.PubDate),
|
||||
PublishedAt: parseWeixinPublicAccountTime(item.PubDate),
|
||||
SourceKey: source.Key,
|
||||
SourceLabel: source.Label,
|
||||
SourceType: source.Type,
|
||||
})
|
||||
}
|
||||
for _, entry := range feed.Entries {
|
||||
title := normalizeOfficialAccountText(entry.Title)
|
||||
title := normalizeWeixinPublicAccountText(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))
|
||||
summary := normalizeWeixinPublicAccountText(firstNonEmpty(entry.Summary, entry.Content))
|
||||
if title == "" || link == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
items = append(items, weixinPublicAccountFetchedHotspot{
|
||||
Title: title,
|
||||
URL: link,
|
||||
Summary: summary,
|
||||
PublishedAt: parseOfficialAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
|
||||
PublishedAt: parseWeixinPublicAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
|
||||
SourceKey: source.Key,
|
||||
SourceLabel: source.Label,
|
||||
SourceType: source.Type,
|
||||
})
|
||||
}
|
||||
return dedupeOfficialAccountFetched(items), nil
|
||||
return dedupeWeixinPublicAccountFetched(items), nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
|
||||
body, err := fetchOfficialAccountSourceBody(client, source.URL)
|
||||
func fetchWeixinPublicAccountWebHotspots(client *http.Client, source weixinPublicAccountHotspotSource, keywords []string) ([]weixinPublicAccountFetchedHotspot, error) {
|
||||
body, err := fetchWeixinPublicAccountSourceBody(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)
|
||||
matches := weixinPublicAccountAnchorRE.FindAllStringSubmatch(string(body), -1)
|
||||
items := make([]weixinPublicAccountFetchedHotspot, 0, 24)
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(match[1])
|
||||
text := normalizeOfficialAccountText(match[2])
|
||||
text := normalizeWeixinPublicAccountText(match[2])
|
||||
if text == "" || len([]rune(text)) < 8 || len([]rune(text)) > 90 {
|
||||
continue
|
||||
}
|
||||
if len(keywords) > 0 && !containsOfficialAccountKeywords(text, keywords) {
|
||||
if len(keywords) > 0 && !containsWeixinPublicAccountKeywords(text, keywords) {
|
||||
continue
|
||||
}
|
||||
if base != nil {
|
||||
@@ -209,7 +212,7 @@ func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccount
|
||||
href = base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
items = append(items, officialAccountFetchedHotspot{
|
||||
items = append(items, weixinPublicAccountFetchedHotspot{
|
||||
Title: text,
|
||||
URL: href,
|
||||
Summary: "",
|
||||
@@ -222,15 +225,15 @@ func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccount
|
||||
break
|
||||
}
|
||||
}
|
||||
return dedupeOfficialAccountFetched(items), nil
|
||||
return dedupeWeixinPublicAccountFetched(items), nil
|
||||
}
|
||||
|
||||
func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) {
|
||||
func fetchWeixinPublicAccountSourceBody(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")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 weixin-public-account-assistant/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -251,21 +254,21 @@ func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte,
|
||||
return decodedBody, nil
|
||||
}
|
||||
|
||||
func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAccountFetchedHotspot) (int, []string) {
|
||||
func upsertWeixinPublicAccountHotspots(domainKey, keyword string, items []weixinPublicAccountFetchedHotspot) (int, []string) {
|
||||
logs := []string{}
|
||||
if len(items) == 0 {
|
||||
return 0, logs
|
||||
}
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
|
||||
now := time.Now()
|
||||
upserts := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(items))
|
||||
upserts := make([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, 0, len(items))
|
||||
for _, item := range items {
|
||||
rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary)
|
||||
rawScore, domainScore, tags := scoreWeixinPublicAccountHotspot(domainKey, keyword, item.Title, item.Summary)
|
||||
if domainScore <= 0 {
|
||||
continue
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(tags)
|
||||
upserts = append(upserts, officialaccountmodel.OfficialAccountHotspot{
|
||||
upserts = append(upserts, weixinpublicaccountmodel.WeixinPublicAccountHotspot{
|
||||
BusinessDomain: domainKey,
|
||||
SourceKey: item.SourceKey,
|
||||
SourceLabel: item.SourceLabel,
|
||||
@@ -276,7 +279,7 @@ func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAc
|
||||
PublishedAt: item.PublishedAt,
|
||||
RawScore: rawScore,
|
||||
DomainScore: domainScore,
|
||||
HeatLabel: officialAccountHeatLabel(domainScore),
|
||||
HeatLabel: weixinPublicAccountHeatLabel(domainScore),
|
||||
TagsJSON: string(tagsJSON),
|
||||
FetchedAt: now,
|
||||
})
|
||||
@@ -292,8 +295,8 @@ func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAc
|
||||
return len(upserts), logs
|
||||
}
|
||||
|
||||
func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
func scoreWeixinPublicAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
|
||||
text := strings.ToLower(title + "\n" + summary)
|
||||
rawScore := 0.0
|
||||
domainScore := 0.0
|
||||
@@ -318,21 +321,21 @@ func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (flo
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
for _, item := range buildOfficialAccountKeywordTerms(keyword) {
|
||||
for _, item := range buildWeixinPublicAccountKeywordTerms(keyword) {
|
||||
if strings.Contains(text, strings.ToLower(item)) {
|
||||
domainScore += 2.5
|
||||
tags = append(tags, item)
|
||||
}
|
||||
}
|
||||
if domainKey == officialAccountBusinessDomainGeneric {
|
||||
if domainKey == weixinPublicAccountBusinessDomainGeneric {
|
||||
domainScore += 2
|
||||
}
|
||||
return rawScore, domainScore, uniqueOfficialAccountStrings(tags)
|
||||
return rawScore, domainScore, uniqueWeixinPublicAccountStrings(tags)
|
||||
}
|
||||
|
||||
func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []officialAccountFetchedHotspot {
|
||||
func dedupeWeixinPublicAccountFetched(items []weixinPublicAccountFetchedHotspot) []weixinPublicAccountFetchedHotspot {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]officialAccountFetchedHotspot, 0, len(items))
|
||||
result := make([]weixinPublicAccountFetchedHotspot, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.URL)
|
||||
if key == "" {
|
||||
@@ -347,14 +350,14 @@ func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []offic
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeOfficialAccountText(value string) string {
|
||||
func normalizeWeixinPublicAccountText(value string) string {
|
||||
value = html.UnescapeString(value)
|
||||
value = officialAccountStripTagRE.ReplaceAllString(value, " ")
|
||||
value = officialAccountSpaceRE.ReplaceAllString(value, " ")
|
||||
value = weixinPublicAccountStripTagRE.ReplaceAllString(value, " ")
|
||||
value = weixinPublicAccountSpaceRE.ReplaceAllString(value, " ")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func parseOfficialAccountTime(value string) *time.Time {
|
||||
func parseWeixinPublicAccountTime(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
@@ -374,7 +377,7 @@ func parseOfficialAccountTime(value string) *time.Time {
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsOfficialAccountKeywords(text string, keywords []string) bool {
|
||||
func containsWeixinPublicAccountKeywords(text string, keywords []string) bool {
|
||||
text = strings.ToLower(text)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, strings.ToLower(keyword)) {
|
||||
@@ -384,13 +387,13 @@ func containsOfficialAccountKeywords(text string, keywords []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.OfficialAccountHotspot, keyword string) []officialaccountmodel.OfficialAccountHotspot {
|
||||
terms := buildOfficialAccountKeywordTerms(keyword)
|
||||
func filterWeixinPublicAccountHotspotsByKeyword(items []weixinpublicaccountmodel.WeixinPublicAccountHotspot, keyword string) []weixinpublicaccountmodel.WeixinPublicAccountHotspot {
|
||||
terms := buildWeixinPublicAccountKeywordTerms(keyword)
|
||||
if len(terms) == 0 {
|
||||
return items
|
||||
}
|
||||
type scoredHotspot struct {
|
||||
item officialaccountmodel.OfficialAccountHotspot
|
||||
item weixinpublicaccountmodel.WeixinPublicAccountHotspot
|
||||
score int
|
||||
}
|
||||
scored := make([]scoredHotspot, 0, len(items))
|
||||
@@ -415,7 +418,7 @@ func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.Officia
|
||||
}
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
result := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(scored))
|
||||
result := make([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, 0, len(scored))
|
||||
for _, item := range scored {
|
||||
result = append(result, item.item)
|
||||
if len(result) >= 20 {
|
||||
@@ -425,11 +428,12 @@ func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.Officia
|
||||
return result
|
||||
}
|
||||
|
||||
func buildOfficialAccountKeywordTerms(keyword string) []string {
|
||||
func buildWeixinPublicAccountKeywordTerms(keyword string) []string {
|
||||
raw := strings.TrimSpace(strings.ToLower(keyword))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
raw = strings.TrimSuffix(raw, "的")
|
||||
splitter := func(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', ',', ',', '、', '/', '|', ';', ';', ':', ':', '(', ')', '(', ')', '+', '-', '_':
|
||||
@@ -445,30 +449,31 @@ func buildOfficialAccountKeywordTerms(keyword string) []string {
|
||||
}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
part = strings.TrimSuffix(part, "的")
|
||||
if utf8RuneCount(part) < 2 {
|
||||
continue
|
||||
}
|
||||
terms = append(terms, part)
|
||||
}
|
||||
return uniqueOfficialAccountStrings(terms)
|
||||
return uniqueWeixinPublicAccountStrings(terms)
|
||||
}
|
||||
|
||||
func buildOfficialAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
|
||||
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
|
||||
func buildWeixinPublicAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
|
||||
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
|
||||
keywords := append([]string{}, cfg.DomainKeywords...)
|
||||
keywords = append(keywords, buildOfficialAccountKeywordTerms(keyword)...)
|
||||
keywords = append(keywords, buildWeixinPublicAccountKeywordTerms(keyword)...)
|
||||
keywords = append(keywords, extras...)
|
||||
if domainKey == officialAccountBusinessDomainGeneric && len(buildOfficialAccountKeywordTerms(keyword)) == 0 {
|
||||
if domainKey == weixinPublicAccountBusinessDomainGeneric && len(buildWeixinPublicAccountKeywordTerms(keyword)) == 0 {
|
||||
keywords = append(keywords, cfg.FallbackSearchHints...)
|
||||
}
|
||||
return uniqueOfficialAccountStrings(keywords)
|
||||
return uniqueWeixinPublicAccountStrings(keywords)
|
||||
}
|
||||
|
||||
func utf8RuneCount(value string) int {
|
||||
return len([]rune(value))
|
||||
}
|
||||
|
||||
func officialAccountHeatLabel(score float64) string {
|
||||
func weixinPublicAccountHeatLabel(score float64) string {
|
||||
switch {
|
||||
case score >= 12:
|
||||
return "爆热"
|
||||
@@ -481,7 +486,7 @@ func officialAccountHeatLabel(score float64) string {
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueOfficialAccountStrings(items []string) []string {
|
||||
func uniqueWeixinPublicAccountStrings(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
+423
-311
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -1,9 +1,19 @@
|
||||
package wechatofficialaccount
|
||||
package weixinpublicaccount
|
||||
|
||||
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
|
||||
|
||||
// 目录文案:专员卡片、市场列表、专员提示词(api/specialist_prompt.go)读的都是这几条,
|
||||
// 所以只能有一份。参考 docs/02_Architecture/AR13_公众号创作专员双层多智能体架构定义.md §5:
|
||||
// 一级工作流固定 8 个阶段 —— 选题 / 标题 / 提纲 / 正文 / 配图提示词 / 图片生成 / 预览与导出 / 分页 MD。
|
||||
const (
|
||||
CatalogSummary = "热点选题、标题、提纲、正文、配图到分页 MD,八步创作工作流"
|
||||
CatalogWorkStatus = "8 个节点可执行"
|
||||
CatalogBaseSkills = "推荐热点选题、标题生成、提纲生成、正文创作、配图提示词、图片生成、预览与导出、分页 MD"
|
||||
CatalogGeneratedSkills = "公众号选题建议、标题建议、文章提纲、正文草稿、配图提示词、图片资源、预览稿、分页 MD"
|
||||
)
|
||||
|
||||
var Manifest = specialistcontracts.Manifest{
|
||||
Key: "wechat-official-account",
|
||||
Key: "weixin-public-account",
|
||||
Label: "公众号创作专员",
|
||||
RuleFileMarkdown: `# 公众号创作专员
|
||||
|
||||
@@ -25,7 +35,7 @@ var Manifest = specialistcontracts.Manifest{
|
||||
- 不编造案例、数据、用户评价。
|
||||
- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`,
|
||||
AllowedSkills: []string{"longform-writing", "copy-proofreading", "mind-map"},
|
||||
OwnedDefinitionKeys: []string{"specialist.wechat-official-account.definition"},
|
||||
OwnedCatalogEntries: []string{"catalog.specialist.wechat-official-account"},
|
||||
OwnedDefinitionKeys: []string{"specialist.weixin-public-account.definition"},
|
||||
OwnedCatalogEntries: []string{"catalog.specialist.weixin-public-account"},
|
||||
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package wechatofficialaccountmodel
|
||||
package weixinpublicaccountmodel
|
||||
|
||||
import "time"
|
||||
|
||||
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
|
||||
type OfficialAccountArticle struct {
|
||||
// WeixinPublicAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
|
||||
type WeixinPublicAccountArticle 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"`
|
||||
@@ -37,4 +37,4 @@ type OfficialAccountArticle struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (OfficialAccountArticle) TableName() string { return "official_account_article" }
|
||||
func (WeixinPublicAccountArticle) TableName() string { return "weixin_public_account_article" }
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package wechatofficialaccountmodel
|
||||
package weixinpublicaccountmodel
|
||||
|
||||
import "time"
|
||||
|
||||
// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
|
||||
type OfficialAccountHotspot struct {
|
||||
// WeixinPublicAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
|
||||
type WeixinPublicAccountHotspot 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"`
|
||||
@@ -22,4 +22,4 @@ type OfficialAccountHotspot struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" }
|
||||
func (WeixinPublicAccountHotspot) TableName() string { return "weixin_public_account_hotspot" }
|
||||
@@ -1,6 +1,8 @@
|
||||
package specialistseeding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
@@ -8,6 +10,7 @@ import (
|
||||
|
||||
specialistcore "eai_agentplatform/backend/internal/specialists/core"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
@@ -39,13 +42,13 @@ func SeedSpecialists(db *gorm.DB) error {
|
||||
SortOrder: 0,
|
||||
},
|
||||
{
|
||||
Key: "wechat-official-account",
|
||||
Key: "weixin-public-account",
|
||||
Label: "公众号创作专员",
|
||||
Tier: "generic",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "/apps/wechat-official-account",
|
||||
Summary: "热点选题、标题、提纲、正文四步创作工作流",
|
||||
WorkStatus: "4 个节点可执行",
|
||||
ObjectEntryRoute: "/apps/weixin-public-account",
|
||||
Summary: weixinpublicaccount.CatalogSummary,
|
||||
WorkStatus: weixinpublicaccount.CatalogWorkStatus,
|
||||
RiskLabel: "0 个异常",
|
||||
Color: "#2563eb",
|
||||
Stage: "正文创作",
|
||||
@@ -56,9 +59,9 @@ func SeedSpecialists(db *gorm.DB) error {
|
||||
PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认",
|
||||
ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿",
|
||||
InfoSources: "选题关键词、目标受众、文章目标、风格要求",
|
||||
BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作",
|
||||
BaseSkills: weixinpublicaccount.CatalogBaseSkills,
|
||||
AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出",
|
||||
GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿",
|
||||
GeneratedSkills: weixinpublicaccount.CatalogGeneratedSkills,
|
||||
State: "active",
|
||||
SortOrder: 45,
|
||||
},
|
||||
@@ -231,11 +234,115 @@ func SeedSpecialists(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
specialistcore.LogRuleFileCoverage(db)
|
||||
log.Println("[OK] 专员目录种子已导入")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 早期版本把公众号创作专员的一级工作流文案写成「四步」,这里给出那一版的原文,
|
||||
// 只用于按值锚定旧数据 —— 改文案时不要跟着改。
|
||||
const (
|
||||
staleWeixinPublicAccountSummary = "热点选题、标题、提纲、正文四步创作工作流"
|
||||
staleWeixinPublicAccountWorkStatus = "4 个节点可执行"
|
||||
staleWeixinPublicAccountBaseSkills = "推荐热点选题、标题生成、提纲生成、正文创作"
|
||||
staleWeixinPublicAccountGeneratedSkills = "公众号选题建议、标题建议、文章提纲、正文草稿"
|
||||
)
|
||||
|
||||
// correctWeixinPublicAccountWorkflowCopy 纠正已经装出去的库里那份写错的四级工作流文案。
|
||||
//
|
||||
// 公众号创作专员的一级工作流是 8 个阶段(AR13 §5):选题 / 标题 / 提纲 / 正文 /
|
||||
// 配图提示词 / 图片生成 / 预览与导出 / 分页 MD。早期文案把它写成「四步」,
|
||||
// 既漏了后半程三个阶段,又把步数写错。这段文案会同时进专员卡片和专员提示词
|
||||
// (见 api/specialist_prompt.go),所以光改种子不够 —— 种子的补齐逻辑只填空字段,
|
||||
// 旧值一旦落库就再也不会被覆盖,必须在这里按值纠正一次。
|
||||
//
|
||||
// 只在值等于旧文案时才改写:管理员在后台改过的文案一律不动,重复执行无副作用。
|
||||
func correctWeixinPublicAccountWorkflowCopy(db *gorm.DB) error {
|
||||
var item specialistmodel.Specialist
|
||||
err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if item.Summary == staleWeixinPublicAccountSummary {
|
||||
updates["summary"] = weixinpublicaccount.CatalogSummary
|
||||
}
|
||||
if item.WorkStatus == staleWeixinPublicAccountWorkStatus {
|
||||
updates["work_status"] = weixinpublicaccount.CatalogWorkStatus
|
||||
}
|
||||
if item.BaseSkills == staleWeixinPublicAccountBaseSkills {
|
||||
updates["base_skills"] = weixinpublicaccount.CatalogBaseSkills
|
||||
}
|
||||
if item.GeneratedSkills == staleWeixinPublicAccountGeneratedSkills {
|
||||
updates["generated_skills"] = weixinpublicaccount.CatalogGeneratedSkills
|
||||
}
|
||||
if card, corrected := correctInteractionCardTagline(item.InteractionCardJSON); corrected {
|
||||
updates["interaction_card_json"] = card
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 动作记录与结果记录是从 base_skills / generated_skills 派生的(见 runtime.EnsureStructuredRecords),
|
||||
// 技能清单变了就必须清空重算,否则专员详情页和对话右栏的流程还停在四个动作。
|
||||
rebuildActionRecords := false
|
||||
if _, changed := updates["base_skills"]; changed {
|
||||
updates["action_records_json"] = ""
|
||||
rebuildActionRecords = true
|
||||
}
|
||||
if _, changed := updates["generated_skills"]; changed {
|
||||
updates["result_records_json"] = ""
|
||||
rebuildActionRecords = true
|
||||
}
|
||||
|
||||
if err := db.Model(&item).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[OK] 公众号创作专员旧工作流文案已纠正(summary/work_status/skills/tagline)")
|
||||
|
||||
if !rebuildActionRecords {
|
||||
return nil
|
||||
}
|
||||
if err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
specialistruntime.EnsureStructuredRecords(&item)
|
||||
return db.Model(&item).Updates(map[string]any{
|
||||
"action_records_json": item.ActionRecordsJSON,
|
||||
"result_records_json": item.ResultRecordsJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// correctInteractionCardTagline 只替换交互卡里的 tagline:整串 JSON 重新解析再写回,
|
||||
// 不做子串替换 —— 同一份 JSON 里 starter_prompts 等字段也可能含相近文案。
|
||||
func correctInteractionCardTagline(raw string) (string, bool) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw, false
|
||||
}
|
||||
card := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(raw), &card); err != nil {
|
||||
return raw, false
|
||||
}
|
||||
tagline, _ := card["tagline"].(string)
|
||||
if tagline != staleWeixinPublicAccountSummary {
|
||||
return raw, false
|
||||
}
|
||||
card["tagline"] = weixinpublicaccount.CatalogSummary
|
||||
encoded, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
return raw, false
|
||||
}
|
||||
return string(encoded), true
|
||||
}
|
||||
|
||||
func applySpecialistCodes(items []specialistmodel.Specialist) []specialistmodel.Specialist {
|
||||
visibleIndex := 1
|
||||
for i := range items {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package specialistseeding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
|
||||
)
|
||||
|
||||
func openSeedingTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "seeding.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&specialistmodel.Specialist{}); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// staleWeixinPublicAccountRow 造一条「旧版四步文案」的专员记录,
|
||||
// 五个载体全部取旧值,外加一份与旧 base_skills 对不上的派生记录。
|
||||
func staleWeixinPublicAccountRow(t *testing.T, db *gorm.DB) specialistmodel.Specialist {
|
||||
t.Helper()
|
||||
card := map[string]any{
|
||||
"name": "公众号创作专员",
|
||||
"tagline": staleWeixinPublicAccountSummary,
|
||||
"greeting": "我是公众号创作专员,已经准备好接手这条任务并继续推进。",
|
||||
"starter_prompts": []string{"帮我处理:推荐热点选题", "帮我处理:标题生成"},
|
||||
}
|
||||
cardJSON, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化交互卡失败: %v", err)
|
||||
}
|
||||
|
||||
item := specialistmodel.Specialist{
|
||||
Key: weixinpublicaccount.Manifest.Key,
|
||||
Label: "公众号创作专员",
|
||||
Tier: "generic",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "/apps/weixin-public-account",
|
||||
Summary: staleWeixinPublicAccountSummary,
|
||||
WorkStatus: staleWeixinPublicAccountWorkStatus,
|
||||
BaseSkills: staleWeixinPublicAccountBaseSkills,
|
||||
GeneratedSkills: staleWeixinPublicAccountGeneratedSkills,
|
||||
MarketTag: "已安装",
|
||||
State: "active",
|
||||
InteractionCardJSON: string(cardJSON),
|
||||
// 派生记录:四步版本产生的四个动作、四个产物。
|
||||
ActionRecordsJSON: `[{"action_name":"推荐热点选题"},{"action_name":"标题生成"},{"action_name":"提纲生成"},{"action_name":"正文创作"}]`,
|
||||
ResultRecordsJSON: `[{"result_title":"公众号选题建议"},{"result_title":"标题建议"},{"result_title":"文章提纲"},{"result_title":"正文草稿"}]`,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("建专员失败: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func reloadWeixinPublicAccount(t *testing.T, db *gorm.DB) specialistmodel.Specialist {
|
||||
t.Helper()
|
||||
var item specialistmodel.Specialist
|
||||
if err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error; err != nil {
|
||||
t.Fatalf("读专员失败: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func taglineOf(t *testing.T, interactionCardJSON string) string {
|
||||
t.Helper()
|
||||
card := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(interactionCardJSON), &card); err != nil {
|
||||
t.Fatalf("解析交互卡失败: %v", err)
|
||||
}
|
||||
tagline, _ := card["tagline"].(string)
|
||||
return tagline
|
||||
}
|
||||
|
||||
// TestCorrectWeixinPublicAccountWorkflowCopy 五个载体(summary / work_status /
|
||||
// base_skills / generated_skills / 交互卡 tagline)全部从四步改成八步,
|
||||
// 并且两份派生记录按新技能清单重建。
|
||||
func TestCorrectWeixinPublicAccountWorkflowCopy(t *testing.T) {
|
||||
db := openSeedingTestDB(t)
|
||||
staleWeixinPublicAccountRow(t, db)
|
||||
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
t.Fatalf("纠正失败: %v", err)
|
||||
}
|
||||
got := reloadWeixinPublicAccount(t, db)
|
||||
|
||||
if got.Summary != weixinpublicaccount.CatalogSummary {
|
||||
t.Errorf("summary 未纠正: %q", got.Summary)
|
||||
}
|
||||
if got.WorkStatus != weixinpublicaccount.CatalogWorkStatus {
|
||||
t.Errorf("work_status 未纠正: %q", got.WorkStatus)
|
||||
}
|
||||
if got.BaseSkills != weixinpublicaccount.CatalogBaseSkills {
|
||||
t.Errorf("base_skills 未纠正: %q", got.BaseSkills)
|
||||
}
|
||||
if got.GeneratedSkills != weixinpublicaccount.CatalogGeneratedSkills {
|
||||
t.Errorf("generated_skills 未纠正: %q", got.GeneratedSkills)
|
||||
}
|
||||
if tagline := taglineOf(t, got.InteractionCardJSON); tagline != weixinpublicaccount.CatalogSummary {
|
||||
t.Errorf("交互卡 tagline 未纠正: %q", tagline)
|
||||
}
|
||||
|
||||
// 交互卡其余字段必须原样保留 —— 整串重写只该动 tagline。
|
||||
card := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(got.InteractionCardJSON), &card); err != nil {
|
||||
t.Fatalf("解析交互卡失败: %v", err)
|
||||
}
|
||||
if card["greeting"] != "我是公众号创作专员,已经准备好接手这条任务并继续推进。" {
|
||||
t.Errorf("greeting 被改动了: %v", card["greeting"])
|
||||
}
|
||||
if prompts, ok := card["starter_prompts"].([]any); !ok || len(prompts) != 2 {
|
||||
t.Errorf("starter_prompts 被改动了: %v", card["starter_prompts"])
|
||||
}
|
||||
|
||||
// 派生记录:动作数应等于技能条数,且补上了后半程阶段。
|
||||
var actions []map[string]any
|
||||
if err := json.Unmarshal([]byte(got.ActionRecordsJSON), &actions); err != nil {
|
||||
t.Fatalf("解析动作记录失败: %v", err)
|
||||
}
|
||||
if len(actions) != 8 {
|
||||
t.Errorf("动作记录条数 = %d,期望 8(每条 base_skill 一个动作)", len(actions))
|
||||
}
|
||||
if !strings.Contains(got.ActionRecordsJSON, "分页 MD") {
|
||||
t.Errorf("动作记录未补上分页 MD: %s", got.ActionRecordsJSON)
|
||||
}
|
||||
if strings.Contains(got.ActionRecordsJSON, `"action_name":"正文创作"}])`) {
|
||||
t.Errorf("动作记录仍是旧的四条骨架: %s", got.ActionRecordsJSON)
|
||||
}
|
||||
|
||||
var results []map[string]any
|
||||
if err := json.Unmarshal([]byte(got.ResultRecordsJSON), &results); err != nil {
|
||||
t.Fatalf("解析结果记录失败: %v", err)
|
||||
}
|
||||
if len(results) != 8 {
|
||||
t.Errorf("结果记录条数 = %d,期望 8(每条 generated_skill 一个产物)", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectWeixinPublicAccountWorkflowCopyKeepsAdminEdits 管理员改过的字段不动:
|
||||
// 纠正只认旧文案本身,不是无条件覆盖。
|
||||
func TestCorrectWeixinPublicAccountWorkflowCopyKeepsAdminEdits(t *testing.T) {
|
||||
db := openSeedingTestDB(t)
|
||||
staleWeixinPublicAccountRow(t, db)
|
||||
|
||||
const adminSummary = "我自己改过的说明"
|
||||
if err := db.Model(&specialistmodel.Specialist{}).
|
||||
Where("key = ?", weixinpublicaccount.Manifest.Key).
|
||||
Update("summary", adminSummary).Error; err != nil {
|
||||
t.Fatalf("模拟管理员改动失败: %v", err)
|
||||
}
|
||||
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
t.Fatalf("纠正失败: %v", err)
|
||||
}
|
||||
got := reloadWeixinPublicAccount(t, db)
|
||||
|
||||
if got.Summary != adminSummary {
|
||||
t.Errorf("管理员改过的 summary 被覆盖: %q", got.Summary)
|
||||
}
|
||||
// 其余仍是旧值的载体照常纠正,说明是按字段锚定而不是整行跳过。
|
||||
if got.WorkStatus != weixinpublicaccount.CatalogWorkStatus {
|
||||
t.Errorf("work_status 未纠正: %q", got.WorkStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectWeixinPublicAccountWorkflowCopyIsIdempotent 重复执行无副作用:
|
||||
// 第二次跑不再产生更新,派生记录也不会被反复清空重建。
|
||||
func TestCorrectWeixinPublicAccountWorkflowCopyIsIdempotent(t *testing.T) {
|
||||
db := openSeedingTestDB(t)
|
||||
staleWeixinPublicAccountRow(t, db)
|
||||
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
t.Fatalf("第一次纠正失败: %v", err)
|
||||
}
|
||||
first := reloadWeixinPublicAccount(t, db)
|
||||
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
t.Fatalf("第二次纠正失败: %v", err)
|
||||
}
|
||||
second := reloadWeixinPublicAccount(t, db)
|
||||
|
||||
if first.ActionRecordsJSON != second.ActionRecordsJSON {
|
||||
t.Errorf("动作记录被二次改写:\n第一次 %s\n第二次 %s", first.ActionRecordsJSON, second.ActionRecordsJSON)
|
||||
}
|
||||
if first.ResultRecordsJSON != second.ResultRecordsJSON {
|
||||
t.Errorf("结果记录被二次改写:\n第一次 %s\n第二次 %s", first.ResultRecordsJSON, second.ResultRecordsJSON)
|
||||
}
|
||||
if first.InteractionCardJSON != second.InteractionCardJSON {
|
||||
t.Errorf("交互卡被二次改写:\n第一次 %s\n第二次 %s", first.InteractionCardJSON, second.InteractionCardJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectWeixinPublicAccountWorkflowCopyMissingRow 库里没有这个专员时直接放行,
|
||||
// 新库不该因为这条纠正而报错。
|
||||
func TestCorrectWeixinPublicAccountWorkflowCopyMissingRow(t *testing.T) {
|
||||
db := openSeedingTestDB(t)
|
||||
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
|
||||
t.Fatalf("空库纠正报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectInteractionCardTaglineIgnoresUnrelatedJSON 交互卡不是 JSON、
|
||||
// 或 tagline 已是新文案时,原样返回。
|
||||
func TestCorrectInteractionCardTaglineIgnoresUnrelatedJSON(t *testing.T) {
|
||||
for _, raw := range []string{"", "not json", `{"tagline":"用户自己写的标语"}`} {
|
||||
got, corrected := correctInteractionCardTagline(raw)
|
||||
if corrected {
|
||||
t.Errorf("不该纠正: %q", raw)
|
||||
}
|
||||
if got != raw {
|
||||
t.Errorf("原文被改动: %q → %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
)
|
||||
|
||||
@@ -45,6 +45,9 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
if err := migrateObjectModeColumns(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := purgeLegacyWeixinPublicAccountData(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
@@ -60,6 +63,8 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.SystemConfig{},
|
||||
&model.KnowledgeSource{},
|
||||
&model.AiCallLog{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatMessage{},
|
||||
&model.LearningProgress{},
|
||||
&model.Position{},
|
||||
&model.PositionKnowledge{},
|
||||
@@ -78,8 +83,8 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.TaskRun{},
|
||||
&model.UserXAppCenter{},
|
||||
&model.XAppDefinition{},
|
||||
&officialaccountmodel.OfficialAccountArticle{},
|
||||
&officialaccountmodel.OfficialAccountHotspot{},
|
||||
&weixinpublicaccountmodel.WeixinPublicAccountArticle{},
|
||||
&weixinpublicaccountmodel.WeixinPublicAccountHotspot{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,6 +293,74 @@ func normalizeSkillObjectKinds(db *gorm.DB) error {
|
||||
`).Error
|
||||
}
|
||||
|
||||
func purgeLegacyWeixinPublicAccountData(db *gorm.DB) error {
|
||||
const legacySpecialistKey = "wechat-official-account"
|
||||
|
||||
changed := false
|
||||
|
||||
if db.Migrator().HasTable("task_artifact") {
|
||||
if result := db.Exec(`DELETE FROM task_artifact WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("task_run") {
|
||||
if result := db.Exec(`DELETE FROM task_run WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("task_record") {
|
||||
if result := db.Exec(`DELETE FROM task_record WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("weixin_public_account_article") {
|
||||
if result := db.Exec(`DELETE FROM weixin_public_account_article WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("specialist") {
|
||||
if result := db.Exec(`DELETE FROM specialist WHERE key = ?`, legacySpecialistKey); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("official_account_article") {
|
||||
if err := db.Migrator().DropTable("official_account_article"); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("official_account_hotspot") {
|
||||
if err := db.Migrator().DropTable("official_account_hotspot"); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := db.Exec(`VACUUM`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateColumnData(db *gorm.DB, tableName, targetColumn, legacyColumn string) error {
|
||||
if !db.Migrator().HasColumn(tableName, legacyColumn) {
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
)
|
||||
|
||||
@@ -258,6 +259,198 @@ func TestMigrateAIUsageKindColumnDropsLegacyColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeLegacyWeixinPublicAccountDataRemovesOldRowsAndTables(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(
|
||||
&specialistmodel.Specialist{},
|
||||
&model.TaskRecord{},
|
||||
&model.TaskArtifact{},
|
||||
&model.TaskRun{},
|
||||
&weixinpublicaccountmodel.WeixinPublicAccountArticle{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE official_account_article (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
specialist_key TEXT NOT NULL
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create official_account_article: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE official_account_hotspot (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_key TEXT NOT NULL
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create official_account_hotspot: %v", err)
|
||||
}
|
||||
|
||||
legacySpecialist := specialistmodel.Specialist{
|
||||
Key: "wechat-official-account",
|
||||
Label: "公众号助手",
|
||||
Tier: "generic",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "/apps/weixin-public-account",
|
||||
State: "active",
|
||||
MarketTag: "installed",
|
||||
}
|
||||
if err := db.Create(&legacySpecialist).Error; err != nil {
|
||||
t.Fatalf("create legacy specialist: %v", err)
|
||||
}
|
||||
|
||||
currentSpecialist := specialistmodel.Specialist{
|
||||
Key: "weixin-public-account",
|
||||
Label: "公众号创作专员",
|
||||
Tier: "generic",
|
||||
SpecialistMode: "dw",
|
||||
ObjectEntryRoute: "/apps/weixin-public-account",
|
||||
State: "active",
|
||||
MarketTag: "installed",
|
||||
}
|
||||
if err := db.Create(¤tSpecialist).Error; err != nil {
|
||||
t.Fatalf("create current specialist: %v", err)
|
||||
}
|
||||
|
||||
legacyTask := model.TaskRecord{
|
||||
SpecialistKey: "wechat-official-account",
|
||||
Title: "旧公众号任务",
|
||||
Priority: "P2",
|
||||
Status: "待处理",
|
||||
}
|
||||
if err := db.Create(&legacyTask).Error; err != nil {
|
||||
t.Fatalf("create legacy task: %v", err)
|
||||
}
|
||||
|
||||
keepTask := model.TaskRecord{
|
||||
SpecialistKey: "weixin-public-account",
|
||||
Title: "新公众号任务",
|
||||
Priority: "P2",
|
||||
Status: "待处理",
|
||||
}
|
||||
if err := db.Create(&keepTask).Error; err != nil {
|
||||
t.Fatalf("create current task: %v", err)
|
||||
}
|
||||
|
||||
legacyArtifact := model.TaskArtifact{
|
||||
TaskID: legacyTask.ID,
|
||||
SpecialistKey: "wechat-official-account",
|
||||
Title: "旧产物",
|
||||
}
|
||||
if err := db.Create(&legacyArtifact).Error; err != nil {
|
||||
t.Fatalf("create legacy artifact: %v", err)
|
||||
}
|
||||
keepArtifact := model.TaskArtifact{
|
||||
TaskID: keepTask.ID,
|
||||
SpecialistKey: "weixin-public-account",
|
||||
Title: "新产物",
|
||||
}
|
||||
if err := db.Create(&keepArtifact).Error; err != nil {
|
||||
t.Fatalf("create current artifact: %v", err)
|
||||
}
|
||||
|
||||
legacyRun := model.TaskRun{
|
||||
TaskID: legacyTask.ID,
|
||||
SpecialistKey: "wechat-official-account",
|
||||
ActionKey: "topic",
|
||||
ActionTitle: "旧运行",
|
||||
}
|
||||
if err := db.Create(&legacyRun).Error; err != nil {
|
||||
t.Fatalf("create legacy run: %v", err)
|
||||
}
|
||||
keepRun := model.TaskRun{
|
||||
TaskID: keepTask.ID,
|
||||
SpecialistKey: "weixin-public-account",
|
||||
ActionKey: "topic",
|
||||
ActionTitle: "新运行",
|
||||
}
|
||||
if err := db.Create(&keepRun).Error; err != nil {
|
||||
t.Fatalf("create current run: %v", err)
|
||||
}
|
||||
|
||||
legacyArticle := weixinpublicaccountmodel.WeixinPublicAccountArticle{
|
||||
TaskID: legacyTask.ID,
|
||||
SpecialistKey: "wechat-official-account",
|
||||
Keyword: "外骨骼机器人",
|
||||
Audience: "合作方",
|
||||
Goal: "推广",
|
||||
Tone: "专业",
|
||||
}
|
||||
if err := db.Create(&legacyArticle).Error; err != nil {
|
||||
t.Fatalf("create legacy article: %v", err)
|
||||
}
|
||||
keepArticle := weixinpublicaccountmodel.WeixinPublicAccountArticle{
|
||||
TaskID: keepTask.ID,
|
||||
SpecialistKey: "weixin-public-account",
|
||||
Keyword: "外骨骼机器人",
|
||||
Audience: "合作方",
|
||||
Goal: "推广",
|
||||
Tone: "专业",
|
||||
}
|
||||
if err := db.Create(&keepArticle).Error; err != nil {
|
||||
t.Fatalf("create current article: %v", err)
|
||||
}
|
||||
|
||||
if err := purgeLegacyWeixinPublicAccountData(db); err != nil {
|
||||
t.Fatalf("purge legacy weixin public account data: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&specialistmodel.Specialist{}).Where("key = ?", "wechat-official-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count legacy specialist: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy specialist should be removed, count=%d", count)
|
||||
}
|
||||
if err := db.Model(&specialistmodel.Specialist{}).Where("key = ?", "weixin-public-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count current specialist: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("current specialist should remain, count=%d", count)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.TaskRecord{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count legacy tasks: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy tasks should be removed, count=%d", count)
|
||||
}
|
||||
if err := db.Model(&model.TaskRecord{}).Where("specialist_key = ?", "weixin-public-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count current tasks: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("current tasks should remain, count=%d", count)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.TaskArtifact{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count legacy artifacts: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy artifacts should be removed, count=%d", count)
|
||||
}
|
||||
if err := db.Model(&model.TaskRun{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count legacy runs: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy runs should be removed, count=%d", count)
|
||||
}
|
||||
if err := db.Model(&weixinpublicaccountmodel.WeixinPublicAccountArticle{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count legacy articles: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy articles should be removed, count=%d", count)
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("official_account_article") {
|
||||
t.Fatalf("official_account_article should be dropped")
|
||||
}
|
||||
if db.Migrator().HasTable("official_account_hotspot") {
|
||||
t.Fatalf("official_account_hotspot should be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkillObjectKindsMapsAssistantToSkill(t *testing.T) {
|
||||
db := openMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil {
|
||||
|
||||
@@ -2,36 +2,117 @@ package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
// AppError 业务错误(对齐 Python core/errors.py 的错误码与状态码)
|
||||
// 错误分类(category)—— 表达展示策略、跳转策略、重试策略、监控聚类,使用小写 snake_case。
|
||||
const (
|
||||
CatValidation = "validation" // 参数不合法 / 表单缺失 / 格式错误
|
||||
CatAuth = "auth" // 认证失败:未登录、会话过期、令牌无效
|
||||
CatPermission = "permission" // 权限不足
|
||||
CatNotFound = "not_found" // 资源不存在
|
||||
CatConflict = "conflict" // 状态冲突:重复创建、并发更新
|
||||
CatRateLimit = "rate_limit" // 限流:请求过快
|
||||
CatDependency = "dependency" // 外部依赖错误:第三方 API、数据库、LLM
|
||||
CatProviderQuota = "provider_quota" // 第三方额度不足:AI 积分耗尽
|
||||
CatServer = "server" // 系统内部异常:未捕获、代码 bug
|
||||
)
|
||||
|
||||
// 稳定业务错误码 —— 分段:1000 通用 / 2000 认证 / 3000 权限 / 6000 AI / 9000 系统。
|
||||
// 同一语义稳定复用,不允许同一错误码被不同语义复用。
|
||||
const (
|
||||
CodeOK = 0 // 成功(response.go OK 信封使用)
|
||||
CodeValidation = 1000 // 通用参数错误
|
||||
CodeNotFound = 1001 // 资源不存在
|
||||
CodeConflict = 1002 // 状态冲突 / 重复创建
|
||||
CodeRateLimit = 1003 // 限流
|
||||
CodeUnauthorized = 2000 // 未认证 / 会话过期 / 令牌无效
|
||||
CodeForbidden = 3000 // 权限不足
|
||||
CodeLLMNotConfigured = 6001 // LLM 未配置
|
||||
CodeLLMRequestFailed = 6002 // LLM 请求失败
|
||||
CodePointsExhausted = 6003 // AI 积分耗尽
|
||||
CodeInternal = 9000 // 系统内部错误(未捕获异常兜底)
|
||||
)
|
||||
|
||||
// 各分类默认的下一步操作建议(hint):message 回答"发生了什么",hint 回答"下一步怎么做"。
|
||||
const (
|
||||
DefaultHintValidation = "请检查输入内容、必填项和字段格式后重试。"
|
||||
DefaultHintAuth = "请重新登录后再试。"
|
||||
DefaultHintPermission = "请确认当前账号是否拥有相应权限,或联系管理员授权。"
|
||||
DefaultHintNotFound = "请确认资源标识是否正确,或刷新列表后再试。"
|
||||
DefaultHintConflict = "请刷新页面确认当前状态后,再重试操作。"
|
||||
DefaultHintRateLimit = "操作过于频繁,请稍后重试。"
|
||||
DefaultHintLLMNotConfig = "请先在系统配置中启用并配置 AI 模型路由后重试。"
|
||||
DefaultHintLLMRequest = "请稍后重试;如持续失败,请检查 AI 服务商状态与网络链路。"
|
||||
DefaultHintPointsExhaust = "请检查账户积分余额,或联系管理员补充后重试。"
|
||||
DefaultHintInternal = "请稍后重试;如持续失败,请将请求编号反馈给开发排查。"
|
||||
)
|
||||
|
||||
// AppError 统一业务错误模型:HTTP 状态码 + 稳定业务错误码 + 分类 + 用户提示。
|
||||
// 对齐项目错误治理规范,为"协议语义(状态码) / 稳定编号(code) / 行为分流(category)
|
||||
// / 用户可理解(message+hint) / 开发可定位(request_id+details)"四层模型。
|
||||
type AppError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
Code int
|
||||
Message string
|
||||
Category string
|
||||
Hint string
|
||||
Details any
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string { return e.Message }
|
||||
|
||||
// NewBadRequest 参数错误(保持旧签名,向后兼容,内部自动携带稳定 code/category/hint)。
|
||||
func NewBadRequest(msg string) *AppError {
|
||||
return &AppError{http.StatusBadRequest, "bad_request", msg}
|
||||
return &AppError{http.StatusBadRequest, CodeValidation, msg, CatValidation, DefaultHintValidation, nil, ""}
|
||||
}
|
||||
|
||||
// NewAuthError 认证失败(未登录 / 会话过期 / 令牌无效)。
|
||||
func NewAuthError(msg string) *AppError {
|
||||
return &AppError{http.StatusUnauthorized, "unauthorized", msg}
|
||||
return &AppError{http.StatusUnauthorized, CodeUnauthorized, msg, CatAuth, DefaultHintAuth, nil, ""}
|
||||
}
|
||||
|
||||
// NewForbiddenError 权限不足。
|
||||
func NewForbiddenError(msg string) *AppError {
|
||||
return &AppError{http.StatusForbidden, "forbidden", msg}
|
||||
return &AppError{http.StatusForbidden, CodeForbidden, msg, CatPermission, DefaultHintPermission, nil, ""}
|
||||
}
|
||||
|
||||
// NewNotFoundError 资源不存在。
|
||||
func NewNotFoundError(msg string) *AppError {
|
||||
return &AppError{http.StatusNotFound, "not_found", msg}
|
||||
return &AppError{http.StatusNotFound, CodeNotFound, msg, CatNotFound, DefaultHintNotFound, nil, ""}
|
||||
}
|
||||
|
||||
// NewConflictError 状态冲突 / 重复创建。
|
||||
func NewConflictError(msg string) *AppError {
|
||||
return &AppError{http.StatusConflict, "conflict", msg}
|
||||
return &AppError{http.StatusConflict, CodeConflict, msg, CatConflict, DefaultHintConflict, nil, ""}
|
||||
}
|
||||
|
||||
// NewLLMNotConfigured LLM 路由未配置。
|
||||
func NewLLMNotConfigured(msg string) *AppError {
|
||||
return &AppError{http.StatusNotImplemented, "llm_not_configured", msg}
|
||||
return &AppError{http.StatusNotImplemented, CodeLLMNotConfigured, msg, CatDependency, DefaultHintLLMNotConfig, nil, ""}
|
||||
}
|
||||
|
||||
// NewLLMError LLM 请求失败(外部依赖)。
|
||||
func NewLLMError(msg string) *AppError {
|
||||
return &AppError{http.StatusBadGateway, "llm_request_failed", msg}
|
||||
return &AppError{http.StatusBadGateway, CodeLLMRequestFailed, msg, CatDependency, DefaultHintLLMRequest, nil, ""}
|
||||
}
|
||||
|
||||
// NewPointsExhausted AI 积分耗尽。
|
||||
func NewPointsExhausted(msg string) *AppError {
|
||||
return &AppError{http.StatusPaymentRequired, "ai_points_exhausted", msg}
|
||||
return &AppError{http.StatusPaymentRequired, CodePointsExhausted, msg, CatProviderQuota, DefaultHintPointsExhaust, nil, ""}
|
||||
}
|
||||
|
||||
// NewServerError 系统内部错误(未捕获异常兜底)。
|
||||
func NewServerError(msg string, details any) *AppError {
|
||||
return &AppError{http.StatusInternalServerError, CodeInternal, msg, CatServer, DefaultHintInternal, details, ""}
|
||||
}
|
||||
|
||||
// WithHint 覆写提示语(链式),用于需要给出更具体下一步建议的场景。
|
||||
func (e *AppError) WithHint(hint string) *AppError {
|
||||
e.Hint = hint
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDetails 附带结构化开发上下文(链式),禁止塞大段日志/堆栈。
|
||||
func (e *AppError) WithDetails(details any) *AppError {
|
||||
e.Details = details
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -1,17 +1,78 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OK 成功响应信封 {"data": ..., "error": null, "message": "success"}
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data, "error": nil, "message": "success"})
|
||||
// CtxRequestID Gin Context 中存放 request_id 的键(由 middleware.RequestID 写入)。
|
||||
const CtxRequestID = "req_id"
|
||||
|
||||
// RequestIDOf 从 Gin Context 读取本次请求的 request_id(无则返回空串)。
|
||||
func RequestIDOf(c *gin.Context) string {
|
||||
if v, ok := c.Get(CtxRequestID); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fail 错误响应信封 {"data": null, "error": ..., "message": ...}
|
||||
// OK 成功响应信封 {"code":0,"message":"success","data":...,"error":null}
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": CodeOK,
|
||||
"message": "success",
|
||||
"data": data,
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Fail 错误响应信封,统一输出四层模型:
|
||||
//
|
||||
// {"code": 稳定业务错误码, "message": 用户主提示, "data": null,
|
||||
// "error": {"category":分类, "status":HTTP状态码, "hint":下一步建议, "request_id":追踪编号, "details":开发上下文}}
|
||||
//
|
||||
// 并回写 X-Request-ID 响应头,便于前后端与日志联查。
|
||||
func Fail(c *gin.Context, e *AppError) {
|
||||
c.JSON(e.StatusCode, gin.H{"data": nil, "error": e.Code, "message": e.Message})
|
||||
requestID := RequestIDOf(c)
|
||||
e.RequestID = requestID
|
||||
c.Header("X-Request-ID", requestID)
|
||||
|
||||
errorObj := gin.H{
|
||||
"category": e.Category,
|
||||
"status": e.StatusCode,
|
||||
"hint": e.Hint,
|
||||
"request_id": requestID,
|
||||
}
|
||||
if e.Details != nil {
|
||||
errorObj["details"] = e.Details
|
||||
}
|
||||
|
||||
logFail(c, e)
|
||||
|
||||
c.JSON(e.StatusCode, gin.H{
|
||||
"code": e.Code,
|
||||
"message": e.Message,
|
||||
"data": nil,
|
||||
"error": errorObj,
|
||||
})
|
||||
}
|
||||
|
||||
// logFail 记录结构化错误日志,满足可定位要求:request_id / method / path / status / code / category。
|
||||
func logFail(c *gin.Context, e *AppError) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
req := c.Request
|
||||
method := ""
|
||||
path := ""
|
||||
if req != nil {
|
||||
method = req.Method
|
||||
path = req.URL.Path
|
||||
}
|
||||
log.Printf("HTTP ERROR [%s] status=%d code=%d %s %s -> %s request_id=%s",
|
||||
e.Category, e.StatusCode, e.Code, method, path, e.Message, e.RequestID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user