- Integrated AI writing capabilities in the super article editor, allowing users to upload multiple reference files and generate drafts using prompts. - Updated the reading page to unify the display of book chapters and super articles, improving user navigation and experience. - Enhanced the "My" page with new UI elements, including gradient icons and improved layout for better visual appeal. - Added backend support for new article management endpoints, ensuring seamless integration with the front end. This update aims to streamline the article creation process and enhance the overall user interface across the application.
530 lines
19 KiB
Go
530 lines
19 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"path"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
|
||
|
||
若用户提供的素材末尾附有「写文章规则提示词」或以「---」分隔的写作规范段落,你必须优先、严格按该段落成稿(体裁、字数、人称、版式、Markdown 交付与自检等)。
|
||
|
||
输出封装(始终遵守):只输出一段合法 JSON,不要有任何 JSON 外的文字。title 为主标题纯文本(可与正文首行 Markdown 标题语义一致,但不要带「#」前缀);content 为正文全文,可为 Markdown(含 **加粗**、分段、---、HTML 注释等),字符串内需合法 JSON 转义。
|
||
|
||
若用户未附带上述写作规范段落,则默认:标题精炼有力≤30字;正文约600~1000字;语气自然真诚,像朋友分享经验;不要使用"首先、其次、最后"等模板化结构词。
|
||
|
||
严格输出 JSON:
|
||
{"title":"文章标题","content":"文章正文"}`
|
||
|
||
// superArticleWritingRulesSuffix 在服务端拼接到用户素材末尾(与 开发文档/ai开发文档.txt 写作规则段落保持一致)。
|
||
const superArticleWritingRulesSuffix = `写文章规则提示词:
|
||
|
||
写文章。严格按以下规则成稿。
|
||
|
||
【材料】用户提供:本场聊天记录/纪要/妙记要点、场次号、主题短句;写作前先按规范阅读飞书运营报表,把报表上有的时长、场观、进房等具体数字写进开篇(只写一次)。
|
||
|
||
【人称与禁用】叙述全程用「我」,禁止「房主」。禁止「这边」「那边」。禁止「回答说,……」——问句后直接接我的陈述。语气偏强势:干脆、笃定,少「可能/也许」。「卡若」每篇最多 1 次,可完全不出现。
|
||
|
||
【刺点三对齐】正文第一行标题「# 第X场|短句」、磁盘文件名、开篇刺激性金句三者同一刺点;不写空泛抒情。
|
||
|
||
【结构与比例】
|
||
- 默认单场 采用「2026 每日派对干货 / 派对体」:三块约 40% / 20%~30% / 余下约 30%;块与块之间必须有过渡口语;每一块结束必须有一段「段末小结」单独成段,30~50 个汉字一句,禁止出现「干货」等标签字。
|
||
|
||
【开篇】先有冲突或世俗金句,再切入场景。固定数据句式(报表有则写具体数,没有的项不写):「第 X 场,X 分钟;场观 XXXXX,进房 XXX 人。」不写「约」、不写「见报表」。时长/场观/进房仅在开篇出现一次。
|
||
|
||
【中段】推进用时间线或事件线;多写对话(「X 号问」「有人问」);我的话直接陈述。约在全文 50% 处插入一段浓缩要点 3~6 条(20~40 字/条),前后用 --- 分隔,条目中禁止「干货」二字。
|
||
|
||
【分享句】约在 20% 处一句、结尾一句,各 ≤50 字,紧扣本节,禁止「干货:」字样。
|
||
|
||
【强调·派对体成稿必选】Markdown **加粗**:至少 2 句完整立场/结论句(分布在不同段落);至少 3 个不同的重点关键词加粗。禁止堆砌无意义加粗。
|
||
|
||
【结尾】升华主题;派对体可有文末小结(3~6 条短金句)+ 总结段落 + 一句收尾金句。
|
||
|
||
【字数与真实性】小节 3000~5000 字;忠于来源不改原意;所有数字必须有依据、写具体算法/金额/人次等,Soul 侧常用口径可参考规范中的曝光/进房/成本写法。
|
||
|
||
【隐晦植入】文中自然散布 2~4 处:联系管理、做切片、做副业相关一句,不单段喊话,随机10%加率不用每篇文章都加
|
||
|
||
【版式强制】一句一行,句间空一行;分号也拆成行;段落之间空行;大段堆砌禁止。
|
||
|
||
【交付】输出 Markdown;标题一行「# 第X场|…」;成稿末尾按模板追加 HTML 注释自检:「我」字数/次数、本场关键字频次、关键字加粗处数、重点句/词加粗是否达标。`
|
||
|
||
const (
|
||
maxArticleMaterialFetchBody = 512 * 1024 // 单素材最大读取
|
||
maxCombinedReferenceRunes = 60000 // 合并后截断上限(含提示与素材摘要)
|
||
)
|
||
|
||
// MiniprogramSuperArticleGenerate POST /api/miniprogram/super/articles/generate
|
||
// 根据提示词 + 可选参考文本 / 素材文件 URL(本站上传链接) / 图片 URL,调用 AI 生成草稿(仅超级个体)。
|
||
func MiniprogramSuperArticleGenerate(c *gin.Context) {
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Description string `json:"description"` // 提示词(与原字段兼容)
|
||
ReferenceText string `json:"referenceText"` // 小程序端可读出的纯文本(多文件拼接)
|
||
MaterialURLs []string `json:"materialUrls"` // 上传后的文件 URL(服务端在安全域名内抓取文本)
|
||
ImageURLs []string `json:"imageUrls"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
|
||
return
|
||
}
|
||
req.UserID = strings.TrimSpace(req.UserID)
|
||
req.Description = strings.TrimSpace(req.Description)
|
||
req.ReferenceText = strings.TrimSpace(req.ReferenceText)
|
||
if req.UserID == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||
return
|
||
}
|
||
|
||
hasPrompt := req.Description != ""
|
||
hasRefText := req.ReferenceText != ""
|
||
|
||
var mats []string
|
||
for _, u := range req.MaterialURLs {
|
||
if u = strings.TrimSpace(u); u != "" {
|
||
mats = append(mats, u)
|
||
}
|
||
}
|
||
var imgs []string
|
||
for _, u := range req.ImageURLs {
|
||
if u = strings.TrimSpace(u); u != "" {
|
||
imgs = append(imgs, u)
|
||
}
|
||
}
|
||
|
||
if !hasPrompt && !hasRefText && len(mats) == 0 && len(imgs) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写提示词,或上传参考文件"})
|
||
return
|
||
}
|
||
|
||
db := database.DB()
|
||
if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可使用 AI 生成", "forbidden": true})
|
||
return
|
||
}
|
||
|
||
cfg := config.Get()
|
||
if cfg == nil || strings.TrimSpace(cfg.SuperArticleAIAPIKey) == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请在服务端配置 SUPER_ARTICLE_AI_API_KEY 或 OPENAI_API_KEY"})
|
||
return
|
||
}
|
||
|
||
// 过滤空 URL
|
||
var validImageURLs []string
|
||
for _, u := range imgs {
|
||
validImageURLs = append(validImageURLs, u)
|
||
}
|
||
|
||
combined, errMsg := assembleSuperArticlePrompt(cfg, req.Description, req.ReferenceText, mats, len(validImageURLs))
|
||
if errMsg != "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": errMsg})
|
||
return
|
||
}
|
||
combined = appendSuperArticleWritingRules(combined)
|
||
|
||
title, content, err := aiGenerateArticle(cfg, combined, validImageURLs)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 生成失败:" + err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"title": title, "content": content}})
|
||
}
|
||
|
||
// assembleSuperArticlePrompt 合并提示词、端上传入的参考正文、以及安全域名内可抓取的素材 URL 文本。
|
||
func assembleSuperArticlePrompt(cfg *config.Config, prompt, refText string, materialURLs []string, imageCount int) (combined string, errMsg string) {
|
||
var sb strings.Builder
|
||
if prompt != "" {
|
||
sb.WriteString("用户写作要求(提示词):\n")
|
||
sb.WriteString(prompt)
|
||
sb.WriteString("\n\n")
|
||
}
|
||
if refText != "" {
|
||
sb.WriteString("以下为用户提供的参考正文(可能来自多个文本文件):\n")
|
||
sb.WriteString(refText)
|
||
sb.WriteString("\n\n")
|
||
}
|
||
for _, mu := range materialURLs {
|
||
if !materialFetchURLAllowed(cfg, mu) {
|
||
return "", "素材链接不在允许范围,请使用小程序内上传生成的链接"
|
||
}
|
||
snippet, note := fetchMaterialText(cfg, mu)
|
||
name := snippetNameFromURL(mu)
|
||
if snippet != "" {
|
||
sb.WriteString("--- 参考文件(")
|
||
sb.WriteString(name)
|
||
sb.WriteString(")---\n")
|
||
sb.WriteString(snippet)
|
||
sb.WriteString("\n\n")
|
||
} else if note != "" {
|
||
sb.WriteString("--- 参考附件(")
|
||
sb.WriteString(name)
|
||
sb.WriteString(")---\n")
|
||
sb.WriteString(note)
|
||
sb.WriteString("\n\n")
|
||
}
|
||
}
|
||
combined = strings.TrimSpace(sb.String())
|
||
if combined == "" && imageCount > 0 {
|
||
combined = "请根据用户上传的图片,结合创业与个体成长语境,写一篇自然真诚、有观点的短文。"
|
||
}
|
||
if len([]rune(combined)) > maxCombinedReferenceRunes {
|
||
rs := []rune(combined)
|
||
combined = string(rs[:maxCombinedReferenceRunes]) + "\n\n(参考资料后文已截断)"
|
||
}
|
||
return combined, ""
|
||
}
|
||
|
||
// appendSuperArticleWritingRules 在用户提示词与参考素材组装结果末尾追加固定写作规则(保证规则段不被 maxCombinedReferenceRunes 截断)。
|
||
func appendSuperArticleWritingRules(combined string) string {
|
||
suffix := strings.TrimSpace(superArticleWritingRulesSuffix)
|
||
if suffix == "" {
|
||
return strings.TrimSpace(combined)
|
||
}
|
||
suffixRunes := len([]rune(suffix))
|
||
sep := "\n\n---\n\n"
|
||
sepLen := len([]rune(sep))
|
||
maxBody := maxCombinedReferenceRunes - suffixRunes - sepLen
|
||
if maxBody < 0 {
|
||
maxBody = 0
|
||
}
|
||
base := strings.TrimSpace(combined)
|
||
if base != "" {
|
||
rs := []rune(base)
|
||
if len(rs) > maxBody {
|
||
base = string(rs[:maxBody]) + "\n\n(上文参考已截断)"
|
||
}
|
||
}
|
||
if base == "" {
|
||
return suffix
|
||
}
|
||
return base + sep + suffix
|
||
}
|
||
|
||
func snippetNameFromURL(raw string) string {
|
||
u, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil {
|
||
return "素材"
|
||
}
|
||
base := path.Base(u.Path)
|
||
if base == "" || base == "." || base == "/" {
|
||
return "素材"
|
||
}
|
||
return base
|
||
}
|
||
|
||
func materialFetchURLAllowed(cfg *config.Config, raw string) bool {
|
||
u, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil || u.Hostname() == "" {
|
||
return false
|
||
}
|
||
switch strings.ToLower(u.Scheme) {
|
||
case "http", "https":
|
||
default:
|
||
return false
|
||
}
|
||
host := strings.ToLower(u.Hostname())
|
||
escPath := u.EscapedPath()
|
||
if !strings.Contains(escPath, "/uploads/") {
|
||
return false
|
||
}
|
||
if cfg != nil && strings.TrimSpace(cfg.BaseURL) != "" {
|
||
if bu, err := url.Parse(strings.TrimSpace(cfg.BaseURL)); err == nil && bu.Hostname() != "" {
|
||
if host == strings.ToLower(bu.Hostname()) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
if strings.Contains(host, ".aliyuncs.com") {
|
||
return true
|
||
}
|
||
if strings.HasPrefix(host, "127.0.0.1") || host == "localhost" {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func fetchMaterialText(cfg *config.Config, rawURL string) (text string, note string) {
|
||
if !materialFetchURLAllowed(cfg, rawURL) {
|
||
return "", ""
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(rawURL), nil)
|
||
if err != nil {
|
||
return "", "无法下载该素材"
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", "下载素材失败,请稍后重试"
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Sprintf("下载素材失败(HTTP %d)", resp.StatusCode)
|
||
}
|
||
limited := io.LimitReader(resp.Body, maxArticleMaterialFetchBody)
|
||
body, err := io.ReadAll(limited)
|
||
if err != nil {
|
||
return "", "读取素材内容失败"
|
||
}
|
||
u, _ := url.Parse(strings.TrimSpace(rawURL))
|
||
ext := ""
|
||
if u != nil {
|
||
ext = strings.ToLower(path.Ext(path.Base(u.Path)))
|
||
}
|
||
ct := strings.ToLower(resp.Header.Get("Content-Type"))
|
||
if s, ok := bodyAsUTF8Reference(ct, ext, body); ok && strings.TrimSpace(s) != "" {
|
||
return strings.TrimSpace(s), ""
|
||
}
|
||
return "", "该文件未能解析为可读文本(如 PDF/Office 请改用 .txt/.md 说明要点,或与图片一起上传由模型看图理解)"
|
||
}
|
||
|
||
func bodyAsUTF8Reference(contentType, extHint string, body []byte) (string, bool) {
|
||
body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
|
||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||
if strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") || ct == "" {
|
||
if looksLikeUTF8ArticleRef(body) {
|
||
return string(body), true
|
||
}
|
||
}
|
||
switch extHint {
|
||
case ".txt", ".md", ".json", ".csv", ".log", ".yaml", ".yml":
|
||
if looksLikeUTF8ArticleRef(body) {
|
||
return string(body), true
|
||
}
|
||
}
|
||
if looksLikeUTF8ArticleRef(body) {
|
||
return string(body), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func looksLikeUTF8ArticleRef(b []byte) bool {
|
||
if len(b) == 0 {
|
||
return false
|
||
}
|
||
if !utf8.Valid(b) {
|
||
return false
|
||
}
|
||
s := string(b)
|
||
printable := 0
|
||
total := 0
|
||
for _, r := range s {
|
||
if r == '\n' || r == '\r' || r == '\t' {
|
||
printable++
|
||
total++
|
||
continue
|
||
}
|
||
total++
|
||
if unicode.IsPrint(r) || unicode.IsSpace(r) {
|
||
printable++
|
||
}
|
||
}
|
||
if total == 0 {
|
||
return false
|
||
}
|
||
return float64(printable)/float64(total) > 0.90
|
||
}
|
||
|
||
// superArticleModelLikelySupportsVision 粗略判断 chat/completions 模型是否适合传 image_url(智增增/OpenAI 等多网关)。
|
||
func superArticleModelLikelySupportsVision(model string) bool {
|
||
m := strings.ToLower(strings.TrimSpace(model))
|
||
if m == "" {
|
||
return false
|
||
}
|
||
if strings.Contains(m, "vision") || strings.Contains(m, "-vl") || strings.Contains(m, "vqa") {
|
||
return true
|
||
}
|
||
if strings.Contains(m, "gpt-4") || strings.Contains(m, "gpt-5") || strings.Contains(m, "gpt-4o") {
|
||
return true
|
||
}
|
||
if strings.Contains(m, "claude-3") || strings.Contains(m, "claude-4") {
|
||
return true
|
||
}
|
||
if strings.Contains(m, "glm-4v") || strings.Contains(m, "qwen-vl") || strings.Contains(m, "gemini") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// aiGenerateArticle 调用 OpenAI 兼容 API 生成文章标题和正文。
|
||
// 若有参考图片 URL:使用多模态 user content(text + image_url);当前主模型不支持 vision 时自动改用 SUPER_ARTICLE_AI_VISION_MODEL(默认同为 gpt-4o-mini)。
|
||
func aiGenerateArticle(cfg *config.Config, description string, imageURLs []string) (title, content string, err error) {
|
||
apiKey := strings.TrimSpace(cfg.SuperArticleAIAPIKey)
|
||
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.SuperArticleAIBaseURL), "/")
|
||
if baseURL == "" {
|
||
baseURL = "https://api.openai.com/v1"
|
||
}
|
||
baseModel := strings.TrimSpace(cfg.SuperArticleAIModel)
|
||
if baseModel == "" {
|
||
baseModel = "gpt-4o-mini"
|
||
}
|
||
|
||
requestModel := baseModel
|
||
useVision := len(imageURLs) > 0
|
||
if useVision {
|
||
if superArticleModelLikelySupportsVision(baseModel) {
|
||
requestModel = baseModel
|
||
} else {
|
||
vm := strings.TrimSpace(cfg.SuperArticleAIVisionModel)
|
||
if vm == "" {
|
||
vm = "gpt-4o-mini"
|
||
}
|
||
requestModel = vm
|
||
}
|
||
}
|
||
|
||
var userContent interface{}
|
||
if useVision {
|
||
parts := []map[string]interface{}{
|
||
{"type": "text", "text": buildUserPrompt(description, imageURLs, true)},
|
||
}
|
||
for _, u := range imageURLs {
|
||
parts = append(parts, map[string]interface{}{
|
||
"type": "image_url",
|
||
"image_url": map[string]string{
|
||
"url": u,
|
||
"detail": "low",
|
||
},
|
||
})
|
||
}
|
||
userContent = parts
|
||
} else {
|
||
userContent = buildUserPrompt(description, imageURLs, false)
|
||
}
|
||
|
||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||
"model": requestModel,
|
||
"messages": []map[string]interface{}{
|
||
{"role": "system", "content": aiArticleSystemPrompt},
|
||
{"role": "user", "content": userContent},
|
||
},
|
||
"temperature": 0.8,
|
||
"max_tokens": 8192,
|
||
})
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||
defer cancel()
|
||
|
||
httpReq, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/chat/completions", bytes.NewReader(reqBody))
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("构建请求失败: %v", err)
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||
|
||
resp, err := http.DefaultClient.Do(httpReq)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("请求 AI 服务失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, errRead := io.ReadAll(resp.Body)
|
||
if errRead != nil {
|
||
return "", "", fmt.Errorf("读取 AI 响应失败: %v", errRead)
|
||
}
|
||
|
||
var apiResp struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", "", fmt.Errorf("AI 服务 HTTP %d", resp.StatusCode)
|
||
}
|
||
return "", "", fmt.Errorf("解析 AI 响应失败")
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
if apiResp.Error != nil && strings.TrimSpace(apiResp.Error.Message) != "" {
|
||
return "", "", fmt.Errorf("%s", strings.TrimSpace(apiResp.Error.Message))
|
||
}
|
||
snippet := strings.TrimSpace(string(body))
|
||
if len(snippet) > 400 {
|
||
snippet = snippet[:400] + "…"
|
||
}
|
||
return "", "", fmt.Errorf("AI 服务 HTTP %d %s", resp.StatusCode, snippet)
|
||
}
|
||
if apiResp.Error != nil && strings.TrimSpace(apiResp.Error.Message) != "" {
|
||
return "", "", fmt.Errorf("%s", strings.TrimSpace(apiResp.Error.Message))
|
||
}
|
||
if len(apiResp.Choices) == 0 || apiResp.Choices[0].Message.Content == "" {
|
||
return "", "", fmt.Errorf("AI 无返回内容")
|
||
}
|
||
|
||
return parseArticleJSON(apiResp.Choices[0].Message.Content)
|
||
}
|
||
|
||
func buildUserPrompt(description string, imageURLs []string, visionMode bool) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("根据以下素材,创作一篇精彩的文章:\n\n")
|
||
sb.WriteString("用户描述:")
|
||
sb.WriteString(description)
|
||
if len(imageURLs) > 0 {
|
||
if visionMode {
|
||
sb.WriteString(fmt.Sprintf("\n\n(附%d张图片,请结合图片内容写作)", len(imageURLs)))
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf("\n\n(用户上传了%d张图片作为参考素材,请在文章中适当体现图片场景)", len(imageURLs)))
|
||
}
|
||
}
|
||
sb.WriteString("\n\n请严格输出 JSON 格式:{\"title\":\"标题\",\"content\":\"正文\"}")
|
||
return sb.String()
|
||
}
|
||
|
||
func parseArticleJSON(raw string) (title, content string, err error) {
|
||
raw = strings.TrimSpace(raw)
|
||
// 去掉 markdown 代码块包裹
|
||
if idx := strings.Index(raw, "{"); idx >= 0 {
|
||
if end := strings.LastIndex(raw, "}"); end > idx {
|
||
raw = raw[idx : end+1]
|
||
}
|
||
}
|
||
|
||
var article struct {
|
||
Title string `json:"title"`
|
||
Content string `json:"content"`
|
||
}
|
||
if jsonErr := json.Unmarshal([]byte(raw), &article); jsonErr != nil {
|
||
// 降级:第一行当标题,其余为正文
|
||
lines := strings.SplitN(raw, "\n", 2)
|
||
if len(lines) >= 2 {
|
||
return strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]), nil
|
||
}
|
||
return "", "", fmt.Errorf("解析 AI 输出失败,请重试")
|
||
}
|
||
|
||
title = strings.TrimSpace(article.Title)
|
||
content = strings.TrimSpace(article.Content)
|
||
if title == "" || content == "" {
|
||
return "", "", fmt.Errorf("AI 生成内容不完整,请重试")
|
||
}
|
||
|
||
// 截断超限内容(派对体长文可到数千字,预留上限避免极端响应撑爆存储)
|
||
if tr := []rune(title); len(tr) > 120 {
|
||
title = string(tr[:120])
|
||
}
|
||
if cr := []rune(content); len(cr) > 20000 {
|
||
content = string(cr[:20000])
|
||
}
|
||
|
||
return title, content, nil
|
||
}
|