仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/handlers/front.go 23.9 KB · 789 行 原始文件
1// 前台:首页瀑布流、帖子详情、发帖(含 WebP 转换与人机验证)、
2// 点赞、评论、举报、个人主页、帖子编辑删除。
3package handlers
4
5import (
6 "net/http"
7 "path"
8 "strings"
9 "sync"
10 "time"
11
12 "clearlove/internal/ai"
13 "clearlove/internal/database"
14 "clearlove/internal/middleware"
15 "clearlove/internal/models"
16 "clearlove/internal/plugin"
17 "clearlove/internal/util"
18)
19
20// ---------- 管理员专享昵称:凭证校验与限流 ----------
21
22// adminTry 单个 IP 的失败计数(防止在线爆破后台密码)
23type adminTry struct {
24 Count int
25 Until time.Time
26}
27
28var (
29 adminTryMu sync.Mutex
30 adminTries = map[string]adminTry{}
31)
32
33// verifyAdminCreds 校验后台管理员账号密码(仅校验,不建立会话)
34func verifyAdminCreds(username, password string) bool {
35 if username == "" || password == "" {
36 return false
37 }
38 var hash string
39 if err := database.DB.QueryRow("SELECT password FROM admins WHERE username=?", username).Scan(&hash); err != nil {
40 return false
41 }
42 return util.CheckPassword(hash, password)
43}
44
45// adminTryBlocked 是否因失败次数过多被暂时锁定(5 次 / 10 分钟)
46func adminTryBlocked(ip string) bool {
47 adminTryMu.Lock()
48 defer adminTryMu.Unlock()
49 t, ok := adminTries[ip]
50 if !ok {
51 return false
52 }
53 if time.Now().After(t.Until) {
54 delete(adminTries, ip)
55 return false
56 }
57 return t.Count >= 5
58}
59
60func adminTryFail(ip string) {
61 adminTryMu.Lock()
62 defer adminTryMu.Unlock()
63 t := adminTries[ip]
64 t.Count++
65 t.Until = time.Now().Add(10 * time.Minute)
66 adminTries[ip] = t
67}
68
69func adminTryReset(ip string) {
70 adminTryMu.Lock()
71 defer adminTryMu.Unlock()
72 delete(adminTries, ip)
73}
74
75// guardCompose 执行发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard 钩子。
76// 返回 (是否管理员帖, 帖子标识列表, 错误码, 错误提示)
77func guardCompose(nickname, adminUser, adminPass, ip string) (int, []string, int, string) {
78 labels := plugin.NicknameBadges(nickname)
79 needAdmin := false
80 msg := ""
81
82 if models.IsAdminNickname(nickname) {
83 needAdmin = true
84 labels = append(labels, "管理员")
85 msg = "「" + nickname + "」为管理员专享昵称,请填写正确的后台账号与密码"
86 }
87 if g := plugin.CheckComposeGuard(nickname); g != nil {
88 labels = append(labels, g.Labels...)
89 if g.Require {
90 needAdmin = true
91 if msg == "" {
92 msg = g.Message
93 }
94 }
95 }
96 // 应用型插件可追加/放宽守卫要求(filter.compose.guard)
97 if plugin.HasApps() {
98 out := plugin.ApplyFilterValue("filter.compose.guard", map[string]any{
99 "require": needAdmin,
100 "nickname": nickname,
101 "labels": labels,
102 "message": msg,
103 }, nil)
104 if m, ok := out.(map[string]any); ok {
105 needAdmin = boolOfAny(m["require"])
106 if v := strOfAny(m["message"]); v != "" {
107 msg = v
108 }
109 if list := anyToStrings(m["labels"]); list != nil {
110 labels = list
111 }
112 }
113 }
114 if !needAdmin {
115 return 0, models.MergeBadges(labels), 0, ""
116 }
117 if msg == "" {
118 msg = "该昵称为管理员专享,请填写正确的后台账号与密码"
119 }
120 if adminTryBlocked(ip) {
121 return 0, nil, 429, "验证失败次数过多,请 10 分钟后再试"
122 }
123 if !verifyAdminCreds(adminUser, adminPass) {
124 adminTryFail(ip)
125 return 0, nil, 403, msg
126 }
127 adminTryReset(ip)
128 return 1, models.MergeBadges(labels), 0, ""
129}
130
131// CommentItem 评论预览项
132type CommentItem struct {
133 Nickname string `json:"nickname"`
134 Content string `json:"content"`
135 CreatedAt string `json:"created_at"`
136}
137
138// Card 帖子卡片(模板与 API 共用)
139type Card struct {
140 ID int64 `json:"id"`
141 UserID int64 `json:"user_id"`
142 Nickname string `json:"nickname"`
143 Content string `json:"content"`
144 Topic string `json:"topic"`
145 Images []string `json:"images"`
146 Videos []string `json:"videos"`
147 Likes int64 `json:"likes"`
148 CommentCount int64 `json:"comment_count"`
149 IsAdmin int `json:"is_admin"`
150 BadgesRaw string `json:"-"`
151 Badges []string `json:"badges"`
152 Comments []CommentItem `json:"comments"`
153 CreatedAt string `json:"created_at"`
154}
155
156// latestNotice 获取最新一条公告
157func latestNotice() (int64, string, string) {
158 var id int64
159 var title, content string
160 err := database.DB.QueryRow(
161 "SELECT id,IFNULL(title,''),IFNULL(content,'') FROM notices ORDER BY id DESC LIMIT 1").Scan(&id, &title, &content)
162 if err != nil {
163 return 0, "", ""
164 }
165 return id, title, content
166}
167
168// fetchCards 分页拉取可见帖子(before=0 表示从最新开始),可按话题过滤,
169// 附带媒体与最新4条评论。
170func fetchCards(before int64, limit int, topic string) []Card {
171 q := `
172 SELECT p.id,p.user_id,IFNULL(p.nickname,'匿名'),IFNULL(p.content,''),IFNULL(t.name,''),
173 p.like_count,p.comment_count,IFNULL(p.is_admin,0),IFNULL(p.badges,''),p.created_at
174 FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
175 WHERE p.status=1 AND (? <= 0 OR p.id < ?)`
176 args := []any{before, before}
177 if topic != "" {
178 q += " AND t.name=?"
179 args = append(args, topic)
180 }
181 q += " ORDER BY p.id DESC LIMIT ?"
182 args = append(args, limit)
183 rows, err := database.DB.Query(q, args...)
184 if err != nil {
185 util.Log("error", "查询帖子失败: %v", err)
186 return nil
187 }
188 defer rows.Close()
189 var order []int64
190 byID := map[int64]*Card{}
191 for rows.Next() {
192 c := Card{Images: []string{}, Videos: []string{}, Comments: []CommentItem{}}
193 if err := rows.Scan(&c.ID, &c.UserID, &c.Nickname, &c.Content, &c.Topic,
194 &c.Likes, &c.CommentCount, &c.IsAdmin, &c.BadgesRaw, &c.CreatedAt); err == nil {
195 order = append(order, c.ID)
196 byID[c.ID] = &c
197 }
198 }
199 if len(order) == 0 {
200 return []Card{}
201 }
202 // 批量查媒体
203 ph, args := buildIN(order)
204 mrows, err := database.DB.Query(
205 "SELECT post_id,type,path FROM medias WHERE post_id IN ("+ph+") ORDER BY id", args...)
206 if err == nil {
207 defer mrows.Close()
208 for mrows.Next() {
209 var pid int64
210 var typ, path string
211 if mrows.Scan(&pid, &typ, &path) == nil {
212 if c := byID[pid]; c != nil {
213 if typ == "video" {
214 c.Videos = append(c.Videos, path)
215 } else {
216 c.Images = append(c.Images, path)
217 }
218 }
219 }
220 }
221 }
222 // 批量查每帖最新4条评论(倒序取前4再反转)
223 crows, err := database.DB.Query(
224 "SELECT post_id,IFNULL(nickname,'匿名'),IFNULL(content,''),IFNULL(created_at,'') FROM comments WHERE status=1 AND post_id IN ("+ph+") ORDER BY post_id, id DESC", args...)
225 if err == nil {
226 defer crows.Close()
227 counts := map[int64]int{}
228 for crows.Next() {
229 var pid int64
230 var it CommentItem
231 if crows.Scan(&pid, &it.Nickname, &it.Content, &it.CreatedAt) == nil && counts[pid] < 4 {
232 counts[pid]++
233 if c := byID[pid]; c != nil {
234 c.Comments = append(c.Comments, it)
235 }
236 }
237 }
238 }
239 out := make([]Card, 0, len(order))
240 for _, id := range order {
241 c := byID[id]
242 c.Badges = models.DisplayBadges(int64(c.IsAdmin), c.BadgesRaw)
243 // 评论按时间正序展示
244 for i, j := 0, len(c.Comments)-1; i < j; i, j = i+1, j-1 {
245 c.Comments[i], c.Comments[j] = c.Comments[j], c.Comments[i]
246 }
247 applyCardFilter(c)
248 out = append(out, *c)
249 }
250 return out
251}
252
253// buildIN 生成 "?,?,?" 占位符
254func buildIN(ids []int64) (string, []any) {
255 ps := make([]string, len(ids))
256 args := make([]any, 0, len(ids))
257 for i, id := range ids {
258 ps[i] = "?"
259 args = append(args, id)
260 }
261 return strings.Join(ps, ","), args
262}
263
264// Index 首页(支持 ?topic= 过滤,瀑布流首屏由前端加载)
265func Index(w http.ResponseWriter, r *http.Request) {
266 data := map[string]any{
267 "topics": listTopics(),
268 "topic": r.URL.Query().Get("topic"),
269 }
270 if id, title, content := latestNotice(); id > 0 {
271 data["notice"] = map[string]any{"id": id, "title": title, "content": content}
272 }
273 Render(w, r, "layout.html", "pg_index", data)
274}
275
276// listTopics 全部话题
277func listTopics() []map[string]any {
278 out := []map[string]any{}
279 rows, err := database.DB.Query("SELECT id,name FROM topics ORDER BY id")
280 if err != nil {
281 return out
282 }
283 defer rows.Close()
284 for rows.Next() {
285 var id int64
286 var name string
287 if rows.Scan(&id, &name) == nil {
288 out = append(out, map[string]any{"id": id, "name": name})
289 }
290 }
291 return out
292}
293
294// PostDetail 帖子详情页(含全部评论与社区守则)
295func PostDetail(w http.ResponseWriter, r *http.Request) {
296 id := pathInt64(r, "id")
297 a := CurrentAdmin(r)
298 u := CurrentUser(r)
299 // 隐藏/删除的帖子仅管理员可见
300 if !isVisible(id) && a == nil {
301 http.Error(w, "该帖子不可见", http.StatusNotFound)
302 return
303 }
304 card := singleCard(id)
305 if card == nil {
306 http.NotFound(w, r)
307 return
308 }
309 data := map[string]any{
310 "post": *card,
311 "comments": allComments(id),
312 "canEdit": (u != nil && u.ID == card.UserID) || a != nil,
313 "rules": models.GetSetting("community_rules"),
314 }
315 Render(w, r, "layout.html", "pg_detail", data)
316}
317
318// isVisible 帖子是否可见(应用型插件可通过 filter.post.visible 追加可见性条件)
319func isVisible(id int64) bool {
320 ok := models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", id) > 0
321 if !ok || !plugin.HasApps() {
322 return ok
323 }
324 return plugin.ApplyFilterBool("filter.post.visible", ok, map[string]any{"post_id": id})
325}
326
327// singleCard 按 ID 构建单个卡片(详情页用,含媒体与评论)
328func singleCard(id int64) *Card {
329 p := loadPost(id)
330 if p == nil {
331 return nil
332 }
333 c := &Card{
334 ID: p.ID, UserID: p.UserID, Nickname: p.Nickname, Content: p.Content,
335 Likes: p.LikeCount, CommentCount: p.CommentCount, IsAdmin: p.IsAdmin,
336 Badges: models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw),
337 CreatedAt: p.CreatedAt,
338 Images: []string{}, Videos: []string{}, Comments: []CommentItem{},
339 }
340 c.Topic = models.QueryStr("SELECT name FROM topics WHERE id=?", p.TopicID)
341 rows, err := database.DB.Query("SELECT type,path FROM medias WHERE post_id=? ORDER BY id", id)
342 if err == nil {
343 for rows.Next() {
344 var typ, path string
345 if rows.Scan(&typ, &path) == nil {
346 if typ == "video" {
347 c.Videos = append(c.Videos, path)
348 } else {
349 c.Images = append(c.Images, path)
350 }
351 }
352 }
353 rows.Close()
354 }
355 c.Comments = nil
356 for _, cm := range allComments(id) {
357 c.Comments = append(c.Comments, CommentItem{
358 Nickname: cm["nickname"].(string), Content: cm["content"].(string), CreatedAt: cm["created_at"].(string),
359 })
360 }
361 applyCardFilter(c)
362 return c
363}
364
365// allComments 帖子全部可见评论(正序)
366func allComments(postID int64) []map[string]any {
367 out := []map[string]any{}
368 rows, err := database.DB.Query(
369 "SELECT IFNULL(nickname,'匿名'),IFNULL(content,''),IFNULL(created_at,'') FROM comments WHERE post_id=? AND status=1 ORDER BY id", postID)
370 if err != nil {
371 return out
372 }
373 defer rows.Close()
374 for rows.Next() {
375 var n, c, t string
376 if rows.Scan(&n, &c, &t) == nil {
377 out = append(out, map[string]any{"nickname": n, "content": c, "created_at": t})
378 }
379 }
380 return out
381}
382
383// ComposePage 发帖页
384func ComposePage(w http.ResponseWriter, r *http.Request) {
385 if models.GetSetting("require_login_post") == "1" && CurrentUser(r) == nil {
386 http.Redirect(w, r, "/login?next=/compose", http.StatusFound)
387 return
388 }
389 Render(w, r, "layout.html", "pg_compose", map[string]any{
390 "topics": listTopics(),
391 "requireLogin": models.GetSetting("require_login_post") == "1",
392 "maxImages": 15, "maxVideos": 3,
393 // AI 发帖预审是否生效(开启且模型已接入)——前端据此展示审核动画
394 "aiPrecheck": models.GetSetting("ai_precheck") == "1" && ai.Enabled(),
395 // 需要验证后台账号的昵称(内置管理员昵称 + 插件守卫),
396 // 前端据此在提交时弹出账号验证弹窗
397 "adminNicks": strings.Join(
398 append(models.AdminNicknames(), plugin.GuardNicknames()...), ","),
399 })
400}
401
402// ComposeSubmit 发帖提交(multipart):验证码校验 -> 内容校验 -> 媒体处理 ->
403// 封禁/预审 -> 入库。AJAX 请求返回 JSON,普通表单返回 303 重定向。
404func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
405 r.Body = http.MaxBytesReader(w, r.Body, 330<<20)
406 if err := r.ParseMultipartForm(32 << 20); err != nil {
407 fail(w, 400, "上传内容过大或表单无效")
408 return
409 }
410 fp := util.FingerprintOf(r)
411 if middleware.IsFingerprintBanned(fp) {
412 fail(w, 403, "您的浏览器指纹已被封禁")
413 return
414 }
415 if !util.VerifyCaptcha(r.FormValue("captcha_id"), r.FormValue("captcha_code")) {
416 fail(w, 400, "验证码错误或已过期")
417 return
418 }
419 content := plugin.ApplyFilterStr("filter.content", util.StripHTML(r.FormValue("content")))
420 if content == "" || len([]rune(content)) > 3000 {
421 fail(w, 400, "帖子内容需为 1-3000 字")
422 return
423 }
424 // 昵称:开启登录发帖且已登录时自动匿名
425 nickname := util.StripHTML(r.FormValue("nickname"))
426 if len([]rune(nickname)) > 20 {
427 nickname = string([]rune(nickname)[:20])
428 }
429 user := CurrentUser(r)
430 if user == nil && models.GetSetting("require_login_post") == "1" {
431 fail(w, 401, "请先登录后发帖")
432 return
433 }
434 if user != nil && models.GetSetting("require_login_post") == "1" {
435 nickname = "匿名"
436 }
437 if nickname == "" {
438 nickname = "匿名"
439 }
440
441 // 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
442 isAdmin, badgeList, code, gErr := guardCompose(nickname,
443 strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass"), util.ClientIP(r))
444 if gErr != "" {
445 fail(w, code, gErr)
446 return
447 }
448
449 // 先全量校验文件(数量/大小/格式),再落盘,避免部分文件写入后失败
450 imgs := r.MultipartForm.File["images"]
451 vids := r.MultipartForm.File["videos"]
452 if len(imgs) > util.MaxImages {
453 fail(w, 400, "图片最多上传 15 张")
454 return
455 }
456 if len(vids) > util.MaxVideos {
457 fail(w, 400, "视频最多上传 3 个")
458 return
459 }
460 for _, fh := range imgs {
461 ext := strings.ToLower(path.Ext(fh.Filename))
462 if !util.IsImageExt(ext) {
463 fail(w, 400, "不支持的图片格式: "+ext)
464 return
465 }
466 if fh.Size > util.MaxImageSize {
467 fail(w, 400, "单张图片不能超过 10MB")
468 return
469 }
470 }
471 for _, fh := range vids {
472 ext := strings.ToLower(path.Ext(fh.Filename))
473 if !util.IsVideoExt(ext) {
474 fail(w, 400, "不支持的视频格式: "+ext)
475 return
476 }
477 if fh.Size > util.MaxVideoSize {
478 fail(w, 400, "单个视频不能超过 70MB")
479 return
480 }
481 }
482 var media []struct {
483 typ string
484 path string
485 }
486 var mediaPaths []string // 供 AI 图片审核
487 for _, fh := range imgs {
488 p, err := util.SaveImage(fh)
489 if err != nil {
490 fail(w, 400, err.Error())
491 return
492 }
493 mediaPaths = append(mediaPaths, p)
494 media = append(media, struct {
495 typ string
496 path string
497 }{"image", p})
498 }
499 for _, fh := range vids {
500 p, err := util.SaveVideo(fh)
501 if err != nil {
502 fail(w, 400, err.Error())
503 return
504 }
505 media = append(media, struct {
506 typ string
507 path string
508 }{"video", p})
509 }
510
511 // 话题:选择已有或新建
512 topicID := formInt64(r, "topic_id")
513 if newName := util.StripHTML(r.FormValue("topic_new")); newName != "" && len([]rune(newName)) <= 30 {
514 topicID = ensureTopic(newName)
515 }
516
517 // AI 发帖预审(含图片,需多模态模型;失败按 ai_precheck_fail 策略:open 放行 / closed 拦截)
518 if models.GetSetting("ai_precheck") == "1" {
519 bad, reason, err := ai.Moderate(content, mediaPaths)
520 if err != nil {
521 util.Log("warn", "AI 发帖预审失败: %v", err)
522 if models.GetSetting("ai_precheck_fail") == "closed" {
523 fail(w, 503, "自动审核服务暂时不可用,请稍后再试")
524 return
525 }
526 } else if bad {
527 fail(w, 422, "内容未通过自动审核:"+reason+",请修改后重新发布")
528 return
529 }
530 }
531
532 // 入库
533 res, err := database.DB.Exec(
534 "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
535 userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin,
536 models.BadgesJSON(badgeList), models.Now())
537 if err != nil {
538 fail(w, 500, "发布失败,请稍后重试")
539 return
540 }
541 pid, _ := res.LastInsertId()
542 for _, m := range media {
543 _, _ = database.DB.Exec(
544 "INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)", pid, m.typ, m.path, models.Now())
545 }
546 plugin.Emit("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
547 if isAJAX(r) {
548 okJSON(w, map[string]any{"redirect": "/", "id": pid})
549 return
550 }
551 http.Redirect(w, r, "/", http.StatusSeeOther)
552}
553
554// userIDOf 空用户返回 0
555func userIDOf(u *models.User) int64 {
556 if u == nil {
557 return 0
558 }
559 return u.ID
560}
561
562// ensureTopic 话题不存在则创建,返回话题 ID
563func ensureTopic(name string) int64 {
564 id := models.QueryInt("SELECT id FROM topics WHERE name=?", name)
565 if id > 0 {
566 return id
567 }
568 res, err := database.DB.Exec("INSERT INTO topics(name,created_at) VALUES(?,?)", name, models.Now())
569 if err != nil {
570 return 0
571 }
572 id, _ = res.LastInsertId()
573 return id
574}
575
576// toggleLike 点赞/取消(按指纹去重,支持匿名)
577func toggleLike(postID int64, fp string) (int64, bool) {
578 if fp == "" {
579 return 0, false
580 }
581 exists := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
582 liked := exists == 0
583 if exists > 0 {
584 _, _ = database.DB.Exec("DELETE FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
585 } else {
586 // 唯一索引 idx_likes_uniq 拦截并发重复点赞,冲突报错直接忽略(等价于已被并发请求点亮)
587 _, _ = database.DB.Exec(
588 "INSERT INTO likes(post_id,fingerprint,created_at) VALUES(?,?,?)", postID, fp, models.Now())
589 }
590 // 单语句内重算计数,避免"读-改-写"交错导致 like_count 与实际行数永久漂移
591 _, _ = database.DB.Exec(
592 "UPDATE posts SET like_count=(SELECT COUNT(1) FROM likes WHERE post_id=?) WHERE id=?", postID, postID)
593 n := models.QueryInt("SELECT like_count FROM posts WHERE id=?", postID)
594 plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": liked, "count": n})
595 return n, liked
596}
597
598// addComment 发表评论
599func addComment(postID int64, nickname, content, fp string, u *models.User) (int64, string) {
600 content = util.StripHTML(content)
601 if content == "" || len([]rune(content)) > 500 {
602 return 0, "评论需为 1-500 字"
603 }
604 if models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", postID) == 0 {
605 return 0, "帖子不存在或不可见"
606 }
607 // AI 评论预审(失败按 ai_precheck_fail 策略:open 放行 / closed 拦截)
608 if models.GetSetting("ai_precheck") == "1" {
609 bad, reason, err := ai.Moderate(content, nil)
610 if err != nil {
611 util.Log("warn", "AI 评论预审失败: %v", err)
612 if models.GetSetting("ai_precheck_fail") == "closed" {
613 return 0, "自动审核服务暂时不可用,请稍后再试"
614 }
615 } else if bad {
616 return 0, "评论未通过自动审核:" + reason
617 }
618 }
619 if nickname == "" {
620 nickname = "匿名"
621 }
622 if len([]rune(nickname)) > 20 {
623 nickname = string([]rune(nickname)[:20])
624 }
625 res, err := database.DB.Exec(
626 "INSERT INTO comments(post_id,user_id,nickname,content,ip,fingerprint,created_at) VALUES(?,?,?,?,?,?,?)",
627 postID, userIDOf(u), nickname, content, "", fp, models.Now())
628 if err != nil {
629 return 0, "评论失败,请稍后重试"
630 }
631 cid, _ := res.LastInsertId()
632 _, _ = database.DB.Exec("UPDATE posts SET comment_count=comment_count+1 WHERE id=?", postID)
633 plugin.Emit("comment_created", map[string]any{"post_id": postID, "content": content})
634 return cid, ""
635}
636
637// PostEdit 编辑自己的帖子(管理员亦可)
638func PostEdit(w http.ResponseWriter, r *http.Request) {
639 id := pathInt64(r, "id")
640 p := loadPost(id)
641 if p == nil {
642 http.NotFound(w, r)
643 return
644 }
645 u := CurrentUser(r)
646 a := CurrentAdmin(r)
647 if (u == nil || u.ID != p.UserID) && a == nil {
648 http.Error(w, "没有编辑权限", http.StatusForbidden)
649 return
650 }
651 Render(w, r, "layout.html", "pg_edit", map[string]any{"post": p})
652}
653
654// PostEditSave 保存编辑(仅文字内容)
655func PostEditSave(w http.ResponseWriter, r *http.Request) {
656 id := pathInt64(r, "id")
657 p := loadPost(id)
658 if p == nil {
659 http.NotFound(w, r)
660 return
661 }
662 u := CurrentUser(r)
663 a := CurrentAdmin(r)
664 if (u == nil || u.ID != p.UserID) && a == nil {
665 http.Error(w, "没有编辑权限", http.StatusForbidden)
666 return
667 }
668 content := util.StripHTML(r.FormValue("content"))
669 if content == "" || len([]rune(content)) > 3000 {
670 http.Error(w, "内容需为 1-3000 字", http.StatusBadRequest)
671 return
672 }
673 if _, err := database.DB.Exec("UPDATE posts SET content=? WHERE id=?", content, id); err != nil {
674 http.Error(w, "保存失败", http.StatusInternalServerError)
675 return
676 }
677 plugin.Emit("post_updated", map[string]any{"post_id": id, "content": content})
678 http.Redirect(w, r, "/post/"+r.PathValue("id"), http.StatusSeeOther)
679}
680
681// loadPost 加载帖子基础信息
682func loadPost(id int64) *models.Post {
683 p := &models.Post{}
684 err := database.DB.QueryRow(
685 "SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE id=?", id,
686 ).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
687 if err != nil {
688 return nil
689 }
690 return p
691}
692
693// PostDelete 删除帖子(本人或管理员),级联清理关联数据
694func PostDelete(w http.ResponseWriter, r *http.Request) {
695 id := pathInt64(r, "id")
696 p := loadPost(id)
697 if p == nil {
698 http.NotFound(w, r)
699 return
700 }
701 u := CurrentUser(r)
702 a := CurrentAdmin(r)
703 if (u == nil || u.ID != p.UserID) && a == nil {
704 http.Error(w, "没有删除权限", http.StatusForbidden)
705 return
706 }
707 deletePostCascade(id)
708 plugin.Emit("post_deleted", map[string]any{"post_id": id})
709 if isAJAX(r) {
710 okJSON(w, map[string]any{"redirect": "/"})
711 return
712 }
713 http.Redirect(w, r, "/", http.StatusSeeOther)
714}
715
716// deletePostCascade 删除帖子及其评论、媒体、点赞(并清理磁盘媒体文件)
717func deletePostCascade(id int64) {
718 // 先取媒体路径,删除后清理磁盘文件
719 if rows, err := database.DB.Query("SELECT IFNULL(path,'') FROM medias WHERE post_id=?", id); err == nil {
720 for rows.Next() {
721 var p string
722 if rows.Scan(&p) == nil && p != "" {
723 util.RemoveUpload(p)
724 }
725 }
726 rows.Close()
727 }
728 for _, t := range []string{"comments", "medias", "likes"} {
729 _, _ = database.DB.Exec("DELETE FROM "+t+" WHERE post_id=?", id)
730 }
731 _, _ = database.DB.Exec("DELETE FROM posts WHERE id=?", id)
732}
733
734// Profile 个人主页:展示该用户发布的帖子
735func Profile(w http.ResponseWriter, r *http.Request) {
736 uid := pathInt64(r, "id")
737 u := models.GetUserByID(uid)
738 if u == nil {
739 http.NotFound(w, r)
740 return
741 }
742 self := CurrentUser(r)
743 a := CurrentAdmin(r)
744 where := "user_id=? AND status=1"
745 if (self != nil && self.ID == uid) || a != nil {
746 where = "user_id=?"
747 }
748 rows, err := database.DB.Query(
749 "SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
750 if err != nil {
751 http.Error(w, "查询失败", http.StatusInternalServerError)
752 return
753 }
754 defer rows.Close()
755 type own struct {
756 ID int64
757 Nickname string
758 Content string
759 LikeCount int64
760 CommentCount int64
761 IsAdmin int
762 BadgesRaw string
763 Badges []string
764 CreatedAt string
765 }
766 var posts []own
767 for rows.Next() {
768 var p own
769 _ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount,
770 &p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
771 p.Badges = models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw)
772 posts = append(posts, p)
773 }
774 Render(w, r, "layout.html", "pg_profile", map[string]any{
775 "owner": u, "posts": posts,
776 "self": self != nil && self.ID == uid, "admin": a != nil,
777 })
778}
779
780// isAJAX 是否为前端 AJAX 请求
781func isAJAX(r *http.Request) bool {
782 return r.Header.Get("X-Requested-With") == "XMLHttpRequest"
783}
784
785// Favicon 站点图标(内联 SVG)
786func Favicon(w http.ResponseWriter, r *http.Request) {
787 w.Header().Set("Content-Type", "image/svg+xml")
788 _, _ = w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#ff7ba9"/><path d="M16 24s-8-5.2-8-10.4C8 10 10.2 8 12.8 8c1.6 0 2.7.8 3.2 1.7C16.5 8.8 17.6 8 19.2 8 21.8 8 24 10 24 13.6 24 18.8 16 24 16 24z" fill="#fff"/></svg>`))
789}