Files
Mycontent/soul-api/internal/handler/miniprogram_ai_article.go
乘风 83a420f1b9 feat: implement audit mode restrictions across various pages
- 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.
2026-04-24 18:23:34 +08:00

221 lines
6.8 KiB
Go
Raw 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"
"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
}