Files
Mycontent/soul-api/internal/handler/miniprogram_ai_article.go
乘风 2645212d47 feat: update API base URLs and enhance icon designs
- Updated the API base URLs in app.js to point to local development server for testing.
- Enhanced SVG icons with gradient fills for improved visual appeal across various components.
- Refactored the avatar upload functionality in avatar-nickname.js and profile-edit.js to utilize a new upload utility for better code maintainability.
- Improved the layout and styling of the my page and super article editor for a more user-friendly interface.

This update aims to streamline development processes and enhance the overall user experience in the application.
2026-05-09 16:00:16 +08:00

423 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}