仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/handlers/api.go 7.8 KB · 254 行 原始文件
1// RESTful API(/api/v1):覆盖帖子列表/详情/发布/上传/点赞/评论/话题/举报。
2// 鉴权策略:
3// - 读取类接口(列表/详情/话题)与匿名互动(点赞/评论/举报)无需鉴权,与前台行为一致;
4// - 发布帖子与媒体上传需要 已登录会话 或 X-API-Key(供第三方客户端调用,防刷)。
5package handlers
6
7import (
8 "encoding/json"
9 "io"
10 "net/http"
11 "strings"
12
13 "clearlove/internal/ai"
14 "clearlove/internal/database"
15 "clearlove/internal/middleware"
16 "clearlove/internal/models"
17 "clearlove/internal/plugin"
18 "clearlove/internal/util"
19)
20
21// apiAuth 需鉴权的接口使用:API Key(第三方客户端)或已登录会话
22func apiAuth(r *http.Request) (*models.User, bool) {
23 if middleware.HasAPIKey(r) {
24 return CurrentUser(r), true // Key 合法即放行(用户可为空=匿名身份)
25 }
26 if u := CurrentUser(r); u != nil {
27 return u, true
28 }
29 return nil, false
30}
31
32// CaptchaJSON 获取人机验证码(内联 SVG)
33func CaptchaJSON(w http.ResponseWriter, r *http.Request) {
34 id, svg := util.NewCaptcha()
35 JSON(w, http.StatusOK, map[string]any{"id": id, "svg": svg})
36}
37
38// APIListPosts 帖子分页列表(瀑布流无限滚动数据源,公开访问)
39func APIListPosts(w http.ResponseWriter, r *http.Request) {
40 before := formInt64(r, "before")
41 limit := int(formInt64(r, "limit"))
42 if limit <= 0 || limit > 30 {
43 limit = 10
44 }
45 okJSON(w, map[string]any{"posts": fetchCards(before, limit, r.URL.Query().Get("topic"))})
46}
47
48// APIGetPost 帖子详情(含全部评论,公开访问)
49func APIGetPost(w http.ResponseWriter, r *http.Request) {
50 id := pathInt64(r, "id")
51 if !isVisible(id) {
52 fail(w, http.StatusNotFound, "帖子不存在或不可见")
53 return
54 }
55 okJSON(w, map[string]any{"post": singleCard(id), "comments": allComments(id)})
56}
57
58// APILike 点赞/取消点赞(匿名可用,按浏览器指纹去重)
59func APILike(w http.ResponseWriter, r *http.Request) {
60 postID := pathInt64(r, "id")
61 if models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", postID) == 0 {
62 fail(w, 404, "帖子不存在或不可见")
63 return
64 }
65 // 点赞身份:优先浏览器指纹,登录用户回退到账号 ID
66 fp := util.FingerprintOf(r)
67 if fp == "" {
68 if u := CurrentUser(r); u != nil {
69 fp = "u" + int64ToStr(u.ID)
70 }
71 }
72 if fp == "" {
73 fail(w, 400, "无法识别点赞身份,请刷新页面后重试")
74 return
75 }
76 n, liked := toggleLike(postID, fp)
77 okJSON(w, map[string]any{"likes": n, "liked": liked})
78}
79
80// int64ToStr 快速整型转字符串
81func int64ToStr(n int64) string {
82 if n == 0 {
83 return "0"
84 }
85 var b [20]byte
86 i := len(b)
87 neg := n < 0
88 if neg {
89 n = -n
90 }
91 for n > 0 {
92 i--
93 b[i] = byte('0' + n%10)
94 n /= 10
95 }
96 if neg {
97 i--
98 b[i] = '-'
99 }
100 return string(b[i:])
101}
102
103// APIComment 发表评论(匿名可用)
104func APIComment(w http.ResponseWriter, r *http.Request) {
105 if middleware.IsFingerprintBanned(util.FingerprintOf(r)) {
106 fail(w, 403, "您的浏览器指纹已被封禁")
107 return
108 }
109 postID := formInt64(r, "post_id")
110 content := plugin.ApplyFilterStr("filter.content", util.StripHTML(r.FormValue("content")))
111 cid, errMsg := addComment(postID, util.StripHTML(r.FormValue("nickname")), content, util.FingerprintOf(r), CurrentUser(r))
112 if errMsg != "" {
113 fail(w, 400, errMsg)
114 return
115 }
116 okJSON(w, map[string]any{"id": cid})
117}
118
119// APITopics 话题列表
120func APITopics(w http.ResponseWriter, r *http.Request) {
121 okJSON(w, map[string]any{"topics": listTopics()})
122}
123
124// APIReport 提交举报(匿名可用)
125func APIReport(w http.ResponseWriter, r *http.Request) {
126 postID := formInt64(r, "post_id")
127 reason := util.StripHTML(r.FormValue("reason"))
128 if models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=?", postID) == 0 {
129 fail(w, 404, "帖子不存在")
130 return
131 }
132 _, err := database.DB.Exec(
133 "INSERT INTO reports(post_id,reason,reporter_ip,status,created_at) VALUES(?,?,?,0,?)",
134 postID, reason, util.ClientIP(r), models.Now())
135 if err != nil {
136 fail(w, 500, "举报提交失败")
137 return
138 }
139 plugin.Emit("report_created", map[string]any{"post_id": postID, "reason": reason})
140 okJSON(w, map[string]any{"msg": "举报已提交,感谢您的监督"})
141}
142
143// APICreatePost API 发帖(JSON 体,供第三方客户端;媒体经 /api/v1/upload 预传)
144func APICreatePost(w http.ResponseWriter, r *http.Request) {
145 u, ok := apiAuth(r)
146 if !ok {
147 fail(w, http.StatusUnauthorized, "请先登录或携带 API Key")
148 return
149 }
150 if middleware.IsFingerprintBanned(r.Header.Get("X-Fingerprint")) {
151 fail(w, 403, "您的浏览器指纹已被封禁")
152 return
153 }
154 var body struct {
155 Content string `json:"content"`
156 Nickname string `json:"nickname"`
157 Topic string `json:"topic"`
158 Images []string `json:"images"`
159 Videos []string `json:"videos"`
160 // 使用管理员专享昵称时必填
161 AdminUser string `json:"admin_user"`
162 AdminPass string `json:"admin_pass"`
163 }
164 if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
165 fail(w, 400, "JSON 格式错误")
166 return
167 }
168 content := plugin.ApplyFilterStr("filter.content", util.StripHTML(body.Content))
169 if content == "" || len([]rune(content)) > 3000 {
170 fail(w, 400, "帖子内容需为 1-3000 字")
171 return
172 }
173 if len(body.Images) > 15 || len(body.Videos) > 3 {
174 fail(w, 400, "图片最多15张、视频最多3个")
175 return
176 }
177 // AI 预审(含图片,需多模态模型;失败按 ai_precheck_fail 策略:open 放行 / closed 拦截)
178 if models.GetSetting("ai_precheck") == "1" {
179 bad, reason, err := ai.Moderate(content, body.Images)
180 if err != nil {
181 util.Log("warn", "AI API 预审失败: %v", err)
182 if models.GetSetting("ai_precheck_fail") == "closed" {
183 fail(w, http.StatusServiceUnavailable, "自动审核服务暂时不可用,请稍后再试")
184 return
185 }
186 } else if bad {
187 fail(w, 422, "内容未通过自动审核:"+reason)
188 return
189 }
190 }
191 nickname := util.StripHTML(body.Nickname)
192 if nickname == "" {
193 nickname = "匿名"
194 }
195 // 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
196 isAdmin, badgeList, code, gErr := guardCompose(nickname,
197 strings.TrimSpace(body.AdminUser), body.AdminPass, util.ClientIP(r))
198 if gErr != "" {
199 fail(w, code, gErr)
200 return
201 }
202 topicID := int64(0)
203 if t := util.StripHTML(body.Topic); t != "" {
204 topicID = ensureTopic(t)
205 }
206 res, err := database.DB.Exec(
207 "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
208 userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"),
209 isAdmin, models.BadgesJSON(badgeList), models.Now())
210 if err != nil {
211 fail(w, 500, "发布失败")
212 return
213 }
214 pid, _ := res.LastInsertId()
215 for _, p := range body.Images {
216 if strings.HasPrefix(p, "/uploads/") {
217 _, _ = database.DB.Exec("INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)", pid, "image", p, models.Now())
218 }
219 }
220 for _, p := range body.Videos {
221 if strings.HasPrefix(p, "/uploads/") {
222 _, _ = database.DB.Exec("INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)", pid, "video", p, models.Now())
223 }
224 }
225 plugin.Emit("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
226 okJSON(w, map[string]any{"id": pid})
227}
228
229// APIUpload API 媒体上传(multipart,字段 file),返回可访问路径
230func APIUpload(w http.ResponseWriter, r *http.Request) {
231 if _, ok := apiAuth(r); !ok {
232 fail(w, http.StatusUnauthorized, "请携带 API Key")
233 return
234 }
235 r.Body = http.MaxBytesReader(w, r.Body, 75<<20)
236 f, fh, err := r.FormFile("file")
237 if err != nil {
238 fail(w, 400, "缺少文件字段 file")
239 return
240 }
241 f.Close()
242 var path string
243 switch {
244 case strings.HasPrefix(fh.Header.Get("Content-Type"), "video/"):
245 path, err = util.SaveVideo(fh)
246 default:
247 path, err = util.SaveImage(fh)
248 }
249 if err != nil {
250 fail(w, 400, err.Error())
251 return
252 }
253 okJSON(w, map[string]any{"url": path})
254}