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 = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。 写作要求: - 标题:精炼有力,≤30字,有吸引力,点明核心价值 - 正文:600-1000字,有真实故事或案例,有观点洞察,语气自然真诚 - 风格:像朋友分享经验,而非说教;有画面感,接地气 - 不要使用"首先、其次、最后"等模板化结构词 严格输出 JSON,不要有任何额外文字: {"title":"文章标题","content":"文章正文"}` 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.OpenAIAPIKey) == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请联系管理员配置"}) 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 } 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, "" } 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 } // aiGenerateArticle 调用 OpenAI 兼容 API 生成文章标题和正文。 // 若有图片 URL 且模型支持视觉,以多模态格式传入;否则退化为纯文本。 func aiGenerateArticle(cfg *config.Config, description string, imageURLs []string) (title, content string, err error) { apiKey := strings.TrimSpace(cfg.OpenAIAPIKey) baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.OpenAIBaseURL), "/") if baseURL == "" { baseURL = "https://api.openai.com/v1" } model := strings.TrimSpace(cfg.OpenAIModel) if model == "" { model = "gpt-4o-mini" } // 判断模型是否支持视觉(OpenAI gpt-4 系列 / claude-3 系列) supportsVision := len(imageURLs) > 0 && ( strings.Contains(model, "gpt-4") || strings.Contains(model, "claude-3") || strings.Contains(model, "vision")) var userContent interface{} if supportsVision { 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": model, "messages": []map[string]interface{}{ {"role": "system", "content": aiArticleSystemPrompt}, {"role": "user", "content": userContent}, }, "temperature": 0.8, "max_tokens": 2000, }) ctx, cancel := context.WithTimeout(context.Background(), 90*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, _ := io.ReadAll(resp.Body) 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 { return "", "", fmt.Errorf("解析 AI 响应失败") } if apiResp.Error != nil { return "", "", fmt.Errorf("%s", 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) > 40 { title = string(tr[:40]) } if cr := []rune(content); len(cr) > 5000 { content = string(cr[:5000]) } return title, content, nil }