- 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.
423 lines
13 KiB
Go
423 lines
13 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 = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
|
||
|
||
写作要求:
|
||
- 标题:精炼有力,≤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
|
||
}
|