添加公众号助手

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