- Added checks for audit mode in VIP navigation and member detail pages, preventing access and displaying appropriate messages. - Updated UI elements to conditionally render based on audit mode, enhancing user experience by hiding VIP-related options when in audit mode. - Introduced loading indicators on the VIP page to manage user expectations during content validation. - Enhanced error handling for image uploads in the article editor, ensuring a smoother content creation process. This update aims to improve the overall user experience by enforcing access restrictions and providing clear feedback in audit mode.
221 lines
6.8 KiB
Go
221 lines
6.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
|
||
|
||
写作要求:
|
||
- 标题:精炼有力,≤30字,有吸引力,点明核心价值
|
||
- 正文:600-1000字,有真实故事或案例,有观点洞察,语气自然真诚
|
||
- 风格:像朋友分享经验,而非说教;有画面感,接地气
|
||
- 不要使用"首先、其次、最后"等模板化结构词
|
||
|
||
严格输出 JSON,不要有任何额外文字:
|
||
{"title":"文章标题","content":"文章正文"}`
|
||
|
||
// MiniprogramSuperArticleGenerate POST /api/miniprogram/super/articles/generate
|
||
// 根据描述 + 图片 URL,调用 AI 生成文章草稿(仅超级个体可用)。
|
||
func MiniprogramSuperArticleGenerate(c *gin.Context) {
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Description string `json:"description"`
|
||
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)
|
||
if req.UserID == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||
return
|
||
}
|
||
if req.Description == "" {
|
||
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 req.ImageURLs {
|
||
if u = strings.TrimSpace(u); u != "" {
|
||
validImageURLs = append(validImageURLs, u)
|
||
}
|
||
}
|
||
|
||
title, content, err := aiGenerateArticle(cfg, req.Description, 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}})
|
||
}
|
||
|
||
// 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
|
||
}
|