chore: 清理 SSR 改造后的历史遗留死代码与误提交产物
- 删除图形验证码整条死链:service/captcha.go、APICaptcha、 GET /api/captcha、CaptchaService 装配、ErrCaptchaInvalid 及前端 api.captcha()(注册/登录已改用邮箱验证码,Verify 无任何调用方) - 删除 GET /api/posts/hot 死路由:APIHotPosts + PostService.HotPosts (热门排序走 /api/posts?sort=hot) - 删除 GET /api/posts/:id/revisions/:revId 死路由: APIPostRevisionDetail + PostService.GetRevision + ErrRevisionNotFound 及前端 api.postRevision()(历史面板仅用列表接口) - 停止跟踪误提交的 27.5MB Linux 二进制 jiang13-linux-amd64 与 .vite 依赖缓存;.gitignore 补充对应规则并移除已失效的 embed_static 条目
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,11 +8,12 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
/embed_static/static/spa/
|
/.vite/
|
||||||
|
|
||||||
# Go 编译产物
|
# Go 编译产物
|
||||||
/dist/
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
|
/jiang13-*
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
tmp-cookie.txt
|
tmp-cookie.txt
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "de7e4cff",
|
|
||||||
"configHash": "9a7296da",
|
|
||||||
"lockfileHash": "e3b0c442",
|
|
||||||
"browserHash": "8c168d3c",
|
|
||||||
"optimized": {},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
@@ -264,8 +264,6 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
postRevisions: (id: number) =>
|
postRevisions: (id: number) =>
|
||||||
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
||||||
postRevision: (id: number, revId: number) =>
|
|
||||||
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
|
|
||||||
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -582,7 +580,6 @@ export const api = {
|
|||||||
`/api/users/search?${sp}`,
|
`/api/users/search?${sp}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
|
||||||
logout: () => request('/api/logout', { method: 'POST' }),
|
logout: () => request('/api/logout', { method: 'POST' }),
|
||||||
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
||||||
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
|
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -1127,16 +1127,6 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
|
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHotPosts 近期活跃讨论(近 7 日有回复)
|
|
||||||
func (h *Handlers) APIHotPosts(c *gin.Context) {
|
|
||||||
items, err := h.Post.HotPosts(10)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"posts": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
// APITags 标签云(按使用次数聚合)
|
// APITags 标签云(按使用次数聚合)
|
||||||
func (h *Handlers) APITags(c *gin.Context) {
|
func (h *Handlers) APITags(c *gin.Context) {
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
||||||
@@ -1211,29 +1201,6 @@ func (h *Handlers) APIPostRevisions(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIPostRevisionDetail 查看某个历史版本
|
|
||||||
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
|
|
||||||
post, err := h.Post.FindByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uid := h.currentUserID(c)
|
|
||||||
isAdmin := h.isAdmin(c)
|
|
||||||
if !isAdmin && post.UserID != uid {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rev, err := h.Post.GetRevision(uint(id), uint(revID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isClientLimitError(err error) bool {
|
func isClientLimitError(err error) bool {
|
||||||
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
||||||
errors.Is(err, service.ErrSearchKeywordTooLong)
|
errors.Is(err, service.ErrSearchKeywordTooLong)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -33,7 +32,6 @@ type Handlers struct {
|
|||||||
Filter *service.SensitiveFilter
|
Filter *service.SensitiveFilter
|
||||||
Limiter *service.RateLimiter
|
Limiter *service.RateLimiter
|
||||||
Settings *service.ForumSettingsService
|
Settings *service.ForumSettingsService
|
||||||
Captcha *service.CaptchaService
|
|
||||||
Mail *service.MailService
|
Mail *service.MailService
|
||||||
EmailCode *service.EmailCodeService
|
EmailCode *service.EmailCodeService
|
||||||
OIDC *service.OIDCService
|
OIDC *service.OIDCService
|
||||||
@@ -114,18 +112,6 @@ func calcTotalPages(total int64, size int) int {
|
|||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
|
|
||||||
func (h *Handlers) APICaptcha(c *gin.Context) {
|
|
||||||
id, svg, err := h.Captcha.Generate()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"id": id,
|
|
||||||
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIRegisterConfig 注册页所需公开配置
|
// APIRegisterConfig 注册页所需公开配置
|
||||||
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||||
userCount := h.Auth.UserCount()
|
userCount := h.Auth.UserCount()
|
||||||
|
|||||||
Binary file not shown.
@@ -50,7 +50,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
|
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
|
||||||
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
||||||
limiter := service.NewRateLimiter(settingsSvc)
|
limiter := service.NewRateLimiter(settingsSvc)
|
||||||
captchaSvc := service.NewCaptchaService()
|
|
||||||
mailSvc := service.NewMailService(settingsSvc)
|
mailSvc := service.NewMailService(settingsSvc)
|
||||||
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
||||||
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
|
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
|
||||||
@@ -82,7 +81,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
||||||
Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
|
Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
|
||||||
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||||
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||||
OIDC: oidcSvc, Gitea: giteaSvc,
|
OIDC: oidcSvc, Gitea: giteaSvc,
|
||||||
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
|
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
|
||||||
SitePage: service.NewSitePageService(filter),
|
SitePage: service.NewSitePageService(filter),
|
||||||
@@ -127,13 +126,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/site-branding", h.APISiteBranding)
|
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||||
pubAPI.GET("/pages", h.APIPages)
|
pubAPI.GET("/pages", h.APIPages)
|
||||||
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
||||||
pubAPI.GET("/captcha", h.APICaptcha)
|
|
||||||
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
||||||
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
||||||
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
||||||
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
||||||
pubAPI.GET("/posts", h.APIPosts)
|
pubAPI.GET("/posts", h.APIPosts)
|
||||||
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
|
||||||
pubAPI.GET("/tags", h.APITags)
|
pubAPI.GET("/tags", h.APITags)
|
||||||
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||||
// search / recent 须在 :id 之前
|
// search / recent 须在 :id 之前
|
||||||
@@ -164,7 +161,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.PUT("/posts/:id", h.APIUpdatePost)
|
api.PUT("/posts/:id", h.APIUpdatePost)
|
||||||
api.DELETE("/posts/:id", h.APIDeletePost)
|
api.DELETE("/posts/:id", h.APIDeletePost)
|
||||||
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
||||||
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
|
||||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
||||||
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
captchaLen = 4
|
|
||||||
captchaTTL = 5 * time.Minute
|
|
||||||
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
captchaWidth = 120
|
|
||||||
captchaHeight = 40
|
|
||||||
)
|
|
||||||
|
|
||||||
type captchaEntry struct {
|
|
||||||
answer string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptchaService 内存图形验证码
|
|
||||||
type CaptchaService struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]captchaEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaService() *CaptchaService {
|
|
||||||
s := &CaptchaService{entries: make(map[string]captchaEntry)}
|
|
||||||
go s.cleanup()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate 生成验证码,返回 id 与 SVG 图片
|
|
||||||
func (s *CaptchaService) Generate() (id, svg string, err error) {
|
|
||||||
answer, err := randomCaptchaText(captchaLen)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
rawID := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(rawID); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
id = hex.EncodeToString(rawID)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.entries[id] = captchaEntry{
|
|
||||||
answer: strings.ToUpper(answer),
|
|
||||||
expiresAt: time.Now().Add(captchaTTL),
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return id, renderCaptchaSVG(answer), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify 校验验证码(一次性,大小写不敏感)
|
|
||||||
func (s *CaptchaService) Verify(id, answer string) bool {
|
|
||||||
if id == "" || answer == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
entry, ok := s.entries[id]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delete(s.entries, id)
|
|
||||||
if time.Now().After(entry.expiresAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CaptchaService) cleanup() {
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
for range ticker.C {
|
|
||||||
now := time.Now()
|
|
||||||
s.mu.Lock()
|
|
||||||
for id, entry := range s.entries {
|
|
||||||
if now.After(entry.expiresAt) {
|
|
||||||
delete(s.entries, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomCaptchaText(n int) (string, error) {
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(n)
|
|
||||||
max := big.NewInt(int64(len(captchaChars)))
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
idx, err := rand.Int(rand.Reader, max)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
b.WriteByte(captchaChars[idx.Int64()])
|
|
||||||
}
|
|
||||||
return b.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCaptchaSVG(text string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
|
||||||
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
|
|
||||||
))
|
|
||||||
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
|
|
||||||
|
|
||||||
// 干扰线
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
x1, _ := randInt(0, captchaWidth)
|
|
||||||
y1, _ := randInt(0, captchaHeight)
|
|
||||||
x2, _ := randInt(0, captchaWidth)
|
|
||||||
y2, _ := randInt(0, captchaHeight)
|
|
||||||
color := noiseColor()
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
|
|
||||||
x1, y1, x2, y2, color,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
step := captchaWidth / (len(text) + 1)
|
|
||||||
for i, ch := range text {
|
|
||||||
x := step*(i+1) - 6
|
|
||||||
y, _ := randInt(26, 34)
|
|
||||||
rot, _ := randInt(-18, 18)
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
|
|
||||||
x, y, rot, x+6, y-6, ch,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(`</svg>`)
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func randInt(min, max int) (int, error) {
|
|
||||||
if max <= min {
|
|
||||||
return min, nil
|
|
||||||
}
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
|
||||||
if err != nil {
|
|
||||||
return min, err
|
|
||||||
}
|
|
||||||
return min + int(n.Int64()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func noiseColor() string {
|
|
||||||
r, _ := randInt(160, 210)
|
|
||||||
g, _ := randInt(170, 220)
|
|
||||||
b, _ := randInt(160, 210)
|
|
||||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@ var (
|
|||||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||||
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
|
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
|
||||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
|
||||||
ErrInvalidSetting = errors.New("无效的设置值")
|
ErrInvalidSetting = errors.New("无效的设置值")
|
||||||
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
||||||
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
||||||
@@ -35,7 +34,6 @@ var (
|
|||||||
ErrPostTagsTooLong = errors.New("标签过长")
|
ErrPostTagsTooLong = errors.New("标签过长")
|
||||||
ErrPostContentTooLong = errors.New("正文过长")
|
ErrPostContentTooLong = errors.New("正文过长")
|
||||||
ErrCommentTooLong = errors.New("评论内容过长")
|
ErrCommentTooLong = errors.New("评论内容过长")
|
||||||
ErrCaptchaInvalid = errors.New("验证码错误或已过期")
|
|
||||||
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
||||||
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
||||||
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
||||||
|
|||||||
@@ -172,52 +172,6 @@ func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
|
|
||||||
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-7 * 24 * time.Hour)
|
|
||||||
var posts []model.Post
|
|
||||||
err := model.DB.Preload("User").Preload("Board").
|
|
||||||
Where("status = ?", model.ContentStatusPublished).
|
|
||||||
Where(`EXISTS (
|
|
||||||
SELECT 1 FROM comments
|
|
||||||
WHERE comments.post_id = posts.id
|
|
||||||
AND comments.deleted_at IS NULL
|
|
||||||
AND comments.status = ?
|
|
||||||
AND comments.created_at >= ?
|
|
||||||
)`, model.ContentStatusPublished, since).
|
|
||||||
Order(`(
|
|
||||||
SELECT MAX(created_at) FROM comments
|
|
||||||
WHERE comments.post_id = posts.id
|
|
||||||
AND comments.deleted_at IS NULL
|
|
||||||
AND comments.status = 'published'
|
|
||||||
) DESC`).
|
|
||||||
Limit(limit).Find(&posts).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids := make([]uint, len(posts))
|
|
||||||
for i, p := range posts {
|
|
||||||
ids[i] = p.ID
|
|
||||||
}
|
|
||||||
countMap := s.commentCountMap(ids)
|
|
||||||
replyMap := s.lastReplyInfoMap(ids)
|
|
||||||
items := make([]PostListItem, len(posts))
|
|
||||||
for i, p := range posts {
|
|
||||||
info := replyMap[p.ID]
|
|
||||||
items[i] = PostListItem{
|
|
||||||
Post: p,
|
|
||||||
CommentCount: countMap[p.ID],
|
|
||||||
LastReplyAt: info.At,
|
|
||||||
LastReplyUser: info.User,
|
|
||||||
LastReplyGuestNick: info.GuestNick,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagCount 标签及其出现次数
|
// TagCount 标签及其出现次数
|
||||||
type TagCount struct {
|
type TagCount struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -680,16 +634,6 @@ func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
|
|||||||
return revs, nil
|
return revs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
|
|
||||||
var rev model.PostRevision
|
|
||||||
err := model.DB.Preload("Editor").
|
|
||||||
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrRevisionNotFound
|
|
||||||
}
|
|
||||||
return &rev, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。
|
// Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。
|
||||||
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
|
|||||||
Reference in New Issue
Block a user